Skip to content

Fix chat UI state that leaks across a workbench switch - #481

Merged
TheGreatAxios merged 6 commits into
mainfrom
cl-7198-fix-chat-ui-workbench-switch
Aug 30, 2026
Merged

TheGreatAxios merged 6 commits into
mainfrom
cl-7198-fix-chat-ui-workbench-switch

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Contributor

Summary

  • Send continuations no-op their thread navigation when the workbench captured at send time is no longer the active one
  • The composer's send guard now flips synchronously on a ref before any await, so two events firing in the same tick cannot both post
  • The coalescing feed refresh timer is scoped to the (tenantId, workbenchId) pair it was scheduled for, and cleared when the active workbench changes, instead of a bare timer handle shared across benches

Test plan

  • WORKBENCH_CHECK_SINCE=origin/main bun run typecheck (4 affected packages)
  • bun run lint
  • WORKBENCH_CHECK_SINCE=origin/main bun run test (chat-ui: 823 pass, 0 fail)
  • bun run check:structural
  • CI

https://linear.app/abklabs/issue/CL-7198/fix-chat-ui-state-that-leaks-across-a-workbench-switch

A send continuation reopens the reply thread it just created using the
workbench id it was sent with, with no check that workbench is still
the one on screen. Covers a continuation resolving after a switch away
(no navigation), after resolving on the same bench (navigates as
before), and after switching away and back (still navigates, since the
reader is where the send targeted).
sendPending closes over the activeWorkbenchId its own render was
called with, which never changes for the life of that async call. If
the reader switches workbenches while a reply's first send is still in
flight, the continuation resolved and reopened that reply thread
regardless — dragging the reader back into a bench they had already
left, undoing use-thread-navigation's own reset-on-switch effect.

A ref now tracks the live activeWorkbenchId; the navigation call only
fires when it still matches the value the send was made with.
The send guard tested the sending state variable, which performSend
only sets after the caller that started it has already returned. Two
triggers landing in the same tick both read sending === false and
both post. Proves a second click in the same tick as the first is
turned away.
canSendComposerAction's sending check reads React state, which
performSend only sets true after the event handler that called it has
already returned to the browser. Two sends triggered in the same
synchronous tick both read sending === false and both post, producing
two requests with two distinct nonces that nothing downstream dedupes.

The guard now lives in performSend itself (covering every caller,
including the /summarize slash command) and flips synchronously before
any await, so a second call in the same tick is turned away.
The coalescing refresh timer was a single ref, neither scoped to nor
cleared on the active workbench. A pending timer scheduled for one
workbench made a different workbench's own refreshFeed() call
early-return, and the timer then invalidated the first workbench's now-
inactive query key. Proves a refresh scheduled just before a switch
never fires for the old bench and the new bench's own refresh fires
instead, and that repeat calls for the same bench still coalesce into
one.
refreshTimerRef held a bare timer handle with no record of which
workbench scheduled it. Switching benches while a refresh was pending
made the new bench's own refreshFeed() call early-return on the old
timer, and when that timer fired it invalidated the old (now-inactive)
query key instead of ever refreshing the bench actually on screen.

The ref now carries the (tenantId, workbenchId) it was scheduled for;
refreshFeed clears a timer scheduled for a different one instead of
deferring to it, and the unmount effect's dependency array was widened
so the same clear runs on every switch, not only on unmount.
@TheGreatAxios

Copy link
Copy Markdown
Contributor Author

Review notes (code review pass)

Composer send guard, stale send-continuation navigation, and per-workbench feed-refresh timer scoping, all keyed on the ticket's three reported leaks.

Composer in-flight ref release

sendInFlightRef is set synchronously before onSend and released in a finally block (composer.tsx's performSend), so it clears on every exit path including a thrown/rejected onSend - the composer cannot wedge. Confirmed performSend is the only caller of onSend (two call sites, both routed through it, including the /summarize slash command), so the guard covers every send path.

Per-workbench refresh timer

The unmount-cleanup effect's dependency array was widened from [] to [tenantId, activeWorkbenchId], so the same clearTimeout + ref-clear now runs both on a workbench switch and on unmount, not only on unmount. refreshFeed itself also clears a stale timer scheduled for a different (tenantId, workbenchId) pair before scheduling a new one.

Discarded-diff check

Checked the branch's final state directly (git log, git diff against origin/main): all three commits/fixes are present - the composer ref, the stale-navigation no-op in use-optimistic-sends.ts, and the timer scoping in use-workbench-feed.ts. Nothing was lost.

Checks run (foreground)

  • prettier --check on all six touched/added files: clean
  • WORKBENCH_CHECK_SINCE=origin/main bun run typecheck: exit 0
  • bun run lint: 0 errors, 8 pre-existing warnings unrelated to this diff
  • packages/chat-ui test suite: flaky under current host load (act()-timing warnings and a stream-timeout in unrelated pre-existing test files - use-workbench-stream.test.tsx, team-avatar-stack.test.tsx, presence-stack.test.tsx, failed-turn-strip.test.tsx, zero-refetch-on-stream-event.test.tsx, use-streaming-reply.test.tsx, agent-typing-indicator.test.tsx, none touched by this diff); those seven files pass cleanly in isolation, and the three new/changed test files for this ticket pass consistently across three isolated runs. Treated as environmental flake under host contention, not a regression from this diff.

Commit messages clean (no ticket refs, no long lines).

No issues found. Not merging.

@TheGreatAxios
TheGreatAxios merged commit e7da00d into main Aug 30, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant