taskchef 7.26.1 → 7.27.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.
@@ -1,6 +1,7 @@
1
1
  import { Box, Group, 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";
4
5
  import { GitHubLinks } from "./GitHubLinks";
5
6
  import { LinkedText } from "./LinkedText";
6
7
  import { OpenChatButton } from "./OpenChatButton";
@@ -19,6 +20,7 @@ export function TaskCard({
19
20
  task: Task;
20
21
  }) {
21
22
  const latest = latestTurnPresentation(task);
23
+ const reportedWork = taskReportedWorkView(task);
22
24
  return (
23
25
  <Paper className="taskchef-task-row" component="article" p="md" radius="md" withBorder>
24
26
  <Stack className="taskchef-task-main" gap="sm">
@@ -52,7 +54,18 @@ export function TaskCard({
52
54
 
53
55
  <GitHubLinks task={task} />
54
56
  <Group className="taskchef-card-footer" gap="md" justify="space-between">
55
- <RelativeTime label={`Updated time for ${task.title}`} value={task.meaningfulUpdatedAt ?? task.updatedAt} />
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>
56
69
  <OpenChatButton onClick={() => onOpenCodex(task)} taskTitle={task.title} />
57
70
  </Group>
58
71
  </Stack>
@@ -133,7 +133,7 @@ test("covers terminal, active, and unavailable timeline metrics", () => {
133
133
  });
134
134
  render(<MantineProvider><ActivityTimeline highlightTurnRef={null} task={task} /></MantineProvider>);
135
135
  expect(screen.getByLabelText(/elapsed unavailable.*reported wall-clock/i))
136
- .toHaveTextContent("ElapsedUnavailable");
136
+ .toHaveTextContent("Elapsedn/a");
137
137
  expect(screen.getByText("Manual dashboard turns do not have usage boundaries.")).toBeVisible();
138
138
  expect(screen.getByLabelText(/tokens unavailable: manual dashboard turns/i)).toBeVisible();
139
139
  }
@@ -36,7 +36,7 @@ describe("task list usage summary", () => {
36
36
  status: "completed",
37
37
  usage: { status: "available", task: { totalTokens: 1_324_567, estimatedCostUsd: null } },
38
38
  }));
39
- expect(screen.getByText("1.32M tokens · cost unavailable")).toHaveAccessibleName(
39
+ expect(screen.getByText("1.32M tokens · cost n/a")).toHaveAccessibleName(
40
40
  /1,324,567 tokens; estimated cost unavailable/i,
41
41
  );
42
42
  });
@@ -76,7 +76,7 @@ describe("task list usage summary", () => {
76
76
  status: "completed",
77
77
  usage: { status: "unavailable", reason: "No matching cached boundary." },
78
78
  }));
79
- const summary = screen.getByText("Token usage unavailable");
79
+ const summary = screen.getByText("n/a tokens");
80
80
  expect(summary).toHaveAttribute("data-usage-state", "unavailable");
81
81
  expect(summary).toHaveAttribute("title", "No matching cached boundary.");
82
82
  });
@@ -98,4 +98,25 @@ describe("task list usage summary", () => {
98
98
  expect(screen.getByRole("button", { name: /^review checkout reconciliation$/i })).toBeVisible();
99
99
  expect(screen.getByRole("button", { name: /open chat/i })).toBeVisible();
100
100
  });
101
+
102
+ test("places reported work beside the updated time with matching typography", () => {
103
+ const { container } = render(
104
+ <MantineProvider>
105
+ <TaskCard
106
+ onOpenCodex={vi.fn()}
107
+ onOpenDetail={vi.fn()}
108
+ task={fixtureTask({
109
+ turns: undefined,
110
+ reportedWork: { terminalTurns: 1, validTurns: 1, totalMilliseconds: 1_112_000 },
111
+ })}
112
+ />
113
+ </MantineProvider>,
114
+ );
115
+ const timing = container.querySelector(".taskchef-card-timing");
116
+ expect(timing).not.toBeNull();
117
+ expect(timing?.children[0]).toHaveClass("taskchef-time");
118
+ expect(timing?.children[1]).toHaveClass("taskchef-card-duration");
119
+ expect(screen.getByText("Reported work")).not.toHaveClass("taskchef-field-label");
120
+ expect(timing?.children[1]?.lastChild).toHaveTextContent("18m 32s");
121
+ });
101
122
  });
