taskchef 7.23.4 → 7.24.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.
Files changed (31) 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/react/App.tsx +432 -0
  8. package/src/dashboard/react/api.test.ts +24 -0
  9. package/src/dashboard/react/api.ts +95 -0
  10. package/src/dashboard/react/components/ActivityTimeline.tsx +63 -0
  11. package/src/dashboard/react/components/GitHubLinks.tsx +25 -0
  12. package/src/dashboard/react/components/NotificationCenter.test.tsx +71 -0
  13. package/src/dashboard/react/components/NotificationCenter.tsx +111 -0
  14. package/src/dashboard/react/components/RelativeTime.tsx +28 -0
  15. package/src/dashboard/react/components/ShimmerText.tsx +9 -0
  16. package/src/dashboard/react/components/StatusBadge.tsx +7 -0
  17. package/src/dashboard/react/components/TaskCard.tsx +62 -0
  18. package/src/dashboard/react/components/TaskDetail.test.tsx +62 -0
  19. package/src/dashboard/react/components/TaskDetail.tsx +251 -0
  20. package/src/dashboard/react/components/UsagePanel.tsx +41 -0
  21. package/src/dashboard/react/fixtures.ts +52 -0
  22. package/src/dashboard/react/index.html +15 -0
  23. package/src/dashboard/react/main.tsx +12 -0
  24. package/src/dashboard/react/overlay-state.test.ts +79 -0
  25. package/src/dashboard/react/overlay-state.ts +38 -0
  26. package/src/dashboard/react/presentation.test.tsx +128 -0
  27. package/src/dashboard/react/presentation.ts +80 -0
  28. package/src/dashboard/react/styles.css +123 -0
  29. package/src/dashboard/react/test-setup.ts +36 -0
  30. package/src/dashboard/react/types.ts +106 -0
  31. package/src/dashboard.js +29 -18
