taskchef 7.23.4 → 7.24.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.
Files changed (33) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/package.json +24 -2
  3. package/src/dashboard/dist/app.js +164 -0
  4. package/src/dashboard/dist/app.js.map +1 -0
  5. package/src/dashboard/dist/index.html +16 -0
  6. package/src/dashboard/dist/styles.css +1 -0
  7. package/src/dashboard/github-links.js +12 -4
  8. package/src/dashboard/react/App.tsx +432 -0
  9. package/src/dashboard/react/api.test.ts +24 -0
  10. package/src/dashboard/react/api.ts +95 -0
  11. package/src/dashboard/react/components/ActivityTimeline.tsx +66 -0
  12. package/src/dashboard/react/components/GitHubLinks.tsx +46 -0
  13. package/src/dashboard/react/components/LinkedText.tsx +63 -0
  14. package/src/dashboard/react/components/NotificationCenter.test.tsx +71 -0
  15. package/src/dashboard/react/components/NotificationCenter.tsx +111 -0
  16. package/src/dashboard/react/components/RelativeTime.tsx +28 -0
  17. package/src/dashboard/react/components/ShimmerText.tsx +9 -0
  18. package/src/dashboard/react/components/StatusBadge.tsx +7 -0
  19. package/src/dashboard/react/components/TaskCard.tsx +65 -0
  20. package/src/dashboard/react/components/TaskDetail.test.tsx +62 -0
  21. package/src/dashboard/react/components/TaskDetail.tsx +251 -0
  22. package/src/dashboard/react/components/UsagePanel.tsx +73 -0
  23. package/src/dashboard/react/fixtures.ts +52 -0
  24. package/src/dashboard/react/index.html +15 -0
  25. package/src/dashboard/react/main.tsx +12 -0
  26. package/src/dashboard/react/overlay-state.test.ts +79 -0
  27. package/src/dashboard/react/overlay-state.ts +38 -0
  28. package/src/dashboard/react/presentation.test.tsx +241 -0
  29. package/src/dashboard/react/presentation.ts +102 -0
  30. package/src/dashboard/react/styles.css +128 -0
  31. package/src/dashboard/react/test-setup.ts +36 -0
  32. package/src/dashboard/react/types.ts +106 -0
  33. package/src/dashboard.js +29 -18
