Desktop composer unavailability errors after v0.17 upgrade

NousResearch/hermes-agent · cluster 49903 · apps/desktop

Data as of

Nothing open on GitHub. Fix merged in #51728. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #51728. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #51728. That is separate from the fix event.
Still open
Nothing open on GitHub.

A cluster of bug reports and fixes addressing 'Composer is not available' errors introduced in the v0.17.0 desktop release, which caused unresponsive input and repeated process restarts. The fixes guard composer mutations and maintain usability during reconnect and boot scenarios.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #51728 fix(desktop): guard composer mutations when the composer core isn't bound (#49903)

    merged to main

    files: apps/desktop/src/app/chat/composer/composer-text-guard.test.tsx, apps/desktop/src/app/chat/composer/index.tsx

    Show the diff
    • apps/desktop/src/app/chat/composer/composer-text-guard.test.tsx
      @@ -0,0 +1,106 @@
      +// @vitest-environment jsdom
      +import { act, cleanup, render } from '@testing-library/react'
      +import { useCallback, useRef } from 'react'
      +import { afterEach, describe, expect, it, vi } from 'vitest'
      +
      +afterEach(cleanup)
      +
      +// Regression repro for #49903: on desktop v0.17.0 the composer threw an
      +// uncaught `Error: Composer is not available` at startup and the input went
      +// unresponsive. The throw comes from @assistant-ui/core's composer-runtime —
      +// every *mutator* (setText/send/…) does `if (!core) throw new Error("Composer
      +// is not available")` when the thread's composer core isn't bound yet. Unlike
      +// the read path (`s.composer.text`, which is null-safe: `runtime?.text ?? ""`),
      +// the mutators have no graceful fallback. ChatBar's mount-time effects (draft
      +// restore, clearDraft, external inserts) push text via `aui.composer().setText`
      +// before the core binds, and the popout refactor (#49488) widened that window,
      +// so the throw surfaced as an uncaught error that wedged the input.
      +//
      +// The fix wraps every `aui.composer().setText` call in a `setComposerText`
      +// helper that swallows the unbound-core throw — the contentEditable DOM +
      +// draftRef already hold the text and the draft⇄editor sync re-applies it once
      +// the core attaches, so nothing is lost. This Harness mirrors that helper
      +// faithfully (same try/catch shape) over a fake `aui` whose composer can be
      +// toggled bound/unbound, the way the assistant-ui runtime behaves across mount.
      +
      +interface FakeComposer {
      +  setText: (value: string) => void
      +}
      +
      +// Mirror of index.tsx's `useAui()` composer surface: composer() returns a
      +// runtime whose setText throws exactly like @assistant-ui/core when unbound.
      +function makeFakeAui(bound: { current: boolean }, applied: string[]) {
      +  const composer: FakeComposer = {
      +    setText(value: string) {
      +      if (!bound.current) {
      +        throw new Error('Composer is not available')
      +      }
      +
      +      applied.push(value)
      +    }
      +  }
      +
      +  return { composer: () => composer }
      +}
      +
      +function Harness({
      +  bound,
      +  applied,
      +  onError
      +}: {
      +  applied: string[]
      +  bound: { current: boolean }
      +  onError: (err: unknown) => void
      +}) {
      +  const aui = useRef(makeFakeAui(bound, applied)).current
      +
      +  // Verbatim mirror of the production `setComposerText` helper in index.tsx.
      +  const setComposerText = useCallback(
      +    (value: string) => {
      +      try {
      +        aui.composer().setText(value)
      +      } catch {
      +        // Composer core not bound yet — swallow so the input stays usable.
      +      }
      +    },
      +    [aui]
      +  )
      +
      +  // A draft-restore-on-mount that fires while the core may still be unbound,
      +  // exactly like loadIntoComposer/clearDraft do on startup.
      +  try {
      +    setComposerText('restored draft')
      +  } catch (err) {
      +    onError(err)
      +  }
      +
      +  return null
      +}
      +
      +describe('setComposerText guard (#49903)', () => {
      +  it('swallows the unbound-core throw at startup instead of crashing the renderer', () => {
      +    const applied: string[] = []
      +    const bound = { current: false }
      +    const onError = vi.fn()
      +
      +    expect(() => render(<Harness applied={applied} bound={bound} onError={onError} />)).not.toThrow()
      +
      +    // The guard absorbed the throw — nothing escaped to the renderer, and no
      +    // assistant-ui write landed (core was unbound).
      +    expect(onError).not.toHaveBeenCalled()
      +    expect(applied).toEqual([])
      +  })
      +
      +  it('writes through to the composer once the core is bound', () => {
      +    const applied: string[] = []
      +    const bound = { current: true }
      +    const onError = vi.fn()
      +
      +    act(() => {
      +      render(<Harness applied={applied} bound={bound} onError={onError} />)
      +    })
      +
      +    expect(onError).not.toHaveBeenCalled()
      +    expect(applied).toEqual(['restored draft'])
      +  })
      +})
    • apps/desktop/src/app/chat/composer/index.tsx
      @@ -193,6 +193,32 @@ export function ChatBar({
       }: ChatBarProps) {
         const aui = useAui()
         const draft = useAuiState(s => s.composer.text)
      +
      +  // assistant-ui's composer *mutators* (setText/send/…) throw "Composer is not
      +  // available" when the thread's composer core isn't bound yet — and unlike the
      +  // read path (`s.composer.text`, which is null-safe), there's no graceful
      +  // fallback. There's a startup/thread-swap window where this ChatBar's mount
      +  // effects (draft restore, clearDraft, external inserts) run before the core
      +  // binds; the popout refactor (#49488) widened it by moving the composer out
      +  // of the contain wrapper into a sibling of the thread, so the throw began
      +  // surfacing as an uncaught error that wedged the desktop input (#49903).
      +  //
      +  // Guard every mutation: if the core isn't ready, no-op the assistant-ui write.
      +  // The contentEditable DOM + draftRef already hold the text, and the
      +  // draft⇄editor sync reconciles composer state once the core attaches, so the
      +  // draft is never lost — only the (premature) state push is skipped.
      +  const setComposerText = useCallback(
      +    (value: string) => {
      +      try {
      +        aui.composer().setText(value)
      +      } catch {
      +        // Composer core not bound yet — DOM/draftRef carry the text; the sync
      +        // effect re-applies it after bind. Swallow so the input stays usable.
      +      }
      +    },
      +    [aui]
      +  )
      +
         const attachments = useStore($composerAttachments)
         const queuedPromptsBySession = useStore($queuedPromptsBySession)
         const statusItemsBySession = useStore($statusItemsBySession)
      @@ -370,7 +396,7 @@ export function ChatBar({
             const next = `${base}${sep}${value}`
       
             draftRef.current = next
      -      aui.composer().setText(next)
      +      setComposerText(next)
       
             const editor = editorRef.current
       
      @@ -381,7 +407,7 @@ export function ChatBar({
       
             setFocusRequestId(id => id + 1)
           },
      -    [aui]
      +    [setComposerText]
         )
       
         useEffect(() => {
      @@ -591,7 +617,7 @@ export function ChatBar({
           const nextDraft = `${currentDraft}${sep}${text}`
       
           draftRef.current = nextDraft
      -    aui.composer().setText(nextDraft)
      +    setComposerText(nextDraft)
       
           // Push the new text into the contentEditable editor directly. Setting the
           // assistant-ui composer state alone is not enough: the draft→editor sync
      @@ -624,7 +650,7 @@ export function ChatBar({
           }
       
           draftRef.current = nextDraft
      -    aui.composer().setText(nextDraft)
      +    setComposerText(nextDraft)
           requestMainFocus()
       
           return true
      @@ -710,7 +736,7 @@ export function ChatBar({
       
           if (nextDraft !== draftRef.current) {
             draftRef.current = nextDraft
      -      aui.composer().setText(nextDraft)
      +      setComposerText(nextDraft)
           }
       
           window.setTimeout(refreshTrigger, 0)
      @@ -836,7 +862,7 @@ export function ChatBar({
             renderComposerContents(editor, prefix)
             placeCaretEnd(editor)
             draftRef.current = composerPlainText(editor)
      -      aui.composer().setText(draftRef.current)
      +      setComposerText(draftRef.current)
             closeTrigger()
             runAction()
             requestMainFocus()
      @@ -864,7 +890,7 @@ export function ChatBar({
       
           const finish = () => {
             draftRef.current = composerPlainText(editor)
      -      aui.composer().setText(draftRef.current)
      +      setComposerText(draftRef.current)
             requestMainFocus()
             keepTriggerOpen ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
           }
      @@ -1316,17 +1342,17 @@ export function ChatBar({
         }
       
         const clearDraft = useCallback(() => {
      -    aui.composer().setText('')
      +    setComposerText('')
           draftRef.current = ''
       
           if (editorRef.current) {
             editorRef.current.replaceChildren()
           }
      -  }, [aui])
      +  }, [setComposerText])
       
         const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => {
           draftRef.current = text
      -    aui.composer().setText(text)
      +    setComposerText(text)
           $composerAttachments.set(cloneAttachments(attachments))
       
           const editor = editorRef.current
      @@ -1699,7 +1725,7 @@ export function ChatBar({
       
             if (domText !== draftRef.current) {
               draftRef.current = domText
      -        aui.composer().setText(domText)
      +        setComposerText(domText)
             }
           }
       

1 PR closed without merging

From the close comments: done elsewhere 1

  • #49979 fix(desktop): prevent "Composer is not available" error on boot after v0.17 upgrade · done elsewhere → 281a439, #51728, #49903
Closed issues · 2
  • Closed#49903bug(desktop): v0.17.0 throws "Uncaught Error: Composer is not available" — input becomes unresponsive after upgrade
  • Closed#61268[bug] 0.17.0 desktop update: `Composer is not available` error causes `Hermes.exe` to repeatedly restart through the updater in an unrecoverable loop
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.39: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
apps/desktop
Merged PRs
#51728
Closed issues
2
Attempts
1 PR closed without merging
Fix link checked
24 Jun 2026

For your agent

The same facts, typed.