@@ -0,0 +1,432 @@
1
+ import {
2
+ Alert,
3
+ ActionIcon,
4
+ AppShell,
5
+ Box,
6
+ CloseButton,
7
+ Container,
8
+ Group,
9
+ MantineProvider,
10
+ Paper,
11
+ SegmentedControl,
12
+ Select,
13
+ Stack,
14
+ Text,
15
+ Title,
16
+ Tooltip,
17
+ createTheme,
18
+ useComputedColorScheme,
19
+ useMantineColorScheme,
20
+ } from "@mantine/core";
21
+ import { IconAlertTriangle, IconCircleFilled, IconMoon, IconSun } from "@tabler/icons-react";
22
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
23
+ import {
24
+ dismissNotification,
25
+ filterTasks,
26
+ manualTransitionExpectedState,
27
+ reconcileManualTransitionResponse,
28
+ reconcileNotifications,
29
+ statusFilterCounts,
30
+ STATUS_FILTERS,
31
+ } from "../state.js";
32
+ import { connectDashboard, dashboardVersion, manualTransition, openInCodex, taskDetail } from "./api";
33
+ import { clearNotificationHistory, notificationClicked } from "./overlay-state";
34
+ import { usageStillCalculating } from "./presentation";
35
+ import type { DashboardSnapshot, NotificationSnapshot, Task } from "./types";
36
+ import { NotificationCenter } from "./components/NotificationCenter";
37
+ import { TaskCard } from "./components/TaskCard";
38
+ import { TaskDetail } from "./components/TaskDetail";
39
+ import { RelativeTimeProvider } from "./components/RelativeTime";
40
+
41
+ const theme = createTheme({
42
+ primaryColor: "teal",
43
+ colors: {
44
+ teal: [
45
+ "#e6fcf5",
46
+ "#c3fae8",
47
+ "#96f2d7",
48
+ "#63e6be",
49
+ "#38d9a9",
50
+ "#20c997",
51
+ "#087f5b",
52
+ "#067052",
53
+ "#055e46",
54
+ "#044d39",
55
+ ],
56
+ },
57
+ defaultRadius: "md",
58
+ fontFamily: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif",
59
+ headings: { fontFamily: "inherit", fontWeight: "680" },
60
+ components: {
61
+ Button: { defaultProps: { radius: "md" } },
62
+ Paper: { defaultProps: { radius: "md" } },
63
+ },
64
+ });
65
+ const styleNonce = document.querySelector<HTMLMetaElement>('meta[name="taskchef-style-nonce"]')?.content;
66
+
67
+ interface NotificationState {
68
+ initialized: boolean;
69
+ announcements: NotificationSnapshot[];
70
+ notifications: NotificationSnapshot[];
71
+ seenIds: Set<string>;
72
+ signatures: Map<string, string>;
73
+ }
74
+
75
+ export function DashboardApp({
76
+ connect = true,
77
+ initialTasks = [],
78
+ }: {
79
+ connect?: boolean;
80
+ initialTasks?: Task[];
81
+ }) {
82
+ const [tasks, setTasks] = useState<Task[]>(initialTasks);
83
+ const [connected, setConnected] = useState(false);
84
+ const [message, setMessage] = useState<string | null>(null);
85
+ const [version, setVersion] = useState<string | null>(null);
86
+ const [project, setProject] = useState("");
87
+ const [status, setStatus] = useState("");
88
+ const [date, setDate] = useState("all");
89
+ const [now, setNow] = useState(() => Date.now());
90
+ const [selectedTask, setSelectedTask] = useState<Task | null>(null);
91
+ const [detailOpened, setDetailOpened] = useState(false);
92
+ const [detailBusy, setDetailBusy] = useState(false);
93
+ const [detailError, setDetailError] = useState<string | null>(null);
94
+ const [detailNotice, setDetailNotice] = useState<string | null>(null);
95
+ const [highlightTurnRef, setHighlightTurnRef] = useState<string | null>(null);
96
+ const [notificationState, setNotificationState] = useState<NotificationState>({
97
+ initialized: initialTasks.length > 0,
98
+ announcements: [],
99
+ notifications: [],
100
+ seenIds: new Set(),
101
+ signatures: new Map(),
102
+ });
103
+ const selectedId = selectedTask?.id ?? null;
104
+ const selectedIdRef = useRef<string | null>(selectedId);
105
+ selectedIdRef.current = selectedId;
106
+ const detailGeneration = useRef(0);
107
+ const detailListUpdatedAt = useRef<string | null>(null);
108
+
109
+ const applySnapshot = useCallback((snapshot: DashboardSnapshot) => {
110
+ setTasks(snapshot.tasks);
111
+ setNotificationState((current) => {
112
+ const reconciled = reconcileNotifications(current, snapshot.tasks);
113
+ return {
114
+ initialized: true,
115
+ announcements: reconciled.additions,
116
+ notifications: reconciled.notifications,
117
+ seenIds: reconciled.seenIds,
118
+ signatures: reconciled.signatures,
119
+ };
120
+ });
121
+ setMessage(snapshot.healthy === false
122
+ ? "The task log is temporarily unavailable. Showing the last valid snapshot."
123
+ : null);
124
+ const currentSelectedId = selectedIdRef.current;
125
+ if (currentSelectedId) {
126
+ const updated = snapshot.tasks.find((task) => task.id === currentSelectedId);
127
+ if (updated) setSelectedTask((current) => current ? { ...current, ...updated } : updated);
128
+ }
129
+ }, []);
130
+
131
+ useEffect(() => {
132
+ const timer = window.setInterval(() => setNow(Date.now()), 30_000);
133
+ return () => window.clearInterval(timer);
134
+ }, []);
135
+
136
+ useEffect(() => {
137
+ if (!connect) return;
138
+ void dashboardVersion().then(setVersion).catch(() => setVersion(null));
139
+ const stream = connectDashboard({
140
+ onConnection: setConnected,
141
+ onError: setMessage,
142
+ onSnapshot: applySnapshot,
143
+ });
144
+ return () => stream.close();
145
+ }, [applySnapshot, connect]);
146
+
147
+ const loadDetail = useCallback(async (task: Task, focusTurnRef: string | null = null) => {
148
+ const generation = ++detailGeneration.current;
149
+ detailListUpdatedAt.current = task.updatedAt;
150
+ setSelectedTask((current) => current?.id === task.id ? { ...current, ...task } : task);
151
+ setDetailOpened(true);
152
+ setHighlightTurnRef(focusTurnRef);
153
+ setDetailError(null);
154
+ setDetailNotice(null);
155
+ try {
156
+ for (let attempt = 0; attempt <= 40; attempt += 1) {
157
+ if (generation !== detailGeneration.current) return;
158
+ const detail = await taskDetail(task.id);
159
+ if (generation !== detailGeneration.current) return;
160
+ setSelectedTask(detail);
161
+ if (!usageStillCalculating(detail) || attempt === 40) return;
162
+ await new Promise((resolve) => window.setTimeout(resolve, 1_500));
163
+ }
164
+ } catch {
165
+ if (generation === detailGeneration.current) {
166
+ setDetailError("Task activity is temporarily unavailable. Showing the latest list snapshot.");
167
+ }
168
+ }
169
+ }, []);
170
+
171
+ useEffect(() => {
172
+ if (!detailOpened || !selectedId || !connect) return;
173
+ const listTask = tasks.find((task) => task.id === selectedId);
174
+ if (!listTask || listTask.updatedAt === detailListUpdatedAt.current) return;
175
+ void loadDetail(listTask, highlightTurnRef);
176
+ // A task signature update while detail is open refreshes the full projection.
177
+ // eslint-disable-next-line react-hooks/exhaustive-deps
178
+ }, [tasks.find((task) => task.id === selectedId)?.updatedAt]);
179
+
180
+ const projects = useMemo(() => [
181
+ { label: "All projects", value: "" },
182
+ ...[...new Set(tasks.map((task) => task.project.name))].sort().map((value) => ({ label: value, value })),
183
+ ], [tasks]);
184
+ const visible = useMemo(() => filterTasks(tasks, { project, status, date, now }), [tasks, project, status, date, now]);
185
+ const counts = useMemo(() => statusFilterCounts(tasks, { project, date, now }), [tasks, project, date, now]);
186
+ const statusData = STATUS_FILTERS.map(({ label, value }: { label: string; value: string }) => ({
187
+ label: counts[value] > 0 ? `${label} ${counts[value]}` : label,
188
+ value,
189
+ }));
190
+
191
+ async function handleOpenCodex(task: Task) {
192
+ setDetailBusy(true);
193
+ try {
194
+ const result = await openInCodex(task.id);
195
+ if (result) setMessage(result);
196
+ } catch {
197
+ setMessage("Codex could not be opened. Open the project and select the recorded task instead.");
198
+ } finally {
199
+ setDetailBusy(false);
200
+ }
201
+ }
202
+
203
+ async function handleTransition(targetStatus: "completed" | "failed", actionId: string) {
204
+ if (!selectedTask) return { ok: false };
205
+ const requestTask = selectedTask;
206
+ const expected = manualTransitionExpectedState(requestTask);
207
+ setDetailBusy(true);
208
+ setDetailError(null);
209
+ const result = await manualTransition(requestTask, targetStatus, actionId);
210
+ setDetailBusy(false);
211
+ if (result.task) {
212
+ setSelectedTask((current) => reconcileManualTransitionResponse({
213
+ requestTask,
214
+ expected,
215
+ responseTask: result.task,
216
+ selectedTask: current,
217
+ }));
218
+ setTasks((current) => current.map((candidate) => candidate.id === requestTask.id
219
+ ? reconcileManualTransitionResponse({
220
+ requestTask,
221
+ expected,
222
+ responseTask: result.task,
223
+ selectedTask: candidate,
224
+ })
225
+ : candidate));
226
+ }
227
+ if (result.ok && result.task) {
228
+ setHighlightTurnRef(result.task.turnRef ?? result.task.turnId);
229
+ return { ok: true };
230
+ } else {
231
+ setDetailError(result.message ?? "Task state could not be changed.");
232
+ return { ok: false, rotateActionId: result.code === "stale_task" };
233
+ }
234
+ }
235
+
236
+ function handleNotificationOpen(notification: NotificationSnapshot) {
237
+ const currentTask = tasks.find((task) => task.id === notification.taskId);
238
+ const overlay = notificationClicked({
239
+ detailTaskId: selectedId,
240
+ detailOpened,
241
+ highlightTurnRef,
242
+ notifications: notificationState.notifications,
243
+ refreshGeneration: detailGeneration.current,
244
+ }, notification, Boolean(currentTask));
245
+ setNotificationState((current) => ({ ...current, notifications: overlay.notifications }));
246
+ if (!currentTask) {
247
+ setMessage("This task is no longer present in the current snapshot.");
248
+ return;
249
+ }
250
+ void loadDetail(currentTask, overlay.highlightTurnRef);
251
+ }
252
+
253
+ return (
254
+ <MantineProvider
255
+ defaultColorScheme="auto"
256
+ getStyleNonce={styleNonce ? () => styleNonce : undefined}
257
+ theme={theme}
258
+ >
259
+ <RelativeTimeProvider now={now}>
260
+ <AppShell className="taskchef-shell" padding={0}>
261
+ <Container className="taskchef-header" component="header" size={1080}>
262
+ <Group className="taskchef-header-layout" align="stretch" justify="space-between" wrap="nowrap">
263
+ <Box className="taskchef-header-copy">
264
+ <Text c="var(--taskchef-accent)" fw={750} size="xs" tt="uppercase">TaskChef {version && <span className="taskchef-version">v{version}</span>}</Text>
265
+ <Group gap="sm" mt={5} wrap="nowrap">
266
+ <BrandIcon />
267
+ <Title order={1} size="h2">Dashboard</Title>
268
+ </Group>
269
+ </Box>
270
+ <Stack align="flex-end" className="taskchef-header-actions" gap={0} justify="space-between">
271
+ <Group aria-live="polite" className="taskchef-connection" gap={7} role="status" wrap="nowrap">
272
+ <IconCircleFilled aria-hidden color={connected ? "var(--mantine-color-teal-6)" : "var(--mantine-color-yellow-6)"} size={9} />
273
+ <Text c="dimmed" size="xs">{connected ? "Live" : connect ? "Connecting…" : "Fixture preview"}</Text>
274
+ </Group>
275
+ <ThemeToggle />
276
+ </Stack>
277
+ </Group>
278
+ </Container>
279
+
280
+ <AppShell.Main>
281
+ <Container className="taskchef-main" pb={80} size={1080}>
282
+ <Paper className="taskchef-toolbar" p="sm" radius="md" withBorder>
283
+ <Stack gap="sm">
284
+ <Group align="end" gap="sm">
285
+ <Select aria-label="Project" data={projects} label="Project" onChange={(value) => setProject(value ?? "")} size="sm" value={project} />
286
+ <Select
287
+ aria-label="Updated"
288
+ data={[{ label: "Latest 24 hours", value: "24h" }, { label: "Latest 7 days", value: "7d" }, { label: "All time", value: "all" }]}
289
+ label="Updated"
290
+ onChange={(value) => setDate(value ?? "all")}
291
+ size="sm"
292
+ value={date}
293
+ />
294
+ <Box className="taskchef-task-count-field">
295
+ <Text className="taskchef-filter-label" mb={3}>Tasks</Text>
296
+ <Text className="taskchef-count-value" size="sm">{visible.length} of {tasks.length}</Text>
297
+ </Box>
298
+ </Group>
299
+ <Box>
300
+ <Text className="taskchef-filter-label" mb={5}>Status</Text>
301
+ <SegmentedControl aria-label="Status" data={statusData} fullWidth onChange={setStatus} size="xs" value={status} />
302
+ </Box>
303
+ </Stack>
304
+ </Paper>
305
+
306
+ {message && (
307
+ <Alert color="yellow" icon={<IconAlertTriangle aria-hidden size={17} />} mt="md" role="status">
308
+ <Group gap="sm" justify="space-between" wrap="nowrap">
309
+ <Text size="sm">{message}</Text>
310
+ <CloseButton aria-label="Dismiss dashboard message" onClick={() => setMessage(null)} size="sm" />
311
+ </Group>
312
+ </Alert>
313
+ )}
314
+
315
+ <Stack aria-label="Tasks" component="section" gap="sm" mt="md">
316
+ {visible.map((task: Task) => (
317
+ <TaskCard key={task.id} onOpenCodex={handleOpenCodex} onOpenDetail={(value) => void loadDetail(value)} task={task} />
318
+ ))}
319
+ {visible.length === 0 && (
320
+ <Paper className="taskchef-empty" p={48} ta="center" withBorder>
321
+ <Title order={2} size="h4">No tasks match these filters</Title>
322
+ <Text c="dimmed" mt={4} size="sm">Choose a different project, date, or status.</Text>
323
+ </Paper>
324
+ )}
325
+ </Stack>
326
+ </Container>
327
+ </AppShell.Main>
328
+ </AppShell>
329
+
330
+ <TaskDetail
331
+ busy={detailBusy}
332
+ error={detailError}
333
+ highlightTurnRef={highlightTurnRef}
334
+ onClose={() => {
335
+ if (!detailBusy) {
336
+ detailGeneration.current += 1;
337
+ setDetailOpened(false);
338
+ setHighlightTurnRef(null);
339
+ }
340
+ }}
341
+ onCopy={() => {
342
+ if (!selectedTask) return;
343
+ setDetailNotice(null);
344
+ void navigator.clipboard.writeText(selectedTask.id).then(
345
+ () => setDetailNotice("Task ID copied."),
346
+ () => setDetailNotice("Clipboard access is unavailable. Copy the Task ID from metadata."),
347
+ );
348
+ }}
349
+ onOpenCodex={() => selectedTask && void handleOpenCodex(selectedTask)}
350
+ onTransition={handleTransition}
351
+ opened={detailOpened}
352
+ task={selectedTask}
353
+ notice={detailNotice}
354
+ notifications={detailOpened ? (
355
+ <NotificationCenter
356
+ announcements={notificationState.announcements}
357
+ notifications={notificationState.notifications}
358
+ onClear={() => setNotificationState((current) => ({
359
+ ...current,
360
+ notifications: clearNotificationHistory({
361
+ detailTaskId: selectedId,
362
+ detailOpened,
363
+ highlightTurnRef,
364
+ notifications: current.notifications,
365
+ refreshGeneration: detailGeneration.current,
366
+ }).notifications,
367
+ }))}
368
+ onDismiss={(notification) => setNotificationState((current) => ({ ...current, notifications: dismissNotification(current.notifications, notification.id) }))}
369
+ onOpen={handleNotificationOpen}
370
+ tasks={tasks}
371
+ withinPortal={false}
372
+ />
373
+ ) : null}
374
+ />
375
+ {!detailOpened && (
376
+ <NotificationCenter
377
+ announcements={notificationState.announcements}
378
+ notifications={notificationState.notifications}
379
+ onClear={() => setNotificationState((current) => ({
380
+ ...current,
381
+ notifications: clearNotificationHistory({
382
+ detailTaskId: selectedId,
383
+ detailOpened,
384
+ highlightTurnRef,
385
+ notifications: current.notifications,
386
+ refreshGeneration: detailGeneration.current,
387
+ }).notifications,
388
+ }))}
389
+ onDismiss={(notification) => setNotificationState((current) => ({ ...current, notifications: dismissNotification(current.notifications, notification.id) }))}
390
+ onOpen={handleNotificationOpen}
391
+ tasks={tasks}
392
+ />
393
+ )}
394
+ </RelativeTimeProvider>
395
+ </MantineProvider>
396
+ );
397
+ }
398
+
399
+ function ThemeToggle() {
400
+ const { setColorScheme } = useMantineColorScheme();
401
+ const colorScheme = useComputedColorScheme("light");
402
+ const next = colorScheme === "dark" ? "light" : "dark";
403
+ return (
404
+ <Tooltip label={`Use ${next} theme`}>
405
+ <ActionIcon
406
+ aria-label={`Use ${next} theme`}
407
+ color="gray"
408
+ onClick={() => setColorScheme(next)}
409
+ size="lg"
410
+ variant="subtle"
411
+ >
412
+ {colorScheme === "dark" ? <IconSun aria-hidden size={18} /> : <IconMoon aria-hidden size={18} />}
413
+ </ActionIcon>
414
+ </Tooltip>
415
+ );
416
+ }
417
+
418
+ function BrandIcon() {
419
+ const colorScheme = useComputedColorScheme("light");
420
+ return (
421
+ <img
422
+ alt=""
423
+ aria-hidden
424
+ className="taskchef-brand-icon"
425
+ height="42"
426
+ src={colorScheme === "dark" ? "/assets/taskchef-dark.svg" : "/assets/taskchef.svg"}
427
+ width="42"
428
+ />
429
+ );
430
+ }
431
+
432
+ export default DashboardApp;
@@ -0,0 +1,24 @@
1
+ import { afterEach, expect, test, vi } from "vitest";
2
+ import { fixtureTask } from "./fixtures";
3
+ import { manualTransition } 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
+ });
@@ -0,0 +1,95 @@
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 taskDetail(taskId: string): Promise<Task> {
43
+ return (await jsonRequest<{ task: Task }>(`/api/tasks/${encodeURIComponent(taskId)}`)).task;
44
+ }
45
+
46
+ export async function openInCodex(taskId: string): Promise<string | null> {
47
+ const result = await jsonRequest<{ message?: string }>(
48
+ `/api/tasks/${encodeURIComponent(taskId)}/open-codex`,
49
+ { method: "POST" },
50
+ );
51
+ return result.message ?? null;
52
+ }
53
+
54
+ export async function manualTransition(
55
+ task: Task,
56
+ targetStatus: Exclude<TaskStatus, "working" | "needs_input" | null>,
57
+ actionId: string,
58
+ ): Promise<ManualTransitionResponse> {
59
+ const controller = new AbortController();
60
+ const timeout = window.setTimeout(() => controller.abort(), 10_000);
61
+ try {
62
+ const result = await jsonRequest<{ task: Task }>(
63
+ `/api/tasks/${encodeURIComponent(task.id)}/manual-transition`,
64
+ {
65
+ method: "POST",
66
+ headers: { "Content-Type": "application/json" },
67
+ body: JSON.stringify({
68
+ schemaVersion: 1,
69
+ actionId,
70
+ expected: {
71
+ status: task.status,
72
+ turnRef: task.turnRef,
73
+ threadId: task.threadId,
74
+ updatedAt: task.updatedAt,
75
+ },
76
+ targetStatus,
77
+ }),
78
+ signal: controller.signal,
79
+ },
80
+ );
81
+ return { ok: true, task: result.task };
82
+ } catch (error) {
83
+ const payload = (error as { payload?: { code?: string; message?: string; task?: Task } }).payload;
84
+ return {
85
+ ok: false,
86
+ code: payload?.code ?? (controller.signal.aborted ? "request_timeout" : "network_error"),
87
+ message: payload?.message ?? (controller.signal.aborted
88
+ ? "Task state change timed out. Try again."
89
+ : "Task state changes are temporarily unavailable. Try again."),
90
+ task: payload?.task ?? null,
91
+ };
92
+ } finally {
93
+ window.clearTimeout(timeout);
94
+ }
95
+ }
@@ -0,0 +1,63 @@
1
+ import { Box, Paper, Stack, Text } from "@mantine/core";
2
+ import { mergeProjectedTurns, turnPresentation } from "../../state.js";
3
+ import { turnUsageView } from "../presentation";
4
+ import type { Task } from "../types";
5
+ import { RelativeTime } from "./RelativeTime";
6
+ import { ShimmerText } from "./ShimmerText";
7
+ import { StatusBadge } from "./StatusBadge";
8
+
9
+ export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef: string | null; task: Task }) {
10
+ const turns = mergeProjectedTurns(task, []) as Task["turns"];
11
+ if (!turns?.length) return <Text c="dimmed" size="sm">No turn history has been recorded.</Text>;
12
+ return (
13
+ <Stack gap="sm">
14
+ {[...turns].reverse().map((turn, index) => {
15
+ const presentation = turnPresentation(turn);
16
+ const identity = turn.turnRef ?? turn.turnId ?? `turn-${index}`;
17
+ const highlighted = identity === highlightTurnRef;
18
+ const usage = turnUsageView(task, turn);
19
+ return (
20
+ <Paper
21
+ className={`taskchef-turn${highlighted ? " taskchef-turn-highlighted" : ""}`}
22
+ data-turn-ref={identity}
23
+ key={identity}
24
+ p="sm"
25
+ radius="md"
26
+ tabIndex={highlighted ? -1 : undefined}
27
+ withBorder
28
+ >
29
+ <Stack gap={7}>
30
+ <Box className="taskchef-turn-heading">
31
+ <StatusBadge status={presentation.status} />
32
+ <RelativeTime label="Turn update time" value={presentation.updatedAt} />
33
+ </Box>
34
+ {presentation.sourceLabel && <Text c="dimmed" size="xs">{presentation.sourceLabel}</Text>}
35
+ <Box>
36
+ <Text c="dimmed" className="taskchef-field-label" size="xs">Request</Text>
37
+ <Text className="taskchef-preserve-lines" size="sm">{turn.requestSummary ?? "Request not recorded."}</Text>
38
+ </Box>
39
+ <Box>
40
+ <Text c="dimmed" className="taskchef-field-label" size="xs">Result</Text>
41
+ <Text className="taskchef-preserve-lines" size="sm">
42
+ {presentation.status === "working"
43
+ ? <ShimmerText>{presentation.summary}</ShimmerText>
44
+ : presentation.summary}
45
+ </Text>
46
+ </Box>
47
+ <Text
48
+ c="dimmed"
49
+ className={usage.kind === "calculating" || usage.kind === "pending" ? "taskchef-turn-usage" : undefined}
50
+ size="xs"
51
+ >
52
+ {usage.kind === "calculating" || usage.kind === "pending"
53
+ ? <ShimmerText>{usage.label}</ShimmerText>
54
+ : usage.label}
55
+ </Text>
56
+ <Text c="dimmed" className="taskchef-mono" size="xs">Turn ref: {identity ?? "—"}</Text>
57
+ </Stack>
58
+ </Paper>
59
+ );
60
+ })}
61
+ </Stack>
62
+ );
63
+ }
@@ -0,0 +1,25 @@
1
+ import { Anchor, Group, Text } from "@mantine/core";
2
+ import type { Task } from "../types";
3
+
4
+ export function GitHubLinks({ task }: { task: Task }) {
5
+ const links = task.relatedGitHubLinks ?? [];
6
+ if (links.length === 0 && !task.relatedGitHubLinksTruncated) return null;
7
+ return (
8
+ <Group aria-label={`Related GitHub links for ${task.title}`} component="nav" gap={5} wrap="wrap">
9
+ {links.map((link) => (
10
+ <Anchor
11
+ aria-label={`${link.label}, GitHub ${link.type === "pull" ? "pull request" : link.type ?? "link"} (opens in a new tab)`}
12
+ className="taskchef-github-link"
13
+ href={link.url}
14
+ key={link.url}
15
+ rel="noopener noreferrer"
16
+ size="xs"
17
+ target="_blank"
18
+ >
19
+ {link.label}
20
+ </Anchor>
21
+ ))}
22
+ {task.relatedGitHubLinksTruncated && <Text c="dimmed" size="xs">More links in task history</Text>}
23
+ </Group>
24
+ );
25
+ }