@staix/agent-hub 0.12.23 → 0.12.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +98 -0
- package/README.md +8 -3
- package/docs/addons/claude-mem.md +24 -0
- package/docs/addons/codex-plugin.md +8 -0
- package/docs/addons/dot-admit.md +8 -0
- package/docs/addons/open-code-review.md +8 -0
- package/docs/addons/ponytail.md +23 -0
- package/docs/addons/ripwire.md +22 -0
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +8 -1
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +7 -1
- package/docs/agent-notes/local-worker.md +4 -2
- package/docs/agent-notes/tasks.md +4 -0
- package/docs/agent-notes/tests.md +2 -1
- package/docs/bench.md +28 -3
- package/docs/cooperbench.md +4 -0
- package/docs/data-policy.md +36 -0
- package/docs/events.md +3 -0
- package/docs/gsd-collaboration-analysis.md +48 -0
- package/docs/memory.md +231 -0
- package/docs/operations.md +651 -14
- package/docs/plans/2026-10-11-issues-109-sequential.md +81 -0
- package/docs/plans/2026-10-11-routing-evaluation-proposal.md +30 -0
- package/docs/profiles.md +57 -0
- package/docs/quickstart.md +1 -1
- package/docs/research.md +31 -1
- package/docs/roles.md +85 -0
- package/docs/sdlc.md +170 -0
- package/docs/security.md +111 -4
- package/docs/smoke.md +282 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +601 -7
- package/docs/specs/2026-10-12-backlog-implementation-plan.md +186 -0
- package/docs/specs/2026-10-12-office-view-design.md +212 -0
- package/docs/text-projection.md +19 -0
- package/docs/verification/2026-10-11-acp-watch-t0.md +33 -0
- package/docs/verification/2026-10-11-agent-config-inventory.md +20 -0
- package/docs/verification/2026-10-11-jcode-peer-contract.md +27 -0
- package/docs/verification/2026-10-13-0.12.24.md +94 -0
- package/docs/verification/2026-10-13-board-phone.md +31 -0
- package/docs/verification/2026-10-13-fsmonitor-fixture-attribution.md +19 -0
- package/docs/verified.json +22 -13
- package/package.json +2 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/ROLE-NOTICE.md +26 -0
- package/plugins/agent-hub/server.js +1175 -339
- package/scripts/check-docs.mjs +129 -0
- package/src/adapters/acp.ts +112 -13
- package/src/adapters/claude-channel.ts +48 -11
- package/src/adapters/codex-appserver.ts +11 -0
- package/src/adapters/jcode.ts +110 -0
- package/src/adapters/local-worker.ts +10 -7
- package/src/adapters/pi.ts +94 -20
- package/src/cli/attention.ts +60 -0
- package/src/cli/bench.ts +24 -5
- package/src/cli/board-read.ts +12 -0
- package/src/cli/console-state.ts +103 -17
- package/src/cli/console.ts +54 -8
- package/src/cli/facts-hook.ts +12 -4
- package/src/cli/help.ts +35 -9
- package/src/cli/identity.ts +18 -7
- package/src/cli/init.ts +48 -10
- package/src/cli/jcode-mcp.ts +58 -0
- package/src/cli/kimi-mcp.ts +53 -0
- package/src/cli/launch.ts +16 -1
- package/src/cli/main.ts +392 -57
- package/src/cli/memory.ts +193 -0
- package/src/cli/output.ts +45 -4
- package/src/cli/pet-config.ts +1 -0
- package/src/cli/pet.ts +83 -0
- package/src/cli/preview.ts +38 -5
- package/src/cli/status-lines.ts +1 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-runtime.ts +32 -3
- package/src/cli/watch.ts +31 -0
- package/src/hub/activity.ts +53 -0
- package/src/hub/ask.ts +10 -5
- package/src/hub/bench.ts +10 -3
- package/src/hub/board.ts +222 -16
- package/src/hub/bus.ts +48 -9
- package/src/hub/citations.ts +7 -0
- package/src/hub/conductor.ts +12 -7
- package/src/hub/config-trust.ts +4 -2
- package/src/hub/crash.ts +9 -0
- package/src/hub/daemon.ts +910 -120
- package/src/hub/delivery-journal.ts +5 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +12 -2
- package/src/hub/facts.ts +26 -7
- package/src/hub/git.ts +8 -2
- package/src/hub/github-import.ts +28 -0
- package/src/hub/hub-tools.ts +49 -10
- package/src/hub/inference.ts +1 -1
- package/src/hub/lifecycle-schema.ts +17 -0
- package/src/hub/listen.ts +15 -0
- package/src/hub/manager.ts +12 -5
- package/src/hub/path-policy.ts +96 -0
- package/src/hub/peer-catalog.ts +28 -0
- package/src/hub/permission-mode.ts +19 -5
- package/src/hub/planning.ts +70 -0
- package/src/hub/profiles.ts +74 -0
- package/src/hub/projected-diff.ts +73 -0
- package/src/hub/projection-exec.ts +24 -0
- package/src/hub/research.ts +144 -21
- package/src/hub/roles.ts +123 -0
- package/src/hub/roster.ts +23 -0
- package/src/hub/routing.ts +18 -5
- package/src/hub/sdlc-queue.ts +112 -0
- package/src/hub/sdlc-release.ts +125 -0
- package/src/hub/sdlc.ts +250 -0
- package/src/hub/settings.ts +34 -15
- package/src/hub/shipped-roles.ts +25 -0
- package/src/hub/snapshots.ts +89 -15
- package/src/hub/start-mode.ts +7 -5
- package/src/hub/supervision.ts +1 -0
- package/src/hub/task-sweep.ts +1 -1
- package/src/hub/tasks.ts +892 -87
- package/src/hub/text-projection.ts +126 -0
- package/src/hub/tooling.ts +159 -0
- package/src/hub/ui.ts +16 -2
- package/src/hub/workflow.ts +71 -0
- package/src/local/sandbox.ts +6 -4
- package/src/local/tools.ts +11 -6
- package/src/memory/brief.ts +28 -14
- package/src/memory/import.ts +55 -0
- package/src/memory/recall.ts +84 -0
- package/src/memory/store.ts +750 -0
- package/src/memory/sync.ts +145 -0
- package/src/pet/NOTICE.md +10 -0
- package/src/pet/characters.ts +1438 -0
- package/src/pet/config.ts +31 -0
- package/src/pet/frame.ts +17 -0
- package/src/pet/pixels.ts +28 -0
- package/src/role-text.d.ts +1 -0
- package/src/ui/index.html +842 -46
- package/src/ui/needs.ts +58 -0
- package/src/ui/office-model.ts +440 -0
- package/src/ui/peer-palette.ts +16 -0
- package/src/ui/task-board.ts +49 -0
- package/src/ui/task-progress.ts +5 -1
- package/templates/AGENTS.block.md +3 -2
- package/templates/config.json +5 -1
- package/templates/profiles/dev.json +22 -0
- package/templates/profiles/general.json +26 -0
- package/templates/profiles/research.json +57 -0
- package/templates/profiles/writing.json +28 -0
- package/templates/roles/NOTICE.md +26 -0
- package/templates/roles/dev/backend-engineer.md +26 -0
- package/templates/roles/dev/code-reviewer.md +26 -0
- package/templates/roles/dev/frontend-engineer.md +26 -0
- package/templates/roles/dev/security-reviewer.md +26 -0
- package/templates/roles/dev/software-architect.md +26 -0
- package/templates/roles/dev/test-engineer.md +26 -0
- package/templates/roles/research/analyst.md +26 -0
- package/templates/roles/research/fact-checker.md +26 -0
- package/templates/roles/research/research-lead.md +26 -0
- package/templates/roles/research/researcher.md +26 -0
- package/templates/roles/research/statistician.md +26 -0
package/src/ui/index.html
CHANGED
|
@@ -15,16 +15,30 @@ const dashboardTheme = (() => {
|
|
|
15
15
|
</script>
|
|
16
16
|
<style>
|
|
17
17
|
: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}}
|
|
18
|
-
.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-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)}.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}
|
|
18
|
+
.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-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)}.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}
|
|
19
|
+
/* PEER_PALETTE_CSS */
|
|
20
|
+
.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)}
|
|
21
|
+
@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%}}
|
|
22
|
+
.office-scene{display:block;width:100%;height:auto}.office-scene text{font-family:system-ui,sans-serif}.office-room{fill:var(--panel-soft);stroke:var(--line)}.office-room-title{fill:var(--muted);font-size:1.8px;letter-spacing:.1px;text-transform:uppercase}.office-room-count{fill:var(--ink);font-size:1.8px;font-variant-numeric:tabular-nums}.office-desk-surface{fill:var(--panel);stroke:var(--line)}.office-desk.office-dimmed{opacity:.55}.office-name{fill:var(--ink);font-size:1.6px;font-weight:600}.office-state{fill:var(--muted);font-size:1.4px}.office-glyph{font-size:1.8px}.office-idle .office-glyph{fill:var(--dot-ok)}.office-busy .office-glyph{fill:var(--dot-warn)}.office-offline .office-glyph{fill:var(--dot)}.office-paused .office-glyph{fill:var(--dot-warn)}.office-waiting .office-glyph{fill:var(--dot-err)}.office-unknown .office-glyph{fill:var(--dot)}.office-meta{fill:var(--muted);font-size:1.2px}.office-hand{fill:var(--dot-err);font-size:1.6px}.office-bubble{fill:var(--blue-soft);stroke:var(--blue)}.office-bubble-text{fill:var(--blue);font-size:1.3px}.office-mode{fill:var(--amber);font-size:1.2px;font-family:ui-monospace,monospace}.office-card{fill:var(--control);stroke:var(--control-border)}.office-card-text{fill:var(--ink);font-size:1.3px;font-family:ui-monospace,monospace}.office-waiting-card .office-card{stroke:var(--waiting);stroke-dasharray:.5 .4}.office-rework-card .office-card{stroke:var(--red)}.office-more{fill:var(--blue);font-size:1.4px}.office-spotlight .office-desk-surface,.office-spotlight .office-card{stroke:var(--focus);stroke-width:.5}.row.spotlight{background:var(--hover)}
|
|
23
|
+
.office-movable{transition:transform .8s ease}.office-arc .office-arc-path{fill:none;stroke:var(--blue);stroke-width:.35}.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:1.2px}.office-alert{fill:var(--red);font-size:1.4px}.office-latch{fill:var(--dot-warn);font-size:1.4px}.office-link-line{fill:none;stroke-width:.45;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:1.2px;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:1.1px}.office-card-checking .office-card{stroke:var(--blue)}
|
|
24
|
+
.office-avatar{fill:var(--muted)}.office-idle .office-avatar{fill:var(--dot-ok)}.office-busy .office-avatar{fill:var(--dot-warn)}.office-offline .office-avatar{fill:var(--dot)}.office-paused .office-avatar{fill:var(--dot-warn)}.office-waiting .office-avatar{fill:var(--amber)}.office-waiting .office-desk-surface{stroke:var(--amber);stroke-width:.6}.office-glyph-shape{fill:none;stroke-width:.32}.office-idle .office-glyph-shape{stroke:var(--dot-ok)}.office-busy .office-glyph-shape{stroke:var(--dot-warn);fill:var(--dot-warn)}.office-paused .office-glyph-shape{stroke:var(--dot-warn);fill:var(--dot-warn)}.office-offline .office-glyph-shape{stroke:var(--dot)}.office-handshape{fill:none;stroke:var(--dot-err);stroke-width:.28}.office-desk-leg{fill:var(--panel-soft);stroke:var(--line);stroke-width:.2}.office-nameplate{fill:var(--control);stroke:var(--control-border);stroke-width:.2}.office-room-wire{fill:none;stroke:var(--line);stroke-width:.25}.office-divider{fill:none;stroke:var(--line);stroke-width:.2}.office-shelf{fill:var(--panel-soft);stroke:var(--line);stroke-width:.3}.office-shelf-card{fill:var(--green-soft);stroke:var(--green);stroke-width:.25}.office-box-top{fill:var(--control);stroke:var(--control-border);stroke-width:.2}.office-box-left{fill:var(--panel-soft);stroke:var(--line);stroke-width:.2}.office-box-right{fill:var(--panel);stroke:var(--line);stroke-width:.2}.office-busy .office-glyph,.office-hand{animation:office-pulse 1.2s ease-in-out infinite}.office-appear{animation:office-appear .5s ease-out}.office-leaving{pointer-events:none;animation:office-fade .4s linear forwards}.office-fadeout{animation-name:office-fade;animation-duration:1s;animation-timing-function:linear;animation-fill-mode:forwards}.office-settle .office-movable{transition:none}.office-settle .office-appear{animation:none}
|
|
25
|
+
@keyframes office-pulse{50%{opacity:.45}}@keyframes office-appear{from{opacity:0}}@keyframes office-fade{to{opacity:0}}
|
|
26
|
+
@media(prefers-reduced-motion:reduce){.office-scene,.office-scene *{transition:none!important;animation:none!important}}
|
|
27
|
+
|
|
28
|
+
.pet-container[hidden]{display:none}.pet-container{display:flex;justify-content:flex-end;padding:8px 24px}.pet-control{display:flex;align-items:center;gap:12px;min-height:64px;min-width:40px;font-variant-numeric:tabular-nums}.pet-art{width:72px;height:64px;fill:currentColor}.pet-pixel{shape-rendering:crispEdges}.pet-accent{fill:var(--amber)}.pet-blink{opacity:0}.pet-waiting .pet-open{animation:pet-open 2s steps(1,end) infinite}.pet-waiting .pet-blink{animation:pet-blink 2s steps(1,end) infinite}@keyframes pet-open{50%{opacity:0}}@keyframes pet-blink{50%{opacity:1}}@media(prefers-reduced-motion:reduce){.pet-waiting .pet-open,.pet-waiting .pet-blink{animation:none}}
|
|
19
29
|
</style>
|
|
20
30
|
</head>
|
|
21
31
|
<body>
|
|
22
|
-
<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>
|
|
32
|
+
<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><div class="filters"><label for="office-view">View</label><select id="office-view"><option value="off">Off</option><option value="flat">Flat</option><option value="isometric">Isometric</option></select></div></header>
|
|
33
|
+
<div id="pet-container" class="pet-container" hidden></div>
|
|
23
34
|
<main>
|
|
24
35
|
<p id="notice" class="notice" role="status" aria-live="polite"></p>
|
|
36
|
+
<section class="task-board-section" aria-labelledby="tasks-title"><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="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"><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>
|
|
25
37
|
<div class="stack">
|
|
38
|
+
<section id="office-section" aria-labelledby="office-title" hidden><div class="section-head"><h2 id="office-title">Office</h2></div><div id="office" class="content"></div></section>
|
|
26
39
|
<section aria-labelledby="stream-title"><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>
|
|
27
|
-
|
|
40
|
+
|
|
41
|
+
<section aria-labelledby="memory-title"><div class="section-head"><h2 id="memory-title">Memory</h2><div class="filters"><div id="memory-tiers" class="chips" role="group" aria-label="Memory tier"></div><span id="memory-count" class="meta"></span></div></div><div class="memory-controls"><input id="memory-q" type="search" maxlength="200" placeholder="Search memory" aria-label="Search memory"><select id="memory-kind" aria-label="Filter memory by kind"><option value="">All kinds</option><option value="decision">Decisions</option><option value="contract">Contracts</option><option value="fail">Fails</option><option value="finding">Findings</option></select><select id="memory-peer" aria-label="Filter memory by peer"><option value="">All peers</option></select><select id="memory-machine" aria-label="Filter memory by machine"><option value="">All machines</option></select><select id="memory-since" aria-label="Filter memory by time"><option value="">Any time</option><option value="1">Last day</option><option value="7">Last week</option><option value="30">Last 30 days</option></select><select id="memory-hops" aria-label="Graph neighbourhood depth"><option value="1">1 hop</option><option value="2">2 hops</option></select></div><div id="memory-list" class="task-list" aria-label="Memory entries"></div><div id="memory-graph" class="memory-graph"></div><div id="memory-detail"></div></section>
|
|
28
42
|
<section aria-labelledby="compose-title"><div class="section-head"><h2 id="compose-title">Console message</h2></div><form id="send" class="content"><div class="field"><label for="recipient">Recipient</label><select id="recipient" name="recipient"><option value="">All peers</option></select></div><div class="field"><label for="message">Message</label><textarea id="message" name="body" required maxlength="8000" placeholder="Send an instruction to the team..."></textarea></div><button class="primary" data-mutation="project" type="submit">Send message</button></form></section>
|
|
29
43
|
<section aria-labelledby="settings-title"><div class="section-head"><h2 id="settings-title">Settings</h2><span id="settings-session" class="meta" role="status"></span></div><div id="settings"><p class="empty">Waiting for the hub...</p></div></section>
|
|
30
44
|
</div>
|
|
@@ -41,7 +55,16 @@ const dashboardTheme = (() => {
|
|
|
41
55
|
'use strict';
|
|
42
56
|
/* TASK_PROGRESS_MODEL */
|
|
43
57
|
/* PEER_START_RETRY_MS */
|
|
58
|
+
/* BOARD_MODEL */
|
|
59
|
+
/* PET_MODEL */
|
|
60
|
+
/* OFFICE_MODEL */
|
|
61
|
+
const officeView = (() => {
|
|
62
|
+
let choice = 'off';
|
|
63
|
+
try { const saved = document.cookie.split(';').map(value => value.trim()).find(value => value.startsWith('agent-hub-office='))?.slice('agent-hub-office='.length); if (['off','flat','isometric'].includes(saved)) choice = saved; } catch {}
|
|
64
|
+
return { get: () => choice, set: (value) => { choice = ['off','flat','isometric'].includes(value) ? value : 'off'; try { document.cookie = `agent-hub-office=${choice}; Path=/; Max-Age=31536000; SameSite=Strict`; } catch {} } };
|
|
65
|
+
})();
|
|
44
66
|
const themeControl = document.getElementById('theme'); themeControl.value = dashboardTheme.get(); themeControl.addEventListener('change', () => dashboardTheme.set(themeControl.value));
|
|
67
|
+
const officeControl = document.getElementById('office-view'); officeControl.value = officeView.get(); document.getElementById('office-section').hidden = officeView.get() === 'off'; officeControl.addEventListener('change', () => { officeView.set(officeControl.value); if (lastSnapshot) render(lastSnapshot); else document.getElementById('office-section').hidden = officeView.get() === 'off'; });
|
|
45
68
|
let ticket = location.hash.slice(1);
|
|
46
69
|
history.replaceState(null, '', location.pathname);
|
|
47
70
|
const $ = (id) => document.getElementById(id);
|
|
@@ -49,6 +72,8 @@ const el = (tag, text, cls) => { const node = document.createElement(tag); if (t
|
|
|
49
72
|
let stopped = false, cursor = 0, timer, projectsTimer, eventCount = 0;
|
|
50
73
|
let managerMode = false, projectList = [], selectedProjectId = '', selectedInstanceId = '', snapshotValid = false, viewGeneration = 0, projectRequest = 0;
|
|
51
74
|
let lastSnapshot = null;
|
|
75
|
+
let officePrev = null;
|
|
76
|
+
const peerRows = new Map(), taskRows = new Map(), officeDesks = new Map(), officeCards = new Map();
|
|
52
77
|
const drafts = new Map();
|
|
53
78
|
const pendingButtons = new WeakSet();
|
|
54
79
|
const pendingStarts = new Map();
|
|
@@ -110,7 +135,8 @@ async function action(payload, button, managerAction = false) {
|
|
|
110
135
|
}
|
|
111
136
|
if (key && pendingStarts.get(key) === pending) clearPendingStart(key);
|
|
112
137
|
if (!managerMode || captured.generation === viewGeneration) notice(result.text || 'Action accepted.');
|
|
113
|
-
return
|
|
138
|
+
if ((payload.action === 'sdlc_queue' || payload.action === 'sdlc_release') && (stopped || captured.generation !== viewGeneration || captured.projectId !== selectedProjectId || captured.instanceId !== selectedInstanceId)) return false;
|
|
139
|
+
return (payload.action === 'sdlc_queue' || payload.action === 'sdlc_release') ? result.report : true;
|
|
114
140
|
}
|
|
115
141
|
catch (error) { if (key && pendingStarts.get(key) === pending) clearPendingStart(key); if (!stopped && (!managerMode || captured.generation === viewGeneration)) notice(error.message, true); return false; }
|
|
116
142
|
finally { if (button) { pendingButtons.delete(button); if (!stopped) button.disabled = Boolean(key && pendingStarts.has(key)) || (!managerAction && managerMode && !snapshotValid); } if (key) redrawStartControls(); }
|
|
@@ -124,10 +150,11 @@ function saveDraft() {
|
|
|
124
150
|
if (!managerMode || !selectedProjectId) return;
|
|
125
151
|
const previous = drafts.get(selectedProjectId) || {};
|
|
126
152
|
drafts.set(selectedProjectId, {message:$('message').value, recipient:draftChoicesPending ? previous.recipient || '' : $('recipient').value,
|
|
127
|
-
title:$('title').value, detail:$('detail').value, class:$('class').value, owner:draftChoicesPending ? previous.owner || '' : $('owner').value});
|
|
153
|
+
...($('backlog-proposal')?.checked ? {backlog:true} : {}), title:$('title').value, detail:$('detail').value, class:$('class').value, owner:draftChoicesPending ? previous.owner || '' : $('owner').value});
|
|
128
154
|
}
|
|
129
155
|
function restoreDraft(choices = false) {
|
|
130
156
|
const draft = drafts.get(selectedProjectId) || {};
|
|
157
|
+
const staged=$('backlog-proposal'); if(staged)staged.checked=draft.backlog===true;
|
|
131
158
|
if (choices) {
|
|
132
159
|
for (const id of ['recipient','owner']) $(id).value = Array.from($(id).options).some((o) => o.value === draft[id]) ? draft[id] : '';
|
|
133
160
|
} else { $('message').value = draft.message || ''; $('title').value = draft.title || ''; $('detail').value = draft.detail || ''; $('class').value = draft.class || 'implement'; }
|
|
@@ -136,10 +163,21 @@ function syncMutationControls() { document.querySelectorAll('button[data-mutatio
|
|
|
136
163
|
function resetView(id, instance) {
|
|
137
164
|
saveDraft();
|
|
138
165
|
selectedProjectId = id || ''; selectedInstanceId = instance || ''; snapshotValid = false; cursor = 0; eventCount = 0; viewGeneration++;
|
|
166
|
+
lastSnapshot = null;
|
|
167
|
+
officePrev = null; officeDesks.clear(); officeCards.clear(); officeScene = null;
|
|
168
|
+
$('office').replaceChildren(); $('office-section').hidden = true;
|
|
169
|
+
const pet=$('pet-container');if(pet){pet.replaceChildren();pet.hidden=true;}
|
|
170
|
+
if(typeof petSignature!=='undefined')petSignature='';if(typeof petFirst!=='undefined')petFirst=undefined;if(typeof petBaseTitle!=='undefined')document.title=petBaseTitle;
|
|
139
171
|
signatures.clear(); $('stream').replaceChildren(); empty($('stream'), managerMode ? 'Select a running project...' : 'Waiting for hub activity...');
|
|
140
172
|
if (streamFilter) { streamFilter = ''; for (const child of $('stream-filters').children) child.setAttribute('aria-pressed', 'false'); }
|
|
141
173
|
$('stream-empty').hidden = true; $('task-filter').value = '';
|
|
142
|
-
|
|
174
|
+
if(typeof loadedPlanningDates!=='undefined')loadedPlanningDates.clear();
|
|
175
|
+
if(typeof planningEdit!=='undefined')planningEdit=null;if(typeof releaseEdit!=='undefined')releaseEdit=null; if (typeof boardViewState !== 'undefined') boardViewState=null; if (typeof showAllDone !== 'undefined') showAllDone=false;
|
|
176
|
+
const legend = $('task-legend'); if (legend) legend.replaceChildren(); const filter = $('task-peer-filter'); if (filter) filter.value = '';
|
|
177
|
+
$('split-titles').value='';$('split-reason').value='';$('split-sequential').checked=false;
|
|
178
|
+
$('release-name').value='';$('release-goal').value='';$('release-order').value='0';
|
|
179
|
+
$('delivery-report').textContent = ''; $('delivery-repo').value = ''; $('delivery-live').value = '';
|
|
180
|
+
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); }
|
|
143
181
|
$('settings-session').textContent = '';
|
|
144
182
|
$('peer-count').textContent = ''; $('task-count').textContent = ''; $('permission-count').textContent = ''; $('project-context').textContent = '';
|
|
145
183
|
notice('');
|
|
@@ -213,7 +251,7 @@ function renderApprovals(permissions) {
|
|
|
213
251
|
update('permissions', permissions, (target) => {
|
|
214
252
|
if (!permissions.length) return empty(target, 'No approvals waiting.');
|
|
215
253
|
for (const permission of permissions) {
|
|
216
|
-
const row = el('div', undefined, 'row'); row.append(el('div', permission.peer, 'label'), el('p', permission.title, 'body'));
|
|
254
|
+
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'));
|
|
217
255
|
if (permission.terminalOnly) row.append(el('p', 'Review in the console or ahub tail; answer with ahub permit. You can deny here.', 'meta'));
|
|
218
256
|
const actions = el('div', undefined, 'actions');
|
|
219
257
|
for (const option of permission.options) { const deny = option.kind === 'reject_once' || option.kind === 'reject_always'; if (permission.terminalOnly && !deny) continue; actions.append(button(option.name, {action:'permit', id:permission.id, option:option.optionId}, deny ? 'danger' : 'primary')); }
|
|
@@ -221,7 +259,7 @@ function renderApprovals(permissions) {
|
|
|
221
259
|
}
|
|
222
260
|
});
|
|
223
261
|
}
|
|
224
|
-
const SETTING_GROUPS = ['Permissions', 'Start', 'Routing', 'Switches'], INHERIT = '__inherit__';
|
|
262
|
+
const SETTING_GROUPS = ['Roster', 'Permissions', 'Roles', 'Start', 'Routing', 'Switches'], INHERIT = '__inherit__';
|
|
225
263
|
const settingsOpen = new Set(['Permissions']);
|
|
226
264
|
const settingText = (value) => value === null || value === undefined ? '(not set)' : Array.isArray(value) ? (value.join(', ') || '(none)') : String(value);
|
|
227
265
|
async function previewSetting(row, value, out, control) {
|
|
@@ -245,13 +283,27 @@ function settingRow(row, raise) {
|
|
|
245
283
|
const title = el('label', row.label, 'label'); title.htmlFor = id;
|
|
246
284
|
head.append(title, badge(settingText(row.value), row.group === 'Permissions' && row.value !== null && row.value !== 'ask' ? 'warn' : ''));
|
|
247
285
|
node.append(head, el('p', row.key + ' / from ' + row.source + ' / ' + (row.applies === 'live' ? 'applies at once' : row.applies === 'peer start' ? "applies at the peer's next start" : 'read at hub start') + ' / written to ' + row.file, 'meta'));
|
|
248
|
-
if (row.pending !== undefined) node.append(el('p', 'Pending restart: ' + settingText(row.pending) + ' at the next hub start.', 'meta'));
|
|
286
|
+
if (row.pending !== undefined) node.append(el('p', 'Pending restart: ' + settingText(row.pending) + ' at the next ' + (row.applies === 'peer start' ? 'peer' : 'hub') + ' start.', 'meta'));
|
|
249
287
|
if (row.hint) node.append(el('p', 'Bounds: ' + row.hint, 'meta'));
|
|
250
288
|
if (row.note) node.append(el('p', row.note, 'meta'));
|
|
251
289
|
if (row.risk === 'raises' && !raise) node.append(el('p', 'Raising this needs a settings session: run ahub ui --settings.' + (row.floor === undefined ? '' : ' Setting it to ' + row.floor + ' works here.'), 'meta'));
|
|
252
290
|
const actions = el('div', undefined, 'actions'), stored = row.file !== 'this hub run';
|
|
253
|
-
let read, confirm;
|
|
254
|
-
if (row.type === '
|
|
291
|
+
let read, confirm, force;
|
|
292
|
+
if (row.type === 'roles') {
|
|
293
|
+
const current = Array.isArray(row.pending) ? row.pending : Array.isArray(row.value) ? row.value : [];
|
|
294
|
+
const choices = [0, 1].map(index => {
|
|
295
|
+
const select = el('select'); select.id = index ? id + '-second' : id;
|
|
296
|
+
if (index) select.setAttribute('aria-label', row.label + ': second optional role');
|
|
297
|
+
select.append(Object.assign(el('option', 'No role'), {value:''}));
|
|
298
|
+
for (const value of row.values || []) select.append(Object.assign(el('option', value), {value}));
|
|
299
|
+
if (current[index] && !(row.values || []).includes(current[index])) select.append(Object.assign(el('option', current[index] + ' (unavailable)'), {value:current[index],disabled:true}));
|
|
300
|
+
select.value = current[index] || ''; actions.append(select); return select;
|
|
301
|
+
});
|
|
302
|
+
read = () => choices.map(select => select.value).filter(Boolean);
|
|
303
|
+
} else if (row.type === 'integer') {
|
|
304
|
+
const input=el('input');input.type='number';input.id=id;input.step='1';input.min=String(row.min ?? 0);input.max=String(row.max ?? Number.MAX_SAFE_INTEGER);const current=row.pending!==undefined ? row.pending : row.value;input.value=current===null ? '' : String(current);input.placeholder='Empty inherits';
|
|
305
|
+
read=()=>input.value==='' ? null : Number(input.value);actions.append(input);
|
|
306
|
+
} else if (row.type === 'peers') {
|
|
255
307
|
const input = el('input'); input.type = 'text'; input.id = id; input.value = Array.isArray(row.value) ? row.value.join(', ') : ''; input.placeholder = 'peer ids, comma-separated; empty inherits'; input.autocomplete = 'off'; input.spellcheck = false;
|
|
256
308
|
read = () => { const list = input.value.split(',').map((value) => value.trim()).filter(Boolean); return list.length ? list : null; };
|
|
257
309
|
actions.append(input);
|
|
@@ -273,19 +325,26 @@ function settingRow(row, raise) {
|
|
|
273
325
|
actions.append(confirm);
|
|
274
326
|
}
|
|
275
327
|
}
|
|
328
|
+
if (row.group === 'Roster') {
|
|
329
|
+
const peer = row.key.split('.').pop();
|
|
330
|
+
confirm = el('input'); confirm.type = 'text'; confirm.placeholder = 'type ' + peer + ' to confirm removal'; confirm.setAttribute('aria-label', 'Confirm removal of ' + peer); confirm.autocomplete = 'off'; confirm.spellcheck = false;
|
|
331
|
+
force = el('input'); force.type = 'checkbox'; force.setAttribute('aria-label', 'Stop an active turn now');
|
|
332
|
+
const label = el('label', ' Stop an active turn now (--now)'); label.append(force); actions.append(confirm, label);
|
|
333
|
+
}
|
|
276
334
|
const out = el('div', undefined, 'preview'); out.setAttribute('role', 'status'); out.setAttribute('aria-live', 'polite');
|
|
277
|
-
if (row.
|
|
335
|
+
if (row.type === 'roles') for (const select of actions.querySelectorAll('select')) select.addEventListener('change', () => { const ids = read(); out.textContent = new Set(ids).size !== ids.length ? 'Choose two distinct roles, or clear the second.' : 'Selection applies at the next peer start.'; });
|
|
336
|
+
if (row.group === 'Permissions' || row.group === 'Routing' || row.group === 'Roster') {
|
|
278
337
|
const preview = el('button', 'Preview'); preview.type = 'button'; preview.setAttribute('aria-label', 'Preview ' + row.label);
|
|
279
338
|
preview.addEventListener('click', () => void previewSetting(row, read(), out, preview)); actions.append(preview);
|
|
280
339
|
}
|
|
281
340
|
const save = el('button', 'Save', 'primary'); save.type = 'button'; save.dataset.mutation = 'project'; save.disabled = managerMode && !snapshotValid; save.setAttribute('aria-label', 'Save ' + row.label);
|
|
282
|
-
save.addEventListener('click', () => { const payload = {action:'setting', key:row.key, value:read()}; if (confirm && !confirm.hidden && confirm.value.trim()) payload.confirm = confirm.value.trim(); void action(payload, save); });
|
|
283
|
-
actions.append(save); node.append(actions, out);
|
|
341
|
+
save.addEventListener('click', async () => { const payload = {action:'setting', key:row.key, value:read()}; if (confirm && !confirm.hidden && confirm.value.trim()) payload.confirm = confirm.value.trim(); if (force) payload.now = force.checked; if (row.group === 'Roster') await previewSetting(row, read(), out, save); if (row.type === 'roles') { if (new Set(payload.value).size !== payload.value.length) { out.textContent = 'Choose two distinct roles, or clear the second.'; return; } out.textContent = 'Saving role guidance...'; const accepted = await action(payload, save); out.textContent = accepted ? 'Saved. Applies at the next peer start.' : $('notice').textContent; } else void action(payload, save); });
|
|
342
|
+
actions.append(save); if (row.type === 'roles') { const inherit = el('button', 'Use profile/shared roles'); inherit.type = 'button'; inherit.dataset.mutation = 'project'; inherit.disabled = managerMode && !snapshotValid; inherit.setAttribute('aria-label', 'Inherit ' + row.label); inherit.addEventListener('click', async () => { out.textContent = 'Restoring inherited role guidance...'; const accepted = await action({action:'setting',key:row.key,value:null}, inherit); out.textContent = accepted ? 'Inherited selection saved. Applies at the next peer start.' : $('notice').textContent; }); actions.append(inherit); } node.append(actions, out);
|
|
284
343
|
return node;
|
|
285
344
|
}
|
|
286
345
|
const settingSlots = new Map();
|
|
287
346
|
function setText(node, text) { if (node.textContent !== text) node.textContent = text; }
|
|
288
|
-
function renderSettings(settings) {
|
|
347
|
+
function renderSettings(settings, roster = []) {
|
|
289
348
|
if (!settings || !Array.isArray(settings.rows)) { setText($('settings-session'), ''); settingSlots.clear(); return update('settings', 'none', (target) => empty(target, 'This hub does not report settings.')); }
|
|
290
349
|
const raise = typeof settings.sessionUntil === 'number' && settings.sessionUntil > Date.now();
|
|
291
350
|
// Written only when it changes: a status region that is rewritten every second is read out every second.
|
|
@@ -310,13 +369,27 @@ function renderSettings(settings) {
|
|
|
310
369
|
undoSlot.signature = undoKey; undoSlot.node.replaceChildren();
|
|
311
370
|
if (undoKey) {
|
|
312
371
|
const row = el('div', undefined, 'row'); row.append(el('p', 'Last settings write: ' + undoKey, 'meta'));
|
|
313
|
-
const actions = el('div', undefined, 'actions');
|
|
372
|
+
const actions = el('div', undefined, 'actions');
|
|
373
|
+
if (undoKey.startsWith('roster.')) {
|
|
374
|
+
const peer = undoKey.slice('roster.'.length), confirm = el('input'); confirm.type = 'text'; confirm.placeholder = 'type ' + peer + ' if undo removes it'; confirm.setAttribute('aria-label', 'Confirm roster undo for ' + peer);
|
|
375
|
+
const force = el('input'); force.type = 'checkbox'; force.setAttribute('aria-label', 'Stop an active turn now for undo');
|
|
376
|
+
const undo = el('button', 'Undo last write'); undo.type = 'button'; undo.dataset.mutation = 'project'; undo.addEventListener('click', () => void action({action:'setting_undo', confirm:confirm.value.trim(), now:force.checked}, undo));
|
|
377
|
+
actions.append(confirm, el('label', 'Stop an active turn now'), force, undo);
|
|
378
|
+
} else actions.append(button('Undo last write', {action:'setting_undo'}));
|
|
379
|
+
row.append(actions); undoSlot.node.append(row);
|
|
314
380
|
}
|
|
315
381
|
}
|
|
316
382
|
for (const row of settings.rows) {
|
|
383
|
+
const membership = roster.find(peer => row.key === 'roster.' + peer.peer);
|
|
317
384
|
const slot = settingSlots.get(row.key), signature = JSON.stringify([row, raise]);
|
|
318
|
-
if (!slot
|
|
319
|
-
slot.signature
|
|
385
|
+
if (!slot) continue;
|
|
386
|
+
if (slot.signature !== signature) {
|
|
387
|
+
slot.signature = signature; slot.node.replaceChildren(settingRow(row, raise)); slot.membership = null;
|
|
388
|
+
}
|
|
389
|
+
if (membership) {
|
|
390
|
+
if (!slot.membership) { slot.membership = el('p', '', 'meta'); slot.node.append(slot.membership); }
|
|
391
|
+
setText(slot.membership, `${membership.state}; ${membership.queued} queued; ${membership.tasks} open tasks`);
|
|
392
|
+
}
|
|
320
393
|
}
|
|
321
394
|
}
|
|
322
395
|
function syncStarts(snapshot) {
|
|
@@ -336,57 +409,296 @@ function renderStarts(target, starts) {
|
|
|
336
409
|
target.append(row);
|
|
337
410
|
}
|
|
338
411
|
}
|
|
412
|
+
let boardViewState = null, showAllDone = false;
|
|
413
|
+
let planningEdit = null, releaseEdit = null;
|
|
414
|
+
const loadedPlanningDates = new Map();
|
|
415
|
+
const localDateInput = value => {
|
|
416
|
+
if(!Number.isSafeInteger(value) || value<=0 || value>8_640_000_000_000_000)return '';
|
|
417
|
+
const date=new Date(value),pad=(value,size=2)=>String(value).padStart(size,'0');
|
|
418
|
+
return pad(date.getFullYear(),4)+'-'+pad(date.getMonth()+1)+'-'+pad(date.getDate())+'T'+pad(date.getHours())+':'+pad(date.getMinutes())+':'+pad(date.getSeconds())+'.'+pad(date.getMilliseconds(),3);
|
|
419
|
+
};
|
|
420
|
+
function loadPlanningDate(id,value) {
|
|
421
|
+
const input=$(id);input.value=localDateInput(value);
|
|
422
|
+
loadedPlanningDates.set(id,{text:input.value,utc:Number.isSafeInteger(value)&&value>0&&value<=8_640_000_000_000_000 ? value : null});
|
|
423
|
+
}
|
|
424
|
+
const plannedDateInput = id => {
|
|
425
|
+
const value=$(id).value,loaded=loadedPlanningDates.get(id);
|
|
426
|
+
if(loaded && loaded.text===value)return loaded.utc;
|
|
427
|
+
return value ? new Date(value.replace(/^(\d{5,})-/,(_match,year)=>'+'+year.padStart(6,'0')+'-')).getTime() : null;
|
|
428
|
+
};
|
|
429
|
+
const dateText = value => !Number.isSafeInteger(value) || value<=0 || value>8_640_000_000_000_000 ? 'unknown' : new Date(value).toLocaleString();
|
|
430
|
+
function loadPlanning() {
|
|
431
|
+
const task=boardViewState?.snapshot.tasks.find(task=>task.id===Number($('planning-task').value));
|
|
432
|
+
planningEdit=task ? {id:task.id,revision:task.planningRevision ?? 0,context:boardContext()} : null;
|
|
433
|
+
if(!task)return;
|
|
434
|
+
$('planning-priority').value=task.priority == null ? '' : String(task.priority);$('planning-rank').value=task.rank == null ? '' : String(task.rank);
|
|
435
|
+
$('planning-release').value=task.release == null ? '' : String(task.release);$('planning-pinned').checked=task.pinned===true;
|
|
436
|
+
loadPlanningDate('planning-start',task.plannedStart);loadPlanningDate('planning-due',task.due);$('planning-reason').value='';
|
|
437
|
+
$('planning-version').textContent='Loaded current task plan.';
|
|
438
|
+
}
|
|
439
|
+
function loadReleasePlanning() {
|
|
440
|
+
const release=boardViewState?.snapshot.releases?.find(release=>release.id===Number($('release-edit').value));
|
|
441
|
+
releaseEdit=release ? {id:release.id,revision:release.revision,context:boardContext()} : null;
|
|
442
|
+
$('release-version').textContent=release ? 'Loaded current release.' : 'Select release.';
|
|
443
|
+
for(const [id,key] of [['release-start','plannedStart'],['release-cut','cut'],['release-ship','ship']])loadPlanningDate(id,release?.[key]);
|
|
444
|
+
}
|
|
445
|
+
function planningOptions(node, rows, label, selected) {
|
|
446
|
+
node.replaceChildren();const initial=el('option',label);initial.value='';node.append(initial);
|
|
447
|
+
for(const row of rows){const option=el('option',row.label);option.value=String(row.id);node.append(option)}
|
|
448
|
+
if(rows.some(row=>String(row.id)===selected))node.value=selected;
|
|
449
|
+
}
|
|
450
|
+
function renderPlanning(snapshot) {
|
|
451
|
+
const supported=snapshot.planningCapabilities?.enabled===true;
|
|
452
|
+
$('split-panel').hidden=snapshot.planningCapabilities?.split!==true;$('split-save').disabled=snapshot.planningCapabilities?.split!==true || !sameBoardContext(boardContext());
|
|
453
|
+
for(const id of ['planning-save','planning-undo','release-save'])$(id).disabled=!supported || !sameBoardContext(boardContext());
|
|
454
|
+
const releases=Array.isArray(snapshot.releases)?snapshot.releases:[], releaseRows=releases.map(release=>({id:release.id,label:'#'+release.id+' '+release.name}));
|
|
455
|
+
for(const id of ['task-release-filter','planning-release','release-edit'])planningOptions($(id),releaseRows,id==='task-release-filter'?'All releases':'No release',$(id).value);
|
|
456
|
+
if(releaseEdit && (!sameBoardContext(releaseEdit.context) || releases.find(release=>release.id===releaseEdit.id)?.revision!==releaseEdit.revision)){releaseEdit=null;$('release-version').textContent='Release changed. Reload before saving.';}
|
|
457
|
+
const selection=$('planning-task').value;planningOptions($('planning-task'),snapshot.tasks.filter(task=>task.state!=='approved').map(task=>({id:task.id,label:'#'+task.id+' '+task.title})),'Select task',selection);
|
|
458
|
+
if(planningEdit && (!sameBoardContext(planningEdit.context) || !snapshot.tasks.some(task=>task.id===planningEdit.id)))planningEdit=null;
|
|
459
|
+
if(planningEdit && snapshot.tasks.find(task=>task.id===planningEdit.id)?.planningRevision!==planningEdit.revision)$('planning-version').textContent='Task changed. Reload before saving.';
|
|
460
|
+
const states=new Map(snapshot.tasks.map(task=>[task.id,task.state]));
|
|
461
|
+
update('releases',{releases,taskStates:[...states]},target=>{
|
|
462
|
+
if(!releases.length)return empty(target,'No releases planned.');
|
|
463
|
+
const auditIds=(ids,count)=>ids.map(id=>'#'+id).join(', ')+(Number.isSafeInteger(count)&&count>ids.length?' (showing '+ids.length+' of '+count+')':'');
|
|
464
|
+
for(const release of releases){const row=el('div',undefined,'row');row.append(el('strong','#'+release.id+' '+release.name),badge(release.state),el('p',release.goal));
|
|
465
|
+
const scope=Array.isArray(release.tasks)?release.tasks:null, remaining=scope?.filter(task=>task.state!=='approved');
|
|
466
|
+
const blocking=remaining?.filter(task=>(task.deps || []).some(id=>states.get(id)!=='approved'));
|
|
467
|
+
row.append(el('p',scope?remaining.length+' remaining of '+scope.length+' / '+blocking.length+' blocked':'Scope unknown','meta'));
|
|
468
|
+
if(scope){const progress=el('progress');progress.max=Math.max(1,scope.length);progress.value=scope.length-remaining.length;progress.setAttribute('aria-label','Approved tasks in '+release.name);row.append(progress)}
|
|
469
|
+
row.append(el('p','Start '+dateText(release.plannedStart)+' / cut '+dateText(release.cut)+' / ship '+dateText(release.ship),'meta'));
|
|
470
|
+
row.append(el('p','Actual: active '+dateText(release.activatedAt)+' / frozen '+dateText(release.frozenAt)+' / shipped '+dateText(release.shippedAt),'meta'));
|
|
471
|
+
if(Array.isArray(release.afterCutUnknown) && release.afterCutUnknown.length)row.append(el('p','Timing relative to release cut unknown: '+auditIds(release.afterCutUnknown,release.afterCutUnknownCount),'meta'));
|
|
472
|
+
if(Array.isArray(release.afterCut) && release.afterCut.length)row.append(el('p','Approved after cut outside this release: '+auditIds(release.afterCut,release.afterCutCount),'meta'));
|
|
473
|
+
const controls=el('div',undefined,'actions');for(const [from,state,label] of [['planning','active','Activate'],['active','frozen','Freeze'],['frozen','shipped','Record shipped']])if(release.state===from)controls.append(boardButton(label,{action:'release_change',id:release.id,revision:release.revision,fields:{state}}));
|
|
474
|
+
if(!['shipped','dropped'].includes(release.state))controls.append(boardButton('Drop release',{action:'release_change',id:release.id,revision:release.revision,fields:{state:'dropped'}}));
|
|
475
|
+
row.append(controls);target.append(row);
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function peerMark(peer, reviewer = false) {
|
|
481
|
+
const key = Object.hasOwn(peerPalette, peer || '') ? peer : 'other';
|
|
482
|
+
const mark = el('span', undefined, 'peer-mark peer-' + key), color = el('span', undefined, 'peer-color-dot' + (reviewer ? ' reviewer' : ''));
|
|
483
|
+
color.setAttribute('aria-hidden', 'true'); mark.append(color, el('span', (reviewer ? 'reviewer: ' : 'owner: ') + (peer || 'unassigned'))); return mark;
|
|
484
|
+
}
|
|
485
|
+
function boardContext() {
|
|
486
|
+
const current=context(), snapshot=boardViewState?.snapshot || {};
|
|
487
|
+
return {...current,origin:location.origin,projectId:typeof snapshot.projectId==='string' ? snapshot.projectId : current.projectId,instanceId:typeof snapshot.instanceId==='string' ? snapshot.instanceId : current.instanceId};
|
|
488
|
+
}
|
|
489
|
+
function sameBoardContext(expected) {
|
|
490
|
+
const current=boardContext(); return !stopped && canMutate() && Boolean(current.projectId && current.instanceId) && expected.origin===current.origin && expected.projectId===current.projectId && expected.instanceId===current.instanceId && expected.generation===current.generation;
|
|
491
|
+
}
|
|
492
|
+
function boardButton(text, payload) {
|
|
493
|
+
const expected=boardContext(), node=el('button',text); node.type='button';node.dataset.mutation='project';node.disabled=managerMode && !snapshotValid;
|
|
494
|
+
node.addEventListener('click',()=>{if(!sameBoardContext(expected))return notice('The project changed. Use its current board.',true);void action(payload,node)});return node;
|
|
495
|
+
}
|
|
496
|
+
function draggedCard(event) {
|
|
497
|
+
if (!canMutate() || !boardViewState) return null;
|
|
498
|
+
try {
|
|
499
|
+
const raw = event.dataTransfer?.getData('application/x-agent-hub-task');
|
|
500
|
+
if (!raw || raw.length > 1024) return null;
|
|
501
|
+
const data = JSON.parse(raw), current = boardContext();
|
|
502
|
+
if (data.origin !== current.origin || data.projectId !== current.projectId || data.instanceId !== current.instanceId || data.generation !== current.generation || !Number.isSafeInteger(data.id)) return null;
|
|
503
|
+
return boardViewState.cards.get(data.id) || null;
|
|
504
|
+
} catch { return null; }
|
|
505
|
+
}
|
|
506
|
+
function dropTarget(node, accepted, payload) {
|
|
507
|
+
const expected=boardContext();
|
|
508
|
+
node.addEventListener('dragover', event => { if (sameBoardContext(expected)) { event.preventDefault(); node.classList.add('board-drop-active'); } });
|
|
509
|
+
node.addEventListener('dragleave', () => node.classList.remove('board-drop-active'));
|
|
510
|
+
node.addEventListener('drop', event => {
|
|
511
|
+
event.preventDefault(); node.classList.remove('board-drop-active');
|
|
512
|
+
if(!sameBoardContext(expected))return notice('The project changed. Use its current board.',true);
|
|
513
|
+
const card = draggedCard(event);
|
|
514
|
+
if (!card || !accepted(card)) return notice('This task cannot move here. Use its available buttons.', true);
|
|
515
|
+
void action({...payload, id:card.id});
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
function taskCard(task, card, stage, ids) {
|
|
519
|
+
const row = el('article', undefined, 'task-card'), head = el('div', undefined, 'row-head');
|
|
520
|
+
row.tabIndex = 0; taskRows.set(task.id,row);
|
|
521
|
+
const trackCard = on => { const sceneCard=officeCards.get(task.id);if(sceneCard)sceneCard.classList.toggle('office-spotlight',on); };
|
|
522
|
+
row.addEventListener('mouseenter',()=>trackCard(true));row.addEventListener('mouseleave',()=>trackCard(false));
|
|
523
|
+
row.addEventListener('focus',()=>trackCard(true));row.addEventListener('blur',()=>trackCard(false));
|
|
524
|
+
|
|
525
|
+
row.setAttribute('aria-label', '#' + task.id + ' ' + task.title + ', ' + task.state.replaceAll('_', ' ') + ', owner ' + (task.owner || 'unassigned') + ', priority P' + card.priority);
|
|
526
|
+
head.append(el('span', '#' + task.id + ' ' + task.title, 'label'), badge('P' + card.priority));
|
|
527
|
+
if (card.starred) { const star = el('span', '★'); star.setAttribute('aria-hidden','true'); head.append(star); }
|
|
528
|
+
row.append(head, peerMark(task.owner)); if (task.reviewer) row.append(peerMark(task.reviewer, true));
|
|
529
|
+
row.append(el('p', task.profileClass ? task.profileClass + ' (base ' + task.class + ')' : task.class, 'meta'));
|
|
530
|
+
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);}
|
|
531
|
+
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);}
|
|
532
|
+
if (stage) row.append(renderTaskStage(stage));
|
|
533
|
+
if (card.waiting.length) row.append(el('p', 'Waiting: ' + card.waiting.join('; '), 'meta'));
|
|
534
|
+
if (task.reworkRound) row.append(el('p', 'Rework round ' + task.reworkRound, 'meta'));
|
|
535
|
+
if (task.checking) row.append(el('p', 'Check running', 'meta'));
|
|
536
|
+
if (task.lifecycle) row.append(el('p', 'SDLC ' + task.lifecycle.mode + (task.lifecycle.workflow ? ' (' + task.lifecycle.workflow + ')' : '') + ' / ' + task.lifecycle.stage + ': ' + task.lifecycle.next + ' / next: ' + task.lifecycle.actor, 'meta'));
|
|
537
|
+
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'));
|
|
538
|
+
if (task.detail) row.append(el('p', task.detail, 'body'));
|
|
539
|
+
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);}
|
|
540
|
+
if(task.pinned)row.append(el('p','Pinned','meta'));
|
|
541
|
+
if(task.plannedStart != null || task.due != null)row.append(el('p','Planned '+dateText(task.plannedStart)+' / due '+dateText(task.due),'meta'));
|
|
542
|
+
if(task.due != null && task.due<Date.now() && task.state!=='approved')row.append(badge('Overdue','warn'));
|
|
543
|
+
if(task.actualDates)row.append(el('p','Actual start '+dateText(task.actualDates.startedAt)+' / review '+dateText(task.actualDates.reviewAt)+' / done '+dateText(task.actualDates.doneAt),'meta'));
|
|
544
|
+
|
|
545
|
+
const controls = el('div', undefined, 'actions');
|
|
546
|
+
if (card.pull && boardViewState?.snapshot.boardCapabilities?.backlog===true) controls.append(boardButton('Pull into To Do', {action:'backlog_pull',id:task.id}));
|
|
547
|
+
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'}));
|
|
548
|
+
if (card.returnable && boardViewState?.snapshot.boardCapabilities?.backlog===true) controls.append(boardButton('Return to Backlog', {action:'backlog_return',id:task.id}));
|
|
549
|
+
if (card.assignable && ids.length) {
|
|
550
|
+
const expected=boardContext(), select = el('select'); select.setAttribute('aria-label', 'Assign task ' + task.id); peerOptions(select, ids, 'Select peer'); if (ids.includes(task.owner)) select.value = task.owner;
|
|
551
|
+
const assign = el('button', 'Assign'); assign.type='button'; assign.dataset.mutation='project'; assign.disabled=managerMode && !snapshotValid;
|
|
552
|
+
assign.addEventListener('click', () => { if(!sameBoardContext(expected))return notice('The project changed. Use its current board.',true);if(select.value)void action({action:'assign',id:task.id,peer:select.value},assign);else notice('Select a peer before assigning this task.',true); }); controls.append(select,assign);
|
|
553
|
+
}
|
|
554
|
+
if (controls.children.length) row.append(controls);
|
|
555
|
+
if (card.pull || card.assignable) {
|
|
556
|
+
row.draggable = true; row.title = 'Drag to an available peer or Backlog/To Do, or use the buttons.';
|
|
557
|
+
row.addEventListener('dragstart', event => {
|
|
558
|
+
if (!canMutate() || !event.dataTransfer) return event.preventDefault();
|
|
559
|
+
event.dataTransfer.effectAllowed='move'; event.dataTransfer.setData('application/x-agent-hub-task',JSON.stringify({...boardContext(),id:task.id}));
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
return row;
|
|
563
|
+
}
|
|
564
|
+
function renderBoard(snapshot, peers, ids) {
|
|
565
|
+
if (!Array.isArray(snapshot.tasks)) { boardViewState=null; return update('tasks','unavailable',target=>empty(target,'Task board unavailable.')); }
|
|
566
|
+
const progress = taskProgress(snapshot.tasks), model = boardColumns(snapshot.tasks, peers);
|
|
567
|
+
renderTaskProgress(progress);
|
|
568
|
+
const stages = new Map(progress.stages.map(stage=>[stage.id,stage])), byId = new Map(snapshot.tasks.map(task=>[task.id,task]));
|
|
569
|
+
const cards = new Map(model.columns.flatMap(column=>[...column.ready,...column.waiting]).map(card=>[card.id,card]));
|
|
570
|
+
boardViewState={snapshot,peers,ids,cards};
|
|
571
|
+
renderPlanning(snapshot);
|
|
572
|
+
const staged=$('backlog-proposal');staged.disabled=snapshot.boardCapabilities?.backlog!==true || !sameBoardContext(boardContext());
|
|
573
|
+
if(snapshot.boardCapabilities?.backlog!==true)staged.checked=false;
|
|
574
|
+
const peerFilter=$('task-peer-filter'), previous=peerFilter.value;
|
|
575
|
+
peerOptions(peerFilter,ids,'All agents'); if (ids.includes(previous)) peerFilter.value=previous;
|
|
576
|
+
const taskFilter=$('task-filter').value, ownerFilter=peerFilter.value, releaseFilter=$('task-release-filter').value, lanes=$('task-lanes').value;
|
|
577
|
+
const eligible=ids.filter(id=>['idle','busy'].includes(peers[id].state) && peers[id].attached!==false && peers[id].rostered!==false);
|
|
578
|
+
$('task-count').textContent=snapshot.tasks.length+' tasks';
|
|
579
|
+
update('task-legend',[snapshot.boardLegend,peers],target=>{
|
|
580
|
+
for (const id of ids) {
|
|
581
|
+
const data=(snapshot.boardLegend || []).find(item=>item.peer===id), row=el('div',undefined,'agent-slot');
|
|
582
|
+
row.append(peerMark(id),el('p',String(peers[id].state || 'unknown'),'meta'));
|
|
583
|
+
const wip=data && Number.isSafeInteger(data.wip) && data.wip>=0 ? data.wip : 'unknown';
|
|
584
|
+
row.append(el('p','WIP '+wip+(Number.isSafeInteger(data?.wipLimit) ? '/'+data.wipLimit : '')+' / permission '+(data?.permissionMode || 'unknown'),'meta'));
|
|
585
|
+
if (Array.isArray(data?.activeTasks) && data.activeTasks.length) row.append(el('p','Active tasks: '+data.activeTasks.map(id=>'#'+id).join(', '),'meta'));
|
|
586
|
+
const filter=el('button','View '+id);filter.type='button';filter.addEventListener('click',()=>{if(!boardViewState)return;peerFilter.value=id;renderBoard(boardViewState.snapshot,boardViewState.peers,boardViewState.ids)});row.append(filter);
|
|
587
|
+
if (eligible.includes(id)) dropTarget(row,card=>card.assignable,{action:'assign',peer:id});
|
|
588
|
+
target.append(row);
|
|
589
|
+
}
|
|
590
|
+
if (!ids.length) empty(target,'No peer status available.');
|
|
591
|
+
});
|
|
592
|
+
update('tasks',[snapshot.tasks,peers,snapshot.boardCapabilities,taskFilter,ownerFilter,releaseFilter,lanes,showAllDone],target=>{
|
|
593
|
+
taskRows.clear();
|
|
594
|
+
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)));
|
|
595
|
+
const visibleIds=new Set(visible.map(task=>task.id));
|
|
596
|
+
const owners=lanes==='agents' ? [...new Set(visible.map(task=>task.owner || ''))] : lanes==='releases' ? [...new Set(visible.map(task=>task.release ?? 0))] : [null];
|
|
597
|
+
if (!owners.length) owners.push(null);
|
|
598
|
+
for (const [laneIndex, owner] of owners.entries()) {
|
|
599
|
+
const lane=el('div',undefined,'board-lane');
|
|
600
|
+
if(owner!==null) {
|
|
601
|
+
const heading=el('h3',lanes==='releases' ? (snapshot.releases?.some(release=>release.id===owner) ? '#'+owner+' '+snapshot.releases.find(release=>release.id===owner).name : 'No release') : owner || 'Unassigned');
|
|
602
|
+
if(lanes==='agents' && eligible.includes(owner)) dropTarget(heading,card=>card.assignable,{action:'assign',peer:owner});
|
|
603
|
+
lane.append(heading);
|
|
604
|
+
}
|
|
605
|
+
const grid=el('div',undefined,'board-columns');
|
|
606
|
+
const inLane=card=>visibleIds.has(card.id) && (owner===null || (lanes==='releases' ? (byId.get(card.id)?.release ?? 0)===owner : (byId.get(card.id)?.owner || '')===owner));
|
|
607
|
+
for (const column of model.columns) {
|
|
608
|
+
const box=el('div',undefined,'board-column'), heading=el('h3',column.label);heading.id='board-lane-'+laneIndex+'-'+column.id;box.setAttribute('role','group');box.setAttribute('aria-labelledby',heading.id);box.append(heading);
|
|
609
|
+
if (column.id==='backlog' && snapshot.boardCapabilities?.backlog===true) dropTarget(box,card=>card.returnable,{action:'backlog_return'});
|
|
610
|
+
if (column.id==='todo' && snapshot.boardCapabilities?.backlog===true) dropTarget(box,card=>card.pull,{action:'backlog_pull'});
|
|
611
|
+
let ready=column.ready.filter(inLane), waiting=column.waiting.filter(inLane);
|
|
612
|
+
if(column.id==='done') ready.sort((a,b)=>(byId.get(b.id)?.updated || 0)-(byId.get(a.id)?.updated || 0) || b.id-a.id);
|
|
613
|
+
const hidden=column.id==='done' && !showAllDone ? Math.max(0,ready.length-10) : 0;
|
|
614
|
+
if(hidden) ready=ready.slice(0,10);
|
|
615
|
+
for(const [label,list] of [['Ready',ready],['Waiting',waiting]]) {
|
|
616
|
+
if(column.id==='todo') box.append(el('h4',label+' ('+list.length+')'));
|
|
617
|
+
for(const card of list) box.append(taskCard(byId.get(card.id),card,stages.get(card.id),eligible));
|
|
618
|
+
}
|
|
619
|
+
if(!ready.length && !waiting.length) empty(box,'No tasks');
|
|
620
|
+
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)}
|
|
621
|
+
grid.append(box);
|
|
622
|
+
}
|
|
623
|
+
lane.append(grid);target.append(lane);
|
|
624
|
+
}
|
|
625
|
+
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'));
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
const petBaseTitle = typeof document === 'undefined' ? 'agent-hub | Local dashboard' : document.title;
|
|
629
|
+
let petSignature = '', petFirst;
|
|
630
|
+
function reachPetNeed() {
|
|
631
|
+
if (!snapshotValid || !petFirst) return;
|
|
632
|
+
const item = petFirst;
|
|
633
|
+
const row = item.kind === 'review' || item.kind === 'offline' ? taskRows.get(item.id) : item.kind === 'delivery' || item.kind === 'default' ? peerRows.get(item.peer) : item.kind === 'approval' ? document.querySelector('[data-permission-id="'+item.id+'"]') : undefined;
|
|
634
|
+
if (row) { row.scrollIntoView({block:'center',behavior:'instant'}); row.focus?.(); return; }
|
|
635
|
+
const heading = item.kind === 'approval' ? 'permissions-title' : ['review','offline'].includes(item.kind) ? 'tasks-title' : item.kind === 'budget' ? 'budget-title' : 'peers-title';
|
|
636
|
+
$(heading)?.scrollIntoView({block:'start',behavior:'instant'});
|
|
637
|
+
notice('Attention: '+item.kind+(item.peer?' '+item.peer:'')+(item.id?' '+item.id:''));
|
|
638
|
+
}
|
|
639
|
+
function renderPetAttention(snapshot) {
|
|
640
|
+
const valid = snapshot && snapshot.pet?.enabled === true && typeof snapshot.pet.character === 'string' && Object.hasOwn(petCharacters,snapshot.pet.character) && snapshot.attention && typeof snapshot.attention === 'object' && !Array.isArray(snapshot.attention);
|
|
641
|
+
const target=$('pet-container');if(!target)return;
|
|
642
|
+
const input=snapshot?.attention;
|
|
643
|
+
const shape=valid && Array.isArray(input.approvals) && Array.isArray(input.defaults) && Array.isArray(input.tasks) && input.peers && typeof input.peers==='object' && !Array.isArray(input.peers) && input.budget && typeof input.budget==='object' && !Array.isArray(input.budget) && typeof input.storageBlocked==='boolean';
|
|
644
|
+
let value;try{if(shape)value=needs(input,Date.now());}catch{ /* malformed optional attention never breaks the existing dashboard */ }
|
|
645
|
+
const signature = JSON.stringify(value ? [snapshot.pet.character,snapshot.pet.blink,value] : null);
|
|
646
|
+
if(signature===petSignature)return;
|
|
647
|
+
petSignature=signature;petFirst=value?.items[0];
|
|
648
|
+
target.replaceChildren();target.hidden=!value;
|
|
649
|
+
const title=value?.act?'('+value.act+') agent-hub':petBaseTitle;if(document.title!==title)document.title=title;
|
|
650
|
+
if(!value)return;
|
|
651
|
+
const character=petCharacters[snapshot.pet.character],frame=character.frames.blocks[value.state].full,model=petPixels(frame);
|
|
652
|
+
const blink = snapshot.pet.blink!==false && value.items.some(item=>item.kind==='approval');
|
|
653
|
+
const button=el('button',undefined,'pet-control'+(blink?' pet-waiting':''));button.type='button';
|
|
654
|
+
const label=character.name+': '+value.state+', '+value.act+' action, '+value.watch+' watch';button.setAttribute('aria-label',label);button.addEventListener('click',reachPetNeed);
|
|
655
|
+
const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');svg.setAttribute('viewBox','0 0 '+model.width+' '+model.height);svg.setAttribute('class','pet-art');svg.setAttribute('aria-hidden','true');
|
|
656
|
+
const accent=character.accent?.full;
|
|
657
|
+
const poses=blink?[['pet-open',model],['pet-blink',petPixels(character.frames.blocks.waitingBlink.full)]]:[['pet-open',model]];
|
|
658
|
+
for(const [cls,pose] of poses){const group=document.createElementNS('http://www.w3.org/2000/svg','g');group.setAttribute('class',cls);
|
|
659
|
+
for(const [x,y] of pose.pixels){const pixel=document.createElementNS('http://www.w3.org/2000/svg','rect');pixel.setAttribute('x',String(x));pixel.setAttribute('y',String(y));pixel.setAttribute('width','1');pixel.setAttribute('height','1');pixel.setAttribute('class',accent && Math.floor(y/8)===accent.row && Math.floor(x/5)>=accent.start && Math.floor(x/5)<accent.start+accent.length?'pet-pixel pet-accent':'pet-pixel');group.append(pixel);}svg.append(group);}
|
|
660
|
+
button.append(svg,el('span',value.state+' / '+value.act+' action'+(value.watch?' +'+value.watch+' watch':'')));target.append(button);
|
|
661
|
+
}
|
|
339
662
|
function render(snapshot) {
|
|
340
|
-
|
|
663
|
+
renderPetAttention(snapshot);
|
|
664
|
+
const peers = Object.fromEntries(Object.entries(snapshot.status.peers || {}).map(([id, peer]) => [id, {...peer, ...(snapshot.status.roleInfo || {})[id]}])), ids = Object.keys(peers), starts = Array.isArray(snapshot.starts) ? snapshot.starts : [];
|
|
341
665
|
$('peer-count').textContent = ids.length + ' peers';
|
|
342
666
|
syncStarts(snapshot);
|
|
343
667
|
update('peers', [peers, starts, starts.map(start => { const pending = pendingStarts.get(startKey(start.peer)); return pending ? [pending.phase, pending.retryAt || null] : null; })], (target) => {
|
|
668
|
+
peerRows.clear();
|
|
344
669
|
if (!ids.length) { empty(target, 'No peers connected. Start one below, or from your terminal.'); return renderStarts(target, starts); }
|
|
345
670
|
for (const [id, peer] of Object.entries(peers)) {
|
|
346
671
|
const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head'), name = el('div', undefined, 'peername');
|
|
347
672
|
name.append(dot(peerDot[peer.state] || ''), el('span', id, 'label'));
|
|
348
|
-
head.append(name, badge(peer.state, peerDot[peer.state] || ''));
|
|
673
|
+
head.append(name, badge(peer.rostered === false ? peer.state + ' off roster' : peer.state, peerDot[peer.state] || ''));
|
|
349
674
|
row.append(head, el('p', peer.queued + ' queued' + (peer.queuedImportant ? ' (' + peer.queuedImportant + ' important)' : ''), 'meta'));
|
|
675
|
+
if (peer.flowRoles) row.append(el('p', 'Flow roles: ' + (peer.flowRoles.join(', ') || 'none'), 'meta'));
|
|
676
|
+
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')); }
|
|
350
677
|
if (peer.needsReview) row.append(el('p', peer.needsReview + ' need review: use ahub queue list', 'meta'));
|
|
351
678
|
if (peer.oldestQueuedAt !== undefined) row.append(el('p', 'Oldest pending: ' + Math.max(0, Math.floor((Date.now() - peer.oldestQueuedAt) / 1000)) + 's', 'meta'));
|
|
352
679
|
if (peer.attached === false) row.append(el('p', 'Disconnected recipient; work is retained.', 'meta'));
|
|
353
680
|
if (peer.toolsOnly) row.append(el('p', peer.toolsOnly, 'meta'));
|
|
681
|
+
if (peer.permissionNote) row.append(el('p', peer.permissionNote, 'meta'));
|
|
682
|
+
if (peer.pullOnly && id === 'kimi') 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'));
|
|
354
683
|
if (peer.paused) row.append(el('p', peer.paused, 'meta'));
|
|
355
684
|
if (peer.requestedModel) row.append(el('p', 'Requested: ' + peer.requestedModel, 'meta'));
|
|
356
685
|
for (const backend of peer.backends || []) {
|
|
357
686
|
if (backend.requestedModel) row.append(el('p', backend.kind.toUpperCase() + ': ' + (backend.actualModel || 'model not reported') + ' (' + backend.state + ')', 'meta'));
|
|
358
687
|
}
|
|
359
688
|
const paused = peer.state === 'paused' || peer.paused;
|
|
689
|
+
row.tabIndex = 0; peerRows.set(id, row);
|
|
690
|
+
const trackDesk = (on) => { const desk = officeDesks.get(id); if (desk) desk.classList.toggle('office-spotlight', on); };
|
|
691
|
+
row.addEventListener('mouseenter', () => trackDesk(true)); row.addEventListener('mouseleave', () => trackDesk(false));
|
|
692
|
+
row.addEventListener('focus', () => trackDesk(true)); row.addEventListener('blur', () => trackDesk(false));
|
|
360
693
|
row.append(button(paused ? 'Resume' : 'Pause', {action:paused ? 'resume' : 'pause', peer:id})); target.append(row);
|
|
361
694
|
}
|
|
362
695
|
renderStarts(target, starts);
|
|
363
696
|
});
|
|
697
|
+
renderOffice(snapshot);
|
|
364
698
|
const assignable = ids.filter((id) => peers[id].state !== 'offline' && peers[id].attached !== false);
|
|
365
699
|
const peerSignature = JSON.stringify([ids, assignable]);
|
|
366
700
|
if (signatures.get('peer-options') !== peerSignature) { signatures.set('peer-options', peerSignature); peerOptions($('owner'), assignable, 'Automatic'); peerOptions($('recipient'), ids, 'All peers'); if (draftChoicesPending) { restoreDraft(true); draftChoicesPending = false; } }
|
|
367
|
-
|
|
368
|
-
renderTaskProgress(progress);
|
|
369
|
-
const progressStages = new Map(progress.stages.map(stage => [stage.id, stage]));
|
|
370
|
-
$('task-count').textContent = snapshot.tasks.length + ' tasks';
|
|
371
|
-
const taskFilter = $('task-filter').value;
|
|
372
|
-
const visibleTasks = taskFilter ? snapshot.tasks.filter((task) => task.state === taskFilter) : snapshot.tasks;
|
|
373
|
-
update('tasks', [visibleTasks, ids, taskFilter, progress], (target) => {
|
|
374
|
-
if (!snapshot.tasks.length) return empty(target, 'No public tasks yet. Propose a task below to get started.');
|
|
375
|
-
if (!visibleTasks.length) return empty(target, 'No tasks in this state.');
|
|
376
|
-
for (const task of visibleTasks) {
|
|
377
|
-
const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head');
|
|
378
|
-
head.append(el('span', '#' + task.id + ' ' + task.title, 'label'), badge(task.state.replaceAll('_', ' '), taskBadge[task.state] || ''));
|
|
379
|
-
const stage = progressStages.get(task.id);
|
|
380
|
-
row.append(head, renderTaskStage(stage), el('p', task.class + ' / ' + (task.owner || 'unassigned') + (task.reviewer ? ' / review: ' + task.reviewer : ''), 'meta'));
|
|
381
|
-
if (task.detail) row.append(el('p', task.detail, 'body'));
|
|
382
|
-
if (task.state !== 'approved' && ids.length) {
|
|
383
|
-
const actions = el('div', undefined, 'actions'), select = el('select'); select.setAttribute('aria-label', 'Assign task ' + task.id); peerOptions(select, ids, 'Select peer'); if (ids.includes(task.owner)) select.value = task.owner;
|
|
384
|
-
const assign = el('button', 'Assign'); assign.type = 'button'; assign.dataset.mutation = 'project'; assign.disabled = managerMode && !snapshotValid; assign.addEventListener('click', () => { if (select.value) void action({action:'assign', id:task.id, peer:select.value}, assign); else notice('Select a peer before assigning this task.', true); });
|
|
385
|
-
actions.append(select, assign); row.append(actions);
|
|
386
|
-
}
|
|
387
|
-
target.append(row);
|
|
388
|
-
}
|
|
389
|
-
});
|
|
701
|
+
renderBoard(snapshot, peers, ids);
|
|
390
702
|
renderApprovals(snapshot.permissions);
|
|
391
703
|
update('budget', snapshot.budget, (target) => {
|
|
392
704
|
if (!Object.keys(snapshot.budget).length) return empty(target, 'No usage readings yet. Usage appears when a peer reports it.');
|
|
@@ -413,7 +725,8 @@ function render(snapshot) {
|
|
|
413
725
|
}
|
|
414
726
|
});
|
|
415
727
|
renderBench(snapshot.bench);
|
|
416
|
-
|
|
728
|
+
renderMemory(snapshot.memory);
|
|
729
|
+
renderSettings(snapshot.settings, snapshot.roster);
|
|
417
730
|
const stream = $('stream');
|
|
418
731
|
if (!eventCount) { stream.replaceChildren(); empty(stream, 'No activity yet.'); }
|
|
419
732
|
for (const entry of snapshot.events || []) {
|
|
@@ -438,7 +751,7 @@ async function poll() {
|
|
|
438
751
|
const captured = context();
|
|
439
752
|
if (managerMode && (!captured.projectId || !captured.instanceId)) { if (!stopped) timer = setTimeout(poll, 1000); return; }
|
|
440
753
|
try {
|
|
441
|
-
const input = {after:cursor}; if (managerMode) { input.projectId = captured.projectId; input.instanceId = captured.instanceId; }
|
|
754
|
+
const input = {after:cursor, memory:memoryInput()}; if (managerMode) { input.projectId = captured.projectId; input.instanceId = captured.instanceId; }
|
|
442
755
|
const result = await post('/snapshot', input);
|
|
443
756
|
if (!managerMode || (result.projectId === captured.projectId && result.instanceId === captured.instanceId && captured.generation === viewGeneration)) {
|
|
444
757
|
snapshotValid = true; lastSnapshot = result; render(result); $('connection').textContent = 'Live / updated ' + new Date().toLocaleTimeString();
|
|
@@ -447,6 +760,343 @@ async function poll() {
|
|
|
447
760
|
catch (error) { if (!stopped) $('connection').textContent = 'Disconnected / retrying: ' + error.message; }
|
|
448
761
|
if (!stopped) timer = setTimeout(poll, 1000);
|
|
449
762
|
}
|
|
763
|
+
// office-scene-begin (issue 264): the office, drawn Flat or Isometric from the shared officeModel as inline
|
|
764
|
+
// SVG, re-rendered only when its signature changed; no timer, no frame loop, no request and no action here.
|
|
765
|
+
// Motion is CSS only: transform/opacity transitions for card moves, keyframe fades for arcs, alerts and the
|
|
766
|
+
// busy latch, timed at render from the model's timestamps; expired nodes purge on the next changed render or
|
|
767
|
+
// their own animationend. The projection (phase 4) maps grid coordinates only: x' = (x - y) cos 30, y' =
|
|
768
|
+
// (x + y) sin 30; nodes are laid out upright in local coordinates, so text is never rotated and the DOM
|
|
769
|
+
// order and accessible names do not depend on the projection.
|
|
770
|
+
let officeProjection = 'flat';
|
|
771
|
+
let officeLastProjection = '';
|
|
772
|
+
const officeRound = (v) => Math.round(v * 100) / 100;
|
|
773
|
+
function officeIsoLocal(x, y) { return [(x - y) * 0.8660254, (x + y) * 0.5]; }
|
|
774
|
+
function officeProject(x, y) { return officeProjection === 'iso' ? officeIsoLocal(x, y) : [x, y]; }
|
|
775
|
+
function officePlace(node, x, y) { const p = officeProject(x, y); node.style.transform = 'translate(' + p[0] + 'px,' + p[1] + 'px)'; }
|
|
776
|
+
function officePolygon(points, cls) { return officeNode('polygon', { points: points.map((p) => p.map(officeRound).join(',')).join(' '), class: cls }); }
|
|
777
|
+
function officeTile(w, h, cls) {
|
|
778
|
+
if (officeProjection !== 'iso') return officeNode('rect', { x: 0, y: 0, width: w, height: h, rx: 1, class: cls });
|
|
779
|
+
return officePolygon([[0, 0], officeIsoLocal(w, 0), officeIsoLocal(w, h), officeIsoLocal(0, h)], cls);
|
|
780
|
+
}
|
|
781
|
+
function officeViewBox(w, h) {
|
|
782
|
+
if (officeProjection !== 'iso') return '0 0 ' + w + ' ' + h;
|
|
783
|
+
const corners = [officeProject(0, 0), officeProject(w, 0), officeProject(0, h), officeProject(w, h)];
|
|
784
|
+
const xs = corners.map((p) => p[0]), ys = corners.map((p) => p[1]), m = 3;
|
|
785
|
+
const minX = Math.min(...xs), minY = Math.min(...ys);
|
|
786
|
+
return (minX - m) + ' ' + (minY - m) + ' ' + (Math.max(...xs) - minX + 2 * m) + ' ' + (Math.max(...ys) - minY + 2 * m);
|
|
787
|
+
}
|
|
788
|
+
// A raised hand as outline shapes (palm and fingers), coloured by the desk's condition class.
|
|
789
|
+
function officeHand(x, y) {
|
|
790
|
+
const g = officeNode('g', { class: 'office-handshape' });
|
|
791
|
+
g.append(officeNode('rect', { x: x + 0.05, y: y + 0.8, width: 1.5, height: 1.2, rx: 0.55 }));
|
|
792
|
+
for (let i = 0; i < 3; i++) g.append(officeNode('rect', { x: x + 0.24 + i * 0.42, y, width: 0.28, height: 1.05, rx: 0.14 }));
|
|
793
|
+
return g;
|
|
794
|
+
}
|
|
795
|
+
// The condition glyph as clean outline shapes; the label text next to it carries the state in words.
|
|
796
|
+
function officeGlyphNode(desk, cx, cy) {
|
|
797
|
+
if (desk.condition === 'waiting') return officeHand(cx - 0.8, cy - 1.05);
|
|
798
|
+
const g = officeNode('g', { class: 'office-glyph-shape' });
|
|
799
|
+
if (desk.condition === 'busy' || desk.condition === 'idle') g.append(officeNode('circle', { cx, cy, r: 0.8 }));
|
|
800
|
+
else if (desk.condition === 'paused') g.append(officeNode('rect', { x: cx - 0.65, y: cy - 0.75, width: 0.45, height: 1.5, rx: 0.15 }), officeNode('rect', { x: cx + 0.2, y: cy - 0.75, width: 0.45, height: 1.5, rx: 0.15 }));
|
|
801
|
+
else if (desk.condition === 'offline') g.append(officeNode('line', { x1: cx - 0.55, y1: cy - 0.55, x2: cx + 0.55, y2: cy + 0.55 }), officeNode('line', { x1: cx - 0.55, y1: cy + 0.55, x2: cx + 0.55, y2: cy - 0.55 }));
|
|
802
|
+
else g.append(officeNode('text', { x: cx - 0.4, y: cy + 0.55, class: 'office-glyph' }, desk.glyph));
|
|
803
|
+
return g;
|
|
804
|
+
}
|
|
805
|
+
// A person: head over shoulders, upright in both projections. Isometric stands behind the desk.
|
|
806
|
+
function officeAvatar() {
|
|
807
|
+
if (officeProjection === 'iso') return [
|
|
808
|
+
officeNode('circle', { cx: 1.5, cy: -3.7, r: 1.05, class: 'office-avatar' }),
|
|
809
|
+
officeNode('rect', { x: 0.55, y: -2.85, width: 1.9, height: 3.05, rx: 0.9, class: 'office-avatar' })];
|
|
810
|
+
return [
|
|
811
|
+
officeNode('circle', { cx: 2.2, cy: 3.1, r: 0.78, class: 'office-avatar' }),
|
|
812
|
+
officeNode('path', { d: 'M 1.2 4.6 A 1 1 0 0 1 3.2 4.6 Z', class: 'office-avatar' })];
|
|
813
|
+
}
|
|
814
|
+
// The Done shelf: a small cabinet of stacked card rows (flat) or boxes on a slab (iso); the count stays text.
|
|
815
|
+
function officeShelf(iso) {
|
|
816
|
+
if (!iso) {
|
|
817
|
+
const nodes = [officeNode('rect', { x: 6.5, y: 4.4, width: 9, height: 10.4, rx: 1, class: 'office-shelf' })];
|
|
818
|
+
for (const sy of [7.8, 11.2]) nodes.push(officeNode('line', { x1: 6.5, y1: sy, x2: 15.5, y2: sy, class: 'office-divider' }));
|
|
819
|
+
for (const sy of [5.1, 8.5, 11.9]) for (const sx of [7.2, 11.4]) nodes.push(officeNode('rect', { x: sx, y: sy, width: 3.4, height: 2.1, rx: 0.5, class: 'office-shelf-card' }));
|
|
820
|
+
return nodes;
|
|
821
|
+
}
|
|
822
|
+
const nodes = [officePolygon([officeIsoLocal(6, 3), officeIsoLocal(16, 3), officeIsoLocal(16, 9), officeIsoLocal(6, 9)], 'office-shelf')];
|
|
823
|
+
for (const [bx, by] of [[7, 4], [11.5, 4], [9.2, 6.2]]) {
|
|
824
|
+
const s = 2.4, rise = 2.2;
|
|
825
|
+
const top = [officeIsoLocal(bx, by), officeIsoLocal(bx + s, by), officeIsoLocal(bx + s, by + s), officeIsoLocal(bx, by + s)];
|
|
826
|
+
const up = top.map(([x, y]) => [x, y - rise]);
|
|
827
|
+
nodes.push(officePolygon(up, 'office-box-top'), officePolygon([up[3], up[2], top[2], top[3]], 'office-box-left'), officePolygon([up[2], up[1], top[1], top[2]], 'office-box-right'));
|
|
828
|
+
}
|
|
829
|
+
return nodes;
|
|
830
|
+
}
|
|
831
|
+
function officeClip(text, max) { text = String(text); return text.length > max ? text.slice(0, max - 3) + '...' : text; }
|
|
832
|
+
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; }
|
|
833
|
+
function officeKeys(event, activate) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); activate(); } }
|
|
834
|
+
let spotlitRow = null;
|
|
835
|
+
function spotlightRow(row) { if (spotlitRow && spotlitRow !== row) spotlitRow.classList.remove('spotlight'); spotlitRow = row; if (row) row.classList.add('spotlight'); }
|
|
836
|
+
function activateOfficePeer(id) { const row = peerRows.get(id); if (!row) return; spotlightRow(row); row.scrollIntoView({ block: 'nearest' }); }
|
|
837
|
+
function activateOfficeTask(id) { if (!taskRows.has(id)) officeFilter(''); const row = taskRows.get(id); if (!row) return; spotlightRow(row); row.scrollIntoView({ block: 'nearest' }); }
|
|
838
|
+
function officeFilter(state) { $('task-filter').value = state; $('task-peer-filter').value = ''; $('task-release-filter').value = ''; if (!stopped && snapshotValid && lastSnapshot) render(lastSnapshot); }
|
|
839
|
+
// Fade timing for a transient mark: it holds, then fades over the final second so it is gone at `until`.
|
|
840
|
+
// Applied once at creation; later renders leave the running animation alone.
|
|
841
|
+
function officeFade(node, until, now) {
|
|
842
|
+
node.dataset.officeUntil = String(until);
|
|
843
|
+
const remaining = Math.max(0, until - now);
|
|
844
|
+
if (remaining <= 0) return false;
|
|
845
|
+
if (officeReducedMotion()) return true;
|
|
846
|
+
const duration = Math.min(1000, remaining);
|
|
847
|
+
node.setAttribute('class', ((node.getAttribute('class') || '') + ' office-fadeout').trim());
|
|
848
|
+
if (duration !== 1000) node.style.animationDuration = duration + 'ms';
|
|
849
|
+
node.style.animationDelay = (remaining - duration) + 'ms';
|
|
850
|
+
return true;
|
|
851
|
+
}
|
|
852
|
+
function officeCardPaint(node, card) {
|
|
853
|
+
node.setAttribute('class', 'office-card-node office-movable' + (card.waiting ? ' office-waiting-card' : '') + (card.rework ? ' office-rework-card' : '') + (card.checking ? ' office-card-checking' : ''));
|
|
854
|
+
node.setAttribute('aria-label', card.name);
|
|
855
|
+
const children = [
|
|
856
|
+
officeNode('rect', { x: 0, y: 0, width: card.w, height: card.h, rx: 0.6, class: 'office-card' }),
|
|
857
|
+
officeNode('text', { x: 0.8, y: card.h - 0.7, class: 'office-card-text' }, officeClip(card.label, card.w > 12 ? 22 : 10))];
|
|
858
|
+
if (card.checking) children.push(officeNode('text', { x: card.w - 0.6, y: card.h - 0.7, 'text-anchor': 'end', class: 'office-checking' }, '⟳'));
|
|
859
|
+
node.replaceChildren(...children);
|
|
860
|
+
}
|
|
861
|
+
function officeDeskPaint(node, desk) {
|
|
862
|
+
node.setAttribute('class', 'office-desk office-' + desk.condition + (desk.dimmed ? ' office-dimmed' : ''));
|
|
863
|
+
node.setAttribute('aria-label', desk.name);
|
|
864
|
+
officePlace(node, desk.x, desk.y);
|
|
865
|
+
const iso = officeProjection === 'iso';
|
|
866
|
+
const children = [];
|
|
867
|
+
if (iso) {
|
|
868
|
+
children.push(...officeAvatar());
|
|
869
|
+
// The desk is a light extrusion: the top tile plus two thin front legs.
|
|
870
|
+
const B = officeIsoLocal(desk.w, 0), C = officeIsoLocal(desk.w, desk.h), D = officeIsoLocal(0, desk.h);
|
|
871
|
+
for (const [from, to] of [[D, C], [B, C]]) {
|
|
872
|
+
const at = (t) => [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t];
|
|
873
|
+
const a = at(0.78), b = at(0.92);
|
|
874
|
+
children.push(officePolygon([a, b, [b[0], b[1] + 2.4], [a[0], a[1] + 2.4]], 'office-desk-leg'));
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
children.push(officeTile(desk.w, desk.h, 'office-desk-surface'));
|
|
878
|
+
if (iso) {
|
|
879
|
+
// The name plate is a small parallelogram on the desk's front edge; the name text stays upright.
|
|
880
|
+
const D = officeIsoLocal(0, desk.h), C = officeIsoLocal(desk.w, desk.h), off = officeIsoLocal(0, 1);
|
|
881
|
+
const c0 = [(D[0] + C[0]) / 2 + off[0], (D[1] + C[1]) / 2 + off[1]];
|
|
882
|
+
children.push(officePolygon([[-2.4, -0.75], [2.4, -0.75], [2.4, 0.75], [-2.4, 0.75]].map(([gx, gy]) => { const p = officeIsoLocal(gx, gy); return [c0[0] + p[0], c0[1] + p[1]]; }), 'office-nameplate'));
|
|
883
|
+
children.push(officeNode('text', { x: c0[0], y: c0[1] + 2.5, 'text-anchor': 'middle', class: 'office-name' }, desk.id));
|
|
884
|
+
} else {
|
|
885
|
+
children.push(officeNode('text', { x: 1, y: 2, class: 'office-name' }, desk.id));
|
|
886
|
+
children.push(...officeAvatar());
|
|
887
|
+
}
|
|
888
|
+
if (desk.bubble) {
|
|
889
|
+
const bx = iso ? 3.6 : 0.6; // clear of the standing avatar in iso
|
|
890
|
+
children.push(officeNode('rect', { x: bx, y: -2.3, width: desk.w - 1.2, height: 1.9, rx: 0.9, class: 'office-bubble' }));
|
|
891
|
+
children.push(officeNode('text', { x: bx + (desk.w - 1.2) / 2, y: -0.95, 'text-anchor': 'middle', class: 'office-bubble-text' }, officeClip(desk.bubble, 16)));
|
|
892
|
+
}
|
|
893
|
+
if (desk.mode) children.push(officeNode('text', { x: desk.w - 1, y: 2, 'text-anchor': 'end', class: 'office-mode' }, desk.mode));
|
|
894
|
+
children.push(officeGlyphNode(desk, iso ? 5.2 : 4.3, 3.65));
|
|
895
|
+
children.push(officeNode('text', { x: iso ? 6.3 : 5.3, y: 4, class: 'office-state' }, desk.label));
|
|
896
|
+
if (desk.approvals > 0) { children.push(officeHand(desk.w - 2.7, 2.5)); children.push(officeNode('text', { x: desk.w - 1, y: 4, 'text-anchor': 'end', class: 'office-hand' }, String(desk.approvals))); }
|
|
897
|
+
let line = 5.35;
|
|
898
|
+
if (desk.breakNote) { children.push(officeNode('text', { x: 1, y: line, class: 'office-meta' }, officeClip('Break: ' + desk.breakNote + (desk.breakResetsAt ? ' / resets ' + rel(desk.breakResetsAt) : ''), 38))); line += 1.05; }
|
|
899
|
+
if (desk.finishing) { children.push(officeNode('text', { x: 1, y: line, class: 'office-meta' }, 'finishing a turn')); line += 1.05; }
|
|
900
|
+
if (!desk.attached) { children.push(officeNode('text', { x: 1, y: line, class: 'office-meta' }, 'disconnected; work retained')); line += 1.05; }
|
|
901
|
+
const tray = [];
|
|
902
|
+
if (desk.tray.queued) tray.push(desk.tray.queued + ' queued');
|
|
903
|
+
if (desk.tray.important) tray.push(desk.tray.important + ' important');
|
|
904
|
+
if (desk.tray.needsReview) tray.push(desk.tray.needsReview + ' to review');
|
|
905
|
+
if (desk.tray.heldBy) tray.push('held by ' + desk.tray.heldBy);
|
|
906
|
+
if (tray.length) children.push(officeNode('text', { x: 1, y: line, class: 'office-meta' }, officeClip(tray.join(' / '), 38)));
|
|
907
|
+
if (desk.extraCards > 0) children.push(officeNode('text', { x: 1, y: desk.h - 0.4, class: 'office-meta' }, '+' + desk.extraCards + ' more'));
|
|
908
|
+
node.replaceChildren(...children);
|
|
909
|
+
}
|
|
910
|
+
function officeRoomPaint(node, room) {
|
|
911
|
+
node.setAttribute('class', 'office-roombox office-roombox-' + room.id);
|
|
912
|
+
officePlace(node, room.x, room.y);
|
|
913
|
+
const iso = officeProjection === 'iso';
|
|
914
|
+
const children = [officeTile(room.w, room.h, 'office-room')];
|
|
915
|
+
let moreAt = { x: 1, y: room.h - 1, anchor: 'start' };
|
|
916
|
+
if (iso) {
|
|
917
|
+
// A faint wireframe floor box: the floor tile and the two back vertical edges; the label sits at the base.
|
|
918
|
+
const B = officeIsoLocal(room.w, 0), D = officeIsoLocal(0, room.h), C = officeIsoLocal(room.w, room.h);
|
|
919
|
+
for (const p of [B, D]) children.push(officeNode('line', { x1: p[0], y1: p[1], x2: p[0], y2: p[1] - 6, class: 'office-room-wire' }));
|
|
920
|
+
children.push(officeNode('text', { x: C[0], y: C[1] + 2.4, 'text-anchor': 'middle', class: 'office-room-title' }, room.label));
|
|
921
|
+
children.push(officeNode('text', { x: C[0], y: C[1] + 3.8, 'text-anchor': 'middle', class: 'office-room-count' }, String(room.count)));
|
|
922
|
+
moreAt = { x: D[0], y: D[1] + 2.4, anchor: 'middle' };
|
|
923
|
+
} else {
|
|
924
|
+
if (room.x > 0) children.push(officeNode('line', { x1: -1, y1: 0.6, x2: -1, y2: room.h - 0.6, class: 'office-divider' }));
|
|
925
|
+
children.push(officeNode('text', { x: 1, y: 2.2, class: 'office-room-title' }, room.label));
|
|
926
|
+
children.push(officeNode('text', { x: room.w - 1, y: 2.2, 'text-anchor': 'end', class: 'office-room-count' }, String(room.count)));
|
|
927
|
+
}
|
|
928
|
+
if (room.id === 'done') children.push(...officeShelf(iso));
|
|
929
|
+
node.replaceChildren(...children);
|
|
930
|
+
if (room.overflow > 0 && room.filterState) {
|
|
931
|
+
const more = officeNode('g', { tabindex: '0', role: 'button', 'aria-label': 'Show ' + room.overflow + ' more ' + room.filterState.replaceAll('_', ' ') + ' tasks in the task list' });
|
|
932
|
+
more.append(officeNode('text', { x: moreAt.x, y: moreAt.y, 'text-anchor': moreAt.anchor, class: 'office-more' }, '+' + room.overflow + ' more'));
|
|
933
|
+
const open = () => officeFilter(room.filterState);
|
|
934
|
+
more.addEventListener('click', open);
|
|
935
|
+
more.addEventListener('keydown', (event) => officeKeys(event, open));
|
|
936
|
+
node.append(more);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
let officeScene = null;
|
|
940
|
+
function officeEnsure(target) {
|
|
941
|
+
if (officeScene) return officeScene;
|
|
942
|
+
const svg = officeNode('svg', { class: 'office-scene', role: 'group', 'aria-label': 'Office: desks, rooms and task cards' });
|
|
943
|
+
const layers = {};
|
|
944
|
+
for (const name of ['rooms', 'desks', 'links', 'marks', 'arcs', 'cards']) { const layer = officeNode('g', { class: 'office-layer office-layer-' + name }); svg.append(layer); layers[name] = layer; }
|
|
945
|
+
officeScene = { svg, layers, els: new Map(), leaving: new Map(), settled: true };
|
|
946
|
+
target.replaceChildren(svg);
|
|
947
|
+
return officeScene;
|
|
948
|
+
}
|
|
949
|
+
function officeReducedMotion() { return globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true; }
|
|
950
|
+
function officePrune(scene, now) {
|
|
951
|
+
if(!scene)return;
|
|
952
|
+
for(const [node,until] of scene.leaving)if(officeReducedMotion() || until<=now){node.remove();scene.leaving.delete(node)}
|
|
953
|
+
for(const [key,entry] of scene.els){
|
|
954
|
+
if(officeReducedMotion())entry.node.classList.remove('office-appear');
|
|
955
|
+
if(entry.node.dataset.officeUntil && Number(entry.node.dataset.officeUntil)<=now){entry.node.remove();scene.els.delete(key)}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
function officeLeave(scene, key) {
|
|
959
|
+
const entry = scene.els.get(key);
|
|
960
|
+
if (!entry) return;
|
|
961
|
+
scene.els.delete(key);
|
|
962
|
+
if(officeReducedMotion() || scene.settled){entry.node.remove();return;}
|
|
963
|
+
scene.leaving.set(entry.node,Date.now()+400);
|
|
964
|
+
entry.node.classList.add('office-leaving');
|
|
965
|
+
entry.node.addEventListener('animationend', () => {entry.node.remove();scene.leaving.delete(entry.node)});
|
|
966
|
+
}
|
|
967
|
+
function drawOffice(target, model) {
|
|
968
|
+
const scene = officeEnsure(target), els = scene.els, now = Date.now();
|
|
969
|
+
scene.svg.setAttribute('viewBox', officeViewBox(model.width, model.height));
|
|
970
|
+
const switched = officeLastProjection !== officeProjection; // a view switch jumps, never animates
|
|
971
|
+
officeLastProjection = officeProjection;
|
|
972
|
+
scene.settled = model.settle || switched;
|
|
973
|
+
officePrune(scene, now);
|
|
974
|
+
scene.svg.setAttribute('class', 'office-scene' + (model.settle || switched ? ' office-settle' : ''));
|
|
975
|
+
const seen = new Set();
|
|
976
|
+
const keep = (key, sig, layer, paint, appear) => {
|
|
977
|
+
seen.add(key);
|
|
978
|
+
sig = officeProjection + '|' + sig; // a projection switch repaints every node in place
|
|
979
|
+
const entry = els.get(key);
|
|
980
|
+
if (entry && entry.sig === sig) return { node: entry.node, fresh: false };
|
|
981
|
+
const node = entry ? entry.node : officeNode('g', {});
|
|
982
|
+
paint(node);
|
|
983
|
+
if (entry) entry.sig = sig;
|
|
984
|
+
else {
|
|
985
|
+
if (appear !== false && !scene.settled && !officeReducedMotion()) { node.classList.add('office-appear'); node.addEventListener('animationend', () => node.classList.remove('office-appear')); }
|
|
986
|
+
layer.append(node);
|
|
987
|
+
els.set(key, { node, sig });
|
|
988
|
+
}
|
|
989
|
+
return { node, fresh: true };
|
|
990
|
+
};
|
|
991
|
+
for (const room of model.rooms) {
|
|
992
|
+
keep('room:' + room.id, JSON.stringify({ ...room, cards: undefined }), scene.layers.rooms, (node) => officeRoomPaint(node, room), false);
|
|
993
|
+
}
|
|
994
|
+
for (const desk of model.desks) {
|
|
995
|
+
const { node, fresh } = keep('desk:' + desk.id, JSON.stringify({ ...desk, cards: undefined }), scene.layers.desks, (n) => officeDeskPaint(n, desk));
|
|
996
|
+
if (fresh && !node.getAttribute('tabindex')) {
|
|
997
|
+
node.setAttribute('tabindex', '0'); node.setAttribute('role', 'button');
|
|
998
|
+
const open = () => activateOfficePeer(desk.id);
|
|
999
|
+
node.addEventListener('click', open);
|
|
1000
|
+
node.addEventListener('keydown', (event) => officeKeys(event, open));
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
const cards = [];
|
|
1004
|
+
for (const desk of model.desks) cards.push(...desk.cards);
|
|
1005
|
+
for (const room of model.rooms) cards.push(...room.cards);
|
|
1006
|
+
for (const card of cards) {
|
|
1007
|
+
const key = 'card:' + card.id;
|
|
1008
|
+
const { node, fresh } = keep(key, JSON.stringify([card.label, card.name, card.waiting, card.rework, card.checking, card.w, card.h]), scene.layers.cards, (n) => officeCardPaint(n, card));
|
|
1009
|
+
if (fresh && !node.getAttribute('tabindex')) {
|
|
1010
|
+
node.setAttribute('tabindex', '0'); node.setAttribute('role', 'button');
|
|
1011
|
+
const open = () => activateOfficeTask(card.id);
|
|
1012
|
+
node.addEventListener('click', open);
|
|
1013
|
+
node.addEventListener('keydown', (event) => officeKeys(event, open));
|
|
1014
|
+
}
|
|
1015
|
+
const p = officeProject(card.x, card.y), transform = 'translate(' + p[0] + 'px,' + p[1] + 'px)';
|
|
1016
|
+
if (node.style.transform !== transform) node.style.transform = transform; // the only animated property
|
|
1017
|
+
}
|
|
1018
|
+
if (model.extraPeers > 0) {
|
|
1019
|
+
keep('extra:peers', String(model.extraPeers) + ':' + model.width, scene.layers.marks,
|
|
1020
|
+
(node) => { officePlace(node, model.width, 0); node.replaceChildren(officeNode('text', { x: 0, y: 1.8, 'text-anchor': 'end', class: 'office-meta' }, '+' + model.extraPeers + ' peers')); }, false);
|
|
1021
|
+
}
|
|
1022
|
+
const deskById = new Map(model.desks.map((desk) => [desk.id, desk]));
|
|
1023
|
+
// Warning lines between desks (phase 3): overlap, conflict and cohort links, ids only, state-driven (no fade).
|
|
1024
|
+
for (const link of model.links) {
|
|
1025
|
+
const offset = link.kind === 'overlap' ? -1.5 : link.kind === 'cohort' ? 1.5 : 0;
|
|
1026
|
+
const points = [...new Set(link.peers)].map((id) => deskById.get(id)).filter(Boolean).map((d) => {const p=officeProject(d.x + d.w / 2,d.y + d.h / 2);return [p[0],p[1]+offset]});
|
|
1027
|
+
if (points.length < 2) continue;
|
|
1028
|
+
const segments = [];
|
|
1029
|
+
for (let i = 0; i + 1 < points.length; i++) segments.push([points[i], points[i + 1]]);
|
|
1030
|
+
const label = link.kind + ' ' + link.tasks.map((id) => '#' + id).join(' ');
|
|
1031
|
+
keep('link:' + link.kind + ':' + link.tasks.join(','), JSON.stringify([link.kind, link.tasks, link.peers, points]), scene.layers.links,
|
|
1032
|
+
(n) => {
|
|
1033
|
+
n.setAttribute('class', 'office-link office-link-' + link.kind);
|
|
1034
|
+
n.setAttribute('aria-hidden', 'true');
|
|
1035
|
+
n.replaceChildren(...segments.map(([a, b]) => officeNode('line', { x1: a[0], y1: a[1], x2: b[0], y2: b[1], class: 'office-link-line' })),
|
|
1036
|
+
officeNode('text', { x: (segments[0][0][0] + segments[0][1][0]) / 2 + 1, y: (segments[0][0][1] + segments[0][1][1]) / 2 - 1, class: 'office-link-label' }, officeClip(label, 28)));
|
|
1037
|
+
}, false);
|
|
1038
|
+
}
|
|
1039
|
+
for (const arc of model.arcs) {
|
|
1040
|
+
if(arc.until<=now)continue;
|
|
1041
|
+
const from = deskById.get(arc.from);
|
|
1042
|
+
const label = arc.kind + (arc.task ? ' #' + arc.task : '');
|
|
1043
|
+
for (const target of arc.to.length ? arc.to : model.desks.map((desk) => desk.id)) {
|
|
1044
|
+
const to = deskById.get(target);
|
|
1045
|
+
if (!to) continue;
|
|
1046
|
+
const p1 = officeProject(from ? from.x + from.w / 2 : 1, from ? from.y : 1), p2 = officeProject(to.x + to.w / 2, to.y);
|
|
1047
|
+
const mx = (p1[0] + p2[0]) / 2, my = Math.min(p1[1], p2[1]) - 4;
|
|
1048
|
+
const angle = Math.atan2(p2[1] - my, p2[0] - mx);
|
|
1049
|
+
const tip = (a) => [officeRound(p2[0] - 1.5 * Math.cos(a)), officeRound(p2[1] - 1.5 * Math.sin(a))];
|
|
1050
|
+
const cls = 'office-arc' + (arc.priority === 'important' ? ' office-arc-important' : '') + (arc.dropped ? ' office-arc-dropped' : '');
|
|
1051
|
+
const { node, fresh } = keep('arc:' + arc.seq + ':' + target, JSON.stringify([arc.seq, target, arc.kind, arc.task, arc.priority, arc.dropped, p1, p2]), scene.layers.arcs,
|
|
1052
|
+
(n) => { n.setAttribute('class', cls); n.setAttribute('aria-hidden', 'true'); n.replaceChildren(
|
|
1053
|
+
officeNode('path', { d: 'M ' + officeRound(p1[0]) + ' ' + officeRound(p1[1]) + ' Q ' + officeRound(mx) + ' ' + officeRound(my) + ' ' + officeRound(p2[0]) + ' ' + officeRound(p2[1]), class: 'office-arc-path' }),
|
|
1054
|
+
officePolygon([[officeRound(p2[0]), officeRound(p2[1])], tip(angle - 0.45), tip(angle + 0.45)], 'office-arc-head'),
|
|
1055
|
+
officeNode('text', { x: mx, y: my - 0.8, 'text-anchor': 'middle', class: 'office-arc-label' }, label)); }, false);
|
|
1056
|
+
if (fresh) officeFade(node, arc.until, now);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
const latestAlert = new Map();
|
|
1060
|
+
for (const alert of model.alerts) if (alert.until > now) latestAlert.set(alert.peer, alert);
|
|
1061
|
+
for (const [peer, alert] of latestAlert) {
|
|
1062
|
+
const desk = deskById.get(peer);
|
|
1063
|
+
if (!desk) continue;
|
|
1064
|
+
const { node, fresh } = keep('alert:' + peer, String(alert.seq), scene.layers.marks,
|
|
1065
|
+
(n) => { officePlace(n, desk.x, desk.y); n.setAttribute('class', 'office-alertmark'); n.setAttribute('aria-hidden', 'true'); n.replaceChildren(officeNode('text', { x: desk.w - 1, y: 5.1, 'text-anchor': 'end', class: 'office-alert' }, '! ' + alert.kind)); }, false);
|
|
1066
|
+
if (fresh) officeFade(node, alert.until, now);
|
|
1067
|
+
}
|
|
1068
|
+
for (const desk of model.desks) {
|
|
1069
|
+
const latch = model.latched[desk.id];
|
|
1070
|
+
if (desk.condition !== 'idle' || !latch || latch.until <= now) continue;
|
|
1071
|
+
const { node, fresh } = keep('latch:' + desk.id, String(latch.seq), scene.layers.marks,
|
|
1072
|
+
(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);
|
|
1073
|
+
if (fresh) officeFade(node, latch.until, now);
|
|
1074
|
+
}
|
|
1075
|
+
for (const key of [...els.keys()]) if (!seen.has(key)) officeLeave(scene, key);
|
|
1076
|
+
officeDesks.clear(); officeCards.clear();
|
|
1077
|
+
for (const [key, entry] of els) {
|
|
1078
|
+
if (key.startsWith('desk:')) officeDesks.set(key.slice(5), entry.node);
|
|
1079
|
+
else if (key.startsWith('card:')) officeCards.set(Number(key.slice(5)), entry.node);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
function renderOffice(snapshot) {
|
|
1083
|
+
const view = officeView.get(), section = $('office-section');
|
|
1084
|
+
officeProjection = view === 'isometric' ? 'iso' : 'flat';
|
|
1085
|
+
section.hidden = view === 'off';
|
|
1086
|
+
if (view === 'off') {
|
|
1087
|
+
if (signatures.has('office')) { signatures.delete('office'); $('office').replaceChildren(); officeScene = null; }
|
|
1088
|
+
officePrev = null; officeDesks.clear(); officeCards.clear(); return;
|
|
1089
|
+
}
|
|
1090
|
+
const cols = (section.clientWidth || 0) >= 1000 ? 4 : (section.clientWidth || 0) >= 700 ? 2 : 1;
|
|
1091
|
+
const model = officeModel(snapshot, officePrev, { cols, now: Date.now() });
|
|
1092
|
+
officePrev = model;
|
|
1093
|
+
const signature = JSON.stringify([view, cols, model]);
|
|
1094
|
+
officePrune(officeScene,Date.now());
|
|
1095
|
+
if (signatures.get('office') === signature) return;
|
|
1096
|
+
signatures.set('office', signature);
|
|
1097
|
+
drawOffice($('office'), model);
|
|
1098
|
+
}
|
|
1099
|
+
// office-scene-end
|
|
450
1100
|
const STREAM_KINDS = ['chat', 'task', 'review', 'status', 'budget', 'presence', 'state'];
|
|
451
1101
|
let streamFilter = '';
|
|
452
1102
|
function applyStreamFilter(node) { node.hidden = streamFilter !== '' && node.dataset.kind !== streamFilter; }
|
|
@@ -491,6 +1141,132 @@ function renderBench(bench) {
|
|
|
491
1141
|
}
|
|
492
1142
|
});
|
|
493
1143
|
}
|
|
1144
|
+
// #258: the hub's memory, read-only. A tier at a time, filtered; selecting an entry or a node shows its neighbourhood
|
|
1145
|
+
// (inline SVG, a small force layout computed here) with the same edges as a list, and its detail.
|
|
1146
|
+
const memoryQuery = {tier:'long', q:'', kind:'', peer:'', machine:'', since:'', node:'', hops:1};
|
|
1147
|
+
let memoryGeneration = -1;
|
|
1148
|
+
function memoryInput() {
|
|
1149
|
+
if (memoryGeneration !== viewGeneration) { // another project: its own nodes, peers and machines
|
|
1150
|
+
memoryGeneration = viewGeneration; memoryQuery.node = ''; memoryQuery.peer = ''; memoryQuery.machine = '';
|
|
1151
|
+
for (const id of ['memory-peer', 'memory-machine']) $(id).value = '';
|
|
1152
|
+
}
|
|
1153
|
+
const out = {tier:memoryQuery.tier, hops:memoryQuery.hops};
|
|
1154
|
+
for (const key of ['q', 'kind', 'peer', 'machine', 'node']) if (memoryQuery[key]) out[key] = memoryQuery[key];
|
|
1155
|
+
// A whole timestamp, floored to the hour so the query stays the same between polls.
|
|
1156
|
+
if (memoryQuery.since) out.since = new Date(Math.floor((Date.now() - Number(memoryQuery.since) * 86400000) / 3600000) * 3600000).toISOString();
|
|
1157
|
+
return out;
|
|
1158
|
+
}
|
|
1159
|
+
// The next poll (at most a second away) carries the changed query; polling is never started twice.
|
|
1160
|
+
function rerenderMemory() { for (const chip of $('memory-tiers').children) chip.setAttribute('aria-pressed', String(chip.dataset.tier === memoryQuery.tier)); }
|
|
1161
|
+
function memoryOptions(id, values, all, key) {
|
|
1162
|
+
const select = $(id), keep = select.value;
|
|
1163
|
+
const want = [''].concat(values);
|
|
1164
|
+
if ([...select.options].map((o) => o.value).join('\0') === want.join('\0')) return;
|
|
1165
|
+
select.replaceChildren(...want.map((v) => { const o = el('option', v || all); o.value = v; return o; }));
|
|
1166
|
+
select.value = want.includes(keep) ? keep : '';
|
|
1167
|
+
memoryQuery[key] = select.value; // a filter whose value is gone reads as none, on the page and in the next query
|
|
1168
|
+
}
|
|
1169
|
+
function layout(graph) {
|
|
1170
|
+
// Deterministic: start on a circle in node order, then a fixed number of spring steps; no animation loop.
|
|
1171
|
+
const W = 640, H = 320, n = graph.nodes.length, pos = new Map();
|
|
1172
|
+
graph.nodes.forEach((node, i) => pos.set(node.id, {x: W / 2 + Math.cos(2 * Math.PI * i / Math.max(n, 1)) * W * 0.35, y: H / 2 + Math.sin(2 * Math.PI * i / Math.max(n, 1)) * H * 0.35}));
|
|
1173
|
+
for (let step = 0; step < 120; step++) {
|
|
1174
|
+
const force = new Map(graph.nodes.map((node) => [node.id, {x: 0, y: 0}]));
|
|
1175
|
+
for (const a of graph.nodes) for (const b of graph.nodes) {
|
|
1176
|
+
if (a.id >= b.id) continue;
|
|
1177
|
+
const p = pos.get(a.id), q = pos.get(b.id), dx = p.x - q.x || 0.01, dy = p.y - q.y || 0.01, d2 = Math.max(dx * dx + dy * dy, 25), f = 4000 / d2;
|
|
1178
|
+
force.get(a.id).x += f * dx / Math.sqrt(d2); force.get(a.id).y += f * dy / Math.sqrt(d2); force.get(b.id).x -= f * dx / Math.sqrt(d2); force.get(b.id).y -= f * dy / Math.sqrt(d2);
|
|
1179
|
+
}
|
|
1180
|
+
for (const e of graph.edges) {
|
|
1181
|
+
const p = pos.get(e.from), q = pos.get(e.to); if (!p || !q) continue;
|
|
1182
|
+
const dx = q.x - p.x, dy = q.y - p.y, d = Math.sqrt(dx * dx + dy * dy) || 1, f = (d - 90) * 0.05;
|
|
1183
|
+
force.get(e.from).x += f * dx / d; force.get(e.from).y += f * dy / d; force.get(e.to).x -= f * dx / d; force.get(e.to).y -= f * dy / d;
|
|
1184
|
+
}
|
|
1185
|
+
for (const node of graph.nodes) { const p = pos.get(node.id), f = force.get(node.id); p.x = Math.min(W - 20, Math.max(20, p.x + Math.max(-10, Math.min(10, f.x)))); p.y = Math.min(H - 20, Math.max(20, p.y + Math.max(-10, Math.min(10, f.y)))); }
|
|
1186
|
+
}
|
|
1187
|
+
return pos;
|
|
1188
|
+
}
|
|
1189
|
+
function selectMemoryNode(node) { memoryQuery.node = memoryQuery.node === node ? '' : node; rerenderMemory(); }
|
|
1190
|
+
function renderMemory(memory) {
|
|
1191
|
+
const tiers = $('memory-tiers');
|
|
1192
|
+
if (!tiers.children.length) for (const [tier, label] of [['short', 'Short-term'], ['long', 'Long-term'], ['goals', 'Long-term tasks']]) {
|
|
1193
|
+
const chip = el('button', label, 'chip'); chip.type = 'button'; chip.dataset.tier = tier;
|
|
1194
|
+
chip.addEventListener('click', () => { memoryQuery.tier = tier; memoryQuery.node = ''; rerenderMemory(); });
|
|
1195
|
+
tiers.append(chip);
|
|
1196
|
+
}
|
|
1197
|
+
for (const chip of tiers.children) chip.setAttribute('aria-pressed', String(chip.dataset.tier === memoryQuery.tier));
|
|
1198
|
+
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; }
|
|
1199
|
+
$('memory-count').textContent = memory.counts.long + ' long-term / ' + memory.counts.short + ' short-term / ' + (memory.counts.goals || 0) + ' open tasks';
|
|
1200
|
+
if (memory.goals) {
|
|
1201
|
+
// Long-term tasks: state, who holds the lease and until when (a conflict when two machines claimed it), linked board tasks.
|
|
1202
|
+
const who = (m) => m === memory.machine ? 'this machine' : 'machine ' + m.slice(0, 8);
|
|
1203
|
+
update('memory-list', {goals: memory.goals, node: memoryQuery.node}, (target) => {
|
|
1204
|
+
if (!memory.goals.length) return empty(target, 'No long-term tasks. A person creates one with ahub goal new <title>.');
|
|
1205
|
+
for (const g of memory.goals) {
|
|
1206
|
+
const row = el('button', undefined, 'memory-row'); row.type = 'button'; row.setAttribute('aria-pressed', String(memoryQuery.node === 'goal:' + g.id));
|
|
1207
|
+
const head = el('div', undefined, 'row-head');
|
|
1208
|
+
head.append(el('span', g.title, 'label'), badge(g.state, g.state === 'done' ? 'ok' : g.state === 'blocked' ? 'err' : g.state === 'active' ? 'info' : ''));
|
|
1209
|
+
const lease = g.lease.holder ? 'held by ' + who(g.lease.holder) + ' until ' + time(g.lease.until) + (g.lease.conflict.length ? ' / conflict: ' + g.lease.conflict.map(who).join(', ') : '') : g.lease.expired ? 'free (lease of ' + who(g.lease.lastHolder) + ' ended ' + time(g.lease.until) + ')' : 'free';
|
|
1210
|
+
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'));
|
|
1211
|
+
if (g.lease.conflict.length) row.append(badge('lease conflict', 'warn'));
|
|
1212
|
+
row.addEventListener('click', () => selectMemoryNode('goal:' + g.id));
|
|
1213
|
+
target.append(row);
|
|
1214
|
+
}
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
memoryOptions('memory-peer', memory.peers, 'All peers', 'peer');
|
|
1218
|
+
memoryOptions('memory-machine', memory.machines, 'All machines', 'machine');
|
|
1219
|
+
if (!memory.goals) update('memory-list', {entries: memory.entries, node: memoryQuery.node}, (target) => {
|
|
1220
|
+
if (!memory.entries.length) return empty(target, 'No ' + memory.tier + '-term entries match.');
|
|
1221
|
+
for (const entry of memory.entries) {
|
|
1222
|
+
const row = el('button', undefined, 'memory-row'); row.type = 'button'; row.setAttribute('aria-pressed', String(memoryQuery.node === 'mem:' + entry.id));
|
|
1223
|
+
const head = el('div', undefined, 'row-head');
|
|
1224
|
+
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' : ''));
|
|
1225
|
+
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'));
|
|
1226
|
+
row.addEventListener('click', () => selectMemoryNode('mem:' + entry.id));
|
|
1227
|
+
target.append(row);
|
|
1228
|
+
}
|
|
1229
|
+
});
|
|
1230
|
+
update('memory-graph', memory.graph || null, (target) => {
|
|
1231
|
+
const graph = memory.graph;
|
|
1232
|
+
if (!graph || !graph.nodes.length) return;
|
|
1233
|
+
const NS = 'http://www.w3.org/2000/svg', svg = document.createElementNS(NS, 'svg'), pos = layout(graph);
|
|
1234
|
+
svg.setAttribute('viewBox', '0 0 640 320'); svg.setAttribute('role', 'img'); svg.setAttribute('aria-label', 'Neighbourhood of ' + memory.node + ': ' + graph.nodes.length + ' nodes, ' + graph.edges.length + ' edges; the list below has the same edges');
|
|
1235
|
+
for (const e of graph.edges) {
|
|
1236
|
+
const p = pos.get(e.from), q = pos.get(e.to); if (!p || !q) continue;
|
|
1237
|
+
const line = document.createElementNS(NS, 'line'); line.setAttribute('x1', p.x); line.setAttribute('y1', p.y); line.setAttribute('x2', q.x); line.setAttribute('y2', q.y); line.setAttribute('class', 'medge ' + e.rel);
|
|
1238
|
+
svg.append(line);
|
|
1239
|
+
}
|
|
1240
|
+
for (const node of graph.nodes) {
|
|
1241
|
+
const p = pos.get(node.id), g = document.createElementNS(NS, 'g'), c = document.createElementNS(NS, 'circle'), t = document.createElementNS(NS, 'text');
|
|
1242
|
+
g.setAttribute('class', 'mnode ' + node.type + (node.kind ? ' ' + node.kind : '') + (node.id === memory.node ? ' selected' : '')); g.setAttribute('tabindex', '0'); g.setAttribute('role', 'button'); g.setAttribute('aria-label', node.type + ' ' + node.label);
|
|
1243
|
+
c.setAttribute('cx', p.x); c.setAttribute('cy', p.y); c.setAttribute('r', node.type === 'memory' ? 9 : 6);
|
|
1244
|
+
t.setAttribute('x', p.x + 11); t.setAttribute('y', p.y + 4); t.textContent = node.label.slice(0, 28);
|
|
1245
|
+
g.append(c, t);
|
|
1246
|
+
g.addEventListener('click', () => selectMemoryNode(node.id));
|
|
1247
|
+
g.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); selectMemoryNode(node.id); } });
|
|
1248
|
+
svg.append(g);
|
|
1249
|
+
}
|
|
1250
|
+
const list = el('ul', undefined, 'memory-edges');
|
|
1251
|
+
for (const e of graph.edges) list.append(el('li', e.from + ' ' + e.rel + ' ' + e.to));
|
|
1252
|
+
target.append(svg, list);
|
|
1253
|
+
});
|
|
1254
|
+
update('memory-detail', memory.detail || null, (target) => {
|
|
1255
|
+
const d = memory.detail; if (!d) return;
|
|
1256
|
+
const box = el('div', undefined, 'row');
|
|
1257
|
+
box.append(el('div', d.kind + ', ' + d.tier + '-term' + (d.expiresAt ? ' until ' + time(d.expiresAt) : '') + ', ' + d.scope, 'label'));
|
|
1258
|
+
box.append(el('div', 'by ' + d.peer + ' on machine ' + d.machine + ', ' + time(d.at) + ', project ' + d.project + (d.task ? ', task ' + d.task : '') + (d.machine === memory.machine ? ' (this machine)' : ' (synced)'), 'meta'));
|
|
1259
|
+
if (d.supersededBy) box.append(el('div', 'superseded by ' + d.supersededBy.slice(0, 8), 'meta'));
|
|
1260
|
+
if (d.promotedFrom) box.append(el('div', 'promoted from ' + d.promotedFrom.slice(0, 8), 'meta'));
|
|
1261
|
+
if (d.about.length) box.append(el('div', 'about: ' + d.about.join(', '), 'meta'));
|
|
1262
|
+
box.append(el('div', (d.title ? d.title + '\n' : '') + d.text, 'body'));
|
|
1263
|
+
target.append(box);
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
for (const [id, key] of [['memory-kind', 'kind'], ['memory-peer', 'peer'], ['memory-machine', 'machine'], ['memory-since', 'since']]) $(id).addEventListener('change', () => { memoryQuery[key] = $(id).value; rerenderMemory(); });
|
|
1267
|
+
$('memory-hops').addEventListener('change', () => { memoryQuery.hops = Number($('memory-hops').value); rerenderMemory(); });
|
|
1268
|
+
let memorySearch;
|
|
1269
|
+
$('memory-q').addEventListener('input', () => { clearTimeout(memorySearch); memorySearch = setTimeout(() => { memoryQuery.q = $('memory-q').value.trim(); rerenderMemory(); }, 300); });
|
|
494
1270
|
function renderStreamChips() {
|
|
495
1271
|
const group = $('stream-filters');
|
|
496
1272
|
if (group.children.length) return;
|
|
@@ -506,9 +1282,29 @@ function renderStreamChips() {
|
|
|
506
1282
|
group.append(chip);
|
|
507
1283
|
}
|
|
508
1284
|
}
|
|
1285
|
+
$('planning-task').addEventListener('change',loadPlanning);$('planning-reload').addEventListener('click',loadPlanning);$('release-edit').addEventListener('change',loadReleasePlanning);$('release-reload').addEventListener('click',loadReleasePlanning);
|
|
1286
|
+
$('planning').addEventListener('submit',async event=>{event.preventDefault();if(!planningEdit || !sameBoardContext(planningEdit.context) || boardViewState?.snapshot.planningCapabilities?.enabled!==true)return notice('Select a task from its current board.',true);
|
|
1287
|
+
const fields={priority:$('planning-priority').value===''?null:Number($('planning-priority').value),rank:$('planning-rank').value===''?null:Number($('planning-rank').value),release:$('planning-release').value===''?null:Number($('planning-release').value),plannedStart:plannedDateInput('planning-start'),due:plannedDateInput('planning-due'),pinned:$('planning-pinned').checked};
|
|
1288
|
+
await action({action:'planning',id:planningEdit.id,revision:planningEdit.revision,fields,reason:$('planning-reason').value},event.submitter);
|
|
1289
|
+
});
|
|
1290
|
+
$('split-backlog').addEventListener('submit',async event=>{event.preventDefault();if(!planningEdit || !sameBoardContext(planningEdit.context) || boardViewState?.snapshot.planningCapabilities?.split!==true)return notice('Select a current backlog task to split.',true);const titles=$('split-titles').value.split('\n').map(title=>title.trim()).filter(Boolean),parts=titles.map((title,index)=>({title,...($('split-sequential').checked && index>0 ? {afterParts:[index-1]} : {})}));await action({action:'backlog_split',id:planningEdit.id,revision:planningEdit.revision,parts,reason:$('split-reason').value},event.submitter)});
|
|
1291
|
+
$('planning-undo').addEventListener('click',async()=>{if(planningEdit && sameBoardContext(planningEdit.context))await action({action:'planning_undo',id:planningEdit.id,revision:planningEdit.revision},$('planning-undo'))});
|
|
1292
|
+
$('release-new').addEventListener('submit',async event=>{event.preventDefault();if(!sameBoardContext(boardContext()) || boardViewState?.snapshot.planningCapabilities?.enabled!==true)return notice('Release planning is unavailable.',true);await action({action:'release_new',name:$('release-name').value,goal:$('release-goal').value,order:Number($('release-order').value)},event.submitter)});
|
|
1293
|
+
$('release-plan').addEventListener('submit',async event=>{event.preventDefault();if(!releaseEdit || !sameBoardContext(releaseEdit.context))return notice('Select a current release.',true);await action({action:'release_change',id:releaseEdit.id,revision:releaseEdit.revision,fields:{plannedStart:plannedDateInput('release-start'),cut:plannedDateInput('release-cut'),ship:plannedDateInput('release-ship')}},event.submitter)});
|
|
1294
|
+
$('task-release-filter').addEventListener('change',()=>{if(boardViewState)renderBoard(boardViewState.snapshot,boardViewState.peers,boardViewState.ids)});
|
|
1295
|
+
$('task-peer-filter').addEventListener('change',()=>{if(boardViewState)renderBoard(boardViewState.snapshot,boardViewState.peers,boardViewState.ids)});
|
|
1296
|
+
$('task-lanes').addEventListener('change',()=>{if(boardViewState)renderBoard(boardViewState.snapshot,boardViewState.peers,boardViewState.ids)});
|
|
1297
|
+
for (const [id, operation] of [['delivery-queue','sdlc_queue'],['delivery-release','sdlc_release']]) $(id).addEventListener('click', async () => {
|
|
1298
|
+
const repo = $('delivery-repo').value.trim();
|
|
1299
|
+
const liveIssues = $('delivery-live').value.trim() ? $('delivery-live').value.split(/[, ]+/).filter(Boolean).map(Number) : [];
|
|
1300
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || liveIssues.length > 32 || liveIssues.some(id => !Number.isSafeInteger(id) || id <= 0)) { notice('Enter owner/repository and valid issue numbers.', true); return; }
|
|
1301
|
+
const report = await action({action:operation,repo,liveIssues}, $(id));
|
|
1302
|
+
if (report) $('delivery-report').textContent = JSON.stringify(report, null, 2);
|
|
1303
|
+
});
|
|
509
1304
|
$('task-filter').addEventListener('change', () => { if (!stopped && snapshotValid && lastSnapshot) render(lastSnapshot); });
|
|
1305
|
+
window.addEventListener('resize', () => { if (!stopped && officeView.get() !== 'off' && snapshotValid && lastSnapshot) renderOffice(lastSnapshot); });
|
|
510
1306
|
$('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 = ''; });
|
|
511
|
-
$('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 (await action(payload, event.submitter) && generation === viewGeneration) { $('title').value = ''; $('detail').value = ''; } });
|
|
1307
|
+
$('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 = ''; } });
|
|
512
1308
|
(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'; } } })();
|
|
513
1309
|
</script>
|
|
514
1310
|
</body>
|