{"repo":"NousResearch/hermes-agent","public_id":"49903","display_label":"Desktop composer unavailability errors after v0.17 upgrade","summary":"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.","subsystem":"apps/desktop","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":51728},"resolution":{"state":"partially_resolved","by":"pr-51728"},"freshness":{"state":"verified","last_verified_at":"2026-06-24T06:47:45+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.3933333333333333},"fix_edges":[{"issue_number":49903,"pr_number":51728,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":49903,"title":"bug(desktop): v0.17.0 throws \"Uncaught Error: Composer is not available\" — input becomes unresponsive after upgrade","url":null,"state":"closed"},{"kind":"issue","number":61268,"title":"[bug] 0.17.0 desktop update: `Composer is not available` error causes `Hermes.exe` to repeatedly restart through the updater in an unrecoverable loop","url":null,"state":"closed"},{"kind":"pr","number":49979,"title":"fix(desktop): prevent \"Composer is not available\" error on boot after v0.17 upgrade","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"281a439ad483e6f130c2305a5e932c652778cb3b","number":0},{"kind":"pr","value":"#51728","number":51728},{"kind":"pr","value":"#49903","number":49903}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":51728,"title":"fix(desktop): guard composer mutations when the composer core isn't bound (#49903)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/src/app/chat/composer/composer-text-guard.test.tsx","apps/desktop/src/app/chat/composer/index.tsx"],"fix_hunks":[{"file":"apps/desktop/src/app/chat/composer/composer-text-guard.test.tsx","hunks":["@@ -0,0 +1,106 @@\n+// @vitest-environment jsdom\n+import { act, cleanup, render } from '@testing-library/react'\n+import { useCallback, useRef } from 'react'\n+import { afterEach, describe, expect, it, vi } from 'vitest'\n+\n+afterEach(cleanup)\n+\n+// Regression repro for #49903: on desktop v0.17.0 the composer threw an\n+// uncaught `Error: Composer is not available` at startup and the input went\n+// unresponsive. The throw comes from @assistant-ui/core's composer-runtime —\n+// every *mutator* (setText/send/…) does `if (!core) throw new Error(\"Composer\n+// is not available\")` when the thread's composer core isn't bound yet. Unlike\n+// the read path (`s.composer.text`, which is null-safe: `runtime?.text ?? \"\"`),\n+// the mutators have no graceful fallback. ChatBar's mount-time effects (draft\n+// restore, clearDraft, external inserts) push text via `aui.composer().setText`\n+// before the core binds, and the popout refactor (#49488) widened that window,\n+// so the throw surfaced as an uncaught error that wedged the input.\n+//\n+// The fix wraps every `aui.composer().setText` call in a `setComposerText`\n+// helper that swallows the unbound-core throw — the contentEditable DOM +\n+// draftRef already hold the text and the draft⇄editor sync re-applies it once\n+// the core attaches, so nothing is lost. This Harness mirrors that helper\n+// faithfully (same try/catch shape) over a fake `aui` whose composer can be\n+// toggled bound/unbound, the way the assistant-ui runtime behaves across mount.\n+\n+interface FakeComposer {\n+  setText: (value: string) => void\n+}\n+\n+// Mirror of index.tsx's `useAui()` composer surface: composer() returns a\n+// runtime whose setText throws exactly like @assistant-ui/core when unbound.\n+function makeFakeAui(bound: { current: boolean }, applied: string[]) {\n+  const composer: FakeComposer = {\n+    setText(value: string) {\n+      if (!bound.current) {\n+        throw new Error('Composer is not available')\n+      }\n+\n+      applied.push(value)\n+    }\n+  }\n+\n+  return { composer: () => composer }\n+}\n+\n+function Harness({\n+  bound,\n+  applied,\n+  onError\n+}: {\n+  applied: string[]\n+  bound: { current: boolean }\n+  onError: (err: unknown) => void\n+}) {\n+  const aui = useRef(makeFakeAui(bound, applied)).current\n+\n+  // Verbatim mirror of the production `setComposerText` helper in index.tsx.\n+  const setComposerText = useCallback(\n+    (value: string) => {\n+      try {\n+        aui.composer().setText(value)\n+      } catch {\n+        // Composer core not bound yet — swallow so the input stays usable.\n+      }\n+    },\n+    [aui]\n+  )\n+\n+  // A draft-restore-on-mount that fires while the core may still be unbound,\n+  // exactly like loadIntoComposer/clearDraft do on startup.\n+  try {\n+    setComposerText('restored draft')\n+  } catch (err) {\n+    onError(err)\n+  }\n+\n+  return null\n+}\n+\n+describe('setComposerText guard (#49903)', () => {\n+  it('swallows the unbound-core throw at startup instead of crashing the renderer', () => {\n+    const applied: string[] = []\n+    const bound = { current: false }\n+    const onError = vi.fn()\n+\n+    expect(() => render(<Harness applied={applied} bound={bound} onError={onError} />)).not.toThrow()\n+\n+    // The guard absorbed the throw — nothing escaped to the renderer, and no\n+    // assistant-ui write landed (core was unbound).\n+    expect(onError).not.toHaveBeenCalled()\n+    expect(applied).toEqual([])\n+  })\n+\n+  it('writes through to the composer once the core is bound', () => {\n+    const applied: string[] = []\n+    const bound = { current: true }\n+    const onError = vi.fn()\n+\n+    act(() => {\n+      render(<Harness applied={applied} bound={bound} onError={onError} />)\n+    })\n+\n+    expect(onError).not.toHaveBeenCalled()\n+    expect(applied).toEqual(['restored draft'])\n+  })\n+})"]},{"file":"apps/desktop/src/app/chat/composer/index.tsx","hunks":["@@ -193,6 +193,32 @@ export function ChatBar({\n }: ChatBarProps) {\n   const aui = useAui()\n   const draft = useAuiState(s => s.composer.text)\n+\n+  // assistant-ui's composer *mutators* (setText/send/…) throw \"Composer is not\n+  // available\" when the thread's composer core isn't bound yet — and unlike the\n+  // read path (`s.composer.text`, which is null-safe), there's no graceful\n+  // fallback. There's a startup/thread-swap window where this ChatBar's mount\n+  // effects (draft restore, clearDraft, external inserts) run before the core\n+  // binds; the popout refactor (#49488) widened it by moving the composer out\n+  // of the contain wrapper into a sibling of the thread, so the throw began\n+  // surfacing as an uncaught error that wedged the desktop input (#49903).\n+  //\n+  // Guard every mutation: if the core isn't ready, no-op the assistant-ui write.\n+  // The contentEditable DOM + draftRef already hold the text, and the\n+  // draft⇄editor sync reconciles composer state once the core attaches, so the\n+  // draft is never lost — only the (premature) state push is skipped.\n+  const setComposerText = useCallback(\n+    (value: string) => {\n+      try {\n+        aui.composer().setText(value)\n+      } catch {\n+        // Composer core not bound yet — DOM/draftRef carry the text; the sync\n+        // effect re-applies it after bind. Swallow so the input stays usable.\n+      }\n+    },\n+    [aui]\n+  )\n+\n   const attachments = useStore($composerAttachments)\n   const queuedPromptsBySession = useStore($queuedPromptsBySession)\n   const statusItemsBySession = useStore($statusItemsBySession)","@@ -370,7 +396,7 @@ export function ChatBar({\n       const next = `${base}${sep}${value}`\n \n       draftRef.current = next\n-      aui.composer().setText(next)\n+      setComposerText(next)\n \n       const editor = editorRef.current\n ","@@ -381,7 +407,7 @@ export function ChatBar({\n \n       setFocusRequestId(id => id + 1)\n     },\n-    [aui]\n+    [setComposerText]\n   )\n \n   useEffect(() => {","@@ -591,7 +617,7 @@ export function ChatBar({\n     const nextDraft = `${currentDraft}${sep}${text}`\n \n     draftRef.current = nextDraft\n-    aui.composer().setText(nextDraft)\n+    setComposerText(nextDraft)\n \n     // Push the new text into the contentEditable editor directly. Setting the\n     // assistant-ui composer state alone is not enough: the draft→editor sync","@@ -624,7 +650,7 @@ export function ChatBar({\n     }\n \n     draftRef.current = nextDraft\n-    aui.composer().setText(nextDraft)\n+    setComposerText(nextDraft)\n     requestMainFocus()\n \n     return true","@@ -710,7 +736,7 @@ export function ChatBar({\n \n     if (nextDraft !== draftRef.current) {\n       draftRef.current = nextDraft\n-      aui.composer().setText(nextDraft)\n+      setComposerText(nextDraft)\n     }\n \n     window.setTimeout(refreshTrigger, 0)","@@ -836,7 +862,7 @@ export function ChatBar({\n       renderComposerContents(editor, prefix)\n       placeCaretEnd(editor)\n       draftRef.current = composerPlainText(editor)\n-      aui.composer().setText(draftRef.current)\n+      setComposerText(draftRef.current)\n       closeTrigger()\n       runAction()\n       requestMainFocus()","@@ -864,7 +890,7 @@ export function ChatBar({\n \n     const finish = () => {\n       draftRef.current = composerPlainText(editor)\n-      aui.composer().setText(draftRef.current)\n+      setComposerText(draftRef.current)\n       requestMainFocus()\n       keepTriggerOpen ? window.setTimeout(refreshTrigger, 0) : closeTrigger()\n     }","@@ -1316,17 +1342,17 @@ export function ChatBar({\n   }\n \n   const clearDraft = useCallback(() => {\n-    aui.composer().setText('')\n+    setComposerText('')\n     draftRef.current = ''\n \n     if (editorRef.current) {\n       editorRef.current.replaceChildren()\n     }\n-  }, [aui])\n+  }, [setComposerText])\n \n   const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => {\n     draftRef.current = text\n-    aui.composer().setText(text)\n+    setComposerText(text)\n     $composerAttachments.set(cloneAttachments(attachments))\n \n     const editor = editorRef.current","@@ -1699,7 +1725,7 @@ export function ChatBar({\n \n       if (domText !== draftRef.current) {\n         draftRef.current = domText\n-        aui.composer().setText(domText)\n+        setComposerText(domText)\n       }\n     }\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}