Skip to content

fix(app): isolate pane query reads behind suspense islands so the route boundary never detaches the composer (#346) - #391

Merged
omridevk merged 3 commits into
mainfrom
fix/346-panel-focus-suspense
Aug 11, 2026
Merged

fix(app): isolate pane query reads behind suspense islands so the route boundary never detaches the composer (#346)#391
omridevk merged 3 commits into
mainfrom
fix/346-panel-focus-suspense

Conversation

@omridevk

@omridevk omridevk commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Closes #346. Full rework after review feedback — the earlier read-guard helper is gone; this is the platform approach.

Mechanism

solid-router wraps every route Match in Solid.Suspense with an undefined fallback. A solid-query .data read from a tracked scope (memo or JSX render effect — component-body reads run untracked and never suspend) while the query is pending-with-no-cached-data registers with the NEAREST Suspense boundary. With no inner boundaries that's the route Match: the whole subtree detaches, silently. The worst case was the FAB's class reading working() (→ sessions.list) — it suspended the ROOT Match, unmounting the entire widget until the session list resolved. The composer-focus flake was the same detach hitting the panel route between paint and TipTap's rAF-deferred focus().

Fix — Suspense islands with real loading states

Reads stay plain and idiomatic. Each query-reading region gets its own <Suspense> with a designed fallback (skeleton chips, role=\"status\" + sr-only text, motion-reduce respected): session pill, usage chip, view tabs, composer actions, thread conversation, quick-pane variants, and the FAB working-ring (fallback: the calm FAB — button, ref, aria and mascot rig sit outside the boundary and can never unmount). Nearest-boundary-wins keeps the route Match from ever tripping. Invariant verified by tree-trace: the composer input shares a boundary with no query read.

Bug fixed in flight: toolCtx.harnessId was an eager body-level snapshot taken while meta was always pending — frozen at '' forever. Now a getter, read inside the Thread island.

Regression test

Holds the first /rpc/sessions/list (widget pinned to the retained fetch transport): pre-fix, even the FAB is absent from the DOM (verbatim red run in the work log); post-fix the shell paints with skeletons, the composer focuses and accepts typing while the query is still pending, and text survives the fallback→content swap.

Verified

typecheck/lint/format green; @conciv/app 98/98; @conciv/embed 121/121; panel-focus suite ×3 consecutive green; fallow 0 introduced; FAB working-ring before/after frame-capture identical.

Flagged, not fixed here

  • rebind.it.test.ts:192 frame-count flake (one occurrence, passes isolated + rerun; outside this diff's ownership).
  • imageInput's pre-existing isPending gate in chat-pane needs paneAttachments reshaped to take an accessor before the last hand-rolled guard can go.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added loading placeholders for session selection, usage details, view tabs, and composer actions.
    • Added loading feedback in the panel and quick terminal header while content is unavailable.
    • Added a dedicated busy indicator for the floating action button.
  • Bug Fixes

    • Improved panel responsiveness so the composer remains available while session data loads.
    • Updated busy-state styling to display consistently across chat states.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@omridevk, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 30 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e325a361-e0dd-483f-8422-fd9c0020e156

📥 Commits

Reviewing files that changed from the base of the PR and between 903d634 and bde77b5.

📒 Files selected for processing (1)
  • packages/embed/tests/e2e/panel-focus.it.test.ts
📝 Walkthrough

Walkthrough

The PR adds accessible suspense fallbacks for panel, quick-pane, chat, and composer content. It replaces the FAB working attribute with a busy indicator. It adds delayed-session focus coverage and waits for page-query RPC completion in the test helper.

Changes

Conciv loading states

Layer / File(s) Summary
Pending UI components
apps/conciv/src/shell/pending.tsx
Adds accessible skeleton components for session, usage, view-tab, and composer-action loading states.
Route and chat suspense boundaries
apps/conciv/src/routes/panel.$sessionId.tsx, apps/conciv/src/routes/quick.tsx, apps/conciv/src/pane/chat-pane.tsx
Adds suspense boundaries and explicit fallbacks for session, usage, tabs, conversation content, and composer actions.
FAB busy state
apps/conciv/src/shell/fab.tsx, apps/conciv/src/shell/fab-robot.tsx, apps/conciv/src/styles.css
Renders a busy indicator while work is active and updates the eye-glow selector to use that indicator.
Panel loading validation
packages/embed/test/panel-focus.it.test.ts, packages/extensions/tanstack/test/helpers/tanstack-test-api.ts
Blocks the initial session request to test panel rendering, focus, and typing. The widget helper waits for page-query RPC completion.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main Suspense-island change and its purpose of preserving the composer during pending queries.
Linked Issues check ✅ Passed The PR addresses the focus race in [#346] with Suspense isolation, a regression test, and no timeout-only workaround.
Out of Scope Changes check ✅ Passed The changes support the linked issue by preserving the panel shell, adding loading states, and synchronizing the related test helper.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/346-panel-focus-suspense

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Prevents pending Solid Query reads from suspending route DOM and disrupting composer focus.

Changes:

  • Adds a suspense-safe settledData helper.
  • Guards session, model, metadata, marker, and catalog reads.
  • Adds a browser regression test for delayed session loading.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
apps/conciv/src/data/settled-data.ts Adds the guarded query-data reader.
apps/conciv/src/routes/__root.tsx Guards launcher session state.
apps/conciv/src/routes/quick.tsx Guards quick-pane session usage.
apps/conciv/src/routes/panel.$sessionId.tsx Guards panel session lookup.
apps/conciv/src/pane/chat-pane.tsx Guards pane query results.
apps/conciv/src/composer/session-selector.tsx Guards session rows.
apps/conciv/src/composer/model-selector.tsx Guards model and session metadata.
apps/conciv/src/composer/actions.tsx Guards harness metadata.
packages/embed/test/panel-focus.it.test.ts Adds the delayed-response focus regression test.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +79 to +96
const host = await serveHost(() =>
hostPage({apiBase: suite.kit().base, widget: '{"quickTerminal":false,"transport":"fetch"}'}),
)
const page = await suite.browser().newPage()
const releaseSessionList = await holdFirstSessionList(page)
await page.goto(host.base, {waitUntil: 'domcontentloaded'})
try {
await expectLocator(page.getByRole('button', {name: 'Open conciv chat'})).toBeVisible({timeout: 15_000})
} finally {
releaseSessionList()
}
await openPanel(page)
await expectLocator(sessionPill(page)).toBeVisible({timeout: 30_000})
await expectLocator(composer(page)).toBeFocused({timeout: 10_000})
await page.keyboard.type('typed after the session list resolved')
await expectLocator(composer(page)).toHaveText('typed after the session list resolved')
await page.close()
await host.close()
@omridevk
omridevk force-pushed the fix/346-panel-focus-suspense branch from 92a4cfc to eb12fad Compare August 10, 2026 01:00
@omridevk omridevk changed the title fix(app): keep composer focus by making pane query reads suspense-safe (#346) fix(app): isolate pane query reads behind suspense islands so the route boundary never detaches the composer (#346) Aug 10, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/extension-testkit/src/get-extension-test-api.ts`:
- Around line 63-67: Update the dispose callback to guarantee the cleanup
sequence continues when closeBrowser() or close() fails: run close() in a
finally block after the browser-close attempt, and stop() in a nested/final
finally so it always executes. Preserve the first cleanup error and rethrow it
only after stop() completes, while retaining the existing timeout call and
cleanup order.

In `@packages/harness-testkit/src/call-tool.ts`:
- Around line 123-128: Update the approval-pump handling around drainApprovals
so failures are not unconditionally swallowed. Suppress only the expected stream
error triggered by abort.abort(), while propagating other failures—including
permissionDecision deadline rejections—through the existing deadline('testkit
approval pump drain', ...) flow. Add test coverage for a rejected permission
decision.

In `@packages/harness-testkit/src/deadline.ts`:
- Around line 3-16: Update deadline to accept a work function receiving an
AbortSignal instead of an already-started PromiseLike, create an
AbortController, and pass its signal to the work function. Abort the controller
when the timer expires, while preserving resolution and rejection cleanup; also
add late-result cleanup so a result produced after timeout is stopped or
otherwise disposed when cancellation is unsupported.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fa5255fb-9554-494c-905b-994fc8607a12

📥 Commits

Reviewing files that changed from the base of the PR and between 92a4cfc and eb12fad.

📒 Files selected for processing (17)
  • apps/conciv/src/pane/chat-pane.tsx
  • apps/conciv/src/routes/panel.$sessionId.tsx
  • apps/conciv/src/routes/quick.tsx
  • apps/conciv/src/shell/fab-robot.tsx
  • apps/conciv/src/shell/fab.tsx
  • apps/conciv/src/shell/pending.tsx
  • apps/conciv/src/styles.css
  • packages/extension-testkit/src/boot-server.ts
  • packages/extension-testkit/src/get-extension-test-api.ts
  • packages/harness-testkit/package.json
  • packages/harness-testkit/src/call-tool.ts
  • packages/harness-testkit/src/deadline.ts
  • packages/harness-testkit/src/session.ts
  • packages/harness-testkit/src/testkit.ts
  • packages/harness-testkit/src/until.ts
  • packages/harness-testkit/test/deadline.test.ts
  • packages/harness-testkit/test/until.test.ts

Comment thread packages/extension-testkit/src/get-extension-test-api.ts
Comment on lines +123 to +128
const pump = drainApprovals(rpc, stream, onApproved).catch(() => {})
try {
return await run()
} finally {
abort.abort()
await pump.catch(() => {})
await deadline('testkit approval pump drain', TESTKIT_DEADLINE_MS, pump)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Do not discard approval-pump failures.

Line 123 converts every drainApprovals failure into fulfillment. If rpc.chat.permissionDecision() exceeds its deadline, line 128 observes a successful pump. The test then fails later with an unrelated timeout, or it can return a successful run() result.

Preserve non-abort errors from the approval pump. Suppress only the expected stream error caused by abort.abort(). Add coverage for a rejected permission decision.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/harness-testkit/src/call-tool.ts` around lines 123 - 128, Update the
approval-pump handling around drainApprovals so failures are not unconditionally
swallowed. Suppress only the expected stream error triggered by abort.abort(),
while propagating other failures—including permissionDecision deadline
rejections—through the existing deadline('testkit approval pump drain', ...)
flow. Add test coverage for a rejected permission decision.

Comment on lines +3 to +16
export function deadline<Result>(label: string, budgetMs: number, work: PromiseLike<Result>): Promise<Result> {
return new Promise<Result>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`${label} exceeded ${budgetMs}ms`)), budgetMs)
Promise.resolve(work).then(
(value) => {
clearTimeout(timer)
resolve(value)
},
(error: unknown) => {
clearTimeout(timer)
reject(error)
},
)
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Make timed-out work cancellable.

work starts before deadline receives it. Line 5 rejects only the wrapper promise. The underlying operation continues after timeout.

A timed-out start() can later create an Engine that no caller stops. This can leave a server and temporary state directory active after the test fails.

Change deadline to accept a work function with an AbortSignal. Abort it on timeout. Add late-result cleanup for operations that cannot accept cancellation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/harness-testkit/src/deadline.ts` around lines 3 - 16, Update
deadline to accept a work function receiving an AbortSignal instead of an
already-started PromiseLike, create an AbortController, and pass its signal to
the work function. Abort the controller when the timer expires, while preserving
resolution and rejection cleanup; also add late-result cleanup so a result
produced after timeout is stopped or otherwise disposed when cancellation is
unsupported.

@omridevk

Copy link
Copy Markdown
Contributor Author

Second commit added after the shard-7 RCA: da27bba6 — the framework-adapter failure was NOT a defect in this diff. The islands change makes the FAB paint before its data resolves (the point of the PR), which broke tanstack-test-api's accidental synchronization: it used FAB visibility as a proxy for "widget connected", and that proxy only ever held because the pre-#346 FAB suspended on the sessions query until after the widget's page/queries subscription reached the server. The helper now gates on the real signal (rpc observer completion for page/queries, subscribed before the visibility wait). Deterministic: 3/3 red without the helper fix, 5/5 green with it, revert-check clean.

Latent follow-up worth a decision (not changed here): adapter.client.detect swallows every error into null, making "not a TanStack app" indistinguishable from "widget not connected" — that's why this raced silently instead of ever saying no widget connected.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

omridevk and others added 3 commits August 11, 2026 02:45
…te boundary never detaches the composer (#346)

solid-router wraps every route Match in a Suspense with an undefined
fallback. Solid registers a suspension when a solid-query `.data` read
runs inside a non-user computation (a memo or a JSX render effect) while
the query is pending with no cached data, and the nearest enclosing
Suspense catches it. With no inner boundary that was the route Match, so
the whole subtree detached: blank shell, composer unmounted, focus lost.

Per the user's direction this uses the platform rather than guarding the
reads: every query-reading island now sits in its own Suspense with a
real loading state (session pill, context usage, view tabs, composer
actions, thread) and the reads stay plain. The root FAB's working state
became a real ring element inside its own boundary, so the button, its
ref and the mascot rig never suspend. The composer input shares a
boundary with no query read at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… page tools (#346)

waitForWidget treated "the FAB is visible" as "the widget is connected". That
held only by accident: the FAB's class read sessions.data, so the whole button
suspended until that query round trip finished, which was always after the page
plane's page.queries subscription reached the server. #346 moved the working()
read into its own Suspense island, so the FAB now paints immediately and the
accidental synchronisation is gone. The first adapter.client.detect() then hit
an empty page bus, got NO_PAGE_CLIENT, and the adapter's catch turned it into
null.

Gate the helper on the real readiness signal instead: the rpc observer's
completed() for the page.queries subscription.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…wright Test

Main moved the embed integration suite off vitest onto @playwright/test under
tests/e2e (#415) since this branch opened. Port the pending test from the old
packages/embed/test/panel-focus.it.test.ts onto the new suite: native
expect()/test.describe idiom, hostPage/serveHost from tests/helpers, the
page.route hold of the first /rpc/sessions/list carried over unchanged.
@omridevk
omridevk force-pushed the fix/346-panel-focus-suspense branch from 903d634 to bde77b5 Compare August 10, 2026 23:52
@omridevk
omridevk merged commit c24f1e5 into main Aug 11, 2026
42 of 46 checks passed
@omridevk
omridevk deleted the fix/346-panel-focus-suspense branch August 11, 2026 01:08
omridevk added a commit that referenced this pull request Aug 12, 2026
…ver disposes the composer (#434) (#440)

ChatPane builds its ToolViewCtx eagerly, in imperative body code that runs
before any of its JSX (and before any of the #391 Suspense islands) exist.
makeToolViewCtx() immediately invokes the harnessId callback passed to it
(harnessId: deps.harnessId()), which read meta.data unguarded. Per
@tanstack/solid-query's useBaseQuery, .data reads while a query is pending
with no cached value call the underlying resource accessor
(queryResource()), which is solid-query's Suspense-registering read. That
read executed under ChatPane's own owner, which traces up through the
Show in ChatPaneRoute to the Match-level Suspense that @tanstack/solid-router
wraps every route match in (node_modules/@tanstack/solid-router
dist/esm/Match.js:355) -- not any of the #391 islands, which only own their
own JSX children and can't retroactively cover code that ran before they
were created. So a slow /rpc/meta/models response suspended the whole
route match, detaching and remounting ChatPane -- exactly the #346/#391
mechanism, just through an eager pre-JSX read #391 didn't touch.

The fix follows the guard pattern already established in this same file
(imageInput already checks meta.isPending before touching meta.data): guard
harnessId the same way, so the read never fires while meta is pending and
never registers a suspense boundary. No new pattern, no behavior change --
harnessId already fell back to '' whenever meta.data was undefined.

Investigated but not changed: the markers/list-delay failure the prior
round also isolated. dividersAt/dividersInRange only read markers.data
inside JSX already wrapped by the Thread Suspense island (chat-pane.tsx
line 317, from #391). Solid's JSX children are compiler-emitted getters, so
these reads are lazily evaluated exactly when Suspense (and further-nested
<For>/<Show> primitives) evaluate their children -- confirmed against the
babel-preset-solid compiled output for this exact code shape, solid-router's
Match/Outlet source, and the Thread.Messages implementation in
@conciv/ui-kit-chat. No second eager, unguarded read of markers.data was
found anywhere reachable from ChatPane. Empirically, an instrumented
onCleanup on ChatPane showed a single mount/dispose pair (at teardown)
across 10 unstarved runs of all three focus-stability tests, both before
and after this fix -- consistent with the established finding that the
failure needs CPU starvation to manifest, which this investigation does not
reproduce.

Extends panel-focus-stability.browser.test.tsx with a composer DOM identity
check (captures the editor node once visible, asserts isSameNode once
settled) as a standing guard against detach/remount. This does not fail on
unstarved main with the bug present (confirmed by reverting the fix and
rerunning), so it is a guard, not a proven red/green regression test.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

Flaky: panel-focus.it.test.ts 'focuses the composer input when the panel opens' failed once in a serial embed gate, green in isolation and clean re-run

2 participants