SIGN IN SIGN UP

Let somebody talk to a Bot that is already working (#16)

* Let somebody talk to a Bot that is already working

Typing into a channel while the Bot had the turn did nothing. The composer took
the keystrokes and refused the send, so a person watching their coworker head
off in the wrong direction had two ways out: stop the turn and lose whatever it
had done, or wait for it to finish being wrong. Neither is what they wanted,
which was to say "no, the other one" while it was working and have that land.

A message typed mid-turn is now parked instead of dropped. It appears in the
transcript straight away as their own bubble, faded, saying Queued underneath,
with a Remove next to it; and when the turn ends everything parked runs as one
follow-up turn with the lines joined by newlines. A burst of three corrections
costs one turn, not three.

The drain is keyed on the turn being over and never asks how it ended, which is
what makes Stop a way of steering rather than a way of giving up: park a
correction, press Stop, and the correction is what runs next. There is no stop
path in the code to forget about.

The rule is one pure reducer in composer/queue.ts and is tested as one. The
state is held by ConversationView, which is the nearest thing that owns both the
composer that parks a message and the transcript that has to show it. Its
docblock says plainly what the state is worth: memory in one tab, gone on
reload, not an outbox. The affordance is drawn only while a turn is in flight,
so a reload finds no queue and shows none rather than promising to send words it
will never send.

The compose screen does not get this. It creates the channel on send and then
navigates away, so anything parked there would go down with the unmount, which
is worse than a send button that visibly will not go. Queueing is therefore an
opt-in prop and only the channel view asks for it.

* Stop letting the run decide when the turn is over

The queue drained in the middle of answers. It waits for the turn to end and read
the end of the turn off the agent, and the agent does not know: it reports the run
on the wire, and a turn that touches the browser is several runs in a row. The Bot
asks for a click, the run ends so the browser can answer it, and another run starts
carrying the answer. In every one of those gaps the agent says it is idle. OpenBot
registers every computer tool as a frontend tool, so the gaps open on ordinary work.

The view patched half of it by also watching its own send, which covered turns typed
into the composer and nothing else. A channel starts turns by two other routes — the
first message of a new channel, and a button inside a rendered component — and for
those the queue drained on the Bot's first browser action. That posted a correction
as a second turn while the first was still going, two runs racing on one thread, and
took the unanswered tool call along with it: the history repair stitches a fabricated
"produced no result" over a call that is still executing and about to produce a real
one, which is two results for one call, which providers refuse.

So the fact is now kept where every turn passes through it. `say` is the one funnel
in the channel, and it counts what it is holding: a turn from the moment somebody
asks for it until the whole thing has come back, browser actions included. That is
what the composer is told, and what the queue waits for.

Stop is counted separately, and separately on purpose. It reaches a run through the
core's abort controller, and that controller does not exist until `say` has finished
waiting for the runtime agent — as much as a second and a half on a channel that is
still joining. Drawn from the turn, a Stop button appeared in that window, aborted
nothing, and let the message go anyway; the one control the whole affordance leans on
was quietly lying. It is drawn from the run instead, and the composer takes the two
facts as two props.

Three smaller things came out of the same reading. A queue no longer drains into a
conversation that has been disabled, because a coworker deleted mid-turn takes the
channel with it and one more user turn posted into it helps nobody; the cost is that
parked words stay on screen unrun, under the notice that says why. Each parked line's
Remove button now names the sentence it would delete, so three of them in a row are
three different buttons to somebody reading by name. And the reducer no longer leans
on an argument about two components' timing to rule out an idle send meeting a
non-empty queue: it runs them in the order they were typed instead of letting the new
one jump the line.
J
Jerel Velarde committed
f1670fd53f6e07c9ff30fd3e241b346b62da9fcd
Parent: 93e357f
Committed by GitHub <noreply@github.com> on 8/19/2026, 11:48:44 PM