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