chore: uptick
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user