@staix/agent-hub 0.12.44 → 0.12.45

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.
Files changed (137) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +3 -3
  3. package/THIRD_PARTY_NOTICES.md +29 -0
  4. package/docs/adding-an-ai-tool.md +102 -14
  5. package/docs/agent-notes/README.md +9 -0
  6. package/docs/agent-notes/adapters.md +58 -44
  7. package/docs/agent-notes/budget.md +1 -1
  8. package/docs/agent-notes/daemon.md +9 -7
  9. package/docs/agent-notes/local-worker.md +4 -3
  10. package/docs/agent-notes/tasks.md +8 -5
  11. package/docs/agent-notes/tests.md +7 -7
  12. package/docs/operations.md +99 -7
  13. package/docs/quickstart.md +5 -1
  14. package/docs/sdlc.md +26 -8
  15. package/docs/security.md +6 -3
  16. package/docs/specs/2026-09-19-agent-hub-design.md +114 -5
  17. package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
  18. package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
  19. package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
  20. package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
  21. package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
  22. package/docs/verification/2026-10-22-0.12.45.md +60 -0
  23. package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
  24. package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
  25. package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
  26. package/docs/verification/assets/469-r1-board-390-light.png +0 -0
  27. package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
  28. package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
  29. package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
  30. package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
  31. package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
  32. package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
  33. package/docs/verification/assets/469-r2-board-390-light.png +0 -0
  34. package/docs/verification/assets/469-r2-board-768-light.png +0 -0
  35. package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
  36. package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
  37. package/docs/verification/assets/469-r3-board-390-light.png +0 -0
  38. package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
  39. package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
  40. package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
  41. package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
  42. package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
  43. package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
  44. package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
  45. package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
  46. package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
  47. package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
  48. package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
  49. package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
  50. package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
  51. package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
  52. package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
  53. package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
  54. package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
  55. package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
  56. package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
  57. package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
  58. package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
  59. package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
  60. package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
  61. package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
  62. package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
  63. package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
  64. package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
  65. package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
  66. package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
  67. package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
  68. package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
  69. package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
  70. package/docs/verified.json +19 -16
  71. package/package.json +5 -1
  72. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  73. package/plugins/agent-hub/server.js +9818 -9605
  74. package/src/adapters/adapter-api.ts +2 -0
  75. package/src/adapters/agy/definition.ts +3 -1
  76. package/src/adapters/claude-channel.ts +14 -5
  77. package/src/adapters/codex-appserver.ts +1 -0
  78. package/src/adapters/conformance/acp.ts +114 -0
  79. package/src/adapters/conformance/codex.ts +191 -0
  80. package/src/adapters/conformance/enrollment.ts +43 -0
  81. package/src/adapters/conformance/factories.ts +17 -0
  82. package/src/adapters/conformance/fake-paths.ts +12 -0
  83. package/src/adapters/conformance/fakes/acp-server.ts +267 -0
  84. package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
  85. package/src/adapters/conformance/fakes/app-server.ts +119 -0
  86. package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
  87. package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
  88. package/src/adapters/conformance/fakes/model-server.ts +39 -0
  89. package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
  90. package/src/adapters/conformance/index.ts +11 -0
  91. package/src/adapters/conformance/kit.ts +50 -0
  92. package/src/adapters/conformance/local-pi.ts +100 -0
  93. package/src/adapters/conformance/owned-acp.ts +44 -0
  94. package/src/adapters/definitions/builtin-metadata.ts +23 -6
  95. package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
  96. package/src/adapters/definitions/native-cli-entry.ts +7 -19
  97. package/src/adapters/definitions/native-cli-preview.ts +2 -11
  98. package/src/adapters/definitions/qwen.ts +4 -1
  99. package/src/adapters/external-metadata.ts +48 -0
  100. package/src/adapters/external-module-scan.ts +126 -0
  101. package/src/adapters/external-modules.ts +240 -0
  102. package/src/adapters/pi.ts +2 -0
  103. package/src/adapters/public.ts +7 -0
  104. package/src/adapters/registry-types.ts +10 -0
  105. package/src/adapters/registry.ts +51 -15
  106. package/src/cli/help.ts +21 -16
  107. package/src/cli/identity.ts +1 -1
  108. package/src/cli/main.ts +23 -5
  109. package/src/cli/upgrade-labels.ts +1 -1
  110. package/src/cli/upgrade-presentation.ts +1 -1
  111. package/src/cli/upgrade-restore-state.ts +3 -2
  112. package/src/cli/upgrade-runtime.ts +4 -1
  113. package/src/hub/board.ts +30 -4
  114. package/src/hub/config-apply.ts +18 -10
  115. package/src/hub/config-reload.ts +8 -5
  116. package/src/hub/config-trust.ts +1 -1
  117. package/src/hub/control-client.ts +4 -1
  118. package/src/hub/daemon.ts +95 -39
  119. package/src/hub/facts.ts +76 -13
  120. package/src/hub/hub-tools.ts +21 -5
  121. package/src/hub/instance-config.ts +17 -5
  122. package/src/hub/instance-execution.ts +20 -8
  123. package/src/hub/instances.ts +2 -1
  124. package/src/hub/peers.ts +9 -0
  125. package/src/hub/plan-conformance.ts +83 -0
  126. package/src/hub/review-findings.ts +104 -0
  127. package/src/hub/roster.ts +31 -5
  128. package/src/hub/sdlc-queue.ts +19 -4
  129. package/src/hub/sdlc-release.ts +16 -1
  130. package/src/hub/sdlc.ts +27 -12
  131. package/src/hub/settings.ts +5 -1
  132. package/src/hub/snapshots.ts +8 -6
  133. package/src/hub/tasks.ts +275 -45
  134. package/src/hub/ui.ts +2 -2
  135. package/src/ui/index.html +559 -78
  136. package/src/ui/office-model.ts +3 -1
  137. package/src/ui/peer-palette.ts +44 -0
package/src/ui/index.html CHANGED
@@ -18,11 +18,225 @@ const dashboardTheme = (() => {
18
18
  })();
19
19
  </script>
20
20
  <style>
