taskchef 8.1.1 → 8.2.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,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 }) {
@@ -30,13 +37,9 @@ export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef:
30
37
  >
31
38
  <Stack gap={7}>
32
39
  <Box className="taskchef-turn-heading">
40
+ <Text c="dimmed" size="xs">{presentation.sourceLabel}</Text>
33
41
  <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
42
  </Box>
39
- {presentation.sourceLabel && <Text c="dimmed" size="xs">{presentation.sourceLabel}</Text>}
40
43
  <Box>
41
44
  <Text c="dimmed" className="taskchef-field-label" size="xs">Request</Text>
42
45
  <Text className="taskchef-preserve-lines" size="sm">
@@ -51,24 +54,37 @@ export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef:
51
54
  : <LinkedText task={task} text={presentation.summary} />}
52
55
  </Text>
53
56
  </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>
57
+ <StatsGrid variant="turn">
58
+ <StatCell>
59
+ <RelativeTime
60
+ icon={<IconHistory aria-hidden size={14} />}
61
+ label="Turn update time"
62
+ value={presentation.updatedAt}
63
+ />
64
+ </StatCell>
65
+ <StatCell>
66
+ <TurnReportedWork turn={turn} />
67
+ </StatCell>
68
+ <StatCell usageState={usage.kind}>
69
+ <StaticStat
70
+ accessibleLabel={usage.tokens.accessibleLabel}
71
+ animated={usage.animated}
72
+ icon={<IconStack2 aria-hidden size={14} />}
73
+ title={usage.title}
74
+ value={usage.tokens.value}
75
+ />
76
+ </StatCell>
77
+ <StatCell usageState={usage.kind}>
78
+ <StaticStat
79
+ accessibleLabel={usage.cost.accessibleLabel}
80
+ animated={usage.animated}
81
+ icon={<IconCurrencyDollar aria-hidden size={14} />}
82
+ title={usage.title}
83
+ value={usage.cost.value}
84
+ />
85
+ </StatCell>
86
+ </StatsGrid>
70
87
  {usage.note && <Text c="dimmed" size="xs">{usage.note}</Text>}
71
- <Text c="dimmed" className="taskchef-mono" size="xs">Turn ref: {identity ?? "—"}</Text>
72
88
  </Stack>
73
89
  </Paper>
74
90
  );
@@ -81,39 +97,12 @@ function TurnReportedWork({ turn }: { turn: TaskTurn }) {
81
97
  const now = useLiveNow(turn.result === null);
82
98
  const elapsed = turnReportedWorkView(turn, now);
83
99
  return (
84
- <Text
85
- aria-label={elapsed.accessibleLabel}
86
- c="dimmed"
87
- className="taskchef-turn-duration"
88
- size="xs"
100
+ <StaticStat
101
+ accessibleLabel={elapsed.accessibleLabel}
102
+ icon={<IconHourglass aria-hidden size={14} />}
89
103
  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>
104
+ value={elapsed.value}
105
+ />
117
106
  );
118
107
  }
119
108
 
