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.
- package/.codex-plugin/plugin.json +1 -1
- package/package.json +24 -2
- package/src/dashboard/dist/app.js +164 -0
- package/src/dashboard/dist/app.js.map +1 -0
- package/src/dashboard/dist/index.html +16 -0
- package/src/dashboard/dist/styles.css +1 -0
- package/src/dashboard/github-links.js +12 -4
- package/src/dashboard/react/App.tsx +432 -0
- package/src/dashboard/react/api.test.ts +24 -0
- package/src/dashboard/react/api.ts +95 -0
- package/src/dashboard/react/components/ActivityTimeline.tsx +66 -0
- package/src/dashboard/react/components/GitHubLinks.tsx +46 -0
- package/src/dashboard/react/components/LinkedText.tsx +63 -0
- package/src/dashboard/react/components/NotificationCenter.test.tsx +71 -0
- package/src/dashboard/react/components/NotificationCenter.tsx +111 -0
- package/src/dashboard/react/components/RelativeTime.tsx +28 -0
- package/src/dashboard/react/components/ShimmerText.tsx +9 -0
- package/src/dashboard/react/components/StatusBadge.tsx +7 -0
- package/src/dashboard/react/components/TaskCard.tsx +65 -0
- package/src/dashboard/react/components/TaskDetail.test.tsx +62 -0
- package/src/dashboard/react/components/TaskDetail.tsx +251 -0
- package/src/dashboard/react/components/UsagePanel.tsx +73 -0
- package/src/dashboard/react/fixtures.ts +52 -0
- package/src/dashboard/react/index.html +15 -0
- package/src/dashboard/react/main.tsx +12 -0
- package/src/dashboard/react/overlay-state.test.ts +79 -0
- package/src/dashboard/react/overlay-state.ts +38 -0
- package/src/dashboard/react/presentation.test.tsx +241 -0
- package/src/dashboard/react/presentation.ts +102 -0
- package/src/dashboard/react/styles.css +128 -0
- package/src/dashboard/react/test-setup.ts +36 -0
- package/src/dashboard/react/types.ts +106 -0
- package/src/dashboard.js +29 -18
|
@@ -60,12 +60,20 @@ function compareReferenceNumbers(left, right) {
|
|
|
60
60
|
|
|
61
61
|
export function groupRelatedGitHubLinks(links) {
|
|
62
62
|
const groups = new Map();
|
|
63
|
-
const
|
|
63
|
+
const seenLabels = new Set();
|
|
64
|
+
const seenNumbers = new Set();
|
|
65
|
+
const seenUrls = new Set();
|
|
64
66
|
for (const link of links ?? []) {
|
|
65
|
-
if (link.type === "repository" || seen.has(link.label)) continue;
|
|
66
|
-
seen.add(link.label);
|
|
67
67
|
const repository = repositoryIdentity(link);
|
|
68
|
-
|
|
68
|
+
const reference = link.number ? `${repository}#${link.number}` : null;
|
|
69
|
+
if (link.type === "repository"
|
|
70
|
+
|| !repository
|
|
71
|
+
|| seenLabels.has(link.label)
|
|
72
|
+
|| seenUrls.has(link.url)
|
|
73
|
+
|| (reference && seenNumbers.has(reference))) continue;
|
|
74
|
+
seenLabels.add(link.label);
|
|
75
|
+
seenUrls.add(link.url);
|
|
76
|
+
if (reference) seenNumbers.add(reference);
|
|
69
77
|
const group = groups.get(repository) ?? [];
|
|
70
78
|
group.push(link);
|
|
71
79
|
groups.set(repository, group);
|
|
@@ -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,66 @@
|
|
|
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 { LinkedText } from "./LinkedText";
|
|
7
|
+
import { ShimmerText } from "./ShimmerText";
|
|
8
|
+
import { StatusBadge } from "./StatusBadge";
|
|
9
|
+
|
|
10
|
+
export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef: string | null; task: Task }) {
|
|
11
|
+
const turns = mergeProjectedTurns(task, []) as Task["turns"];
|
|
12
|
+
if (!turns?.length) return <Text c="dimmed" size="sm">No turn history has been recorded.</Text>;
|
|
13
|
+
return (
|
|
14
|
+
<Stack gap="sm">
|
|
15
|
+
{[...turns].reverse().map((turn, index) => {
|
|
16
|
+
const presentation = turnPresentation(turn);
|
|
17
|
+
const identity = turn.turnRef ?? turn.turnId ?? `turn-${index}`;
|
|
18
|
+
const highlighted = identity === highlightTurnRef;
|
|
19
|
+
const usage = turnUsageView(task, turn);
|
|
20
|
+
return (
|
|
21
|
+
<Paper
|
|
22
|
+
className={`taskchef-turn${highlighted ? " taskchef-turn-highlighted" : ""}`}
|
|
23
|
+
data-turn-ref={identity}
|
|
24
|
+
key={identity}
|
|
25
|
+
p="sm"
|
|
26
|
+
radius="md"
|
|
27
|
+
tabIndex={highlighted ? -1 : undefined}
|
|
28
|
+
withBorder
|
|
29
|
+
>
|
|
30
|
+
<Stack gap={7}>
|
|
31
|
+
<Box className="taskchef-turn-heading">
|
|
32
|
+
<StatusBadge status={presentation.status} />
|
|
33
|
+
<RelativeTime label="Turn update time" value={presentation.updatedAt} />
|
|
34
|
+
</Box>
|
|
35
|
+
{presentation.sourceLabel && <Text c="dimmed" size="xs">{presentation.sourceLabel}</Text>}
|
|
36
|
+
<Box>
|
|
37
|
+
<Text c="dimmed" className="taskchef-field-label" size="xs">Request</Text>
|
|
38
|
+
<Text className="taskchef-preserve-lines" size="sm">
|
|
39
|
+
<LinkedText task={task} text={turn.requestSummary ?? "Request not recorded."} />
|
|
40
|
+
</Text>
|
|
41
|
+
</Box>
|
|
42
|
+
<Box>
|
|
43
|
+
<Text c="dimmed" className="taskchef-field-label" size="xs">Result</Text>
|
|
44
|
+
<Text className="taskchef-preserve-lines" size="sm">
|
|
45
|
+
{presentation.status === "working"
|
|
46
|
+
? <ShimmerText>{presentation.summary}</ShimmerText>
|
|
47
|
+
: <LinkedText task={task} text={presentation.summary} />}
|
|
48
|
+
</Text>
|
|
49
|
+
</Box>
|
|
50
|
+
<Text
|
|
51
|
+
c="dimmed"
|
|
52
|
+
className={usage.kind === "calculating" || usage.kind === "pending" ? "taskchef-turn-usage" : undefined}
|
|
53
|
+
size="xs"
|
|
54
|
+
>
|
|
55
|
+
{usage.kind === "calculating" || usage.kind === "pending"
|
|
56
|
+
? <ShimmerText>{usage.label}</ShimmerText>
|
|
57
|
+
: usage.label}
|
|
58
|
+
</Text>
|
|
59
|
+
<Text c="dimmed" className="taskchef-mono" size="xs">Turn ref: {identity ?? "—"}</Text>
|
|
60
|
+
</Stack>
|
|
61
|
+
</Paper>
|
|
62
|
+
);
|
|
63
|
+
})}
|
|
64
|
+
</Stack>
|
|
65
|
+
);
|
|
66
|
+
}
|