Files
stack/agents/dewey/work/queue-53/round3.patch
T

161 lines
12 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--- 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()');