Skip to content

Commit fbbe2c6

Browse files
Relay handoff answers home, show working channels, and open the app to the keyboard (#290)
* Gate first sign-in behind a per-user onboarding wizard Two columns on users say where somebody is in first-run onboarding and when they finished; /api/me carries the status and one POST moves it. The app redirects an unfinished user to /onboarding — an animated three-step wizard — and an address the build does not know now goes home through the same gate instead of a bare 404. * Say work is offered aloud, so a sweep can start now instead of at its next poll The handoff queue was swept every two seconds, and a person is waiting through every hop, so each leg of a handoff cost up to a full interval doing nothing. Offering work now fires pg_notify with the kind as payload, inside the offering transaction where there is one so it fires on commit and never before, and any replica can listen on a dedicated connection and kick its sweep immediately. A notification is a latency optimisation, never a delivery mechanism: one lost in transit costs up to one poll interval, not the work, and the queue's tables stay the only truth. * Let a channel tell its members a turn is running in it A transient busy flag on the channel activity event, announced and never written: busy is a moment, not a fact about the channel, and a missed signal costs at most a stuck-looking dot until the next real event, never data. Two ways in. The server signals by thread (signalBusy) for the runs it can see — the runtime's lock acquire and release now carry an onRunBusy seam, so every run the platform processes lights its channel, including one whose tab has navigated away — and a scratch thread maps to no channel and signals nowhere, which is the point of a scratch thread. The browser signals by channel (POST /:channelId/busy) for the one thing the server cannot see, a person's own turn beginning, with a membership check so belonging to a channel is not something an outsider can probe for. * Relay a handoff answer back into the conversation that asked A forward hop used to answer in the addressed Bot's own channel with the person: two conversations for one question, and the answer landed somewhere they never asked anything. Now the hop runs in a scratch thread of the addressed Bot's own — minted per hop, never mapped to a channel, never shown — and what it said comes home through a second queued hop that has the asking Bot relay the answer, attributed, in the conversation the person is watching. The delivery gathers the Bot's words from the stream as it goes past, because the runner publishes the turn to the platform and the events are the one chance to hear it. The relay rides the same durable queue as the turn that produced it, so a pod dying between the two loses the relay to a retry rather than for ever; the answerIn marker that stops a failure notice recursing stops a relay relaying. Answers are clipped at 12k characters so a Bot that comes back with a book cannot swamp the relaying run's prompt, and a backwards hop reads only the tail of the asking conversation so relaying never grows slower with the channel. Wired in index.ts: the scratch thread replaces the direct-channel answerIn, the roster announcement resolves thread to channel and happens only when the turn said something, the asking channel is lit while a forward hop runs, and the queue's new offered-work notification kicks the sweep so a person is not waiting out a poll interval per leg. * Show a channel working on the roster while a turn runs in it Three bouncing dots badged on the channel's avatar, driven by the busy flag on the activity socket. Socket-only and transient on the summary type: the roster query never returns it, so it is undefined until a busy event arrives and drops whenever the roster refetches — the acceptable failure for a hint about a moment. The event patcher flips only the busy field: the spread that serves ordinary activity would carry the event's null message onto the row and wipe the preview, and busy is not activity, so the row does not re-sort either. The channel reports its own turns over the new busy endpoint, fire-and-forget, and deliberately does not clear on unmount: a turn keeps running server-side after the person leaves the channel, and the server clears it when the run's lock is released. * Keep an open channel current with turns nobody here streamed A relayed handoff answer runs on the server and lands in the thread with no browser attached; the transcript restored history once, on mount, and would show the new turn only after leaving and coming back. The chat now watches the roster's own channel-list cache — the sidebar updating and the transcript refreshing are one signal and cannot drift apart — and when this channel's lastMessageAt advances to a moment a Bot authored, the durable history is read again and messages whose ids the transcript has never seen are appended. Appended by id, not compared by length, because the stored read keeps only what the platform can parse and can be shorter than the screen while still holding the news. Retried briefly, since the roster is patched when the turn is on record with the runner and the platform's read can be a beat behind. The chat also reports its own turns to the busy endpoint, keyed on whether a turn is in flight, so the roster's working dots cover the one run the server cannot see begin. * Start a new chat from anywhere with Shift+N A small hotkey registry, one place on purpose: the binding a listener matches against and the combo the settings page shows are the same record, so the list under Preferences is what the keys actually do rather than what somebody remembered they did — each key drawn as its own keycap, symbols on a Mac and names elsewhere. Matching is exact rather than at-least, so Shift+N does not fire on Cmd+Shift+N and shadow whatever the browser means by it, and a plain Shift+letter combo is left alone while the focus is anywhere editable. Bound in _authed rather than _app, so a person on settings or admin can start a chat without first clicking back into the app frame. The new-channel composer's recipient picker takes focus on arrival, so Shift+N then typing a name is one motion. * Format the onboarding route The wizard shipped unformatted; biome's own output, no hand edits. * Read the asking conversation before taking the run's lock The history read is the one call in a delivery that throws on a platform error, and it sat after the lock was acquired but before the try whose finally gives the lock back: a 500 from the platform leaked the lock for its full TTL. On a forward hop that lock is a scratch thread and the leak costs nothing; on a relay it is the asking conversation itself, so the person could not type for two minutes, the retry a minute later collided with the hop's own leftover hold and spent an attempt on it, and a relay that ran out of attempts vanished without a notice. The read is of the conversation that asked, which the lock — taken on the conversation being answered in — never protected. Hoisted above the acquire it fails before anything is held, and the happy path holds the lock for less of the turn too. Raised by kevin9327 on #290, who had the same reordering up as #305 against main; a test now pins the ordering. * Refetch the roster as well as the user when onboarding completes Finishing the wizard navigates straight into the app, and the channel list — cached from before the wizard, or from a fetch that ran while it held the screen — could greet the person with an empty sidebar their reload then fixed. Seen once on a real deployment during review of #290 and not cleanly reproduced, so this is a hardening of the seam rather than a confirmed fix: both invalidations use refetchType "all" for the same reason, nothing is observing these queries while the wizard is up. * Say which wizard agents are examples, and unstick its two classes Two review findings on the wizard's roster step. The invented agents that top up a sparse deployment rendered identically to real ones, so "Support Agent" read as a Bot the deployment has; they are dimmed and labelled Example now. And max-w-lg had run together with overflow-hidden into a class that applied neither, which is why the roster cards drew wider than every other step. Also stops the onboarding store's comment promising that step is where the wizard resumes: it is stored and served but nothing resumes from it yet, and the comment now says so instead of describing a behaviour that does not exist. * Say in the changelog what changed, including reversing 0.0.5's relay stance Five user-visible changes were on this branch with no entry: the relay, the working indicator, the transcript catch-up, the onboarding wizard, and Shift+N. Worse, 0.0.5's notes promised the asking Bot does not relay text on the addressed Bot's behalf, and this branch makes it do exactly that — so somebody upgrading off those notes got the opposite of what they were told. The Unreleased section now names the reversal outright, and the 0.0.5 paragraph carries a pointer forward rather than standing uncorrected. * Give the queue's wake-up listener back on the way out, like its siblings `startWorkOfferedListener` holds a Postgres connection for the life of the process and returns a `stop()` for giving it back, and the return value was dropped on the floor. The shutdown beside it stops the two listeners either side and says why in its own comment: "so a watch-mode restart does not leave two behind on every reload". This one was the third, and it was the one not stopped, so every reload of `bun run dev` on a deployment with handing work between Bots switched on left a connection behind. Held at module scope because the listener is started inside the gate that may never run, and the shutdown is at the top level: undefined there means a deployment with the capability off, which never started one. Checked rather than assumed: `pg_stat_activity` goes from one row to two when the listener starts and back to one after `stop()`. * Say in the README that this is a template to clone, not a product The framing existed only in a code comment ("OpenBot exists to be forked"), which is the wrong place for the first thing somebody needs to know: that there is nothing to sign up for, nothing to install as a dependency, and that the example tenant package is a worked example to replace rather than a default to keep. Read without it, "Quick start" looks like the way you use OpenBot rather than the way you try the starting point before making it yours. Placed above the alpha and laptop callouts because it frames both. * Add a setup prompt for somebody doing this with an AI, and link it from the README Reported as hard to set up even with an assistant helping, and the reason is legible in `.env.example`: it ships ten empty keys, and only three of them are a person's to fill. The start script defaults two and generates the third, so an assistant reading the file alone will walk somebody through seven values that fill themselves. `prompt.txt` says which three, what each start-up refusal means in the words the server actually prints, and what an assistant must not do on somebody's behalf: run the browser sign-in, echo their keys back, or change the Intelligence URLs, which are correct as shipped and not self-serve to replace. Every claim in it was checked against this repository rather than written from memory: the pinned Bun version, the port defaults, the five refusal messages quoted verbatim, and that TENANT_PACKAGE_DIR is relative to `server/` rather than the root, which is why the default carries a `../` that is easy to drop. --------- Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
1 parent fb0c797 commit fbbe2c6

49 files changed

Lines changed: 8759 additions & 253 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,34 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
88

99
## Unreleased
1010

11+
### A Bot's answer comes back to the conversation that asked
12+
13+
**This reverses what 0.0.5 shipped.** The 0.0.5 notes below say the asking Bot does not relay text
14+
on the addressed Bot's behalf, and the answer lands in that Bot's own conversation. In practice
15+
that meant reading the answer somewhere you never asked anything, so it is now the other way
16+
around: the addressed Bot works in a scratch conversation nobody is shown, and the asking Bot
17+
relays what came back — attributed by name — into the conversation you are watching. What you read
18+
is the asking Bot's account of the answer rather than the answer verbatim; very long answers are
19+
clipped to keep the relay itself from failing.
20+
21+
### Channels say when a Bot is working in them
22+
23+
A channel whose Bot is mid-turn shows a working indicator on its roster avatar — including turns no
24+
browser started, such as a handoff running on the server or a routine. An open conversation also
25+
picks up turns that arrived while nobody here streamed them, so a relayed answer appears without
26+
leaving and coming back.
27+
28+
### First sign-in gets an onboarding wizard
29+
30+
A new person lands in a short welcome wizard before the app; everyone who signed in before this
31+
upgrade is stamped as already onboarded by the migration and sees nothing.
32+
33+
### Shift+N starts a new chat from anywhere
34+
35+
Bound across the signed-in app, shown under **Settings → Keyboard shortcuts**, and inert while you
36+
are typing in a field. Handoff work is also picked up the moment it is queued rather than at the
37+
next poll, so an answer's round trip no longer pays up to two seconds per leg.
38+
1139
### A Bot's shell can no longer reach the embedded database without a password
1240

1341
In the all-in-one image the cluster was `trust`-auth on loopback, and the Bot's shell runs in the
@@ -113,9 +141,10 @@ separately in #287.
113141
A Bot asked something it is not the right Bot for can now put the question to one that is. The
114142
addressed Bot answers **as itself, in its own conversation**, with its own tools and its own
115143
knowledge. The asking Bot does not relay text on its behalf, so what you read is the answer that
116-
Bot actually gave rather than another Bot's summary of it. The asking conversation records that the
117-
question was put and to whom. A Bot that judges no other Bot will do can instead reach the person
118-
who asked it.
144+
Bot actually gave rather than another Bot's summary of it. *(Reversed since: see Unreleased — the
145+
answer is now relayed back into the conversation that asked.)* The asking conversation records
146+
that the question was put and to whom. A Bot that judges no other Bot will do can instead reach
147+
the person who asked it.
119148

120149
**No Bot may address any other until an administrator says so.** Which Bot may reach which is an
121150
ordinary grant, made per Bot on that Bot's own screen under **Bots it may ask**, and a Bot with no

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ your own machine.
2525

2626
</div>
2727

28+
> **A template, not a product.** OpenBot is meant to be cloned and made your own. There is no hosted version to sign up for, and nothing here is published as a package to depend on: every workspace in this repository is private. You take the repository, replace the example tenant package under `examples/` with your own coworkers, channels and skills, and run it. Everything below describes a starting point, not a finished thing somebody operates for you.
29+
2830
> **Alpha, and under active development.** OpenBot is early. Expect rough edges and bugs, and expect things to move. Issues and pull requests are welcome.
2931
3032
> **Runs on your machine.** Everything below is written for a laptop. `.env.example` carries `OPENBOT_SINGLE_USER=true`, which admits every request as one administrator, so a fresh clone reaches the product without registering an OAuth client first. [Sign-in](#sign-in) turns that off, and is required before anybody else can reach the deployment.
@@ -57,6 +59,11 @@ A Bot is any endpoint speaking [AG-UI](https://github.com/ag-ui-protocol/ag-ui),
5759

5860
## Quick start
5961

62+
> **Setting up with an AI assistant?** Paste [`prompt.txt`](prompt.txt) into it first. It carries the
63+
> same steps as below plus the things that are easy to get wrong: which of the ten blank keys in
64+
> `.env.example` are actually yours to fill (three), which the start script generates for you, and
65+
> what each start-up refusal means. Every claim in it is checked against this repository.
66+
6067
1. Create `.env`:
6168

6269
```sh

app/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"@shadcn/react": "^0.3.0",
2222
"@tabler/icons-react": "^3.36.1",
2323
"@tanstack/react-form": "^1.33.5",
24+
"@tanstack/react-hotkeys": "^0.10.0",
2425
"@tanstack/react-query": "^5.101.4",
2526
"@tanstack/react-router": "^1.170.27",
2627
"better-auth": "^1.6.27",
@@ -31,6 +32,7 @@
3132
"prompt-area": "^0.6.3",
3233
"react": "^19.2.0",
3334
"react-dom": "^19.2.0",
35+
"react-use-measure": "^2.1.7",
3436
"shadcn": "^4.17.0",
3537
"streamdown": "^2.5.0",
3638
"tailwind-merge": "^3.6.0",

app/src/components/app-sidebar/app-sidebar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ function ChannelRow({
199199
}
200200
pinned={channel.pinned}
201201
unread={unread}
202+
busy={channel.busy ?? false}
202203
/>
203204
</motion.div>
204205
);

app/src/components/app-sidebar/channel.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export const Channel = memo(function Channel({
4343
lastMessageAt,
4444
pinned,
4545
unread,
46+
busy,
4647
}: {
4748
channelId: string;
4849
participantIds: string[];
@@ -51,6 +52,7 @@ export const Channel = memo(function Channel({
5152
lastMessageAt?: string;
5253
pinned: boolean;
5354
unread: boolean;
55+
busy: boolean;
5456
}) {
5557
const queryClient = useQueryClient();
5658
const navigate = useNavigate();
@@ -110,7 +112,11 @@ export const Channel = memo(function Channel({
110112
}}
111113
>
112114
<div className="">
113-
<ChannelAvatar participantIds={participantIds} size={32} />
115+
<ChannelAvatar
116+
participantIds={participantIds}
117+
size={32}
118+
typing={busy}
119+
/>
114120
</div>
115121
<div className="flex-col min-w-0 flex-1">
116122
<div className="flex flex-row items-center justify-between gap-2">
Lines changed: 48 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,81 @@
11
import Avatar from "boring-avatars";
22
import { memo } from "react";
3+
import { cn } from "@/lib/utils";
34

45
/**
56
* Memoized roster avatar. Row updates usually change preview/timestamp only, and
67
* `use-channel-events` preserves participant id arrays for unchanged rows.
78
*
89
* `size-full` opts the generated SVG out of ancestor icon selectors such as
910
* `[&_svg:not([class*='size-'])]:size-4`.
11+
*
12+
* `typing` overlays a working indicator at the bottom-right — three bouncing dots, so a channel
13+
* whose agent is mid-turn reads as busy from the roster without moving the row's layout.
1014
*/
1115
export const ChannelAvatar = memo(function ChannelAvatar({
1216
participantIds,
1317
size = 32,
18+
typing = false,
1419
}: {
1520
participantIds: string[];
1621
size?: number;
22+
typing?: boolean;
1723
}) {
1824
const channelSize = participantIds?.length;
1925

20-
if (channelSize === 1) {
21-
return (
22-
<div className="" style={{ height: size, width: size }}>
23-
<Avatar className="size-full" name={participantIds[0]} size={size} />
24-
</div>
25-
);
26-
}
27-
28-
const firstThree = participantIds.slice(0, 3);
29-
30-
return (
31-
<div
32-
className="flex flex-row items-center"
33-
style={{ height: size, width: size }}
34-
>
35-
{firstThree.map((c, i) => {
36-
return (
26+
const avatar =
27+
channelSize === 1 ? (
28+
<Avatar className="size-full" name={participantIds[0]} size={size} />
29+
) : (
30+
<div className="flex flex-row items-center size-full">
31+
{participantIds.slice(0, 3).map((c, i, shown) => (
3732
<div
38-
key={c}
3933
className="shrink-0 border-2 border-sidebar rounded-full flex items-center justify-center"
34+
key={c}
4035
style={{
41-
height: size / (firstThree.length / 2),
42-
width: size / (firstThree.length / 2),
36+
height: size / (shown.length / 2),
37+
width: size / (shown.length / 2),
4338
transform: `translateX(${i * -75}%)`,
4439
}}
4540
>
4641
<Avatar
4742
className="size-full"
4843
name={c}
49-
size={size / (firstThree.length / 2)}
44+
size={size / (shown.length / 2)}
5045
/>
5146
</div>
52-
);
53-
})}
47+
))}
48+
</div>
49+
);
50+
51+
return (
52+
<div className="relative" style={{ height: size, width: size }}>
53+
{avatar}
54+
{typing ? <TypingBadge /> : null}
5455
</div>
5556
);
5657
});
58+
59+
/**
60+
* Three bouncing dots in a small badge, ringed in the sidebar's own colour so it sits on the
61+
* avatar as a badge rather than floating over it. The staggered negative delays start each dot at
62+
* a different point in the same bounce, which is what makes the three read as one wave.
63+
*/
64+
function TypingBadge() {
65+
return (
66+
<div className="absolute -bottom-0.5 -right-0.5 flex items-center gap-0.5 rounded-full bg-sidebar p-0.5 ring-2 ring-sidebar">
67+
<span className="sr-only">Working…</span>
68+
<Dot className="[animation-delay:-0.3s]" />
69+
<Dot className="[animation-delay:-0.15s]" />
70+
<Dot />
71+
</div>
72+
);
73+
}
74+
75+
function Dot({ className }: { className?: string }) {
76+
return (
77+
<span
78+
className={cn("size-1 rounded-full bg-primary animate-bounce", className)}
79+
/>
80+
);
81+
}

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

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,23 @@ import {
1414
transcriptMessages,
1515
} from "@/components/channels/transcript-messages";
1616
import { agentListQueryOptions } from "@/lib/agents/queries";
17-
import { recordChannelActivityMutationOptions } from "@/lib/channels/mutations";
18-
import type { AgentChannel } from "@/lib/channels/queries";
17+
import {
18+
recordChannelActivityMutationOptions,
19+
setChannelBusyMutationOptions,
20+
} from "@/lib/channels/mutations";
21+
import {
22+
type AgentChannel,
23+
type ChannelSummary,
24+
channelKeys,
25+
} from "@/lib/channels/queries";
1926
import { useActiveBot } from "@/lib/copilot/active-bot";
2027
import { ConversationProvider } from "@/lib/copilot/conversation";
2128
import { afterMs, joinWithin } from "@/lib/copilot/join-thread";
2229
import { repairUnansweredToolCalls } from "@/lib/copilot/repair-history";
2330
import { stoppedReason } from "@/lib/copilot/stopped-turn";
2431
import { readThreadMessages } from "@/lib/copilot/thread-messages";
2532
import { useSkillCommands } from "@/lib/plugins/skill-commands";
33+
import { queryClient } from "@/query-client";
2634
import { newId } from "../../lib/new-id";
2735

2836
/**
@@ -182,6 +190,73 @@ export function ChannelChat({
182190
};
183191
}, [copilotkit, agent, isReady, channel.threadId, runtimeAgentId]);
184192

193+
/*
194+
* A turn nobody here streamed, surfaced while the channel is open.
195+
*
196+
* A relayed handoff answer runs on the server and lands in this thread with no browser attached.
197+
* The roster hears about it — the activity socket patches the channel-list cache — but this
198+
* transcript restores history once, on mount, and would show the new turn only after leaving and
199+
* coming back. So it watches that same cache: when this channel's `lastMessageAt` advances to a
200+
* moment a Bot authored, the durable history is read again. Riding the roster's own cache rather
201+
* than a second subscription means "the sidebar updated" and "the transcript refreshes" are the
202+
* one signal, and cannot drift apart.
203+
*
204+
* APPENDED BY ID, NOT COMPARED BY LENGTH. The stored history is not the local transcript: it
205+
* keeps only what `readableTurns` can parse, and the local side keeps tool lines the platform
206+
* does not hand back — so after a headless turn the stored read can be shorter than the screen
207+
* and still hold the news. What is new is exactly the messages whose ids this transcript has
208+
* never seen; appending them leaves everything local intact, and this tab's own turns echo back
209+
* with ids already on screen and append nothing.
210+
*
211+
* Retried briefly, because the roster is patched when the turn is on record with the runner and
212+
* the platform's read of the thread can be a beat behind it.
213+
*/
214+
useEffect(() => {
215+
const authoredAt = () => {
216+
const cache = queryClient.getQueryData<{
217+
pages: { channels: ChannelSummary[] }[];
218+
}>(channelKeys.list());
219+
const summary = cache?.pages
220+
.flatMap((page) => page.channels)
221+
.find((row) => row.id === channel.id);
222+
// Only a Bot's turn is news here; a person's own line arrives through the run that sent it.
223+
if (!summary || summary.lastMessageAgentId === null) return null;
224+
return summary.lastMessageAt;
225+
};
226+
227+
let lastSeen = authoredAt();
228+
229+
const pull = () => {
230+
void (async () => {
231+
for (const delayMs of [0, 750, 1500]) {
232+
if (delayMs > 0) {
233+
await new Promise((resolve) => setTimeout(resolve, delayMs));
234+
}
235+
const stored = await readThreadMessages(
236+
channel.threadId,
237+
runtimeAgentId,
238+
);
239+
const current = agentRef.current;
240+
const seen = new Set(current.messages.map((message) => message.id));
241+
const fresh = stored.messages.filter(
242+
(message) => !seen.has(message.id),
243+
);
244+
if (fresh.length === 0) continue;
245+
current.setMessages([...current.messages, ...fresh]);
246+
return;
247+
}
248+
})();
249+
};
250+
251+
return queryClient.getQueryCache().subscribe(() => {
252+
const at = authoredAt();
253+
if (at && at !== lastSeen) {
254+
lastSeen = at;
255+
pull();
256+
}
257+
});
258+
}, [channel.id, channel.threadId, runtimeAgentId]);
259+
185260
// Tool calls from this conversation act on this coworker's own computer.
186261
useActiveBot(runtimeAgentId);
187262

@@ -222,6 +297,24 @@ export function ChannelChat({
222297
* Tell the roster what was just said. Failures here must not block the conversation.
223298
*/
224299
const recordActivity = useMutation(recordChannelActivityMutationOptions());
300+
301+
/*
302+
* Show this channel as working on the roster while its own turn runs.
303+
*
304+
* The server cannot see a person's turn begin — the runtime does not tell it — so the browser
305+
* reports it, keyed on whether a turn is in flight. The server broadcasts it to every member, so
306+
* the row shows the dots even on a tab that has since navigated elsewhere; a run that outlives
307+
* this tab clears itself when the roster next refetches, which is the acceptable failure for a
308+
* transient hint. Not cleared on unmount on purpose: a turn keeps running server-side after the
309+
* person leaves the channel, and clearing here would drop the indicator while the work goes on.
310+
*/
311+
const setBusy = useMutation(setChannelBusyMutationOptions());
312+
const busy = turnsInFlight > 0;
313+
// Keyed on the busy transition alone; `setBusy.mutate` is a stable handle, not a dependency.
314+
// biome-ignore lint/correctness/useExhaustiveDependencies: firing on the busy transition only.
315+
useEffect(() => {
316+
setBusy.mutate({ channelId: channel.id, busy });
317+
}, [busy, channel.id]);
225318
const report = (text: string, agentId: string | null) => {
226319
const trimmed = text.trim();
227320
if (!trimmed) return;

0 commit comments

Comments
 (0)