taskchef 7.29.0 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/README.md +15 -0
  3. package/bin/taskchef.js +11 -1
  4. package/docs/dashboard-lifecycle.md +10 -0
  5. package/docs/spec.md +28 -0
  6. package/docs/workflows.md +5 -1
  7. package/index.js +14 -0
  8. package/package.json +1 -1
  9. package/skills/taskchef-bootstrap/SKILL.md +7 -3
  10. package/skills/taskchef-bootstrap/references/project-index.md +21 -3
  11. package/skills/taskchef-copilot/SKILL.md +2 -0
  12. package/skills/taskchef-dashboard/SKILL.md +4 -0
  13. package/skills/taskchef-delegate/SKILL.md +3 -2
  14. package/src/backups.js +359 -0
  15. package/src/cli.js +180 -16
  16. package/src/config-audit.js +99 -0
  17. package/src/config-mutations.js +98 -0
  18. package/src/dashboard/dist/app.js +58 -38
  19. package/src/dashboard/dist/app.js.map +1 -1
  20. package/src/dashboard/dist/styles.css +1 -1
  21. package/src/dashboard/github-links.js +9 -1
  22. package/src/dashboard/react/App.tsx +16 -0
  23. package/src/dashboard/react/components/RelativeTime.tsx +23 -5
  24. package/src/dashboard/react/components/TaskCard.tsx +4 -21
  25. package/src/dashboard/react/components/TaskCardStats.test.tsx +122 -0
  26. package/src/dashboard/react/components/TaskCardStats.tsx +86 -0
  27. package/src/dashboard/react/presentation.test.tsx +2 -2
  28. package/src/dashboard/react/presentation.ts +50 -20
  29. package/src/dashboard/react/styles.css +16 -11
  30. package/src/dashboard/react/types.ts +10 -0
  31. package/src/dashboard-manager.js +16 -0
  32. package/src/dashboard.js +50 -23
  33. package/src/state-store.js +81 -0
  34. package/src/workspace.js +639 -37
  35. package/src/dashboard/react/components/TaskUsageSummary.test.tsx +0 -122
  36. package/src/dashboard/react/components/TaskUsageSummary.tsx +0 -24
@@ -78,7 +78,15 @@ export function groupRelatedGitHubLinks(links) {
78
78
  group.push(link);
79
79
  groups.set(repository, group);
80
80
  }
81
- return [...groups.values()].map((group) => [...group].sort(compareReferenceNumbers));
81
+ const collisions = collidingRepositoryBasenames(links ?? []);
82
+ return [...groups.values()]
83
+ .map((group) => [...group].sort(compareReferenceNumbers))
84
+ .sort((left, right) => {
85
+ const leftLabel = visibleRepositoryLabel(left[0], collisions);
86
+ const rightLabel = visibleRepositoryLabel(right[0], collisions);
87
+ return leftLabel.localeCompare(rightLabel, undefined, { sensitivity: "base" })
88
+ || repositoryIdentity(left[0]).localeCompare(repositoryIdentity(right[0]));
89
+ });
82
90
  }
83
91
 
