@@ -142,6 +142,33 @@ export function ChannelChat({
142142 const [ runError , setRunError ] = useState < string | null > ( null ) ;
143143 const awaitingReply = useRef ( false ) ;
144144
145+ /*
146+ * TWO DIFFERENT FACTS ABOUT ONE TURN, AND NEITHER OF THEM IS `agent.isRunning`.
147+ *
148+ * `turnsInFlight` counts what a person would call the Bot having the turn: from the moment `say`
149+ * is entered until the whole thing has come back, browser actions in the middle included. It is
150+ * what decides whether the next thing typed is sent or parked, and what tells the queue its wait
151+ * is over.
152+ *
153+ * `runsInFlight` counts what Stop can actually reach: the run `copilotkit.runAgent` opens, and
154+ * nothing before it. A turn can be in flight for a second and a half before that, while `say`
155+ * waits for the runtime agent, and a Stop drawn in that window aborts a controller nobody has
156+ * made yet.
157+ *
158+ * `agent.isRunning` looks like both and is neither. It reports the run on the wire, and a turn
159+ * that touches the browser is several runs in a row: the Bot asks for a click, the run ENDS so
160+ * the browser can answer it, and another run starts carrying the answer. The agent reports itself
161+ * idle in every one of those gaps — the truth about the wire and a lie about the turn. OpenBot
162+ * registers every computer tool as a frontend tool, so the gaps open on ordinary work rather than
163+ * on some edge case, and anything keyed on the turn ending fires in the middle of one instead.
164+ *
165+ * Counters rather than booleans because nothing stops a second turn being started from a
166+ * component button while the first is still going, and two overlapping turns must not have the
167+ * first one to finish declare the conversation idle.
168+ */
169+ const [ turnsInFlight , setTurnsInFlight ] = useState ( 0 ) ;
170+ const [ runsInFlight , setRunsInFlight ] = useState ( 0 ) ;
171+
145172 /**
146173 * Tell the roster what was just said. Failures here must not block the conversation.
147174 */
@@ -160,12 +187,10 @@ export function ChannelChat({
160187 reportRef . current = report ;
161188
162189 /**
163- * Send a user turn through the channel, including activity reporting and history repair.
190+ * Everything `say` does once it has something worth sending, split out so the counter it is
191+ * wrapped in covers every way out of here, a throw included.
164192 */
165- const say = async ( text : string , skillInstructions : string [ ] = [ ] ) => {
166- const trimmed = text . trim ( ) ;
167- if ( ! trimmed ) return ;
168-
193+ const deliver = async ( trimmed : string , skillInstructions : string [ ] ) => {
169194 // Wait briefly for the runtime agent instance before adding the message.
170195 if ( ! isReadyRef . current ) {
171196 await Promise . race ( [
@@ -212,7 +237,32 @@ export function ChannelChat({
212237 agent . setMessages ( repaired as typeof agent . messages ) ;
213238 }
214239
215- await copilotkit . runAgent ( { agent } ) ;
240+ setRunsInFlight ( ( count ) => count + 1 ) ;
241+ try {
242+ await copilotkit . runAgent ( { agent } ) ;
243+ } finally {
244+ setRunsInFlight ( ( count ) => count - 1 ) ;
245+ }
246+ } ;
247+
248+ /**
249+ * Send a user turn through the channel, including activity reporting and history repair.
250+ *
251+ * Every user turn in this channel goes through here — what the composer sends, the seed from the
252+ * compose screen, and a button inside a rendered component. That is what makes the counter worth
253+ * keeping here rather than in the view: the view sees only the turns it started itself, and a
254+ * queue that drains on the wrong one of those posts a correction into the middle of an answer.
255+ */
256+ const say = async ( text : string , skillInstructions : string [ ] = [ ] ) => {
257+ const trimmed = text . trim ( ) ;
258+ if ( ! trimmed ) return ;
259+
260+ setTurnsInFlight ( ( count ) => count + 1 ) ;
261+ try {
262+ await deliver ( trimmed , skillInstructions ) ;
263+ } finally {
264+ setTurnsInFlight ( ( count ) => count - 1 ) ;
265+ }
216266 } ;
217267
218268 useEffect ( ( ) => {
@@ -321,7 +371,26 @@ export function ChannelChat({
321371 awaitingReply . current = false ;
322372 copilotkit . stopAgent ( { agent } ) ;
323373 } }
324- pending = { agent . isRunning }
374+ /*
375+ * The turn, not the run. A browser action ends one run and starts another, and telling the
376+ * conversation it is idle in between is what would drain a parked correction into the
377+ * middle of an answer: a second turn racing the first on one thread, with a fabricated
378+ * result stitched over a tool call that is still executing.
379+ */
380+ pending = { agent . isRunning || turnsInFlight > 0 }
381+ /*
382+ * A channel outlives its turns, so it is the screen where waiting is worth offering. A
383+ * correction typed mid-answer is held here, in this tab, and runs as one follow-up turn the
384+ * moment this one is over — including when it is over because somebody pressed the button
385+ * above.
386+ */
387+ queueWhileBusy
388+ /*
389+ * The run, not the turn. Stop reaches a run through the core's abort controller, and that
390+ * controller does not exist until `say` has finished waiting for the runtime agent — so
391+ * this is the one place the narrower fact is the honest one to draw a button from.
392+ */
393+ stoppable = { agent . isRunning || runsInFlight > 0 }
325394 /*
326395 * At the END OF THE TRANSCRIPT rather than above the composer, which is where this used to
327396 * be. A turn that ends without an answer leaves a gap exactly where the reply was going to
0 commit comments