From 2afbe777636078622ae39c60ddb85300d0690296 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Jul 2026 17:28:32 -0400 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20session=20hooks=20=E2=80=94=20?= =?UTF-8?q?open-in-tile,=20per-session=20actions,=20resilient=20resume?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/gateway/hooks/use-gateway-boot.ts | 4 + .../hooks/use-message-stream/gateway-event.ts | 17 +- .../hooks/use-prompt-actions/index.test.tsx | 1 + .../session/hooks/use-prompt-actions/index.ts | 248 +++------------- .../hooks/use-prompt-actions/rewind.ts | 271 ++++++++++++++++++ .../hooks/use-prompt-actions/submit.ts | 40 ++- .../hooks/use-session-actions.test.tsx | 1 + .../hooks/use-session-actions/index.ts | 186 +++++++++--- .../hooks/use-session-actions/utils.ts | 9 +- .../session/hooks/use-session-list-actions.ts | 3 +- .../session/hooks/use-session-state-cache.ts | 5 + 11 files changed, 518 insertions(+), 267 deletions(-) create mode 100644 apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 2f239569f15b..a9c07ad57fd1 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -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) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 77104ba6295d..d33b345b55e9 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -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) { diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index 3e725b1480d3..b898e43e1caa 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -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 }[] = [] let submitAttempts = 0 + let releaseResume: () => void = () => {} const selectedStoredSessionIdRef: MutableRefObject = { current: STORED_SESSION_A } diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 51a3689ae4e1..fb7f053b49c8 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -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, diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts new file mode 100644 index 000000000000..bd934ed448cd --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts @@ -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 = (method: string, params?: Record, timeoutMs?: number) => Promise + +/** + * 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 { + 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 +} diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index b1eaa4966bf2..347217740382 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -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 = { + 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 diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 45bab0c79c1d..bf53209d4f03 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -426,6 +426,7 @@ describe('resumeSession failure recovery', () => { storedSessionId: 'stored-1', streamId: null, turnStartedAt: null, + usage: null, yolo: false } ] diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 831fba0fe257..d8a7e0bd15d1 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -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() + +// 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> { + 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('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('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('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, diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index 06458eb59025..f202997ef1fc 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -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 } diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts index 6f971c3165be..6e4cbb7cc021 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts @@ -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 diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts index 22ce8796f74c..7f89eec64f9e 100644 --- a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts +++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts @@ -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)