Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/frontend/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
7 changes: 7 additions & 0 deletions apps/frontend/src/preload/api/task-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ export interface TaskAPI {
// Task Token Stats
getTokenStats: (projectPath: string, specId: string) => Promise<IPCResult<import('../../shared/types').TaskTokenStats | null>>;

// Task Cost Report (cost_report.json for a spec)
getCostReport: (projectId: string, specId: string) => Promise<IPCResult<import('../../shared/types').CostReport>>;

// Task Spec File Reading (for task overview display)
getSpecContent: (taskId: string) => Promise<IPCResult<string | null>>;
getImplementationPlan: (taskId: string) => Promise<IPCResult<ImplementationPlan | null>>;
Expand Down Expand Up @@ -353,6 +356,10 @@ export const createTaskAPI = (): TaskAPI => ({
getTokenStats: (projectPath: string, specId: string): Promise<IPCResult<import('../../shared/types').TaskTokenStats | null>> =>
ipcRenderer.invoke(IPC_CHANNELS.TASK_TOKEN_STATS_GET, projectPath, specId),

// Task Cost Report
getCostReport: (projectId: string, specId: string): Promise<IPCResult<import('../../shared/types').CostReport>> =>
ipcRenderer.invoke(IPC_CHANNELS.PROJECT_LOAD_COST_REPORT, projectId, specId),

// Task Spec File Reading
getSpecContent: (taskId: string): Promise<IPCResult<string | null>> =>
ipcRenderer.invoke(IPC_CHANNELS.TASK_SPEC_CONTENT_GET, taskId),
Expand Down
48 changes: 48 additions & 0 deletions apps/frontend/src/renderer/__tests__/autoCodeClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
175 changes: 175 additions & 0 deletions apps/frontend/src/renderer/__tests__/task-meta-sections.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* 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> = {}): 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> = {}): 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');
});

it('pins the k/M boundary behavior', () => {
expect(formatTokenCount(999_999)).toBe('1000k');
expect(formatTokenCount(1_234_000_000)).toBe('1234.0M');
});
});

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']);
});

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',
]);
}
});
});
72 changes: 71 additions & 1 deletion apps/frontend/src/renderer/components/KanbanPilotView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down Expand Up @@ -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);

Expand All @@ -83,6 +92,27 @@ function PilotDetail({
Object.fromEntries(columns.map((column) => [column.status, column.label])),
[columns],
);
const tabLabels = useMemo<Partial<Record<TaskDetailTabId, string>>>(
() => ({
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<Record<UiSubtaskStatus, string>>
>(
() => ({
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 (
<div className="h-full overflow-auto">
Expand All @@ -93,6 +123,8 @@ function PilotDetail({
onBack={onBack}
onRetry={reload}
statusLabels={statusLabels}
tabLabels={tabLabels}
subtaskStatusLabels={subtaskStatusLabels}
/>
</div>
);
Expand Down Expand Up @@ -133,13 +165,51 @@ export function KanbanPilotView() {
failed: t('kanban:pilot.phases.failed'),
},
};
const metaLabelsRef = useRef<TaskMetaLabels>({} 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, {
loadSpecContent: async (task) => {
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;
},
}),
[],
);
Expand Down
Loading
Loading