@staix/agent-hub 0.12.26 → 0.12.28

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/src/ui/index.html CHANGED
@@ -5,12 +5,16 @@
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
6
  <title>agent-hub | Local dashboard</title>
7
7
  <script>
8
+ const dashboardCookie = {
9
+ get: (name, fallback = '') => { try { return document.cookie.split(';').map(value => value.trim()).find(value => value.startsWith(name + '='))?.slice(name.length + 1) ?? fallback; } catch { return fallback; } },
10
+ set: (name, value) => { try { document.cookie = `${name}=${value}; Path=/; Max-Age=31536000; SameSite=Strict`; } catch {} }
11
+ };
8
12
  const dashboardTheme = (() => {
9
13
  let choice = 'system';
10
- try { const saved = document.cookie.split(';').map(value => value.trim()).find(value => value.startsWith('agent-hub-theme='))?.slice('agent-hub-theme='.length); if (['system','light','dark'].includes(saved)) choice = saved; } catch {}
14
+ const saved = dashboardCookie.get('agent-hub-theme'); if (['system','light','dark'].includes(saved)) choice = saved;
11
15
  const apply = () => { document.documentElement.dataset.theme = choice; };
12
16
  apply();
13
- return { get: () => choice, set: (value) => { choice = ['system','light','dark'].includes(value) ? value : 'system'; apply(); try { document.cookie = `agent-hub-theme=${choice}; Path=/; Max-Age=31536000; SameSite=Strict`; } catch {} } };
17
+ return { get: () => choice, set: (value) => { choice = ['system','light','dark'].includes(value) ? value : 'system'; apply(); dashboardCookie.set('agent-hub-theme', choice) } };
14
18
  })();
15
19
  </script>
16
20
  <style>
@@ -19,52 +23,86 @@ const dashboardTheme = (() => {
19
23
  /* PEER_PALETTE_CSS */
20
24
  .task-board-section{grid-column:1/-1}.board-columns{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;padding:18px}.board-column{min-width:0;background:var(--panel-soft);border:1px solid var(--line);border-radius:6px;padding:10px}.board-column h3{font-size:14px;margin:0 0 12px}.board-column h4{font-size:12px;color:var(--muted);margin:12px 0 8px}.task-card{background:var(--panel);border:1px solid var(--line);border-radius:5px;padding:12px;margin:0 0 10px;overflow-wrap:anywhere}.task-card .row-head{align-items:flex-start}.task-card .label{font-size:14px}.task-card .actions{display:grid;gap:8px}.task-card .actions select{max-width:none;width:100%}.task-card button,.task-card select,.agent-legend button{min-height:44px}.task-card .body{font-size:13px}.task-card .task-stage{font-size:12px}.board-columns .empty{padding:8px 0}.board-lane{border-top:1px solid var(--line)}.board-lane>h3{padding:14px 18px 0;font-size:15px}.peer-mark{display:inline-flex;align-items:center;gap:6px}.peer-color-dot{width:9px;height:9px;display:inline-block;border-radius:50%;background:var(--peer-color);flex-shrink:0}.peer-color-dot.reviewer{background:transparent;border:2px solid var(--peer-color)}.agent-legend{display:flex;flex-wrap:wrap;gap:8px;padding:14px 18px;border-bottom:1px solid var(--line)}.agent-legend .agent-slot{border:1px solid var(--line);border-radius:5px;padding:10px;flex:1 1 180px;min-width:0}.agent-legend .meta{overflow-wrap:anywhere}.board-drop-active{outline:2px solid var(--green);outline-offset:2px}.board-toolbar{padding:12px 18px;display:flex;gap:14px;flex-wrap:wrap;align-items:center}.board-toolbar label{margin:0}.board-toolbar select{width:auto;max-width:220px}.board-unknown{padding:12px 18px;color:var(--amber)}.task-list{max-height:none;overflow:visible}.progress-backlog{fill:var(--dot)}
21
25
  @media(max-width:1100px){.board-columns{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:600px){.split{grid-template-columns:minmax(0,1fr)}.board-columns{grid-template-columns:1fr;padding:12px}.board-toolbar{align-items:stretch}.board-toolbar select{max-width:none;width:100%}.board-toolbar label{width:100%}}
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}
26
+ .office-scene{display:block;max-width:100%;height:auto;margin-inline:auto;font-family:system-ui,sans-serif;font-size:1px}.office-scene :is(rect,path,line,polygon,circle){vector-effect:non-scaling-stroke;stroke-width:1px}.office-room{fill:var(--panel-soft);stroke:var(--line)}.office-room-title{fill:var(--ink);font-size:1.083333px;font-weight:600}.office-room-count{fill:var(--ink);font-size:1px;font-family:ui-monospace,monospace;font-variant-numeric:tabular-nums}.office-header-surface{fill:var(--panel);stroke:var(--line)}.office-count-pill{fill:var(--panel);stroke:var(--line)}.office-desk-surface{fill:var(--panel);stroke:var(--line)}.office-desk.office-dimmed .office-avatar{opacity:.55}.office-name{fill:var(--ink);font-size:1.083333px;font-weight:600}.office-state,.office-meta,.office-empty{fill:var(--muted);font-size:1px}.office-glyph,.office-hand{font-size:1px}.office-idle .office-glyph{fill:var(--green)}.office-busy .office-glyph{fill:var(--amber)}.office-offline .office-glyph,.office-unknown .office-glyph{fill:var(--muted)}.office-paused .office-glyph{fill:var(--amber)}.office-waiting .office-glyph{fill:var(--red)}.office-hand{fill:var(--red)}.office-bubble{fill:var(--panel);stroke:var(--blue)}.office-bubble-text{fill:var(--ink);font-size:1px}.office-mode{fill:var(--muted);font-size:1px;font-family:ui-monospace,monospace}.office-card{fill:var(--control);stroke:var(--control-border)}.office-card-text{fill:var(--ink);font-size:1px}.office-card-id{font-family:ui-monospace,monospace}.office-card-accent{fill:var(--line)}.office-waiting-card .office-card-accent{fill:var(--waiting)}.office-rework-card .office-card-accent{fill:var(--red)}.office-card-checking .office-card-accent{fill:var(--blue)}.office-more{fill:var(--blue);font-size:1px}.office-scene .office-spotlight .office-desk-surface,.office-scene .office-spotlight .office-card,.office-scene [role="button"]:focus-visible > .office-card{stroke:var(--focus);stroke-width:2px}.office-card-hit{fill:transparent;stroke:none;pointer-events:all}.office-card-focus:focus-visible{outline:none}.row.spotlight{background:var(--hover)}
27
+
28
+ .office-movable{transition:transform .8s ease}.office-arc .office-arc-path{fill:none;stroke:var(--blue);stroke-width:1px}.office-arc .office-arc-head{fill:var(--blue)}.office-arc-important .office-arc-path{stroke:var(--amber)}.office-arc-important .office-arc-head{fill:var(--amber)}.office-arc-dropped .office-arc-path{stroke:var(--red);stroke-dasharray:.6 .4}.office-arc-dropped .office-arc-head{fill:var(--red)}.office-arc-label{fill:var(--muted);font-size:1px}.office-alert{fill:var(--red);font-size:1px}.office-latch{fill:var(--amber);font-size:1px}.office-link-line{fill:none;stroke-width:1px;stroke-dasharray:.8 .5}.office-link-overlap .office-link-line{stroke:var(--amber)}.office-link-conflict .office-link-line{stroke:var(--red)}.office-link-cohort .office-link-line{stroke:var(--blue)}.office-link-label{font-size:1px;fill:var(--muted)}.office-link-conflict .office-link-label{fill:var(--red)}.office-link-overlap .office-link-label{fill:var(--amber)}.office-link-cohort .office-link-label{fill:var(--blue)}.office-checking{fill:var(--blue);font-size:.916667px}
29
+ .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)}.office-glyph-shape{fill:none;stroke-width:1px}.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:1px}.office-desk-leg{fill:var(--panel-soft);stroke:var(--line);stroke-width:1px}.office-nameplate{fill:var(--control);stroke:var(--control-border);stroke-width:1px}.office-room-wire{fill:none;stroke:var(--line);stroke-width:1px}.office-divider{fill:none;stroke:var(--line);stroke-width:1px}.office-shelf{fill:var(--panel-soft);stroke:var(--line);stroke-width:1px}.office-shelf-card{fill:var(--green-soft);stroke:var(--green);stroke-width:1px}.office-box-top{fill:var(--control);stroke:var(--control-border);stroke-width:1px}.office-box-left{fill:var(--panel-soft);stroke:var(--line);stroke-width:1px}.office-box-right{fill:var(--panel);stroke:var(--line);stroke-width:1px}.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
30
  @keyframes office-pulse{50%{opacity:.45}}@keyframes office-appear{from{opacity:0}}@keyframes office-fade{to{opacity:0}}
26
31
  @media(prefers-reduced-motion:reduce){.office-scene,.office-scene *{transition:none!important;animation:none!important}}
