taskchef 8.7.1 → 9.0.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 (150) hide show
  1. package/.codex-plugin/plugin.json +7 -10
  2. package/.mcp.json +3 -2
  3. package/README.md +35 -158
  4. package/docs/mcp-app.md +23 -6
  5. package/docs/spec.md +38 -746
  6. package/docs/taskchef-next-storage.md +2 -2
  7. package/index.js +2 -99
  8. package/package.json +12 -49
  9. package/skills/delegate/SKILL.md +44 -0
  10. package/skills/delegate/agents/openai.yaml +4 -0
  11. package/src/codex-app.js +3 -152
  12. package/src/codex-session-scanner.js +2 -2
  13. package/src/file-lock.js +25 -0
  14. package/src/mcp-app/react/NextTaskDetail.test.tsx +1 -1
  15. package/src/mcp-app/react/NextTaskDetail.tsx +8 -8
  16. package/src/mcp-app/react/main.test.tsx +3 -3
  17. package/src/mcp-app/react/main.tsx +7 -7
  18. package/src/mcp-app.js +2 -2
  19. package/src/mcp.js +4 -542
  20. package/src/next-cache.js +2 -2
  21. package/src/next-github.js +3 -3
  22. package/src/next-notifications.js +2 -2
  23. package/src/{dashboard/react → ui}/components/CardStatusIcons.tsx +1 -1
  24. package/src/{dashboard/react → ui}/components/GitHubLinks.tsx +1 -1
  25. package/src/{dashboard/react → ui}/components/LinkedText.tsx +1 -1
  26. package/src/{dashboard/react → ui}/components/RelativeTime.test.tsx +1 -1
  27. package/src/{dashboard/react → ui}/components/RelativeTime.tsx +1 -1
  28. package/src/{dashboard/react → ui}/components/ReplyImagePreview.test.tsx +1 -1
  29. package/src/{dashboard/react → ui}/components/TaskBoard.tsx +1 -1
  30. package/src/{dashboard/react → ui}/components/TaskCard.tsx +1 -1
  31. package/src/{dashboard/react → ui}/components/TurnContent.tsx +1 -1
  32. package/src/{dashboard/react → ui}/presentation.ts +1 -1
  33. package/src/user-prompt.js +11 -0
  34. package/src/version.js +0 -1
  35. package/BACKLOG.md +0 -48
  36. package/assets/e2e-benchmark-example.json +0 -50
  37. package/assets/taskchef-dispatcher-instructions.md +0 -44
  38. package/bin/taskchef.js +0 -20
  39. package/docs/advanced-guide.md +0 -497
  40. package/docs/dashboard-lifecycle.md +0 -149
  41. package/docs/dashboard-ui.md +0 -40
  42. package/docs/firstmate-taskchef-comparison.md +0 -283
  43. package/docs/images/ccusage-token-consumption.png +0 -0
  44. package/docs/images/dashboard-identity.jpg +0 -0
  45. package/docs/images/dashboard-token-pending.jpg +0 -0
  46. package/docs/images/interrupted-turn-recovery.jpg +0 -0
  47. package/docs/images/notification-event-snapshots.jpg +0 -0
  48. package/docs/images/readme-board.png +0 -0
  49. package/docs/images/readme-completed.png +0 -0
  50. package/docs/images/readme-list.png +0 -0
  51. package/docs/images/readme-needs-input.png +0 -0
  52. package/docs/images/readme-roles.png +0 -0
  53. package/docs/images/reported-work-duration-desktop.jpg +0 -0
  54. package/docs/images/reported-work-duration-mobile.jpg +0 -0
  55. package/docs/images/result-history-dashboard.jpg +0 -0
  56. package/docs/images/taskchef-pr-icon-comparison.jpg +0 -0
  57. package/docs/images/taskchef-unified-status-popup.jpg +0 -0
  58. package/docs/workflows.md +0 -476
  59. package/mcp/dashboard-session.js +0 -51
  60. package/scripts/benchmark-dispatch-prepare.js +0 -90
  61. package/scripts/e2e-benchmark.js +0 -206
  62. package/scripts/roles/resolve_roles.py +0 -293
  63. package/skills/taskchef-bootstrap/SKILL.md +0 -96
  64. package/skills/taskchef-bootstrap/agents/openai.yaml +0 -4
  65. package/skills/taskchef-bootstrap/references/project-index.md +0 -154
  66. package/skills/taskchef-copilot/SKILL.md +0 -75
  67. package/skills/taskchef-copilot/agents/openai.yaml +0 -4
  68. package/skills/taskchef-dashboard/SKILL.md +0 -24
  69. package/skills/taskchef-dashboard/agents/openai.yaml +0 -4
  70. package/skills/taskchef-delegate/SKILL.md +0 -102
  71. package/skills/taskchef-delegate/agents/openai.yaml +0 -4
  72. package/skills/taskchef-delegate/references/model-roles.md +0 -29
  73. package/skills/taskchef-executor/SKILL.md +0 -154
  74. package/skills/taskchef-executor/agents/openai.yaml +0 -4
  75. package/skills/taskchef-executor/references/compatibility.md +0 -22
  76. package/skills/taskchef-executor/references/ending-actions.md +0 -26
  77. package/src/backups.js +0 -359
  78. package/src/cli.js +0 -733
  79. package/src/config-audit.js +0 -99
  80. package/src/config-mutations.js +0 -106
  81. package/src/dashboard/actions.js +0 -136
  82. package/src/dashboard/app.js +0 -1172
  83. package/src/dashboard/dist/app.js +0 -269
  84. package/src/dashboard/dist/app.js.map +0 -1
  85. package/src/dashboard/dist/index.html +0 -16
  86. package/src/dashboard/dist/styles.css +0 -1
  87. package/src/dashboard/index.html +0 -148
  88. package/src/dashboard/react/App.test.tsx +0 -346
  89. package/src/dashboard/react/App.tsx +0 -515
  90. package/src/dashboard/react/api.test.ts +0 -40
  91. package/src/dashboard/react/api.ts +0 -103
  92. package/src/dashboard/react/components/ActivityTimeline.tsx +0 -118
  93. package/src/dashboard/react/components/ModelSettings.test.tsx +0 -180
  94. package/src/dashboard/react/components/ModelSettings.tsx +0 -106
  95. package/src/dashboard/react/components/NotificationCenter.test.tsx +0 -76
  96. package/src/dashboard/react/components/NotificationCenter.tsx +0 -111
  97. package/src/dashboard/react/components/PullRequestBadges.tsx +0 -18
  98. package/src/dashboard/react/components/TaskDetail.test.tsx +0 -255
  99. package/src/dashboard/react/components/TaskDetail.tsx +0 -271
  100. package/src/dashboard/react/components/UsagePanel.tsx +0 -96
  101. package/src/dashboard/react/index.html +0 -15
  102. package/src/dashboard/react/main.tsx +0 -12
  103. package/src/dashboard/react/overlay-state.test.ts +0 -110
  104. package/src/dashboard/react/overlay-state.ts +0 -60
  105. package/src/dashboard/react/presentation.test.tsx +0 -571
  106. package/src/dashboard/styles.css +0 -261
  107. package/src/dashboard-control.js +0 -34
  108. package/src/dashboard-manager.js +0 -339
  109. package/src/dashboard-session-process.js +0 -88
  110. package/src/dashboard-session.js +0 -86
  111. package/src/dashboard.js +0 -1303
  112. package/src/delegation.js +0 -277
  113. package/src/github.js +0 -107
  114. package/src/model-roles.js +0 -70
  115. package/src/recorded-usage-tracker.js +0 -92
  116. package/src/usage-tracker.js +0 -650
  117. package/src/usage.js +0 -367
  118. package/src/workspace-path.js +0 -48
  119. package/src/workspace.js +0 -3629
  120. /package/src/{dashboard/react → ui}/components/AnimatedUsage.test.tsx +0 -0
  121. /package/src/{dashboard/react → ui}/components/CardStatusIcons.test.tsx +0 -0
  122. /package/src/{dashboard/react → ui}/components/ImageGallery.tsx +0 -0
  123. /package/src/{dashboard/react → ui}/components/OpenChatButton.test.tsx +0 -0
  124. /package/src/{dashboard/react → ui}/components/OpenChatButton.tsx +0 -0
  125. /package/src/{dashboard/react → ui}/components/PrGlyph.tsx +0 -0
  126. /package/src/{dashboard/react → ui}/components/QueuedPrompts.test.tsx +0 -0
  127. /package/src/{dashboard/react → ui}/components/QueuedPrompts.tsx +0 -0
  128. /package/src/{dashboard/react → ui}/components/ReplyCover.test.tsx +0 -0
  129. /package/src/{dashboard/react → ui}/components/ReplyCover.tsx +0 -0
  130. /package/src/{dashboard/react → ui}/components/ReplyImagePreview.tsx +0 -0
  131. /package/src/{dashboard/react → ui}/components/ReplyMarkdown.test.tsx +0 -0
  132. /package/src/{dashboard/react → ui}/components/ReplyMarkdown.tsx +0 -0
  133. /package/src/{dashboard/react → ui}/components/SavedUsageFormat.test.tsx +0 -0
  134. /package/src/{dashboard/react → ui}/components/SavedUsageFormat.tsx +0 -0
  135. /package/src/{dashboard/react → ui}/components/ShimmerText.tsx +0 -0
  136. /package/src/{dashboard/react → ui}/components/StatsGrid.tsx +0 -0
  137. /package/src/{dashboard/react → ui}/components/StatusBadge.tsx +0 -0
  138. /package/src/{dashboard/react → ui}/components/TaskBoard.test.tsx +0 -0
  139. /package/src/{dashboard/react → ui}/components/TaskCardStats.test.tsx +0 -0
  140. /package/src/{dashboard/react → ui}/components/TaskCardStats.tsx +0 -0
  141. /package/src/{dashboard/react → ui}/components/TurnContent.test.tsx +0 -0
  142. /package/src/{dashboard/react → ui}/fixtures.ts +0 -0
  143. /package/src/{dashboard → ui}/github-links.js +0 -0
  144. /package/src/{dashboard/react → ui}/image-cache.test.ts +0 -0
  145. /package/src/{dashboard/react → ui}/image-cache.ts +0 -0
  146. /package/src/{dashboard → ui}/state.js +0 -0
  147. /package/src/{dashboard/react → ui}/styles.css +0 -0
  148. /package/src/{dashboard/react → ui}/test-setup.ts +0 -0
  149. /package/src/{dashboard → ui}/time.js +0 -0
  150. /package/src/{dashboard/react → ui}/types.ts +0 -0
@@ -1,515 +0,0 @@
1
- import { ModelSettings } from "./components/ModelSettings";
2
- import {
3
- Alert,
4
- ActionIcon,
5
- AppShell,
6
- Box,
7
- Button,
8
- CloseButton,
9
- Container,
10
- Group,
11
- MantineProvider,
12
- Paper,
13
- SegmentedControl,
14
- Select,
15
- Stack,
16
- Text,
17
- Title,
18
- Tooltip,
19
- createTheme,
20
- useComputedColorScheme,
21
- useMantineColorScheme,
22
- } from "@mantine/core";
23
- import { IconArrowLeft, IconSettings, IconAlertTriangle, IconCircleFilled, IconMoon, IconSun } from "@tabler/icons-react";
24
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
25
- import {
26
- dismissNotification,
27
- filterTasks,
28
- manualTransitionExpectedState,
29
- reconcileManualTransitionResponse,
30
- reconcileNotifications,
31
- statusFilterCounts,
32
- STATUS_FILTERS,
33
- } from "../state.js";
34
- import { connectDashboard, dashboardVersion, manualTransition, openInCodex, taskDetail } from "./api";
35
- import {
36
- clearNotificationHistory,
37
- listUsageSignature,
38
- mergeListTaskIntoDetail,
39
- notificationClicked,
40
- } from "./overlay-state";
41
- import { usageStillCalculating } from "./presentation";
42
- import type { DashboardSnapshot, NotificationSnapshot, Task } from "./types";
43
- import { NotificationCenter } from "./components/NotificationCenter";
44
- import { TaskCard } from "./components/TaskCard";
45
- import { TaskBoard } from "./components/TaskBoard";
46
- import { TaskDetail } from "./components/TaskDetail";
47
- import { RelativeTimeProvider } from "./components/RelativeTime";
48
-
49
- const theme = createTheme({
50
- primaryColor: "teal",
51
- colors: {
52
- teal: [
53
- "#e6fcf5",
54
- "#c3fae8",
55
- "#96f2d7",
56
- "#63e6be",
57
- "#38d9a9",
58
- "#20c997",
59
- "#087f5b",
60
- "#067052",
61
- "#055e46",
62
- "#044d39",
63
- ],
64
- },
65
- defaultRadius: "md",
66
- fontFamily: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif",
67
- headings: { fontFamily: "inherit", fontWeight: "680" },
68
- components: {
69
- Button: { defaultProps: { radius: "md" } },
70
- Paper: { defaultProps: { radius: "md" } },
71
- Select: { defaultProps: { size: "xs" } },
72
- },
73
- });
74
- const styleNonce = document.querySelector<HTMLMetaElement>('meta[name="taskchef-style-nonce"]')?.content;
75
- const VIEW_STORAGE_KEY = "taskchef.dashboard.view";
76
-
77
- function savedView(): "board" | "list" {
78
- try { return window.localStorage.getItem(VIEW_STORAGE_KEY) === "board" ? "board" : "list"; }
79
- catch { return "list"; }
80
- }
81
-
82
- interface NotificationState {
83
- initialized: boolean;
84
- announcements: NotificationSnapshot[];
85
- notifications: NotificationSnapshot[];
86
- seenIds: Set<string>;
87
- signatures: Map<string, string>;
88
- }
89
-
90
- export function TaskResultsSummary({
91
- totalCount,
92
- visibleCount,
93
- }: {
94
- totalCount: number;
95
- visibleCount: number;
96
- }) {
97
- return (
98
- <Text aria-live="polite" className="taskchef-results-summary taskchef-content" id="task-results-summary">
99
- Tasks: {visibleCount} of {totalCount}
100
- </Text>
101
- );
102
- }
103
-
104
- export function DashboardApp({
105
- connect = true,
106
- initialFilters = {},
107
- initialTasks = [],
108
- }: {
109
- connect?: boolean;
110
- initialFilters?: { project?: string; status?: string };
111
- initialTasks?: Task[];
112
- }) {
113
- const [settings, setSettings] = useState(() => window.location.hash === '#settings');
114
- useEffect(() => {
115
- const update = () => setSettings(window.location.hash === '#settings');
116
- window.addEventListener('hashchange', update);
117
- return () => window.removeEventListener('hashchange', update);
118
- }, []);
119
- const [tasks, setTasks] = useState<Task[]>(initialTasks);
120
- const [projectIndex, setProjectIndex] = useState<DashboardSnapshot["projectIndex"]>();
121
- const [connected, setConnected] = useState(false);
122
- const [message, setMessage] = useState<string | null>(null);
123
- const [version, setVersion] = useState<string | null>(null);
124
- const [project, setProject] = useState(initialFilters.project ?? "");
125
- const [status, setStatus] = useState(initialFilters.status ?? "");
126
- const [date, setDate] = useState("all");
127
- const [preferredView, setPreferredView] = useState(savedView);
128
- const [completedLimit, setCompletedLimit] = useState(5);
129
- const [now, setNow] = useState(() => Date.now());
130
- const [selectedTask, setSelectedTask] = useState<Task | null>(null);
131
- const [detailOpened, setDetailOpened] = useState(false);
132
- const [detailBusy, setDetailBusy] = useState(false);
133
- const [detailError, setDetailError] = useState<string | null>(null);
134
- const [detailNotice, setDetailNotice] = useState<string | null>(null);
135
- const [highlightTurnRef, setHighlightTurnRef] = useState<string | null>(null);
136
- const [notificationState, setNotificationState] = useState<NotificationState>({
137
- initialized: initialTasks.length > 0,
138
- announcements: [],
139
- notifications: [],
140
- seenIds: new Set(),
141
- signatures: new Map(),
142
- });
143
- const selectedId = selectedTask?.id ?? null;
144
- const selectedIdRef = useRef<string | null>(selectedId);
145
- selectedIdRef.current = selectedId;
146
- const detailGeneration = useRef(0);
147
- const detailListUpdatedAt = useRef<string | null>(null);
148
- const detailListUsageSignature = useRef(listUsageSignature(null));
149
-
150
- const applySnapshot = useCallback((snapshot: DashboardSnapshot) => {
151
- setTasks(snapshot.tasks);
152
- setProjectIndex(snapshot.projectIndex);
153
- setNotificationState((current) => {
154
- const reconciled = reconcileNotifications(current, snapshot.tasks);
155
- return {
156
- initialized: true,
157
- announcements: reconciled.additions,
158
- notifications: reconciled.notifications,
159
- seenIds: reconciled.seenIds,
160
- signatures: reconciled.signatures,
161
- };
162
- });
163
- setMessage(snapshot.healthy === false
164
- ? "The task log is temporarily unavailable. Showing the last valid snapshot."
165
- : null);
166
- const currentSelectedId = selectedIdRef.current;
167
- if (currentSelectedId) {
168
- const updated = snapshot.tasks.find((task) => task.id === currentSelectedId);
169
- if (updated) {
170
- setSelectedTask((current) => current ? mergeListTaskIntoDetail(current, updated) : updated);
171
- }
172
- }
173
- }, []);
174
-
175
- useEffect(() => {
176
- const timer = window.setInterval(() => setNow(Date.now()), 30_000);
177
- return () => window.clearInterval(timer);
178
- }, []);
179
-
180
- useEffect(() => {
181
- if (!connect) return;
182
- void dashboardVersion().then(setVersion).catch(() => setVersion(null));
183
- const stream = connectDashboard({
184
- onConnection: setConnected,
185
- onError: setMessage,
186
- onSnapshot: applySnapshot,
187
- });
188
- return () => stream.close();
189
- }, [applySnapshot, connect]);
190
-
191
- const loadDetail = useCallback(async (task: Task, focusTurnRef: string | null = null) => {
192
- const generation = ++detailGeneration.current;
193
- detailListUpdatedAt.current = task.updatedAt;
194
- detailListUsageSignature.current = listUsageSignature(task);
195
- setSelectedTask((current) => current?.id === task.id
196
- ? mergeListTaskIntoDetail(current, task)
197
- : task);
198
- setDetailOpened(true);
199
- setHighlightTurnRef(focusTurnRef);
200
- setDetailError(null);
201
- setDetailNotice(null);
202
- try {
203
- for (let attempt = 0; attempt <= 40; attempt += 1) {
204
- if (generation !== detailGeneration.current) return;
205
- const detail = await taskDetail(task.id);
206
- if (generation !== detailGeneration.current) return;
207
- detailListUpdatedAt.current = detail.updatedAt;
208
- detailListUsageSignature.current = listUsageSignature(detail);
209
- setSelectedTask(detail);
210
- if (!usageStillCalculating(detail) || attempt === 40) return;
211
- await new Promise((resolve) => window.setTimeout(resolve, 1_500));
212
- }
213
- } catch {
214
- if (generation === detailGeneration.current) {
215
- setDetailError("Task activity is temporarily unavailable. Showing the latest list snapshot.");
216
- }
217
- }
218
- }, []);
219
-
220
- const selectedListTask = tasks.find((task) => task.id === selectedId) ?? null;
221
- const selectedListUsageSignature = listUsageSignature(selectedListTask);
222
-
223
- useEffect(() => {
224
- if (!detailOpened || !selectedId || !connect) return;
225
- if (!selectedListTask) return;
226
- if (
227
- selectedListTask.updatedAt === detailListUpdatedAt.current
228
- && selectedListUsageSignature === detailListUsageSignature.current
229
- ) return;
230
- void loadDetail(selectedListTask, highlightTurnRef);
231
- // Task or cached usage changes while detail is open refresh the full projection.
232
- // eslint-disable-next-line react-hooks/exhaustive-deps
233
- }, [selectedListTask?.updatedAt, selectedListUsageSignature]);
234
-
235
- const projects = useMemo(() => [
236
- { label: "All projects", value: "" },
237
- ...[...new Set(tasks.map((task) => task.project.name))].sort().map((value) => ({ label: value, value })),
238
- ], [tasks]);
239
- const visible = useMemo(() => filterTasks(tasks, { project, status, date, now }), [tasks, project, status, date, now]);
240
- const boardTasks = useMemo(() => filterTasks(tasks, { project, date, now }), [tasks, project, date, now]);
241
- const board = preferredView === "board";
242
- const listLayout = !board && !settings;
243
- const counts = useMemo(() => statusFilterCounts(tasks, { project, date, now }), [tasks, project, date, now]);
244
- const statusData = STATUS_FILTERS.map(({ label, value }: { label: string; value: string }) => ({
245
- label: counts[value] > 0 ? `${label} ${counts[value]}` : label,
246
- value,
247
- }));
248
-
249
- function changeView(value: string) {
250
- if (value !== "board" && value !== "list") return;
251
- setPreferredView(value);
252
- try { window.localStorage.setItem(VIEW_STORAGE_KEY, value); } catch { /* Preference remains in memory. */ }
253
- }
254
-
255
- async function handleOpenCodex(task: Task) {
256
- setDetailBusy(true);
257
- try {
258
- const result = await openInCodex(task.id);
259
- if (result) setMessage(result);
260
- } catch {
261
- setMessage("Codex could not be opened. Open the project and select the recorded task instead.");
262
- } finally {
263
- setDetailBusy(false);
264
- }
265
- }
266
-
267
- async function handleTransition(targetStatus: "completed" | "failed", actionId: string) {
268
- if (!selectedTask) return { ok: false };
269
- const requestTask = selectedTask;
270
- const expected = manualTransitionExpectedState(requestTask);
271
- setDetailBusy(true);
272
- setDetailError(null);
273
- const result = await manualTransition(requestTask, targetStatus, actionId);
274
- setDetailBusy(false);
275
- if (result.task) {
276
- setSelectedTask((current) => reconcileManualTransitionResponse({
277
- requestTask,
278
- expected,
279
- responseTask: result.task,
280
- selectedTask: current,
281
- }));
282
- setTasks((current) => current.map((candidate) => candidate.id === requestTask.id
283
- ? reconcileManualTransitionResponse({
284
- requestTask,
285
- expected,
286
- responseTask: result.task,
287
- selectedTask: candidate,
288
- })
289
- : candidate));
290
- }
291
- if (result.ok && result.task) {
292
- setHighlightTurnRef(result.task.turnRef ?? result.task.turnId);
293
- return { ok: true };
294
- } else {
295
- setDetailError(result.message ?? "Task state could not be changed.");
296
- return { ok: false, rotateActionId: result.code === "stale_task" };
297
- }
298
- }
299
-
300
- function handleNotificationOpen(notification: NotificationSnapshot) {
301
- const currentTask = tasks.find((task) => task.id === notification.taskId);
302
- const overlay = notificationClicked({
303
- detailTaskId: selectedId,
304
- detailOpened,
305
- highlightTurnRef,
306
- notifications: notificationState.notifications,
307
- refreshGeneration: detailGeneration.current,
308
- }, notification, Boolean(currentTask));
309
- setNotificationState((current) => ({ ...current, notifications: overlay.notifications }));
310
- if (!currentTask) {
311
- setMessage("This task is no longer present in the current snapshot.");
312
- return;
313
- }
314
- void loadDetail(currentTask, overlay.highlightTurnRef);
315
- }
316
-
317
- return (
318
- <MantineProvider
319
- defaultColorScheme="auto"
320
- getStyleNonce={styleNonce ? () => styleNonce : undefined}
321
- theme={theme}
322
- >
323
- <RelativeTimeProvider now={now}>
324
- <AppShell className="taskchef-shell" padding={0}>
325
- <Box className="taskchef-header-shell" component="header">
326
- <Container className={`taskchef-header${board ? " taskchef-header-board" : ""}${listLayout ? " taskchef-list-layout" : ""}`} size="100%">
327
- <Group className="taskchef-header-layout" justify="space-between" wrap="nowrap">
328
- <Group className="taskchef-header-copy" gap="xs" wrap="nowrap">
329
- <BrandIcon />
330
- <Title className="taskchef-brand-title" order={1}>
331
- <span>TaskChef Dashboard</span>
332
- {version && <span className="taskchef-version">v{version}</span>}
333
- </Title>
334
- </Group>
335
- <Group className="taskchef-header-actions" gap={2} justify="flex-end" wrap="nowrap">
336
- <Tooltip label="Settings"><ActionIcon className="taskchef-icon-button" component="a" href="#settings" aria-label="Settings" aria-current={settings ? 'page' : undefined} variant="subtle" color="gray" size="lg"><IconSettings size={18} stroke={1.8} /></ActionIcon></Tooltip>
337
- <ThemeToggle />
338
- <Group aria-live="polite" className="taskchef-connection" gap={7} role="status" wrap="nowrap">
339
- <IconCircleFilled aria-hidden color={connected ? "var(--mantine-color-teal-6)" : "var(--mantine-color-yellow-6)"} size={9} />
340
- <Text c="dimmed" size="xs">{connected ? "Live" : connect ? "Connecting…" : "Fixture preview"}</Text>
341
- </Group>
342
- </Group>
343
- </Group>
344
- </Container>
345
- </Box>
346
-
347
- <AppShell.Main>
348
- <Container className={`taskchef-main${board ? " taskchef-main-board" : ""}${listLayout ? " taskchef-list-layout" : ""}`} pb={80} size="100%">
349
- {projectIndex?.status === "unavailable" && (
350
- <Alert color="yellow" icon={<IconAlertTriangle size={18} />} mb="md" title="Project index unavailable">
351
- Task history is still visible. Verify the index and inspect backups before making project changes.
352
- </Alert>
353
- )}
354
- {projectIndex?.status === "available" && projectIndex.missingProjects.length > 0 && (
355
- <Alert color="yellow" icon={<IconAlertTriangle size={18} />} mb="md" title="Historical projects are missing from the current index">
356
- {projectIndex.missingProjects.map((item) => (
357
- <Text key={item.path} size="sm">
358
- {item.snapshotNames.join(", ")}: {item.taskCount} task(s). Run <code>taskchef backup list --json</code> and preview any restore; the dashboard never restores automatically.
359
- </Text>
360
- ))}
361
- </Alert>
362
- )}
363
- {settings ? <Stack className="taskchef-settings-page taskchef-content" gap="sm"><Button component="a" href="#" variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} style={{ alignSelf: 'flex-start' }}>Back to tasks</Button><ModelSettings /></Stack> : <>
364
- <Paper className="taskchef-toolbar" radius={0}>
365
- <Stack gap="sm">
366
- <Group className="taskchef-toolbar-view">
367
- <SegmentedControl aria-label="View" data={[{ label: "Board", value: "board" }, { label: "List", value: "list" }]} onChange={changeView} size="xs" value={preferredView} withItemsBorders={false} />
368
- </Group>
369
- <Group className="taskchef-toolbar-filters" gap="sm" wrap="nowrap">
370
- <Select aria-label="Project" data={projects} onChange={(value) => { setProject(value ?? ""); setCompletedLimit(5); }} value={project} />
371
- <Select aria-label="Updated" data={[{ label: "Latest 24 hours", value: "24h" }, { label: "Latest 7 days", value: "7d" }, { label: "All time", value: "all" }]} onChange={(value) => { setDate(value ?? "all"); setCompletedLimit(5); }} value={date} />
372
- </Group>
373
- {!board && <Box className="taskchef-toolbar-status">
374
- <SegmentedControl aria-label="Status" data={statusData} onChange={setStatus} size="xs" value={status} withItemsBorders={false} />
375
- </Box>}
376
- </Stack>
377
- </Paper>
378
-
379
- {!board && <TaskResultsSummary totalCount={tasks.length} visibleCount={visible.length} />}
380
-
381
- {message && (
382
- <Alert color="yellow" icon={<IconAlertTriangle aria-hidden size={17} />} mt="md" role="status">
383
- <Group gap="sm" justify="space-between" wrap="nowrap">
384
- <Text size="sm">{message}</Text>
385
- <CloseButton aria-label="Dismiss dashboard message" onClick={() => setMessage(null)} size="sm" />
386
- </Group>
387
- </Alert>
388
- )}
389
-
390
- {board ? <TaskBoard
391
- completedLimit={completedLimit}
392
- onMoreCompleted={() => setCompletedLimit((limit) => limit + 5)}
393
- onOpenCodex={handleOpenCodex}
394
- onOpenDetail={(value) => void loadDetail(value)}
395
- tasks={boardTasks}
396
- /> : <Stack aria-describedby="task-results-summary" aria-label="Tasks" className="taskchef-list taskchef-content" component="section" gap="sm" mt="xs">
397
- {visible.map((task: Task) => (
398
- <TaskCard key={task.id} onOpenCodex={handleOpenCodex} onOpenDetail={(value) => void loadDetail(value)} task={task} />
399
- ))}
400
- {visible.length === 0 && (
401
- <Paper className="taskchef-empty" p={48} ta="center" withBorder>
402
- <Title order={2} size="h4">No tasks match these filters</Title>
403
- <Text c="dimmed" mt={4} size="sm">Choose a different project, update window, or status.</Text>
404
- </Paper>
405
- )}
406
- </Stack>}
407
- </>}
408
- </Container>
409
- </AppShell.Main>
410
- </AppShell>
411
-
412
- <TaskDetail
413
- busy={detailBusy}
414
- error={detailError}
415
- highlightTurnRef={highlightTurnRef}
416
- onClose={() => {
417
- if (!detailBusy) {
418
- detailGeneration.current += 1;
419
- setDetailOpened(false);
420
- setHighlightTurnRef(null);
421
- }
422
- }}
423
- onCopy={() => {
424
- if (!selectedTask) return;
425
- setDetailNotice(null);
426
- void navigator.clipboard.writeText(selectedTask.id).then(
427
- () => setDetailNotice("Task ID copied."),
428
- () => setDetailNotice("Clipboard access is unavailable. Copy the Task ID from metadata."),
429
- );
430
- }}
431
- onOpenCodex={() => selectedTask && void handleOpenCodex(selectedTask)}
432
- onTransition={handleTransition}
433
- opened={detailOpened}
434
- task={selectedTask}
435
- notice={detailNotice}
436
- notifications={detailOpened ? (
437
- <NotificationCenter
438
- announcements={notificationState.announcements}
439
- notifications={notificationState.notifications}
440
- onClear={() => setNotificationState((current) => ({
441
- ...current,
442
- notifications: clearNotificationHistory({
443
- detailTaskId: selectedId,
444
- detailOpened,
445
- highlightTurnRef,
446
- notifications: current.notifications,
447
- refreshGeneration: detailGeneration.current,
448
- }).notifications,
449
- }))}
450
- onDismiss={(notification) => setNotificationState((current) => ({ ...current, notifications: dismissNotification(current.notifications, notification.id) }))}
451
- onOpen={handleNotificationOpen}
452
- tasks={tasks}
453
- withinPortal={false}
454
- />
455
- ) : null}
456
- />
457
- {!detailOpened && (
458
- <NotificationCenter
459
- announcements={notificationState.announcements}
460
- notifications={notificationState.notifications}
461
- onClear={() => setNotificationState((current) => ({
462
- ...current,
463
- notifications: clearNotificationHistory({
464
- detailTaskId: selectedId,
465
- detailOpened,
466
- highlightTurnRef,
467
- notifications: current.notifications,
468
- refreshGeneration: detailGeneration.current,
469
- }).notifications,
470
- }))}
471
- onDismiss={(notification) => setNotificationState((current) => ({ ...current, notifications: dismissNotification(current.notifications, notification.id) }))}
472
- onOpen={handleNotificationOpen}
473
- tasks={tasks}
474
- />
475
- )}
476
- </RelativeTimeProvider>
477
- </MantineProvider>
478
- );
479
- }
480
-
481
- function ThemeToggle() {
482
- const { setColorScheme } = useMantineColorScheme();
483
- const colorScheme = useComputedColorScheme("light");
484
- const next = colorScheme === "dark" ? "light" : "dark";
485
- return (
486
- <Tooltip label={`Use ${next} theme`}>
487
- <ActionIcon
488
- aria-label={`Use ${next} theme`}
489
- className="taskchef-icon-button"
490
- color="gray"
491
- onClick={() => setColorScheme(next)}
492
- size="lg"
493
- variant="subtle"
494
- >
495
- {colorScheme === "dark" ? <IconSun aria-hidden size={18} stroke={1.8} /> : <IconMoon aria-hidden size={18} stroke={1.8} />}
496
- </ActionIcon>
497
- </Tooltip>
498
- );
499
- }
500
-
501
- function BrandIcon() {
502
- const colorScheme = useComputedColorScheme("light");
503
- return (
504
- <img
505
- alt=""
506
- aria-hidden
507
- className="taskchef-brand-icon"
508
- height="28"
509
- src={colorScheme === "dark" ? "/assets/taskchef-dark.svg" : "/assets/taskchef.svg"}
510
- width="28"
511
- />
512
- );
513
- }
514
-
515
- export default DashboardApp;
@@ -1,40 +0,0 @@
1
- import { afterEach, expect, test, vi } from "vitest";
2
- import { fixtureTask } from "./fixtures";
3
- import { manualTransition, updateModelRole } from "./api";
4
-
5
- afterEach(() => {
6
- vi.useRealTimers();
7
- vi.unstubAllGlobals();
8
- });
9
-
10
- test("times out a stalled manual transition and returns a retryable error", async () => {
11
- vi.useFakeTimers();
12
- vi.stubGlobal("fetch", vi.fn((_url: string, init?: RequestInit) => new Promise((_resolve, reject) => {
13
- init?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")));
14
- })));
15
-
16
- const request = manualTransition(fixtureTask(), "completed", crypto.randomUUID());
17
- await vi.advanceTimersByTimeAsync(10_000);
18
-
19
- await expect(request).resolves.toMatchObject({
20
- ok: false,
21
- code: "request_timeout",
22
- message: "Task state change timed out. Try again.",
23
- });
24
- });
25
-
26
- test("updates a personal model role with an exact JSON request", async () => {
27
- const fetchMock = vi.fn().mockResolvedValue({
28
- ok: true,
29
- json: async () => ({ profile: { id: "personal" } }),
30
- });
31
- vi.stubGlobal("fetch", fetchMock);
32
- await expect(updateModelRole("planner", "gpt-fixture", "high")).resolves.toEqual({
33
- profile: { id: "personal" },
34
- });
35
- expect(fetchMock).toHaveBeenCalledWith("/api/settings/planner", {
36
- method: "POST",
37
- headers: { "Content-Type": "application/json" },
38
- body: JSON.stringify({ schemaVersion: 1, model: "gpt-fixture", effort: "high" }),
39
- });
40
- });
@@ -1,103 +0,0 @@
1
- import type { DashboardSnapshot, ManualTransitionResponse, Task, TaskStatus } from "./types";
2
-
3
- export interface DashboardStream {
4
- close(): void;
5
- }
6
-
7
- export function connectDashboard({
8
- onConnection,
9
- onError,
10
- onSnapshot,
11
- }: {
12
- onConnection: (connected: boolean) => void;
13
- onError: (message: string) => void;
14
- onSnapshot: (snapshot: DashboardSnapshot) => void;
15
- }): DashboardStream {
16
- const events = new EventSource("/api/events");
17
- events.addEventListener("open", () => onConnection(true));
18
- events.addEventListener("error", () => onConnection(false));
19
- events.addEventListener("snapshot", (event) => {
20
- onConnection(true);
21
- onSnapshot(JSON.parse((event as MessageEvent).data) as DashboardSnapshot);
22
- });
23
- events.addEventListener("dashboard-error", (event) => {
24
- const payload = JSON.parse((event as MessageEvent).data) as { message?: string };
25
- onError(payload.message ?? "The task log is temporarily unavailable.");
26
- });
27
- return events;
28
- }
29
-
30
- async function jsonRequest<T>(url: string, init?: RequestInit): Promise<T> {
31
- const response = await fetch(url, init);
32
- const payload = await response.json().catch(() => ({}));
33
- if (!response.ok) throw Object.assign(new Error(payload.message ?? "Request failed."), { payload });
34
- return payload as T;
35
- }
36
-
37
- export async function dashboardVersion(): Promise<string> {
38
- const health = await jsonRequest<{ taskchefVersion: string }>("/api/health");
39
- return health.taskchefVersion;
40
- }
41
-
42
- export async function updateModelRole(role: string, model: string, effort: string) {
43
- return jsonRequest<{ profile: unknown }>(`/api/settings/${encodeURIComponent(role)}`, {
44
- method: "POST",
45
- headers: { "Content-Type": "application/json" },
46
- body: JSON.stringify({ schemaVersion: 1, model, effort }),
47
- });
48
- }
49
-
50
- export async function taskDetail(taskId: string): Promise<Task> {
51
- return (await jsonRequest<{ task: Task }>(`/api/tasks/${encodeURIComponent(taskId)}`)).task;
52
- }
53
-
54
- export async function openInCodex(taskId: string): Promise<string | null> {
55
- const result = await jsonRequest<{ message?: string }>(
56
- `/api/tasks/${encodeURIComponent(taskId)}/open-codex`,
57
- { method: "POST" },
58
- );
59
- return result.message ?? null;
60
- }
61
-
62
- export async function manualTransition(
63
- task: Task,
64
- targetStatus: Exclude<TaskStatus, "working" | "needs_input" | null>,
65
- actionId: string,
66
- ): Promise<ManualTransitionResponse> {
67
- const controller = new AbortController();
68
- const timeout = window.setTimeout(() => controller.abort(), 10_000);
69
- try {
70
- const result = await jsonRequest<{ task: Task }>(
71
- `/api/tasks/${encodeURIComponent(task.id)}/manual-transition`,
72
- {
73
- method: "POST",
74
- headers: { "Content-Type": "application/json" },
75
- body: JSON.stringify({
76
- schemaVersion: 1,
77
- actionId,
78
- expected: {
79
- status: task.status,
80
- turnRef: task.turnRef,
81
- threadId: task.threadId,
82
- updatedAt: task.updatedAt,
83
- },
84
- targetStatus,
85
- }),
86
- signal: controller.signal,
87
- },
88
- );
89
- return { ok: true, task: result.task };
90
- } catch (error) {
91
- const payload = (error as { payload?: { code?: string; message?: string; task?: Task } }).payload;
92
- return {
93
- ok: false,
94
- code: payload?.code ?? (controller.signal.aborted ? "request_timeout" : "network_error"),
95
- message: payload?.message ?? (controller.signal.aborted
96
- ? "Task state change timed out. Try again."
97
- : "Task state changes are temporarily unavailable. Try again."),
98
- task: payload?.task ?? null,
99
- };
100
- } finally {
101
- window.clearTimeout(timeout);
102
- }
103
- }