@@ -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,11 +79,11 @@ 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", () => {
85
- renderStats(fixtureTask({
86
+ const { container } = renderStats(fixtureTask({
86
87
  status: "working",
87
88
  turnRef: "turn-two",
88
89
  usage: {
@@ -91,8 +92,40 @@ describe("task card stats", () => {
91
92
  task: { totalTokens: 1_324_567, estimatedCostUsd: 12.3449 },
92
93
  },
93
94
  }));
94
- 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…");
95
+ expect(screen.getByLabelText(/1,324,567 tokens; updating/i)).toHaveTextContent("1.32M tokens");
96
+ expect(screen.getByLabelText(/estimated cost \$12\.34; updating/i)).toHaveTextContent("12.34");
97
+ expect(container.querySelectorAll(".taskchef-shimmer")).toHaveLength(2);
98
+ });
99
+
100
+ test("retains terminal totals while a newer usage generation is calculating", () => {
101
+ const { container } = renderStats(fixtureTask({
102
+ status: "completed",
103
+ turnRef: "turn-two",
104
+ usage: {
105
+ generationTurnRef: "turn-one",
106
+ status: "calculating",
107
+ task: { totalTokens: 1_324_567, estimatedCostUsd: 12.3449 },
108
+ },
109
+ }));
110
+ expect(screen.getByLabelText(/1,324,567 tokens; updating/i)).toHaveTextContent("1.32M tokens");
111
+ expect(screen.getByLabelText(/estimated cost \$12\.34; updating/i)).toHaveTextContent("12.34");
112
+ expect(container.querySelectorAll(".taskchef-shimmer")).toHaveLength(2);
113
+ });
114
+
115
+ test("keeps unavailable cost static while retained tokens update", () => {
116
+ const { container } = renderStats(fixtureTask({
117
+ status: "working",
118
+ turnRef: "turn-two",
119
+ usage: {
120
+ generationTurnRef: "turn-one",
121
+ status: "calculating",
122
+ task: { totalTokens: 1_324_567, estimatedCostUsd: null },
123
+ },
124
+ }));
125
+ expect(screen.getByLabelText(/1,324,567 tokens; updating/i)).toHaveTextContent("1.32M tokens");
126
+ expect(screen.getByLabelText(/estimated cost unavailable/i)).toHaveTextContent("n/a");
127
+ expect(screen.getByLabelText(/estimated cost unavailable/i)).not.toHaveAccessibleName(/updating/i);
128
+ expect(container.querySelectorAll(".taskchef-shimmer")).toHaveLength(1);
96
129
  });
97
130
 
98
131
  test("places Open chat beneath the status badge and keeps callbacks working", () => {
@@ -1,69 +1,39 @@
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
- animated={usage.animated}
36
+ animated={usage.tokens.animated}
67
37
  icon={<IconStack2 aria-hidden size={14} />}
68
38
  title={usage.tokens.title}
69
39
  value={usage.tokens.value}
@@ -71,16 +41,16 @@ 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
- animated={usage.animated}
48
+ animated={usage.cost.animated}
79
49
  icon={<IconCurrencyDollar aria-hidden size={14} />}
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
  }
@@ -63,22 +63,29 @@ test("covers terminal, active, and unavailable timeline metrics", () => {
63
63
  }],
64
64
  usage: {
65
65
  status: "available",
66
- turns: { "turn-one": { status: "available", totalTokens: 330, estimatedCostUsd: 0.12 } },
66
+ turns: { "turn-one": { status: "available", totalTokens: 1_324_567, estimatedCostUsd: 0.12 } },
67
67
  },
68
68
  });
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
+ expect(container.querySelector(".taskchef-turn-heading .taskchef-status-badge"))
75
+ .toHaveTextContent("completed");
76
+ expect(screen.queryByText(/^Turn ref:/)).not.toBeInTheDocument();
77
+ const cells = [...stats.children] as HTMLElement[];
78
+ expect(cells).toHaveLength(4);
79
+ expect(within(cells[0]).getByLabelText(/^Turn update time:/i)).toBeVisible();
80
+ expect(cells[0].querySelector(".tabler-icon-history")).not.toBeNull();
81
+ expect(within(cells[1]).getByLabelText(/completed turn reported wall-clock elapsed time/i))
82
+ .toHaveTextContent("18m 32s");
83
+ expect(cells[1].querySelector(".tabler-icon-hourglass")).not.toBeNull();
84
+ expect(within(cells[2]).getByLabelText("1,324,567 tokens")).toHaveTextContent("1.32M");
85
+ expect(cells[2].querySelector(".tabler-icon-stack-2")).not.toBeNull();
86
+ expect(within(cells[3]).getByLabelText("Estimated cost $0.12")).toHaveTextContent("0.12");
87
+ expect(within(cells[3]).getByLabelText("Estimated cost $0.12")).not.toHaveTextContent("$");
88
+ expect(cells[3].querySelector(".tabler-icon-currency-dollar")).not.toBeNull();
82
89
  cleanup();
83
90
  }
84
91
 
@@ -94,9 +101,9 @@ test("covers terminal, active, and unavailable timeline metrics", () => {
94
101
  const task = fixtureTask();
95
102
  render(<MantineProvider><ActivityTimeline highlightTurnRef={null} task={task} /></MantineProvider>);
96
103
  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");
104
+ .toHaveTextContent("18m 32s");
105
+ expect(screen.getByLabelText("Tokens pending")).toHaveTextContent("Pending");
106
+ expect(screen.getByLabelText("Estimated cost pending")).toHaveTextContent("Pending");
100
107
 
101
108
  act(() => {
102
109
  now += 2_000;
@@ -104,7 +111,7 @@ test("covers terminal, active, and unavailable timeline metrics", () => {
104
111
  });
105
112
 
106
113
  expect(screen.getByLabelText(/current turn reported wall-clock elapsed time/i))
107
- .toHaveTextContent("Elapsed so far18m 34s");
114
+ .toHaveTextContent("18m 34s");
108
115
  cleanup();
109
116
  vi.restoreAllMocks();
110
117
  }
@@ -133,7 +140,7 @@ test("covers terminal, active, and unavailable timeline metrics", () => {
133
140
  });
134
141
  render(<MantineProvider><ActivityTimeline highlightTurnRef={null} task={task} /></MantineProvider>);
135
142
  expect(screen.getByLabelText(/elapsed unavailable.*reported wall-clock/i))
136
- .toHaveTextContent("Elapsedn/a");
143
+ .toHaveTextContent("n/a");
137
144
  expect(screen.getByText("Manual dashboard turns do not have usage boundaries.")).toBeVisible();
138
145
  expect(screen.getByLabelText(/tokens unavailable: manual dashboard turns/i)).toBeVisible();
139
146
  }
@@ -61,6 +61,21 @@ describe("token and working presentation", () => {
61
61
  if (view.kind === "ready") expect(view.knownSoFar).toBe(true);
62
62
  });
63
63
 
64
+ test("keeps cumulative usage visible while a newer terminal generation is calculating", () => {
65
+ const view = usageView(fixtureTask({
66
+ status: "completed",
67
+ turnRef: "turn-two",
68
+ usage: {
69
+ generationTurnRef: "turn-one",
70
+ status: "calculating",
71
+ task: { totalTokens: 12345, estimatedCostUsd: 0.1234 },
72
+ },
73
+ }));
74
+ expect(view.kind).toBe("ready");
75
+ expect(view.label).toBe("12.35K tokens · Estimated cost $0.12 · known so far");
76
+ if (view.kind === "ready") expect(view.knownSoFar).toBe(true);
77
+ });
78
+
64
79
  test("distinguishes available, pending, calculating, and unavailable per-turn usage", () => {
65
80
  const task = fixtureTask({
66
81
  usage: {
@@ -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" }
@@ -144,6 +151,7 @@ export function usageView(task: Task): UsageView {
144
151
  return { kind: "pending", label: "Token usage pending" };
145
152
  }
146
153
  if (usage?.status === "calculating" || priorGeneration) {
154
+ if (usage.task) return readyUsageView(usage.task, true);
147
155
  return { kind: "calculating", label: "Calculating token usage" };
148
156
  }
149
157
  if (usage?.status === "available" && usage.task) {
@@ -193,20 +201,22 @@ export function turnUsageMetricsView(task: Task, turn: NonNullable<Task["turns"]
193
201
  if (view.kind === "ready") {
194
202
  const identity = turn.turnRef ?? turn.turnId;
195
203
  const usage = identity ? task.usage?.turns?.[identity] : null;
196
- 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 fullTokens = typeof usage?.totalTokens === "number" ? formatFullTokens(usage.totalTokens) : NOT_AVAILABLE;
205
+ const compactTokens = typeof usage?.totalTokens === "number" ? formatCompactTokens(usage.totalTokens) : NOT_AVAILABLE;
206
+ const estimatedCost = usage?.estimatedCostUsd;
207
+ const hasCost = typeof estimatedCost === "number" && Number.isFinite(estimatedCost);
208
+ const cost = hasCost ? formatEstimatedCostAmount(estimatedCost) : NOT_AVAILABLE;
209
+ const accessibleCost = hasCost ? formatEstimatedCost(estimatedCost).toLowerCase() : "unavailable";
200
210
  return {
201
211
  animated: false,
202
212
  cost: {
203
- accessibleLabel: `Estimated cost ${usage?.estimatedCostUsd == null ? "unavailable" : cost.toLowerCase()}`,
213
+ accessibleLabel: `Estimated cost ${accessibleCost}`,
204
214
  value: cost,
205
215
  },
206
216
  kind: view.kind,
207
217
  note: null,
208
218
  title: view.label,
209
- tokens: { accessibleLabel: `${tokens} tokens`, value: tokens },
219
+ tokens: { accessibleLabel: `${fullTokens} tokens`, value: compactTokens },
210
220
  };
211
221
  }
212
222
  const value = view.kind === "pending"
@@ -239,12 +249,12 @@ export function usageStillCalculating(task: Task) {
239
249
 
240
250
  export type ListUsageMetric = {
241
251
  accessibleLabel: string;
252
+ animated: boolean;
242
253
  title: string;
243
254
  value: string;
244
255
  };
245
256
 
246
257
  export type ListUsageMetricsView = {
247
- animated: boolean;
248
258
  cost: ListUsageMetric;
249
259
  kind: "pending" | "calculating" | "ready" | "unavailable";
250
260
  tokens: ListUsageMetric;
@@ -255,15 +265,16 @@ export function listUsageMetricsView(task: Task): ListUsageMetricsView {
255
265
  if (view.kind === "pending" || view.kind === "calculating") {
256
266
  const value = view.kind === "pending" ? "Pending" : "Calculating";
257
267
  return {
258
- animated: true,
259
268
  cost: {
260
269
  accessibleLabel: `Estimated cost ${value.toLowerCase()}`,
270
+ animated: true,
261
271
  title: view.label,
262
272
  value,
263
273
  },
264
274
  kind: view.kind,
265
275
  tokens: {
266
276
  accessibleLabel: `Tokens ${value.toLowerCase()}`,
277
+ animated: true,
267
278
  title: view.label,
268
279
  value,
269
280
  },
@@ -272,15 +283,16 @@ export function listUsageMetricsView(task: Task): ListUsageMetricsView {
272
283
  if (view.kind === "unavailable") {
273
284
  const reason = view.label === "Token usage unavailable" ? "" : `: ${view.label}`;
274
285
  return {
275
- animated: false,
276
286
  cost: {
277
287
  accessibleLabel: `Estimated cost unavailable${reason}`,
288
+ animated: false,
278
289
  title: view.label,
279
- value: "est. n/a",
290
+ value: NOT_AVAILABLE,
280
291
  },
281
292
  kind: "unavailable",
282
293
  tokens: {
283
294
  accessibleLabel: `Token usage unavailable${reason}`,
295
+ animated: false,
284
296
  title: view.label,
285
297
  value: `${NOT_AVAILABLE} tokens`,
286
298
  },
@@ -288,29 +300,33 @@ export function listUsageMetricsView(task: Task): ListUsageMetricsView {
288
300
  }
289
301
  const compactTokens = formatCompactTokens(view.usage.totalTokens);
290
302
  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;
294
- const qualifier = view.knownSoFar ? " · Updating…" : "";
303
+ const estimatedCost = view.usage.estimatedCostUsd;
304
+ const hasCost = typeof estimatedCost === "number" && Number.isFinite(estimatedCost);
305
+ const formattedCost = hasCost ? formatEstimatedCost(estimatedCost) : null;
306
+ const formattedCostAmount = hasCost ? formatEstimatedCostAmount(estimatedCost) : null;
307
+ const tooltipQualifier = view.knownSoFar ? " · Updating…" : "";
295
308
  const accessibleQualifier = view.knownSoFar ? "; updating" : "";
309
+ const costTooltipQualifier = hasCost ? tooltipQualifier : "";
310
+ const costAccessibleQualifier = hasCost ? accessibleQualifier : "";
296
311
  const freshness = view.usage.sourceUpdatedAt ?? view.usage.sampledAt ?? task.usage?.updatedAt ?? null;
297
312
  const freshnessLabel = freshness && !Number.isNaN(Date.parse(freshness))
298
313
  ? ` Cached usage updated ${new Date(freshness).toLocaleString()}.`
299
314
  : "";
300
315
  return {
301
- animated: false,
302
316
  cost: {
303
- accessibleLabel: `Estimated cost ${formattedCost ?? "unavailable"}${accessibleQualifier}.${freshnessLabel}`,
317
+ accessibleLabel: `Estimated cost ${formattedCost ?? "unavailable"}${costAccessibleQualifier}.${freshnessLabel}`,
318
+ animated: view.knownSoFar && hasCost,
304
319
  title: `${formattedCost === null
305
320
  ? "Estimated cost unavailable"
306
- : `Unrounded estimate: $${view.usage.estimatedCostUsd}`}${qualifier}.${freshnessLabel}`,
307
- value: `${formattedCost === null ? "est. n/a" : `est. ${formattedCost}`}${qualifier}`,
321
+ : `Unrounded estimate: $${view.usage.estimatedCostUsd}`}${costTooltipQualifier}.${freshnessLabel}`,
322
+ value: formattedCostAmount ?? NOT_AVAILABLE,
308
323
  },
309
324
  kind: "ready",
310
325
  tokens: {
311
326
  accessibleLabel: `${fullTokens} tokens${accessibleQualifier}.${freshnessLabel}`,
312
- title: `${fullTokens} tokens${qualifier}.${freshnessLabel}`,
313
- value: `${compactTokens} tokens${qualifier}`,
327
+ animated: view.knownSoFar,
328
+ title: `${fullTokens} tokens${tooltipQualifier}.${freshnessLabel}`,
329
+ value: `${compactTokens} tokens`,
314
330
  },
315
331
  };
316
332
  }