taskchef 7.25.2 → 7.26.1

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,4 +1,5 @@
1
1
  import { act, cleanup, render, screen } from "@testing-library/react";
2
+ import type { ElementType, ReactNode } from "react";
2
3
  import { afterEach, beforeEach, expect, test, vi } from "vitest";
3
4
  import { DashboardApp } from "./App";
4
5
  import { fixtureTask } from "./fixtures";
@@ -16,6 +17,117 @@ vi.mock("./api", () => apiMocks);
16
17
  vi.mock("./components/NotificationCenter", () => ({ NotificationCenter: () => null }));
17
18
  vi.mock("./components/TaskCard", () => ({ TaskCard: () => null }));
18
19
  vi.mock("./components/TaskDetail", () => ({ TaskDetail: () => null }));
20
+ vi.mock("@tabler/icons-react", async () => {
21
+ const { createElement } = await vi.importActual<typeof import("react")>("react");
22
+ const Icon = (props: Record<string, unknown>) => createElement("svg", props);
23
+ return {
24
+ IconAlertTriangle: Icon,
25
+ IconCircleFilled: Icon,
26
+ IconMoon: Icon,
27
+ IconSun: Icon,
28
+ };
29
+ });
30
+ vi.mock("@mantine/core", async () => {
31
+ const { createElement } = await vi.importActual<typeof import("react")>("react");
32
+ const primitive = (tag: string) => ({
33
+ align: _align,
34
+ c: _color,
35
+ children,
36
+ component,
37
+ fw: _fontWeight,
38
+ gap: _gap,
39
+ justify: _justify,
40
+ mb: _marginBottom,
41
+ mt: _marginTop,
42
+ p: _padding,
43
+ pb: _paddingBottom,
44
+ radius: _radius,
45
+ size: _size,
46
+ ta: _textAlign,
47
+ tt: _textTransform,
48
+ withBorder: _withBorder,
49
+ wrap: _wrap,
50
+ ...props
51
+ }: Record<string, unknown> & { children?: ReactNode; component?: ElementType }) => (
52
+ createElement(component ?? tag, props, children)
53
+ );
54
+ const AppShell = Object.assign(primitive("div"), { Main: primitive("main") });
55
+ const Select = ({
56
+ "aria-label": ariaLabel,
57
+ data,
58
+ label,
59
+ onChange,
60
+ value,
61
+ }: {
62
+ "aria-label": string;
63
+ data: Array<string | { label: string; value: string }>;
64
+ label: string;
65
+ onChange: (value: string | null) => void;
66
+ value: string;
67
+ }) => {
68
+ const options = data.map((item) => typeof item === "string"
69
+ ? { label: item, value: item }
70
+ : item);
71
+ const selected = options.find((item) => item.value === value)?.label ?? value;
72
+ return createElement("label", null, label, createElement(
73
+ "select",
74
+ {
75
+ "aria-label": ariaLabel,
76
+ onChange: (event: { currentTarget: { value: string } }) => {
77
+ const next = options.find((item) => item.label === event.currentTarget.value);
78
+ onChange(next?.value ?? null);
79
+ },
80
+ value: selected,
81
+ },
82
+ options.map((item) => createElement("option", { key: item.value, value: item.label }, item.label)),
83
+ ));
84
+ };
85
+ const SegmentedControl = ({
86
+ "aria-label": ariaLabel,
87
+ data,
88
+ onChange,
89
+ value,
90
+ }: {
91
+ "aria-label": string;
92
+ data: Array<{ label: string; value: string }>;
93
+ onChange: (value: string) => void;
94
+ value: string;
95
+ }) => createElement(
96
+ "div",
97
+ { "aria-label": ariaLabel, role: "radiogroup" },
98
+ data.map((item) => createElement(
99
+ "label",
100
+ { key: item.value },
101
+ createElement("input", {
102
+ checked: item.value === value,
103
+ name: ariaLabel,
104
+ onChange: () => onChange(item.value),
105
+ type: "radio",
106
+ }),
107
+ item.label,
108
+ )),
109
+ );
110
+ return {
111
+ ActionIcon: primitive("button"),
112
+ Alert: primitive("div"),
113
+ AppShell,
114
+ Box: primitive("div"),
115
+ CloseButton: primitive("button"),
116
+ Container: primitive("div"),
117
+ Group: primitive("div"),
118
+ MantineProvider: ({ children }: { children?: ReactNode }) => createElement("div", null, children),
119
+ Paper: primitive("div"),
120
+ SegmentedControl,
121
+ Select,
122
+ Stack: primitive("div"),
123
+ Text: primitive("p"),
124
+ Title: primitive("h1"),
125
+ Tooltip: primitive("div"),
126
+ createTheme: (theme: unknown) => theme,
127
+ useComputedColorScheme: () => "light",
128
+ useMantineColorScheme: () => ({ setColorScheme: vi.fn() }),
129
+ };
130
+ });
19
131
 
20
132
  afterEach(cleanup);
21
133
  beforeEach(() => {
@@ -1,7 +1,8 @@
1
1
  import { Box, Paper, Stack, Text } from "@mantine/core";
2
2
  import { mergeProjectedTurns, turnPresentation } from "../../state.js";
3
- import { turnUsageView } from "../presentation";
4
- import type { Task } from "../types";
3
+ import { turnReportedWorkView, turnUsageMetricsView } from "../presentation";
4
+ import type { Task, TaskTurn } from "../types";
5
+ import { useEffect, useState } from "react";
5
6
  import { RelativeTime } from "./RelativeTime";
6
7
  import { LinkedText } from "./LinkedText";
7
8
  import { ShimmerText } from "./ShimmerText";
@@ -16,7 +17,7 @@ export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef:
16
17
  const presentation = turnPresentation(turn);
17
18
  const identity = turn.turnRef ?? turn.turnId ?? `turn-${index}`;
18
19
  const highlighted = identity === highlightTurnRef;
19
- const usage = turnUsageView(task, turn);
20
+ const usage = turnUsageMetricsView(task, turn);
20
21
  return (
21
22
  <Paper
22
23
  className={`taskchef-turn${highlighted ? " taskchef-turn-highlighted" : ""}`}
@@ -30,7 +31,10 @@ export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef:
30
31
  <Stack gap={7}>
31
32
  <Box className="taskchef-turn-heading">
32
33
  <StatusBadge status={presentation.status} />
33
- <RelativeTime label="Turn update time" value={presentation.updatedAt} />
34
+ <Box className="taskchef-turn-timing">
35
+ <RelativeTime label="Turn update time" value={presentation.updatedAt} />
36
+ <TurnReportedWork turn={turn} />
37
+ </Box>
34
38
  </Box>
35
39
  {presentation.sourceLabel && <Text c="dimmed" size="xs">{presentation.sourceLabel}</Text>}
36
40
  <Box>
@@ -47,15 +51,23 @@ export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef:
47
51
  : <LinkedText task={task} text={presentation.summary} />}
48
52
  </Text>
49
53
  </Box>
50
- <Text
51
- c="dimmed"
52
- className={usage.kind === "calculating" || usage.kind === "pending" ? "taskchef-turn-usage" : undefined}
53
- size="xs"
54
- >
55
- {usage.kind === "calculating" || usage.kind === "pending"
56
- ? <ShimmerText>{usage.label}</ShimmerText>
57
- : usage.label}
58
- </Text>
54
+ <Box className="taskchef-turn-metrics">
55
+ <TurnMetric
56
+ accessibleLabel={usage.tokens.accessibleLabel}
57
+ animated={usage.animated}
58
+ label="Tokens"
59
+ title={usage.title}
60
+ value={usage.tokens.value}
61
+ />
62
+ <TurnMetric
63
+ accessibleLabel={usage.cost.accessibleLabel}
64
+ animated={usage.animated}
65
+ label="Estimated cost"
66
+ title={usage.title}
67
+ value={usage.cost.value}
68
+ />
69
+ </Box>
70
+ {usage.note && <Text c="dimmed" size="xs">{usage.note}</Text>}
59
71
  <Text c="dimmed" className="taskchef-mono" size="xs">Turn ref: {identity ?? "—"}</Text>
60
72
  </Stack>
61
73
  </Paper>
@@ -64,3 +76,54 @@ export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef:
64
76
  </Stack>
65
77
  );
66
78
  }
