Skip to content

fix(desktop): recover both boundaries from assistant-ui lookup races, reactive edit context - #81232

Merged
OutThisLife merged 4 commits into
mainfrom
bb/desktop-boundary-recovery-triage
Aug 7, 2026
Merged

fix(desktop): recover both boundaries from assistant-ui lookup races, reactive edit context#81232
OutThisLife merged 4 commits into
mainfrom
bb/desktop-boundary-recovery-triage

Conversation

@OutThisLife

Copy link
Copy Markdown
Collaborator

What does this PR do?

Consolidates the two open PRs fixing the same assistant-ui lookup race into one
change, because they fix that race at two complementary layers and collide on a
shared test file.

@assistant-ui/store's index-keyed child-scope lookup throws — rather than
returning undefined — when a subscriber reads an index the message/parts list
no longer has. It races during high-frequency store replacement (session switch
mid-stream, gateway reconnect replay, post-compaction list shrink). On current
main that race has two ways to blank the UI, and neither self-heals:

1. The message-local boundary strands a turn mid-stream.
MessageRenderBoundary swallows the transient throw and renders null until
resetKey changes. Since #72504 that key is deliberately structural-only
(list.tsx structuralSignature) — the right call, it stopped per-token
reconciles of every turn — but that removed the implicit recovery: mid-turn the
structure is stable, so a race during a stream leaves the turn blank until an
unrelated message add/remove. The boundary now self-retries on a 0ms timer
(rAF never fires in a parked renderer), bounded to 5 consecutive transient
catches with the budget reset on successful recovery. A persistent failure falls
back to the old wait-for-structure behavior, and non-transient errors still
re-throw to the root.

