161 lines
12 KiB
Diff
161 lines
12 KiB
Diff
--- a/packages/webui/README.md
|
||
+++ b/packages/webui/README.md
|
||
@@ -127,8 +127,12 @@
|
||
accessibility tree, so the first copy is announced.
|
||
- The command bar shows, in UTC, when the board was scanned and the bus read,
|
||
and says when the last attempt failed. A board refusal drops the last scan
|
||
- and everything drawn from it: the table, the inspector, the project tree,
|
||
- the counts, the footer and the scan line all say the board was not read.
|
||
+ and everything drawn from it: the cards, the table, the inspector, the
|
||
+ project filter and tree, the counts, the footer and the scan line all say
|
||
+ the board was not read. A failed read with no scan held, after a refusal or
|
||
+ before the first scan, shows the board empty and says the read failed; it
|
||
+ never brings back rows from a refused scan. A refusal also closes an open
|
||
+ conversation and clears its history.
|
||
- From 760px the footer is a one-line status bar and the side column fits
|
||
between it and the command bar. Below 760px the section list is one strip
|
||
that scrolls inside itself.
|
||
@@ -211,7 +215,8 @@
|
||
Shell tests (`shell.test.mjs`) check the copies, the font hashes and the kept
|
||
strings, and in the browser at 400px: System mode under an emulated dark and
|
||
light OS, the empty, stale and refused states of the board and the bus views
|
||
-(a board refusal with the inspector open), not found, the palette, the toast,
|
||
+(a board refusal with the inspector open and a project picked, then a failed
|
||
+read, and a refusal with a conversation open), not found, the palette, the toast,
|
||
keyboard and visible focus, the section strip at 360 and 400px, no sideways
|
||
scroll, no script error and no request other than GET. A second test walks
|
||
the Board, Inbox, Tasks, Agents and a decision trail through loading (a held
|
||
--- a/packages/webui/src/public/app.js
|
||
+++ b/packages/webui/src/public/app.js
|
||
@@ -301,18 +301,22 @@
|
||
render();
|
||
}
|
||
// A refusal (403) fails closed: the last data is dropped, not kept or shown as current, and so is
|
||
- // everything drawn from it: the inspector, the project tree, the counts and the footer. Any other
|
||
- // failure keeps the last data with its scan time.
|
||
+ // everything drawn from it: the cards, the table, the inspector, an open conversation and its
|
||
+ // history, the project tree, the counts and the footer. Any other failure keeps the last data with
|
||
+ // its scan time. With no data, from a refusal or before the first scan, the board is drawn empty
|
||
+ // and says why, so a later failure never brings back old rows.
|
||
function error(err) {
|
||
const refused = err.status === 403;
|
||
failed = refused ? 'refused' : true; $('error').hidden = false;
|
||
- if (refused) {
|
||
- data = null; selected = null; project = null;
|
||
- $('projects').innerHTML = '<p class="muted">Not read: the board refused the read.</p>';
|
||
+ if (refused) { data = null; selected = null; project = null; if (conv) { $('conv-log').replaceChildren(); closeConversation(); } }
|
||
+ if (!data) {
|
||
+ const why = refused ? 'the board refused the read' : 'the read failed';
|
||
+ $('projects').innerHTML = `<p class="muted">Not read: ${why}.</p>`;
|
||
+ for (const id of ['waiting', 'seen', 'sessions']) $(id).replaceChildren();
|
||
for (const id of ['waiting-count', 'seen-count', 'session-count']) $(id).textContent = '–';
|
||
- $('footer').textContent = 'Board not read: refused';
|
||
- $('status').textContent = 'Not read: the board refused the read';
|
||
- inspect(); convForm();
|
||
+ $('footer').textContent = `Board not read: ${refused ? 'refused' : 'the read failed'}`;
|
||
+ $('status').textContent = `Not read: ${why}`;
|
||
+ inspect();
|
||
}
|
||
$('error').className = refused ? 'banner ref' : 'banner err';
|
||
$('board-refused').hidden = !refused; document.body.classList.toggle('board-refused', refused);
|
||
--- a/packages/webui/tests/shell.test.mjs
|
||
+++ b/packages/webui/tests/shell.test.mjs
|
||
@@ -59,20 +59,28 @@
|
||
function board() {
|
||
let reply = { status: 200, body: { sessions: [], counts: {}, generatedAt: new Date().toISOString() } };
|
||
const one = { agent: 'agent1', project: 'proj', state: 'waiting', waitingOnYou: true, task: 'Fixture task', lastActivity: '2026-10-10T00:00:00Z', lastAssistantText: 'Input needed: fixture', registered: null };
|
||
+ // While the board answers, agent1 has one conversation with one message. A failure or refusal answers every path.
|
||
+ const catalogue = { conversations: [{ project: 'proj', seat: 'agent1', conversation: 'c1', availability: 'available', title: 'Fixture', conversationCreatedAt: '2026-10-10T00:00:00Z', lastActivityAt: '2026-10-10T00:00:00Z' }] };
|
||
+ const page = { page: { branch: 'b1', entries: [{ message: 'm1', role: 'assistant', createdAt: '2026-10-10T00:00:00Z', content: [{ block: 0, type: 'text', text: 'Fixture history line' }] }] }, view: { incomplete: false, defaultBranch: 'b1', branch: 'b1' }, cursor: null, follow: null };
|
||
let gate = null;
|
||
const server = createServer(async (req, res) => {
|
||
for await (const chunk of req) { void chunk; }
|
||
await gate;
|
||
- res.writeHead(reply.status, { 'content-type': 'application/json' }); res.end(JSON.stringify(reply.body));
|
||
+ const path = new URL(req.url, 'http://localhost').pathname;
|
||
+ const body = reply.status !== 200 ? reply.body : path === '/api/conversations' ? catalogue : path === '/api/conversation' ? page : reply.body;
|
||
+ res.writeHead(reply.status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body));
|
||
});
|
||
return {
|
||
server,
|
||
// Holds every reply until the returned function is called.
|
||
hold: () => { let go; gate = new Promise(r => { go = r; }); return () => { gate = null; go(); }; },
|
||
empty: () => { reply = { status: 200, body: { sessions: [], counts: {}, generatedAt: new Date().toISOString() } }; },
|
||
- one: () => { reply = { status: 200, body: { sessions: [one, { ...one, agent: 'agent2', state: 'working', waitingOnYou: false }], counts: { waiting: 1, working: 1 }, generatedAt: new Date().toISOString() } }; },
|
||
+ one: () => { reply = { status: 200, body: { sessions: [one, { ...one, agent: 'agent2', state: 'working', waitingOnYou: false }, { ...one, agent: 'agent3', state: 'idle', waitingOnYou: false, seen: true }], counts: { waiting: 1, working: 1 }, generatedAt: new Date().toISOString() } }; },
|
||
fail: () => { reply = { status: 500, body: { error: 'board scan failed' } }; },
|
||
refuse: () => { reply = { status: 403, body: { error: 'board-refused' } }; },
|
||
+ // One seat a reply can reach: registered, alive, with a tmux session.
|
||
+ registered: () => { reply = { status: 200, body: { sessions: [{ ...one, registered: { alive: true, tmux: { session: 'fixture' } } }], counts: { waiting: 1 }, generatedAt: new Date().toISOString() } }; },
|
||
+ unavailable: () => { reply = { status: 503, body: { error: 'board unavailable' } }; },
|
||
};
|
||
}
|
||
|
||
@@ -95,6 +103,7 @@
|
||
const shot = async name => { if (out) await b.screenshot(join(out, `shell-${name}.png`)); };
|
||
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
|
||
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
|
||
+ const count = sel => b.evaluate(`document.querySelectorAll(${JSON.stringify(sel)}).length`);
|
||
const flat = async () => assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, 'sideways scroll at 400px');
|
||
const refresh = async () => { await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled'); };
|
||
const go = async (hash, h1) => { await b.evaluate(`location.hash=${JSON.stringify(hash)}`); await wait(`document.querySelector("#bus-view h1")?.textContent.startsWith(${JSON.stringify(h1)}) && !document.querySelector("#bus-view [aria-busy]")`); };
|
||
@@ -140,10 +149,13 @@
|
||
await flat(); await shot('board-stale-400');
|
||
|
||
// Board, a refusal: fails closed. The kept rows are hidden, and so is everything drawn from
|
||
- // them: the open inspector, the project tree's counts and the footer.
|
||
+ // them: the open inspector, the project filter, the project tree's counts and the footer.
|
||
+ await b.evaluate('document.querySelector("#projects [data-project=proj]").click()');
|
||
+ assert.equal(await b.evaluate('document.querySelector("#projects [data-project=proj]").getAttribute("aria-pressed")'), 'true');
|
||
await b.evaluate('document.querySelector("table.sessions [data-open]").click()'); await wait('!document.querySelector("#inspector").hidden');
|
||
assert.equal(await text('#footer'), 'waiting 1 · working 1');
|
||
assert.equal(await b.evaluate('document.querySelectorAll("#projects .count").length'), 2);
|
||
+ assert.equal(await count('#seen .wait-item'), 1);
|
||
brd.refuse(); await refresh();
|
||
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner ref');
|
||
assert.match(await text('#error'), /The board refused the read\. refused: board-refused/);
|
||
@@ -155,14 +167,43 @@
|
||
assert.equal(await text('#footer'), 'Board not read: refused');
|
||
assert.equal(await text('#fresh-board'), 'Board not read: refused');
|
||
assert.doesNotMatch(await text('#status'), /Scanned/);
|
||
+ for (const id of ['#waiting-count', '#seen-count', '#session-count']) assert.equal(await text(id), '–', id);
|
||
await flat(); await shot('board-refused-400');
|
||
+ // A failed read after the refusal shows the board again, empty: no row, card or count from
|
||
+ // the refused scan, and the footer says what the banner says.
|
||
+ brd.unavailable(); await refresh();
|
||
+ assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner err');
|
||
+ assert.match(await text('#error'), /board unavailable.*No board data loaded\./);
|
||
+ assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
||
+ assert.equal(await count('#sessions tr, #sessions [data-open], #waiting .wait-item, #seen .wait-item, #board-view [data-history]'), 0);
|
||
+ for (const id of ['#waiting-count', '#seen-count', '#session-count']) assert.equal(await text(id), '–', id);
|
||
+ assert.equal(await text('#footer'), 'Board not read: the read failed');
|
||
+ assert.equal(await text('#fresh-board'), 'Board not read: the read failed');
|
||
+ assert.equal(await text('#status'), 'Not read: the read failed');
|
||
+ assert.equal(await text('#projects'), 'Not read: the read failed.');
|
||
+ await flat(); await shot('board-refused-then-failed-400');
|
||
brd.one(); await refresh();
|
||
assert.equal(await b.evaluate('document.querySelector("#error").hidden'), true);
|
||
+ assert.equal(await b.evaluate('document.querySelector("#projects [data-project=\'\']").getAttribute("aria-pressed")'), 'true');
|
||
assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
||
assert.equal(await text('#footer'), 'waiting 1 · working 1');
|
||
// A good read after a failed one drops the stale words.
|
||
brd.fail(); await refresh(); assert.match(await text('#fresh-board'), /stale/);
|
||
brd.one(); await refresh(); assert.match(await text('#fresh-board'), /^Board scanned \d\d:\d\d:\d\d UTC/); assert.doesNotMatch(await text('#fresh-board'), /stale/);
|
||
+ // A refusal with a conversation open closes it and clears its history and reply form: the
|
||
+ // session is in no scan the page holds.
|
||
+ brd.registered(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===1');
|
||
+ await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
|
||
+ await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")');
|
||
+ assert.equal(await b.evaluate('document.querySelector("#conv-form").hidden'), false);
|
||
+ brd.refuse(); await refresh();
|
||
+ assert.equal(await b.evaluate('document.querySelector("#conversation").hidden'), true);
|
||
+ assert.equal(await b.evaluate('document.body.classList.contains("has-conversation")'), false);
|
||
+ assert.equal(await count('#conv-log > *'), 0);
|
||
+ assert.equal(await b.evaluate('document.querySelector("#conv-form").checkVisibility()'), false);
|
||
+ assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
||
+ assert.equal(await b.evaluate('document.querySelector("#board-refused").hidden'), false);
|
||
+ brd.one(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2');
|
||
|
||
// Keyboard on the board: down moves between rows, Enter opens, Esc closes and returns focus.
|
||
await b.evaluate('document.querySelector("table.sessions [data-open]").focus()');
|