Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
35 changes: 15 additions & 20 deletions app/src/components/channels/channel-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
import { agentListQueryOptions } from "@/lib/agents/queries";
import {
recordChannelActivityMutationOptions,
setChannelBusyMutationOptions,
setChannelBusy,
} from "@/lib/channels/mutations";
import {
type AgentChannel,
Expand Down Expand Up @@ -291,30 +291,15 @@ 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);

/**
* Tell the roster what was just said. Failures here must not block the conversation.
*/
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;
Expand Down Expand Up @@ -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 });
}
}
};

Expand Down
18 changes: 10 additions & 8 deletions app/src/lib/channels/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
});
}

Expand Down