@staix/agent-hub 0.12.26 → 0.12.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +2 -2
- package/docs/operations.md +80 -24
- package/docs/quickstart.md +1 -1
- package/docs/security.md +34 -6
- package/docs/specs/2026-09-19-agent-hub-design.md +76 -27
- package/docs/specs/2026-10-12-office-view-design.md +55 -11
- package/docs/verification/2026-10-15-0.12.27.md +31 -0
- package/docs/verification/assets/391-pet-window-spike.png +0 -0
- package/docs/verified.json +8 -8
- package/package.json +3 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3 -1
- package/src/cli/help.ts +1 -0
- package/src/cli/identity.ts +1 -0
- package/src/cli/main.ts +15 -3
- package/src/cli/pet-config.ts +1 -1
- package/src/hub/ui.ts +1 -3
- package/src/pet/config.ts +42 -5
- package/src/pet/window.html +114 -0
- package/src/pet/window.jxa.js +122 -0
- package/src/pet/window.ts +154 -0
- package/src/ui/index.html +318 -129
- package/src/ui/office-model.ts +12 -12
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
|
-
|
|
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();
|
|
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
|
|
23
|
-
|
|
24
|
-
.office-
|
|
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
|
-
|
|
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
|
|
33
|
-
<
|
|
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 & 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 & 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
|
-
/*
|
|
75
|
+
/* NEEDS_MODEL */
|
|
60
76
|
/* OFFICE_MODEL */
|
|
61
77
|
const officeView = (() => {
|
|
62
78
|
let choice = 'off';
|
|
63
|
-
|
|
64
|
-
return { get: () => choice, set: (value) => { choice = ['off','flat','isometric'].includes(value) ? value : 'off';
|
|
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
|
-
|
|
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
|
|
519
|
-
const row = el('article', undefined, 'task-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
630
|
-
let
|
|
631
|
-
function
|
|
632
|
-
if (!snapshotValid || !
|
|
633
|
-
const item =
|
|
634
|
-
|
|
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
|
|
641
|
-
const
|
|
642
|
-
const
|
|
643
|
-
|
|
644
|
-
const
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
target.
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
const
|
|
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
|
-
|
|
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:
|
|
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 -
|
|
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
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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) {
|
|
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
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
|
916
|
-
|
|
917
|
-
if (
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
993
|
-
|
|
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
|
-
|
|
996
|
-
|
|
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.
|
|
1006
|
-
for (const room of model.rooms) cards.push(...room.
|
|
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
|
|
1009
|
-
|
|
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
|
-
|
|
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>
|