Repository navigation
rooms: the agent's reply appears as it is written - #132
Merged
Merged
Conversation
A room showed an agent's reply only once the whole message existed: three pulsing dots, then the text. A live member (sail 0.47) streams its turn, so the room now opens one stream of its own beside the app-wide one, /v1/events/stream?room=<id>&type=agent_text_delta,agent_tool_call,agent_turn_ended, and draws the turn as it happens: a draft bubble growing under the member's name, a line per tool step, and the posted message taking the draft's place in the same render, so a turn is never drawn twice and never not at all. - liveTurns.ts is the pure reducer: one draft per (run_id, turn), named by the event's principal, every payload field clipped and drawn as text. - EventStream gains a `room` filter and a per-instance backfill switch, off for the room's stream: the box never stores what it carries. - A stream gap keeps what arrived and marks where text went missing; if the gap swallowed the turn's end, the message its principal posted still replaces the draft. - A turn that ended early is told on the draft, and on the message it became, with the box's reason. - The typing row yields to the draft; a reader scrolled up sees neither, the "N new" pill counts the turn, and reaching the bottom releases what the pill held. - On the app-wide stream the three types are telemetry: no refresh, no activity, no row, and no spec reload. - The demo gateway scripts a streamed turn after a post in a room with a live member, paced by an injectable scheduler.
…o a question A turn end and its message's event can both be lost when the room's stream and the app-wide stream drop together. The reconnect gap-fill recovered the event from the spec's lane but never told the live turns, so the final message was drawn beside the stale draft. The gap-fill now folds a recovered message event into the live turns as the live handler does. The gap-fill's watermark trails the message events the live handler took, so the drain tells them again. A message the room held before the gap is skipped there: without that, the reply of the turn before would take the place of a draft still being written. A question the member asks mid-turn is not the turn's end. It was recorded as the message that replaces the draft, so a later stream drop removed the partial reply while the turn went on. A message event marked as a question no longer counts.
… being written The app-wide handler folded a message event into the live turns before it rejected one the room already held, so a replay of the turn before's reply after a room-stream gap was recorded as the open draft's replacement and removed it. The held check now runs first, as it does in the gap-fill.
…aft whose end was lost A message fetch started by an earlier message could load the member's reply before the reply's own event arrived. The event was then dropped as one for a message the room holds, so nothing recorded the reply against the gapped draft and both stayed drawn. Every message enters the room through applySources, so a live arrival is told to the live turns there, by the same rule the event follows: the principal's own message, never a question, never an optimistic one, never one the room held or paged back to.
A fetch can bring a reply of an earlier turn the room never held: the recovery page reaching below the loaded window, or the first load after one that failed. With a gapped draft open, that reply took the draft's place and the partial text was lost. A draft now remembers the time of the first event that opened it, and a message is its reply only when written at or after that, whether it came by event or by fetch. A time that cannot be read replaces nothing.
A draft whose end was lost stays open beside the turn after it while its message is fetched. The next turn's reply was recorded on that older draft, so with both ends lost the newer draft stayed under its own posted reply. The reply now goes to the newest open draft whose first event is not after it; an earlier reply still reaches the older draft.
Six review rounds each found one more ordering of gap, fetch and event in which a draft whose turn end was lost either stayed beside its reply or left for an earlier turn's. The room recorded, event by event, which message a turn had posted, and every path a message can take into the room needed its own guard. The draft now reads the room instead. A turn's end names its message, as before. A draft a stream gap touched, with no end told, yields to a reply its principal wrote since the turn's first event, whenever and however the room came to hold it: first load, live fetch, recovery page, gap fill. The live turns are settled against the room's messages on every change to either, so no ordering is special. Gone with it: the `posted` hint on a draft, `messagePosted`, the message event's branch in the reducer, and the three per-path guards in the room. A gapped draft now stays beside the turn after it until a reply takes its place, rather than only when a message event had been seen for it.
…s it A review of the whole change in a real engine and against the reducer found five things. The draft row strobed. It was drawn only for a reader at the bottom, and the bottom was measured with the row in it: within a draft's height of the end the row left and came back about fifteen times a second until the turn's message arrived. A draft is now drawn at the end of the timeline wherever the reader is, as the typing row is, and the pill counts messages only. The room's own glide to a bottom that had just moved passed through places that were not the bottom, so a burst of large deltas left the reader behind for the rest of the turn. A reader now stops following only by moving up. A room stream kept a cursor of event ids, and the box's ids count from one again whenever it starts: after a box restart an open room drew no turn until it was left and entered again. A stream with backfill off keeps no cursor. A draft a gap had touched outlived the turn after it until its principal replied, which a member that died with its box never does. A turn that begins now ends every draft with no end told; one whose end named a message still waits for it. A note the member posted mid-turn took the draft's place once the stream had dropped, even when the turn had gone on since. An event of a turn now says the turn is still open, and a reply is one written since the last of the turn the room saw. Also: an event that writes nothing opens no draft, a room keeps at most eight drafts, and the demo member writes one turn at a time.
A second read in a real engine found the room still moving a reader who had not moved, and a reader unable to move. A reader scrolled up inside a draft was thrown to the bottom when its turn ended: the reply went behind the pill, the draft left, and what was below them shrank to nothing. A draft now has one place for its whole life, decided when its turn begins as a message's is when it arrives: drawn if the reader followed then, behind the pill and counted by it if they were away, until they return. The message of a draft the reader can see is drawn where the draft was, wherever they are. Each delta began another glide to the bottom, and a glide in flight swallowed the wheel: during a fast stream the reader could not leave, and above about 400px a second the view fell behind without bound. The timeline no longer animates its own scrolls. A reader follows until they move up at all, told from the room's own last move, and again once back within reach of the latest; the latest moving away is never leaving. Also: the demo member that owes a turn answers only while still in the room, and its tests cannot spin.
…ey follow A fourth read found the room pulling back a reader who had just left. Its move to the latest was queued when a delta was drawn and ran a frame later whatever the reader had done in between, so a single wheel notch during a stream was undone, and near sixty deltas a second the reader could not leave at all. The room now looks again in that frame: a reader who still follows is taken to the latest, one who has left is let be. The same step settles two cases no scroll tells of. A reader left resting at the very end, because what was below them shrank until the room fit its window, follows again; before, the next turn waited behind a pill that no scrolling could release. And a room that is not on screen keeps no place, so its first move up after it returns is still read as one.
…ey came to be there Following again released the drafts that waited behind the pill but left the buffered messages there unless the reader had scrolled to the end, so a reader the room found resting at the end of a room that now fits its window followed with a pill still showing. Following is now what releases both, in one place, and the scroll handler has no case of its own for it.
…t the pill releases
…ts scroll is told A fifth read found a reader who never moved dropped out of following. When a reply shorter than its draft takes the draft's place, the engine rests a reader who was past the new end on it, lower than they were, and tells of that scroll a frame later. If anything grew the room in between, the scroll read as the reader moving up to somewhere short of the end. The frame after used to pull them back by accident; since the room stopped moving a reader who has left, nothing did. The room now reads its own layouts as it makes them, and its window's as they are told: a reader resting at the very end did not move up to get there, and follows. So the scroll that arrives later is no move up, whatever the end has done since. That is also where a reader left at the end of a room that now fits its window follows again, which the frame did before; and a window that grows taller, or a room that comes back on screen, is settled the same way. Following releases what is behind the pill only on the return, so a scroll at the latest draws nothing again.
Reading a shrink off the reader resting at the very end missed the case where the engine leaves them somewhere else: with the next turn's first delta already drawn below, an engine that anchors scrolls put a reader who never moved short of the end when a reply took the draft's place, and the scroll that told of it read as a move up. The room now notes where the reader is before it draws and where they are once it is laid out, and takes exactly that difference off what it remembers of their place. Whatever the engine did, and wherever it left them, the scroll told later is a move up only if the reader made one. A window's resize has no before to read, so it keeps the rule it had.
…old from Measuring a layout's effect needed the reader's place before the room drew, and reading it while rendering made the engine lay out there and then: a window being dragged taller clamped the reader at that read, where nothing took it off, and a reader who never scrolled stopped following. The room now looks only after it has drawn. Wherever that layout left the reader is their place, so the scroll told later for it is no move at all. An engine tells a reader's own scroll before anything else runs in the frame it learns of it, so there is none of theirs waiting to be taken for the layout's.
The observer was added for a window dragged taller during a stream. Now that the room takes its place from every layout it draws, a turn being written covers that case by itself: ten of ten runs in the engine still follow without it. What it alone did was older and outside this change, a pill left over a room that a growing window made fit and a follower not taken to the end when a hidden room returns, each healed by the next thing drawn, as on main.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Spec:
mast-live-room-member. Depends onsail-live-room-member(sail 0.47, merged).A room showed an agent's reply only once the whole message existed. A live member streams its turn, so the room now draws it as it is written: a draft bubble growing under the member's name, a line per tool step, and the posted message taking the draft's place in the same render.
What changed
liveTurns.ts— the pure reducer. One draft per(run_id, turn), named by the event'sprincipal;agent_tool_callis one line fromsummary, running until itsfinished;agent_turn_endednames the message that replaces the draft. Every payload field is validated, clipped and drawn as text; a room keeps at most eight drafts.EventStreamgains aroomoption and a per-instancebackfillswitch.Gateway.openRoomStream(roomId)opens/v1/events/stream?room=<id>&type=agent_text_delta,agent_tool_call,agent_turn_endedwith backfill off;SpecRoomopens one per room and closes it on leaving. No Rust change:validate_stream_pathalready admits the query.SpecRoom(whichChatRoomPanereuses) — the draft row below the groups, tool lines, replacement on turn end, the typing row yielding to the draft, the error bubble. Drafts live in component state only.SpecDetail's debounced reload skips them too.emit, one turn at a time, paced by an injectable scheduler; live events are never added to its stored history.openRoomStreamfrom one helper,test/roomStreams.ts.The rules a reviewer should hold this to
How a draft leaves (
liveTurns.ts):The last row is the only inference, and it reads the messages the room holds: first load, live fetch, recovery page, load-earlier and gap-fill all pass through
applySources, which settles the drafts against them. Six review rounds each found one more ordering of gap, fetch and event wrong while that row was event-by-event bookkeeping with a guard per path; it is now one predicate (replacementOf).Where a draft is (
Draft.behind): a draft has the place a message arriving when its turn began would have. Drawn if the reader followed then; behind the "N new" pill and counted by it if they were away, until they return. The message of a draft the reader can see is drawn where the draft was.Following (
onScroll,keepPlace, and the layout effect beside it):A scroll is told a frame after it happens, when the end may have moved again. So the room takes the reader's place from every layout it draws, and reads a scroll as a move up only against that. The timeline's own scrolls are instant.
One limit of that: a move of the reader's own that no scroll has told of yet when the room draws is taken as part of the layout. In Chromium that never happened across every real input tried. In WebKitGTK it happened once in about 430 trials: one arrow-key step, taken within a millisecond of a delta, was undone, and the next press left. macOS WKWebView scrolls on its own thread and could not be tested here; if it tells scrolls later than WebKitGTK does, a single step during a stream is undone more often. A gesture of several events always leaves.
Departures from the spec, and decisions for Uday
mast-event-cursor-survives-a-box-restart.scroll-behavior: smoothis gone from.room-timeline. A glide restarted on every delta swallowed the wheel during a fast stream and let the view fall more than 1,000px behind. The pill's jump and a new message's arrival are instant too.mast-draft-yields-to-its-own-reply(revised) has sail tell a turn's beginning and, on connect, which turn is open, which also retires the inference above and its limits (listed there).mast-room-fetches-follow-the-room(a room swapped in place keeps asking the old room; a failed post forgets a deferred reply; nothing settles the reader's place when only the window or the composer changes size, which in an engine that anchors scrolls can leave a reader who follows stranded after an idle resize).Verification
bun run typecheck,bun test(1,378 pass, 0 fail),bun run build:tauri-webgreen locally; every non-test line this PR adds is executed by a test.SpecRoomandChatRoomPaneagainst a fake room stream, the demo's scripted turn, the Tauri wire, and the stream'sroom/backfill options.~/workspace/sail-audit/2026-10-13-mast-room-harness/on the devbox.tauri devis macOS-only and this was built on Linux.