84
92
  export function githubReferenceAccessibleLabel(link) {
@@ -109,6 +109,7 @@ export function DashboardApp({
109
109
  return () => window.removeEventListener('hashchange', update);
110
110
  }, []);
111
111
  const [tasks, setTasks] = useState<Task[]>(initialTasks);
112
+ const [projectIndex, setProjectIndex] = useState<DashboardSnapshot["projectIndex"]>();
112
113
  const [connected, setConnected] = useState(false);
113
114
  const [message, setMessage] = useState<string | null>(null);
114
115
  const [version, setVersion] = useState<string | null>(null);
@@ -138,6 +139,7 @@ export function DashboardApp({
138
139
 
139
140
  const applySnapshot = useCallback((snapshot: DashboardSnapshot) => {
140
141
  setTasks(snapshot.tasks);
142
+ setProjectIndex(snapshot.projectIndex);
141
143
  setNotificationState((current) => {
142
144
  const reconciled = reconcileNotifications(current, snapshot.tasks);
143
145
  return {
@@ -325,6 +327,20 @@ export function DashboardApp({
325
327
 
326
328
  <AppShell.Main>
327
329
  <Container className="taskchef-main" pb={80} size={1080}>
330
+ {projectIndex?.status === "unavailable" && (
331
+ <Alert color="yellow" icon={<IconAlertTriangle size={18} />} mb="md" title="Project index unavailable">
332
+ Task history is still visible. Verify the index and inspect backups before making project changes.
333
+ </Alert>
334
+ )}
335
+ {projectIndex?.status === "available" && projectIndex.missingProjects.length > 0 && (
336
+ <Alert color="yellow" icon={<IconAlertTriangle size={18} />} mb="md" title="Historical projects are missing from the current index">
337
+ {projectIndex.missingProjects.map((item) => (
338
+ <Text key={item.path} size="sm">
339
+ {item.snapshotNames.join(", ")}: {item.taskCount} task(s). Run <code>taskchef backup list --json</code> and preview any restore; the dashboard never restores automatically.
340
+ </Text>
341
+ ))}
342
+ </Alert>
343
+ )}
328
344
  {settings ? <Stack gap="md"><Button component="a" href="#" variant="subtle" size="sm" leftSection={<IconArrowLeft size={16} />} style={{ alignSelf: 'flex-start' }}>Back to tasks</Button><ModelSettings /></Stack> : <>
329
345
  <Paper className="taskchef-toolbar" p="sm" radius="md" withBorder>
330
346
  <Stack gap="sm">
@@ -9,19 +9,37 @@ export function RelativeTimeProvider({ children, now }: { children: ReactNode; n
9
9
  return <RelativeTimeClock value={now}>{children}</RelativeTimeClock>;
10
10
  }
11
11
 
12
- export function RelativeTime({ label, value }: { label: string; value: string | null | undefined }) {
12
+ export function RelativeTime({
13
+ icon,
14
+ label,
15
+ value,
16
+ }: {
17
+ icon?: ReactNode;
18
+ label: string;
19
+ value: string | null | undefined;
20
+ }) {
13
21
  const [exact, setExact] = useState(false);
14
22
  useContext(RelativeTimeClock);
15
23
  const text = exact ? formatExactTime(value) : formatRelativeTime(value);
24
+ const exactText = formatExactTime(value);
25
+ const unavailable = exactText === "—";
26
+ const tooltip = exactText === "—"
27
+ ? "Updated time unavailable"
28
+ : exact
29
+ ? "Show relative time"
30
+ : `Exact time: ${exactText}. Show exact time`;
16
31
  return (
17
- <Tooltip label={exact ? "Show relative time" : "Show exact time"}>
32
+ <Tooltip label={tooltip}>
18
33
  <UnstyledButton
19
- aria-label={`${label}: ${text}. ${exact ? "Show relative time" : "Show exact time"}`}
34
+ aria-label={unavailable
35
+ ? `${label}: unavailable.`
36
+ : `${label}: ${text}. ${exact ? "Show relative time" : "Show exact time"}`}
20
37
  className="taskchef-time"
38
+ disabled={unavailable}
21
39
  onClick={() => setExact((value) => !value)}
22
40
  >
23
- <IconClock aria-hidden size={12} />
24
- <span className="taskchef-time-label">{text}</span>
41
+ {icon ?? <IconClock aria-hidden size={12} />}
42
+ <bdi className="taskchef-time-label">{text}</bdi>
25
43
  </UnstyledButton>
26
44
  </Tooltip>
27
45
  );
@@ -1,14 +1,12 @@
1
- import { Box, Group, Paper, Stack, Text, Title } from "@mantine/core";
1
+ import { Box, Paper, Stack, Text, Title } from "@mantine/core";
2
2
  import { latestTurnPresentation } from "../../state.js";
3
3
  import type { Task } from "../types";
4
- import { taskReportedWorkView } from "../presentation";
5
4
  import { GitHubLinks } from "./GitHubLinks";
6
5
  import { LinkedText } from "./LinkedText";
7
6
  import { OpenChatButton } from "./OpenChatButton";
8
- import { RelativeTime } from "./RelativeTime";
9
7
  import { ShimmerText } from "./ShimmerText";
10
8
  import { StatusBadge } from "./StatusBadge";
11
- import { TaskUsageSummary } from "./TaskUsageSummary";
9
+ import { TaskCardStats } from "./TaskCardStats";
12
10
 
13
11
  export function TaskCard({
14
12
  onOpenCodex,
@@ -20,7 +18,6 @@ export function TaskCard({
20
18
  task: Task;
21
19
  }) {
22
20
  const latest = latestTurnPresentation(task);
23
- const reportedWork = taskReportedWorkView(task);
24
21
  return (
25
22
  <Paper className="taskchef-task-row" component="article" p="md" radius="md" withBorder>
26
23
  <Stack className="taskchef-task-main" gap="sm">
@@ -33,7 +30,7 @@ export function TaskCard({
33
30
  </Title>
34
31
  <Stack align="flex-end" className="taskchef-card-metadata" gap={4}>
35
32
  <StatusBadge status={task.status} />
36
- <TaskUsageSummary task={task} />
33
+ <OpenChatButton onClick={() => onOpenCodex(task)} taskTitle={task.title} />
37
34
  </Stack>
38
35
  </Box>
39
36
  <Text c="dimmed" mt={2} size="xs">{task.project.name}</Text>
@@ -53,21 +50,7 @@ export function TaskCard({
53
50
  </Box>
54
51
 
55
52
  <GitHubLinks task={task} />
56
- <Group className="taskchef-card-footer" gap="md" justify="space-between">
57
- <Group className="taskchef-card-timing" gap="sm">
58
- <RelativeTime label={`Updated time for ${task.title}`} value={task.meaningfulUpdatedAt ?? task.updatedAt} />
59
- <Text
60
- aria-label={reportedWork.accessibleLabel}
61
- className="taskchef-card-duration"
62
- size="xs"
63
- title={reportedWork.title}
64
- >
65
- <span>Reported work</span>
66
- <span>{reportedWork.value}</span>
67
- </Text>
68
- </Group>
69
- <OpenChatButton onClick={() => onOpenCodex(task)} taskTitle={task.title} />
70
- </Group>
53
+ <TaskCardStats task={task} />
71
54
  </Stack>
72
55
  </Paper>
73
56
  );
@@ -0,0 +1,122 @@
1
+ import { MantineProvider } from "@mantine/core";
2
+ import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
3
+ import { afterEach, describe, expect, test, vi } from "vitest";
4
+ import { fixtureTask } from "../fixtures";
5
+ import { TaskCard } from "./TaskCard";
6
+ import { TaskCardStats } from "./TaskCardStats";
7
+
8
+ afterEach(cleanup);
9
+
10
+ function renderStats(task = fixtureTask()) {
11
+ return render(<MantineProvider><TaskCardStats task={task} /></MantineProvider>);
12
+ }
13
+
14
+ describe("task card stats", () => {
15
+ test("renders the four metrics in order with compact values and full accessibility", () => {
16
+ const { container } = renderStats(fixtureTask({
17
+ status: "completed",
18
+ turns: undefined,
19
+ reportedWork: { terminalTurns: 1, validTurns: 1, totalMilliseconds: 1_112_000 },
20
+ usage: {
21
+ status: "available",
22
+ updatedAt: "2026-08-30T09:00:00.000Z",
23
+ task: {
24
+ totalTokens: 1_324_567,
25
+ estimatedCostUsd: 12.3449,
26
+ sourceUpdatedAt: "2026-08-30T09:00:00.000Z",
27
+ },
28
+ },
29
+ }));
30
+ const stats = container.querySelector(".taskchef-card-stats") as HTMLElement;
31
+ expect(stats).not.toBeNull();
32
+ const cells = [...stats.children] as HTMLElement[];
33
+ expect(cells).toHaveLength(4);
34
+ expect(within(cells[0]).getByLabelText(/updated time/i)).toBeVisible();
35
+ expect(within(cells[1]).getByLabelText(/reported work 18m 32s/i)).toBeVisible();
36
+ expect(within(cells[2]).getByLabelText(/1,324,567 tokens/i)).toHaveTextContent("1.32M tokens");
37
+ expect(within(cells[3]).getByLabelText(/estimated cost \$12\.34/i)).toHaveTextContent("est. $12.34");
38
+ expect(within(cells[2]).getByRole("status")).toHaveTextContent(/1,324,567 tokens.*estimated cost \$12\.34/i);
39
+ });
40
+
41
+ test("keeps known tokens when cost is missing and never substitutes zero", () => {
42
+ renderStats(fixtureTask({
43
+ status: "completed",
44
+ usage: { status: "available", task: { totalTokens: 1_324_567, estimatedCostUsd: null } },
45
+ }));
46
+ expect(screen.getByLabelText(/1,324,567 tokens/i)).toHaveTextContent("1.32M tokens");
47
+ expect(screen.getByLabelText(/estimated cost unavailable/i)).toHaveTextContent("est. n/a");
48
+ });
49
+
50
+ test("shows metric details to keyboard users on focus", () => {
51
+ vi.useFakeTimers();
52
+ renderStats(fixtureTask({
53
+ status: "completed",
54
+ usage: { status: "available", task: { totalTokens: 1_324_567, estimatedCostUsd: 12.3449 } },
55
+ }));
56
+ const cost = screen.getByLabelText(/estimated cost \$12\.34/i);
57
+ fireEvent.focus(cost);
58
+ vi.advanceTimersByTime(150);
59
+ expect(screen.getByRole("tooltip")).toHaveTextContent("Unrounded estimate: $12.3449");
60
+ vi.useRealTimers();
61
+ });
62
+
63
+ test("shows pending, calculating, and unavailable states in both usage cells", () => {
64
+ const { rerender } = renderStats();
65
+ expect(screen.getByLabelText("Tokens pending")).toHaveTextContent("Pending");
66
+ expect(screen.getByLabelText("Estimated cost pending")).toHaveTextContent("Pending");
67
+ expect(screen.getAllByText("Pending").every((node) => node.classList.contains("taskchef-shimmer"))).toBe(true);
68
+
69
+ rerender(<MantineProvider><TaskCardStats task={fixtureTask({
70
+ status: "completed",
71
+ usage: { generationTurnRef: "turn-one", status: "calculating" },
72
+ })} /></MantineProvider>);
73
+ expect(screen.getByLabelText("Tokens calculating")).toHaveTextContent("Calculating");
74
+ expect(screen.getByLabelText("Estimated cost calculating")).toHaveTextContent("Calculating");
75
+
76
+ rerender(<MantineProvider><TaskCardStats task={fixtureTask({
77
+ status: "completed",
78
+ usage: { status: "unavailable", reason: "No matching cached boundary." },
79
+ })} /></MantineProvider>);
80
+ expect(screen.getByLabelText(/token usage unavailable: no matching/i)).toHaveTextContent("n/a tokens");
81
+ expect(screen.getByLabelText(/estimated cost unavailable: no matching/i)).toHaveTextContent("est. n/a");
82
+ });
83
+
84
+ test("retains known totals and marks both usage values as updating", () => {
85
+ renderStats(fixtureTask({
86
+ status: "working",
87
+ turnRef: "turn-two",
88
+ usage: {
89
+ generationTurnRef: "turn-one",
90
+ status: "calculating",
91
+ task: { totalTokens: 1_324_567, estimatedCostUsd: 12.3449 },
92
+ },
93
+ }));
94
+ expect(screen.getByLabelText(/1,324,567 tokens; updating/i)).toHaveTextContent("1.32M tokens · Updating…");
95
+ expect(screen.getByLabelText(/estimated cost \$12\.34; updating/i)).toHaveTextContent("est. $12.34 · Updating…");
96
+ });
97
+
98
+ test("places Open chat beneath the status badge and keeps callbacks working", () => {
99
+ const openChat = vi.fn();
100
+ const openDetail = vi.fn();
101
+ const { container } = render(<MantineProvider><TaskCard
102
+ onOpenCodex={openChat}
103
+ onOpenDetail={openDetail}
104
+ task={fixtureTask()}
105
+ /></MantineProvider>);
106
+ const metadata = container.querySelector(".taskchef-card-metadata") as HTMLElement;
107
+ expect(metadata.children[0]).toHaveClass("taskchef-status-badge");
108
+ expect(within(metadata).getByRole("button", { name: /open chat/i })).toBe(metadata.children[1]);
109
+ fireEvent.click(within(metadata).getByRole("button", { name: /open chat/i }));
110
+ fireEvent.click(screen.getByRole("button", { name: /^review checkout reconciliation$/i }));
111
+ expect(openChat).toHaveBeenCalledOnce();
112
+ expect(openDetail).toHaveBeenCalledOnce();
113
+ });
114
+
115
+ test("toggles the updated time while keeping the selected History icon", () => {
116
+ const { container } = renderStats(fixtureTask({ meaningfulUpdatedAt: "2026-08-30T08:00:00.000Z" }));
117
+ const time = screen.getByLabelText(/updated time for review checkout reconciliation/i);
118
+ expect(container.querySelector(".tabler-icon-history")).not.toBeNull();
119
+ fireEvent.click(time);
120
+ expect(time).toHaveAccessibleName(/show relative time/i);
121
+ });
122
+ });
@@ -0,0 +1,86 @@
1
+ import { Box, Text, Tooltip } from "@mantine/core";
2
+ import {
3
+ IconCurrencyDollar,
4
+ IconHistory,
5
+ IconHourglass,
6
+ IconStack2,
7
+ } from "@tabler/icons-react";
8
+ import type { ReactNode } from "react";
9
+ import type { Task } from "../types";
10
+ import { listUsageMetricsView, taskReportedWorkView } from "../presentation";
11
+ import { RelativeTime } from "./RelativeTime";
12
+ import { ShimmerText } from "./ShimmerText";
13
+
14
+ function StaticStat({
15
+ accessibleLabel,
16
+ animated = false,
17
+ icon,
18
+ title,
19
+ value,
20
+ }: {
21
+ accessibleLabel: string;
22
+ animated?: boolean;
23
+ icon: ReactNode;
24
+ title: string;
25
+ value: string;
26
+ }) {
27
+ return (
28
+ <Tooltip events={{ focus: true, hover: true, touch: false }} label={title} multiline>
29
+ <Text
30
+ aria-label={accessibleLabel}
31
+ className="taskchef-card-stat-content"
32
+ component="span"
33
+ size="xs"
34
+ tabIndex={0}
35
+ >
36
+ {icon}
37
+ <bdi>{animated ? <ShimmerText>{value}</ShimmerText> : value}</bdi>
38
+ </Text>
39
+ </Tooltip>
40
+ );
41
+ }
42
+
43
+ export function TaskCardStats({ task }: { task: Task }) {
44
+ const reportedWork = taskReportedWorkView(task);
45
+ const usage = listUsageMetricsView(task);
46
+ return (
47
+ <Box className="taskchef-card-stats" component="div">
48
+ <Box className="taskchef-card-stat">
49
+ <RelativeTime
50
+ icon={<IconHistory aria-hidden size={14} />}
51
+ label={`Updated time for ${task.title}`}
52
+ value={task.meaningfulUpdatedAt ?? task.updatedAt}
53
+ />
54
+ </Box>
55
+ <Box className="taskchef-card-stat">
56
+ <StaticStat
57
+ accessibleLabel={reportedWork.accessibleLabel}
58
+ icon={<IconHourglass aria-hidden size={14} />}
59
+ title={reportedWork.title}
60
+ value={reportedWork.value}
61
+ />
62
+ </Box>
63
+ <Box className="taskchef-card-stat" data-usage-state={usage.kind}>
64
+ <StaticStat
65
+ accessibleLabel={usage.tokens.accessibleLabel}
66
+ animated={usage.animated}
67
+ icon={<IconStack2 aria-hidden size={14} />}
68
+ title={usage.tokens.title}
69
+ value={usage.tokens.value}
70
+ />
71
+ <Text className="taskchef-visually-hidden" component="span" role="status">
72
+ {usage.tokens.accessibleLabel}; {usage.cost.accessibleLabel}
73
+ </Text>
74
+ </Box>
75
+ <Box className="taskchef-card-stat" data-usage-state={usage.kind}>
76
+ <StaticStat
77
+ accessibleLabel={usage.cost.accessibleLabel}
78
+ animated={usage.animated}
79
+ icon={<IconCurrencyDollar aria-hidden size={14} />}
80
+ title={usage.cost.title}
81
+ value={usage.cost.value}
82
+ />
83
+ </Box>
84
+ </Box>
85
+ );
86
+ }
@@ -271,9 +271,9 @@ describe("token and working presentation", () => {
271
271
  const groups = container.querySelectorAll(".taskchef-github-group");
272
272
  expect(groups).toHaveLength(2);
273
273
  expect(within(groups[0] as HTMLElement).getAllByRole("link").map((link) => link.textContent))
274
- .toEqual(["taskchef #79", "#80", "#83"]);
275
- expect(within(groups[1] as HTMLElement).getAllByRole("link").map((link) => link.textContent))
276
274
  .toEqual(["guzuoshou-workspace #108", "#109", "#124"]);
275
+ expect(within(groups[1] as HTMLElement).getAllByRole("link").map((link) => link.textContent))
276
+ .toEqual(["taskchef #79", "#80", "#83"]);
277
277
  const first = screen.getByRole("link", { name: /favoyang\/taskchef Issue #79/ });
278
278
  expect(first).toHaveAttribute("href", "https://github.com/favoyang/taskchef/issues/79");
279
279
  expect(first).toHaveAttribute("target", "_blank");
@@ -237,36 +237,60 @@ export function usageStillCalculating(task: Task) {
237
237
  || Object.values(task.usage?.turns ?? {}).some((turn) => turn.status === "calculating"));
238
238
  }
239
239
 
240
- export type ListUsageView = {
240
+ export type ListUsageMetric = {
241
241
  accessibleLabel: string;
242
- kind: "pending" | "calculating" | "ready" | "unavailable";
243
- label: string;
244
242
  title: string;
243
+ value: string;
244
+ };
245
+
246
+ export type ListUsageMetricsView = {
247
+ animated: boolean;
248
+ cost: ListUsageMetric;
249
+ kind: "pending" | "calculating" | "ready" | "unavailable";
250
+ tokens: ListUsageMetric;
245
251
  };
246
252
 
247
- export function listUsageView(task: Task): ListUsageView {
253
+ export function listUsageMetricsView(task: Task): ListUsageMetricsView {
248
254
  const view = usageView(task);
249
255
  if (view.kind === "pending" || view.kind === "calculating") {
256
+ const value = view.kind === "pending" ? "Pending" : "Calculating";
250
257
  return {
251
- accessibleLabel: view.label,
258
+ animated: true,
259
+ cost: {
260
+ accessibleLabel: `Estimated cost ${value.toLowerCase()}`,
261
+ title: view.label,
262
+ value,
263
+ },
252
264
  kind: view.kind,
253
- label: view.label,
254
- title: view.label,
265
+ tokens: {
266
+ accessibleLabel: `Tokens ${value.toLowerCase()}`,
267
+ title: view.label,
268
+ value,
269
+ },
255
270
  };
256
271
  }
257
272
  if (view.kind === "unavailable") {
273
+ const reason = view.label === "Token usage unavailable" ? "" : `: ${view.label}`;
258
274
  return {
259
- accessibleLabel: `Token usage unavailable${view.label === "Token usage unavailable" ? "" : `: ${view.label}`}`,
275
+ animated: false,
276
+ cost: {
277
+ accessibleLabel: `Estimated cost unavailable${reason}`,
278
+ title: view.label,
279
+ value: "est. n/a",
280
+ },
260
281
  kind: "unavailable",
261
- label: `${NOT_AVAILABLE} tokens`,
262
- title: view.label,
282
+ tokens: {
283
+ accessibleLabel: `Token usage unavailable${reason}`,
284
+ title: view.label,
285
+ value: `${NOT_AVAILABLE} tokens`,
286
+ },
263
287
  };
264
288
  }
265
289
  const compactTokens = formatCompactTokens(view.usage.totalTokens);
266
290
  const fullTokens = formatFullTokens(view.usage.totalTokens);
267
- const cost = view.usage.estimatedCostUsd == null
268
- ? "cost n/a"
269
- : `est. ${formatEstimatedCost(view.usage.estimatedCostUsd)}`;
291
+ const hasCost = typeof view.usage.estimatedCostUsd === "number"
292
+ && Number.isFinite(view.usage.estimatedCostUsd);
293
+ const formattedCost = hasCost ? formatEstimatedCost(view.usage.estimatedCostUsd) : null;
270
294
  const qualifier = view.knownSoFar ? " · Updating…" : "";
271
295
  const accessibleQualifier = view.knownSoFar ? "; updating" : "";
272
296
  const freshness = view.usage.sourceUpdatedAt ?? view.usage.sampledAt ?? task.usage?.updatedAt ?? null;
@@ -274,14 +298,20 @@ export function listUsageView(task: Task): ListUsageView {
274
298
  ? ` Cached usage updated ${new Date(freshness).toLocaleString()}.`
275
299
  : "";
276
300
  return {
277
- accessibleLabel: `${fullTokens} tokens; ${view.usage.estimatedCostUsd == null
278
- ? "estimated cost unavailable"
279
- : `estimated cost ${formatEstimatedCost(view.usage.estimatedCostUsd)}`}${accessibleQualifier}.${freshnessLabel}`,
301
+ animated: false,
302
+ cost: {
303
+ accessibleLabel: `Estimated cost ${formattedCost ?? "unavailable"}${accessibleQualifier}.${freshnessLabel}`,
304
+ title: `${formattedCost === null
305
+ ? "Estimated cost unavailable"
306
+ : `Unrounded estimate: $${view.usage.estimatedCostUsd}`}${qualifier}.${freshnessLabel}`,
307
+ value: `${formattedCost === null ? "est. n/a" : `est. ${formattedCost}`}${qualifier}`,
308
+ },
280
309
  kind: "ready",
281
- label: `${compactTokens} tokens · ${cost}${qualifier}`,
282
- title: `${fullTokens} tokens · ${view.usage.estimatedCostUsd == null
283
- ? "estimated cost unavailable"
284
- : `unrounded estimate $${view.usage.estimatedCostUsd}`}${qualifier}.${freshnessLabel}`,
310
+ tokens: {
311
+ accessibleLabel: `${fullTokens} tokens${accessibleQualifier}.${freshnessLabel}`,
312
+ title: `${fullTokens} tokens${qualifier}.${freshnessLabel}`,
313
+ value: `${compactTokens} tokens${qualifier}`,
314
+ },
285
315
  };
286
316
  }
287
317
 
@@ -31,13 +31,12 @@ body {
31
31
  .taskchef-toolbar .mantine-InputWrapper-label, .taskchef-filter-label { font-size: var(--mantine-font-size-sm); font-weight: 600; line-height: var(--mantine-line-height-sm); }
32
32
  .taskchef-toolbar .mantine-SegmentedControl-root { max-width: 650px; }
33
33
  .taskchef-results-summary { margin-top: 12px; color: var(--taskchef-muted); font-size: var(--mantine-font-size-xs); line-height: 1.35; white-space: nowrap; }
34
- .taskchef-task-row { background: light-dark(#fff, #202421); box-shadow: 0 1px 0 light-dark(rgb(22 31 25 / 3%), transparent); }
34
+ .taskchef-task-row { container-type: inline-size; background: light-dark(#fff, #202421); box-shadow: 0 1px 0 light-dark(rgb(22 31 25 / 3%), transparent); }
35
35
  .taskchef-task-row:hover { border-color: light-dark(var(--mantine-color-teal-4), var(--mantine-color-teal-8)); }
36
36
  .taskchef-task-main { min-width: 0; flex: 1; }
37
37
  .taskchef-card-heading { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: var(--mantine-spacing-sm); }
38
- .taskchef-card-metadata { width: max-content; min-width: 0; max-width: min(25rem, 45vw); }
38
+ .taskchef-card-metadata { width: max-content; min-width: 0; max-width: min(25rem, 45cqw); }
39
39
  .taskchef-status-badge { flex: none; font-size: var(--mantine-font-size-xs); }
40
- .taskchef-task-usage { max-width: 100%; text-align: right; overflow-wrap: anywhere; }
41
40
  .taskchef-title-button {
42
41
  all: unset;
43
42
  border-radius: 3px;
@@ -57,12 +56,15 @@ body {
57
56
  .taskchef-github-group { min-width: 0; }
58
57
  .taskchef-github-reference-ambiguous { border-bottom: 2px dotted var(--mantine-color-yellow-7); cursor: help; }
59
58
  .taskchef-github-reference-ambiguous:focus-visible { outline: 2px solid var(--mantine-color-yellow-7); outline-offset: 2px; }
60
- .taskchef-time, .taskchef-card-duration { display: inline-flex; align-items: center; gap: 4px; width: fit-content; padding: 2px 3px; border-radius: 4px; color: var(--taskchef-muted); font-size: var(--mantine-font-size-xs); font-weight: 400; letter-spacing: normal; line-height: var(--mantine-line-height-xs); text-transform: none; }
59
+ .taskchef-time, .taskchef-card-stat-content { display: inline-flex; align-items: center; gap: 6px; width: fit-content; max-width: 100%; padding: 3px 4px; border-radius: 4px; color: var(--taskchef-muted); font-size: var(--mantine-font-size-xs); font-weight: 400; letter-spacing: normal; line-height: var(--mantine-line-height-xs); text-transform: none; }
61
60
  .taskchef-time:hover { color: var(--taskchef-text); background: var(--taskchef-surface-muted); }
62
61
  .taskchef-time:focus-visible { outline: 2px solid var(--mantine-color-teal-6); outline-offset: 2px; }
63
- .taskchef-time-label { white-space: nowrap; }
64
- .taskchef-card-timing { min-width: 0; flex-wrap: wrap; }
65
- .taskchef-card-duration { min-width: 0; white-space: nowrap; }
62
+ .taskchef-time-label, .taskchef-card-stat-content bdi { min-width: 0; overflow-wrap: anywhere; unicode-bidi: isolate; }
63
+ .taskchef-card-stat-content:focus-visible { outline: 2px solid var(--mantine-color-teal-6); outline-offset: 2px; }
64
+ .taskchef-card-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-top: 2px; }
65
+ .taskchef-card-stat { display: flex; align-items: center; min-width: 0; padding: 4px 10px; }
66
+ .taskchef-card-stat:first-child { padding-inline-start: 0; }
67
+ .taskchef-card-stat + .taskchef-card-stat { border-inline-start: 1px solid var(--taskchef-border); }
66
68
  .taskchef-empty { border-style: dashed; background: transparent; }
67
69
 
68
70
  .taskchef-notifications {
@@ -137,10 +139,13 @@ body {
137
139
  @media (max-width: 34em) {
138
140
  .taskchef-header { padding-top: 22px; padding-bottom: 18px; }
139
141
  .taskchef-brand-icon, .taskchef-brand-icon img { width: 36px; height: 36px; }
140
- .taskchef-card-heading { grid-template-columns: minmax(0, 1fr); }
141
- .taskchef-card-metadata { align-items: flex-start; width: 100%; max-width: 100%; }
142
- .taskchef-task-usage { text-align: left; }
143
- .taskchef-card-footer { align-items: flex-start; flex-direction: column; }
142
+ }
143
+
144
+ @container (max-width: 27.5rem) {
145
+ .taskchef-card-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
146
+ .taskchef-card-stat { padding-block: 8px; }
147
+ .taskchef-card-stat:nth-child(3) { border-inline-start: 0; padding-inline-start: 0; }
148
+ .taskchef-card-stat:nth-child(n + 3) { border-top: 1px solid var(--taskchef-border); }
144
149
  }
145
150
 
146
151
  .taskchef-refresh-spinning {
@@ -106,6 +106,16 @@ export interface NotificationSnapshot {
106
106
  export interface DashboardSnapshot {
107
107
  healthy?: boolean;
108
108
  tasks: Task[];
109
+ projectIndex?: {
110
+ status: "available" | "unavailable";
111
+ projectCount: number | null;
112
+ missingProjects: Array<{
113
+ path: string;
114
+ snapshotNames: string[];
115
+ taskCount: number;
116
+ }>;
117
+ truncated?: boolean;
118
+ };
109
119
  }
110
120
 
111
121
  export interface ManualTransitionResponse {
@@ -246,6 +246,7 @@ export function readDashboardIdentity({
246
246
  port = DEFAULT_PORT,
247
247
  maximumBytes = DASHBOARD_HEALTH_MAX_BYTES,
248
248
  timeoutMs = HEALTH_TIMEOUT_MS,
249
+ deadlineAt = Date.now() + timeoutMs,
249
250
  } = {}) {
250
251
  return new Promise((resolve, reject) => {
251
252
  let settled = false;
@@ -278,6 +279,21 @@ export function readDashboardIdentity({
278
279
  chunks.push(chunk);
279
280
  });
280
281
  response.on("end", () => {
282
+ if (response.statusCode === 503 && deadlineAt - Date.now() > 10) {
283
+ settled = true;
284
+ clearTimeout(deadline);
285
+ const remaining = deadlineAt - Date.now();
286
+ setTimeout(() => {
287
+ readDashboardIdentity({
288
+ host,
289
+ port,
290
+ maximumBytes,
291
+ timeoutMs: Math.max(1, remaining),
292
+ deadlineAt,
293
+ }).then(resolve, reject);
294
+ }, 10);
295
+ return;
296
+ }
281
297
  if (response.statusCode !== 200) {
282
298
  finish(new Error(`dashboard health returned HTTP ${response.statusCode}`));
283
299
  return;