79
+
80
+ function TurnReportedWork({ turn }: { turn: TaskTurn }) {
81
+ const now = useLiveNow(turn.result === null);
82
+ const elapsed = turnReportedWorkView(turn, now);
83
+ return (
84
+ <Text
85
+ aria-label={elapsed.accessibleLabel}
86
+ c="dimmed"
87
+ className="taskchef-turn-duration"
88
+ size="xs"
89
+ title={elapsed.title}
90
+ >
91
+ <span className="taskchef-field-label">{elapsed.label}</span>
92
+ <span>{elapsed.value}</span>
93
+ </Text>
94
+ );
95
+ }
96
+
97
+ function TurnMetric({
98
+ accessibleLabel,
99
+ animated = false,
100
+ label,
101
+ title,
102
+ value,
103
+ }: {
104
+ accessibleLabel?: string;
105
+ animated?: boolean;
106
+ label: string;
107
+ title?: string;
108
+ value: string;
109
+ }) {
110
+ return (
111
+ <Box className="taskchef-turn-metric" title={title}>
112
+ <Text c="dimmed" className="taskchef-field-label" size="xs">{label}</Text>
113
+ <Text aria-label={accessibleLabel} c="dimmed" size="xs">
114
+ {animated ? <ShimmerText>{value}</ShimmerText> : value}
115
+ </Text>
116
+ </Box>
117
+ );
118
+ }
119
+
120
+ function useLiveNow(active: boolean) {
121
+ const [now, setNow] = useState(() => Date.now());
122
+ useEffect(() => {
123
+ if (!active) return undefined;
124
+ setNow(Date.now());
125
+ const interval = window.setInterval(() => setNow(Date.now()), 1_000);
126
+ return () => window.clearInterval(interval);
127
+ }, [active]);
128
+ return now;
129
+ }
@@ -1,10 +1,15 @@
1
1
  import { MantineProvider } from "@mantine/core";
2
- import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
2
+ import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
3
3
  import { useState } from "react";
4
4
  import { afterEach, expect, test, vi } from "vitest";
5
+ import { fixtureTask } from "../fixtures";
6
+ import { ActivityTimeline } from "./ActivityTimeline";
5
7
  import { ManualTransitionConfirmation, type TerminalStatus } from "./TaskDetail";
6
8
 
7
- afterEach(cleanup);
9
+ afterEach(() => {
10
+ cleanup();
11
+ vi.restoreAllMocks();
12
+ });
8
13
 
