taskchef 7.5.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "7.5.0",
3
+ "version": "7.7.0",
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
@@ -176,6 +176,16 @@ taskchef dashboard --port 3211
176
176
  The loopback dashboard watches `tasks.jsonl`, groups current states, and opens
177
177
  linked Codex tasks. List snapshots and SSE events carry only the latest-result
178
178
  projection; opening task details fetches the full newest-first result history.
179
+ Task and result times are relative through 29 days (with minute detail for the
180
+ first six hours), then use a locale-aware calendar date. Each time is a keyboard-
181
+ accessible toggle for its full locale-aware date and time, and one shared
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.
179
189
  It does not mutate TaskChef data and prints its local URL.
180
190
  When a compatible foreground dashboard already owns port 3210,
181
191
  `ensure_dashboard` reuses it but does not take ownership. If an unknown,
@@ -187,6 +197,8 @@ The health endpoint contains only a fixed service marker, health schema,
187
197
  TaskChef version, dashboard-server version, and canonical workspace. It exposes
188
198
  no task data, credentials, environment variables, process control, or secrets.
189
199
 
200
+ ![Immutable event-time dashboard notifications](docs/images/notification-event-snapshots.jpg)
201
+
190
202
  ![Task detail result history](docs/images/result-history-dashboard.jpg)
191
203
 
192
204
  ## Common recovery
@@ -0,0 +1,3 @@
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>
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.5.0",
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",
@@ -1,23 +1,30 @@
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";
15
+ import { formatRelativeTime, RelativeTimeController, parsedTimestamp } from "./time.js";
11
16
 
12
17
  const state = {
13
18
  tasks: [],
14
19
  signatures: new Map(),
15
20
  notifications: [],
21
+ seenNotificationIds: new Set(),
16
22
  initialized: false,
17
23
  selectedTask: null,
18
24
  };
19
25
  let dateRefreshTimer = null;
20
26
  let detailRequestGeneration = 0;
27
+ const relativeTimes = new RelativeTimeController();
21
28
 
