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.
- package/.codex-plugin/plugin.json +1 -1
- package/index.js +2 -0
- package/package.json +1 -1
- package/src/dashboard/dist/app.js +40 -40
- package/src/dashboard/dist/app.js.map +1 -1
- package/src/dashboard/dist/styles.css +1 -1
- package/src/dashboard/github-links.js +12 -4
- package/src/dashboard/react/App.tsx +27 -8
- package/src/dashboard/react/components/ActivityTimeline.tsx +5 -2
- package/src/dashboard/react/components/GitHubLinks.tsx +37 -16
- package/src/dashboard/react/components/LinkedText.tsx +63 -0
- package/src/dashboard/react/components/NotificationCenter.test.tsx +6 -1
- package/src/dashboard/react/components/TaskCard.tsx +13 -6
- package/src/dashboard/react/components/TaskUsageSummary.test.tsx +85 -0
- package/src/dashboard/react/components/TaskUsageSummary.tsx +24 -0
- package/src/dashboard/react/components/UsagePanel.tsx +41 -9
- package/src/dashboard/react/overlay-state.test.ts +31 -0
- package/src/dashboard/react/overlay-state.ts +23 -1
- package/src/dashboard/react/presentation.test.tsx +119 -6
- package/src/dashboard/react/presentation.ts +73 -4
- package/src/dashboard/react/styles.css +12 -0
- package/src/dashboard/react/types.ts +3 -0
- package/src/dashboard.js +243 -15
- package/src/usage.js +3 -3
|
@@ -60,12 +60,20 @@ function compareReferenceNumbers(left, right) {
|
|
|
60
60
|
|
|
61
61
|
export function groupRelatedGitHubLinks(links) {
|
|
62
62
|
const groups = new Map();
|
|
63
|
-
const
|
|
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
|
-
|
|
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 {
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
if (
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
}, [
|
|
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">
|
|
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
|
|
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
|
-
<
|
|
9
|
-
{
|
|
10
|
-
<
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
</
|
|
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(
|
|
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
|
-
<
|
|
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
|
-
<
|
|
31
|
-
|
|
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">
|
|
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 {
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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((
|
|
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
|
|
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
|
+
}
|