hermes-agent/apps/desktop/src/app/session/hooks/use-prompt-actions.ts
brooklyn! d62979a6f3
feat(desktop): composer status stack, live subagent windows, editable prompts (#44630)
* feat(desktop): session-scoped status stack + kill new-window theme flash

Stack subagents, background tasks, and the queue into one collapsible
"sink" above the composer, reusing the queue's chrome so every status
reads as one piece. Extracts shared StatusSection / StatusRow /
TerminalOutput primitives and a unified $statusItemsBySession store
(subagents mirrored, background owned here, merged + grouped for render).
Renames BrailleSpinner → GlyphSpinner now that it drives more than braille.

Separately, fix the white flash on every new/cmd-clicked window: macOS
`vibrancy` paints an NSVisualEffectView that follows the OS appearance and
ignores `backgroundColor`, so a dark app on a light-mode Mac flashed white
until the renderer painted over it. Pin `nativeTheme.themeSource` to the
app theme (persisted to userData so cold launches paint right before the
renderer loads), hold windows with `show:false` until `ready-to-show`, and
pre-paint the themed background via an inline script before the bundle runs.

* feat(desktop): dock the slash popover to the composer via one shared fill var

The slash·@ popover (and ? help) now docks onto the composer's edge with the
same chrome as the queue/status stack — rounded outer corners, fused borderless
edge, no shadow — but keeps its own narrow width.

Surface + drawer paint a single --composer-fill var; the state ladder
(rest / scrolled / focused / drawer-open) lives once in styles.css on
[data-slot='composer-root']. The :has() drawer-open rule is last and forces an
opaque fill, since translucent glass sampling different backdrops (thread vs
fade gradient) can never match. This replaces the focus-within !important
override that repainted the surface behind every previous matching attempt.

Also drop the chevron column from the project file tree — the folder open/closed
icon already carries the expand state.

* feat(desktop): base inset for file tree rows (post-chevron alignment)

* feat(desktop): wire the status stack's background tasks to the real process registry

The background group was UI-only (dev-mock seeded). Now it's live e2e:

- tui_gateway: new session-scoped `process.list` (registry snapshot filtered
  by the session's session_key, plus a 4KB output tail for the inline
  terminal viewer) and `process.kill` (single process, ownership-checked —
  unlike process.stop's kill_all).
- Renderer: `reconcileBackgroundProcesses` syncs snapshots into the store
  layout-stably — rows keep their position when state flips (never re-sort),
  new processes append, unchanged rows keep object identity so memoised rows
  skip re-rendering, and a dismissed-set stops the registry's retained
  finished procs from resurrecting X-ed rows.
- Refresh triggers: session open, terminal/process tool.complete,
  status.update(kind=process) from the gateway's notification poller, and a
  5s poll armed only while a running row is visible (catches silent exits).
- Stop = real `process.kill` + optimistic dismiss; Dismiss = client-side
  with resurrection guard.
- Re-keyed the stack to the RUNTIME session id: it was keyed by the stored
  session id, where neither subagent events nor process.list would ever land.
- Deleted dev-status-mocks.ts (__hermesStatusMocks) — no more seed shit.

Reconcile invariants covered in store/composer-status.test.ts.

* feat(desktop): todos + openable subagents in the status stack, self-healing file tree

- todo lists move out of the inline chat panel into the composer status stack
  (checklist icon, dashed ring = pending, spinner = in progress, check = done),
  fed live from todo tool events and seeded from history on session open
- subagent rows carry the child's real session id end-to-end
  (delegate_tool → gateway → renderer) so clicking one opens ITS session window
- status stack publishes its measured height so the thread's bottom clearance
  grows with it; card paints the shared --composer-fill so focused/scrolled
  states match the composer exactly
- file tree self-heals: ENOENT roots retry on a 3s cadence + Try again button,
  and the main process expands ~ in IPC paths (gateway cwds arrive as ~/...)
- composer drag-drop of tree entries inserts inline refs instead of attachments

* fix(desktop): file tree falls back to the workspace dir when a session's cwd is gone

Sessions record their launch cwd; deleted worktrees leave that path dead,
so opening such a session swapped the tree from the default workspace to a
directory that ENOENTs forever — the 3s retry just spun on it. On a root
read error the tree now asks main to sanitize the cwd (prefers the
configured default project dir), displays that fallback, and quietly
re-probes the original path so it switches back if the dir reappears.

* feat(desktop): working restore-checkpoint button on past user prompts

The discard icon on hover of a past user bubble was decorative — clicking
did nothing. It's now a real control: a confirmation dialog explains that
everything after the prompt is removed, then the session rewinds to that
turn and reruns the same prompt (prompt.submit with
truncate_before_user_ordinal, the same mechanism the edit composer uses).
Failures rethrow into the dialog's inline error instead of toasting.

* fix(desktop): show the restore-checkpoint button on the latest user prompt too

Restoring the most recent prompt is just 'retry this turn' — no reason to
exclude it. Stop still takes the slot while the turn is running.

* fix(desktop): finished todo lists clear themselves out of the status stack

A list whose every item is completed/cancelled lingers ~4s so the final
checkmark is visible, then the todo group drops out of the stack. A fresh
active list arriving within the linger cancels the scheduled clear.

* chore(desktop): drop dead editableCheckpoint copy, terser restore confirm

* fix(desktop): rewind clears the abandoned timeline's todos + background

Restoring to (or editing) an earlier prompt rewinds the conversation, but
the todos and background processes spawned by the now-discarded turns kept
showing in the status stack — and the real background processes kept
running. Both rewind paths now clear the session's todo rows and kill +
drop its background processes before the fresh run repopulates them. Also
drops the click-to-edit clamp transition, which flashed a half-expanded
bubble on the way into the edit composer.

* feat(desktop): user messages are always editable; edit/restore revert mid-stream

The bubble is now always click-to-edit — even while a turn streams — instead
of going inert during a run. Sending an edit acts like restore: it rewinds to
that prompt and re-runs with the new text. Both edit and restore can fire
mid-stream now; the gateway refuses prompt.submit while a turn runs (4009
"session busy"), so they interrupt the live turn first and retry the submit
until the cooperative interrupt winds it down. Restore (re-run as-is) shows on
every prompt except the latest running one, which keeps the Stop button.

* fix(desktop): label preview-pane ⌘L selections with the filename, not "zsh"

The terminal owns a global ⌘/Ctrl+L "send selection to composer" shortcut, so
selecting text in the file preview pane and hitting it fell through to the
terminal handler — which imported the right text but labelled the composer ref
"zsh:N lines" off the shell name. When the selection isn't an xterm selection,
label it with the previewed file instead.

* fix(desktop): ⌘L on a preview line selection inserts the @line ref, like dragging

The source preview lets you select lines in the gutter and drag them into the
composer as an @line:path:start-end ref. ⌘/Ctrl+L now does the same when a line
selection is active — it drops the identical ref instead of falling through to
the terminal's global handler (which grabbed the native text selection and sent
a bogus terminal block). Capture-phase + stopPropagation so it wins; with a line
selection there's no native selection, so the terminal handler stays out of it.

* chore: gitignore apps/desktop/demo/ scratch output

The desktop demo prompt writes demo/*.txt during recorded walkthroughs; it's
throwaway, never part of the app. Ignore it so it stops cluttering git status.

* feat(desktop): subagent watch windows, hard stop, sidebar hygiene

Child-session mirror for live subagent windows, delegate sessions tagged
and excluded from the sidebar, composer focus/stop polish, and WS stall
resilience on the gateway transport.

* refactor: DRY delegate SQL + trim status-stack noise

Extract shared listable-child and delegate-delete helpers in hermes_state,
collapse cancelRun busy release, and cut comment bloat in resume/status paths.

* fix(desktop): hide orphaned subagent sessions in sidebar

Cascade-delete all ephemeral children on parent delete (not just tagged rows),
run v16 backfill to tag legacy orphans, and record new delegates as source=subagent.

* fix: restore orphan contract for untagged children + lazy session eviction

Cascade-delete only _delegate_from-tagged rows (v16 backfill covers legacy),
walk marker chains recursively with FK-safe orphaning, gate lazy watch
sessions out of the still-starting eviction exemption via an explicit flag,
pass session_id to _make_agent only when resuming, and hide source=subagent
from session search.

* fix(gateway): gate child mirror off upgraded sessions + age out stale run entries

Review findings: the mirror could interleave synthetic events with a real
native stream once a watch window upgrades (prompt.submit builds an agent),
and a lost subagent.complete left _active_child_runs pinning running=true
forever. Mirror now stops when the live session owns an agent; liveness
reads ignore entries older than an hour.

* fix(gateway): reject prompt.submit into a watch session while its child runs

A lazy watch session's running flag is False (the run lives in the parent
turn), so typing mid-run sailed past the busy guard and built a second agent
racing the in-flight child on the same stored session. Busy error until the
run completes; afterwards the submit upgrades into a normal conversation.

* refactor(gateway): DRY watch-resume payload + compose listable-child SQL

Fold the duplicated child-run busy overlay into one _reuse_live_payload
helper across both resume reuse paths, collapse the twin mirror early-returns,
and build _LISTABLE_CHILD_SQL from _BRANCH_CHILD_SQL instead of restating it.

* fix(desktop): clip horizontal overflow on sidebar scroll areas

Add overflow-x-hidden alongside overflow-y-auto on session list scrollers
and the shared SidebarContent primitive — vertical scroll unchanged.
2026-06-12 08:30:06 -05:00

1675 lines
55 KiB
TypeScript

import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { getProfiles, transcribeAudio } from '@/hermes'
import { translateNow, type Translations, useI18n } from '@/i18n'
import { stripAnsi } from '@/lib/ansi'
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import {
optimisticAttachmentRef,
parseCommandDispatch,
parseSlashCommand,
pathLabel,
sessionTitle,
SLASH_COMMAND_RE
} from '@/lib/chat-runtime'
import {
type CommandsCatalogLike,
type DesktopActionId,
type DesktopPickerId,
desktopSlashUnavailableMessage,
filterDesktopCommandsCatalog,
isDesktopSlashCommand,
resolveDesktopCommand
} from '@/lib/desktop-slash-commands'
import { triggerHaptic } from '@/lib/haptics'
import { setMutableRef } from '@/lib/mutable-ref'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { setSessionYolo } from '@/lib/yolo-session'
import {
$composerAttachments,
clearComposerAttachments,
type ComposerAttachment,
setComposerAttachmentUploadState,
terminalContextBlocksFromDraft,
updateComposerAttachment
} from '@/store/composer'
import { resetSessionBackground } from '@/store/composer-status'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import {
$busy,
$connection,
$messages,
$sessions,
$yoloActive,
setAwaitingResponse,
setBusy,
setMessages,
setModelPickerOpen,
setSessionPickerOpen,
setSessions,
setYoloActive
} from '@/store/session'
import { clearSessionSubagents } from '@/store/subagents'
import { clearSessionTodos } from '@/store/todos'
import type {
ClientSessionState,
FileAttachResponse,
HandoffFailResponse,
HandoffRequestResponse,
HandoffStateResponse,
ImageAttachResponse,
SessionSteerResponse,
SessionTitleResponse,
SlashExecResponse
} from '../../types'
interface HandoffResult {
ok: boolean
error?: string
}
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
function isSessionIdCandidate(value: string): boolean {
const trimmed = value.trim()
return /^\d{8}_\d{6}_[A-Fa-f0-9]{6}$/.test(trimmed) || /^[A-Fa-f0-9]{32}$/.test(trimmed)
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener('load', () => {
if (typeof reader.result === 'string') {
resolve(reader.result)
} else {
reject(new Error(translateNow('desktop.audioReadFailed')))
}
})
reader.addEventListener('error', () => reject(reader.error || new Error(translateNow('desktop.audioReadFailed'))))
reader.readAsDataURL(blob)
})
}
function isProviderSetupError(error: unknown) {
const message = error instanceof Error ? error.message : String(error)
return isProviderSetupErrorMessage(message)
}
function inlineErrorMessage(error: unknown, fallback: string): string {
const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : fallback
return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim()
}
function isSessionNotFoundError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /session not found/i.test(message)
}
// The gateway refuses prompt.submit while a turn is running (4009 "session
// busy"). Edit/restore (revert) can fire mid-turn, so they interrupt first then
// retry the submit until the cooperative interrupt has wound the turn down.
const REWIND_INTERRUPT_TIMEOUT_MS = 6_000
const REWIND_RETRY_INTERVAL_MS = 150
function isSessionBusyError(error: unknown): boolean {
return /session busy/i.test(error instanceof Error ? error.message : String(error))
}
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
function base64FromDataUrl(dataUrl: string): string {
const comma = dataUrl.indexOf(',')
return comma >= 0 ? dataUrl.slice(comma + 1) : ''
}
function imageFilenameFromPath(filePath: string): string {
return filePath.split(/[\\/]/).filter(Boolean).pop() || 'image.png'
}
// Remote gateway: the local composer-image file lives on THIS machine's disk,
// not the gateway's, so read the bytes here and upload them via
// image.attach_bytes. Returns null when the file can't be read.
async function readImageForRemoteAttach(
filePath: string
): Promise<{ contentBase64: string; filename: string } | null> {
const dataUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
const contentBase64 = dataUrl ? base64FromDataUrl(dataUrl) : ''
return contentBase64 ? { contentBase64, filename: imageFilenameFromPath(filePath) } : null
}
// Read a non-image file as a data URL for upload via file.attach. Returns null
// when the desktop bridge can't read the file (e.g. it was moved/deleted).
async function readFileDataUrlForAttach(filePath: string): Promise<string | null> {
const reader = window.hermesDesktop?.readFileDataUrl
if (!reader) {
return null
}
const dataUrl = await reader(filePath)
return dataUrl || null
}
// The readFileDataUrl IPC base64-loads the whole file into memory and is
// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.cjs, which
// rejects with a raw "file is too large (N bytes; limit M bytes)" string. In
// remote mode every attachment's bytes go through that read, so a big file
// surfaces that internal message verbatim in the failure toast. Translate it
// into a friendly "too large to upload to the remote gateway" line, parsing the
// limit out of the message so it tracks the real cap. Non-cap errors pass
// through unchanged.
function friendlyRemoteAttachError(err: unknown, label: string): Error {
const message = err instanceof Error ? err.message : String(err)
if (!/too large/i.test(message)) {
return err instanceof Error ? err : new Error(message)
}
const limitBytes = Number(message.match(/limit (\d+) bytes/)?.[1])
const cap = Number.isFinite(limitBytes) && limitBytes > 0 ? ` (max ${Math.floor(limitBytes / (1024 * 1024))} MB)` : ''
return new Error(`${label} is too large to upload to the remote gateway${cap}.`)
}
type GatewayRequest = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
/**
* Stage one file/image attachment into the session workspace and return the
* attachment rewritten with the gateway-side ref. Images upload their bytes in
* remote mode (so vision works) and pass the path locally; non-image files
* upload bytes remotely and pass the path locally. Throws on failure so callers
* can surface an error. Shared by submit-time sync, the eager drop-time upload,
* and the message-edit composer drop — keep them in lockstep.
*/
export async function uploadComposerAttachment(
attachment: ComposerAttachment,
opts: { remote: boolean; requestGateway: GatewayRequest; sessionId: string }
): Promise<ComposerAttachment> {
const { remote, requestGateway, sessionId } = opts
const path = attachment.path ?? ''
const label = attachment.label || pathLabel(path)
if (attachment.kind === 'image') {
let result: ImageAttachResponse
if (remote) {
let payload: Awaited<ReturnType<typeof readImageForRemoteAttach>>
try {
payload = await readImageForRemoteAttach(path)
} catch (err) {
throw friendlyRemoteAttachError(err, label)
}
if (!payload) {
throw new Error(`Could not read ${label}`)
}
result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
session_id: sessionId,
content_base64: payload.contentBase64,
filename: payload.filename
})
} else {
result = await requestGateway<ImageAttachResponse>('image.attach', {
path,
session_id: sessionId
})
}
if (!result.attached) {
throw new Error(result.message || `Could not attach ${label}`)
}
const attachedPath = result.path || path
return {
...attachment,
attachedSessionId: sessionId,
label: attachedPath ? pathLabel(attachedPath) : attachment.label,
path: attachedPath,
uploadState: undefined
}
}
// Non-image file.
let dataUrl: string | null = null
if (remote) {
try {
dataUrl = await readFileDataUrlForAttach(path)
} catch (err) {
throw friendlyRemoteAttachError(err, label)
}
if (!dataUrl) {
throw new Error(`Could not read ${label}`)
}
}
const result = await requestGateway<FileAttachResponse>('file.attach', {
name: label,
path,
session_id: sessionId,
...(dataUrl ? { data_url: dataUrl } : {})
})
if (!result.attached || !result.ref_text) {
throw new Error(result.message || `Could not attach ${label}`)
}
return {
...attachment,
attachedSessionId: sessionId,
refText: result.ref_text,
uploadState: undefined
}
}
interface PromptActionsOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
busyRef: MutableRefObject<boolean>
branchCurrentSession: () => Promise<boolean>
createBackendSessionForSend: (preview?: string | null) => Promise<string | null>
handleSkinCommand: (arg: string) => string
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
resumeStoredSession: (storedSessionId: string) => Promise<void> | void
selectedStoredSessionIdRef: MutableRefObject<string | null>
startFreshSessionDraft: () => void
sttEnabled: boolean
updateSessionState: (
sessionId: string,
updater: (state: ClientSessionState) => ClientSessionState,
storedSessionId?: string | null
) => ClientSessionState
}
interface SubmitTextOptions {
attachments?: ComposerAttachment[]
fromQueue?: boolean
}
/** Everything a slash handler needs about the invocation it's serving. */
interface SlashActionCtx {
arg: string
command: string
name: string
recordInput: boolean
sessionHint?: string
}
function renderCommandsCatalog(catalog: CommandsCatalogLike, copy: Translations['desktop']): string {
const desktopCatalog = filterDesktopCommandsCatalog(catalog)
const sections = desktopCatalog.categories?.length
? desktopCatalog.categories
: [{ name: copy.desktopCommands, pairs: desktopCatalog.pairs ?? [] }]
const body = sections
.filter(section => section.pairs.length > 0)
.map(section => {
const rows = section.pairs.map(([cmd, desc]) => `${cmd.padEnd(18)} ${desc}`)
return [`${section.name}:`, ...rows].join('\n')
})
.join('\n\n')
const tail = [
desktopCatalog.skill_count ? copy.skillCommandsAvailable(desktopCatalog.skill_count) : '',
desktopCatalog.warning ? copy.warningLine(desktopCatalog.warning) : ''
]
.filter(Boolean)
.join('\n')
return [body || 'No desktop commands available.', tail].filter(Boolean).join('\n\n')
}
function slashStatusText(command: string, output: string): string {
return [`slash:${command}`, output.trim()].filter(Boolean).join('\n')
}
function appendText(message: AppendMessage): string {
return message.content
.map(part => ('text' in part ? part.text : ''))
.join('')
.trim()
}
function visibleUserOrdinal(messages: readonly ChatMessage[], end: number): number {
return messages.slice(0, end).filter(m => m.role === 'user' && !m.hidden).length
}
export function usePromptActions({
activeSessionId,
activeSessionIdRef,
busyRef,
branchCurrentSession,
createBackendSessionForSend,
handleSkinCommand,
refreshSessions,
requestGateway,
resumeStoredSession,
selectedStoredSessionIdRef,
startFreshSessionDraft,
sttEnabled,
updateSessionState
}: PromptActionsOptions) {
const { t } = useI18n()
const copy = t.desktop
const appendSessionTextMessage = useCallback(
(sessionId: string, role: ChatMessage['role'], text: string) => {
// Strip ANSI: slash-command output from the backend worker carries SGR
// color codes (e.g. "Unknown command" in red). The ESC byte is invisible
// in the chat panel, so without this the `[1;31m…[0m` payload leaks as
// literal text.
const body = stripAnsi(text).trim()
if (!body) {
return
}
updateSessionState(
sessionId,
state => ({
...state,
messages: [
...state.messages,
{
id: `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
role,
parts: [textPart(body)]
}
]
}),
selectedStoredSessionIdRef.current
)
},
[selectedStoredSessionIdRef, updateSessionState]
)
// In-flight drop-time eager uploads, keyed by attachment id. Submit joins
// these before re-uploading so a drop-then-immediately-Enter can't fire
// file.attach twice and stage duplicate copies on the gateway.
const eagerUploadInFlight = useRef<Map<string, Promise<void>>>(new Map())
const syncAttachmentsForSubmit = useCallback(
async (
sessionId: string,
attachments: ComposerAttachment[],
options: { updateComposerAttachments?: boolean } = {}
): Promise<ComposerAttachment[]> => {
const updateComposerAttachments = options.updateComposerAttachments ?? true
const remote = $connection.get()?.mode === 'remote'
const synced: ComposerAttachment[] = []
for (const original of attachments) {
let attachment = original
// Join a drop-time eager upload still in flight for this attachment
// before deciding anything — otherwise submit and the eager task both
// call file.attach and stage duplicate files. After it settles, take the
// store's updated copy (its gateway ref, or its failure) over the stale
// pre-upload snapshot.
const inFlight = eagerUploadInFlight.current.get(attachment.id)
if (inFlight) {
await inFlight
attachment = $composerAttachments.get().find(item => item.id === attachment.id) ?? attachment
}
// Already-synced or pathless refs (terminal, url, etc.) pass through.
// A drop-time eager upload may already have staged this one (matching
// attachedSessionId) — don't re-upload it.
if (!attachment.path || attachment.attachedSessionId === sessionId) {
synced.push(attachment)
continue
}
if (attachment.kind === 'image' || attachment.kind === 'file') {
const nextAttachment = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId })
// Update-only: never resurrect a chip the user removed mid-upload.
if (updateComposerAttachments) {
updateComposerAttachment(nextAttachment)
}
synced.push(nextAttachment)
continue
}
synced.push(attachment)
}
return synced
},
[requestGateway]
)
// Stage a freshly dropped file as soon as it lands (when a session already
// exists), so the upload runs while the user is still typing rather than
// stalling the send. The card shows a spinner via `uploadState`; on success
// the chip carries its gateway-side ref so submit skips re-uploading.
//
// Images are intentionally NOT eager-uploaded: attachImagePath adds the chip
// and then fills in `previewUrl` (the base64 thumbnail) on a second tick, so
// an eager upload would race that write — clobbering the thumbnail and
// swapping `path` to a gateway path the local preview can't read. Images are
// small and still byte-upload at submit via image.attach_bytes.
const eagerlyUploadAttachment = useCallback(
async (sessionId: string, attachment: ComposerAttachment) => {
const remote = $connection.get()?.mode === 'remote'
setComposerAttachmentUploadState(attachment.id, 'uploading')
try {
// Update-only: if the user removed the chip while this was uploading,
// don't resurrect it — just drop the staged result on the floor.
updateComposerAttachment(await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId }))
} catch (err) {
// Leave the chip in place so submit-time sync can retry (or the user can
// remove it) and flag the card; also toast so a hard failure (unreadable
// file, gateway perms) isn't swallowed while the user keeps typing.
setComposerAttachmentUploadState(attachment.id, 'error')
notifyError(err, copy.dropFiles)
}
},
[copy.dropFiles, requestGateway]
)
const composerAttachments = useStore($composerAttachments)
useEffect(() => {
if (!activeSessionId) {
return
}
for (const attachment of composerAttachments) {
const needsUpload =
attachment.kind === 'file' &&
Boolean(attachment.path) &&
!attachment.attachedSessionId &&
!attachment.uploadState &&
!eagerUploadInFlight.current.has(attachment.id)
if (!needsUpload) {
continue
}
const task = eagerlyUploadAttachment(activeSessionId, attachment).finally(() =>
eagerUploadInFlight.current.delete(attachment.id)
)
eagerUploadInFlight.current.set(attachment.id, task)
}
}, [activeSessionId, composerAttachments, eagerlyUploadAttachment])
const submitPromptText = useCallback(
async (rawText: string, options?: SubmitTextOptions) => {
const visibleText = rawText.trim()
const usingComposerAttachments = !options?.attachments
const attachments = options?.attachments ?? $composerAttachments.get()
const terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n')
const hasImage = attachments.some(a => a.kind === 'image')
// Refs are recomputed after sync (file.attach rewrites @file: refs to
// workspace-relative paths the remote gateway can resolve). Seed the
// optimistic message with the pre-sync refs, then rewrite once synced.
// Images use their base64 preview so the thumbnail renders inline without
// a (remote-mode 403-prone) /api/media fetch — see optimisticAttachmentRef.
let attachmentRefs = attachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r))
const buildContextText = (atts: ComposerAttachment[]): string => {
const contextRefs = atts
.map(a => a.refText)
.filter(Boolean)
.join('\n')
return (
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
(atts.some(a => a.kind === 'image') ? 'What do you see in this image?' : '')
)
}
// Queue drains fire on the busy→false settle edge, where busyRef (synced
// from $busy by a separate effect) may still read true — honoring it would
// bounce the drained send. The drain lock serializes them; the user path
// keeps the guard so a stray Enter mid-turn can't double-submit.
const hasSendable = Boolean(visibleText || terminalContextBlocks || attachments.length || hasImage)
if (!hasSendable || (!options?.fromQueue && busyRef.current)) {
return false
}
const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const buildUserMessage = (): ChatMessage => ({
id: optimisticId,
role: 'user',
parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))],
attachmentRefs
})
const releaseBusy = () => {
setMutableRef(busyRef, false)
setBusy(false)
setAwaitingResponse(false)
}
// Idempotent optimistic insert — re-running with the resolved sessionId
// after createBackendSessionForSend just overwrites with the same id.
const seedOptimistic = (sid: string) =>
updateSessionState(
sid,
state => ({
...state,
messages: state.messages.some(m => m.id === optimisticId)
? state.messages
: [...state.messages, buildUserMessage()],
busy: true,
awaitingResponse: true,
pendingBranchGroup: null,
sawAssistantPayload: false,
// Fresh submit = new turn — clear any leftover interrupt flag, else
// mutateStream/completeAssistantMessage drop every delta of this turn
// (what made drained-after-interrupt sends go silent).
interrupted: false
}),
selectedStoredSessionIdRef.current
)
// After sync rewrites refs, refresh the optimistic message in place so the
// transcript shows the resolved @file: ref rather than the local path.
const rewriteOptimistic = (sid: string) =>
updateSessionState(
sid,
state => ({
...state,
messages: state.messages.map(message => (message.id === optimisticId ? buildUserMessage() : message))
}),
selectedStoredSessionIdRef.current
)
const dropOptimistic = (sid: null | string) => {
if (!sid) {
setMessages(current => current.filter(m => m.id !== optimisticId))
return
}
updateSessionState(
sid,
state => ({
...state,
messages: state.messages.filter(m => m.id !== optimisticId),
busy: false,
awaitingResponse: false,
pendingBranchGroup: null
}),
selectedStoredSessionIdRef.current
)
}
setMutableRef(busyRef, true)
setBusy(true)
setAwaitingResponse(true)
clearNotifications()
let sessionId: null | string = activeSessionId
if (sessionId) {
seedOptimistic(sessionId)
} else {
setMessages(current => [...current, buildUserMessage()])
}
if (!sessionId) {
try {
sessionId = await createBackendSessionForSend(visibleText)
} catch (err) {
dropOptimistic(null)
releaseBusy()
notifyError(err, copy.sessionUnavailable)
return false
}
if (!sessionId) {
dropOptimistic(null)
releaseBusy()
notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed })
return false
}
seedOptimistic(sessionId)
}
try {
const syncedAttachments = await syncAttachmentsForSubmit(sessionId, attachments, {
updateComposerAttachments: usingComposerAttachments
})
// Rewrite the optimistic message + prompt text with the synced refs so
// the gateway receives @file: paths that resolve in its workspace.
// (Images keep their inline base64 preview — see optimisticAttachmentRef.)
attachmentRefs = syncedAttachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r))
rewriteOptimistic(sessionId)
const text = buildContextText(syncedAttachments)
// On sleep/wake the gateway's in-memory session may have been cleared
// while the desktop app still holds the old session ID. Detect this,
// resume the stored session to re-register it, and retry once.
let submitErr: unknown = null
try {
await requestGateway('prompt.submit', { session_id: sessionId, text })
} catch (firstErr) {
if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) {
// Re-register the session in the gateway and get a fresh live ID.
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current
})
const recoveredId = resumed?.session_id
if (recoveredId) {
activeSessionIdRef.current = recoveredId
await requestGateway('prompt.submit', { session_id: recoveredId, text })
} else {
submitErr = firstErr
}
} else {
submitErr = firstErr
}
}
if (submitErr !== null) {
throw submitErr
}
if (usingComposerAttachments) {
clearComposerAttachments()
}
return true
} catch (err) {
const message = inlineErrorMessage(err, copy.promptFailed)
releaseBusy()
updateSessionState(sessionId, state => ({
...state,
messages: [
...state.messages,
{
id: `assistant-error-${Date.now()}`,
role: 'assistant',
parts: [],
error: message || copy.promptFailed,
branchGroupId: state.pendingBranchGroup ?? undefined
}
],
busy: false,
awaitingResponse: false,
pendingBranchGroup: null,
sawAssistantPayload: true
}))
if (isProviderSetupError(err)) {
requestDesktopOnboarding(copy.providerCredentialRequired)
return false
}
notifyError(err, copy.promptFailed)
return false
}
},
[
activeSessionId,
busyRef,
copy,
createBackendSessionForSend,
requestGateway,
selectedStoredSessionIdRef,
syncAttachmentsForSubmit,
updateSessionState
]
)
// Queue a handoff of this session to a messaging platform and watch it to
// a terminal state. We only write the request through the gateway; the
// separate `hermes gateway` process performs the actual transfer, so we
// poll `handoff.state` (mirror of the CLI's block-poll) for the result.
const handoffSession = useCallback(
async (
platform: string,
options?: { onProgress?: (state: string) => void; sessionId?: string }
): Promise<HandoffResult> => {
const sid = options?.sessionId || activeSessionIdRef.current
if (!sid) {
return { error: copy.sessionUnavailable, ok: false }
}
const target = platform.trim().toLowerCase()
if (!target) {
return { error: copy.handoff.failed(''), ok: false }
}
try {
options?.onProgress?.('pending')
await requestGateway<HandoffRequestResponse>('handoff.request', {
platform: target,
session_id: sid
})
} catch (err) {
return { error: inlineErrorMessage(err, copy.handoff.failed(target)), ok: false }
}
const deadline = Date.now() + 60_000
let lastState = 'pending'
while (Date.now() < deadline) {
await delay(800)
let record: HandoffStateResponse
try {
record = await requestGateway<HandoffStateResponse>('handoff.state', { session_id: sid })
} catch {
continue
}
const state = record.state || 'pending'
if (state !== lastState) {
options?.onProgress?.(state)
lastState = state
}
if (state === 'completed') {
appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
notify({ kind: 'success', message: copy.handoff.success(target) })
return { ok: true }
}
if (state === 'failed') {
return { error: record.error || copy.handoff.failed(target), ok: false }
}
}
const cleanup = await requestGateway<HandoffFailResponse>('handoff.fail', {
error: copy.handoff.timedOut,
session_id: sid
}).catch(() => null)
if (cleanup?.state === 'completed') {
appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
notify({ kind: 'success', message: copy.handoff.success(target) })
return { ok: true }
}
return { error: copy.handoff.timedOut, ok: false }
},
[activeSessionIdRef, appendSessionTextMessage, copy, requestGateway]
)
const executeSlashCommand = useCallback(
async (rawCommand: string, options?: { sessionId?: string; recordInput?: boolean }) => {
const ensureSessionId = async (sessionHint?: string) =>
sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
// Resolve the target session plus a writer for inline slash output, or
// notify + return null when none can be created. Folds the ensure / bail /
// build-renderSlashOutput boilerplate every exec-style handler repeats.
const withSlashOutput = async (
ctx: SlashActionCtx
): Promise<{ render: (text: string) => void; sessionId: string } | null> => {
const sessionId = await ensureSessionId(ctx.sessionHint)
if (!sessionId) {
notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed })
return null
}
const render = (text: string) =>
appendSessionTextMessage(sessionId, 'system', ctx.recordInput ? slashStatusText(ctx.command, text) : text)
return { render, sessionId }
}
// `exec` commands (and unknown skill / quick commands the backend owns)
// run on the gateway and render their text output inline. This is the only
// path that talks to slash.exec / command.dispatch.
async function runExec(ctx: SlashActionCtx): Promise<void> {
const { arg, command, name } = ctx
const resolved = await withSlashOutput(ctx)
if (!resolved) {
return
}
const { render: renderSlashOutput, sessionId } = resolved
if (!isDesktopSlashCommand(name)) {
renderSlashOutput(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`)
return
}
try {
const result = await requestGateway<SlashExecResponse>('slash.exec', {
session_id: sessionId,
command: command.replace(/^\/+/, '')
})
const body = result?.output || `/${name}: no output`
renderSlashOutput(result?.warning ? `warning: ${result.warning}\n${body}` : body)
return
} catch {
// Fall back to command.dispatch for skill/send/alias directives.
}
try {
const dispatch = parseCommandDispatch(
await requestGateway<unknown>('command.dispatch', { session_id: sessionId, name, arg })
)
if (!dispatch) {
renderSlashOutput('error: invalid response: command.dispatch')
return
}
if (dispatch.type === 'exec' || dispatch.type === 'plugin') {
renderSlashOutput(dispatch.output ?? '(no output)')
return
}
if (dispatch.type === 'alias') {
await runSlash(`/${dispatch.target}${arg ? ` ${arg}` : ''}`, sessionId, false)
return
}
const message = ('message' in dispatch ? dispatch.message : '')?.trim() ?? ''
if (!message) {
renderSlashOutput(
`/${name}: ${dispatch.type === 'skill' ? 'skill payload missing message' : 'empty message'}`
)
return
}
if (dispatch.type === 'skill') {
renderSlashOutput(`⚡ loading skill: ${dispatch.name}`)
}
if (busyRef.current) {
renderSlashOutput('session busy — /interrupt the current turn before sending this command')
return
}
await submitPromptText(message)
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
}
// One handler per `action` command. Adding a desktop-native command is a
// registry row in desktop-slash-commands.ts plus an entry here — never a
// new branch in a dispatch ladder.
const actionHandlers: Record<DesktopActionId, (ctx: SlashActionCtx) => Promise<void>> = {
new: async () => {
startFreshSessionDraft()
},
branch: async () => {
await branchCurrentSession()
},
// /yolo maps to the status-bar YOLO control — a per-session approval
// bypass, same scope as the TUI's Shift+Tab. With no session yet we arm
// it locally; the session-create path applies it on the first message.
yolo: async ({ sessionHint }) => {
const sid = sessionHint || activeSessionIdRef.current
const next = !$yoloActive.get()
if (!sid) {
setYoloActive(next)
notify({ kind: 'success', message: next ? copy.yoloArmed : copy.yoloOff })
return
}
try {
const active = await setSessionYolo(requestGateway, sid, next)
appendSessionTextMessage(sid, 'system', copy.yoloSystem(active))
} catch {
notify({ kind: 'error', title: copy.yoloTitle, message: copy.yoloToggleFailed })
}
},
// /handoff hands this session to a messaging platform. The platform is
// completed inline in the slash popover (backend _handoff_completions),
// so there is no overlay: `/handoff <platform>` runs the desktop's own
// handoff RPC. cli_only on the backend, so it must not reach slash.exec.
handoff: async ({ arg, command, recordInput, sessionHint }) => {
const platform = arg.trim()
if (!platform) {
notify({ kind: 'success', message: copy.handoff.pickPlatform })
return
}
const sid = sessionHint || activeSessionIdRef.current
if (!sid) {
notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed })
return
}
const result = await handoffSession(platform, { sessionId: sid })
if (!result.ok && result.error) {
appendSessionTextMessage(sid, 'system', recordInput ? slashStatusText(command, result.error) : result.error)
}
},
// /profile selects which profile new chats open in — no app relaunch.
// A profile is per-session now, so an existing thread can't change its
// profile mid-stream; `/profile <name>` points the next new chat (and
// the current empty draft) at that profile's backend.
profile: async ({ arg }) => {
const target = arg.trim()
const current = normalizeProfileKey($activeGatewayProfile.get())
if (!target) {
notify({ kind: 'success', message: copy.profileStatus(current) })
return
}
try {
const { profiles } = await getProfiles()
const match = profiles.find(profile => profile.name === target)
if (!match) {
notify({
kind: 'error',
title: copy.unknownProfile,
message: copy.noProfileNamed(target, profiles.map(profile => profile.name).join(', '))
})
return
}
const key = normalizeProfileKey(match.name)
$newChatProfile.set(key)
await ensureGatewayProfile(key)
notify({ kind: 'success', message: copy.newChatsProfile(match.name) })
} catch (err) {
notifyError(err, copy.setProfileFailed)
}
},
skin: async ({ arg, command, recordInput, sessionHint }) => {
const sid = sessionHint || activeSessionIdRef.current
const message = handleSkinCommand(arg)
// No session to print into yet — surface it as a toast instead of
// spinning up a backend session just to change the theme.
if (!sid) {
notify({ kind: 'success', message })
return
}
appendSessionTextMessage(sid, 'system', recordInput ? slashStatusText(command, message) : message)
},
// /title <name> renames via the gateway's session.title RPC — the same
// path the TUI uses, NOT REST renameSession (which 404s on runtime ids)
// nor the slash worker (whose DB write can silently fail). Bare /title
// shows the current title, which the worker owns, so delegate to exec.
title: async ctx => {
if (!ctx.arg) {
await runExec(ctx)
return
}
const resolved = await withSlashOutput(ctx)
if (!resolved) {
return
}
const { render: renderSlashOutput, sessionId } = resolved
const { arg } = ctx
try {
const result = await requestGateway<SessionTitleResponse>('session.title', {
session_id: sessionId,
title: arg
})
const finalTitle = (result?.title || arg).trim()
const queued = result?.pending === true
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
await refreshSessions().catch(() => undefined)
renderSlashOutput(
finalTitle
? `Session title set: ${finalTitle}${queued ? ' (queued while session initializes)' : ''}`
: 'Session title cleared.'
)
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
},
help: async ctx => {
const resolved = await withSlashOutput(ctx)
if (!resolved) {
return
}
const { render: renderSlashOutput, sessionId } = resolved
try {
const catalog = await requestGateway<CommandsCatalogLike>('commands.catalog', { session_id: sessionId })
renderSlashOutput(renderCommandsCatalog(catalog, copy))
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
}
}
// Picker commands open a desktop overlay; a typed arg is resolved by that
// picker so the command never dead-ends or falls through to the backend.
const openPicker = async (pickerId: DesktopPickerId, ctx: SlashActionCtx): Promise<void> => {
if (pickerId === 'model') {
if (!ctx.arg.trim()) {
setModelPickerOpen(true)
return
}
// Power users can still type `/model <name>` — run it on the backend.
await runExec(ctx)
return
}
// session picker — /resume, /sessions, /switch
const query = ctx.arg.trim()
if (!query) {
setSessionPickerOpen(true)
return
}
const sessions = $sessions.get()
const lower = query.toLowerCase()
const match =
sessions.find(session => session.id === query) ||
sessions.find(session => sessionTitle(session).toLowerCase().includes(lower)) ||
sessions.find(session => (session.preview ?? '').toLowerCase().includes(lower))
if (!match) {
if (isSessionIdCandidate(query)) {
await resumeStoredSession(query)
return
}
notify({ kind: 'error', message: copy.resumeFailed })
return
}
await resumeStoredSession(match.id)
}
// The whole dispatcher: resolve the command's desktop surface, then act on
// its kind. No per-command ladder — behavior lives in the registry.
async function runSlash(commandText: string, sessionHint?: string, recordInput = true): Promise<void> {
const command = commandText.trim()
const { name, arg } = parseSlashCommand(command)
if (!name) {
const sessionId = await ensureSessionId(sessionHint)
if (sessionId) {
appendSessionTextMessage(sessionId, 'system', copy.emptySlashCommand)
}
return
}
const ctx: SlashActionCtx = { arg, command, name, recordInput, sessionHint }
const surface = resolveDesktopCommand(`/${name}`)?.surface
switch (surface?.kind) {
case 'unavailable': {
const resolved = await withSlashOutput(ctx)
resolved?.render(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`)
return
}
case 'picker':
return openPicker(surface.picker, ctx)
case 'action':
return actionHandlers[surface.action](ctx)
default:
// exec spec, or an unknown skill / quick command the backend owns.
return runExec(ctx)
}
}
await runSlash(rawCommand, options?.sessionId, options?.recordInput ?? true)
},
[
activeSessionIdRef,
appendSessionTextMessage,
branchCurrentSession,
busyRef,
copy,
createBackendSessionForSend,
handleSkinCommand,
handoffSession,
refreshSessions,
requestGateway,
resumeStoredSession,
startFreshSessionDraft,
submitPromptText
]
)
const submitText = useCallback(
async (rawText: string, options?: SubmitTextOptions) => {
const visibleText = rawText.trim()
const attachments = options?.attachments ?? $composerAttachments.get()
if (!attachments.length && SLASH_COMMAND_RE.test(visibleText)) {
triggerHaptic('selection')
await executeSlashCommand(visibleText)
return true
}
return await submitPromptText(rawText, options)
},
[executeSlashCommand, submitPromptText]
)
const transcribeVoiceAudio = useCallback(
async (audio: Blob) => {
if (!sttEnabled) {
throw new Error(copy.sttDisabled)
}
const dataUrl = await blobToDataUrl(audio)
const result = await transcribeAudio(dataUrl, audio.type)
return result.transcript
},
[copy.sttDisabled, sttEnabled]
)
const cancelRun = useCallback(async () => {
const sessionId = activeSessionId || activeSessionIdRef.current
const releaseBusy = () => {
setMutableRef(busyRef, false)
setBusy(false)
}
setAwaitingResponse(false)
const finalizeMessages = (messages: ChatMessage[], streamId?: string | null) =>
messages
.filter(
message =>
!((message.pending || message.id === streamId) && !chatMessageText(message).trim())
)
.map(message =>
message.pending || message.id === streamId ? { ...message, pending: false } : message
)
if (!sessionId) {
releaseBusy()
setMessages(finalizeMessages($messages.get()))
return
}
updateSessionState(sessionId, state => {
const streamId = state.streamId
const messages = finalizeMessages(state.messages, streamId)
return {
...state,
messages,
busy: false,
awaitingResponse: false,
streamId: null,
pendingBranchGroup: null,
interrupted: true
}
})
clearSessionTodos(sessionId)
clearSessionSubagents(sessionId)
resetSessionBackground(sessionId)
try {
await requestGateway('session.interrupt', { session_id: sessionId })
releaseBusy()
} catch (err) {
let stopError = err
if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) {
try {
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current
})
const recoveredId = resumed?.session_id
if (recoveredId) {
activeSessionIdRef.current = recoveredId
await requestGateway('session.interrupt', { session_id: recoveredId })
releaseBusy()
return
}
} catch (resumeErr) {
stopError = resumeErr
}
}
releaseBusy()
notifyError(stopError, copy.stopFailed)
}
}, [
activeSessionId,
activeSessionIdRef,
busyRef,
copy.stopFailed,
requestGateway,
selectedStoredSessionIdRef,
updateSessionState
])
// Steer = nudge the live turn without interrupting: the gateway appends the
// text to the next tool result so the model reads it on its next iteration
// (desktop parity with `/steer`). Returns false on reject (no live tool
// window) so the caller can fall back to queueing the words for the next turn.
const steerPrompt = useCallback(
async (rawText: string): Promise<boolean> => {
const text = rawText.trim()
const sessionId = activeSessionId || activeSessionIdRef.current
if (!text || !sessionId) {
return false
}
try {
const result = await requestGateway<SessionSteerResponse>('session.steer', { session_id: sessionId, text })
if (result?.status === 'queued') {
triggerHaptic('submit')
// Inline note (not a toast) so the nudge lives in the transcript next
// to the turn it steered. The `steer:` prefix is rendered as a codicon
// row by SystemMessage (see STEER_NOTE_RE), same style as slash output.
appendSessionTextMessage(sessionId, 'system', `steer:${text}`)
return true
}
} catch {
// Swallow — caller queues the text so nothing is lost.
}
return false
},
[activeSessionId, activeSessionIdRef, appendSessionTextMessage, requestGateway]
)
const reloadFromMessage = useCallback(
async (parentId: string | null) => {
if (!activeSessionId || $busy.get()) {
return
}
const messages = $messages.get()
const parentIndex = parentId ? messages.findIndex(message => message.id === parentId) : messages.length - 1
const userIndex =
parentIndex >= 0
? [...messages.slice(0, parentIndex + 1)].reverse().findIndex(message => message.role === 'user')
: -1
if (userIndex < 0) {
return
}
const absoluteUserIndex = parentIndex - userIndex
const userMessage = messages[absoluteUserIndex]
const userText = userMessage ? chatMessageText(userMessage).trim() : ''
if (!userText) {
return
}
const targetAssistant =
parentId && messages[parentIndex]?.role === 'assistant'
? messages[parentIndex]
: messages.slice(absoluteUserIndex + 1).find(message => message.role === 'assistant')
const branchGroupId = targetAssistant?.branchGroupId ?? branchGroupForUser(userMessage)
const truncateBeforeUserOrdinal = visibleUserOrdinal(messages, absoluteUserIndex)
clearNotifications()
updateSessionState(activeSessionId, state => {
const nextUserIndex = state.messages.findIndex(
(message, index) => index > absoluteUserIndex && message.role === 'user'
)
const end = nextUserIndex < 0 ? state.messages.length : nextUserIndex
return {
...state,
busy: true,
awaitingResponse: true,
pendingBranchGroup: branchGroupId,
sawAssistantPayload: false,
interrupted: false,
messages: [
...state.messages.slice(0, absoluteUserIndex + 1),
...state.messages
.slice(absoluteUserIndex + 1, end)
.map(message => (message.role === 'assistant' ? { ...message, branchGroupId, hidden: true } : message))
]
}
})
try {
await requestGateway('prompt.submit', {
session_id: activeSessionId,
text: userText,
truncate_before_user_ordinal: truncateBeforeUserOrdinal
})
} catch (err) {
updateSessionState(activeSessionId, state => ({
...state,
busy: false,
awaitingResponse: false
}))
notifyError(err, copy.regenerateFailed)
}
},
[activeSessionId, copy.regenerateFailed, requestGateway, updateSessionState]
)
// Cursor-style "restore checkpoint": rewind the conversation to a past user
// prompt and run it again from there. Reuses the edit composer's rewind
// mechanism — `prompt.submit` with `truncate_before_user_ordinal` drops that
// user turn and everything after it from the session history, then the same
// text is submitted as a fresh turn. Callers confirm before invoking; errors
// are rethrown so the confirmation dialog can surface them inline.
// Submit a rewind (truncate-before-ordinal + resubmit). Because edit/restore
// can fire while a turn is streaming, interrupt the live turn first, then
// retry the submit until the gateway stops reporting "session busy" — the
// interrupt is cooperative, so the running turn takes a beat to wind down.
const submitRewindPrompt = useCallback(
async (sessionId: string, text: string, truncateOrdinal: number | undefined, wasRunning: boolean) => {
if (wasRunning) {
try {
await requestGateway('session.interrupt', { session_id: sessionId })
} catch {
// Best-effort — the busy-retry below still gates the submit.
}
}
const deadline = Date.now() + REWIND_INTERRUPT_TIMEOUT_MS
for (;;) {
try {
await requestGateway('prompt.submit', {
session_id: sessionId,
text,
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
})
return
} catch (err) {
if (isSessionBusyError(err) && Date.now() < deadline) {
await sleep(REWIND_RETRY_INTERVAL_MS)
continue
}
throw err
}
}
},
[requestGateway]
)
const restoreToMessage = useCallback(
async (messageId: string) => {
const sessionId = activeSessionId || activeSessionIdRef.current
if (!sessionId) {
return
}
const messages = $messages.get()
const sourceIndex = messages.findIndex(m => m.id === messageId)
const source = messages[sourceIndex]
if (!source || source.role !== 'user') {
return
}
const text = chatMessageText(source).trim()
if (!text) {
return
}
const wasRunning = $busy.get()
const truncateBeforeUserOrdinal = visibleUserOrdinal(messages, sourceIndex)
// The turns we're discarding may have spawned todos and background
// processes; they belong to the abandoned timeline, so wipe their status
// rows (and kill the live processes) before the fresh run repopulates.
clearSessionTodos(sessionId)
resetSessionBackground(sessionId)
clearNotifications()
setMutableRef(busyRef, true)
setBusy(true)
setAwaitingResponse(true)
updateSessionState(sessionId, state => ({
...state,
busy: true,
awaitingResponse: true,
pendingBranchGroup: null,
sawAssistantPayload: false,
interrupted: false,
messages: state.messages.slice(0, sourceIndex + 1)
}))
try {
await submitRewindPrompt(sessionId, text, truncateBeforeUserOrdinal, wasRunning)
} catch (err) {
setMutableRef(busyRef, false)
setBusy(false)
setAwaitingResponse(false)
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
throw err
}
},
[activeSessionId, activeSessionIdRef, busyRef, submitRewindPrompt, updateSessionState]
)
const editMessage = useCallback(
async (edited: AppendMessage) => {
const sessionId = activeSessionId || activeSessionIdRef.current
const sourceId = edited.sourceId || edited.parentId
const text = appendText(edited)
if (!sessionId || !sourceId || !text || edited.role !== 'user') {
return
}
const messages = $messages.get()
const sourceIndex = messages.findIndex(m => m.id === sourceId)
const source = messages[sourceIndex]
if (!source || source.role !== 'user' || chatMessageText(source).trim() === text) {
return
}
// Sending an edit is a revert: rewind to this prompt and re-run with the
// new text. It can fire mid-turn, so capture the live state — the submit
// helper interrupts first when a turn is running.
const wasRunning = $busy.get()
// Failed turn: optimistic user msg never reached the gateway, so truncating
// by ordinal would 422. Submit as a plain resend instead.
const nextMessage = messages[sourceIndex + 1]
const isFailedTurn = nextMessage?.role === 'assistant' && Boolean(nextMessage.error)
const editedMessage: ChatMessage = { ...source, parts: [textPart(text)] }
// Editing rewinds the conversation to this prompt — same as restore — so
// drop the abandoned timeline's todos/background rows (and kill the live
// processes) before the re-run repopulates them.
clearSessionTodos(sessionId)
resetSessionBackground(sessionId)
clearNotifications()
setMutableRef(busyRef, true)
setBusy(true)
setAwaitingResponse(true)
updateSessionState(sessionId, state => ({
...state,
busy: true,
awaitingResponse: true,
pendingBranchGroup: null,
sawAssistantPayload: false,
interrupted: false,
messages: [...state.messages.slice(0, sourceIndex), editedMessage]
}))
const isStaleTargetError = (err: unknown) =>
/no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err))
try {
await submitRewindPrompt(sessionId, text, isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex), wasRunning)
} catch (err) {
let surfaced = err
if (!isFailedTurn && isStaleTargetError(err)) {
try {
// Already interrupted on the first attempt — submit as a plain resend.
await submitRewindPrompt(sessionId, text, undefined, false)
return
} catch (retryErr) {
surfaced = retryErr
}
}
setMutableRef(busyRef, false)
setBusy(false)
setAwaitingResponse(false)
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
notifyError(surfaced, copy.editFailed)
}
},
[activeSessionId, activeSessionIdRef, busyRef, copy.editFailed, submitRewindPrompt, updateSessionState]
)
const handleThreadMessagesChange = useCallback(
(nextMessages: readonly ThreadMessage[]) => {
const visibleIds = new Set(nextMessages.map(m => m.id))
const sessionId = activeSessionIdRef.current
if (!sessionId) {
return
}
updateSessionState(sessionId, state => {
let changed = false
const messages = state.messages.map(message => {
if (message.role !== 'assistant' || !message.branchGroupId) {
return message
}
const hidden = !visibleIds.has(message.id)
if (message.hidden === hidden) {
return message
}
changed = true
return { ...message, hidden }
})
return changed ? { ...state, messages } : state
})
},
[activeSessionIdRef, updateSessionState]
)
return {
cancelRun,
editMessage,
handleThreadMessagesChange,
handoffSession,
reloadFromMessage,
restoreToMessage,
steerPrompt,
submitText,
transcribeVoiceAudio
}
}