mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
feat(desktop): session hooks — open-in-tile, per-session actions, resilient resume
This commit is contained in:
parent
860a3f67bb
commit
2afbe77763
11 changed files with 518 additions and 267 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
271
apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts
Normal file
271
apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -426,6 +426,7 @@ describe('resumeSession failure recovery', () => {
|
|||
storedSessionId: 'stored-1',
|
||||
streamId: null,
|
||||
turnStartedAt: null,
|
||||
usage: null,
|
||||
yolo: false
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue