taskchef 8.1.0 → 8.2.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/README.md +2 -2
- package/docs/spec.md +9 -6
- package/package.json +1 -1
- package/src/dashboard/app.js +0 -3
- 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/react/components/ActivityTimeline.tsx +43 -55
- package/src/dashboard/react/components/StatsGrid.tsx +60 -0
- package/src/dashboard/react/components/TaskCardStats.test.tsx +5 -4
- package/src/dashboard/react/components/TaskCardStats.tsx +12 -42
- package/src/dashboard/react/components/TaskDetail.test.tsx +19 -15
- package/src/dashboard/react/presentation.ts +18 -9
- package/src/dashboard/react/styles.css +12 -20
- package/src/usage.js +11 -19
|
@@ -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 }) {
|
|
@@ -29,13 +36,7 @@ export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef:
|
|
|
29
36
|
withBorder
|
|
30
37
|
>
|
|
31
38
|
<Stack gap={7}>
|
|
32
|
-
<
|
|
33
|
-
<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
|
-
</Box>
|
|
39
|
+
<StatusBadge status={presentation.status} />
|
|
39
40
|
{presentation.sourceLabel && <Text c="dimmed" size="xs">{presentation.sourceLabel}</Text>}
|
|
40
41
|
<Box>
|
|
41
42
|
<Text c="dimmed" className="taskchef-field-label" size="xs">Request</Text>
|
|
@@ -51,22 +52,36 @@ export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef:
|
|
|
51
52
|
: <LinkedText task={task} text={presentation.summary} />}
|
|
52
53
|
</Text>
|
|
53
54
|
</Box>
|
|
54
|
-
<
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
55
|
+
<StatsGrid variant="turn">
|
|
56
|
+
<StatCell>
|
|
57
|
+
<RelativeTime
|
|
58
|
+
icon={<IconHistory aria-hidden size={14} />}
|
|
59
|
+
label="Turn update time"
|
|
60
|
+
value={presentation.updatedAt}
|
|
61
|
+
/>
|
|
62
|
+
</StatCell>
|
|
63
|
+
<StatCell>
|
|
64
|
+
<TurnReportedWork turn={turn} />
|
|
65
|
+
</StatCell>
|
|
66
|
+
<StatCell usageState={usage.kind}>
|
|
67
|
+
<StaticStat
|
|
68
|
+
accessibleLabel={usage.tokens.accessibleLabel}
|
|
69
|
+
animated={usage.animated}
|
|
70
|
+
icon={<IconStack2 aria-hidden size={14} />}
|
|
71
|
+
title={usage.title}
|
|
72
|
+
value={usage.tokens.value}
|
|
73
|
+
/>
|
|
74
|
+
</StatCell>
|
|
75
|
+
<StatCell usageState={usage.kind}>
|
|
76
|
+
<StaticStat
|
|
77
|
+
accessibleLabel={usage.cost.accessibleLabel}
|
|
78
|
+
animated={usage.animated}
|
|
79
|
+
icon={<IconCurrencyDollar aria-hidden size={14} />}
|
|
80
|
+
title={usage.title}
|
|
81
|
+
value={usage.cost.value}
|
|
82
|
+
/>
|
|
83
|
+
</StatCell>
|
|
84
|
+
</StatsGrid>
|
|
70
85
|
{usage.note && <Text c="dimmed" size="xs">{usage.note}</Text>}
|
|
71
86
|
<Text c="dimmed" className="taskchef-mono" size="xs">Turn ref: {identity ?? "—"}</Text>
|
|
72
87
|
</Stack>
|
|
@@ -81,39 +96,12 @@ function TurnReportedWork({ turn }: { turn: TaskTurn }) {
|
|
|
81
96
|
const now = useLiveNow(turn.result === null);
|
|
82
97
|
const elapsed = turnReportedWorkView(turn, now);
|
|
83
98
|
return (
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
className="taskchef-turn-duration"
|
|
88
|
-
size="xs"
|
|
99
|
+
<StaticStat
|
|
100
|
+
accessibleLabel={elapsed.accessibleLabel}
|
|
101
|
+
icon={<IconHourglass aria-hidden size={14} />}
|
|
89
102
|
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>
|
|
103
|
+
value={elapsed.value}
|
|
104
|
+
/>
|
|
117
105
|
);
|
|
118
106
|
}
|
|
119
107
|
|
|
@@ -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,7 +79,7 @@ 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", () => {
|
|
@@ -92,7 +93,7 @@ describe("task card stats", () => {
|
|
|
92
93
|
},
|
|
93
94
|
}));
|
|
94
95
|
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("
|
|
96
|
+
expect(screen.getByLabelText(/estimated cost \$12\.34; updating/i)).toHaveTextContent("12.34 · Updating…");
|
|
96
97
|
});
|
|
97
98
|
|
|
98
99
|
test("places Open chat beneath the status badge and keeps callbacks working", () => {
|
|
@@ -1,66 +1,36 @@
|
|
|
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
36
|
animated={usage.animated}
|
|
@@ -71,8 +41,8 @@ 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
48
|
animated={usage.animated}
|
|
@@ -80,7 +50,7 @@ export function TaskCardStats({ task }: { task: Task }) {
|
|
|
80
50
|
title={usage.cost.title}
|
|
81
51
|
value={usage.cost.value}
|
|
82
52
|
/>
|
|
83
|
-
</
|
|
84
|
-
</
|
|
53
|
+
</StatCell>
|
|
54
|
+
</StatsGrid>
|
|
85
55
|
);
|
|
86
56
|
}
|
|
@@ -69,16 +69,20 @@ test("covers terminal, active, and unavailable timeline metrics", () => {
|
|
|
69
69
|
const { container } = render(
|
|
70
70
|
<MantineProvider><ActivityTimeline highlightTurnRef={null} task={task} /></MantineProvider>,
|
|
71
71
|
);
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
expect(
|
|
76
|
-
expect(within(
|
|
77
|
-
expect(
|
|
78
|
-
expect(within(
|
|
79
|
-
|
|
80
|
-
expect(
|
|
81
|
-
|
|
72
|
+
const stats = container.querySelector(".taskchef-turn-stats") as HTMLElement;
|
|
73
|
+
expect(stats).not.toBeNull();
|
|
74
|
+
const cells = [...stats.children] as HTMLElement[];
|
|
75
|
+
expect(cells).toHaveLength(4);
|
|
76
|
+
expect(within(cells[0]).getByLabelText(/^Turn update time:/i)).toBeVisible();
|
|
77
|
+
expect(cells[0].querySelector(".tabler-icon-history")).not.toBeNull();
|
|
78
|
+
expect(within(cells[1]).getByLabelText(/completed turn reported wall-clock elapsed time/i))
|
|
79
|
+
.toHaveTextContent("18m 32s");
|
|
80
|
+
expect(cells[1].querySelector(".tabler-icon-hourglass")).not.toBeNull();
|
|
81
|
+
expect(within(cells[2]).getByLabelText("330 tokens")).toHaveTextContent("330");
|
|
82
|
+
expect(cells[2].querySelector(".tabler-icon-stack-2")).not.toBeNull();
|
|
83
|
+
expect(within(cells[3]).getByLabelText("Estimated cost $0.12")).toHaveTextContent("0.12");
|
|
84
|
+
expect(within(cells[3]).getByLabelText("Estimated cost $0.12")).not.toHaveTextContent("$");
|
|
85
|
+
expect(cells[3].querySelector(".tabler-icon-currency-dollar")).not.toBeNull();
|
|
82
86
|
cleanup();
|
|
83
87
|
}
|
|
84
88
|
|
|
@@ -94,9 +98,9 @@ test("covers terminal, active, and unavailable timeline metrics", () => {
|
|
|
94
98
|
const task = fixtureTask();
|
|
95
99
|
render(<MantineProvider><ActivityTimeline highlightTurnRef={null} task={task} /></MantineProvider>);
|
|
96
100
|
expect(screen.getByLabelText(/current turn reported wall-clock elapsed time/i))
|
|
97
|
-
.toHaveTextContent("
|
|
98
|
-
expect(screen.
|
|
99
|
-
expect(screen.
|
|
101
|
+
.toHaveTextContent("18m 32s");
|
|
102
|
+
expect(screen.getByLabelText("Tokens pending")).toHaveTextContent("Pending");
|
|
103
|
+
expect(screen.getByLabelText("Estimated cost pending")).toHaveTextContent("Pending");
|
|
100
104
|
|
|
101
105
|
act(() => {
|
|
102
106
|
now += 2_000;
|
|
@@ -104,7 +108,7 @@ test("covers terminal, active, and unavailable timeline metrics", () => {
|
|
|
104
108
|
});
|
|
105
109
|
|
|
106
110
|
expect(screen.getByLabelText(/current turn reported wall-clock elapsed time/i))
|
|
107
|
-
.toHaveTextContent("
|
|
111
|
+
.toHaveTextContent("18m 34s");
|
|
108
112
|
cleanup();
|
|
109
113
|
vi.restoreAllMocks();
|
|
110
114
|
}
|
|
@@ -133,7 +137,7 @@ test("covers terminal, active, and unavailable timeline metrics", () => {
|
|
|
133
137
|
});
|
|
134
138
|
render(<MantineProvider><ActivityTimeline highlightTurnRef={null} task={task} /></MantineProvider>);
|
|
135
139
|
expect(screen.getByLabelText(/elapsed unavailable.*reported wall-clock/i))
|
|
136
|
-
.toHaveTextContent("
|
|
140
|
+
.toHaveTextContent("n/a");
|
|
137
141
|
expect(screen.getByText("Manual dashboard turns do not have usage boundaries.")).toBeVisible();
|
|
138
142
|
expect(screen.getByLabelText(/tokens unavailable: manual dashboard turns/i)).toBeVisible();
|
|
139
143
|
}
|
|
@@ -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" }
|
|
@@ -194,13 +201,14 @@ export function turnUsageMetricsView(task: Task, turn: NonNullable<Task["turns"]
|
|
|
194
201
|
const identity = turn.turnRef ?? turn.turnId;
|
|
195
202
|
const usage = identity ? task.usage?.turns?.[identity] : null;
|
|
196
203
|
const tokens = typeof usage?.totalTokens === "number" ? formatFullTokens(usage.totalTokens) : NOT_AVAILABLE;
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
204
|
+
const estimatedCost = usage?.estimatedCostUsd;
|
|
205
|
+
const hasCost = typeof estimatedCost === "number" && Number.isFinite(estimatedCost);
|
|
206
|
+
const cost = hasCost ? formatEstimatedCostAmount(estimatedCost) : NOT_AVAILABLE;
|
|
207
|
+
const accessibleCost = hasCost ? formatEstimatedCost(estimatedCost).toLowerCase() : "unavailable";
|
|
200
208
|
return {
|
|
201
209
|
animated: false,
|
|
202
210
|
cost: {
|
|
203
|
-
accessibleLabel: `Estimated cost ${
|
|
211
|
+
accessibleLabel: `Estimated cost ${accessibleCost}`,
|
|
204
212
|
value: cost,
|
|
205
213
|
},
|
|
206
214
|
kind: view.kind,
|
|
@@ -276,7 +284,7 @@ export function listUsageMetricsView(task: Task): ListUsageMetricsView {
|
|
|
276
284
|
cost: {
|
|
277
285
|
accessibleLabel: `Estimated cost unavailable${reason}`,
|
|
278
286
|
title: view.label,
|
|
279
|
-
value:
|
|
287
|
+
value: NOT_AVAILABLE,
|
|
280
288
|
},
|
|
281
289
|
kind: "unavailable",
|
|
282
290
|
tokens: {
|
|
@@ -288,9 +296,10 @@ export function listUsageMetricsView(task: Task): ListUsageMetricsView {
|
|
|
288
296
|
}
|
|
289
297
|
const compactTokens = formatCompactTokens(view.usage.totalTokens);
|
|
290
298
|
const fullTokens = formatFullTokens(view.usage.totalTokens);
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
const formattedCost = hasCost ? formatEstimatedCost(
|
|
299
|
+
const estimatedCost = view.usage.estimatedCostUsd;
|
|
300
|
+
const hasCost = typeof estimatedCost === "number" && Number.isFinite(estimatedCost);
|
|
301
|
+
const formattedCost = hasCost ? formatEstimatedCost(estimatedCost) : null;
|
|
302
|
+
const formattedCostAmount = hasCost ? formatEstimatedCostAmount(estimatedCost) : null;
|
|
294
303
|
const qualifier = view.knownSoFar ? " · Updating…" : "";
|
|
295
304
|
const accessibleQualifier = view.knownSoFar ? "; updating" : "";
|
|
296
305
|
const freshness = view.usage.sourceUpdatedAt ?? view.usage.sampledAt ?? task.usage?.updatedAt ?? null;
|
|
@@ -304,7 +313,7 @@ export function listUsageMetricsView(task: Task): ListUsageMetricsView {
|
|
|
304
313
|
title: `${formattedCost === null
|
|
305
314
|
? "Estimated cost unavailable"
|
|
306
315
|
: `Unrounded estimate: $${view.usage.estimatedCostUsd}`}${qualifier}.${freshnessLabel}`,
|
|
307
|
-
value: `${
|
|
316
|
+
value: `${formattedCostAmount ?? NOT_AVAILABLE}${qualifier}`,
|
|
308
317
|
},
|
|
309
318
|
kind: "ready",
|
|
310
319
|
tokens: {
|
|
@@ -56,15 +56,15 @@ body {
|
|
|
56
56
|
.taskchef-github-group { min-width: 0; }
|
|
57
57
|
.taskchef-github-reference-ambiguous { border-bottom: 2px dotted var(--mantine-color-yellow-7); cursor: help; }
|
|
58
58
|
.taskchef-github-reference-ambiguous:focus-visible { outline: 2px solid var(--mantine-color-yellow-7); outline-offset: 2px; }
|
|
59
|
-
.taskchef-time, .taskchef-
|
|
59
|
+
.taskchef-time, .taskchef-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; }
|
|
60
60
|
.taskchef-time:hover { color: var(--taskchef-text); background: var(--taskchef-surface-muted); }
|
|
61
61
|
.taskchef-time:focus-visible { outline: 2px solid var(--mantine-color-teal-6); outline-offset: 2px; }
|
|
62
|
-
.taskchef-time-label, .taskchef-
|
|
63
|
-
.taskchef-
|
|
64
|
-
.taskchef-
|
|
65
|
-
.taskchef-
|
|
66
|
-
.taskchef-
|
|
67
|
-
.taskchef-
|
|
62
|
+
.taskchef-time-label, .taskchef-stat-content bdi { min-width: 0; overflow-wrap: anywhere; unicode-bidi: isolate; }
|
|
63
|
+
.taskchef-stat-content:focus-visible { outline: 2px solid var(--mantine-color-teal-6); outline-offset: 2px; }
|
|
64
|
+
.taskchef-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-top: 2px; }
|
|
65
|
+
.taskchef-stat { display: flex; align-items: center; min-width: 0; padding: 4px 10px; }
|
|
66
|
+
.taskchef-stat:first-child { padding-inline-start: 0; }
|
|
67
|
+
.taskchef-stat + .taskchef-stat { border-inline-start: 1px solid var(--taskchef-border); }
|
|
68
68
|
.taskchef-empty { border-style: dashed; background: transparent; }
|
|
69
69
|
|
|
70
70
|
.taskchef-notifications {
|
|
@@ -84,13 +84,8 @@ body {
|
|
|
84
84
|
.taskchef-metric { min-width: 0; background: transparent; }
|
|
85
85
|
.taskchef-metric-value { overflow-wrap: anywhere; }
|
|
86
86
|
.taskchef-metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: var(--mantine-spacing-xs); }
|
|
87
|
-
.taskchef-turn { background: var(--taskchef-surface-muted); }
|
|
87
|
+
.taskchef-turn { container-type: inline-size; background: var(--taskchef-surface-muted); }
|
|
88
88
|
.taskchef-turn-highlighted { border-color: var(--mantine-color-teal-6); box-shadow: inset 3px 0 var(--mantine-color-teal-6); outline: 3px solid color-mix(in srgb, var(--mantine-color-teal-6) 22%, transparent); }
|
|
89
|
-
.taskchef-turn-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
90
|
-
.taskchef-turn-timing { display: flex; align-items: center; justify-content: flex-end; gap: var(--mantine-spacing-sm); min-width: 0; }
|
|
91
|
-
.taskchef-turn-duration { display: inline-flex; align-items: baseline; gap: 4px; white-space: nowrap; }
|
|
92
|
-
.taskchef-turn-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--mantine-spacing-xs); }
|
|
93
|
-
.taskchef-turn-metric { min-width: 0; overflow-wrap: anywhere; }
|
|
94
89
|
.taskchef-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
|
|
95
90
|
.taskchef-code-panel { max-height: 280px; margin: 0; padding: 12px; overflow: auto; border-radius: var(--mantine-radius-md); background: var(--taskchef-surface-muted); white-space: pre-wrap; overflow-wrap: anywhere; font: 0.78rem/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
96
91
|
.taskchef-metadata { display: grid; grid-template-columns: minmax(120px, 150px) minmax(0, 1fr); margin: 0; font-size: var(--mantine-font-size-sm); }
|
|
@@ -130,9 +125,6 @@ body {
|
|
|
130
125
|
.taskchef-notifications { right: 16px; bottom: 16px; width: min(330px, calc(100vw - 32px)); }
|
|
131
126
|
.taskchef-metadata { grid-template-columns: 1fr; }
|
|
132
127
|
.taskchef-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
133
|
-
.taskchef-turn-metrics { grid-template-columns: 1fr; }
|
|
134
|
-
.taskchef-turn-heading { align-items: flex-start; flex-direction: column; gap: 6px; }
|
|
135
|
-
.taskchef-turn-timing { flex-wrap: wrap; justify-content: flex-start; }
|
|
136
128
|
.taskchef-metadata dt { padding-bottom: 0; border-bottom: 0; }
|
|
137
129
|
}
|
|
138
130
|
|
|
@@ -142,10 +134,10 @@ body {
|
|
|
142
134
|
}
|
|
143
135
|
|
|
144
136
|
@container (max-width: 27.5rem) {
|
|
145
|
-
.taskchef-
|
|
146
|
-
.taskchef-
|
|
147
|
-
.taskchef-
|
|
148
|
-
.taskchef-
|
|
137
|
+
.taskchef-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
138
|
+
.taskchef-stat { padding-block: 8px; }
|
|
139
|
+
.taskchef-stat:nth-child(3) { border-inline-start: 0; padding-inline-start: 0; }
|
|
140
|
+
.taskchef-stat:nth-child(n + 3) { border-top: 1px solid var(--taskchef-border); }
|
|
149
141
|
}
|
|
150
142
|
|
|
151
143
|
.taskchef-refresh-spinning {
|
package/src/usage.js
CHANGED
|
@@ -19,6 +19,7 @@ const PINNED_CCUSAGE_VERSION = taskchefPackage.optionalDependencies?.ccusage;
|
|
|
19
19
|
const CCUSAGE_RESOLVER = fileURLToPath(new URL("./resolve-ccusage.js", import.meta.url));
|
|
20
20
|
let npxResolvedCcusageInvocation = null;
|
|
21
21
|
const USAGE_FILE_NAME = ".taskchef-usage.json";
|
|
22
|
+
const USAGE_SCHEMA_VERSION = 2;
|
|
22
23
|
const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi;
|
|
23
24
|
const TOKEN_FIELDS = [
|
|
24
25
|
"inputTokens",
|
|
@@ -92,10 +93,6 @@ function normalizeProvenance(value, name, { includeSessionCount = false } = {})
|
|
|
92
93
|
pricingMode: value.pricingMode === "online" || value.pricingMode === "offline"
|
|
93
94
|
? value.pricingMode
|
|
94
95
|
: null,
|
|
95
|
-
costCoverage: value.costCoverage === "cache_writes_unverified"
|
|
96
|
-
|| value.costCoverage === "ccusage_reported"
|
|
97
|
-
? value.costCoverage
|
|
98
|
-
: null,
|
|
99
96
|
...(includeSessionCount ? {
|
|
100
97
|
sessionCount: nonNegativeNumber(value.sessionCount, `${name}.provenance.sessionCount`),
|
|
101
98
|
} : {}),
|
|
@@ -127,10 +124,6 @@ function normalizeStoredSnapshot(value, name) {
|
|
|
127
124
|
}
|
|
128
125
|
snapshot.models = models;
|
|
129
126
|
snapshot.provenance = normalizeProvenance(value.provenance, name, { includeSessionCount: true });
|
|
130
|
-
if (snapshot.provenance.pricingMode === null || snapshot.provenance.costCoverage === null) {
|
|
131
|
-
snapshot.estimatedCostUsd = null;
|
|
132
|
-
snapshot.costStatus = "unavailable";
|
|
133
|
-
}
|
|
134
127
|
snapshot.sampledAt = timestampOrNull(value.sampledAt, `${name}.sampledAt`);
|
|
135
128
|
snapshot.sourceUpdatedAt = timestampOrNull(value.sourceUpdatedAt, `${name}.sourceUpdatedAt`);
|
|
136
129
|
return snapshot;
|
|
@@ -150,7 +143,7 @@ function normalizeStoredTurn(value, name) {
|
|
|
150
143
|
? null
|
|
151
144
|
: nonNegativeNumber(value.estimatedCostUsd, `${name}.estimatedCostUsd`);
|
|
152
145
|
const provenance = normalizeProvenance(value.provenance, name);
|
|
153
|
-
if (provenance.pricingMode === null
|
|
146
|
+
if (provenance.pricingMode === null) {
|
|
154
147
|
normalized.estimatedCostUsd = null;
|
|
155
148
|
}
|
|
156
149
|
return {
|
|
@@ -271,9 +264,6 @@ export function aggregateCcusageSessions(payload, threadId, {
|
|
|
271
264
|
provider: "ccusage",
|
|
272
265
|
version,
|
|
273
266
|
pricingMode,
|
|
274
|
-
costCoverage: Object.keys(models).some((model) => /^gpt-5\.6(?:-|$)/i.test(model))
|
|
275
|
-
? "cache_writes_unverified"
|
|
276
|
-
: "ccusage_reported",
|
|
277
267
|
sessionCount: sessions.length,
|
|
278
268
|
},
|
|
279
269
|
sampledAt,
|
|
@@ -627,21 +617,23 @@ export async function readUsageStore(workspace) {
|
|
|
627
617
|
if (error.code === "ENOENT") return null;
|
|
628
618
|
throw error;
|
|
629
619
|
});
|
|
630
|
-
if (details === null) return { schemaVersion:
|
|
620
|
+
if (details === null) return { schemaVersion: USAGE_SCHEMA_VERSION, tasks: {} };
|
|
631
621
|
if (details.isSymbolicLink() || !details.isFile()) {
|
|
632
622
|
throw new Error("TaskChef usage cache must be a regular file");
|
|
633
623
|
}
|
|
634
624
|
if (details.size > MAX_USAGE_FILE_BYTES) {
|
|
635
|
-
return { schemaVersion:
|
|
625
|
+
return { schemaVersion: USAGE_SCHEMA_VERSION, tasks: {} };
|
|
636
626
|
}
|
|
637
627
|
const value = JSON.parse(await readFile(filePath, "utf8"));
|
|
638
|
-
if (
|
|
628
|
+
if (![1, USAGE_SCHEMA_VERSION].includes(value?.schemaVersion)
|
|
629
|
+
|| !value.tasks
|
|
630
|
+
|| typeof value.tasks !== "object") {
|
|
639
631
|
throw new Error("TaskChef usage cache has an unsupported schema");
|
|
640
632
|
}
|
|
641
633
|
const tasks = Object.entries(value.tasks);
|
|
642
|
-
if (tasks.length > 2_000) return { schemaVersion:
|
|
634
|
+
if (tasks.length > 2_000) return { schemaVersion: USAGE_SCHEMA_VERSION, tasks: {} };
|
|
643
635
|
return {
|
|
644
|
-
schemaVersion:
|
|
636
|
+
schemaVersion: USAGE_SCHEMA_VERSION,
|
|
645
637
|
tasks: Object.fromEntries(tasks.map(([taskId, record]) => [
|
|
646
638
|
taskId,
|
|
647
639
|
normalizeStoredRecord(record, `usage task ${taskId}`),
|
|
@@ -672,7 +664,7 @@ export function compactUsageStore(store) {
|
|
|
672
664
|
tasks[taskId] = compacted;
|
|
673
665
|
approximateBytes += entryBytes;
|
|
674
666
|
}
|
|
675
|
-
return { schemaVersion:
|
|
667
|
+
return { schemaVersion: USAGE_SCHEMA_VERSION, tasks };
|
|
676
668
|
}
|
|
677
669
|
|
|
678
670
|
export async function writeUsageStore(workspace, store) {
|
|
@@ -704,7 +696,7 @@ export function usageDelta(current, previous = null) {
|
|
|
704
696
|
if (!Number.isFinite(value) || value < 0) return null;
|
|
705
697
|
delta[field] = value;
|
|
706
698
|
}
|
|
707
|
-
const samePricingSource = previous === null || ["version", "pricingMode"
|
|
699
|
+
const samePricingSource = previous === null || ["version", "pricingMode"]
|
|
708
700
|
.every((field) => current.provenance?.[field] === previous.provenance?.[field]);
|
|
709
701
|
let estimatedCostUsd = !samePricingSource
|
|
710
702
|
|| current.estimatedCostUsd === null
|