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