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,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" : ""}`}
@@ -47,15 +48,24 @@ export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef:
47
48
  : <LinkedText task={task} text={presentation.summary} />}
48
49
  </Text>
49
50
  </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>
51
+ <Box className="taskchef-turn-metrics">
52
+ <TurnMetric
53
+ accessibleLabel={usage.tokens.accessibleLabel}
54
+ animated={usage.animated}
55
+ label="Tokens"
56
+ title={usage.title}
57
+ value={usage.tokens.value}
58
+ />
59
+ <TurnMetric
60
+ accessibleLabel={usage.cost.accessibleLabel}
61
+ animated={usage.animated}
62
+ label="Estimated cost"
63
+ title={usage.title}
64
+ value={usage.cost.value}
65
+ />
66
+ <TurnReportedWorkMetric turn={turn} />
67
+ </Box>
68
+ {usage.note && <Text c="dimmed" size="xs">{usage.note}</Text>}
59
69
  <Text c="dimmed" className="taskchef-mono" size="xs">Turn ref: {identity ?? "—"}</Text>
60
70
  </Stack>
61
71
  </Paper>
@@ -64,3 +74,50 @@ export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef:
64
74
  </Stack>
65
75
  );
66
76
  }
77
+
78
+ function TurnReportedWorkMetric({ turn }: { turn: TaskTurn }) {
79
+ const now = useLiveNow(turn.result === null);
80
+ const elapsed = turnReportedWorkView(turn, now);
81
+ return (
82
+ <TurnMetric
83
+ accessibleLabel={elapsed.accessibleLabel}
84
+ label={elapsed.label}
85
+ title={elapsed.title}
86
+ value={elapsed.value}
87
+ />
88
+ );
89
+ }
90
+
91
+ function TurnMetric({
92
+ accessibleLabel,
93
+ animated = false,
94
+ label,
95
+ title,
96
+ value,
97
+ }: {
98
+ accessibleLabel?: string;
99
+ animated?: boolean;
100
+ label: string;
101
+ title?: string;
102
+ value: string;
103
+ }) {
104
+ return (
105
+ <Box className="taskchef-turn-metric" title={title}>
106
+ <Text c="dimmed" className="taskchef-field-label" size="xs">{label}</Text>
107
+ <Text aria-label={accessibleLabel} c="dimmed" size="xs">
108
+ {animated ? <ShimmerText>{value}</ShimmerText> : value}
109
+ </Text>
110
+ </Box>
111
+ );
112
+ }
113
+
114
+ function useLiveNow(active: boolean) {
115
+ const [now, setNow] = useState(() => Date.now());
116
+ useEffect(() => {
117
+ if (!active) return undefined;
118
+ setNow(Date.now());
119
+ const interval = window.setInterval(() => setNow(Date.now()), 1_000);
120
+ return () => window.clearInterval(interval);
121
+ }, [active]);
122
+ return now;
123
+ }
@@ -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,90 @@ 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
+ expect(metrics).not.toBeNull();
74
+ expect(within(metrics).getByText("Tokens").nextSibling).toHaveTextContent("330");
75
+ expect(within(metrics).getByText("Estimated cost").nextSibling).toHaveTextContent("$0.12");
76
+ expect(within(metrics).getByText("Elapsed").nextSibling).toHaveTextContent("18m 32s");
77
+ expect(within(metrics).getByLabelText(/completed turn reported wall-clock elapsed time/i)).toBeVisible();
78
+ cleanup();
79
+ }
80
+
81
+ {
82
+ let now = Date.parse("2026-08-30T08:18:32.000Z");
83
+ let tick: (() => void) | undefined;
84
+ vi.spyOn(Date, "now").mockImplementation(() => now);
85
+ vi.spyOn(window, "setInterval").mockImplementation(((handler: TimerHandler) => {
86
+ tick = handler as () => void;
87
+ return 1;
88
+ }) as typeof window.setInterval);
89
+ vi.spyOn(window, "clearInterval").mockImplementation(() => undefined);
90
+ const task = fixtureTask();
91
+ render(<MantineProvider><ActivityTimeline highlightTurnRef={null} task={task} /></MantineProvider>);
92
+ expect(screen.getByText("Elapsed so far").nextSibling).toHaveTextContent("18m 32s");
93
+ expect(screen.getByText("Tokens").nextSibling).toHaveTextContent("Pending");
94
+ expect(screen.getByText("Estimated cost").nextSibling).toHaveTextContent("Pending");
95
+
96
+ act(() => {
97
+ now += 2_000;
98
+ tick?.();
99
+ });
100
+
101
+ expect(screen.getByText("Elapsed so far").nextSibling).toHaveTextContent("18m 34s");
102
+ cleanup();
103
+ vi.restoreAllMocks();
104
+ }
105
+
106
+ {
107
+ const task = fixtureTask({
108
+ status: "completed",
109
+ turns: [{
110
+ ...fixtureTask().turns![0],
111
+ startedAt: "2026-08-30T08:18:33.000Z",
112
+ result: {
113
+ status: "completed",
114
+ summary: "Done.",
115
+ updatedAt: "2026-08-30T08:18:32.000Z",
116
+ },
117
+ }],
118
+ usage: {
119
+ status: "unavailable",
120
+ turns: {
121
+ "turn-one": {
122
+ status: "unavailable",
123
+ reason: "Manual dashboard turns do not have usage boundaries.",
124
+ },
125
+ },
126
+ },
127
+ });
128
+ render(<MantineProvider><ActivityTimeline highlightTurnRef={null} task={task} /></MantineProvider>);
129
+ expect(screen.getByText("Elapsed").nextSibling).toHaveTextContent("Unavailable");
130
+ expect(screen.getByLabelText(/elapsed unavailable.*reported wall-clock/i)).toBeVisible();
131
+ expect(screen.getByText("Manual dashboard turns do not have usage boundaries.")).toBeVisible();
132
+ expect(screen.getByLabelText(/tokens unavailable: manual dashboard turns/i)).toBeVisible();
133
+ }
134
+ });
135
+
47
136
  function ConfirmationHarness({ onTransition }: { onTransition: (status: TerminalStatus, actionId: string) => Promise<{ ok: boolean; rotateActionId?: boolean }> }) {
48
137
  const [opened, setOpened] = useState(true);
49
138
  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
  }