# NousResearch/hermes-agent · Desktop composer unavailability errors after v0.17 upgrade

## Open now

- 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.

## TL;DR

- 4 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #51728. Verify it addresses your case.
- **Recommended:** read PR #51728 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-51728`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Closed / duplicate issues

- #49903: bug(desktop): v0.17.0 throws "Uncaught Error: Composer is not available" — input becomes unresponsive after upgrade
- #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

## Prior & competing PRs

- **#51728: fix(desktop): guard composer mutations when the composer core isn't bound (#49903)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `apps/desktop/src/app/chat/composer/composer-text-guard.test.tsx`, `apps/desktop/src/app/chat/composer/index.tsx`
  - fix hunks:
  - `apps/desktop/src/app/chat/composer/composer-text-guard.test.tsx`

    ```diff
    @@ -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`

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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
     
    ```

    ```diff
    @@ -381,7 +407,7 @@ export function ChatBar({
     
           setFocusRequestId(id => id + 1)
         },
    -    [aui]
    +    [setComposerText]
       )
     
       useEffect(() => {
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -624,7 +650,7 @@ export function ChatBar({
         }
     
         draftRef.current = nextDraft
    -    aui.composer().setText(nextDraft)
    +    setComposerText(nextDraft)
         requestMainFocus()
     
         return true
    ```

    ```diff
    @@ -710,7 +736,7 @@ export function ChatBar({
     
         if (nextDraft !== draftRef.current) {
           draftRef.current = nextDraft
    -      aui.composer().setText(nextDraft)
    +      setComposerText(nextDraft)
         }
     
         window.setTimeout(refreshTrigger, 0)
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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()
         }
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -1699,7 +1725,7 @@ export function ChatBar({
     
           if (domText !== draftRef.current) {
             draftRef.current = domText
    -        aui.composer().setText(domText)
    +        setComposerText(domText)
           }
         }
     
    ```
- **#49979: fix(desktop): prevent "Composer is not available" error on boot after v0.17 upgrade**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `281a439ad483e6f130c2305a5e932c652778cb3b`, `#51728`, `#49903`

## Fix status

- Merged to main in PR #51728 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-24`.
- Subsystem: `apps/desktop`