2. What escapes the local boundary strands the whole app. The root
ErrorBoundary only logs in componentDidCatch, so the root fallback sticks
until the user restarts the app. Root recovery for exactly this error family
existed (added in 2e3efce66, merged as #52704) and was removed by 344415892.
This restores it, narrowly: root-label-gated, matching only the lookup
out-of-bounds family, bounded to 3 attempts per 5s window, with persistent
failures still visible and manually retryable. Also fixes a StrictMode
lifecycle bug found while exercising the real root composition — the synthetic
unmount cleared the timer scheduled by componentDidCatch and the replayed
mount never restored it, so recovery ownership is now tracked separately from
the timer handle.

Both layers share one classifier, /(useClientLookup|tapClient(Lookup|Resource)).*out of bounds/.
The current production error name is useClientLookup (renamed upstream in
@assistant-ui/store@0.2.19); the legacy tapClient* names stay covered.

3. An open edit composer keeps a stale cwd / gateway / sessionId. #72524
moved those three values out of the messageComponents memo deps into a
render-time ref so session switches stop reminting the component types — load
bearing on the hot path. But a composer that is already open never re-reads
the ref: a same-session change (the agent relocating the session's cwd via
session.info, a gateway reconnect) leaves every ThreadMessageList prop
referentially equal, so the memo'd list bails out and @-completions, slash
completions, and OS-drop uploads act on the old context. Thread now provides
the three values through a memoized ThreadEditContext. Context propagates
through the bail-out, component type identity is untouched, and the transcript
never remounts.

Related Issue

Fixes #72866
Fixes #64308
Supersedes #72867
Supersedes #64310

Related to the broader assistant-ui race tracked by #45403.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Security fix
  • Documentation update
  • Tests (adding or improving test coverage)
  • Refactor (no behavior change)
  • New skill (bundled or hub)

Changes Made

  • apps/desktop/src/components/assistant-ui/message-render-boundary.tsx:
    bounded timer self-retry for transient lookup errors, budget reset on
    recovery, timer cleared on unmount and on structural reset.
  • apps/desktop/src/components/error-boundary.tsx: root-only bounded auto
    recovery for the same error family, recovery ownership preserved across a
    StrictMode synthetic unmount without weakening real-unmount cleanup,
    window reset at the 5s boundary, manual reset cancels an active timer and
    starts a fresh budget, RootErrorBoundary composition so only the real root
    owner auto-recovers.
  • apps/desktop/src/main.tsx: uses RootErrorBoundary at the Desktop root.
  • apps/desktop/src/components/assistant-ui/thread/index.tsx: editContextRef
    replaced with a memoized ThreadEditContext provided around the list; the
    UserEditComposer wrapper reads it via useContext. Comments explain why
    neither memo deps nor a ref can carry these values.
  • Tests: timer recovery without a resetKey change, retry cap, budget reset
    across streaks, no retry for non-transient errors, classifier parity across
    useClientLookup / tapClientLookup / tapClientResource, root transient
    recovery, persistent budget exhaustion, 5s window reset, manual reset with an
    active timer, pending-timer unmount cleanup, scoped-boundary exclusion, real
    root composition under StrictMode and production-style without it,
    same-session cwd change reaching a mounted composer, session-switch control,
    and a perf invariant proving a cwd rerender remounts no transcript DOM node.

How to Test

npm --workspace apps/desktop exec -- vitest run --project ui \
  src/components/error-boundary.test.tsx \
  src/components/assistant-ui/message-render-boundary.test.tsx \
  src/components/assistant-ui/thread/edit-context.test.tsx
# 3 files, 25 tests passed

npm --workspace apps/desktop exec -- vitest run --project ui src/components/assistant-ui/
# 36 files, 304 tests passed

npm --workspace apps/desktop run typecheck   # clean (3 tsconfigs)
npm --workspace apps/desktop exec -- eslint <touched files>      # clean
npm --workspace apps/desktop exec -- prettier --check <touched>  # clean

Proof of the bugs on main: with the three source files reverted, the
timer-recovery, budget-reset, root-recovery, and same-session-cwd tests fail.

Credit

Consolidated from two independent contributions, authorship preserved via
cherry-pick:

The only edit on top of their commits was resolving an additive conflict in
message-render-boundary.test.tsx, where both PRs added distinct test helpers —
both are kept.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits
  • I searched for existing PRs to make sure this isn't a duplicate — this PR
    is the consolidation of the two that existed
  • My PR contains only changes related to this fix
  • I've run the vitest suites above and they pass
  • I've added tests for my changes
  • I've tested on my platform: Windows 11 (contributors: Windows 11, CachyOS Linux)

Documentation & Housekeeping

  • Documentation N/A — no user-facing workflow or configuration changed
  • cli-config.yaml.example N/A — no config keys changed
  • CONTRIBUTING.md / AGENTS.md N/A — no architecture change
  • Cross-platform impact considered — React class boundaries, context, and
    browser timers only; no platform API
  • Tool descriptions/schemas N/A — no tool behavior changed

Adolanium and others added 4 commits August 7, 2026 12:27
…poser context

Two correctness holes left by the session-switch perf work (#72504 / #72524):

1. MessageRenderBoundary only cleared a swallowed transient useClientLookup
   error when the structural resetKey changed. Mid-turn, ids/roles/count are
   stable, so a lookup race during a stream left the boundary rendering null
   for the rest of the turn. The boundary now self-retries on a 0ms timer
   (rAF never fires in a parked renderer), bounded to 5 consecutive
   transient catches with the budget reset on recovery; the structural
   resetKey path is unchanged, and non-transient errors still re-throw.

2. cwd / gateway / sessionId were removed from the messageComponents memo
   deps and read through a render-time ref so session switches stop
   reminting the component types. But a mounted UserEditComposer only
   reads that ref when it renders, and a same-session change (cwd remap,
   gateway reconnect) leaves every ThreadMessageList prop referentially
   equal, so the memo'd list bails out and the open composer keeps stale
   values: @-completions, slash completions, and OS-drop uploads target
   the old cwd / gateway / session. Thread now provides the three values
   through a memoized ThreadEditContext; context propagates through the
   bail-out, the component type identity is untouched, and the transcript
   never remounts.
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on 24b7ca7

⚠️ Warnings

OSV vulnerability scan · View job

52 known vulnerabilities found in pinned dependencies.

How to fix:

Review the findings in the Security tab. Update the affected dependencies if a patched version is available.


debug info

CI timings

CI timings · View report · View job

Wall time 6m24s vs 7m32s (-15.0%). 7 job(s) slower, 8 faster, 2 unchanged.

  • JS & TS checks / apps/desktop / check:test:ui: -31.0s
  • JS & TS checks / ui-tui/packages/hermes-ink / check: +20.0s
  • JS & TS checks / tests-js / check: +18.0s
  • JS & TS checks / apps/bootstrap-installer / check: +18.0s
  • JS & TS checks / apps/desktop / check:lint: -12.0s

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/desktop Electron desktop app (apps/desktop/*) area/sessions Session lifecycle, resume, persistence, history area/streaming Streaming responses: gateway delivery, provider wire sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Aug 7, 2026
@OutThisLife
OutThisLife enabled auto-merge August 7, 2026 17:43
@OutThisLife
OutThisLife merged commit df7e178 into main Aug 7, 2026
39 checks passed
@OutThisLife
OutThisLife deleted the bb/desktop-boundary-recovery-triage branch August 7, 2026 17:43
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…undary-recovery-triage

fix(desktop): recover both boundaries from assistant-ui lookup races, reactive edit context
33hodl pushed a commit to 33hodl/hermes-agent that referenced this pull request Aug 12, 2026
…undary-recovery-triage

fix(desktop): recover both boundaries from assistant-ui lookup races, reactive edit context
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/sessions Session lifecycle, resume, persistence, history area/streaming Streaming responses: gateway delivery, provider wire comp/desktop Electron desktop app (apps/desktop/*) P2 Medium — degraded but workaround exists sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

4 participants