Skip to content

Commit f1670fd

Browse files
authored
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.
1 parent 93e357f commit f1670fd

7 files changed

Lines changed: 847 additions & 25 deletions

File tree

app/src/components/channels/channel-chat.tsx

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,33 @@ export function ChannelChat({
141141
const [runError, setRunError] = useState<string | null>(null);
142142
const awaitingReply = useRef(false);
143143

144+
/*
145+
* TWO DIFFERENT FACTS ABOUT ONE TURN, AND NEITHER OF THEM IS `agent.isRunning`.
146+
*
147+
* `turnsInFlight` counts what a person would call the Bot having the turn: from the moment `say`
148+
* is entered until the whole thing has come back, browser actions in the middle included. It is
149+
* what decides whether the next thing typed is sent or parked, and what tells the queue its wait
150+
* is over.
151+
*
152+
* `runsInFlight` counts what Stop can actually reach: the run `copilotkit.runAgent` opens, and
153+
* nothing before it. A turn can be in flight for a second and a half before that, while `say`
154+
* waits for the runtime agent, and a Stop drawn in that window aborts a controller nobody has
155+
* made yet.
156+
*
157+
* `agent.isRunning` looks like both and is neither. It reports the run on the wire, and a turn
158+
* that touches the browser is several runs in a row: the Bot asks for a click, the run ENDS so
159+
* the browser can answer it, and another run starts carrying the answer. The agent reports itself
160+
* idle in every one of those gaps — the truth about the wire and a lie about the turn. OpenBot
161+
* registers every computer tool as a frontend tool, so the gaps open on ordinary work rather than
162+
* on some edge case, and anything keyed on the turn ending fires in the middle of one instead.
163+
*
164+
* Counters rather than booleans because nothing stops a second turn being started from a
165+
* component button while the first is still going, and two overlapping turns must not have the
166+
* first one to finish declare the conversation idle.
167+
*/
168+
const [turnsInFlight, setTurnsInFlight] = useState(0);
169+
const [runsInFlight, setRunsInFlight] = useState(0);
170+
144171
/**
145172
* Tell the roster what was just said. Failures here must not block the conversation.
146173
*/
@@ -159,12 +186,10 @@ export function ChannelChat({
159186
reportRef.current = report;
160187

161188
/**
162-
* Send a user turn through the channel, including activity reporting and history repair.
189+
* Everything `say` does once it has something worth sending, split out so the counter it is
190+
* wrapped in covers every way out of here, a throw included.
163191
*/
164-
const say = async (text: string, skillInstructions: string[] = []) => {
165-
const trimmed = text.trim();
166-
if (!trimmed) return;
167-
192+
const deliver = async (trimmed: string, skillInstructions: string[]) => {
168193
// Wait briefly for the runtime agent instance before adding the message.
169194
if (!isReadyRef.current) {
170195
await Promise.race([
@@ -211,7 +236,32 @@ export function ChannelChat({
211236
agent.setMessages(repaired as typeof agent.messages);
212237
}
213238

214-
await copilotkit.runAgent({ agent });
239+
setRunsInFlight((count) => count + 1);
240+
try {
241+
await copilotkit.runAgent({ agent });
242+
} finally {
243+
setRunsInFlight((count) => count - 1);
244+
}
245+
};
246+
247+
/**
248+
* Send a user turn through the channel, including activity reporting and history repair.
249+
*
250+
* Every user turn in this channel goes through here — what the composer sends, the seed from the
251+
* compose screen, and a button inside a rendered component. That is what makes the counter worth
252+
* keeping here rather than in the view: the view sees only the turns it started itself, and a
253+
* queue that drains on the wrong one of those posts a correction into the middle of an answer.
254+
*/
255+
const say = async (text: string, skillInstructions: string[] = []) => {
256+
const trimmed = text.trim();
257+
if (!trimmed) return;
258+
259+
setTurnsInFlight((count) => count + 1);
260+
try {
261+
await deliver(trimmed, skillInstructions);
262+
} finally {
263+
setTurnsInFlight((count) => count - 1);
264+
}
215265
};
216266

217267
useEffect(() => {
@@ -335,7 +385,26 @@ export function ChannelChat({
335385
awaitingReply.current = false;
336386
copilotkit.stopAgent({ agent });
337387
}}
338-
pending={agent.isRunning}
388+
/*
389+
* The turn, not the run. A browser action ends one run and starts another, and telling the
390+
* conversation it is idle in between is what would drain a parked correction into the
391+
* middle of an answer: a second turn racing the first on one thread, with a fabricated
392+
* result stitched over a tool call that is still executing.
393+
*/
394+
pending={agent.isRunning || turnsInFlight > 0}
395+
/*
396+
* A channel outlives its turns, so it is the screen where waiting is worth offering. A
397+
* correction typed mid-answer is held here, in this tab, and runs as one follow-up turn the
398+
* moment this one is over — including when it is over because somebody pressed the button
399+
* above.
400+
*/
401+
queueWhileBusy
402+
/*
403+
* The run, not the turn. Stop reaches a run through the core's abort controller, and that
404+
* controller does not exist until `say` has finished waiting for the runtime agent — so
405+
* this is the one place the narrower fact is the honest one to draw a button from.
406+
*/
407+
stoppable={agent.isRunning || runsInFlight > 0}
339408
/>
340409
</ConversationProvider>
341410
);

app/src/components/channels/chat-transcript.tsx

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,22 @@ import { Streamdown } from "streamdown";
77
import { markdownComponents } from "@/lib/markdown";
88
import { EASE_OUT, ENTRANCE_SECONDS } from "@/lib/motion";
99
import { Bubble, BubbleContent } from "@/components/ui/bubble";
10-
import { MessageContent, Message as MessageRow } from "@/components/ui/message";
10+
import {
11+
MessageContent,
12+
MessageFooter,
13+
Message as MessageRow,
14+
} from "@/components/ui/message";
1115
import {
1216
MessageScroller,
1317
MessageScrollerButton,
1418
MessageScrollerContent,
1519
MessageScrollerItem,
1620
MessageScrollerProvider,
1721
MessageScrollerViewport,
22+
useMessageScroller,
1823
} from "@/components/ui/message-scroller";
1924
import { toVisibleChatItems } from "./chat-messages";
25+
import type { QueuedMessage } from "./composer";
2026
import { ToolLine } from "./tool-line";
2127
import { ToolRenderBoundary } from "./tool-boundary";
2228

@@ -25,8 +31,18 @@ type ChatTranscriptProps = {
2531
/** Comma-separated `/` command names, used to tell a skill chip from a leading slash. */
2632
commandNames?: string;
2733
messages: ReadonlyArray<Readonly<Message>>;
34+
/**
35+
* Typed while the Bot had the turn, and waiting for it to finish. Empty on a screen that does not
36+
* offer queueing at all.
37+
*/
38+
queued?: readonly QueuedMessage[];
39+
/** Take one back before it runs. Without it a queued line is shown but cannot be undone. */
40+
onRemoveQueued?: (id: string) => void;
2841
};
2942

43+
/** One shared empty array, so a screen without a queue does not hand down a new one per render. */
44+
const EMPTY_QUEUE: readonly QueuedMessage[] = [];
45+
3046
/**
3147
* Split a person's message into the skill they invoked and the rest of what they typed.
3248
*
@@ -75,6 +91,102 @@ function Thinking() {
7591
);
7692
}
7793

94+
/**
95+
* Something the person said while the Bot was working, waiting its turn.
96+
*
97+
* IT IS DRAWN AS THEIR MESSAGE, NOT AS A NOTICE ABOUT ONE. The whole point of letting somebody type
98+
* mid-turn is that they can see their words landed, and a status line saying "1 message queued"
99+
* does not do that — they would still be wondering whether the sentence they typed is the sentence
100+
* that will run. So it is the same bubble, in the same column, with the same wrapping, and only two
101+
* things say it has not run yet: it is faded, and it says so underneath.
102+
*
103+
* The footer carries the taking-back too, because that is where the reader's eye already is once
104+
* they have decided this was a mistake, and because a control on the bubble itself would have to
105+
* hover over the words it is offering to delete.
106+
*/
107+
function Queued({
108+
text,
109+
onRemove,
110+
}: {
111+
text: string;
112+
onRemove?: (() => void) | undefined;
113+
}) {
114+
return (
115+
<MessageRow align="end">
116+
<MessageContent>
117+
<Bubble align="end" className="opacity-60" variant="muted">
118+
<BubbleContent>
119+
{/* Shown exactly as typed, for the same reason a sent message is. */}
120+
<span className="whitespace-pre-wrap">{text}</span>
121+
</BubbleContent>
122+
</Bubble>
123+
<MessageFooter>
124+
{/*
125+
* `status` rather than `alert`, matching the thinking line: a person who has just chosen
126+
* to queue something is not being interrupted by the news that it is queued.
127+
*/}
128+
<span role="status">Queued</span>
129+
{onRemove ? (
130+
<button
131+
/*
132+
* The sentence it deletes, in the name. Three parked corrections put three buttons
133+
* called "Remove" in a row, and somebody reading by name alone is told what they can
134+
* do and nothing about which one it would happen to. The visible word stays short
135+
* because the bubble it sits under is the answer for everybody who can see it.
136+
*/
137+
aria-label={`Remove queued message: ${text}`}
138+
className="ml-2 underline underline-offset-2 hover:text-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
139+
onClick={onRemove}
140+
type="button"
141+
>
142+
Remove
143+
</button>
144+
) : null}
145+
</MessageFooter>
146+
</MessageContent>
147+
</MessageRow>
148+
);
149+
}
150+
151+
/**
152+
* Put the newest queued message where the person who just typed it can see it.
153+
*
154+
* WITHOUT THIS THE AFFORDANCE IS INVISIBLE EXACTLY WHEN IT MATTERS. The scroller holds its anchor on
155+
* the turn being answered rather than following the bottom, so during a long streamed answer the
156+
* transcript sits a screen or so above the end — and a line appended below it lands off screen.
157+
* Measured at the point somebody would actually use this: eighty-odd pixels under the fold, with
158+
* the composer emptying at the same moment. They would have watched their correction vanish.
159+
*
160+
* Keyed on the newest queued id rather than on the list, so it does not fire again for every chunk
161+
* of the answer still streaming above it. It does fire when the bottom-most queued line is taken
162+
* back, which is a scroll nobody asked for and which lands on the end of the conversation anyway,
163+
* and it stays quiet on a drain, when the id goes to null.
164+
*
165+
* IT COSTS THE ANCHOR, AND THAT IS THE PRICE OF THE SCROLL RATHER THAN A SIDE EFFECT OF IT.
166+
* `scrollToEnd` drops whatever turn the scroller was holding its position against and starts
167+
* following the bottom instead, so the rest of that answer streams past under the reader rather
168+
* than staying put beneath the question. Somebody who has just typed at the bottom of the
169+
* conversation has asked to be at the bottom of the conversation, so following it is the reading
170+
* they chose; but they chose it for the whole turn and not only for the moment, and the button
171+
* back to the anchored view is the scroller's own, not ours to restore.
172+
*
173+
* Rendering nothing and living inside the provider is what buys access to the scroller at all; the
174+
* alternative is threading a ref out through three components with no other reason to know a
175+
* scroller exists.
176+
*/
177+
function ScrollNewestQueuedIntoView({ newest }: { newest: string | null }) {
178+
const { scrollToEnd } = useMessageScroller();
179+
180+
useEffect(() => {
181+
if (newest === null) {
182+
return;
183+
}
184+
scrollToEnd();
185+
}, [newest, scrollToEnd]);
186+
187+
return null;
188+
}
189+
78190
/**
79191
* How many of the newest turns cascade when a channel is opened, and how far apart.
80192
*
@@ -341,6 +453,8 @@ export function ChatTranscript({
341453
busy = false,
342454
commandNames = "",
343455
messages,
456+
onRemoveQueued,
457+
queued = EMPTY_QUEUE,
344458
}: ChatTranscriptProps) {
345459
/*
346460
* NOT MEMOISED, AND THAT IS DELIBERATE. `useMemo` keyed on `messages` looks obviously right and
@@ -436,9 +550,26 @@ export function ChatTranscript({
436550
* the scroller to measure and anchor something that exists for a second and a half.
437551
*/}
438552
{waitingOnFirstToken ? <Thinking /> : null}
553+
{/*
554+
* Below the thinking line, and outside the item list for the same reason it is: these
555+
* are not yet turns. They have ids of their own, but they are this tab's ids and not the
556+
* thread's, so handing them to the scroller would ask it to anchor on something that is
557+
* about to be replaced by a message with a different id — and the replacement is the
558+
* one worth scrolling to.
559+
*/}
560+
{queued.map((message) => (
561+
<Queued
562+
key={message.id}
563+
onRemove={
564+
onRemoveQueued ? () => onRemoveQueued(message.id) : undefined
565+
}
566+
text={message.text}
567+
/>
568+
))}
439569
</MessageScrollerContent>
440570
</MessageScrollerViewport>
441571
<MessageScrollerButton />
572+
<ScrollNewestQueuedIntoView newest={queued.at(-1)?.id ?? null} />
442573
</MessageScroller>
443574
</MessageScrollerProvider>
444575
);

0 commit comments

Comments
 (0)