22
29
  const elements = {
23
30
  clearNotifications: document.querySelector("#clear-notifications"),
@@ -37,6 +44,7 @@ const elements = {
37
44
  dismissDashboardMessage: document.querySelector("#dismiss-dashboard-message"),
38
45
  emptyState: document.querySelector("#empty-state"),
39
46
  notifications: document.querySelector("#notifications"),
47
+ notificationAnnouncer: document.querySelector("#notification-announcer"),
40
48
  openProject: document.querySelector("#open-codex"),
41
49
  projectFilter: document.querySelector("#project-filter"),
42
50
  statusFilter: document.querySelector("#status-filter"),
@@ -44,13 +52,44 @@ const elements = {
44
52
  taskList: document.querySelector("#task-list"),
45
53
  toastList: document.querySelector("#toast-list"),
46
54
  };
55
+ let notificationDescriptionSerial = 0;
47
56
 
48
- function formatTime(value) {
49
- if (!value) return "—";
50
- return new Intl.DateTimeFormat(undefined, {
51
- dateStyle: "medium",
52
- timeStyle: "short",
53
- }).format(new Date(value));
57
+ function timestampControl(value, { accessibleName, key, prefix = "" }) {
58
+ if (!parsedTimestamp(value)) {
59
+ const missing = document.createElement("span");
60
+ missing.className = "timestamp-missing";
61
+ missing.textContent = `${prefix}—`;
62
+ return missing;
63
+ }
64
+ const control = document.createElement("button");
65
+ control.type = "button";
66
+ control.className = "timestamp-toggle";
67
+ const time = document.createElement("time");
68
+ control.append(time);
69
+ relativeTimes.register(key, value, ({ exact, iso, label }) => {
70
+ time.dateTime = iso;
71
+ time.textContent = `${prefix}${label}`;
72
+ const action = exact ? "Show relative time" : "Show exact date and time";
73
+ control.title = action;
74
+ control.setAttribute("aria-label", `${accessibleName}: ${label}. ${action}.`);
75
+ }, { isActive: () => control.isConnected });
76
+ control.addEventListener("click", () => relativeTimes.toggle(key));
77
+ return control;
78
+ }
79
+
80
+ function codexIcon() {
81
+ const icon = document.createElement("span");
82
+ icon.className = "codex-icon";
83
+ icon.setAttribute("aria-hidden", "true");
84
+ return icon;
85
+ }
86
+
87
+ function configureOpenTaskControl(control, ariaLabel) {
88
+ control.classList.add("task-action");
89
+ control.setAttribute("aria-label", ariaLabel);
90
+ const label = document.createElement("span");
91
+ label.textContent = "Open task";
92
+ control.replaceChildren(codexIcon(), label);
54
93
  }
55
94
 
56
95
  function setConnection(connected) {
@@ -76,17 +115,37 @@ function replaceOptions(select, values, allLabel) {
76
115
 
77
116
  function notificationToast(notification) {
78
117
  const task = findCurrentTask(state.tasks, notification.taskId);
79
- if (!task) return null;
80
118
  const toast = document.createElement("div");
81
- toast.className = "toast";
119
+ toast.className = `toast${task ? "" : " toast-missing"}`;
82
120
  const text = document.createElement("button");
83
121
  text.type = "button";
84
122
  text.className = "toast-content";
123
+ text.setAttribute("aria-label", notificationOpenLabel(notification, Boolean(task)));
124
+ const describedBy = [];
85
125
  const title = document.createElement("strong");
86
- title.textContent = notificationTitle(task, notification.kind);
126
+ title.textContent = notificationTitle(notification);
87
127
  const description = document.createElement("span");
88
- description.textContent = `${task.title} · ${taskStatusLabel(task)}`;
128
+ description.textContent = notification.title;
89
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(" "));
90
149
  text.addEventListener("click", () => {
91
150
  const current = findCurrentTask(state.tasks, notification.taskId);
92
151
  if (current) openDialog(current);
@@ -95,32 +154,49 @@ function notificationToast(notification) {
95
154
  const dismiss = document.createElement("button");
96
155
  dismiss.type = "button";
97
156
  dismiss.className = "icon-button";
98
- dismiss.setAttribute("aria-label", `Dismiss notification for ${task.title}`);
157
+ dismiss.setAttribute("aria-label", notificationDismissLabel(notification));
158
+ dismiss.setAttribute("aria-describedby", describedBy.join(" "));
99
159
  dismiss.textContent = "×";
100
160
  dismiss.addEventListener("click", () => {
101
- state.notifications = state.notifications.filter(({ id }) => id !== notification.id);
161
+ state.notifications = dismissNotification(state.notifications, notification.id);
102
162
  renderNotifications();
103
163
  });
104
164
  toast.append(text, dismiss);
105
165
  return toast;
106
166
  }
107
167
 
108
- function renderNotifications() {
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 = []) {
109
179
  elements.toastList.replaceChildren(
110
- ...state.notifications.map(notificationToast).filter(Boolean),
180
+ ...state.notifications.map(notificationToast),
111
181
  );
112
182
  elements.notifications.hidden = state.notifications.length === 0;
183
+ if (additions.length > 0) {
184
+ elements.notificationAnnouncer.textContent = additions
185
+ .map(notificationAnnouncement)
186
+ .join(". ");
187
+ }
113
188
  }
114
189
 
115
190
  function detailRow(term, value) {
116
191
  const dt = document.createElement("dt");
117
192
  dt.textContent = term;
118
193
  const dd = document.createElement("dd");
119
- dd.textContent = value ?? "—";
194
+ if (value instanceof Node) dd.append(value);
195
+ else dd.textContent = value ?? "—";
120
196
  return [dt, dd];
121
197
  }
122
198
 
123
- function resultHistory(results) {
199
+ function resultHistory(taskId, results) {
124
200
  if (results.length === 0) {
125
201
  const empty = document.createElement("p");
126
202
  empty.className = "result-history-empty";
@@ -135,9 +211,11 @@ function resultHistory(results) {
135
211
  const status = document.createElement("span");
136
212
  status.className = `status status-${result.status}`;
137
213
  status.textContent = result.status.replaceAll("_", " ");
138
- const timestamp = document.createElement("time");
139
- timestamp.dateTime = result.updatedAt;
140
- timestamp.textContent = formatTime(result.updatedAt);
214
+ const resultKey = result.turnId ?? `${result.status}:${index}`;
215
+ const timestamp = timestampControl(result.updatedAt, {
216
+ accessibleName: `Result updated time for ${result.status.replaceAll("_", " ")}`,
217
+ key: `detail:${taskId}:result:${resultKey}`,
218
+ });
141
219
  header.append(status, timestamp);
142
220
  const summary = document.createElement("p");
143
221
  summary.className = "preserve-lines";
@@ -161,7 +239,7 @@ function renderDialog(task) {
161
239
  state.selectedTask = detailedTask;
162
240
  elements.dialogProject.textContent = task.project.name;
163
241
  elements.dialogTitle.textContent = task.title;
164
- elements.dialogResults.replaceChildren(...resultHistory(detailedTask.results));
242
+ elements.dialogResults.replaceChildren(...resultHistory(task.id, detailedTask.results));
165
243
  elements.dialogInstruction.textContent = task.instruction;
166
244
  elements.copyThreadId.disabled = !task.threadId;
167
245
  elements.dialogMetadata.replaceChildren(
@@ -169,16 +247,27 @@ function renderDialog(task) {
169
247
  ...detailRow("Current turn ID", task.turnId),
170
248
  ...detailRow("Last result status", task.lastResult?.status?.replaceAll("_", " ")),
171
249
  ...detailRow("Last result turn ID", task.lastResult?.turnId),
172
- ...detailRow("Last result updated", formatTime(task.lastResult?.updatedAt)),
250
+ ...detailRow("Last result updated", timestampControl(task.lastResult?.updatedAt, {
251
+ accessibleName: `Last result updated time for ${task.title}`,
252
+ key: `detail:${task.id}:last-result-updated`,
253
+ })),
173
254
  ...detailRow("Task ID", task.id),
174
255
  ...detailRow("Thread ID", task.threadId),
175
256
  ...detailRow("Project path", task.project.path),
176
- ...detailRow("Created", formatTime(task.createdAt)),
177
- ...detailRow("Updated", formatTime(
257
+ ...detailRow("Created", timestampControl(task.createdAt, {
258
+ accessibleName: `Created time for ${task.title}`,
259
+ key: `detail:${task.id}:created`,
260
+ })),
261
+ ...detailRow("Updated", timestampControl(
178
262
  task.meaningfulUpdatedAt ?? task.updatedAt ?? task.createdAt,
263
+ {
264
+ accessibleName: `Updated time for ${task.title}`,
265
+ key: `detail:${task.id}:updated`,
266
+ },
179
267
  )),
180
268
  ...detailRow("Updated by", task.updatedBy),
181
269
  );
270
+ configureOpenTaskControl(elements.openProject, `Open ${task.title} in Codex`);
182
271
  }
183
272
 
184
273
  async function openDialog(task) {
@@ -223,16 +312,20 @@ function taskCard(task) {
223
312
  const summary = document.createElement("p");
224
313
  summary.className = "task-summary";
225
314
  summary.textContent = task.lastResult?.summary ?? "No semantic result reported yet.";
226
- const time = document.createElement("time");
227
- time.dateTime = task.meaningfulUpdatedAt ?? task.updatedAt ?? task.createdAt;
228
- time.textContent = `Updated ${formatTime(time.dateTime)}`;
315
+ const time = timestampControl(
316
+ task.meaningfulUpdatedAt ?? task.updatedAt ?? task.createdAt,
317
+ {
318
+ accessibleName: `Updated time for ${task.title}`,
319
+ key: `list:${task.id}:updated`,
320
+ prefix: "Updated ",
321
+ },
322
+ );
229
323
  const footer = document.createElement("div");
230
324
  footer.className = "task-footer";
231
325
  const openTask = document.createElement("button");
232
326
  openTask.type = "button";
233
327
  openTask.className = "secondary-button task-open";
234
- openTask.textContent = "Open task";
235
- openTask.setAttribute("aria-label", `Open ${task.title} in Codex`);
328
+ configureOpenTaskControl(openTask, `Open ${task.title} in Codex`);
236
329
  openTask.addEventListener("click", (event) => openTaskFromControl(event, task.id, {
237
330
  showMessage,
238
331
  }));
@@ -261,11 +354,13 @@ function applySnapshot(snapshot) {
261
354
  const reconciled = reconcileNotifications({
262
355
  initialized: state.initialized,
263
356
  notifications: state.notifications,
357
+ seenIds: state.seenNotificationIds,
264
358
  signatures: state.signatures,
265
- }, snapshot.tasks, snapshot.revision);
359
+ }, snapshot.tasks);
266
360
  state.tasks = snapshot.tasks;
267
361
  state.signatures = reconciled.signatures;
268
362
  state.notifications = reconciled.notifications;
363
+ state.seenNotificationIds = reconciled.seenIds;
269
364
  state.initialized = true;
270
365
  if (snapshot.healthy === false) {
271
366
  showMessage("The task log is temporarily unavailable. Showing the last valid snapshot.");
@@ -286,7 +381,7 @@ function applySnapshot(snapshot) {
286
381
  const updated = state.tasks.find((task) => task.id === state.selectedTask.id);
287
382
  if (updated && elements.dialog.open) openDialog(updated);
288
383
  }
289
- renderNotifications();
384
+ renderNotifications(reconciled.additions);
290
385
  render();
291
386
  }
292
387
 
@@ -313,7 +408,7 @@ elements.dismissDashboardMessage.addEventListener("click", () => {
313
408
  elements.dashboardMessage.hidden = true;
314
409
  });
315
410
  elements.clearNotifications.addEventListener("click", () => {
316
- state.notifications = [];
411
+ state.notifications = clearNotifications();
317
412
  renderNotifications();
318
413
  });
319
414
  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">
@@ -83,7 +84,10 @@
83
84
  <button id="close-dialog" class="icon-button" type="button" aria-label="Close task details">×</button>
84
85
  </div>
85
86
  <div class="dialog-actions">
86
- <button id="open-codex" class="primary-button" type="button">Open task in Codex</button>
87
+ <button id="open-codex" class="primary-button task-action" type="button" aria-label="Open this task in Codex">
88
+ <span class="codex-icon" aria-hidden="true"></span>
89
+ <span>Open task</span>
90
+ </button>
87
91
  <button id="copy-thread-id" class="secondary-button" type="button">Copy thread ID</button>
88
92
  </div>
89
93
  <section>
@@ -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
  }
@@ -51,7 +51,7 @@ h3 { margin-bottom: 8px; font-size: 0.84rem; letter-spacing: 0.08em; text-transf
51
51
  .dashboard-icon img { display: block; width: 100%; height: 100%; object-fit: contain; }
52
52
 
53
53
  .eyebrow { margin-bottom: 10px; color: var(--accent); font-size: 0.78rem; font-weight: 750; letter-spacing: 0.15em; text-transform: uppercase; }
54
- .subtitle, .task-project, time { color: var(--muted); }
54
+ .subtitle, .task-project, time, .timestamp-missing { color: var(--muted); }
55
55
  .subtitle { margin-bottom: 0; }
56
56
 
57
57
  .connection { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 0.9rem; }
@@ -78,9 +78,13 @@ select { min-width: 180px; padding: 9px 34px 9px 11px; border: 1px solid var(--b
78
78
  .task-title:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
79
79
  .task-project { margin: 3px 0 12px; font-size: 0.86rem; }
80
80
  .task-summary { max-width: 78ch; margin-bottom: 14px; }
81
- time { font-size: 0.78rem; }
81
+ time, .timestamp-missing { font-size: 0.78rem; }
82
+ .timestamp-toggle { padding: 2px 0; border: 0; background: none; cursor: pointer; text-align: left; }
83
+ .timestamp-toggle:hover time { color: var(--text); text-decoration: underline; text-underline-offset: 3px; }
82
84
  .task-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
83
85
  .task-open { flex: none; }
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; }
84
88
 
85
89
  .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; }
86
90
  .status-completed { background: var(--accent-soft); color: var(--accent); }
@@ -101,6 +105,9 @@ time { font-size: 0.78rem; }
101
105
  .toast { display: flex; align-items: flex-start; gap: 8px; padding: 10px; border-radius: 7px; background: var(--surface-muted); }
102
106
  .toast-content { display: grid; flex: 1; gap: 2px; padding: 0; border: 0; background: none; text-align: left; cursor: pointer; }
103
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); }
104
111
 
105
112
  .text-button, .icon-button { border: 0; background: none; cursor: pointer; }
106
113
  .text-button { color: var(--accent); font-size: 0.82rem; font-weight: 700; }
@@ -142,8 +149,8 @@ pre { max-height: 280px; margin: 0; padding: 14px; overflow: auto; border-radius
142
149
  .task-count { margin: 0; }
143
150
  .task-heading { align-items: flex-start; }
144
151
  .task-footer { align-items: flex-start; flex-direction: column; }
145
- .task-open { width: 100%; }
146
- .dialog-actions { align-items: stretch; flex-direction: column; }
152
+ .task-open { align-self: flex-start; }
153
+ .dialog-actions { align-items: center; flex-flow: row wrap; }
147
154
  .metadata { grid-template-columns: 1fr; }
148
155
  .metadata dt { padding-bottom: 0; border-bottom: 0; }
149
156
  }
@@ -0,0 +1,146 @@
1
+ export const RELATIVE_TIME_REFRESH_MS = 30_000;
2
+ export const RELATIVE_DATE_LIMIT_DAYS = 30;
3
+
4
+ const MINUTE_MS = 60_000;
5
+ const HOUR_MS = 60 * MINUTE_MS;
6
+ const DAY_MS = 24 * HOUR_MS;
7
+
8
+ export function parsedTimestamp(value) {
9
+ if (typeof value !== "string" || value.trim() === "") return null;
10
+ const milliseconds = Date.parse(value);
11
+ if (!Number.isFinite(milliseconds)) return null;
12
+ return {
13
+ date: new Date(milliseconds),
14
+ iso: new Date(milliseconds).toISOString(),
15
+ milliseconds,
16
+ };
17
+ }
18
+
19
+ export function formatExactTime(value, { locale, timeZone } = {}) {
20
+ const parsed = parsedTimestamp(value);
21
+ if (!parsed) return "—";
22
+ return new Intl.DateTimeFormat(locale, {
23
+ dateStyle: "medium",
24
+ timeStyle: "short",
25
+ timeZone,
26
+ }).format(parsed.date);
27
+ }
28
+
29
+ function hourPhrase(milliseconds, direction) {
30
+ const hours = Math.floor(milliseconds / HOUR_MS);
31
+ const minutes = Math.floor((milliseconds % HOUR_MS) / MINUTE_MS);
32
+ const hourText = hours === 1 ? "1 hour" : `${hours} hours`;
33
+ const detail = hours < 6 && minutes > 0 ? ` ${minutes} minutes` : "";
34
+ return direction === "future"
35
+ ? `in ${hourText}${detail}`
36
+ : `${hourText}${detail} ago`;
37
+ }
38
+
39
+ export function formatRelativeTime(value, {
40
+ now = Date.now(),
41
+ locale,
42
+ timeZone,
43
+ } = {}) {
44
+ const parsed = parsedTimestamp(value);
45
+ if (!parsed) return "—";
46
+ const difference = now - parsed.milliseconds;
47
+ const future = difference < 0;
48
+ const elapsed = Math.abs(difference);
49
+ if (elapsed < MINUTE_MS) return "just now";
50
+ if (elapsed < 2 * MINUTE_MS) return future ? "in 1 minute" : "1 minute ago";
51
+ if (elapsed < HOUR_MS) {
52
+ const minutes = future
53
+ ? Math.ceil(elapsed / MINUTE_MS)
54
+ : Math.floor(elapsed / MINUTE_MS);
55
+ return future ? `in ${minutes} minutes` : `${minutes} minutes ago`;
56
+ }
57
+ if (elapsed < 2 * HOUR_MS) return future ? "in 1 hour" : "1 hour ago";
58
+ if (elapsed < DAY_MS) return hourPhrase(elapsed, future ? "future" : "past");
59
+ if (elapsed < 2 * DAY_MS) return future ? "in 1 day" : "1 day ago";
60
+ if (elapsed < RELATIVE_DATE_LIMIT_DAYS * DAY_MS) {
61
+ const days = future
62
+ ? Math.ceil(elapsed / DAY_MS)
63
+ : Math.floor(elapsed / DAY_MS);
64
+ return future ? `in ${days} days` : `${days} days ago`;
65
+ }
66
+ return new Intl.DateTimeFormat(locale, {
67
+ dateStyle: "medium",
68
+ timeZone,
69
+ }).format(parsed.date);
70
+ }
71
+
72
+ export function timestampPresentation(value, {
73
+ exact = false,
74
+ now = Date.now(),
75
+ locale,
76
+ timeZone,
77
+ } = {}) {
78
+ const parsed = parsedTimestamp(value);
79
+ if (!parsed) return { valid: false, label: "—", iso: null };
80
+ return {
81
+ exact,
82
+ iso: parsed.iso,
83
+ label: exact
84
+ ? formatExactTime(value, { locale, timeZone })
85
+ : formatRelativeTime(value, { now, locale, timeZone }),
86
+ valid: true,
87
+ };
88
+ }
89
+
90
+ export class RelativeTimeController {
91
+ constructor({
92
+ now = () => Date.now(),
93
+ setIntervalFn = (...args) => globalThis.setInterval(...args),
94
+ clearIntervalFn = (...args) => globalThis.clearInterval(...args),
95
+ refreshEveryMs = RELATIVE_TIME_REFRESH_MS,
96
+ } = {}) {
97
+ this.now = now;
98
+ this.setIntervalFn = setIntervalFn;
99
+ this.clearIntervalFn = clearIntervalFn;
100
+ this.refreshEveryMs = refreshEveryMs;
101
+ this.entries = new Map();
102
+ this.exactKeys = new Set();
103
+ this.timer = null;
104
+ }
105
+
106
+ register(key, value, render, { isActive = () => true } = {}) {
107
+ const token = Symbol(key);
108
+ this.entries.set(token, { isActive, key, render, value });
109
+ this.renderEntry(this.entries.get(token));
110
+ if (this.timer === null) {
111
+ this.timer = this.setIntervalFn(() => this.refresh(), this.refreshEveryMs);
112
+ }
113
+ return () => this.entries.delete(token);
114
+ }
115
+
116
+ renderEntry(entry) {
117
+ entry.render(timestampPresentation(entry.value, {
118
+ exact: this.exactKeys.has(entry.key),
119
+ now: this.now(),
120
+ }));
121
+ }
122
+
123
+ toggle(key) {
124
+ if (this.exactKeys.has(key)) this.exactKeys.delete(key);
125
+ else this.exactKeys.add(key);
126
+ for (const entry of this.entries.values()) {
127
+ if (entry.key === key) this.renderEntry(entry);
128
+ }
129
+ }
130
+
131
+ refresh() {
132
+ for (const [token, entry] of this.entries) {
133
+ if (!entry.isActive()) {
134
+ this.entries.delete(token);
135
+ continue;
136
+ }
137
+ if (!this.exactKeys.has(entry.key)) this.renderEntry(entry);
138
+ }
139
+ }
140
+
141
+ stop() {
142
+ if (this.timer !== null) this.clearIntervalFn(this.timer);
143
+ this.timer = null;
144
+ this.entries.clear();
145
+ }
146
+ }
package/src/dashboard.js CHANGED
@@ -43,10 +43,12 @@ const STATIC_FILES = new Map([
43
43
  ["/", [path.join(STATIC_ROOT, "index.html"), "text/html; charset=utf-8"]],
44
44
  ["/actions.js", [path.join(STATIC_ROOT, "actions.js"), "text/javascript; charset=utf-8"]],
45
45
  ["/app.js", [path.join(STATIC_ROOT, "app.js"), "text/javascript; charset=utf-8"]],
46
+ ["/time.js", [path.join(STATIC_ROOT, "time.js"), "text/javascript; charset=utf-8"]],
46
47
  ["/state.js", [path.join(STATIC_ROOT, "state.js"), "text/javascript; charset=utf-8"]],
47
48
  ["/styles.css", [path.join(STATIC_ROOT, "styles.css"), "text/css; charset=utf-8"]],
48
49
  ["/assets/taskchef-dark.svg", [path.join(ASSET_ROOT, "taskchef-dark.svg"), "image/svg+xml"]],
49
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"]],
50
52
  ]);
51
53
 
52
54
  export function dashboardAuthority(host, port) {