taskchef 7.22.4 → 7.23.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/docs/workflows.md CHANGED
@@ -4,7 +4,8 @@ This developer and advanced-agent guide explains how the current implementation
4
4
  moves data through TaskChef. The [specification](spec.md) is normative; the
5
5
  [README](../README.md) owns user operation. The
6
6
  [FirstMate comparison](firstmate-taskchef-comparison.md) is non-normative
7
- research.
7
+ research. The [dashboard lifecycle](dashboard-lifecycle.md) owns the focused
8
+ start, upgrade, shutdown, and security rationale.
8
9
 
9
10
  ## Implementation map
10
11
 
@@ -15,12 +16,14 @@ research.
15
16
  | `skills/taskchef-bootstrap/SKILL.md` | Initialize the workspace and maintain the Codex project index. |
16
17
  | `skills/taskchef-dashboard/SKILL.md` | Ensure or recover the canonical dashboard and return its URL. |
17
18
  | `skills/taskchef-copilot/SKILL.md` | Explain normalized cached briefs and coordinate safe next actions. |
18
- | `src/mcp.js` | Dashboard ensure, four primary lifecycle tools, one deprecated alias, shutdown ownership, and MCP annotations. |
19
+ | `src/mcp.js` | Dashboard ensure, four primary lifecycle tools, one deprecated alias, MCP cleanup, and annotations. |
19
20
  | `src/delegation.js` | UUID marker, concise executor-skill invocation shape, and creation-failure handling. |
20
21
  | `src/workspace.js` | Current schemas, validation, locking, atomic JSONL writes, linking, and result freshness. |
21
22
  | `src/cli.js` | Administration, normalized cached briefs, inspection, diagnostics, and dashboard startup. |
22
23
  | `src/dashboard.js` | Versioned health identity, validated compact snapshots, SSE fan-out, on-demand details, and bounded open actions. |
23
- | `src/dashboard-manager.js` | Concurrent singleton ensure, exact listener reuse, conflicts, and owned shutdown. |
24
+ | `src/dashboard-manager.js` | Concurrent session ensure, authenticated reuse and upgrade handoff, and safe conflicts. |
25
+ | `src/dashboard-session-process.js` | Independent loopback server, private ownership publication, signals, and Codex-session lease. |
26
+ | `src/dashboard-session.js` | Exact registered-PID liveness checks and grace-period expiry. |
24
27
  | `src/usage.js` | Optional bounded ccusage execution, exact primary-thread mapping, normalized aggregation, and the private usage cache. |
25
28
  | `src/usage-tracker.js` | Deferred sampling, cumulative boundaries, historical availability, and per-turn deltas. |
26
29
 
@@ -40,19 +43,25 @@ sequenceDiagram
40
43
  participant D as Dispatcher
41
44
  participant M as TaskChef MCP
42
45
  participant H as Loopback health
43
- participant S as Dashboard server
46
+ participant S as Dashboard session process
44
47
  M->>M: Read dashboard.autostart (absent means true)
45
48
  M->>M: Best-effort ensure before MCP transport connects
46
49
  D->>M: ensure_dashboard()
47
50
  M->>M: Serialize concurrent ensure calls
48
51
  M->>H: GET 127.0.0.1:3210/api/health
49
- alt Exact service, versions, canonical workspace, and MCP launcher
52
+ alt Exact service, versions, canonical workspace, and session launcher
50
53
  H-->>M: Bounded compatible identity
54
+ M->>S: Authenticate owner and register Codex parent PID
51
55
  M-->>D: reused, URL, workspace, versions
52
56
  else No listener
53
57
  H--xM: Connection refused
54
- M->>S: Start in this MCP process on 127.0.0.1:3210
55
- S-->>M: Owned server
58
+ M->>S: Launch session process on 127.0.0.1:3210
59
+ S-->>M: Private owner metadata and bounded identity
60
+ M-->>D: started, URL, workspace, versions
61
+ else Verified older TaskChef listener for this workspace
62
+ M->>S: Authenticated prepare, lease join, and commit
63
+ S-->>M: Signed final leases and bounded retry before shutdown
64
+ M->>S: Launch installed session version
56
65
  M-->>D: started, URL, workspace, versions
57
66
  else Standalone, unknown, stale, or different workspace