@@ -0,0 +1,251 @@
1
+ import {
2
+ ActionIcon,
3
+ Alert,
4
+ Box,
5
+ Button,
6
+ Divider,
7
+ Drawer,
8
+ Group,
9
+ Menu,
10
+ Modal,
11
+ ScrollArea,
12
+ Stack,
13
+ Text,
14
+ Title,
15
+ Tooltip,
16
+ } from "@mantine/core";
17
+ import { useMediaQuery } from "@mantine/hooks";
18
+ import {
19
+ IconCheck,
20
+ IconClipboard,
21
+ IconDots,
22
+ IconExternalLink,
23
+ IconX,
24
+ } from "@tabler/icons-react";
25
+ import { useEffect, useRef, useState, type ReactNode } from "react";
26
+ import type { Task } from "../types";
27
+ import { ActivityTimeline } from "./ActivityTimeline";
28
+ import { GitHubLinks } from "./GitHubLinks";
29
+ import { RelativeTime } from "./RelativeTime";
30
+ import { StatusBadge } from "./StatusBadge";
31
+ import { UsagePanel } from "./UsagePanel";
32
+
33
+ export type TerminalStatus = "completed" | "failed";
34
+ export interface ManualTransitionResult {
35
+ ok: boolean;
36
+ rotateActionId?: boolean;
37
+ }
38
+
39
+ export function TaskDetail({
40
+ busy,
41
+ error,
42
+ highlightTurnRef,
43
+ onClose,
44
+ onCopy,
45
+ onOpenCodex,
46
+ onTransition,
47
+ opened,
48
+ task,
49
+ notifications,
50
+ notice,
51
+ }: {
52
+ busy: boolean;
53
+ error: string | null;
54
+ highlightTurnRef: string | null;
55
+ onClose: () => void;
56
+ onCopy: () => void;
57
+ onOpenCodex: () => void;
58
+ onTransition: (status: TerminalStatus, actionId: string) => Promise<ManualTransitionResult>;
59
+ opened: boolean;
60
+ task: Task | null;
61
+ notifications?: ReactNode;
62
+ notice?: string | null;
63
+ }) {
64
+ const mobile = useMediaQuery("(max-width: 48em)");
65
+ const [confirmStatus, setConfirmStatus] = useState<TerminalStatus | null>(null);
66
+ useEffect(() => {
67
+ if (!opened) setConfirmStatus(null);
68
+ }, [opened]);
69
+ useEffect(() => {
70
+ if (opened && highlightTurnRef) {
71
+ requestAnimationFrame(() => {
72
+ document.querySelector<HTMLElement>(`[data-turn-ref="${CSS.escape(highlightTurnRef)}"]`)?.focus();
73
+ });
74
+ }
75
+ }, [highlightTurnRef, opened, task?.turns?.length, task?.updatedAt]);
76
+ useEffect(() => {
77
+ if (confirmStatus && (!task || !manualTransitionTargets(task).includes(confirmStatus))) {
78
+ setConfirmStatus(null);
79
+ }
80
+ }, [confirmStatus, task?.status]);
81
+
82
+ if (!task) return null;
83
+ const transitionTargets = manualTransitionTargets(task);
84
+ const content = (
85
+ <Stack gap="lg">
86
+ {notifications}
87
+ <Box>
88
+ <Group align="flex-start" gap="sm" justify="space-between" wrap="nowrap">
89
+ <Box>
90
+ <Text c="teal" fw={700} size="xs" tt="uppercase">{task.project.name}</Text>
91
+ <Title id="task-detail-title" order={2} size="h3" tabIndex={-1}>{task.title}</Title>
92
+ </Box>
93
+ <StatusBadge status={task.status} />
94
+ </Group>
95
+ <Group gap="xs" mt="md">
96
+ <Button
97
+ leftSection={<IconExternalLink aria-hidden size={15} />}
98
+ loading={busy}
99
+ onClick={onOpenCodex}
100
+ size="compact-sm"
101
+ >
102
+ Open chat
103
+ </Button>
104
+ <Menu position="bottom-start" shadow="md" withinPortal zIndex={360}>
105
+ <Menu.Target>
106
+ <Tooltip label="More task actions">
107
+ <ActionIcon aria-label="More task actions" disabled={busy} variant="default">
108
+ <IconDots aria-hidden size={17} />
109
+ </ActionIcon>
110
+ </Tooltip>
111
+ </Menu.Target>
112
+ <Menu.Dropdown>
113
+ <Menu.Item leftSection={<IconClipboard size={14} />} onClick={onCopy}>Copy Task ID</Menu.Item>
114
+ {transitionTargets.includes("completed") && (
115
+ <Menu.Item leftSection={<IconCheck size={14} />} onClick={() => setConfirmStatus("completed")}>Mark completed</Menu.Item>
116
+ )}
117
+ {transitionTargets.includes("failed") && (
118
+ <Menu.Item color="red" leftSection={<IconX size={14} />} onClick={() => setConfirmStatus("failed")}>Mark failed</Menu.Item>
119
+ )}
120
+ </Menu.Dropdown>
121
+ </Menu>
122
+ </Group>
123
+ <Box mt="sm">
124
+ <GitHubLinks task={task} />
125
+ </Box>
126
+ </Box>
127
+
128
+ {confirmStatus && (
129
+ <ManualTransitionConfirmation
130
+ key={confirmStatus}
131
+ busy={busy}
132
+ onCancel={() => setConfirmStatus(null)}
133
+ onTransition={onTransition}
134
+ status={confirmStatus}
135
+ />
136
+ )}
137
+ {notice && <Alert color="teal" role="status">{notice}</Alert>}
138
+ {error && <Alert color="red" role="alert">{error}</Alert>}
139
+
140
+ <section aria-labelledby="usage-heading">
141
+ <Title id="usage-heading" mb="xs" order={3} size="h5">Usage</Title>
142
+ <UsagePanel task={task} />
143
+ </section>
144
+ <Divider />
145
+ <section aria-labelledby="activity-heading">
146
+ <Title id="activity-heading" mb="sm" order={3} size="h5">Activity timeline</Title>
147
+ <ActivityTimeline highlightTurnRef={highlightTurnRef} task={task} />
148
+ </section>
149
+ <section aria-labelledby="instruction-heading">
150
+ <Title id="instruction-heading" mb="xs" order={3} size="h5">Original instruction</Title>
151
+ <Box className="taskchef-code-panel" component="pre">{task.instruction}</Box>
152
+ </section>
153
+ <section aria-labelledby="metadata-heading">
154
+ <Title id="metadata-heading" mb="xs" order={3} size="h5">Metadata</Title>
155
+ <dl className="taskchef-metadata">
156
+ <dt>Task ID</dt><dd>{task.id}</dd>
157
+ <dt>Thread ID</dt><dd>{task.threadId ?? "—"}</dd>
158
+ <dt>Current turn ref</dt><dd>{task.turnRef ?? "—"}</dd>
159
+ <dt>Project path</dt><dd>{task.project.path}</dd>
160
+ <dt>Created</dt><dd><RelativeTime label="Created time" value={task.createdAt} /></dd>
161
+ <dt>Updated</dt><dd><RelativeTime label="Updated time" value={task.meaningfulUpdatedAt ?? task.updatedAt} /></dd>
162
+ <dt>Updated by</dt><dd>{task.updatedBy ?? "—"}</dd>
163
+ </dl>
164
+ </section>
165
+ </Stack>
166
+ );
167
+
168
+ if (mobile) {
169
+ return (
170
+ <Drawer
171
+ closeOnClickOutside={!busy}
172
+ closeOnEscape={!busy}
173
+ onClose={onClose}
174
+ opened={opened}
175
+ overlayProps={{ backgroundOpacity: 0.55, blur: 2 }}
176
+ position="bottom"
177
+ scrollAreaComponent={ScrollArea.Autosize}
178
+ size="92%"
179
+ title={<span className="taskchef-visually-hidden">Task details</span>}
180
+ trapFocus
181
+ zIndex={300}
182
+ >
183
+ {content}
184
+ </Drawer>
185
+ );
186
+ }
187
+ return (
188
+ <Modal
189
+ aria-labelledby="task-detail-title"
190
+ closeOnClickOutside={!busy}
191
+ closeOnEscape={!busy}
192
+ onClose={onClose}
193
+ opened={opened}
194
+ overlayProps={{ backgroundOpacity: 0.55, blur: 2 }}
195
+ scrollAreaComponent={ScrollArea.Autosize}
196
+ size="min(820px, calc(100vw - 32px))"
197
+ title={<span className="taskchef-visually-hidden">Task details</span>}
198
+ trapFocus
199
+ zIndex={300}
200
+ >
201
+ {content}
202
+ </Modal>
203
+ );
204
+ }
205
+
206
+ function manualTransitionTargets(task: Task): TerminalStatus[] {
207
+ return task.status === "completed"
208
+ ? ["failed"]
209
+ : task.status === "failed"
210
+ ? ["completed"]
211
+ : task.status === "working" || task.status === "needs_input"
212
+ ? ["completed", "failed"]
213
+ : [];
214
+ }
215
+
216
+ export function ManualTransitionConfirmation({
217
+ busy,
218
+ onCancel,
219
+ onTransition,
220
+ status,
221
+ }: {
222
+ busy: boolean;
223
+ onCancel: () => void;
224
+ onTransition: (status: TerminalStatus, actionId: string) => Promise<ManualTransitionResult>;
225
+ status: TerminalStatus;
226
+ }) {
227
+ const actionId = useRef(crypto.randomUUID());
228
+ return (
229
+ <Alert color={status === "failed" ? "red" : "teal"} role="alert" title={`Mark task ${status}?`}>
230
+ <Text mb="sm" size="sm">This interrupts active work and appends an audited manual dashboard turn.</Text>
231
+ <Group gap="xs">
232
+ <Button
233
+ color={status === "failed" ? "red" : "teal"}
234
+ loading={busy}
235
+ onClick={async () => {
236
+ const result = await onTransition(status, actionId.current);
237
+ if (result.ok) {
238
+ onCancel();
239
+ requestAnimationFrame(() => document.getElementById("task-detail-title")?.focus());
240
+ }
241
+ else if (result.rotateActionId) actionId.current = crypto.randomUUID();
242
+ }}
243
+ size="compact-sm"
244
+ >
245
+ Confirm
246
+ </Button>
247
+ <Button disabled={busy} onClick={onCancel} size="compact-sm" variant="default">Cancel</Button>
248
+ </Group>
249
+ </Alert>
250
+ );
251
+ }
@@ -0,0 +1,73 @@
1
+ import { Box, Group, Paper, Text } from "@mantine/core";
2
+ import { IconCoins } from "@tabler/icons-react";
3
+ import type { Task } from "../types";
4
+ import {
5
+ formatCompactTokens,
6
+ formatEstimatedCost,
7
+ formatFullTokens,
8
+ usageView,
9
+ } from "../presentation";
10
+ import { ShimmerText } from "./ShimmerText";
11
+
12
+ export function UsagePanel({ task }: { task: Task }) {
13
+ const usage = usageView(task);
14
+ if (usage.kind === "pending" || usage.kind === "calculating") {
15
+ return (
16
+ <Paper aria-live="polite" className="taskchef-subtle-panel" p="sm" radius="md">
17
+ <Group gap="xs">
18
+ <IconCoins aria-hidden size={16} />
19
+ <Text size="sm"><ShimmerText>{usage.label}</ShimmerText></Text>
20
+ </Group>
21
+ </Paper>
22
+ );
23
+ }
24
+ if (usage.kind === "unavailable") {
25
+ return <Text aria-live="polite" c="dimmed" size="sm">{usage.label}</Text>;
26
+ }
27
+ const cached = usage.usage.cachedInputTokens ?? 0;
28
+ const input = usage.usage.inputTokens ?? 0;
29
+ const cacheRatio = input + cached > 0 ? `${Math.round((cached / (input + cached)) * 100)}%` : "—";
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
+ ];
55
+ return (
56
+ <Box aria-live="polite" className="taskchef-metrics">
57
+ {values.map(({ accessibleValue, label, title, value }) => (
58
+ <Paper className="taskchef-metric" key={label} p="sm" radius="md" withBorder>
59
+ <Text c="dimmed" size="xs">{label}</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>
69
+ </Paper>
70
+ ))}
71
+ </Box>
72
+ );
73
+ }
@@ -0,0 +1,52 @@
1
+ import type { Task } from "./types";
2
+
3
+ export function fixtureTask(overrides: Partial<Task> = {}): Task {
4
+ const timestamp = "2026-08-30T08:00:00.000Z";
5
+ const id = overrides.id ?? "11111111-1111-4111-8111-111111111111";
6
+ return {
7
+ id,
8
+ schemaVersion: 10,
9
+ title: "Review checkout reconciliation",
10
+ instruction: "Verify the isolated dashboard fixture without using live TaskChef data.",
11
+ project: {
12
+ name: "TaskChef Preview",
13
+ path: "/tmp/taskchef-preview-project",
14
+ githubRepos: ["https://github.com/example/taskchef-preview"],
15
+ },
16
+ createdAt: timestamp,
17
+ updatedAt: timestamp,
18
+ meaningfulUpdatedAt: timestamp,
19
+ updatedBy: "mcp",
20
+ status: "working",
21
+ summary: null,
22
+ threadId: "019ffb69-57a6-7801-8b7a-8ff4c32a398c",
23
+ turnRef: "turn-one",
24
+ turnId: null,
25
+ lastResult: null,
26
+ latestTurn: {
27
+ requestSummary: "Review the operator-facing task state.",
28
+ result: null,
29
+ startedAt: timestamp,
30
+ turnRef: "turn-one",
31
+ turnId: null,
32
+ },
33
+ turns: [{
34
+ requestSummary: "Review the operator-facing task state.",
35
+ result: null,
36
+ startedAt: timestamp,
37
+ turnRef: "turn-one",
38
+ turnId: null,
39
+ }],
40
+ results: [],
41
+ usage: { generationTurnRef: "turn-one", status: "pending" },
42
+ relatedGitHubLinks: [{
43
+ label: "example/taskchef-preview#24",
44
+ owner: "example",
45
+ repository: "taskchef-preview",
46
+ number: "24",
47
+ type: "pull",
48
+ url: "https://github.com/example/taskchef-preview/pull/24",
49
+ }],
50
+ ...overrides,
51
+ };
52
+ }
@@ -0,0 +1,15 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="color-scheme" content="light dark" />
7
+ <meta name="theme-color" content="#f3f5f2" />
8
+ <meta name="taskchef-style-nonce" content="__TASKCHEF_STYLE_NONCE__" />
9
+ <title>TaskChef Dashboard</title>
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ <script type="module" src="/main.tsx"></script>
14
+ </body>
15
+ </html>
@@ -0,0 +1,12 @@
1
+ import "@mantine/core/styles.css";
2
+ import "@mantine/notifications/styles.css";
3
+ import { StrictMode } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import DashboardApp from "./App";
6
+ import "./styles.css";
7
+
8
+ createRoot(document.getElementById("root")!).render(
9
+ <StrictMode>
10
+ <DashboardApp />
11
+ </StrictMode>,
12
+ );
@@ -0,0 +1,79 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import type { NotificationSnapshot } from "./types";
3
+ import {
4
+ clearNotificationHistory,
5
+ notificationAppeared,
6
+ notificationClicked,
7
+ type OverlayState,
8
+ } from "./overlay-state";
9
+
10
+ const taskUpdate: NotificationSnapshot = {
11
+ id: "task-one-completed",
12
+ taskId: "task-one",
13
+ title: "Review reconciliation",
14
+ status: "completed",
15
+ event: "completed",
16
+ turnRef: "turn-two",
17
+ turnId: null,
18
+ timestamp: "2026-08-30T08:10:00.000Z",
19
+ summary: "Review completed.",
20
+ };
21
+ const unrelated: NotificationSnapshot = {
22
+ ...taskUpdate,
23
+ id: "task-two-failed",
24
+ taskId: "task-two",
25
+ title: "Validate fixture",
26
+ status: "failed",
27
+ event: "failed",
28
+ turnRef: "turn-three",
29
+ };
30
+
31
+ function state(overrides: Partial<OverlayState> = {}): OverlayState {
32
+ return {
33
+ detailTaskId: null,
34
+ detailOpened: false,
35
+ highlightTurnRef: null,
36
+ notifications: [],
37
+ refreshGeneration: 0,
38
+ ...overrides,
39
+ };
40
+ }
41
+
42
+ describe("overlay coordination", () => {
43
+ test("a later notification changes visibility without changing the open detail", () => {
44
+ const open = state({ detailTaskId: "task-one", detailOpened: true });
45
+ expect(notificationAppeared(open, [taskUpdate])).toEqual({
46
+ ...open,
47
+ notifications: [taskUpdate],
48
+ });
49
+ });
50
+
51
+ test("clicking a notification dismisses only it and opens its detail above prior content", () => {
52
+ const next = notificationClicked(state({ notifications: [taskUpdate, unrelated] }), taskUpdate, true);
53
+ expect(next.detailOpened).toBe(true);
54
+ expect(next.detailTaskId).toBe("task-one");
55
+ expect(next.highlightTurnRef).toBe("turn-two");
56
+ expect(next.notifications).toEqual([unrelated]);
57
+ expect(next.refreshGeneration).toBe(1);
58
+ });
59
+
60
+ test("clicking an update for the already-open task refreshes and highlights without closing unrelated messages", () => {
61
+ const current = state({
62
+ detailTaskId: "task-one",
63
+ detailOpened: true,
64
+ notifications: [taskUpdate, unrelated],
65
+ refreshGeneration: 4,
66
+ });
67
+ const next = notificationClicked(current, taskUpdate, true);
68
+ expect(next.detailTaskId).toBe(current.detailTaskId);
69
+ expect(next.detailOpened).toBe(true);
70
+ expect(next.highlightTurnRef).toBe("turn-two");
71
+ expect(next.refreshGeneration).toBe(5);
72
+ expect(next.notifications).toEqual([unrelated]);
73
+ });
74
+
75
+ test("clearing notification history leaves detail state untouched", () => {
76
+ const current = state({ detailTaskId: "task-one", detailOpened: true, notifications: [taskUpdate] });
77
+ expect(clearNotificationHistory(current)).toEqual({ ...current, notifications: [] });
78
+ });
79
+ });
@@ -0,0 +1,38 @@
1
+ import type { NotificationSnapshot } from "./types";
2
+
3
+ export interface OverlayState {
4
+ detailTaskId: string | null;
5
+ detailOpened: boolean;
6
+ highlightTurnRef: string | null;
7
+ notifications: NotificationSnapshot[];
8
+ refreshGeneration: number;
9
+ }
10
+
11
+ export function notificationAppeared(
12
+ state: OverlayState,
13
+ notifications: NotificationSnapshot[],
14
+ ): OverlayState {
15
+ // Visibility changes only; focus remains where the operator left it.
16
+ return { ...state, notifications };
17
+ }
18
+
19
+ export function notificationClicked(
20
+ state: OverlayState,
21
+ notification: NotificationSnapshot,
22
+ taskAvailable: boolean,
23
+ ): OverlayState {
24
+ const notifications = state.notifications.filter(({ id }) => id !== notification.id);
25
+ if (!taskAvailable) return { ...state, notifications };
26
+ return {
27
+ ...state,
28
+ detailTaskId: notification.taskId,
29
+ detailOpened: true,
30
+ highlightTurnRef: notification.turnRef ?? notification.turnId,
31
+ notifications,
32
+ refreshGeneration: state.refreshGeneration + 1,
33
+ };
34
+ }
35
+
36
+ export function clearNotificationHistory(state: OverlayState): OverlayState {
37
+ return { ...state, notifications: [] };
38
+ }