feat(desktop): session hooks — open-in-tile, per-session actions, resilient resume

This commit is contained in:
Brooklyn Nicholson 2026-07-13 17:28:32 -04:00
parent 860a3f67bb
commit 2afbe77763
11 changed files with 518 additions and 267 deletions

View file

@ -39,6 +39,7 @@ import {
setCurrentCwd,
setSessionsLoading
} from '@/store/session'
import { resetTileRuntimeBindings } from '@/store/session-states'
import type { RpcEvent } from '@/types/hermes'
// After this many consecutive failed reconnects (≈45s with the 1→15s backoff)
@ -167,6 +168,9 @@ export function useGatewayBoot({
}
reconnectAttempt = 0
// A respawned backend re-mints (recycles) runtime ids, so any tile's
// bound runtime id is now stale — drop them so each tile re-resumes.
resetTileRuntimeBindings()
// Resync state that may have moved on the backend while we were asleep.
await callbacksRef.current.refreshHermesConfig().catch(() => undefined)
await callbacksRef.current.refreshSessions().catch(() => undefined)

View file

@ -26,6 +26,7 @@ import { followActiveSessionCwd } from '@/store/projects'
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
import {
$currentCwd,
sessionMatchesStoredId,
setCurrentBranch,
setCurrentCwd,
setCurrentFastMode,
@ -380,7 +381,17 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
}
if (payload?.usage) {
setCurrentUsage(current => ({ ...current, ...payload.usage }))
// Per-session twin FIRST (the statusbar reads it for focused tiles);
// the primary-only global mirrors the ACTIVE session — ungated it
// let a background tile's turn overwrite the primary's count.
updateSessionState(sessionId, state => ({
...state,
usage: { calls: 0, input: 0, output: 0, total: 0, ...state.usage, ...payload.usage }
}))
if (isActiveEvent) {
setCurrentUsage(current => ({ ...current, ...payload.usage }))
}
}
} else if (event.type === 'session.title') {
// Live auto-title push (titler runs async, after the turn's refresh).
@ -388,9 +399,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
const nextTitle = typeof payload?.title === 'string' ? payload.title.trim() : ''
if (storedId && nextTitle) {
setSessions(prev =>
prev.map(s => (s.id === storedId || s._lineage_root_id === storedId ? { ...s, title: nextTitle } : s))
)
setSessions(prev => prev.map(s => (sessionMatchesStoredId(s, storedId) ? { ...s, title: nextTitle } : s)))
}
} else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') {
if (!sessionId) {

View file

@ -1426,6 +1426,7 @@ describe('usePromptActions submit session-context isolation (#54527)', () => {
it('aborts recovery submit when the user switches sessions during timeout resume', async () => {
const calls: { method: string; params?: Record<string, unknown> }[] = []
let submitAttempts = 0
let releaseResume: () => void = () => {}
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: STORED_SESSION_A }

View file

@ -5,7 +5,7 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS, transcribeAudio } from '@/hermes'
import { useI18n } from '@/i18n'
import { stripAnsi } from '@/lib/ansi'
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import { type ChatMessage, textPart } from '@/lib/chat-messages'
import { pathLabel, SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { setMutableRef } from '@/lib/mutable-ref'
@ -35,23 +35,28 @@ import type {
SessionSteerResponse
} from '../../../types'
import {
applyBranchVisibility,
applyReloadOptimistic,
applyRewindOptimistic,
finalizeInterruptedMessages,
planEdit,
planReload,
planRestore,
runRewindSubmit
} from './rewind'
import { useSlashCommand } from './slash'
import { useSubmitPrompt } from './submit'
import {
appendText,
blobToDataUrl,
delay,
friendlyRemoteAttachError,
type GatewayRequest,
inlineErrorMessage,
isSessionBusyError,
isSessionNotFoundError,
readFileDataUrlForAttach,
readImageForRemoteAttach,
type SubmitTextOptions,
visibleUserIndexAtOrdinal,
visibleUserOrdinal,
withSessionBusyRetry
type SubmitTextOptions
} from './utils'
interface HandoffResult {
@ -394,6 +399,13 @@ export function usePromptActions({
return { error: inlineErrorMessage(err, copy.handoff.failed(target)), ok: false }
}
const markCompleted = (): HandoffResult => {
appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
notify({ kind: 'success', message: copy.handoff.success(target) })
return { ok: true }
}
const deadline = Date.now() + 60_000
let lastState = 'pending'
@ -416,10 +428,7 @@ export function usePromptActions({
}
if (state === 'completed') {
appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
notify({ kind: 'success', message: copy.handoff.success(target) })
return { ok: true }
return markCompleted()
}
if (state === 'failed') {
@ -433,10 +442,7 @@ export function usePromptActions({
}).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 markCompleted()
}
return { error: copy.handoff.timedOut, ok: false }
@ -502,21 +508,16 @@ export function usePromptActions({
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()))
setMessages(finalizeInterruptedMessages($messages.get()))
return
}
updateSessionState(sessionId, state => {
const streamId = state.streamId
const messages = finalizeMessages(state.messages, streamId)
const messages = finalizeInterruptedMessages(state.messages, streamId)
return {
...state,
@ -620,66 +621,19 @@ export function usePromptActions({
return
}
const messages = $messages.get()
const parentIndex = parentId ? messages.findIndex(message => message.id === parentId) : messages.length - 1
const plan = planReload($messages.get(), parentId)
const userIndex =
parentIndex >= 0
? [...messages.slice(0, parentIndex + 1)].reverse().findIndex(message => message.role === 'user')
: -1
if (userIndex < 0) {
if (!plan) {
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))
]
}
})
updateSessionState(activeSessionId, state => applyReloadOptimistic(state, plan))
try {
await requestGateway(
'prompt.submit',
{
session_id: activeSessionId,
text: userText,
truncate_before_user_ordinal: truncateBeforeUserOrdinal
},
{ session_id: activeSessionId, text: plan.text, truncate_before_user_ordinal: plan.truncateOrdinal },
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
)
} catch (err) {
@ -704,41 +658,8 @@ export function usePromptActions({
// fresh turn. Live/stuck turns interrupt first, and a raced "session busy"
// response interrupts + retries through the shared busy gate.
const submitRewindPrompt = useCallback(
async (sessionId: string, text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) => {
const interrupt = async () => {
try {
await requestGateway('session.interrupt', { session_id: sessionId })
} catch {
// Best-effort. The submit path still gates on the gateway state.
}
}
const submit = () =>
requestGateway(
'prompt.submit',
{
session_id: sessionId,
text,
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
},
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
)
if (interruptFirst) {
await interrupt()
}
try {
await submit()
} catch (err) {
if (!isSessionBusyError(err)) {
throw err
}
await interrupt()
await withSessionBusyRetry(submit)
}
},
(sessionId: string, text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) =>
runRewindSubmit(requestGateway, sessionId, text, truncateOrdinal, interruptFirst),
[requestGateway]
)
@ -751,30 +672,7 @@ export function usePromptActions({
}
const messages = $messages.get()
const idIndex = messages.findIndex(m => m.id === messageId && m.role === 'user')
const fallbackIndex =
target?.userOrdinal === null || target?.userOrdinal === undefined
? -1
: visibleUserIndexAtOrdinal(messages, target.userOrdinal)
const sourceIndex = idIndex >= 0 ? idIndex : fallbackIndex
const source = messages[sourceIndex]
if (!source || source.role !== 'user') {
throw new Error('Could not find the message to restore.')
}
const text = (chatMessageText(source).trim() || target?.text?.trim() || '').trim()
if (!text) {
throw new Error('Cannot restore an empty message.')
}
const truncateBeforeUserOrdinal =
target?.userOrdinal === null || target?.userOrdinal === undefined
? visibleUserOrdinal(messages, sourceIndex)
: target.userOrdinal
const plan = planRestore(messages, messageId, target)
// The turns we're discarding may have spawned todos and background
// processes; they belong to the abandoned timeline, so wipe their status
@ -787,18 +685,10 @@ export function usePromptActions({
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)
}))
updateSessionState(sessionId, state => applyRewindOptimistic(state, plan.sourceIndex))
try {
await submitRewindPrompt(sessionId, text, truncateBeforeUserOrdinal, busyRef.current || $busy.get())
await submitRewindPrompt(sessionId, plan.text, plan.truncateOrdinal, busyRef.current || $busy.get())
} catch (err) {
// The rewind never landed (e.g. the gateway stayed busy past the retry
// deadline). Roll the optimistic truncation back to the full original
@ -822,34 +712,17 @@ export function usePromptActions({
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]
const plan = sessionId ? planEdit(messages, edited) : null
if (!source || source.role !== 'user' || chatMessageText(source).trim() === text) {
if (!sessionId || !plan) {
return
}
// Sending an edit is a revert: rewind to this prompt and re-run with the
// new text. It can fire mid-turn; submitRewindPrompt always interrupts
// first, so a live turn is wound down before the resubmit.
// 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.
// new text (submitRewindPrompt interrupts a live turn first). Same as
// restore, so drop the abandoned timeline's todos/background rows before
// the re-run repopulates them.
clearSessionTodos(sessionId)
resetSessionBackground(sessionId)
clearPreviewArtifacts(sessionId)
@ -858,33 +731,20 @@ export function usePromptActions({
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]
}))
updateSessionState(sessionId, state => applyRewindOptimistic(state, plan.sourceIndex, plan.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),
busyRef.current || $busy.get()
)
await submitRewindPrompt(sessionId, plan.text, plan.truncateOrdinal, busyRef.current || $busy.get())
} catch (err) {
let surfaced = err
if (!isFailedTurn && isStaleTargetError(err)) {
if (!plan.isFailedTurn && isStaleTargetError(err)) {
try {
// Already interrupted on the first attempt — submit as a plain resend.
await submitRewindPrompt(sessionId, text, undefined, false)
await submitRewindPrompt(sessionId, plan.text, undefined, false)
return
} catch (retryErr) {
@ -907,34 +767,13 @@ export function usePromptActions({
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
})
updateSessionState(sessionId, state => applyBranchVisibility(state, nextMessages))
},
[activeSessionIdRef, updateSessionState]
)
@ -942,6 +781,9 @@ export function usePromptActions({
return {
cancelRun,
editMessage,
// Session tiles route their slash input here (targets THEIR session via
// options.sessionId; app-level effects — branch, handoff — act on main).
executeSlashCommand,
handleThreadMessagesChange,
handoffSession,
reloadFromMessage,

View file

@ -0,0 +1,271 @@
/**
* Shared rewind/interrupt core for the prompt verbs the ONE implementation
* of the submit primitive + the pure message math behind cancel / reload /
* restore / edit / branch-visibility. Both the primary chat (`index.ts`) and
* session tiles (`session-tile-actions.ts`) build on these so the two surfaces
* can't silently diverge (the tile's "sends only once" busy-ref bug was exactly
* that class of drift). The functions here are PURE planners compute from a
* `ChatMessage[]`, optimistic transforms map a `ClientSessionState` to the next
* so each caller keeps its own state-write + error-handling wiring.
*/
import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
import type { ClientSessionState } from '@/app/types'
import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes'
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import { appendText, isSessionBusyError, visibleUserIndexAtOrdinal, visibleUserOrdinal, withSessionBusyRetry } from './utils'
type RequestGateway = <T = unknown>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
/**
* Rewind a turn: `prompt.submit` with an optional `truncate_before_user_ordinal`
* (drops that user turn + everything after). Idle rewinds submit directly
* (interrupting an idle agent can leave a stale interrupt flag that cancels the
* fresh turn); live/stuck turns interrupt first, and a raced "session busy"
* response interrupts + retries through the shared busy gate.
*/
export async function runRewindSubmit(
requestGateway: RequestGateway,
sessionId: string,
text: string,
truncateOrdinal: number | undefined,
interruptFirst: boolean
): Promise<void> {
const interrupt = async () => {
try {
await requestGateway('session.interrupt', { session_id: sessionId })
} catch {
// Best-effort. The submit path still gates on the gateway state.
}
}
const submit = () =>
requestGateway(
'prompt.submit',
{ session_id: sessionId, text, ...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal }) },
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
)
if (interruptFirst) {
await interrupt()
}
try {
await submit()
} catch (err) {
if (!isSessionBusyError(err)) {
throw err
}
await interrupt()
await withSessionBusyRetry(submit)
}
}
/** Cancel/stop finalize: drop empty pending/stream placeholders, un-pend the rest. */
export function finalizeInterruptedMessages(messages: ChatMessage[], streamId?: null | string): ChatMessage[] {
return messages
.filter(message => !((message.pending || message.id === streamId) && !chatMessageText(message).trim()))
.map(message => (message.pending || message.id === streamId ? { ...message, pending: false } : message))
}
// ---------------------------------------------------------------------------
// Reload (regenerate)
// ---------------------------------------------------------------------------
export interface ReloadPlan {
branchGroupId: string
text: string
truncateOrdinal: number
userIndex: number
}
/** The user turn to re-run for a reload from `parentId` (or the last turn). */
export function planReload(messages: ChatMessage[], parentId: null | string): null | ReloadPlan {
const parentIndex = parentId ? messages.findIndex(m => m.id === parentId) : messages.length - 1
const userBack =
parentIndex >= 0 ? [...messages.slice(0, parentIndex + 1)].reverse().findIndex(m => m.role === 'user') : -1
if (userBack < 0) {
return null
}
const userIndex = parentIndex - userBack
const userMessage = messages[userIndex]
const text = userMessage ? chatMessageText(userMessage).trim() : ''
if (!userMessage || !text) {
return null
}
const targetAssistant =
parentId && messages[parentIndex]?.role === 'assistant'
? messages[parentIndex]
: messages.slice(userIndex + 1).find(m => m.role === 'assistant')
return {
branchGroupId: targetAssistant?.branchGroupId ?? branchGroupForUser(userMessage),
text,
truncateOrdinal: visibleUserOrdinal(messages, userIndex),
userIndex
}
}
/** Optimistic reload state: keep the user turn, hide the branch's assistants. */
export function applyReloadOptimistic(state: ClientSessionState, plan: ReloadPlan): ClientSessionState {
const nextUserIndex = state.messages.findIndex((m, i) => i > plan.userIndex && m.role === 'user')
const end = nextUserIndex < 0 ? state.messages.length : nextUserIndex
return {
...state,
awaitingResponse: true,
busy: true,
interrupted: false,
messages: [
...state.messages.slice(0, plan.userIndex + 1),
...state.messages
.slice(plan.userIndex + 1, end)
.map(m => (m.role === 'assistant' ? { ...m, branchGroupId: plan.branchGroupId, hidden: true } : m))
],
pendingBranchGroup: plan.branchGroupId,
sawAssistantPayload: false
}
}
// ---------------------------------------------------------------------------
// Restore (rewind checkpoint)
// ---------------------------------------------------------------------------
export interface RestoreTarget {
text?: string
userOrdinal?: null | number
}
export interface RestorePlan {
sourceIndex: number
text: string
truncateOrdinal: number
}
/** Resolve the user turn to rewind to; throws with a user-facing reason. */
export function planRestore(messages: ChatMessage[], messageId: string, target?: RestoreTarget): RestorePlan {
const idIndex = messages.findIndex(m => m.id === messageId && m.role === 'user')
const fallbackIndex =
target?.userOrdinal === null || target?.userOrdinal === undefined
? -1
: visibleUserIndexAtOrdinal(messages, target.userOrdinal)
const sourceIndex = idIndex >= 0 ? idIndex : fallbackIndex
const source = messages[sourceIndex]
if (!source || source.role !== 'user') {
throw new Error('Could not find the message to restore.')
}
const text = (chatMessageText(source).trim() || target?.text?.trim() || '').trim()
if (!text) {
throw new Error('Cannot restore an empty message.')
}
const truncateOrdinal =
target?.userOrdinal === null || target?.userOrdinal === undefined
? visibleUserOrdinal(messages, sourceIndex)
: target.userOrdinal
return { sourceIndex, text, truncateOrdinal }
}
// ---------------------------------------------------------------------------
// Edit (revert + resubmit with new text)
// ---------------------------------------------------------------------------
export interface EditPlan {
editedMessage: ChatMessage
isFailedTurn: boolean
sourceIndex: number
text: string
truncateOrdinal: number | undefined
}
/** Resolve the edited user turn, or null when nothing changed / invalid. */
export function planEdit(messages: ChatMessage[], edited: AppendMessage): EditPlan | null {
const sourceId = edited.sourceId || edited.parentId
const text = appendText(edited)
if (!sourceId || !text || edited.role !== 'user') {
return null
}
const sourceIndex = messages.findIndex(m => m.id === sourceId)
const source = messages[sourceIndex]
if (!source || source.role !== 'user' || chatMessageText(source).trim() === text) {
return null
}
// Failed turn: the optimistic user msg never reached the gateway, so a
// truncate-by-ordinal would 422 — resubmit plainly instead.
const nextMessage = messages[sourceIndex + 1]
const isFailedTurn = nextMessage?.role === 'assistant' && Boolean(nextMessage.error)
return {
editedMessage: { ...source, parts: [textPart(text)] },
isFailedTurn,
sourceIndex,
text,
truncateOrdinal: isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex)
}
}
/** Optimistic rewind-to state for restore/edit: drop everything after the
* source turn (edit swaps in the edited message; restore keeps the original). */
export function applyRewindOptimistic(
state: ClientSessionState,
sourceIndex: number,
editedMessage?: ChatMessage
): ClientSessionState {
return {
...state,
awaitingResponse: true,
busy: true,
interrupted: false,
messages: editedMessage
? [...state.messages.slice(0, sourceIndex), editedMessage]
: state.messages.slice(0, sourceIndex + 1),
pendingBranchGroup: null,
sawAssistantPayload: false
}
}
// ---------------------------------------------------------------------------
// Branch visibility (assistant-ui hides non-active branches)
// ---------------------------------------------------------------------------
/** Sync each assistant branch message's `hidden` to what the thread renders. */
export function applyBranchVisibility(state: ClientSessionState, next: readonly ThreadMessage[]): ClientSessionState {
const visibleIds = new Set(next.map(m => m.id))
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
}

View file

@ -48,6 +48,26 @@ interface SubmitPromptDeps {
updater: (state: ClientSessionState) => ClientSessionState,
storedSessionId?: string | null
) => ClientSessionState
/** Composer-scope seams: the main chat runs on the module-level globals
* (defaults); a session tile injects its own so a tile submit never writes
* the primary view's $busy/$messages or clears the main attachment chips. */
scope?: {
clearAttachments: () => void
readAttachments: () => ComposerAttachment[]
setAwaitingResponse: (awaiting: boolean) => void
setBusy: (busy: boolean) => void
setMessages: (updater: (current: ChatMessage[]) => ChatMessage[]) => void
}
}
// Stable identity — a fresh default object per render would churn the
// useCallback below on every render.
const MAIN_SUBMIT_SCOPE: NonNullable<SubmitPromptDeps['scope']> = {
clearAttachments: clearComposerAttachments,
readAttachments: () => $composerAttachments.get(),
setAwaitingResponse,
setBusy,
setMessages
}
/** The prompt submit pipeline, extracted from usePromptActions. */
@ -62,7 +82,8 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
requestGateway,
selectedStoredSessionIdRef,
syncAttachmentsForSubmit,
updateSessionState
updateSessionState,
scope = MAIN_SUBMIT_SCOPE
} = deps
return useCallback(
@ -75,7 +96,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
// this, the sibling iterations below (a.kind / a.label / a.refText, and the
// sync step) throw "Cannot read properties of undefined (reading 'refText')"
// and break the chat surface.
const attachments = (options?.attachments ?? $composerAttachments.get()).filter((a): a is ComposerAttachment =>
const attachments = (options?.attachments ?? scope.readAttachments()).filter((a): a is ComposerAttachment =>
Boolean(a)
)
@ -158,8 +179,8 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
const releaseBusy = () => {
releaseSubmitLock()
setMutableRef(busyRef, false)
setBusy(false)
setAwaitingResponse(false)
scope.setBusy(false)
scope.setAwaitingResponse(false)
}
// Idempotent optimistic insert — re-running with the resolved sessionId
@ -198,7 +219,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
const dropOptimistic = (sid: null | string) => {
if (!sid) {
setMessages(current => current.filter(m => m.id !== optimisticId))
scope.setMessages(current => current.filter(m => m.id !== optimisticId))
return
}
@ -224,8 +245,8 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
}
setMutableRef(busyRef, true)
setBusy(true)
setAwaitingResponse(true)
scope.setBusy(true)
scope.setAwaitingResponse(true)
clearNotifications()
let sessionId: null | string = activeSessionId
@ -233,7 +254,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
if (sessionId) {
seedOptimistic(sessionId)
} else {
setMessages(current => [...current, buildUserMessage()])
scope.setMessages(current => [...current, buildUserMessage()])
}
if (!sessionId && startingStoredSessionId) {
@ -382,7 +403,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
}
if (usingComposerAttachments) {
clearComposerAttachments()
scope.clearAttachments()
}
// Submit landed — the turn now runs (busy stays true), but the submit
@ -439,6 +460,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
createBackendSessionForSend,
getRouteToken,
requestGateway,
scope,
selectedStoredSessionIdRef,
syncAttachmentsForSubmit,
updateSessionState

View file

@ -426,6 +426,7 @@ describe('resumeSession failure recovery', () => {
storedSessionId: 'stored-1',
streamId: null,
turnStartedAt: null,
usage: null,
yolo: false
}
]

View file

@ -2,6 +2,7 @@ import type { MutableRefObject } from 'react'
import { useCallback, useRef } from 'react'
import type { NavigateFunction } from 'react-router-dom'
import { revealTreePane } from '@/components/pane-shell/tree/store'
import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
import { useI18n } from '@/i18n'
import { preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
@ -44,6 +45,7 @@ import {
setTurnStartedAt,
setYoloActive
} from '@/store/session'
import { closeSessionTile, dropSessionState, openSessionTile, patchSessionTile, publishSessionState, type TileDock } from '@/store/session-states'
import { broadcastSessionsChanged } from '@/store/session-sync'
import { isWatchWindow } from '@/store/windows'
import type { SessionCreateResponse, SessionResumeResponse, UsageStats } from '@/types/hermes'
@ -88,6 +90,54 @@ interface SessionActionsOptions {
) => ClientSessionState
}
// Stored ids created in THIS renderer run. A brand-new session lives only in the
// gateway's in-memory map until its first turn persists a state.db row — so if a
// respawning/flapping backend drops it, both resume RPC and the REST transcript
// 404 even though the user just made it. We must NOT treat that as "gone" (which
// yanks them to a fresh draft — the "new sessions clear themselves" bug); the
// bounded retry rebinds it when the backend returns. Boot-into-a-stale-last-id
// (NOT in this set) still legitimately drops to a draft.
const createdThisRun = new Set<string>()
// Reflect a stored row's persisted token counts into the live usage atom
// (total is derived, so callers can't drift it out of sync with input/output).
function applyStoredUsage(stored: { input_tokens?: number | null; output_tokens?: number | null }) {
const input = stored.input_tokens || 0
const output = stored.output_tokens || 0
setCurrentUsage(current => ({ ...current, input, output, total: input + output }))
}
// `session.create` params from the current profile + sticky-UI model/effort/fast,
// ensuring the gateway is on that profile first. Shared by the primary send path
// and the "open in split" tile path; `cwd` is the one thing that differs (the
// live composer cwd for a send, the resolved new-session cwd for a fresh tile).
//
// Resolving null profile to the active gateway's is load-bearing: in global-remote
// mode one backend serves every profile, so an omitted profile silently lands the
// chat on the launch (default) profile — the "rubberbands back to default" bug.
// A no-op for single-profile/local-pooled users (a backend resolves its own launch
// profile to None). The sticky UI model/effort/fast ride as per-session overrides,
// never the profile default (that lives in Settings → Model).
async function desktopSessionCreateParams(cwd: string): Promise<Record<string, unknown>> {
const profile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
await ensureGatewayProfile(profile)
const model = $currentModel.get().trim()
const provider = $currentProvider.get().trim()
const effort = $currentReasoningEffort.get().trim()
return {
cols: 96,
source: 'desktop',
...(cwd && { cwd }),
...(profile ? { profile } : {}),
...(model ? { model, ...(provider ? { provider } : {}) } : {}),
...(effort ? { reasoning_effort: effort } : {}),
...($currentFastMode.get() ? { fast: true } : {})
}
}
interface FreshSessionDraftOptions {
replaceRoute?: boolean
workspaceTarget?: NewChatWorkspaceTarget
@ -186,19 +236,9 @@ export function useSessionActions({
creatingSessionRef.current = true
try {
// A plain new session (top "New Session", /new, keybind) leaves
// $newChatProfile null to mean "use the live context"; the per-profile
// "+" sets it explicitly. Resolve null to the active gateway profile so
// session.create always carries it: in global-remote mode one backend
// serves every profile, so an omitted profile param silently lands the
// chat on the launch (default) profile — the "rubberbands back to
// default" bug. This is a no-op for single-profile/local-pooled users:
// a backend resolves its own launch profile to None (_profile_home).
const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
await ensureGatewayProfile(newChatProfile)
// An explicit one-shot workspace target (null → detached, string → that
// folder) wins; otherwise fall through to the live cwd, then the
// project-aware default (resolveNewSessionCwd).
// folder) wins; otherwise the live cwd, then the project-aware default
// (resolveNewSessionCwd — a project's new session keeps its repo cwd).
const workspaceTarget = $newChatWorkspaceTarget.get()
const cwd =
@ -208,26 +248,8 @@ export function useSessionActions({
? workspaceTarget.trim()
: $currentCwd.get().trim() || resolveNewSessionCwd()
// The composer's model/effort/fast is sticky UI state ($currentModel,
// $currentProvider, $currentReasoningEffort, $currentFastMode). Ship it
// with every session.create so the new chat opens on whatever the picker
// shows — applied as per-session overrides, never written to the profile
// default (that lives in Settings → Model).
const uiModel = $currentModel.get().trim()
const uiProvider = $currentProvider.get().trim()
const uiEffort = $currentReasoningEffort.get().trim()
const uiFast = $currentFastMode.get()
const created = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
source: 'desktop',
...(cwd && { cwd }),
...(newChatProfile ? { profile: newChatProfile } : {}),
...(uiModel ? { model: uiModel, ...(uiProvider ? { provider: uiProvider } : {}) } : {}),
...(uiEffort ? { reasoning_effort: uiEffort } : {}),
...(uiFast ? { fast: true } : {})
})
const params = await desktopSessionCreateParams(cwd)
const created = await requestGateway<SessionCreateResponse>('session.create', params)
const stored = created.stored_session_id ?? null
if (
@ -246,6 +268,7 @@ export function useSessionActions({
ensureSessionState(created.session_id, stored)
if (stored) {
createdThisRun.add(stored)
// Seed the sidebar preview with the user's first message so the row
// reads meaningfully while the turn is in flight, instead of flashing
// "Untitled session" until the turn persists and auto-title runs. The
@ -310,6 +333,44 @@ export function useSessionActions({
[navigate, startFreshSessionDraft]
)
/** Create a fresh session and open it as a tile leaves the primary chat alone.
* Used by the New session row's "Open in split" menu (and any future
* "new chat beside" affordance). */
const openNewSessionTile = useCallback(
async (dir: TileDock = 'right') => {
try {
// Fresh tile → the resolved new-session cwd (project/default), not the
// primary composer's live cwd.
const params = await desktopSessionCreateParams(resolveNewSessionCwd().trim())
const created = await requestGateway<SessionCreateResponse>('session.create', params)
const stored = created.stored_session_id
if (!stored) {
await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined)
notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed })
return
}
createdThisRun.add(stored)
// Seed the sidebar + per-runtime cache, but DON'T steal the primary
// selection — this session lives in the tile. Prime it with the create
// runtime so the tile skips a redundant resume.
upsertOptimisticSession(created, stored, null, null)
const runtimeInfo = applyRuntimeInfo(created.info)
updateSessionState(created.session_id, state => (runtimeInfo ? { ...state, ...runtimeInfo } : state), stored)
openSessionTile(stored, dir)
patchSessionTile(stored, { runtimeId: created.session_id })
revealTreePane(`session-tile:${stored}`)
broadcastSessionsChanged()
} catch (error) {
notifyError(error, copy.createSessionFailed)
}
},
[copy, requestGateway, updateSessionState]
)
const openSettings = useCallback(() => {
navigate(SETTINGS_ROUTE)
}, [navigate])
@ -375,6 +436,7 @@ export function useSessionActions({
if (state.storedSessionId !== storedSessionId) {
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(runtimeId)
dropSessionState(runtimeId)
return null
}
@ -423,11 +485,13 @@ export function useSessionActions({
if (cachedViewState !== cachedState) {
sessionStateByRuntimeIdRef.current.set(cachedRuntimeId, cachedViewState)
publishSessionState(cachedRuntimeId, cachedViewState)
}
if (sessionShouldHaveTranscript(stored) && cachedViewState.messages.length === 0) {
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
dropSessionState(cachedRuntimeId)
} else {
setFreshDraftReady(false)
clearNotifications()
@ -464,6 +528,7 @@ export function useSessionActions({
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
dropSessionState(cachedRuntimeId)
}
}
}
@ -485,12 +550,7 @@ export function useSessionActions({
applyStoredSessionPreviewRuntimeInfo(stored)
if (stored) {
setCurrentUsage(current => ({
...current,
input: stored.input_tokens || 0,
output: stored.output_tokens || 0,
total: (stored.input_tokens || 0) + (stored.output_tokens || 0)
}))
applyStoredUsage(stored)
}
let resumedRunning = false
@ -642,6 +702,16 @@ export function useSessionActions({
// permanently-dead id. (Booting straight into a no-longer-existent
// last-session id is the common trigger.)
if ($messages.get().length === 0 && isSessionGoneError(fallbackError)) {
// A session created THIS run isn't gone — its backend just flapped
// before the turn-less session persisted. Keep the empty view and arm
// the bounded retry to rebind, rather than yanking to a fresh draft.
// Only a stale id from a PRIOR run drops to a draft.
if (createdThisRun.has(storedSessionId)) {
setResumeFailedSessionId(storedSessionId)
return
}
startFreshSessionDraft(true)
return
@ -876,6 +946,18 @@ export function useSessionActions({
if (closingRuntimeId) {
clearQueuedPrompts(closingRuntimeId)
}
// A tiled copy of this session must not outlive it: collapse the pane
// and evict its mirrored runtime state so nothing submits to (or renders)
// a deleted session.
const tiledRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
closeSessionTile(storedSessionId)
if (tiledRuntimeId) {
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(tiledRuntimeId)
dropSessionState(tiledRuntimeId)
}
} catch (err) {
if (removed) {
setSessions(prev => [removed, ...prev])
@ -892,12 +974,7 @@ export function useSessionActions({
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
if (stored) {
setCurrentUsage(current => ({
...current,
input: stored.input_tokens || 0,
output: stored.output_tokens || 0,
total: (stored.input_tokens || 0) + (stored.output_tokens || 0)
}))
applyStoredUsage(stored)
}
setMessages(previousMessages)
@ -918,8 +995,10 @@ export function useSessionActions({
copy,
navigate,
requestGateway,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef,
startFreshSessionDraft
]
)
@ -956,6 +1035,16 @@ export function useSessionActions({
// not appear to do nothing until the next full refresh.
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
$pinnedSessionIds.set($pinnedSessionIds.get().filter(id => id !== storedSessionId && id !== archivedPinId))
// An archived session is hidden from the sidebar; its tile must go too.
const tiledRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
closeSessionTile(storedSessionId)
if (tiledRuntimeId) {
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(tiledRuntimeId)
dropSessionState(tiledRuntimeId)
}
notify({ durationMs: 2_000, kind: 'success', message: copy.archived })
} catch (err) {
if (archived) {
@ -968,7 +1057,13 @@ export function useSessionActions({
notifyError(err, copy.archiveFailed)
}
},
[copy, selectedStoredSessionId, startFreshSessionDraft]
[
copy,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
sessionStateByRuntimeIdRef,
startFreshSessionDraft
]
)
return {
@ -977,6 +1072,7 @@ export function useSessionActions({
branchStoredSession,
closeSettings,
createBackendSessionForSend,
openNewSessionTile,
openSettings,
removeSession,
resumeSession,

View file

@ -8,6 +8,7 @@ import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/p
import {
$currentCwd,
$sessions,
sessionMatchesStoredId,
setCurrentBranch,
setCurrentCwd,
setCurrentFastMode,
@ -20,6 +21,10 @@ import {
setSessions,
setYoloActive
} from '@/store/session'
// Re-exported for the many session-actions/tile call sites that already import
// it from here; the canonical definition lives in @/store/session.
export { sessionMatchesStoredId }
import { reportBackendContract, reportInstallMethodWarning } from '@/store/updates'
import type { SessionCreateResponse, SessionInfo, SessionRuntimeInfo } from '@/types/hermes'
@ -183,10 +188,6 @@ export function patchSessionWorkspace(sessionId: string, cwd: string | undefined
setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session)))
}
export function sessionMatchesStoredId(session: SessionInfo, storedSessionId: string): boolean {
return session.id === storedSessionId || session._lineage_root_id === storedSessionId
}
export function sessionShouldHaveTranscript(session: SessionInfo | undefined): boolean {
return (session?.message_count ?? 0) > 0
}

View file

@ -1,6 +1,7 @@
import { useCallback, useRef } from 'react'
import { getCronJobs, listAllProfileSessions, type SessionInfo } from '@/hermes'
import { sameCronSignature } from '@/lib/session-signatures'
import {
isMessagingSource,
LOCAL_SESSION_SOURCE_IDS,
@ -29,8 +30,6 @@ import {
setSessionsTotal
} from '@/store/session'
import { sameCronSignature } from '../../desktop-controller-utils'
// The recents list is local-only: cron rows have their own section, and each
// messaging platform (telegram, discord, …) is fetched separately into its own
// self-managed sidebar section (refreshMessagingSessions). Excluding both here

View file

@ -21,6 +21,7 @@ import {
setTurnStartedAt,
setYoloActive
} from '@/store/session'
import { publishSessionState } from '@/store/session-states'
import type { ClientSessionState } from '../../types'
@ -263,6 +264,10 @@ export function useSessionStateCache({
const previous = ensureSessionState(sessionId, storedSessionId)
const next = updater({ ...previous, messages: previous.messages })
sessionStateByRuntimeIdRef.current.set(sessionId, next)
// Mirror into the reactive multi-session store — session tiles (and any
// other non-primary surface) subscribe per runtime id there instead of
// through the single active $messages view.
publishSessionState(sessionId, next)
if (previous.storedSessionId !== next.storedSessionId || !next.busy) {
setSessionWorking(previous.storedSessionId, false)