58
67
  H-->>M: Missing or incompatible identity
@@ -63,14 +72,15 @@ sequenceDiagram
63
72
  Note over D: Created-thread directive, when any, precedes final dashboard link
64
73
  ```
65
74
 
66
- When the MCP transport or plugin process closes, it closes only the server it
67
- started. A foreground `taskchef dashboard` listener identifies itself as
68
- standalone and is never reused as the canonical MCP dashboard, because its
69
- archive child process may inherit a different host environment. No TaskChef
70
- path terminates an incompatible listener or installs OS persistence.
75
+ When one MCP transport or plugin process closes, the dashboard remains while a
76
+ registered Codex session PID is alive. After every registered PID disappears
77
+ for the grace period, the dashboard closes and exits. A foreground
78
+ `taskchef dashboard` listener identifies itself as standalone and is never
79
+ reused as the canonical session dashboard. No TaskChef path terminates an
80
+ incompatible listener or installs OS persistence.
71
81
 
72
- Autostart and explicit ensures share the same manager promise, so concurrent
73
- initialization and recovery calls produce at most one owned listener. An
82
+ Autostart and explicit ensures share the same manager promise, while
83
+ cross-process port races converge through authenticated reuse. An
74
84
  explicit `dashboard.autostart: false` skips only activation-time ensure. Any
75
85
  failure is reduced to a fixed stderr diagnostic; tool registration and MCP
76
86
  availability continue. Activation never opens a browser.
@@ -82,13 +92,15 @@ The practical release handoff ends in this order:
82
92
  1. Install the released plugin.
83
93
  2. Activate or reload its new TaskChef MCP process.
84
94
  3. Run `$taskchef-dashboard` or call `ensure_dashboard`.
85
- 4. Verify the expected TaskChef version, protocol `serverVersion`, `mcp`
95
+ 4. Verify the expected TaskChef version, protocol `serverVersion`, `session`
86
96
  launcher, canonical workspace, and canonical URL returned by the dashboard identity.
87
97
 
88
98
  Replacing plugin files alone cannot execute autostart because old code remains
89
- in the already-running MCP process. Installation does not necessarily reload
90
- Codex. An exact-compatible MCP dashboard may be reused; a standalone or unknown
91
- listener is never terminated or replaced.
99
+ in an already-running MCP process. Installation does not necessarily reload
100
+ Codex. Activation of the installed MCP authenticates and retires a verified
101
+ older TaskChef listener before starting the installed session version. An
102
+ exact-compatible session dashboard may be reused; a newer, standalone, or
103
+ unknown listener is never terminated or replaced.
92
104
 
93
105
  ## Normal delegation and self-linking
94
106
 
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runDashboardSessionProcess } from "../src/dashboard-session-process.js";
4
+
5
+ let cancelled = false;
6
+ let runtime = null;
7
+ let readySent = false;
8
+ const startupController = new AbortController();
9
+ process.on("message", (message) => {
10
+ if (message?.type !== "cancel") return;
11
+ cancelled = true;
12
+ startupController.abort();
13
+ if (runtime) void runtime.close().finally(() => { process.exitCode = 1; });
14
+ });
15
+ process.on("disconnect", () => {
16
+ if (readySent) return;
17
+ cancelled = true;
18
+ startupController.abort();
19
+ if (runtime) void runtime.close().finally(() => { process.exitCode = 1; });
20
+ });
21
+
22
+ try {
23
+ runtime = await runDashboardSessionProcess({ signal: startupController.signal });
24
+ if (cancelled) {
25
+ await runtime.close();
26
+ throw Object.assign(new Error("dashboard session startup was cancelled"), {
27
+ code: "TASKCHEF_DASHBOARD_START_TIMEOUT",
28
+ });
29
+ }
30
+ if (process.send && process.connected) {
31
+ readySent = true;
32
+ await new Promise((resolve, reject) => {
33
+ process.send({ type: "ready", port: runtime.server.port }, (error) =>
34
+ error ? reject(error) : resolve());
35
+ });
36
+ if (process.connected) process.disconnect();
37
+ }
38
+ } catch (error) {
39
+ readySent = false;
40
+ if (runtime) await runtime.close().catch(() => {});
41
+ if (process.send && process.connected) {
42
+ await new Promise((resolve) => {
43
+ process.send({
44
+ type: "error",
45
+ code: typeof error?.code === "string" ? error.code : "TASKCHEF_DASHBOARD_START_FAILED",
46
+ }, () => resolve());
47
+ }).catch(() => {});
48
+ if (process.connected) process.disconnect();
49
+ }
50
+ process.exitCode = 1;
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "7.22.4",
3
+ "version": "7.23.0",
4
4
  "description": "A non-blocking interactive dispatcher for visible Codex tasks.",
5
5
  "license": "MIT",
6
6
  "author": "Favo Yang",
@@ -25,12 +25,14 @@
25
25
  "bin",
26
26
  "docs/spec.md",
27
27
  "docs/workflows.md",
28
+ "docs/dashboard-lifecycle.md",
28
29
  "docs/firstmate-taskchef-comparison.md",
29
30
  "docs/images/dashboard-identity.jpg",
30
31
  "docs/images/ccusage-token-consumption.png",
31
32
  "docs/images/notification-event-snapshots.jpg",
32
33
  "docs/images/result-history-dashboard.jpg",
33
34
  "docs/images/interrupted-turn-recovery.jpg",
35
+ "docs/images/dashboard-token-pending.jpg",
34
36
  "index.js",
35
37
  "mcp",
36
38
  "scripts/benchmark-dispatch-prepare.js",
@@ -49,6 +49,7 @@ const state = {
49
49
  notifications: [],
50
50
  seenNotificationIds: new Set(),
51
51
  initialized: false,
52
+ detailNavigation: null,
52
53
  manualTransition: null,
53
54
  selectedTask: null,
54
55
  };
@@ -80,6 +81,7 @@ const elements = {
80
81
  dismissDashboardMessage: document.querySelector("#dismiss-dashboard-message"),
81
82
  emptyState: document.querySelector("#empty-state"),
82
83
  notifications: document.querySelector("#notifications"),
84
+ notificationHost: document.querySelector("#notification-host"),
83
85
  notificationAnnouncer: document.querySelector("#notification-announcer"),
84
86
  manualTransitionPanel: document.querySelector("#manual-transition-panel"),
85
87
  manualTransitionError: document.querySelector("#manual-transition-error"),
@@ -103,6 +105,7 @@ function referenceLink(link, { compact = false, displayLabel } = {}) {
103
105
  anchor.href = link.url;
104
106
  anchor.target = "_blank";
105
107
  anchor.rel = "noopener noreferrer";
108
+ anchor.dataset.focusKey = `link:${link.url}`;
106
109
  const label = link.label ?? link.text;
107
110
  anchor.textContent = displayLabel ?? label;
108
111
  const accessibleLabel = githubReferenceAccessibleLabel(link);
@@ -211,6 +214,7 @@ function timestampControl(value, { accessibleName, key, prefix = "" }) {
211
214
  const control = document.createElement("button");
212
215
  control.type = "button";
213
216
  control.className = "timestamp-toggle";
217
+ control.dataset.focusKey = `timestamp:${key}`;
214
218
  const time = document.createElement("time");
215
219
  control.append(time);
216
220
  relativeTimes.register(key, value, ({ exact, iso, label }) => {
@@ -323,10 +327,16 @@ function notificationToast(notification) {
323
327
  text.append(metadata);
324
328
  describedBy.push(metadata.id);
325
329
  text.setAttribute("aria-describedby", describedBy.join(" "));
326
- text.addEventListener("click", () => {
330
+ text.addEventListener("click", async () => {
327
331
  const current = findCurrentTask(state.tasks, notification.taskId);
328
- if (current) openDialog(current);
329
- else showMessage("This task is no longer present in the current snapshot.");
332
+ state.notifications = dismissNotification(state.notifications, notification.id);
333
+ renderNotifications();
334
+ if (current) {
335
+ await openDialog(current, {
336
+ focus: true,
337
+ highlightTurnRef: notification.turnRef ?? notification.turnId,
338
+ });
339
+ } else showMessage("This task is no longer present in the current snapshot.");
330
340
  });
331
341
  const dismiss = document.createElement("button");
332
342
  dismiss.type = "button";
@@ -356,7 +366,31 @@ function renderNotifications(additions = []) {
356
366
  elements.toastList.replaceChildren(
357
367
  ...state.notifications.map(notificationToast),
358
368
  );
359
- elements.notifications.hidden = state.notifications.length === 0;
369
+ const notificationHost = elements.dialog.open ? elements.dialog : elements.notificationHost;
370
+ const movingLayer = elements.notifications.parentNode !== notificationHost;
371
+ if ((movingLayer || additions.length > 0) && state.notifications.length > 0) {
372
+ try {
373
+ elements.notifications.hidePopover?.();
374
+ } catch {
375
+ // The notification layer is not open yet.
376
+ }
377
+ }
378
+ if (movingLayer) notificationHost.append(elements.notifications);
379
+ if (state.notifications.length === 0) {
380
+ try {
381
+ elements.notifications.hidePopover?.();
382
+ } catch {
383
+ // The notification layer is already closed.
384
+ }
385
+ elements.notifications.hidden = true;
386
+ } else {
387
+ elements.notifications.hidden = false;
388
+ try {
389
+ elements.notifications.showPopover?.();
390
+ } catch {
391
+ // Re-rendering an already-open manual popover needs no further action.
392
+ }
393
+ }
360
394
  if (additions.length > 0) {
361
395
  elements.notificationAnnouncer.textContent = additions
362
396
  .map(notificationAnnouncement)
@@ -406,24 +440,31 @@ function usageBreakdownText(usage) {
406
440
  ].join(" · ");
407
441
  }
408
442
 
409
- function usagePresentation(usage, { wholeTask = false } = {}) {
443
+ export function usagePresentation(usage, { wholeTask = false } = {}) {
410
444
  const container = document.createElement("div");
411
- container.className = `usage-summary usage-${usage?.status ?? "calculating"}`;
412
- if (!usage || usage.status === "calculating") {
413
- const indicator = document.createElement("span");
414
- indicator.className = "usage-spinner";
415
- indicator.setAttribute("aria-hidden", "true");
445
+ container.className = `usage-summary usage-${usage?.status ?? "unavailable"}`;
446
+ if (usage?.status === "pending" || usage?.status === "calculating") {
416
447
  const text = document.createElement("span");
417
- text.textContent = "Tokens: calculating…";
418
- container.replaceChildren(indicator, text);
448
+ text.className = "usage-shimmer";
449
+ text.textContent = usage.status === "pending"
450
+ ? "Tokens pending · available when turn finishes"
451
+ : "Calculating token usage…";
452
+ container.replaceChildren(text);
419
453
  return container;
420
454
  }
421
- if (usage.status !== "available") {
422
- container.textContent = `Tokens unavailable${usage.reason ? ` — ${usage.reason}` : "."}`;
455
+ if (usage?.status !== "available") {
456
+ const headline = document.createElement("strong");
457
+ headline.textContent = "Token usage unavailable";
458
+ container.append(headline);
459
+ if (usage?.reason) {
460
+ const reason = document.createElement("span");
461
+ reason.textContent = usage.reason;
462
+ container.append(reason);
463
+ }
423
464
  return container;
424
465
  }
425
466
  const headline = document.createElement("strong");
426
- headline.textContent = `${tokenFormatter.format(usage.totalTokens)} tokens · ${formatEstimatedCost(usage.estimatedCostUsd)}`;
467
+ headline.textContent = `${tokenFormatter.format(usage.totalTokens)} tokens · ${formatEstimatedCost(usage.estimatedCostUsd)}${usage.knownSoFar ? " · known so far" : ""}`;
427
468
  const breakdown = document.createElement("span");
428
469
  breakdown.textContent = usageBreakdownText(usage);
429
470
  container.append(headline, breakdown);
@@ -449,7 +490,7 @@ function usageStillCalculating(task) {
449
490
  .some((turn) => turn.status === "calculating"));
450
491
  }
451
492
 
452
- function turnTimeline(task) {
493
+ function turnTimeline(task, { highlightTurnRef = null } = {}) {
453
494
  if (task.turns.length === 0) {
454
495
  const empty = document.createElement("p");
455
496
  empty.className = "result-history-empty";
@@ -467,6 +508,11 @@ function turnTimeline(task) {
467
508
  status.className = `status status-${turnStatus}`;
468
509
  status.textContent = turnStatus.replaceAll("_", " ");
469
510
  const turnKey = turn.turnRef ?? turn.turnId ?? `no-turn:${index}`;
511
+ item.dataset.turnRef = turnKey;
512
+ if (highlightTurnRef !== null && turnKey === highlightTurnRef) {
513
+ item.className += " result-history-notified";
514
+ item.tabIndex = -1;
515
+ }
470
516
  const timestamp = timestampControl(presentation.updatedAt, {
471
517
  accessibleName: `Turn updated time for ${turnStatus.replaceAll("_", " ")}`,
472
518
  key: `detail:${task.id}:turn:${turnKey}`,
@@ -495,11 +541,13 @@ function turnTimeline(task) {
495
541
  const turnMetadata = document.createElement("p");
496
542
  turnMetadata.className = "result-history-turn";
497
543
  turnMetadata.textContent = `Turn ref ${turn.turnRef ?? "not recorded"}; Codex turn ${turn.turnId ?? "unavailable"}`;
498
- const turnUsage = task.usage?.turns?.[turn.turnRef ?? turn.turnId] ?? (
499
- turn.result === null
500
- ? { status: "calculating" }
501
- : { status: "unavailable", reason: "No reliable turn boundary is available." }
502
- );
544
+ const recordedUsage = task.usage?.turns?.[turn.turnRef ?? turn.turnId];
545
+ const turnUsage = turn.result === null
546
+ ? { status: "pending" }
547
+ : recordedUsage ?? {
548
+ status: "unavailable",
549
+ reason: "No reliable turn boundary is available.",
550
+ };
503
551
  item.append(
504
552
  header,
505
553
  requestLabel,
@@ -513,6 +561,39 @@ function turnTimeline(task) {
513
561
  });
514
562
  }
515
563
 
564
+ function descendantPath(root, descendant) {
565
+ const path = [];
566
+ let current = descendant;
567
+ while (current && current !== root) {
568
+ const parent = current.parentNode;
569
+ if (!parent) return null;
570
+ const index = Array.prototype.indexOf.call(parent.children, current);
571
+ if (index < 0) return null;
572
+ path.unshift(index);
573
+ current = parent;
574
+ }
575
+ return current === root ? path : null;
576
+ }
577
+
578
+ function descendantAtPath(root, path) {
579
+ let current = root;
580
+ for (const index of path ?? []) {
581
+ current = current?.children?.[index];
582
+ if (!current) return null;
583
+ }
584
+ return current;
585
+ }
586
+
587
+ function descendantWithFocusKey(root, focusKey) {
588
+ if (!root || !focusKey) return null;
589
+ if (root.dataset?.focusKey === focusKey) return root;
590
+ for (const child of root.children ?? []) {
591
+ const match = descendantWithFocusKey(child, focusKey);
592
+ if (match) return match;
593
+ }
594
+ return null;
595
+ }
596
+
516
597
  function manualTransitionPending() {
517
598
  return state.manualTransition?.stage === "pending";
518
599
  }
@@ -648,7 +729,7 @@ async function submitManualTransition(targetStatus, event) {
648
729
  elements.manualTransitionError.focus();
649
730
  }
650
731
 
651
- function renderDialog(task) {
732
+ function renderDialog(task, { highlightTurnRef = null } = {}) {
652
733
  if (state.selectedTask?.id !== task.id) {
653
734
  copyTaskIdGeneration += 1;
654
735
  clearTimeout(copyTaskIdTimer);
@@ -657,11 +738,27 @@ function renderDialog(task) {
657
738
  const preservedResults = state.selectedTask?.id === task.id
658
739
  ? state.selectedTask.results
659
740
  : null;
741
+ const preservedUsage = state.selectedTask?.id === task.id
742
+ ? state.selectedTask.usage
743
+ : null;
660
744
  const detailedTask = {
661
745
  ...task,
662
746
  turns: mergeProjectedTurns(task, state.selectedTask?.turns ?? []),
663
747
  results: task.results ?? preservedResults ?? [],
748
+ usage: task.usage ?? preservedUsage ?? null,
664
749
  };
750
+ const focusedElement = elements.dialogResults.contains?.(document.activeElement)
751
+ ? document.activeElement
752
+ : null;
753
+ let focusedTurn = focusedElement;
754
+ while (focusedTurn && focusedTurn !== elements.dialogResults && !focusedTurn.dataset?.turnRef) {
755
+ focusedTurn = focusedTurn.parentNode;
756
+ }
757
+ const focusedTurnRef = focusedTurn?.dataset?.turnRef ?? null;
758
+ const focusedDescendantKey = focusedElement?.dataset?.focusKey ?? null;
759
+ const focusedDescendantPath = focusedTurn && focusedElement
760
+ ? descendantPath(focusedTurn, focusedElement)
761
+ : null;
665
762
  state.selectedTask = detailedTask;
666
763
  renderProject(elements.dialogProject, task);
667
764
  elements.dialogTitle.textContent = task.title;
@@ -673,11 +770,48 @@ function renderDialog(task) {
673
770
  elements.dialogRelatedLinks.hidden = (
674
771
  (task.relatedGitHubLinks?.length ?? 0) === 0 && !task.relatedGitHubLinksTruncated
675
772
  );
676
- elements.dialogResults.replaceChildren(...turnTimeline(detailedTask));
773
+ const timeline = turnTimeline(detailedTask, { highlightTurnRef });
774
+ elements.dialogResults.replaceChildren(...timeline);
775
+ const highlightedTurn = timeline.find((item) => item.dataset.turnRef === highlightTurnRef) ?? null;
776
+ const replacedFocusedTurn = timeline.find((item) => item.dataset.turnRef === focusedTurnRef) ?? null;
777
+ if (replacedFocusedTurn) {
778
+ const pathMatchedElement = descendantAtPath(replacedFocusedTurn, focusedDescendantPath);
779
+ const replacedFocusedElement = (
780
+ pathMatchedElement?.dataset?.focusKey === focusedDescendantKey
781
+ ? pathMatchedElement
782
+ : null
783
+ ) ?? descendantWithFocusKey(replacedFocusedTurn, focusedDescendantKey);
784
+ if (replacedFocusedElement && replacedFocusedElement !== replacedFocusedTurn) {
785
+ replacedFocusedElement.focus?.();
786
+ } else {
787
+ replacedFocusedTurn.tabIndex = -1;
788
+ replacedFocusedTurn.focus?.();
789
+ }
790
+ }
791
+ const currentTurnRef = task.turnRef ?? task.latestTurn?.turnRef ?? null;
792
+ const preservedUsageIsPriorGeneration = task.usage == null
793
+ && preservedUsage != null
794
+ && currentTurnRef != null
795
+ && preservedUsage.generationTurnRef !== currentTurnRef;
796
+ const taskUsage = detailedTask.usage?.task
797
+ && (detailedTask.usage.status === "available" || task.status === "working")
798
+ && (!preservedUsageIsPriorGeneration || task.status === "working")
799
+ ? {
800
+ status: "available",
801
+ ...detailedTask.usage.task,
802
+ knownSoFar: task.status === "working",
803
+ }
804
+ : detailedTask.usage?.status === "calculating" || preservedUsageIsPriorGeneration
805
+ ? { ...detailedTask.usage, status: task.status === "working" ? "pending" : "calculating" }
806
+ : detailedTask.usage ?? {
807
+ status: task.status === "working"
808
+ ? "pending"
809
+ : task.threadId
810
+ ? "calculating"
811
+ : "unavailable",
812
+ };
677
813
  elements.dialogUsage.replaceChildren(usagePresentation(
678
- task.usage?.status === "available" && task.usage.task
679
- ? { status: "available", ...task.usage.task }
680
- : task.usage ?? { status: task.threadId ? "calculating" : "unavailable" },
814
+ taskUsage,
681
815
  { wholeTask: true },
682
816
  ));
683
817
  elements.dialogInstruction.textContent = task.instruction;
@@ -722,12 +856,31 @@ function renderDialog(task) {
722
856
  archived ? `${task.title} is archived in Codex` : `Archive ${task.title} in Codex`,
723
857
  );
724
858
  renderManualTransition(detailedTask);
859
+ return highlightedTurn;
725
860
  }
726
861
 
727
- async function openDialog(task) {
862
+ async function openDialog(task, {
863
+ focus = false,
864
+ highlightTurnRef = null,
865
+ preserveNavigation = false,
866
+ } = {}) {
867
+ if (focus) {
868
+ state.detailNavigation = {
869
+ focusPending: true,
870
+ highlightTurnRef,
871
+ taskId: task.id,
872
+ };
873
+ } else if (!preserveNavigation) state.detailNavigation = null;
874
+ const navigation = state.detailNavigation?.taskId === task.id
875
+ ? state.detailNavigation
876
+ : null;
877
+ const activeHighlightTurnRef = navigation?.highlightTurnRef ?? highlightTurnRef;
728
878
  const requestGeneration = ++detailRequestGeneration;
729
- renderDialog(task);
730
- if (!elements.dialog.open) elements.dialog.showModal();
879
+ renderDialog(task, { highlightTurnRef: activeHighlightTurnRef });
880
+ if (!elements.dialog.open) {
881
+ elements.dialog.showModal();
882
+ renderNotifications();
883
+ }
731
884
  const load = async (attempt = 0) => {
732
885
  try {
733
886
  const response = await fetch(`/api/tasks/${encodeURIComponent(task.id)}`);
@@ -738,14 +891,34 @@ async function openDialog(task) {
738
891
  && state.selectedTask?.id === task.id
739
892
  && elements.dialog.open
740
893
  ) {
741
- renderDialog(detail.task);
894
+ const activeNavigation = state.detailNavigation?.taskId === task.id
895
+ ? state.detailNavigation
896
+ : null;
897
+ const highlightedTurn = renderDialog(detail.task, {
898
+ highlightTurnRef: activeNavigation?.highlightTurnRef ?? activeHighlightTurnRef,
899
+ });
900
+ if (activeNavigation?.focusPending) {
901
+ (highlightedTurn ?? elements.dialogTitle).focus?.();
902
+ activeNavigation.focusPending = false;
903
+ }
742
904
  if (usageStillCalculating(detail.task) && attempt < MAX_USAGE_POLL_ATTEMPTS) {
743
905
  setTimeout(() => load(attempt + 1), USAGE_POLL_INTERVAL_MS);
744
906
  }
745
907
  }
746
908
  } catch {
747
- if (state.selectedTask?.id === task.id) {
909
+ if (
910
+ requestGeneration === detailRequestGeneration
911
+ && state.selectedTask?.id === task.id
912
+ && elements.dialog.open
913
+ ) {
748
914
  showMessage("Task activity timeline is temporarily unavailable.");
915
+ const activeNavigation = state.detailNavigation?.taskId === task.id
916
+ ? state.detailNavigation
917
+ : null;
918
+ if (activeNavigation?.focusPending) {
919
+ elements.dialogTitle.focus?.();
920
+ activeNavigation.focusPending = false;
921
+ }
749
922
  }
750
923
  }
751
924
  };
@@ -869,7 +1042,7 @@ function applySnapshot(snapshot) {
869
1042
  );
870
1043
  if (state.selectedTask) {
871
1044
  const updated = state.tasks.find((task) => task.id === state.selectedTask.id);
872
- if (updated && elements.dialog.open) openDialog(updated);
1045
+ if (updated && elements.dialog.open) openDialog(updated, { preserveNavigation: true });
873
1046
  }
874
1047
  renderNotifications(reconciled.additions);
875
1048
  render();
@@ -924,6 +1097,8 @@ elements.dialog.addEventListener("cancel", (event) => {
924
1097
  });
925
1098
  elements.dialog.addEventListener("close", () => {
926
1099
  if (!manualTransitionPending()) state.manualTransition = null;
1100
+ state.detailNavigation = null;
1101
+ renderNotifications();
927
1102
  });
928
1103
  elements.moreTaskActions.addEventListener("click", () => {
929
1104
  const task = state.selectedTask;
@@ -87,14 +87,16 @@
87
87
  </section>
88
88
  </main>
89
89
 
90
- <aside id="notifications" class="notifications" aria-label="Task notifications" hidden>
91
- <div class="notifications-heading">
92
- <strong>Updates</strong>
93
- <button id="clear-notifications" class="text-button" type="button">Clear all</button>
94
- </div>
95
- <p id="notification-announcer" class="visually-hidden" role="status" aria-live="polite"></p>
96
- <div id="toast-list" class="toast-list"></div>
97
- </aside>
90
+ <div id="notification-host">
91
+ <aside id="notifications" class="notifications" aria-label="Task notifications" popover="manual">
92
+ <div class="notifications-heading">
93
+ <strong>Updates</strong>
94
+ <button id="clear-notifications" class="text-button" type="button">Clear all</button>
95
+ </div>
96
+ <p id="notification-announcer" class="visually-hidden" role="status" aria-live="polite"></p>
97
+ <div id="toast-list" class="toast-list"></div>
98
+ </aside>
99
+ </div>
98
100
 
99
101
  <dialog id="task-dialog" aria-labelledby="dialog-title">
100
102
  <div class="dialog-header">
@@ -134,7 +134,7 @@ time, .timestamp-missing { font-size: 0.75rem; }
134
134
  .dashboard-message span { flex: 1; }
135
135
  .dashboard-message .icon-button { flex: none; padding: 4px 7px; color: inherit; }
136
136
 
137
- .notifications { position: fixed; right: 20px; bottom: 20px; z-index: 3; width: min(380px, calc(100vw - 40px)); padding: 14px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); box-shadow: var(--shadow); }
137
+ .notifications { position: fixed; inset: auto 20px 20px auto; width: min(380px, calc(100vw - 40px)); margin: 0; padding: 14px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); color: var(--text); box-shadow: var(--shadow); }
138
138
  .notifications-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
139
139
  .toast-list { display: grid; gap: 7px; max-height: 50vh; overflow: auto; }
140
140
  .toast { display: flex; align-items: flex-start; gap: 8px; padding: 10px; border-radius: 7px; background: var(--surface-muted); }
@@ -170,6 +170,7 @@ dialog section + section { margin-top: 24px; }
170
170
  .result-history { display: grid; gap: 10px; }
171
171
  .result-history-item { padding: 13px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-muted); }
172
172
  .result-history-latest { border-color: var(--accent); background: var(--accent-soft); box-shadow: inset 3px 0 var(--accent); }
173
+ .result-history-notified { outline: 3px solid color-mix(in srgb, var(--accent) 35%, transparent); outline-offset: 2px; }
173
174
  .result-history-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 9px; }
174
175
  .result-history-header .timestamp-toggle { margin-left: auto; }
175
176
  .result-history-source { color: var(--muted); font-size: 0.72rem; font-weight: 700; }
@@ -181,10 +182,24 @@ dialog section + section { margin-top: 24px; }
181
182
  .usage-panel { margin-top: 8px; }
182
183
  .usage-summary { display: grid; gap: 3px; margin: 10px 0 4px; color: var(--muted); font-size: 0.8rem; }
183
184
  .usage-summary strong { color: var(--text); font-size: 0.9rem; }
184
- .usage-calculating { display: flex; align-items: center; gap: 7px; }
185
- .usage-spinner { width: 12px; height: 12px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: usage-spin 0.8s linear infinite; }
186
- @keyframes usage-spin { to { transform: rotate(360deg); } }
187
- @media (prefers-reduced-motion: reduce) { .usage-spinner { animation: none; border-top-color: var(--border); background: var(--accent); } }
185
+ .usage-pending, .usage-calculating { display: flex; align-items: center; }
186
+ .usage-shimmer {
187
+ color: var(--muted);
188
+ background: linear-gradient(100deg, var(--muted) 35%, var(--text) 50%, var(--muted) 65%);
189
+ background-size: 250% 100%;
190
+ background-position: 100% 0;
191
+ background-clip: text;
192
+ -webkit-background-clip: text;
193
+ -webkit-text-fill-color: transparent;
194
+ animation: usage-shimmer 2.8s ease-in-out infinite;
195
+ }
196
+ @keyframes usage-shimmer {
197
+ 0% { background-position: 100% 0; }
198
+ 72%, 100% { background-position: -100% 0; }
199
+ }
200
+ @media (prefers-reduced-motion: reduce) {
201
+ .usage-shimmer { background: none; animation: none; -webkit-text-fill-color: currentcolor; }
202
+ }
188
203
  pre { max-height: 280px; margin: 0; padding: 14px; overflow: auto; border-radius: 7px; background: var(--surface-muted); white-space: pre-wrap; overflow-wrap: anywhere; font: 0.86rem/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
189
204
  .metadata { display: grid; grid-template-columns: minmax(100px, 150px) 1fr; margin: 0; font-size: 0.88rem; }
190
205
  .metadata dt, .metadata dd { padding: 7px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }