taskchef 8.0.0 → 8.1.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.
- package/.codex-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/src/dashboard/dist/app.js +55 -35
- package/src/dashboard/dist/app.js.map +1 -1
- package/src/dashboard/dist/styles.css +1 -1
- package/src/dashboard/github-links.js +9 -1
- package/src/dashboard/react/components/RelativeTime.tsx +23 -5
- package/src/dashboard/react/components/TaskCard.tsx +4 -21
- package/src/dashboard/react/components/TaskCardStats.test.tsx +122 -0
- package/src/dashboard/react/components/TaskCardStats.tsx +86 -0
- package/src/dashboard/react/presentation.test.tsx +2 -2
- package/src/dashboard/react/presentation.ts +50 -20
- package/src/dashboard/react/styles.css +16 -11
- package/src/dashboard/react/components/TaskUsageSummary.test.tsx +0 -122
- package/src/dashboard/react/components/TaskUsageSummary.tsx +0 -24
|
@@ -78,7 +78,15 @@ export function groupRelatedGitHubLinks(links) {
|
|
|
78
78
|
group.push(link);
|
|
79
79
|
groups.set(repository, group);
|
|
80
80
|
}
|
|
81
|
-
|
|
81
|
+
const collisions = collidingRepositoryBasenames(links ?? []);
|
|
82
|
+
return [...groups.values()]
|
|
83
|
+
.map((group) => [...group].sort(compareReferenceNumbers))
|
|
84
|
+
.sort((left, right) => {
|
|
85
|
+
const leftLabel = visibleRepositoryLabel(left[0], collisions);
|
|
86
|
+
const rightLabel = visibleRepositoryLabel(right[0], collisions);
|
|
87
|
+
return leftLabel.localeCompare(rightLabel, undefined, { sensitivity: "base" })
|
|
88
|
+
|| repositoryIdentity(left[0]).localeCompare(repositoryIdentity(right[0]));
|
|
89
|
+
});
|
|
82
90
|
}
|
|
83
91
|
|
|
84
92
|
export function githubReferenceAccessibleLabel(link) {
|
|
@@ -9,19 +9,37 @@ export function RelativeTimeProvider({ children, now }: { children: ReactNode; n
|
|
|
9
9
|
return <RelativeTimeClock value={now}>{children}</RelativeTimeClock>;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export function RelativeTime({
|
|
12
|
+
export function RelativeTime({
|
|
13
|
+
icon,
|
|
14
|
+
label,
|
|
15
|
+
value,
|
|
16
|
+
}: {
|
|
17
|
+
icon?: ReactNode;
|
|
18
|
+
label: string;
|
|
19
|
+
value: string | null | undefined;
|
|
20
|
+
}) {
|
|
13
21
|
const [exact, setExact] = useState(false);
|
|
14
22
|
useContext(RelativeTimeClock);
|
|
15
23
|
const text = exact ? formatExactTime(value) : formatRelativeTime(value);
|
|
24
|
+
const exactText = formatExactTime(value);
|
|
25
|
+
const unavailable = exactText === "—";
|
|
26
|
+
const tooltip = exactText === "—"
|
|
27
|
+
? "Updated time unavailable"
|
|
28
|
+
: exact
|
|
29
|
+
? "Show relative time"
|
|
30
|
+
: `Exact time: ${exactText}. Show exact time`;
|
|
16
31
|
return (
|
|
17
|
-
<Tooltip label={
|
|
32
|
+
<Tooltip label={tooltip}>
|
|
18
33
|
<UnstyledButton
|
|
19
|
-
aria-label={
|
|
34
|
+
aria-label={unavailable
|
|
35
|
+
? `${label}: unavailable.`
|
|
36
|
+
: `${label}: ${text}. ${exact ? "Show relative time" : "Show exact time"}`}
|
|
20
37
|
className="taskchef-time"
|
|
38
|
+
disabled={unavailable}
|
|
21
39
|
onClick={() => setExact((value) => !value)}
|
|
22
40
|
>
|
|
23
|
-
<IconClock aria-hidden size={12} />
|
|
24
|
-
<
|
|
41
|
+
{icon ?? <IconClock aria-hidden size={12} />}
|
|
42
|
+
<bdi className="taskchef-time-label">{text}</bdi>
|
|
25
43
|
</UnstyledButton>
|
|
26
44
|
</Tooltip>
|
|
27
45
|
);
|
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
import { Box,
|
|
1
|
+
import { Box, Paper, Stack, Text, Title } from "@mantine/core";
|
|
2
2
|
import { latestTurnPresentation } from "../../state.js";
|
|
3
3
|
import type { Task } from "../types";
|
|
4
|
-
import { taskReportedWorkView } from "../presentation";
|
|
5
4
|
import { GitHubLinks } from "./GitHubLinks";
|
|
6
5
|
import { LinkedText } from "./LinkedText";
|
|
7
6
|
import { OpenChatButton } from "./OpenChatButton";
|
|
8
|
-
import { RelativeTime } from "./RelativeTime";
|
|
9
7
|
import { ShimmerText } from "./ShimmerText";
|
|
10
8
|
import { StatusBadge } from "./StatusBadge";
|
|
11
|
-
import {
|
|
9
|
+
import { TaskCardStats } from "./TaskCardStats";
|
|
12
10
|
|
|
13
11
|
export function TaskCard({
|
|
14
12
|
onOpenCodex,
|
|
@@ -20,7 +18,6 @@ export function TaskCard({
|
|
|
20
18
|
task: Task;
|
|
21
19
|
}) {
|
|
22
20
|
const latest = latestTurnPresentation(task);
|
|
23
|
-
const reportedWork = taskReportedWorkView(task);
|
|
24
21
|
return (
|
|
25
22
|
<Paper className="taskchef-task-row" component="article" p="md" radius="md" withBorder>
|
|
26
23
|
<Stack className="taskchef-task-main" gap="sm">
|
|
@@ -33,7 +30,7 @@ export function TaskCard({
|
|
|
33
30
|
</Title>
|
|
34
31
|
<Stack align="flex-end" className="taskchef-card-metadata" gap={4}>
|
|
35
32
|
<StatusBadge status={task.status} />
|
|
36
|
-
<
|
|
33
|
+
<OpenChatButton onClick={() => onOpenCodex(task)} taskTitle={task.title} />
|
|
37
34
|
</Stack>
|
|
38
35
|
</Box>
|
|
39
36
|
<Text c="dimmed" mt={2} size="xs">{task.project.name}</Text>
|
|
@@ -53,21 +50,7 @@ export function TaskCard({
|
|
|
53
50
|
</Box>
|
|
54
51
|
|
|
55
52
|
<GitHubLinks task={task} />
|
|
56
|
-
<
|
|
57
|
-
<Group className="taskchef-card-timing" gap="sm">
|
|
58
|
-
<RelativeTime label={`Updated time for ${task.title}`} value={task.meaningfulUpdatedAt ?? task.updatedAt} />
|
|
59
|
-
<Text
|
|
60
|
-
aria-label={reportedWork.accessibleLabel}
|
|
61
|
-
className="taskchef-card-duration"
|
|
62
|
-
size="xs"
|
|
63
|
-
title={reportedWork.title}
|
|
64
|
-
>
|
|
65
|
-
<span>Reported work</span>
|
|
66
|
-
<span>{reportedWork.value}</span>
|
|
67
|
-
</Text>
|
|
68
|
-
</Group>
|
|
69
|
-
<OpenChatButton onClick={() => onOpenCodex(task)} taskTitle={task.title} />
|
|
70
|
-
</Group>
|
|
53
|
+
<TaskCardStats task={task} />
|
|
71
54
|
</Stack>
|
|
72
55
|
</Paper>
|
|
73
56
|
);
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { MantineProvider } from "@mantine/core";
|
|
2
|
+
import { cleanup, fireEvent, render, screen, within } 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 { TaskCardStats } from "./TaskCardStats";
|
|
7
|
+
|
|
8
|
+
afterEach(cleanup);
|
|
9
|
+
|
|
10
|
+
function renderStats(task = fixtureTask()) {
|
|
11
|
+
return render(<MantineProvider><TaskCardStats task={task} /></MantineProvider>);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("task card stats", () => {
|
|
15
|
+
test("renders the four metrics in order with compact values and full accessibility", () => {
|
|
16
|
+
const { container } = renderStats(fixtureTask({
|
|
17
|
+
status: "completed",
|
|
18
|
+
turns: undefined,
|
|
19
|
+
reportedWork: { terminalTurns: 1, validTurns: 1, totalMilliseconds: 1_112_000 },
|
|
20
|
+
usage: {
|
|
21
|
+
status: "available",
|
|
22
|
+
updatedAt: "2026-08-30T09:00:00.000Z",
|
|
23
|
+
task: {
|
|
24
|
+
totalTokens: 1_324_567,
|
|
25
|
+
estimatedCostUsd: 12.3449,
|
|
26
|
+
sourceUpdatedAt: "2026-08-30T09:00:00.000Z",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
const stats = container.querySelector(".taskchef-card-stats") as HTMLElement;
|
|
31
|
+
expect(stats).not.toBeNull();
|
|
32
|
+
const cells = [...stats.children] as HTMLElement[];
|
|
33
|
+
expect(cells).toHaveLength(4);
|
|
34
|
+
expect(within(cells[0]).getByLabelText(/updated time/i)).toBeVisible();
|
|
35
|
+
expect(within(cells[1]).getByLabelText(/reported work 18m 32s/i)).toBeVisible();
|
|
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");
|
|
38
|
+
expect(within(cells[2]).getByRole("status")).toHaveTextContent(/1,324,567 tokens.*estimated cost \$12\.34/i);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("keeps known tokens when cost is missing and never substitutes zero", () => {
|
|
42
|
+
renderStats(fixtureTask({
|
|
43
|
+
status: "completed",
|
|
44
|
+
usage: { status: "available", task: { totalTokens: 1_324_567, estimatedCostUsd: null } },
|
|
45
|
+
}));
|
|
46
|
+
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
|
+
});
|
|
49
|
+
|
|
50
|
+
test("shows metric details to keyboard users on focus", () => {
|
|
51
|
+
vi.useFakeTimers();
|
|
52
|
+
renderStats(fixtureTask({
|
|
53
|
+
status: "completed",
|
|
54
|
+
usage: { status: "available", task: { totalTokens: 1_324_567, estimatedCostUsd: 12.3449 } },
|
|
55
|
+
}));
|
|
56
|
+
const cost = screen.getByLabelText(/estimated cost \$12\.34/i);
|
|
57
|
+
fireEvent.focus(cost);
|
|
58
|
+
vi.advanceTimersByTime(150);
|
|
59
|
+
expect(screen.getByRole("tooltip")).toHaveTextContent("Unrounded estimate: $12.3449");
|
|
60
|
+
vi.useRealTimers();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("shows pending, calculating, and unavailable states in both usage cells", () => {
|
|
64
|
+
const { rerender } = renderStats();
|
|
65
|
+
expect(screen.getByLabelText("Tokens pending")).toHaveTextContent("Pending");
|
|
66
|
+
expect(screen.getByLabelText("Estimated cost pending")).toHaveTextContent("Pending");
|
|
67
|
+
expect(screen.getAllByText("Pending").every((node) => node.classList.contains("taskchef-shimmer"))).toBe(true);
|
|
68
|
+
|
|
69
|
+
rerender(<MantineProvider><TaskCardStats task={fixtureTask({
|
|
70
|
+
status: "completed",
|
|
71
|
+
usage: { generationTurnRef: "turn-one", status: "calculating" },
|
|
72
|
+
})} /></MantineProvider>);
|
|
73
|
+
expect(screen.getByLabelText("Tokens calculating")).toHaveTextContent("Calculating");
|
|
74
|
+
expect(screen.getByLabelText("Estimated cost calculating")).toHaveTextContent("Calculating");
|
|
75
|
+
|
|
76
|
+
rerender(<MantineProvider><TaskCardStats task={fixtureTask({
|
|
77
|
+
status: "completed",
|
|
78
|
+
usage: { status: "unavailable", reason: "No matching cached boundary." },
|
|
79
|
+
})} /></MantineProvider>);
|
|
80
|
+
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
|
+
});
|
|
83
|
+
|
|
84
|
+
test("retains known totals and marks both usage values as updating", () => {
|
|
85
|
+
renderStats(fixtureTask({
|
|
86
|
+
status: "working",
|
|
87
|
+
turnRef: "turn-two",
|
|
88
|
+
usage: {
|
|
89
|
+
generationTurnRef: "turn-one",
|
|
90
|
+
status: "calculating",
|
|
91
|
+
task: { totalTokens: 1_324_567, estimatedCostUsd: 12.3449 },
|
|
92
|
+
},
|
|
93
|
+
}));
|
|
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…");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("places Open chat beneath the status badge and keeps callbacks working", () => {
|
|
99
|
+
const openChat = vi.fn();
|
|
100
|
+
const openDetail = vi.fn();
|
|
101
|
+
const { container } = render(<MantineProvider><TaskCard
|
|
102
|
+
onOpenCodex={openChat}
|
|
103
|
+
onOpenDetail={openDetail}
|
|
104
|
+
task={fixtureTask()}
|
|
105
|
+
/></MantineProvider>);
|
|
106
|
+
const metadata = container.querySelector(".taskchef-card-metadata") as HTMLElement;
|
|
107
|
+
expect(metadata.children[0]).toHaveClass("taskchef-status-badge");
|
|
108
|
+
expect(within(metadata).getByRole("button", { name: /open chat/i })).toBe(metadata.children[1]);
|
|
109
|
+
fireEvent.click(within(metadata).getByRole("button", { name: /open chat/i }));
|
|
110
|
+
fireEvent.click(screen.getByRole("button", { name: /^review checkout reconciliation$/i }));
|
|
111
|
+
expect(openChat).toHaveBeenCalledOnce();
|
|
112
|
+
expect(openDetail).toHaveBeenCalledOnce();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("toggles the updated time while keeping the selected History icon", () => {
|
|
116
|
+
const { container } = renderStats(fixtureTask({ meaningfulUpdatedAt: "2026-08-30T08:00:00.000Z" }));
|
|
117
|
+
const time = screen.getByLabelText(/updated time for review checkout reconciliation/i);
|
|
118
|
+
expect(container.querySelector(".tabler-icon-history")).not.toBeNull();
|
|
119
|
+
fireEvent.click(time);
|
|
120
|
+
expect(time).toHaveAccessibleName(/show relative time/i);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Box, Text, Tooltip } from "@mantine/core";
|
|
2
|
+
import {
|
|
3
|
+
IconCurrencyDollar,
|
|
4
|
+
IconHistory,
|
|
5
|
+
IconHourglass,
|
|
6
|
+
IconStack2,
|
|
7
|
+
} from "@tabler/icons-react";
|
|
8
|
+
import type { ReactNode } from "react";
|
|
9
|
+
import type { Task } from "../types";
|
|
10
|
+
import { listUsageMetricsView, taskReportedWorkView } from "../presentation";
|
|
11
|
+
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
|
+
}
|
|
42
|
+
|
|
43
|
+
export function TaskCardStats({ task }: { task: Task }) {
|
|
44
|
+
const reportedWork = taskReportedWorkView(task);
|
|
45
|
+
const usage = listUsageMetricsView(task);
|
|
46
|
+
return (
|
|
47
|
+
<Box className="taskchef-card-stats" component="div">
|
|
48
|
+
<Box className="taskchef-card-stat">
|
|
49
|
+
<RelativeTime
|
|
50
|
+
icon={<IconHistory aria-hidden size={14} />}
|
|
51
|
+
label={`Updated time for ${task.title}`}
|
|
52
|
+
value={task.meaningfulUpdatedAt ?? task.updatedAt}
|
|
53
|
+
/>
|
|
54
|
+
</Box>
|
|
55
|
+
<Box className="taskchef-card-stat">
|
|
56
|
+
<StaticStat
|
|
57
|
+
accessibleLabel={reportedWork.accessibleLabel}
|
|
58
|
+
icon={<IconHourglass aria-hidden size={14} />}
|
|
59
|
+
title={reportedWork.title}
|
|
60
|
+
value={reportedWork.value}
|
|
61
|
+
/>
|
|
62
|
+
</Box>
|
|
63
|
+
<Box className="taskchef-card-stat" data-usage-state={usage.kind}>
|
|
64
|
+
<StaticStat
|
|
65
|
+
accessibleLabel={usage.tokens.accessibleLabel}
|
|
66
|
+
animated={usage.animated}
|
|
67
|
+
icon={<IconStack2 aria-hidden size={14} />}
|
|
68
|
+
title={usage.tokens.title}
|
|
69
|
+
value={usage.tokens.value}
|
|
70
|
+
/>
|
|
71
|
+
<Text className="taskchef-visually-hidden" component="span" role="status">
|
|
72
|
+
{usage.tokens.accessibleLabel}; {usage.cost.accessibleLabel}
|
|
73
|
+
</Text>
|
|
74
|
+
</Box>
|
|
75
|
+
<Box className="taskchef-card-stat" data-usage-state={usage.kind}>
|
|
76
|
+
<StaticStat
|
|
77
|
+
accessibleLabel={usage.cost.accessibleLabel}
|
|
78
|
+
animated={usage.animated}
|
|
79
|
+
icon={<IconCurrencyDollar aria-hidden size={14} />}
|
|
80
|
+
title={usage.cost.title}
|
|
81
|
+
value={usage.cost.value}
|
|
82
|
+
/>
|
|
83
|
+
</Box>
|
|
84
|
+
</Box>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -271,9 +271,9 @@ describe("token and working presentation", () => {
|
|
|
271
271
|
const groups = container.querySelectorAll(".taskchef-github-group");
|
|
272
272
|
expect(groups).toHaveLength(2);
|
|
273
273
|
expect(within(groups[0] as HTMLElement).getAllByRole("link").map((link) => link.textContent))
|
|
274
|
-
.toEqual(["taskchef #79", "#80", "#83"]);
|
|
275
|
-
expect(within(groups[1] as HTMLElement).getAllByRole("link").map((link) => link.textContent))
|
|
276
274
|
.toEqual(["guzuoshou-workspace #108", "#109", "#124"]);
|
|
275
|
+
expect(within(groups[1] as HTMLElement).getAllByRole("link").map((link) => link.textContent))
|
|
276
|
+
.toEqual(["taskchef #79", "#80", "#83"]);
|
|
277
277
|
const first = screen.getByRole("link", { name: /favoyang\/taskchef Issue #79/ });
|
|
278
278
|
expect(first).toHaveAttribute("href", "https://github.com/favoyang/taskchef/issues/79");
|
|
279
279
|
expect(first).toHaveAttribute("target", "_blank");
|
|
@@ -237,36 +237,60 @@ export function usageStillCalculating(task: Task) {
|
|
|
237
237
|
|| Object.values(task.usage?.turns ?? {}).some((turn) => turn.status === "calculating"));
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
-
export type
|
|
240
|
+
export type ListUsageMetric = {
|
|
241
241
|
accessibleLabel: string;
|
|
242
|
-
kind: "pending" | "calculating" | "ready" | "unavailable";
|
|
243
|
-
label: string;
|
|
244
242
|
title: string;
|
|
243
|
+
value: string;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
export type ListUsageMetricsView = {
|
|
247
|
+
animated: boolean;
|
|
248
|
+
cost: ListUsageMetric;
|
|
249
|
+
kind: "pending" | "calculating" | "ready" | "unavailable";
|
|
250
|
+
tokens: ListUsageMetric;
|
|
245
251
|
};
|
|
246
252
|
|
|
247
|
-
export function
|
|
253
|
+
export function listUsageMetricsView(task: Task): ListUsageMetricsView {
|
|
248
254
|
const view = usageView(task);
|
|
249
255
|
if (view.kind === "pending" || view.kind === "calculating") {
|
|
256
|
+
const value = view.kind === "pending" ? "Pending" : "Calculating";
|
|
250
257
|
return {
|
|
251
|
-
|
|
258
|
+
animated: true,
|
|
259
|
+
cost: {
|
|
260
|
+
accessibleLabel: `Estimated cost ${value.toLowerCase()}`,
|
|
261
|
+
title: view.label,
|
|
262
|
+
value,
|
|
263
|
+
},
|
|
252
264
|
kind: view.kind,
|
|
253
|
-
|
|
254
|
-
|
|
265
|
+
tokens: {
|
|
266
|
+
accessibleLabel: `Tokens ${value.toLowerCase()}`,
|
|
267
|
+
title: view.label,
|
|
268
|
+
value,
|
|
269
|
+
},
|
|
255
270
|
};
|
|
256
271
|
}
|
|
257
272
|
if (view.kind === "unavailable") {
|
|
273
|
+
const reason = view.label === "Token usage unavailable" ? "" : `: ${view.label}`;
|
|
258
274
|
return {
|
|
259
|
-
|
|
275
|
+
animated: false,
|
|
276
|
+
cost: {
|
|
277
|
+
accessibleLabel: `Estimated cost unavailable${reason}`,
|
|
278
|
+
title: view.label,
|
|
279
|
+
value: "est. n/a",
|
|
280
|
+
},
|
|
260
281
|
kind: "unavailable",
|
|
261
|
-
|
|
262
|
-
|
|
282
|
+
tokens: {
|
|
283
|
+
accessibleLabel: `Token usage unavailable${reason}`,
|
|
284
|
+
title: view.label,
|
|
285
|
+
value: `${NOT_AVAILABLE} tokens`,
|
|
286
|
+
},
|
|
263
287
|
};
|
|
264
288
|
}
|
|
265
289
|
const compactTokens = formatCompactTokens(view.usage.totalTokens);
|
|
266
290
|
const fullTokens = formatFullTokens(view.usage.totalTokens);
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
291
|
+
const hasCost = typeof view.usage.estimatedCostUsd === "number"
|
|
292
|
+
&& Number.isFinite(view.usage.estimatedCostUsd);
|
|
293
|
+
const formattedCost = hasCost ? formatEstimatedCost(view.usage.estimatedCostUsd) : null;
|
|
270
294
|
const qualifier = view.knownSoFar ? " · Updating…" : "";
|
|
271
295
|
const accessibleQualifier = view.knownSoFar ? "; updating" : "";
|
|
272
296
|
const freshness = view.usage.sourceUpdatedAt ?? view.usage.sampledAt ?? task.usage?.updatedAt ?? null;
|
|
@@ -274,14 +298,20 @@ export function listUsageView(task: Task): ListUsageView {
|
|
|
274
298
|
? ` Cached usage updated ${new Date(freshness).toLocaleString()}.`
|
|
275
299
|
: "";
|
|
276
300
|
return {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
: `
|
|
301
|
+
animated: false,
|
|
302
|
+
cost: {
|
|
303
|
+
accessibleLabel: `Estimated cost ${formattedCost ?? "unavailable"}${accessibleQualifier}.${freshnessLabel}`,
|
|
304
|
+
title: `${formattedCost === null
|
|
305
|
+
? "Estimated cost unavailable"
|
|
306
|
+
: `Unrounded estimate: $${view.usage.estimatedCostUsd}`}${qualifier}.${freshnessLabel}`,
|
|
307
|
+
value: `${formattedCost === null ? "est. n/a" : `est. ${formattedCost}`}${qualifier}`,
|
|
308
|
+
},
|
|
280
309
|
kind: "ready",
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
:
|
|
310
|
+
tokens: {
|
|
311
|
+
accessibleLabel: `${fullTokens} tokens${accessibleQualifier}.${freshnessLabel}`,
|
|
312
|
+
title: `${fullTokens} tokens${qualifier}.${freshnessLabel}`,
|
|
313
|
+
value: `${compactTokens} tokens${qualifier}`,
|
|
314
|
+
},
|
|
285
315
|
};
|
|
286
316
|
}
|
|
287
317
|
|
|
@@ -31,13 +31,12 @@ body {
|
|
|
31
31
|
.taskchef-toolbar .mantine-InputWrapper-label, .taskchef-filter-label { font-size: var(--mantine-font-size-sm); font-weight: 600; line-height: var(--mantine-line-height-sm); }
|
|
32
32
|
.taskchef-toolbar .mantine-SegmentedControl-root { max-width: 650px; }
|
|
33
33
|
.taskchef-results-summary { margin-top: 12px; color: var(--taskchef-muted); font-size: var(--mantine-font-size-xs); line-height: 1.35; white-space: nowrap; }
|
|
34
|
-
.taskchef-task-row { background: light-dark(#fff, #202421); box-shadow: 0 1px 0 light-dark(rgb(22 31 25 / 3%), transparent); }
|
|
34
|
+
.taskchef-task-row { container-type: inline-size; background: light-dark(#fff, #202421); box-shadow: 0 1px 0 light-dark(rgb(22 31 25 / 3%), transparent); }
|
|
35
35
|
.taskchef-task-row:hover { border-color: light-dark(var(--mantine-color-teal-4), var(--mantine-color-teal-8)); }
|
|
36
36
|
.taskchef-task-main { min-width: 0; flex: 1; }
|
|
37
37
|
.taskchef-card-heading { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: var(--mantine-spacing-sm); }
|
|
38
|
-
.taskchef-card-metadata { width: max-content; min-width: 0; max-width: min(25rem,
|
|
38
|
+
.taskchef-card-metadata { width: max-content; min-width: 0; max-width: min(25rem, 45cqw); }
|
|
39
39
|
.taskchef-status-badge { flex: none; font-size: var(--mantine-font-size-xs); }
|
|
40
|
-
.taskchef-task-usage { max-width: 100%; text-align: right; overflow-wrap: anywhere; }
|
|
41
40
|
.taskchef-title-button {
|
|
42
41
|
all: unset;
|
|
43
42
|
border-radius: 3px;
|
|
@@ -57,12 +56,15 @@ body {
|
|
|
57
56
|
.taskchef-github-group { min-width: 0; }
|
|
58
57
|
.taskchef-github-reference-ambiguous { border-bottom: 2px dotted var(--mantine-color-yellow-7); cursor: help; }
|
|
59
58
|
.taskchef-github-reference-ambiguous:focus-visible { outline: 2px solid var(--mantine-color-yellow-7); outline-offset: 2px; }
|
|
60
|
-
.taskchef-time, .taskchef-card-
|
|
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; }
|
|
61
60
|
.taskchef-time:hover { color: var(--taskchef-text); background: var(--taskchef-surface-muted); }
|
|
62
61
|
.taskchef-time:focus-visible { outline: 2px solid var(--mantine-color-teal-6); outline-offset: 2px; }
|
|
63
|
-
.taskchef-time-label {
|
|
64
|
-
.taskchef-card-
|
|
65
|
-
.taskchef-card-
|
|
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); }
|
|
66
68
|
.taskchef-empty { border-style: dashed; background: transparent; }
|
|
67
69
|
|
|
68
70
|
.taskchef-notifications {
|
|
@@ -137,10 +139,13 @@ body {
|
|
|
137
139
|
@media (max-width: 34em) {
|
|
138
140
|
.taskchef-header { padding-top: 22px; padding-bottom: 18px; }
|
|
139
141
|
.taskchef-brand-icon, .taskchef-brand-icon img { width: 36px; height: 36px; }
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
.taskchef-card-
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
@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); }
|
|
144
149
|
}
|
|
145
150
|
|
|
146
151
|
.taskchef-refresh-spinning {
|
|
@@ -1,122 +0,0 @@
|
|
|
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 n/a")).toHaveAccessibleName(
|
|
40
|
-
/1,324,567 tokens; estimated cost unavailable/i,
|
|
41
|
-
);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test("keeps the known task total visible while an active turn updates usage", () => {
|
|
45
|
-
renderUsage(fixtureTask({
|
|
46
|
-
status: "working",
|
|
47
|
-
turnRef: "turn-two",
|
|
48
|
-
usage: {
|
|
49
|
-
generationTurnRef: "turn-one",
|
|
50
|
-
status: "unavailable",
|
|
51
|
-
reason: "Token usage calculation was interrupted.",
|
|
52
|
-
task: { totalTokens: 1_324_567, estimatedCostUsd: 12.3449 },
|
|
53
|
-
},
|
|
54
|
-
}));
|
|
55
|
-
const summary = screen.getByText("1.32M tokens · est. $12.34 · Updating…");
|
|
56
|
-
expect(summary).toHaveAttribute("data-usage-state", "ready");
|
|
57
|
-
expect(summary).toHaveAccessibleName(/1,324,567 tokens; estimated cost \$12\.34; updating/i);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("keeps pending and calculating wording distinct and uses the restrained shimmer", () => {
|
|
61
|
-
const { rerender } = renderUsage();
|
|
62
|
-
expect(screen.getByText("Token usage pending")).toHaveClass("taskchef-shimmer");
|
|
63
|
-
rerender(
|
|
64
|
-
<MantineProvider>
|
|
65
|
-
<TaskUsageSummary task={fixtureTask({
|
|
66
|
-
status: "completed",
|
|
67
|
-
usage: { generationTurnRef: "turn-one", status: "calculating" },
|
|
68
|
-
})} />
|
|
69
|
-
</MantineProvider>,
|
|
70
|
-
);
|
|
71
|
-
expect(screen.getByText("Calculating token usage")).toHaveClass("taskchef-shimmer");
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test("shows a quiet explicit unavailable state with the cache reason in its tooltip", () => {
|
|
75
|
-
renderUsage(fixtureTask({
|
|
76
|
-
status: "completed",
|
|
77
|
-
usage: { status: "unavailable", reason: "No matching cached boundary." },
|
|
78
|
-
}));
|
|
79
|
-
const summary = screen.getByText("n/a tokens");
|
|
80
|
-
expect(summary).toHaveAttribute("data-usage-state", "unavailable");
|
|
81
|
-
expect(summary).toHaveAttribute("title", "No matching cached boundary.");
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test("places usage directly beneath the primary status badge in a responsive metadata block", () => {
|
|
85
|
-
const { container } = render(
|
|
86
|
-
<MantineProvider>
|
|
87
|
-
<TaskCard
|
|
88
|
-
onOpenCodex={vi.fn()}
|
|
89
|
-
onOpenDetail={vi.fn()}
|
|
90
|
-
task={fixtureTask()}
|
|
91
|
-
/>
|
|
92
|
-
</MantineProvider>,
|
|
93
|
-
);
|
|
94
|
-
const metadata = container.querySelector(".taskchef-card-metadata");
|
|
95
|
-
expect(metadata).not.toBeNull();
|
|
96
|
-
expect(metadata?.children[0]).toHaveClass("taskchef-status-badge");
|
|
97
|
-
expect(metadata?.children[1]).toHaveClass("taskchef-task-usage");
|
|
98
|
-
expect(screen.getByRole("button", { name: /^review checkout reconciliation$/i })).toBeVisible();
|
|
99
|
-
expect(screen.getByRole("button", { name: /open chat/i })).toBeVisible();
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test("places reported work beside the updated time with matching typography", () => {
|
|
103
|
-
const { container } = render(
|
|
104
|
-
<MantineProvider>
|
|
105
|
-
<TaskCard
|
|
106
|
-
onOpenCodex={vi.fn()}
|
|
107
|
-
onOpenDetail={vi.fn()}
|
|
108
|
-
task={fixtureTask({
|
|
109
|
-
turns: undefined,
|
|
110
|
-
reportedWork: { terminalTurns: 1, validTurns: 1, totalMilliseconds: 1_112_000 },
|
|
111
|
-
})}
|
|
112
|
-
/>
|
|
113
|
-
</MantineProvider>,
|
|
114
|
-
);
|
|
115
|
-
const timing = container.querySelector(".taskchef-card-timing");
|
|
116
|
-
expect(timing).not.toBeNull();
|
|
117
|
-
expect(timing?.children[0]).toHaveClass("taskchef-time");
|
|
118
|
-
expect(timing?.children[1]).toHaveClass("taskchef-card-duration");
|
|
119
|
-
expect(screen.getByText("Reported work")).not.toHaveClass("taskchef-field-label");
|
|
120
|
-
expect(timing?.children[1]?.lastChild).toHaveTextContent("18m 32s");
|
|
121
|
-
});
|
|
122
|
-
});
|
|
@@ -1,24 +0,0 @@
|
|
|
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
|
-
}
|