From a1815afc27cd421be560b8222bc534ab44dd7022 Mon Sep 17 00:00:00 2001 From: Oleg Miagkov Date: Sun, 12 Jul 2026 22:45:44 +0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(ui):=20TaskDetail=20v2=20=E2=80=94=20p?= =?UTF-8?q?ipeline=20tabs,=20subtasks=20panel,=20meta=20rail=20(U-slice)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuild the shared TaskDetail screen to match the desktop-task-detail-v2 mockup: - libs/ui: tab bar (Overview / Subtasks·N / Logs / Files / Timeline — the last three rendered disabled until their data flows land), a two-column body with the overview/subtasks content on the left and meta cards (Workspace, Cost & tokens, …) on the right rail, and a SubtasksPanel with status check circles + badges. Tab state resets per task (key) and falls back to Overview if a live update disables the selected tab. New shared types: UiSubtask, UiSubtaskStatus, UiMetaRow, UiMetaSection, TaskDetailTabId. - Electron adapter: maps store subtasks onto the shared shape and gains a loadMetaSections progressive-enhancement loader (same swallowed- failure contract as loadSpecContent). - Pilot wiring: KanbanPilotView builds the meta rail from getTokenStats + the newly exposed getCostReport preload wrapper (over PROJECT_LOAD_COST_REPORT) via the pure task-meta-sections helper; tab/subtask-status labels injected from i18n (en+fr). - Web pilot: TaskDetailNext passes localized tab labels (en+fr). - Tests: task-meta-sections unit suite + adapter subtasks/metaSections coverage (32 passing); Storybook stories extended with a full pipeline story (subtasks + meta cards) verified in both themes. Co-Authored-By: Claude Fable 5 --- apps/frontend/src/__tests__/setup.ts | 2 + apps/frontend/src/preload/api/task-api.ts | 7 + .../renderer/__tests__/autoCodeClient.test.ts | 48 +++++ .../__tests__/task-meta-sections.test.ts | 143 ++++++++++++++ .../renderer/components/KanbanPilotView.tsx | 72 ++++++- .../src/renderer/lib/autoCodeClient.ts | 28 ++- .../src/renderer/lib/mocks/task-mock.ts | 5 + .../src/renderer/lib/task-meta-sections.ts | 84 ++++++++ .../src/shared/i18n/locales/en/kanban.json | 26 +++ .../src/shared/i18n/locales/fr/kanban.json | 26 +++ apps/frontend/src/shared/types/ipc.ts | 3 + .../src/i18n/locales/en/tasks.json | 9 + .../src/i18n/locales/fr/tasks.json | 9 + .../web-frontend/src/pages/TaskDetailNext.tsx | 13 +- libs/ui/src/client/types.ts | 26 +++ libs/ui/src/index.ts | 6 +- libs/ui/src/screens/TaskDetail.css | 179 +++++++++++++++++- libs/ui/src/screens/TaskDetail.stories.tsx | 92 ++++++++- libs/ui/src/screens/TaskDetail.tsx | 176 ++++++++++++++++- 19 files changed, 937 insertions(+), 17 deletions(-) create mode 100644 apps/frontend/src/renderer/__tests__/task-meta-sections.test.ts create mode 100644 apps/frontend/src/renderer/lib/task-meta-sections.ts diff --git a/apps/frontend/src/__tests__/setup.ts b/apps/frontend/src/__tests__/setup.ts index f891d4b8c..003c93422 100644 --- a/apps/frontend/src/__tests__/setup.ts +++ b/apps/frontend/src/__tests__/setup.ts @@ -109,6 +109,8 @@ if (typeof window !== 'undefined') { onTaskLog: vi.fn(() => vi.fn()), onTaskStatusChange: vi.fn(() => vi.fn()), getSpecContent: vi.fn().mockResolvedValue({ success: true, data: null }), + getTokenStats: vi.fn().mockResolvedValue({ success: true, data: null }), + getCostReport: vi.fn().mockResolvedValue({ success: false, error: 'not available' }), getImplementationPlan: vi.fn().mockResolvedValue({ success: true, data: null }), getGenericEditArtifactManifest: vi.fn().mockResolvedValue({ success: true, data: null }), getQAReport: vi.fn().mockResolvedValue({ success: true, data: null }), diff --git a/apps/frontend/src/preload/api/task-api.ts b/apps/frontend/src/preload/api/task-api.ts index cd86a9cfa..e478c9e99 100644 --- a/apps/frontend/src/preload/api/task-api.ts +++ b/apps/frontend/src/preload/api/task-api.ts @@ -98,6 +98,9 @@ export interface TaskAPI { // Task Token Stats getTokenStats: (projectPath: string, specId: string) => Promise>; + // Task Cost Report (cost_report.json for a spec) + getCostReport: (projectId: string, specId: string) => Promise>; + // Task Spec File Reading (for task overview display) getSpecContent: (taskId: string) => Promise>; getImplementationPlan: (taskId: string) => Promise>; @@ -353,6 +356,10 @@ export const createTaskAPI = (): TaskAPI => ({ getTokenStats: (projectPath: string, specId: string): Promise> => ipcRenderer.invoke(IPC_CHANNELS.TASK_TOKEN_STATS_GET, projectPath, specId), + // Task Cost Report + getCostReport: (projectId: string, specId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.PROJECT_LOAD_COST_REPORT, projectId, specId), + // Task Spec File Reading getSpecContent: (taskId: string): Promise> => ipcRenderer.invoke(IPC_CHANNELS.TASK_SPEC_CONTENT_GET, taskId), diff --git a/apps/frontend/src/renderer/__tests__/autoCodeClient.test.ts b/apps/frontend/src/renderer/__tests__/autoCodeClient.test.ts index 8382f9570..0eb76093c 100644 --- a/apps/frontend/src/renderer/__tests__/autoCodeClient.test.ts +++ b/apps/frontend/src/renderer/__tests__/autoCodeClient.test.ts @@ -280,6 +280,24 @@ describe('mapTaskToUiTaskDetail', () => { it('omits the breakdown without subtasks', () => { expect(mapTaskToUiTaskDetail(makeTask(), LABELS).progressBreakdown).toBeUndefined(); }); + + it('maps subtasks onto the shared shape, dropping empty descriptions', () => { + const task = makeTask({ + subtasks: [ + { id: 's1', title: 'First', description: 'does x', status: 'completed', files: [] }, + { id: 's2', title: 'Second', description: '', status: 'in_progress', files: [] }, + ], + }); + const detail = mapTaskToUiTaskDetail(task, LABELS); + expect(detail.subtasks).toEqual([ + { id: 's1', title: 'First', description: 'does x', status: 'completed' }, + { id: 's2', title: 'Second', description: undefined, status: 'in_progress' }, + ]); + }); + + it('omits subtasks when the task has none', () => { + expect(mapTaskToUiTaskDetail(makeTask(), LABELS).subtasks).toBeUndefined(); + }); }); describe('createTaskStoreAutoCodeClient.getTask', () => { @@ -322,6 +340,36 @@ describe('createTaskStoreAutoCodeClient.getTask', () => { expect(detail?.id).toBe('known'); expect(detail?.specContent).toBeUndefined(); }); + + it('injects meta sections from the loadMetaSections option', async () => { + const sections = [ + { title: 'Cost & tokens', rows: [{ label: 'Cost', value: '$1.20' }] }, + ]; + const client = createTaskStoreAutoCodeClient(makeDetailStore(), LABELS, { + loadMetaSections: async () => sections, + }); + const detail = await client.getTask?.('known'); + expect(detail?.metaSections).toEqual(sections); + }); + + it('leaves metaSections undefined when the loader returns null', async () => { + const client = createTaskStoreAutoCodeClient(makeDetailStore(), LABELS, { + loadMetaSections: async () => null, + }); + const detail = await client.getTask?.('known'); + expect(detail?.metaSections).toBeUndefined(); + }); + + it('still resolves the detail when the meta loader fails', async () => { + const client = createTaskStoreAutoCodeClient(makeDetailStore(), LABELS, { + loadMetaSections: async () => { + throw new Error('IPC unavailable'); + }, + }); + const detail = await client.getTask?.('known'); + expect(detail?.id).toBe('known'); + expect(detail?.metaSections).toBeUndefined(); + }); }); describe('statusChip', () => { diff --git a/apps/frontend/src/renderer/__tests__/task-meta-sections.test.ts b/apps/frontend/src/renderer/__tests__/task-meta-sections.test.ts new file mode 100644 index 000000000..8daa3ac10 --- /dev/null +++ b/apps/frontend/src/renderer/__tests__/task-meta-sections.test.ts @@ -0,0 +1,143 @@ +/** + * Tests for the TaskDetail meta-rail builder: workspace + cost & tokens + * cards assembled from the store Task, TaskTokenStats, and CostReport. + */ + +import { describe, expect, it } from 'vitest'; +import { + buildTaskMetaSections, + formatTokenCount, +} from '../lib/task-meta-sections'; +import type { TaskMetaLabels } from '../lib/task-meta-sections'; +import type { + CostReport, + Task, + TaskTokenStats, +} from '../../shared/types'; + +const LABELS: TaskMetaLabels = { + workspaceTitle: 'Workspace', + costTokensTitle: 'Cost & tokens', + specId: 'Spec', + location: 'Location', + updated: 'Updated', + cost: 'Cost', + inputTokens: 'Input', + outputTokens: 'Output', + sessions: 'Sessions', +}; + +function makeTask(overrides: Partial = {}): Task { + return { + id: 't1', + specId: '004-auth', + projectId: 'p1', + title: 'Add user authentication', + description: '', + status: 'in_progress', + subtasks: [], + logs: [], + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + ...overrides, + }; +} + +function makeTokenStats(overrides: Partial = {}): TaskTokenStats { + return { + phases: { + planning: { + phase: 'planning', + input_tokens: 12_000, + output_tokens: 3_000, + total_tokens: 15_000, + session_count: 2, + updated_at: '2026-01-02T00:00:00Z', + }, + coding: { + phase: 'coding', + input_tokens: 100_000, + output_tokens: 25_000, + total_tokens: 125_000, + session_count: 7, + updated_at: '2026-01-02T00:00:00Z', + }, + }, + total_input_tokens: 112_000, + total_output_tokens: 28_000, + total_tokens: 140_000, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-02T00:00:00Z', + ...overrides, + }; +} + +const COST_REPORT: CostReport = { + spec_dir: '/x/specs/004-auth', + total_cost: 4.867, + records: [], + last_updated: '2026-01-02T00:00:00Z', +}; + +describe('formatTokenCount', () => { + it('renders verbatim, k, and M forms', () => { + expect(formatTokenCount(950)).toBe('950'); + expect(formatTokenCount(112_000)).toBe('112k'); + expect(formatTokenCount(1_200_000)).toBe('1.2M'); + }); + + it('clamps negative and non-finite input to 0', () => { + expect(formatTokenCount(-5)).toBe('0'); + expect(formatTokenCount(Number.NaN)).toBe('0'); + }); +}); + +describe('buildTaskMetaSections', () => { + it('builds workspace and cost cards from full data', () => { + const task = makeTask({ location: 'worktree' }); + const sections = buildTaskMetaSections(task, makeTokenStats(), COST_REPORT, LABELS); + expect(sections).toHaveLength(2); + + const [workspace, cost] = sections; + expect(workspace.title).toBe('Workspace'); + expect(workspace.rows[0]).toEqual({ label: 'Spec', value: '004-auth' }); + expect(workspace.rows[1]).toEqual({ label: 'Location', value: 'worktree' }); + expect(workspace.rows[2].label).toBe('Updated'); + + expect(cost.title).toBe('Cost & tokens'); + expect(cost.rows).toEqual([ + { label: 'Cost', value: '$4.87' }, + { label: 'Input', value: '112k' }, + { label: 'Output', value: '28k' }, + { label: 'Sessions', value: '9' }, + ]); + }); + + it('omits the cost card entirely without stats or report', () => { + const sections = buildTaskMetaSections(makeTask(), null, null, LABELS); + expect(sections).toHaveLength(1); + expect(sections[0].title).toBe('Workspace'); + }); + + it('renders a tokens-only cost card when the report is missing', () => { + const sections = buildTaskMetaSections(makeTask(), makeTokenStats(), null, LABELS); + const cost = sections[1]; + expect(cost.rows.map((row) => row.label)).toEqual([ + 'Input', + 'Output', + 'Sessions', + ]); + }); + + it('skips the sessions row when phase counts sum to zero', () => { + const stats = makeTokenStats({ phases: {} }); + const sections = buildTaskMetaSections(makeTask(), stats, COST_REPORT, LABELS); + const cost = sections[1]; + expect(cost.rows.map((row) => row.label)).toEqual(['Cost', 'Input', 'Output']); + }); + + it('omits the location row when the task has none', () => { + const sections = buildTaskMetaSections(makeTask(), null, null, LABELS); + expect(sections[0].rows.map((row) => row.label)).toEqual(['Spec', 'Updated']); + }); +}); diff --git a/apps/frontend/src/renderer/components/KanbanPilotView.tsx b/apps/frontend/src/renderer/components/KanbanPilotView.tsx index 42c3e92fa..60b42d579 100644 --- a/apps/frontend/src/renderer/components/KanbanPilotView.tsx +++ b/apps/frontend/src/renderer/components/KanbanPilotView.tsx @@ -18,10 +18,18 @@ import { useTask, useTasks, } from '@auto-code/ui'; -import type { KanbanColumn, TaskStatus } from '@auto-code/ui'; +import type { + KanbanColumn, + TaskDetailTabId, + TaskStatus, + UiSubtaskStatus, +} from '@auto-code/ui'; import { useTaskStore } from '../stores/task-store'; +import { useProjectStore } from '../stores/project-store'; import { createTaskStoreAutoCodeClient } from '../lib/autoCodeClient'; import type { UiTaskBadgeLabels } from '../lib/autoCodeClient'; +import { buildTaskMetaSections } from '../lib/task-meta-sections'; +import type { TaskMetaLabels } from '../lib/task-meta-sections'; function usePilotColumns(): KanbanColumn[] { const { t } = useTranslation(['kanban']); @@ -75,6 +83,7 @@ function PilotDetail({ id, onBack, }: Readonly<{ id: string; onBack: () => void }>) { + const { t } = useTranslation(['kanban']); const columns = usePilotColumns(); const { task, loading, error, reload } = useTask(id); @@ -83,6 +92,27 @@ function PilotDetail({ Object.fromEntries(columns.map((column) => [column.status, column.label])), [columns], ); + const tabLabels = useMemo>>( + () => ({ + overview: t('kanban:pilot.detail.tabs.overview'), + subtasks: t('kanban:pilot.detail.tabs.subtasks'), + logs: t('kanban:pilot.detail.tabs.logs'), + files: t('kanban:pilot.detail.tabs.files'), + timeline: t('kanban:pilot.detail.tabs.timeline'), + }), + [t], + ); + const subtaskStatusLabels = useMemo< + Partial> + >( + () => ({ + pending: t('kanban:pilot.detail.subtaskStatus.pending'), + in_progress: t('kanban:pilot.detail.subtaskStatus.in_progress'), + completed: t('kanban:pilot.detail.subtaskStatus.completed'), + failed: t('kanban:pilot.detail.subtaskStatus.failed'), + }), + [t], + ); return (
@@ -93,6 +123,8 @@ function PilotDetail({ onBack={onBack} onRetry={reload} statusLabels={statusLabels} + tabLabels={tabLabels} + subtaskStatusLabels={subtaskStatusLabels} />
); @@ -133,6 +165,18 @@ export function KanbanPilotView() { failed: t('kanban:pilot.phases.failed'), }, }; + const metaLabelsRef = useRef({} as TaskMetaLabels); + metaLabelsRef.current = { + workspaceTitle: t('kanban:pilot.detail.meta.workspaceTitle'), + costTokensTitle: t('kanban:pilot.detail.meta.costTokensTitle'), + specId: t('kanban:pilot.detail.meta.specId'), + location: t('kanban:pilot.detail.meta.location'), + updated: t('kanban:pilot.detail.meta.updated'), + cost: t('kanban:pilot.detail.meta.cost'), + inputTokens: t('kanban:pilot.detail.meta.inputTokens'), + outputTokens: t('kanban:pilot.detail.meta.outputTokens'), + sessions: t('kanban:pilot.detail.meta.sessions'), + }; const client = useMemo( () => createTaskStoreAutoCodeClient(useTaskStore, () => labelsRef.current, { @@ -140,6 +184,32 @@ export function KanbanPilotView() { const result = await window.electronAPI.getSpecContent(task.id); return result.success ? (result.data ?? null) : null; }, + loadMetaSections: async (task) => { + const project = useProjectStore + .getState() + .projects.find((p) => p.id === task.projectId); + const [tokensResult, costResult] = await Promise.all([ + project + ? window.electronAPI.getTokenStats(project.path, task.specId) + : Promise.resolve(null), + window.electronAPI.getCostReport(task.projectId, task.specId), + ]); + const tokenStats = + tokensResult?.success && tokensResult.data != null + ? tokensResult.data + : (task.tokenStats ?? null); + const costReport = + costResult.success && costResult.data != null + ? costResult.data + : null; + const sections = buildTaskMetaSections( + task, + tokenStats, + costReport, + metaLabelsRef.current, + ); + return sections.length > 0 ? sections : null; + }, }), [], ); diff --git a/apps/frontend/src/renderer/lib/autoCodeClient.ts b/apps/frontend/src/renderer/lib/autoCodeClient.ts index 889ac145c..d4d98a44a 100644 --- a/apps/frontend/src/renderer/lib/autoCodeClient.ts +++ b/apps/frontend/src/renderer/lib/autoCodeClient.ts @@ -10,6 +10,7 @@ import type { AutoCodeClient, TaskStatus as UiTaskStatus, + UiMetaSection, UiTask, UiTaskBadge, UiTaskDetail, @@ -115,7 +116,7 @@ export function mapTaskToUiTask(task: Task, labels: UiTaskBadgeLabels): UiTask { }; } -/** Detail view of a store task: base card fields + subtask breakdown. +/** Detail view of a store task: base card fields + subtask breakdown + rows. The renderer store carries no spec body; the client's ``loadSpecContent`` option fetches it separately (over IPC) when the detail is requested. */ @@ -138,6 +139,16 @@ export function mapTaskToUiTaskDetail( total: subtasks.length, } : undefined, + // Desktop subtask statuses are already the shared closed set. + subtasks: + subtasks.length > 0 + ? subtasks.map((subtask) => ({ + id: subtask.id, + title: subtask.title, + description: subtask.description || undefined, + status: subtask.status, + })) + : undefined, }; } @@ -154,6 +165,12 @@ export interface TaskStoreClientOptions { * swallowed and the detail view renders without it. */ loadSpecContent?: (task: Task) => Promise; + /** + * Build the detail's right-rail meta cards (cost & tokens, workspace, …) + * for a task, or null when unavailable. Progressive enhancement like + * ``loadSpecContent``: failures are swallowed. + */ + loadMetaSections?: (task: Task) => Promise; } /** @@ -189,6 +206,15 @@ export function createTaskStoreAutoCodeClient( console.warn('[autoCodeClient] Failed to load spec content:', err); } } + if (options.loadMetaSections) { + try { + detail.metaSections = + (await options.loadMetaSections(task)) ?? undefined; + } catch (err) { + // Same contract as the spec body: render without the rail. + console.warn('[autoCodeClient] Failed to load meta sections:', err); + } + } return detail; }, subscribeTasks: (onChange) => { diff --git a/apps/frontend/src/renderer/lib/mocks/task-mock.ts b/apps/frontend/src/renderer/lib/mocks/task-mock.ts index 151105da9..f043360f7 100644 --- a/apps/frontend/src/renderer/lib/mocks/task-mock.ts +++ b/apps/frontend/src/renderer/lib/mocks/task-mock.ts @@ -170,6 +170,11 @@ export const taskMock = { data: null }), + getCostReport: async () => ({ + success: false, + error: 'Cost report not available in browser mode' + }), + getImplementationPlan: async () => ({ success: true, data: null diff --git a/apps/frontend/src/renderer/lib/task-meta-sections.ts b/apps/frontend/src/renderer/lib/task-meta-sections.ts new file mode 100644 index 000000000..4406eb6fe --- /dev/null +++ b/apps/frontend/src/renderer/lib/task-meta-sections.ts @@ -0,0 +1,84 @@ +/** + * Builds the TaskDetail right-rail meta cards (workspace, cost & tokens) + * from the desktop data sources: the store Task, token_stats.json (over + * getTokenStats) and cost_report.json (over getCostReport). Pure — labels + * are injected so text goes through i18n. + */ + +import type { UiMetaSection } from '@auto-code/ui'; +import type { CostReport, Task, TaskTokenStats } from '../../shared/types'; + +export interface TaskMetaLabels { + workspaceTitle: string; + costTokensTitle: string; + specId: string; + location: string; + updated: string; + cost: string; + inputTokens: string; + outputTokens: string; + sessions: string; +} + +/** 112000 → "112k", 1200000 → "1.2M"; small counts stay verbatim. */ +export function formatTokenCount(count: number): string { + if (!Number.isFinite(count) || count < 0) return '0'; + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; + if (count >= 1_000) return `${Math.round(count / 1_000)}k`; + return String(count); +} + +export function buildTaskMetaSections( + task: Task, + tokenStats: TaskTokenStats | null, + costReport: CostReport | null, + labels: TaskMetaLabels, +): UiMetaSection[] { + const sections: UiMetaSection[] = []; + + const workspaceRows = [ + { label: labels.specId, value: task.specId }, + ...(task.location != null + ? [{ label: labels.location, value: task.location }] + : []), + ...(task.updatedAt != null + ? [ + { + label: labels.updated, + value: new Date(task.updatedAt).toLocaleString(), + }, + ] + : []), + ]; + sections.push({ title: labels.workspaceTitle, rows: workspaceRows }); + + const costRows = []; + if (costReport != null) { + costRows.push({ + label: labels.cost, + value: `$${costReport.total_cost.toFixed(2)}`, + }); + } + if (tokenStats != null) { + costRows.push({ + label: labels.inputTokens, + value: formatTokenCount(tokenStats.total_input_tokens), + }); + costRows.push({ + label: labels.outputTokens, + value: formatTokenCount(tokenStats.total_output_tokens), + }); + const sessions = Object.values(tokenStats.phases ?? {}).reduce( + (total, phase) => total + (phase.session_count ?? 0), + 0, + ); + if (sessions > 0) { + costRows.push({ label: labels.sessions, value: String(sessions) }); + } + } + if (costRows.length > 0) { + sections.push({ title: labels.costTokensTitle, rows: costRows }); + } + + return sections; +} diff --git a/apps/frontend/src/shared/i18n/locales/en/kanban.json b/apps/frontend/src/shared/i18n/locales/en/kanban.json index 93abee84b..274cd6fee 100644 --- a/apps/frontend/src/shared/i18n/locales/en/kanban.json +++ b/apps/frontend/src/shared/i18n/locales/en/kanban.json @@ -74,6 +74,32 @@ "table": "Table", "timeline": "Timeline" } + }, + "detail": { + "tabs": { + "overview": "Overview", + "subtasks": "Subtasks", + "logs": "Logs", + "files": "Files", + "timeline": "Timeline" + }, + "subtaskStatus": { + "pending": "Pending", + "in_progress": "Running", + "completed": "Done", + "failed": "Failed" + }, + "meta": { + "workspaceTitle": "Workspace", + "costTokensTitle": "Cost & tokens", + "specId": "Spec", + "location": "Location", + "updated": "Updated", + "cost": "Cost", + "inputTokens": "Input", + "outputTokens": "Output", + "sessions": "Sessions" + } } } } diff --git a/apps/frontend/src/shared/i18n/locales/fr/kanban.json b/apps/frontend/src/shared/i18n/locales/fr/kanban.json index 699f7c6e0..3b382b5db 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/kanban.json +++ b/apps/frontend/src/shared/i18n/locales/fr/kanban.json @@ -74,6 +74,32 @@ "table": "Table", "timeline": "Chronologie" } + }, + "detail": { + "tabs": { + "overview": "Aperçu", + "subtasks": "Sous-tâches", + "logs": "Journaux", + "files": "Fichiers", + "timeline": "Chronologie" + }, + "subtaskStatus": { + "pending": "En attente", + "in_progress": "En cours", + "completed": "Terminé", + "failed": "Échoué" + }, + "meta": { + "workspaceTitle": "Espace de travail", + "costTokensTitle": "Coût et tokens", + "specId": "Spéc", + "location": "Emplacement", + "updated": "Mis à jour", + "cost": "Coût", + "inputTokens": "Entrée", + "outputTokens": "Sortie", + "sessions": "Sessions" + } } } } diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index fc1603d91..3c03f5c98 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -973,6 +973,9 @@ export interface ElectronAPI { // Token statistics getTokenStats: (projectPath: string, specId: string) => Promise>; + // Cost report (cost_report.json for a spec) + getCostReport: (projectId: string, specId: string) => Promise>; + // Task spec file reading (for task overview display) getSpecContent: (taskId: string) => Promise>; getImplementationPlan: (taskId: string) => Promise>; diff --git a/apps/web-frontend/src/i18n/locales/en/tasks.json b/apps/web-frontend/src/i18n/locales/en/tasks.json index 74e129da9..94f22a82c 100644 --- a/apps/web-frontend/src/i18n/locales/en/tasks.json +++ b/apps/web-frontend/src/i18n/locales/en/tasks.json @@ -148,6 +148,15 @@ "table": "Table", "timeline": "Timeline" } + }, + "detail": { + "tabs": { + "overview": "Overview", + "subtasks": "Subtasks", + "logs": "Logs", + "files": "Files", + "timeline": "Timeline" + } } } } diff --git a/apps/web-frontend/src/i18n/locales/fr/tasks.json b/apps/web-frontend/src/i18n/locales/fr/tasks.json index 2c811bee9..7380cf158 100644 --- a/apps/web-frontend/src/i18n/locales/fr/tasks.json +++ b/apps/web-frontend/src/i18n/locales/fr/tasks.json @@ -148,6 +148,15 @@ "table": "Table", "timeline": "Chronologie" } + }, + "detail": { + "tabs": { + "overview": "Aperçu", + "subtasks": "Sous-tâches", + "logs": "Journaux", + "files": "Fichiers", + "timeline": "Chronologie" + } } } } diff --git a/apps/web-frontend/src/pages/TaskDetailNext.tsx b/apps/web-frontend/src/pages/TaskDetailNext.tsx index 162939ea9..0c89aee9d 100644 --- a/apps/web-frontend/src/pages/TaskDetailNext.tsx +++ b/apps/web-frontend/src/pages/TaskDetailNext.tsx @@ -11,7 +11,7 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Navigate, useNavigate, useParams } from "react-router-dom"; import { AutoCodeClientProvider, TaskDetail, useTask } from "@auto-code/ui"; -import type { TaskStatus } from "@auto-code/ui"; +import type { TaskDetailTabId, TaskStatus } from "@auto-code/ui"; import { createRestAutoCodeClient } from "../api/autoCodeClient"; import { apiClient } from "../api/client"; @@ -29,6 +29,16 @@ function DetailBody({ id }: Readonly<{ id: string }>) { }), [t], ); + const tabLabels = useMemo>>( + () => ({ + overview: t("tasks:kanbanPilot.detail.tabs.overview"), + subtasks: t("tasks:kanbanPilot.detail.tabs.subtasks"), + logs: t("tasks:kanbanPilot.detail.tabs.logs"), + files: t("tasks:kanbanPilot.detail.tabs.files"), + timeline: t("tasks:kanbanPilot.detail.tabs.timeline"), + }), + [t], + ); return ( ) { onBack={() => navigate("/kanban-next")} onRetry={reload} statusLabels={statusLabels} + tabLabels={tabLabels} /> ); } diff --git a/libs/ui/src/client/types.ts b/libs/ui/src/client/types.ts index 4087be804..15f912f58 100644 --- a/libs/ui/src/client/types.ts +++ b/libs/ui/src/client/types.ts @@ -41,6 +41,28 @@ export interface UiTaskProgress { total: number; } +export type UiSubtaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed'; + +/** One subtask row in the detail's pipeline view. */ +export interface UiSubtask { + id: string; + title: string; + description?: string; + status: UiSubtaskStatus; +} + +/** Key-value row inside a detail meta card. */ +export interface UiMetaRow { + label: string; + value: string; +} + +/** Right-rail meta card on the detail (e.g. "Cost & tokens"). */ +export interface UiMetaSection { + title: string; + rows: UiMetaRow[]; +} + /** * The detail view of a task/spec. Extends UiTask with the spec body and a * per-status progress breakdown; each adapter maps its richer model down to @@ -51,6 +73,10 @@ export interface UiTaskDetail extends UiTask { specContent?: string; /** Subtask counts by status, when a build plan exists. */ progressBreakdown?: UiTaskProgress; + /** Subtask rows for the pipeline view, when the source exposes them. */ + subtasks?: UiSubtask[]; + /** Right-rail meta cards (workspace, cost & tokens, …), when available. */ + metaSections?: UiMetaSection[]; } /** Input for creating a new task/spec from the shared UI. */ diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index b4bf49369..ddc19ddb2 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -33,6 +33,10 @@ export type { UiTaskBadge, UiTaskDetail, UiTaskProgress, + UiSubtask, + UiSubtaskStatus, + UiMetaRow, + UiMetaSection, CreateTaskInput, TaskStatus, BadgeTone, @@ -41,7 +45,7 @@ export { KanbanBoard, DEFAULT_KANBAN_COLUMNS } from './screens/KanbanBoard'; export type { KanbanBoardProps, KanbanColumn } from './screens/KanbanBoard'; // Canonical screens (U2) export { TaskDetail } from './screens/TaskDetail'; -export type { TaskDetailProps } from './screens/TaskDetail'; +export type { TaskDetailProps, TaskDetailTabId } from './screens/TaskDetail'; // Board chrome (U3) export { BoardToolbar } from './screens/BoardToolbar'; export type { diff --git a/libs/ui/src/screens/TaskDetail.css b/libs/ui/src/screens/TaskDetail.css index 933132a05..86db8c8d2 100644 --- a/libs/ui/src/screens/TaskDetail.css +++ b/libs/ui/src/screens/TaskDetail.css @@ -1,7 +1,7 @@ .ac-task-detail { font-family: var(--font-sans); color: var(--ink); - max-width: 820px; + max-width: 1180px; padding: 20px 24px; display: flex; flex-direction: column; @@ -132,3 +132,180 @@ overflow-wrap: anywhere; overflow-x: auto; } + +/* ===== Pipeline chrome (tabs + two-column body), per desktop-task-detail-v2 ===== */ + +.ac-task-detail__tabs { + display: flex; + border-bottom: 1px solid var(--line); + overflow-x: auto; +} + +.ac-task-detail__tab { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 42px; + padding: 0 14px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--muted); + font-family: inherit; + font-size: 13px; + font-weight: 720; + white-space: nowrap; + cursor: pointer; +} + +.ac-task-detail__tab:disabled { + cursor: default; + opacity: 0.5; +} + +.ac-task-detail__tab:focus-visible { + outline: 2px solid var(--blue); + outline-offset: -2px; +} + +.ac-task-detail__tab--on { + color: var(--ink); + border-bottom-color: var(--ink); + background: var(--panel); +} + +.ac-task-detail__tab-count { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + color: var(--quiet); +} + +.ac-task-detail__body { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 16px; + align-items: start; +} + +.ac-task-detail__body--single { + grid-template-columns: minmax(0, 1fr); +} + +.ac-task-detail__center { + display: grid; + gap: 16px; + align-content: start; + min-width: 0; +} + +.ac-task-detail__rail { + display: grid; + gap: 14px; + align-content: start; +} + +.ac-task-detail__card { + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel); +} + +.ac-task-detail__card h3 { + margin: 0 0 6px; + font-size: 13px; + font-weight: 760; +} + +.ac-task-detail__kv { + display: grid; + grid-template-columns: 110px minmax(0, 1fr); + gap: 8px; + padding: 2px 0; + font-size: 12.5px; +} + +.ac-task-detail__kv span { + color: var(--quiet); + font-weight: 720; +} + +.ac-task-detail__kv strong { + font-weight: 720; + overflow-wrap: anywhere; +} + +.ac-task-detail__panel { + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel); + overflow: hidden; +} + +.ac-task-detail__subtask { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) auto; + gap: 12px; + align-items: start; + padding: 12px 16px; + border-top: 1px solid var(--line); +} + +.ac-task-detail__subtask:first-child { + border-top: 0; +} + +.ac-task-detail__subtask h3 { + margin: 0; + font-size: 13px; + font-weight: 720; +} + +.ac-task-detail__subtask p { + margin: 4px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.4; +} + +.ac-task-detail__check { + width: 16px; + height: 16px; + margin-top: 2px; + border: 1px solid var(--line-strong); + border-radius: 5px; +} + +.ac-task-detail__check--completed { + position: relative; + border-color: var(--green); + background: var(--green); +} + +.ac-task-detail__check--completed::after { + content: ''; + position: absolute; + left: 4px; + top: 1px; + width: 4px; + height: 9px; + border: solid #fff; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +.ac-task-detail__check--in_progress { + border-color: var(--blue); + background: var(--blue-soft); +} + +.ac-task-detail__check--failed { + border-color: var(--red); + background: var(--red-soft); +} + +@media (max-width: 1024px) { + .ac-task-detail__body { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/libs/ui/src/screens/TaskDetail.stories.tsx b/libs/ui/src/screens/TaskDetail.stories.tsx index b86be9d15..d24a24afb 100644 --- a/libs/ui/src/screens/TaskDetail.stories.tsx +++ b/libs/ui/src/screens/TaskDetail.stories.tsx @@ -16,16 +16,86 @@ const meta = { status: 'running', description: 'JWT access + refresh, bcrypt hashing, workspace bootstrap.', progress: 40, - badges: [{ label: 'coder', tone: 'info' }], + badges: [ + { label: 'Coder', tone: 'info' }, + { label: 'On track', tone: 'good' }, + { label: 'Worktree: ac/spec-004', tone: 'neutral' }, + ], progressBreakdown: { completed: 2, inProgress: 1, - pending: 2, - failed: 0, - total: 5, + pending: 3, + failed: 1, + total: 7, }, specContent: '# 004 — Add user authentication\n\n## Goal\nEmail+password auth with JWT and refresh rotation.\n\n## Acceptance\n- POST /api/users/register issues a token\n- Passwords hashed with bcrypt', + subtasks: [ + { + id: 'st-1', + title: 'Scaffold auth module + routes', + description: 'users router, auth middleware, config plumbing', + status: 'completed', + }, + { + id: 'st-2', + title: 'Password hashing with bcrypt', + status: 'completed', + }, + { + id: 'st-3', + title: 'JWT access + refresh token issuing', + description: 'rotation on refresh, revocation list', + status: 'in_progress', + }, + { + id: 'st-4', + title: 'Login/logout endpoints', + status: 'pending', + }, + { + id: 'st-5', + title: 'Session persistence across restarts', + status: 'pending', + }, + { + id: 'st-6', + title: 'Rate-limit login attempts', + status: 'pending', + }, + { + id: 'st-7', + title: 'Legacy session migration', + description: 'blocked: legacy store schema mismatch', + status: 'failed', + }, + ], + metaSections: [ + { + title: 'Workspace', + rows: [ + { label: 'Branch', value: 'ac/spec-004' }, + { label: 'Base', value: 'develop' }, + { label: 'Worktree', value: '.worktrees/spec-004' }, + ], + }, + { + title: 'Cost & tokens', + rows: [ + { label: 'Cost', value: '$4.87' }, + { label: 'Input', value: '112k' }, + { label: 'Output', value: '28k' }, + { label: 'Sessions', value: '9' }, + ], + }, + { + title: 'Next checkpoint', + rows: [ + { label: 'Gate', value: 'QA review after subtask 4' }, + { label: 'Mode', value: 'Full autonomous' }, + ], + }, + ], }, }, } satisfies Meta; @@ -35,6 +105,20 @@ type Story = StoryObj; export const RunningTask: Story = {}; +export const OverviewOnly: Story = { + args: { + task: { + id: '002', + title: 'Fix board drag-and-drop', + status: 'review', + description: 'Cards drop on the wrong column when the board scrolls.', + progress: 100, + badges: [{ label: 'QA', tone: 'warn' }], + specContent: '# 002 — Fix board drag-and-drop\n\nRepro + fix notes.', + }, + }, +}; + export const Loading: Story = { args: { task: null, loading: true }, }; diff --git a/libs/ui/src/screens/TaskDetail.tsx b/libs/ui/src/screens/TaskDetail.tsx index 9dc40661d..871e47616 100644 --- a/libs/ui/src/screens/TaskDetail.tsx +++ b/libs/ui/src/screens/TaskDetail.tsx @@ -1,5 +1,12 @@ +import { useState } from 'react'; import { Badge } from '../primitives/Badge'; -import type { TaskStatus, UiTaskDetail } from '../client/types'; +import type { + BadgeTone, + TaskStatus, + UiSubtask, + UiSubtaskStatus, + UiTaskDetail, +} from '../client/types'; import './TaskDetail.css'; const STATUS_LABELS: Record = { @@ -9,6 +16,31 @@ const STATUS_LABELS: Record = { done: 'Done', }; +const TAB_IDS = ['overview', 'subtasks', 'logs', 'files', 'timeline'] as const; +export type TaskDetailTabId = (typeof TAB_IDS)[number]; + +const TAB_LABELS: Record = { + overview: 'Overview', + subtasks: 'Subtasks', + logs: 'Logs', + files: 'Files', + timeline: 'Timeline', +}; + +const SUBTASK_STATUS_LABELS: Record = { + pending: 'Pending', + in_progress: 'Running', + completed: 'Done', + failed: 'Failed', +}; + +const SUBTASK_STATUS_TONES: Record = { + pending: 'neutral', + in_progress: 'info', + completed: 'good', + failed: 'bad', +}; + export interface TaskDetailProps { task: UiTaskDetail | null; loading?: boolean; @@ -18,13 +50,19 @@ export interface TaskDetailProps { onRetry?: () => void; /** Localized status labels; falls back to English. */ statusLabels?: Partial>; + /** Localized tab labels; falls back to English. */ + tabLabels?: Partial>; + /** Localized subtask status labels; falls back to English. */ + subtaskStatusLabels?: Partial>; } /** - * Presentational task/spec detail: header (id, title, status, badges), - * a per-status progress breakdown, and the spec body. Data-agnostic — pair - * with `useTask()` + an AutoCodeClient adapter. Mirrors the `.lazyweb` - * task-detail layout. + * Presentational task/spec detail mirroring the `.lazyweb` pipeline view: + * header (id, title, status, badges), tab bar, a two-column body with the + * overview/subtasks content on the left and meta cards (workspace, cost & + * tokens, …) on the right rail. Logs/Files/Timeline tabs are rendered but + * disabled until their data flows land. Data-agnostic — pair with + * `useTask()` + an AutoCodeClient adapter. */ export function TaskDetail({ task, @@ -33,6 +71,8 @@ export function TaskDetail({ onBack, onRetry, statusLabels, + tabLabels, + subtaskStatusLabels, }: Readonly) { return (
@@ -64,7 +104,13 @@ export function TaskDetail({ )} {!loading && error == null && task != null && ( - + )}
); @@ -73,11 +119,36 @@ export function TaskDetail({ interface TaskDetailBodyProps { task: UiTaskDetail; statusLabels?: Partial>; + tabLabels?: Partial>; + subtaskStatusLabels?: Partial>; } -function TaskDetailBody({ task, statusLabels }: Readonly) { +function TaskDetailBody({ + task, + statusLabels, + tabLabels, + subtaskStatusLabels, +}: Readonly) { + const [activeTab, setActiveTab] = useState('overview'); const statusLabel = statusLabels?.[task.status] ?? STATUS_LABELS[task.status]; - const breakdown = task.progressBreakdown; + const subtasks = task.subtasks ?? []; + const metaSections = task.metaSections ?? []; + + // Only tabs whose content exists are selectable; the rest are rendered + // disabled so the pipeline chrome matches the mockup honestly. + const enabled: Record = { + overview: true, + subtasks: subtasks.length > 0, + logs: false, + files: false, + timeline: false, + }; + // A live update can disable the selected tab (e.g. subtasks emptied) — + // fall back to Overview rather than rendering a dead panel. + const currentTab = enabled[activeTab] ? activeTab : 'overview'; + const counts: Partial> = { + subtasks: subtasks.length > 0 ? subtasks.length : undefined, + }; return ( <> @@ -107,6 +178,65 @@ function TaskDetailBody({ task, statusLabels }: Readonly) { )} + + +
0 ? '' : ' ac-task-detail__body--single' + }`} + > +
+ {currentTab === 'overview' && } + {currentTab === 'subtasks' && ( + + )} +
+ + {metaSections.length > 0 && ( + + )} +
+ + ); +} + +function OverviewTab({ task }: Readonly<{ task: UiTaskDetail }>) { + const breakdown = task.progressBreakdown; + return ( + <> {task.description != null && task.description !== '' && (

{task.description}

)} @@ -144,3 +274,33 @@ function TaskDetailBody({ task, statusLabels }: Readonly) { ); } + +function SubtasksPanel({ + subtasks, + statusLabels, +}: Readonly<{ + subtasks: UiSubtask[]; + statusLabels?: Partial>; +}>) { + return ( +
+ {subtasks.map((subtask) => ( +
+ +
+

{subtask.title}

+ {subtask.description != null && subtask.description !== '' && ( +

{subtask.description}

+ )} +
+ + {statusLabels?.[subtask.status] ?? + SUBTASK_STATUS_LABELS[subtask.status]} + +
+ ))} +
+ ); +} From 29e48be00a43ff4247bc59826c23dcacba88351d Mon Sep 17 00:00:00 2001 From: Oleg Miagkov Date: Sun, 12 Jul 2026 23:06:48 +0400 Subject: [PATCH 2/2] fix(ui): guard malformed total_cost in task meta sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cost_report.json crosses IPC as a bare JSON.parse cast, and historical or hand-edited files can carry a missing/string/negative total_cost (the backend cost_tracking loader is defensive for the same reason). Unguarded .toFixed(2) threw inside loadMetaSections and the adapter's swallowed-failure contract silently dropped the entire meta rail — including the Workspace card built from purely local data. Skip the cost row instead, matching formatTokenCount's clamping; pin the k/M boundary behavior and the cost-only card in tests. Confirmed by the adversarial review workflow (2 confirmed findings, same root cause; 6 refuted). Co-Authored-By: Claude Fable 5 --- .../__tests__/task-meta-sections.test.ts | 32 +++++++++++++++++++ .../src/renderer/lib/task-meta-sections.ts | 11 ++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/renderer/__tests__/task-meta-sections.test.ts b/apps/frontend/src/renderer/__tests__/task-meta-sections.test.ts index 8daa3ac10..633506f92 100644 --- a/apps/frontend/src/renderer/__tests__/task-meta-sections.test.ts +++ b/apps/frontend/src/renderer/__tests__/task-meta-sections.test.ts @@ -90,6 +90,11 @@ describe('formatTokenCount', () => { expect(formatTokenCount(-5)).toBe('0'); expect(formatTokenCount(Number.NaN)).toBe('0'); }); + + it('pins the k/M boundary behavior', () => { + expect(formatTokenCount(999_999)).toBe('1000k'); + expect(formatTokenCount(1_234_000_000)).toBe('1234.0M'); + }); }); describe('buildTaskMetaSections', () => { @@ -140,4 +145,31 @@ describe('buildTaskMetaSections', () => { const sections = buildTaskMetaSections(makeTask(), null, null, LABELS); expect(sections[0].rows.map((row) => row.label)).toEqual(['Spec', 'Updated']); }); + + it('renders a cost-only card when token stats are missing', () => { + const sections = buildTaskMetaSections(makeTask(), null, COST_REPORT, LABELS); + expect(sections).toHaveLength(2); + expect(sections[1].rows).toEqual([{ label: 'Cost', value: '$4.87' }]); + }); + + it('skips the cost row on malformed, negative, or non-finite total_cost', () => { + const malformed = (total_cost: unknown): CostReport => + ({ ...COST_REPORT, total_cost }) as CostReport; + for (const bad of ['4.87', null, undefined, -1, Number.POSITIVE_INFINITY]) { + const sections = buildTaskMetaSections( + makeTask(), + makeTokenStats(), + malformed(bad), + LABELS, + ); + // The rail must survive a bad cost_report.json: workspace card intact, + // token rows intact, only the cost row dropped. + expect(sections).toHaveLength(2); + expect(sections[1].rows.map((row) => row.label)).toEqual([ + 'Input', + 'Output', + 'Sessions', + ]); + } + }); }); diff --git a/apps/frontend/src/renderer/lib/task-meta-sections.ts b/apps/frontend/src/renderer/lib/task-meta-sections.ts index 4406eb6fe..c9318ad35 100644 --- a/apps/frontend/src/renderer/lib/task-meta-sections.ts +++ b/apps/frontend/src/renderer/lib/task-meta-sections.ts @@ -53,7 +53,16 @@ export function buildTaskMetaSections( sections.push({ title: labels.workspaceTitle, rows: workspaceRows }); const costRows = []; - if (costReport != null) { + // cost_report.json crosses IPC as a bare JSON.parse cast — historical or + // hand-edited files can carry a missing/string total_cost (the backend's + // cost_tracking loader is defensive for the same reason). Skip the row + // rather than throwing and losing the whole rail. + if ( + costReport != null && + typeof costReport.total_cost === 'number' && + Number.isFinite(costReport.total_cost) && + costReport.total_cost >= 0 + ) { costRows.push({ label: labels.cost, value: `$${costReport.total_cost.toFixed(2)}`,