chore: uptick

This commit is contained in:
Brooklyn Nicholson
2026-05-02 03:19:39 -05:00
parent 420f68e4e2
commit db884f4646
240 changed files with 25206 additions and 3155 deletions
@@ -2,18 +2,20 @@ import type { QueryClient } from '@tanstack/react-query'
import { type MutableRefObject, useCallback } from 'react'
import {
appendAssistantTextPart,
appendReasoningPart,
appendTextPart,
assistantTextPart,
type ChatMessage,
type ChatMessagePart,
chatMessageText,
type GatewayEventPayload,
reasoningPart,
textPart,
renderMediaTags,
upsertToolPart
} from '@/lib/chat-messages'
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { setClarifyRequest } from '@/store/clarify'
import { notify } from '@/store/notifications'
import {
setCurrentBranch,
@@ -22,6 +24,7 @@ import {
setCurrentPersonality,
setCurrentProvider
} from '@/store/session'
import { recordToolDiff } from '@/store/tool-diffs'
import type { RpcEvent } from '@/types/hermes'
import type { ClientSessionState } from '../../types'
@@ -123,8 +126,8 @@ export function useMessageStream({
mutateStream(
sessionId,
parts => appendTextPart(parts, delta),
() => [textPart(delta)]
parts => appendAssistantTextPart(parts, delta),
() => [assistantTextPart(delta)]
)
},
[mutateStream]
@@ -181,7 +184,7 @@ export function useMessageStream({
}
const streamId = state.streamId
const finalText = text.trim()
const finalText = renderMediaTags(text).trim()
const normalize = (value: string) => value.replace(/\s+/g, ' ').trim()
const dedupeReference = normalize(finalText)
@@ -200,7 +203,7 @@ export function useMessageStream({
return !(r && (dedupeReference.startsWith(r) || r.startsWith(dedupeReference)))
})
return text ? [...kept, textPart(text)] : kept
return finalText ? [...kept, assistantTextPart(finalText)] : kept
}
const completeMessage = (message: ChatMessage): ChatMessage => ({
@@ -228,24 +231,24 @@ export function useMessageStream({
nextMessages = prev.map((message, messageIndex) =>
messageIndex === index ? completeMessage(message) : message
)
} else if (text) {
} else if (finalText) {
nextMessages = [
...prev,
{
id: `assistant-${Date.now()}`,
role: 'assistant',
parts: [textPart(text)],
parts: [assistantTextPart(finalText)],
branchGroupId: state.pendingBranchGroup ?? undefined
}
]
}
} else if (text) {
} else if (finalText) {
nextMessages = [
...prev,
{
id: `assistant-${Date.now()}`,
role: 'assistant',
parts: [textPart(text)],
parts: [assistantTextPart(finalText)],
branchGroupId: state.pendingBranchGroup ?? undefined
}
]
@@ -408,6 +411,29 @@ export function useMessageStream({
if (sessionId) {
upsertToolCall(sessionId, payload, 'complete')
}
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
recordToolDiff(payload.tool_id || payload.name || '', payload.inline_diff)
}
} else if (event.type === 'clarify.request') {
if (!isActiveEvent) {
return
}
// Surface the clarify tool's overlay. The Python side is blocked on
// `clarify.respond`, so without this handler the agent would hang
// forever (see tools/clarify_tool.py + tui_gateway/server.py:_block).
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
const question = typeof payload?.question === 'string' ? payload.question : ''
if (requestId && question) {
setClarifyRequest({
requestId,
question,
choices: Array.isArray(payload?.choices) ? payload!.choices!.filter(c => typeof c === 'string') : null,
sessionId: sessionId ?? null
})
}
} else if (event.type === 'error') {
if (isActiveEvent) {
notify({
@@ -1,4 +1,4 @@
import type { ThreadMessage } from '@assistant-ui/react'
import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
import { type MutableRefObject, useCallback } from 'react'
import { transcribeAudio } from '@/hermes'
@@ -8,14 +8,21 @@ import {
INTERRUPTED_MARKER,
parseCommandDispatch,
parseSlashCommand,
pathLabel,
SLASH_COMMAND_RE
} from '@/lib/chat-runtime'
import {
type CommandsCatalogLike,
desktopSlashUnavailableMessage,
filterDesktopCommandsCatalog,
isDesktopSlashCommand
} from '@/lib/desktop-slash-commands'
import { triggerHaptic } from '@/lib/haptics'
import { $composerAttachments, clearComposerAttachments } from '@/store/composer'
import { $composerAttachments, addComposerAttachment, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { $busy, $messages, setAwaitingResponse, setBusy, setMessages } from '@/store/session'
import type { ClientSessionState, SlashExecResponse } from '../../types'
import type { ClientSessionState, ImageAttachResponse, SlashExecResponse } from '../../types'
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
@@ -37,9 +44,12 @@ interface PromptActionsOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
busyRef: MutableRefObject<boolean>
branchCurrentSession: () => Promise<boolean>
createBackendSessionForSend: () => Promise<string | null>
handleSkinCommand: (arg: string) => string
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
selectedStoredSessionIdRef: MutableRefObject<string | null>
startFreshSessionDraft: () => void
sttEnabled: boolean
updateSessionState: (
sessionId: string,
@@ -48,15 +58,12 @@ interface PromptActionsOptions {
) => ClientSessionState
}
interface CommandsCatalogResponse {
categories?: Array<{ name: string; pairs: [string, string][] }>
pairs?: [string, string][]
skill_count?: number
warning?: string
}
function renderCommandsCatalog(catalog: CommandsCatalogLike): string {
const desktopCatalog = filterDesktopCommandsCatalog(catalog)
function renderCommandsCatalog(catalog: CommandsCatalogResponse): string {
const sections = catalog.categories?.length ? catalog.categories : [{ name: 'Commands', pairs: catalog.pairs ?? [] }]
const sections = desktopCatalog.categories?.length
? desktopCatalog.categories
: [{ name: 'Desktop commands', pairs: desktopCatalog.pairs ?? [] }]
const body = sections
.filter(section => section.pairs.length > 0)
@@ -68,22 +75,40 @@ function renderCommandsCatalog(catalog: CommandsCatalogResponse): string {
.join('\n\n')
const tail = [
catalog.skill_count ? `${catalog.skill_count} skill commands available.` : '',
catalog.warning ? `warning: ${catalog.warning}` : ''
desktopCatalog.skill_count ? `${desktopCatalog.skill_count} skill commands available.` : '',
desktopCatalog.warning ? `warning: ${desktopCatalog.warning}` : ''
]
.filter(Boolean)
.join('\n')
return [body || 'No commands available.', tail].filter(Boolean).join('\n\n')
return [body || 'No desktop commands available.', tail].filter(Boolean).join('\n\n')
}
function slashStatusText(command: string, output: string): string {
return [`slash:${command}`, output.trim()].filter(Boolean).join('\n')
}
function appendText(message: AppendMessage): string {
return message.content
.map(part => ('text' in part ? part.text : ''))
.join('')
.trim()
}
function visibleUserOrdinal(messages: readonly ChatMessage[], end: number): number {
return messages.slice(0, end).filter(m => m.role === 'user' && !m.hidden).length
}
export function usePromptActions({
activeSessionId,
activeSessionIdRef,
busyRef,
branchCurrentSession,
createBackendSessionForSend,
handleSkinCommand,
requestGateway,
selectedStoredSessionIdRef,
startFreshSessionDraft,
sttEnabled,
updateSessionState
}: PromptActionsOptions) {
@@ -114,6 +139,39 @@ export function usePromptActions({
[selectedStoredSessionIdRef, updateSessionState]
)
const syncImageAttachmentsForSubmit = useCallback(
async (sessionId: string, attachments: ComposerAttachment[]) => {
const images = attachments.filter(attachment => attachment.kind === 'image' && attachment.path)
for (const attachment of images) {
if (attachment.attachedSessionId === sessionId) {
continue
}
const result = await requestGateway<ImageAttachResponse>('image.attach', {
session_id: sessionId,
path: attachment.path
})
if (!result.attached) {
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
throw new Error(result.message || `Could not attach ${label}`)
}
const attachedPath = result.path || attachment.path
addComposerAttachment({
...attachment,
id: attachment.id,
label: attachedPath ? pathLabel(attachedPath) : attachment.label,
path: attachedPath,
attachedSessionId: sessionId
})
}
},
[requestGateway]
)
const submitPromptText = useCallback(
async (rawText: string) => {
const visibleText = rawText.trim()
@@ -146,21 +204,33 @@ export function usePromptActions({
]
}
const releaseBusy = () => {
busyRef.current = false
setBusy(false)
setAwaitingResponse(false)
}
busyRef.current = true
setBusy(true)
setAwaitingResponse(true)
clearNotifications()
const sessionId = activeSessionId ? activeSessionId : await createBackendSessionForSend()
let sessionId = activeSessionId
if (!sessionId) {
busyRef.current = false
setBusy(false)
setAwaitingResponse(false)
notify({
kind: 'error',
title: 'Session unavailable',
message: 'Could not create a new session'
})
try {
sessionId = await createBackendSessionForSend()
} catch (err) {
releaseBusy()
notifyError(err, 'Session unavailable')
return
}
}
if (!sessionId) {
releaseBusy()
notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' })
return
}
@@ -180,20 +250,24 @@ export function usePromptActions({
)
try {
await syncImageAttachmentsForSubmit(sessionId, attachments)
await requestGateway('prompt.submit', { session_id: sessionId, text })
clearComposerAttachments()
} catch (err) {
busyRef.current = false
updateSessionState(sessionId, state => ({
...state,
messages: state.messages.filter(message => message.id !== userMessage.id),
busy: false,
awaitingResponse: false
}))
releaseBusy()
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
notifyError(err, 'Prompt failed')
}
},
[activeSessionId, createBackendSessionForSend, requestGateway, selectedStoredSessionIdRef, updateSessionState]
[
activeSessionId,
busyRef,
createBackendSessionForSend,
requestGateway,
selectedStoredSessionIdRef,
syncImageAttachmentsForSubmit,
updateSessionState
]
)
const executeSlashCommand = useCallback(
@@ -201,6 +275,36 @@ export function usePromptActions({
const runSlash = async (commandText: string, sessionHint?: string, recordInput = true): Promise<void> => {
const command = commandText.trim()
const { name, arg } = parseSlashCommand(command)
const normalizedName = name.toLowerCase()
if (!name) {
const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
if (sessionId) {
appendSessionTextMessage(sessionId, 'system', 'empty slash command')
}
return
}
if (normalizedName === 'new' || normalizedName === 'reset') {
startFreshSessionDraft()
return
}
if (normalizedName === 'branch' || normalizedName === 'fork') {
await branchCurrentSession()
return
}
if (normalizedName === 'skin' && !sessionHint && !activeSessionIdRef.current) {
notify({ kind: 'success', message: handleSkinCommand(arg) })
return
}
const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
if (!sessionId) {
@@ -213,21 +317,18 @@ export function usePromptActions({
return
}
const renderSlashOutput = (text: string) => appendSessionTextMessage(sessionId, 'system', text)
const renderSlashOutput = (text: string) =>
appendSessionTextMessage(sessionId, 'system', recordInput ? slashStatusText(command, text) : text)
if (recordInput) {
appendSessionTextMessage(sessionId, 'user', command)
}
if (!name) {
renderSlashOutput('empty slash command')
if (normalizedName === 'skin') {
renderSlashOutput(handleSkinCommand(arg))
return
}
if (name === 'help' || name === 'commands') {
try {
const catalog = await requestGateway<CommandsCatalogResponse>('commands.catalog', { session_id: sessionId })
const catalog = await requestGateway<CommandsCatalogLike>('commands.catalog', { session_id: sessionId })
renderSlashOutput(renderCommandsCatalog(catalog))
} catch (err) {
@@ -237,6 +338,12 @@ export function usePromptActions({
return
}
if (!isDesktopSlashCommand(name)) {
renderSlashOutput(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`)
return
}
try {
const result = await requestGateway<SlashExecResponse>('slash.exec', {
session_id: sessionId,
@@ -306,7 +413,17 @@ export function usePromptActions({
await runSlash(rawCommand, options?.sessionId, options?.recordInput ?? true)
},
[activeSessionIdRef, appendSessionTextMessage, createBackendSessionForSend, requestGateway, submitPromptText]
[
activeSessionIdRef,
appendSessionTextMessage,
branchCurrentSession,
busyRef,
createBackendSessionForSend,
handleSkinCommand,
requestGateway,
startFreshSessionDraft,
submitPromptText
]
)
const submitText = useCallback(
@@ -433,6 +550,7 @@ export function usePromptActions({
: messages.slice(absoluteUserIndex + 1).find(message => message.role === 'assistant')
const branchGroupId = targetAssistant?.branchGroupId ?? branchGroupForUser(userMessage)
const truncateBeforeUserOrdinal = visibleUserOrdinal(messages, absoluteUserIndex)
clearNotifications()
updateSessionState(activeSessionId, state => {
@@ -459,7 +577,11 @@ export function usePromptActions({
})
try {
await requestGateway('prompt.submit', { session_id: activeSessionId, text: userText })
await requestGateway('prompt.submit', {
session_id: activeSessionId,
text: userText,
truncate_before_user_ordinal: truncateBeforeUserOrdinal
})
} catch (err) {
updateSessionState(activeSessionId, state => ({
...state,
@@ -472,26 +594,80 @@ export function usePromptActions({
[activeSessionId, requestGateway, updateSessionState]
)
const editMessage = useCallback(
async (edited: AppendMessage) => {
const sessionId = activeSessionId || activeSessionIdRef.current
const sourceId = edited.sourceId || edited.parentId
const text = appendText(edited)
if (!sessionId || !sourceId || !text || edited.role !== 'user' || $busy.get()) {
return
}
const messages = $messages.get()
const sourceIndex = messages.findIndex(m => m.id === sourceId)
const source = messages[sourceIndex]
if (!source || source.role !== 'user' || chatMessageText(source).trim() === text) {
return
}
const truncate_before_user_ordinal = visibleUserOrdinal(messages, sourceIndex)
const editedMessage: ChatMessage = { ...source, parts: [textPart(text)] }
clearNotifications()
updateSessionState(sessionId, state => ({
...state,
busy: true,
awaitingResponse: true,
pendingBranchGroup: null,
sawAssistantPayload: false,
interrupted: false,
messages: [...state.messages.slice(0, sourceIndex), editedMessage]
}))
try {
await requestGateway('prompt.submit', { session_id: sessionId, text, truncate_before_user_ordinal })
} catch (err) {
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
notifyError(err, 'Edit failed')
}
},
[activeSessionId, activeSessionIdRef, requestGateway, updateSessionState]
)
const handleThreadMessagesChange = useCallback(
(nextMessages: readonly ThreadMessage[]) => {
const visibleIds = new Set(nextMessages.map(message => message.id))
const visibleIds = new Set(nextMessages.map(m => m.id))
const sessionId = activeSessionIdRef.current
if (!sessionId) {
return
}
updateSessionState(sessionId, state => ({
...state,
messages: state.messages.map(message =>
message.role === 'assistant' && message.branchGroupId
? { ...message, hidden: !visibleIds.has(message.id) }
: message
)
}))
updateSessionState(sessionId, state => {
let changed = false
const messages = state.messages.map(message => {
if (message.role !== 'assistant' || !message.branchGroupId) {
return message
}
const hidden = !visibleIds.has(message.id)
if (message.hidden === hidden) {
return message
}
changed = true
return { ...message, hidden }
})
return changed ? { ...state, messages } : state
})
},
[activeSessionIdRef, updateSessionState]
)
return { cancelRun, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio }
return { cancelRun, editMessage, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio }
}
@@ -3,8 +3,9 @@ import { useCallback, useRef } from 'react'
import type { NavigateFunction } from 'react-router-dom'
import { deleteSession, getSessionMessages } from '@/hermes'
import { chatMessageText, toChatMessages } from '@/lib/chat-messages'
import { type ChatMessage, chatMessageText, toChatMessages } from '@/lib/chat-messages'
import { normalizePersonalityValue } from '@/lib/chat-runtime'
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
import { clearComposerAttachments, clearComposerDraft } from '@/store/composer'
import { $pinnedSessionIds } from '@/store/layout'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
@@ -25,7 +26,7 @@ import {
setSelectedStoredSessionId,
setSessions
} from '@/store/session'
import type { SessionCreateResponse, SessionResumeResponse } from '@/types/hermes'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse } from '@/types/hermes'
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes'
import type { ClientSessionState, SidebarNavItem } from '../../types'
@@ -34,7 +35,9 @@ interface SessionActionsOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
busyRef: MutableRefObject<boolean>
creatingSessionRef: MutableRefObject<boolean>
ensureSessionState: (sessionId: string, storedSessionId?: string | null) => ClientSessionState
getRouteToken: () => string
navigate: NavigateFunction
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
@@ -49,11 +52,156 @@ interface SessionActionsOptions {
) => ClientSessionState
}
function withAppendedText(message: ChatMessage, suffix: string): ChatMessage {
let appended = false
const parts = message.parts.map(part => {
if (part.type !== 'text' || appended) {
return part
}
appended = true
return { ...part, text: `${part.text}${suffix}` }
})
return appended ? { ...message, parts } : message
}
function preserveReasoningParts(message: ChatMessage, previous: ChatMessage): ChatMessage {
if (message.parts.some(part => part.type === 'reasoning')) {
return message
}
const reasoningParts = previous.parts.filter(part => part.type === 'reasoning')
return reasoningParts.length ? { ...message, parts: [...reasoningParts, ...message.parts] } : message
}
function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean {
if (a.id !== b.id || a.role !== b.role || a.pending !== b.pending || a.hidden !== b.hidden || a.branchGroupId !== b.branchGroupId) {
return false
}
if (a.parts.length !== b.parts.length) {
return false
}
return a.parts.every((part, index) => JSON.stringify(part) === JSON.stringify(b.parts[index]))
}
function chatMessageArraysEquivalent(a: ChatMessage[], b: ChatMessage[]): boolean {
return a.length === b.length && a.every((message, index) => chatMessagesEquivalent(message, b[index]))
}
function reconcileResumeMessages(nextMessages: ChatMessage[], previousMessages: ChatMessage[]): ChatMessage[] {
if (!previousMessages.length) {
return nextMessages
}
const previousByRoleOrdinal = new Map<string, ChatMessage>()
const previousRoleCounts = new Map<string, number>()
for (const message of previousMessages) {
const ordinal = previousRoleCounts.get(message.role) ?? 0
previousRoleCounts.set(message.role, ordinal + 1)
previousByRoleOrdinal.set(`${message.role}:${ordinal}`, message)
}
const nextRoleCounts = new Map<string, number>()
return nextMessages.map(message => {
const ordinal = nextRoleCounts.get(message.role) ?? 0
nextRoleCounts.set(message.role, ordinal + 1)
const previous = previousByRoleOrdinal.get(`${message.role}:${ordinal}`)
if (!previous) {
return message
}
const nextText = chatMessageText(message).trim()
const previousText = chatMessageText(previous)
const previousVisibleText = textWithoutEmbeddedImages(previousText)
let preserved = message
if (nextText === previousVisibleText || nextText === previousText.trim()) {
preserved = preserveReasoningParts(preserved, previous)
}
const previousImages = embeddedImageUrls(previousText)
if (!previousImages.length || embeddedImageUrls(chatMessageText(preserved)).length) {
return preserved
}
if (nextText !== previousVisibleText) {
return preserved
}
return withAppendedText(preserved, previousImages.map(url => `\n${url}`).join(''))
})
}
function upsertOptimisticSession(
created: SessionCreateResponse,
id: string,
title: string | null = null,
preview: string | null = null
) {
const now = Date.now() / 1000
const session: SessionInfo = {
ended_at: null,
id,
input_tokens: 0,
is_active: true,
last_active: now,
message_count: created.message_count ?? created.messages?.length ?? 0,
model: created.info?.model ?? null,
output_tokens: 0,
preview,
source: 'tui',
started_at: now,
title,
tool_call_count: 0
}
setSessions(prev => [session, ...prev.filter(s => s.id !== id)])
}
function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined) {
if (!info) {
return
}
if (info.model) {
setCurrentModel(info.model)
}
if (info.provider) {
setCurrentProvider(info.provider)
}
if (info.cwd) {
setCurrentCwd(info.cwd)
}
if (info.branch !== undefined) {
setCurrentBranch(info.branch || '')
}
if (typeof info.personality === 'string') {
setCurrentPersonality(normalizePersonalityValue(info.personality))
}
}
export function useSessionActions({
activeSessionId,
activeSessionIdRef,
busyRef,
creatingSessionRef,
ensureSessionState,
getRouteToken,
navigate,
requestGateway,
runtimeIdByStoredSessionIdRef,
@@ -86,43 +234,47 @@ export function useSessionActions({
)
const createBackendSessionForSend = useCallback(async (): Promise<string | null> => {
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96 })
const startingActiveSessionId = activeSessionIdRef.current
const startingStoredSessionId = selectedStoredSessionIdRef.current
const startingRouteToken = getRouteToken()
if (created.stored_session_id) {
navigate(sessionRoute(created.stored_session_id), { replace: true })
creatingSessionRef.current = true
try {
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96 })
const stored = created.stored_session_id ?? null
if (
activeSessionIdRef.current !== startingActiveSessionId ||
selectedStoredSessionIdRef.current !== startingStoredSessionId ||
getRouteToken() !== startingRouteToken
) {
await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined)
return null
}
activeSessionIdRef.current = created.session_id
selectedStoredSessionIdRef.current = stored
ensureSessionState(created.session_id, stored)
if (stored) {
upsertOptimisticSession(created, stored)
navigate(sessionRoute(stored), { replace: true })
}
setFreshDraftReady(false)
setActiveSessionId(created.session_id)
setSelectedStoredSessionId(stored)
applyRuntimeInfo(created.info)
return created.session_id
} finally {
window.setTimeout(() => {
creatingSessionRef.current = false
}, 0)
}
setActiveSessionId(created.session_id)
activeSessionIdRef.current = created.session_id
ensureSessionState(created.session_id, created.stored_session_id ?? null)
if (created.stored_session_id) {
setSelectedStoredSessionId(created.stored_session_id)
selectedStoredSessionIdRef.current = created.stored_session_id
}
if (created.info?.model) {
setCurrentModel(created.info.model)
}
if (created.info?.provider) {
setCurrentProvider(created.info.provider)
}
if (created.info?.cwd) {
setCurrentCwd(created.info.cwd)
}
if (created.info?.branch) {
setCurrentBranch(created.info.branch)
}
if (typeof created.info?.personality === 'string') {
setCurrentPersonality(normalizePersonalityValue(created.info.personality))
}
return created.session_id
}, [activeSessionIdRef, ensureSessionState, navigate, requestGateway, selectedStoredSessionIdRef])
}, [activeSessionIdRef, creatingSessionRef, ensureSessionState, getRouteToken, navigate, requestGateway, selectedStoredSessionIdRef])
const selectSidebarItem = useCallback(
(item: SidebarNavItem) => {
@@ -187,41 +339,65 @@ export function useSessionActions({
clearNotifications()
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
setMessages([])
try {
let resumeApplied = false
// Load the local snapshot first, then ask the gateway to resume.
// Previously these raced:
// 1. clear messages to []
// 2. local getSessionMessages -> 45 msgs
// 3. a second resume path cleared [] again
// 4. gateway resume -> 43 msgs
// That is the ctrl+R flash chain. Avoid showing an empty thread
// while we already have a route-scoped session id, and don't race the
// local snapshot against gateway resume.
let localSnapshot = $messages.get()
const storedMessagesPromise = getSessionMessages(storedSessionId)
.then(storedMessages => {
if (!resumeApplied && isCurrentResume()) {
setMessages(toChatMessages(storedMessages.messages))
try {
const storedMessages = await getSessionMessages(storedSessionId)
if (isCurrentResume()) {
localSnapshot = toChatMessages(storedMessages.messages)
if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) {
setMessages(localSnapshot)
}
})
.catch(() => undefined)
}
} catch {
// Non-fatal: gateway resume below can still hydrate the session.
}
const resumePromise = requestGateway<SessionResumeResponse>('session.resume', {
const resumed = await requestGateway<SessionResumeResponse>('session.resume', {
session_id: storedSessionId,
cols: 96
})
void storedMessagesPromise
const resumed = await resumePromise
resumeApplied = true
if (!isCurrentResume()) {
return
}
const currentMessages = $messages.get()
const resumedMessages = reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages)
// Avoid a second visible transcript rebuild on resume/switch.
// `getSessionMessages()` is the stable stored transcript snapshot and
// paints first; `session.resume` can return a slightly different
// runtime-shaped projection (e.g. tool/system coalescing), which was
// causing a second full message-list replacement a second later.
// Keep the already-painted local snapshot for the view/cache when it
// exists; use gateway messages only as a fallback when no local
// snapshot was available.
const messagesForView = localSnapshot.length > 0
? localSnapshot
: chatMessageArraysEquivalent(currentMessages, resumedMessages)
? currentMessages
: resumedMessages
setActiveSessionId(resumed.session_id)
activeSessionIdRef.current = resumed.session_id
updateSessionState(
resumed.session_id,
state => ({
...state,
messages: toChatMessages(resumed.messages),
messages: messagesForView,
busy: false,
awaitingResponse: false
}),
@@ -229,24 +405,7 @@ export function useSessionActions({
)
clearComposerDraft()
clearComposerAttachments()
if (resumed.info?.model) {
setCurrentModel(resumed.info.model)
}
if (resumed.info?.provider) {
setCurrentProvider(resumed.info.provider)
}
if (resumed.info?.cwd) {
setCurrentCwd(resumed.info.cwd)
}
setCurrentBranch(resumed.info?.branch || '')
if (typeof resumed.info?.personality === 'string') {
setCurrentPersonality(normalizePersonalityValue(resumed.info.personality))
}
applyRuntimeInfo(resumed.info)
} catch (err) {
if (!isCurrentResume()) {
return
@@ -304,9 +463,15 @@ export function useSessionActions({
return false
}
creatingSessionRef.current = true
try {
const currentMessages = $messages.get()
const targetIndex = messageId ? currentMessages.findIndex(message => message.id === messageId) : -1
const targetIndex = messageId
? currentMessages.findIndex(message => message.id === messageId)
: currentMessages.findLastIndex(message => message.role === 'assistant' || message.role === 'user')
const branchStart = targetIndex >= 0 ? targetIndex : Math.max(currentMessages.length - 1, 0)
const branchEnd = targetIndex >= 0 ? targetIndex + 1 : currentMessages.length
@@ -317,7 +482,7 @@ export function useSessionActions({
source: message,
role: message.role
}))
.filter(message => message.content.trim() && ['assistant', 'system', 'user'].includes(message.role))
.filter(message => message.content.trim() && ['assistant', 'user'].includes(message.role))
if (!branchMessages.length) {
notify({
@@ -338,8 +503,10 @@ export function useSessionActions({
})
const routedSessionId = branched.stored_session_id ?? branched.session_id
const preview = branchMessages.map(({ content }) => content).find(Boolean) ?? null
setFreshDraftReady(false)
upsertOptimisticSession(branched, routedSessionId, 'Branch', preview)
ensureSessionState(branched.session_id, routedSessionId)
setActiveSessionId(branched.session_id)
activeSessionIdRef.current = branched.session_id
@@ -359,35 +526,23 @@ export function useSessionActions({
clearComposerDraft()
clearComposerAttachments()
if (branched.info?.model) {
setCurrentModel(branched.info.model)
}
if (branched.info?.provider) {
setCurrentProvider(branched.info.provider)
}
if (branched.info?.cwd) {
setCurrentCwd(branched.info.cwd)
}
setCurrentBranch(branched.info?.branch || '')
if (typeof branched.info?.personality === 'string') {
setCurrentPersonality(normalizePersonalityValue(branched.info.personality))
}
applyRuntimeInfo(branched.info)
return true
} catch (err) {
notifyError(err, 'Branch failed')
return false
} finally {
window.setTimeout(() => {
creatingSessionRef.current = false
}, 0)
}
},
[
activeSessionIdRef,
busyRef,
creatingSessionRef,
ensureSessionState,
navigate,
requestGateway,
@@ -399,49 +554,60 @@ export function useSessionActions({
const removeSession = useCallback(
async (storedSessionId: string) => {
clearNotifications()
const removed = $sessions.get().find(s => s.id === storedSessionId)
const wasSelected = selectedStoredSessionId === storedSessionId
const closingRuntimeId = wasSelected ? activeSessionId : null
const previousMessages = $messages.get()
const previousPinnedSessionIds = $pinnedSessionIds.get()
const previousPinned = $pinnedSessionIds.get()
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
$pinnedSessionIds.set(previousPinnedSessionIds.filter(id => id !== storedSessionId))
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
// Tear down before awaiting so the route effect can't resume the
// doomed session via the stale /<sid> URL.
if (wasSelected) {
setSelectedStoredSessionId(null)
selectedStoredSessionIdRef.current = null
setMessages([])
startFreshSessionDraft(true)
}
try {
if (wasSelected && activeSessionId) {
await requestGateway('session.close', {
session_id: activeSessionId
}).catch(() => undefined)
if (closingRuntimeId) {
await requestGateway('session.close', { session_id: closingRuntimeId }).catch(() => undefined)
}
await deleteSession(storedSessionId)
if (wasSelected) {
startFreshSessionDraft()
}
} catch (err) {
if (removed) {
setSessions(prev => [removed, ...prev])
}
$pinnedSessionIds.set(previousPinnedSessionIds)
$pinnedSessionIds.set(previousPinned)
if (wasSelected) {
setFreshDraftReady(false)
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
setMessages(previousMessages)
navigate(sessionRoute(storedSessionId), { replace: true })
if (closingRuntimeId) {
setActiveSessionId(closingRuntimeId)
activeSessionIdRef.current = closingRuntimeId
}
}
notifyError(err, 'Delete failed')
}
},
[activeSessionId, selectedStoredSessionId, selectedStoredSessionIdRef, startFreshSessionDraft, requestGateway]
[
activeSessionId,
activeSessionIdRef,
navigate,
requestGateway,
selectedStoredSessionId,
selectedStoredSessionIdRef,
startFreshSessionDraft
]
)
return {