taskchef 7.6.0 → 7.7.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "7.6.0",
3
+ "version": "7.7.1",
4
4
  "description": "Dispatch work from a data-only workspace to visible Codex project tasks.",
5
5
  "author": {
6
6
  "name": "Favo Yang",
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
+ ![Immutable event-time dashboard notifications](docs/images/notification-event-snapshots.jpg)
201
+
194
202
  ![Task detail result history](docs/images/result-history-dashboard.jpg)
195
203
 
196
204
  ## Common recovery
@@ -0,0 +1,22 @@
1
+ # Codex app icon assets
2
+
3
+ `codex-app-dark.png` and `codex-app-light.png` are unmodified Codex app icon
4
+ assets from OpenAI's official macOS application bundle. The source bundle was
5
+ ChatGPT/Codex `26.818.61809` (build `7019`), bundle identifier
6
+ `com.openai.codex`, signed with Apple developer team identifier `2DC432GLL2`.
7
+ The files were copied from these bundle-relative resource paths:
8
+
9
+ - `Contents/Resources/icon-codex-dark-color.png`
10
+ - `Contents/Resources/icon-codex-light.png`
11
+
12
+ The application itself selects these files as the dark and light Codex dock
13
+ icon previews. Their SHA-256 digests are:
14
+
15
+ - `codex-app-dark.png`: `69fb4384e161be8a20dcb94a9ac34aea4fbfaeb67514110a71e7b0732eccb0fc`
16
+ - `codex-app-light.png`: `de7d43f3386105ab20952958c2c25beb0d903e2aeb6e1aef57c49a648c0d1c07`
17
+
18
+ The Codex icon is an OpenAI trademark and copyrighted asset. It is not covered
19
+ by TaskChef's MIT license. TaskChef uses it unchanged and only beside the
20
+ visible **Open task** label to identify an action that opens the corresponding
21
+ task in OpenAI Codex. Use remains subject to the [OpenAI Design
22
+ Guidelines](https://openai.com/brand/), including OpenAI's Marks usage terms.
Binary file
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.6.0",
3
+ "version": "7.7.1",
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",
@@ -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)) {
@@ -71,10 +78,19 @@ function timestampControl(value, { accessibleName, key, prefix = "" }) {
71
78
  }
72
79
 
73
80
  function codexIcon() {
74
- const icon = document.createElement("span");
75
- icon.className = "codex-icon";
76
- icon.setAttribute("aria-hidden", "true");
77
- return icon;
81
+ const picture = document.createElement("picture");
82
+ picture.className = "codex-icon";
83
+ picture.setAttribute("aria-hidden", "true");
84
+ const dark = document.createElement("source");
85
+ dark.srcset = "/assets/codex-app-dark.png";
86
+ dark.media = "(prefers-color-scheme: dark)";
87
+ const image = document.createElement("img");
88
+ image.src = "/assets/codex-app-light.png";
89
+ image.alt = "";
90
+ image.width = 16;
91
+ image.height = 16;
92
+ picture.append(dark, image);
93
+ return picture;
78
94
  }
79
95
 
80
96
  function configureOpenTaskControl(control, ariaLabel) {
@@ -108,17 +124,37 @@ function replaceOptions(select, values, allLabel) {
108
124
 
109
125
  function notificationToast(notification) {
110
126
  const task = findCurrentTask(state.tasks, notification.taskId);
111
- if (!task) return null;
112
127
  const toast = document.createElement("div");
113
- toast.className = "toast";
128
+ toast.className = `toast${task ? "" : " toast-missing"}`;
114
129
  const text = document.createElement("button");
115
130
  text.type = "button";
116
131
  text.className = "toast-content";
132
+ text.setAttribute("aria-label", notificationOpenLabel(notification, Boolean(task)));
133
+ const describedBy = [];
117
134
  const title = document.createElement("strong");
118
- title.textContent = notificationTitle(task, notification.kind);
135
+ title.textContent = notificationTitle(notification);
119
136
  const description = document.createElement("span");
120
- description.textContent = `${task.title} · ${taskStatusLabel(task)}`;
137
+ description.textContent = notification.title;
121
138
  text.append(title, description);
139
+ if (notification.summary) {
140
+ const summary = document.createElement("span");
141
+ summary.className = "toast-summary preserve-lines";
142
+ summary.id = `notification-description-${notificationDescriptionSerial += 1}`;
143
+ summary.textContent = notification.summary;
144
+ text.append(summary);
145
+ describedBy.push(summary.id);
146
+ }
147
+ const metadata = document.createElement("span");
148
+ metadata.className = "toast-metadata";
149
+ metadata.id = `notification-description-${notificationDescriptionSerial += 1}`;
150
+ const timestamp = document.createElement("time");
151
+ timestamp.dateTime = notification.timestamp ?? "";
152
+ timestamp.textContent = formatRelativeTime(notification.timestamp);
153
+ metadata.append(timestamp);
154
+ if (!task) metadata.append(" · Task no longer available");
155
+ text.append(metadata);
156
+ describedBy.push(metadata.id);
157
+ text.setAttribute("aria-describedby", describedBy.join(" "));
122
158
  text.addEventListener("click", () => {
123
159
  const current = findCurrentTask(state.tasks, notification.taskId);
124
160
  if (current) openDialog(current);
@@ -127,21 +163,37 @@ function notificationToast(notification) {
127
163
  const dismiss = document.createElement("button");
128
164
  dismiss.type = "button";
129
165
  dismiss.className = "icon-button";
130
- dismiss.setAttribute("aria-label", `Dismiss notification for ${task.title}`);
166
+ dismiss.setAttribute("aria-label", notificationDismissLabel(notification));
167
+ dismiss.setAttribute("aria-describedby", describedBy.join(" "));
131
168
  dismiss.textContent = "×";
132
169
  dismiss.addEventListener("click", () => {
133
- state.notifications = state.notifications.filter(({ id }) => id !== notification.id);
170
+ state.notifications = dismissNotification(state.notifications, notification.id);
134
171
  renderNotifications();
135
172
  });
136
173
  toast.append(text, dismiss);
137
174
  return toast;
138
175
  }
139
176
 
140
- function renderNotifications() {
177
+ function notificationAnnouncement(notification) {
178
+ return [
179
+ notificationTitle(notification),
180
+ notification.title,
181
+ notification.summary,
182
+ notification.turnId ? `Turn ${notification.turnId}` : null,
183
+ formatRelativeTime(notification.timestamp),
184
+ ].filter(Boolean).join(". ");
185
+ }
186
+
187
+ function renderNotifications(additions = []) {
141
188
  elements.toastList.replaceChildren(
142
- ...state.notifications.map(notificationToast).filter(Boolean),
189
+ ...state.notifications.map(notificationToast),
143
190
  );
144
191
  elements.notifications.hidden = state.notifications.length === 0;
192
+ if (additions.length > 0) {
193
+ elements.notificationAnnouncer.textContent = additions
194
+ .map(notificationAnnouncement)
195
+ .join(". ");
196
+ }
145
197
  }
146
198
 
147
199
  function detailRow(term, value) {
@@ -311,11 +363,13 @@ function applySnapshot(snapshot) {
311
363
  const reconciled = reconcileNotifications({
312
364
  initialized: state.initialized,
313
365
  notifications: state.notifications,
366
+ seenIds: state.seenNotificationIds,
314
367
  signatures: state.signatures,
315
- }, snapshot.tasks, snapshot.revision);
368
+ }, snapshot.tasks);
316
369
  state.tasks = snapshot.tasks;
317
370
  state.signatures = reconciled.signatures;
318
371
  state.notifications = reconciled.notifications;
372
+ state.seenNotificationIds = reconciled.seenIds;
319
373
  state.initialized = true;
320
374
  if (snapshot.healthy === false) {
321
375
  showMessage("The task log is temporarily unavailable. Showing the last valid snapshot.");
@@ -336,7 +390,7 @@ function applySnapshot(snapshot) {
336
390
  const updated = state.tasks.find((task) => task.id === state.selectedTask.id);
337
391
  if (updated && elements.dialog.open) openDialog(updated);
338
392
  }
339
- renderNotifications();
393
+ renderNotifications(reconciled.additions);
340
394
  render();
341
395
  }
342
396
 
@@ -363,7 +417,7 @@ elements.dismissDashboardMessage.addEventListener("click", () => {
363
417
  elements.dashboardMessage.hidden = true;
364
418
  });
365
419
  elements.clearNotifications.addEventListener("click", () => {
366
- state.notifications = [];
420
+ state.notifications = clearNotifications();
367
421
  renderNotifications();
368
422
  });
369
423
  elements.closeDialog.addEventListener("click", () => elements.dialog.close());
@@ -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
- <div id="toast-list" class="toast-list" aria-live="polite"></div>
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">
@@ -84,7 +85,10 @@
84
85
  </div>
85
86
  <div class="dialog-actions">
86
87
  <button id="open-codex" class="primary-button task-action" type="button" aria-label="Open this task in Codex">
87
- <span class="codex-icon" aria-hidden="true"></span>
88
+ <picture class="codex-icon" aria-hidden="true">
89
+ <source srcset="/assets/codex-app-dark.png" media="(prefers-color-scheme: dark)">
90
+ <img src="/assets/codex-app-light.png" alt="" width="16" height="16">
91
+ </picture>
88
92
  <span>Open task</span>
89
93
  </button>
90
94
  <button id="copy-thread-id" class="secondary-button" type="button">Copy thread ID</button>
@@ -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(task, kind) {
25
- return kind === "new" ? "New task" : `Task ${taskStatusLabel(task)}`;
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(tasks.map((task) => [task.id, taskSignature(task)]));
68
- if (!initialized) return { notifications, signatures: nextSignatures };
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
- let kind = null;
72
- if (!signatures.has(task.id)) kind = "new";
73
- else if (signatures.get(task.id) !== nextSignatures.get(task.id)) kind = "changed";
74
- if (kind) additions.push({
75
- id: `${revision}:${task.id}`,
76
- kind,
77
- taskId: task.id,
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
  }
@@ -84,7 +84,8 @@ time, .timestamp-missing { font-size: 0.78rem; }
84
84
  .task-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
85
85
  .task-open { flex: none; }
86
86
  .task-action { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 32px; padding: 5px 9px; font-size: 0.82rem; line-height: 1.2; white-space: nowrap; }
87
- .codex-icon { display: inline-block; flex: none; width: 16px; height: 16px; background: currentColor; mask: url("/assets/codex.svg") center / contain no-repeat; }
87
+ .codex-icon { display: block; flex: none; width: 16px; height: 16px; }
88
+ .codex-icon img { display: block; width: 100%; height: 100%; object-fit: contain; }
88
89
 
89
90
  .status { flex: none; padding: 4px 9px; border-radius: 999px; background: var(--surface-muted); color: var(--muted); font-size: 0.72rem; font-weight: 750; text-transform: capitalize; }
90
91
  .status-completed { background: var(--accent-soft); color: var(--accent); }
@@ -105,6 +106,9 @@ time, .timestamp-missing { font-size: 0.78rem; }
105
106
  .toast { display: flex; align-items: flex-start; gap: 8px; padding: 10px; border-radius: 7px; background: var(--surface-muted); }
106
107
  .toast-content { display: grid; flex: 1; gap: 2px; padding: 0; border: 0; background: none; text-align: left; cursor: pointer; }
107
108
  .toast-content span { color: var(--muted); font-size: 0.85rem; }
109
+ .toast-content .toast-summary { color: var(--text); }
110
+ .toast-content .toast-metadata { font-size: 0.75rem; }
111
+ .toast-missing { border: 1px dashed var(--border); }
108
112
 
109
113
  .text-button, .icon-button { border: 0; background: none; cursor: pointer; }
110
114
  .text-button { color: var(--accent); font-size: 0.82rem; font-weight: 700; }
package/src/dashboard.js CHANGED
@@ -48,7 +48,8 @@ const STATIC_FILES = new Map([
48
48
  ["/styles.css", [path.join(STATIC_ROOT, "styles.css"), "text/css; charset=utf-8"]],
49
49
  ["/assets/taskchef-dark.svg", [path.join(ASSET_ROOT, "taskchef-dark.svg"), "image/svg+xml"]],
50
50
  ["/assets/taskchef.svg", [path.join(ASSET_ROOT, "taskchef.svg"), "image/svg+xml"]],
51
- ["/assets/codex.svg", [path.join(ASSET_ROOT, "codex.svg"), "image/svg+xml"]],
51
+ ["/assets/codex-app-dark.png", [path.join(ASSET_ROOT, "codex-app-dark.png"), "image/png"]],
52
+ ["/assets/codex-app-light.png", [path.join(ASSET_ROOT, "codex-app-light.png"), "image/png"]],
52
53
  ]);
53
54
 
54
55
  export function dashboardAuthority(host, port) {
package/assets/codex.svg DELETED
@@ -1,3 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2
- <path fill="currentColor" d="M3 1.5h10A1.5 1.5 0 0 1 14.5 3v10a1.5 1.5 0 0 1-1.5 1.5H3A1.5 1.5 0 0 1 1.5 13V3A1.5 1.5 0 0 1 3 1.5Zm0 1a.5.5 0 0 0-.5.5v10a.5.5 0 0 0 .5.5h10a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5H3Zm1.15 3.1.7-.7L7.95 8l-3.1 3.1-.7-.7L6.55 8l-2.4-2.4ZM8 10h4v1H8v-1Z"/>
3
- </svg>