feat(webui): CHAT-02 Console, read-only conversation view (#1507)

History opens a seat's conversation from the Waiting card, table row
and inspector. It pages the whole branch through the CHAT-02 board
routes, renders untrusted text inert, polls with the follow cursor, and
marks every switch (branch, newer, reconcile, gone). The WebUI proxy
passes only the two conversation routes' queries upstream.

Dewey authored it. Filbert asked for changes on r1 (24b046af) and
approved r2 (d06de6a7) in review 160dd68d. A relaunch shows 'newer',
not 'reconcile', a deviation from brief 2.3 item 6 that Filbert
accepted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-26 18:05:11 -05:00
co-authored by Claude Opus 5.5
parent 3a209eeafe
commit c9e771cf59
34 changed files with 1669 additions and 26 deletions
+34
View File
@@ -3192,3 +3192,37 @@ never opened on the Discord side, as expected. Row 25 is live: records,
approval request, button, and the approval recorded in SetSpark. The approval request, button, and the approval recorded in SetSpark. The
service-side approver validation gap went to the SetSpark lead (see service-side approver validation gap went to the SetSpark lead (see
DEFERRED). DEFERRED).
## 2026-09-26: CHAT-02 Console (#1507, row 5, Dewey, Filbert, Sage)
The WebUI can now show a seat's conversation, read-only, through the two
CHAT-02 board routes. History opens it from the Waiting card, the table
row and the inspector. The view pages through the whole branch without
clipping. Untrusted text is set only with textContent, and control and
bidi characters are drawn as visible marks. On the last page it polls
with the follow cursor and replies through the existing /api/reply path.
The WebUI proxy passes only the two conversation routes' query strings
upstream.
Review: Filbert asked for changes on revision 1 (24b046af). The blocker,
B1: after a reconcile, "Reload conversation" reopened on the default
branch without saying so. Revision 2 (d06de6a7) keeps the branch, adds a
`gone` marker when that branch no longer exists, and takes notes N1 to
N3. Filbert approved it in review 160dd68d, with 188/188 including two
probes of his own. Sage checked the ten pins on the staged index: webui,
conversation, control-board and seat tests 186/186 serial. The eight
suites are green, and the chat-00, chat-01 and chat-01c checks exit 0.
Dewey's mutation runs caught 17 of 17.
Deviation from brief §2.3 item 6, accepted by Filbert (who wrote the
item) and recorded by Sage: a relaunched seat shows the `newer` marker
("Open the newest session"), not `reconcile`. The relaunch writes a new
session file, while the old one stays readable, so nothing needs
reconciling. Item 6's wording was too narrow.
Also in this commit: Filbert's CHAT-02 backend reproduction scripts,
referenced by his backend review (a5beb6d9), and Darkwing's two
test-only route notes. Filbert's idsDigest note waits in
agents/dewey/work/chat-02/FOLLOWUPS.md. Still due: a WebUI restart,
because the running proxy predates these routes, then the brief §4 live
check.
@@ -0,0 +1,161 @@
# CHAT-02 Console: review packet (#1507, row 5)
Author: Dewey, 2026-09-26. Brief: `BRIEF.md` R4 (`636b0fac…`), §2.2, §2.3
and §4. Base: `3a209eea` on `refactor`, which includes the backend commit
`a5beb6d9`. The commits after `a4d38a3d` touch only `packages/ledger` and
records. Nothing here is committed; the candidate is the working tree,
pinned by the hashes below.
Reviewer (Sage's order): Filbert, all ten files.
## 1. Candidate hashes
```
77039b18845c913dfcbfaf4cd6854ed97865a14eb762773af6f6d19cb704fc11 packages/webui/README.md
68a054ee61f033f200a5d53b16b3274cbfb89d8bd065c8a049d5f804d51fe0a7 packages/webui/src/public/app.js
b972e2f7f22dafbbf7425773c0715875f9bbcf795af1e5f66a2d9dc394c77047 packages/webui/src/public/index.html
8747b83d16d93d880cf1502267aefc3e48f02e7b5b364177c6fb492320f63024 packages/webui/src/public/live.css
1187a98f52e937f30dc0fbbb3d83445feff7c0a38d3535252af1c7e7e704b7b3 packages/webui/src/serve.mjs
0477f66d5caf9d8f76ba3fb14d3f122f124d6701259f3d7447da639f69982525 packages/webui/tests/serve.test.mjs
9ed68e39904181ba37961d22eea200b024344ef5a7de06bd7c2e8a41ebb1b0e0 packages/webui/tests/conversation.test.mjs
b312c8a192a7a92f0642ee977b136a2ef5909b55632a4d0f669f4ca06e30eee0 packages/webui/tests/history-fixture.mjs
0918aeeaac89cac3d6977ad2e106d299c623e8f2ac6af631ab116ae7f14d3013 packages/webui/tests/history-return-flow.test.mjs
105d87ec3394589afb5c5a43230dd0a43752fcac05d6558b6205013a611a69b2 packages/control-board/tests/serve.test.mjs
```
The last three webui tests are new files; the rest are diffs against the
base (`git diff 3a209eea -- packages/webui packages/control-board/tests`).
The `packages/ledger` edits in the shared tree belong to another seat and
are not part of this candidate.
## 2. What it does
- **Entry points.** A History button on each seat's Waiting card, table row
and inspector opens a read-only conversation view in place of the board.
Back returns focus to the button that opened it.
- **Rendering.** The view reads the whole branch through
`/api/conversation`, page by page, and joins fragments and continuation
parts by block. Nothing is clipped. Tool calls, tool results and thinking
are collapsed `details`; redacted thinking says "not available".
- **Untrusted text.** Session content is set only with `textContent`.
Markdown stays as source. C0 controls and DEL show as Unicode control
pictures (`␛`, `␇`, `␡`); bidi overrides and isolates show as `[U+202E]`
and similar. Newlines and tabs stay.
- **Polling.** On the last page the view keeps the `follow` cursor and reads
from it on each board refresh (every 10 s; Pause stops it). It scrolls only
when the reader was already near the end.
- **Nothing switches silently.** Three markers, each with a button:
- `branch`: `view.defaultBranch` differs from the open branch. "Open the
latest branch" reopens without a branch parameter.
- `newer`: the board row's session id changed after the view opened, which
is a relaunch. "Open the newest session."
- `reconcile`: a refusal with `reconcile: true`. The view keeps what it
showed, stops polling and offers "Reload conversation".
- **Session picker.** Lists every catalogue row for the seat, newest first;
unavailable ones say why. Non-Pi harnesses and seats without files say so
and show no reply form.
- **Reply.** The view's form uses the same `/api/reply` path, draft map,
pending-send lock and receipts as the inspector. A delivered send clears
the box only if its text is unchanged.
- **Proxy.** `webui/src/serve.mjs` adds `GET /api/conversations` and
`/api/conversation`. Only those two carry their query string upstream; the
board validates it. Upstream status and body pass through unchanged.
- **Age** is unchanged from `42c08d52` (no Age lines in the diff).
## 3. Choices to review
1. **The relaunch marker is `newer`, not `reconcile`.** Brief §2.3 item 6
says "shows the reconcile marker". A new session file does not change the
open file, so the reader has nothing to refuse and the open view is still
accurate. The view shows a separate `newer` marker instead, and the test
asserts it, the kept file, the absence of the new file's text and the
draft. `reconcile` is kept for refusals (tested in `conversation.test.mjs`
with a same-inode rewrite).
2. **Relaunch detection uses the board's `sessionId`** for the newest
readable conversation only. An older session opened from the picker never
shows `newer`, because it was never the board's current session.
3. **The conversation form has its own `conv-form` and `conv-receipt`
classes.** My first draft reused `reply-form` and `receipt`. The hidden
view sits earlier in the DOM, so `querySelector(".reply-form")` in four
existing browser tests found it first and failed; those are the four
failures Filbert saw in the shared tree during the backend review. The
inspector's classes are unchanged. CSS rules list both classes, and the
submit handler matches `.reply-form, #conv-form`.
4. **Heading focus ring.** Opening the view focuses its heading
(`tabindex="-1"`). The shared `:focus-visible` rule draws its ring, as it
does for the inspector title. A synthetic `click()` counts as keyboard
focus, so the screenshots show the ring; a real mouse click does not. I
kept it for keyboard users.
5. **Darkwing's two R2 notes** on the backend routes are taken here, as Sage
offered: the refusal-status test now scans every `.mjs` in
`packages/conversation/src` and pins the whole `REFUSAL_STATUS` object.
Test-only; `serve.mjs` is unchanged from `a5beb6d9`.
## 4. Evidence
### 4.1 Suites and contract checks
- In `/tmp/dewey-chat02/overlay-console`, a `git archive` of `3a209eea`
with only the ten files overlaid: conversation 29, control-board 124,
webui 13 and seat 19, 185 of 185 pass.
- `node docs/plans/chat-00/check.mjs`, `chat-01/check.mjs` and
`chat-01c/check.mjs` all exit 0 there.
- The shared tree gives the same 185/185.
### 4.2 Acceptance map (brief §4)
| Item | Test |
|---|---|
| §2.2 hostile-render fixture | `conversation.test.mjs` test 1: `<script>`, `<img onerror>`, a Markdown and an HTML `javascript:` link, ANSI and OSC escapes and a bidi override, in assistant text and tool output. It asserts the exact visible text; no `script`, `img`, `a`, `iframe`, `object`, `embed`, `svg`, `style` or `link` element in the view; no `on*` attribute anywhere; no raw ESC or U+202E in the page; `window.injected` undefined after a click; and an unchanged URL. |
| Full history, collapsed tools, hidden thinking | test 1: roles in order, the long answer equal to its source, three closed `details` whose contents are not visible |
| Malformed-line and reconcile markers | test 1 |
| Branch marker, no silent switch, open on request | test 2 (new): after the fork, the open branch grows and the next poll still follows it; the reopen then shows the fork |
| No history, non-reply seats | test 3 |
| Entry points | card (`history-return-flow`), table and inspector (test 1) |
| §2.3 items 1–7 | `history-return-flow.test.mjs`: send from the view; toolCall, then a draft with its caret at 4, then a delayed toolResult on a later poll; a peer message; an answer with `MID_SENTINEL` after character 250 and `END_SENTINEL` at the end, shown once with no "…"; a 4.5-million-character answer checked by SHA-256 in the page and split into at least two ordered continuation parts over the real route; a relaunch with the `newer` marker, the kept file and the draft; then "Open the newest session" with the draft kept. Refresh is never pressed. |
| Proxy | `webui/tests/serve.test.mjs`: exact query forwarding for both routes, upstream 404 and body preserved, 405 on POST, a cross-origin 403 before the upstream is touched, and no query forwarded for `/api/board` |
| Browser evidence | `evidence/console/screens/conversation-{320,1440}-{light,dark}.png`, from `WEBUI_EVIDENCE` in test 1: long answer, tool call and result, inert hostile text, malformed-line notice, reconcile marker. Test 1 asserts no horizontal overflow at 320 and 1440 in both modes. |
### 4.3 Mutation testing
In scratch copies under `/tmp/dewey-chat02/`, never the served tree.
Scripts and output in `evidence/console/`.
- Proxy (`mutate-proxy.py`): 4 of 4 caught. Query dropped, query sent on
every route, routes not proxied, routes accept POST.
- Refusal-status test (`mutate-status.py`): 2 of 2 caught. `unavailable`
changed to 422, and a new refusal raised from `parts.mjs`.
- Conversation view (`mutate-console.py`): 10 of 10 caught, run against
both browser test files. The mutants: text set as HTML; controls not made
visible; fragments replaced instead of joined; details open by default;
no branch marker; no newer-session marker; no reconcile marker; the
follow poll drops its branch; the follow poll takes `view.defaultBranch`
instead of `page.branch`; and the two forms sharing the `reply-form`
class.
- The first pass (`mutate-console-pass1.txt`) caught 9. It missed the
`defaultBranch` mutant because the fork test reopened the view before the
next poll ran. The fork test now adds an entry to the open branch after
the fork and requires the next poll to show it with no reconcile marker.
Under the mutant, that poll sends `main`'s cursor with the fork's branch
and is refused. The second pass (`mutate-console.txt`) catches it.
## 5. Known limits
- **Polling, not streaming.** New entries arrive on the 10 s refresh.
Streaming is CHAT-03.
- **A very long branch loads fully.** The view reads every page on open;
the largest real conversation is 18.6 MB. Windowing is CHAT-05.
- **Collapsed state survives redraws of a message,** but a full reopen
(picker, reload or branch change) starts collapsed again.
- **Pi only.** Claude seats show "not available yet" (D2, B1).
## 6. After review
- Pushing goes through Sage with Jason's word.
- The live WebUI (PID 1266267, running since 2026-09-13 from this checkout)
reads its static files from the working tree on every request, so it
already serves this candidate's page. Its proxy is the 09-13 code in
memory, which has no conversation routes, so History there answers
"History unavailable: not found." It must restart after the commit.
- Brief §4 live check: one board send with a long answer, shown in full in
the view. It needs the commit and the WebUI restart, then Jason or Sage.
+207
View File
@@ -0,0 +1,207 @@
# CHAT-02 Console: review packet (#1507, row 5), revision 2
Author: Dewey, 2026-09-26. Brief: `BRIEF.md` R4 (`636b0fac…`), §2.2, §2.3
and §4. Base: `3a209eea` on `refactor`, which includes the backend commit
`a5beb6d9`. The commits after `a4d38a3d` touch only `packages/ledger` and
records. Nothing here is committed; the candidate is the working tree,
pinned by the hashes below.
Reviewer (Sage's order): Filbert, all ten files. Revision 1
(`CONSOLE-r1-24b046af.md`, frozen) drew changes requested: one blocker, B1,
and three nonblocking notes (`agents/filbert/work/chat-02-console-review-2026-09-26.md`,
`91214fad…02bf8`). §0 is the delta.
## 0. Revision 2 delta
Three files changed: `app.js`, `conversation.test.mjs` and the README. The
delta against the revision 1 pins is `evidence/console/r2-delta.patch`
(`c1d65628…5866`). Its removed `HOSTILE` line still carries revision 1's raw
U+202E.
- **B1, Reload switched to the default branch without saying so.** The
reconcile marker's button is now `reload` and reopens with the branch the
view was on (`convURL({ id, branch })`). The reopened view raises the
`branch` marker again if the default has moved. If the board answers
`unknown-branch`, the view opens the default branch and shows a `gone`
marker: "The branch this view was on is no longer in the session. This
view shows the latest branch." "Open the latest branch" has its own id,
`latest`, and still reopens without a branch.
- **B1 test.** The fork test continues in probe A's shape: the fork becomes
the default, a same-inode rewrite refuses the next check, and Reload must
show the rewritten `main` (`QUESTION`, `MAIN_ANSWER`, `MAIN_MORE`) with the
`branch` marker. "Open the latest branch" then shows the fork. A second
rewrite removes the fork; Reload must show `main` with the `gone` marker
and no reconcile marker.
- **N1 taken.** When "Open the newest session" lands on the file already
open, the view keeps the old session id, so `newer` stays, and its text
says the newer session's history is not readable yet. New test 3: a
header-only relaunch file, the button keeps the marker and the old file;
after the new file gets an entry, the same button opens it.
- **N2 taken.** No raw bidi characters remain in `app.js` or the tests; the
regex and `HOSTILE` use `\u` escapes.
- **N3 taken.** `inert()` also shows C1 controls (U+0080–U+009F) and LRM,
RLM and ALM as `[U+XXXX]`, padded to four digits. The hostile fixture adds
a single-byte CSI (U+009B) and an LRM, and the page check rejects raw ESC,
CSI, LRM and RLO.
## 1. Candidate hashes
```
5a1a4de713a27404af4b049963ffcaac83371a1e2bd0ce3812d214d9a33dacc4 packages/webui/README.md
3c7f2f4c6a6e7867fef3f85b853a7538d98b03b2e303fdb0abcfa9b09eb80d6e packages/webui/src/public/app.js
b972e2f7f22dafbbf7425773c0715875f9bbcf795af1e5f66a2d9dc394c77047 packages/webui/src/public/index.html
8747b83d16d93d880cf1502267aefc3e48f02e7b5b364177c6fb492320f63024 packages/webui/src/public/live.css
1187a98f52e937f30dc0fbbb3d83445feff7c0a38d3535252af1c7e7e704b7b3 packages/webui/src/serve.mjs
0477f66d5caf9d8f76ba3fb14d3f122f124d6701259f3d7447da639f69982525 packages/webui/tests/serve.test.mjs
52c2c6632713f1d48bdb4975b73d2dd920769401f6a01269d1a09ad1cac5a4a5 packages/webui/tests/conversation.test.mjs
b312c8a192a7a92f0642ee977b136a2ef5909b55632a4d0f669f4ca06e30eee0 packages/webui/tests/history-fixture.mjs
0918aeeaac89cac3d6977ad2e106d299c623e8f2ac6af631ab116ae7f14d3013 packages/webui/tests/history-return-flow.test.mjs
105d87ec3394589afb5c5a43230dd0a43752fcac05d6558b6205013a611a69b2 packages/control-board/tests/serve.test.mjs
```
The last three webui tests are new files; the rest are diffs against the
base (`git diff 3a209eea -- packages/webui packages/control-board/tests`).
The `packages/ledger` edits in the shared tree belong to another seat and
are not part of this candidate.
## 2. What it does
- **Entry points.** A History button on each seat's Waiting card, table row
and inspector opens a read-only conversation view in place of the board.
Back returns focus to the button that opened it.
- **Rendering.** The view reads the whole branch through
`/api/conversation`, page by page, and joins fragments and continuation
parts by block. Nothing is clipped. Tool calls, tool results and thinking
are collapsed `details`; redacted thinking says "not available".
- **Untrusted text.** Session content is set only with `textContent`.
Markdown stays as source. C0 controls and DEL show as Unicode control
pictures (`␛`, `␇`, `␡`). C1 controls, bidi overrides and isolates, and
LRM, RLM and ALM show as `[U+009B]`, `[U+202E]` and similar. Newlines and
tabs stay.
- **Polling.** On the last page the view keeps the `follow` cursor and reads
from it on each board refresh (every 10 s; Pause stops it). It scrolls only
when the reader was already near the end.
- **Nothing switches silently.** Four markers; three have a button:
- `branch`: `view.defaultBranch` differs from the open branch. "Open the
latest branch" reopens without a branch parameter.
- `newer`: the board row's session id changed after the view opened, which
is a relaunch. "Open the newest session." If that lands on the open
file, the marker stays and says the newer history is not readable yet.
- `reconcile`: a refusal with `reconcile: true`. The view keeps what it
showed, stops polling and offers "Reload conversation", which reopens
the same branch.
- `gone`: Reload found the branch removed and opened the latest one.
- **Session picker.** Lists every catalogue row for the seat, newest first;
unavailable ones say why. Non-Pi harnesses and seats without files say so
and show no reply form.
- **Reply.** The view's form uses the same `/api/reply` path, draft map,
pending-send lock and receipts as the inspector. A delivered send clears
the box only if its text is unchanged.
- **Proxy.** `webui/src/serve.mjs` adds `GET /api/conversations` and
`/api/conversation`. Only those two carry their query string upstream; the
board validates it. Upstream status and body pass through unchanged.
- **Age** is unchanged from `42c08d52` (no Age lines in the diff).
## 3. Choices to review
1. **The relaunch marker is `newer`, not `reconcile`.** Brief §2.3 item 6
says "shows the reconcile marker". A new session file does not change the
open file, so the reader has nothing to refuse and the open view is still
accurate. The view shows a separate `newer` marker instead, and the test
asserts it, the kept file, the absence of the new file's text and the
draft. `reconcile` is kept for refusals (tested in `conversation.test.mjs`
with a same-inode rewrite).
2. **Relaunch detection uses the board's `sessionId`** for the newest
readable conversation only. An older session opened from the picker never
shows `newer`, because it was never the board's current session.
3. **The conversation form has its own `conv-form` and `conv-receipt`
classes.** My first draft reused `reply-form` and `receipt`. The hidden
view sits earlier in the DOM, so `querySelector(".reply-form")` in four
existing browser tests found it first and failed; those are the four
failures Filbert saw in the shared tree during the backend review. The
inspector's classes are unchanged. CSS rules list both classes, and the
submit handler matches `.reply-form, #conv-form`.
4. **Heading focus ring.** Opening the view focuses its heading
(`tabindex="-1"`). The shared `:focus-visible` rule draws its ring, as it
does for the inspector title. A synthetic `click()` counts as keyboard
focus, so the screenshots show the ring; a real mouse click does not. I
kept it for keyboard users.
5. **Darkwing's two R2 notes** on the backend routes are taken here, as Sage
offered: the refusal-status test now scans every `.mjs` in
`packages/conversation/src` and pins the whole `REFUSAL_STATUS` object.
Test-only; `serve.mjs` is unchanged from `a5beb6d9`.
## 4. Evidence
### 4.1 Suites and contract checks
- In `/tmp/dewey-chat02/overlay-console`, a `git archive` of `3a209eea`
with only the ten files overlaid: conversation 29, control-board 124,
webui 14 and seat 19, 186 of 186 pass.
- `node docs/plans/chat-00/check.mjs`, `chat-01/check.mjs` and
`chat-01c/check.mjs` all exit 0 there.
- The shared tree gives the same 186/186.
### 4.2 Acceptance map (brief §4)
| Item | Test |
|---|---|
| §2.2 hostile-render fixture | `conversation.test.mjs` test 1: `<script>`, `<img onerror>`, a Markdown and an HTML `javascript:` link, ANSI and OSC escapes, a C1 CSI, an LRM and a bidi override, in assistant text and tool output. It asserts the exact visible text; no `script`, `img`, `a`, `iframe`, `object`, `embed`, `svg`, `style` or `link` element in the view; no `on*` attribute anywhere; no raw ESC, CSI, LRM or RLO in the page; `window.injected` undefined after a click; and an unchanged URL. |
| Full history, collapsed tools, hidden thinking | test 1: roles in order, the long answer equal to its source, three closed `details` whose contents are not visible |
| Malformed-line and reconcile markers | test 1 |
| Branch marker, no silent switch, open on request | test 2 (new): after the fork, the open branch grows and the next poll still follows it; Reload after a rewrite keeps that branch with the marker; "Open the latest branch" shows the fork; a vanished branch reloads to the default with the `gone` marker |
| Newer session not yet readable (N1) | test 3 (new) |
| No history, non-reply seats | test 4 |
| Entry points | card (`history-return-flow`), table and inspector (test 1) |
| §2.3 items 1–7 | `history-return-flow.test.mjs`: send from the view; toolCall, then a draft with its caret at 4, then a delayed toolResult on a later poll; a peer message; an answer with `MID_SENTINEL` after character 250 and `END_SENTINEL` at the end, shown once with no "…"; a 4.5-million-character answer checked by SHA-256 in the page and split into at least two ordered continuation parts over the real route; a relaunch with the `newer` marker, the kept file and the draft; then "Open the newest session" with the draft kept. Refresh is never pressed. |
| Proxy | `webui/tests/serve.test.mjs`: exact query forwarding for both routes, upstream 404 and body preserved, 405 on POST, a cross-origin 403 before the upstream is touched, and no query forwarded for `/api/board` |
| Browser evidence | `evidence/console/screens/conversation-{320,1440}-{light,dark}.png`, from `WEBUI_EVIDENCE` in test 1: long answer, tool call and result, inert hostile text, malformed-line notice, reconcile marker. Test 1 asserts no horizontal overflow at 320 and 1440 in both modes. |
### 4.3 Mutation testing
In scratch copies under `/tmp/dewey-chat02/`, never the served tree.
Scripts and output in `evidence/console/`.
- Proxy (`mutate-proxy.py`): 4 of 4 caught. Query dropped, query sent on
every route, routes not proxied, routes accept POST.
- Refusal-status test (`mutate-status.py`): 2 of 2 caught. `unavailable`
changed to 422, and a new refusal raised from `parts.mjs`.
- Conversation view (`mutate-console.py`), revision 2: 17 of 17 caught, run
against both browser test files (`mutate-console.txt`). The revision 1
mutants: text set as HTML; controls not made visible; fragments replaced
instead of joined; details open by default; no branch marker; no
newer-session marker; no reconcile marker; the follow poll drops its
branch; the follow poll takes `view.defaultBranch` instead of
`page.branch`; and the two forms sharing the `reply-form` class. Added in
revision 2: Reload drops its branch; "Open the latest branch" keeps the
open branch; a gone branch is not reopened; a gone branch is reopened
without its marker; "newest" on the same file clears the marker; C1
controls left raw; bidi marks left raw.
- Revision 1's 10 of 10 is kept as `mutate-console-r1.txt`.
- The first pass (`mutate-console-pass1.txt`) caught 9. It missed the
`defaultBranch` mutant because the fork test reopened the view before the
next poll ran. The fork test now adds an entry to the open branch after
the fork and requires the next poll to show it with no reconcile marker.
Under the mutant, that poll sends `main`'s cursor with the fork's branch
and is refused. The second pass caught it.
## 5. Known limits
- **Polling, not streaming.** New entries arrive on the 10 s refresh.
Streaming is CHAT-03.
- **A very long branch loads fully.** The view reads every page on open;
the largest real conversation is 18.6 MB. Windowing is CHAT-05.
- **Collapsed state survives redraws of a message,** but a full reopen
(picker, reload or branch change) starts collapsed again.
- **Pi only.** Claude seats show "not available yet" (D2, B1).
## 6. After review
- Pushing goes through Sage with Jason's word.
- The live WebUI (PID 1266267, running since 2026-09-13 from this checkout)
reads its static files from the working tree on every request, so it
already serves this candidate's page. Its proxy is the 09-13 code in
memory, which has no conversation routes, so History there answers
"History unavailable: not found." It must restart after the commit.
- Brief §4 live check: one board send with a long answer, shown in full in
the view. It needs the commit and the WebUI restart, then Jason or Sage.
+20
View File
@@ -0,0 +1,20 @@
# CHAT-02 backend: nonblocking follow-ups
These are from the approved reviews of packet revision 2 (`0cf177b1`). They are
not in the approved pins. Each one changes a pinned file, so it lands after the
backend commit as its own reviewed change.
1. **Hash the served parts, not only their ids** (Filbert, report `3b14d66c`).
`idsDigest` covers entry ids only. Hashing the serialized parts closes the
same-position duplicate-id limit in BACKEND.md §5. Pi never reuses ids, so
nothing is exposed today. Files: `packages/conversation/src/reader.mjs`,
plus one test.
2. **Pin every `REFUSAL_STATUS` value** (Darkwing, report `b9d92003`, note 1).
The coverage test checks keys, not values. Add a table test that asserts
the whole object. File: `packages/control-board/tests/serve.test.mjs`.
**Done in the Console round** (Sage, 2026-09-26: take them there).
3. **Scan every `.mjs` in `packages/conversation/src`** (Darkwing, note 2).
The coverage test reads a fixed list of three files, so a refusal added to
`parts.mjs` or a new file would fall to 422 unseen. File:
`packages/control-board/tests/serve.test.mjs`. **Done in the Console
round**; evidence `evidence/console/mutate-status.txt`.
@@ -0,0 +1,10 @@
CAUGHT text set as HTML -> conversation view: full history, collapsed tools, hidden thi
CAUGHT controls not made visible -> conversation view: full history, collapsed tools, hidden thi
CAUGHT fragments replace instead of join -> return flow through the conversation view: send, tool call,
CAUGHT details open by default -> conversation view: full history, collapsed tools, hidden thi; return flow through the conversation view: send, tool call,
CAUGHT no branch marker -> conversation view: a fork keeps the open branch, says so, an
CAUGHT no newer-session marker -> return flow through the conversation view: send, tool call,
CAUGHT no reconcile marker -> conversation view: full history, collapsed tools, hidden thi
CAUGHT follow drops its branch -> conversation view: a fork keeps the open branch, says so, an; conversation view: full history, collapsed tools, hidden thi; return flow through the conversation view: send, tool call,
MISSED follow switches to the default branch ->
CAUGHT forms share the reply-form class -> return flow through the conversation view: send, tool call,
@@ -0,0 +1,10 @@
CAUGHT text set as HTML -> conversation view: full history, collapsed tools, hidden thi
CAUGHT controls not made visible -> conversation view: full history, collapsed tools, hidden thi
CAUGHT fragments replace instead of join -> return flow through the conversation view: send, tool call,
CAUGHT details open by default -> conversation view: full history, collapsed tools, hidden thi; return flow through the conversation view: send, tool call,
CAUGHT no branch marker -> conversation view: a fork keeps the open branch, says so, an
CAUGHT no newer-session marker -> return flow through the conversation view: send, tool call,
CAUGHT no reconcile marker -> conversation view: full history, collapsed tools, hidden thi
CAUGHT follow drops its branch -> conversation view: a fork keeps the open branch, says so, an; conversation view: full history, collapsed tools, hidden thi; return flow through the conversation view: send, tool call,
CAUGHT follow switches to the default branch -> conversation view: a fork keeps the open branch, says so, an
CAUGHT forms share the reply-form class -> return flow through the conversation view: send, tool call,
@@ -0,0 +1,29 @@
# Console conversation-view mutants. Run from a full scratch copy of the checkout
# (/tmp/dewey-chat02/scratch-board/packages/webui), never the served tree.
import subprocess
f="src/public/app.js"; orig=open(f).read()
M=[("text set as HTML","e.textContent = inert(text)","e.innerHTML = text"),
("controls not made visible","e.textContent = inert(text)","e.textContent = String(text)"),
("fragments replace instead of join","b.value += c[FIELDS[c.type]]","b.value = c[FIELDS[c.type]]"),
("details open by default","n.open = open[i++] ?? false","n.open = open[i++] ?? true"),
("no branch marker","if (v.defaultBranch !== v.branch) marker(","if (false) marker("),
("no newer-session marker","if (conv.sessionId && r?.sessionId && r.sessionId !== conv.sessionId) marker(","if (false) marker("),
("no reconcile marker","if (res.body.refusal?.reconcile) marker(","if (false) marker("),
("follow drops its branch","branch: c.branch, cursor: c.follow","cursor: c.follow"),
("follow switches to the default branch","conv.branch = body.page.branch;","conv.branch = body.view.defaultBranch;"),
("forms share the reply-form class","if (!e.target.matches('.reply-form, #conv-form')) return;","if (!e.target.matches('.reply-form')) return;"),
# Revision 2 (Filbert B1, N1, N3).
("reload drops its branch","a === 'reload' ? { branch: c.branch } :","a === 'reload' ? {} :"),
("latest keeps the open branch","a === 'newest' ? { from: c.id, sessionId: c.sessionId } : {}","a === 'newest' ? { from: c.id, sessionId: c.sessionId } : { branch: c.branch }"),
("gone branch not reopened","if (!first.ok && opts.branch && first.body.refusal?.code === 'unknown-branch') {","if (false) {"),
("gone branch reopened silently","if (first.ok) marker('gone',","if (false) marker('gone',"),
("newest on the same file clears the marker","c.newerUnread = !!opts.from && opts.from === choice.conversation;","c.newerUnread = false;"),
("C1 controls left raw","UNSEEN = /[\\u0080-\\u009f\\u061c","UNSEEN = /[\\u061c"),
("bidi marks left raw","\\u061c\\u200e\\u200f\\u202a","\\u202a")]
for n,a,b in M:
assert orig.count(a)==1,n
open(f,"w").write(orig.replace(a,b))
r=subprocess.run(["node","--test","--test-concurrency=1","tests/conversation.test.mjs","tests/history-return-flow.test.mjs"],capture_output=True,text=True,timeout=900)
fails=sorted(set(l.strip()[2:].split(" (")[0][:60] for l in r.stdout.splitlines() if l.lstrip().startswith("✖") and "failing tests" not in l))
print(("CAUGHT " if r.returncode else "MISSED ")+n+" -> "+"; ".join(fails))
open(f,"w").write(orig)
@@ -0,0 +1,17 @@
CAUGHT text set as HTML -> conversation view: full history, collapsed tools, hidden thi
CAUGHT controls not made visible -> conversation view: full history, collapsed tools, hidden thi
CAUGHT fragments replace instead of join -> return flow through the conversation view: send, tool call,
CAUGHT details open by default -> conversation view: full history, collapsed tools, hidden thi; return flow through the conversation view: send, tool call,
CAUGHT no branch marker -> conversation view: a fork keeps the open branch, says so, an
CAUGHT no newer-session marker -> conversation view: a newer session with no readable history ; return flow through the conversation view: send, tool call,
CAUGHT no reconcile marker -> conversation view: a fork keeps the open branch, says so, an; conversation view: full history, collapsed tools, hidden thi
CAUGHT follow drops its branch -> conversation view: a fork keeps the open branch, says so, an; conversation view: full history, collapsed tools, hidden thi; return flow through the conversation view: send, tool call,
CAUGHT follow switches to the default branch -> conversation view: a fork keeps the open branch, says so, an
CAUGHT forms share the reply-form class -> return flow through the conversation view: send, tool call,
CAUGHT reload drops its branch -> conversation view: a fork keeps the open branch, says so, an
CAUGHT latest keeps the open branch -> conversation view: a fork keeps the open branch, says so, an
CAUGHT gone branch not reopened -> conversation view: a fork keeps the open branch, says so, an
CAUGHT gone branch reopened silently -> conversation view: a fork keeps the open branch, says so, an
CAUGHT newest on the same file clears the marker -> conversation view: a newer session with no readable history
CAUGHT C1 controls left raw -> conversation view: full history, collapsed tools, hidden thi
CAUGHT bidi marks left raw -> conversation view: full history, collapsed tools, hidden thi
@@ -0,0 +1,14 @@
# Proxy mutants for the WebUI conversation routes. Run from a scratch copy of
# packages/webui (never the served tree): /tmp/dewey-chat02/scratch-board/packages/webui
import subprocess
f="src/serve.mjs"; orig=open(f).read()
M=[("query dropped","upstream + path + (conversation ? search : '')","upstream + path"),
("query sent on every route","upstream + path + (conversation ? search : '')","upstream + path + search"),
("conversation routes not proxied","const allowed = path === '/api/board' || conversation ? 'GET'","const allowed = path === '/api/board' ? 'GET'"),
("conversation routes accept POST","const allowed = path === '/api/board' || conversation ? 'GET'","const allowed = path === '/api/board' ? 'GET' : conversation ? req.method")]
for n,a,b in M:
assert orig.count(a)==1,n
open(f,"w").write(orig.replace(a,b))
r=subprocess.run(["node","--test","tests/serve.test.mjs"],capture_output=True,text=True,timeout=300)
print(("CAUGHT " if r.returncode else "MISSED ")+n)
open(f,"w").write(orig)
@@ -0,0 +1,4 @@
CAUGHT query dropped
CAUGHT query sent on every route
CAUGHT conversation routes not proxied
CAUGHT conversation routes accept POST
@@ -0,0 +1,11 @@
# Mutants for the REFUSAL_STATUS test (Darkwing's R2 notes). Run from a full
# scratch copy of the checkout (/tmp/dewey-chat02/scratch-board), never the served tree.
import subprocess
M=[("unavailable becomes 422","packages/control-board/src/serve.mjs"," unavailable: 404,\n"," unavailable: 422,\n"),
("new refusal in parts.mjs","packages/conversation/src/parts.mjs","export ",'const _probe = () => new Refusal("probe-code", "x");\nexport ')]
for n,f,a,b in M:
o=open(f).read(); assert a in o,n
open(f,"w").write(o.replace(a,b,1))
r=subprocess.run(["node","--test","--test-name-pattern=every refusal code","tests/serve.test.mjs"],cwd="packages/control-board",capture_output=True,text=True,timeout=600)
print(("CAUGHT " if r.returncode else "MISSED ")+n)
open(f,"w").write(o)
@@ -0,0 +1,2 @@
CAUGHT unavailable becomes 422
CAUGHT new refusal in parts.mjs
@@ -0,0 +1,197 @@
--- a/packages/webui/README.md
+++ b/packages/webui/README.md
@@ -42,12 +42,14 @@
card, its table row or its inspector (#1507, CHAT-02). It shows the whole
branch with nothing clipped. Tool calls, tool results and thinking start
collapsed. Session text is always shown as text: Markdown stays as source,
- and terminal and bidi controls show as visible symbols. Reply in the view
+ and terminal controls, bidi controls and marks show as visible symbols. Reply in the view
uses the same board reply path as the inspector.
- The view checks for new entries on the same ten-second refresh, and Pause
stops it. It never switches on its own. A fork, a newer session for the seat
or a rewritten file each shows a marker with a button to open the other
- history. Session lists older sessions for the seat. Only repository Pi seats
+ history. Reloading after a rewrite keeps the branch the view was on; if that
+ branch is gone, the view opens the latest one and says so. Session lists
+ older sessions for the seat. Only repository Pi seats
have history; other harnesses say so.
Drafts and receipts stay in page memory, including across refresh, inspector
--- a/packages/webui/src/public/app.js
+++ b/packages/webui/src/public/app.js
@@ -85,9 +85,10 @@
// textContent only, so it never creates an element, handler or navigation.
// Markdown shows as its source text. New entries arrive by polling a follow cursor.
let conv = null, convGen = 0;
- const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f]/g, BIDI = /[‪-‮⁦-⁩]/g;
- // Terminal and bidi controls show as visible symbols. Newlines and tabs stay.
- const inert = v => String(v).replace(CONTROL, c => c === '\u007f' ? '␡' : String.fromCharCode(0x2400 + c.charCodeAt(0))).replace(BIDI, c => `[U+${c.charCodeAt(0).toString(16).toUpperCase()}]`);
+ const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f]/g, UNSEEN = /[\u0080-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
+ // C0 controls and DEL show as control pictures. C1 controls and bidi controls and marks have no picture
+ // and show as [U+XXXX]. Newlines and tabs stay.
+ const inert = v => String(v).replace(CONTROL, c => c === '\u007f' ? '␡' : String.fromCharCode(0x2400 + c.charCodeAt(0))).replace(UNSEEN, c => `[U+${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}]`);
const node = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = inert(text); return e; };
const ROLES = { user: 'User', assistant: 'Assistant', tool: 'Tool', notice: 'Notice', compaction: 'Compaction' };
// The CHAT-01 field that holds each block type's string. Fragments of one block concatenate in order.
@@ -145,14 +146,14 @@
const added = addEntries(body.page.entries);
const v = body.view;
if (v.incomplete) marker('incomplete', 'The last line of this session is still being written. It shows here once it is complete.'); else unmark('incomplete');
- if (v.defaultBranch !== v.branch) marker('branch', 'This conversation continued on another branch. This view stays on the branch it opened.', { id: 'reopen', label: 'Open the latest branch' });
+ if (v.defaultBranch !== v.branch) marker('branch', 'This conversation continued on another branch. This view stays on the branch it opened.', { id: 'latest', label: 'Open the latest branch' });
return added;
}
// A refusal with reconcile keeps everything already shown and stops polling. Nothing switches silently.
function refused(res) {
const code = res.body.refusal?.code;
conv.follow = null;
- if (res.body.refusal?.reconcile) marker('reconcile', `This view is out of date (${code}): ${res.body.error}. The history below is what was loaded before.`, { id: 'reopen', label: 'Reload conversation' }), convStatus();
+ if (res.body.refusal?.reconcile) marker('reconcile', `This view is out of date (${code}): ${res.body.error}. The history below is what was loaded before.`, { id: 'reload', label: 'Reload conversation' }), convStatus();
else convStatus(`History unavailable: ${res.body.error || 'unknown error'}${code ? ` (${code})` : ''}.`);
}
const nearEnd = () => $('conv-log').getBoundingClientRect().bottom <= innerHeight + 300;
@@ -180,9 +181,10 @@
$('conv-receipt').hidden = !receipt;
if (receipt) { $('conv-receipt').className = `conv-receipt ${receipt.delivered ? '' : 'failed'}`; $('conv-receipt').textContent = receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`; }
// The board's newest file for this seat changed after the view opened: a relaunch. The view keeps its file.
- if (conv.sessionId && r?.sessionId && r.sessionId !== conv.sessionId) marker('newer', 'A newer session started for this seat. This view stays on the session you opened.', { id: 'newest', label: 'Open the newest session' });
+ if (conv.sessionId && r?.sessionId && r.sessionId !== conv.sessionId) marker('newer', conv.newerUnread ? 'A newer session started for this seat, but its history is not readable yet. This view stays on the session you opened.' : 'A newer session started for this seat. This view stays on the session you opened.', { id: 'newest', label: 'Open the newest session' });
}
- async function openConversation(rowKey, pick = null, returnTo = conv?.returnFocus) {
+ // opts.branch reopens that branch (Reload). opts.from and opts.sessionId come from "Open the newest session".
+ async function openConversation(rowKey, pick = null, returnTo = conv?.returnFocus, opts = {}) {
const gen = ++convGen, r = row(rowKey), sameRow = conv?.row === rowKey;
conv = { row: rowKey, id: null, branch: null, follow: null, messages: new Map(), markers: new Map(), sessionId: null, loading: true, polling: false, returnFocus: returnTo };
selected = null; render(); showConversation(true);
@@ -205,9 +207,18 @@
$('conv-pick').value = c.id = choice.conversation;
$('conv-meta').textContent = `${project} · ${choice.title || 'Untitled'} · started ${choice.conversationCreatedAt || 'unknown'}`;
if (choice.availability !== 'available') { convStatus(choice.unsupportedReason === 'unsupported-harness' ? `History for the ${choice.harness} harness is not available yet.` : `History unavailable (${choice.refusal?.code || choice.availability}).`); return; }
- // Relaunch detection only makes sense for the newest session, the one the board row shows.
- if (choice === readable[0]) c.sessionId = r?.sessionId ?? null;
- const done = await readPages(c, gen, await getJSON(convURL({ id: c.id })), () => convStatus(`Loading history… ${c.messages.size} messages`));
+ // Relaunch detection only makes sense for the newest session, the one the board row shows. If "newest"
+ // lands on the file already open, the board's newer file is not readable yet: keep the marker.
+ c.newerUnread = !!opts.from && opts.from === choice.conversation;
+ if (choice === readable[0]) c.sessionId = c.newerUnread ? opts.sessionId : r?.sessionId ?? null;
+ let first = await getJSON(convURL(opts.branch ? { id: c.id, branch: opts.branch } : { id: c.id }));
+ // The branch this view was on is gone: open the default branch and say so.
+ if (!first.ok && opts.branch && first.body.refusal?.code === 'unknown-branch') {
+ first = await getJSON(convURL({ id: c.id }));
+ if (gen !== convGen) return;
+ if (first.ok) marker('gone', 'The branch this view was on is no longer in the session. This view shows the latest branch.');
+ }
+ const done = await readPages(c, gen, first, () => convStatus(`Loading history… ${c.messages.size} messages`));
if (gen !== convGen) return;
if (done) convStatus();
$('conv-log').lastElementChild?.scrollIntoView({ block: 'end' });
@@ -295,7 +306,8 @@
const history = e.target.closest('[data-history]');
if (history) { openConversation(history.dataset.history, null, { history: history.dataset.history, container: history.closest('[id]')?.id }); $('conv-title').focus(); return; }
const action = e.target.closest('[data-conv-action]');
- if (action && conv) { openConversation(conv.row, action.dataset.convAction === 'reopen' ? conv.id : null); $('conv-title').focus(); return; }
+ // Reload keeps the open branch; "Open the latest branch" takes the default; "Open the newest session" takes the newest file.
+ if (action && conv) { const c = conv, a = action.dataset.convAction; openConversation(c.row, a === 'newest' ? null : c.id, undefined, a === 'reload' ? { branch: c.branch } : a === 'newest' ? { from: c.id, sessionId: c.sessionId } : {}); $('conv-title').focus(); return; }
const open = e.target.closest('[data-open]');
if (open) { if (conv) closeConversation(); returnFocus = focusSnapshot(); selected = open.dataset.open; render(); $('inspector-title').focus(); announce(`Inspecting ${row(selected)?.agent}`); return; }
const projectButton = e.target.closest('[data-project]');
--- a/packages/webui/tests/conversation.test.mjs
+++ b/packages/webui/tests/conversation.test.mjs
@@ -10,7 +10,7 @@
import { browser } from './browser.mjs';
import { historyFixture, session, user, assistant, toolResult, at, waiter, turns } from './history-fixture.mjs';
-const HOSTILE = '<script>window.injected=1</script><img src=x onerror="window.injected=2"> [click](javascript:window.injected=3) <a href="javascript:window.injected=4">link</a> \u001b[31mRED\u001b[0m \u001b]8;;http://example.invalid\u0007osc\u001b]8;;\u0007 ‮evil';
+const HOSTILE = '<script>window.injected=1</script><img src=x onerror="window.injected=2"> [click](javascript:window.injected=3) <a href="javascript:window.injected=4">link</a> \u001b[31mRED\u001b[0m \u001b]8;;http://example.invalid\u0007osc\u001b]8;;\u0007 \u009b31mCSI \u200emark \u202eevil';
const LONG = 'This answer is longer than the board summary. '.repeat(40) + 'LONG_END';
test('conversation view: full history, collapsed tools, hidden thinking, inert hostile content, malformed and reconcile markers', { timeout: 120000 }, async () => {
@@ -53,11 +53,11 @@
await b.evaluate('document.querySelectorAll("#conv-log details").forEach(d => d.open = true)');
const shown = await b.evaluate('[...document.querySelectorAll("#conv-log .conv-text")].find(e => e.textContent.startsWith("Here: ")).textContent');
assert.ok(shown.startsWith('Here: <script>window.injected=1</script><img src=x onerror="window.injected=2"> [click](javascript:window.injected=3) <a href="javascript:window.injected=4">link</a> ␛[31mRED'), shown);
- assert.ok(shown.includes('␛]8;;http://example.invalid␇osc') && shown.endsWith('[U+202E]evil'), shown);
+ assert.ok(shown.includes('␛]8;;http://example.invalid␇osc') && shown.endsWith('[U+009B]31mCSI [U+200E]mark [U+202E]evil'), shown);
assert.equal(await b.evaluate('document.querySelector(".conv-tool-result pre").textContent'), shown.slice(6));
assert.equal(await b.evaluate('document.querySelectorAll("#conversation script, #conversation img, #conversation a, #conversation iframe, #conversation object, #conversation embed, #conversation svg, #conversation style, #conversation link").length'), 0);
assert.equal(await b.evaluate('[...document.querySelectorAll("*")].some(e => [...e.attributes].some(a => a.name.startsWith("on")))'), false);
- assert.equal(await b.evaluate('document.body.textContent.includes("\\u001b") || document.body.textContent.includes("\\u202e")'), false);
+ assert.equal(await b.evaluate('/[\\u001b\\u009b\\u200e\\u202e]/.test(document.body.textContent)'), false);
await b.evaluate('[...document.querySelectorAll("#conv-log .conv-text")].find(e => e.textContent.startsWith("Here: ")).click()');
assert.equal(await b.evaluate('typeof window.injected'), 'undefined');
assert.equal(await b.evaluate('location.href'), href);
@@ -84,7 +84,7 @@
}
// Reload takes a fresh snapshot of the rewritten file; Back returns focus to the History button.
- await b.evaluate('document.querySelector("[data-conv-action=reopen]").click()');
+ await b.evaluate('document.querySelector("[data-conv-action=reload]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("Show me the FILE")');
assert.equal(await b.evaluate('document.querySelector("[data-marker=reconcile]")'), null);
await b.evaluate('document.querySelector("#conv-back").click()');
@@ -125,16 +125,61 @@
await wait('document.querySelector("#conv-log").textContent.includes("MAIN_MORE") || !!document.querySelector("[data-marker=reconcile]") || document.querySelector("#conv-status").textContent.includes("unavailable")');
assert.deepEqual(await turns(b), [['User', 'Question'], ['Assistant', 'MAIN_ANSWER'], ['User', 'MAIN_MORE']]);
assert.equal(await b.evaluate('document.querySelector("[data-marker=reconcile]")'), null);
- // The fork becomes the default leaf again before the view reopens.
+ // The fork becomes the default leaf again. A same-inode rewrite then refuses the next check.
log.raw(JSON.stringify({ type: 'message', id: 'fork-2', parentId: 'fork-1', timestamp: at(0), message: user('FORK_MORE') }));
- await b.evaluate('document.querySelector("[data-conv-action=reopen]").click()');
+ writeFileSync(file, readFileSync(file, 'utf8').replace('"Question"', '"QUESTION"'));
+ await b.evaluate('document.querySelector("#refresh").click()');
+ await wait('document.querySelector("[data-marker=reconcile]")');
+ // Reload stays on the branch the view was on, and still says the conversation went elsewhere.
+ await b.evaluate('document.querySelector("[data-conv-action=reload]").click()');
+ await wait('document.querySelector("#conv-log").textContent.includes("QUESTION") && !document.querySelector("[data-marker=reconcile]")');
+ await wait('document.querySelector("[data-marker=branch]")');
+ assert.deepEqual(await turns(b), [['User', 'QUESTION'], ['Assistant', 'MAIN_ANSWER'], ['User', 'MAIN_MORE']]);
+ // "Open the latest branch" takes the default.
+ await b.evaluate('document.querySelector("[data-conv-action=latest]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("FORK_MORE")');
- assert.deepEqual(await turns(b), [['User', 'Question'], ['Assistant', 'FORK_ANSWER'], ['User', 'FORK_MORE']]);
+ assert.deepEqual(await turns(b), [['User', 'QUESTION'], ['Assistant', 'FORK_ANSWER'], ['User', 'FORK_MORE']]);
assert.equal(await b.evaluate('document.querySelector("[data-marker=branch]")'), null);
+ // The fork is rewritten away. Reload cannot keep a branch that is gone, so it opens the default and says so.
+ writeFileSync(file, readFileSync(file, 'utf8').split('\n').filter(l => !l.includes('"fork-')).join('\n'));
+ await b.evaluate('document.querySelector("#refresh").click()');
+ await wait('document.querySelector("[data-marker=reconcile]")');
+ await b.evaluate('document.querySelector("[data-conv-action=reload]").click()');
+ await wait('document.querySelector("[data-marker=gone]")');
+ assert.match(await b.evaluate('document.querySelector("[data-marker=gone]").textContent'), /^The branch this view was on is no longer in the session\. This view shows the latest branch\.$/);
+ assert.deepEqual(await turns(b), [['User', 'QUESTION'], ['Assistant', 'MAIN_ANSWER'], ['User', 'MAIN_MORE']]);
+ assert.equal(await b.evaluate('document.querySelector("[data-marker=reconcile]")'), null);
+ } finally { if (b) await b.close(); await f.close(); }
+});
+
+test('conversation view: a newer session with no readable history keeps the marker', { timeout: 60000 }, async () => {
+ const f = await historyFixture();
+ let b;
+ try {
+ const log = session(join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl'), f.projectRoot);
+ log.add(user('Question'), at(-120));
+ log.add(assistant('OLD_ANSWER'), at(-110));
+ b = await browser(); await b.viewport(1440, 1000);
+ const wait = waiter(b);
+ await b.navigate(f.base); await wait('document.querySelector("table.sessions [data-history]")');
+ await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
+ await wait('document.querySelector("#conv-status").textContent.startsWith("2 messages")');
+ // The board takes the newest file by mtime; the catalogue orders by last entry, so a header-only file sorts last.
+ const log2 = session(join(f.sessionsDir, '2026-09-26T11-00-00_s2.jsonl'), f.projectRoot, { id: 'sess-2', timestamp: at(0) });
+ await b.evaluate('document.querySelector("#refresh").click()');
+ await wait('document.querySelector("[data-marker=newer]")');
+ await b.evaluate('document.querySelector("[data-conv-action=newest]").click()');
+ await wait('/not readable yet/.test(document.querySelector("[data-marker=newer]")?.textContent)');
+ assert.match(await b.evaluate('document.querySelector("#conv-log").textContent'), /OLD_ANSWER/);
+ // Once the new file has entries, the same button opens it.
+ log2.add(user('NEW_QUESTION'), at(1));
+ await b.evaluate('document.querySelector("[data-conv-action=newest]").click()');
+ await wait('document.querySelector("#conv-log").textContent.includes("NEW_QUESTION")');
+ assert.equal(await b.evaluate('document.querySelector("[data-marker=newer]")'), null);
} finally { if (b) await b.close(); await f.close(); }
});
-test('conversation view: seats without history say so and offer no reply', { timeout: 60000 }, async () => {
+test('conversation view: seats without history say so and offer no reply',{ timeout: 60000 }, async () => {
const f = await historyFixture();
let b;
try {
Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

@@ -0,0 +1,19 @@
import { mkdirSync, writeFileSync, appendFileSync, mkdtempSync, realpathSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createReader } from "../../../../packages/conversation/src/reader.mjs";
const proj = realpathSync(mkdtempSync(join(tmpdir(), "fb-proj-")));
const dir = join(proj, ".pi", "state", "x", "sessions"); mkdirSync(dir, { recursive: true });
const f = join(dir, "s.jsonl");
const L = (o) => JSON.stringify(o) + "\n";
const msg = (id, parentId, role, text) => L({ type: "message", id, parentId, timestamp: "2026-09-26T00:00:00Z", message: role === "assistant" ? { role, content: [{ type: "text", text }], stopReason: "stop" } : { role, content: text } });
writeFileSync(f, L({ type: "session", id: "sess", cwd: proj, timestamp: "2026-09-26T00:00:00Z" }) + msg("a", null, "user", "hi") + msg("b", "a", "assistant", "hello"));
const reader = createReader({ roots: [{ seat: "x", project: "p", projectRoot: proj, dir, harness: "pi", unsupportedReason: null, engineStartedAt: null }] });
const conv = reader.catalogue().conversations[0].conversation;
const p1 = reader.open({ conversation: conv });
console.log("open branch", p1.page.branch, "entries", p1.page.entries.map(e => e.id + "@" + e.branch));
appendFileSync(f, msg("c", "b", "user", "more") + msg("d", "c", "assistant", "ok"));
const p2 = reader.next({ cursor: p1.follow.id, conversation: conv, branch: p1.page.branch });
console.log("follow ok", p2.ok, "page branch", p2.page?.branch, "entries", p2.page?.entries.map(e => e.id + "@" + e.branch), "view", JSON.stringify(p2.view?.branches));
const p3 = reader.open({ conversation: conv, branch: p1.page.branch });
console.log("reopen with first branch id:", JSON.stringify(p3.refusal ?? p3.page.branch));
@@ -0,0 +1,15 @@
import { mkdirSync, writeFileSync, mkdtempSync, realpathSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createReader } from "../../../../packages/conversation/src/reader.mjs";
const proj = realpathSync(mkdtempSync(join(tmpdir(), "fb-proj-")));
const dir = join(proj, ".pi", "state", "x", "sessions"); mkdirSync(dir, { recursive: true });
const L = (o) => JSON.stringify(o) + "\n";
const msg = (id, parentId, text) => L({ type: "message", id, parentId, timestamp: "2026-09-26T00:00:00Z", message: { role: "user", content: text } });
// a -> b is one branch. X (lost, malformed) was a child of a: a fork. y -> X.
writeFileSync(join(dir, "s.jsonl"), L({ type: "session", id: "sess", cwd: proj, timestamp: "2026-09-26T00:00:00Z" }) + msg("a", null, "root") + msg("b", "a", "ON THE OTHER BRANCH") + '{"type":"message","id":"X","parentId":"a","timest\n' + msg("y", "X", "leaf"));
const reader = createReader({ roots: [{ seat: "x", project: "p", projectRoot: proj, dir, harness: "pi", unsupportedReason: null, engineStartedAt: null }] });
const conv = reader.catalogue().conversations[0].conversation;
const p = reader.open({ conversation: conv });
console.log("branches", JSON.stringify(p.view.branches));
for (const e of p.page.entries) console.log(e.branch, e.role, JSON.stringify(e.content[0].text).slice(0, 90));
@@ -0,0 +1,24 @@
import { mkdirSync, writeFileSync, appendFileSync, mkdtempSync, realpathSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createReader } from "../../../../packages/conversation/src/reader.mjs";
const proj = realpathSync(mkdtempSync(join(tmpdir(), "fb-proj-")));
const dir = join(proj, ".pi", "state", "x", "sessions"); mkdirSync(dir, { recursive: true });
const f = join(dir, "s.jsonl");
const L = (o) => JSON.stringify(o) + "\n";
const m = (id, parentId, text) => L({ type: "message", id, parentId, timestamp: "2026-09-26T00:00:00Z", message: { role: "user", content: text } });
writeFileSync(f, L({ type: "session", id: "s", cwd: proj, timestamp: "2026-09-26T00:00:00Z" }) + m("a", null, "A") + m("b", "a", "B") + m("c", "b", "C"));
const reader = createReader({ roots: [{ seat: "x", project: "p", projectRoot: proj, dir, harness: "pi", unsupportedReason: null, engineStartedAt: null }] });
const conv = reader.catalogue().conversations[0].conversation;
const t = (p) => p.ok ? `${p.page.branch} [${p.page.entries.map((e) => e.content[0].text).join(",")}] default=${p.view.defaultBranch} branches=${p.view.branches.map((b) => b.branch).join("|")}` : JSON.stringify(p.refusal);
let main = reader.open({ conversation: conv }); console.log("1 open", t(main));
// Pi navigates back to b and continues: fork from the middle of main.
appendFileSync(f, m("x", "b", "X") + m("y", "x", "Y"));
let fm = reader.next({ cursor: main.follow.id, conversation: conv, branch: "main" }); console.log("2 main follow", t(fm));
let dx = reader.open({ conversation: conv }); console.log("3 default open", t(dx));
// Pi goes back to c on main and continues; then resetLeaf starts a new root.
appendFileSync(f, m("d", "c", "D") + m("r", null, "R"));
console.log("4 main follow", t(reader.next({ cursor: fm.follow.id, conversation: conv, branch: "main" })));
console.log("5 b.x follow", t(reader.next({ cursor: dx.follow.id, conversation: conv, branch: dx.page.branch })));
console.log("6 open b.x", t(reader.open({ conversation: conv, branch: "b.x" })));
console.log("7 open default", t(reader.open({ conversation: conv })));
@@ -0,0 +1,15 @@
import { mkdirSync, writeFileSync, mkdtempSync, realpathSync, chmodSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createReader } from "../../../../packages/conversation/src/reader.mjs";
const proj = realpathSync(mkdtempSync(join(tmpdir(), "fb-proj-")));
const mk = (seat) => { const d = join(proj, ".pi", "state", seat, "sessions"); mkdirSync(d, { recursive: true }); writeFileSync(join(d, "s.jsonl"), JSON.stringify({ type: "session", id: "s", cwd: proj, timestamp: "2026-09-26T00:00:00Z" }) + "\n"); return d; };
const good = mk("good"), bad = mk("bad");
chmodSync(join(proj, ".pi", "state", "bad"), 0o000);
const root = (seat, dir) => ({ seat, project: "p", projectRoot: proj, dir, harness: "pi", unsupportedReason: null, engineStartedAt: null });
const reader = createReader({ roots: [root("good", good), root("bad", bad)] });
try { const c = reader.catalogue(); console.log("catalogue ok", c.conversations.length, JSON.stringify(c.refusedRoots)); }
catch (e) { console.log("catalogue THREW", e.code, e.message); }
try { console.log("open", JSON.stringify(reader.open({ conversation: "pi-" + "0".repeat(32) }).refusal)); }
catch (e) { console.log("open THREW", e.code); }
chmodSync(join(proj, ".pi", "state", "bad"), 0o755);
@@ -0,0 +1,264 @@
# CHAT-02 Console, revision 1: Filbert's code review
Reviewer: Filbert, 2026-09-26. Requested by Dewey, assigned by Sage (#1507,
row 5). Measured against brief R4 (`agents/dewey/work/chat-02/BRIEF.md`) §2.2,
§2.3 and §4, and Sage's D1–D4.
Candidate: packet `agents/dewey/work/chat-02/CONSOLE.md`, sha256
`24b046af…cd9ccd`, ten files on base `3a209eea`. All ten pins verify in the
shared tree and in my overlay.
**Verdict: changes requested.** One blocking finding (B1): "Reload
conversation" after a reconcile moves a view that sits on an older branch to
the default branch without saying so. The rest of the candidate is sound, and
I accept all five §3 choices. The fix is small, and I'll re-review only the
delta.
## 1. What I ran
- **Overlay.** A detached worktree at `3a209eea` (`/tmp/fb-console-wt`), with
only the ten pinned files overlaid (`sha256sum -c` clean) and
`node_modules` symlinked.
- **Suites.** Running `node --test --test-concurrency=1` over the
conversation, control-board, webui and seat tests gives 185/185. The
`chat-00`, `chat-01` and `chat-01c` `check.mjs` scripts each exit 0.
- **Screens.** I regenerated test 1's screens with `WEBUI_EVIDENCE` at the
pinned hashes. The four PNGs have the same dimensions as Dewey's
(305×3315 at 320, 1425×1586 at 1440), and I looked at 320-dark and
1440-light. They show the long answer in full, the tool call and result,
inert hostile text (with ␛, ␇ and `[U+202E]` visible), the line-5 notice
and the reconcile marker. Test 1 asserts no horizontal overflow at 320 and
1440 in both modes.
- **Two probes of my own,** in a scratch test file in the overlay (not the
shared tree). Both results are below: B1 and N1.
## 2. Against the brief
- **§2.2 rendering.**
- Every session string goes through `node()`, which sets `textContent`
after `inert()`. No session value reaches `innerHTML`. The existing
card, table and inspector templates gain only `historyButton(r)`, and
both values it interpolates go through `esc()`.
- Tool calls, tool results, thinking and compaction are closed
`details`. Thinking is hidden by default, and unavailable thinking
says so.
- The hostile fixture matches R1, in both assistant text and tool output.
The assertions cover the absence of any element, any `on*` attribute and
any navigation.
- Age is untouched. Reply keeps the board path, and the view polls.
- **§2.3 items 1–7** are in `history-return-flow.test.mjs`, with no
injection and no Refresh. Item 6 is covered with `newer` in place of
`reconcile` (see §3.1).
- **Proxy.** `/api/conversations` and `/api/conversation` are GET-only and
forward their query string unchanged. `/api/board` gets no query. The Host
and Origin checks run first, the JSON-only response rule still holds, and
so does `redirect: 'error'`. The webui serve test pins each of these.
- **§4.**
- The suites and checks are green.
- The screens are present, and I reproduced them at the pins.
- The live check is still open: it needs the commit and a WebUI restart,
as the packet's §6 says.
## 3. The five choices Dewey asked about
1. **`newer` instead of `reconcile` for a relaunch: accept.**
- In this code, `reconcile` means "the source refused, polling stopped".
A relaunch refuses nothing. The open file is still accurate, and
polling on it should go on.
- A separate marker with its own action is the better reading of item 6's
intent, which is to keep the file and never switch silently.
- I wrote that line in my R2 review, and the wording was too narrow.
Sage should record the deviation against brief §2.3 item 6, in the
BUILD-LOG entry or as a brief erratum, so the brief and the test agree.
2. **Detection only for the newest readable conversation: accept.** An
older session is an explicit choice from the picker, so it has nothing
newer to warn about. There is one latent edge in the matching; see N1.
3. **The `conv-form` and `conv-receipt` classes: accept.**
- The cause is right: the hidden view comes earlier in the DOM, so a
bare `.reply-form` query found it.
- The four browser tests pass in the overlay.
- Dewey's "forms share the reply-form class" mutant is caught.
- The CSS lists both class pairs, and the submit handler matches
`.reply-form, #conv-form`.
4. **The heading focus ring: accept.** The heading uses `tabindex="-1"` and
the shared `:focus-visible` rule, the same as the inspector title. The
ring shows in the screens only because a synthetic `click()` counts as
keyboard focus.
5. **Darkwing's R2 notes, test-only: accept.**
- Only `packages/control-board/tests/serve.test.mjs` changes under
control-board (`git diff 3a209eea --stat`), so the board's `serve.mjs`
is unchanged.
- The refusal scan now reads every `.mjs` in `packages/conversation/src`,
and the pin covers the whole `REFUSAL_STATUS` object, all 16 codes.
## 4. Blocking
**B1. After a reconcile, Reload silently switches the branch.**
- **Where.** The reconcile marker's button is
`{ id: 'reopen', label: 'Reload conversation' }`. The click handler calls
`openConversation(conv.row, conv.id)`, which reads `convURL({ id })` with
no branch, so the server returns the default branch. `openConversation`
resets the markers, and `apply()` raises `branch` only when
`view.defaultBranch !== view.branch`. On the default branch, that is
never.
- **Probe A.** I opened a view, then a fork made another leaf the default,
and the `branch` marker showed. That part is correct. Next I did a
same-inode rewrite that keeps both branches, which triggered a reconcile,
and clicked Reload.
- Result: turns `[["User","QUESTION"],["Assistant","FORK_ANSWER"]]` and
no markers.
- The reader was on `MAIN_ANSWER`'s branch. They now see the fork's
branch, and nothing on screen says so.
- **Why it blocks.**
- The branch marker's own text promises "This view stays on the branch it
opened".
- Test 2's assertion is named "no silent switch".
- Brief §2.1 says nothing switches silently.
- **It is reachable in normal use, not only on a rewrite.** Cursors are held
in board memory with a 10-minute TTL (`reader.mjs:204`), so either of
these produces the reconcile:
- a board restart (`cursor-unknown`);
- Pause held for more than ten minutes (`cursor-expired`).
- **Suggested fix.**
- Reload reopens with the branch it was on: `convURL({ id, branch })`.
- If the board answers `unknown-branch`, open the default and show a
marker that says the old branch is gone.
- Add a test in probe A's shape: fork, a refusal, Reload, then assert the
open branch's text or a marker. Also run a mutant that drops the branch
from Reload.
- The "Open the latest branch" button shares the `reopen` id. It should
keep going to the default, so the two buttons need different ids.
## 5. Nonblocking
**N1. The board and the catalogue disagree on "newest".**
- **The mismatch.** The board row's `sessionId` comes from the newest file
by mtime (`scan.mjs:49–58`). The catalogue sorts by the last entry's
timestamp and puts `null` last (`reader.mjs:262`).
`openConversation` takes `readable[0]` as "the board's session" and, for
the "Open the newest session" action, again as "the newest".
- **Probe B.** A relaunch file holding only its header:
- `newer` shows. Clicking "Open the newest session" reopens the **old**
file, clears the marker and records the new `sessionId`.
- After a message then lands in the new file, the view is still on the
old file and has no marker. It never returns.
- **Why it doesn't block.** Real Pi doesn't write a header-only file. Its
`_persist` creates the file with `wx` only once an assistant message
exists (`session-manager.js:739–766`), so header, user and assistant land
together. The new file's last timestamp is then newer, and the two rules
agree.
- **Cheap guard.** After the `newest` action, if `choice.conversation`
equals the conversation that was open, keep the marker and say the newer
session isn't readable yet.
**N2. Raw bidi controls in the source.**
- **Where.** `app.js:88` builds `BIDI` from raw U+202A, U+202E, U+2066 and
U+2069 characters inside the regex literal. `conversation.test.mjs`'s
`HOSTILE` string holds a raw U+202E.
- **Why it matters.** They behave correctly, but a raw override in a diff is
exactly what a reviewer can't see; this is the Trojan Source pattern.
- **Fix.** Write `/[‪-‮⁦-⁩]/g` and `'‮evil'`. The
behaviour is identical.
**N3. `inert()` coverage.**
- **What's missing.** It leaves out:
- LRM, RLM and ALM (U+200E, U+200F, U+061C);
- the C1 controls U+0080–U+009F, which include the single-byte CSI
U+009B.
- **Why it's minor.** In a browser, the marks can only nudge neutral
characters next to them, and C1 has no effect. Take it or leave it. If
taken, the fixture gains one of each.
## 6. Scratch
- **Worktree.** `/tmp/fb-console-wt` stays in place for the delta re-review.
Its only additions are the probe file
`packages/webui/tests/zz-filbert-probe.test.mjs` and the symlinked
`node_modules`, and the ten pins still verify. Screens are in
`/tmp/fb-console-ev`.
- **Nothing live was touched.** I didn't use the shared tree's served WebUI
or any live process.
- No commit or push.
## Revision 2: delta re-review
Candidate: packet `CONSOLE.md` `d06de6a7…72d2`, and
`evidence/console/r2-delta.patch` `c1d65628…5866`. Three files changed:
- `app.js` `3c7f2f4c…0d6e`
- `conversation.test.mjs` `52c2c663…a4a5`
- `README.md` `5a1a4de7…acc4`
The other seven pins are unchanged from revision 1.
**Chain.** In my overlay, the patch applies in reverse to revision 1's ten
pins (all ten verify). Applied forward again, it gives the three revision 2
hashes. In both the overlay and the shared tree, all ten revision 2 pins
verify.
**Runs.** Suites 188/188: Dewey's 186, plus my probes A and B, kept for the
run. The chat-00, chat-01 and chat-01c checks exit 0.
**B1: fixed.**
- The reconcile button is now `reload` and reopens with
`{ branch: c.branch }`. "Open the latest branch" is now `latest`, which
reopens without a branch. `newest` passes `from` and `sessionId`.
- Probe A, rerun: after the rewrite, Reload shows
`[QUESTION, MAIN_ANSWER]` with the `branch` marker. Revision 1 showed the
fork with no marker.
- On `unknown-branch`, the view falls back to the default and raises
`gone`. The fallback runs only when a branch was asked for, and the second
fetch checks the generation.
- The fork test now covers:
- Reload keeping `main`;
- `latest` taking the fork;
- a fork rewritten away, giving `gone` with no reconcile marker.
- Dewey's four new mutants for this (reload drops its branch, latest keeps
the open branch, gone not reopened, gone reopened silently) are all
caught.
**N1: taken.**
- `newerUnread` is set when `newest` lands on the conversation already open.
The view then keeps the old `sessionId`, so `newer` stays and says the new
history isn't readable yet.
- Probe B, rerun: the marker survives the click and a later poll.
- Dewey's test 3 adds the second click once the new file has an entry, and
that click opens it.
**N2: taken.** A scan of `app.js` and both conversation test files finds no
raw C1, LRM, RLM, ALM, bidi override or isolate, U+2028, U+2029 or ESC
characters.
**N3: taken.**
- `UNSEEN` covers U+0080–U+009F, U+061C, U+200E, U+200F, U+202A–U+202E and
U+2066–U+2069, printed as `[U+XXXX]` and padded to four digits.
- The fixture asserts `[U+009B]31mCSI [U+200E]mark [U+202E]evil`, and the
page check rejects all four raw characters.
**Nit, no action needed.** Test 4's declaration lost a space
(`reply',{ timeout`).
**Screens.** I didn't regenerate revision 2's screens. Test 1 asserts the
new visible text at the pins, and that overflow check passed in my run.
**Verdict: approve** revision 2. These are the exact ten files:
| File | sha256 |
|---|---|
| `packages/webui/README.md` | `5a1a4de7…acc4` |
| `packages/webui/src/public/app.js` | `3c7f2f4c…0d6e` |
| `packages/webui/src/public/index.html` | `b972e2f7` (unchanged) |
| `packages/webui/src/public/live.css` | `8747b83d` (unchanged) |
| `packages/webui/src/serve.mjs` | `1187a98f` (unchanged) |
| `packages/webui/tests/serve.test.mjs` | `0477f66d` (unchanged) |
| `packages/webui/tests/conversation.test.mjs` | `52c2c663…a4a5` |
| `packages/webui/tests/history-fixture.mjs` | `b312c8a1` (unchanged) |
| `packages/webui/tests/history-return-flow.test.mjs` | `0918aeea` (unchanged) |
| `packages/control-board/tests/serve.test.mjs` | `105d87ec` (unchanged) |
**Still open, and not part of this verdict:**
- the brief §4 live check, after the commit and a WebUI restart;
- Sage recording the `newer` deviation from §2.3 item 6.
**Scratch.** I removed the probe file and verified the pins, then removed
the worktree `/tmp/fb-console-wt`. No commit or push.
+5
View File
@@ -403,3 +403,8 @@ are never rewritten or removed; corrections are new entries.
2026-09-26T21:42:17Z | Sage (T3 Claude Code, thread 1ef1e4f8) | #1509 row 25 live check | Jason's button approval passed (DEC-010 request 2, 21:38:58Z). Approver validation gap sent to the SetSpark lead (ac03938d), who does not own shared-signals; owner and DEC-009 go to Jason. Lead decision 19. 2026-09-26T21:42:17Z | Sage (T3 Claude Code, thread 1ef1e4f8) | #1509 row 25 live check | Jason's button approval passed (DEC-010 request 2, 21:38:58Z). Approver validation gap sent to the SetSpark lead (ac03938d), who does not own shared-signals; owner and DEC-009 go to Jason. Lead decision 19.
2026-09-26T21:54:38Z | Filbert (T3 Claude Code, thread 9cb9731e) | #1506 Gate F follow-up (notes 1-3) code review | Manifest 382f5bb0 (three pins, clean a4d38a3d worktree): approve. Ledger 51/51; rethrow mutation killed; CLI non-SQLite path prints generic message, exit 1. Live diagnostic 15th traced (shape only): content-engine agent header with spaces in Sage thread, counted human. Review 6fd693b6. Reported to Darkwing and Sage. No commit or push. 2026-09-26T21:54:38Z | Filbert (T3 Claude Code, thread 9cb9731e) | #1506 Gate F follow-up (notes 1-3) code review | Manifest 382f5bb0 (three pins, clean a4d38a3d worktree): approve. Ledger 51/51; rethrow mutation killed; CLI non-SQLite path prints generic message, exit 1. Live diagnostic 15th traced (shape only): content-engine agent header with spaces in Sage thread, counted human. Review 6fd693b6. Reported to Darkwing and Sage. No commit or push.
2026-09-26T21:57:51Z | Sage (T3 Claude Code, thread 1ef1e4f8) | #1506 Gate F follow-up commit | Darkwing notes 1-3 (manifest 382f5bb0, pins and patch verified), Filbert approve 6fd693b6. Ledger 51/51, eight suites green on the index. Header-role gap noted in DEFERRED. 2026-09-26T21:57:51Z | Sage (T3 Claude Code, thread 1ef1e4f8) | #1506 Gate F follow-up commit | Darkwing notes 1-3 (manifest 382f5bb0, pins and patch verified), Filbert approve 6fd693b6. Ledger 51/51, eight suites green on the index. Header-role gap noted in DEFERRED.
2026-09-26T22:05:18Z | Sage (T3 Claude Code, thread 1ef1e4f8) | pkill recovery | Claude sessions dropped 22:02:32Z. Darkwing (A1, two untracked files) and Dewey (Console mutation pass) resumed with state; Filbert idle, Rocko (codex) and SetSpark lead unaffected or self-recovered. Discord connector 954332 and board 1042473 still up.
2026-09-26T22:23:02Z | Filbert (T3 Claude Code, thread 9cb9731e) | CHAT-02 Console review, packet 24b046af (#1507 row 5) | changes requested: B1 Reload after reconcile silently switches branch (probe reproduced); N1–N3 nonblocking; 185/185, chat checks 0; review agents/filbert/work/chat-02-console-review-2026-09-26.md
2026-09-26T22:51:14Z | Filbert (T3 Claude Code, thread 9cb9731e) | CHAT-02 Console revision 2 delta re-review, packet d06de6a7 (#1507 row 5) | approved: B1 fixed (probe A rerun keeps branch), N1–N3 taken; 186/186 plus probes, chat checks 0; ten pins in review file
2026-09-26T22:51:39Z | Dewey (T3 Claude Code, thread 84fb346c) | CHAT-02 Console (#1507 row 5), revisions 1 and 2 | approved by Filbert (review 160dd68d): packet CONSOLE.md d06de6a7, ten pins; B1 Reload-branch fix and N1–N3 taken; 186/186 on overlay 3a209eea and shared tree, chat checks 0, mutants 17/17; uncommitted, handed to Sage for commit; live check waits on commit and WebUI restart
2026-09-26T23:05:11Z | Sage (T3 Claude Code, thread 1ef1e4f8) | #1507 CHAT-02 Console commit | Dewey r2 d06de6a7, Filbert approve 160dd68d; ten pins verified; 186/186 serial, eight suites and chat checks green on the index. §2.3 item 6 deviation (newer, not reconcile) recorded. WebUI restart and live check next.
+7
View File
@@ -154,6 +154,13 @@ at every gate. Started 2026-09-12 during the control board MVP.
Sage asked the SetSpark lead to use a one-word role. The rule stays as Sage asked the SetSpark lead to use a one-word role. The rule stays as
it is. (2026-09-26, #1506) it is. (2026-09-26, #1506)
- **A nested `node --test` passes even when its tests fail.** A child
`node --test` inherits NODE_TEST_CONTEXT from its parent and exits 0
whatever its results. Darkwing found it building queue A1 and fixed that
path with `env -u NODE_TEST_CONTEXT`, plus a test. Other suites that
start `node --test` from inside a test run have not been checked, so
they may be hiding failures. (2026-09-26, #1508)
## Queue ## Queue
Moved to `docs/plans/QUEUE.md` on 2026-09-13. This file holds only gaps. Moved to `docs/plans/QUEUE.md` on 2026-09-13. This file holds only gaps.
+10 -1
View File
@@ -1104,12 +1104,21 @@ test("conversation routes (F16): a foreign Host, a wrong port and a cross-origin
}); });
test("every refusal code the reader can raise has an HTTP status", () => { test("every refusal code the reader can raise has an HTTP status", () => {
const src = ["reader.mjs", "pi.mjs", "safe-fs.mjs"].map((f) => readFileSync(join(pkgRoot, "..", "conversation", "src", f), "utf8")).join("\n"); // Every source file in the reader package, so a refusal added in a new file is seen too.
const dir = join(pkgRoot, "..", "conversation", "src");
const src = readdirSync(dir).filter((f) => f.endsWith(".mjs")).map((f) => readFileSync(join(dir, f), "utf8")).join("\n");
const codes = new Set([...src.matchAll(/new Refusal\(\s*"([a-z-]+)"/g)].map((m) => m[1])); const codes = new Set([...src.matchAll(/new Refusal\(\s*"([a-z-]+)"/g)].map((m) => m[1]));
codes.add(UNSUPPORTED_HARNESS); // raised by value, as a root's unsupportedReason codes.add(UNSUPPORTED_HARNESS); // raised by value, as a root's unsupportedReason
assert.ok(codes.size >= 15, [...codes].join(" ")); assert.ok(codes.size >= 15, [...codes].join(" "));
assert.deepEqual([...codes].filter((c) => !(c in REFUSAL_STATUS)), []); assert.deepEqual([...codes].filter((c) => !(c in REFUSAL_STATUS)), []);
assert.deepEqual(Object.keys(REFUSAL_STATUS).filter((c) => !codes.has(c)), [], "no stale entries"); assert.deepEqual(Object.keys(REFUSAL_STATUS).filter((c) => !codes.has(c)), [], "no stale entries");
// The statuses themselves, so a changed value fails here even where no route test reaches it.
assert.deepEqual(REFUSAL_STATUS, {
"unknown-conversation": 404, "unknown-branch": 404, unavailable: 404,
"cursor-unknown": 409, "cursor-expired": 409, "cursor-foreign": 409, "source-replaced": 409, "incomplete-header": 409,
"unsafe-path": 403, "foreign-project": 403, unreadable: 403, "unknown-actor": 403,
"unsupported-harness": 422, "not-a-pi-session": 422, "too-large": 422, "unsupported-purpose": 422,
});
}); });
test("conversation routes: catalogue, first page, next page and follow over HTTP; refusals map to 4xx with their code; nothing is written", async () => { test("conversation routes: catalogue, first page, next page and follow over HTTP; refusals map to 4xx with their code; nothing is written", async () => {
+28 -5
View File
@@ -38,6 +38,19 @@ bytes. Do not expose either unauthenticated server through a public proxy.
with a warning. There are no automatic action retries. with a warning. There are no automatic action retries.
- Palette and appearance use the existing brand tokens and persist in this - Palette and appearance use the existing brand tokens and persist in this
browser when local storage is available. No settings screen is added. browser when local storage is available. No settings screen is added.
- History opens a read-only conversation view for a seat, from its Waiting
card, its table row or its inspector (#1507, CHAT-02). It shows the whole
branch with nothing clipped. Tool calls, tool results and thinking start
collapsed. Session text is always shown as text: Markdown stays as source,
and terminal controls, bidi controls and marks show as visible symbols. Reply in the view
uses the same board reply path as the inspector.
- The view checks for new entries on the same ten-second refresh, and Pause
stops it. It never switches on its own. A fork, a newer session for the seat
or a rewritten file each shows a marker with a button to open the other
history. Reloading after a rewrite keeps the branch the view was on; if that
branch is gone, the view opens the latest one and says so. Session lists
older sessions for the seat. Only repository Pi seats
have history; other harnesses say so.
Drafts and receipts stay in page memory, including across refresh, inspector Drafts and receipts stay in page memory, including across refresh, inspector
changes and a stale registration. Reloading or closing the page loses them. changes and a stale registration. Reloading or closing the page loses them.
@@ -47,11 +60,13 @@ delivery may be unknown: inspect the seat before sending again.
## Data and boundaries ## Data and boundaries
GET `/api/board` and POST `/api/seen` and `/api/reply` proxy only the existing GET `/api/board`, `/api/conversations` and `/api/conversation`, and POST
board paths. POST bytes and upstream status/JSON are preserved. GET `/api/config` `/api/seen` and `/api/reply` proxy only those board paths. Only the two
returns the configured board URL for the page's error message. No scanner, conversation routes carry their query string; the board validates it. POST
registration, session reader or transport is implemented here. No control-board, bytes and upstream status/JSON are preserved. GET `/api/config` returns the
seat, fleet, comms or root package files are changed. configured board URL for the page's error message. No scanner, registration,
session reader or transport is implemented here. The session reader is
`packages/conversation`, served by the board.
Console's shared CSS, Console CSS, brand.js and local Manrope fonts were copied Console's shared CSS, Console CSS, brand.js and local Manrope fonts were copied
unchanged from `agents/dewey/work/wui/`. Font license and source URLs accompany unchanged from `agents/dewey/work/wui/`. Font license and source URLs accompany
@@ -67,6 +82,7 @@ or mockup session hierarchy is presented as live data.
node --test packages/webui/tests/ node --test packages/webui/tests/
node --test packages/control-board/tests/ packages/seat/tests/ packages/ledger/tests/ packages/mosaic/tests/ node --test packages/control-board/tests/ packages/seat/tests/ packages/ledger/tests/ packages/mosaic/tests/
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/browser.test.mjs WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/browser.test.mjs
WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/conversation.test.mjs
``` ```
Node's test runner and installed `/usr/bin/chromium` are required. Set `CHROMIUM` Node's test runner and installed `/usr/bin/chromium` are required. Set `CHROMIUM`
@@ -83,6 +99,13 @@ on 330 rendered samples across ten palettes and three modes. Layouts are checked
at 320, 390, 768, 1440 and 2560px. Horizontal scrolling is intentional within the at 320, 390, 768, 1440 and 2560px. Horizontal scrolling is intentional within the
dense table; the page itself must not overflow. dense table; the page itself must not overflow.
Conversation tests run the real board routes over temporary repository-layout
session files. They cover full-length answers, collapsed tools and thinking,
hostile content rendered inert, malformed-line and reconcile markers, forks, and
the return flow: a send from the view, a tool call and a delayed result while a
draft is typed, a peer message, a 4.5-million-character answer split into
continuation parts, and a relaunch mid-turn.
No root CI workflow is configured for this package. These local tests are not a No root CI workflow is configured for this package. These local tests are not a
claim of CI, deployment, live-seat delivery or user acceptance. claim of CI, deployment, live-seat delivery or user acceptance.
+188 -13
View File
@@ -52,14 +52,15 @@
if (!['waiting', 'error'].includes(r.state) || !r.lastActivity) return ''; if (!['waiting', 'error'].includes(r.state) || !r.lastActivity) return '';
return `<button type="button" class="btn" data-seen="${esc(key(r))}" aria-label="${r.seen ? 'Unsee' : 'Mark seen'} ${esc(r.agent)}">${r.seen ? 'Unsee' : 'Seen'}</button>`; return `<button type="button" class="btn" data-seen="${esc(key(r))}" aria-label="${r.seen ? 'Unsee' : 'Mark seen'} ${esc(r.agent)}">${r.seen ? 'Unsee' : 'Seen'}</button>`;
} }
const historyButton = r => `<button type="button" class="btn conv-open" data-history="${esc(key(r))}" aria-label="History for ${esc(r.agent)}">History</button>`;
const openButton = r => `<button type="button" class="session-open" data-open="${esc(key(r))}" aria-controls="inspector" aria-expanded="${selected === key(r)}">${esc(r.agent)}</button>`; const openButton = r => `<button type="button" class="session-open" data-open="${esc(key(r))}" aria-controls="inspector" aria-expanded="${selected === key(r)}">${esc(r.agent)}</button>`;
function cards(rows) { function cards(rows) {
if (!rows.length) return '<p class="muted">Nothing here.</p>'; if (!rows.length) return '<p class="muted">Nothing here.</p>';
return `<ul class="waiting">${rows.map(r => `<li class="wait-item"><div class="wait-head">${openButton(r)} ${badge(r.state)}${connectorStatus(r)}</div><p class="small muted">${esc(r.project)} · ${esc(r.activeProject || 'unknown')}</p><p>${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</p><p class="preview">${esc(relaunchNotice(r) || r.lastError || r.lastAssistantText || 'No assistant text yet.')}</p><div class="actions"><span class="small muted">${esc(relaunchNotice(r) || (activity(r) ? `Last activity ${activity(r)}` : 'No activity'))}</span>${seenButton(r)}</div></li>`).join('')}</ul>`; return `<ul class="waiting">${rows.map(r => `<li class="wait-item"><div class="wait-head">${openButton(r)} ${badge(r.state)}${connectorStatus(r)}</div><p class="small muted">${esc(r.project)} · ${esc(r.activeProject || 'unknown')}</p><p>${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</p><p class="preview">${esc(relaunchNotice(r) || r.lastError || r.lastAssistantText || 'No assistant text yet.')}</p><div class="actions"><span class="small muted">${esc(relaunchNotice(r) || (activity(r) ? `Last activity ${activity(r)}` : 'No activity'))}</span><span>${historyButton(r)}${seenButton(r)}</span></div></li>`).join('')}</ul>`;
} }
function table(rows) { function table(rows) {
if (!rows.length) return '<p class="state">No sessions match these filters.</p>'; if (!rows.length) return '<p class="state">No sessions match these filters.</p>';
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Session table, scroll horizontally for all columns"><table class="sessions"><thead><tr>${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr data-row="${esc(key(r))}"><td>${openButton(r)}<span class="source">${esc(r.project)}</span></td><td>${badge(r.state)}${connectorStatus(r)}${r.seen ? '<span class="source">seen</span>' : ''}</td><td class="task">${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</td><td>${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}</td><td>${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}</td><td>${esc(r.model || 'unknown')}<span class="source">${esc(r.provider)}</span></td><td>${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}</td><td>${relaunchNotice(r) ? esc(relaunchNotice(r)) : activity(r) ? `${esc(activity(r))}${activity(r) === r.lastActivity ? '' : `<span class="source">${esc(r.lastActivity)}</span>`}` : 'unknown'}</td></tr>`).join('')}</tbody></table></div>`; return `<div class="table-wrap" tabindex="0" role="region" aria-label="Session table, scroll horizontally for all columns"><table class="sessions"><thead><tr>${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr data-row="${esc(key(r))}"><td>${openButton(r)}<span class="source">${esc(r.project)}</span>${historyButton(r)}</td><td>${badge(r.state)}${connectorStatus(r)}${r.seen ? '<span class="source">seen</span>' : ''}</td><td class="task">${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</td><td>${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}</td><td>${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}</td><td>${esc(r.model || 'unknown')}<span class="source">${esc(r.provider)}</span></td><td>${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}</td><td>${relaunchNotice(r) ? esc(relaunchNotice(r)) : activity(r) ? `${esc(activity(r))}${activity(r) === r.lastActivity ? '' : `<span class="source">${esc(r.lastActivity)}</span>`}` : 'unknown'}</td></tr>`).join('')}</tbody></table></div>`;
} }
function registered(r) { function registered(r) {
const reg = r.registered; const reg = r.registered;
@@ -77,16 +78,183 @@
if (r.relaunchedAt) fields.unshift(['Current activity', relaunchNotice(r)]); if (r.relaunchedAt) fields.unshift(['Current activity', relaunchNotice(r)]);
if (r.connector) fields.push(['Connector', `${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'}; owner ${r.connector.ownerState}`]); if (r.connector) fields.push(['Connector', `${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'}; owner ${r.connector.ownerState}`]);
const receipt = receipts.get(selected); const receipt = receipts.get(selected);
$('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}${pending(r) ? `<dt>Reply</dt><dd>Waiting for a reply to your message sent ${esc(awaiting.get(selected))}. The page checks every 10 seconds${paused ? ' once you resume' : ''}.</dd><dt>${r.relaunchedAt ? 'Historical assistant text' : 'Previous assistant text'}, before your message</dt>` : `<dt>${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}</dt>`}<dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>${r.relaunchedAt ? 'Historical last error' : 'Last error'}</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : `<p class="muted">${r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}</p>`}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`; $('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}${pending(r) ? `<dt>Reply</dt><dd>Waiting for a reply to your message sent ${esc(awaiting.get(selected))}. The page checks every 10 seconds${paused ? ' once you resume' : ''}.</dd><dt>${r.relaunchedAt ? 'Historical assistant text' : 'Previous assistant text'}, before your message</dt>` : `<dt>${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}</dt>`}<dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>${r.relaunchedAt ? 'Historical last error' : 'Last error'}</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${historyButton(r)}${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : `<p class="muted">${r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}</p>`}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
} }
// Conversation view (#1507, CHAT-02): the full read-only history from the board's
// /api/conversations and /api/conversation routes. Session content is set with
// textContent only, so it never creates an element, handler or navigation.
// Markdown shows as its source text. New entries arrive by polling a follow cursor.
let conv = null, convGen = 0;
const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f]/g, UNSEEN = /[\u0080-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
// C0 controls and DEL show as control pictures. C1 controls and bidi controls and marks have no picture
// and show as [U+XXXX]. Newlines and tabs stay.
const inert = v => String(v).replace(CONTROL, c => c === '\u007f' ? '␡' : String.fromCharCode(0x2400 + c.charCodeAt(0))).replace(UNSEEN, c => `[U+${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}]`);
const node = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = inert(text); return e; };
const ROLES = { user: 'User', assistant: 'Assistant', tool: 'Tool', notice: 'Notice', compaction: 'Compaction' };
// The CHAT-01 field that holds each block type's string. Fragments of one block concatenate in order.
const FIELDS = { text: 'text', thinking: 'text', 'tool-call': 'argumentsText', 'tool-result': 'text', compaction: 'summary' };
const convURL = params => '/api/conversation?' + new URLSearchParams(params);
async function getJSON(path) {
const res = await fetch(path, { cache: 'no-store' });
let body; try { body = await res.json(); } catch { body = { error: `HTTP ${res.status}` }; }
return { ok: res.ok, body };
}
function blockNode(b) {
if (b.type === 'text') return node('div', 'conv-text', b.value);
if (b.type === 'attachment') return node('p', 'small muted', 'Attachment not shown.');
if (!FIELDS[b.type]) return node('p', 'small muted', `A ${b.type} block is not shown.`);
const summary = b.type === 'thinking' ? (b.visibility === 'unavailable' ? 'Thinking (not available)' : 'Thinking')
: b.type === 'tool-call' ? `Tool call: ${b.name}` : b.type === 'tool-result' ? (b.isError ? 'Tool result (error)' : 'Tool result') : 'Earlier context was compacted. Summary';
const d = node('details', `conv-${b.type}`); d.append(node('summary', '', summary), node('pre', 'conv-pre', b.value));
return d;
}
function drawMessage(m) {
const li = node('li', `turn conv-turn conv-${m.role}`), time = node('time', '', m.createdAt);
time.dateTime = m.createdAt; li.append(node('span', 'who', ROLES[m.role] || m.role), time);
const open = m.el ? [...m.el.querySelectorAll('details')].map(d => d.open) : [];
let i = 0;
for (const b of m.blocks) if (b) { const n = blockNode(b); if (n.tagName === 'DETAILS') n.open = open[i++] ?? false; li.append(n); }
if (m.el) m.el.replaceWith(li); else $('conv-log').append(li);
m.el = li;
}
function addEntries(entries) {
const touched = new Set();
for (const e of entries) {
let m = conv.messages.get(e.message);
if (!m) { m = { role: e.role, createdAt: e.createdAt, blocks: [], el: null }; conv.messages.set(e.message, m); }
for (const c of e.content) { const b = m.blocks[c.block] ??= { ...c, value: '' }; if (FIELDS[c.type]) b.value += c[FIELDS[c.type]]; }
touched.add(m);
}
for (const m of touched) drawMessage(m);
return touched.size;
}
function drawMarkers() {
const box = $('conv-markers'); box.replaceChildren();
for (const [kind, m] of conv.markers) {
const d = node('div', 'notice notice-attn conv-marker'); d.dataset.marker = kind; d.append(node('p', '', m.text));
if (m.action) { const b = node('button', 'btn', m.action.label); b.type = 'button'; b.dataset.convAction = m.action.id; d.append(b); }
box.append(d);
}
}
function marker(kind, text, action) { if (conv.markers.get(kind)?.text === text) return; conv.markers.set(kind, { text, action }); drawMarkers(); announce(text); }
function unmark(kind) { if (conv.markers.delete(kind)) drawMarkers(); }
function convStatus(text) {
$('conv-status').textContent = text ?? `${conv.messages.size} messages · ${!conv.follow ? 'not checking for new entries' : paused ? 'checking for new entries paused' : 'checks for new entries every 10 seconds'}`;
}
function apply(body) {
conv.branch = body.page.branch;
const added = addEntries(body.page.entries);
const v = body.view;
if (v.incomplete) marker('incomplete', 'The last line of this session is still being written. It shows here once it is complete.'); else unmark('incomplete');
if (v.defaultBranch !== v.branch) marker('branch', 'This conversation continued on another branch. This view stays on the branch it opened.', { id: 'latest', label: 'Open the latest branch' });
return added;
}
// A refusal with reconcile keeps everything already shown and stops polling. Nothing switches silently.
function refused(res) {
const code = res.body.refusal?.code;
conv.follow = null;
if (res.body.refusal?.reconcile) marker('reconcile', `This view is out of date (${code}): ${res.body.error}. The history below is what was loaded before.`, { id: 'reload', label: 'Reload conversation' }), convStatus();
else convStatus(`History unavailable: ${res.body.error || 'unknown error'}${code ? ` (${code})` : ''}.`);
}
const nearEnd = () => $('conv-log').getBoundingClientRect().bottom <= innerHeight + 300;
// Reads pages from `res` until the snapshot ends. Returns false when the view changed or a page was refused.
async function readPages(c, gen, res, onPage) {
for (;;) {
if (gen !== convGen) return false;
if (!res.ok) { refused(res); return false; }
onPage(apply(res.body));
if (!res.body.cursor) { c.follow = res.body.follow?.id ?? null; return true; }
res = await getJSON(convURL({ id: c.id, branch: c.branch, cursor: res.body.cursor.id }));
}
}
function showConversation(on) {
$('conversation').hidden = !on; $('board-view').hidden = on;
document.body.classList.toggle('has-conversation', on);
}
function convForm() {
if (!conv) return;
const r = row(conv.row), can = !!r && !!canReply(r), receipt = receipts.get(conv.row);
$('conv-form').hidden = !can; $('conv-no-reply').hidden = can;
$('conv-no-reply').textContent = !r ? 'This session is no longer in the board scan.' : r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat';
if (r) $('conv-reply-label').textContent = `Reply to ${r.agent}`;
$('conv-send').disabled = sending.has(conv.row); $('conv-send').textContent = sending.has(conv.row) ? 'Sending…' : 'Send';
$('conv-receipt').hidden = !receipt;
if (receipt) { $('conv-receipt').className = `conv-receipt ${receipt.delivered ? '' : 'failed'}`; $('conv-receipt').textContent = receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`; }
// The board's newest file for this seat changed after the view opened: a relaunch. The view keeps its file.
if (conv.sessionId && r?.sessionId && r.sessionId !== conv.sessionId) marker('newer', conv.newerUnread ? 'A newer session started for this seat, but its history is not readable yet. This view stays on the session you opened.' : 'A newer session started for this seat. This view stays on the session you opened.', { id: 'newest', label: 'Open the newest session' });
}
// opts.branch reopens that branch (Reload). opts.from and opts.sessionId come from "Open the newest session".
async function openConversation(rowKey, pick = null, returnTo = conv?.returnFocus, opts = {}) {
const gen = ++convGen, r = row(rowKey), sameRow = conv?.row === rowKey;
conv = { row: rowKey, id: null, branch: null, follow: null, messages: new Map(), markers: new Map(), sessionId: null, loading: true, polling: false, returnFocus: returnTo };
selected = null; render(); showConversation(true);
$('conv-title').textContent = r ? `${r.agent} conversation` : 'Conversation';
$('conv-meta').textContent = ''; $('conv-log').replaceChildren(); drawMarkers(); convForm();
if (!sameRow) $('conv-reply').value = drafts.get(rowKey) || '';
convStatus('Loading history…');
const slash = rowKey.lastIndexOf('/'), project = r?.project ?? rowKey.slice(0, slash), agent = r?.agent ?? rowKey.slice(slash + 1);
const c = conv;
try {
const cat = await getJSON('/api/conversations');
if (gen !== convGen) return;
if (!cat.ok) { convStatus(`History unavailable: ${cat.body.error || 'the board refused the catalogue'}.`); return; }
const mine = cat.body.conversations.filter(x => x.project === project && x.seat === agent);
const readable = mine.filter(x => x.availability === 'available');
const choice = mine.find(x => x.conversation === pick) ?? readable[0] ?? mine[0];
$('conv-pick').replaceChildren(...mine.map(x => { const o = node('option', '', `${x.title || 'Untitled'} · ${x.lastActivityAt || x.conversationCreatedAt || 'no activity'}${x.availability === 'available' ? '' : ` (${x.unsupportedReason || x.refusal?.code || x.availability})`}`); o.value = x.conversation; return o; }));
$('conv-pick').disabled = mine.length < 2;
if (!choice) { convStatus('No readable history for this seat. Only repository Pi seats have history here.'); return; }
$('conv-pick').value = c.id = choice.conversation;
$('conv-meta').textContent = `${project} · ${choice.title || 'Untitled'} · started ${choice.conversationCreatedAt || 'unknown'}`;
if (choice.availability !== 'available') { convStatus(choice.unsupportedReason === 'unsupported-harness' ? `History for the ${choice.harness} harness is not available yet.` : `History unavailable (${choice.refusal?.code || choice.availability}).`); return; }
// Relaunch detection only makes sense for the newest session, the one the board row shows. If "newest"
// lands on the file already open, the board's newer file is not readable yet: keep the marker.
c.newerUnread = !!opts.from && opts.from === choice.conversation;
if (choice === readable[0]) c.sessionId = c.newerUnread ? opts.sessionId : r?.sessionId ?? null;
let first = await getJSON(convURL(opts.branch ? { id: c.id, branch: opts.branch } : { id: c.id }));
// The branch this view was on is gone: open the default branch and say so.
if (!first.ok && opts.branch && first.body.refusal?.code === 'unknown-branch') {
first = await getJSON(convURL({ id: c.id }));
if (gen !== convGen) return;
if (first.ok) marker('gone', 'The branch this view was on is no longer in the session. This view shows the latest branch.');
}
const done = await readPages(c, gen, first, () => convStatus(`Loading history… ${c.messages.size} messages`));
if (gen !== convGen) return;
if (done) convStatus();
$('conv-log').lastElementChild?.scrollIntoView({ block: 'end' });
convForm();
} catch (err) { if (gen === convGen) convStatus(`History unavailable: ${err.message}.`); }
finally { c.loading = false; }
}
async function pollConversation() {
const c = conv, gen = convGen;
if (!c || c.loading || c.polling || !c.follow) return;
c.polling = true;
try {
const atEnd = nearEnd();
let added = 0;
const done = await readPages(c, gen, await getJSON(convURL({ id: c.id, branch: c.branch, cursor: c.follow })), n => { added += n; });
if (gen !== convGen) return;
if (done) convStatus();
if (added && atEnd) $('conv-log').lastElementChild?.scrollIntoView({ block: 'end' });
} catch (err) { if (gen === convGen) convStatus(`Could not check for new entries: ${err.message}. The view keeps what it has; the next check tries again.`); }
finally { c.polling = false; }
}
function closeConversation() {
const back = conv?.returnFocus; convGen++; conv = null; showConversation(false); render();
if (back) restore(back);
if (document.activeElement === document.body) $('main').focus();
}
$('conv-back').onclick = closeConversation;
$('conv-pick').onchange = e => { if (conv) openConversation(conv.row, e.target.value); };
// Restore by data attribute equality, never interpolate API ids into selectors. // Restore by data attribute equality, never interpolate API ids into selectors.
function focusSnapshot() { function focusSnapshot() {
const a = document.activeElement; const a = document.activeElement;
return { id: a?.id, open: a?.dataset.open, seen: a?.dataset.seen, project: a?.dataset.project, container: a?.closest('[id]')?.id, start: a?.selectionStart, end: a?.selectionEnd }; return { id: a?.id, open: a?.dataset.open, seen: a?.dataset.seen, history: a?.dataset.history, project: a?.dataset.project, container: a?.closest('[id]')?.id, start: a?.selectionStart, end: a?.selectionEnd };
} }
function restore(f) { function restore(f) {
let el = f.id ? $(f.id) : null; let el = f.id ? $(f.id) : null;
for (const attr of ['open', 'seen', 'project']) if (f[attr] !== undefined) { for (const attr of ['open', 'seen', 'history', 'project']) if (f[attr] !== undefined) {
el = [...document.querySelectorAll(`[data-${attr}]`)].find(e => e.dataset[attr] === f[attr] && e.closest('[id]')?.id === f.container); el = [...document.querySelectorAll(`[data-${attr}]`)].find(e => e.dataset[attr] === f[attr] && e.closest('[id]')?.id === f.container);
} }
if (el && !el.closest('[hidden]')) { el.focus({ preventScroll: true }); if (f.start != null) el.setSelectionRange(f.start, f.end); } if (el && !el.closest('[hidden]')) { el.focus({ preventScroll: true }); if (f.start != null) el.setSelectionRange(f.start, f.end); }
@@ -107,7 +275,7 @@
$('sessions').innerHTML = table(visible); $('sessions').innerHTML = table(visible);
$('footer').textContent = Object.entries(data.counts || {}).map(([s, n]) => `${s} ${n}`).join(' · ') || 'No sessions'; $('footer').textContent = Object.entries(data.counts || {}).map(([s, n]) => `${s} ${n}`).join(' · ') || 'No sessions';
$('status').textContent = `Scanned ${data.generatedAt || 'unknown'} · ${paused ? 'auto-refresh paused' : 'refresh every 10s'}`; $('status').textContent = `Scanned ${data.generatedAt || 'unknown'} · ${paused ? 'auto-refresh paused' : 'refresh every 10s'}`;
inspect(); restore(focus); inspect(); convForm(); if (conv) convStatus(); restore(focus);
} }
async function api(path, value) { async function api(path, value) {
const res = await fetch(path, value === undefined ? { cache: 'no-store' } : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value) }); const res = await fetch(path, value === undefined ? { cache: 'no-store' } : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value) });
@@ -126,7 +294,7 @@
async function refresh() { async function refresh() {
if (busy) return; if (busy) return;
busy = true; $('refresh').disabled = true; busy = true; $('refresh').disabled = true;
try { accept(await api('/api/board')); } catch (err) { error(err); } try { try { accept(await api('/api/board')); } catch (err) { error(err); } await pollConversation(); }
finally { busy = false; $('refresh').disabled = false; schedule(); } finally { busy = false; $('refresh').disabled = false; schedule(); }
} }
$('refresh').onclick = refresh; $('refresh').onclick = refresh;
@@ -135,10 +303,15 @@
function close() { selected = null; render(); if (returnFocus) restore(returnFocus); } function close() { selected = null; render(); if (returnFocus) restore(returnFocus); }
$('close').onclick = close; $('close').onclick = close;
document.addEventListener('click', async e => { document.addEventListener('click', async e => {
const history = e.target.closest('[data-history]');
if (history) { openConversation(history.dataset.history, null, { history: history.dataset.history, container: history.closest('[id]')?.id }); $('conv-title').focus(); return; }
const action = e.target.closest('[data-conv-action]');
// Reload keeps the open branch; "Open the latest branch" takes the default; "Open the newest session" takes the newest file.
if (action && conv) { const c = conv, a = action.dataset.convAction; openConversation(c.row, a === 'newest' ? null : c.id, undefined, a === 'reload' ? { branch: c.branch } : a === 'newest' ? { from: c.id, sessionId: c.sessionId } : {}); $('conv-title').focus(); return; }
const open = e.target.closest('[data-open]'); const open = e.target.closest('[data-open]');
if (open) { returnFocus = focusSnapshot(); selected = open.dataset.open; render(); $('inspector-title').focus(); announce(`Inspecting ${row(selected)?.agent}`); return; } if (open) { if (conv) closeConversation(); returnFocus = focusSnapshot(); selected = open.dataset.open; render(); $('inspector-title').focus(); announce(`Inspecting ${row(selected)?.agent}`); return; }
const projectButton = e.target.closest('[data-project]'); const projectButton = e.target.closest('[data-project]');
if (projectButton) { project = projectButton.dataset.project || null; render(); return; } if (projectButton) { if (conv) closeConversation(); project = projectButton.dataset.project || null; render(); return; }
const seen = e.target.closest('[data-seen]'); const seen = e.target.closest('[data-seen]');
if (!seen || busy) return; if (!seen || busy) return;
const r = row(seen.dataset.seen); if (!r) return; const r = row(seen.dataset.seen); if (!r) return;
@@ -147,18 +320,20 @@
catch (err) { error(err); seen.disabled = false; } catch (err) { error(err); seen.disabled = false; }
finally { busy = false; schedule(); } finally { busy = false; schedule(); }
}); });
document.addEventListener('input', e => { if (e.target.id === 'reply') drafts.set(selected, e.target.value); }); document.addEventListener('input', e => { if (e.target.id === 'reply') drafts.set(selected, e.target.value); if (e.target.id === 'conv-reply' && conv) drafts.set(conv.row, e.target.value); });
document.addEventListener('submit', async e => { document.addEventListener('submit', async e => {
if (!e.target.matches('.reply-form')) return; if (!e.target.matches('.reply-form, #conv-form')) return;
e.preventDefault(); e.preventDefault();
const id = selected, text = $('reply').value; // The inspector form is rebuilt from drafts on render; the conversation form is not rebuilt, so its box is cleared here.
const inView = e.target.id === 'conv-form', id = inView ? conv?.row : selected, box = inView ? $('conv-reply') : $('reply'), text = box.value;
if (!id) return;
if (!text.trim() || sending.has(id)) return; if (!text.trim() || sending.has(id)) return;
drafts.set(id, text); sending.add(id); render(); drafts.set(id, text); sending.add(id); render();
try { try {
const result = await api('/api/reply', { agent: id, text }); receipts.set(id, result); const result = await api('/api/reply', { agent: id, text }); receipts.set(id, result);
if (result.delivered) awaiting.set(id, result.sentAt); if (result.delivered) awaiting.set(id, result.sentAt);
// Do not discard text typed while the request was in flight. // Do not discard text typed while the request was in flight.
if (result.delivered && drafts.get(id) === text) drafts.delete(id); if (result.delivered && drafts.get(id) === text) { drafts.delete(id); if (inView && box.value === text) box.value = ''; }
if (result.delivered && !paused) await refresh(); if (result.delivered && !paused) await refresh();
} catch (err) { receipts.set(id, { error: err.message + ' Delivery may be unknown; check the seat before sending again.' }); } } catch (err) { receipts.set(id, { error: err.message + ' Delivery may be unknown; check the seat before sending again.' }); }
finally { sending.delete(id); render(); announce(receipts.get(id)?.delivered ? 'Reply delivered' : 'Reply failed; draft kept'); } finally { sending.delete(id); render(); announce(receipts.get(id)?.delivered ? 'Reply delivered' : 'Reply failed; draft kept'); }
+5 -2
View File
@@ -11,9 +11,12 @@
<nav class="tree" aria-label="Projects"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></nav> <nav class="tree" aria-label="Projects"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></nav>
<main id="main" class="content" tabindex="-1"><div class="page-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div></div> <main id="main" class="content" tabindex="-1"><div class="page-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div></div>
<div id="error" class="state state-error" role="alert" hidden></div> <div id="error" class="state state-error" role="alert" hidden></div>
<section class="console-waiting" aria-labelledby="waiting-title"><h2 id="waiting-title">Waiting on you <span id="waiting-count" class="count">0</span></h2><div id="waiting"><p class="muted">Loading sessions…</p></div></section> <section id="conversation" class="conversation" aria-labelledby="conv-title" hidden><div class="page-head"><div><h2 id="conv-title" tabindex="-1">Conversation</h2><p id="conv-meta" class="small muted"></p></div><div class="conv-actions"><label>Session<select id="conv-pick"></select></label><button class="btn" id="conv-back" type="button">Back to board</button></div></div>
<div id="conv-markers"></div><p id="conv-status" class="small muted" role="status"></p><ol id="conv-log" class="turns conv-log" aria-label="Conversation history"></ol>
<form id="conv-form" class="conv-form" hidden><label for="conv-reply" id="conv-reply-label">Reply</label><textarea id="conv-reply" maxlength="2000" rows="3" placeholder="Message this seat"></textarea><button class="btn primary" type="submit" id="conv-send">Send</button><p id="conv-receipt" class="conv-receipt" role="status" hidden></p></form><p id="conv-no-reply" class="muted" hidden></p></section>
<div id="board-view"><section class="console-waiting" aria-labelledby="waiting-title"><h2 id="waiting-title">Waiting on you <span id="waiting-count" class="count">0</span></h2><div id="waiting"><p class="muted">Loading sessions…</p></div></section>
<details id="seen-section"><summary>Seen <span id="seen-count" class="count">0</span></summary><div id="seen"></div></details> <details id="seen-section"><summary>Seen <span id="seen-count" class="count">0</span></summary><div id="seen"></div></details>
<section aria-labelledby="sessions-title"><div class="page-head"><h2 id="sessions-title">All sessions <span id="session-count" class="count">0</span></h2><div class="filters"><label><input id="hide-offline" type="checkbox" checked>Hide offline</label><label><input id="hide-seen" type="checkbox" checked>Hide seen</label></div></div><p class="small muted">Select an agent to inspect. Arrow keys move between agents; Enter opens the inspector. Scroll the table for all columns.</p><div id="sessions"></div></section> <section aria-labelledby="sessions-title"><div class="page-head"><h2 id="sessions-title">All sessions <span id="session-count" class="count">0</span></h2><div class="filters"><label><input id="hide-offline" type="checkbox" checked>Hide offline</label><label><input id="hide-seen" type="checkbox" checked>Hide seen</label></div></div><p class="small muted">Select an agent to inspect. Arrow keys move between agents; Enter opens the inspector. Scroll the table for all columns.</p><div id="sessions"></div></section></div>
</main> </main>
<aside class="inspector" id="inspector" aria-labelledby="inspector-title" hidden><div class="inspector-head"><h2 id="inspector-title" tabindex="-1">Inspector</h2><button type="button" class="btn" id="close">Close</button></div><div id="inspection"></div></aside> <aside class="inspector" id="inspector" aria-labelledby="inspector-title" hidden><div class="inspector-head"><h2 id="inspector-title" tabindex="-1">Inspector</h2><button type="button" class="btn" id="close">Close</button></div><div id="inspection"></div></aside>
</div><footer class="foot"><span id="footer">Mosaic Stack</span><span id="board-url"></span></footer><div id="announce" class="sr-only" aria-live="polite"></div> </div><footer class="foot"><span id="footer">Mosaic Stack</span><span id="board-url"></span></footer><div id="announce" class="sr-only" aria-live="polite"></div>
+6 -1
View File
@@ -6,6 +6,11 @@
section{margin-bottom:20px}#seen-section{margin-bottom:20px}summary{cursor:pointer}#seen{margin-top:10px} section{margin-bottom:20px}#seen-section{margin-bottom:20px}summary{cursor:pointer}#seen{margin-top:10px}
.table-wrap{background:var(--surface);border:1px solid var(--line);border-radius:var(--r)}table.sessions{min-width:1050px}table.sessions td{max-width:270px;overflow-wrap:anywhere}table.sessions .task{min-width:190px}.source{display:block;color:var(--muted);font-size:.75rem}.session-open{font:inherit;font-weight:600;background:none;border:0;color:var(--action);padding:3px;cursor:pointer;text-align:left;overflow-wrap:anywhere} .table-wrap{background:var(--surface);border:1px solid var(--line);border-radius:var(--r)}table.sessions{min-width:1050px}table.sessions td{max-width:270px;overflow-wrap:anywhere}table.sessions .task{min-width:190px}.source{display:block;color:var(--muted);font-size:.75rem}.session-open{font:inherit;font-weight:600;background:none;border:0;color:var(--action);padding:3px;cursor:pointer;text-align:left;overflow-wrap:anywhere}
.waiting{grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr))}.wait-item .session-open{font-size:1rem}.wait-item p{margin-top:6px}.wait-item .actions{justify-content:space-between}.wait-item .preview{white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden} .waiting{grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr))}.wait-item .session-open{font-size:1rem}.wait-item p{margin-top:6px}.wait-item .actions{justify-content:space-between}.wait-item .preview{white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.inspector .kv{grid-template-columns:1fr}.inspector .kv dd{margin-bottom:10px}.last-text,.receipt{white-space:pre-wrap;overflow-wrap:anywhere}.last-text{font:inherit;font-size:.9rem;margin:0}.receipt{font-size:.85rem;margin-top:8px}.receipt.failed{color:var(--danger)}.reply-form{display:grid;gap:8px;margin-top:14px}.reply-form .btn{justify-self:start}.inspector-head{flex-wrap:wrap}#inspection{min-width:0}.badge{white-space:nowrap}.foot>*{min-width:0;overflow-wrap:anywhere} .inspector .kv{grid-template-columns:1fr}.inspector .kv dd{margin-bottom:10px}.last-text,.receipt,.conv-receipt{white-space:pre-wrap;overflow-wrap:anywhere}.last-text{font:inherit;font-size:.9rem;margin:0}.receipt,.conv-receipt{font-size:.85rem;margin-top:8px}.receipt.failed,.conv-receipt.failed{color:var(--danger)}.reply-form,.conv-form{display:grid;gap:8px;margin-top:14px}.reply-form .btn,.conv-form .btn{justify-self:start}.inspector-head{flex-wrap:wrap}#inspection{min-width:0}.badge{white-space:nowrap}.foot>*{min-width:0;overflow-wrap:anywhere}
@media(max-width:959px){.tree #projects{flex-basis:100%;width:100%}.tree-list{display:flex;flex-wrap:wrap}.tree-list li{max-width:100%}.tree .small{flex-basis:100%;margin:0}.inspector{max-height:75vh}.cmdbar{position:static}} @media(max-width:959px){.tree #projects{flex-basis:100%;width:100%}.tree-list{display:flex;flex-wrap:wrap}.tree-list li{max-width:100%}.tree .small{flex-basis:100%;margin:0}.inspector{max-height:75vh}.cmdbar{position:static}}
@media(max-width:479px){.cmd-right{margin-left:0;gap:6px}.cmd-right label{flex:1 1 110px}.cmd-right select{max-width:100%;width:100%}.kv{grid-template-columns:1fr}.content{padding:12px}.tree{padding:12px}.page-head>h2{flex-basis:100%}} @media(max-width:479px){.cmd-right{margin-left:0;gap:6px}.cmd-right label{flex:1 1 110px}.cmd-right select{max-width:100%;width:100%}.kv{grid-template-columns:1fr}.content{padding:12px}.tree{padding:12px}.page-head>h2{flex-basis:100%}}
/* Conversation view (#1507, CHAT-02). Text wraps; nothing in it scrolls the page sideways. */
.conversation{min-width:0}.conversation .page-head{flex-wrap:wrap;gap:10px}.conversation .page-head>div:first-child{min-width:0;overflow-wrap:anywhere}.conv-actions{display:flex;gap:8px;align-items:end;flex-wrap:wrap;max-width:100%}.conv-actions label{display:grid;gap:2px;font-size:.75rem;min-width:0;max-width:100%}.conv-actions select{max-width:min(100%,360px);min-height:36px}
.conv-log .turn{max-width:100%;min-width:0}.conv-turn time{display:block;font-size:.72rem;color:var(--muted);margin-bottom:4px}.conv-user{border-left:3px solid var(--action)}.conv-notice,.conv-compaction{background:none;border:1px dashed var(--border)}
.conv-text,.conv-pre{white-space:pre-wrap;overflow-wrap:anywhere;margin:0}.conv-pre{font-family:var(--mono);font-size:.82rem;max-height:60vh;overflow:auto;margin-top:6px}.conv-turn details{margin-top:6px}.conv-turn summary{font-size:.85rem;color:var(--muted);cursor:pointer}
.conv-marker{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.conv-marker p{margin:0;flex:1 1 220px;overflow-wrap:anywhere}.conv-open{margin-left:6px;padding:2px 8px;min-height:0;font-size:.8rem}#conv-form{max-width:72ch}
+6 -4
View File
@@ -49,15 +49,17 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
res.setHeader('x-content-type-options', 'nosniff'); res.setHeader('x-content-type-options', 'nosniff');
res.setHeader('referrer-policy', 'no-referrer'); res.setHeader('referrer-policy', 'no-referrer');
res.setHeader('content-security-policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"); res.setHeader('content-security-policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
let path; let path, search;
try { try {
const authority = new URL(`http://${req.headers.host}`); const authority = new URL(`http://${req.headers.host}`);
if (!isLoopback(authority.hostname.replace(/^\[|\]$/g, '')) || Number(authority.port || 80) !== server.address().port) return json(res, 403, { error: 'non-local Host refused' }); if (!isLoopback(authority.hostname.replace(/^\[|\]$/g, '')) || Number(authority.port || 80) !== server.address().port) return json(res, 403, { error: 'non-local Host refused' });
path = new URL(req.url, 'http://localhost').pathname; ({ pathname: path, search } = new URL(req.url, 'http://localhost'));
} catch { return json(res, 400, { error: 'invalid URL' }); } } catch { return json(res, 400, { error: 'invalid URL' }); }
// No CORS. JSON content type and same-origin checks keep browser forms out. // No CORS. JSON content type and same-origin checks keep browser forms out.
if (req.headers.origin && req.headers.origin !== `http://${req.headers.host}`) return json(res, 403, { error: 'cross-origin request refused' }); if (req.headers.origin && req.headers.origin !== `http://${req.headers.host}`) return json(res, 403, { error: 'cross-origin request refused' });
const allowed = path === '/api/board' ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null; // The conversation routes (#1507, CHAT-02) keep their query; the board validates it.
const conversation = path === '/api/conversations' || path === '/api/conversation';
const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null;
if (allowed) { if (allowed) {
if (req.method !== allowed) return json(res, 405, { error: 'method not allowed' }); if (req.method !== allowed) return json(res, 405, { error: 'method not allowed' });
let bytes; let bytes;
@@ -65,7 +67,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
try { bytes = await body(req); } catch (err) { return json(res, 400, { error: err.message }); } try { bytes = await body(req); } catch (err) { return json(res, 400, { error: err.message }); }
} }
try { try {
const response = await fetch(upstream + path, { const response = await fetch(upstream + path + (conversation ? search : ''), {
method: allowed, headers: bytes ? { 'content-type': 'application/json' } : {}, body: bytes, method: allowed, headers: bytes ? { 'content-type': 'application/json' } : {}, body: bytes,
redirect: 'error', signal: AbortSignal.timeout(timeout), redirect: 'error', signal: AbortSignal.timeout(timeout),
}); });
+195
View File
@@ -0,0 +1,195 @@
// Conversation view (#1507, CHAT-02 brief §2.2): full branch history through
// the real board routes and the real WebUI. The hostile-render fixture (R1)
// must stay inert text: no element, handler or navigation from session content.
// WEBUI_EVIDENCE=<dir> also saves full-page screenshots at 320 and 1440 in the
// light and dark modes.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { browser } from './browser.mjs';
import { historyFixture, session, user, assistant, toolResult, at, waiter, turns } from './history-fixture.mjs';
const HOSTILE = '<script>window.injected=1</script><img src=x onerror="window.injected=2"> [click](javascript:window.injected=3) <a href="javascript:window.injected=4">link</a> \u001b[31mRED\u001b[0m \u001b]8;;http://example.invalid\u0007osc\u001b]8;;\u0007 \u009b31mCSI \u200emark \u202eevil';
const LONG = 'This answer is longer than the board summary. '.repeat(40) + 'LONG_END';
test('conversation view: full history, collapsed tools, hidden thinking, inert hostile content, malformed and reconcile markers', { timeout: 120000 }, async () => {
const f = await historyFixture();
let b;
try {
const file = join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl');
const log = session(file, f.projectRoot);
log.add(user('Show me the file'), at(-300));
log.add(assistant([{ type: 'thinking', thinking: 'SECRET_THOUGHT' }, { type: 'text', text: 'Reading it.' }, { type: 'toolCall', id: 'call_1|fc_2', name: 'read', arguments: { path: 'README.md' } }], 'toolUse'), at(-290));
log.add(toolResult('call_1|fc_2', HOSTILE), at(-280));
log.raw('{"type":"message","id":');
log.add(assistant('Here: ' + HOSTILE), at(-270));
log.add(user('And the summary?'), at(-260));
log.add(assistant(LONG), at(-250));
b = await browser(); await b.viewport(1440, 1000);
const wait = waiter(b);
await b.navigate(f.base); await wait('document.querySelector("table.sessions [data-history]")');
const href = await b.evaluate('location.href');
await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-status").textContent.startsWith("7 messages")');
assert.equal(await b.evaluate('document.querySelector("#board-view").hidden'), true);
assert.equal(await b.evaluate('document.activeElement.id'), 'conv-title');
assert.match(await b.evaluate('document.querySelector("#conv-meta").textContent'), /^repo · Show me the file · started /);
assert.deepEqual((await turns(b)).map(t => t[0]), ['User', 'Assistant', 'Tool', 'Notice', 'Assistant', 'User', 'Assistant']);
assert.match(await b.evaluate('document.querySelectorAll("#conv-log > li")[3].textContent'), /Line 5 could not be read/);
// The long answer in full: the board's 240-character summary is not used.
assert.equal(await b.evaluate('[...document.querySelectorAll("#conv-log .conv-text")].at(-1).textContent'), LONG);
// Thinking and tools collapsed; their contents are not rendered visible.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#conv-log details")].map(d => [d.className, d.querySelector("summary").textContent, d.open, d.querySelector("pre").checkVisibility()])'), [
['conv-thinking', 'Thinking', false, false],
['conv-tool-call', 'Tool call: read', false, false],
['conv-tool-result', 'Tool result', false, false],
]);
assert.equal(await b.evaluate('document.querySelector(".conv-tool-call pre").textContent'), '{"path":"README.md"}');
// Hostile content: shown as text, controls made visible, nothing active created.
await b.evaluate('document.querySelectorAll("#conv-log details").forEach(d => d.open = true)');
const shown = await b.evaluate('[...document.querySelectorAll("#conv-log .conv-text")].find(e => e.textContent.startsWith("Here: ")).textContent');
assert.ok(shown.startsWith('Here: <script>window.injected=1</script><img src=x onerror="window.injected=2"> [click](javascript:window.injected=3) <a href="javascript:window.injected=4">link</a> ␛[31mRED'), shown);
assert.ok(shown.includes('␛]8;;http://example.invalid␇osc') && shown.endsWith('[U+009B]31mCSI [U+200E]mark [U+202E]evil'), shown);
assert.equal(await b.evaluate('document.querySelector(".conv-tool-result pre").textContent'), shown.slice(6));
assert.equal(await b.evaluate('document.querySelectorAll("#conversation script, #conversation img, #conversation a, #conversation iframe, #conversation object, #conversation embed, #conversation svg, #conversation style, #conversation link").length'), 0);
assert.equal(await b.evaluate('[...document.querySelectorAll("*")].some(e => [...e.attributes].some(a => a.name.startsWith("on")))'), false);
assert.equal(await b.evaluate('/[\\u001b\\u009b\\u200e\\u202e]/.test(document.body.textContent)'), false);
await b.evaluate('[...document.querySelectorAll("#conv-log .conv-text")].find(e => e.textContent.startsWith("Here: ")).click()');
assert.equal(await b.evaluate('typeof window.injected'), 'undefined');
assert.equal(await b.evaluate('location.href'), href);
// A same-inode rewrite of the history: the next check refuses, the view keeps what it showed.
writeFileSync(file, readFileSync(file, 'utf8').replace('Show me the file', 'Show me the FILE'));
await b.evaluate('document.querySelector("#refresh").click()');
await wait('document.querySelector("[data-marker=reconcile]")');
assert.match(await b.evaluate('document.querySelector("[data-marker=reconcile]").textContent'), /out of date \(source-replaced\).*loaded before\.Reload conversation$/);
assert.equal(await b.evaluate('document.querySelectorAll("#conv-log > li").length'), 7);
assert.match(await b.evaluate('document.querySelector("#conv-log").textContent'), /Show me the file/);
assert.match(await b.evaluate('document.querySelector("#conv-status").textContent'), /not checking for new entries/);
// Evidence shows thinking as a reader first sees it (closed), from the top of the page.
await b.evaluate('document.querySelector(".conv-thinking").open = false; scrollTo(0, 0)');
for (const width of [320, 1440]) for (const mode of ['light', 'dark']) {
await b.viewport(width, 1000);
await b.evaluate(`(() => { const m = document.querySelector("#mode"); m.value = ${JSON.stringify(mode)}; m.dispatchEvent(new Event("change")); })()`);
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true, `no horizontal overflow at ${width} ${mode}`);
if (process.env.WEBUI_EVIDENCE) {
const { data } = await b.call('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
writeFileSync(join(process.env.WEBUI_EVIDENCE, `conversation-${width}-${mode}.png`), Buffer.from(data, 'base64'));
}
}
// Reload takes a fresh snapshot of the rewritten file; Back returns focus to the History button.
await b.evaluate('document.querySelector("[data-conv-action=reload]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("Show me the FILE")');
assert.equal(await b.evaluate('document.querySelector("[data-marker=reconcile]")'), null);
await b.evaluate('document.querySelector("#conv-back").click()');
assert.equal(await b.evaluate('document.querySelector("#conversation").hidden'), true);
assert.equal(await b.evaluate('document.activeElement.dataset.history'), 'repo/fixture');
// The inspector opens the same view.
await b.evaluate('document.querySelector("table.sessions [data-open]").click()');
await wait('document.querySelector("#inspection [data-history]")');
await b.evaluate('document.querySelector("#inspection [data-history]").click()');
await wait('!document.querySelector("#conversation").hidden && document.querySelector("#conv-status").textContent.startsWith("7 messages")');
assert.match(await b.evaluate('document.querySelector("#conv-log").textContent'), /Show me the FILE/);
} finally { if (b) await b.close(); await f.close(); }
});
test('conversation view: a fork keeps the open branch, says so, and opens the new one on request', { timeout: 60000 }, async () => {
const f = await historyFixture();
let b;
try {
const file = join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl');
const log = session(file, f.projectRoot);
const first = log.add(user('Question'), at(-120));
log.add(assistant('MAIN_ANSWER'), at(-110));
b = await browser(); await b.viewport(1440, 1000);
const wait = waiter(b);
await b.navigate(f.base); await wait('document.querySelector("table.sessions [data-history]")');
await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-status").textContent.startsWith("2 messages")');
// Pi forks from the first entry; the fork is now its default leaf, on branch b.fork-1.
log.raw(JSON.stringify({ type: 'message', id: 'fork-1', parentId: first, timestamp: at(0), message: assistant('FORK_ANSWER') }));
await b.evaluate('document.querySelector("#refresh").click()');
await wait('document.querySelector("[data-marker=branch]")');
assert.match(await b.evaluate('document.querySelector("[data-marker=branch]").textContent'), /continued on another branch\. This view stays on the branch it opened\.Open the latest branch$/);
const text = await b.evaluate('document.querySelector("#conv-log").textContent');
assert.ok(text.includes('MAIN_ANSWER') && !text.includes('FORK_ANSWER'), 'no silent switch');
// The open branch grows; the next poll still follows it, not the default branch.
log.add(user('MAIN_MORE'), at(0));
await b.evaluate('document.querySelector("#refresh").click()');
await wait('document.querySelector("#conv-log").textContent.includes("MAIN_MORE") || !!document.querySelector("[data-marker=reconcile]") || document.querySelector("#conv-status").textContent.includes("unavailable")');
assert.deepEqual(await turns(b), [['User', 'Question'], ['Assistant', 'MAIN_ANSWER'], ['User', 'MAIN_MORE']]);
assert.equal(await b.evaluate('document.querySelector("[data-marker=reconcile]")'), null);
// The fork becomes the default leaf again. A same-inode rewrite then refuses the next check.
log.raw(JSON.stringify({ type: 'message', id: 'fork-2', parentId: 'fork-1', timestamp: at(0), message: user('FORK_MORE') }));
writeFileSync(file, readFileSync(file, 'utf8').replace('"Question"', '"QUESTION"'));
await b.evaluate('document.querySelector("#refresh").click()');
await wait('document.querySelector("[data-marker=reconcile]")');
// Reload stays on the branch the view was on, and still says the conversation went elsewhere.
await b.evaluate('document.querySelector("[data-conv-action=reload]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("QUESTION") && !document.querySelector("[data-marker=reconcile]")');
await wait('document.querySelector("[data-marker=branch]")');
assert.deepEqual(await turns(b), [['User', 'QUESTION'], ['Assistant', 'MAIN_ANSWER'], ['User', 'MAIN_MORE']]);
// "Open the latest branch" takes the default.
await b.evaluate('document.querySelector("[data-conv-action=latest]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("FORK_MORE")');
assert.deepEqual(await turns(b), [['User', 'QUESTION'], ['Assistant', 'FORK_ANSWER'], ['User', 'FORK_MORE']]);
assert.equal(await b.evaluate('document.querySelector("[data-marker=branch]")'), null);
// The fork is rewritten away. Reload cannot keep a branch that is gone, so it opens the default and says so.
writeFileSync(file, readFileSync(file, 'utf8').split('\n').filter(l => !l.includes('"fork-')).join('\n'));
await b.evaluate('document.querySelector("#refresh").click()');
await wait('document.querySelector("[data-marker=reconcile]")');
await b.evaluate('document.querySelector("[data-conv-action=reload]").click()');
await wait('document.querySelector("[data-marker=gone]")');
assert.match(await b.evaluate('document.querySelector("[data-marker=gone]").textContent'), /^The branch this view was on is no longer in the session\. This view shows the latest branch\.$/);
assert.deepEqual(await turns(b), [['User', 'QUESTION'], ['Assistant', 'MAIN_ANSWER'], ['User', 'MAIN_MORE']]);
assert.equal(await b.evaluate('document.querySelector("[data-marker=reconcile]")'), null);
} finally { if (b) await b.close(); await f.close(); }
});
test('conversation view: a newer session with no readable history keeps the marker', { timeout: 60000 }, async () => {
const f = await historyFixture();
let b;
try {
const log = session(join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl'), f.projectRoot);
log.add(user('Question'), at(-120));
log.add(assistant('OLD_ANSWER'), at(-110));
b = await browser(); await b.viewport(1440, 1000);
const wait = waiter(b);
await b.navigate(f.base); await wait('document.querySelector("table.sessions [data-history]")');
await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-status").textContent.startsWith("2 messages")');
// The board takes the newest file by mtime; the catalogue orders by last entry, so a header-only file sorts last.
const log2 = session(join(f.sessionsDir, '2026-09-26T11-00-00_s2.jsonl'), f.projectRoot, { id: 'sess-2', timestamp: at(0) });
await b.evaluate('document.querySelector("#refresh").click()');
await wait('document.querySelector("[data-marker=newer]")');
await b.evaluate('document.querySelector("[data-conv-action=newest]").click()');
await wait('/not readable yet/.test(document.querySelector("[data-marker=newer]")?.textContent)');
assert.match(await b.evaluate('document.querySelector("#conv-log").textContent'), /OLD_ANSWER/);
// Once the new file has entries, the same button opens it.
log2.add(user('NEW_QUESTION'), at(1));
await b.evaluate('document.querySelector("[data-conv-action=newest]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("NEW_QUESTION")');
assert.equal(await b.evaluate('document.querySelector("[data-marker=newer]")'), null);
} finally { if (b) await b.close(); await f.close(); }
});
test('conversation view: seats without history say so and offer no reply',{ timeout: 60000 }, async () => {
const f = await historyFixture();
let b;
try {
// No session file yet: the board still lists the registered seat.
b = await browser(); await b.viewport(390, 900);
const wait = waiter(b);
await b.navigate(f.base); await wait('document.querySelector("table.sessions [data-history]")');
await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
await wait('document.querySelector("#conv-status").textContent.includes("No readable history")');
assert.equal(await b.evaluate('document.querySelectorAll("#conv-log > li").length'), 0);
assert.equal(await b.evaluate('document.querySelector("#conv-pick").disabled'), true);
} finally { if (b) await b.close(); await f.close(); }
});
+51
View File
@@ -0,0 +1,51 @@
// Repository-layout fixture for the conversation view (#1507, CHAT-02). The
// board's history reader reads only <projectRoot>/.pi/state/<seat>/sessions,
// with the project named after the root directory and a Pi header whose cwd is
// inside the project. Real board, real routes, real WebUI; temporary data only.
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
import { close } from './fixture.mjs';
export const at = s => new Date(Date.now() + s * 1000).toISOString();
// Appends Pi entries to one session file, each the child of the one before.
export function session(file, cwd, { id = 'sess-1', timestamp = at(-600) } = {}) {
let n = 0, parent = null;
writeFileSync(file, JSON.stringify({ type: 'session', version: 3, id, timestamp, cwd }) + '\n');
return {
add(message, timestamp = at(0)) {
const entry = `${id}-e${++n}`;
appendFileSync(file, JSON.stringify({ type: 'message', id: entry, parentId: parent, timestamp, message }) + '\n');
parent = entry;
return entry;
},
raw(text) { appendFileSync(file, text + '\n'); },
};
}
export const user = text => ({ role: 'user', content: [{ type: 'text', text }] });
export const assistant = (content, stopReason = 'stop') => ({ role: 'assistant', stopReason, content: typeof content === 'string' ? [{ type: 'text', text: content }] : content });
export const toolResult = (toolCallId, text, isError = false) => ({ role: 'toolResult', toolCallId, toolName: 'read', content: [{ type: 'text', text }], isError });
export async function historyFixture({ seat = 'fixture', project = 'repo' } = {}) {
const root = mkdtempSync(join(tmpdir(), 'webui-history-'));
const projectRoot = join(root, project), sessionsDir = join(projectRoot, '.pi', 'state', seat, 'sessions');
mkdirSync(sessionsDir, { recursive: true });
const seatsDir = join(root, 'seats'), captures = [];
writeRegistration(seatsDir, makeRegistration({ resolved: { seat, project, sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: projectRoot }, task: 'Fixed task', tmux: { session: seat, socket: null }, pid: process.pid, now: () => new Date(Date.now() - 3600000) }));
const board = await startBoard({ port: 0, specs: [{ agent: seat, project, sessionsDir, tmux: { session: seat } }], boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true,
exec: (f, args) => { captures.push(args); return { status: 0, stdout: '', stderr: '' }; } });
const web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` });
return { root, projectRoot, sessionsDir, board, web, base: `http://127.0.0.1:${web.address().port}`, captures,
async close() { await close(web); await close(board); rmSync(root, { recursive: true, force: true }); },
};
}
// Browser helpers shared by the history tests.
export function waiter(b) {
return (expression, ms = 25000) => b.evaluate(`(async()=>{for(const end=Date.now()+${ms};Date.now()<end;){if(${expression})return true;await new Promise(r=>setTimeout(r,100))}throw Error('timeout: '+${JSON.stringify(expression)})})()`);
}
export const turns = b => b.evaluate('[...document.querySelectorAll("#conv-log > li")].map(li => [li.querySelector(".who").textContent, li.querySelector(".conv-text")?.textContent ?? [...li.querySelectorAll("summary")].map(s => s.textContent).join(" | ")])');
@@ -0,0 +1,99 @@
// CHAT-02 return-flow regression (#1507, brief §2.3). Real board, real
// conversation routes, real WebUI; nothing is injected into the page and the
// Refresh button is never used, so every new entry arrives by polling.
// 1 path, 2 exactness, 3 continuation, 4 thread order, 5 interleaving,
// 6 relaunch mid-turn, 7 a delayed tool result with a draft being typed.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { browser } from './browser.mjs';
import { historyFixture, session, user, assistant, toolResult, at, waiter, turns } from './history-fixture.mjs';
const PEER = '[from: darkwing (7525c8e4) -> to: fixture (00000000)] peer note that lands mid-turn';
const ANSWER = 'A'.repeat(250) + 'MID_SENTINEL' + ' and the rest of the answer'.repeat(20) + ' END_SENTINEL';
const BIG = 'BIG_START ' + Array.from({ length: 900000 }, (_, i) => (i % 1679616).toString(36).padStart(4, '0')).join('-') + ' BIG_END';
test('return flow through the conversation view: send, tool call, delayed result, peer message, exact long answers, relaunch', { timeout: 240000 }, async () => {
const f = await historyFixture();
let b;
try {
const file = join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl');
const log = session(file, f.projectRoot);
log.add(user('Start'), at(-120));
log.add(assistant('Input needed: OLD_ANSWER'), at(-90));
b = await browser(); await b.viewport(1440, 1000);
const wait = waiter(b);
const draft = () => b.evaluate('(() => { const t = document.querySelector("#conv-reply"); return [t.value, t.selectionStart, document.activeElement === t]; })()');
// The seat is waiting on Jason, so the view opens from its card.
await b.navigate(f.base); await wait('document.querySelector("#waiting [data-history]")');
await b.evaluate('document.querySelector("#waiting [data-history]").click()');
await wait('document.querySelector("#conv-status").textContent.startsWith("2 messages")');
const opened = await b.evaluate('document.querySelector("#conv-pick").value');
// 1. Send from the conversation view, through the board's reply path.
await b.evaluate('(() => { const t = document.querySelector("#conv-reply"); t.value = "REPLY_FROM_JASON"; t.dispatchEvent(new Event("input", { bubbles: true })); document.querySelector("#conv-form").requestSubmit(); })()');
await wait('document.querySelector("#conv-receipt").textContent.includes("delivered")');
const sent = f.captures.at(-1).at(-1);
assert.match(sent, /^REPLY_FROM_JASON\n/);
assert.equal(await b.evaluate('document.querySelector("#conv-reply").value'), '', 'delivered draft cleared');
// The seat takes the message and calls a tool. No answer yet.
log.add(user(sent), at(0));
log.add(assistant([{ type: 'toolCall', id: 'call-1', name: 'read', arguments: { path: 'x' } }], 'toolUse'), at(1));
await wait('document.querySelector("#conv-log .conv-tool-call")');
// 7. A draft typed while the seat works. The tool result lands only after that poll.
await b.evaluate('(() => { const t = document.querySelector("#conv-reply"); t.focus(); t.value = "NEXT_DRAFT"; t.setSelectionRange(4, 4); t.dispatchEvent(new Event("input", { bubbles: true })); })()');
log.add(toolResult('call-1', 'file body'), at(2));
await wait('document.querySelector("#conv-log .conv-tool-result")');
assert.deepEqual(await draft(), ['NEXT_DRAFT', 4, true], 'draft and caret survive the poll');
// 5. A peer agent-send lands before the final answer. 2. The answer has sentinels past 240 and at the end.
log.add(user(PEER), at(3));
log.add(assistant(ANSWER), at(4));
await wait('document.querySelector("#conv-log").textContent.includes("END_SENTINEL")');
// 4. Thread order: sent message, collapsed tool call and result, peer message, answer.
assert.deepEqual(await turns(b), [
['User', 'Start'], ['Assistant', 'Input needed: OLD_ANSWER'], ['User', sent], ['Assistant', 'Tool call: read'],
['Tool', 'Tool result'], ['User', PEER], ['Assistant', ANSWER],
]);
const text = await b.evaluate('document.querySelector("#conv-log").textContent');
assert.equal(text.split('MID_SENTINEL').length, 2, 'answer shown once');
assert.equal(text.includes('…'), false, 'nothing clipped');
assert.equal(await b.evaluate('document.querySelectorAll("#conv-log details[open]").length'), 0, 'tools stay collapsed');
assert.deepEqual(await draft(), ['NEXT_DRAFT', 4, true]);
// 3. An answer long enough to split into continuation parts reassembles exactly and in order.
const bigId = log.add(assistant(BIG), at(5));
await wait('document.querySelector("#conv-log").textContent.endsWith("BIG_END")', 45000);
const hash = await b.evaluate('crypto.subtle.digest("SHA-256", new TextEncoder().encode([...document.querySelectorAll("#conv-log .conv-text")].at(-1).textContent)).then(d => [...new Uint8Array(d)].map(x => x.toString(16).padStart(2, "0")).join(""))');
assert.equal(hash, createHash('sha256').update(BIG).digest('hex'));
const parts = [];
let res = await (await fetch(`${f.base}/api/conversation?id=${opened}`)).json();
for (;;) {
parts.push(...res.page.entries.filter(e => e.nativeEntry === bigId));
if (!res.cursor) break;
res = await (await fetch(`${f.base}/api/conversation?id=${opened}&branch=${res.page.branch}&cursor=${res.cursor.id}`)).json();
}
assert.ok(parts.length >= 2, `continuation parts: ${parts.length}`);
assert.deepEqual(parts.map(p => [p.part, p.lastPart]), parts.map((_, i) => [i, i === parts.length - 1]));
assert.deepEqual(await draft(), ['NEXT_DRAFT', 4, true]);
// 6. Relaunch mid-turn: a new session file appears. The view keeps its file and says so.
const log2 = session(join(f.sessionsDir, '2026-09-26T11-00-00_s2.jsonl'), f.projectRoot, { id: 'sess-2', timestamp: at(6) });
log2.add(user('NEW_SESSION_MESSAGE'), at(7));
await wait('document.querySelector("[data-marker=newer]")');
assert.match(await b.evaluate('document.querySelector("[data-marker=newer]").textContent'), /newer session started.*stays on the session you opened/);
assert.equal(await b.evaluate('document.querySelector("#conv-pick").value'), opened);
const after = await b.evaluate('document.querySelector("#conv-log").textContent');
assert.ok(after.includes('END_SENTINEL') && !after.includes('NEW_SESSION_MESSAGE'), 'no silent switch');
assert.deepEqual(await draft(), ['NEXT_DRAFT', 4, true]);
// Opening the newest session is Jason's choice; the draft for this seat stays.
await b.evaluate('document.querySelector("[data-conv-action=newest]").click()');
await wait('document.querySelector("#conv-log").textContent.includes("NEW_SESSION_MESSAGE")');
assert.notEqual(await b.evaluate('document.querySelector("#conv-pick").value'), opened);
assert.equal(await b.evaluate('document.querySelector("[data-marker=newer]")'), null);
assert.equal(await b.evaluate('document.querySelector("#conv-reply").value'), 'NEXT_DRAFT');
} finally { if (b) await b.close(); await f.close(); }
});
+16
View File
@@ -54,6 +54,7 @@ test('proxy preserves exact request bytes, status and receipt, rejects forms and
let body = ''; for await (const c of req) body += c; let body = ''; for await (const c of req) body += c;
requests.push({ url: req.url, method: req.method, body }); requests.push({ url: req.url, method: req.method, body });
if (req.url === '/api/board') { res.writeHead(302, { location: 'http://192.0.2.1/' }); return res.end('{}'); } if (req.url === '/api/board') { res.writeHead(302, { location: 'http://192.0.2.1/' }); return res.end('{}'); }
if (req.url.startsWith('/api/conversation')) { res.writeHead(404, { 'content-type': 'application/json' }); return res.end('{"error":"fixture unknown <x>","refusal":{"code":"unknown-branch","reconcile":true}}'); }
res.writeHead(409, { 'content-type': 'application/json' }); res.end('{"error":"fixture refusal <unsafe>"}'); res.writeHead(409, { 'content-type': 'application/json' }); res.end('{"error":"fixture refusal <unsafe>"}');
}); });
await new Promise(r => upstream.listen(0, '127.0.0.1', r)); await new Promise(r => upstream.listen(0, '127.0.0.1', r));
@@ -73,6 +74,21 @@ test('proxy preserves exact request bytes, status and receipt, rejects forms and
assert.equal(requests.length, 2); assert.equal(requests.length, 2);
assert.equal((await fetch(base + '/api/board')).status, 502); assert.equal((await fetch(base + '/api/board')).status, 502);
assert.equal(requests.length, 3); assert.equal(requests.length, 3);
// CHAT-02 conversation routes: GET only, the query passes unchanged, the board's status and body come back as sent.
for (const path of ['/api/conversations', '/api/conversation?id=pi-0a&branch=b.e5&cursor=c-1', '/api/conversation?id=x&id=y&unknown=%3C']) {
const res = await fetch(base + path);
assert.equal(res.status, 404, path);
assert.equal(await res.text(), '{"error":"fixture unknown <x>","refusal":{"code":"unknown-branch","reconcile":true}}');
assert.deepEqual(requests.at(-1), { url: path, method: 'GET', body: '' });
}
const before = requests.length;
assert.equal((await post(base, '/api/conversation?id=x', '{}')).status, 405);
assert.equal((await post(base, '/api/conversations', '{}')).status, 405);
assert.equal((await fetch(base + '/api/conversation?id=x', { headers: { origin: 'https://evil.example' } })).status, 403);
assert.equal(requests.length, before, 'refused before the board');
// Only the conversation routes carry a query upstream.
await fetch(base + '/api/board?x=1');
assert.equal(requests.at(-1).url, '/api/board');
} finally { await close(web); await close(upstream); } } finally { await close(web); await close(upstream); }
}); });