21
- :root{color-scheme:light;--waiting:#795399;--paper:#f5f4ef;--ink:#252b29;--muted:#68716c;--line:#d9ddd5;--panel:#ffffff;--panel-soft:#f4f6f2;--control:#ffffff;--control-border:#bbc4bb;--hover:#f0f4ef;--green:#245f45;--green-soft:#e4efe9;--amber:#8a5b12;--amber-soft:#f8eed6;--blue:#245a93;--blue-soft:#e6eef8;--red:#a2392f;--red-soft:#f6e5e2;--dot:#98a29b;--dot-ok:#2f7a52;--dot-warn:#b07a16;--dot-err:#b24539;--focus:#245f45;--on-accent:#ffffff}:root[data-theme="dark"]{color-scheme:dark;--waiting:#b9a0df;--paper:#161a18;--ink:#e5e8e3;--muted:#97a099;--line:#2c342f;--panel:#1d2320;--panel-soft:#17201b;--control:#1d2320;--control-border:#3a443d;--hover:#263029;--green:#6fc2a0;--green-soft:#1e3a2e;--amber:#d9a64f;--amber-soft:#3a2f14;--blue:#8ab8ea;--blue-soft:#1d2f44;--red:#e28b80;--red-soft:#3d2320;--dot:#5b6660;--dot-ok:#4cb77f;--dot-warn:#d9a64f;--dot-err:#e28b80;--focus:#6fc2a0;--on-accent:#161a18}@media(prefers-color-scheme:dark){:root:not([data-theme="light"]):not([data-theme="dark"]){color-scheme:dark;--waiting:#b9a0df;--paper:#161a18;--ink:#e5e8e3;--muted:#97a099;--line:#2c342f;--panel:#1d2320;--panel-soft:#17201b;--control:#1d2320;--control-border:#3a443d;--hover:#263029;--green:#6fc2a0;--green-soft:#1e3a2e;--amber:#d9a64f;--amber-soft:#3a2f14;--blue:#8ab8ea;--blue-soft:#1d2f44;--red:#e28b80;--red-soft:#3d2320;--dot:#5b6660;--dot-ok:#4cb77f;--dot-warn:#d9a64f;--dot-err:#e28b80;--focus:#6fc2a0;--on-accent:#161a18}}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.5 system-ui,sans-serif}header,main{max-width:1440px;margin:auto;padding:24px 32px}header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);gap:20px}h1{font-size:24px;letter-spacing:-1px;margin:0}h1 span{font-weight:400;color:var(--muted)}h2{font-size:15px;margin:0}p{margin:6px 0}.eyebrow{overflow-wrap:anywhere;font-size:12px;color:var(--muted)}.eyebrow{letter-spacing:1.5px;text-transform:uppercase}.meta{overflow-wrap:anywhere;font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}.connection{font-size:12px;color:var(--muted)}main{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:24px;align-items:start}.stack{display:flex;flex-direction:column;gap:24px;min-width:0}section{border:1px solid var(--line);background:var(--panel);border-radius:8px;overflow:hidden}.section-head{display:flex;justify-content:space-between;align-items:center;padding:14px 18px;border-bottom:1px solid var(--line);gap:12px}.section-head .filters{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.content{padding:16px 18px}.empty{color:var(--muted);padding:20px 18px}.row{padding:14px 18px;border-bottom:1px solid var(--line)}.row:last-child{border-bottom:0}.row-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.peername{display:flex;align-items:center;gap:8px;min-width:0}.label{font-weight:600;overflow-wrap:anywhere}.badge{display:inline-block;font:11px ui-monospace,monospace;padding:2px 7px;background:var(--panel-soft);color:var(--muted);border-radius:4px;white-space:nowrap}.badge.ok{background:var(--green-soft);color:var(--green)}.badge.warn{color:var(--amber);background:var(--amber-soft)}.badge.info{color:var(--blue);background:var(--blue-soft)}.badge.err{color:var(--red);background:var(--red-soft)}.dot{flex:none;width:9px;height:9px;border-radius:50%;background:var(--dot)}.dot.ok{background:var(--dot-ok)}.dot.warn{background:var(--dot-warn)}.dot.err{background:var(--dot-err)}.chips{display:flex;gap:6px;flex-wrap:wrap}.chip{background:var(--control);color:var(--muted);border:1px solid var(--control-border);border-radius:999px;padding:4px 10px;font-size:12px;cursor:pointer;line-height:1.2}.chip:hover{background:var(--hover)}.chip[aria-pressed="true"]{background:var(--green);color:var(--on-accent);border-color:var(--green);font-weight:600}.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:10px}button,input,select,textarea{font:inherit}button{background:var(--control);color:var(--ink);border:1px solid var(--control-border);border-radius:5px;padding:6px 10px;cursor:pointer}button:hover{background:var(--hover)}button.primary{background:var(--green);color:var(--on-accent);border-color:var(--green)}button.danger{color:var(--red)}button:disabled{opacity:.5;cursor:default}:focus-visible{outline:2px solid var(--focus);outline-offset:3px}input,select,textarea{width:100%;border:1px solid var(--control-border);border-radius:5px;padding:8px;background:var(--control);color:var(--ink)}input[type="checkbox"]{width:auto;margin-right:6px}textarea{resize:vertical;min-height:78px}label{display:block;font-size:12px;color:var(--muted);margin-bottom:5px}form .field{margin-bottom:12px}.actions select{width:auto;max-width:220px}.section-head .filters select{width:auto;max-width:220px}.body{white-space:pre-wrap;overflow-wrap:anywhere;margin-top:7px}.stream{height:360px;overflow:auto}.event{padding:12px 18px;border-bottom:1px solid var(--line)}.event[hidden]{display:none}.event .body{font:13px/1.55 ui-monospace,monospace}.notice{grid-column:1/-1;margin:0;border:1px solid var(--line);border-radius:6px;padding:10px 14px;background:var(--panel);overflow-wrap:anywhere}.notice.error{color:var(--red);border-color:var(--red)}.notice:empty{display:none}progress{width:100%;height:8px;accent-color:var(--green);display:block;margin:8px 0}progress.warn{accent-color:var(--amber)}progress.warn::-webkit-progress-value{background:var(--amber)}progress.critical{accent-color:var(--red)}progress.critical::-webkit-progress-value{background:var(--red)}progress::-moz-progress-bar{background:var(--green)}progress.warn::-moz-progress-bar{background:var(--amber)}progress.critical::-moz-progress-bar{background:var(--red)}.budget-window+.budget-window{margin-top:14px}.split{display:grid;grid-template-columns:1fr 1fr;gap:12px}details summary{cursor:pointer;padding:14px 18px;color:var(--green);font-weight:600}details form{padding:0 18px 18px}.task-list{max-height:460px;overflow:auto}footer{max-width:1440px;margin:auto;padding:0 32px 24px;color:var(--muted);font-size:12px}@media(max-width:900px){main{grid-template-columns:1fr}.stack{gap:18px}header,main{padding:18px}.stream{height:300px}footer{padding:0 18px 18px}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto}}
22
- .task-progress{padding:12px 18px;border-bottom:1px solid var(--line)}.task-progress svg{display:block;width:100%;height:12px;margin:8px 0}.progress-proposed{fill:var(--dot)}.progress-unknown{fill:var(--dot)}.progress-cancelled{fill:var(--muted)}.progress-waiting{fill:var(--waiting)}.progress-in_progress{fill:var(--blue)}.progress-in_review{fill:var(--amber)}.progress-changes_requested{fill:var(--red)}.progress-approved{fill:var(--green)}.task-stage{font:12px ui-monospace,monospace;margin:8px 0;color:var(--muted)}.task-stage .reached{color:var(--green)}.task-stage.changes{color:var(--red)}.task-stage.cancelled{color:var(--muted)}.theme-choice{width:auto}.actions input[type="text"]{width:auto;flex:1 1 160px;max-width:320px}details.settings-group>summary{padding:12px 18px;cursor:pointer;border-bottom:1px solid var(--line);font-weight:600}details.settings-group:last-child:not([open])>summary{border-bottom:0}.preview{margin-top:8px;padding:8px 10px;background:var(--panel-soft);border-radius:5px;font:12px ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.preview:empty{display:none}.memory-controls{display:flex;gap:8px;flex-wrap:wrap;padding:12px 18px;border-bottom:1px solid var(--line)}.memory-controls input,.memory-controls select{width:auto;flex:1 1 130px}.memory-row{display:block;width:100%;text-align:left;border:0;border-bottom:1px solid var(--line);border-radius:0;padding:10px 18px;background:var(--panel)}.memory-row[aria-pressed="true"]{background:var(--green-soft)}.memory-graph svg{display:block;width:100%;height:320px;background:var(--panel-soft)}.medge{stroke:var(--dot);stroke-width:1.5}.medge.rules_out{stroke:var(--red)}.medge.supersedes{stroke-dasharray:4 3}.medge.suggested{stroke-dasharray:2 4;opacity:.75}.medge.contradicts{stroke:var(--amber)}.mnode{cursor:pointer}.mnode circle{fill:var(--dot);stroke:var(--panel);stroke-width:2}.mnode.memory circle{fill:var(--blue)}.mnode.fail circle{fill:var(--red)}.mnode.decision circle{fill:var(--green)}.mnode.contract circle{fill:var(--amber)}.mnode.selected circle{stroke:var(--focus);stroke-width:3}.mnode:focus-visible circle{stroke:var(--focus);stroke-width:4}.mnode text{font:11px system-ui,sans-serif;fill:var(--ink)}.memory-edges{margin:0;padding:8px 18px 12px 34px;font:12px ui-monospace,monospace;color:var(--muted);max-height:160px;overflow:auto}
21
+ /* Design tokens (#469 R1). Every colour lives in these three blocks and nowhere else. The dark values appear twice because
22
+ the theme can be chosen (data-theme) or follow the system; the two blocks must stay identical (a test compares them).
23
+ Neutrals are one cool family. The accent is for primary actions, focus and selection. Green, amber, blue and red keep
24
+ their state meaning. Spacing is on a 4px base. Radius rule: controls 6px, surfaces (panels, cards, dialogs) 10px,
25
+ chips and avatars full; a flush list row keeps 0. Two elevations, used only on raised elements (--shadow-1 is for the board cards in R3). The Office scene rules below
26
+ are unchanged until R5; keep a comment out from directly before them, because a test finds those rules by their selector text. */
27
+ :root{color-scheme:light;
28
+ --paper:#eeeef1;--panel:#f8f8fa;--raised:#fcfcfd;--panel-soft:#f1f1f4;--control:#fcfcfd;--hover:#ececf0;
29
+ --ink:#1f2024;--muted:#5a5c66;--faint:#6a6c76;--line:#dcdce2;--control-border:#c4c4cc;
30
+ --accent:#4338ca;--accent-soft:#e6e7fb;--on-accent:#fafafc;--on-peer:#121316;--focus:#4338ca;
31
+ --green:#245f45;--green-soft:#e4efe9;--amber:#8a5b12;--amber-soft:#f8eed6;--blue:#245a93;--blue-soft:#e6eef8;--red:#a2392f;--red-soft:#f6e5e2;--waiting:#795399;
32
+ --dot:#80828c;--dot-ok:#2f7a52;--dot-warn:#b07a16;--dot-err:#b24539;
33
+ --scrim:rgba(18,19,22,.4);--shadow-1:0 1px 2px rgba(31,32,36,.08),0 2px 8px rgba(31,32,36,.06);--shadow-2:0 8px 24px rgba(31,32,36,.16),0 2px 6px rgba(31,32,36,.1);
34
+ --space-1:4px;--space-2:8px;--space-3:12px;--space-4:16px;--space-6:24px;--space-8:32px;
35
+ --text-xs:12px;--text-sm:13px;--text-base:14px;--text-lg:16px;--text-xl:20px;
36
+ --font-sans:ui-sans-serif,-apple-system,"Segoe UI Variable",system-ui,sans-serif;--font-mono:ui-monospace,"SF Mono","Cascadia Code",monospace;
37
+ --radius-control:6px;--radius-surface:10px;--radius-full:999px}
38
+ :root[data-theme="dark"]{color-scheme:dark;
39
+ --paper:#121316;--panel:#1a1b1f;--raised:#222328;--panel-soft:#16171a;--control:#1a1b1f;--hover:#25262c;
40
+ --ink:#e7e7ea;--muted:#a3a5ae;--faint:#8d8f99;--line:#2a2b31;--control-border:#3a3b43;
41
+ --accent:#a5b4fc;--accent-soft:#25283f;--on-accent:#121316;--on-peer:#121316;--focus:#a5b4fc;
42
+ --green:#6fc2a0;--green-soft:#1e3a2e;--amber:#d9a64f;--amber-soft:#3a2f14;--blue:#8ab8ea;--blue-soft:#1d2f44;--red:#e28b80;--red-soft:#3d2320;--waiting:#b9a0df;
43
+ --dot:#767985;--dot-ok:#4cb77f;--dot-warn:#d9a64f;--dot-err:#e28b80;
44
+ --scrim:rgba(0,0,0,.55);--shadow-1:0 1px 2px rgba(0,0,0,.4),0 2px 8px rgba(0,0,0,.3);--shadow-2:0 8px 24px rgba(0,0,0,.55),0 2px 6px rgba(0,0,0,.4)}
45
+ @media(prefers-color-scheme:dark){:root:not([data-theme="light"]):not([data-theme="dark"]){color-scheme:dark;
46
+ --paper:#121316;--panel:#1a1b1f;--raised:#222328;--panel-soft:#16171a;--control:#1a1b1f;--hover:#25262c;
47
+ --ink:#e7e7ea;--muted:#a3a5ae;--faint:#8d8f99;--line:#2a2b31;--control-border:#3a3b43;
48
+ --accent:#a5b4fc;--accent-soft:#25283f;--on-accent:#121316;--on-peer:#121316;--focus:#a5b4fc;
49
+ --green:#6fc2a0;--green-soft:#1e3a2e;--amber:#d9a64f;--amber-soft:#3a2f14;--blue:#8ab8ea;--blue-soft:#1d2f44;--red:#e28b80;--red-soft:#3d2320;--waiting:#b9a0df;
50
+ --dot:#767985;--dot-ok:#4cb77f;--dot-warn:#d9a64f;--dot-err:#e28b80;
51
+ --scrim:rgba(0,0,0,.55);--shadow-1:0 1px 2px rgba(0,0,0,.4),0 2px 8px rgba(0,0,0,.3);--shadow-2:0 8px 24px rgba(0,0,0,.55),0 2px 6px rgba(0,0,0,.4)}}
52
+
53
+ /* Base */
54
+ *{box-sizing:border-box}
55
+ body{margin:0;background:var(--paper);color:var(--ink);font:var(--text-base)/1.5 var(--font-sans)}
56
+ h1{font-size:var(--text-xl);letter-spacing:-.02em;margin:0}
57
+ h1 span{font-weight:400;color:var(--muted)}
58
+ h2{font-size:var(--text-lg);margin:0}
59
+ p{margin:var(--space-2) 0}
60
+ .eyebrow{overflow-wrap:anywhere;font-size:var(--text-xs);color:var(--faint);letter-spacing:.12em;text-transform:uppercase}
61
+ .meta{overflow-wrap:anywhere;font-size:var(--text-xs);color:var(--muted);font-variant-numeric:tabular-nums}
62
+ .connection{font-size:var(--text-xs);color:var(--muted)}
63
+ .stack{display:flex;flex-direction:column;gap:var(--space-6);min-width:0}
64
+ footer{max-width:1440px;margin:auto;padding:var(--space-4) var(--space-8) var(--space-6);color:var(--faint);font-size:var(--text-xs)}
65
+
66
+ /* Surfaces and rows */
67
+ section{border:1px solid var(--line);background:var(--panel);border-radius:var(--radius-surface);overflow:hidden}
68
+ .section-head{display:flex;justify-content:space-between;align-items:center;padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--line);gap:var(--space-3)}
69
+ .section-head .filters{display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap}
70
+ .content{padding:var(--space-4)}
71
+ .empty{color:var(--muted);padding:var(--space-4)}
72
+ .row{padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--line)}
73
+ .row:last-child{border-bottom:0}
74
+ .row-head{display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)}
75
+ .peername{display:flex;align-items:center;gap:var(--space-2);min-width:0}
76
+ .label{font-weight:600;overflow-wrap:anywhere}
77
+ .body{white-space:pre-wrap;overflow-wrap:anywhere;margin-top:var(--space-2)}
78
+ .split{display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)}
79
+ .stream{height:360px;overflow:auto}
80
+ .event{padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--line)}
81
+ .event .body{font:var(--text-sm)/1.55 var(--font-mono)}
82
+
83
+ /* Badges, dots, chips */
84
+ .badge{display:inline-block;font:var(--text-xs) var(--font-mono);font-variant-numeric:tabular-nums;padding:2px var(--space-2);background:var(--panel-soft);color:var(--muted);border-radius:var(--radius-full);white-space:nowrap}
85
+ .badge.ok{background:var(--green-soft);color:var(--green)}
86
+ .badge.warn{color:var(--amber);background:var(--amber-soft)}
87
+ .badge.info{color:var(--blue);background:var(--blue-soft)}
88
+ .badge.err{color:var(--red);background:var(--red-soft)}
89
+ .dot{flex:none;width:9px;height:9px;border-radius:var(--radius-full);background:var(--dot)}
90
+ .dot.ok{background:var(--dot-ok)}
91
+ .dot.warn{background:var(--dot-warn)}
92
+ .dot.err{background:var(--dot-err)}
93
+ .chips{display:flex;gap:var(--space-2);flex-wrap:wrap}
94
+ .chip{background:var(--control);color:var(--muted);border:1px solid var(--control-border);border-radius:var(--radius-full);padding:var(--space-1) var(--space-3);font-size:var(--text-xs);font-variant-numeric:tabular-nums;cursor:pointer;line-height:1.2}
95
+ .chip:hover{background:var(--hover)}
96
+ .chip[aria-pressed="true"]{background:var(--accent);color:var(--on-accent);border-color:var(--accent);font-weight:600}
97
+
98
+ /* Controls */
99
+ button,input,select,textarea{font:inherit}
100
+ button{background:var(--control);color:var(--ink);border:1px solid var(--control-border);border-radius:var(--radius-control);padding:var(--space-2) var(--space-3);cursor:pointer}
101
+ button:hover{background:var(--hover)}
102
+ button.primary{background:var(--accent);color:var(--on-accent);border-color:var(--accent)}
103
+ button.danger{color:var(--red)}
104
+ button:disabled{opacity:.5;cursor:default}
105
+ :focus-visible{outline:2px solid var(--focus);outline-offset:3px}
106
+ input,select,textarea{width:100%;border:1px solid var(--control-border);border-radius:var(--radius-control);padding:var(--space-2);background:var(--control);color:var(--ink)}
107
+ input[type="checkbox"]{width:auto;margin-right:var(--space-2)}
108
+ textarea{resize:vertical;min-height:80px}
109
+ label{display:block;font-size:var(--text-xs);color:var(--muted);margin-bottom:var(--space-1)}
110
+ form .field{margin-bottom:var(--space-3)}
111
+ .actions{display:flex;gap:var(--space-2);align-items:center;flex-wrap:wrap;margin-top:var(--space-3)}
112
+ .actions select{width:auto;max-width:220px}
113
+ .actions input[type="text"]{width:auto;flex:1 1 160px;max-width:320px}
114
+ .section-head .filters select{width:auto;max-width:220px}
115
+ .theme-choice{width:auto}
116
+ details summary{cursor:pointer;padding:var(--space-3) var(--space-4);color:var(--accent);font-weight:600}
117
+ details form{padding:0 var(--space-4) var(--space-4)}
118
+ details.settings-group>summary{padding:var(--space-3) var(--space-4);cursor:pointer;border-bottom:1px solid var(--line);font-weight:600}
119
+ details.settings-group:last-child:not([open])>summary{border-bottom:0}
120
+ .preview{margin-top:var(--space-2);padding:var(--space-2) var(--space-3);background:var(--panel-soft);border-radius:var(--radius-control);font:var(--text-xs) var(--font-mono);white-space:pre-wrap;overflow-wrap:anywhere}
121
+ .preview:empty{display:none}
122
+
123
+ /* Budgets */
124
+ progress{width:100%;height:8px;accent-color:var(--green);display:block;margin:var(--space-2) 0}
125
+ progress.warn{accent-color:var(--amber)}
126
+ progress.warn::-webkit-progress-value{background:var(--amber)}
127
+ progress.critical{accent-color:var(--red)}
128
+ progress.critical::-webkit-progress-value{background:var(--red)}
129
+ progress::-moz-progress-bar{background:var(--green)}
130
+ progress.warn::-moz-progress-bar{background:var(--amber)}
131
+ progress.critical::-moz-progress-bar{background:var(--red)}
132
+ .budget-window+.budget-window{margin-top:var(--space-4)}
133
+
134
+ /* Task progress strip: the state colours keep their meaning */
135
+ .task-progress{padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--line)}
136
+ .task-progress svg{display:block;width:100%;height:12px;margin:var(--space-2) 0}
137
+ .progress-proposed{fill:var(--dot)}
138
+ .progress-unknown{fill:var(--dot)}
139
+ .progress-backlog{fill:var(--dot)}
140
+ .progress-cancelled{fill:var(--muted)}
141
+ .progress-waiting{fill:var(--waiting)}
142
+ .progress-in_progress{fill:var(--blue)}
143
+ .progress-in_review{fill:var(--amber)}
144
+ .progress-changes_requested{fill:var(--red)}
145
+ .progress-approved{fill:var(--green)}
146
+ .task-stage{font:var(--text-xs) var(--font-mono);margin:var(--space-2) 0;color:var(--muted)}
147
+ .task-stage .reached{color:var(--green)}
148
+ .task-stage.changes{color:var(--red)}
149
+ .task-stage.cancelled{color:var(--muted)}
150
+
151
+ /* Memory */
152
+ .memory-controls{display:flex;gap:var(--space-2);flex-wrap:wrap;padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--line)}
153
+ .memory-controls input,.memory-controls select{width:auto;flex:1 1 130px}
154
+ .memory-row{display:block;width:100%;text-align:left;border:0;border-bottom:1px solid var(--line);border-radius:0;padding:var(--space-3) var(--space-4);background:var(--panel)}
155
+ .memory-row[aria-pressed="true"]{background:var(--accent-soft);box-shadow:inset 3px 0 0 var(--accent)}
156
+ .memory-row:hover{background:var(--hover)}
157
+ .memory-row[aria-pressed="true"]:hover{background:var(--accent-soft)}
158
+ .memory-row .label{font-weight:500}
159
+ .memory-row .meta{margin-top:var(--space-1)}
160
+ .memory-row:last-child{border-bottom:0}
161
+ #memory-list{max-height:420px;overflow:auto}
162
+ #memory-detail:not(:empty){border-top:1px solid var(--line)}
163
+ #memory-detail .body{font-size:var(--text-sm);line-height:1.55}
164
+ .memory-graph svg{display:block;width:100%;height:320px;background:var(--panel-soft)}
165
+ .medge{stroke:var(--dot);stroke-width:1.5}
166
+ .medge.rules_out{stroke:var(--red)}
167
+ .medge.supersedes{stroke-dasharray:4 3}
168
+ .medge.suggested{stroke-dasharray:2 4;opacity:.75}
169
+ .medge.contradicts{stroke:var(--amber)}
170
+ .mnode{cursor:pointer}
171
+ .mnode circle{fill:var(--dot);stroke:var(--panel);stroke-width:2}
172
+ .mnode.memory circle{fill:var(--blue)}
173
+ .mnode.fail circle{fill:var(--red)}
174
+ .mnode.decision circle{fill:var(--green)}
175
+ .mnode.contract circle{fill:var(--amber)}
176
+ .mnode.selected circle{stroke:var(--focus);stroke-width:3}
177
+ .mnode:focus-visible circle{stroke:var(--focus);stroke-width:4}
178
+ .mnode text{font:var(--text-xs) var(--font-sans);fill:var(--ink)}
179
+ .memory-edges{margin:0;padding:var(--space-2) var(--space-4) var(--space-3) var(--space-8);font:var(--text-xs) var(--font-mono);color:var(--muted);max-height:160px;overflow:auto}
180
+ /* Message (#469 R4): the shared form styles (label above field, .field spacing); only the width is capped so a line of text stays readable. */
181
+ #send{max-width:640px}
182
+ #send textarea{min-height:140px}
183
+
184
+ /* Agents (#469 R4): a summary line per peer, the diagnostics folded into one disclosure, approvals first (their section is first in the DOM). */
185
+ .peer-summary{display:flex;flex-wrap:wrap;gap:var(--space-1) var(--space-4);margin:var(--space-2) 0 0}
186
+ .peer-fold{margin-top:var(--space-2)}
187
+ .peer-fold>summary{padding:var(--space-1) 0;color:var(--muted);font-weight:500;font-size:var(--text-xs)}
188
+ .peer-more{padding:var(--space-1) 0 0 var(--space-3);border-left:2px solid var(--line)}
189
+ .notice-warn{color:var(--amber)}
190
+ /* Stream: aligned rows. The filter chips and Follow latest sit in the section head, outside the scrolling list, so they stay in view while it scrolls. */
191
+ .stream{padding:0}
192
+ .event{display:grid;grid-template-columns:minmax(0,1fr);gap:var(--space-1);align-items:baseline}
193
+ .event>.meta{display:flex;flex-wrap:wrap;align-items:center;gap:0 var(--space-1);color:var(--muted)}
194
+ .event .icon{width:14px;height:14px}
195
+ .event:nth-child(even){background:var(--panel-soft)}
196
+ /* Settings: a group is a titled section; a restart-pending value carries a badge, not only a line of text. */
197
+ .settings-reload{padding:var(--space-3) var(--space-4)}
198
+ .settings-reload:empty{display:none}
199
+ .settings-reload .meta{margin-top:var(--space-1)}
200
+ details.settings-group{border-bottom:1px solid var(--line)}
201
+ details.settings-group>summary{background:var(--panel-soft)}
202
+ details.settings-group .row{border-bottom:1px dashed var(--line)}
203
+ details.settings-group .row:last-child{border-bottom:0}
204
+ /* Peer colours: the registry's hue, one variant per theme, generated by peer-palette.ts */
23
205
  /* PEER_PALETTE_CSS */
