From aa06d09c93e02a337e23868dad3949392aa0d89b Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Mon, 16 Mar 2026 14:38:17 +0000 Subject: [PATCH] =?UTF-8?q?feat(ui):=20a=20bunch=20of=20UI=20updates=20?= =?UTF-8?q?=E2=80=94=20sidebar=20reorder,=20compact=20forms,=20stacked=20c?= =?UTF-8?q?hart,=20cost=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/web/project-navigation.test.ts | 4 +- tests/unit/web/utils.test.ts | 33 +- .../projects/project-general-form.tsx | 468 ++++++++++-------- .../projects/project-harness-form.tsx | 2 + .../projects/project-work-table.tsx | 4 +- web/src/components/projects/stats-summary.tsx | 4 +- .../components/runs/work-item-cost-chart.tsx | 3 +- .../runs/work-item-duration-chart.tsx | 150 +++--- web/src/lib/project-sections.ts | 4 +- web/src/lib/utils.ts | 7 + .../work-items/$projectId.$workItemId.tsx | 31 -- 11 files changed, 372 insertions(+), 338 deletions(-) diff --git a/tests/unit/web/project-navigation.test.ts b/tests/unit/web/project-navigation.test.ts index a39bcdaa..b54c1e46 100644 --- a/tests/unit/web/project-navigation.test.ts +++ b/tests/unit/web/project-navigation.test.ts @@ -12,11 +12,11 @@ describe('PROJECT_SECTIONS', () => { expect(PROJECT_SECTIONS.map((s) => s.id)).toEqual([ 'general', 'harness', - 'work', - 'stats', 'integrations', 'agent-configs', 'lifecycle', + 'work', + 'stats', ]); }); diff --git a/tests/unit/web/utils.test.ts b/tests/unit/web/utils.test.ts index 0756ce8b..079d58ef 100644 --- a/tests/unit/web/utils.test.ts +++ b/tests/unit/web/utils.test.ts @@ -3,7 +3,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('clsx', () => ({ clsx: (...args: unknown[]) => args.join(' ') })); vi.mock('tailwind-merge', () => ({ twMerge: (s: string) => s })); -import { formatCost, formatDuration, formatRelativeTime } from '../../../web/src/lib/utils.js'; +import { + formatCost, + formatCostSummary, + formatDuration, + formatRelativeTime, +} from '../../../web/src/lib/utils.js'; describe('formatDuration', () => { it('returns "-" for null', () => { @@ -60,6 +65,32 @@ describe('formatCost', () => { }); }); +describe('formatCostSummary', () => { + it('returns "-" for null', () => { + expect(formatCostSummary(null)).toBe('-'); + }); + + it('returns "-" for undefined', () => { + expect(formatCostSummary(undefined)).toBe('-'); + }); + + it('formats number with 2 decimal places', () => { + expect(formatCostSummary(0.001)).toBe('$0.00'); + expect(formatCostSummary(1.23456)).toBe('$1.23'); + expect(formatCostSummary(0)).toBe('$0.00'); + expect(formatCostSummary(5.5)).toBe('$5.50'); + }); + + it('handles string input', () => { + expect(formatCostSummary('0.5')).toBe('$0.50'); + expect(formatCostSummary('1.23456')).toBe('$1.23'); + }); + + it('returns "-" for NaN string input', () => { + expect(formatCostSummary('not-a-number')).toBe('-'); + }); +}); + describe('formatRelativeTime', () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/web/src/components/projects/project-general-form.tsx b/web/src/components/projects/project-general-form.tsx index 5a27adfe..a794b7cd 100644 --- a/web/src/components/projects/project-general-form.tsx +++ b/web/src/components/projects/project-general-form.tsx @@ -1,22 +1,22 @@ import { ProjectSecretField } from '@/components/projects/project-secret-field.js'; import { useProjectUpdate } from '@/components/projects/use-project-update.js'; import { Badge } from '@/components/ui/badge.js'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js'; import { Input } from '@/components/ui/input.js'; import { Label } from '@/components/ui/label.js'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip.js'; import { trpc } from '@/lib/trpc.js'; import { useQuery } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; +import { HelpCircle } from 'lucide-react'; import { useMemo, useState } from 'react'; import { toast } from 'sonner'; -function formatMs(ms: number): string { - const minutes = ms / 1000 / 60; - if (minutes % 60 === 0) - return `${ms.toLocaleString()} (${minutes / 60} hour${minutes / 60 !== 1 ? 's' : ''})`; - return `${ms.toLocaleString()} (${minutes} min)`; -} - interface Project { id: string; name: string; @@ -37,6 +37,19 @@ function numericFieldDefault(value: number | null | undefined): string { return value != null ? String(value) : ''; } +/** Convert watchdog ms → whole minutes for display */ +function msToMinutes(ms: number | null | undefined): string { + if (ms == null) return ''; + return String(Math.round(ms / 60000)); +} + +/** Convert minutes string → ms for storage */ +function minutesToMs(minutes: string): number | null { + if (!minutes) return null; + const parsed = Number.parseInt(minutes, 10); + return Number.isNaN(parsed) ? null : parsed * 60000; +} + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: five independent form sections (identity, budget, progress, watchdog, run links) with shared dirty-state tracking and reset logic export function ProjectGeneralForm({ project }: { project: Project }) { const updateMutation = useProjectUpdate(project.id); @@ -50,9 +63,7 @@ export function ProjectGeneralForm({ project }: { project: Project }) { const defaults = defaultsQuery.data; const [name, setName] = useState(project.name); - const [watchdogTimeoutMs, setWatchdogTimeoutMs] = useState( - numericFieldDefault(project.watchdogTimeoutMs), - ); + const [watchdogMinutes, setWatchdogMinutes] = useState(msToMinutes(project.watchdogTimeoutMs)); const [progressModel, setProgressModel] = useState(project.progressModel ?? ''); const [progressIntervalMinutes, setProgressIntervalMinutes] = useState( project.progressIntervalMinutes ?? '', @@ -67,7 +78,7 @@ export function ProjectGeneralForm({ project }: { project: Project }) { const isDirty = useMemo(() => { return ( name !== project.name || - watchdogTimeoutMs !== numericFieldDefault(project.watchdogTimeoutMs) || + watchdogMinutes !== msToMinutes(project.watchdogTimeoutMs) || progressModel !== (project.progressModel ?? '') || progressIntervalMinutes !== (project.progressIntervalMinutes ?? '') || workItemBudgetUsd !== (project.workItemBudgetUsd ?? '') || @@ -76,7 +87,7 @@ export function ProjectGeneralForm({ project }: { project: Project }) { ); }, [ name, - watchdogTimeoutMs, + watchdogMinutes, progressModel, progressIntervalMinutes, workItemBudgetUsd, @@ -87,7 +98,7 @@ export function ProjectGeneralForm({ project }: { project: Project }) { function handleReset() { setName(project.name); - setWatchdogTimeoutMs(numericFieldDefault(project.watchdogTimeoutMs)); + setWatchdogMinutes(msToMinutes(project.watchdogTimeoutMs)); setProgressModel(project.progressModel ?? ''); setProgressIntervalMinutes(project.progressIntervalMinutes ?? ''); setWorkItemBudgetUsd(project.workItemBudgetUsd ?? ''); @@ -100,7 +111,7 @@ export function ProjectGeneralForm({ project }: { project: Project }) { updateMutation.mutate( { name, - watchdogTimeoutMs: watchdogTimeoutMs ? Number.parseInt(watchdogTimeoutMs, 10) : null, + watchdogTimeoutMs: minutesToMs(watchdogMinutes), progressModel: progressModel || null, progressIntervalMinutes: progressIntervalMinutes || null, workItemBudgetUsd: workItemBudgetUsd || null, @@ -125,14 +136,15 @@ export function ProjectGeneralForm({ project }: { project: Project }) { const budgetPlaceholder = defaults ? `${defaults.workItemBudgetUsd.toFixed(2)} (default)` : 'e.g. 5.00'; - const budgetDescription = defaults ? `$${defaults.workItemBudgetUsd.toFixed(2)} USD` : '…'; - const watchdogPlaceholder = defaults ? formatMs(defaults.watchdogTimeoutMs) : 'e.g. 1800000'; - const watchdogDescription = defaults ? formatMs(defaults.watchdogTimeoutMs) : '…'; + const watchdogDefaultMinutes = defaults ? Math.round(defaults.watchdogTimeoutMs / 60000) : null; + const watchdogPlaceholder = + watchdogDefaultMinutes != null ? `${watchdogDefaultMinutes} (default)` : 'e.g. 30'; + const watchdogDescription = + watchdogDefaultMinutes != null ? `Default: ${watchdogDefaultMinutes} min` : '…'; const progressModelPlaceholder = defaults ? defaults.progressModel : 'e.g. gemini-flash'; const progressIntervalPlaceholder = defaults ? `${defaults.progressIntervalMinutes} (default)` : 'e.g. 5'; - const progressIntervalDescription = defaults ? `${defaults.progressIntervalMinutes} min` : '…'; const progressModelDescription = defaults ? ( {defaults.progressModel} ) : ( @@ -140,219 +152,245 @@ export function ProjectGeneralForm({ project }: { project: Project }) { ); return ( -
-
- {/* Project Identity */} - - - Project Identity - Basic identification and naming for this project. - - -
- ID: - - {project.id} - -
-
- Repository: - {project.repo ? ( - - {project.repo} - - ) : ( - - Not configured —{' '} - +
+ + {/* Project Identity */} + + + Project Identity + + +
+ ID: + + {project.id} + +
+
+ Repository: + {project.repo ? ( + - set on Integrations tab → - - - )} -
-
- - setName(e.target.value)} required /> -

- Display name for this project shown in the dashboard. -

-
-
-
- - {/* Budget & Limits */} - - - Budget & Limits - - Control spending and concurrency limits for agent runs. - - - -
-
- - setMaxInFlightItems(e.target.value)} - placeholder="1 (default)" - /> + + setName(e.target.value)} required />

- Maximum items in TODO + In Progress + In Review simultaneously. Defaults to 1. + Display name for this project shown in the dashboard.

-
-
- - setWatchdogTimeoutMs(e.target.value)} - placeholder={watchdogPlaceholder} - /> -

- Maximum duration before a stalled agent run is forcibly terminated. Leave empty to - use default: {watchdogDescription}. -

-
- - + + - {/* Progress Monitoring */} - - - Progress Monitoring - - Configure how agent progress is reported during long-running tasks. - - - -
-
- - setProgressModel(e.target.value)} - placeholder={progressModelPlaceholder} - /> -

- LLM model used for progress summaries. Leave empty to use default:{' '} - {progressModelDescription}. -

+ {/* Budget & Limits */} + + +
+ Budget & Limits + + + + + + Control spending and concurrency limits for agent runs. + + +
+
+ +
+
+ + setWorkItemBudgetUsd(e.target.value)} + placeholder={budgetPlaceholder} + /> +

+ Max spend per work item. Default: $ + {defaults ? defaults.workItemBudgetUsd.toFixed(2) : '…'}. +

+
+
+ + setMaxInFlightItems(e.target.value)} + placeholder="1 (default)" + /> +

+ Max items in TODO + In Progress + In Review. Default: 1. +

+
- +
+ + + + + + + Maximum duration before a stalled agent run is forcibly terminated. + + +
setProgressIntervalMinutes(e.target.value)} - placeholder={progressIntervalPlaceholder} + step="1" + className="w-32" + value={watchdogMinutes} + onChange={(e) => setWatchdogMinutes(e.target.value)} + placeholder={watchdogPlaceholder} /> -

- How often the agent posts a progress update. Leave empty to use default:{' '} - {progressIntervalDescription}. -

+

{watchdogDescription}

-
-
- setRunLinksEnabled(e.target.checked)} - className="h-4 w-4 rounded border-border" - /> -
- -

- Adds a dashboard link to agent comments. Requires{' '} - CASCADE_DASHBOARD_URL env var. -

+ + + + {/* Progress Monitoring */} + + +
+ Progress Monitoring + + + + + + Configure how agent progress is reported during long-running tasks. + + +
+
+ +
+
+ + setProgressModel(e.target.value)} + placeholder={progressModelPlaceholder} + /> +

+ LLM model for progress summaries. Default: {progressModelDescription}. +

+
+
+ + setProgressIntervalMinutes(e.target.value)} + placeholder={progressIntervalPlaceholder} + /> +

+ How often the agent posts a progress update. +

+
+
+
+ setRunLinksEnabled(e.target.checked)} + className="h-4 w-4 rounded border-border" + /> +
+ +

+ Adds a dashboard link to agent comments. Requires{' '} + CASCADE_DASHBOARD_URL env var. +

+
+
+
+ + {/* Save / Reset */} +
+ + +
+ + + {/* API Keys */} + + +
+ API Keys + + + + + + Project-scoped API keys for LLM providers. Values are stored encrypted and never + returned to the browser. Engine-specific keys are on the Engine tab. + +
+
+ +
- - {/* Save / Reset */} -
- - -
- - - {/* API Keys */} - - - API Keys - - Project-scoped API keys for LLM providers. Values are stored encrypted and never - returned to the browser. Engine-specific keys are on the{' '} - - Engine tab - - . - - - - - - -
+
+ ); } diff --git a/web/src/components/projects/project-harness-form.tsx b/web/src/components/projects/project-harness-form.tsx index e0702300..5a41d05c 100644 --- a/web/src/components/projects/project-harness-form.tsx +++ b/web/src/components/projects/project-harness-form.tsx @@ -233,6 +233,8 @@ export function ProjectHarnessForm({ project }: { project: Project }) { id="maxIterations" type="number" min="1" + step="1" + className="w-32" value={maxIterations} onChange={(e) => setMaxIterations(e.target.value)} placeholder={defaults ? `${defaults.maxIterations} (default)` : 'e.g. 50'} diff --git a/web/src/components/projects/project-work-table.tsx b/web/src/components/projects/project-work-table.tsx index 8ecfce45..59e0b3b6 100644 --- a/web/src/components/projects/project-work-table.tsx +++ b/web/src/components/projects/project-work-table.tsx @@ -1,4 +1,4 @@ -import { formatCost } from '@/lib/utils.js'; +import { formatCostSummary } from '@/lib/utils.js'; import { useNavigate } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router'; import { ClipboardList, ExternalLink, GitPullRequest } from 'lucide-react'; @@ -198,7 +198,7 @@ function WorkItemRow({ item, projectId }: WorkItemRowProps) { {/* Cost */} - {formatCost(item.totalCostUsd)} + {formatCostSummary(item.totalCostUsd)} ); diff --git a/web/src/components/projects/stats-summary.tsx b/web/src/components/projects/stats-summary.tsx index 82aa06bc..9a8ad958 100644 --- a/web/src/components/projects/stats-summary.tsx +++ b/web/src/components/projects/stats-summary.tsx @@ -1,5 +1,5 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js'; -import { formatCost, formatDuration } from '@/lib/utils.js'; +import { formatCostSummary, formatDuration } from '@/lib/utils.js'; interface StatsSummaryProps { totalRuns: number; @@ -21,7 +21,7 @@ export function StatsSummary({ }, { label: 'Total Cost', - value: formatCost(totalCostUsd), + value: formatCostSummary(totalCostUsd), }, { label: 'Avg Duration', diff --git a/web/src/components/runs/work-item-cost-chart.tsx b/web/src/components/runs/work-item-cost-chart.tsx index abf48dda..f756a95f 100644 --- a/web/src/components/runs/work-item-cost-chart.tsx +++ b/web/src/components/runs/work-item-cost-chart.tsx @@ -1,5 +1,6 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js'; import { agentTypeLabel, getAgentColor } from '@/lib/chart-colors.js'; +import { formatCostSummary } from '@/lib/utils.js'; import { Cell, Label, Legend, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'; interface WorkItemRun { @@ -81,7 +82,7 @@ export function WorkItemCostChart({ runs, byAgentType }: WorkItemCostChartProps) ); } - const totalLabel = `$${totalCost.toFixed(4)}`; + const totalLabel = formatCostSummary(totalCost); return ( diff --git a/web/src/components/runs/work-item-duration-chart.tsx b/web/src/components/runs/work-item-duration-chart.tsx index bd4b9a1c..302bc33a 100644 --- a/web/src/components/runs/work-item-duration-chart.tsx +++ b/web/src/components/runs/work-item-duration-chart.tsx @@ -1,17 +1,6 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js'; import { agentTypeLabel, getAgentColor } from '@/lib/chart-colors.js'; import { formatDuration } from '@/lib/utils.js'; -import { - Bar, - BarChart, - CartesianGrid, - Cell, - Legend, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts'; interface WorkItemRun { id: string; @@ -27,7 +16,8 @@ interface WorkItemDurationChartProps { runs: WorkItemRun[]; } -interface ChartEntry { +interface SegmentEntry { + id: string; label: string; durationMs: number; agentType: string; @@ -35,6 +25,7 @@ interface ChartEntry { model: string | null; costUsd: string | null; color: string; + pct: number; } export function WorkItemDurationChart({ runs }: WorkItemDurationChartProps) { @@ -55,103 +46,98 @@ export function WorkItemDurationChart({ runs }: WorkItemDurationChartProps) { ); } - // Build chart data: one entry per run with a duration + const totalMs = runsWithDuration.reduce((sum, r) => sum + (r.durationMs as number), 0); + + // Build segments: one per run const agentRunCounts: Record = {}; - const data: ChartEntry[] = runsWithDuration.map((run) => { + const segments: SegmentEntry[] = runsWithDuration.map((run) => { agentRunCounts[run.agentType] = (agentRunCounts[run.agentType] ?? 0) + 1; const count = agentRunCounts[run.agentType]; + const durationMs = run.durationMs as number; return { + id: run.id, label: `${agentTypeLabel(run.agentType)} #${count}`, - durationMs: run.durationMs as number, + durationMs, agentType: run.agentType, status: run.status, model: run.model, costUsd: run.costUsd, color: getAgentColor(run.agentType), + pct: totalMs > 0 ? (durationMs / totalMs) * 100 : 0, }; }); - const chartHeight = Math.max(200, data.length * 48); - // Unique agent types for legend const uniqueAgentTypes = Array.from(new Set(runsWithDuration.map((r) => r.agentType))); - - // Legend items: we render a custom content to show agent type → color const legendItems = uniqueAgentTypes.map((at) => ({ + agentType: at, label: agentTypeLabel(at), color: getAgentColor(at), })); - const formatTick = (value: number) => formatDuration(value); - return ( Run Durations - - - - - + {/* Horizontal stacked bar */} +
+ {segments.map((seg) => ( +
- - { - if (!active || !payload?.length) return null; - const entry = payload[0]?.payload as ChartEntry; - return ( -
-

{agentTypeLabel(entry.agentType)}

-

- Duration: {formatDuration(entry.durationMs)} -

-

Status: {entry.status}

- {entry.model &&

Model: {entry.model}

} - {entry.costUsd != null && ( -

- Cost: ${Number.parseFloat(entry.costUsd).toFixed(4)} -

- )} -
- ); - }} - /> - ( -
- {legendItems.map((item) => ( -
- - {item.label} -
- ))} -
+ ))} +
+ + {/* Legend */} +
+ {legendItems.map((item) => ( +
+ + {item.label} +
+ ))} +
+ + {/* Segment detail list */} +
+ {segments.map((seg) => ( +
+ + {seg.label} + {formatDuration(seg.durationMs)} + · + {seg.status} + {seg.model && ( + <> + · + {seg.model} + )} - /> - - {data.map((entry) => ( - - ))} - - - +
+ ))} +
); diff --git a/web/src/lib/project-sections.ts b/web/src/lib/project-sections.ts index 52cf9df3..db884b55 100644 --- a/web/src/lib/project-sections.ts +++ b/web/src/lib/project-sections.ts @@ -24,8 +24,6 @@ export const PROJECT_SECTIONS: { }[] = [ { id: 'general', label: 'General', path: 'general', route: '/projects/$projectId/general' }, { id: 'harness', label: 'Engine', path: 'harness', route: '/projects/$projectId/harness' }, - { id: 'work', label: 'Work', path: 'work', route: '/projects/$projectId/work' }, - { id: 'stats', label: 'Stats', path: 'stats', route: '/projects/$projectId/stats' }, { id: 'integrations', label: 'Integrations', @@ -44,6 +42,8 @@ export const PROJECT_SECTIONS: { path: 'lifecycle', route: '/projects/$projectId/lifecycle', }, + { id: 'work', label: 'Work', path: 'work', route: '/projects/$projectId/work' }, + { id: 'stats', label: 'Stats', path: 'stats', route: '/projects/$projectId/stats' }, ]; export const DEFAULT_PROJECT_SECTION: ProjectSection = 'general'; diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 136398d5..03fa8240 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -22,6 +22,13 @@ export function formatCost(usd: string | number | null | undefined): string { return `$${n.toFixed(4)}`; } +export function formatCostSummary(usd: string | number | null | undefined): string { + if (usd == null) return '-'; + const n = typeof usd === 'string' ? Number.parseFloat(usd) : usd; + if (Number.isNaN(n)) return '-'; + return `$${n.toFixed(2)}`; +} + export function formatRelativeTime(date: Date | string | null | undefined): string { if (!date) return '-'; const d = typeof date === 'string' ? new Date(date) : date; diff --git a/web/src/routes/work-items/$projectId.$workItemId.tsx b/web/src/routes/work-items/$projectId.$workItemId.tsx index 9606f1e5..8ce121e3 100644 --- a/web/src/routes/work-items/$projectId.$workItemId.tsx +++ b/web/src/routes/work-items/$projectId.$workItemId.tsx @@ -23,11 +23,6 @@ function WorkItemRunsPage() { const workItemTitle = firstRun?.workItemTitle ?? workItemId; const workItemUrl = firstRun?.workItemUrl; - const runningCount = runs?.filter((r) => r.status === 'running').length ?? 0; - const totalCount = runs?.length ?? 0; - const completedCount = runs?.filter((r) => r.status === 'completed').length ?? 0; - const failedCount = runs?.filter((r) => r.status === 'failed').length ?? 0; - return (

Work Item Runs

@@ -48,34 +43,8 @@ function WorkItemRunsPage() { {workItemTitle} )}
-

Work Item ID: {workItemId}

- {runs && ( -
-
- {totalCount} - total -
- {runningCount > 0 && ( -
- {runningCount} - running -
- )} -
- {completedCount} - completed -
- {failedCount > 0 && ( -
- {failedCount} - failed -
- )} -
- )} - {runs && runs.length > 0 && (