taskchef 7.25.2 → 7.26.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/README.md +7 -0
- package/docs/dashboard-lifecycle.md +23 -0
- package/docs/images/reported-work-duration-desktop.jpg +0 -0
- package/docs/images/reported-work-duration-mobile.jpg +0 -0
- package/package.json +3 -1
- package/src/dashboard/dist/app.js +41 -46
- package/src/dashboard/dist/app.js.map +1 -1
- package/src/dashboard/dist/styles.css +1 -1
- package/src/dashboard/react/App.test.tsx +112 -0
- package/src/dashboard/react/components/ActivityTimeline.tsx +69 -12
- package/src/dashboard/react/components/TaskDetail.test.tsx +91 -2
- package/src/dashboard/react/components/UsagePanel.tsx +64 -46
- package/src/dashboard/react/presentation.test.tsx +231 -3
- package/src/dashboard/react/presentation.ts +181 -1
- package/src/dashboard/react/styles.css +4 -1
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { MantineProvider } from "@mantine/core";
|
|
2
|
-
import { render, screen, within } from "@testing-library/react";
|
|
3
|
-
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { cleanup, render, screen, within } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, test } from "vitest";
|
|
4
4
|
import { fixtureTask } from "./fixtures";
|
|
5
5
|
import {
|
|
6
|
+
durationBetween,
|
|
6
7
|
formatCompactTokens,
|
|
7
8
|
formatEstimatedCost,
|
|
9
|
+
formatReportedDuration,
|
|
10
|
+
taskReportedWorkView,
|
|
11
|
+
turnReportedWorkView,
|
|
12
|
+
turnUsageMetricsView,
|
|
8
13
|
turnUsageView,
|
|
9
14
|
usageStillCalculating,
|
|
10
15
|
usageView,
|
|
@@ -14,6 +19,8 @@ import { GitHubLinks } from "./components/GitHubLinks";
|
|
|
14
19
|
import { LinkedText } from "./components/LinkedText";
|
|
15
20
|
import { UsagePanel } from "./components/UsagePanel";
|
|
16
21
|
|
|
22
|
+
afterEach(cleanup);
|
|
23
|
+
|
|
17
24
|
describe("token and working presentation", () => {
|
|
18
25
|
test("keeps pending, calculating, ready cost, and unavailable wording distinct", () => {
|
|
19
26
|
expect(usageView(fixtureTask()).label).toBe("Token usage pending");
|
|
@@ -99,11 +106,20 @@ describe("token and working presentation", () => {
|
|
|
99
106
|
expect(screen.getByText("In progress")).toHaveAttribute("data-animation", "text-shimmer");
|
|
100
107
|
});
|
|
101
108
|
|
|
102
|
-
test("summarizes ready usage as
|
|
109
|
+
test("summarizes ready usage and reported work as one operator-facing metric group", () => {
|
|
110
|
+
const completedTurn = {
|
|
111
|
+
...fixtureTask().turns![0],
|
|
112
|
+
result: {
|
|
113
|
+
status: "completed" as const,
|
|
114
|
+
summary: "Done.",
|
|
115
|
+
updatedAt: "2026-08-30T08:18:32.000Z",
|
|
116
|
+
},
|
|
117
|
+
};
|
|
103
118
|
render(
|
|
104
119
|
<MantineProvider>
|
|
105
120
|
<UsagePanel task={fixtureTask({
|
|
106
121
|
status: "completed",
|
|
122
|
+
turns: [completedTurn],
|
|
107
123
|
usage: {
|
|
108
124
|
generationTurnRef: "turn-one",
|
|
109
125
|
status: "available",
|
|
@@ -123,11 +139,36 @@ describe("token and working presentation", () => {
|
|
|
123
139
|
|
|
124
140
|
expect(screen.getByText("Tokens").nextSibling).toHaveTextContent("330");
|
|
125
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();
|
|
126
144
|
expect(screen.getByText("Model").nextSibling).toHaveTextContent("gpt-5.6-sol, gpt-5.6-luna");
|
|
127
145
|
expect(screen.getByText("Cache ratio").nextSibling).toHaveTextContent("67%");
|
|
128
146
|
expect(screen.queryByText(/API-equivalent/i)).not.toBeInTheDocument();
|
|
129
147
|
});
|
|
130
148
|
|
|
149
|
+
test("keeps reported work visible across pending, calculating, and unavailable usage", () => {
|
|
150
|
+
for (const [task, expected, accessibleCost] of [
|
|
151
|
+
[fixtureTask(), "Pending", "Estimated cost pending"],
|
|
152
|
+
[
|
|
153
|
+
fixtureTask({ status: "completed", usage: { status: "calculating" } }),
|
|
154
|
+
"Calculating",
|
|
155
|
+
"Estimated cost calculating",
|
|
156
|
+
],
|
|
157
|
+
[
|
|
158
|
+
fixtureTask({ status: "completed", usage: { status: "unavailable" } }),
|
|
159
|
+
"Unavailable",
|
|
160
|
+
"Estimated cost unavailable: Token usage unavailable",
|
|
161
|
+
],
|
|
162
|
+
] as const) {
|
|
163
|
+
render(<MantineProvider><UsagePanel task={task} /></MantineProvider>);
|
|
164
|
+
expect(screen.getByText("Tokens").nextSibling).toHaveTextContent(expected);
|
|
165
|
+
expect(screen.getByText("Estimated cost").nextSibling).toHaveTextContent(expected);
|
|
166
|
+
expect(screen.getByLabelText(accessibleCost)).toBeVisible();
|
|
167
|
+
expect(screen.getByText("Total reported work").nextSibling).toHaveTextContent("Not yet reported");
|
|
168
|
+
cleanup();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
131
172
|
test("uses the same borderless link treatment wherever links are rendered", () => {
|
|
132
173
|
render(<MantineProvider><GitHubLinks task={fixtureTask()} /></MantineProvider>);
|
|
133
174
|
expect(screen.getByRole("link")).toHaveClass("taskchef-github-link");
|
|
@@ -239,3 +280,190 @@ describe("token and working presentation", () => {
|
|
|
239
280
|
.toHaveTextContent("taskchef PR #83");
|
|
240
281
|
});
|
|
241
282
|
});
|
|
283
|
+
|
|
284
|
+
describe("reported wall-clock work presentation", () => {
|
|
285
|
+
test("covers formatting, derivation, unavailable boundaries, usage states, and accessibility", () => {
|
|
286
|
+
expect(formatReportedDuration(500)).toBe("<1s");
|
|
287
|
+
expect(formatReportedDuration(32_999)).toBe("32s");
|
|
288
|
+
expect(formatReportedDuration((18 * 60 + 32) * 1_000 + 999)).toBe("18m 32s");
|
|
289
|
+
expect(formatReportedDuration((2 * 60 + 14) * 60 * 1_000 + 59_999)).toBe("2h 14m");
|
|
290
|
+
expect(formatReportedDuration((27 * 60 + 59) * 60 * 1_000)).toBe("1d 3h");
|
|
291
|
+
expect(formatReportedDuration(-1)).toBeNull();
|
|
292
|
+
expect(formatReportedDuration(Number.NaN)).toBeNull();
|
|
293
|
+
|
|
294
|
+
{
|
|
295
|
+
const task = fixtureTask({
|
|
296
|
+
status: "working",
|
|
297
|
+
turns: [
|
|
298
|
+
{
|
|
299
|
+
requestSummary: "First turn",
|
|
300
|
+
startedAt: "2026-08-30T08:00:00.000Z",
|
|
301
|
+
turnRef: "turn-one",
|
|
302
|
+
turnId: null,
|
|
303
|
+
result: {
|
|
304
|
+
status: "completed",
|
|
305
|
+
summary: "First done",
|
|
306
|
+
updatedAt: "2026-08-30T08:18:32.000Z",
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
requestSummary: "Second turn after a long idle gap",
|
|
311
|
+
startedAt: "2026-08-30T18:00:00.000Z",
|
|
312
|
+
turnRef: "turn-two",
|
|
313
|
+
turnId: null,
|
|
314
|
+
result: {
|
|
315
|
+
status: "failed",
|
|
316
|
+
summary: "Second done",
|
|
317
|
+
updatedAt: "2026-08-30T20:14:00.000Z",
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
requestSummary: "Active follow-up",
|
|
322
|
+
startedAt: "2026-08-31T08:00:00.000Z",
|
|
323
|
+
turnRef: "turn-three",
|
|
324
|
+
turnId: null,
|
|
325
|
+
result: null,
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
});
|
|
329
|
+
const view = taskReportedWorkView(task);
|
|
330
|
+
expect(view.kind).toBe("available");
|
|
331
|
+
expect(view.value).toBe("2h 32m");
|
|
332
|
+
expect(view.title).toMatch(/idle gaps are excluded/i);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
{
|
|
336
|
+
const invalid = fixtureTask({
|
|
337
|
+
status: "completed",
|
|
338
|
+
turns: [{
|
|
339
|
+
requestSummary: "Historical turn",
|
|
340
|
+
startedAt: "not-a-date",
|
|
341
|
+
turnRef: "turn-one",
|
|
342
|
+
turnId: null,
|
|
343
|
+
result: { status: "completed", summary: "Done", updatedAt: "2026-08-30T08:00:00.000Z" },
|
|
344
|
+
}],
|
|
345
|
+
});
|
|
346
|
+
expect(taskReportedWorkView(invalid)).toMatchObject({ kind: "unavailable", value: "Unavailable" });
|
|
347
|
+
expect(durationBetween("2026-08-30T08:01:00.000Z", "2026-08-30T08:00:00.000Z")).toBeNull();
|
|
348
|
+
expect(durationBetween("2026-02-30T08:00:00.000Z", "2026-03-02T08:01:00.000Z")).toBeNull();
|
|
349
|
+
expect(durationBetween("March 2, 2026 08:00:00 UTC", "2026-03-02T08:01:00.000Z")).toBeNull();
|
|
350
|
+
expect(turnReportedWorkView(invalid.turns![0])).toMatchObject({
|
|
351
|
+
kind: "unavailable",
|
|
352
|
+
label: "Elapsed",
|
|
353
|
+
value: "Unavailable",
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
{
|
|
358
|
+
const terminalResult = {
|
|
359
|
+
status: "completed" as const,
|
|
360
|
+
summary: "Done.",
|
|
361
|
+
updatedAt: "2026-08-30T08:00:00.000Z",
|
|
362
|
+
};
|
|
363
|
+
const legacyTurn = {
|
|
364
|
+
requestSummary: null,
|
|
365
|
+
startedAt: terminalResult.updatedAt,
|
|
366
|
+
turnRef: "legacy-turn",
|
|
367
|
+
turnId: null,
|
|
368
|
+
provenance: { kind: "legacy" },
|
|
369
|
+
result: terminalResult,
|
|
370
|
+
};
|
|
371
|
+
const terminalOnlyTurn = {
|
|
372
|
+
...legacyTurn,
|
|
373
|
+
turnRef: "terminal-only-turn",
|
|
374
|
+
provenance: { kind: "mcp" },
|
|
375
|
+
};
|
|
376
|
+
for (const turn of [legacyTurn, terminalOnlyTurn]) {
|
|
377
|
+
expect(turnReportedWorkView(turn)).toMatchObject({ kind: "unavailable", value: "Unavailable" });
|
|
378
|
+
expect(taskReportedWorkView(fixtureTask({ status: "completed", turns: [turn] })))
|
|
379
|
+
.toMatchObject({ kind: "unavailable", value: "Unavailable" });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
{
|
|
384
|
+
const observed = {
|
|
385
|
+
requestSummary: "Perform the observed turn.",
|
|
386
|
+
startedAt: "2026-08-30T08:00:00.000Z",
|
|
387
|
+
turnRef: "observed-turn",
|
|
388
|
+
turnId: null,
|
|
389
|
+
provenance: { kind: "mcp" },
|
|
390
|
+
result: {
|
|
391
|
+
status: "completed" as const,
|
|
392
|
+
summary: "Done.",
|
|
393
|
+
updatedAt: "2026-08-30T08:00:00.000Z",
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
expect(turnReportedWorkView(observed)).toMatchObject({ kind: "unavailable", value: "Unavailable" });
|
|
397
|
+
expect(turnReportedWorkView({
|
|
398
|
+
...observed,
|
|
399
|
+
result: { ...observed.result, updatedAt: "2026-08-30T08:00:00.500Z" },
|
|
400
|
+
})).toMatchObject({ kind: "available", value: "<1s" });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
{
|
|
404
|
+
const compactTerminal = fixtureTask({
|
|
405
|
+
status: "completed",
|
|
406
|
+
turns: undefined,
|
|
407
|
+
latestTurn: {
|
|
408
|
+
...fixtureTask().latestTurn!,
|
|
409
|
+
result: {
|
|
410
|
+
status: "completed",
|
|
411
|
+
summary: "Done.",
|
|
412
|
+
updatedAt: "2026-08-30T08:18:32.000Z",
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
expect(taskReportedWorkView(compactTerminal)).toMatchObject({
|
|
417
|
+
kind: "unavailable",
|
|
418
|
+
value: "Unavailable",
|
|
419
|
+
});
|
|
420
|
+
render(<MantineProvider><UsagePanel task={compactTerminal} /></MantineProvider>);
|
|
421
|
+
expect(screen.getByText("Total reported work").nextSibling).toHaveTextContent("Unavailable");
|
|
422
|
+
cleanup();
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
{
|
|
426
|
+
const priorResult = {
|
|
427
|
+
status: "completed" as const,
|
|
428
|
+
summary: "Prior turn done.",
|
|
429
|
+
updatedAt: "2026-08-30T08:18:32.000Z",
|
|
430
|
+
turnRef: "turn-one",
|
|
431
|
+
turnId: null,
|
|
432
|
+
};
|
|
433
|
+
expect(taskReportedWorkView(fixtureTask({
|
|
434
|
+
status: "working",
|
|
435
|
+
turnRef: "turn-two",
|
|
436
|
+
turns: undefined,
|
|
437
|
+
lastResult: priorResult,
|
|
438
|
+
results: undefined,
|
|
439
|
+
}))).toMatchObject({ kind: "unavailable", value: "Unavailable" });
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
{
|
|
443
|
+
render(
|
|
444
|
+
<MantineProvider>
|
|
445
|
+
<UsagePanel task={fixtureTask({
|
|
446
|
+
status: "completed",
|
|
447
|
+
usage: { status: "unavailable", reason: "No matching cached boundary." },
|
|
448
|
+
})} />
|
|
449
|
+
</MantineProvider>,
|
|
450
|
+
);
|
|
451
|
+
expect(screen.getByText("No matching cached boundary.")).toBeVisible();
|
|
452
|
+
expect(screen.getByLabelText("No matching cached boundary.")).toBeVisible();
|
|
453
|
+
cleanup();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
{
|
|
457
|
+
const task = fixtureTask();
|
|
458
|
+
expect(turnReportedWorkView(task.turns![0], Date.parse("2026-08-30T08:18:32.000Z"))).toMatchObject({
|
|
459
|
+
label: "Elapsed so far",
|
|
460
|
+
value: "18m 32s",
|
|
461
|
+
});
|
|
462
|
+
expect(turnUsageMetricsView(task, task.turns![0])).toMatchObject({
|
|
463
|
+
animated: true,
|
|
464
|
+
cost: { value: "Pending" },
|
|
465
|
+
tokens: { value: "Pending" },
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
});
|
|
@@ -1,4 +1,145 @@
|
|
|
1
|
-
import type { Task, UsageNumbers, UsageProjection } from "./types";
|
|
1
|
+
import type { Task, TaskTurn, UsageNumbers, UsageProjection } from "./types";
|
|
2
|
+
|
|
3
|
+
const SECOND_MS = 1_000;
|
|
4
|
+
const MINUTE_MS = 60 * SECOND_MS;
|
|
5
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
6
|
+
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
|
+
}
|
|
47
|
+
|
|
48
|
+
export function formatReportedDuration(milliseconds: number) {
|
|
49
|
+
if (!Number.isFinite(milliseconds) || milliseconds < 0) return null;
|
|
50
|
+
if (milliseconds > 0 && milliseconds < SECOND_MS) return "<1s";
|
|
51
|
+
const seconds = Math.floor(milliseconds / SECOND_MS);
|
|
52
|
+
if (milliseconds < MINUTE_MS) return `${seconds}s`;
|
|
53
|
+
if (milliseconds < HOUR_MS) {
|
|
54
|
+
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
55
|
+
}
|
|
56
|
+
const minutes = Math.floor(milliseconds / MINUTE_MS);
|
|
57
|
+
if (milliseconds < DAY_MS) {
|
|
58
|
+
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
59
|
+
}
|
|
60
|
+
const hours = Math.floor(milliseconds / HOUR_MS);
|
|
61
|
+
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type ReportedWorkView = {
|
|
65
|
+
accessibleLabel: string;
|
|
66
|
+
kind: "available" | "not-reported" | "unavailable";
|
|
67
|
+
title: string;
|
|
68
|
+
value: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export function taskReportedWorkView(task: Task): ReportedWorkView {
|
|
72
|
+
if (task.turns === undefined) {
|
|
73
|
+
return {
|
|
74
|
+
accessibleLabel: "Total reported work unavailable because terminal turn history is not available.",
|
|
75
|
+
kind: "unavailable",
|
|
76
|
+
title: "Reported wall-clock elapsed time requires the terminal turn history from the task detail projection.",
|
|
77
|
+
value: "Unavailable",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const terminalTurns = (task.turns ?? []).filter((turn) => turn.result !== null);
|
|
81
|
+
if (terminalTurns.length === 0) {
|
|
82
|
+
return {
|
|
83
|
+
accessibleLabel: "Total reported work not yet reported. Unfinished turns are excluded.",
|
|
84
|
+
kind: "not-reported",
|
|
85
|
+
title: "Reported wall-clock elapsed time is added only after a turn reaches a terminal state.",
|
|
86
|
+
value: "Not yet reported",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const durations = terminalTurns.map(terminalTurnDuration);
|
|
90
|
+
const validDurations = durations.filter((duration): duration is number => duration !== null);
|
|
91
|
+
if (validDurations.length === 0) {
|
|
92
|
+
return {
|
|
93
|
+
accessibleLabel: "Total reported work unavailable because completed turn timestamps are missing or invalid.",
|
|
94
|
+
kind: "unavailable",
|
|
95
|
+
title: "Reported wall-clock elapsed time is unavailable because no terminal turn has a valid start and end timestamp.",
|
|
96
|
+
value: "Unavailable",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const total = validDurations.reduce((sum, duration) => sum + duration, 0);
|
|
100
|
+
const value = Number.isSafeInteger(total) ? formatReportedDuration(total) : null;
|
|
101
|
+
if (value === null) {
|
|
102
|
+
return {
|
|
103
|
+
accessibleLabel: "Total reported work unavailable because the duration total is outside the supported range.",
|
|
104
|
+
kind: "unavailable",
|
|
105
|
+
title: "Reported wall-clock elapsed time is unavailable because the duration total is outside the supported range.",
|
|
106
|
+
value: "Unavailable",
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const excluded = terminalTurns.length - validDurations.length;
|
|
110
|
+
const exclusion = excluded > 0
|
|
111
|
+
? ` ${excluded} terminal ${excluded === 1 ? "turn was" : "turns were"} unavailable and excluded.`
|
|
112
|
+
: "";
|
|
113
|
+
return {
|
|
114
|
+
accessibleLabel: `Total reported work ${value}. This is reported wall-clock elapsed time.${exclusion}`,
|
|
115
|
+
kind: "available",
|
|
116
|
+
title: `Sum of valid terminal-turn wall-clock elapsed times; unfinished turns and idle gaps are excluded.${exclusion}`,
|
|
117
|
+
value,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function turnReportedWorkView(turn: TaskTurn, now = Date.now()) {
|
|
122
|
+
const active = turn.result === null;
|
|
123
|
+
const duration = active
|
|
124
|
+
? durationBetween(turn.startedAt, new Date(now).toISOString())
|
|
125
|
+
: terminalTurnDuration(turn);
|
|
126
|
+
const label = active ? "Elapsed so far" : "Elapsed";
|
|
127
|
+
const value = duration === null ? "Unavailable" : formatReportedDuration(duration) ?? "Unavailable";
|
|
128
|
+
return {
|
|
129
|
+
accessibleLabel: `${label} ${value}. ${active ? "Current" : "Completed"} turn reported wall-clock elapsed time.`,
|
|
130
|
+
kind: duration === null ? "unavailable" as const : "available" as const,
|
|
131
|
+
label,
|
|
132
|
+
title: duration === null
|
|
133
|
+
? "Reported wall-clock elapsed time is unavailable because the turn timestamps are missing, invalid, or reversed."
|
|
134
|
+
: `Reported wall-clock elapsed time from startedAt ${active ? "to the current time" : "to result.updatedAt"}.`,
|
|
135
|
+
value,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function terminalTurnDuration(turn: TaskTurn) {
|
|
140
|
+
const duration = durationBetween(turn.startedAt, turn.result?.updatedAt);
|
|
141
|
+
return duration === 0 ? null : duration;
|
|
142
|
+
}
|
|
2
143
|
|
|
3
144
|
export function formatCompactTokens(value: number, locales?: Intl.LocalesArgument) {
|
|
4
145
|
return new Intl.NumberFormat(locales, {
|
|
@@ -83,6 +224,45 @@ export function turnUsageView(task: Task, turn: NonNullable<Task["turns"]>[numbe
|
|
|
83
224
|
};
|
|
84
225
|
}
|
|
85
226
|
|
|
227
|
+
export function turnUsageMetricsView(task: Task, turn: NonNullable<Task["turns"]>[number]) {
|
|
228
|
+
const view = turnUsageView(task, turn);
|
|
229
|
+
if (view.kind === "ready") {
|
|
230
|
+
const identity = turn.turnRef ?? turn.turnId;
|
|
231
|
+
const usage = identity ? task.usage?.turns?.[identity] : null;
|
|
232
|
+
const tokens = typeof usage?.totalTokens === "number" ? formatFullTokens(usage.totalTokens) : "Unavailable";
|
|
233
|
+
const cost = usage?.estimatedCostUsd == null
|
|
234
|
+
? "Unavailable"
|
|
235
|
+
: formatEstimatedCost(usage.estimatedCostUsd);
|
|
236
|
+
return {
|
|
237
|
+
animated: false,
|
|
238
|
+
cost: { accessibleLabel: `Estimated cost ${cost.toLowerCase()}`, value: cost },
|
|
239
|
+
kind: view.kind,
|
|
240
|
+
note: null,
|
|
241
|
+
title: view.label,
|
|
242
|
+
tokens: { accessibleLabel: `${tokens} tokens`, value: tokens },
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
const value = view.kind === "pending"
|
|
246
|
+
? "Pending"
|
|
247
|
+
: view.kind === "calculating"
|
|
248
|
+
? "Calculating"
|
|
249
|
+
: "Unavailable";
|
|
250
|
+
return {
|
|
251
|
+
animated: view.kind === "pending" || view.kind === "calculating",
|
|
252
|
+
cost: {
|
|
253
|
+
accessibleLabel: `Estimated cost ${value.toLowerCase()}${view.kind === "unavailable" ? `: ${view.label}` : ""}`,
|
|
254
|
+
value,
|
|
255
|
+
},
|
|
256
|
+
kind: view.kind,
|
|
257
|
+
note: view.kind === "unavailable" ? view.label : null,
|
|
258
|
+
title: view.label,
|
|
259
|
+
tokens: {
|
|
260
|
+
accessibleLabel: `Tokens ${value.toLowerCase()}${view.kind === "unavailable" ? `: ${view.label}` : ""}`,
|
|
261
|
+
value,
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
86
266
|
export function usageStillCalculating(task: Task) {
|
|
87
267
|
return task.status !== "working"
|
|
88
268
|
&& (task.usage?.status === "calculating"
|
|
@@ -77,10 +77,12 @@ body {
|
|
|
77
77
|
.taskchef-subtle-panel { background: var(--taskchef-surface-muted); }
|
|
78
78
|
.taskchef-metric { min-width: 0; background: transparent; }
|
|
79
79
|
.taskchef-metric-value { overflow-wrap: anywhere; }
|
|
80
|
-
.taskchef-metrics { display: grid; grid-template-columns: repeat(
|
|
80
|
+
.taskchef-metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: var(--mantine-spacing-xs); }
|
|
81
81
|
.taskchef-turn { background: var(--taskchef-surface-muted); }
|
|
82
82
|
.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
83
|
.taskchef-turn-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
84
|
+
.taskchef-turn-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--mantine-spacing-xs); }
|
|
85
|
+
.taskchef-turn-metric { min-width: 0; overflow-wrap: anywhere; }
|
|
84
86
|
.taskchef-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
|
|
85
87
|
.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; }
|
|
86
88
|
.taskchef-metadata { display: grid; grid-template-columns: minmax(120px, 150px) minmax(0, 1fr); margin: 0; font-size: var(--mantine-font-size-sm); }
|
|
@@ -120,6 +122,7 @@ body {
|
|
|
120
122
|
.taskchef-notifications { right: 16px; bottom: 16px; width: min(330px, calc(100vw - 32px)); }
|
|
121
123
|
.taskchef-metadata { grid-template-columns: 1fr; }
|
|
122
124
|
.taskchef-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
125
|
+
.taskchef-turn-metrics { grid-template-columns: 1fr; }
|
|
123
126
|
.taskchef-metadata dt { padding-bottom: 0; border-bottom: 0; }
|
|
124
127
|
}
|
|
125
128
|
|