taskchef 7.26.0 → 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.
- package/.codex-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/src/dashboard/dist/app.js +39 -39
- package/src/dashboard/dist/app.js.map +1 -1
- package/src/dashboard/dist/styles.css +1 -1
- package/src/dashboard/react/components/ActivityTimeline.tsx +14 -8
- package/src/dashboard/react/components/TaskCard.tsx +14 -1
- package/src/dashboard/react/components/TaskDetail.test.tsx +12 -6
- package/src/dashboard/react/components/TaskUsageSummary.test.tsx +23 -2
- package/src/dashboard/react/components/UsagePanel.tsx +23 -9
- package/src/dashboard/react/presentation.test.tsx +37 -12
- package/src/dashboard/react/presentation.ts +44 -76
- package/src/dashboard/react/styles.css +11 -5
- package/src/dashboard/react/types.ts +7 -0
- package/src/dashboard.js +2 -0
- package/src/reported-work.js +61 -0
|
@@ -31,7 +31,10 @@ export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef:
|
|
|
31
31
|
<Stack gap={7}>
|
|
32
32
|
<Box className="taskchef-turn-heading">
|
|
33
33
|
<StatusBadge status={presentation.status} />
|
|
34
|
-
<
|
|
34
|
+
<Box className="taskchef-turn-timing">
|
|
35
|
+
<RelativeTime label="Turn update time" value={presentation.updatedAt} />
|
|
36
|
+
<TurnReportedWork turn={turn} />
|
|
37
|
+
</Box>
|
|
35
38
|
</Box>
|
|
36
39
|
{presentation.sourceLabel && <Text c="dimmed" size="xs">{presentation.sourceLabel}</Text>}
|
|
37
40
|
<Box>
|
|
@@ -63,7 +66,6 @@ export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef:
|
|
|
63
66
|
title={usage.title}
|
|
64
67
|
value={usage.cost.value}
|
|
65
68
|
/>
|
|
66
|
-
<TurnReportedWorkMetric turn={turn} />
|
|
67
69
|
</Box>
|
|
68
70
|
{usage.note && <Text c="dimmed" size="xs">{usage.note}</Text>}
|
|
69
71
|
<Text c="dimmed" className="taskchef-mono" size="xs">Turn ref: {identity ?? "—"}</Text>
|
|
@@ -75,16 +77,20 @@ export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef:
|
|
|
75
77
|
);
|
|
76
78
|
}
|
|
77
79
|
|
|
78
|
-
function
|
|
80
|
+
function TurnReportedWork({ turn }: { turn: TaskTurn }) {
|
|
79
81
|
const now = useLiveNow(turn.result === null);
|
|
80
82
|
const elapsed = turnReportedWorkView(turn, now);
|
|
81
83
|
return (
|
|
82
|
-
<
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
<Text
|
|
85
|
+
aria-label={elapsed.accessibleLabel}
|
|
86
|
+
c="dimmed"
|
|
87
|
+
className="taskchef-turn-duration"
|
|
88
|
+
size="xs"
|
|
85
89
|
title={elapsed.title}
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
>
|
|
91
|
+
<span className="taskchef-field-label">{elapsed.label}</span>
|
|
92
|
+
<span>{elapsed.value}</span>
|
|
93
|
+
</Text>
|
|
88
94
|
);
|
|
89
95
|
}
|
|
90
96
|
|
|
@@ -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
|
-
<
|
|
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>
|
|
@@ -70,11 +70,15 @@ test("covers terminal, active, and unavailable timeline metrics", () => {
|
|
|
70
70
|
<MantineProvider><ActivityTimeline highlightTurnRef={null} task={task} /></MantineProvider>,
|
|
71
71
|
);
|
|
72
72
|
const metrics = container.querySelector(".taskchef-turn-metrics") as HTMLElement;
|
|
73
|
+
const timing = container.querySelector(".taskchef-turn-timing") as HTMLElement;
|
|
73
74
|
expect(metrics).not.toBeNull();
|
|
75
|
+
expect(timing).not.toBeNull();
|
|
74
76
|
expect(within(metrics).getByText("Tokens").nextSibling).toHaveTextContent("330");
|
|
75
77
|
expect(within(metrics).getByText("Estimated cost").nextSibling).toHaveTextContent("$0.12");
|
|
76
|
-
expect(within(metrics).
|
|
77
|
-
expect(within(
|
|
78
|
+
expect(within(metrics).queryByText("Elapsed")).not.toBeInTheDocument();
|
|
79
|
+
expect(within(timing).getByLabelText(/^Turn update time:/i)).toBeVisible();
|
|
80
|
+
expect(within(timing).getByLabelText(/completed turn reported wall-clock elapsed time/i))
|
|
81
|
+
.toHaveTextContent("Elapsed18m 32s");
|
|
78
82
|
cleanup();
|
|
79
83
|
}
|
|
80
84
|
|
|
@@ -89,7 +93,8 @@ test("covers terminal, active, and unavailable timeline metrics", () => {
|
|
|
89
93
|
vi.spyOn(window, "clearInterval").mockImplementation(() => undefined);
|
|
90
94
|
const task = fixtureTask();
|
|
91
95
|
render(<MantineProvider><ActivityTimeline highlightTurnRef={null} task={task} /></MantineProvider>);
|
|
92
|
-
expect(screen.
|
|
96
|
+
expect(screen.getByLabelText(/current turn reported wall-clock elapsed time/i))
|
|
97
|
+
.toHaveTextContent("Elapsed so far18m 32s");
|
|
93
98
|
expect(screen.getByText("Tokens").nextSibling).toHaveTextContent("Pending");
|
|
94
99
|
expect(screen.getByText("Estimated cost").nextSibling).toHaveTextContent("Pending");
|
|
95
100
|
|
|
@@ -98,7 +103,8 @@ test("covers terminal, active, and unavailable timeline metrics", () => {
|
|
|
98
103
|
tick?.();
|
|
99
104
|
});
|
|
100
105
|
|
|
101
|
-
expect(screen.
|
|
106
|
+
expect(screen.getByLabelText(/current turn reported wall-clock elapsed time/i))
|
|
107
|
+
.toHaveTextContent("Elapsed so far18m 34s");
|
|
102
108
|
cleanup();
|
|
103
109
|
vi.restoreAllMocks();
|
|
104
110
|
}
|
|
@@ -126,8 +132,8 @@ test("covers terminal, active, and unavailable timeline metrics", () => {
|
|
|
126
132
|
},
|
|
127
133
|
});
|
|
128
134
|
render(<MantineProvider><ActivityTimeline highlightTurnRef={null} task={task} /></MantineProvider>);
|
|
129
|
-
expect(screen.
|
|
130
|
-
|
|
135
|
+
expect(screen.getByLabelText(/elapsed unavailable.*reported wall-clock/i))
|
|
136
|
+
.toHaveTextContent("Elapsedn/a");
|
|
131
137
|
expect(screen.getByText("Manual dashboard turns do not have usage boundaries.")).toBeVisible();
|
|
132
138
|
expect(screen.getByLabelText(/tokens unavailable: manual dashboard turns/i)).toBeVisible();
|
|
133
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
|
|
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("
|
|
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
|
-
: "
|
|
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 ${
|
|
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
|
-
?
|
|
55
|
+
? "Estimated cost unavailable"
|
|
54
56
|
: `Unrounded estimate: $${ready.usage.estimatedCostUsd}`,
|
|
55
|
-
value: ready
|
|
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: "
|
|
63
|
+
label: "Reported work",
|
|
60
64
|
title: reportedWork.title,
|
|
61
65
|
value: reportedWork.value,
|
|
62
66
|
},
|
|
63
67
|
...(ready ? [
|
|
64
|
-
{
|
|
65
|
-
|
|
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("
|
|
143
|
-
expect(screen.getByLabelText(/
|
|
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
|
-
"
|
|
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("
|
|
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: "
|
|
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: "
|
|
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: "
|
|
398
|
+
expect(turnReportedWorkView(turn)).toMatchObject({ kind: "unavailable", value: "n/a" });
|
|
378
399
|
expect(taskReportedWorkView(fixtureTask({ status: "completed", turns: [turn] })))
|
|
379
|
-
.toMatchObject({ kind: "unavailable", value: "
|
|
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: "
|
|
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: "
|
|
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("
|
|
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: "
|
|
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
|
-
|
|
40
|
+
const summary = task.turns === undefined
|
|
41
|
+
? task.reportedWork
|
|
42
|
+
: reportedWorkSummary(task.turns ?? []);
|
|
43
|
+
if (summary === undefined) {
|
|
73
44
|
return {
|
|
74
|
-
accessibleLabel: "
|
|
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:
|
|
48
|
+
value: NOT_AVAILABLE,
|
|
78
49
|
};
|
|
79
50
|
}
|
|
80
|
-
|
|
81
|
-
if (terminalTurns.length === 0) {
|
|
51
|
+
if (summary.terminalTurns === 0) {
|
|
82
52
|
return {
|
|
83
|
-
accessibleLabel: "
|
|
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:
|
|
56
|
+
value: NOT_AVAILABLE,
|
|
87
57
|
};
|
|
88
58
|
}
|
|
89
|
-
|
|
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: "
|
|
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:
|
|
64
|
+
value: NOT_AVAILABLE,
|
|
97
65
|
};
|
|
98
66
|
}
|
|
99
|
-
const
|
|
100
|
-
|
|
67
|
+
const value = summary.totalMilliseconds === null
|
|
68
|
+
? null
|
|
69
|
+
: formatReportedDuration(summary.totalMilliseconds);
|
|
101
70
|
if (value === null) {
|
|
102
71
|
return {
|
|
103
|
-
accessibleLabel: "
|
|
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:
|
|
75
|
+
value: NOT_AVAILABLE,
|
|
107
76
|
};
|
|
108
77
|
}
|
|
109
|
-
const excluded = terminalTurns
|
|
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: `
|
|
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 ?
|
|
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
|
|
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
|
|
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) :
|
|
196
|
+
const tokens = typeof usage?.totalTokens === "number" ? formatFullTokens(usage.totalTokens) : NOT_AVAILABLE;
|
|
233
197
|
const cost = usage?.estimatedCostUsd == null
|
|
234
|
-
?
|
|
198
|
+
? NOT_AVAILABLE
|
|
235
199
|
: formatEstimatedCost(usage.estimatedCostUsd);
|
|
236
200
|
return {
|
|
237
201
|
animated: false,
|
|
238
|
-
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
|
-
:
|
|
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 ${
|
|
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 ${
|
|
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:
|
|
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
|
|
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;
|
|
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:
|
|
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 {
|
|
@@ -81,7 +83,9 @@ body {
|
|
|
81
83
|
.taskchef-turn { background: var(--taskchef-surface-muted); }
|
|
82
84
|
.taskchef-turn-highlighted { border-color: var(--mantine-color-teal-6); box-shadow: inset 3px 0 var(--mantine-color-teal-6); outline: 3px solid color-mix(in srgb, var(--mantine-color-teal-6) 22%, transparent); }
|
|
83
85
|
.taskchef-turn-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
84
|
-
.taskchef-turn-
|
|
86
|
+
.taskchef-turn-timing { display: flex; align-items: center; justify-content: flex-end; gap: var(--mantine-spacing-sm); min-width: 0; }
|
|
87
|
+
.taskchef-turn-duration { display: inline-flex; align-items: baseline; gap: 4px; white-space: nowrap; }
|
|
88
|
+
.taskchef-turn-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--mantine-spacing-xs); }
|
|
85
89
|
.taskchef-turn-metric { min-width: 0; overflow-wrap: anywhere; }
|
|
86
90
|
.taskchef-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
|
|
87
91
|
.taskchef-code-panel { max-height: 280px; margin: 0; padding: 12px; overflow: auto; border-radius: var(--mantine-radius-md); background: var(--taskchef-surface-muted); white-space: pre-wrap; overflow-wrap: anywhere; font: 0.78rem/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
@@ -121,8 +125,10 @@ body {
|
|
|
121
125
|
.taskchef-toolbar .mantine-SegmentedControl-root { overflow-x: auto; }
|
|
122
126
|
.taskchef-notifications { right: 16px; bottom: 16px; width: min(330px, calc(100vw - 32px)); }
|
|
123
127
|
.taskchef-metadata { grid-template-columns: 1fr; }
|
|
124
|
-
.taskchef-metrics { grid-template-columns: repeat(
|
|
128
|
+
.taskchef-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
125
129
|
.taskchef-turn-metrics { grid-template-columns: 1fr; }
|
|
130
|
+
.taskchef-turn-heading { align-items: flex-start; flex-direction: column; gap: 6px; }
|
|
131
|
+
.taskchef-turn-timing { flex-wrap: wrap; justify-content: flex-start; }
|
|
126
132
|
.taskchef-metadata dt { padding-bottom: 0; border-bottom: 0; }
|
|
127
133
|
}
|
|
128
134
|
|