taskchef 7.6.0 → 7.7.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.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +8 -0
- package/docs/images/notification-event-snapshots.jpg +0 -0
- package/docs/spec.md +28 -0
- package/docs/workflows.md +22 -0
- package/package.json +2 -1
- package/src/dashboard/app.js +57 -12
- package/src/dashboard/index.html +2 -1
- package/src/dashboard/state.js +135 -23
- package/src/dashboard/styles.css +3 -0
package/README.md
CHANGED
|
@@ -180,6 +180,12 @@ Task and result times are relative through 29 days (with minute detail for the
|
|
|
180
180
|
first six hours), then use a locale-aware calendar date. Each time is a keyboard-
|
|
181
181
|
accessible toggle for its full locale-aware date and time, and one shared
|
|
182
182
|
30-second timer keeps relative labels current without reloading the page.
|
|
183
|
+
The Updates panel captures immutable event-time lifecycle notices. It identifies
|
|
184
|
+
them by task, turn when available, and lifecycle event rather than dashboard
|
|
185
|
+
revision, so reconnects and non-semantic rewrites do not replay a notice and a
|
|
186
|
+
later task state cannot rewrite an older notice. A notice remains readable if
|
|
187
|
+
its task disappears; selecting it then explains that current details are no
|
|
188
|
+
longer available.
|
|
183
189
|
It does not mutate TaskChef data and prints its local URL.
|
|
184
190
|
When a compatible foreground dashboard already owns port 3210,
|
|
185
191
|
`ensure_dashboard` reuses it but does not take ownership. If an unknown,
|
|
@@ -191,6 +197,8 @@ The health endpoint contains only a fixed service marker, health schema,
|
|
|
191
197
|
TaskChef version, dashboard-server version, and canonical workspace. It exposes
|
|
192
198
|
no task data, credentials, environment variables, process control, or secrets.
|
|
193
199
|
|
|
200
|
+

|
|
201
|
+
|
|
194
202
|

|
|
195
203
|
|
|
196
204
|
## Common recovery
|
|
Binary file
|
package/docs/spec.md
CHANGED
|
@@ -327,6 +327,34 @@ current configuration before use.
|
|
|
327
327
|
Snapshot and SSE list payloads MUST omit full `results` history and include the
|
|
328
328
|
derived latest-result projection. The bounded per-task detail endpoint MAY
|
|
329
329
|
return the full validated task so the dialog can render history newest first.
|
|
330
|
+
Dashboard notifications MUST capture an immutable event-time projection of the
|
|
331
|
+
task title, lifecycle state and event, turn ID when present, event timestamp,
|
|
332
|
+
and relevant concise summary. Rendering MUST NOT resolve historical notice text
|
|
333
|
+
from the task's later current state. Notice identity and deduplication MUST use
|
|
334
|
+
task ID, turn ID, and lifecycle event; a creation notice without a turn ID MUST
|
|
335
|
+
fall back to task ID plus the immutable creation timestamp. Dashboard revision
|
|
336
|
+
MUST NOT be the sole identity. Identical snapshots, SSE reconnects, idempotent
|
|
337
|
+
reports, schema normalization, and non-semantic rewrites MUST NOT add notices.
|
|
338
|
+
Distinct working and semantic-result events for one turn MAY each be retained.
|
|
339
|
+
When one compact snapshot first exposes both a latest semantic result and a
|
|
340
|
+
newer working turn, the browser MUST reconcile both events. Temporary task
|
|
341
|
+
absence MUST NOT discard the prior semantic signature or turn a later
|
|
342
|
+
reappearance into another creation event. The first page snapshot MUST establish
|
|
343
|
+
a quiet baseline rather than replay existing history. After that baseline, a
|
|
344
|
+
new task first observed with a turn or semantic result MUST retain its creation
|
|
345
|
+
event and each lifecycle event observable in that compact projection.
|
|
346
|
+
|
|
347
|
+
Working with no prior result SHOULD be labeled as a task start. Working on a
|
|
348
|
+
newer turn while a prior result remains projected SHOULD be labeled as a
|
|
349
|
+
follow-up start. Creation, completion, input-needed, and failure labels MUST be
|
|
350
|
+
distinct. The Updates panel MUST remain bounded and support individual dismiss
|
|
351
|
+
and clear-all without resetting replay protection. A retained notice whose task
|
|
352
|
+
is absent from the current list MUST remain readable; selecting it MUST NOT
|
|
353
|
+
navigate or mutate data and SHOULD explain that current details are unavailable.
|
|
354
|
+
The retained notice list MUST NOT be a live region that re-announces old notices
|
|
355
|
+
when it rerenders. A separate polite status region MUST announce only newly
|
|
356
|
+
reconciled events. Each notice control's accessible description MUST include
|
|
357
|
+
its displayed summary when present, event time, and missing-task state.
|
|
330
358
|
|
|
331
359
|
## Task-log migration
|
|
332
360
|
|
package/docs/workflows.md
CHANGED
|
@@ -284,6 +284,28 @@ unnecessary history. A read-only per-task endpoint returns full history only
|
|
|
284
284
|
when the dialog opens. Historical project paths are untrusted until matched
|
|
285
285
|
against current configuration.
|
|
286
286
|
|
|
287
|
+
The browser derives one immutable Updates-panel snapshot when it first observes
|
|
288
|
+
a semantic lifecycle transition. The snapshot keeps its event-time title,
|
|
289
|
+
state/event, turn ID, timestamp, and relevant summary. Its identity combines
|
|
290
|
+
task ID, turn ID, and event, with creation time as the fallback when creation
|
|
291
|
+
has no turn. A separate seen-identity set outlives the bounded visible notices,
|
|
292
|
+
individual dismissals, and Clear all, preventing replay after reconnect,
|
|
293
|
+
normalization, or disappearance and reappearance. Per-task semantic signatures
|
|
294
|
+
also remain as tombstones while a task is absent. When one compact snapshot
|
|
295
|
+
first reveals a latest semantic result plus a newer working turn, the browser
|
|
296
|
+
captures both. The first page snapshot is a quiet baseline so opening or
|
|
297
|
+
refreshing the dashboard does not replay existing history. If a task is first
|
|
298
|
+
observed in a progressed state after that baseline, creation is retained
|
|
299
|
+
alongside the observable result and working events. The current task list is
|
|
300
|
+
used only as the navigation target: if the task is gone, the notice stays
|
|
301
|
+
readable and selection reports that current details are unavailable.
|
|
302
|
+
|
|
303
|
+
The retained toast list is not live. Reconciliation passes only its newly added
|
|
304
|
+
snapshots to a separate polite status region, so reconnect, dismissal, Clear
|
|
305
|
+
all, and ordinary rerendering do not re-announce retained history. Toast action
|
|
306
|
+
labels remain concise while `aria-describedby` connects the visible summary,
|
|
307
|
+
event time, and missing-task explanation for assistive technology.
|
|
308
|
+
|
|
287
309
|
## Schema 4/5 migration
|
|
288
310
|
|
|
289
311
|
`taskchef workspace migrate` acquires the same workspace lock as lifecycle
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "taskchef",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.7.0",
|
|
4
4
|
"description": "A non-blocking interactive dispatcher for visible Codex tasks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Favo Yang",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"docs/spec.md",
|
|
27
27
|
"docs/workflows.md",
|
|
28
28
|
"docs/firstmate-taskchef-comparison.md",
|
|
29
|
+
"docs/images/notification-event-snapshots.jpg",
|
|
29
30
|
"docs/images/result-history-dashboard.jpg",
|
|
30
31
|
"index.js",
|
|
31
32
|
"mcp",
|
package/src/dashboard/app.js
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
import {
|
|
2
|
+
clearNotifications,
|
|
3
|
+
dismissNotification,
|
|
2
4
|
findCurrentTask,
|
|
3
5
|
KNOWN_TASK_STATUSES,
|
|
4
6
|
nextDateFilterRefreshDelay,
|
|
7
|
+
notificationDismissLabel,
|
|
8
|
+
notificationOpenLabel,
|
|
5
9
|
notificationTitle,
|
|
6
10
|
reconcileNotifications,
|
|
7
11
|
taskStatusLabel,
|
|
8
12
|
taskWithinDateFilter,
|
|
9
13
|
} from "./state.js";
|
|
10
14
|
import { openTaskFromControl } from "./actions.js";
|
|
11
|
-
import { RelativeTimeController, parsedTimestamp } from "./time.js";
|
|
15
|
+
import { formatRelativeTime, RelativeTimeController, parsedTimestamp } from "./time.js";
|
|
12
16
|
|
|
13
17
|
const state = {
|
|
14
18
|
tasks: [],
|
|
15
19
|
signatures: new Map(),
|
|
16
20
|
notifications: [],
|
|
21
|
+
seenNotificationIds: new Set(),
|
|
17
22
|
initialized: false,
|
|
18
23
|
selectedTask: null,
|
|
19
24
|
};
|
|
@@ -39,6 +44,7 @@ const elements = {
|
|
|
39
44
|
dismissDashboardMessage: document.querySelector("#dismiss-dashboard-message"),
|
|
40
45
|
emptyState: document.querySelector("#empty-state"),
|
|
41
46
|
notifications: document.querySelector("#notifications"),
|
|
47
|
+
notificationAnnouncer: document.querySelector("#notification-announcer"),
|
|
42
48
|
openProject: document.querySelector("#open-codex"),
|
|
43
49
|
projectFilter: document.querySelector("#project-filter"),
|
|
44
50
|
statusFilter: document.querySelector("#status-filter"),
|
|
@@ -46,6 +52,7 @@ const elements = {
|
|
|
46
52
|
taskList: document.querySelector("#task-list"),
|
|
47
53
|
toastList: document.querySelector("#toast-list"),
|
|
48
54
|
};
|
|
55
|
+
let notificationDescriptionSerial = 0;
|
|
49
56
|
|
|
50
57
|
function timestampControl(value, { accessibleName, key, prefix = "" }) {
|
|
51
58
|
if (!parsedTimestamp(value)) {
|
|
@@ -108,17 +115,37 @@ function replaceOptions(select, values, allLabel) {
|
|
|
108
115
|
|
|
109
116
|
function notificationToast(notification) {
|
|
110
117
|
const task = findCurrentTask(state.tasks, notification.taskId);
|
|
111
|
-
if (!task) return null;
|
|
112
118
|
const toast = document.createElement("div");
|
|
113
|
-
toast.className = "toast"
|
|
119
|
+
toast.className = `toast${task ? "" : " toast-missing"}`;
|
|
114
120
|
const text = document.createElement("button");
|
|
115
121
|
text.type = "button";
|
|
116
122
|
text.className = "toast-content";
|
|
123
|
+
text.setAttribute("aria-label", notificationOpenLabel(notification, Boolean(task)));
|
|
124
|
+
const describedBy = [];
|
|
117
125
|
const title = document.createElement("strong");
|
|
118
|
-
title.textContent = notificationTitle(
|
|
126
|
+
title.textContent = notificationTitle(notification);
|
|
119
127
|
const description = document.createElement("span");
|
|
120
|
-
description.textContent =
|
|
128
|
+
description.textContent = notification.title;
|
|
121
129
|
text.append(title, description);
|
|
130
|
+
if (notification.summary) {
|
|
131
|
+
const summary = document.createElement("span");
|
|
132
|
+
summary.className = "toast-summary preserve-lines";
|
|
133
|
+
summary.id = `notification-description-${notificationDescriptionSerial += 1}`;
|
|
134
|
+
summary.textContent = notification.summary;
|
|
135
|
+
text.append(summary);
|
|
136
|
+
describedBy.push(summary.id);
|
|
137
|
+
}
|
|
138
|
+
const metadata = document.createElement("span");
|
|
139
|
+
metadata.className = "toast-metadata";
|
|
140
|
+
metadata.id = `notification-description-${notificationDescriptionSerial += 1}`;
|
|
141
|
+
const timestamp = document.createElement("time");
|
|
142
|
+
timestamp.dateTime = notification.timestamp ?? "";
|
|
143
|
+
timestamp.textContent = formatRelativeTime(notification.timestamp);
|
|
144
|
+
metadata.append(timestamp);
|
|
145
|
+
if (!task) metadata.append(" · Task no longer available");
|
|
146
|
+
text.append(metadata);
|
|
147
|
+
describedBy.push(metadata.id);
|
|
148
|
+
text.setAttribute("aria-describedby", describedBy.join(" "));
|
|
122
149
|
text.addEventListener("click", () => {
|
|
123
150
|
const current = findCurrentTask(state.tasks, notification.taskId);
|
|
124
151
|
if (current) openDialog(current);
|
|
@@ -127,21 +154,37 @@ function notificationToast(notification) {
|
|
|
127
154
|
const dismiss = document.createElement("button");
|
|
128
155
|
dismiss.type = "button";
|
|
129
156
|
dismiss.className = "icon-button";
|
|
130
|
-
dismiss.setAttribute("aria-label",
|
|
157
|
+
dismiss.setAttribute("aria-label", notificationDismissLabel(notification));
|
|
158
|
+
dismiss.setAttribute("aria-describedby", describedBy.join(" "));
|
|
131
159
|
dismiss.textContent = "×";
|
|
132
160
|
dismiss.addEventListener("click", () => {
|
|
133
|
-
state.notifications = state.notifications
|
|
161
|
+
state.notifications = dismissNotification(state.notifications, notification.id);
|
|
134
162
|
renderNotifications();
|
|
135
163
|
});
|
|
136
164
|
toast.append(text, dismiss);
|
|
137
165
|
return toast;
|
|
138
166
|
}
|
|
139
167
|
|
|
140
|
-
function
|
|
168
|
+
function notificationAnnouncement(notification) {
|
|
169
|
+
return [
|
|
170
|
+
notificationTitle(notification),
|
|
171
|
+
notification.title,
|
|
172
|
+
notification.summary,
|
|
173
|
+
notification.turnId ? `Turn ${notification.turnId}` : null,
|
|
174
|
+
formatRelativeTime(notification.timestamp),
|
|
175
|
+
].filter(Boolean).join(". ");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function renderNotifications(additions = []) {
|
|
141
179
|
elements.toastList.replaceChildren(
|
|
142
|
-
...state.notifications.map(notificationToast)
|
|
180
|
+
...state.notifications.map(notificationToast),
|
|
143
181
|
);
|
|
144
182
|
elements.notifications.hidden = state.notifications.length === 0;
|
|
183
|
+
if (additions.length > 0) {
|
|
184
|
+
elements.notificationAnnouncer.textContent = additions
|
|
185
|
+
.map(notificationAnnouncement)
|
|
186
|
+
.join(". ");
|
|
187
|
+
}
|
|
145
188
|
}
|
|
146
189
|
|
|
147
190
|
function detailRow(term, value) {
|
|
@@ -311,11 +354,13 @@ function applySnapshot(snapshot) {
|
|
|
311
354
|
const reconciled = reconcileNotifications({
|
|
312
355
|
initialized: state.initialized,
|
|
313
356
|
notifications: state.notifications,
|
|
357
|
+
seenIds: state.seenNotificationIds,
|
|
314
358
|
signatures: state.signatures,
|
|
315
|
-
}, snapshot.tasks
|
|
359
|
+
}, snapshot.tasks);
|
|
316
360
|
state.tasks = snapshot.tasks;
|
|
317
361
|
state.signatures = reconciled.signatures;
|
|
318
362
|
state.notifications = reconciled.notifications;
|
|
363
|
+
state.seenNotificationIds = reconciled.seenIds;
|
|
319
364
|
state.initialized = true;
|
|
320
365
|
if (snapshot.healthy === false) {
|
|
321
366
|
showMessage("The task log is temporarily unavailable. Showing the last valid snapshot.");
|
|
@@ -336,7 +381,7 @@ function applySnapshot(snapshot) {
|
|
|
336
381
|
const updated = state.tasks.find((task) => task.id === state.selectedTask.id);
|
|
337
382
|
if (updated && elements.dialog.open) openDialog(updated);
|
|
338
383
|
}
|
|
339
|
-
renderNotifications();
|
|
384
|
+
renderNotifications(reconciled.additions);
|
|
340
385
|
render();
|
|
341
386
|
}
|
|
342
387
|
|
|
@@ -363,7 +408,7 @@ elements.dismissDashboardMessage.addEventListener("click", () => {
|
|
|
363
408
|
elements.dashboardMessage.hidden = true;
|
|
364
409
|
});
|
|
365
410
|
elements.clearNotifications.addEventListener("click", () => {
|
|
366
|
-
state.notifications =
|
|
411
|
+
state.notifications = clearNotifications();
|
|
367
412
|
renderNotifications();
|
|
368
413
|
});
|
|
369
414
|
elements.closeDialog.addEventListener("click", () => elements.dialog.close());
|
package/src/dashboard/index.html
CHANGED
|
@@ -71,7 +71,8 @@
|
|
|
71
71
|
<strong>Updates</strong>
|
|
72
72
|
<button id="clear-notifications" class="text-button" type="button">Clear all</button>
|
|
73
73
|
</div>
|
|
74
|
-
<
|
|
74
|
+
<p id="notification-announcer" class="visually-hidden" role="status" aria-live="polite"></p>
|
|
75
|
+
<div id="toast-list" class="toast-list"></div>
|
|
75
76
|
</aside>
|
|
76
77
|
|
|
77
78
|
<dialog id="task-dialog" aria-labelledby="dialog-title">
|
package/src/dashboard/state.js
CHANGED
|
@@ -7,6 +7,16 @@ export const KNOWN_TASK_STATUSES = [
|
|
|
7
7
|
"unresolved",
|
|
8
8
|
];
|
|
9
9
|
|
|
10
|
+
const NOTIFICATION_TITLES = new Map([
|
|
11
|
+
["created", "Task created"],
|
|
12
|
+
["task_started", "Task started"],
|
|
13
|
+
["follow_up_started", "Follow-up started"],
|
|
14
|
+
["completed", "Task completed"],
|
|
15
|
+
["needs_input", "Task needs input"],
|
|
16
|
+
["failed", "Task failed"],
|
|
17
|
+
["unresolved", "Task updated"],
|
|
18
|
+
]);
|
|
19
|
+
|
|
10
20
|
const DATE_WINDOWS_MS = new Map([
|
|
11
21
|
["24h", 24 * 60 * 60 * 1_000],
|
|
12
22
|
["7d", 7 * 24 * 60 * 60 * 1_000],
|
|
@@ -21,8 +31,8 @@ export function taskStatusLabel(task) {
|
|
|
21
31
|
return task.status === null ? "unresolved" : task.status.replaceAll("_", " ");
|
|
22
32
|
}
|
|
23
33
|
|
|
24
|
-
export function notificationTitle(
|
|
25
|
-
return
|
|
34
|
+
export function notificationTitle(notification) {
|
|
35
|
+
return NOTIFICATION_TITLES.get(notification.event) ?? "Task updated";
|
|
26
36
|
}
|
|
27
37
|
|
|
28
38
|
export function taskWithinDateFilter(task, filter, now = Date.now()) {
|
|
@@ -44,41 +54,143 @@ export function nextDateFilterRefreshDelay(tasks, filter, now = Date.now()) {
|
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
export function taskSignature(task) {
|
|
47
|
-
return JSON.stringify([
|
|
48
|
-
task.threadId,
|
|
49
|
-
task.status,
|
|
50
|
-
task.summary,
|
|
51
|
-
task.turnId,
|
|
52
|
-
task.updatedAt,
|
|
53
|
-
task.updatedBy,
|
|
54
|
-
task.lastResult,
|
|
55
|
-
]);
|
|
57
|
+
return JSON.stringify([task.id, task.turnId ?? null, task.status ?? "unresolved"]);
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
export function findCurrentTask(tasks, taskId) {
|
|
59
61
|
return tasks.find((task) => task.id === taskId) ?? null;
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
function lifecycleEvent(task) {
|
|
65
|
+
if (task.status === "working") {
|
|
66
|
+
return task.lastResult ? "follow_up_started" : "task_started";
|
|
67
|
+
}
|
|
68
|
+
return task.status ?? "unresolved";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function notificationIdentity(task, event) {
|
|
72
|
+
if (event === "created") {
|
|
73
|
+
return JSON.stringify([task.id, null, event, task.createdAt ?? null]);
|
|
74
|
+
}
|
|
75
|
+
return JSON.stringify([task.id, task.turnId ?? null, event]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function eventTimestamp(task, event) {
|
|
79
|
+
if (event === "created") return task.createdAt ?? task.updatedAt ?? null;
|
|
80
|
+
if (
|
|
81
|
+
task.lastResult?.status === task.status
|
|
82
|
+
&& task.lastResult?.turnId === task.turnId
|
|
83
|
+
) {
|
|
84
|
+
return task.lastResult.updatedAt;
|
|
85
|
+
}
|
|
86
|
+
return task.updatedAt ?? task.createdAt ?? null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function eventSummary(task, event) {
|
|
90
|
+
if (!["completed", "needs_input", "failed"].includes(event)) return null;
|
|
91
|
+
if (
|
|
92
|
+
task.lastResult?.status === task.status
|
|
93
|
+
&& task.lastResult?.turnId === task.turnId
|
|
94
|
+
) {
|
|
95
|
+
return task.lastResult.summary;
|
|
96
|
+
}
|
|
97
|
+
return task.summary ?? null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function notificationSnapshot(task, event = lifecycleEvent(task)) {
|
|
101
|
+
const created = event === "created";
|
|
102
|
+
return Object.freeze({
|
|
103
|
+
id: notificationIdentity(task, event),
|
|
104
|
+
taskId: task.id,
|
|
105
|
+
title: task.title,
|
|
106
|
+
status: created ? "working" : task.status,
|
|
107
|
+
event,
|
|
108
|
+
turnId: created ? null : task.turnId ?? null,
|
|
109
|
+
timestamp: eventTimestamp(task, event),
|
|
110
|
+
summary: eventSummary(task, event),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function resultNotificationSnapshot(task) {
|
|
115
|
+
const result = task.lastResult;
|
|
116
|
+
if (!result) return null;
|
|
117
|
+
return Object.freeze({
|
|
118
|
+
id: notificationIdentity({ ...task, turnId: result.turnId }, result.status),
|
|
119
|
+
taskId: task.id,
|
|
120
|
+
title: task.title,
|
|
121
|
+
status: result.status,
|
|
122
|
+
event: result.status,
|
|
123
|
+
turnId: result.turnId ?? null,
|
|
124
|
+
timestamp: result.updatedAt,
|
|
125
|
+
summary: result.summary,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function notificationOpenLabel(notification, available) {
|
|
130
|
+
const action = available ? "Open current task details" : "Show task availability";
|
|
131
|
+
return `${action} for ${notification.title}: ${notificationTitle(notification)}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function notificationDismissLabel(notification) {
|
|
135
|
+
return `Dismiss ${notificationTitle(notification)} notification for ${notification.title}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function dismissNotification(notifications, notificationId) {
|
|
139
|
+
return notifications.filter(({ id }) => id !== notificationId);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function clearNotifications() {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
|
|
62
146
|
export function reconcileNotifications(
|
|
63
|
-
{ initialized, notifications, signatures },
|
|
147
|
+
{ initialized, notifications, seenIds = new Set(), signatures },
|
|
64
148
|
tasks,
|
|
65
|
-
revision,
|
|
66
149
|
) {
|
|
67
|
-
const nextSignatures = new Map(
|
|
68
|
-
|
|
150
|
+
const nextSignatures = new Map(signatures);
|
|
151
|
+
for (const task of tasks) nextSignatures.set(task.id, taskSignature(task));
|
|
152
|
+
const nextSeenIds = new Set(seenIds);
|
|
153
|
+
if (!initialized) {
|
|
154
|
+
for (const task of tasks) {
|
|
155
|
+
nextSeenIds.add(notificationSnapshot(task, "created").id);
|
|
156
|
+
const resultNotification = resultNotificationSnapshot(task);
|
|
157
|
+
if (resultNotification) nextSeenIds.add(resultNotification.id);
|
|
158
|
+
nextSeenIds.add(notificationSnapshot(task).id);
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
additions: [],
|
|
162
|
+
notifications,
|
|
163
|
+
seenIds: nextSeenIds,
|
|
164
|
+
signatures: nextSignatures,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
69
167
|
const additions = [];
|
|
70
168
|
for (const task of tasks) {
|
|
71
|
-
|
|
72
|
-
if (!signatures.has(task.id))
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
169
|
+
const candidates = [];
|
|
170
|
+
if (!signatures.has(task.id)) {
|
|
171
|
+
const resultNotification = resultNotificationSnapshot(task);
|
|
172
|
+
if (task.turnId || task.status !== "working" || resultNotification) {
|
|
173
|
+
candidates.push(notificationSnapshot(task));
|
|
174
|
+
if (resultNotification) candidates.push(resultNotification);
|
|
175
|
+
}
|
|
176
|
+
candidates.push(notificationSnapshot(task, "created"));
|
|
177
|
+
} else if (signatures.get(task.id) !== nextSignatures.get(task.id)) {
|
|
178
|
+
candidates.push(notificationSnapshot(task));
|
|
179
|
+
const resultNotification = resultNotificationSnapshot(task);
|
|
180
|
+
if (resultNotification) candidates.push(resultNotification);
|
|
181
|
+
}
|
|
182
|
+
for (const notification of candidates) {
|
|
183
|
+
if (!nextSeenIds.has(notification.id)) additions.push(notification);
|
|
184
|
+
nextSeenIds.add(notification.id);
|
|
185
|
+
}
|
|
186
|
+
const resultNotification = resultNotificationSnapshot(task);
|
|
187
|
+
if (resultNotification) nextSeenIds.add(resultNotification.id);
|
|
188
|
+
nextSeenIds.add(notificationSnapshot(task).id);
|
|
79
189
|
}
|
|
80
190
|
return {
|
|
191
|
+
additions,
|
|
81
192
|
notifications: [...additions, ...notifications].slice(0, MAX_NOTIFICATIONS),
|
|
193
|
+
seenIds: nextSeenIds,
|
|
82
194
|
signatures: nextSignatures,
|
|
83
195
|
};
|
|
84
196
|
}
|
package/src/dashboard/styles.css
CHANGED
|
@@ -105,6 +105,9 @@ time, .timestamp-missing { font-size: 0.78rem; }
|
|
|
105
105
|
.toast { display: flex; align-items: flex-start; gap: 8px; padding: 10px; border-radius: 7px; background: var(--surface-muted); }
|
|
106
106
|
.toast-content { display: grid; flex: 1; gap: 2px; padding: 0; border: 0; background: none; text-align: left; cursor: pointer; }
|
|
107
107
|
.toast-content span { color: var(--muted); font-size: 0.85rem; }
|
|
108
|
+
.toast-content .toast-summary { color: var(--text); }
|
|
109
|
+
.toast-content .toast-metadata { font-size: 0.75rem; }
|
|
110
|
+
.toast-missing { border: 1px dashed var(--border); }
|
|
108
111
|
|
|
109
112
|
.text-button, .icon-button { border: 0; background: none; cursor: pointer; }
|
|
110
113
|
.text-button { color: var(--accent); font-size: 0.82rem; font-weight: 700; }
|