diff --git a/CHANGELOG.md b/CHANGELOG.md index efa50060..d446e043 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,18 @@ to authenticate. In the same spirit, the handoff panel explains once when a cowo work on (it runs as its own agent, outside this deployment's loop) instead of offering switches the server can only refuse; its existing grants stay visible so they can still be revoked. +### A channel stops showing a working indicator once its turn has ended + +Sending a message and then opening another channel before the reply arrived left the first channel +showing three bouncing dots on the roster, and they stayed there after the answer had landed and +been drawn into its preview, until the roster was refetched for some unrelated reason. A person's +own turn is reported by the browser, because the server is not told when one begins, and that +reporting was keyed on state belonging to the channel screen: opening another channel replaced the +screen, and the replacement reported the channel it had just opened rather than the one still +working. The report now belongs to the turn instead of to the screen, so the channel that was +running is the channel told when it stops. Opening a channel also no longer announces that it is +idle twice before anything has run in it. + ### A hop the boundary refused now names the Bot that was refused The audit page renders its Bot column from `payload.bot` and nothing else. `agent.handoff_offered` diff --git a/app/src/components/channels/channel-chat.tsx b/app/src/components/channels/channel-chat.tsx index 2010a386..4be9ba33 100644 --- a/app/src/components/channels/channel-chat.tsx +++ b/app/src/components/channels/channel-chat.tsx @@ -16,7 +16,7 @@ import { import { agentListQueryOptions } from "@/lib/agents/queries"; import { recordChannelActivityMutationOptions, - setChannelBusyMutationOptions, + setChannelBusy, } from "@/lib/channels/mutations"; import { type AgentChannel, @@ -291,6 +291,8 @@ export function ChannelChat({ * first one to finish declare the conversation idle. */ const [turnsInFlight, setTurnsInFlight] = useState(0); + /* Authoritative once this screen unmounts, where `setTurnsInFlight` becomes a no-op. */ + const turnsRef = useRef(0); const [runsInFlight, setRunsInFlight] = useState(0); /** @@ -298,23 +300,6 @@ export function ChannelChat({ */ const recordActivity = useMutation(recordChannelActivityMutationOptions()); - /* - * Show this channel as working on the roster while its own turn runs. - * - * The server cannot see a person's turn begin — the runtime does not tell it — so the browser - * reports it, keyed on whether a turn is in flight. The server broadcasts it to every member, so - * the row shows the dots even on a tab that has since navigated elsewhere; a run that outlives - * this tab clears itself when the roster next refetches, which is the acceptable failure for a - * transient hint. Not cleared on unmount on purpose: a turn keeps running server-side after the - * person leaves the channel, and clearing here would drop the indicator while the work goes on. - */ - const setBusy = useMutation(setChannelBusyMutationOptions()); - const busy = turnsInFlight > 0; - // Keyed on the busy transition alone; `setBusy.mutate` is a stable handle, not a dependency. - // biome-ignore lint/correctness/useExhaustiveDependencies: firing on the busy transition only. - useEffect(() => { - setBusy.mutate({ channelId: channel.id, busy }); - }, [busy, channel.id]); const report = (text: string, agentId: string | null) => { const trimmed = text.trim(); if (!trimmed) return; @@ -411,11 +396,21 @@ export function ChannelChat({ const trimmed = text.trim(); if (!trimmed) return; - setTurnsInFlight((count) => count + 1); + turnsRef.current += 1; + setTurnsInFlight(turnsRef.current); + if (turnsRef.current === 1) { + void setChannelBusy({ channelId: channel.id, busy: true }); + } try { await deliver(trimmed, skillInstructions); } finally { - setTurnsInFlight((count) => count - 1); + turnsRef.current -= 1; + setTurnsInFlight(turnsRef.current); + // Sent from here rather than from an effect on `turnsInFlight`: this runs after unmount, that + // does not, and the last turn out is what takes the roster's working indicator down. + if (turnsRef.current === 0) { + void setChannelBusy({ channelId: channel.id, busy: false }); + } } }; diff --git a/app/src/lib/channels/mutations.ts b/app/src/lib/channels/mutations.ts index 83212ef5..d9cae872 100644 --- a/app/src/lib/channels/mutations.ts +++ b/app/src/lib/channels/mutations.ts @@ -62,15 +62,17 @@ export function recordChannelActivityMutationOptions() { * worth nothing next to the run itself, and a person is never shown an error for it. The server * broadcasts it to the channel's members, so the row shows a working dot even on a tab that has * navigated elsewhere. + * + * A plain function, not a mutation handle: the turn it reports on outlives the screen that started + * it, and `mutate` on an unmounted component is a no-op. */ -export function setChannelBusyMutationOptions() { - return mutationOptions({ - mutationFn: async (variables: { channelId: string; busy: boolean }) => { - await tryClient(`/api/channels/${variables.channelId}/busy`, { - method: "POST", - body: { busy: variables.busy }, - }); - }, +export async function setChannelBusy(variables: { + channelId: string; + busy: boolean; +}) { + await tryClient(`/api/channels/${variables.channelId}/busy`, { + method: "POST", + body: { busy: variables.busy }, }); }