taskchef 7.25.1 → 7.26.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 +7 -0
- package/docs/dashboard-lifecycle.md +23 -0
- package/docs/images/reported-work-duration-desktop.jpg +0 -0
- package/docs/images/reported-work-duration-mobile.jpg +0 -0
- package/package.json +3 -1
- package/src/dashboard/dist/app.js +41 -46
- package/src/dashboard/dist/app.js.map +1 -1
- package/src/dashboard/dist/styles.css +1 -1
- package/src/dashboard/react/App.test.tsx +202 -0
- package/src/dashboard/react/App.tsx +22 -8
- package/src/dashboard/react/components/ActivityTimeline.tsx +69 -12
- package/src/dashboard/react/components/OpenChatButton.test.tsx +22 -0
- package/src/dashboard/react/components/OpenChatButton.tsx +25 -0
- package/src/dashboard/react/components/TaskCard.tsx +3 -11
- package/src/dashboard/react/components/TaskDetail.test.tsx +91 -2
- package/src/dashboard/react/components/TaskDetail.tsx +2 -9
- package/src/dashboard/react/components/UsagePanel.tsx +64 -46
- package/src/dashboard/react/presentation.test.tsx +231 -3
- package/src/dashboard/react/presentation.ts +181 -1
- package/src/dashboard/react/styles.css +5 -4
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { act, cleanup, render, screen } from "@testing-library/react";
|
|
2
|
+
import type { ElementType, ReactNode } from "react";
|
|
3
|
+
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
|
4
|
+
import { DashboardApp } from "./App";
|
|
5
|
+
import { fixtureTask } from "./fixtures";
|
|
6
|
+
import type { DashboardSnapshot } from "./types";
|
|
7
|
+
|
|
8
|
+
const apiMocks = vi.hoisted(() => ({
|
|
9
|
+
connectDashboard: vi.fn(),
|
|
10
|
+
dashboardVersion: vi.fn(),
|
|
11
|
+
manualTransition: vi.fn(),
|
|
12
|
+
openInCodex: vi.fn(),
|
|
13
|
+
taskDetail: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock("./api", () => apiMocks);
|
|
17
|
+
vi.mock("./components/NotificationCenter", () => ({ NotificationCenter: () => null }));
|
|
18
|
+
vi.mock("./components/TaskCard", () => ({ TaskCard: () => null }));
|
|
19
|
+
vi.mock("./components/TaskDetail", () => ({ TaskDetail: () => null }));
|
|
20
|
+
vi.mock("@tabler/icons-react", async () => {
|
|
21
|
+
const { createElement } = await vi.importActual<typeof import("react")>("react");
|
|
22
|
+
const Icon = (props: Record<string, unknown>) => createElement("svg", props);
|
|
23
|
+
return {
|
|
24
|
+
IconAlertTriangle: Icon,
|
|
25
|
+
IconCircleFilled: Icon,
|
|
26
|
+
IconMoon: Icon,
|
|
27
|
+
IconSun: Icon,
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
vi.mock("@mantine/core", async () => {
|
|
31
|
+
const { createElement } = await vi.importActual<typeof import("react")>("react");
|
|
32
|
+
const primitive = (tag: string) => ({
|
|
33
|
+
align: _align,
|
|
34
|
+
c: _color,
|
|
35
|
+
children,
|
|
36
|
+
component,
|
|
37
|
+
fw: _fontWeight,
|
|
38
|
+
gap: _gap,
|
|
39
|
+
justify: _justify,
|
|
40
|
+
mb: _marginBottom,
|
|
41
|
+
mt: _marginTop,
|
|
42
|
+
p: _padding,
|
|
43
|
+
pb: _paddingBottom,
|
|
44
|
+
radius: _radius,
|
|
45
|
+
size: _size,
|
|
46
|
+
ta: _textAlign,
|
|
47
|
+
tt: _textTransform,
|
|
48
|
+
withBorder: _withBorder,
|
|
49
|
+
wrap: _wrap,
|
|
50
|
+
...props
|
|
51
|
+
}: Record<string, unknown> & { children?: ReactNode; component?: ElementType }) => (
|
|
52
|
+
createElement(component ?? tag, props, children)
|
|
53
|
+
);
|
|
54
|
+
const AppShell = Object.assign(primitive("div"), { Main: primitive("main") });
|
|
55
|
+
const Select = ({
|
|
56
|
+
"aria-label": ariaLabel,
|
|
57
|
+
data,
|
|
58
|
+
label,
|
|
59
|
+
onChange,
|
|
60
|
+
value,
|
|
61
|
+
}: {
|
|
62
|
+
"aria-label": string;
|
|
63
|
+
data: Array<string | { label: string; value: string }>;
|
|
64
|
+
label: string;
|
|
65
|
+
onChange: (value: string | null) => void;
|
|
66
|
+
value: string;
|
|
67
|
+
}) => {
|
|
68
|
+
const options = data.map((item) => typeof item === "string"
|
|
69
|
+
? { label: item, value: item }
|
|
70
|
+
: item);
|
|
71
|
+
const selected = options.find((item) => item.value === value)?.label ?? value;
|
|
72
|
+
return createElement("label", null, label, createElement(
|
|
73
|
+
"select",
|
|
74
|
+
{
|
|
75
|
+
"aria-label": ariaLabel,
|
|
76
|
+
onChange: (event: { currentTarget: { value: string } }) => {
|
|
77
|
+
const next = options.find((item) => item.label === event.currentTarget.value);
|
|
78
|
+
onChange(next?.value ?? null);
|
|
79
|
+
},
|
|
80
|
+
value: selected,
|
|
81
|
+
},
|
|
82
|
+
options.map((item) => createElement("option", { key: item.value, value: item.label }, item.label)),
|
|
83
|
+
));
|
|
84
|
+
};
|
|
85
|
+
const SegmentedControl = ({
|
|
86
|
+
"aria-label": ariaLabel,
|
|
87
|
+
data,
|
|
88
|
+
onChange,
|
|
89
|
+
value,
|
|
90
|
+
}: {
|
|
91
|
+
"aria-label": string;
|
|
92
|
+
data: Array<{ label: string; value: string }>;
|
|
93
|
+
onChange: (value: string) => void;
|
|
94
|
+
value: string;
|
|
95
|
+
}) => createElement(
|
|
96
|
+
"div",
|
|
97
|
+
{ "aria-label": ariaLabel, role: "radiogroup" },
|
|
98
|
+
data.map((item) => createElement(
|
|
99
|
+
"label",
|
|
100
|
+
{ key: item.value },
|
|
101
|
+
createElement("input", {
|
|
102
|
+
checked: item.value === value,
|
|
103
|
+
name: ariaLabel,
|
|
104
|
+
onChange: () => onChange(item.value),
|
|
105
|
+
type: "radio",
|
|
106
|
+
}),
|
|
107
|
+
item.label,
|
|
108
|
+
)),
|
|
109
|
+
);
|
|
110
|
+
return {
|
|
111
|
+
ActionIcon: primitive("button"),
|
|
112
|
+
Alert: primitive("div"),
|
|
113
|
+
AppShell,
|
|
114
|
+
Box: primitive("div"),
|
|
115
|
+
CloseButton: primitive("button"),
|
|
116
|
+
Container: primitive("div"),
|
|
117
|
+
Group: primitive("div"),
|
|
118
|
+
MantineProvider: ({ children }: { children?: ReactNode }) => createElement("div", null, children),
|
|
119
|
+
Paper: primitive("div"),
|
|
120
|
+
SegmentedControl,
|
|
121
|
+
Select,
|
|
122
|
+
Stack: primitive("div"),
|
|
123
|
+
Text: primitive("p"),
|
|
124
|
+
Title: primitive("h1"),
|
|
125
|
+
Tooltip: primitive("div"),
|
|
126
|
+
createTheme: (theme: unknown) => theme,
|
|
127
|
+
useComputedColorScheme: () => "light",
|
|
128
|
+
useMantineColorScheme: () => ({ setColorScheme: vi.fn() }),
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
afterEach(cleanup);
|
|
133
|
+
beforeEach(() => {
|
|
134
|
+
vi.clearAllMocks();
|
|
135
|
+
apiMocks.dashboardVersion.mockResolvedValue("7.25.1");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("places the filtered task count below the toolbar and updates it from live snapshots", () => {
|
|
139
|
+
const recent = new Date().toISOString();
|
|
140
|
+
const tasks = [
|
|
141
|
+
fixtureTask({
|
|
142
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
143
|
+
project: { name: "Another project", path: "/tmp/another", githubRepos: [] },
|
|
144
|
+
status: "completed",
|
|
145
|
+
meaningfulUpdatedAt: recent,
|
|
146
|
+
updatedAt: recent,
|
|
147
|
+
}),
|
|
148
|
+
fixtureTask({
|
|
149
|
+
id: "22222222-2222-4222-8222-222222222222",
|
|
150
|
+
project: { name: "Another project", path: "/tmp/another", githubRepos: [] },
|
|
151
|
+
meaningfulUpdatedAt: recent,
|
|
152
|
+
updatedAt: recent,
|
|
153
|
+
}),
|
|
154
|
+
fixtureTask({
|
|
155
|
+
id: "33333333-3333-4333-8333-333333333333",
|
|
156
|
+
status: "completed",
|
|
157
|
+
}),
|
|
158
|
+
];
|
|
159
|
+
let onSnapshot: ((snapshot: DashboardSnapshot) => void) | undefined;
|
|
160
|
+
apiMocks.connectDashboard.mockImplementation((handlers) => {
|
|
161
|
+
onSnapshot = handlers.onSnapshot;
|
|
162
|
+
return { close: vi.fn() };
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
render(
|
|
166
|
+
<DashboardApp
|
|
167
|
+
initialFilters={{ date: "24h", project: "Another project", status: "completed" }}
|
|
168
|
+
initialTasks={tasks}
|
|
169
|
+
/>,
|
|
170
|
+
);
|
|
171
|
+
const projectFilter = screen.getByRole("combobox", { name: "Project" });
|
|
172
|
+
const dateFilter = screen.getByRole("combobox", { name: "Updated" });
|
|
173
|
+
const toolbar = document.querySelector<HTMLElement>(".taskchef-toolbar");
|
|
174
|
+
const summary = document.querySelector<HTMLElement>(".taskchef-results-summary");
|
|
175
|
+
const taskList = screen.getByRole("region", { name: "Tasks" });
|
|
176
|
+
|
|
177
|
+
expect(projectFilter).toHaveValue("Another project");
|
|
178
|
+
expect(dateFilter).toHaveValue("Latest 24 hours");
|
|
179
|
+
expect(screen.getByRole("radio", { name: /Completed/ })).toBeChecked();
|
|
180
|
+
expect(summary).toHaveTextContent("Tasks: 1 of 3");
|
|
181
|
+
expect(toolbar).not.toContainElement(summary);
|
|
182
|
+
expect(toolbar?.nextElementSibling).toBe(summary);
|
|
183
|
+
expect(taskList).toHaveAttribute("aria-describedby", summary?.id);
|
|
184
|
+
|
|
185
|
+
act(() => {
|
|
186
|
+
onSnapshot?.({
|
|
187
|
+
tasks: [
|
|
188
|
+
tasks[0],
|
|
189
|
+
fixtureTask({
|
|
190
|
+
id: "44444444-4444-4444-8444-444444444444",
|
|
191
|
+
project: { name: "Another project", path: "/tmp/another", githubRepos: [] },
|
|
192
|
+
status: "completed",
|
|
193
|
+
meaningfulUpdatedAt: recent,
|
|
194
|
+
updatedAt: recent,
|
|
195
|
+
}),
|
|
196
|
+
],
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(summary).toHaveTextContent("Tasks: 2 of 2");
|
|
201
|
+
expect(summary).toHaveAttribute("aria-live", "polite");
|
|
202
|
+
}, 60_000);
|
|
@@ -77,20 +77,36 @@ interface NotificationState {
|
|
|
77
77
|
signatures: Map<string, string>;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
export function TaskResultsSummary({
|
|
81
|
+
totalCount,
|
|
82
|
+
visibleCount,
|
|
83
|
+
}: {
|
|
84
|
+
totalCount: number;
|
|
85
|
+
visibleCount: number;
|
|
86
|
+
}) {
|
|
87
|
+
return (
|
|
88
|
+
<Text aria-live="polite" className="taskchef-results-summary" id="task-results-summary">
|
|
89
|
+
Tasks: {visibleCount} of {totalCount}
|
|
90
|
+
</Text>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
80
94
|
export function DashboardApp({
|
|
81
95
|
connect = true,
|
|
96
|
+
initialFilters = {},
|
|
82
97
|
initialTasks = [],
|
|
83
98
|
}: {
|
|
84
99
|
connect?: boolean;
|
|
100
|
+
initialFilters?: { date?: string; project?: string; status?: string };
|
|
85
101
|
initialTasks?: Task[];
|
|
86
102
|
}) {
|
|
87
103
|
const [tasks, setTasks] = useState<Task[]>(initialTasks);
|
|
88
104
|
const [connected, setConnected] = useState(false);
|
|
89
105
|
const [message, setMessage] = useState<string | null>(null);
|
|
90
106
|
const [version, setVersion] = useState<string | null>(null);
|
|
91
|
-
const [project, setProject] = useState("");
|
|
92
|
-
const [status, setStatus] = useState("");
|
|
93
|
-
const [date, setDate] = useState("all");
|
|
107
|
+
const [project, setProject] = useState(initialFilters.project ?? "");
|
|
108
|
+
const [status, setStatus] = useState(initialFilters.status ?? "");
|
|
109
|
+
const [date, setDate] = useState(initialFilters.date ?? "all");
|
|
94
110
|
const [now, setNow] = useState(() => Date.now());
|
|
95
111
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
|
96
112
|
const [detailOpened, setDetailOpened] = useState(false);
|
|
@@ -310,10 +326,6 @@ export function DashboardApp({
|
|
|
310
326
|
size="sm"
|
|
311
327
|
value={date}
|
|
312
328
|
/>
|
|
313
|
-
<Box className="taskchef-task-count-field">
|
|
314
|
-
<Text className="taskchef-filter-label" mb={3}>Tasks</Text>
|
|
315
|
-
<Text className="taskchef-count-value" size="sm">{visible.length} of {tasks.length}</Text>
|
|
316
|
-
</Box>
|
|
317
329
|
</Group>
|
|
318
330
|
<Box>
|
|
319
331
|
<Text className="taskchef-filter-label" mb={5}>Status</Text>
|
|
@@ -322,6 +334,8 @@ export function DashboardApp({
|
|
|
322
334
|
</Stack>
|
|
323
335
|
</Paper>
|
|
324
336
|
|
|
337
|
+
<TaskResultsSummary totalCount={tasks.length} visibleCount={visible.length} />
|
|
338
|
+
|
|
325
339
|
{message && (
|
|
326
340
|
<Alert color="yellow" icon={<IconAlertTriangle aria-hidden size={17} />} mt="md" role="status">
|
|
327
341
|
<Group gap="sm" justify="space-between" wrap="nowrap">
|
|
@@ -331,7 +345,7 @@ export function DashboardApp({
|
|
|
331
345
|
</Alert>
|
|
332
346
|
)}
|
|
333
347
|
|
|
334
|
-
<Stack aria-label="Tasks" component="section" gap="sm" mt="
|
|
348
|
+
<Stack aria-describedby="task-results-summary" aria-label="Tasks" component="section" gap="sm" mt="xs">
|
|
335
349
|
{visible.map((task: Task) => (
|
|
336
350
|
<TaskCard key={task.id} onOpenCodex={handleOpenCodex} onOpenDetail={(value) => void loadDetail(value)} task={task} />
|
|
337
351
|
))}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Box, Paper, Stack, Text } from "@mantine/core";
|
|
2
2
|
import { mergeProjectedTurns, turnPresentation } from "../../state.js";
|
|
3
|
-
import {
|
|
4
|
-
import type { Task } from "../types";
|
|
3
|
+
import { turnReportedWorkView, turnUsageMetricsView } from "../presentation";
|
|
4
|
+
import type { Task, TaskTurn } from "../types";
|
|
5
|
+
import { useEffect, useState } from "react";
|
|
5
6
|
import { RelativeTime } from "./RelativeTime";
|
|
6
7
|
import { LinkedText } from "./LinkedText";
|
|
7
8
|
import { ShimmerText } from "./ShimmerText";
|
|
@@ -16,7 +17,7 @@ export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef:
|
|
|
16
17
|
const presentation = turnPresentation(turn);
|
|
17
18
|
const identity = turn.turnRef ?? turn.turnId ?? `turn-${index}`;
|
|
18
19
|
const highlighted = identity === highlightTurnRef;
|
|
19
|
-
const usage =
|
|
20
|
+
const usage = turnUsageMetricsView(task, turn);
|
|
20
21
|
return (
|
|
21
22
|
<Paper
|
|
22
23
|
className={`taskchef-turn${highlighted ? " taskchef-turn-highlighted" : ""}`}
|
|
@@ -47,15 +48,24 @@ export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef:
|
|
|
47
48
|
: <LinkedText task={task} text={presentation.summary} />}
|
|
48
49
|
</Text>
|
|
49
50
|
</Box>
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
51
|
+
<Box className="taskchef-turn-metrics">
|
|
52
|
+
<TurnMetric
|
|
53
|
+
accessibleLabel={usage.tokens.accessibleLabel}
|
|
54
|
+
animated={usage.animated}
|
|
55
|
+
label="Tokens"
|
|
56
|
+
title={usage.title}
|
|
57
|
+
value={usage.tokens.value}
|
|
58
|
+
/>
|
|
59
|
+
<TurnMetric
|
|
60
|
+
accessibleLabel={usage.cost.accessibleLabel}
|
|
61
|
+
animated={usage.animated}
|
|
62
|
+
label="Estimated cost"
|
|
63
|
+
title={usage.title}
|
|
64
|
+
value={usage.cost.value}
|
|
65
|
+
/>
|
|
66
|
+
<TurnReportedWorkMetric turn={turn} />
|
|
67
|
+
</Box>
|
|
68
|
+
{usage.note && <Text c="dimmed" size="xs">{usage.note}</Text>}
|
|
59
69
|
<Text c="dimmed" className="taskchef-mono" size="xs">Turn ref: {identity ?? "—"}</Text>
|
|
60
70
|
</Stack>
|
|
61
71
|
</Paper>
|
|
@@ -64,3 +74,50 @@ export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef:
|
|
|
64
74
|
</Stack>
|
|
65
75
|
);
|
|
66
76
|
}
|
|
77
|
+
|
|
78
|
+
function TurnReportedWorkMetric({ turn }: { turn: TaskTurn }) {
|
|
79
|
+
const now = useLiveNow(turn.result === null);
|
|
80
|
+
const elapsed = turnReportedWorkView(turn, now);
|
|
81
|
+
return (
|
|
82
|
+
<TurnMetric
|
|
83
|
+
accessibleLabel={elapsed.accessibleLabel}
|
|
84
|
+
label={elapsed.label}
|
|
85
|
+
title={elapsed.title}
|
|
86
|
+
value={elapsed.value}
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function TurnMetric({
|
|
92
|
+
accessibleLabel,
|
|
93
|
+
animated = false,
|
|
94
|
+
label,
|
|
95
|
+
title,
|
|
96
|
+
value,
|
|
97
|
+
}: {
|
|
98
|
+
accessibleLabel?: string;
|
|
99
|
+
animated?: boolean;
|
|
100
|
+
label: string;
|
|
101
|
+
title?: string;
|
|
102
|
+
value: string;
|
|
103
|
+
}) {
|
|
104
|
+
return (
|
|
105
|
+
<Box className="taskchef-turn-metric" title={title}>
|
|
106
|
+
<Text c="dimmed" className="taskchef-field-label" size="xs">{label}</Text>
|
|
107
|
+
<Text aria-label={accessibleLabel} c="dimmed" size="xs">
|
|
108
|
+
{animated ? <ShimmerText>{value}</ShimmerText> : value}
|
|
109
|
+
</Text>
|
|
110
|
+
</Box>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function useLiveNow(active: boolean) {
|
|
115
|
+
const [now, setNow] = useState(() => Date.now());
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (!active) return undefined;
|
|
118
|
+
setNow(Date.now());
|
|
119
|
+
const interval = window.setInterval(() => setNow(Date.now()), 1_000);
|
|
120
|
+
return () => window.clearInterval(interval);
|
|
121
|
+
}, [active]);
|
|
122
|
+
return now;
|
|
123
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { MantineProvider } from "@mantine/core";
|
|
2
|
+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import { afterEach, expect, test, vi } from "vitest";
|
|
4
|
+
import { OpenChatButton } from "./OpenChatButton";
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
test("uses the shared open-chat presentation and accessible task label", () => {
|
|
9
|
+
const onClick = vi.fn();
|
|
10
|
+
render(
|
|
11
|
+
<MantineProvider>
|
|
12
|
+
<OpenChatButton onClick={onClick} taskTitle="Review checkout reconciliation" />
|
|
13
|
+
</MantineProvider>,
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const button = screen.getByRole("button", { name: "Open chat for Review checkout reconciliation" });
|
|
17
|
+
expect(button).toHaveTextContent("Open chat");
|
|
18
|
+
expect(button).toHaveAttribute("data-variant", "default");
|
|
19
|
+
expect(button.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
|
|
20
|
+
fireEvent.click(button);
|
|
21
|
+
expect(onClick).toHaveBeenCalledOnce();
|
|
22
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Button } from "@mantine/core";
|
|
2
|
+
import { IconExternalLink } from "@tabler/icons-react";
|
|
3
|
+
|
|
4
|
+
export function OpenChatButton({
|
|
5
|
+
loading,
|
|
6
|
+
onClick,
|
|
7
|
+
taskTitle,
|
|
8
|
+
}: {
|
|
9
|
+
loading?: boolean;
|
|
10
|
+
onClick: () => void;
|
|
11
|
+
taskTitle: string;
|
|
12
|
+
}) {
|
|
13
|
+
return (
|
|
14
|
+
<Button
|
|
15
|
+
aria-label={`Open chat for ${taskTitle}`}
|
|
16
|
+
leftSection={<IconExternalLink aria-hidden size={15} />}
|
|
17
|
+
loading={loading}
|
|
18
|
+
onClick={onClick}
|
|
19
|
+
size="compact-sm"
|
|
20
|
+
variant="default"
|
|
21
|
+
>
|
|
22
|
+
Open chat
|
|
23
|
+
</Button>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { Box,
|
|
2
|
-
import { IconExternalLink } from "@tabler/icons-react";
|
|
1
|
+
import { Box, Group, Paper, Stack, Text, Title } from "@mantine/core";
|
|
3
2
|
import { latestTurnPresentation } from "../../state.js";
|
|
4
3
|
import type { Task } from "../types";
|
|
5
4
|
import { GitHubLinks } from "./GitHubLinks";
|
|
6
5
|
import { LinkedText } from "./LinkedText";
|
|
6
|
+
import { OpenChatButton } from "./OpenChatButton";
|
|
7
7
|
import { RelativeTime } from "./RelativeTime";
|
|
8
8
|
import { ShimmerText } from "./ShimmerText";
|
|
9
9
|
import { StatusBadge } from "./StatusBadge";
|
|
@@ -53,15 +53,7 @@ export function TaskCard({
|
|
|
53
53
|
<GitHubLinks task={task} />
|
|
54
54
|
<Group className="taskchef-card-footer" gap="md" justify="space-between">
|
|
55
55
|
<RelativeTime label={`Updated time for ${task.title}`} value={task.meaningfulUpdatedAt ?? task.updatedAt} />
|
|
56
|
-
<
|
|
57
|
-
aria-label={`Open chat for ${task.title}`}
|
|
58
|
-
leftSection={<IconExternalLink aria-hidden size={15} />}
|
|
59
|
-
onClick={() => onOpenCodex(task)}
|
|
60
|
-
size="compact-sm"
|
|
61
|
-
variant="default"
|
|
62
|
-
>
|
|
63
|
-
Open chat
|
|
64
|
-
</Button>
|
|
56
|
+
<OpenChatButton onClick={() => onOpenCodex(task)} taskTitle={task.title} />
|
|
65
57
|
</Group>
|
|
66
58
|
</Stack>
|
|
67
59
|
</Paper>
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { MantineProvider } from "@mantine/core";
|
|
2
|
-
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import { afterEach, expect, test, vi } from "vitest";
|
|
5
|
+
import { fixtureTask } from "../fixtures";
|
|
6
|
+
import { ActivityTimeline } from "./ActivityTimeline";
|
|
5
7
|
import { ManualTransitionConfirmation, type TerminalStatus } from "./TaskDetail";
|
|
6
8
|
|
|
7
|
-
afterEach(
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
cleanup();
|
|
11
|
+
vi.restoreAllMocks();
|
|
12
|
+
});
|
|
8
13
|
|
|
9
14
|
test("clears a manual-transition confirmation only after success", async () => {
|
|
10
15
|
const onTransition = vi.fn().mockResolvedValue({ ok: true });
|
|
@@ -44,6 +49,90 @@ test("reuses an action ID for retryable failures and rotates it after a stale ta
|
|
|
44
49
|
expect(onTransition.mock.calls[2][1]).not.toBe(firstActionId);
|
|
45
50
|
});
|
|
46
51
|
|
|
52
|
+
test("covers terminal, active, and unavailable timeline metrics", () => {
|
|
53
|
+
{
|
|
54
|
+
const task = fixtureTask({
|
|
55
|
+
status: "completed",
|
|
56
|
+
turns: [{
|
|
57
|
+
...fixtureTask().turns![0],
|
|
58
|
+
result: {
|
|
59
|
+
status: "completed",
|
|
60
|
+
summary: "Done.",
|
|
61
|
+
updatedAt: "2026-08-30T08:18:32.000Z",
|
|
62
|
+
},
|
|
63
|
+
}],
|
|
64
|
+
usage: {
|
|
65
|
+
status: "available",
|
|
66
|
+
turns: { "turn-one": { status: "available", totalTokens: 330, estimatedCostUsd: 0.12 } },
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
const { container } = render(
|
|
70
|
+
<MantineProvider><ActivityTimeline highlightTurnRef={null} task={task} /></MantineProvider>,
|
|
71
|
+
);
|
|
72
|
+
const metrics = container.querySelector(".taskchef-turn-metrics") as HTMLElement;
|
|
73
|
+
expect(metrics).not.toBeNull();
|
|
74
|
+
expect(within(metrics).getByText("Tokens").nextSibling).toHaveTextContent("330");
|
|
75
|
+
expect(within(metrics).getByText("Estimated cost").nextSibling).toHaveTextContent("$0.12");
|
|
76
|
+
expect(within(metrics).getByText("Elapsed").nextSibling).toHaveTextContent("18m 32s");
|
|
77
|
+
expect(within(metrics).getByLabelText(/completed turn reported wall-clock elapsed time/i)).toBeVisible();
|
|
78
|
+
cleanup();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
{
|
|
82
|
+
let now = Date.parse("2026-08-30T08:18:32.000Z");
|
|
83
|
+
let tick: (() => void) | undefined;
|
|
84
|
+
vi.spyOn(Date, "now").mockImplementation(() => now);
|
|
85
|
+
vi.spyOn(window, "setInterval").mockImplementation(((handler: TimerHandler) => {
|
|
86
|
+
tick = handler as () => void;
|
|
87
|
+
return 1;
|
|
88
|
+
}) as typeof window.setInterval);
|
|
89
|
+
vi.spyOn(window, "clearInterval").mockImplementation(() => undefined);
|
|
90
|
+
const task = fixtureTask();
|
|
91
|
+
render(<MantineProvider><ActivityTimeline highlightTurnRef={null} task={task} /></MantineProvider>);
|
|
92
|
+
expect(screen.getByText("Elapsed so far").nextSibling).toHaveTextContent("18m 32s");
|
|
93
|
+
expect(screen.getByText("Tokens").nextSibling).toHaveTextContent("Pending");
|
|
94
|
+
expect(screen.getByText("Estimated cost").nextSibling).toHaveTextContent("Pending");
|
|
95
|
+
|
|
96
|
+
act(() => {
|
|
97
|
+
now += 2_000;
|
|
98
|
+
tick?.();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(screen.getByText("Elapsed so far").nextSibling).toHaveTextContent("18m 34s");
|
|
102
|
+
cleanup();
|
|
103
|
+
vi.restoreAllMocks();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
{
|
|
107
|
+
const task = fixtureTask({
|
|
108
|
+
status: "completed",
|
|
109
|
+
turns: [{
|
|
110
|
+
...fixtureTask().turns![0],
|
|
111
|
+
startedAt: "2026-08-30T08:18:33.000Z",
|
|
112
|
+
result: {
|
|
113
|
+
status: "completed",
|
|
114
|
+
summary: "Done.",
|
|
115
|
+
updatedAt: "2026-08-30T08:18:32.000Z",
|
|
116
|
+
},
|
|
117
|
+
}],
|
|
118
|
+
usage: {
|
|
119
|
+
status: "unavailable",
|
|
120
|
+
turns: {
|
|
121
|
+
"turn-one": {
|
|
122
|
+
status: "unavailable",
|
|
123
|
+
reason: "Manual dashboard turns do not have usage boundaries.",
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
render(<MantineProvider><ActivityTimeline highlightTurnRef={null} task={task} /></MantineProvider>);
|
|
129
|
+
expect(screen.getByText("Elapsed").nextSibling).toHaveTextContent("Unavailable");
|
|
130
|
+
expect(screen.getByLabelText(/elapsed unavailable.*reported wall-clock/i)).toBeVisible();
|
|
131
|
+
expect(screen.getByText("Manual dashboard turns do not have usage boundaries.")).toBeVisible();
|
|
132
|
+
expect(screen.getByLabelText(/tokens unavailable: manual dashboard turns/i)).toBeVisible();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
47
136
|
function ConfirmationHarness({ onTransition }: { onTransition: (status: TerminalStatus, actionId: string) => Promise<{ ok: boolean; rotateActionId?: boolean }> }) {
|
|
48
137
|
const [opened, setOpened] = useState(true);
|
|
49
138
|
return (
|
|
@@ -19,13 +19,13 @@ import {
|
|
|
19
19
|
IconCheck,
|
|
20
20
|
IconClipboard,
|
|
21
21
|
IconDots,
|
|
22
|
-
IconExternalLink,
|
|
23
22
|
IconX,
|
|
24
23
|
} from "@tabler/icons-react";
|
|
25
24
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
26
25
|
import type { Task } from "../types";
|
|
27
26
|
import { ActivityTimeline } from "./ActivityTimeline";
|
|
28
27
|
import { GitHubLinks } from "./GitHubLinks";
|
|
28
|
+
import { OpenChatButton } from "./OpenChatButton";
|
|
29
29
|
import { RelativeTime } from "./RelativeTime";
|
|
30
30
|
import { StatusBadge } from "./StatusBadge";
|
|
31
31
|
import { UsagePanel } from "./UsagePanel";
|
|
@@ -93,14 +93,7 @@ export function TaskDetail({
|
|
|
93
93
|
<StatusBadge status={task.status} />
|
|
94
94
|
</Group>
|
|
95
95
|
<Group gap="xs" mt="md">
|
|
96
|
-
<
|
|
97
|
-
leftSection={<IconExternalLink aria-hidden size={15} />}
|
|
98
|
-
loading={busy}
|
|
99
|
-
onClick={onOpenCodex}
|
|
100
|
-
size="compact-sm"
|
|
101
|
-
>
|
|
102
|
-
Open chat
|
|
103
|
-
</Button>
|
|
96
|
+
<OpenChatButton loading={busy} onClick={onOpenCodex} taskTitle={task.title} />
|
|
104
97
|
<Menu position="bottom-start" shadow="md" withinPortal zIndex={360}>
|
|
105
98
|
<Menu.Target>
|
|
106
99
|
<Tooltip label="More task actions">
|