24
- .task-board-section{grid-column:1/-1}.board-columns{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;padding:18px}.board-column{min-width:0;background:var(--panel-soft);border:1px solid var(--line);border-radius:6px;padding:10px}.board-column h3{font-size:14px;margin:0 0 12px}.board-column h4{font-size:12px;color:var(--muted);margin:12px 0 8px}.task-card{background:var(--panel);border:1px solid var(--line);border-radius:5px;padding:12px;margin:0 0 10px;overflow-wrap:anywhere}.task-card .row-head{align-items:flex-start}.task-card .label{font-size:14px}.task-card .actions{display:grid;gap:8px}.task-card .actions select{max-width:none;width:100%}.task-card button,.task-card select,.agent-legend button{min-height:44px}.task-card .body{font-size:13px}.task-card .task-stage{font-size:12px}.board-columns .empty{padding:8px 0}.board-lane{border-top:1px solid var(--line)}.board-lane>h3{padding:14px 18px 0;font-size:15px}.peer-mark{display:inline-flex;align-items:center;gap:6px}.peer-color-dot{width:9px;height:9px;display:inline-block;border-radius:50%;background:var(--peer-color);flex-shrink:0}.peer-color-dot.reviewer{background:transparent;border:2px solid var(--peer-color)}.agent-legend{display:flex;flex-wrap:wrap;gap:8px;padding:14px 18px;border-bottom:1px solid var(--line)}.agent-legend .agent-slot{border:1px solid var(--line);border-radius:5px;padding:10px;flex:1 1 180px;min-width:0}.agent-legend .meta{overflow-wrap:anywhere}.board-drop-active{outline:2px solid var(--green);outline-offset:2px}.board-toolbar{padding:12px 18px;display:flex;gap:14px;flex-wrap:wrap;align-items:center}.board-toolbar label{margin:0}.board-toolbar select{width:auto;max-width:220px}.board-unknown{padding:12px 18px;color:var(--amber)}.task-list{max-height:none;overflow:visible}.progress-backlog{fill:var(--dot)}
25
- @media(max-width:1100px){.board-columns{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:600px){.split{grid-template-columns:minmax(0,1fr)}.board-columns{grid-template-columns:1fr;padding:12px}.board-toolbar{align-items:stretch}.board-toolbar select{max-width:none;width:100%}.board-toolbar label{width:100%}}
206
+
207
+ /* Task board */
208
+ .task-board-section{min-width:0}
209
+ .board-columns{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:var(--space-3);padding:var(--space-4)}
210
+ .board-column{min-width:0;background:var(--panel-soft);border:1px solid var(--line);border-radius:var(--radius-surface);padding:var(--space-3)}
211
+ .board-column h3{font-size:var(--text-base);margin:0 0 var(--space-3)}
212
+ .board-column h4{font-size:var(--text-xs);color:var(--muted);margin:var(--space-3) 0 var(--space-2)}
213
+ .task-card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius-surface);padding:var(--space-3);margin:0 0 var(--space-3);overflow-wrap:anywhere}
214
+ .task-card .row-head{align-items:flex-start}
215
+ .task-card .label{font-size:var(--text-base)}
216
+ .task-card .actions{display:grid;gap:var(--space-2)}
217
+ .task-card .actions select{max-width:none;width:100%}
218
+ .task-card button,.task-card select,.agent-legend button{min-height:44px}
219
+ .task-card .body{font-size:var(--text-sm)}
220
+ .task-card .task-stage{font-size:var(--text-xs)}
221
+ .board-columns .empty{padding:var(--space-2) 0}
222
+ .board-lane{border-top:1px solid var(--line)}
223
+ .board-lane>h3{padding:var(--space-3) var(--space-4) 0;font-size:var(--text-lg)}
224
+ .peer-mark{display:inline-flex;align-items:center;gap:var(--space-2)}
225
+ .peer-color-dot{width:9px;height:9px;display:inline-block;border-radius:var(--radius-full);background:var(--peer-color);flex-shrink:0}
226
+ .peer-color-dot.reviewer{background:transparent;border:2px solid var(--peer-color)}
227
+ .agent-legend{display:flex;flex-wrap:wrap;gap:var(--space-2);padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--line)}
228
+ .agent-legend .agent-slot{border:1px solid var(--line);border-radius:var(--radius-surface);padding:var(--space-3);flex:1 1 180px;min-width:0}
229
+ .board-drop-active{outline:2px solid var(--accent);outline-offset:2px}
230
+ .board-toolbar{padding:var(--space-3) var(--space-4);display:flex;gap:var(--space-4);flex-wrap:wrap;align-items:center}
231
+ .board-toolbar label{margin:0}
232
+ .board-toolbar select{width:auto;max-width:220px}
233
+ .board-unknown{padding:var(--space-3) var(--space-4);color:var(--amber)}
234
+ @media(max-width:1100px){.board-columns{grid-template-columns:repeat(2,minmax(0,1fr))}}
235
+ @media(max-width:900px){.stack{gap:var(--space-4)}.stream{height:300px}footer{padding:var(--space-4)}}
236
+ @media(max-width:600px){.split{grid-template-columns:minmax(0,1fr)}.board-columns{grid-template-columns:1fr;padding:var(--space-3)}.board-toolbar{align-items:stretch}.board-toolbar select{max-width:none;width:100%}.board-toolbar label{width:100%}}
237
+ /* Reduced motion (#469 R6): one rule for the whole page. Every transition and animation is off, whatever rule declares it. */
238
+ @media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important;scroll-behavior:auto!important}}
239
+
26
240
  .office-scene{display:block;max-width:100%;height:auto;margin-inline:auto;font-family:system-ui,sans-serif;font-size:1px}.office-scene :is(rect,path,line,polygon,circle){vector-effect:non-scaling-stroke;stroke-width:1px}.office-room{fill:var(--panel-soft);stroke:var(--line)}.office-room-title{fill:var(--ink);font-size:1.083333px;font-weight:600}.office-room-count{fill:var(--ink);font-size:1px;font-family:ui-monospace,monospace;font-variant-numeric:tabular-nums}.office-header-surface{fill:var(--panel);stroke:var(--line)}.office-count-pill{fill:var(--panel);stroke:var(--line)}.office-desk-surface{fill:var(--panel);stroke:var(--line)}.office-desk.office-dimmed .office-avatar{opacity:.55}.office-name{fill:var(--ink);font-size:1.083333px;font-weight:600}.office-state,.office-meta,.office-empty{fill:var(--muted);font-size:1px}.office-glyph,.office-hand{font-size:1px}.office-idle .office-glyph{fill:var(--green)}.office-busy .office-glyph{fill:var(--amber)}.office-offline .office-glyph,.office-unknown .office-glyph{fill:var(--muted)}.office-paused .office-glyph{fill:var(--amber)}.office-waiting .office-glyph{fill:var(--red)}.office-hand{fill:var(--red)}.office-bubble{fill:var(--panel);stroke:var(--blue)}.office-bubble-text{fill:var(--ink);font-size:1px}.office-mode{fill:var(--muted);font-size:1px;font-family:ui-monospace,monospace}.office-card{fill:var(--control);stroke:var(--control-border)}.office-card-text{fill:var(--ink);font-size:1px}.office-card-id{font-family:ui-monospace,monospace}.office-card-accent{fill:var(--line)}.office-waiting-card .office-card-accent{fill:var(--waiting)}.office-rework-card .office-card-accent{fill:var(--red)}.office-card-checking .office-card-accent{fill:var(--blue)}.office-more{fill:var(--blue);font-size:1px}.office-scene .office-spotlight .office-desk-surface,.office-scene .office-spotlight .office-card,.office-scene [role="button"]:focus-visible > .office-card{stroke:var(--focus);stroke-width:2px}.office-card-hit{fill:transparent;stroke:none;pointer-events:all}.office-card-focus:focus-visible{outline:none}.row.spotlight{background:var(--hover)}
27
241
 
28
242
  .office-movable{transition:transform .8s ease}.office-arc .office-arc-path{fill:none;stroke:var(--blue);stroke-width:1px}.office-arc .office-arc-head{fill:var(--blue)}.office-arc-important .office-arc-path{stroke:var(--amber)}.office-arc-important .office-arc-head{fill:var(--amber)}.office-arc-dropped .office-arc-path{stroke:var(--red);stroke-dasharray:.6 .4}.office-arc-dropped .office-arc-head{fill:var(--red)}.office-arc-label{fill:var(--muted);font-size:1px}.office-alert{fill:var(--red);font-size:1px}.office-latch{fill:var(--amber);font-size:1px}.office-link-line{fill:none;stroke-width:1px;stroke-dasharray:.8 .5}.office-link-overlap .office-link-line{stroke:var(--amber)}.office-link-conflict .office-link-line{stroke:var(--red)}.office-link-cohort .office-link-line{stroke:var(--blue)}.office-link-label{font-size:1px;fill:var(--muted)}.office-link-conflict .office-link-label{fill:var(--red)}.office-link-overlap .office-link-label{fill:var(--amber)}.office-link-cohort .office-link-label{fill:var(--blue)}.office-checking{fill:var(--blue);font-size:.916667px}