27
32
 
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}}
33
+
34
+ [hidden]{display:none!important}main{display:block}main>.stack{margin-top:24px}.view-nav{max-width:1440px;margin:auto;padding:16px 32px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}.view-nav button{min-height:44px}.view-nav button[aria-current="page"]{background:var(--green);color:var(--on-accent);border-color:var(--green)}.view-nav .attention-badge{margin-left:auto}.view-nav .badge{margin-left:4px}.task-card.compact{cursor:pointer;min-height:44px}.task-card.compact:hover{border-color:var(--focus)}.task-card.compact .row-head{gap:6px;flex-wrap:wrap}.task-card.compact .label{flex:1 1 100px}.task-detail .actions{display:flex;flex-wrap:wrap}.task-detail .actions select{width:auto;max-width:260px}.task-detail button,.task-detail select{min-height:44px}.board-focus{grid-template-columns:minmax(0,1fr)}.column-head{display:flex;justify-content:space-between;gap:8px;align-items:center;margin-bottom:12px}.column-head h3{margin:0}.column-head button{min-height:44px;font-size:12px}#task-detail-dialog{width:min(720px,calc(100% - 32px));max-height:85vh;overflow:auto;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--ink);padding:20px}#task-detail-dialog::backdrop{background:rgba(0,0,0,.35)}.dialog-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:16px}.dialog-head button{min-height:44px}@media(max-width:600px){.view-nav{padding:12px}.view-nav .attention-badge{margin-left:0}.board-focus{grid-template-columns:minmax(0,1fr)}}
29
35
  </style>
30
36
  </head>
31
37
  <body>
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>
38
+ <header><div><div class="eyebrow">Local control room</div><h1>agent-hub <span>/ dashboard</span></h1><div id="project-context" class="meta"></div></div><div><label for="theme">Theme</label><select id="theme" class="theme-choice"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select><div id="connection" class="connection" role="status">Connecting...</div></div></header>
39
+ <nav id="dashboard-nav" class="view-nav" aria-label="Dashboard views">
40
+ <button type="button" data-view-target="board" aria-current="page">Board</button>
41
+ <button type="button" data-view-target="office">Office</button>
42
+ <button type="button" data-view-target="stream">Stream</button>
43
+ <button type="button" data-view-target="agents">Agents <span id="agents-approval-count" class="badge warn" hidden></span></button>
44
+ <button type="button" data-view-target="message">Message</button>
45
+ <button type="button" data-view-target="memory">Memory</button>
46
+ <button type="button" data-view-target="settings">Settings</button>
47
+ <button type="button" id="attention-badge" class="attention-badge" aria-label="Global attention" hidden>(0)</button>
48
+ </nav>
34
49
  <main>
35
50
  <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>
51
+ <section class="task-board-section" aria-labelledby="tasks-title" data-view="board"><div class="section-head"><h2 id="tasks-title">Task board</h2><div class="filters"><select id="task-filter" aria-label="Filter tasks by state"><option value="">All states</option><option value="backlog">Backlog</option><option value="proposed">To Do</option><option value="work">Work (including rework)</option><option value="in_progress">In progress</option><option value="in_review">In review</option><option value="approved">Approved</option><option value="changes_requested">Changes requested</option></select><span id="task-count" class="meta"></span></div></div><div id="task-progress" class="task-progress" aria-live="polite"></div><div class="board-toolbar"><button id="board-back" type="button" hidden>Back to board</button><span id="board-focus-title" class="meta" hidden></span><label for="task-peer-filter">Agent<select id="task-peer-filter"><option value="">All agents</option></select></label><label for="task-release-filter">Release<select id="task-release-filter"><option value="">All releases</option></select></label><label for="task-lanes">View<select id="task-lanes"><option value="columns">By state</option><option value="agents">By agent</option><option value="releases">By release</option></select></label></div><div id="task-legend" class="agent-legend" role="group" aria-label="Agent legend and assignment targets"></div><div id="tasks" class="task-list"></div><details><summary>Plan a task</summary><form id="planning"><div class="field"><label for="planning-task">Task</label><select id="planning-task"><option value="">Select task</option></select></div><div class="split"><label>Priority<select id="planning-priority"><option value="">Default</option><option value="0">P0</option><option value="1">P1</option><option value="2">P2</option><option value="3">P3</option></select></label><label>Order<input id="planning-rank" type="number" min="0" step="1"></label><label>Release<select id="planning-release"><option value="">No release</option></select></label><label>Planned start<input id="planning-start" type="datetime-local" step="any"></label><label>Due<input id="planning-due" type="datetime-local" step="any"></label></div><label><input id="planning-pinned" type="checkbox">Pin against planner changes</label><label>Reason<input id="planning-reason" maxlength="1000" required></label><div class="actions"><button id="planning-save" type="submit" data-mutation="project">Save plan</button><button id="planning-undo" type="button" data-mutation="project">Undo latest plan</button><button id="planning-reload" type="button">Reload current values</button></div><p id="planning-version" class="meta"></p></form></details><details id="split-panel" hidden><summary>Split the selected backlog task</summary><form id="split-backlog"><label>Part titles, one per line<textarea id="split-titles" maxlength="2408" required></textarea></label><label><input id="split-sequential" type="checkbox">Each part waits for the previous part</label><label>Reason<input id="split-reason" maxlength="1000" required></label><p class="meta">Two to eight parts. The original task retains integration and acceptance. Unused parts can be undone from its current plan.</p><button id="split-save" type="submit" data-mutation="project">Stage parts</button></form></details><details><summary>Releases</summary><div id="releases" class="content"></div><form id="release-new"><div class="split"><label>Name<input id="release-name" maxlength="64" required></label><label>Order<input id="release-order" type="number" min="0" value="0" step="1" required></label></div><label>Goal<input id="release-goal" maxlength="1000" required></label><button type="submit" data-mutation="project">Create release</button></form><form id="release-plan"><label>Release<select id="release-edit"><option value="">Select release</option></select></label><div class="split"><label>Planned start<input id="release-start" type="datetime-local" step="any"></label><label>Cut<input id="release-cut" type="datetime-local" step="any"></label><label>Ship<input id="release-ship" type="datetime-local" step="any"></label></div><button id="release-save" type="submit" data-mutation="project">Save dates</button><button id="release-reload" type="button">Reload current release</button><p id="release-version" class="meta"></p></form></details><details id="delivery"><summary>Delivery evidence (read-only)</summary><div class="field"><label for="delivery-repo">GitHub repository</label><input id="delivery-repo" maxlength="200" placeholder="owner/repository"></div><div class="field"><label for="delivery-live">Live verification issue IDs (optional)</label><input id="delivery-live" maxlength="300" placeholder="Comma-separated issue numbers"></div><div class="actions"><button id="delivery-queue" type="button">Read merge queue</button><button id="delivery-release" type="button">Read release checklist</button></div><pre id="delivery-report" class="body" aria-live="polite"></pre></details><details><summary>Propose a task</summary><form id="propose"><div class="field"><label for="title">Title</label><input id="title" name="title" required maxlength="300" placeholder="What should the team do?"></div><div class="field"><label for="detail">Details (optional)</label><textarea id="detail" name="detail" maxlength="8000"></textarea></div><div class="split"><div class="field"><label for="class">Class</label><select id="class" name="class"><option>implement</option><option>plan</option><option>bulk_edit</option><option>test</option><option>review</option><option>summarize</option><option>triage</option></select></div><div class="field"><label for="owner">Owner</label><select id="owner" name="owner"><option value="">Automatic</option></select></div></div><label><input id="backlog-proposal" type="checkbox"> Add to Backlog without offering</label><button class="primary" data-mutation="project" type="submit">Propose task</button></form></details></section>
37
52
  <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>
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>
53
+ <section id="office-section" aria-labelledby="office-title" data-view="office" hidden><div class="section-head"><h2 id="office-title">Office</h2><div class="filters"><label for="office-view">Office mode</label><select id="office-view"><option value="off">Off</option><option value="flat">Flat</option><option value="isometric">Isometric</option></select></div></div><div id="office" class="content"></div></section>
54
+ <section aria-labelledby="stream-title" data-view="stream" hidden><div class="section-head"><h2 id="stream-title">Live stream</h2><div class="filters"><div id="stream-filters" class="chips" role="group" aria-label="Filter events by kind"></div><label><input id="follow" type="checkbox" checked> Follow latest</label></div></div><div id="stream" class="stream" tabindex="0" aria-label="Recent hub events"><p class="empty">Waiting for hub activity...</p></div><p id="stream-empty" class="empty" hidden>No events match this filter.</p></section>
40
55
 
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>
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>
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>
56
+ <section aria-labelledby="memory-title" data-view="memory" hidden><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>
57
+ <section aria-labelledby="compose-title" data-view="message" hidden><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>
58
+ <section aria-labelledby="settings-title" data-view="settings" hidden><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>
44
59
  </div>
45
60
  <div class="stack">
46
- <section id="projects-panel" aria-labelledby="projects-title"><div class="section-head"><h2 id="projects-title">Projects</h2><span id="project-count" class="meta"></span></div><div id="projects"></div></section>
47
- <section aria-labelledby="peers-title"><div class="section-head"><h2 id="peers-title">Peers &amp; queues</h2><span id="peer-count" class="meta"></span></div><div id="peers"></div></section>
48
- <section aria-labelledby="permissions-title"><div class="section-head"><h2 id="permissions-title">Pending approvals</h2><span id="permission-count" class="meta"></span></div><div id="permissions"></div></section>
49
- <section aria-labelledby="budget-title"><div class="section-head"><h2 id="budget-title">Usage budgets</h2></div><div id="budget"></div></section>
50
- <section aria-labelledby="bench-title"><div class="section-head"><h2 id="bench-title">Benchmarks</h2><span id="bench-count" class="meta"></span></div><div id="bench"></div></section>
61
+ <section id="projects-panel" aria-labelledby="projects-title" data-view="agents" hidden><div class="section-head"><h2 id="projects-title">Projects</h2><span id="project-count" class="meta"></span></div><div id="projects"></div></section>
62
+ <section aria-labelledby="peers-title" data-view="agents" hidden><div class="section-head"><h2 id="peers-title">Peers &amp; queues</h2><span id="peer-count" class="meta"></span></div><div id="peers"></div></section>
63
+ <section aria-labelledby="permissions-title" data-view="agents" hidden><div class="section-head"><h2 id="permissions-title">Pending approvals</h2><span id="permission-count" class="meta"></span></div><div id="permissions"></div></section>
64
+ <section aria-labelledby="budget-title" data-view="agents" hidden><div class="section-head"><h2 id="budget-title">Usage budgets</h2></div><div id="budget"></div></section>
65
+ <section aria-labelledby="bench-title" data-view="agents" hidden><div class="section-head"><h2 id="bench-title">Benchmarks</h2><span id="bench-count" class="meta"></span></div><div id="bench"></div></section>
51
66
  </div>
52
67
  </main>
68
+ <dialog id="task-detail-dialog" aria-labelledby="task-detail-title"><div class="dialog-head"><h2 id="task-detail-title">Task details</h2><button id="task-detail-close" type="button" autofocus>Close details</button></div><div id="task-detail-content"></div></dialog>
53
69
  <footer>Loopback session. Private task content stays in the terminal. Reopen with <code>ahub ui</code> when your session expires.</footer>
54
70
  <script>
55
71
  'use strict';
56
72
  /* TASK_PROGRESS_MODEL */
57
73
  /* PEER_START_RETRY_MS */
58
74
  /* BOARD_MODEL */
59
- /* PET_MODEL */
75
+ /* NEEDS_MODEL */
60
76
  /* OFFICE_MODEL */
61
77
  const officeView = (() => {
62
78
  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 {} } };
79
+ const saved = dashboardCookie.get('agent-hub-office'); if (['off','flat','isometric'].includes(saved)) choice = saved;
80
+ return { get: () => choice, set: (value) => { choice = ['off','flat','isometric'].includes(value) ? value : 'off'; dashboardCookie.set('agent-hub-office', choice) } };
65
81
  })();
66
82
  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'; });
83
+ 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'; applyDashboardView(); });
84
+ // dashboard-navigation-begin
85
+ const DASHBOARD_VIEWS = ['board','office','stream','agents','message','memory','settings'];
86
+ let currentView = dashboardCookie.get('agent-hub-view', 'board');
87
+ if (!DASHBOARD_VIEWS.includes(currentView)) currentView = 'board';
88
+ function applyDashboardView() {
89
+ for (const section of document.querySelectorAll('main section[data-view]')) section.hidden = section.dataset.view !== currentView;
90
+ for (const stack of document.querySelectorAll('main > .stack')) stack.hidden = ![...stack.children].some(section => section.dataset.view === currentView);
91
+ for (const button of document.querySelectorAll('button[data-view-target]')) button.setAttribute('aria-current', button.dataset.viewTarget === currentView ? 'page' : 'false');
92
+ }
93
+ function switchView(view) {
94
+ if (!DASHBOARD_VIEWS.includes(view)) return;
95
+ currentView = view; dashboardCookie.set('agent-hub-view', view); applyDashboardView();
96
+ }
97
+ function initializeDashboardNavigation() {
98
+ for (const button of document.querySelectorAll('button[data-view-target]')) button.addEventListener('click', () => switchView(button.dataset.viewTarget));
99
+ $('attention-badge').addEventListener('click', reachAttention);
100
+ $('board-back').addEventListener('click', () => setColumnFocus(null));
101
+ $('task-detail-close').addEventListener('click', closeTaskDetail);
102
+ $('task-detail-dialog').addEventListener('close', () => { if ($('task-detail-dialog').open) return; taskDetailId = null; $('task-detail-content').replaceChildren(); signatures.delete('task-detail-content'); });
103
+ applyDashboardView();
104
+ }
105
+ // dashboard-navigation-end
68
106
  let ticket = location.hash.slice(1);
69
107
  history.replaceState(null, '', location.pathname);
70
108
  const $ = (id) => document.getElementById(id);
@@ -166,8 +204,7 @@ function resetView(id, instance) {
166
204
  lastSnapshot = null;
167
205
  officePrev = null; officeDesks.clear(); officeCards.clear(); officeScene = null;
168
206
  $('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;
207
+ closeTaskDetail(); renderNeedsAttention(null); applyDashboardView();
171
208
  signatures.clear(); $('stream').replaceChildren(); empty($('stream'), managerMode ? 'Select a running project...' : 'Waiting for hub activity...');
172
209
  if (streamFilter) { streamFilter = ''; for (const child of $('stream-filters').children) child.setAttribute('aria-pressed', 'false'); }
173
210
  $('stream-empty').hidden = true; $('task-filter').value = '';
@@ -410,6 +447,8 @@ function renderStarts(target, starts) {
410
447
  }
411
448
  }
412
449
  let boardViewState = null, showAllDone = false;
450
+ const BOARD_FOCUS_COLUMNS = ['backlog','todo','progress','review','done'];
451
+ let columnFocus, taskDetailId = null;
413
452
  let planningEdit = null, releaseEdit = null;
414
453
  const loadedPlanningDates = new Map();
415
454
  const localDateInput = value => {
@@ -515,13 +554,8 @@ function dropTarget(node, accepted, payload) {
515
554
  void action({...payload, id:card.id});
516
555
  });
517
556
  }