@@ -17,12 +17,14 @@ export function UsagePanel({ task }: { task: Task }) {
17
17
  const input = ready?.usage.inputTokens ?? 0;
18
18
  const cacheRatio = ready && input + cached > 0
19
19
  ? `${Math.round((cached / (input + cached)) * 100)}%`
20
- : "—";
20
+ : "n/a";
21
+ const models = Object.keys(ready?.usage.models ?? {}).join(", ");
21
22
  const stateValue = usage.kind === "pending"
22
23
  ? "Pending"
23
24
  : usage.kind === "calculating"
24
25
  ? "Calculating"
25
- : "Unavailable";
26
+ : "n/a";
27
+ const accessibleState = usage.kind === "unavailable" ? "unavailable" : stateValue.toLowerCase();
26
28
  const values: Array<{
27
29
  accessibleValue?: string;
28
30
  animated?: boolean;
@@ -44,25 +46,37 @@ export function UsagePanel({ task }: { task: Task }) {
44
46
  ? ready.usage.estimatedCostUsd == null
45
47
  ? "Estimated cost unavailable"
46
48
  : `Estimated cost ${formatEstimatedCost(ready.usage.estimatedCostUsd)}`
47
- : `Estimated cost ${stateValue.toLowerCase()}${
49
+ : `Estimated cost ${accessibleState}${
48
50
  usage.kind === "unavailable" ? `: ${usage.label}` : ""
49
51
  }`,
50
52
  animated: usage.kind === "pending" || usage.kind === "calculating",
51
53
  label: "Estimated cost",
52
54
  title: ready?.usage.estimatedCostUsd == null
53
- ? usage.label
55
+ ? "Estimated cost unavailable"
54
56
  : `Unrounded estimate: $${ready.usage.estimatedCostUsd}`,
55
- value: ready ? formatEstimatedCost(ready.usage.estimatedCostUsd) : stateValue,
57
+ value: ready
58
+ ? ready.usage.estimatedCostUsd == null ? "n/a" : formatEstimatedCost(ready.usage.estimatedCostUsd)
59
+ : stateValue,
56
60
  },
57
61
  {
58
62
  accessibleValue: reportedWork.accessibleLabel,
59
- label: "Total reported work",
63
+ label: "Reported work",
60
64
  title: reportedWork.title,
61
65
  value: reportedWork.value,
62
66
  },
63
67
  ...(ready ? [
64
- { label: "Model", value: Object.keys(ready.usage.models ?? {}).join(", ") || "—" },
65
- { label: "Cache ratio", value: cacheRatio },
68
+ {
69
+ accessibleValue: models || "Model unavailable",
70
+ label: "Model",
71
+ title: models || "Model unavailable",
72
+ value: models || "n/a",
73
+ },
74
+ {
75
+ accessibleValue: cacheRatio === "n/a" ? "Cache ratio unavailable" : `Cache ratio ${cacheRatio}`,
76
+ label: "Cache ratio",
77
+ title: cacheRatio === "n/a" ? "Cache ratio unavailable because no input-token total is available." : undefined,
78
+ value: cacheRatio,
79
+ },
66
80
  ] : []),
67
81
  ];
68
82
  return (
@@ -83,7 +97,7 @@ export function UsagePanel({ task }: { task: Task }) {
83
97
  </Paper>
84
98
  ))}
85
99
  </Box>
86
- {usage.kind === "unavailable" && (
100
+ {usage.kind === "unavailable" && usage.label !== "Token usage unavailable" && (
87
101
  <Text c="dimmed" mt="xs" size="xs">{usage.label}</Text>
88
102
  )}
89
103
  </Box>
@@ -139,13 +139,34 @@ describe("token and working presentation", () => {
139
139
 
140
140
  expect(screen.getByText("Tokens").nextSibling).toHaveTextContent("330");
141
141
  expect(screen.getByText("Estimated cost").nextSibling).toHaveTextContent("$0.12");
142
- expect(screen.getByText("Total reported work").nextSibling).toHaveTextContent("18m 32s");
143
- expect(screen.getByLabelText(/total reported work 18m 32s.*wall-clock/i)).toBeVisible();
142
+ expect(screen.getByText("Reported work").nextSibling).toHaveTextContent("18m 32s");
143
+ expect(screen.getByLabelText(/reported work 18m 32s.*wall-clock/i)).toBeVisible();
144
144
  expect(screen.getByText("Model").nextSibling).toHaveTextContent("gpt-5.6-sol, gpt-5.6-luna");
145
145
  expect(screen.getByText("Cache ratio").nextSibling).toHaveTextContent("67%");
146
146
  expect(screen.queryByText(/API-equivalent/i)).not.toBeInTheDocument();
147
147
  });
148
148
 
149
+ test("uses n/a for missing ready usage metrics with explanatory accessibility", () => {
150
+ render(
151
+ <MantineProvider>
152
+ <UsagePanel task={fixtureTask({
153
+ status: "completed",
154
+ usage: {
155
+ status: "available",
156
+ task: { totalTokens: 330, estimatedCostUsd: null },
157
+ },
158
+ })} />
159
+ </MantineProvider>,
160
+ );
161
+
162
+ expect(screen.getByText("Estimated cost").nextSibling).toHaveTextContent("n/a");
163
+ expect(screen.getByLabelText("Estimated cost unavailable")).toBeVisible();
164
+ expect(screen.getByText("Model").nextSibling).toHaveTextContent("n/a");
165
+ expect(screen.getByLabelText("Model unavailable")).toBeVisible();
166
+ expect(screen.getByText("Cache ratio").nextSibling).toHaveTextContent("n/a");
167
+ expect(screen.getByLabelText("Cache ratio unavailable")).toBeVisible();
168
+ });
169
+
149
170
  test("keeps reported work visible across pending, calculating, and unavailable usage", () => {
150
171
  for (const [task, expected, accessibleCost] of [
151
172
  [fixtureTask(), "Pending", "Estimated cost pending"],
@@ -156,7 +177,7 @@ describe("token and working presentation", () => {
156
177
  ],
157
178
  [
158
179
  fixtureTask({ status: "completed", usage: { status: "unavailable" } }),
159
- "Unavailable",
180
+ "n/a",
160
181
  "Estimated cost unavailable: Token usage unavailable",
161
182
  ],
162
183
  ] as const) {
@@ -164,7 +185,7 @@ describe("token and working presentation", () => {
164
185
  expect(screen.getByText("Tokens").nextSibling).toHaveTextContent(expected);
165
186
  expect(screen.getByText("Estimated cost").nextSibling).toHaveTextContent(expected);
166
187
  expect(screen.getByLabelText(accessibleCost)).toBeVisible();
167
- expect(screen.getByText("Total reported work").nextSibling).toHaveTextContent("Not yet reported");
188
+ expect(screen.getByText("Reported work").nextSibling).toHaveTextContent("n/a");
168
189
  cleanup();
169
190
  }
170
191
  });
@@ -343,14 +364,14 @@ describe("reported wall-clock work presentation", () => {
343
364
  result: { status: "completed", summary: "Done", updatedAt: "2026-08-30T08:00:00.000Z" },
344
365
  }],
345
366
  });
346
- expect(taskReportedWorkView(invalid)).toMatchObject({ kind: "unavailable", value: "Unavailable" });
367
+ expect(taskReportedWorkView(invalid)).toMatchObject({ kind: "unavailable", value: "n/a" });
347
368
  expect(durationBetween("2026-08-30T08:01:00.000Z", "2026-08-30T08:00:00.000Z")).toBeNull();
348
369
  expect(durationBetween("2026-02-30T08:00:00.000Z", "2026-03-02T08:01:00.000Z")).toBeNull();
349
370
  expect(durationBetween("March 2, 2026 08:00:00 UTC", "2026-03-02T08:01:00.000Z")).toBeNull();
350
371
  expect(turnReportedWorkView(invalid.turns![0])).toMatchObject({
351
372
  kind: "unavailable",
352
373
  label: "Elapsed",
353
- value: "Unavailable",
374
+ value: "n/a",
354
375
  });
355
376
  }
356
377
 
@@ -374,9 +395,9 @@ describe("reported wall-clock work presentation", () => {
374
395
  provenance: { kind: "mcp" },
375
396
  };
376
397
  for (const turn of [legacyTurn, terminalOnlyTurn]) {
377
- expect(turnReportedWorkView(turn)).toMatchObject({ kind: "unavailable", value: "Unavailable" });
398
+ expect(turnReportedWorkView(turn)).toMatchObject({ kind: "unavailable", value: "n/a" });
378
399
  expect(taskReportedWorkView(fixtureTask({ status: "completed", turns: [turn] })))
379
- .toMatchObject({ kind: "unavailable", value: "Unavailable" });
400
+ .toMatchObject({ kind: "unavailable", value: "n/a" });
380
401
  }
381
402
  }
382
403
 
@@ -393,7 +414,7 @@ describe("reported wall-clock work presentation", () => {
393
414
  updatedAt: "2026-08-30T08:00:00.000Z",
394
415
  },
395
416
  };
396
- expect(turnReportedWorkView(observed)).toMatchObject({ kind: "unavailable", value: "Unavailable" });
417
+ expect(turnReportedWorkView(observed)).toMatchObject({ kind: "unavailable", value: "n/a" });
397
418
  expect(turnReportedWorkView({
398
419
  ...observed,
399
420
  result: { ...observed.result, updatedAt: "2026-08-30T08:00:00.500Z" },
@@ -415,10 +436,14 @@ describe("reported wall-clock work presentation", () => {
415
436
  });
416
437
  expect(taskReportedWorkView(compactTerminal)).toMatchObject({
417
438
  kind: "unavailable",
418
- value: "Unavailable",
439
+ value: "n/a",
419
440
  });
441
+ expect(taskReportedWorkView({
442
+ ...compactTerminal,
443
+ reportedWork: { terminalTurns: 1, validTurns: 1, totalMilliseconds: 1_112_000 },
444
+ })).toMatchObject({ kind: "available", value: "18m 32s" });
420
445
  render(<MantineProvider><UsagePanel task={compactTerminal} /></MantineProvider>);
421
- expect(screen.getByText("Total reported work").nextSibling).toHaveTextContent("Unavailable");
446
+ expect(screen.getByText("Reported work").nextSibling).toHaveTextContent("n/a");
422
447
  cleanup();
423
448
  }
424
449
 
@@ -436,7 +461,7 @@ describe("reported wall-clock work presentation", () => {
436
461
  turns: undefined,
437
462
  lastResult: priorResult,
438
463
  results: undefined,
439
- }))).toMatchObject({ kind: "unavailable", value: "Unavailable" });
464
+ }))).toMatchObject({ kind: "unavailable", value: "n/a" });
440
465
  }
441
466
 
442
467
  {
@@ -1,49 +1,17 @@
1
1
  import type { Task, TaskTurn, UsageNumbers, UsageProjection } from "./types";
2
+ import {
3
+ durationBetween,
4
+ reportedWorkSummary,
5
+ terminalTurnDuration,
6
+ } from "../../reported-work.js";
7
+
8
+ export { durationBetween };
2
9
 
3
10
  const SECOND_MS = 1_000;
4
11
  const MINUTE_MS = 60 * SECOND_MS;
5
12
  const HOUR_MS = 60 * MINUTE_MS;
6
13
  const DAY_MS = 24 * HOUR_MS;
7
-
8
- export function durationBetween(startedAt: unknown, endedAt: unknown) {
9
- const start = parseLifecycleTimestamp(startedAt);
10
- const end = parseLifecycleTimestamp(endedAt);
11
- if (start === null || end === null) return null;
12
- const duration = end - start;
13
- return duration >= 0 ? duration : null;
14
- }
15
-
16
- function parseLifecycleTimestamp(value: unknown) {
17
- if (typeof value !== "string") return null;
18
- const match = value.match(
19
- /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?(Z|[+-]\d{2}:\d{2})$/,
20
- );
21
- if (!match) return null;
22
- const [, yearText, monthText, dayText, hourText, minuteText, secondText, , zone] = match;
23
- const year = Number(yearText);
24
- const month = Number(monthText);
25
- const day = Number(dayText);
26
- const hour = Number(hourText);
27
- const minute = Number(minuteText);
28
- const second = Number(secondText);
29
- const zoneHour = zone === "Z" ? 0 : Number(zone.slice(1, 3));
30
- const zoneMinute = zone === "Z" ? 0 : Number(zone.slice(4, 6));
31
- const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
32
- const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
33
- if (
34
- month < 1
35
- || month > 12
36
- || day < 1
37
- || day > daysInMonth[month - 1]
38
- || hour > 23
39
- || minute > 59
40
- || second > 59
41
- || zoneHour > 23
42
- || zoneMinute > 59
43
- ) return null;
44
- const parsed = Date.parse(value);
45
- return Number.isFinite(parsed) ? parsed : null;
46
- }
14
+ const NOT_AVAILABLE = "n/a";
47
15
 
48
16
  export function formatReportedDuration(milliseconds: number) {
49
17
  if (!Number.isFinite(milliseconds) || milliseconds < 0) return null;
@@ -69,49 +37,50 @@ export type ReportedWorkView = {
69
37
  };
70
38
 
71
39
  export function taskReportedWorkView(task: Task): ReportedWorkView {
72
- if (task.turns === undefined) {
40
+ const summary = task.turns === undefined
41
+ ? task.reportedWork
42
+ : reportedWorkSummary(task.turns ?? []);
43
+ if (summary === undefined) {
73
44
  return {
74
- accessibleLabel: "Total reported work unavailable because terminal turn history is not available.",
45
+ accessibleLabel: "Reported work unavailable because terminal turn history is not available.",
75
46
  kind: "unavailable",
76
47
  title: "Reported wall-clock elapsed time requires the terminal turn history from the task detail projection.",
77
- value: "Unavailable",
48
+ value: NOT_AVAILABLE,
78
49
  };
79
50
  }
80
- const terminalTurns = (task.turns ?? []).filter((turn) => turn.result !== null);
81
- if (terminalTurns.length === 0) {
51
+ if (summary.terminalTurns === 0) {
82
52
  return {
83
- accessibleLabel: "Total reported work not yet reported. Unfinished turns are excluded.",
53
+ accessibleLabel: "Reported work not yet reported. Unfinished turns are excluded.",
84
54
  kind: "not-reported",
85
55
  title: "Reported wall-clock elapsed time is added only after a turn reaches a terminal state.",
86
- value: "Not yet reported",
56
+ value: NOT_AVAILABLE,
87
57
  };
88
58
  }
89
- const durations = terminalTurns.map(terminalTurnDuration);
90
- const validDurations = durations.filter((duration): duration is number => duration !== null);
91
- if (validDurations.length === 0) {
59
+ if (summary.validTurns === 0) {
92
60
  return {
93
- accessibleLabel: "Total reported work unavailable because completed turn timestamps are missing or invalid.",
61
+ accessibleLabel: "Reported work unavailable because completed turn timestamps are missing or invalid.",
94
62
  kind: "unavailable",
95
63
  title: "Reported wall-clock elapsed time is unavailable because no terminal turn has a valid start and end timestamp.",
96
- value: "Unavailable",
64
+ value: NOT_AVAILABLE,
97
65
  };
98
66
  }
99
- const total = validDurations.reduce((sum, duration) => sum + duration, 0);
100
- const value = Number.isSafeInteger(total) ? formatReportedDuration(total) : null;
67
+ const value = summary.totalMilliseconds === null
68
+ ? null
69
+ : formatReportedDuration(summary.totalMilliseconds);
101
70
  if (value === null) {
102
71
  return {
103
- accessibleLabel: "Total reported work unavailable because the duration total is outside the supported range.",
72
+ accessibleLabel: "Reported work unavailable because the duration total is outside the supported range.",
104
73
  kind: "unavailable",
105
74
  title: "Reported wall-clock elapsed time is unavailable because the duration total is outside the supported range.",
106
- value: "Unavailable",
75
+ value: NOT_AVAILABLE,
107
76
  };
108
77
  }
109
- const excluded = terminalTurns.length - validDurations.length;
78
+ const excluded = summary.terminalTurns - summary.validTurns;
110
79
  const exclusion = excluded > 0
111
80
  ? ` ${excluded} terminal ${excluded === 1 ? "turn was" : "turns were"} unavailable and excluded.`
112
81
  : "";
113
82
  return {
114
- accessibleLabel: `Total reported work ${value}. This is reported wall-clock elapsed time.${exclusion}`,
83
+ accessibleLabel: `Reported work ${value}. This is reported wall-clock elapsed time.${exclusion}`,
115
84
  kind: "available",
116
85
  title: `Sum of valid terminal-turn wall-clock elapsed times; unfinished turns and idle gaps are excluded.${exclusion}`,
117
86
  value,
@@ -124,9 +93,9 @@ export function turnReportedWorkView(turn: TaskTurn, now = Date.now()) {
124
93
  ? durationBetween(turn.startedAt, new Date(now).toISOString())
125
94
  : terminalTurnDuration(turn);
126
95
  const label = active ? "Elapsed so far" : "Elapsed";
127
- const value = duration === null ? "Unavailable" : formatReportedDuration(duration) ?? "Unavailable";
96
+ const value = duration === null ? NOT_AVAILABLE : formatReportedDuration(duration) ?? NOT_AVAILABLE;
128
97
  return {
129
- accessibleLabel: `${label} ${value}. ${active ? "Current" : "Completed"} turn reported wall-clock elapsed time.`,
98
+ accessibleLabel: `${label} ${duration === null ? "unavailable" : value}. ${active ? "Current" : "Completed"} turn reported wall-clock elapsed time.`,
130
99
  kind: duration === null ? "unavailable" as const : "available" as const,
131
100
  label,
132
101
  title: duration === null
@@ -136,11 +105,6 @@ export function turnReportedWorkView(turn: TaskTurn, now = Date.now()) {
136
105
  };
137
106
  }
138
107
 
139
- function terminalTurnDuration(turn: TaskTurn) {
140
- const duration = durationBetween(turn.startedAt, turn.result?.updatedAt);
141
- return duration === 0 ? null : duration;
142
- }
143
-
144
108
  export function formatCompactTokens(value: number, locales?: Intl.LocalesArgument) {
145
109
  return new Intl.NumberFormat(locales, {
146
110
  maximumFractionDigits: 2,
@@ -194,7 +158,7 @@ export function usageView(task: Task): UsageView {
194
158
  function readyUsageView(usage: UsageNumbers, knownSoFar: boolean): UsageView {
195
159
  const tokens = formatCompactTokens(usage.totalTokens);
196
160
  const cost = usage.estimatedCostUsd == null
197
- ? "Estimated cost unavailable"
161
+ ? "Estimated cost n/a"
198
162
  : `Estimated cost ${formatEstimatedCost(usage.estimatedCostUsd)}`;
199
163
  return {
200
164
  kind: "ready",
@@ -214,7 +178,7 @@ export function turnUsageView(task: Task, turn: NonNullable<Task["turns"]>[numbe
214
178
  if (usage?.status === "available" && typeof usage.totalTokens === "number") {
215
179
  const tokens = formatFullTokens(usage.totalTokens);
216
180
  const cost = usage.estimatedCostUsd == null
217
- ? "Estimated cost unavailable"
181
+ ? "Estimated cost n/a"
218
182
  : `Estimated cost ${formatEstimatedCost(usage.estimatedCostUsd)}`;
219
183
  return { kind: "ready" as const, label: `${tokens} tokens · ${cost}` };
220
184
  }
@@ -229,13 +193,16 @@ export function turnUsageMetricsView(task: Task, turn: NonNullable<Task["turns"]
229
193
  if (view.kind === "ready") {
230
194
  const identity = turn.turnRef ?? turn.turnId;
231
195
  const usage = identity ? task.usage?.turns?.[identity] : null;
232
- const tokens = typeof usage?.totalTokens === "number" ? formatFullTokens(usage.totalTokens) : "Unavailable";
196
+ const tokens = typeof usage?.totalTokens === "number" ? formatFullTokens(usage.totalTokens) : NOT_AVAILABLE;
233
197
  const cost = usage?.estimatedCostUsd == null
234
- ? "Unavailable"
198
+ ? NOT_AVAILABLE
235
199
  : formatEstimatedCost(usage.estimatedCostUsd);
236
200
  return {
237
201
  animated: false,
238
- cost: { accessibleLabel: `Estimated cost ${cost.toLowerCase()}`, value: cost },
202
+ cost: {
203
+ accessibleLabel: `Estimated cost ${usage?.estimatedCostUsd == null ? "unavailable" : cost.toLowerCase()}`,
204
+ value: cost,
205
+ },
239
206
  kind: view.kind,
240
207
  note: null,
241
208
  title: view.label,
@@ -246,18 +213,19 @@ export function turnUsageMetricsView(task: Task, turn: NonNullable<Task["turns"]
246
213
  ? "Pending"
247
214
  : view.kind === "calculating"
248
215
  ? "Calculating"
249
- : "Unavailable";
216
+ : NOT_AVAILABLE;
217
+ const accessibleValue = view.kind === "unavailable" ? "unavailable" : value.toLowerCase();
250
218
  return {
251
219
  animated: view.kind === "pending" || view.kind === "calculating",
252
220
  cost: {
253
- accessibleLabel: `Estimated cost ${value.toLowerCase()}${view.kind === "unavailable" ? `: ${view.label}` : ""}`,
221
+ accessibleLabel: `Estimated cost ${accessibleValue}${view.kind === "unavailable" ? `: ${view.label}` : ""}`,
254
222
  value,
255
223
  },
256
224
  kind: view.kind,
257
- note: view.kind === "unavailable" ? view.label : null,
225
+ note: view.kind === "unavailable" && view.label !== "Turn usage unavailable" ? view.label : null,
258
226
  title: view.label,
259
227
  tokens: {
260
- accessibleLabel: `Tokens ${value.toLowerCase()}${view.kind === "unavailable" ? `: ${view.label}` : ""}`,
228
+ accessibleLabel: `Tokens ${accessibleValue}${view.kind === "unavailable" ? `: ${view.label}` : ""}`,
261
229
  value,
262
230
  },
263
231
  };
@@ -290,14 +258,14 @@ export function listUsageView(task: Task): ListUsageView {
290
258
  return {
291
259
  accessibleLabel: `Token usage unavailable${view.label === "Token usage unavailable" ? "" : `: ${view.label}`}`,
292
260
  kind: "unavailable",
293
- label: "Token usage unavailable",
261
+ label: `${NOT_AVAILABLE} tokens`,
294
262
  title: view.label,
295
263
  };
296
264
  }
297
265
  const compactTokens = formatCompactTokens(view.usage.totalTokens);
298
266
  const fullTokens = formatFullTokens(view.usage.totalTokens);
299
267
  const cost = view.usage.estimatedCostUsd == null
300
- ? "cost unavailable"
268
+ ? "cost n/a"
301
269
  : `est. ${formatEstimatedCost(view.usage.estimatedCostUsd)}`;
302
270
  const qualifier = view.knownSoFar ? " · Updating…" : "";
303
271
  const accessibleQualifier = view.knownSoFar ? "; updating" : "";
@@ -34,8 +34,8 @@ body {
34
34
  .taskchef-task-main { min-width: 0; flex: 1; }
35
35
  .taskchef-card-heading { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: var(--mantine-spacing-sm); }
36
36
  .taskchef-card-metadata { width: max-content; min-width: 0; max-width: min(25rem, 45vw); }
37
- .taskchef-status-badge { flex: none; }
38
- .taskchef-task-usage { max-width: 100%; text-align: right; line-height: 1.25; overflow-wrap: anywhere; }
37
+ .taskchef-status-badge { flex: none; font-size: var(--mantine-font-size-xs); }
38
+ .taskchef-task-usage { max-width: 100%; text-align: right; overflow-wrap: anywhere; }
39
39
  .taskchef-title-button {
40
40
  all: unset;
41
41
  border-radius: 3px;
@@ -55,10 +55,12 @@ body {
55
55
  .taskchef-github-group { min-width: 0; }
56
56
  .taskchef-github-reference-ambiguous { border-bottom: 2px dotted var(--mantine-color-yellow-7); cursor: help; }
57
57
  .taskchef-github-reference-ambiguous:focus-visible { outline: 2px solid var(--mantine-color-yellow-7); outline-offset: 2px; }
58
- .taskchef-time { display: inline-flex; align-items: center; gap: 4px; width: fit-content; padding: 2px 3px; border-radius: 4px; color: var(--taskchef-muted); font-size: 0.7rem; line-height: 1.2; }
58
+ .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
59
  .taskchef-time:hover { color: var(--taskchef-text); background: var(--taskchef-surface-muted); }
60
60
  .taskchef-time:focus-visible { outline: 2px solid var(--mantine-color-teal-6); outline-offset: 2px; }
61
61
  .taskchef-time-label { white-space: nowrap; }
62
+ .taskchef-card-timing { min-width: 0; flex-wrap: wrap; }
63
+ .taskchef-card-duration { min-width: 0; white-space: nowrap; }
62
64
  .taskchef-empty { border-style: dashed; background: transparent; }
63
65
 
64
66
  .taskchef-notifications {
@@ -123,7 +125,7 @@ body {
123
125
  .taskchef-toolbar .mantine-SegmentedControl-root { overflow-x: auto; }
124
126
  .taskchef-notifications { right: 16px; bottom: 16px; width: min(330px, calc(100vw - 32px)); }
125
127
  .taskchef-metadata { grid-template-columns: 1fr; }
126
- .taskchef-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
128
+ .taskchef-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
127
129
  .taskchef-turn-metrics { grid-template-columns: 1fr; }
128
130
  .taskchef-turn-heading { align-items: flex-start; flex-direction: column; gap: 6px; }
129
131
  .taskchef-turn-timing { flex-wrap: wrap; justify-content: flex-start; }
@@ -59,6 +59,12 @@ export interface UsageProjection {
59
59
  updatedAt?: string | null;
60
60
  }
61
61
 
62
+ export interface ReportedWorkSummary {
63
+ terminalTurns: number;
64
+ validTurns: number;
65
+ totalMilliseconds: number | null;
66
+ }
67
+
62
68
  export interface Task {
63
69
  schemaVersion?: number;
64
70
  id: string;
@@ -78,6 +84,7 @@ export interface Task {
78
84
  latestTurn: TaskTurn | null;
79
85
  turns?: TaskTurn[];
80
86
  results?: TaskResult[];
87
+ reportedWork?: ReportedWorkSummary;
81
88
  usage?: UsageProjection | null;
82
89
  relatedGitHubLinks?: GitHubLink[];
83
90
  relatedGitHubLinksTruncated?: boolean;
package/src/dashboard.js CHANGED
@@ -27,6 +27,7 @@ import { taskGitHubProjection } from "./dashboard/github-links.js";
27
27
  import { CODEX_CHAT_ARCHIVE_ENABLED } from "./dashboard/state.js";
28
28
  import { createUsageTracker } from "./usage-tracker.js";
29
29
  import { readUsageStore, usageStorePath } from "./usage.js";
30
+ import { reportedWorkSummary } from "./reported-work.js";
30
31
  import {
31
32
  MAX_DASHBOARD_SESSION_PIDS,
32
33
  MAX_TRANSFERRED_DASHBOARD_SESSION_PIDS,
@@ -229,6 +230,7 @@ function taskListProjection(task) {
229
230
  const { turns: _turns, results: _results, ...projection } = {
230
231
  ...task,
231
232
  ...taskGitHubProjection(task),
233
+ reportedWork: reportedWorkSummary(task.turns ?? []),
232
234
  };
233
235
  return projection;
234
236
  }
@@ -0,0 +1,61 @@
1
+ const LIFECYCLE_TIMESTAMP_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?(Z|[+-]\d{2}:\d{2})$/;
2
+
3
+ export function durationBetween(startedAt, endedAt) {
4
+ const start = parseLifecycleTimestamp(startedAt);
5
+ const end = parseLifecycleTimestamp(endedAt);
6
+ if (start === null || end === null) return null;
7
+ const duration = end - start;
8
+ return duration >= 0 ? duration : null;
9
+ }
10
+
11
+ export function terminalTurnDuration(turn) {
12
+ const duration = durationBetween(turn.startedAt, turn.result?.updatedAt);
13
+ return duration === 0 ? null : duration;
14
+ }
15
+
16
+ export function reportedWorkSummary(turns) {
17
+ const terminalTurns = turns.filter((turn) => turn.result !== null);
18
+ const durations = terminalTurns
19
+ .map(terminalTurnDuration)
20
+ .filter((duration) => duration !== null);
21
+ const totalMilliseconds = durations.length === 0
22
+ ? terminalTurns.length === 0 ? 0 : null
23
+ : durations.reduce((total, duration) => total + duration, 0);
24
+ return Object.freeze({
25
+ terminalTurns: terminalTurns.length,
26
+ validTurns: durations.length,
27
+ totalMilliseconds: totalMilliseconds === null || Number.isSafeInteger(totalMilliseconds)
28
+ ? totalMilliseconds
29
+ : null,
30
+ });
31
+ }
32
+
33
+ function parseLifecycleTimestamp(value) {
34
+ if (typeof value !== "string") return null;
35
+ const match = value.match(LIFECYCLE_TIMESTAMP_PATTERN);
36
+ if (!match) return null;
37
+ const [, yearText, monthText, dayText, hourText, minuteText, secondText, , zone] = match;
38
+ const year = Number(yearText);
39
+ const month = Number(monthText);
40
+ const day = Number(dayText);
41
+ const hour = Number(hourText);
42
+ const minute = Number(minuteText);
43
+ const second = Number(secondText);
44
+ const zoneHour = zone === "Z" ? 0 : Number(zone.slice(1, 3));
45
+ const zoneMinute = zone === "Z" ? 0 : Number(zone.slice(4, 6));
46
+ const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
47
+ const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
48
+ if (
49
+ month < 1
50
+ || month > 12
51
+ || day < 1
52
+ || day > daysInMonth[month - 1]
53
+ || hour > 23
54
+ || minute > 59
55
+ || second > 59
56
+ || zoneHour > 23
57
+ || zoneMinute > 59
58
+ ) return null;
59
+ const parsed = Date.parse(value);
60
+ return Number.isFinite(parsed) ? parsed : null;
61
+ }