@@ -30,25 +244,154 @@ const dashboardTheme = (() => {
30
244
  @keyframes office-pulse{50%{opacity:.45}}@keyframes office-appear{from{opacity:0}}@keyframes office-fade{to{opacity:0}}
31
245
  @media(prefers-reduced-motion:reduce){.office-scene,.office-scene *{transition:none!important;animation:none!important}}
32
246
 
247
+ /* View navigation, task detail and dialog */
248
+ [hidden]{display:none!important}
249
+ /* App shell (#469 R2). From 1024px: a sticky top bar over a left sidebar and the content. Below 1024px: the same nav is one
250
+ row of tabs that scrolls sideways inside itself, so the page never scrolls sideways. The nav buttons, their order and the
251
+ data-view-target markers are the ones the navigation tests pin. */
252
+ .sprite{position:absolute;width:0;height:0;overflow:hidden}
253
+ .icon{width:20px;height:20px;flex:none;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
254
+ .skip-link{position:absolute;left:var(--space-2);top:-48px;z-index:30;background:var(--accent);color:var(--on-accent);padding:var(--space-2) var(--space-3);border-radius:var(--radius-control)}
255
+ .skip-link:focus{top:var(--space-2)}
256
+ .shell{display:grid;grid-template-columns:minmax(0,1fr);grid-template-rows:auto auto minmax(0,1fr);min-height:100vh}
257
+ .topbar{grid-row:1;display:flex;align-items:center;gap:var(--space-4);padding:var(--space-2) var(--space-4);min-height:56px;background:var(--panel);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:20}
258
+ .brand{min-width:0}
259
+ .brand .eyebrow{line-height:1.2}
260
+ .brand h1{font-size:var(--text-lg);line-height:1.2;white-space:nowrap}
261
+ .topbar #project-context{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
262
+ .topbar-end{margin-left:auto;display:flex;align-items:center;gap:var(--space-3);min-width:0}
263
+ .topbar-end .connection{white-space:nowrap}
264
+ .connection::before{content:"";display:inline-block;width:8px;height:8px;margin-right:var(--space-2);border-radius:var(--radius-full);background:var(--dot);vertical-align:baseline}
265
+ .connection[data-state="live"]{color:var(--green)}.connection[data-state="live"]::before{background:var(--dot-ok)}
266
+ .connection[data-state="down"]{color:var(--red)}.connection[data-state="down"]::before{background:var(--dot-err)}
267
+ .theme-field{display:flex;align-items:center;gap:var(--space-2)}
268
+ .theme-field label{margin:0}
269
+ .theme-field select{width:auto}
270
+ .icon-button{display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:44px;padding:0}
271
+ .attention-badge{min-height:44px}
272
+ .view-nav{grid-row:2;display:flex;gap:var(--space-1);padding:var(--space-2) var(--space-4);overflow-x:auto;overscroll-behavior-x:contain;background:var(--panel);border-bottom:1px solid var(--line);scrollbar-width:thin}
273
+ .view-nav button{display:inline-flex;align-items:center;gap:var(--space-2);flex:none;min-height:44px;border-color:transparent;background:transparent;white-space:nowrap}
274
+ .view-nav button:hover{background:var(--hover)}
275
+ .view-nav button[aria-current="page"]{background:var(--accent-soft);color:var(--accent);border-color:var(--accent);font-weight:600}
276
+ .view-nav .badge{margin-left:var(--space-1)}
277
+ .shell-body{grid-row:3;min-width:0}
278
+ main{max-width:1440px;margin:auto;padding:var(--space-6) var(--space-8);display:block;min-width:0}
279
+ main:focus{outline:none}
280
+ .keys-list{margin:0 0 var(--space-3);display:grid;gap:var(--space-2)}
281
+ .keys-list>div{display:grid;grid-template-columns:96px 1fr;gap:var(--space-3);align-items:baseline}
282
+ .keys-list dt{font-weight:600}.keys-list dd{margin:0}
283
+ kbd{display:inline-block;min-width:24px;padding:1px var(--space-2);border:1px solid var(--control-border);border-bottom-width:2px;border-radius:var(--radius-control);background:var(--control);font:var(--text-xs) var(--font-mono);text-align:center}
284
+ #keys-dialog{width:min(480px,calc(100% - 32px));border:1px solid var(--line);border-radius:var(--radius-surface);background:var(--raised);color:var(--ink);padding:var(--space-6);box-shadow:var(--shadow-2)}
285
+ #keys-dialog::backdrop{background:var(--scrim)}
286
+ #keys-dialog .dialog-head button{min-width:44px;padding:0}
287
+ @media(min-width:1024px){
288
+ .shell{grid-template-columns:200px minmax(0,1fr);grid-template-rows:auto minmax(0,1fr)}
289
+ .topbar{grid-column:1/-1}
290
+ .view-nav{grid-row:2;grid-column:1;flex-direction:column;overflow-x:visible;overflow-y:auto;padding:var(--space-3);border-bottom:0;border-right:1px solid var(--line);position:sticky;top:56px;align-self:start;height:calc(100vh - 56px)}
291
+ .view-nav button{width:100%;justify-content:flex-start}
292
+ .view-nav .badge{margin-left:auto}
293
+ .shell-body{grid-row:2;grid-column:2}
294
+ }
295
+ @media(max-width:1023px){.topbar{position:static;flex-wrap:wrap;gap:var(--space-1) var(--space-3);padding-block:var(--space-2)}.topbar #project-context{order:3;flex-basis:100%}.topbar-end{margin-left:0;flex-wrap:wrap;width:100%;gap:var(--space-2) var(--space-3)}.topbar-end .connection{flex:1 0 100%;white-space:normal}.topbar-end .theme-field{margin-left:auto}main{padding:var(--space-4)}.brand h1 span{display:none}}
296
+ @media(max-width:600px){main{padding:var(--space-3)}.topbar{padding:var(--space-2) var(--space-3)}.theme-field label{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}}
297
+ main>.stack{margin-top:var(--space-6)}
298
+ .task-card.compact{cursor:pointer;min-height:44px;transition:transform .15s ease-out}
299
+ .task-card.compact:hover,.task-card.compact:focus-visible{border-color:var(--focus);box-shadow:var(--shadow-1);transform:translateY(-1px)}
300
+ .task-card.compact[draggable="true"]:active{box-shadow:var(--shadow-2);transform:translateY(-2px)}
301
+ .task-card.compact .card-head{display:flex;justify-content:flex-start;align-items:flex-start;gap:var(--space-2)}
302
+ .task-card.compact .card-title{flex:1 1 auto;min-width:0;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden}
303
+ .card-meta{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2);margin-top:var(--space-2)}
304
+ .release-chip{font-size:var(--text-xs);color:var(--muted);border:1px solid var(--line);border-radius:var(--radius-full);padding:0 var(--space-2);overflow-wrap:anywhere}
305
+ .owner-avatar{flex:none;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:var(--radius-full);background:var(--peer-color);color:var(--on-peer);font-size:var(--text-xs);font-weight:700;line-height:1}
306
+ @media(prefers-reduced-motion:reduce){.task-card.compact{transition:none}.task-card.compact:hover,.task-card.compact:focus-visible,.task-card.compact[draggable="true"]:active{transform:none}}
307
+ .task-detail .actions{display:flex;flex-wrap:wrap}
308
+ .task-detail .actions select{width:auto;max-width:260px}
309
+ .task-detail button,.task-detail select{min-height:44px}
310
+ .board-focus{grid-template-columns:minmax(0,1fr)}
311
+ .column-head{display:flex;justify-content:space-between;gap:var(--space-2);align-items:center;margin-bottom:var(--space-3)}
312
+ .column-head h3{margin:0}
313
+ .column-head button{min-height:44px;font-size:var(--text-xs)}
314
+ /* Side sheets (#469 R3): the task detail and the Plan sheet are native dialogs pinned to the right edge. Full height; on narrow
315
+ screens they fill the width. They slide in on transform and fade on opacity only, and not at all under reduced motion. */
316
+ #task-detail-dialog,#plan-dialog{position:fixed;inset:0 0 0 auto;margin:0;width:min(520px,100%);max-width:100%;height:100vh;max-height:100vh;overflow:auto;border:0;border-left:1px solid var(--line);border-radius:0;background:var(--raised);color:var(--ink);padding:var(--space-6);box-shadow:var(--shadow-2)}
317
+ #plan-dialog{width:min(560px,100%)}
318
+ #task-detail-dialog[open],#plan-dialog[open]{animation:sheet-in .18s ease-out}
319
+ @keyframes sheet-in{from{transform:translateX(24px);opacity:0}}
320
+ @media(prefers-reduced-motion:reduce){#task-detail-dialog[open],#plan-dialog[open]{animation:none}}
321
+ @media(max-width:600px){#task-detail-dialog,#plan-dialog{padding:var(--space-4);border-left:0}}
322
+ #plan-dialog::backdrop{background:var(--scrim)}
323
+ .plan-block{border:1px solid var(--line);border-radius:var(--radius-surface);background:var(--panel);margin:0 0 var(--space-4);padding:var(--space-4);overflow:visible}
324
+ .plan-block>h3{font-size:var(--text-base);margin:0 0 var(--space-3)}
325
+ .plan-block form,.plan-block .content{padding:0}
326
+ /* A group is a plain heading and its lines: the global section{} box rule must not wrap each one in a card. */
327
+ .detail-group{margin:var(--space-4) 0;border:0;background:none;border-radius:0;overflow:visible}
328
+ .detail-heading{font-size:var(--text-xs);letter-spacing:.08em;text-transform:uppercase;color:var(--faint);margin:0 0 var(--space-2);border-bottom:1px solid var(--line);padding-bottom:var(--space-1)}
329
+ #task-detail-dialog::backdrop{background:var(--scrim)}
330
+ .dialog-head{display:flex;justify-content:space-between;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4)}
331
+ .dialog-head button{min-height:44px}
332
+ @media(max-width:600px){.board-focus{grid-template-columns:minmax(0,1fr)}}
33
333
 
34
- [hidden]{display:none!important}main{display:block}main>.stack{margin-top:24px}.view-nav{max-width:1440px;margin:auto;padding:16px 32px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}.view-nav button{min-height:44px}.view-nav button[aria-current="page"]{background:var(--green);color:var(--on-accent);border-color:var(--green)}.view-nav .attention-badge{margin-left:auto}.view-nav .badge{margin-left:4px}.task-card.compact{cursor:pointer;min-height:44px}.task-card.compact:hover{border-color:var(--focus)}.task-card.compact .row-head{gap:6px;flex-wrap:wrap}.task-card.compact .label{flex:1 1 100px}.task-detail .actions{display:flex;flex-wrap:wrap}.task-detail .actions select{width:auto;max-width:260px}.task-detail button,.task-detail select{min-height:44px}.board-focus{grid-template-columns:minmax(0,1fr)}.column-head{display:flex;justify-content:space-between;gap:8px;align-items:center;margin-bottom:12px}.column-head h3{margin:0}.column-head button{min-height:44px;font-size:12px}#task-detail-dialog{width:min(720px,calc(100% - 32px));max-height:85vh;overflow:auto;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--ink);padding:20px}#task-detail-dialog::backdrop{background:rgba(0,0,0,.35)}.dialog-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:16px}.dialog-head button{min-height:44px}@media(max-width:600px){.view-nav{padding:12px}.view-nav .attention-badge{margin-left:0}.board-focus{grid-template-columns:minmax(0,1fr)}}
334
+ /* Quiet motion (#469 R6). Only transform and opacity move, 120 to 200 ms, ease-out. The Office keeps its own pinned motion above. */
335
+ button{transition:transform .12s ease-out}
336
+ button:active:not(:disabled){transform:translateY(1px)}
337
+ main section[data-view]:not([hidden]){animation:view-in .16s ease-out}
338
+ .task-card.is-new{animation:card-in .16s ease-out}
339
+ @keyframes view-in{from{opacity:0}}
340
+ @keyframes card-in{from{opacity:0;transform:translateY(4px)}}
341
+ @keyframes toast-in{from{opacity:0;transform:translateY(8px)}}
342
+ /* Toasts: a stack in the bottom corner, newest last. An error is dismissed by hand; a success fades. */
343
+ .notice{position:fixed;right:var(--space-4);bottom:var(--space-4);z-index:20;display:flex;flex-direction:column;gap:var(--space-2);width:min(420px,calc(100vw - 2 * var(--space-4)));margin:0;pointer-events:none}
344
+ .notice:empty{display:none}
345
+ .toast{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--space-3);border:1px solid var(--line);border-radius:var(--radius-surface);padding:var(--space-3) var(--space-4);background:var(--raised);box-shadow:var(--shadow-2);overflow-wrap:anywhere;pointer-events:auto;animation:toast-in .16s ease-out}
346
+ .toast.error{color:var(--red);border-color:var(--red)}
347
+ .toast-out{opacity:0;transition:opacity .16s ease-out}
348
+ .toast-text{flex:1 1 auto;min-width:0}
349
+ .toast-dismiss{flex:none;min-height:32px;padding:var(--space-1) var(--space-2);font-size:var(--text-xs)}
350
+ /* Disconnected: the connection status is already err; the content dims, stays readable, and one line says when the last snapshot came. */
351
+ .disconnected{margin:0 0 var(--space-4);padding:var(--space-2) var(--space-4);border:1px solid var(--red);border-radius:var(--radius-control);color:var(--red);background:var(--panel);font-size:var(--text-sm)}
352
+ main[data-stale]>:not(#notice):not(#disconnected){opacity:.6;transition:opacity .16s ease-out}
353
+ /* First load: blocks shaped like the board columns and the peer rows, until the first snapshot replaces them. They do not animate. */
354
+ .skeleton{display:flex;flex-direction:column;gap:var(--space-3);padding:var(--space-4)}
355
+ .skeleton-board{flex-direction:row;align-items:flex-start}
356
+ .skeleton-col{flex:1 1 0;min-width:0;display:flex;flex-direction:column;gap:var(--space-3);padding:var(--space-3);border:1px solid var(--line);border-radius:var(--radius-surface);background:var(--panel-soft)}
357
+ .skeleton-bar{display:block;height:var(--space-4);border-radius:var(--radius-control);background:var(--line)}
358
+ .skeleton-tall{height:var(--space-8)}
359
+ @media(max-width:600px){.skeleton-board{flex-direction:column}}
35
360
  </style>
36
361
  </head>
37
362
  <body>
38
- <header><div><div class="eyebrow">Local control room</div><h1>agent-hub <span>/ dashboard</span></h1><div id="project-context" class="meta"></div></div><div><label for="theme">Theme</label><select id="theme" class="theme-choice"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select><div id="connection" class="connection" role="status">Connecting...</div></div></header>
363
+ <!-- Icons: Tabler Icons, outline set, path data copied unchanged. The MIT notice is in THIRD_PARTY_NOTICES.md. At most 20 may live in this sprite. -->
364
+ <svg class="sprite" width="0" height="0" aria-hidden="true" focusable="false"><defs>
365
+ <symbol id="i-board" viewBox="0 0 24 24"><path d="M4 4l6 0" /><path d="M14 4l6 0" /><path d="M4 10a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2l0 -8" /><path d="M14 10a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v2a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2l0 -2" /></symbol>
366
+ <symbol id="i-office" viewBox="0 0 24 24"><path d="M3 21l18 0" /><path d="M3 7v1a3 3 0 0 0 6 0v-1m0 1a3 3 0 0 0 6 0v-1m0 1a3 3 0 0 0 6 0v-1h-18l2 -4h14l2 4" /><path d="M5 21l0 -10.15" /><path d="M19 21l0 -10.15" /><path d="M9 21v-4a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v4" /></symbol>
367
+ <symbol id="i-stream" viewBox="0 0 24 24"><path d="M3 12h4l3 8l4 -16l3 8h4" /></symbol>
368
+ <symbol id="i-agents" viewBox="0 0 24 24"><path d="M5 7a4 4 0 1 0 8 0a4 4 0 1 0 -8 0" /><path d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /><path d="M21 21v-2a4 4 0 0 0 -3 -3.85" /></symbol>
369
+ <symbol id="i-message" viewBox="0 0 24 24"><path d="M8 9h8" /><path d="M8 13h6" /><path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12" /></symbol>
370
+ <symbol id="i-memory" viewBox="0 0 24 24"><path d="M15.5 13a3.5 3.5 0 0 0 -3.5 3.5v1a3.5 3.5 0 0 0 7 0v-1.8" /><path d="M8.5 13a3.5 3.5 0 0 1 3.5 3.5v1a3.5 3.5 0 0 1 -7 0v-1.8" /><path d="M17.5 16a3.5 3.5 0 0 0 0 -7h-.5" /><path d="M19 9.3v-2.8a3.5 3.5 0 0 0 -7 0" /><path d="M6.5 16a3.5 3.5 0 0 1 0 -7h.5" /><path d="M5 9.3v-2.8a3.5 3.5 0 0 1 7 0v10" /></symbol>
371
+ <symbol id="i-settings" viewBox="0 0 24 24"><path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065" /><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" /></symbol>
372
+ <symbol id="i-help" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M12 17l0 .01" /><path d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4" /></symbol>
373
+ <symbol id="i-close" viewBox="0 0 24 24"><path d="M18 6l-12 12" /><path d="M6 6l12 12" /></symbol>
374
+ <symbol id="i-star" viewBox="0 0 24 24"><path d="M12 17.75l-6.172 3.245l1.179 -6.873l-5 -4.867l6.9 -1l3.086 -6.253l3.086 6.253l6.9 1l-5 4.867l1.179 6.873l-6.158 -3.245" /></symbol>
375
+ <symbol id="i-arrow" viewBox="0 0 24 24"><path d="M5 12l14 0" /><path d="M13 18l6 -6" /><path d="M13 6l6 6" /></symbol>
376
+ <symbol id="i-check" viewBox="0 0 24 24"><path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4" /><path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" /></symbol>
377
+ </defs></svg>
378
+ <a class="skip-link" href="#main-content">Skip to content</a>
379
+ <div class="shell">
380
+ <header class="topbar"><div class="brand"><div class="eyebrow">Local control room</div><h1>agent-hub <span>/ dashboard</span></h1></div><div id="project-context" class="meta"></div><div class="topbar-end"><div id="connection" class="connection" role="status">Connecting...</div><button type="button" id="attention-badge" class="attention-badge" aria-label="Global attention" hidden>(0)</button><div class="theme-field"><label for="theme">Theme</label><select id="theme" class="theme-choice"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select></div><button type="button" id="keys-open" class="icon-button" aria-label="Keyboard shortcuts" aria-haspopup="dialog"><svg class="icon" aria-hidden="true" focusable="false"><use href="#i-help"/></svg></button></div></header>
39
381
  <nav id="dashboard-nav" class="view-nav" aria-label="Dashboard views">
40
- <button type="button" data-view-target="board" aria-current="page">Board</button>
41
- <button type="button" data-view-target="office">Office</button>
42
- <button type="button" data-view-target="stream">Stream</button>
43
- <button type="button" data-view-target="agents">Agents <span id="agents-approval-count" class="badge warn" hidden></span></button>
44
- <button type="button" data-view-target="message">Message</button>
45
- <button type="button" data-view-target="memory">Memory</button>
46
- <button type="button" data-view-target="settings">Settings</button>
47
- <button type="button" id="attention-badge" class="attention-badge" aria-label="Global attention" hidden>(0)</button>
382
+ <button type="button" data-view-target="board" aria-current="page"><svg class="icon" aria-hidden="true" focusable="false"><use href="#i-board"/></svg><span class="nav-label">Board</span></button>
383
+ <button type="button" data-view-target="office"><svg class="icon" aria-hidden="true" focusable="false"><use href="#i-office"/></svg><span class="nav-label">Office</span></button>
384
+ <button type="button" data-view-target="stream"><svg class="icon" aria-hidden="true" focusable="false"><use href="#i-stream"/></svg><span class="nav-label">Stream</span></button>
385
+ <button type="button" data-view-target="agents"><svg class="icon" aria-hidden="true" focusable="false"><use href="#i-agents"/></svg><span class="nav-label">Agents</span> <span id="agents-approval-count" class="badge warn" hidden></span></button>
386
+ <button type="button" data-view-target="message"><svg class="icon" aria-hidden="true" focusable="false"><use href="#i-message"/></svg><span class="nav-label">Message</span></button>
387
+ <button type="button" data-view-target="memory"><svg class="icon" aria-hidden="true" focusable="false"><use href="#i-memory"/></svg><span class="nav-label">Memory</span></button>
388
+ <button type="button" data-view-target="settings"><svg class="icon" aria-hidden="true" focusable="false"><use href="#i-settings"/></svg><span class="nav-label">Settings</span></button>
48
389
  </nav>
49
- <main>
50
- <p id="notice" class="notice" role="status" aria-live="polite"></p>
51
- <section class="task-board-section" aria-labelledby="tasks-title" data-view="board"><div class="section-head"><h2 id="tasks-title">Task board</h2><div class="filters"><select id="task-filter" aria-label="Filter tasks by state"><option value="">All states</option><option value="backlog">Backlog</option><option value="proposed">To Do</option><option value="work">Work (including rework)</option><option value="in_progress">In progress</option><option value="in_review">In review</option><option value="approved">Approved</option><option value="cancelled">Cancelled</option><option value="changes_requested">Changes requested</option></select><span id="task-count" class="meta"></span></div></div><div id="task-progress" class="task-progress" aria-live="polite"></div><div class="board-toolbar"><button id="board-back" type="button" hidden>Back to board</button><span id="board-focus-title" class="meta" hidden></span><label for="task-peer-filter">Agent<select id="task-peer-filter"><option value="">All agents</option></select></label><label for="task-release-filter">Release<select id="task-release-filter"><option value="">All releases</option></select></label><label for="task-lanes">View<select id="task-lanes"><option value="columns">By state</option><option value="agents">By agent</option><option value="releases">By release</option></select></label></div><div id="task-legend" class="agent-legend" role="group" aria-label="Agent legend and assignment targets"></div><div id="tasks" class="task-list"></div><details><summary>Plan a task</summary><form id="planning"><div class="field"><label for="planning-task">Task</label><select id="planning-task"><option value="">Select task</option></select></div><div class="split"><label>Priority<select id="planning-priority"><option value="">Default</option><option value="0">P0</option><option value="1">P1</option><option value="2">P2</option><option value="3">P3</option></select></label><label>Order<input id="planning-rank" type="number" min="0" step="1"></label><label>Release<select id="planning-release"><option value="">No release</option></select></label><label>Planned start<input id="planning-start" type="datetime-local" step="any"></label><label>Due<input id="planning-due" type="datetime-local" step="any"></label></div><label><input id="planning-pinned" type="checkbox">Pin against planner changes</label><label>Reason<input id="planning-reason" maxlength="1000" required></label><div class="actions"><button id="planning-save" type="submit" data-mutation="project">Save plan</button><button id="planning-undo" type="button" data-mutation="project">Undo latest plan</button><button id="planning-reload" type="button">Reload current values</button></div><p id="planning-version" class="meta"></p></form></details><details id="split-panel" hidden><summary>Split the selected backlog task</summary><form id="split-backlog"><label>Part titles, one per line<textarea id="split-titles" maxlength="2408" required></textarea></label><label><input id="split-sequential" type="checkbox">Each part waits for the previous part</label><label>Reason<input id="split-reason" maxlength="1000" required></label><p class="meta">Two to eight parts. The original task retains integration and acceptance. Unused parts can be undone from its current plan.</p><button id="split-save" type="submit" data-mutation="project">Stage parts</button></form></details><details><summary>Releases</summary><div id="releases" class="content"></div><form id="release-new"><div class="split"><label>Name<input id="release-name" maxlength="64" required></label><label>Order<input id="release-order" type="number" min="0" value="0" step="1" required></label></div><label>Goal<input id="release-goal" maxlength="1000" required></label><button type="submit" data-mutation="project">Create release</button></form><form id="release-plan"><label>Release<select id="release-edit"><option value="">Select release</option></select></label><div class="split"><label>Planned start<input id="release-start" type="datetime-local" step="any"></label><label>Cut<input id="release-cut" type="datetime-local" step="any"></label><label>Ship<input id="release-ship" type="datetime-local" step="any"></label></div><button id="release-save" type="submit" data-mutation="project">Save dates</button><button id="release-reload" type="button">Reload current release</button><p id="release-version" class="meta"></p></form></details><details id="delivery"><summary>Delivery evidence (read-only)</summary><div class="field"><label for="delivery-repo">GitHub repository</label><input id="delivery-repo" maxlength="200" placeholder="owner/repository"></div><div class="field"><label for="delivery-live">Live verification issue IDs (optional)</label><input id="delivery-live" maxlength="300" placeholder="Comma-separated issue numbers"></div><div class="actions"><button id="delivery-queue" type="button">Read merge queue</button><button id="delivery-release" type="button">Read release checklist</button></div><pre id="delivery-report" class="body" aria-live="polite"></pre></details><details><summary>Propose a task</summary><form id="propose"><div class="field"><label for="title">Title</label><input id="title" name="title" required maxlength="300" placeholder="What should the team do?"></div><div class="field"><label for="detail">Details (optional)</label><textarea id="detail" name="detail" maxlength="8000"></textarea></div><div class="split"><div class="field"><label for="class">Class</label><select id="class" name="class"><option>implement</option><option>plan</option><option>bulk_edit</option><option>test</option><option>review</option><option>summarize</option><option>triage</option></select></div><div class="field"><label for="owner">Owner</label><select id="owner" name="owner"><option value="">Automatic</option></select></div></div><label><input id="backlog-proposal" type="checkbox"> Add to Backlog without offering</label><button class="primary" data-mutation="project" type="submit">Propose task</button></form></details></section>
390
+ <div class="shell-body">
391
+ <main id="main-content" tabindex="-1">
392
+ <p id="disconnected" class="disconnected" role="status" hidden></p>
393
+ <div id="notice" class="notice" role="status" aria-live="polite" aria-label="Notices"></div>
394
+ <section class="task-board-section" aria-labelledby="tasks-title" data-view="board"><div class="section-head"><h2 id="tasks-title">Task board</h2><div class="filters"><select id="task-filter" aria-label="Filter tasks by state"><option value="">All states</option><option value="backlog">Backlog</option><option value="proposed">To Do</option><option value="work">Work (including rework)</option><option value="in_progress">In progress</option><option value="in_review">In review</option><option value="approved">Approved</option><option value="cancelled">Cancelled</option><option value="changes_requested">Changes requested</option></select><span id="task-count" class="meta"></span></div></div><div id="task-progress" class="task-progress" aria-live="polite"></div><div class="board-toolbar"><button id="plan-open" type="button" class="primary" aria-haspopup="dialog">Plan</button><button id="board-back" type="button" hidden>Back to board</button><span id="board-focus-title" class="meta" hidden></span><label for="task-peer-filter">Agent<select id="task-peer-filter"><option value="">All agents</option></select></label><label for="task-release-filter">Release<select id="task-release-filter"><option value="">All releases</option></select></label><label for="task-lanes">View<select id="task-lanes"><option value="columns">By state</option><option value="agents">By agent</option><option value="releases">By release</option></select></label></div><div id="task-legend" class="agent-legend" role="group" aria-label="Agent legend and assignment targets"></div><div id="tasks" class="task-list"><div class="skeleton skeleton-board" aria-hidden="true"><div class="skeleton-col"><span class="skeleton-bar"></span><span class="skeleton-bar skeleton-tall"></span><span class="skeleton-bar skeleton-tall"></span></div><div class="skeleton-col"><span class="skeleton-bar"></span><span class="skeleton-bar skeleton-tall"></span></div><div class="skeleton-col"><span class="skeleton-bar"></span><span class="skeleton-bar skeleton-tall"></span></div><div class="skeleton-col"><span class="skeleton-bar"></span></div><div class="skeleton-col"><span class="skeleton-bar"></span><span class="skeleton-bar skeleton-tall"></span></div></div><p class="empty">Loading the board...</p></div></section>
52
395
  <div class="stack">
53
396
  <section id="office-section" aria-labelledby="office-title" data-view="office" hidden><div class="section-head"><h2 id="office-title">Office</h2><div class="filters"><label for="office-view">Office mode</label><select id="office-view"><option value="off">Off</option><option value="flat">Flat</option><option value="isometric">Isometric</option></select></div></div><div id="office" class="content"></div></section>
54
397
  <section aria-labelledby="stream-title" data-view="stream" hidden><div class="section-head"><h2 id="stream-title">Live stream</h2><div class="filters"><div id="stream-filters" class="chips" role="group" aria-label="Filter events by kind"></div><label><input id="follow" type="checkbox" checked> Follow latest</label></div></div><div id="stream" class="stream" tabindex="0" aria-label="Recent hub events"><p class="empty">Waiting for hub activity...</p></div><p id="stream-empty" class="empty" hidden>No events match this filter.</p></section>
@@ -59,13 +402,21 @@ const dashboardTheme = (() => {
59
402
  </div>
60
403
  <div class="stack">
61
404
  <section id="projects-panel" aria-labelledby="projects-title" data-view="agents" hidden><div class="section-head"><h2 id="projects-title">Projects</h2><span id="project-count" class="meta"></span></div><div id="projects"></div></section>
62
- <section aria-labelledby="peers-title" data-view="agents" hidden><div class="section-head"><h2 id="peers-title">Peers &amp; queues</h2><span id="peer-count" class="meta"></span></div><div id="peers"></div></section>
405
+ <section aria-labelledby="peers-title" data-view="agents" hidden><div class="section-head"><h2 id="peers-title">Peers &amp; queues</h2><span id="peer-count" class="meta"></span></div><div id="peers"><div class="skeleton" aria-hidden="true"><span class="skeleton-bar skeleton-tall"></span><span class="skeleton-bar skeleton-tall"></span><span class="skeleton-bar skeleton-tall"></span></div><p class="empty">Loading peers...</p></div></section>
63
406
  <section aria-labelledby="permissions-title" data-view="agents" hidden><div class="section-head"><h2 id="permissions-title">Pending approvals</h2><span id="permission-count" class="meta"></span></div><div id="permissions"></div></section>
64
407
  <section aria-labelledby="budget-title" data-view="agents" hidden><div class="section-head"><h2 id="budget-title">Usage budgets</h2></div><div id="budget"></div></section>
65
408
  <section aria-labelledby="bench-title" data-view="agents" hidden><div class="section-head"><h2 id="bench-title">Benchmarks</h2><span id="bench-count" class="meta"></span></div><div id="bench"></div></section>
66
409
  </div>
67
410
  </main>
411
+ </div>
412
+ </div>
413
+ <dialog id="plan-dialog" aria-labelledby="plan-title"><div class="dialog-head"><h2 id="plan-title">Plan</h2><button type="button" id="plan-close" aria-label="Close plan sheet"><svg class="icon" aria-hidden="true" focusable="false"><use href="#i-close"/></svg></button></div>
414
+ <section class="plan-block" aria-labelledby="plan-h-plan"><h3 id="plan-h-plan">Plan a task</h3><form id="planning"><div class="field"><label for="planning-task">Task</label><select id="planning-task"><option value="">Select task</option></select></div><div class="split"><label>Priority<select id="planning-priority"><option value="">Default</option><option value="0">P0</option><option value="1">P1</option><option value="2">P2</option><option value="3">P3</option></select></label><label>Order<input id="planning-rank" type="number" min="0" step="1"></label><label>Release<select id="planning-release"><option value="">No release</option></select></label><label>Planned start<input id="planning-start" type="datetime-local" step="any"></label><label>Due<input id="planning-due" type="datetime-local" step="any"></label></div><label><input id="planning-pinned" type="checkbox">Pin against planner changes</label><label>Reason<input id="planning-reason" maxlength="1000" required></label><div class="actions"><button id="planning-save" type="submit" data-mutation="project">Save plan</button><button id="planning-undo" type="button" data-mutation="project">Undo latest plan</button><button id="planning-reload" type="button">Reload current values</button></div><p id="planning-version" class="meta"></p></form></section><section id="split-panel" class="plan-block" aria-labelledby="plan-h-split" hidden><h3 id="plan-h-split">Split the selected backlog task</h3><form id="split-backlog"><label>Part titles, one per line<textarea id="split-titles" maxlength="2408" required></textarea></label><label><input id="split-sequential" type="checkbox">Each part waits for the previous part</label><label>Reason<input id="split-reason" maxlength="1000" required></label><p class="meta">Two to eight parts. The original task retains integration and acceptance. Unused parts can be undone from its current plan.</p><button id="split-save" type="submit" data-mutation="project">Stage parts</button></form></section><section class="plan-block" aria-labelledby="plan-h-releases"><h3 id="plan-h-releases">Releases</h3><div id="releases" class="content"></div><form id="release-new"><div class="split"><label>Name<input id="release-name" maxlength="64" required></label><label>Order<input id="release-order" type="number" min="0" value="0" step="1" required></label></div><label>Goal<input id="release-goal" maxlength="1000" required></label><button type="submit" data-mutation="project">Create release</button></form><form id="release-plan"><label>Release<select id="release-edit"><option value="">Select release</option></select></label><div class="split"><label>Planned start<input id="release-start" type="datetime-local" step="any"></label><label>Cut<input id="release-cut" type="datetime-local" step="any"></label><label>Ship<input id="release-ship" type="datetime-local" step="any"></label></div><button id="release-save" type="submit" data-mutation="project">Save dates</button><button id="release-reload" type="button">Reload current release</button><p id="release-version" class="meta"></p></form></section><section id="delivery" class="plan-block" aria-labelledby="plan-h-delivery"><h3 id="plan-h-delivery">Delivery evidence (read-only)</h3><div class="field"><label for="delivery-repo">GitHub repository</label><input id="delivery-repo" maxlength="200" placeholder="owner/repository"></div><div class="field"><label for="delivery-live">Live verification issue IDs (optional)</label><input id="delivery-live" maxlength="300" placeholder="Comma-separated issue numbers"></div><div class="actions"><button id="delivery-queue" type="button">Read merge queue</button><button id="delivery-release" type="button">Read release checklist</button></div><pre id="delivery-report" class="body" aria-live="polite"></pre></section><section class="plan-block" aria-labelledby="plan-h-propose"><h3 id="plan-h-propose">Propose a task</h3><form id="propose"><div class="field"><label for="title">Title</label><input id="title" name="title" required maxlength="300" placeholder="What should the team do?"></div><div class="field"><label for="detail">Details (optional)</label><textarea id="detail" name="detail" maxlength="8000"></textarea></div><div class="split"><div class="field"><label for="class">Class</label><select id="class" name="class"><option>implement</option><option>plan</option><option>bulk_edit</option><option>test</option><option>review</option><option>summarize</option><option>triage</option></select></div><div class="field"><label for="owner">Owner</label><select id="owner" name="owner"><option value="">Automatic</option></select></div></div><label><input id="backlog-proposal" type="checkbox"> Add to Backlog without offering</label><button class="primary" data-mutation="project" type="submit">Propose task</button></form></section>
415
+ </dialog>
68
416
  <dialog id="task-detail-dialog" aria-labelledby="task-detail-title"><div class="dialog-head"><h2 id="task-detail-title">Task details</h2><button id="task-detail-close" type="button" autofocus>Close details</button></div><div id="task-detail-content"></div></dialog>
417
+ <dialog id="keys-dialog" aria-labelledby="keys-title"><div class="dialog-head"><h2 id="keys-title">Keyboard shortcuts</h2><button type="button" id="keys-close" aria-label="Close shortcuts"><svg class="icon" aria-hidden="true" focusable="false"><use href="#i-close"/></svg></button></div>
418
+ <dl class="keys-list"><div><dt><kbd>1</kbd>-<kbd>7</kbd></dt><dd>Switch view: Board, Office, Stream, Agents, Message, Memory, Settings</dd></div><div><dt><kbd>?</kbd></dt><dd>Show this list</dd></div><div><dt><kbd>Esc</kbd></dt><dd>Close the open sheet or task detail</dd></div></dl>
419
+ <p class="meta">Keys are ignored while you type in a field, and with Ctrl, Alt or Cmd held.</p></dialog>
69
420
  <footer>Loopback session. Private task content stays in the terminal. Reopen with <code>ahub ui</code> when your session expires.</footer>
70
421
  <script>
71
422
  'use strict';
@@ -95,8 +446,28 @@ function switchView(view) {
95
446
  if (!DASHBOARD_VIEWS.includes(view)) return;
96
447
  currentView = view; dashboardCookie.set('agent-hub-view', view); applyDashboardView();
97
448
  }
449
+ // Keys 1-7 switch views, ? lists the keys, Esc closes (a native <dialog> closes itself on Esc). View keys never fire while typing:
450
+ // not in an input, a textarea, a select or an editable node, and not with Ctrl, Alt or Cmd held.
451
+ const TYPING_TARGETS = ['INPUT', 'TEXTAREA', 'SELECT'];
452
+ function dashboardKey(event) {
453
+ if (event.ctrlKey || event.metaKey || event.altKey) return false;
454
+ const target = event.target || {};
455
+ if (TYPING_TARGETS.includes(String(target.tagName || '').toUpperCase()) || target.isContentEditable) return false;
456
+ const dialog = $('keys-dialog');
457
+ if (event.key === '?') { if (!dialog.open) dialog.showModal?.(); event.preventDefault?.(); return true; }
458
+ if (dialog.open || $('task-detail-dialog').open || $('plan-dialog').open) return false;
459
+ const index = Number(event.key) - 1;
460
+ if (!Number.isInteger(index) || index < 0 || index >= DASHBOARD_VIEWS.length || event.key.length !== 1) return false;
461
+ switchView(DASHBOARD_VIEWS[index]); event.preventDefault?.(); return true;
462
+ }
98
463
  function initializeDashboardNavigation() {
99
464
  for (const button of document.querySelectorAll('button[data-view-target]')) button.addEventListener('click', () => switchView(button.dataset.viewTarget));
465
+ document.addEventListener('keydown', dashboardKey);
466
+ $('keys-open').addEventListener('click', () => { if (!$('keys-dialog').open) $('keys-dialog').showModal?.(); });
467
+ $('keys-close').addEventListener('click', () => $('keys-dialog').close?.());
468
+ // The Plan sheet is a native <dialog>: Esc closes it, and the forms inside keep their ids and handlers.
469
+ $('plan-open').addEventListener('click', () => { if (!$('plan-dialog').open) $('plan-dialog').showModal?.(); });
470
+ $('plan-close').addEventListener('click', () => $('plan-dialog').close?.());
100
471
  $('attention-badge').addEventListener('click', reachAttention);
101
472
  $('board-back').addEventListener('click', () => setColumnFocus(null));
102
473
  $('task-detail-close').addEventListener('click', closeTaskDetail);
@@ -109,6 +480,14 @@ history.replaceState(null, '', location.pathname);
109
480
  const $ = (id) => document.getElementById(id);
110
481
  const el = (tag, text, cls) => { const node = document.createElement(tag); if (text !== undefined) node.textContent = String(text); if (cls) node.className = cls; return node; };
111
482
  let stopped = false, cursor = 0, timer, projectsTimer, eventCount = 0;
483
+ const setConnection = (text, state) => { const node = $('connection'); node.textContent = text; node.dataset.state = state; };
484
+ // Disconnected (#469 R6): the content dims (opacity only) but stays readable, and one line says when the last snapshot arrived.
485
+ let lastSnapshotAt = 0;
486
+ function setDisconnected(down) {
487
+ const main = $('main-content'), line = $('disconnected');
488
+ if (down) { main.dataset.stale = 'true'; line.hidden = false; setText(line, (lastSnapshotAt ? 'Last snapshot at ' + new Date(lastSnapshotAt).toLocaleTimeString() : 'No snapshot received yet') + '. Polling continues every second.'); }
489
+ else { delete main.dataset.stale; line.hidden = true; setText(line, ''); }
490
+ }
112
491
  let managerMode = false, projectList = [], selectedProjectId = '', selectedInstanceId = '', snapshotValid = false, viewGeneration = 0, projectRequest = 0;
113
492
  let lastSnapshot = null;
114
493
  let officePrev = null;
@@ -133,8 +512,21 @@ const taskBadge = { proposed: '', in_progress: 'warn', in_review: 'info', approv
133
512
  const peerDot = { idle: 'ok', busy: 'warn', paused: 'warn', offline: '' };
134
513
  const projectBadge = { running: 'ok', starting: 'warn', stopping: 'warn', stopped: '', unavailable: 'err', incompatible: 'err', missing: 'err' };
135
514
  function refreshRelativeTimes(root) { for (const node of root.querySelectorAll('[data-ts]')) node.textContent = rel(Number(node.dataset.ts)); }
136
- function notice(text, error = false) { $('notice').textContent = text; $('notice').classList.toggle('error', error); }
137
- function expire() { stopped = true; clearTimeout(timer); clearTimeout(projectsTimer); $('connection').textContent = 'Session closed'; notice('Session missing or expired. Run ahub ui in your terminal to reopen the dashboard.', true); document.querySelectorAll('button').forEach((button) => { button.disabled = true; }); }
515
+ // Toasts (#469 R6): #notice is a stack of at most four. An empty text clears it. The same text again replaces its toast. An error stays
516
+ // until it is dismissed; a success fades out after 4 s (opacity only) and stays when the system asks for reduced motion.
517
+ function notice(text, error = false) {
518
+ const TOAST_MAX = 4, TOAST_MS = 4000, TOAST_FADE_MS = 160, stack = $('notice');
519
+ if (!text) { stack.replaceChildren(); return; }
520
+ const gone = (node) => node.remove(); // only the toast that leaves: a rebuild would detach a focused Dismiss button of one that stays (#296)
521
+ const toast = el('div', undefined, 'toast' + (error ? ' error' : '')), body = el('span', text, 'toast-text'), dismiss = el('button', 'Dismiss', 'toast-dismiss');
522
+ dismiss.type = 'button'; dismiss.setAttribute('aria-label', 'Dismiss notice: ' + String(text).slice(0, 60)); dismiss.addEventListener('click', () => gone(toast));
523
+ toast.append(body, dismiss); toast.dataset.key = (error ? 'error:' : 'ok:') + text;
524
+ for (const child of [...stack.children]) if (child.dataset.key === toast.dataset.key) child.remove(); // the same text replaces its own toast
525
+ while (stack.children.length > TOAST_MAX - 1) stack.children[0].remove(); // over four: the oldest goes, nothing is rebuilt
526
+ stack.append(toast);
527
+ if (!error && !(globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true)) setTimeout(() => { toast.className += ' toast-out'; setTimeout(() => gone(toast), TOAST_FADE_MS); }, TOAST_MS);
528
+ }
529
+ function expire() { stopped = true; clearTimeout(timer); clearTimeout(projectsTimer); setConnection('Session closed', 'down'); notice('Session missing or expired. Run ahub ui in your terminal to reopen the dashboard.', true); document.querySelectorAll('button').forEach((button) => { button.disabled = true; }); }
138
530
  async function post(path, body) {
139
531
  const response = await fetch(path, {method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
140
532
  if (response.status === 401) { expire(); throw new Error('Run ahub ui to start a new session.'); }
@@ -181,7 +573,7 @@ async function action(payload, button, managerAction = false) {
181
573
  finally { if (button) { pendingButtons.delete(button); if (!stopped) button.disabled = Boolean(key && pendingStarts.has(key)) || (!managerAction && managerMode && !snapshotValid); } if (key) redrawStartControls(); }
182
574
  }
183
575
  function button(text, payload, cls, managerAction = false) { const node = el('button', text, cls); node.type = 'button'; node.dataset.mutation = managerAction ? 'manager' : 'project'; if (payload.action === 'start_peer') node.dataset.startKey = startKey(payload.peer); node.disabled = Boolean(node.dataset.startKey && pendingStarts.has(node.dataset.startKey)) || (!managerAction && managerMode && !snapshotValid); node.addEventListener('click', () => void action(payload, node, managerAction)); return node; }
184
- function update(id, data, render) { const signature = JSON.stringify(data); if (signatures.get(id) === signature) return; signatures.set(id, signature); const target = $(id); target.replaceChildren(); render(target); }
576
+ function update(id, data, render) { const signature = JSON.stringify(data); if (signatures.get(id) === signature) return; signatures.set(id, signature); const target = $(id); update.keep = {node: (typeof document === 'undefined' ? null : document.activeElement) || null, to: null}; target.replaceChildren(); render(target); const kept = update.keep; update.keep = null; const back = kept.to || kept.node; if (back && back.isConnected && typeof document !== 'undefined' && document.activeElement !== back) back.focus({preventScroll: true}); }
185
577
  function empty(target, text) { target.append(el('p', text, 'empty')); }
186
578
  function count(value) { return Array.isArray(value) ? value.length : value && typeof value === 'object' ? Object.keys(value).length : value; }
187
579
  function taskCount(value) { return Array.isArray(value) ? value.length : value && typeof value === 'object' ? Object.values(value).reduce((total, item) => total + (typeof item === 'number' ? item : 0), 0) : value; }
@@ -199,6 +591,9 @@ function restoreDraft(choices = false) {
199
591
  } else { $('message').value = draft.message || ''; $('title').value = draft.title || ''; $('detail').value = draft.detail || ''; $('class').value = draft.class || 'implement'; }
200
592
  }
201
593
  function syncMutationControls() { document.querySelectorAll('button[data-mutation="project"]').forEach((b) => { b.disabled = pendingButtons.has(b) || Boolean(b.dataset.startKey && pendingStarts.has(b.dataset.startKey)) || (managerMode && !snapshotValid); }); }
594
+ // The first-load blocks the page ships in #tasks and #peers (#296): a project switch puts copies of them back and update() replaces them
595
+ // when that project's first snapshot renders, so a slow or failed first /snapshot reads as loading, not as an empty project.
596
+ const firstLoad = Object.fromEntries(['tasks', 'peers'].map((name) => [name, [...$(name).childNodes].map((node) => node.cloneNode(true))]));
202
597
  function resetView(id, instance) {
203
598
  saveDraft();
204
599
  selectedProjectId = id || ''; selectedInstanceId = instance || ''; snapshotValid = false; cursor = 0; eventCount = 0; viewGeneration++;
@@ -206,7 +601,7 @@ function resetView(id, instance) {
206
601
  officePrev = null; officeDesks.clear(); officeCards.clear(); officeScene = null;
207
602
  $('office').replaceChildren(); $('office-section').hidden = true;
208
603
  closeTaskDetail(); renderNeedsAttention(null); applyDashboardView();
209
- signatures.clear(); $('stream').replaceChildren(); empty($('stream'), managerMode ? 'Select a running project...' : 'Waiting for hub activity...');
604
+ signatures.clear(); if (typeof keyedCaches !== 'undefined') keyedCaches.clear(); $('stream').replaceChildren(); empty($('stream'), managerMode ? 'Select a running project...' : 'No events yet. Messages, task changes and peer states appear here. Send one with ahub say <text>.');
210
605
  if (streamFilter) { streamFilter = ''; for (const child of $('stream-filters').children) child.setAttribute('aria-pressed', 'false'); }
211
606
  $('stream-empty').hidden = true; $('task-filter').value = '';
212
607
  if(typeof loadedPlanningDates!=='undefined')loadedPlanningDates.clear();
@@ -215,7 +610,13 @@ function resetView(id, instance) {
215
610
  $('split-titles').value='';$('split-reason').value='';$('split-sequential').checked=false;
216
611
  $('release-name').value='';$('release-goal').value='';$('release-order').value='0';
217
612
  $('delivery-report').textContent = ''; $('delivery-repo').value = ''; $('delivery-live').value = '';
218
- for (const [id, text] of [['memory-list','No project selected.'],['memory-graph',''],['memory-detail',''],['peers','No project selected.'],['tasks','No project selected.'],['task-progress','No project selected.'],['permissions','No project selected.'],['budget','No project selected.'],['settings','No project selected.']]) { $(id).replaceChildren(); if (text) empty($(id), text); }
613
+ // A project with a running instance is polled next, so the board and the peers show loading; "No project selected" is for no project.
614
+ const loading = Boolean(id && instance) && typeof firstLoad !== 'undefined';
615
+ for (const [id, text] of [['memory-list','No project selected.'],['memory-graph',''],['memory-detail',''],['peers','No project selected.'],['tasks','No project selected.'],['task-progress','No project selected.'],['permissions','No project selected.'],['budget','No project selected.'],['settings','No project selected.']]) {
616
+ $(id).replaceChildren();
617
+ if (loading && firstLoad[id]?.length) $(id).replaceChildren(...firstLoad[id].map((node) => node.cloneNode(true)));
618
+ else if (text) empty($(id), text);
619
+ }
219
620
  $('settings-session').textContent = '';
220
621
  $('peer-count').textContent = ''; $('task-count').textContent = ''; $('permission-count').textContent = ''; $('project-context').textContent = '';
221
622
  notice('');
@@ -260,7 +661,7 @@ async function refreshProjects() {
260
661
  const selected = projectList.find((p) => p && p.id === selectedProjectId), nextInstance = selected && typeof selected.instanceId === 'string' ? selected.instanceId : '';
261
662
  if (!selected || selected.state !== 'running' || nextInstance !== selectedInstanceId) resetView(selected?.id || '', nextInstance);
262
663
  renderProjects();
263
- } catch (error) { if (!stopped) $('connection').textContent = 'Projects unavailable: ' + error.message; }
664
+ } catch (error) { if (!stopped) setConnection('Projects unavailable: ' + error.message, 'down'); }
264
665
  if (!stopped) projectsTimer = setTimeout(refreshProjects, 3000);
265
666
  }
266
667
  function peerOptions(select, ids, first) { const selected = select.value; select.replaceChildren(); const initial = el('option', first); initial.value = ''; select.append(initial); for (const id of ids) { const option = el('option', id); option.value = id; select.append(option); } if (ids.includes(selected)) select.value = selected; }
@@ -288,7 +689,7 @@ function renderApprovals(permissions) {
288
689
  const terminalWaiting = permissions.filter((permission) => adapterDisplay[permission.peer]?.terminalApproval).length;
289
690
  $('permission-count').textContent = permissions.length + ' waiting' + (terminalWaiting ? ', ' + terminalWaiting + ' Pi/local: answer in the console or ahub tail / ahub permit' : '');
290
691
  update('permissions', permissions, (target) => {
291
- if (!permissions.length) return empty(target, 'No approvals waiting.');
692
+ if (!permissions.length) return empty(target, 'No approvals waiting. A peer that asks for a permission appears here; answer with ahub permit.');
292
693
  for (const permission of permissions) {
293
694
  const row = el('div', undefined, 'row'); row.dataset.permissionId=permission.id;row.tabIndex=0;row.append(el('div', permission.peer, 'label'), el('p', permission.title, 'body'));
294
695
  if (permission.terminalOnly) row.append(el('p', 'Review in the console or ahub tail; answer with ahub permit. You can deny here.', 'meta'));
@@ -322,6 +723,7 @@ function settingRow(row, raise) {
322
723
  const title = el('label', row.label, 'label'); title.htmlFor = id;
323
724
  const fileValue = row.fileValue !== undefined ? row.fileValue : row.pending !== undefined ? row.pending : row.value;
324
725
  head.append(title, badge(settingText(row.value), row.group === 'Permissions' && row.value !== null && row.value !== 'ask' ? 'warn' : ''));
726
+ if (row.pending !== undefined && ['hub start', 'peer start'].includes(row.applies)) head.append(badge('restart pending', 'warn'));
325
727
  node.append(head, el('p', row.key + ' / from ' + row.source + ' / ' + (row.applies === 'live' ? 'applies at once' : row.applies === 'next use' ? 'applies at next use' : row.applies === 'restart' ? 'applies after restart' : row.applies === 'peer start' ? "applies at the peer's next start" : 'read at hub start') + ' / written to ' + row.file, 'meta'));
326
728
  node.append(el('p', 'Running: ' + settingText(row.value) + ' / File: ' + settingText(fileValue), 'meta'));
327
729
  if (['live', 'next use', 'restart'].includes(row.applies)) node.append(el('p', 'Apply: ' + row.applies + (row.restartReason ? ' / ' + row.restartReason : ''), 'meta'));
@@ -399,7 +801,7 @@ function renderSettings(settings, roster = []) {
399
801
  // are rebuilt only when their own data changed, so a value being typed in one row survives a save of another.
400
802
  update('settings', settings.rows.map((row) => row.key), (target) => {
401
803
  settingSlots.clear();
402
- const reload = el('div'); settingSlots.set('$reload', { node: reload, signature: null }); target.append(reload);
804
+ const reload = el('div', undefined, 'settings-reload'); settingSlots.set('$reload', { node: reload, signature: null }); target.append(reload);
403
805
  const undo = el('div'); settingSlots.set('', { node: undo, signature: null }); target.append(undo);
404
806
  for (const group of SETTING_GROUPS) {
405
807
  const rows = settings.rows.filter((row) => row.group === group);
@@ -418,7 +820,7 @@ function renderSettings(settings, roster = []) {
418
820
  if (reloadSupported) {
419
821
  reloadSlot.node.append(button('Reload configuration', {action:'setting_reload'}));
420
822
  if (settings.pendingRestart.length) {
421
- reloadSlot.node.append(el('p', 'Pending restart', 'label'));
823
+ const pendingHead = el('p', 'Pending restart', 'label'); pendingHead.append(el('span', ' '), badge(String(settings.pendingRestart.length), 'warn')); reloadSlot.node.append(pendingHead);
422
824
  for (const pending of settings.pendingRestart) reloadSlot.node.append(el('p', pending.key + ': ' + pending.reason, 'meta'));
423
825
  }
424
826
  if (Array.isArray(settings.retainedConfig) && settings.retainedConfig.length) {
@@ -474,6 +876,26 @@ function renderStarts(target, starts) {
474
876
  }
475
877
  }
476
878
  let boardViewState = null, showAllDone = false;
879
+ // Keyed rendering (#469 R6). A pass hands out one node per key: the cached node when its signature is unchanged (so it keeps its
880
+ // DOM node, an open <details>, hover and, with update(), focus), a fresh node when it changed or is new, and end() drops the keys
881
+ // that are gone. A changed item is replaced by a fresh node, not patched: its listeners close over its data. The focused node of
882
+ // update() is carried to the fresh one. With enter, an item that is new after the first pass, or has moved to another group (a
883
+ // column), fades in (class is-new, removed on animationend). A card that disappears is removed at once: there is no leave animation.
884
+ const keyedCaches = new Map();
885
+ function keyedStart(name, enter = false) {
886
+ let cache = keyedCaches.get(name); if (!cache) keyedCaches.set(name, cache = {nodes: new Map(), primed: false});
887
+ const used = new Set();
888
+ const get = (key, signature, build, group = '') => {
889
+ used.add(key); const hit = cache.nodes.get(key);
890
+ if (hit && hit.signature === signature && hit.group === group) return hit.node;
891
+ const node = build(), keep = update.keep;
892
+ if (hit && keep && keep.node && (hit.node === keep.node)) keep.to = node;
893
+ if (enter && cache.primed && (!hit || hit.group !== group) && node.classList?.add) { node.classList.add('is-new'); node.addEventListener('animationend', () => node.classList.remove('is-new'), {once: true}); }
894
+ cache.nodes.set(key, {signature, node, group}); return node;
895
+ };
896
+ get.end = () => { for (const key of [...cache.nodes.keys()]) if (!used.has(key)) cache.nodes.delete(key); cache.primed = true; };
897
+ return get;
898
+ }
477
899
  const BOARD_FOCUS_COLUMNS = ['backlog','todo','progress','review','done'];
478
900
  let columnFocus, taskDetailId = null;
479
901
  let planningEdit = null, releaseEdit = null;
@@ -549,6 +971,30 @@ function peerMark(peer, reviewer = false) {
549
971
  const mark = el('span', undefined, 'peer-mark peer-' + key), color = el('span', undefined, 'peer-color-dot' + (reviewer ? ' reviewer' : ''));
550
972
  color.setAttribute('aria-hidden', 'true'); mark.append(color, el('span', (reviewer ? 'reviewer: ' : 'owner: ') + (peer || 'unassigned'))); return mark;
551
973
  }
974
+ // A sprite icon in the page (#469 R4). Decorative unless a label is given; the text beside it carries the meaning otherwise.
975
+ const icon = (id, label) => {
976
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'), use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
977
+ svg.setAttribute('class', 'icon'); svg.setAttribute('focusable', 'false'); use.setAttribute('href', '#' + id); svg.append(use);
978
+ if (label) { svg.setAttribute('role', 'img'); svg.setAttribute('aria-label', label); } else svg.setAttribute('aria-hidden', 'true');
979
+ return svg;
980
+ };
981
+ // #469 R3: the owner as a small round avatar in the peer colour with its initial. The text name stays reachable
982
+ // (aria-label and title), so the colour is never the only signal. A task with no owner shows a neutral avatar.
983
+ function ownerAvatar(peer) {
984
+ const key = Object.hasOwn(peerPalette, peer || '') ? peer : 'other', name = peer || 'unassigned';
985
+ const avatar = el('span', String(name).slice(0, 1).toUpperCase(), 'owner-avatar peer-' + key);
986
+ avatar.setAttribute('role', 'img'); avatar.setAttribute('aria-label', 'owner: ' + name); avatar.title = 'owner: ' + name;
987
+ return avatar;
988
+ }
989
+ // One badge per card: the state, with the priority folded into its text ("in review P1"). Overdue and a running check fold into the
990
+ // same text (#293: "in progress P2, overdue, check running"), never a second badge. The tones match taskBadge (declared above the
991
+ // board code, so a board-only slice cannot see it); a state with no tone gets the neutral badge. A state that is already err keeps
992
+ // it; otherwise overdue makes the badge warn, and a running check makes it info unless the state already has a tone.
993
+ const CARD_BADGE_TONE = { in_progress: 'warn', in_review: 'info', approved: 'ok', changes_requested: 'err' };
994
+ function cardBadge(task, card, overdue = false) {
995
+ const own = CARD_BADGE_TONE[task.state] || '', tone = own === 'err' ? own : overdue ? 'warn' : own || (task.checking ? 'info' : '');
996
+ return badge(String(task.state).replaceAll('_', ' ') + ' P' + card.priority + (overdue ? ', overdue' : '') + (task.checking ? ', check running' : ''), tone);
997
+ }
552
998
  function boardContext() {
553
999
  const current=context(), snapshot=boardViewState?.snapshot || {};
554
1000
  return {...current,origin:location.origin,projectId:typeof snapshot.projectId==='string' ? snapshot.projectId : current.projectId,instanceId:typeof snapshot.instanceId==='string' ? snapshot.instanceId : current.instanceId};
@@ -586,24 +1032,32 @@ function taskDetail(task, card, stage, ids, overdue) {
586
1032
  const row = el('article', undefined, 'task-detail'), head = el('div', undefined, 'row-head');
587
1033
  row.setAttribute('aria-label', '#' + task.id + ' ' + task.title + ', ' + task.state.replaceAll('_', ' ') + ', owner ' + (task.owner || 'unassigned') + ', priority P' + card.priority);
588
1034
  head.append(el('span', '#' + task.id + ' ' + task.title, 'label'), badge('P' + card.priority));
589
- if (card.starred) { const star = el('span', '★'); star.setAttribute('aria-hidden','true'); head.append(star); }
590
- row.append(head, peerMark(task.owner)); if (task.reviewer) row.append(peerMark(task.reviewer, true));
591
- row.append(el('p', task.profileClass ? task.profileClass + ' (base ' + task.class + ')' : task.class, 'meta'));
592
- if(task.role){const guidance='Task guidance: '+task.role+' (grants no authority)';row.append(el('p',guidance,'meta'));row.setAttribute('aria-label',row.getAttribute('aria-label')+', '+guidance);}
593
- if(task.release!=null){const release=boardViewState?.snapshot.releases?.find(release=>release.id===task.release),label='Release #'+task.release+' '+(release?.name || '(label unavailable)');row.append(el('p',label,'meta'));row.setAttribute('aria-label',row.getAttribute('aria-label')+', '+label);}
594
- if (stage) row.append(renderTaskStage(stage));
595
- if (card.waiting.length) row.append(el('p', 'Waiting: ' + card.waiting.join('; '), 'meta'));
596
- if (task.reworkRound) row.append(el('p', 'Rework round ' + task.reworkRound, 'meta'));
597
- if (task.checking) row.append(el('p', 'Check running', 'meta'));
598
- if (task.lifecycle) row.append(el('p', 'SDLC ' + task.lifecycle.mode + ' / ' + task.lifecycle.stage + (task.lifecycle.playbook ? ' (' + task.lifecycle.playbook + ')' : '') + ': ' + task.lifecycle.next + ' / next: ' + task.lifecycle.actor, 'meta'));
599
- if (task.lifecycle?.definition) row.append(el('p', 'Stage base: ' + task.lifecycle.definition.base + ' / entry: ' + (task.lifecycle.entry?.length ? task.lifecycle.entry.join('; ') : 'ready') + ' / exit: ' + (task.lifecycle.exit?.length ? task.lifecycle.exit.join('; ') : 'ready'), 'meta'));
1035
+ if (card.starred) { const star = el('span', undefined, 'star'); star.title = 'starred'; star.append(icon('i-star', 'starred')); head.append(star); }
1036
+ row.append(head);
1037
+ const status = el('section', undefined, 'detail-group'); status.append(el('h4', 'Status', 'detail-heading'));
1038
+ status.append(el('p', task.profileClass ? task.profileClass + ' (base ' + task.class + ')' : task.class, 'meta'));
1039
+ if (stage) status.append(renderTaskStage(stage));
1040
+ if (card.waiting.length) status.append(el('p', 'Waiting: ' + card.waiting.join('; '), 'meta'));
1041
+ if (task.reworkRound) status.append(el('p', 'Rework round ' + task.reworkRound, 'meta'));
1042
+ if (task.checking) status.append(el('p', 'Check running', 'meta'));
1043
+ if (task.lifecycle) status.append(el('p', 'SDLC ' + task.lifecycle.mode + ' / ' + task.lifecycle.stage + (task.lifecycle.playbook ? ' (' + task.lifecycle.playbook + ')' : '') + ': ' + task.lifecycle.next + ' / next: ' + task.lifecycle.actor, 'meta'));
1044
+ if (task.lifecycle?.definition) status.append(el('p', 'Stage base: ' + task.lifecycle.definition.base + ' / entry: ' + (task.lifecycle.entry?.length ? task.lifecycle.entry.join('; ') : 'ready') + ' / exit: ' + (task.lifecycle.exit?.length ? task.lifecycle.exit.join('; ') : 'ready'), 'meta'));
1045
+ if(overdue)status.append(badge('Overdue','warn'));
1046
+ row.append(status);
1047
+ const people = el('section', undefined, 'detail-group'); people.append(el('h4', 'People', 'detail-heading'));
1048
+ people.append(peerMark(task.owner)); if (task.reviewer) people.append(peerMark(task.reviewer, true));
1049
+ if(task.role){const guidance='Task guidance: '+task.role+' (grants no authority)';people.append(el('p',guidance,'meta'));row.setAttribute('aria-label',row.getAttribute('aria-label')+', '+guidance);}
1050
+ row.append(people);
600
1051
  if (task.detail) row.append(el('p', task.detail, 'body'));
601
- if(task.external?.provider==='github'){const source=el('a','GitHub #'+task.external.issue);source.href=task.external.url;source.rel='noreferrer';source.target='_blank';row.append(source);}
602
- if(task.pinned)row.append(el('p','Pinned','meta'));
603
- if(task.plannedStart != null || task.due != null)row.append(el('p','Planned '+dateText(task.plannedStart)+' / due '+dateText(task.due),'meta'));
604
- if(overdue)row.append(badge('Overdue','warn'));
605
- if(task.actualDates)row.append(el('p','Actual start '+dateText(task.actualDates.startedAt)+' / review '+dateText(task.actualDates.reviewAt)+' / done '+dateText(task.actualDates.doneAt),'meta'));
606
-
1052
+ const dates = el('section', undefined, 'detail-group'); dates.append(el('h4', 'Dates', 'detail-heading'));
1053
+ if(task.plannedStart != null || task.due != null)dates.append(el('p','Planned '+dateText(task.plannedStart)+' / due '+dateText(task.due),'meta'));
1054
+ if(task.actualDates)dates.append(el('p','Actual start '+dateText(task.actualDates.startedAt)+' / review '+dateText(task.actualDates.reviewAt)+' / done '+dateText(task.actualDates.doneAt),'meta'));
1055
+ if (dates.children.length > 1) row.append(dates);
1056
+ const links = el('section', undefined, 'detail-group'); links.append(el('h4', 'Links', 'detail-heading'));
1057
+ if(task.release!=null){const release=boardViewState?.snapshot.releases?.find(release=>release.id===task.release),label='Release #'+task.release+' '+(release?.name || '(label unavailable)');links.append(el('p',label,'meta'));row.setAttribute('aria-label',row.getAttribute('aria-label')+', '+label);}
1058
+ if(task.external?.provider==='github'){const source=el('a','GitHub #'+task.external.issue);source.href=task.external.url;source.rel='noreferrer';source.target='_blank';links.append(source);}
1059
+ if(task.pinned)links.append(el('p','Pinned','meta'));
1060
+ if (links.children.length > 1) row.append(links);
607
1061
  const controls = el('div', undefined, 'actions');
608
1062
  if (card.pull && boardViewState?.snapshot.boardCapabilities?.backlog===true) controls.append(boardButton('Pull into To Do', {action:'backlog_pull',id:task.id}));
609
1063
  if (card.pull && boardViewState?.snapshot.planningCapabilities?.wipPull===true) controls.append(boardButton('Pull within WIP', {action:'planner_pull',id:task.id,revision:task.planningRevision ?? 0,reason:'person requested a WIP-constrained pull'}));
@@ -626,15 +1080,16 @@ function taskCard(task, card, stage, ids, overdue, compact = true) {
626
1080
  if (compact) {
627
1081
  row.setAttribute('role', 'button');
628
1082
  row.setAttribute('aria-label', '#' + task.id + ' ' + task.title + ', ' + task.state.replaceAll('_', ' ') + ', owner ' + (task.owner || 'unassigned') + ', priority P' + card.priority);
1083
+ if (overdue) row.setAttribute('aria-label', row.getAttribute('aria-label') + ', overdue');
1084
+ if (task.checking) row.setAttribute('aria-label', row.getAttribute('aria-label') + ', check running');
629
1085
  if (task.role) row.setAttribute('aria-label', row.getAttribute('aria-label') + ', Task guidance: ' + task.role + ' (grants no authority)');
630
1086
  if (task.release != null) { const release=boardViewState?.snapshot.releases?.find(release=>release.id===task.release);row.setAttribute('aria-label',row.getAttribute('aria-label')+', Release #'+task.release+' '+(release?.name || '(label unavailable)')); }
631
- const head = el('div', undefined, 'row-head'); head.append(el('span', '#' + task.id + ' ' + task.title, 'label'), badge('P' + card.priority));
632
- const owner = el('span', undefined, 'peer-mark peer-' + (Object.hasOwn(peerPalette, task.owner || '') ? task.owner : 'other'));
633
- owner.setAttribute('aria-label', 'owner: ' + (task.owner || 'unassigned')); owner.title = 'owner: ' + (task.owner || 'unassigned'); owner.append(el('span', undefined, 'peer-color-dot')); head.append(owner);
634
- if (card.starred) { const star=el('span','★');star.setAttribute('aria-hidden','true');head.append(star); }
635
- row.append(head);
636
- if (overdue) row.append(badge('Overdue','warn'));
637
- if (task.checking) row.append(badge('Check running','info'));
1087
+ // Title (two lines at most, clamped in CSS), the owner avatar, one state/priority badge and the release. Everything else is in the detail.
1088
+ const head = el('div', undefined, 'row-head card-head'); head.append(ownerAvatar(task.owner), el('span', '#' + task.id + ' ' + task.title, 'label card-title'));
1089
+ if (card.starred) { const star = el('span', undefined, 'star'); star.title = 'starred'; star.append(icon('i-star', 'starred')); head.append(star); }
1090
+ const meta = el('div', undefined, 'card-meta'); meta.append(cardBadge(task, card, overdue));
1091
+ if (task.release != null) { const release=boardViewState?.snapshot.releases?.find(release=>release.id===task.release); meta.append(el('span', 'Release #' + task.release + (release?.name ? ' ' + release.name : ''), 'release-chip')); }
1092
+ row.append(head, meta);
638
1093
  row.addEventListener('click', () => openTaskDetail(task.id));
639
1094
  row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openTaskDetail(task.id); } });
640
1095
  }
@@ -707,7 +1162,8 @@ function renderBoard(snapshot, peers, ids) {
707
1162
  if (!ids.length) empty(target,'No peer status available.');
708
1163
  });
709
1164
  update('tasks',[snapshot.tasks,peers,snapshot.boardCapabilities,taskFilter,ownerFilter,releaseFilter,lanes,showAllDone,[...overdue],columnFocus],target=>{
710
- taskRows.clear();
1165
+ taskRows.clear(); const keyed=keyedStart('cards',true);
1166
+ if(!snapshot.tasks.length)target.append(el('p','No tasks yet. Plan one with the Plan button, or run ahub task propose <title>.','empty'));
711
1167
  const visible=snapshot.tasks.filter(task=>(!taskFilter || (taskFilter==='work' ? ['in_progress','changes_requested'].includes(task.state) : task.state===taskFilter)) && (!ownerFilter || task.owner===ownerFilter || task.reviewer===ownerFilter) && (!releaseFilter || task.release===Number(releaseFilter)));
712
1168
  const visibleIds=new Set(visible.map(task=>task.id));
713
1169
  const owners=columnFocus ? [null] : lanes==='agents' ? [...new Set(visible.map(task=>task.owner || ''))] : lanes==='releases' ? [...new Set(visible.map(task=>task.release ?? 0))] : [null];
@@ -734,7 +1190,7 @@ function renderBoard(snapshot, peers, ids) {
734
1190
  if(hidden) ready=ready.slice(0,10);
735
1191
  for(const [label,list] of [['Ready',ready],['Waiting',waiting]]) {
736
1192
  if(column.id==='todo') box.append(el('h4',label+' ('+list.length+')'));
737
- for(const card of list) box.append(taskCard(byId.get(card.id),card,stages.get(card.id),eligible,overdue.has(card.id),columnFocus === null));
1193
+ for(const card of list){const task=byId.get(card.id),compact=columnFocus === null,release=snapshot.releases?.find(release=>release.id===task.release)?.name,node=keyed(card.id,JSON.stringify([task,card,stages.get(card.id),card.assignable?eligible:null,overdue.has(card.id),compact,release,boardContext(),snapshot.boardCapabilities,snapshot.planningCapabilities]),()=>taskCard(task,card,stages.get(card.id),eligible,overdue.has(card.id),compact),column.id);taskRows.set(card.id,node);box.append(node)}
738
1194
  }
739
1195
  if(!ready.length && !waiting.length) empty(box,'No tasks');
740
1196
  if(hidden){const more=el('button','Show '+hidden+' older Done tasks');more.type='button';more.addEventListener('click',()=>{if(!boardViewState)return;showAllDone=true;renderBoard(boardViewState.snapshot,boardViewState.peers,boardViewState.ids)});box.append(more)}
@@ -742,7 +1198,7 @@ function renderBoard(snapshot, peers, ids) {
742
1198
  }
743
1199
  lane.append(grid);target.append(lane);
744
1200
  }
745
- const unknown=model.unknown.filter(id=>visibleIds.has(id));if(unknown.length) target.append(el('p','Unsupported task states: '+unknown.map(id=>'#'+id).join(', '),'board-unknown'));
1201
+ keyed.end();const unknown=model.unknown.filter(id=>visibleIds.has(id));if(unknown.length) target.append(el('p','Unsupported task states: '+unknown.map(id=>'#'+id).join(', '),'board-unknown'));
746
1202
  });
747
1203
  refreshTaskDetail();
748
1204
  }
@@ -779,35 +1235,45 @@ function render(snapshot) {
779
1235
  syncStarts(snapshot);
780
1236
  update('peers', [peers, starts, starts.map(start => { const pending = pendingStarts.get(startKey(start.peer)); return pending ? [pending.phase, pending.retryAt || null] : null; })], (target) => {
781
1237
  peerRows.clear();
782
- if (!ids.length) { empty(target, 'No peers connected. Start one below, or from your terminal.'); return renderStarts(target, starts); }
1238
+ if (!ids.length) { empty(target, 'No peers connected. Start one below, or run ahub claude or ahub codex in a terminal.'); return renderStarts(target, starts); }
1239
+ const keyed = keyedStart('peers');
783
1240
  for (const [id, peer] of Object.entries(peers)) {
1241
+ const row = keyed(id, JSON.stringify([peer, (snapshot.boardLegend || []).find(item => item.peer === id)?.activeTasks]), () => {
784
1242
  const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head'), name = el('div', undefined, 'peername');
785
1243
  name.append(dot(peerDot[peer.state] || ''), el('span', id, 'label'));
786
1244
  head.append(name, badge(peer.rostered === false ? peer.state + ' off roster' : peer.state, peerDot[peer.state] || ''));
787
1245
  if (peer.planner) name.append(badge('Planner' + (peer.planner === 'setting' ? '' : ' (' + peer.planner + ')'), ''));
788
- row.append(head, el('p', peer.queued + ' queued' + (peer.queuedImportant ? ' (' + peer.queuedImportant + ' important)' : ''), 'meta'));
789
- if (peer.flowRoles) row.append(el('p', 'Flow roles: ' + (peer.flowRoles.join(', ') || 'none'), 'meta'));
790
- if (peer.catalogRoles) { row.append(el('p', 'Catalog guidance: ' + (Array.isArray(peer.catalogRoles.value) ? peer.catalogRoles.value.join(', ') || 'none' : 'unconfirmed'), 'meta')); if (peer.catalogRoles.pending !== undefined) row.append(el('p', 'Pending at next start: ' + (peer.catalogRoles.pending.join(', ') || 'none'), 'meta')); }
791
- if (peer.needsReview) row.append(el('p', peer.needsReview + ' need review: use ahub queue list', 'meta'));
792
- if (peer.oldestQueuedAt !== undefined) row.append(el('p', 'Oldest pending: ' + Math.max(0, Math.floor((Date.now() - peer.oldestQueuedAt) / 1000)) + 's', 'meta'));
793
- if (peer.attached === false) row.append(el('p', 'Disconnected recipient; work is retained.', 'meta'));
794
- if (peer.toolsOnly) row.append(el('p', peer.toolsOnly, 'meta'));
795
- if (peer.permissionNote) row.append(el('p', peer.permissionNote, 'meta'));
796
- if (typeof peer.statusNotice?.message === 'string' && peer.statusNotice.message) { const notice = el('p', peer.statusNotice.message, 'meta'); notice.style.color = 'var(--amber)'; row.append(notice); }
797
- if (peer.pullOnly && adapterDisplay[id]?.pullOnlyRead) row.append(el('p', 'Last hub_inbox read: ' + (typeof peer.lastInboxAt === 'number' ? Math.max(0, Math.floor((Date.now() - peer.lastInboxAt) / 1000)) + 's ago' : 'never'), 'meta'));
798
- if (peer.paused) row.append(el('p', peer.paused, 'meta'));
799
- if (peer.requestedModel) row.append(el('p', 'Requested: ' + peer.requestedModel, 'meta'));
1246
+ // Summary row (#469 R4): name, state, current task and queue. The diagnostic lines below fold into one disclosure.
1247
+ const current = (snapshot.boardLegend || []).find(item => item.peer === id)?.activeTasks, summary = el('p', undefined, 'peer-summary');
1248
+ summary.append(el('span', peer.queued + ' queued' + (peer.queuedImportant ? ' (' + peer.queuedImportant + ' important)' : ''), 'meta'), el('span', Array.isArray(current) && current.length ? 'Working on ' + current.map(task => '#' + task).join(', ') : 'No active task', 'meta'));
1249
+ row.append(head, summary);
1250
+ // A status notice asks a person to act (for example, reattach a native session): it stays visible, never folded away.
1251
+ if (typeof peer.statusNotice?.message === 'string' && peer.statusNotice.message) row.append(el('p', peer.statusNotice.message, 'meta notice-warn'));
1252
+ const more = el('div', undefined, 'peer-more');
1253
+ if (peer.flowRoles) more.append(el('p', 'Flow roles: ' + (peer.flowRoles.join(', ') || 'none'), 'meta'));
1254
+ if (peer.catalogRoles) { more.append(el('p', 'Catalog guidance: ' + (Array.isArray(peer.catalogRoles.value) ? peer.catalogRoles.value.join(', ') || 'none' : 'unconfirmed'), 'meta')); if (peer.catalogRoles.pending !== undefined) more.append(el('p', 'Pending at next start: ' + (peer.catalogRoles.pending.join(', ') || 'none'), 'meta')); }
1255
+ if (peer.needsReview) more.append(el('p', peer.needsReview + ' need review: use ahub queue list', 'meta'));
1256
+ if (peer.oldestQueuedAt !== undefined) more.append(el('p', 'Oldest pending: ' + Math.max(0, Math.floor((Date.now() - peer.oldestQueuedAt) / 1000)) + 's', 'meta'));
1257
+ if (peer.attached === false) more.append(el('p', 'Disconnected recipient; work is retained.', 'meta'));
1258
+ if (peer.toolsOnly) more.append(el('p', peer.toolsOnly, 'meta'));
1259
+ if (peer.permissionNote) more.append(el('p', peer.permissionNote, 'meta'));
1260
+ if (peer.pullOnly && adapterDisplay[id]?.pullOnlyRead) more.append(el('p', 'Last hub_inbox read: ' + (typeof peer.lastInboxAt === 'number' ? Math.max(0, Math.floor((Date.now() - peer.lastInboxAt) / 1000)) + 's ago' : 'never'), 'meta'));
1261
+ if (peer.paused) more.append(el('p', peer.paused, 'meta'));
1262
+ if (peer.requestedModel) more.append(el('p', 'Requested: ' + peer.requestedModel, 'meta'));
800
1263
  for (const backend of peer.backends || []) {
801
- if (backend.requestedModel) row.append(el('p', backend.kind.toUpperCase() + ': ' + (backend.actualModel || 'model not reported') + ' (' + backend.state + ')', 'meta'));
1264
+ if (backend.requestedModel) more.append(el('p', backend.kind.toUpperCase() + ': ' + (backend.actualModel || 'model not reported') + ' (' + backend.state + ')', 'meta'));
802
1265
  }
1266
+ if (more.children.length) { const fold = el('details', undefined, 'peer-fold'); fold.append(el('summary', 'Details (' + more.children.length + ')'), more); row.append(fold); }
803
1267
  const paused = peer.state === 'paused' || peer.paused;
804
1268
  row.tabIndex = 0; peerRows.set(id, row);
805
1269
  const trackDesk = (on) => { const desk = officeDesks.get(id); if (desk) desk.classList.toggle('office-spotlight', on); };
806
1270
  row.addEventListener('mouseenter', () => trackDesk(true)); row.addEventListener('mouseleave', () => trackDesk(false));
807
1271
  row.addEventListener('focus', () => trackDesk(true)); row.addEventListener('blur', () => trackDesk(false));
808
- row.append(button(paused ? 'Resume' : 'Pause', {action:paused ? 'resume' : 'pause', peer:id})); target.append(row);
1272
+ row.append(button(paused ? 'Resume' : 'Pause', {action:paused ? 'resume' : 'pause', peer:id})); return row;
1273
+ });
1274
+ peerRows.set(id, row); target.append(row);
809
1275
  }
810
- renderStarts(target, starts);
1276
+ keyed.end(); renderStarts(target, starts);
811
1277
  });
812
1278
  renderOffice(snapshot);
813
1279
  const assignable = ids.filter((id) => peers[id].state !== 'offline' && peers[id].attached !== false);
@@ -850,7 +1316,7 @@ function render(snapshot) {
850
1316
  const event = entry.event, node = el('div', undefined, 'event'), kind = event.env ? event.env.kind : 'state';
851
1317
  node.dataset.kind = kind; applyStreamFilter(node);
852
1318
  if (event.env) { const env = event.env, meta = el('div', '', 'meta'), stamp = el('span'); stamp.dataset.ts = env.ts; stamp.title = time(env.ts); stamp.textContent = rel(env.ts);
853
- meta.append(stamp, el('span', ' / ' + env.from + ' → ' + (env.to ? env.to.join(', ') : 'all') + ' / ' + env.kind + (event.t !== 'envelope' ? ' / ' + event.t : '') + (event.dropped ? ' / ' + event.dropped : '')));
1319
+ meta.append(stamp, el('span', ' / ' + env.from + ' '), icon('i-arrow', 'to'), el('span', ' ' + (env.to ? env.to.join(', ') : 'all') + ' / ' + env.kind + (event.t !== 'envelope' ? ' / ' + event.t : '') + (event.dropped ? ' / ' + event.dropped : '')));
854
1320
  node.append(meta, el('div', env.body, 'body')); }
855
1321
  else node.append(el('div', event.peer + ' / ' + event.state, 'meta'));
856
1322
  stream.append(node); eventCount++; while (stream.children.length > 200) stream.firstElementChild.remove();
@@ -869,10 +1335,10 @@ async function poll() {
869
1335
  const input = {after:cursor, memory:memoryInput()}; if (managerMode) { input.projectId = captured.projectId; input.instanceId = captured.instanceId; }
870
1336
  const result = await post('/snapshot', input);
871
1337
  if (!managerMode || (result.projectId === captured.projectId && result.instanceId === captured.instanceId && captured.generation === viewGeneration)) {
872
- snapshotValid = true; lastSnapshot = result; render(result); $('connection').textContent = 'Live / updated ' + new Date().toLocaleTimeString();
1338
+ snapshotValid = true; lastSnapshot = result; lastSnapshotAt = Date.now(); render(result); setConnection('Live / updated ' + new Date().toLocaleTimeString(), 'live'); setDisconnected(false);
873
1339
  }
874
1340
  }
875
- catch (error) { if (!stopped) $('connection').textContent = 'Disconnected / retrying: ' + error.message; }
1341
+ catch (error) { if (!stopped) { setConnection('Disconnected / retrying: ' + error.message, 'down'); setDisconnected(true); } }
876
1342
  if (!stopped) timer = setTimeout(poll, 1000);
877
1343
  }
878
1344
  // office-scene-begin (issue 264): the office, drawn Flat or Isometric from the shared officeModel as inline
@@ -1005,6 +1471,11 @@ function officeTileCards(tile, centerX, startY, lift = 0) {
1005
1471
  function officeRoomCards(room) { return officeTileCards(room, room.w / 2, 8, officeRoomOffset); }
1006
1472
  function officeDeskCards(desk) { return officeTileCards(desk, desk.w / 2 - 0.5, 9); }
1007
1473
 
1474
+ // A sprite icon inside the Office scene, sized in scene units; the stroke is the scene's own hairline, not the page's 2px.
1475
+ function officeIcon(id, x, y, size, cls) {
1476
+ const g = officeNode('g', { class: cls, transform: 'translate(' + x + ' ' + y + ') scale(' + (size / 24) + ')', 'aria-hidden': 'true' });
1477
+ g.append(officeNode('use', { href: '#' + id })); return g;
1478
+ }
1008
1479
  function officeNode(tag, attrs, text) { const node = document.createElementNS('http://www.w3.org/2000/svg', tag); for (const key in attrs) node.setAttribute(key, String(attrs[key])); if (text !== undefined) node.textContent = text; return node; }
1009
1480
  function officeKeys(event, activate) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); activate(); } }
1010
1481
  let spotlitRow = null;
@@ -1037,7 +1508,7 @@ function officeCardPaint(node, card) {
1037
1508
  const children = [officeNode('title', {}, card.label), clip,
1038
1509
  officeNode('rect', { x: 0, y: 0, width: card.w, height: card.h, rx: 0.5, class: 'office-card' }),
1039
1510
  officeNode('rect', { x: 0.15, y: 0.25, width: 0.2, height: Math.max(0.4, card.h - 0.5), rx: 0.1, class: 'office-card-accent' }), text];
1040
- if (card.checking) children.push(officeNode('text', { x: card.w - 0.6, y: card.h / 2 + 0.33, 'text-anchor': 'end', class: 'office-checking' }, '⟳'));
1511
+ if (card.checking) children.push(officeIcon('i-check', card.w - 1.5, card.h / 2 - 0.46, 0.92, 'office-checking'));
1041
1512
  node.replaceChildren(...children);
1042
1513
  }
1043
1514
  function officeDeskPaint(node, desk) {
@@ -1288,7 +1759,7 @@ function drawOffice(target, model) {
1288
1759
  const latch = model.latched[desk.id];
1289
1760
  if (desk.condition !== 'idle' || !latch || latch.until <= now) continue;
1290
1761
  const { node, fresh } = keep('latch:' + desk.id, String(latch.seq), scene.layers.marks,
1291
- (n) => { officePlace(n, desk.x, desk.y); n.setAttribute('class', 'office-latchmark'); n.setAttribute('aria-hidden', 'true'); n.replaceChildren(officeNode('text', { x: 5.2, y: 3.9, class: 'office-latch' }, '● busy')); }, false);
1762
+ (n) => { officePlace(n, desk.x, desk.y); n.setAttribute('class', 'office-latchmark'); n.setAttribute('aria-hidden', 'true'); n.replaceChildren(officeNode('circle', { cx: 5.5, cy: 3.55, r: 0.45, class: 'office-latch-dot' }), officeNode('text', { x: 6.3, y: 3.9, class: 'office-latch' }, 'busy')); }, false);
1292
1763
  if (fresh) officeFade(node, latch.until, now);
1293
1764
  }
1294
1765
  for (const key of [...els.keys()]) if (!seen.has(key)) officeLeave(scene, key);
@@ -1414,14 +1885,16 @@ function renderMemory(memory) {
1414
1885
  tiers.append(chip);
1415
1886
  }
1416
1887
  for (const chip of tiers.children) chip.setAttribute('aria-pressed', String(chip.dataset.tier === memoryQuery.tier));
1417
- if (!memory) { $('memory-count').textContent = ''; update('memory-list', null, (target) => empty(target, 'Hub memory is off for this project, or nothing is stored yet.')); update('memory-graph', null, () => {}); update('memory-detail', null, () => {}); return; }
1888
+ if (!memory) { $('memory-count').textContent = ''; update('memory-list', null, (target) => empty(target, 'Hub memory is off for this project, or nothing is stored yet. Save a note with ahub remember <text>.')); update('memory-graph', null, () => {}); update('memory-detail', null, () => {}); return; }
1418
1889
  $('memory-count').textContent = memory.counts.long + ' long-term / ' + memory.counts.short + ' short-term / ' + (memory.counts.goals || 0) + ' open tasks';
1419
1890
  if (memory.goals) {
1420
1891
  // Long-term tasks: state, who holds the lease and until when (a conflict when two machines claimed it), linked board tasks.
1421
1892
  const who = (m) => m === memory.machine ? 'this machine' : 'machine ' + m.slice(0, 8);
1422
1893
  update('memory-list', {goals: memory.goals, node: memoryQuery.node}, (target) => {
1423
1894
  if (!memory.goals.length) return empty(target, 'No long-term tasks. A person creates one with ahub goal new <title>.');
1895
+ const keyed = keyedStart('memory');
1424
1896
  for (const g of memory.goals) {
1897
+ const row = keyed('goal:' + g.id, JSON.stringify([g, memoryQuery.node === 'goal:' + g.id]), () => {
1425
1898
  const row = el('button', undefined, 'memory-row'); row.type = 'button'; row.setAttribute('aria-pressed', String(memoryQuery.node === 'goal:' + g.id));
1426
1899
  const head = el('div', undefined, 'row-head');
1427
1900
  head.append(el('span', g.title, 'label'), badge(g.state, g.state === 'done' ? 'ok' : g.state === 'blocked' ? 'err' : g.state === 'active' ? 'info' : ''));
@@ -1429,22 +1902,30 @@ function renderMemory(memory) {
1429
1902
  row.append(head, el('div', g.id.slice(0, 8) + ' / ' + lease + ' / ' + g.links.length + ' board task' + (g.links.length === 1 ? '' : 's') + (g.milestones.length ? ' / milestones: ' + g.milestones.join('; ') : ''), 'meta'));
1430
1903
  if (g.lease.conflict.length) row.append(badge('lease conflict', 'warn'));
1431
1904
  row.addEventListener('click', () => selectMemoryNode('goal:' + g.id));
1905
+ return row;
1906
+ });
1432
1907
  target.append(row);
1433
1908
  }
1909
+ keyed.end();
1434
1910
  });
1435
1911
  }
1436
1912
  memoryOptions('memory-peer', memory.peers, 'All peers', 'peer');
1437
1913
  memoryOptions('memory-machine', memory.machines, 'All machines', 'machine');
1438
1914
  if (!memory.goals) update('memory-list', {entries: memory.entries, node: memoryQuery.node}, (target) => {
1439
- if (!memory.entries.length) return empty(target, 'No ' + memory.tier + '-term entries match.');
1915
+ if (!memory.entries.length) return empty(target, 'No ' + memory.tier + '-term entries match. Save one with ahub remember <text>.');
1916
+ const keyed = keyedStart('memory');
1440
1917
  for (const entry of memory.entries) {
1918
+ const row = keyed('mem:' + entry.id, JSON.stringify([entry, memoryQuery.node === 'mem:' + entry.id]), () => {
1441
1919
  const row = el('button', undefined, 'memory-row'); row.type = 'button'; row.setAttribute('aria-pressed', String(memoryQuery.node === 'mem:' + entry.id));
1442
1920
  const head = el('div', undefined, 'row-head');
1443
1921
  head.append(el('span', (entry.title ? entry.title + ': ' : '') + entry.text.split('\n')[0].slice(0, 140), 'label'), badge(entry.kind, entry.kind === 'fail' ? 'err' : entry.kind === 'decision' ? 'ok' : entry.kind === 'contract' ? 'warn' : ''));
1444
1922
  row.append(head, el('div', entry.id.slice(0, 8) + ' / ' + entry.peer + ' / ' + time(entry.at) + (entry.scope === 'shared' ? ' / shared' : '') + (entry.supersededBy ? ' / superseded' : '') + (entry.machine !== memory.machine ? ' / from machine ' + entry.machine.slice(0, 6) : ''), 'meta'));
1445
1923
  row.addEventListener('click', () => selectMemoryNode('mem:' + entry.id));
1924
+ return row;
1925
+ });
1446
1926
  target.append(row);
1447
1927
  }
1928
+ keyed.end();
1448
1929
  });
1449
1930
  update('memory-graph', memory.graph || null, (target) => {
1450
1931
  const graph = memory.graph;
@@ -1525,7 +2006,7 @@ window.addEventListener('resize', () => { if (!stopped && officeView.get() !== '
1525
2006
  $('send').addEventListener('submit', async (event) => { event.preventDefault(); const generation = viewGeneration, body = $('message').value.trim(); if (!body) return; const to = $('recipient').value, payload = {action:'send', body}; if (to) payload.to = [to]; if (await action(payload, event.submitter) && generation === viewGeneration) $('message').value = ''; });
1526
2007
  $('propose').addEventListener('submit', async (event) => { event.preventDefault(); const generation = viewGeneration, title = $('title').value.trim(); if (!title) return; const payload = {action:'propose', title, class:$('class').value}; if ($('detail').value.trim()) payload.detail = $('detail').value.trim(); if ($('owner').value) payload.owner = $('owner').value; if ($('backlog-proposal').checked) { if(boardViewState?.snapshot.boardCapabilities?.backlog!==true || !sameBoardContext(boardContext())) return notice('Backlog is unavailable for this project snapshot.',true);payload.backlog=true; } if (await action(payload, event.submitter) && generation === viewGeneration) { $('title').value = ''; $('detail').value = ''; } });
1527
2008
  initializeDashboardNavigation();
1528
- (async () => { try { if (ticket) await post('/session', {ticket}); ticket = ''; renderStreamChips(); await refreshProjects(); await poll(); } catch (error) { ticket = ''; if (!stopped) { stopped = true; notice('Could not start session: ' + error.message + ' Run ahub ui to reopen.', true); $('connection').textContent = 'Session unavailable'; } } })();
2009
+ (async () => { try { if (ticket) await post('/session', {ticket}); ticket = ''; renderStreamChips(); await refreshProjects(); await poll(); } catch (error) { ticket = ''; if (!stopped) { stopped = true; notice('Could not start session: ' + error.message + ' Run ahub ui to reopen.', true); setConnection('Session unavailable', 'down'); } } })();
1529
2010
  </script>
1530
2011
  </body>
1531
2012
  </html>