518
- function taskCard(task, card, stage, ids, overdue) {
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
-
557
+ function taskDetail(task, card, stage, ids, overdue) {
558
+ const row = el('article', undefined, 'task-detail'), head = el('div', undefined, 'row-head');
525
559
  row.setAttribute('aria-label', '#' + task.id + ' ' + task.title + ', ' + task.state.replaceAll('_', ' ') + ', owner ' + (task.owner || 'unassigned') + ', priority P' + card.priority);
526
560
  head.append(el('span', '#' + task.id + ' ' + task.title, 'label'), badge('P' + card.priority));
527
561
  if (card.starred) { const star = el('span', '★'); star.setAttribute('aria-hidden','true'); head.append(star); }
@@ -552,6 +586,30 @@ function taskCard(task, card, stage, ids, overdue) {
552
586
  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
587
  }
554
588
  if (controls.children.length) row.append(controls);
589
+ return row;
590
+ }
591
+ function taskCard(task, card, stage, ids, overdue, compact = true) {
592
+ const row = compact ? el('article', undefined, 'task-card compact') : taskDetail(task, card, stage, ids, overdue);
593
+ if (!compact) row.classList.add('task-card');
594
+ row.tabIndex = 0; taskRows.set(task.id,row);
595
+ const trackCard = on => { const sceneCard=officeCards.get(task.id);if(sceneCard)sceneCard.classList.toggle('office-spotlight',on); };
596
+ row.addEventListener('mouseenter',()=>trackCard(true));row.addEventListener('mouseleave',()=>trackCard(false));
597
+ row.addEventListener('focus',()=>trackCard(true));row.addEventListener('blur',()=>trackCard(false));
598
+ if (compact) {
599
+ row.setAttribute('role', 'button');
600
+ row.setAttribute('aria-label', '#' + task.id + ' ' + task.title + ', ' + task.state.replaceAll('_', ' ') + ', owner ' + (task.owner || 'unassigned') + ', priority P' + card.priority);
601
+ if (task.role) row.setAttribute('aria-label', row.getAttribute('aria-label') + ', Task guidance: ' + task.role + ' (grants no authority)');
602
+ if (task.release != null) { const release=boardViewState?.snapshot.releases?.find(release=>release.id===task.release);row.setAttribute('aria-label',row.getAttribute('aria-label')+', Release #'+task.release+' '+(release?.name || '(label unavailable)')); }
603
+ const head = el('div', undefined, 'row-head'); head.append(el('span', '#' + task.id + ' ' + task.title, 'label'), badge('P' + card.priority));
604
+ const owner = el('span', undefined, 'peer-mark peer-' + (Object.hasOwn(peerPalette, task.owner || '') ? task.owner : 'other'));
605
+ owner.setAttribute('aria-label', 'owner: ' + (task.owner || 'unassigned')); owner.title = 'owner: ' + (task.owner || 'unassigned'); owner.append(el('span', undefined, 'peer-color-dot')); head.append(owner);
606
+ if (card.starred) { const star=el('span','★');star.setAttribute('aria-hidden','true');head.append(star); }
607
+ row.append(head);
608
+ if (overdue) row.append(badge('Overdue','warn'));
609
+ if (task.checking) row.append(badge('Check running','info'));
610
+ row.addEventListener('click', () => openTaskDetail(task.id));
611
+ row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openTaskDetail(task.id); } });
612
+ }
555
613
  if (card.pull || card.assignable) {
556
614
  row.draggable = true; row.title = 'Drag to an available peer or Backlog/To Do, or use the buttons.';
557
615
  row.addEventListener('dragstart', event => {
@@ -561,8 +619,36 @@ function taskCard(task, card, stage, ids, overdue) {
561
619
  }
562
620
  return row;
563
621
  }
622
+ function setColumnFocus(column) {
623
+ if (column !== null && !BOARD_FOCUS_COLUMNS.includes(column)) return;
624
+ columnFocus = column; dashboardCookie.set('agent-hub-column-focus', column || ''); switchView('board');
625
+ if (boardViewState) renderBoard(boardViewState.snapshot, boardViewState.peers, boardViewState.ids);
626
+ }
627
+ function closeTaskDetail() {
628
+ taskDetailId = null; const dialog = $('task-detail-dialog'); if (dialog.open) dialog.close();
629
+ $('task-detail-content').replaceChildren(); signatures.delete('task-detail-content');
630
+ }
631
+ function refreshTaskDetail() {
632
+ if (taskDetailId === null) return;
633
+ const snapshot = boardViewState?.snapshot, task = snapshot?.tasks?.find(task => task.id === taskDetailId), card = boardViewState?.cards.get(taskDetailId);
634
+ if (!task || !card) return closeTaskDetail();
635
+ const stage = taskProgress(snapshot.tasks).stages.find(stage => stage.id === taskDetailId);
636
+ const ids = boardViewState.ids.filter(id => ['idle','busy'].includes(boardViewState.peers[id]?.state) && boardViewState.peers[id]?.attached !== false && boardViewState.peers[id]?.rostered !== false);
637
+ const overdue = task.due != null && task.due < Date.now() && task.state !== 'approved';
638
+ $('task-detail-title').textContent = 'Task #' + task.id;
639
+ update('task-detail-content', [task, card, stage, ids, overdue, boardContext()], target => target.append(taskDetail(task, card, stage, ids, overdue)));
640
+ }
641
+ function openTaskDetail(id) {
642
+ if (stopped || !Number.isSafeInteger(id) || !snapshotValid || !boardViewState?.cards.has(id)) return;
643
+ switchView('board'); taskDetailId = id; refreshTaskDetail();
644
+ const dialog = $('task-detail-dialog'); if (taskDetailId !== null && !dialog.open) dialog.showModal();
645
+ }
564
646
  function renderBoard(snapshot, peers, ids) {
565
- if (!Array.isArray(snapshot.tasks)) { boardViewState=null; return update('tasks','unavailable',target=>empty(target,'Task board unavailable.')); }
647
+ if (columnFocus === undefined) {
648
+ columnFocus = dashboardCookie.get('agent-hub-column-focus') || null;
649
+ if (!BOARD_FOCUS_COLUMNS.includes(columnFocus)) columnFocus = null;
650
+ }
651
+ if (!Array.isArray(snapshot.tasks)) { boardViewState=null; closeTaskDetail(); return update('tasks','unavailable',target=>empty(target,'Task board unavailable.')); }
566
652
  const progress = taskProgress(snapshot.tasks), model = boardColumns(snapshot.tasks, peers);
567
653
  renderTaskProgress(progress);
568
654
  const stages = new Map(progress.stages.map(stage=>[stage.id,stage])), byId = new Map(snapshot.tasks.map(task=>[task.id,task]));
@@ -577,6 +663,8 @@ function renderBoard(snapshot, peers, ids) {
577
663
  const taskFilter=$('task-filter').value, ownerFilter=peerFilter.value, releaseFilter=$('task-release-filter').value, lanes=$('task-lanes').value;
578
664
  const eligible=ids.filter(id=>['idle','busy'].includes(peers[id].state) && peers[id].attached!==false && peers[id].rostered!==false);
579
665
  $('task-count').textContent=snapshot.tasks.length+' tasks';
666
+ $('board-back').hidden = columnFocus === null; $('board-focus-title').hidden = columnFocus === null;
667
+ $('board-focus-title').textContent = columnFocus ? model.columns.find(column => column.id === columnFocus)?.label || '' : '';
580
668
  update('task-legend',[snapshot.boardLegend,peers],target=>{
581
669
  for (const id of ids) {
582
670
  const data=(snapshot.boardLegend || []).find(item=>item.peer===id), row=el('div',undefined,'agent-slot');
@@ -590,11 +678,11 @@ function renderBoard(snapshot, peers, ids) {
590
678
  }
591
679
  if (!ids.length) empty(target,'No peer status available.');
592
680
  });
593
- update('tasks',[snapshot.tasks,peers,snapshot.boardCapabilities,taskFilter,ownerFilter,releaseFilter,lanes,showAllDone,[...overdue]],target=>{
681
+ update('tasks',[snapshot.tasks,peers,snapshot.boardCapabilities,taskFilter,ownerFilter,releaseFilter,lanes,showAllDone,[...overdue],columnFocus],target=>{
594
682
  taskRows.clear();
595
683
  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)));
596
684
  const visibleIds=new Set(visible.map(task=>task.id));
597
- const owners=lanes==='agents' ? [...new Set(visible.map(task=>task.owner || ''))] : lanes==='releases' ? [...new Set(visible.map(task=>task.release ?? 0))] : [null];
685
+ const owners=columnFocus ? [null] : lanes==='agents' ? [...new Set(visible.map(task=>task.owner || ''))] : lanes==='releases' ? [...new Set(visible.map(task=>task.release ?? 0))] : [null];
598
686
  if (!owners.length) owners.push(null);
599
687
  for (const [laneIndex, owner] of owners.entries()) {
600
688
  const lane=el('div',undefined,'board-lane');
@@ -603,19 +691,22 @@ function renderBoard(snapshot, peers, ids) {
603
691
  if(lanes==='agents' && eligible.includes(owner)) dropTarget(heading,card=>card.assignable,{action:'assign',peer:owner});
604
692
  lane.append(heading);
605
693
  }
606
- const grid=el('div',undefined,'board-columns');
694
+ const grid=el('div',undefined,'board-columns' + (columnFocus ? ' board-focus' : ''));
607
695
  const inLane=card=>visibleIds.has(card.id) && (owner===null || (lanes==='releases' ? (byId.get(card.id)?.release ?? 0)===owner : (byId.get(card.id)?.owner || '')===owner));
608
696
  for (const column of model.columns) {
609
- 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);
697
+ if (columnFocus && column.id !== columnFocus) continue;
698
+ 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);const columnHead = el('div',undefined,'column-head'); columnHead.append(heading);
699
+ if (!columnFocus) { const expand=el('button','Expand');expand.type='button';expand.setAttribute('aria-label','Expand '+column.label);expand.addEventListener('click',()=>setColumnFocus(column.id));columnHead.append(expand); }
700
+ box.append(columnHead);
610
701
  if (column.id==='backlog' && snapshot.boardCapabilities?.backlog===true) dropTarget(box,card=>card.returnable,{action:'backlog_return'});
611
702
  if (column.id==='todo' && snapshot.boardCapabilities?.backlog===true) dropTarget(box,card=>card.pull,{action:'backlog_pull'});
612
703
  let ready=column.ready.filter(inLane), waiting=column.waiting.filter(inLane);
613
704
  if(column.id==='done') ready.sort((a,b)=>(byId.get(b.id)?.updated || 0)-(byId.get(a.id)?.updated || 0) || b.id-a.id);
614
- const hidden=column.id==='done' && !showAllDone ? Math.max(0,ready.length-10) : 0;
705
+ const hidden=column.id==='done' && !columnFocus && !showAllDone ? Math.max(0,ready.length-10) : 0;
615
706
  if(hidden) ready=ready.slice(0,10);
616
707
  for(const [label,list] of [['Ready',ready],['Waiting',waiting]]) {
617
708
  if(column.id==='todo') box.append(el('h4',label+' ('+list.length+')'));
618
- for(const card of list) box.append(taskCard(byId.get(card.id),card,stages.get(card.id),eligible,overdue.has(card.id)));
709
+ for(const card of list) box.append(taskCard(byId.get(card.id),card,stages.get(card.id),eligible,overdue.has(card.id),columnFocus === null));
619
710
  }
620
711
  if(!ready.length && !waiting.length) empty(box,'No tasks');
621
712
  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)}
@@ -625,43 +716,36 @@ function renderBoard(snapshot, peers, ids) {
625
716
  }
626
717
  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'));
627
718
  });
719
+ refreshTaskDetail();
628
720
  }
629
- const petBaseTitle = typeof document === 'undefined' ? 'agent-hub | Local dashboard' : document.title;
630
- let petSignature = '', petFirst;
631
- function reachPetNeed() {
632
- if (!snapshotValid || !petFirst) return;
633
- const item = petFirst;
634
- 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;
721
+ const attentionBaseTitle = typeof document === 'undefined' ? 'agent-hub | Local dashboard' : document.title;
722
+ let attentionSignature = '', attentionFirst;
723
+ function reachAttention() {
724
+ if (!snapshotValid || !attentionFirst) return;
725
+ const item = attentionFirst, view = ['review','offline'].includes(item.kind) ? 'board' : 'agents';
726
+ switchView(view);
727
+ const row = ['review','offline'].includes(item.kind) ? taskRows.get(item.id) : item.kind === 'approval' ? [...document.querySelectorAll('[data-permission-id]')].find(row => row.dataset.permissionId === String(item.id)) : undefined;
635
728
  if (row) { row.scrollIntoView({block:'center',behavior:'instant'}); row.focus?.(); return; }
636
729
  const heading = item.kind === 'approval' ? 'permissions-title' : ['review','offline'].includes(item.kind) ? 'tasks-title' : item.kind === 'budget' ? 'budget-title' : 'peers-title';
637
730
  $(heading)?.scrollIntoView({block:'start',behavior:'instant'});
638
731
  notice('Attention: '+item.kind+(item.peer?' '+item.peer:'')+(item.id?' '+item.id:''));
639
732
  }
640
- function renderPetAttention(snapshot) {
641
- 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);
642
- const target=$('pet-container');if(!target)return;
643
- const input=snapshot?.attention;
644
- 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';
645
- let value;try{if(shape)value=needs(input,Date.now());}catch{ /* malformed optional attention never breaks the existing dashboard */ }
646
- const signature = JSON.stringify(value ? [snapshot.pet.character,snapshot.pet.blink,value] : null);
647
- if(signature===petSignature)return;
648
- petSignature=signature;petFirst=value?.items[0];
649
- target.replaceChildren();target.hidden=!value;
650
- const title=value?.act?'('+value.act+') agent-hub':petBaseTitle;if(document.title!==title)document.title=title;
651
- if(!value)return;
652
- const character=petCharacters[snapshot.pet.character],frame=character.frames.blocks[value.state].full,model=petPixels(frame);
653
- const blink = snapshot.pet.blink!==false && value.items.some(item=>item.kind==='approval');
654
- const button=el('button',undefined,'pet-control'+(blink?' pet-waiting':''));button.type='button';
655
- const label=character.name+': '+value.state+', '+value.act+' action, '+value.watch+' watch';button.setAttribute('aria-label',label);button.addEventListener('click',reachPetNeed);
656
- 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');
657
- const accent=character.accent?.full;
658
- const poses=blink?[['pet-open',model],['pet-blink',petPixels(character.frames.blocks.waitingBlink.full)]]:[['pet-open',model]];
659
- for(const [cls,pose] of poses){const group=document.createElementNS('http://www.w3.org/2000/svg','g');group.setAttribute('class',cls);
660
- 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);}
661
- button.append(svg,el('span',value.state+' / '+value.act+' action'+(value.watch?' +'+value.watch+' watch':'')));target.append(button);
733
+ function renderNeedsAttention(snapshot) {
734
+ const input = snapshot?.attention;
735
+ const shape = input && typeof input === 'object' && !Array.isArray(input) && 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';
736
+ let value;try{if(shape)value=needs(input,Date.now());}catch{ /* malformed optional attention remains unavailable */ }
737
+ const approvalCount = Array.isArray(snapshot?.permissions) ? snapshot.permissions.filter(permission => permission && (permission.expiresAt === undefined || permission.expiresAt > Date.now())).length : value?.items.filter(item => item.kind === 'approval').length || 0;
738
+ const signature = JSON.stringify([value || null, approvalCount]);
739
+ if(signature===attentionSignature)return;
740
+ attentionSignature=signature;attentionFirst=value?.items[0];
741
+ const target=$('attention-badge');target.hidden=!value;target.disabled=!value?.items.length;
742
+ target.textContent=value?'('+value.act+')'+(value.watch?' +'+value.watch+' watch':''):'(0)';
743
+ target.setAttribute('aria-label',value?'Global attention: '+value.act+' action, '+value.watch+' watch':'Attention unavailable');
744
+ const approvals=$('agents-approval-count');approvals.textContent=approvalCount?'('+approvalCount+')':'';approvals.hidden=!approvalCount;
745
+ const title=value?.act?'('+value.act+') agent-hub':attentionBaseTitle;if(document.title!==title)document.title=title;
662
746
  }
663
747
  function render(snapshot) {
664
- renderPetAttention(snapshot);
748
+ renderNeedsAttention(snapshot);
665
749
  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 : [];
666
750
  $('peer-count').textContent = ids.length + ' peers';
667
751
  syncStarts(snapshot);
@@ -745,7 +829,7 @@ function render(snapshot) {
745
829
  if ($('follow').checked) stream.scrollTop = stream.scrollHeight;
746
830
  refreshRelativeTimes(document);
747
831
  refreshStreamEmpty();
748
- syncMutationControls();
832
+ syncMutationControls(); applyDashboardView();
749
833
  }
750
834
  async function poll() {
751
835
  if (stopped) return;
@@ -767,24 +851,35 @@ async function poll() {
767
851
  // busy latch, timed at render from the model's timestamps; expired nodes purge on the next changed render or
768
852
  // their own animationend. The projection (phase 4) maps grid coordinates only: x' = (x - y) cos 30, y' =
769
853
  // (x + y) sin 30; nodes are laid out upright in local coordinates, so text is never rotated and the DOM
770
- // order and accessible names do not depend on the projection.
854
+ // focus order for shared visible cards and accessible names do not depend on the projection.
855
+ const OFFICE_SCALE = 12;
771
856
  let officeProjection = 'flat';
772
857
  let officeLastProjection = '';
858
+ let officeRoomOffset = 0;
773
859
  const officeRound = (v) => Math.round(v * 100) / 100;
774
860
  function officeIsoLocal(x, y) { return [(x - y) * 0.8660254, (x + y) * 0.5]; }
775
861
  function officeProject(x, y) { return officeProjection === 'iso' ? officeIsoLocal(x, y) : [x, y]; }
776
- function officePlace(node, x, y) { const p = officeProject(x, y); node.style.transform = 'translate(' + p[0] + 'px,' + p[1] + 'px)'; }
862
+ function officePlace(node, x, y, room = false) { const p = officeProject(x, y); if (room) p[1] += officeRoomOffset; node.style.transform = 'translate(' + p[0] + 'px,' + p[1] + 'px)'; }
863
+ function officeOrderNodes(parent, nodes) {
864
+ for (let index = 0; index < nodes.length; index++) {
865
+ const node = nodes[index], before = parent.children[index] || null;
866
+ if (before === node) continue;
867
+ const active = document.activeElement, restore = active && (active === node || node.contains?.(active));
868
+ if (typeof parent.moveBefore === 'function') parent.moveBefore(node, before);
869
+ else { parent.insertBefore(node, before); if (restore) active.focus({ preventScroll: true }); }
870
+ }
871
+ }
777
872
  function officePolygon(points, cls) { return officeNode('polygon', { points: points.map((p) => p.map(officeRound).join(',')).join(' '), class: cls }); }
778
873
  function officeTile(w, h, cls) {
779
- if (officeProjection !== 'iso') return officeNode('rect', { x: 0, y: 0, width: w, height: h, rx: 1, class: cls });
874
+ if (officeProjection !== 'iso') return officeNode('rect', { x: 0, y: 0, width: w, height: h, rx: 2 / 3, class: cls });
780
875
  return officePolygon([[0, 0], officeIsoLocal(w, 0), officeIsoLocal(w, h), officeIsoLocal(0, h)], cls);
781
876
  }
782
- function officeViewBox(w, h) {
877
+ function officeViewBox(w, h, model) {
783
878
  if (officeProjection !== 'iso') return '0 0 ' + w + ' ' + h;
784
- const corners = [officeProject(0, 0), officeProject(w, 0), officeProject(0, h), officeProject(w, h)];
879
+ const corners = model ? [...model.rooms, ...model.desks].flatMap(tile => [officeProject(tile.x, tile.y), officeProject(tile.x + tile.w, tile.y), officeProject(tile.x, tile.y + tile.h), officeProject(tile.x + tile.w, tile.y + tile.h)].map(([x, y]) => [x, y + (model.rooms.includes(tile) ? officeRoomOffset : 0)])) : [officeProject(0, 0), officeProject(w, 0), officeProject(0, h), officeProject(w, h)];
785
880
  const xs = corners.map((p) => p[0]), ys = corners.map((p) => p[1]), m = 3;
786
881
  const minX = Math.min(...xs), minY = Math.min(...ys);
787
- return (minX - m) + ' ' + (minY - m) + ' ' + (Math.max(...xs) - minX + 2 * m) + ' ' + (Math.max(...ys) - minY + 2 * m);
882
+ return (minX - m) + ' ' + (minY - 6) + ' ' + (Math.max(...xs) - minX + 2 * m) + ' ' + (Math.max(...ys) - minY + 9);
788
883
  }
789
884
  // A raised hand as outline shapes (palm and fingers), coloured by the desk's condition class.
790
885
  function officeHand(x, y) {
@@ -813,29 +908,79 @@ function officeAvatar() {
813
908
  officeNode('path', { d: 'M 1.2 4.6 A 1 1 0 0 1 3.2 4.6 Z', class: 'office-avatar' })];
814
909
  }
815
910
  // The Done shelf: a small cabinet of stacked card rows (flat) or boxes on a slab (iso); the count stays text.
816
- function officeShelf(iso) {
817
- if (!iso) {
818
- const nodes = [officeNode('rect', { x: 6.5, y: 4.4, width: 9, height: 10.4, rx: 1, class: 'office-shelf' })];
819
- 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' }));
820
- 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' }));
821
- return nodes;
822
- }
823
- const nodes = [officePolygon([officeIsoLocal(6, 3), officeIsoLocal(16, 3), officeIsoLocal(16, 9), officeIsoLocal(6, 9)], 'office-shelf')];
824
- for (const [bx, by] of [[7, 4], [11.5, 4], [9.2, 6.2]]) {
825
- const s = 2.4, rise = 2.2;
826
- const top = [officeIsoLocal(bx, by), officeIsoLocal(bx + s, by), officeIsoLocal(bx + s, by + s), officeIsoLocal(bx, by + s)];
827
- const up = top.map(([x, y]) => [x, y - rise]);
828
- 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'));
911
+ function officeShelf(iso, count) {
912
+ const shown = Math.min(Math.max(0, count), iso ? 3 : 6);
913
+ if (!shown) return [];
914
+ const nodes = [];
915
+ for (let i = 0; i < shown; i++) {
916
+ if (!iso) nodes.push(officeNode('rect', { x: 1 + (i % 2) * 10.5, y: 4 + Math.floor(i / 2) * 3, width: 9.5, height: 2.2, rx: 0.5, class: 'office-shelf-card' }));
917
+ else {
918
+ const bx = 7 + (i % 2) * 4.5, by = 8 + Math.floor(i / 2) * 5, size = 2.4, rise = 0.8;
919
+ const top = [officeIsoLocal(bx, by), officeIsoLocal(bx + size, by), officeIsoLocal(bx + size, by + size), officeIsoLocal(bx, by + size)];
920
+ const up = top.map(([x, y]) => [x, y - rise]);
921
+ 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'));
922
+ }
829
923
  }
830
924
  return nodes;
831
925
  }
832
- function officeClip(text, max) { text = String(text); return text.length > max ? text.slice(0, max - 3) + '...' : text; }
926
+ function officeClip(text, max) {
927
+ text = String(text); max = Math.max(4, Math.floor(max));
928
+ if (text.length <= max) return text;
929
+ const cut = max - 3, space = text.lastIndexOf(' ', cut);
930
+ return text.slice(0, space >= cut - 8 && space >= 3 ? space : cut).trimEnd() + '...';
931
+ }
932
+ let officeClipSerial = 0;
933
+ let officeMeasureContext;
934
+ function officeTextWidth(value, monospace) {
935
+ if (officeMeasureContext === undefined) {
936
+ try { officeMeasureContext = document.createElement('canvas').getContext?.('2d') || null; } catch { officeMeasureContext = null; }
937
+ }
938
+ if (officeMeasureContext) {
939
+ officeMeasureContext.font = OFFICE_SCALE + 'px ' + (monospace ? 'ui-monospace,monospace' : 'system-ui,sans-serif');
940
+ return officeMeasureContext.measureText(value).width;
941
+ }
942
+ // Non-browser fixtures or unavailable canvas: conservative glyph widths; clipPath is the hard boundary.
943
+ return [...value].reduce((width, char) => width + (!/^[ -~]$/.test(char) ? 1 : monospace ? 0.6 : /[MW@#%]/.test(char) ? 0.9 : /[ilI., ']/.test(char) ? 0.3 : 0.55), 0) * OFFICE_SCALE;
944
+ }
945
+ function officeCardLabel(card, width) {
946
+ const prefix = '#' + card.id, rest = String(card.label).slice(prefix.length);
947
+ const measure = value => officeTextWidth(prefix, true) + officeTextWidth(value, false);
948
+ if (measure(rest) <= width) return prefix + rest;
949
+ const chars = [...rest], suffix = '...';
950
+ let low = 0, high = chars.length;
951
+ while (low < high) {
952
+ const mid = Math.ceil((low + high) / 2);
953
+ if (measure(chars.slice(0, mid).join('') + suffix) <= width) low = mid; else high = mid - 1;
954
+ }
955
+ let clipped = chars.slice(0, low).join(''), space = clipped.lastIndexOf(' ');
956
+ if (space >= clipped.length - 8 && space > 1) clipped = clipped.slice(0, space);
957
+ return prefix + clipped.trimEnd() + suffix;
958
+ }
959
+ // Upright chips follow the tile grid; omit any that cannot fit wholly inside its padded footprint.
960
+ function officeTileCards(tile, centerX, startY, lift = 0) {
961
+ if (officeProjection !== 'iso') return tile.cards;
962
+ const origin = officeProject(tile.x, tile.y), visible = [];
963
+ for (let i = 0; i < tile.cards.length; i++) {
964
+ const card = tile.cards[i], at = officeIsoLocal(centerX, startY + i * (card.h + 0.5) * 2);
965
+ const left = at[0] - card.w / 2, top = at[1];
966
+ const corners = [[left, top], [left + card.w, top], [left, top + card.h], [left + card.w, top + card.h]];
967
+ const fits = corners.every(([x, y]) => {
968
+ const gx = (x / 0.8660254 + y / 0.5) / 2, gy = (y / 0.5 - x / 0.8660254) / 2;
969
+ return gx >= 1 && gx <= tile.w - 1 && gy >= 1 && gy <= tile.h - 1;
970
+ });
971
+ if (fits) visible.push({ ...card, drawX: origin[0] + left, drawY: origin[1] + top + lift });
972
+ }
973
+ return visible;
974
+ }
975
+ function officeRoomCards(room) { return officeTileCards(room, room.w / 2, 8, officeRoomOffset); }
976
+ function officeDeskCards(desk) { return officeTileCards(desk, desk.w / 2 - 0.5, 9); }
977
+
833
978
  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; }
834
979
  function officeKeys(event, activate) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); activate(); } }
835
980
  let spotlitRow = null;
836
981
  function spotlightRow(row) { if (spotlitRow && spotlitRow !== row) spotlitRow.classList.remove('spotlight'); spotlitRow = row; if (row) row.classList.add('spotlight'); }
837
- function activateOfficePeer(id) { const row = peerRows.get(id); if (!row) return; spotlightRow(row); row.scrollIntoView({ block: 'nearest' }); }
838
- function activateOfficeTask(id) { if (!taskRows.has(id)) officeFilter(''); const row = taskRows.get(id); if (!row) return; spotlightRow(row); row.scrollIntoView({ block: 'nearest' }); }
982
+ function activateOfficePeer(id) { switchView('agents'); const row = peerRows.get(id); if (!row) return; spotlightRow(row); row.scrollIntoView({ block: 'nearest' }); row.focus?.(); }
983
+ function activateOfficeTask(id) { setColumnFocus(null); if (!taskRows.has(id)) officeFilter(''); const row = taskRows.get(id); if (!row) return; spotlightRow(row); row.scrollIntoView({ block: 'nearest' }); openTaskDetail(id); }
839
984
  function officeFilter(state) { $('task-filter').value = state; $('task-peer-filter').value = ''; $('task-release-filter').value = ''; if (!stopped && snapshotValid && lastSnapshot) render(lastSnapshot); }
840
985
  // Fade timing for a transient mark: it holds, then fades over the final second so it is gone at `until`.
841
986
  // Applied once at creation; later renders leave the running animation alone.
@@ -853,10 +998,16 @@ function officeFade(node, until, now) {
853
998
  function officeCardPaint(node, card) {
854
999
  node.setAttribute('class', 'office-card-node office-movable' + (card.waiting ? ' office-waiting-card' : '') + (card.rework ? ' office-rework-card' : '') + (card.checking ? ' office-card-checking' : ''));
855
1000
  node.setAttribute('aria-label', card.name);
856
- const children = [
857
- officeNode('rect', { x: 0, y: 0, width: card.w, height: card.h, rx: 0.6, class: 'office-card' }),
858
- officeNode('text', { x: 0.8, y: card.h - 0.7, class: 'office-card-text' }, officeClip(card.label, card.w > 12 ? 22 : 10))];
859
- if (card.checking) children.push(officeNode('text', { x: card.w - 0.6, y: card.h - 0.7, 'text-anchor': 'end', class: 'office-checking' }, '⟳'));
1001
+ const innerWidth = Math.max(0.1, card.w - 1.6 - (card.checking ? 1 : 0));
1002
+ const label = officeCardLabel(card, innerWidth * OFFICE_SCALE), prefix = '#' + card.id, clipId = node.dataset.officeClipId || (node.dataset.officeClipId = 'office-card-clip-' + (++officeClipSerial));
1003
+ const clip = officeNode('clipPath', { id: clipId, clipPathUnits: 'userSpaceOnUse' });
1004
+ clip.append(officeNode('rect', { x: 0.8, y: 0, width: innerWidth, height: card.h }));
1005
+ const text = officeNode('text', { x: 0.8, y: card.h / 2 + 0.33, class: 'office-card-text', 'clip-path': 'url(#' + clipId + ')' });
1006
+ text.append(officeNode('tspan', { class: 'office-card-id' }, prefix), officeNode('tspan', {}, label.slice(prefix.length)));
1007
+ const children = [officeNode('title', {}, card.label), clip,
1008
+ officeNode('rect', { x: 0, y: 0, width: card.w, height: card.h, rx: 0.5, class: 'office-card' }),
1009
+ officeNode('rect', { x: 0.15, y: 0.25, width: 0.2, height: Math.max(0.4, card.h - 0.5), rx: 0.1, class: 'office-card-accent' }), text];
1010
+ if (card.checking) children.push(officeNode('text', { x: card.w - 0.6, y: card.h / 2 + 0.33, 'text-anchor': 'end', class: 'office-checking' }, '⟳'));
860
1011
  node.replaceChildren(...children);
861
1012
  }
862
1013
  function officeDeskPaint(node, desk) {
@@ -905,32 +1056,37 @@ function officeDeskPaint(node, desk) {
905
1056
  if (desk.tray.needsReview) tray.push(desk.tray.needsReview + ' to review');
906
1057
  if (desk.tray.heldBy) tray.push('held by ' + desk.tray.heldBy);
907
1058
  if (tray.length) children.push(officeNode('text', { x: 1, y: line, class: 'office-meta' }, officeClip(tray.join(' / '), 38)));
908
- if (desk.extraCards > 0) children.push(officeNode('text', { x: 1, y: desk.h - 0.4, class: 'office-meta' }, '+' + desk.extraCards + ' more'));
909
1059
  node.replaceChildren(...children);
910
1060
  }
1061
+ // More is a sibling of the desk button, so assistive technology exposes both targets.
1062
+ function officeDeskMorePaint(node, desk) {
1063
+ officePlace(node, desk.x, desk.y);
1064
+ node.setAttribute('class', 'office-desk-more');
1065
+ node.setAttribute('aria-label', 'Show ' + desk.extraCards + ' more tasks owned by ' + desk.id);
1066
+ const iso = officeProjection === 'iso', at = iso ? officeIsoLocal(desk.w / 2, desk.h + 1) : [1, desk.h - 1];
1067
+ if (iso) at[1] += 4.1; // below the nameplate and its upright desk name, clear of the front legs
1068
+ node.replaceChildren(officeNode('text', { x: at[0], y: at[1], 'text-anchor': iso ? 'middle' : 'start', class: 'office-more' }, '+' + desk.extraCards + ' more'));
1069
+ }
1070
+ function officeRoomHeader(node, room) {
1071
+ officePlace(node, room.x, room.y, true);
1072
+ const iso = officeProjection === 'iso', y = iso ? -1.2 : 2.2, right = iso ? 12 : room.w - 1;
1073
+ node.setAttribute('class', 'office-room-header');
1074
+ node.replaceChildren(...(iso ? [officeNode('rect', { x: -0.5, y: -2.55, width: 12.5, height: 2, rx: 0.5, class: 'office-header-surface' })] : []), officeNode('text', { x: iso ? 0 : 1, y, class: 'office-room-title' }, room.label),
1075
+ officeNode('rect', { x: right - 3, y: y - 1.35, width: 3, height: 2, rx: 0.5, class: 'office-count-pill' }),
1076
+ officeNode('text', { x: right - 1.5, y, 'text-anchor': 'middle', class: 'office-room-count' }, String(room.count)));
1077
+ }
911
1078
  function officeRoomPaint(node, room) {
912
1079
  node.setAttribute('class', 'office-roombox office-roombox-' + room.id);
913
- officePlace(node, room.x, room.y);
914
- const iso = officeProjection === 'iso';
915
- const children = [officeTile(room.w, room.h, 'office-room')];
916
- let moreAt = { x: 1, y: room.h - 1, anchor: 'start' };
917
- if (iso) {
918
- // A faint wireframe floor box: the floor tile and the two back vertical edges; the label sits at the base.
919
- const B = officeIsoLocal(room.w, 0), D = officeIsoLocal(0, room.h), C = officeIsoLocal(room.w, room.h);
920
- 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' }));
921
- children.push(officeNode('text', { x: C[0], y: C[1] + 2.4, 'text-anchor': 'middle', class: 'office-room-title' }, room.label));
922
- children.push(officeNode('text', { x: C[0], y: C[1] + 3.8, 'text-anchor': 'middle', class: 'office-room-count' }, String(room.count)));
923
- moreAt = { x: D[0], y: D[1] + 2.4, anchor: 'middle' };
924
- } else {
925
- if (room.x > 0) children.push(officeNode('line', { x1: -1, y1: 0.6, x2: -1, y2: room.h - 0.6, class: 'office-divider' }));
926
- children.push(officeNode('text', { x: 1, y: 2.2, class: 'office-room-title' }, room.label));
927
- children.push(officeNode('text', { x: room.w - 1, y: 2.2, 'text-anchor': 'end', class: 'office-room-count' }, String(room.count)));
928
- }
929
- if (room.id === 'done') children.push(...officeShelf(iso));
1080
+ officePlace(node, room.x, room.y, true);
1081
+ const iso = officeProjection === 'iso', children = [officeTile(room.w, room.h, 'office-room')];
1082
+ const emptyAt = iso ? officeIsoLocal(room.w / 2, 10) : [1, 5];
1083
+ if (room.id === 'done') children.push(...officeShelf(iso, room.count));
1084
+ if ((room.id === 'done' ? room.count : room.cards.length) === 0) children.push(officeNode('text', { x: emptyAt[0], y: emptyAt[1], 'text-anchor': iso ? 'middle' : 'start', class: 'office-empty' }, 'No tasks'));
930
1085
  node.replaceChildren(...children);
931
1086
  if (room.overflow > 0 && room.filterState) {
1087
+ const at = iso ? officeIsoLocal(room.w / 2, room.h - 1.5) : [1, room.h - 1];
932
1088
  const more = officeNode('g', { tabindex: '0', role: 'button', 'aria-label': 'Show ' + room.overflow + ' more ' + room.filterState.replaceAll('_', ' ') + ' tasks in the task list' });
933
- more.append(officeNode('text', { x: moreAt.x, y: moreAt.y, 'text-anchor': moreAt.anchor, class: 'office-more' }, '+' + room.overflow + ' more'));
1089
+ more.append(officeNode('text', { x: at[0], y: at[1], 'text-anchor': iso ? 'middle' : 'start', class: 'office-more' }, '+' + room.overflow + ' more'));
934
1090
  const open = () => officeFilter(room.filterState);
935
1091
  more.addEventListener('click', open);
936
1092
  more.addEventListener('keydown', (event) => officeKeys(event, open));
@@ -942,7 +1098,7 @@ function officeEnsure(target) {
942
1098
  if (officeScene) return officeScene;
943
1099
  const svg = officeNode('svg', { class: 'office-scene', role: 'group', 'aria-label': 'Office: desks, rooms and task cards' });
944
1100
  const layers = {};
945
- 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; }
1101
+ for (const name of ['rooms', 'desks', 'links', 'marks', 'arcs', 'cards', 'cardFocus', 'headers']) { const layer = officeNode('g', { class: 'office-layer office-layer-' + name }); svg.append(layer); layers[name] = layer; }
946
1102
  officeScene = { svg, layers, els: new Map(), leaving: new Map(), settled: true };
947
1103
  target.replaceChildren(svg);
948
1104
  return officeScene;
@@ -967,7 +1123,12 @@ function officeLeave(scene, key) {
967
1123
  }
968
1124
  function drawOffice(target, model) {
969
1125
  const scene = officeEnsure(target), els = scene.els, now = Date.now();
970
- scene.svg.setAttribute('viewBox', officeViewBox(model.width, model.height));
1126
+ // Separate upright desk labels and room headers by one projected unit, including the desk legs.
1127
+ officeRoomOffset = officeProjection === 'iso' && model.desks.length
1128
+ ? Math.max(0, Math.max(...model.desks.map(desk => officeProject(desk.x + desk.w, desk.y + desk.h)[1] + 2.4)) + 1 - Math.min(...model.rooms.map(room => officeProject(room.x, room.y)[1] - 2.55))) : 0;
1129
+ const viewBox = officeViewBox(model.width, model.height, model);
1130
+ scene.svg.setAttribute('viewBox', viewBox);
1131
+ scene.svg.setAttribute('width', Number(viewBox.split(' ')[2]) * OFFICE_SCALE);
971
1132
  const switched = officeLastProjection !== officeProjection; // a view switch jumps, never animates
972
1133
  officeLastProjection = officeProjection;
973
1134
  scene.settled = model.settle || switched;
@@ -976,7 +1137,7 @@ function drawOffice(target, model) {
976
1137
  const seen = new Set();
977
1138
  const keep = (key, sig, layer, paint, appear) => {
978
1139
  seen.add(key);
979
- sig = officeProjection + '|' + sig; // a projection switch repaints every node in place
1140
+ sig = officeProjection + '|' + officeRoomOffset + '|' + sig; // a projection switch repaints every node in place
980
1141
  const entry = els.get(key);
981
1142
  if (entry && entry.sig === sig) return { node: entry.node, fresh: false };
982
1143
  const node = entry ? entry.node : officeNode('g', {});
@@ -989,33 +1150,60 @@ function drawOffice(target, model) {
989
1150
  }
990
1151
  return { node, fresh: true };
991
1152
  };
992
- for (const room of model.rooms) {
993
- keep('room:' + room.id, JSON.stringify({ ...room, cards: undefined }), scene.layers.rooms, (node) => officeRoomPaint(node, room), false);
1153
+ const roomCards = new Map(model.rooms.map(room => [room.id, officeRoomCards(room)]));
1154
+ for (const original of model.rooms) {
1155
+ const visible = roomCards.get(original.id), room = { ...original, cards: visible, overflow: original.overflow + original.cards.length - visible.length };
1156
+ keep('room:' + room.id, JSON.stringify({ ...room, cards: undefined, visibleCount: room.cards.length }), scene.layers.rooms, (node) => officeRoomPaint(node, room), false);
994
1157
  }
995
- for (const desk of model.desks) {
996
- const { node, fresh } = keep('desk:' + desk.id, JSON.stringify({ ...desk, cards: undefined }), scene.layers.desks, (n) => officeDeskPaint(n, desk));
1158
+ const deskCards = new Map(model.desks.map(desk => [desk.id, officeDeskCards(desk)]));
1159
+ for (const original of model.desks) {
1160
+ const visible = deskCards.get(original.id), desk = { ...original, cards: visible, extraCards: original.extraCards + original.cards.length - visible.length };
1161
+ const { node, fresh } = keep('desk:' + desk.id, JSON.stringify({ ...desk, cards: undefined, visibleCount: desk.cards.length }), scene.layers.desks, (n) => officeDeskPaint(n, desk));
997
1162
  if (fresh && !node.getAttribute('tabindex')) {
998
1163
  node.setAttribute('tabindex', '0'); node.setAttribute('role', 'button');
999
1164
  const open = () => activateOfficePeer(desk.id);
1000
1165
  node.addEventListener('click', open);
1001
1166
  node.addEventListener('keydown', (event) => officeKeys(event, open));
1002
1167
  }
1168
+ if (desk.extraCards > 0) {
1169
+ const { node: more, fresh: newMore } = keep('desk-more:' + desk.id, JSON.stringify([desk.id, desk.x, desk.y, desk.w, desk.h, desk.extraCards]), scene.layers.marks, n => officeDeskMorePaint(n, desk), false);
1170
+ if (newMore && !more.getAttribute('tabindex')) {
1171
+ more.setAttribute('tabindex', '0'); more.setAttribute('role', 'button');
1172
+ const open = event => { event.stopPropagation(); setColumnFocus(null); officeFilter('work'); $('task-peer-filter').value = desk.id; if (!stopped && snapshotValid && lastSnapshot) render(lastSnapshot); switchView('board'); };
1173
+ more.addEventListener('click', open);
1174
+ more.addEventListener('keydown', event => officeKeys(event, () => open(event)));
1175
+ }
1176
+ }
1003
1177
  }
1004
1178
  const cards = [];
1005
- for (const desk of model.desks) cards.push(...desk.cards);
1006
- for (const room of model.rooms) cards.push(...room.cards);
1179
+ for (const desk of model.desks) cards.push(...deskCards.get(desk.id));
1180
+ for (const room of model.rooms) cards.push(...roomCards.get(room.id));
1181
+ const focusCards = [...cards]; // Model order stays stable while visual depth changes.
1182
+ if (officeProjection === 'iso') cards.sort((a, b) => (a.drawY ?? officeProject(a.x, a.y)[1]) - (b.drawY ?? officeProject(b.x, b.y)[1]) || a.id - b.id);
1183
+ const placeCard = (node, card) => {
1184
+ const p = card.drawX === undefined ? officeProject(card.x, card.y) : [card.drawX, card.drawY], transform = 'translate(' + p[0] + 'px,' + p[1] + 'px)';
1185
+ if (node.style.transform !== transform) node.style.transform = transform; // the only animated property
1186
+ };
1187
+ const paintNodes = [], focusNodes = [];
1007
1188
  for (const card of cards) {
1008
- const key = 'card:' + card.id;
1009
- 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));
1189
+ const { node } = keep('paint:card:' + card.id, JSON.stringify([card.label, card.name, card.waiting, card.rework, card.checking, card.w, card.h]), scene.layers.cards, n => { officeCardPaint(n, card); n.setAttribute('aria-hidden', 'true'); });
1190
+ paintNodes.push(node); placeCard(node, card);
1191
+ }
1192
+ for (const card of focusCards) {
1193
+ const { node, fresh } = keep('card:' + card.id, JSON.stringify([card.label, card.name, card.w, card.h]), scene.layers.cardFocus, n => {
1194
+ n.setAttribute('class', 'office-card-focus office-movable'); n.setAttribute('aria-label', card.name);
1195
+ n.replaceChildren(officeNode('title', {}, card.label), officeNode('rect', { x: 0, y: 0, width: card.w, height: card.h, rx: 0.5, class: 'office-card office-card-hit' }));
1196
+ });
1010
1197
  if (fresh && !node.getAttribute('tabindex')) {
1011
1198
  node.setAttribute('tabindex', '0'); node.setAttribute('role', 'button');
1012
1199
  const open = () => activateOfficeTask(card.id);
1013
1200
  node.addEventListener('click', open);
1014
1201
  node.addEventListener('keydown', (event) => officeKeys(event, open));
1015
1202
  }
1016
- const p = officeProject(card.x, card.y), transform = 'translate(' + p[0] + 'px,' + p[1] + 'px)';
1017
- if (node.style.transform !== transform) node.style.transform = transform; // the only animated property
1203
+ focusNodes.push(node); placeCard(node, card);
1018
1204
  }
1205
+ officeOrderNodes(scene.layers.cards, paintNodes); officeOrderNodes(scene.layers.cardFocus, focusNodes);
1206
+ for (const room of model.rooms) keep('header:' + room.id, JSON.stringify([room.x, room.y, room.w, room.count, room.label]), scene.layers.headers, node => officeRoomHeader(node, room), false);
1019
1207
  if (model.extraPeers > 0) {
1020
1208
  keep('extra:peers', String(model.extraPeers) + ':' + model.width, scene.layers.marks,
1021
1209
  (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);
@@ -1086,9 +1274,9 @@ function renderOffice(snapshot) {
1086
1274
  section.hidden = view === 'off';
1087
1275
  if (view === 'off') {
1088
1276
  if (signatures.has('office')) { signatures.delete('office'); $('office').replaceChildren(); officeScene = null; }
1089
- officePrev = null; officeDesks.clear(); officeCards.clear(); return;
1277
+ officePrev = null; officeDesks.clear(); officeCards.clear(); $('office').replaceChildren(el('p','Office is off. Choose Flat or Isometric above.','empty')); return;
1090
1278
  }
1091
- const cols = (section.clientWidth || 0) >= 1000 ? 4 : (section.clientWidth || 0) >= 700 ? 2 : 1;
1279
+ const cols = (section.clientWidth || 0) >= 1000 && view !== 'isometric' ? 4 : (section.clientWidth || 0) >= 700 ? 2 : 1;
1092
1280
  const model = officeModel(snapshot, officePrev, { cols, now: Date.now() });
1093
1281
  officePrev = model;
1094
1282
  const signature = JSON.stringify([view, cols, model]);
@@ -1306,6 +1494,7 @@ $('task-filter').addEventListener('change', () => { if (!stopped && snapshotVali
1306
1494
  window.addEventListener('resize', () => { if (!stopped && officeView.get() !== 'off' && snapshotValid && lastSnapshot) renderOffice(lastSnapshot); });
1307
1495
  $('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 = ''; });
1308
1496
  $('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 = ''; } });
1497
+ initializeDashboardNavigation();
1309
1498
  (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'; } } })();
1310
1499
  </script>
1311
1500
  </body>