@staix/agent-hub 0.6.2 → 0.6.3
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 +4 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1 -1
- package/src/ui/index.html +71 -16
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.3
|
|
4
|
+
|
|
5
|
+
- The dashboard follows the system light/dark scheme with two tuned palettes, adds relative timestamps, kind filters for the message stream, a state filter for the task list and colored usage bars for budgets (#38).
|
|
6
|
+
|
|
3
7
|
## 0.6.2
|
|
4
8
|
|
|
5
9
|
- Address a peer's reply at whoever asked instead of broadcasting it, so one directed question no longer costs every attached peer a turn (#29).
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Native multi-agent hub for one developer's machine: Claude Code, Codex, Kimi Cod
|
|
|
4
4
|
hub-owned local-LLM worker collaborate as peers in independent project directories, with
|
|
5
5
|
task-aware model routing (Switchyard) in front of a self-hosted gateway (OmniRoute).
|
|
6
6
|
|
|
7
|
-
Status: 0.6.
|
|
7
|
+
Status: 0.6.3. All six milestones of the design spec are implemented; the [smoke checklist](docs/smoke.md)
|
|
8
8
|
says what has and has not been verified against real agents.
|
|
9
9
|
|
|
10
10
|
Version 0.5.0 adds [controlled upgrade and session recovery](docs/specs/2026-09-20-upgrade-recovery-design.md).
|
|
@@ -29,7 +29,7 @@ cd <your project> && ahub init && ahub up && ahub tail
|
|
|
29
29
|
Or install the same version from GitHub:
|
|
30
30
|
|
|
31
31
|
```bash
|
|
32
|
-
bun add -g github:STAIxBWLB/agent-hub#v0.6.
|
|
32
|
+
bun add -g github:STAIxBWLB/agent-hub#v0.6.3 && ahub setup
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
The installed commands remain `ahub` and `agent-hub`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-hub",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "Channel between Claude Code and the agent-hub daemon: peer messages from Codex, Kimi and the local worker arrive as channel events; hub_send replies.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Young Joon Lee",
|
|
@@ -15726,7 +15726,7 @@ class ControlClient {
|
|
|
15726
15726
|
// package.json
|
|
15727
15727
|
var package_default = {
|
|
15728
15728
|
name: "@staix/agent-hub",
|
|
15729
|
-
version: "0.6.
|
|
15729
|
+
version: "0.6.3",
|
|
15730
15730
|
description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
|
|
15731
15731
|
license: "MIT",
|
|
15732
15732
|
type: "module",
|
package/src/ui/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
6
|
<title>agent-hub | Local dashboard</title>
|
|
7
7
|
<style>
|
|
8
|
-
:root{color-scheme:light;--paper:#f5f4ef;--ink:#252b29;--muted:#68716c;--line:#d9ddd5;--green:#245f45;--red:#a2392f}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.5 system-ui,sans-serif}header,main{max-width:1440px;margin:auto;padding:24px 32px}header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);gap:20px}h1{font-size:24px;letter-spacing:-1px;margin:0}h1 span{font-weight:400;color:var(--muted)}h2{font-size:15px;margin:0}p{margin:6px 0}.eyebrow
|
|
8
|
+
:root{color-scheme:light;--paper:#f5f4ef;--ink:#252b29;--muted:#68716c;--line:#d9ddd5;--panel:#ffffff;--panel-soft:#f4f6f2;--control:#ffffff;--control-border:#bbc4bb;--hover:#f0f4ef;--green:#245f45;--green-soft:#e4efe9;--amber:#8a5b12;--amber-soft:#f8eed6;--blue:#245a93;--blue-soft:#e6eef8;--red:#a2392f;--red-soft:#f6e5e2;--dot:#98a29b;--dot-ok:#2f7a52;--dot-warn:#b07a16;--dot-err:#b24539;--focus:#245f45;--on-accent:#ffffff}@media(prefers-color-scheme:dark){:root{color-scheme:dark;--paper:#161a18;--ink:#e5e8e3;--muted:#97a099;--line:#2c342f;--panel:#1d2320;--panel-soft:#17201b;--control:#1d2320;--control-border:#3a443d;--hover:#263029;--green:#6fc2a0;--green-soft:#1e3a2e;--amber:#d9a64f;--amber-soft:#3a2f14;--blue:#8ab8ea;--blue-soft:#1d2f44;--red:#e28b80;--red-soft:#3d2320;--dot:#5b6660;--dot-ok:#4cb77f;--dot-warn:#d9a64f;--dot-err:#e28b80;--focus:#6fc2a0;--on-accent:#161a18}}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.5 system-ui,sans-serif}header,main{max-width:1440px;margin:auto;padding:24px 32px}header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);gap:20px}h1{font-size:24px;letter-spacing:-1px;margin:0}h1 span{font-weight:400;color:var(--muted)}h2{font-size:15px;margin:0}p{margin:6px 0}.eyebrow{overflow-wrap:anywhere;font-size:12px;color:var(--muted)}.eyebrow{letter-spacing:1.5px;text-transform:uppercase}.meta{overflow-wrap:anywhere;font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}.connection{font-size:12px;color:var(--muted)}main{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:24px;align-items:start}.stack{display:flex;flex-direction:column;gap:24px;min-width:0}section{border:1px solid var(--line);background:var(--panel);border-radius:8px;overflow:hidden}.section-head{display:flex;justify-content:space-between;align-items:center;padding:14px 18px;border-bottom:1px solid var(--line);gap:12px}.section-head .filters{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.content{padding:16px 18px}.empty{color:var(--muted);padding:20px 18px}.row{padding:14px 18px;border-bottom:1px solid var(--line)}.row:last-child{border-bottom:0}.row-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.peername{display:flex;align-items:center;gap:8px;min-width:0}.label{font-weight:600;overflow-wrap:anywhere}.badge{display:inline-block;font:11px ui-monospace,monospace;padding:2px 7px;background:var(--panel-soft);color:var(--muted);border-radius:4px;white-space:nowrap}.badge.ok{background:var(--green-soft);color:var(--green)}.badge.warn{color:var(--amber);background:var(--amber-soft)}.badge.info{color:var(--blue);background:var(--blue-soft)}.badge.err{color:var(--red);background:var(--red-soft)}.dot{flex:none;width:9px;height:9px;border-radius:50%;background:var(--dot)}.dot.ok{background:var(--dot-ok)}.dot.warn{background:var(--dot-warn)}.dot.err{background:var(--dot-err)}.chips{display:flex;gap:6px;flex-wrap:wrap}.chip{background:var(--control);color:var(--muted);border:1px solid var(--control-border);border-radius:999px;padding:4px 10px;font-size:12px;cursor:pointer;line-height:1.2}.chip:hover{background:var(--hover)}.chip[aria-pressed="true"]{background:var(--green);color:var(--on-accent);border-color:var(--green);font-weight:600}.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:10px}button,input,select,textarea{font:inherit}button{background:var(--control);color:var(--ink);border:1px solid var(--control-border);border-radius:5px;padding:6px 10px;cursor:pointer}button:hover{background:var(--hover)}button.primary{background:var(--green);color:var(--on-accent);border-color:var(--green)}button.danger{color:var(--red)}button:disabled{opacity:.5;cursor:default}:focus-visible{outline:2px solid var(--focus);outline-offset:3px}input,select,textarea{width:100%;border:1px solid var(--control-border);border-radius:5px;padding:8px;background:var(--control);color:var(--ink)}input[type="checkbox"]{width:auto;margin-right:6px}textarea{resize:vertical;min-height:78px}label{display:block;font-size:12px;color:var(--muted);margin-bottom:5px}form .field{margin-bottom:12px}.actions select{width:auto;max-width:220px}.section-head .filters select{width:auto;max-width:220px}.body{white-space:pre-wrap;overflow-wrap:anywhere;margin-top:7px}.stream{height:360px;overflow:auto}.event{padding:12px 18px;border-bottom:1px solid var(--line)}.event[hidden]{display:none}.event .body{font:13px/1.55 ui-monospace,monospace}.notice{grid-column:1/-1;margin:0;border:1px solid var(--line);border-radius:6px;padding:10px 14px;background:var(--panel);overflow-wrap:anywhere}.notice.error{color:var(--red);border-color:var(--red)}.notice:empty{display:none}progress{width:100%;height:8px;accent-color:var(--green);display:block;margin:8px 0}progress.warn{accent-color:var(--amber)}progress.warn::-webkit-progress-value{background:var(--amber)}progress.critical{accent-color:var(--red)}progress.critical::-webkit-progress-value{background:var(--red)}progress::-moz-progress-bar{background:var(--green)}progress.warn::-moz-progress-bar{background:var(--amber)}progress.critical::-moz-progress-bar{background:var(--red)}.budget-window+.budget-window{margin-top:14px}.split{display:grid;grid-template-columns:1fr 1fr;gap:12px}details summary{cursor:pointer;padding:14px 18px;color:var(--green);font-weight:600}details form{padding:0 18px 18px}.task-list{max-height:460px;overflow:auto}footer{max-width:1440px;margin:auto;padding:0 32px 24px;color:var(--muted);font-size:12px}@media(max-width:900px){main{grid-template-columns:1fr}.stack{gap:18px}header,main{padding:18px}.stream{height:300px}footer{padding:0 18px 18px}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto}}
|
|
9
9
|
</style>
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
<main>
|
|
14
14
|
<p id="notice" class="notice" role="status" aria-live="polite"></p>
|
|
15
15
|
<div class="stack">
|
|
16
|
-
<section aria-labelledby="stream-title"><div class="section-head"><h2 id="stream-title">Live stream</h2><label><input id="follow" type="checkbox" checked> Follow latest</label></div><div id="stream" class="stream" tabindex="0" aria-label="Recent hub events"><p class="empty">Waiting for hub activity...</p></div></section>
|
|
17
|
-
<section aria-labelledby="tasks-title"><div class="section-head"><h2 id="tasks-title">Task board</h2><span id="task-count" class="meta"></span></div><div id="tasks" class="task-list"></div><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><button class="primary" data-mutation="project" type="submit">Propose task</button></form></details></section>
|
|
16
|
+
<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>
|
|
17
|
+
<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="proposed">Proposed</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="tasks" class="task-list"></div><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><button class="primary" data-mutation="project" type="submit">Propose task</button></form></details></section>
|
|
18
18
|
<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>
|
|
19
19
|
</div>
|
|
20
20
|
<div class="stack">
|
|
@@ -33,11 +33,26 @@ const $ = (id) => document.getElementById(id);
|
|
|
33
33
|
const el = (tag, text, cls) => { const node = document.createElement(tag); if (text !== undefined) node.textContent = String(text); if (cls) node.className = cls; return node; };
|
|
34
34
|
let stopped = false, cursor = 0, timer, projectsTimer, eventCount = 0;
|
|
35
35
|
let managerMode = false, projectList = [], selectedProjectId = '', selectedInstanceId = '', snapshotValid = false, viewGeneration = 0, projectRequest = 0;
|
|
36
|
+
let lastSnapshot = null;
|
|
36
37
|
const drafts = new Map();
|
|
37
38
|
const pendingButtons = new WeakSet();
|
|
38
39
|
let draftChoicesPending = false;
|
|
39
40
|
const signatures = new Map();
|
|
40
41
|
const time = (value) => value ? new Date(value).toLocaleString() : 'unknown';
|
|
42
|
+
const rel = (value) => {
|
|
43
|
+
if (!value) return 'unknown';
|
|
44
|
+
const delta = Math.round((value - Date.now()) / 1000), past = delta < 0, span = Math.abs(delta);
|
|
45
|
+
if (span < 15) return 'just now';
|
|
46
|
+
const unit = span < 60 ? [span, 's'] : span < 3600 ? [Math.floor(span / 60), 'm'] : span < 86400 ? [Math.floor(span / 3600), 'h'] : [Math.floor(span / 86400), 'd'];
|
|
47
|
+
const text = unit[0] + unit[1];
|
|
48
|
+
return past ? text + ' ago' : 'in ' + text;
|
|
49
|
+
};
|
|
50
|
+
const badge = (text, cls = '') => { const node = el('span', text, 'badge' + (cls ? ' ' + cls : '')); return node; };
|
|
51
|
+
const dot = (cls) => el('span', undefined, 'dot ' + cls);
|
|
52
|
+
const taskBadge = { proposed: '', in_progress: 'warn', in_review: 'info', approved: 'ok', changes_requested: 'err' };
|
|
53
|
+
const peerDot = { idle: 'ok', busy: 'warn', paused: 'warn', offline: '' };
|
|
54
|
+
const projectBadge = { running: 'ok', starting: 'warn', stopping: 'warn', stopped: '', unavailable: 'err', incompatible: 'err', missing: 'err' };
|
|
55
|
+
function refreshRelativeTimes(root) { for (const node of root.querySelectorAll('[data-ts]')) node.textContent = rel(Number(node.dataset.ts)); }
|
|
41
56
|
function notice(text, error = false) { $('notice').textContent = text; $('notice').classList.toggle('error', error); }
|
|
42
57
|
function expire() { stopped = true; clearTimeout(timer); clearTimeout(projectsTimer); $('connection').textContent = 'Session closed'; notice('Session missing or expired. Run ahub ui in your terminal to reopen the dashboard.', true); document.querySelectorAll('button').forEach((button) => { button.disabled = true; }); }
|
|
43
58
|
async function post(path, body) {
|
|
@@ -81,6 +96,8 @@ function resetView(id, instance) {
|
|
|
81
96
|
saveDraft();
|
|
82
97
|
selectedProjectId = id || ''; selectedInstanceId = instance || ''; snapshotValid = false; cursor = 0; eventCount = 0; viewGeneration++;
|
|
83
98
|
signatures.clear(); $('stream').replaceChildren(); empty($('stream'), managerMode ? 'Select a running project...' : 'Waiting for hub activity...');
|
|
99
|
+
if (streamFilter) { streamFilter = ''; for (const child of $('stream-filters').children) child.setAttribute('aria-pressed', 'false'); }
|
|
100
|
+
$('stream-empty').hidden = true; $('task-filter').value = '';
|
|
84
101
|
for (const [id, text] of [['peers','No project selected.'],['tasks','No project selected.'],['permissions','No project selected.'],['budget','No project selected.']]) { $(id).replaceChildren(); empty($(id), text); }
|
|
85
102
|
$('peer-count').textContent = ''; $('task-count').textContent = ''; $('permission-count').textContent = ''; $('project-context').textContent = '';
|
|
86
103
|
notice('');
|
|
@@ -98,8 +115,9 @@ function renderProjects() {
|
|
|
98
115
|
if (!projectList.length) return empty(target, 'No registered projects.');
|
|
99
116
|
for (const project of projectList) {
|
|
100
117
|
const id = typeof project.id === 'string' ? project.id : '', state = String(project.state || 'unavailable');
|
|
101
|
-
const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head');
|
|
102
|
-
|
|
118
|
+
const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head'), name = el('div', undefined, 'peername');
|
|
119
|
+
name.append(dot(projectBadge[state] || ''), el('span', projectName(project.root, id), 'label'));
|
|
120
|
+
head.append(name, badge(state, projectBadge[state])); row.append(head); row.append(el('p', String(project.root || id), 'meta'));
|
|
103
121
|
const counts = []; if (project.peers !== undefined) counts.push(String(count(project.peers)) + ' peers'); if (project.tasks !== undefined) counts.push(String(taskCount(project.tasks)) + ' tasks');
|
|
104
122
|
if (counts.length) row.append(el('p', counts.join(' / '), 'meta')); if (project.warning || project.error) row.append(el('p', project.warning || project.error, 'meta'));
|
|
105
123
|
const actions = el('div', undefined, 'actions');
|
|
@@ -134,8 +152,9 @@ function render(snapshot) {
|
|
|
134
152
|
update('peers', peers, (target) => {
|
|
135
153
|
if (!ids.length) return empty(target, 'No peers connected. Start an agent from your terminal.');
|
|
136
154
|
for (const [id, peer] of Object.entries(peers)) {
|
|
137
|
-
const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head');
|
|
138
|
-
|
|
155
|
+
const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head'), name = el('div', undefined, 'peername');
|
|
156
|
+
name.append(dot(peerDot[peer.state] || ''), el('span', id, 'label'));
|
|
157
|
+
head.append(name, badge(peer.state, peerDot[peer.state] || ''));
|
|
139
158
|
row.append(head, el('p', peer.queued + ' queued', 'meta'));
|
|
140
159
|
if (peer.paused) row.append(el('p', peer.paused, 'meta'));
|
|
141
160
|
if (peer.requestedModel) row.append(el('p', 'Requested: ' + peer.requestedModel, 'meta'));
|
|
@@ -149,11 +168,14 @@ function render(snapshot) {
|
|
|
149
168
|
const peerSignature = JSON.stringify(ids);
|
|
150
169
|
if (signatures.get('peer-options') !== peerSignature) { signatures.set('peer-options', peerSignature); peerOptions($('owner'), ids, 'Automatic'); peerOptions($('recipient'), ids, 'All peers'); if (draftChoicesPending) { restoreDraft(true); draftChoicesPending = false; } }
|
|
151
170
|
$('task-count').textContent = snapshot.tasks.length + ' tasks';
|
|
152
|
-
|
|
171
|
+
const taskFilter = $('task-filter').value;
|
|
172
|
+
const visibleTasks = taskFilter ? snapshot.tasks.filter((task) => task.state === taskFilter) : snapshot.tasks;
|
|
173
|
+
update('tasks', [visibleTasks, ids, taskFilter, snapshot.tasks.length], (target) => {
|
|
153
174
|
if (!snapshot.tasks.length) return empty(target, 'No public tasks yet. Propose a task below to get started.');
|
|
154
|
-
|
|
175
|
+
if (!visibleTasks.length) return empty(target, 'No tasks in this state.');
|
|
176
|
+
for (const task of visibleTasks) {
|
|
155
177
|
const row = el('div', undefined, 'row'), head = el('div', undefined, 'row-head');
|
|
156
|
-
head.append(el('span', '#' + task.id + ' ' + task.title, 'label'),
|
|
178
|
+
head.append(el('span', '#' + task.id + ' ' + task.title, 'label'), badge(task.state.replaceAll('_', ' '), taskBadge[task.state] || ''));
|
|
157
179
|
row.append(head, el('p', task.class + ' / ' + (task.owner || 'unassigned') + (task.reviewer ? ' / review: ' + task.reviewer : ''), 'meta'));
|
|
158
180
|
if (task.detail) row.append(el('p', task.detail, 'body'));
|
|
159
181
|
if (task.state !== 'approved' && ids.length) {
|
|
@@ -179,9 +201,17 @@ function render(snapshot) {
|
|
|
179
201
|
if (!Object.keys(snapshot.budget).length) return empty(target, 'No usage readings yet. Usage appears when a peer reports it.');
|
|
180
202
|
for (const [peer, budget] of Object.entries(snapshot.budget)) {
|
|
181
203
|
const row = el('div', undefined, 'row'); row.append(el('div', peer, 'label'));
|
|
182
|
-
if (budget.paused)
|
|
204
|
+
if (budget.paused) { const line = el('p', 'Paused: ' + budget.paused.reason + ' / resets ', 'meta'), stamp = el('span'); stamp.dataset.ts = budget.paused.resetsAt; stamp.title = time(budget.paused.resetsAt); stamp.textContent = rel(budget.paused.resetsAt); line.append(stamp); row.append(line); }
|
|
183
205
|
if (!budget.windows.length) row.append(el('p', 'No usage windows reported.', 'meta'));
|
|
184
|
-
for (const window of budget.windows) {
|
|
206
|
+
for (const window of budget.windows) {
|
|
207
|
+
const item = el('div', undefined, 'budget-window'), percent = Math.round(window.used * 100);
|
|
208
|
+
item.append(el('p', window.id + ': ' + percent + '% used' + (window.stale ? ' (stale)' : ''), 'meta'));
|
|
209
|
+
const bar = el('progress'); bar.max = 1; bar.value = window.used; bar.className = percent >= 90 ? 'critical' : percent >= 70 ? 'warn' : '';
|
|
210
|
+
bar.setAttribute('aria-label', peer + ' ' + window.id + ' usage'); item.append(bar);
|
|
211
|
+
const meta = el('p', window.source + (window.resetsAt ? ' / resets ' : ''), 'meta');
|
|
212
|
+
if (window.resetsAt) { const stamp = el('span'); stamp.dataset.ts = window.resetsAt; stamp.title = time(window.resetsAt); stamp.textContent = rel(window.resetsAt); meta.append(stamp); }
|
|
213
|
+
item.append(meta); row.append(item);
|
|
214
|
+
}
|
|
185
215
|
target.append(row);
|
|
186
216
|
}
|
|
187
217
|
});
|
|
@@ -190,13 +220,18 @@ function render(snapshot) {
|
|
|
190
220
|
for (const entry of snapshot.events || []) {
|
|
191
221
|
if (entry.seq <= cursor) continue;
|
|
192
222
|
if (!eventCount) stream.replaceChildren();
|
|
193
|
-
const event = entry.event, node = el('div', undefined, 'event');
|
|
194
|
-
|
|
223
|
+
const event = entry.event, node = el('div', undefined, 'event'), kind = event.env ? event.env.kind : 'state';
|
|
224
|
+
node.dataset.kind = kind; applyStreamFilter(node);
|
|
225
|
+
if (event.env) { const env = event.env, meta = el('div', '', 'meta'), stamp = el('span'); stamp.dataset.ts = env.ts; stamp.title = time(env.ts); stamp.textContent = rel(env.ts);
|
|
226
|
+
meta.append(stamp, el('span', ' / ' + env.from + ' → ' + (env.to ? env.to.join(', ') : 'all') + ' / ' + env.kind + (event.t !== 'envelope' ? ' / ' + event.t : '') + (event.dropped ? ' / ' + event.dropped : '')));
|
|
227
|
+
node.append(meta, el('div', env.body, 'body')); }
|
|
195
228
|
else node.append(el('div', event.peer + ' / ' + event.state, 'meta'));
|
|
196
229
|
stream.append(node); eventCount++; while (stream.children.length > 200) stream.firstElementChild.remove();
|
|
197
230
|
}
|
|
198
231
|
cursor = Math.max(cursor, snapshot.cursor || 0);
|
|
199
232
|
if ($('follow').checked) stream.scrollTop = stream.scrollHeight;
|
|
233
|
+
refreshRelativeTimes(document);
|
|
234
|
+
refreshStreamEmpty();
|
|
200
235
|
syncMutationControls();
|
|
201
236
|
}
|
|
202
237
|
async function poll() {
|
|
@@ -207,15 +242,35 @@ async function poll() {
|
|
|
207
242
|
const input = {after:cursor}; if (managerMode) { input.projectId = captured.projectId; input.instanceId = captured.instanceId; }
|
|
208
243
|
const result = await post('/snapshot', input);
|
|
209
244
|
if (!managerMode || (result.projectId === captured.projectId && result.instanceId === captured.instanceId && captured.generation === viewGeneration)) {
|
|
210
|
-
snapshotValid = true; render(result); $('connection').textContent = 'Live / updated ' + new Date().toLocaleTimeString();
|
|
245
|
+
snapshotValid = true; lastSnapshot = result; render(result); $('connection').textContent = 'Live / updated ' + new Date().toLocaleTimeString();
|
|
211
246
|
}
|
|
212
247
|
}
|
|
213
248
|
catch (error) { if (!stopped) $('connection').textContent = 'Disconnected / retrying: ' + error.message; }
|
|
214
249
|
if (!stopped) timer = setTimeout(poll, 1000);
|
|
215
250
|
}
|
|
251
|
+
const STREAM_KINDS = ['chat', 'task', 'review', 'status', 'budget', 'presence', 'state'];
|
|
252
|
+
let streamFilter = '';
|
|
253
|
+
function applyStreamFilter(node) { node.hidden = streamFilter !== '' && node.dataset.kind !== streamFilter; }
|
|
254
|
+
function refreshStreamEmpty() { const has = $('stream').querySelector('.event:not([hidden])') !== null, any = $('stream').querySelector('.event') !== null; $('stream-empty').hidden = has || !any; }
|
|
255
|
+
function renderStreamChips() {
|
|
256
|
+
const group = $('stream-filters');
|
|
257
|
+
if (group.children.length) return;
|
|
258
|
+
for (const kind of STREAM_KINDS) {
|
|
259
|
+
const chip = el('button', kind);
|
|
260
|
+
chip.type = 'button'; chip.className = 'chip'; chip.setAttribute('aria-pressed', 'false');
|
|
261
|
+
chip.addEventListener('click', () => {
|
|
262
|
+
streamFilter = streamFilter === kind ? '' : kind;
|
|
263
|
+
for (const child of group.children) child.setAttribute('aria-pressed', String(child.textContent === streamFilter));
|
|
264
|
+
for (const node of $('stream').querySelectorAll('.event')) applyStreamFilter(node);
|
|
265
|
+
refreshStreamEmpty();
|
|
266
|
+
});
|
|
267
|
+
group.append(chip);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
$('task-filter').addEventListener('change', () => { if (!stopped && snapshotValid && lastSnapshot) render(lastSnapshot); });
|
|
216
271
|
$('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 = ''; });
|
|
217
272
|
$('propose').addEventListener('submit', async (event) => { event.preventDefault(); const generation = viewGeneration, title = $('title').value.trim(); if (!title) return; const payload = {action:'propose', title, class:$('class').value}; if ($('detail').value.trim()) payload.detail = $('detail').value.trim(); if ($('owner').value) payload.owner = $('owner').value; if (await action(payload, event.submitter) && generation === viewGeneration) { $('title').value = ''; $('detail').value = ''; } });
|
|
218
|
-
(async () => { try { if (ticket) await post('/session', {ticket}); ticket = ''; 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'; } } })();
|
|
273
|
+
(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'; } } })();
|
|
219
274
|
</script>
|
|
220
275
|
</body>
|
|
221
276
|
</html>
|