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.
@@ -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 four operator-facing cards", () => {
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(4, minmax(0, 1fr)); gap: var(--mantine-spacing-xs); }
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