taskchef 7.24.1 → 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.
@@ -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: "" },
@@ -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(
@@ -7,6 +7,7 @@ import { LinkedText } from "./LinkedText";
7
7
  import { RelativeTime } from "./RelativeTime";
8
8
  import { ShimmerText } from "./ShimmerText";
9
9
  import { StatusBadge } from "./StatusBadge";
10
+ import { TaskUsageSummary } from "./TaskUsageSummary";
10
11
 
11
12
  export function TaskCard({
12
13
  onOpenCodex,
@@ -22,14 +23,17 @@ export function TaskCard({
22
23
  <Paper className="taskchef-task-row" component="article" p="md" radius="md" withBorder>
23
24
  <Stack className="taskchef-task-main" gap="sm">
24
25
  <Box>
25
- <Group align="flex-start" gap="sm" justify="space-between" wrap="nowrap">
26
+ <Box className="taskchef-card-heading">
26
27
  <Title order={3} size="h5">
27
28
  <button className="taskchef-title-button" onClick={() => onOpenDetail(task)} type="button">
28
29
  {task.title}
29
30
  </button>
30
31
  </Title>
31
- <StatusBadge status={task.status} />
32
- </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>
33
37
  <Text c="dimmed" mt={2} size="xs">{task.project.name}</Text>
34
38
  </Box>
35
39
 
@@ -47,7 +51,7 @@ export function TaskCard({
47
51
  </Box>
48
52
 
49
53
  <GitHubLinks task={task} />
50
- <Group gap="md" justify="space-between">
54
+ <Group className="taskchef-card-footer" gap="md" justify="space-between">
51
55
  <RelativeTime label={`Updated time for ${task.title}`} value={task.meaningfulUpdatedAt ?? task.updatedAt} />
52
56
  <Button
53
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,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
+ }
@@ -89,6 +89,53 @@ export function usageStillCalculating(task: Task) {
89
89
  || Object.values(task.usage?.turns ?? {}).some((turn) => turn.status === "calculating"));
90
90
  }
91
91
 
92
+ export type ListUsageView = {
93
+ accessibleLabel: string;
94
+ kind: "pending" | "calculating" | "ready" | "unavailable";
95
+ label: string;
96
+ title: string;
97
+ };
98
+
99
+ export function listUsageView(task: Task): ListUsageView {
100
+ const view = usageView(task);
101
+ if (view.kind === "pending" || view.kind === "calculating") {
102
+ return {
103
+ accessibleLabel: view.label,
104
+ kind: view.kind,
105
+ label: view.label,
106
+ title: view.label,
107
+ };
108
+ }
109
+ if (view.kind === "unavailable") {
110
+ return {
111
+ accessibleLabel: `Token usage unavailable${view.label === "Token usage unavailable" ? "" : `: ${view.label}`}`,
112
+ kind: "unavailable",
113
+ label: "Token usage unavailable",
114
+ title: view.label,
115
+ };
116
+ }
117
+ const compactTokens = formatCompactTokens(view.usage.totalTokens);
118
+ const fullTokens = formatFullTokens(view.usage.totalTokens);
119
+ const cost = view.usage.estimatedCostUsd == null
120
+ ? "cost unavailable"
121
+ : `est. ${formatEstimatedCost(view.usage.estimatedCostUsd)}`;
122
+ const qualifier = view.knownSoFar ? " · known so far" : "";
123
+ const freshness = view.usage.sourceUpdatedAt ?? view.usage.sampledAt ?? task.usage?.updatedAt ?? null;
124
+ const freshnessLabel = freshness && !Number.isNaN(Date.parse(freshness))
125
+ ? ` Cached usage updated ${new Date(freshness).toLocaleString()}.`
126
+ : "";
127
+ return {
128
+ accessibleLabel: `${fullTokens} tokens; ${view.usage.estimatedCostUsd == null
129
+ ? "estimated cost unavailable"
130
+ : `estimated cost ${formatEstimatedCost(view.usage.estimatedCostUsd)}`}${view.knownSoFar ? "; known so far" : ""}.${freshnessLabel}`,
131
+ kind: "ready",
132
+ label: `${compactTokens} tokens · ${cost}${qualifier}`,
133
+ title: `${fullTokens} tokens · ${view.usage.estimatedCostUsd == null
134
+ ? "estimated cost unavailable"
135
+ : `unrounded estimate $${view.usage.estimatedCostUsd}`}${view.knownSoFar ? " · known so far" : ""}.${freshnessLabel}`,
136
+ };
137
+ }
138
+
92
139
  export function statusColor(status: Task["status"] | "interrupted") {
93
140
  if (status === "completed") return "teal";
94
141
  if (status === "needs_input") return "yellow";
@@ -33,7 +33,10 @@ body {
33
33
  .taskchef-task-row { background: light-dark(#fff, #202421); box-shadow: 0 1px 0 light-dark(rgb(22 31 25 / 3%), transparent); }
34
34
  .taskchef-task-row:hover { border-color: light-dark(var(--mantine-color-teal-4), var(--mantine-color-teal-8)); }
35
35
  .taskchef-task-main { min-width: 0; flex: 1; }
36
+ .taskchef-card-heading { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: var(--mantine-spacing-sm); }
37
+ .taskchef-card-metadata { width: max-content; min-width: 0; max-width: min(25rem, 45vw); }
36
38
  .taskchef-status-badge { flex: none; }
39
+ .taskchef-task-usage { max-width: 100%; text-align: right; line-height: 1.25; overflow-wrap: anywhere; }
37
40
  .taskchef-title-button {
38
41
  all: unset;
39
42
  border-radius: 3px;
@@ -125,4 +128,8 @@ body {
125
128
  @media (max-width: 34em) {
126
129
  .taskchef-header { padding-top: 22px; padding-bottom: 18px; }
127
130
  .taskchef-brand-icon, .taskchef-brand-icon img { width: 36px; height: 36px; }
131
+ .taskchef-card-heading { grid-template-columns: minmax(0, 1fr); }
132
+ .taskchef-card-metadata { align-items: flex-start; width: 100%; max-width: 100%; }
133
+ .taskchef-task-usage { text-align: left; }
134
+ .taskchef-card-footer { align-items: flex-start; flex-direction: column; }
128
135
  }
@@ -41,6 +41,8 @@ export interface UsageNumbers {
41
41
  reasoningOutputTokens?: number;
42
42
  totalTokens: number;
43
43
  estimatedCostUsd?: number | null;
44
+ sampledAt?: string | null;
45
+ sourceUpdatedAt?: string | null;
44
46
  models?: Record<string, unknown>;
45
47
  }
46
48
 
@@ -54,6 +56,7 @@ export interface UsageProjection {
54
56
  }>;
55
57
  reason?: string;
56
58
  message?: string;
59
+ updatedAt?: string | null;
57
60
  }
58
61
 
59
62
  export interface Task {