taskchef 8.1.0 → 8.2.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,10 @@
1
1
  import { Box, Paper, Stack, Text } from "@mantine/core";
2
+ import {
3
+ IconCurrencyDollar,
4
+ IconHistory,
5
+ IconHourglass,
6
+ IconStack2,
7
+ } from "@tabler/icons-react";
2
8
  import { mergeProjectedTurns, turnPresentation } from "../../state.js";
3
9
  import { turnReportedWorkView, turnUsageMetricsView } from "../presentation";
4
10
  import type { Task, TaskTurn } from "../types";
@@ -6,6 +12,7 @@ import { useEffect, useState } from "react";
6
12
  import { RelativeTime } from "./RelativeTime";
7
13
  import { LinkedText } from "./LinkedText";
8
14
  import { ShimmerText } from "./ShimmerText";
15
+ import { StatCell, StaticStat, StatsGrid } from "./StatsGrid";
9
16
  import { StatusBadge } from "./StatusBadge";
10
17
 
11
18
  export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef: string | null; task: Task }) {
@@ -29,13 +36,7 @@ export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef:
29
36
  withBorder
30
37
  >
31
38
  <Stack gap={7}>
32
- <Box className="taskchef-turn-heading">
33
- <StatusBadge status={presentation.status} />
34
- <Box className="taskchef-turn-timing">
35
- <RelativeTime label="Turn update time" value={presentation.updatedAt} />
36
- <TurnReportedWork turn={turn} />
37
- </Box>
38
- </Box>
39
+ <StatusBadge status={presentation.status} />
39
40
  {presentation.sourceLabel && <Text c="dimmed" size="xs">{presentation.sourceLabel}</Text>}
40
41
  <Box>
41
42
  <Text c="dimmed" className="taskchef-field-label" size="xs">Request</Text>
@@ -51,22 +52,36 @@ export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef:
51
52
  : <LinkedText task={task} text={presentation.summary} />}
52
53
  </Text>
53
54
  </Box>
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>
55
+ <StatsGrid variant="turn">
56
+ <StatCell>
57
+ <RelativeTime
58
+ icon={<IconHistory aria-hidden size={14} />}
59
+ label="Turn update time"
60
+ value={presentation.updatedAt}
61
+ />
62
+ </StatCell>
63
+ <StatCell>
64
+ <TurnReportedWork turn={turn} />
65
+ </StatCell>
66
+ <StatCell usageState={usage.kind}>
67
+ <StaticStat
68
+ accessibleLabel={usage.tokens.accessibleLabel}
69
+ animated={usage.animated}
70
+ icon={<IconStack2 aria-hidden size={14} />}
71
+ title={usage.title}
72
+ value={usage.tokens.value}
73
+ />
74
+ </StatCell>
75
+ <StatCell usageState={usage.kind}>
76
+ <StaticStat
77
+ accessibleLabel={usage.cost.accessibleLabel}
78
+ animated={usage.animated}
79
+ icon={<IconCurrencyDollar aria-hidden size={14} />}
80
+ title={usage.title}
81
+ value={usage.cost.value}
82
+ />
83
+ </StatCell>
84
+ </StatsGrid>
70
85
  {usage.note && <Text c="dimmed" size="xs">{usage.note}</Text>}
71
86
  <Text c="dimmed" className="taskchef-mono" size="xs">Turn ref: {identity ?? "—"}</Text>
72
87
  </Stack>
@@ -81,39 +96,12 @@ function TurnReportedWork({ turn }: { turn: TaskTurn }) {
81
96
  const now = useLiveNow(turn.result === null);
82
97
  const elapsed = turnReportedWorkView(turn, now);
83
98
  return (
84
- <Text
85
- aria-label={elapsed.accessibleLabel}
86
- c="dimmed"
87
- className="taskchef-turn-duration"
88
- size="xs"
99
+ <StaticStat
100
+ accessibleLabel={elapsed.accessibleLabel}
101
+ icon={<IconHourglass aria-hidden size={14} />}
89
102
  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>
103
+ value={elapsed.value}
104
+ />
117
105
  );
118
106
  }
119
107
 
@@ -0,0 +1,60 @@
1
+ import { Box, Text, Tooltip } from "@mantine/core";
2
+ import type { ReactNode } from "react";
3
+ import { ShimmerText } from "./ShimmerText";
4
+
5
+ export function StatsGrid({
6
+ children,
7
+ variant,
8
+ }: {
9
+ children: ReactNode;
10
+ variant: "card" | "turn";
11
+ }) {
12
+ return (
13
+ <Box className={`taskchef-stats taskchef-${variant}-stats`} component="div">
14
+ {children}
15
+ </Box>
16
+ );
17
+ }
18
+
19
+ export function StatCell({
20
+ children,
21
+ usageState,
22
+ }: {
23
+ children: ReactNode;
24
+ usageState?: string;
25
+ }) {
26
+ return (
27
+ <Box className="taskchef-stat" data-usage-state={usageState}>
28
+ {children}
29
+ </Box>
30
+ );
31
+ }
32
+
33
+ export function StaticStat({
34
+ accessibleLabel,
35
+ animated = false,
36
+ icon,
37
+ title,
38
+ value,
39
+ }: {
40
+ accessibleLabel: string;
41
+ animated?: boolean;
42
+ icon: ReactNode;
43
+ title: string;
44
+ value: string;
45
+ }) {
46
+ return (
47
+ <Tooltip events={{ focus: true, hover: true, touch: false }} label={title} multiline>
48
+ <Text
49
+ aria-label={accessibleLabel}
50
+ className="taskchef-stat-content"
51
+ component="span"
52
+ size="xs"
53
+ tabIndex={0}
54
+ >
55
+ {icon}
56
+ <bdi>{animated ? <ShimmerText>{value}</ShimmerText> : value}</bdi>
57
+ </Text>
58
+ </Tooltip>
59
+ );
60
+ }
@@ -34,7 +34,8 @@ describe("task card stats", () => {
34
34
  expect(within(cells[0]).getByLabelText(/updated time/i)).toBeVisible();
35
35
  expect(within(cells[1]).getByLabelText(/reported work 18m 32s/i)).toBeVisible();
36
36
  expect(within(cells[2]).getByLabelText(/1,324,567 tokens/i)).toHaveTextContent("1.32M tokens");
37
- expect(within(cells[3]).getByLabelText(/estimated cost \$12\.34/i)).toHaveTextContent("est. $12.34");
37
+ expect(within(cells[3]).getByLabelText(/estimated cost \$12\.34/i)).toHaveTextContent("12.34");
38
+ expect(within(cells[3]).getByLabelText(/estimated cost \$12\.34/i)).not.toHaveTextContent("$");
38
39
  expect(within(cells[2]).getByRole("status")).toHaveTextContent(/1,324,567 tokens.*estimated cost \$12\.34/i);
39
40
  });
40
41
 
@@ -44,7 +45,7 @@ describe("task card stats", () => {
44
45
  usage: { status: "available", task: { totalTokens: 1_324_567, estimatedCostUsd: null } },
45
46
  }));
46
47
  expect(screen.getByLabelText(/1,324,567 tokens/i)).toHaveTextContent("1.32M tokens");
47
- expect(screen.getByLabelText(/estimated cost unavailable/i)).toHaveTextContent("est. n/a");
48
+ expect(screen.getByLabelText(/estimated cost unavailable/i)).toHaveTextContent("n/a");
48
49
  });
49
50
 
50
51
  test("shows metric details to keyboard users on focus", () => {
@@ -78,7 +79,7 @@ describe("task card stats", () => {
78
79
  usage: { status: "unavailable", reason: "No matching cached boundary." },
79
80
  })} /></MantineProvider>);
80
81
  expect(screen.getByLabelText(/token usage unavailable: no matching/i)).toHaveTextContent("n/a tokens");
81
- expect(screen.getByLabelText(/estimated cost unavailable: no matching/i)).toHaveTextContent("est. n/a");
82
+ expect(screen.getByLabelText(/estimated cost unavailable: no matching/i)).toHaveTextContent("n/a");
82
83
  });
83
84
 
84
85
  test("retains known totals and marks both usage values as updating", () => {
@@ -92,7 +93,7 @@ describe("task card stats", () => {
92
93
  },
93
94
  }));
94
95
  expect(screen.getByLabelText(/1,324,567 tokens; updating/i)).toHaveTextContent("1.32M tokens · Updating…");
95
- expect(screen.getByLabelText(/estimated cost \$12\.34; updating/i)).toHaveTextContent("est. $12.34 · Updating…");
96
+ expect(screen.getByLabelText(/estimated cost \$12\.34; updating/i)).toHaveTextContent("12.34 · Updating…");
96
97
  });
97
98
 
98
99
  test("places Open chat beneath the status badge and keeps callbacks working", () => {
@@ -1,66 +1,36 @@
1
- import { Box, Text, Tooltip } from "@mantine/core";
1
+ import { Text } from "@mantine/core";
2
2
  import {
3
3
  IconCurrencyDollar,
4
4
  IconHistory,
5
5
  IconHourglass,
6
6
  IconStack2,
7
7
  } from "@tabler/icons-react";
8
- import type { ReactNode } from "react";
9
8
  import type { Task } from "../types";
10
9
  import { listUsageMetricsView, taskReportedWorkView } from "../presentation";
11
10
  import { RelativeTime } from "./RelativeTime";
12
- import { ShimmerText } from "./ShimmerText";
13
-
14
- function StaticStat({
15
- accessibleLabel,
16
- animated = false,
17
- icon,
18
- title,
19
- value,
20
- }: {
21
- accessibleLabel: string;
22
- animated?: boolean;
23
- icon: ReactNode;
24
- title: string;
25
- value: string;
26
- }) {
27
- return (
28
- <Tooltip events={{ focus: true, hover: true, touch: false }} label={title} multiline>
29
- <Text
30
- aria-label={accessibleLabel}
31
- className="taskchef-card-stat-content"
32
- component="span"
33
- size="xs"
34
- tabIndex={0}
35
- >
36
- {icon}
37
- <bdi>{animated ? <ShimmerText>{value}</ShimmerText> : value}</bdi>
38
- </Text>
39
- </Tooltip>
40
- );
41
- }
11
+ import { StatCell, StaticStat, StatsGrid } from "./StatsGrid";
42
12
 
43
13
  export function TaskCardStats({ task }: { task: Task }) {
44
14
  const reportedWork = taskReportedWorkView(task);
45
15
  const usage = listUsageMetricsView(task);
46
16
  return (
47
- <Box className="taskchef-card-stats" component="div">
48
- <Box className="taskchef-card-stat">
17
+ <StatsGrid variant="card">
18
+ <StatCell>
49
19
  <RelativeTime
50
20
  icon={<IconHistory aria-hidden size={14} />}
51
21
  label={`Updated time for ${task.title}`}
52
22
  value={task.meaningfulUpdatedAt ?? task.updatedAt}
53
23
  />
54
- </Box>
55
- <Box className="taskchef-card-stat">
24
+ </StatCell>
25
+ <StatCell>
56
26
  <StaticStat
57
27
  accessibleLabel={reportedWork.accessibleLabel}
58
28
  icon={<IconHourglass aria-hidden size={14} />}
59
29
  title={reportedWork.title}
60
30
  value={reportedWork.value}
61
31
  />
62
- </Box>
63
- <Box className="taskchef-card-stat" data-usage-state={usage.kind}>
32
+ </StatCell>
33
+ <StatCell usageState={usage.kind}>
64
34
  <StaticStat
65
35
  accessibleLabel={usage.tokens.accessibleLabel}
66
36
  animated={usage.animated}
@@ -71,8 +41,8 @@ export function TaskCardStats({ task }: { task: Task }) {
71
41
  <Text className="taskchef-visually-hidden" component="span" role="status">
72
42
  {usage.tokens.accessibleLabel}; {usage.cost.accessibleLabel}
73
43
  </Text>
74
- </Box>
75
- <Box className="taskchef-card-stat" data-usage-state={usage.kind}>
44
+ </StatCell>
45
+ <StatCell usageState={usage.kind}>
76
46
  <StaticStat
77
47
  accessibleLabel={usage.cost.accessibleLabel}
78
48
  animated={usage.animated}
@@ -80,7 +50,7 @@ export function TaskCardStats({ task }: { task: Task }) {
80
50
  title={usage.cost.title}
81
51
  value={usage.cost.value}
82
52
  />
83
- </Box>
84
- </Box>
53
+ </StatCell>
54
+ </StatsGrid>
85
55
  );
86
56
  }
@@ -69,16 +69,20 @@ test("covers terminal, active, and unavailable timeline metrics", () => {
69
69
  const { container } = render(
70
70
  <MantineProvider><ActivityTimeline highlightTurnRef={null} task={task} /></MantineProvider>,
71
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");
72
+ const stats = container.querySelector(".taskchef-turn-stats") as HTMLElement;
73
+ expect(stats).not.toBeNull();
74
+ const cells = [...stats.children] as HTMLElement[];
75
+ expect(cells).toHaveLength(4);
76
+ expect(within(cells[0]).getByLabelText(/^Turn update time:/i)).toBeVisible();
77
+ expect(cells[0].querySelector(".tabler-icon-history")).not.toBeNull();
78
+ expect(within(cells[1]).getByLabelText(/completed turn reported wall-clock elapsed time/i))
79
+ .toHaveTextContent("18m 32s");
80
+ expect(cells[1].querySelector(".tabler-icon-hourglass")).not.toBeNull();
81
+ expect(within(cells[2]).getByLabelText("330 tokens")).toHaveTextContent("330");
82
+ expect(cells[2].querySelector(".tabler-icon-stack-2")).not.toBeNull();
83
+ expect(within(cells[3]).getByLabelText("Estimated cost $0.12")).toHaveTextContent("0.12");
84
+ expect(within(cells[3]).getByLabelText("Estimated cost $0.12")).not.toHaveTextContent("$");
85
+ expect(cells[3].querySelector(".tabler-icon-currency-dollar")).not.toBeNull();
82
86
  cleanup();
83
87
  }
84
88
 
@@ -94,9 +98,9 @@ test("covers terminal, active, and unavailable timeline metrics", () => {
94
98
  const task = fixtureTask();
95
99
  render(<MantineProvider><ActivityTimeline highlightTurnRef={null} task={task} /></MantineProvider>);
96
100
  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");
101
+ .toHaveTextContent("18m 32s");
102
+ expect(screen.getByLabelText("Tokens pending")).toHaveTextContent("Pending");
103
+ expect(screen.getByLabelText("Estimated cost pending")).toHaveTextContent("Pending");
100
104
 
101
105
  act(() => {
102
106
  now += 2_000;
@@ -104,7 +108,7 @@ test("covers terminal, active, and unavailable timeline metrics", () => {
104
108
  });
105
109
 
106
110
  expect(screen.getByLabelText(/current turn reported wall-clock elapsed time/i))
107
- .toHaveTextContent("Elapsed so far18m 34s");
111
+ .toHaveTextContent("18m 34s");
108
112
  cleanup();
109
113
  vi.restoreAllMocks();
110
114
  }
@@ -133,7 +137,7 @@ test("covers terminal, active, and unavailable timeline metrics", () => {
133
137
  });
134
138
  render(<MantineProvider><ActivityTimeline highlightTurnRef={null} task={task} /></MantineProvider>);
135
139
  expect(screen.getByLabelText(/elapsed unavailable.*reported wall-clock/i))
136
- .toHaveTextContent("Elapsedn/a");
140
+ .toHaveTextContent("n/a");
137
141
  expect(screen.getByText("Manual dashboard turns do not have usage boundaries.")).toBeVisible();
138
142
  expect(screen.getByLabelText(/tokens unavailable: manual dashboard turns/i)).toBeVisible();
139
143
  }
@@ -127,6 +127,13 @@ export function formatEstimatedCost(value: number | null | undefined, locales?:
127
127
  }).format(value);
128
128
  }
129
129
 
130
+ function formatEstimatedCostAmount(value: number, locales?: Intl.LocalesArgument) {
131
+ return new Intl.NumberFormat(locales, {
132
+ maximumFractionDigits: 2,
133
+ minimumFractionDigits: 2,
134
+ }).format(value);
135
+ }
136
+
130
137
  export type UsageView =
131
138
  | { kind: "pending"; label: "Token usage pending" }
132
139
  | { kind: "calculating"; label: "Calculating token usage" }
@@ -194,13 +201,14 @@ export function turnUsageMetricsView(task: Task, turn: NonNullable<Task["turns"]
194
201
  const identity = turn.turnRef ?? turn.turnId;
195
202
  const usage = identity ? task.usage?.turns?.[identity] : null;
196
203
  const tokens = typeof usage?.totalTokens === "number" ? formatFullTokens(usage.totalTokens) : NOT_AVAILABLE;
197
- const cost = usage?.estimatedCostUsd == null
198
- ? NOT_AVAILABLE
199
- : formatEstimatedCost(usage.estimatedCostUsd);
204
+ const estimatedCost = usage?.estimatedCostUsd;
205
+ const hasCost = typeof estimatedCost === "number" && Number.isFinite(estimatedCost);
206
+ const cost = hasCost ? formatEstimatedCostAmount(estimatedCost) : NOT_AVAILABLE;
207
+ const accessibleCost = hasCost ? formatEstimatedCost(estimatedCost).toLowerCase() : "unavailable";
200
208
  return {
201
209
  animated: false,
202
210
  cost: {
203
- accessibleLabel: `Estimated cost ${usage?.estimatedCostUsd == null ? "unavailable" : cost.toLowerCase()}`,
211
+ accessibleLabel: `Estimated cost ${accessibleCost}`,
204
212
  value: cost,
205
213
  },
206
214
  kind: view.kind,
@@ -276,7 +284,7 @@ export function listUsageMetricsView(task: Task): ListUsageMetricsView {
276
284
  cost: {
277
285
  accessibleLabel: `Estimated cost unavailable${reason}`,
278
286
  title: view.label,
279
- value: "est. n/a",
287
+ value: NOT_AVAILABLE,
280
288
  },
281
289
  kind: "unavailable",
282
290
  tokens: {
@@ -288,9 +296,10 @@ export function listUsageMetricsView(task: Task): ListUsageMetricsView {
288
296
  }
289
297
  const compactTokens = formatCompactTokens(view.usage.totalTokens);
290
298
  const fullTokens = formatFullTokens(view.usage.totalTokens);
291
- const hasCost = typeof view.usage.estimatedCostUsd === "number"
292
- && Number.isFinite(view.usage.estimatedCostUsd);
293
- const formattedCost = hasCost ? formatEstimatedCost(view.usage.estimatedCostUsd) : null;
299
+ const estimatedCost = view.usage.estimatedCostUsd;
300
+ const hasCost = typeof estimatedCost === "number" && Number.isFinite(estimatedCost);
301
+ const formattedCost = hasCost ? formatEstimatedCost(estimatedCost) : null;
302
+ const formattedCostAmount = hasCost ? formatEstimatedCostAmount(estimatedCost) : null;
294
303
  const qualifier = view.knownSoFar ? " · Updating…" : "";
295
304
  const accessibleQualifier = view.knownSoFar ? "; updating" : "";
296
305
  const freshness = view.usage.sourceUpdatedAt ?? view.usage.sampledAt ?? task.usage?.updatedAt ?? null;
@@ -304,7 +313,7 @@ export function listUsageMetricsView(task: Task): ListUsageMetricsView {
304
313
  title: `${formattedCost === null
305
314
  ? "Estimated cost unavailable"
306
315
  : `Unrounded estimate: $${view.usage.estimatedCostUsd}`}${qualifier}.${freshnessLabel}`,
307
- value: `${formattedCost === null ? "est. n/a" : `est. ${formattedCost}`}${qualifier}`,
316
+ value: `${formattedCostAmount ?? NOT_AVAILABLE}${qualifier}`,
308
317
  },
309
318
  kind: "ready",
310
319
  tokens: {
@@ -56,15 +56,15 @@ body {
56
56
  .taskchef-github-group { min-width: 0; }
57
57
  .taskchef-github-reference-ambiguous { border-bottom: 2px dotted var(--mantine-color-yellow-7); cursor: help; }
58
58
  .taskchef-github-reference-ambiguous:focus-visible { outline: 2px solid var(--mantine-color-yellow-7); outline-offset: 2px; }
59
- .taskchef-time, .taskchef-card-stat-content { display: inline-flex; align-items: center; gap: 6px; width: fit-content; max-width: 100%; padding: 3px 4px; 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
+ .taskchef-time, .taskchef-stat-content { display: inline-flex; align-items: center; gap: 6px; width: fit-content; max-width: 100%; padding: 3px 4px; 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; }
60
60
  .taskchef-time:hover { color: var(--taskchef-text); background: var(--taskchef-surface-muted); }
61
61
  .taskchef-time:focus-visible { outline: 2px solid var(--mantine-color-teal-6); outline-offset: 2px; }
62
- .taskchef-time-label, .taskchef-card-stat-content bdi { min-width: 0; overflow-wrap: anywhere; unicode-bidi: isolate; }
63
- .taskchef-card-stat-content:focus-visible { outline: 2px solid var(--mantine-color-teal-6); outline-offset: 2px; }
64
- .taskchef-card-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-top: 2px; }
65
- .taskchef-card-stat { display: flex; align-items: center; min-width: 0; padding: 4px 10px; }
66
- .taskchef-card-stat:first-child { padding-inline-start: 0; }
67
- .taskchef-card-stat + .taskchef-card-stat { border-inline-start: 1px solid var(--taskchef-border); }
62
+ .taskchef-time-label, .taskchef-stat-content bdi { min-width: 0; overflow-wrap: anywhere; unicode-bidi: isolate; }
63
+ .taskchef-stat-content:focus-visible { outline: 2px solid var(--mantine-color-teal-6); outline-offset: 2px; }
64
+ .taskchef-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-top: 2px; }
65
+ .taskchef-stat { display: flex; align-items: center; min-width: 0; padding: 4px 10px; }
66
+ .taskchef-stat:first-child { padding-inline-start: 0; }
67
+ .taskchef-stat + .taskchef-stat { border-inline-start: 1px solid var(--taskchef-border); }
68
68
  .taskchef-empty { border-style: dashed; background: transparent; }
69
69
 
70
70
  .taskchef-notifications {
@@ -84,13 +84,8 @@ body {
84
84
  .taskchef-metric { min-width: 0; background: transparent; }
85
85
  .taskchef-metric-value { overflow-wrap: anywhere; }
86
86
  .taskchef-metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: var(--mantine-spacing-xs); }
87
- .taskchef-turn { background: var(--taskchef-surface-muted); }
87
+ .taskchef-turn { container-type: inline-size; background: var(--taskchef-surface-muted); }
88
88
  .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); }
89
- .taskchef-turn-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
90
- .taskchef-turn-timing { display: flex; align-items: center; justify-content: flex-end; gap: var(--mantine-spacing-sm); min-width: 0; }
91
- .taskchef-turn-duration { display: inline-flex; align-items: baseline; gap: 4px; white-space: nowrap; }
92
- .taskchef-turn-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--mantine-spacing-xs); }
93
- .taskchef-turn-metric { min-width: 0; overflow-wrap: anywhere; }
94
89
  .taskchef-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
95
90
  .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; }
96
91
  .taskchef-metadata { display: grid; grid-template-columns: minmax(120px, 150px) minmax(0, 1fr); margin: 0; font-size: var(--mantine-font-size-sm); }
@@ -130,9 +125,6 @@ body {
130
125
  .taskchef-notifications { right: 16px; bottom: 16px; width: min(330px, calc(100vw - 32px)); }
131
126
  .taskchef-metadata { grid-template-columns: 1fr; }
132
127
  .taskchef-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
133
- .taskchef-turn-metrics { grid-template-columns: 1fr; }
134
- .taskchef-turn-heading { align-items: flex-start; flex-direction: column; gap: 6px; }
135
- .taskchef-turn-timing { flex-wrap: wrap; justify-content: flex-start; }
136
128
  .taskchef-metadata dt { padding-bottom: 0; border-bottom: 0; }
137
129
  }
138
130
 
@@ -142,10 +134,10 @@ body {
142
134
  }
143
135
 
144
136
  @container (max-width: 27.5rem) {
145
- .taskchef-card-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
146
- .taskchef-card-stat { padding-block: 8px; }
147
- .taskchef-card-stat:nth-child(3) { border-inline-start: 0; padding-inline-start: 0; }
148
- .taskchef-card-stat:nth-child(n + 3) { border-top: 1px solid var(--taskchef-border); }
137
+ .taskchef-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
138
+ .taskchef-stat { padding-block: 8px; }
139
+ .taskchef-stat:nth-child(3) { border-inline-start: 0; padding-inline-start: 0; }
140
+ .taskchef-stat:nth-child(n + 3) { border-top: 1px solid var(--taskchef-border); }
149
141
  }
150
142
 
151
143
  .taskchef-refresh-spinning {
package/src/usage.js CHANGED
@@ -19,6 +19,7 @@ const PINNED_CCUSAGE_VERSION = taskchefPackage.optionalDependencies?.ccusage;
19
19
  const CCUSAGE_RESOLVER = fileURLToPath(new URL("./resolve-ccusage.js", import.meta.url));
20
20
  let npxResolvedCcusageInvocation = null;
21
21
  const USAGE_FILE_NAME = ".taskchef-usage.json";
22
+ const USAGE_SCHEMA_VERSION = 2;
22
23
  const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi;
23
24
  const TOKEN_FIELDS = [
24
25
  "inputTokens",
@@ -92,10 +93,6 @@ function normalizeProvenance(value, name, { includeSessionCount = false } = {})
92
93
  pricingMode: value.pricingMode === "online" || value.pricingMode === "offline"
93
94
  ? value.pricingMode
94
95
  : null,
95
- costCoverage: value.costCoverage === "cache_writes_unverified"
96
- || value.costCoverage === "ccusage_reported"
97
- ? value.costCoverage
98
- : null,
99
96
  ...(includeSessionCount ? {
100
97
  sessionCount: nonNegativeNumber(value.sessionCount, `${name}.provenance.sessionCount`),
101
98
  } : {}),
@@ -127,10 +124,6 @@ function normalizeStoredSnapshot(value, name) {
127
124
  }
128
125
  snapshot.models = models;
129
126
  snapshot.provenance = normalizeProvenance(value.provenance, name, { includeSessionCount: true });
130
- if (snapshot.provenance.pricingMode === null || snapshot.provenance.costCoverage === null) {
131
- snapshot.estimatedCostUsd = null;
132
- snapshot.costStatus = "unavailable";
133
- }
134
127
  snapshot.sampledAt = timestampOrNull(value.sampledAt, `${name}.sampledAt`);
135
128
  snapshot.sourceUpdatedAt = timestampOrNull(value.sourceUpdatedAt, `${name}.sourceUpdatedAt`);
136
129
  return snapshot;
@@ -150,7 +143,7 @@ function normalizeStoredTurn(value, name) {
150
143
  ? null
151
144
  : nonNegativeNumber(value.estimatedCostUsd, `${name}.estimatedCostUsd`);
152
145
  const provenance = normalizeProvenance(value.provenance, name);
153
- if (provenance.pricingMode === null || provenance.costCoverage === null) {
146
+ if (provenance.pricingMode === null) {
154
147
  normalized.estimatedCostUsd = null;
155
148
  }
156
149
  return {
@@ -271,9 +264,6 @@ export function aggregateCcusageSessions(payload, threadId, {
271
264
  provider: "ccusage",
272
265
  version,
273
266
  pricingMode,
274
- costCoverage: Object.keys(models).some((model) => /^gpt-5\.6(?:-|$)/i.test(model))
275
- ? "cache_writes_unverified"
276
- : "ccusage_reported",
277
267
  sessionCount: sessions.length,
278
268
  },
279
269
  sampledAt,
@@ -627,21 +617,23 @@ export async function readUsageStore(workspace) {
627
617
  if (error.code === "ENOENT") return null;
628
618
  throw error;
629
619
  });
630
- if (details === null) return { schemaVersion: 1, tasks: {} };
620
+ if (details === null) return { schemaVersion: USAGE_SCHEMA_VERSION, tasks: {} };
631
621
  if (details.isSymbolicLink() || !details.isFile()) {
632
622
  throw new Error("TaskChef usage cache must be a regular file");
633
623
  }
634
624
  if (details.size > MAX_USAGE_FILE_BYTES) {
635
- return { schemaVersion: 1, tasks: {} };
625
+ return { schemaVersion: USAGE_SCHEMA_VERSION, tasks: {} };
636
626
  }
637
627
  const value = JSON.parse(await readFile(filePath, "utf8"));
638
- if (value?.schemaVersion !== 1 || !value.tasks || typeof value.tasks !== "object") {
628
+ if (![1, USAGE_SCHEMA_VERSION].includes(value?.schemaVersion)
629
+ || !value.tasks
630
+ || typeof value.tasks !== "object") {
639
631
  throw new Error("TaskChef usage cache has an unsupported schema");
640
632
  }
641
633
  const tasks = Object.entries(value.tasks);
642
- if (tasks.length > 2_000) return { schemaVersion: 1, tasks: {} };
634
+ if (tasks.length > 2_000) return { schemaVersion: USAGE_SCHEMA_VERSION, tasks: {} };
643
635
  return {
644
- schemaVersion: 1,
636
+ schemaVersion: USAGE_SCHEMA_VERSION,
645
637
  tasks: Object.fromEntries(tasks.map(([taskId, record]) => [
646
638
  taskId,
647
639
  normalizeStoredRecord(record, `usage task ${taskId}`),
@@ -672,7 +664,7 @@ export function compactUsageStore(store) {
672
664
  tasks[taskId] = compacted;
673
665
  approximateBytes += entryBytes;
674
666
  }
675
- return { schemaVersion: 1, tasks };
667
+ return { schemaVersion: USAGE_SCHEMA_VERSION, tasks };
676
668
  }
677
669
 
678
670
  export async function writeUsageStore(workspace, store) {
@@ -704,7 +696,7 @@ export function usageDelta(current, previous = null) {
704
696
  if (!Number.isFinite(value) || value < 0) return null;
705
697
  delta[field] = value;
706
698
  }
707
- const samePricingSource = previous === null || ["version", "pricingMode", "costCoverage"]
699
+ const samePricingSource = previous === null || ["version", "pricingMode"]
708
700
  .every((field) => current.provenance?.[field] === previous.provenance?.[field]);
709
701
  let estimatedCostUsd = !samePricingSource
710
702
  || current.estimatedCostUsd === null