taskchef 7.24.0 → 7.25.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.
@@ -60,12 +60,20 @@ function compareReferenceNumbers(left, right) {
60
60
 
61
61
  export function groupRelatedGitHubLinks(links) {
62
62
  const groups = new Map();
63
- const seen = new Set();
63
+ const seenLabels = new Set();
64
+ const seenNumbers = new Set();
65
+ const seenUrls = new Set();
64
66
  for (const link of links ?? []) {
65
- if (link.type === "repository" || seen.has(link.label)) continue;
66
- seen.add(link.label);
67
67
  const repository = repositoryIdentity(link);
68
- if (!repository) continue;
68
+ const reference = link.number ? `${repository}#${link.number}` : null;
69
+ if (link.type === "repository"
70
+ || !repository
71
+ || seenLabels.has(link.label)
72
+ || seenUrls.has(link.url)
73
+ || (reference && seenNumbers.has(reference))) continue;
74
+ seenLabels.add(link.label);
75
+ seenUrls.add(link.url);
76
+ if (reference) seenNumbers.add(reference);
69
77
  const group = groups.get(repository) ?? [];
70
78
  group.push(link);
71
79
  groups.set(repository, group);
@@ -30,7 +30,12 @@ import {
30
30
  STATUS_FILTERS,
31
31
  } from "../state.js";
32
32
  import { connectDashboard, dashboardVersion, manualTransition, openInCodex, taskDetail } from "./api";
33
- import { clearNotificationHistory, notificationClicked } from "./overlay-state";
33
+ import {
34
+ clearNotificationHistory,
35
+ listUsageSignature,
36
+ mergeListTaskIntoDetail,
37
+ notificationClicked,
38
+ } from "./overlay-state";
34
39
  import { usageStillCalculating } from "./presentation";
35
40
  import type { DashboardSnapshot, NotificationSnapshot, Task } from "./types";
36
41
  import { NotificationCenter } from "./components/NotificationCenter";
@@ -105,6 +110,7 @@ export function DashboardApp({
105
110
  selectedIdRef.current = selectedId;
106
111
  const detailGeneration = useRef(0);
107
112
  const detailListUpdatedAt = useRef<string | null>(null);
113
+ const detailListUsageSignature = useRef(listUsageSignature(null));
108
114
 
109
115
  const applySnapshot = useCallback((snapshot: DashboardSnapshot) => {
110
116
  setTasks(snapshot.tasks);
@@ -124,7 +130,9 @@ export function DashboardApp({
124
130
  const currentSelectedId = selectedIdRef.current;
125
131
  if (currentSelectedId) {
126
132
  const updated = snapshot.tasks.find((task) => task.id === currentSelectedId);
127
- if (updated) setSelectedTask((current) => current ? { ...current, ...updated } : updated);
133
+ if (updated) {
134
+ setSelectedTask((current) => current ? mergeListTaskIntoDetail(current, updated) : updated);
135
+ }
128
136
  }
129
137
  }, []);
130
138
 
@@ -147,7 +155,10 @@ export function DashboardApp({
147
155
  const loadDetail = useCallback(async (task: Task, focusTurnRef: string | null = null) => {
148
156
  const generation = ++detailGeneration.current;
149
157
  detailListUpdatedAt.current = task.updatedAt;
150
- setSelectedTask((current) => current?.id === task.id ? { ...current, ...task } : task);
158
+ detailListUsageSignature.current = listUsageSignature(task);
159
+ setSelectedTask((current) => current?.id === task.id
160
+ ? mergeListTaskIntoDetail(current, task)
161
+ : task);
151
162
  setDetailOpened(true);
152
163
  setHighlightTurnRef(focusTurnRef);
153
164
  setDetailError(null);
@@ -157,6 +168,8 @@ export function DashboardApp({
157
168
  if (generation !== detailGeneration.current) return;
158
169
  const detail = await taskDetail(task.id);
159
170
  if (generation !== detailGeneration.current) return;
171
+ detailListUpdatedAt.current = detail.updatedAt;
172
+ detailListUsageSignature.current = listUsageSignature(detail);
160
173
  setSelectedTask(detail);
161
174
  if (!usageStillCalculating(detail) || attempt === 40) return;
162
175
  await new Promise((resolve) => window.setTimeout(resolve, 1_500));
@@ -168,14 +181,20 @@ export function DashboardApp({
168
181
  }
169
182
  }, []);
170
183
 
184
+ const selectedListTask = tasks.find((task) => task.id === selectedId) ?? null;
185
+ const selectedListUsageSignature = listUsageSignature(selectedListTask);
186
+
171
187
  useEffect(() => {
172
188
  if (!detailOpened || !selectedId || !connect) return;
173
- const listTask = tasks.find((task) => task.id === selectedId);
174
- if (!listTask || listTask.updatedAt === detailListUpdatedAt.current) return;
175
- void loadDetail(listTask, highlightTurnRef);
176
- // A task signature update while detail is open refreshes the full projection.
189
+ if (!selectedListTask) return;
190
+ if (
191
+ selectedListTask.updatedAt === detailListUpdatedAt.current
192
+ && selectedListUsageSignature === detailListUsageSignature.current
193
+ ) return;
194
+ void loadDetail(selectedListTask, highlightTurnRef);
195
+ // Task or cached usage changes while detail is open refresh the full projection.
177
196
  // eslint-disable-next-line react-hooks/exhaustive-deps
178
- }, [tasks.find((task) => task.id === selectedId)?.updatedAt]);
197
+ }, [selectedListTask?.updatedAt, selectedListUsageSignature]);
179
198
 
180
199
  const projects = useMemo(() => [
181
200
  { label: "All projects", value: "" },
@@ -3,6 +3,7 @@ import { mergeProjectedTurns, turnPresentation } from "../../state.js";
3
3
  import { turnUsageView } from "../presentation";
4
4
  import type { Task } from "../types";
5
5
  import { RelativeTime } from "./RelativeTime";
6
+ import { LinkedText } from "./LinkedText";
6
7
  import { ShimmerText } from "./ShimmerText";
7
8
  import { StatusBadge } from "./StatusBadge";
8
9
 
@@ -34,14 +35,16 @@ export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef:
34
35
  {presentation.sourceLabel && <Text c="dimmed" size="xs">{presentation.sourceLabel}</Text>}
35
36
  <Box>
36
37
  <Text c="dimmed" className="taskchef-field-label" size="xs">Request</Text>
37
- <Text className="taskchef-preserve-lines" size="sm">{turn.requestSummary ?? "Request not recorded."}</Text>
38
+ <Text className="taskchef-preserve-lines" size="sm">
39
+ <LinkedText task={task} text={turn.requestSummary ?? "Request not recorded."} />
40
+ </Text>
38
41
  </Box>
39
42
  <Box>
40
43
  <Text c="dimmed" className="taskchef-field-label" size="xs">Result</Text>
41
44
  <Text className="taskchef-preserve-lines" size="sm">
42
45
  {presentation.status === "working"
43
46
  ? <ShimmerText>{presentation.summary}</ShimmerText>
44
- : presentation.summary}
47
+ : <LinkedText task={task} text={presentation.summary} />}
45
48
  </Text>
46
49
  </Box>
47
50
  <Text
@@ -1,25 +1,46 @@
1
- import { Anchor, Group, Text } from "@mantine/core";
2
- import type { Task } from "../types";
1
+ import { Anchor, Box, Group, Text } from "@mantine/core";
2
+ import {
3
+ githubReferenceAccessibleLabel,
4
+ githubReferenceDisplayLabels,
5
+ groupRelatedGitHubLinks,
6
+ } from "../../github-links.js";
7
+ import type { GitHubLink, Task } from "../types";
3
8
 
4
9
  export function GitHubLinks({ task }: { task: Task }) {
5
10
  const links = task.relatedGitHubLinks ?? [];
6
11
  if (links.length === 0 && !task.relatedGitHubLinksTruncated) return null;
12
+ const groups = groupRelatedGitHubLinks(links) as GitHubLink[][];
13
+ const displayLabels = githubReferenceDisplayLabels(groups.flat(), { related: true }) as string[];
14
+ let linkIndex = 0;
7
15
  return (
8
- <Group aria-label={`Related GitHub links for ${task.title}`} component="nav" gap={5} wrap="wrap">
9
- {links.map((link) => (
10
- <Anchor
11
- aria-label={`${link.label}, GitHub ${link.type === "pull" ? "pull request" : link.type ?? "link"} (opens in a new tab)`}
12
- className="taskchef-github-link"
13
- href={link.url}
14
- key={link.url}
15
- rel="noopener noreferrer"
16
- size="xs"
17
- target="_blank"
18
- >
19
- {link.label}
20
- </Anchor>
16
+ <Box aria-label={`Related GitHub links for ${task.title}`} className="taskchef-github-groups" component="nav">
17
+ {groups.map((group) => (
18
+ <Group className="taskchef-github-group" gap={5} key={`${group[0].owner}/${group[0].repository}`} wrap="wrap">
19
+ {group.map((link) => {
20
+ const displayLabel = displayLabels[linkIndex];
21
+ linkIndex += 1;
22
+ const kind = link.type === "pull"
23
+ ? ", GitHub pull request"
24
+ : link.type === "issue"
25
+ ? ", GitHub issue"
26
+ : " on GitHub";
27
+ return (
28
+ <Anchor
29
+ aria-label={`${githubReferenceAccessibleLabel(link)}${kind} (opens in a new tab)`}
30
+ className="taskchef-github-link"
31
+ href={link.url}
32
+ key={link.url}
33
+ rel="noopener noreferrer"
34
+ size="xs"
35
+ target="_blank"
36
+ >
37
+ {displayLabel}
38
+ </Anchor>
39
+ );
40
+ })}
41
+ </Group>
21
42
  ))}
22
43
  {task.relatedGitHubLinksTruncated && <Text c="dimmed" size="xs">More links in task history</Text>}
23
- </Group>
44
+ </Box>
24
45
  );
25
46
  }
@@ -0,0 +1,63 @@
1
+ import { Anchor } from "@mantine/core";
2
+ import {
3
+ githubReferenceAccessibleLabel,
4
+ githubReferenceDisplayLabels,
5
+ referenceSegments,
6
+ } from "../../github-links.js";
7
+ import type { Task } from "../types";
8
+
9
+ interface ReferenceSegment {
10
+ kind: "ambiguous" | "link" | "text";
11
+ label?: string;
12
+ owner?: string;
13
+ reason?: string;
14
+ repository?: string;
15
+ text: string;
16
+ type?: "generic" | "issue" | "pull" | "repository";
17
+ url?: string;
18
+ }
19
+
20
+ export function LinkedText({ task, text }: { task: Task; text: string }) {
21
+ const segments = referenceSegments(text, {
22
+ projectRepositories: task.project.githubRepos,
23
+ taskRepository: task.relatedGitHubRepository,
24
+ }) as ReferenceSegment[];
25
+ const links = segments.filter((segment) => segment.kind === "link");
26
+ const displayLabels = githubReferenceDisplayLabels(links) as string[];
27
+ let linkIndex = 0;
28
+ return segments.map((segment, index) => {
29
+ if (segment.kind === "text") return segment.text;
30
+ if (segment.kind === "ambiguous") {
31
+ return (
32
+ <span
33
+ aria-label={segment.reason}
34
+ className="taskchef-github-reference-ambiguous"
35
+ key={`${index}:${segment.text}`}
36
+ tabIndex={0}
37
+ title={segment.reason}
38
+ >
39
+ {segment.text}
40
+ </span>
41
+ );
42
+ }
43
+ const displayLabel = displayLabels[linkIndex];
44
+ linkIndex += 1;
45
+ const kind = segment.type === "pull"
46
+ ? ", GitHub pull request"
47
+ : segment.type === "issue"
48
+ ? ", GitHub issue"
49
+ : segment.owner ? " on GitHub" : "";
50
+ return (
51
+ <Anchor
52
+ aria-label={`${githubReferenceAccessibleLabel(segment)}${kind} (opens in a new tab)`}
53
+ className="taskchef-github-link"
54
+ href={segment.url}
55
+ key={`${index}:${segment.url}`}
56
+ rel="noopener noreferrer"
57
+ target="_blank"
58
+ >
59
+ {displayLabel}
60
+ </Anchor>
61
+ );
62
+ });
63
+ }
@@ -17,9 +17,14 @@ const notification: NotificationSnapshot = {
17
17
  summary: "Review completed.",
18
18
  };
19
19
 
20
- afterEach(cleanup);
20
+ afterEach(() => {
21
+ cleanup();
22
+ vi.useRealTimers();
23
+ });
21
24
 
22
25
  test("notification portal stays non-modal until the operator chooses an action", () => {
26
+ vi.useFakeTimers();
27
+ vi.setSystemTime(new Date("2026-08-30T12:10:00.000Z"));
23
28
  const onOpen = vi.fn();
24
29
  const onDismiss = vi.fn();
25
30
  render(
@@ -3,9 +3,11 @@ import { IconExternalLink } from "@tabler/icons-react";
3
3
  import { latestTurnPresentation } from "../../state.js";
4
4
  import type { Task } from "../types";
5
5
  import { GitHubLinks } from "./GitHubLinks";
6
+ import { LinkedText } from "./LinkedText";
6
7
  import { RelativeTime } from "./RelativeTime";
7
8
  import { ShimmerText } from "./ShimmerText";
8
9
  import { StatusBadge } from "./StatusBadge";
10
+ import { TaskUsageSummary } from "./TaskUsageSummary";
9
11
 
10
12
  export function TaskCard({
11
13
  onOpenCodex,
@@ -21,30 +23,35 @@ export function TaskCard({
21
23
  <Paper className="taskchef-task-row" component="article" p="md" radius="md" withBorder>
22
24
  <Stack className="taskchef-task-main" gap="sm">
23
25
  <Box>
24
- <Group align="flex-start" gap="sm" justify="space-between" wrap="nowrap">
26
+ <Box className="taskchef-card-heading">
25
27
  <Title order={3} size="h5">
26
28
  <button className="taskchef-title-button" onClick={() => onOpenDetail(task)} type="button">
27
29
  {task.title}
28
30
  </button>
29
31
  </Title>
30
- <StatusBadge status={task.status} />
31
- </Group>
32
+ <Stack align="flex-end" className="taskchef-card-metadata" gap={4}>
33
+ <StatusBadge status={task.status} />
34
+ <TaskUsageSummary task={task} />
35
+ </Stack>
36
+ </Box>
32
37
  <Text c="dimmed" mt={2} size="xs">{task.project.name}</Text>
33
38
  </Box>
34
39
 
35
40
  <Box className="taskchef-summary-grid">
36
41
  <Text c="dimmed" className="taskchef-field-label" size="xs">Request</Text>
37
- <Text className="taskchef-preserve-lines" lineClamp={3} size="sm">{latest.requestSummary}</Text>
42
+ <Text className="taskchef-preserve-lines" lineClamp={3} size="sm">
43
+ <LinkedText task={task} text={latest.requestSummary} />
44
+ </Text>
38
45
  <Text c="dimmed" className="taskchef-field-label" size="xs">Result</Text>
39
46
  <Text className="taskchef-preserve-lines" lineClamp={3} size="sm">
40
47
  {latest.resultStatus === "working"
41
48
  ? <ShimmerText>{latest.resultSummary}</ShimmerText>
42
- : latest.resultSummary}
49
+ : <LinkedText task={task} text={latest.resultSummary} />}
43
50
  </Text>
44
51
  </Box>
45
52
 
46
53
  <GitHubLinks task={task} />
47
- <Group gap="md" justify="space-between">
54
+ <Group className="taskchef-card-footer" gap="md" justify="space-between">
48
55
  <RelativeTime label={`Updated time for ${task.title}`} value={task.meaningfulUpdatedAt ?? task.updatedAt} />
49
56
  <Button
50
57
  aria-label={`Open chat for ${task.title}`}
@@ -0,0 +1,85 @@
1
+ import { MantineProvider } from "@mantine/core";
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, test, vi } from "vitest";
4
+ import { fixtureTask } from "../fixtures";
5
+ import { TaskCard } from "./TaskCard";
6
+ import { TaskUsageSummary } from "./TaskUsageSummary";
7
+
8
+ afterEach(cleanup);
9
+
10
+ function renderUsage(task = fixtureTask()) {
11
+ return render(<MantineProvider><TaskUsageSummary task={task} /></MantineProvider>);
12
+ }
13
+
14
+ describe("task list usage summary", () => {
15
+ test("uses shared compact token and currency formatting with full-value accessibility", () => {
16
+ renderUsage(fixtureTask({
17
+ status: "completed",
18
+ usage: {
19
+ status: "available",
20
+ updatedAt: "2026-08-30T09:00:00.000Z",
21
+ task: {
22
+ totalTokens: 1_324_567,
23
+ estimatedCostUsd: 12.3449,
24
+ sourceUpdatedAt: "2026-08-30T09:00:00.000Z",
25
+ },
26
+ },
27
+ }));
28
+ const summary = screen.getByText("1.32M tokens · est. $12.34");
29
+ expect(summary).toHaveAttribute("data-usage-state", "ready");
30
+ expect(summary).toHaveAccessibleName(/1,324,567 tokens; estimated cost \$12\.34/i);
31
+ expect(summary).toHaveAttribute("title", expect.stringContaining("unrounded estimate $12.3449"));
32
+ });
33
+
34
+ test("states cost unavailability without replacing a known token total with zero", () => {
35
+ renderUsage(fixtureTask({
36
+ status: "completed",
37
+ usage: { status: "available", task: { totalTokens: 1_324_567, estimatedCostUsd: null } },
38
+ }));
39
+ expect(screen.getByText("1.32M tokens · cost unavailable")).toHaveAccessibleName(
40
+ /1,324,567 tokens; estimated cost unavailable/i,
41
+ );
42
+ });
43
+
44
+ test("keeps pending and calculating wording distinct and uses the restrained shimmer", () => {
45
+ const { rerender } = renderUsage();
46
+ expect(screen.getByText("Token usage pending")).toHaveClass("taskchef-shimmer");
47
+ rerender(
48
+ <MantineProvider>
49
+ <TaskUsageSummary task={fixtureTask({
50
+ status: "completed",
51
+ usage: { generationTurnRef: "turn-one", status: "calculating" },
52
+ })} />
53
+ </MantineProvider>,
54
+ );
55
+ expect(screen.getByText("Calculating token usage")).toHaveClass("taskchef-shimmer");
56
+ });
57
+
58
+ test("shows a quiet explicit unavailable state with the cache reason in its tooltip", () => {
59
+ renderUsage(fixtureTask({
60
+ status: "completed",
61
+ usage: { status: "unavailable", reason: "No matching cached boundary." },
62
+ }));
63
+ const summary = screen.getByText("Token usage unavailable");
64
+ expect(summary).toHaveAttribute("data-usage-state", "unavailable");
65
+ expect(summary).toHaveAttribute("title", "No matching cached boundary.");
66
+ });
67
+
68
+ test("places usage directly beneath the primary status badge in a responsive metadata block", () => {
69
+ const { container } = render(
70
+ <MantineProvider>
71
+ <TaskCard
72
+ onOpenCodex={vi.fn()}
73
+ onOpenDetail={vi.fn()}
74
+ task={fixtureTask()}
75
+ />
76
+ </MantineProvider>,
77
+ );
78
+ const metadata = container.querySelector(".taskchef-card-metadata");
79
+ expect(metadata).not.toBeNull();
80
+ expect(metadata?.children[0]).toHaveClass("taskchef-status-badge");
81
+ expect(metadata?.children[1]).toHaveClass("taskchef-task-usage");
82
+ expect(screen.getByRole("button", { name: /^review checkout reconciliation$/i })).toBeVisible();
83
+ expect(screen.getByRole("button", { name: /open chat/i })).toBeVisible();
84
+ });
85
+ });
@@ -0,0 +1,24 @@
1
+ import { Text } from "@mantine/core";
2
+ import type { Task } from "../types";
3
+ import { listUsageView } from "../presentation";
4
+ import { ShimmerText } from "./ShimmerText";
5
+
6
+ export function TaskUsageSummary({ task }: { task: Task }) {
7
+ const usage = listUsageView(task);
8
+ const content = usage.kind === "pending" || usage.kind === "calculating"
9
+ ? <ShimmerText>{usage.label}</ShimmerText>
10
+ : usage.label;
11
+ return (
12
+ <Text
13
+ aria-label={usage.accessibleLabel}
14
+ aria-live="polite"
15
+ c="dimmed"
16
+ className="taskchef-task-usage"
17
+ data-usage-state={usage.kind}
18
+ size="xs"
19
+ title={usage.title}
20
+ >
21
+ {content}
22
+ </Text>
23
+ );
24
+ }
@@ -1,7 +1,12 @@
1
1
  import { Box, Group, Paper, Text } from "@mantine/core";
2
2
  import { IconCoins } from "@tabler/icons-react";
3
3
  import type { Task } from "../types";
4
- import { usageView } from "../presentation";
4
+ import {
5
+ formatCompactTokens,
6
+ formatEstimatedCost,
7
+ formatFullTokens,
8
+ usageView,
9
+ } from "../presentation";
5
10
  import { ShimmerText } from "./ShimmerText";
6
11
 
7
12
  export function UsagePanel({ task }: { task: Task }) {
@@ -22,18 +27,45 @@ export function UsagePanel({ task }: { task: Task }) {
22
27
  const cached = usage.usage.cachedInputTokens ?? 0;
23
28
  const input = usage.usage.inputTokens ?? 0;
24
29
  const cacheRatio = input + cached > 0 ? `${Math.round((cached / (input + cached)) * 100)}%` : "—";
25
- const values = [
26
- [usage.knownSoFar ? "Tokens · known so far" : "Tokens", new Intl.NumberFormat().format(usage.usage.totalTokens)],
27
- ["Estimated cost", usage.usage.estimatedCostUsd == null ? "—" : `$${usage.usage.estimatedCostUsd.toFixed(4)}`],
28
- ["Model", Object.keys(usage.usage.models ?? {}).join(", ") || "—"],
29
- ["Cache ratio", cacheRatio],
30
- ] as const;
30
+ const values: Array<{
31
+ accessibleValue?: string;
32
+ label: string;
33
+ title?: string;
34
+ value: string;
35
+ }> = [
36
+ {
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),
41
+ },
42
+ {
43
+ accessibleValue: usage.usage.estimatedCostUsd == null
44
+ ? "Estimated cost unavailable"
45
+ : `Estimated cost ${formatEstimatedCost(usage.usage.estimatedCostUsd)}`,
46
+ label: "Estimated cost",
47
+ title: usage.usage.estimatedCostUsd == null
48
+ ? undefined
49
+ : `Unrounded estimate: $${usage.usage.estimatedCostUsd}`,
50
+ value: formatEstimatedCost(usage.usage.estimatedCostUsd),
51
+ },
52
+ { label: "Model", value: Object.keys(usage.usage.models ?? {}).join(", ") || "—" },
53
+ { label: "Cache ratio", value: cacheRatio },
54
+ ];
31
55
  return (
32
56
  <Box aria-live="polite" className="taskchef-metrics">
33
- {values.map(([label, value]) => (
57
+ {values.map(({ accessibleValue, label, title, value }) => (
34
58
  <Paper className="taskchef-metric" key={label} p="sm" radius="md" withBorder>
35
59
  <Text c="dimmed" size="xs">{label}</Text>
36
- <Text className="taskchef-metric-value" fw={650} size="sm">{value}</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>
37
69
  </Paper>
38
70
  ))}
39
71
  </Box>
@@ -1,7 +1,10 @@
1
1
  import { describe, expect, test } from "vitest";
2
+ import { fixtureTask } from "./fixtures";
2
3
  import type { NotificationSnapshot } from "./types";
3
4
  import {
4
5
  clearNotificationHistory,
6
+ listUsageSignature,
7
+ mergeListTaskIntoDetail,
5
8
  notificationAppeared,
6
9
  notificationClicked,
7
10
  type OverlayState,
@@ -76,4 +79,32 @@ describe("overlay coordination", () => {
76
79
  const current = state({ detailTaskId: "task-one", detailOpened: true, notifications: [taskUpdate] });
77
80
  expect(clearNotificationHistory(current)).toEqual({ ...current, notifications: [] });
78
81
  });
82
+
83
+ test("a cache-only list update preserves rich detail usage and requests a refresh", () => {
84
+ const detail = fixtureTask({
85
+ usage: {
86
+ generationTurnRef: "turn-one",
87
+ status: "available",
88
+ task: { totalTokens: 1200, estimatedCostUsd: 0.24, models: { "gpt-5": {} } },
89
+ turns: { "turn-one": { status: "available", totalTokens: 1200 } },
90
+ updatedAt: "2026-08-30T08:00:00.000Z",
91
+ },
92
+ });
93
+ const list = fixtureTask({
94
+ turns: undefined,
95
+ results: undefined,
96
+ usage: {
97
+ generationTurnRef: "turn-one",
98
+ status: "available",
99
+ task: { totalTokens: 1800, estimatedCostUsd: 0.36 },
100
+ updatedAt: "2026-08-30T08:01:00.000Z",
101
+ },
102
+ });
103
+
104
+ const merged = mergeListTaskIntoDetail(detail, list);
105
+ expect(merged.usage).toBe(detail.usage);
106
+ expect(merged.turns).toBe(detail.turns);
107
+ expect(merged.results).toBe(detail.results);
108
+ expect(listUsageSignature(list)).not.toBe(listUsageSignature(detail));
109
+ });
79
110
  });
@@ -1,4 +1,4 @@
1
- import type { NotificationSnapshot } from "./types";
1
+ import type { NotificationSnapshot, Task } from "./types";
2
2
 
3
3
  export interface OverlayState {
4
4
  detailTaskId: string | null;
@@ -36,3 +36,25 @@ export function notificationClicked(
36
36
  export function clearNotificationHistory(state: OverlayState): OverlayState {
37
37
  return { ...state, notifications: [] };
38
38
  }
39
+
40
+ export function mergeListTaskIntoDetail(current: Task, updated: Task): Task {
41
+ const currentIsDetail = Array.isArray(current.turns) && Array.isArray(current.results);
42
+ const updatedIsList = !Array.isArray(updated.turns) && !Array.isArray(updated.results);
43
+ return {
44
+ ...current,
45
+ ...updated,
46
+ ...(currentIsDetail && updatedIsList
47
+ ? { results: current.results, turns: current.turns, usage: current.usage }
48
+ : {}),
49
+ };
50
+ }
51
+
52
+ export function listUsageSignature(task: Task | null | undefined): string {
53
+ return JSON.stringify([
54
+ task?.usage?.generationTurnRef ?? null,
55
+ task?.usage?.status ?? null,
56
+ task?.usage?.updatedAt ?? null,
57
+ task?.usage?.task?.totalTokens ?? null,
58
+ task?.usage?.task?.estimatedCostUsd ?? null,
59
+ ]);
60
+ }