9
14
  test("clears a manual-transition confirmation only after success", async () => {
10
15
  const onTransition = vi.fn().mockResolvedValue({ ok: true });
@@ -44,6 +49,96 @@ test("reuses an action ID for retryable failures and rotates it after a stale ta
44
49
  expect(onTransition.mock.calls[2][1]).not.toBe(firstActionId);
45
50
  });
46
51
 
52
+ test("covers terminal, active, and unavailable timeline metrics", () => {
53
+ {
54
+ const task = fixtureTask({
55
+ status: "completed",
56
+ turns: [{
57
+ ...fixtureTask().turns![0],
58
+ result: {
59
+ status: "completed",
60
+ summary: "Done.",
61
+ updatedAt: "2026-08-30T08:18:32.000Z",
62
+ },
63
+ }],
64
+ usage: {
65
+ status: "available",
66
+ turns: { "turn-one": { status: "available", totalTokens: 330, estimatedCostUsd: 0.12 } },
67
+ },
68
+ });
69
+ const { container } = render(
70
+ <MantineProvider><ActivityTimeline highlightTurnRef={null} task={task} /></MantineProvider>,
71
+ );
72
+ const metrics = container.querySelector(".taskchef-turn-metrics") as HTMLElement;
73
+ const timing = container.querySelector(".taskchef-turn-timing") as HTMLElement;
74
+ expect(metrics).not.toBeNull();
75
+ expect(timing).not.toBeNull();
76
+ expect(within(metrics).getByText("Tokens").nextSibling).toHaveTextContent("330");
77
+ expect(within(metrics).getByText("Estimated cost").nextSibling).toHaveTextContent("$0.12");
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");
82
+ cleanup();
83
+ }
84
+
85
+ {
86
+ let now = Date.parse("2026-08-30T08:18:32.000Z");
87
+ let tick: (() => void) | undefined;
88
+ vi.spyOn(Date, "now").mockImplementation(() => now);
89
+ vi.spyOn(window, "setInterval").mockImplementation(((handler: TimerHandler) => {
90
+ tick = handler as () => void;
91
+ return 1;
92
+ }) as typeof window.setInterval);
93
+ vi.spyOn(window, "clearInterval").mockImplementation(() => undefined);
94
+ const task = fixtureTask();
95
+ render(<MantineProvider><ActivityTimeline highlightTurnRef={null} task={task} /></MantineProvider>);
96
+ expect(screen.getByLabelText(/current turn reported wall-clock elapsed time/i))
97
+ .toHaveTextContent("Elapsed so far18m 32s");
98
+ expect(screen.getByText("Tokens").nextSibling).toHaveTextContent("Pending");
99
+ expect(screen.getByText("Estimated cost").nextSibling).toHaveTextContent("Pending");
100
+
101
+ act(() => {
102
+ now += 2_000;
103
+ tick?.();
104
+ });
105
+
106
+ expect(screen.getByLabelText(/current turn reported wall-clock elapsed time/i))
107
+ .toHaveTextContent("Elapsed so far18m 34s");
108
+ cleanup();
109
+ vi.restoreAllMocks();
110
+ }
111
+
112
+ {
113
+ const task = fixtureTask({
114
+ status: "completed",
115
+ turns: [{
116
+ ...fixtureTask().turns![0],
117
+ startedAt: "2026-08-30T08:18:33.000Z",
118
+ result: {
119
+ status: "completed",
120
+ summary: "Done.",
121
+ updatedAt: "2026-08-30T08:18:32.000Z",
122
+ },
123
+ }],
124
+ usage: {
125
+ status: "unavailable",
126
+ turns: {
127
+ "turn-one": {
128
+ status: "unavailable",
129
+ reason: "Manual dashboard turns do not have usage boundaries.",
130
+ },
131
+ },
132
+ },
133
+ });
134
+ render(<MantineProvider><ActivityTimeline highlightTurnRef={null} task={task} /></MantineProvider>);
135
+ expect(screen.getByLabelText(/elapsed unavailable.*reported wall-clock/i))
136
+ .toHaveTextContent("ElapsedUnavailable");
137
+ expect(screen.getByText("Manual dashboard turns do not have usage boundaries.")).toBeVisible();
138
+ expect(screen.getByLabelText(/tokens unavailable: manual dashboard turns/i)).toBeVisible();
139
+ }
140
+ });
141
+
47
142
  function ConfirmationHarness({ onTransition }: { onTransition: (status: TerminalStatus, actionId: string) => Promise<{ ok: boolean; rotateActionId?: boolean }> }) {
48
143
  const [opened, setOpened] = useState(true);
49
144
  return (
@@ -1,73 +1,91 @@
1
- import { Box, Group, Paper, Text } from "@mantine/core";
2
- import { IconCoins } from "@tabler/icons-react";
1
+ import { Box, Paper, Text } from "@mantine/core";
3
2
  import type { Task } from "../types";
4
3
  import {
5
4
  formatCompactTokens,
6
5
  formatEstimatedCost,
7
6
  formatFullTokens,
7
+ taskReportedWorkView,
8
8
  usageView,
9
9
  } from "../presentation";
10
10
  import { ShimmerText } from "./ShimmerText";
11
11
 
12
12
  export function UsagePanel({ task }: { task: Task }) {
13
13
  const usage = usageView(task);
14
- if (usage.kind === "pending" || usage.kind === "calculating") {
15
- return (
16
- <Paper aria-live="polite" className="taskchef-subtle-panel" p="sm" radius="md">
17
- <Group gap="xs">
18
- <IconCoins aria-hidden size={16} />
19
- <Text size="sm"><ShimmerText>{usage.label}</ShimmerText></Text>
20
- </Group>
21
- </Paper>
22
- );
23
- }
24
- if (usage.kind === "unavailable") {
25
- return <Text aria-live="polite" c="dimmed" size="sm">{usage.label}</Text>;
26
- }
27
- const cached = usage.usage.cachedInputTokens ?? 0;
28
- const input = usage.usage.inputTokens ?? 0;
29
- const cacheRatio = input + cached > 0 ? `${Math.round((cached / (input + cached)) * 100)}%` : "—";
14
+ const reportedWork = taskReportedWorkView(task);
15
+ const ready = usage.kind === "ready" ? usage : null;
16
+ const cached = ready?.usage.cachedInputTokens ?? 0;
17
+ const input = ready?.usage.inputTokens ?? 0;
18
+ const cacheRatio = ready && input + cached > 0
19
+ ? `${Math.round((cached / (input + cached)) * 100)}%`
20
+ : "—";
21
+ const stateValue = usage.kind === "pending"
22
+ ? "Pending"
23
+ : usage.kind === "calculating"
24
+ ? "Calculating"
25
+ : "Unavailable";
30
26
  const values: Array<{
31
27
  accessibleValue?: string;
28
+ animated?: boolean;
32
29
  label: string;
33
30
  title?: string;
34
31
  value: string;
35
32
  }> = [
36
33
  {
37
- accessibleValue: `${formatFullTokens(usage.usage.totalTokens)} tokens`,
38
- label: usage.knownSoFar ? "Tokens · known so far" : "Tokens",
39
- title: `${formatFullTokens(usage.usage.totalTokens)} tokens`,
40
- value: formatCompactTokens(usage.usage.totalTokens),
34
+ accessibleValue: ready
35
+ ? `${formatFullTokens(ready.usage.totalTokens)} tokens`
36
+ : usage.label,
37
+ animated: usage.kind === "pending" || usage.kind === "calculating",
38
+ label: ready?.knownSoFar ? "Tokens · known so far" : "Tokens",
39
+ title: ready ? `${formatFullTokens(ready.usage.totalTokens)} tokens` : usage.label,
40
+ value: ready ? formatCompactTokens(ready.usage.totalTokens) : stateValue,
41
41
  },
42
42
  {
43
- accessibleValue: usage.usage.estimatedCostUsd == null
44
- ? "Estimated cost unavailable"
45
- : `Estimated cost ${formatEstimatedCost(usage.usage.estimatedCostUsd)}`,
43
+ accessibleValue: ready
44
+ ? ready.usage.estimatedCostUsd == null
45
+ ? "Estimated cost unavailable"
46
+ : `Estimated cost ${formatEstimatedCost(ready.usage.estimatedCostUsd)}`
47
+ : `Estimated cost ${stateValue.toLowerCase()}${
48
+ usage.kind === "unavailable" ? `: ${usage.label}` : ""
49
+ }`,
50
+ animated: usage.kind === "pending" || usage.kind === "calculating",
46
51
  label: "Estimated cost",
47
- title: usage.usage.estimatedCostUsd == null
48
- ? undefined
49
- : `Unrounded estimate: $${usage.usage.estimatedCostUsd}`,
50
- value: formatEstimatedCost(usage.usage.estimatedCostUsd),
52
+ title: ready?.usage.estimatedCostUsd == null
53
+ ? usage.label
54
+ : `Unrounded estimate: $${ready.usage.estimatedCostUsd}`,
55
+ value: ready ? formatEstimatedCost(ready.usage.estimatedCostUsd) : stateValue,
51
56
  },
52
- { label: "Model", value: Object.keys(usage.usage.models ?? {}).join(", ") || "—" },
53
- { label: "Cache ratio", value: cacheRatio },
57
+ {
58
+ accessibleValue: reportedWork.accessibleLabel,
59
+ label: "Total reported work",
60
+ title: reportedWork.title,
61
+ value: reportedWork.value,
62
+ },
63
+ ...(ready ? [
64
+ { label: "Model", value: Object.keys(ready.usage.models ?? {}).join(", ") || "—" },
65
+ { label: "Cache ratio", value: cacheRatio },
66
+ ] : []),
54
67
  ];
55
68
  return (
56
- <Box aria-live="polite" className="taskchef-metrics">
57
- {values.map(({ accessibleValue, label, title, value }) => (
58
- <Paper className="taskchef-metric" key={label} p="sm" radius="md" withBorder>
59
- <Text c="dimmed" size="xs">{label}</Text>
60
- <Text
61
- aria-label={accessibleValue}
62
- className="taskchef-metric-value"
63
- fw={650}
64
- size="sm"
65
- title={title}
66
- >
67
- {value}
68
- </Text>
69
- </Paper>
70
- ))}
69
+ <Box aria-live="polite">
70
+ <Box className="taskchef-metrics">
71
+ {values.map(({ accessibleValue, animated, label, title, value }) => (
72
+ <Paper className="taskchef-metric" key={label} p="sm" radius="md" withBorder>
73
+ <Text c="dimmed" size="xs">{label}</Text>
74
+ <Text
75
+ aria-label={accessibleValue}
76
+ className="taskchef-metric-value"
77
+ fw={650}
78
+ size="sm"
79
+ title={title}
80
+ >
81
+ {animated ? <ShimmerText>{value}</ShimmerText> : value}
82
+ </Text>
83
+ </Paper>
84
+ ))}
85
+ </Box>
86
+ {usage.kind === "unavailable" && (
87
+ <Text c="dimmed" mt="xs" size="xs">{usage.label}</Text>
88
+ )}
71
89
  </Box>
72
90
  );
73
91
  }