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:
@@ -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
|
||||
service-side approver validation gap went to the SetSpark lead (see
|
||||
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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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: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-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.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
Moved to `docs/plans/QUEUE.md` on 2026-09-13. This file holds only gaps.
|
||||
|
||||
@@ -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", () => {
|
||||
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]));
|
||||
codes.add(UNSUPPORTED_HARNESS); // raised by value, as a root's unsupportedReason
|
||||
assert.ok(codes.size >= 15, [...codes].join(" "));
|
||||
assert.deepEqual([...codes].filter((c) => !(c in REFUSAL_STATUS)), []);
|
||||
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 () => {
|
||||
|
||||
@@ -38,6 +38,19 @@ bytes. Do not expose either unauthenticated server through a public proxy.
|
||||
with a warning. There are no automatic action retries.
|
||||
- Palette and appearance use the existing brand tokens and persist in this
|
||||
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
|
||||
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
|
||||
|
||||
GET `/api/board` and POST `/api/seen` and `/api/reply` proxy only the existing
|
||||
board paths. POST bytes and upstream status/JSON are preserved. GET `/api/config`
|
||||
returns the configured board URL for the page's error message. No scanner,
|
||||
registration, session reader or transport is implemented here. No control-board,
|
||||
seat, fleet, comms or root package files are changed.
|
||||
GET `/api/board`, `/api/conversations` and `/api/conversation`, and POST
|
||||
`/api/seen` and `/api/reply` proxy only those board paths. Only the two
|
||||
conversation routes carry their query string; the board validates it. POST
|
||||
bytes and upstream status/JSON are preserved. GET `/api/config` returns the
|
||||
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
|
||||
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/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/conversation.test.mjs
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
claim of CI, deployment, live-seat delivery or user acceptance.
|
||||
|
||||
|
||||
@@ -52,14 +52,15 @@
|
||||
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>`;
|
||||
}
|
||||
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>`;
|
||||
function cards(rows) {
|
||||
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) {
|
||||
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) {
|
||||
const reg = r.registered;
|
||||
@@ -77,16 +78,183 @@
|
||||
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}`]);
|
||||
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.
|
||||
function focusSnapshot() {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
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);
|
||||
$('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'}`;
|
||||
inspect(); restore(focus);
|
||||
inspect(); convForm(); if (conv) convStatus(); restore(focus);
|
||||
}
|
||||
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) });
|
||||
@@ -126,7 +294,7 @@
|
||||
async function refresh() {
|
||||
if (busy) return;
|
||||
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(); }
|
||||
}
|
||||
$('refresh').onclick = refresh;
|
||||
@@ -135,10 +303,15 @@
|
||||
function close() { selected = null; render(); if (returnFocus) restore(returnFocus); }
|
||||
$('close').onclick = close;
|
||||
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]');
|
||||
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]');
|
||||
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]');
|
||||
if (!seen || busy) return;
|
||||
const r = row(seen.dataset.seen); if (!r) return;
|
||||
@@ -147,18 +320,20 @@
|
||||
catch (err) { error(err); seen.disabled = false; }
|
||||
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 => {
|
||||
if (!e.target.matches('.reply-form')) return;
|
||||
if (!e.target.matches('.reply-form, #conv-form')) return;
|
||||
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;
|
||||
drafts.set(id, text); sending.add(id); render();
|
||||
try {
|
||||
const result = await api('/api/reply', { agent: id, text }); receipts.set(id, result);
|
||||
if (result.delivered) awaiting.set(id, result.sentAt);
|
||||
// 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();
|
||||
} 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'); }
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
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}
|
||||
.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: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}
|
||||
|
||||
@@ -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('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'");
|
||||
let path;
|
||||
let path, search;
|
||||
try {
|
||||
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' });
|
||||
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' }); }
|
||||
// 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' });
|
||||
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 (req.method !== allowed) return json(res, 405, { error: 'method not allowed' });
|
||||
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 {
|
||||
const response = await fetch(upstream + path, {
|
||||
const response = await fetch(upstream + path + (conversation ? search : ''), {
|
||||
method: allowed, headers: bytes ? { 'content-type': 'application/json' } : {}, body: bytes,
|
||||
redirect: 'error', signal: AbortSignal.timeout(timeout),
|
||||
});
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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;
|
||||
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.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>"}');
|
||||
});
|
||||
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((await fetch(base + '/api/board')).status, 502);
|
||||
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); }
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user