mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 08:51:53 +00:00
fix(desktop): persist inline assistant errors across hydrate/resume
- Detect provider failure text arriving via message.complete (HTTP 4xx, "API call failed after N retries", Provider/Gateway error: ...) and persist as an inline assistant error instead of regular completion text, blocking the hydrate that was wiping it. - preserveLocalAssistantErrors: merge by id so same-id hydrated messages keep their local error, and preserve the optimistic user+error pair as a unit (with tail-user dedupe). - Hook all hydrate/resume writers (use-session-actions resume + fallback, hydrateFromStoredSession, syncSessionStateToView) into the merge so stale snapshots can't clobber a failed turn. - Add error to chatMessagesEquivalent so the resume diff actually sees error-only changes and paints them. - editMessage on a failed turn now submits a plain resend (no truncate_before_user_ordinal) and retries plainly on the "no longer in session history" race. Style polish on touched files: - Inline error: text-only treatment (no card). - User stop / edit-composer send: shared Tabler IconPlayerStopFilled glyph + shared icon-button class slot for parity.
This commit is contained in:
parent
d67a438fec
commit
f9908af1a0
8 changed files with 627 additions and 165 deletions
|
|
@ -9,7 +9,7 @@ import { useSkinCommand } from '@/themes/use-skin-command'
|
|||
|
||||
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
||||
import { getSessionMessages, listSessions } from '../hermes'
|
||||
import { toChatMessages } from '../lib/chat-messages'
|
||||
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
|
||||
import {
|
||||
$pinnedSessionIds,
|
||||
$sessionsLimit,
|
||||
|
|
@ -280,7 +280,7 @@ export function DesktopController() {
|
|||
runtimeSessionId,
|
||||
state => ({
|
||||
...state,
|
||||
messages: toChatMessages(latest.messages)
|
||||
messages: preserveLocalAssistantErrors(toChatMessages(latest.messages), state.messages)
|
||||
}),
|
||||
storedSessionId
|
||||
)
|
||||
|
|
|
|||
|
|
@ -61,6 +61,21 @@ interface QueuedStreamDeltas {
|
|||
|
||||
const STREAM_DELTA_FLUSH_MS = 16
|
||||
|
||||
// Gateway/provider failures sometimes arrive as message.complete text instead
|
||||
// of an explicit error event. Treat matches as inline assistant errors so they
|
||||
// persist like real error events and don't get erased by hydrate fallback.
|
||||
const COMPLETION_ERROR_PATTERNS = [
|
||||
/^API call failed after \d+ retries:/i,
|
||||
/^HTTP\s+\d{3}\b/i,
|
||||
/^(Provider|Gateway)\s+error:/i
|
||||
]
|
||||
|
||||
function completionErrorText(finalText: string): string | null {
|
||||
const text = finalText.trim()
|
||||
|
||||
return text && COMPLETION_ERROR_PATTERNS.some(re => re.test(text)) ? text : null
|
||||
}
|
||||
|
||||
const SUBAGENT_EVENT_TYPES = new Set([
|
||||
'subagent.spawn_requested',
|
||||
'subagent.start',
|
||||
|
|
@ -377,7 +392,12 @@ export function useMessageStream({
|
|||
) => {
|
||||
if (!nativeSubagentSessionsRef.current.has(sessionId)) {
|
||||
for (const subagentPayload of delegateTaskPayloads(payload, phase, sourceEventType)) {
|
||||
upsertSubagent(sessionId, subagentPayload, true, phase === 'complete' ? 'delegate.complete' : 'delegate.running')
|
||||
upsertSubagent(
|
||||
sessionId,
|
||||
subagentPayload,
|
||||
true,
|
||||
phase === 'complete' ? 'delegate.complete' : 'delegate.running'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -406,6 +426,7 @@ export function useMessageStream({
|
|||
|
||||
const streamId = state.streamId
|
||||
const finalText = renderMediaTags(text).trim()
|
||||
const completionError = completionErrorText(finalText)
|
||||
const normalize = (value: string) => value.replace(/\s+/g, ' ').trim()
|
||||
const dedupeReference = normalize(finalText)
|
||||
|
||||
|
|
@ -427,10 +448,26 @@ export function useMessageStream({
|
|||
return finalText ? [...kept, assistantTextPart(finalText)] : kept
|
||||
}
|
||||
|
||||
const completeMessage = (message: ChatMessage): ChatMessage => ({
|
||||
...message,
|
||||
parts: replaceTextPart(message.parts),
|
||||
pending: false
|
||||
const completeMessage = (message: ChatMessage): ChatMessage =>
|
||||
completionError
|
||||
? {
|
||||
...message,
|
||||
error: completionError,
|
||||
parts: message.parts.filter(part => part.type !== 'text'),
|
||||
pending: false
|
||||
}
|
||||
: {
|
||||
...message,
|
||||
parts: replaceTextPart(message.parts),
|
||||
pending: false
|
||||
}
|
||||
|
||||
const newAssistantFromCompletion = (): ChatMessage => ({
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
parts: completionError ? [] : [assistantTextPart(finalText)],
|
||||
branchGroupId: state.pendingBranchGroup ?? undefined,
|
||||
...(completionError && { error: completionError })
|
||||
})
|
||||
|
||||
const prev = state.messages
|
||||
|
|
@ -453,30 +490,18 @@ export function useMessageStream({
|
|||
messageIndex === index ? completeMessage(message) : message
|
||||
)
|
||||
} else if (finalText) {
|
||||
nextMessages = [
|
||||
...prev,
|
||||
{
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
parts: [assistantTextPart(finalText)],
|
||||
branchGroupId: state.pendingBranchGroup ?? undefined
|
||||
}
|
||||
]
|
||||
nextMessages = [...prev, newAssistantFromCompletion()]
|
||||
}
|
||||
} else if (finalText) {
|
||||
nextMessages = [
|
||||
...prev,
|
||||
{
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
parts: [assistantTextPart(finalText)],
|
||||
branchGroupId: state.pendingBranchGroup ?? undefined
|
||||
}
|
||||
]
|
||||
nextMessages = [...prev, newAssistantFromCompletion()]
|
||||
}
|
||||
}
|
||||
|
||||
shouldHydrate = !state.sawAssistantPayload || !finalText
|
||||
const hasInlineError = nextMessages.some(m => m.role === 'assistant' && m.error && !m.hidden)
|
||||
const lastVisible = [...nextMessages].reverse().find(m => !m.hidden)
|
||||
const unresolvedUserTail = lastVisible?.role === 'user'
|
||||
shouldHydrate =
|
||||
!completionError && !hasInlineError && !unresolvedUserTail && (!state.sawAssistantPayload || !finalText)
|
||||
|
||||
return {
|
||||
...state,
|
||||
|
|
@ -504,6 +529,50 @@ export function useMessageStream({
|
|||
[activeSessionIdRef, hydrateFromStoredSession, refreshSessions, updateSessionState]
|
||||
)
|
||||
|
||||
const failAssistantMessage = useCallback(
|
||||
(sessionId: string, errorMessage: string) => {
|
||||
updateSessionState(sessionId, state => {
|
||||
const streamId = state.streamId ?? `assistant-error-${Date.now()}`
|
||||
const groupId = state.pendingBranchGroup ?? undefined
|
||||
const prev = state.messages
|
||||
const error = errorMessage.trim() || 'Hermes reported an error'
|
||||
|
||||
const nextMessages = prev.some(m => m.id === streamId)
|
||||
? prev.map(message =>
|
||||
message.id === streamId
|
||||
? {
|
||||
...message,
|
||||
error,
|
||||
pending: false
|
||||
}
|
||||
: message
|
||||
)
|
||||
: [
|
||||
...prev,
|
||||
{
|
||||
id: streamId,
|
||||
role: 'assistant' as const,
|
||||
parts: [],
|
||||
error,
|
||||
pending: false,
|
||||
branchGroupId: groupId
|
||||
}
|
||||
]
|
||||
|
||||
return {
|
||||
...state,
|
||||
messages: nextMessages,
|
||||
streamId: null,
|
||||
pendingBranchGroup: null,
|
||||
sawAssistantPayload: true,
|
||||
awaitingResponse: false,
|
||||
busy: false
|
||||
}
|
||||
})
|
||||
},
|
||||
[updateSessionState]
|
||||
)
|
||||
|
||||
const handleGatewayEvent = useCallback(
|
||||
(event: RpcEvent) => {
|
||||
const payload = event.payload as GatewayEventPayload | undefined
|
||||
|
|
@ -738,11 +807,7 @@ export function useMessageStream({
|
|||
|
||||
if (sessionId) {
|
||||
flushQueuedDeltas(sessionId)
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
awaitingResponse: false,
|
||||
busy: false
|
||||
}))
|
||||
failAssistantMessage(sessionId, errorMessage)
|
||||
}
|
||||
|
||||
if (isActiveEvent) {
|
||||
|
|
@ -755,6 +820,7 @@ export function useMessageStream({
|
|||
appendReasoningDelta,
|
||||
activeSessionIdRef,
|
||||
completeAssistantMessage,
|
||||
failAssistantMessage,
|
||||
flushQueuedDeltas,
|
||||
queryClient,
|
||||
refreshHermesConfig,
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ import {
|
|||
$composerAttachments,
|
||||
addComposerAttachment,
|
||||
clearComposerAttachments,
|
||||
terminalContextBlocksFromDraft,
|
||||
type ComposerAttachment
|
||||
type ComposerAttachment,
|
||||
terminalContextBlocksFromDraft
|
||||
} from '@/store/composer'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
|
|
@ -54,6 +54,12 @@ function isProviderSetupError(error: unknown) {
|
|||
return isProviderSetupErrorMessage(message)
|
||||
}
|
||||
|
||||
function inlineErrorMessage(error: unknown, fallback: string): string {
|
||||
const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : fallback
|
||||
|
||||
return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim()
|
||||
}
|
||||
|
||||
interface PromptActionsOptions {
|
||||
activeSessionId: string | null
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
|
|
@ -203,10 +209,12 @@ export function usePromptActions({
|
|||
const visibleText = rawText.trim()
|
||||
const usingComposerAttachments = !options?.attachments
|
||||
const attachments = options?.attachments ?? $composerAttachments.get()
|
||||
|
||||
const contextRefs = attachments
|
||||
.map(a => a.refText)
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
const terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n')
|
||||
const hasImage = attachments.some(a => a.kind === 'image')
|
||||
const attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
|
||||
|
|
@ -314,12 +322,32 @@ export function usePromptActions({
|
|||
})
|
||||
await requestGateway('prompt.submit', { session_id: sessionId, text })
|
||||
|
||||
if (usingComposerAttachments) clearComposerAttachments()
|
||||
if (usingComposerAttachments) {
|
||||
clearComposerAttachments()
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
const message = inlineErrorMessage(err, 'Prompt failed')
|
||||
|
||||
releaseBusy()
|
||||
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
id: `assistant-error-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
parts: [],
|
||||
error: message || 'Prompt failed',
|
||||
branchGroupId: state.pendingBranchGroup ?? undefined
|
||||
}
|
||||
],
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
pendingBranchGroup: null,
|
||||
sawAssistantPayload: true
|
||||
}))
|
||||
|
||||
if (isProviderSetupError(err)) {
|
||||
requestDesktopOnboarding('Add a provider credential before sending your first message.')
|
||||
|
|
@ -328,6 +356,7 @@ export function usePromptActions({
|
|||
}
|
||||
|
||||
notifyError(err, 'Prompt failed')
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
|
@ -684,10 +713,16 @@ export function usePromptActions({
|
|||
return
|
||||
}
|
||||
|
||||
const truncate_before_user_ordinal = visibleUserOrdinal(messages, sourceIndex)
|
||||
// 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)] }
|
||||
|
||||
clearNotifications()
|
||||
busyRef.current = true
|
||||
setBusy(true)
|
||||
setAwaitingResponse(true)
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
busy: true,
|
||||
|
|
@ -698,14 +733,39 @@ export function usePromptActions({
|
|||
messages: [...state.messages.slice(0, sourceIndex), editedMessage]
|
||||
}))
|
||||
|
||||
const submit = (truncateOrdinal?: number) =>
|
||||
requestGateway('prompt.submit', {
|
||||
session_id: sessionId,
|
||||
text,
|
||||
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
|
||||
})
|
||||
|
||||
const isStaleTargetError = (err: unknown) =>
|
||||
/no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err))
|
||||
|
||||
try {
|
||||
await requestGateway('prompt.submit', { session_id: sessionId, text, truncate_before_user_ordinal })
|
||||
await submit(isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex))
|
||||
} catch (err) {
|
||||
let surfaced = err
|
||||
|
||||
if (!isFailedTurn && isStaleTargetError(err)) {
|
||||
try {
|
||||
await submit()
|
||||
|
||||
return
|
||||
} catch (retryErr) {
|
||||
surfaced = retryErr
|
||||
}
|
||||
}
|
||||
|
||||
busyRef.current = false
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
|
||||
notifyError(err, 'Edit failed')
|
||||
notifyError(surfaced, 'Edit failed')
|
||||
}
|
||||
},
|
||||
[activeSessionId, activeSessionIdRef, requestGateway, updateSessionState]
|
||||
[activeSessionId, activeSessionIdRef, busyRef, requestGateway, updateSessionState]
|
||||
)
|
||||
|
||||
const handleThreadMessagesChange = useCallback(
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useCallback, useRef } from 'react'
|
|||
import type { NavigateFunction } from 'react-router-dom'
|
||||
|
||||
import { deleteSession, getSessionMessages } from '@/hermes'
|
||||
import { type ChatMessage, chatMessageText, toChatMessages } from '@/lib/chat-messages'
|
||||
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
|
||||
import { normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
|
||||
import { clearComposerAttachments, clearComposerDraft } from '@/store/composer'
|
||||
|
|
@ -92,6 +92,7 @@ function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean {
|
|||
a.id !== b.id ||
|
||||
a.role !== b.role ||
|
||||
a.pending !== b.pending ||
|
||||
a.error !== b.error ||
|
||||
a.hidden !== b.hidden ||
|
||||
a.branchGroupId !== b.branchGroupId
|
||||
) {
|
||||
|
|
@ -194,7 +195,9 @@ function patchSessionWorkspace(sessionId: string, cwd: string | undefined) {
|
|||
setSessions(prev => prev.map(session => (session.id === sessionId ? { ...session, cwd } : session)))
|
||||
}
|
||||
|
||||
function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined): Partial<Pick<ClientSessionState, 'branch' | 'cwd'>> | null {
|
||||
function applyRuntimeInfo(
|
||||
info: SessionCreateResponse['info'] | undefined
|
||||
): Partial<Pick<ClientSessionState, 'branch' | 'cwd'>> | null {
|
||||
if (!info) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -454,7 +457,7 @@ export function useSessionActions({
|
|||
const storedMessages = await getSessionMessages(storedSessionId)
|
||||
|
||||
if (isCurrentResume()) {
|
||||
localSnapshot = toChatMessages(storedMessages.messages)
|
||||
localSnapshot = preserveLocalAssistantErrors(toChatMessages(storedMessages.messages), $messages.get())
|
||||
|
||||
if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) {
|
||||
setMessages(localSnapshot)
|
||||
|
|
@ -474,7 +477,11 @@ export function useSessionActions({
|
|||
}
|
||||
|
||||
const currentMessages = $messages.get()
|
||||
const resumedMessages = reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages)
|
||||
|
||||
const resumedMessages = preserveLocalAssistantErrors(
|
||||
reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages),
|
||||
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
|
||||
|
|
@ -484,13 +491,15 @@ export function useSessionActions({
|
|||
// exists; use gateway messages only as a fallback when no local
|
||||
// snapshot was available.
|
||||
|
||||
const messagesForView =
|
||||
const preferredMessages =
|
||||
localSnapshot.length > 0
|
||||
? localSnapshot
|
||||
: chatMessageArraysEquivalent(currentMessages, resumedMessages)
|
||||
? currentMessages
|
||||
: resumedMessages
|
||||
|
||||
const messagesForView = preserveLocalAssistantErrors(preferredMessages, currentMessages)
|
||||
|
||||
setActiveSessionId(resumed.session_id)
|
||||
activeSessionIdRef.current = resumed.session_id
|
||||
const runtimeInfo = applyRuntimeInfo(resumed.info)
|
||||
|
|
@ -521,7 +530,7 @@ export function useSessionActions({
|
|||
return
|
||||
}
|
||||
|
||||
setMessages(toChatMessages(fallback.messages))
|
||||
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
|
||||
notifyError(err, 'Resume failed')
|
||||
} finally {
|
||||
if (isCurrentResume()) {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import { useStore } from '@nanostores/react'
|
|||
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $busy, setSessionWorking } from '@/store/session'
|
||||
import { $busy, $messages, setSessionWorking } from '@/store/session'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
||||
|
|
@ -78,6 +79,20 @@ export function useSessionStateCache({
|
|||
return created
|
||||
}, [])
|
||||
|
||||
const flushPendingViewState = useCallback(() => {
|
||||
const pending = pendingViewStateRef.current
|
||||
pendingViewStateRef.current = null
|
||||
|
||||
if (!pending || pending.sessionId !== activeSessionIdRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setMessages(preserveLocalAssistantErrors(pending.state.messages, $messages.get()))
|
||||
setBusy(pending.state.busy)
|
||||
busyRef.current = pending.state.busy
|
||||
setAwaitingResponse(pending.state.awaitingResponse)
|
||||
}, [busyRef, setAwaitingResponse, setBusy, setMessages])
|
||||
|
||||
const syncSessionStateToView = useCallback(
|
||||
(sessionId: string, state: ClientSessionState) => {
|
||||
pendingViewStateRef.current = { sessionId, state }
|
||||
|
|
@ -87,41 +102,17 @@ export function useSessionStateCache({
|
|||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
const pending = pendingViewStateRef.current
|
||||
|
||||
if (!pending || pending.sessionId !== activeSessionIdRef.current) {
|
||||
pendingViewStateRef.current = null
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
pendingViewStateRef.current = null
|
||||
setMessages(pending.state.messages)
|
||||
setBusy(pending.state.busy)
|
||||
busyRef.current = pending.state.busy
|
||||
setAwaitingResponse(pending.state.awaitingResponse)
|
||||
flushPendingViewState()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
viewSyncRafRef.current = window.requestAnimationFrame(() => {
|
||||
viewSyncRafRef.current = null
|
||||
const pending = pendingViewStateRef.current
|
||||
|
||||
if (!pending || pending.sessionId !== activeSessionIdRef.current) {
|
||||
pendingViewStateRef.current = null
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
pendingViewStateRef.current = null
|
||||
setMessages(pending.state.messages)
|
||||
setBusy(pending.state.busy)
|
||||
busyRef.current = pending.state.busy
|
||||
setAwaitingResponse(pending.state.awaitingResponse)
|
||||
flushPendingViewState()
|
||||
})
|
||||
},
|
||||
[busyRef, setAwaitingResponse, setBusy, setMessages]
|
||||
[flushPendingViewState]
|
||||
)
|
||||
|
||||
useEffect(
|
||||
|
|
|
|||
|
|
@ -13,10 +13,12 @@ import {
|
|||
useAuiState
|
||||
} from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { IconPlayerStopFilled } from '@tabler/icons-react'
|
||||
import {
|
||||
type ClipboardEvent,
|
||||
type ComponentProps,
|
||||
type FC,
|
||||
type FocusEvent,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
type DragEvent as ReactDragEvent,
|
||||
|
|
@ -31,9 +33,9 @@ import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
|
|||
|
||||
import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from '@/app/chat/composer/drop-affordance'
|
||||
import {
|
||||
type ComposerInsertMode,
|
||||
focusComposerInput,
|
||||
markActiveComposer,
|
||||
type ComposerInsertMode,
|
||||
onComposerFocusRequest,
|
||||
onComposerInsertRequest
|
||||
} from '@/app/chat/composer/focus'
|
||||
|
|
@ -148,7 +150,17 @@ export const Thread: FC<{
|
|||
onCancel?: () => Promise<void> | void
|
||||
sessionId?: string | null
|
||||
sessionKey?: string | null
|
||||
}> = ({ clampToComposer = false, cwd = null, gateway = null, intro, loading, onBranchInNewChat, onCancel, sessionId = null, sessionKey }) => {
|
||||
}> = ({
|
||||
clampToComposer = false,
|
||||
cwd = null,
|
||||
gateway = null,
|
||||
intro,
|
||||
loading,
|
||||
onBranchInNewChat,
|
||||
onCancel,
|
||||
sessionId = null,
|
||||
sessionKey
|
||||
}) => {
|
||||
const introHero = useAuiState(s => Boolean(intro) && s.thread.isEmpty)
|
||||
|
||||
const messageComponents = useMemo(
|
||||
|
|
@ -195,7 +207,11 @@ export const Thread: FC<{
|
|||
<GroupedThreadMessages components={messageComponents} />
|
||||
{loading === 'response' && <ResponseLoadingIndicator />}
|
||||
{clampToComposer && (
|
||||
<div aria-hidden="true" className="shrink-0" style={{ height: 'var(--thread-last-message-clearance)' }} />
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="shrink-0"
|
||||
style={{ height: 'var(--thread-last-message-clearance)' }}
|
||||
/>
|
||||
)}
|
||||
</StickToBottom.Content>
|
||||
</StickToBottom>
|
||||
|
|
@ -503,7 +519,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
|||
)}
|
||||
<MessagePrimitive.Error>
|
||||
<ErrorPrimitive.Root
|
||||
className="mt-2 rounded-md border border-destructive/20 bg-destructive/5 px-3 py-2 text-sm text-destructive"
|
||||
className="mt-1.5 text-[0.78rem] leading-5 text-[color-mix(in_srgb,var(--dt-destructive)_78%,var(--ui-text-secondary))]"
|
||||
role="alert"
|
||||
>
|
||||
<ErrorPrimitive.Message />
|
||||
|
|
@ -539,7 +555,7 @@ const ResponseLoadingIndicator: FC = () => {
|
|||
|
||||
return (
|
||||
<StatusRow data-slot="aui_response-loading" label="Hermes is loading a response">
|
||||
<span aria-hidden="true" className="inline-block size-1.5 rounded-full bg-(--ui-orange) animate-pulse" />
|
||||
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
|
||||
<ActivityTimerText seconds={elapsed} />
|
||||
</StatusRow>
|
||||
)
|
||||
|
|
@ -628,7 +644,11 @@ const ThinkingDisclosure: FC<{
|
|||
}, [isPreview])
|
||||
|
||||
return (
|
||||
<div className="text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)" data-slot="aui_thinking-disclosure" ref={enterRef}>
|
||||
<div
|
||||
className="text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)"
|
||||
data-slot="aui_thinking-disclosure"
|
||||
ref={enterRef}
|
||||
>
|
||||
<DisclosureRow onToggle={() => setUserOpen(!open)} open={open}>
|
||||
<span className="flex min-w-0 items-baseline gap-1.5">
|
||||
<span
|
||||
|
|
@ -640,7 +660,10 @@ const ThinkingDisclosure: FC<{
|
|||
Thinking
|
||||
</span>
|
||||
{pending && (
|
||||
<ActivityTimerText className="text-[length:var(--conversation-caption-font-size)] tabular-nums text-(--ui-text-tertiary)" seconds={elapsed} />
|
||||
<ActivityTimerText
|
||||
className="text-[length:var(--conversation-caption-font-size)] tabular-nums text-(--ui-text-tertiary)"
|
||||
seconds={elapsed}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</DisclosureRow>
|
||||
|
|
@ -851,13 +874,13 @@ const AssistantFooter: FC<MessageActionProps> = props => (
|
|||
className="inline-flex h-6 items-center gap-1 text-xs text-muted-foreground"
|
||||
hideWhenSingleBranch
|
||||
>
|
||||
<BranchPickerPrimitive.Previous className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-35">
|
||||
<BranchPickerPrimitive.Previous className="grid size-6 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
|
||||
<Codicon name="chevron-left" size="0.875rem" />
|
||||
</BranchPickerPrimitive.Previous>
|
||||
<span className="tabular-nums">
|
||||
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
|
||||
</span>
|
||||
<BranchPickerPrimitive.Next className="grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-35">
|
||||
<BranchPickerPrimitive.Next className="grid size-6 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-35">
|
||||
<Codicon name="chevron-right" size="0.875rem" />
|
||||
</BranchPickerPrimitive.Next>
|
||||
</BranchPickerPrimitive.Root>
|
||||
|
|
@ -878,7 +901,7 @@ function messageAttachmentRefs(value: unknown): string[] {
|
|||
function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="group/user-message sticky top-0 z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--glass-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-2"
|
||||
className="group/user-message sticky top-0 z-40 -mx-4 flex w-[calc(100%+2rem)] min-w-0 max-w-none flex-col items-stretch gap-0 self-end overflow-visible bg-(--ui-chat-surface-background) px-4 pb-(--conversation-turn-gap) pt-2"
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
|
|
@ -894,6 +917,12 @@ function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
|
|||
const USER_BUBBLE_BASE_CLASS =
|
||||
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left shadow-composer'
|
||||
|
||||
const USER_ACTION_ICON_BUTTON_CLASS =
|
||||
'grid cursor-pointer place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
|
||||
|
||||
const USER_ACTION_ICON_SIZE = '0.6875rem'
|
||||
const StopGlyph = <IconPlayerStopFilled aria-hidden className="size-3.5 -translate-y-px" />
|
||||
|
||||
const UserMessage: FC<{
|
||||
onCancel?: () => Promise<void> | void
|
||||
}> = ({ onCancel }) => {
|
||||
|
|
@ -925,86 +954,99 @@ const UserMessage: FC<{
|
|||
const showStop = isLatestUser && threadRunning && Boolean(onCancel)
|
||||
const showRestore = !isLatestUser && !threadRunning
|
||||
|
||||
const bubbleClassName = cn(
|
||||
USER_BUBBLE_BASE_CLASS,
|
||||
'border-(--ui-stroke-tertiary) pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors',
|
||||
!threadRunning && 'cursor-pointer hover:border-(--ui-stroke-secondary)'
|
||||
)
|
||||
|
||||
const bubbleContent = (
|
||||
<>
|
||||
{attachmentRefs.length > 0 && (
|
||||
<span className="-mx-1 flex flex-wrap gap-1 border-b border-border/45 pb-1.5">
|
||||
<DirectiveContent text={attachmentRefs.join(' ')} />
|
||||
</span>
|
||||
)}
|
||||
{hasBody && (
|
||||
<span className="wrap-anywhere block whitespace-pre-line">
|
||||
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root asChild>
|
||||
<StickyHumanMessageContainer>
|
||||
<ActionBarPrimitive.Root className="relative w-full max-w-full" data-slot="aui_user-bubble-actions" hideWhenRunning>
|
||||
<div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
|
||||
<div className="relative w-full">
|
||||
<ActionBarPrimitive.Edit asChild>
|
||||
<button
|
||||
aria-label="Edit message"
|
||||
className={cn(
|
||||
USER_BUBBLE_BASE_CLASS,
|
||||
'cursor-pointer border-(--ui-stroke-tertiary) pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors hover:border-(--ui-stroke-secondary)'
|
||||
)}
|
||||
onClick={() => triggerHaptic('selection')}
|
||||
title="Edit message"
|
||||
type="button"
|
||||
>
|
||||
{attachmentRefs.length > 0 && (
|
||||
<span className="-mx-1 flex flex-wrap gap-1 border-b border-border/45 pb-1.5">
|
||||
<DirectiveContent text={attachmentRefs.join(' ')} />
|
||||
</span>
|
||||
)}
|
||||
{hasBody && (
|
||||
<span className="wrap-anywhere block whitespace-pre-line">
|
||||
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</ActionBarPrimitive.Edit>
|
||||
{(showStop || showRestore) && (
|
||||
<div className="pointer-events-none absolute right-1.5 bottom-1.5 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
|
||||
{showStop ? (
|
||||
<ActionBarPrimitive.Root className="relative w-full max-w-full" data-slot="aui_user-bubble-actions">
|
||||
<div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
|
||||
<div className="relative w-full">
|
||||
{threadRunning ? (
|
||||
<div className={bubbleClassName}>{bubbleContent}</div>
|
||||
) : (
|
||||
<ActionBarPrimitive.Edit asChild>
|
||||
<button
|
||||
aria-label="Stop"
|
||||
className="stop-button pointer-events-auto grid size-6 place-items-center rounded-full bg-(--ui-text-primary) text-(--ui-bg-editor) shadow-sm hover:opacity-90"
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void onCancel?.()
|
||||
}}
|
||||
title="Stop"
|
||||
aria-label="Edit message"
|
||||
className={bubbleClassName}
|
||||
onClick={() => triggerHaptic('selection')}
|
||||
title="Edit message"
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="debug-stop" size="0.75rem" />
|
||||
{bubbleContent}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="restore-button flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary)"
|
||||
title="Editable checkpoint"
|
||||
>
|
||||
<Codicon name="discard" size="0.875rem" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ActionBarPrimitive.Edit>
|
||||
)}
|
||||
{(showStop || showRestore) && (
|
||||
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
|
||||
{showStop ? (
|
||||
<button
|
||||
aria-label="Stop"
|
||||
className={cn('pointer-events-auto size-5', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void onCancel?.()
|
||||
}}
|
||||
title="Stop"
|
||||
type="button"
|
||||
>
|
||||
{StopGlyph}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary)"
|
||||
title="Editable checkpoint"
|
||||
>
|
||||
<Codicon name="discard" size="0.875rem" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<BranchPickerPrimitive.Root
|
||||
className="checkpoint-container flex items-center gap-1 pb-0 pt-1 pl-1.5 text-[0.75rem] leading-none text-(--ui-text-tertiary)"
|
||||
hideWhenSingleBranch
|
||||
>
|
||||
<span aria-hidden className="checkpoint-icon size-1.5 rounded-full border border-current" />
|
||||
<BranchPickerPrimitive.Previous
|
||||
className="checkpoint-restore-text cursor-pointer rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
|
||||
title="Restore previous checkpoint"
|
||||
>
|
||||
Restore checkpoint
|
||||
</BranchPickerPrimitive.Previous>
|
||||
<span className="checkpoint-divider opacity-55">
|
||||
<BranchPickerPrimitive.Number />/<BranchPickerPrimitive.Count />
|
||||
</span>
|
||||
<BranchPickerPrimitive.Next
|
||||
className="checkpoint-restore-text cursor-pointer rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
|
||||
title="Restore next checkpoint"
|
||||
>
|
||||
Go forward
|
||||
</BranchPickerPrimitive.Next>
|
||||
</BranchPickerPrimitive.Root>
|
||||
</div>
|
||||
<BranchPickerPrimitive.Root
|
||||
className="checkpoint-container flex items-center gap-1 pb-0 pt-1 pl-1.5 text-[0.75rem] leading-none text-(--ui-text-tertiary)"
|
||||
hideWhenSingleBranch
|
||||
>
|
||||
<span aria-hidden className="checkpoint-icon size-1.5 rounded-full border border-current" />
|
||||
<BranchPickerPrimitive.Previous
|
||||
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden"
|
||||
title="Restore previous checkpoint"
|
||||
>
|
||||
Restore checkpoint
|
||||
</BranchPickerPrimitive.Previous>
|
||||
<span className="checkpoint-divider opacity-55">
|
||||
<BranchPickerPrimitive.Number />/<BranchPickerPrimitive.Count />
|
||||
</span>
|
||||
<BranchPickerPrimitive.Next
|
||||
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden"
|
||||
title="Restore next checkpoint"
|
||||
>
|
||||
Go forward
|
||||
</BranchPickerPrimitive.Next>
|
||||
</BranchPickerPrimitive.Root>
|
||||
</div>
|
||||
</ActionBarPrimitive.Root>
|
||||
</ActionBarPrimitive.Root>
|
||||
</StickyHumanMessageContainer>
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
|
|
@ -1055,6 +1097,7 @@ interface UserEditComposerProps {
|
|||
const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }) => {
|
||||
const aui = useAui()
|
||||
const draft = useAuiState(s => s.composer.text)
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
const draftRef = useRef(draft)
|
||||
const dragDepthRef = useRef(0)
|
||||
|
|
@ -1064,7 +1107,9 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
|||
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
||||
const [triggerPlacement, setTriggerPlacement] = useState<'bottom' | 'top'>('top')
|
||||
const [focusRequestId, setFocusRequestId] = useState(0)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const expanded = draft.includes('\n') || draft.length > 96
|
||||
const canSubmit = draft.trim().length > 0
|
||||
const at = useAtCompletions({ cwd, gateway, sessionId })
|
||||
const slash = useSlashCompletions({ gateway })
|
||||
|
||||
|
|
@ -1116,7 +1161,10 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
|||
|
||||
const editor = editorRef.current
|
||||
|
||||
if (editor && (editor.childNodes.length === 0 || (document.activeElement !== editor && composerPlainText(editor) !== draft))) {
|
||||
if (
|
||||
editor &&
|
||||
(editor.childNodes.length === 0 || (document.activeElement !== editor && composerPlainText(editor) !== draft))
|
||||
) {
|
||||
renderComposerContents(editor, draft)
|
||||
|
||||
if (document.activeElement === editor) {
|
||||
|
|
@ -1277,7 +1325,10 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
|||
return false
|
||||
}
|
||||
|
||||
const refs = candidates.map(candidate => droppedFileInlineRef(candidate, cwd)).filter((ref): ref is string => Boolean(ref))
|
||||
const refs = candidates
|
||||
.map(candidate => droppedFileInlineRef(candidate, cwd))
|
||||
.filter((ref): ref is string => Boolean(ref))
|
||||
|
||||
const nextDraft = insertInlineRefsIntoEditor(editor, refs)
|
||||
|
||||
if (nextDraft === null) {
|
||||
|
|
@ -1375,10 +1426,39 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
|||
}
|
||||
|
||||
const submitEdit = (editor: HTMLDivElement) => {
|
||||
syncDraftFromEditor(editor)
|
||||
const nextDraft = syncDraftFromEditor(editor)
|
||||
|
||||
if (submitting || !nextDraft.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
aui.composer().send()
|
||||
}
|
||||
|
||||
const handleEditBlur = useCallback(
|
||||
(event: FocusEvent<HTMLDivElement>) => {
|
||||
const nextTarget = event.relatedTarget
|
||||
|
||||
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
|
||||
return
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
const root = rootRef.current
|
||||
const active = document.activeElement
|
||||
|
||||
if (submitting || (root && active && root.contains(active))) {
|
||||
return
|
||||
}
|
||||
|
||||
closeTrigger()
|
||||
aui.composer().cancel()
|
||||
}, 80)
|
||||
},
|
||||
[aui, closeTrigger, submitting]
|
||||
)
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (trigger && triggerItems.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
|
|
@ -1428,17 +1508,16 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
|||
}
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Root
|
||||
className="contents"
|
||||
data-slot="aui_edit-composer-root"
|
||||
>
|
||||
<ComposerPrimitive.Root className="contents" data-slot="aui_edit-composer-root">
|
||||
<StickyHumanMessageContainer>
|
||||
<div
|
||||
className="composer-human-message-container human-execution-message-top relative flex w-full items-start rounded-md bg-(--glass-chat-surface-background)"
|
||||
className="composer-human-message-container human-execution-message-top relative flex w-full items-start rounded-md bg-(--ui-chat-surface-background)"
|
||||
onBlur={handleEditBlur}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
ref={rootRef}
|
||||
>
|
||||
{trigger && (
|
||||
<ComposerTriggerPopover
|
||||
|
|
@ -1464,7 +1543,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
|||
aria-label="Edit message"
|
||||
autoFocus
|
||||
className={cn(
|
||||
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 outline-none',
|
||||
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 outline-none',
|
||||
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
|
||||
'**:data-ref-text:cursor-default',
|
||||
expanded ? 'min-h-16' : 'min-h-[1.25rem]'
|
||||
|
|
@ -1486,6 +1565,22 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
|||
suppressContentEditableWarning
|
||||
/>
|
||||
<ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} />
|
||||
<button
|
||||
aria-label="Send edited message"
|
||||
className={cn('absolute right-2 bottom-2 size-5', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||
disabled={!canSubmit || submitting}
|
||||
onClick={() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (editor) {
|
||||
submitEdit(editor)
|
||||
}
|
||||
}}
|
||||
title="Send edited message"
|
||||
type="button"
|
||||
>
|
||||
{submitting ? StopGlyph : <Codicon name="arrow-up" size={USER_ACTION_ICON_SIZE} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</StickyHumanMessageContainer>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ChatMessagePart } from './chat-messages'
|
||||
import type { ChatMessage, ChatMessagePart } from './chat-messages'
|
||||
import {
|
||||
appendAssistantTextPart,
|
||||
chatMessageText,
|
||||
preserveLocalAssistantErrors,
|
||||
renderMediaTags,
|
||||
toChatMessages,
|
||||
upsertToolPart
|
||||
|
|
@ -142,6 +143,173 @@ describe('renderMediaTags', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('preserveLocalAssistantErrors', () => {
|
||||
it('preserves a local user+error pair when hydration omits the failed turn', () => {
|
||||
const nextMessages: ChatMessage[] = [
|
||||
{
|
||||
id: 'stored-user',
|
||||
parts: [{ text: 'earlier', type: 'text' }],
|
||||
role: 'user'
|
||||
}
|
||||
]
|
||||
|
||||
const currentMessages: ChatMessage[] = [
|
||||
{
|
||||
id: 'stored-user',
|
||||
parts: [{ text: 'earlier', type: 'text' }],
|
||||
role: 'user'
|
||||
},
|
||||
{
|
||||
id: 'user-123',
|
||||
parts: [{ text: 'new prompt', type: 'text' }],
|
||||
role: 'user'
|
||||
},
|
||||
{
|
||||
error: 'OpenRouter 403',
|
||||
id: 'assistant-error-1',
|
||||
parts: [],
|
||||
role: 'assistant'
|
||||
}
|
||||
]
|
||||
|
||||
const merged = preserveLocalAssistantErrors(nextMessages, currentMessages)
|
||||
|
||||
expect(merged.map(message => message.id)).toEqual(['stored-user', 'user-123', 'assistant-error-1'])
|
||||
expect(merged[2]?.error).toBe('OpenRouter 403')
|
||||
})
|
||||
|
||||
it('does not keep orphan local user turns when there is no inline assistant error', () => {
|
||||
const nextMessages: ChatMessage[] = [
|
||||
{
|
||||
id: 'stored-user',
|
||||
parts: [{ text: 'earlier', type: 'text' }],
|
||||
role: 'user'
|
||||
}
|
||||
]
|
||||
|
||||
const currentMessages: ChatMessage[] = [
|
||||
...nextMessages,
|
||||
{
|
||||
id: 'user-123',
|
||||
parts: [{ text: 'new prompt', type: 'text' }],
|
||||
role: 'user'
|
||||
}
|
||||
]
|
||||
|
||||
const merged = preserveLocalAssistantErrors(nextMessages, currentMessages)
|
||||
|
||||
expect(merged.map(message => message.id)).toEqual(['stored-user'])
|
||||
})
|
||||
|
||||
it('does not duplicate local user when stored history already has equivalent text', () => {
|
||||
const nextMessages: ChatMessage[] = [
|
||||
{
|
||||
id: 'stored-user',
|
||||
parts: [{ text: 'hi', type: 'text' }],
|
||||
role: 'user'
|
||||
}
|
||||
]
|
||||
|
||||
const currentMessages: ChatMessage[] = [
|
||||
{
|
||||
id: 'optimistic-user',
|
||||
parts: [{ text: 'hi', type: 'text' }],
|
||||
role: 'user'
|
||||
},
|
||||
{
|
||||
error: 'OpenRouter 403',
|
||||
id: 'assistant-error-1',
|
||||
parts: [],
|
||||
role: 'assistant'
|
||||
}
|
||||
]
|
||||
|
||||
const merged = preserveLocalAssistantErrors(nextMessages, currentMessages)
|
||||
|
||||
expect(merged.map(message => message.id)).toEqual(['stored-user', 'assistant-error-1'])
|
||||
})
|
||||
|
||||
it('keeps local user when only older history has equivalent text', () => {
|
||||
const nextMessages: ChatMessage[] = [
|
||||
{
|
||||
id: 'older-user',
|
||||
parts: [{ text: 'hi', type: 'text' }],
|
||||
role: 'user'
|
||||
},
|
||||
{
|
||||
id: 'older-assistant',
|
||||
parts: [{ text: 'hello', type: 'text' }],
|
||||
role: 'assistant'
|
||||
},
|
||||
{
|
||||
id: 'tail-user',
|
||||
parts: [{ text: 'different prompt', type: 'text' }],
|
||||
role: 'user'
|
||||
}
|
||||
]
|
||||
|
||||
const currentMessages: ChatMessage[] = [
|
||||
{
|
||||
id: 'optimistic-user',
|
||||
parts: [{ text: 'hi', type: 'text' }],
|
||||
role: 'user'
|
||||
},
|
||||
{
|
||||
error: 'OpenRouter 403',
|
||||
id: 'assistant-error-1',
|
||||
parts: [],
|
||||
role: 'assistant'
|
||||
}
|
||||
]
|
||||
|
||||
const merged = preserveLocalAssistantErrors(nextMessages, currentMessages)
|
||||
|
||||
expect(merged.map(message => message.id)).toEqual([
|
||||
'older-user',
|
||||
'older-assistant',
|
||||
'tail-user',
|
||||
'optimistic-user',
|
||||
'assistant-error-1'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps local assistant error when hydrated message reuses same id', () => {
|
||||
const nextMessages: ChatMessage[] = [
|
||||
{
|
||||
id: 'user-1',
|
||||
parts: [{ text: 'new prompt', type: 'text' }],
|
||||
role: 'user'
|
||||
},
|
||||
{
|
||||
id: 'assistant-stream-1',
|
||||
parts: [{ text: '', type: 'text' }],
|
||||
role: 'assistant'
|
||||
}
|
||||
]
|
||||
|
||||
const currentMessages: ChatMessage[] = [
|
||||
{
|
||||
id: 'user-1',
|
||||
parts: [{ text: 'new prompt', type: 'text' }],
|
||||
role: 'user'
|
||||
},
|
||||
{
|
||||
error: 'OpenRouter 403',
|
||||
id: 'assistant-stream-1',
|
||||
parts: [],
|
||||
role: 'assistant'
|
||||
}
|
||||
]
|
||||
|
||||
const merged = preserveLocalAssistantErrors(nextMessages, currentMessages)
|
||||
|
||||
const assistant = merged.find(message => message.id === 'assistant-stream-1')
|
||||
|
||||
expect(assistant?.error).toBe('OpenRouter 403')
|
||||
expect(assistant?.pending).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('upsertToolPart', () => {
|
||||
it('preserves inline diffs from tool completion events', () => {
|
||||
const parts = upsertToolPart(
|
||||
|
|
@ -221,6 +389,7 @@ describe('upsertToolPart', () => {
|
|||
|
||||
const completedResult =
|
||||
completed[0] && 'result' in completed[0] ? (completed[0].result as Record<string, unknown>) : {}
|
||||
|
||||
const clearedResult = cleared[0] && 'result' in cleared[0] ? (cleared[0].result as Record<string, unknown>) : {}
|
||||
|
||||
expect(completedResult.todos).toEqual([{ content: 'Boil water', id: 'boil', status: 'in_progress' }])
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export type ChatMessage = {
|
|||
parts: ChatMessagePart[]
|
||||
timestamp?: number
|
||||
pending?: boolean
|
||||
error?: string
|
||||
branchGroupId?: string
|
||||
hidden?: boolean
|
||||
/** Composer attachment ref strings (`@file:...`, `@image:...`) sent with this user message. */
|
||||
|
|
@ -801,6 +802,77 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
|
|||
)
|
||||
}
|
||||
|
||||
export function preserveLocalAssistantErrors(
|
||||
nextMessages: ChatMessage[],
|
||||
currentMessages: ChatMessage[]
|
||||
): ChatMessage[] {
|
||||
const localById = new Map(currentMessages.map(message => [message.id, message]))
|
||||
|
||||
const mergedNextMessages = nextMessages.map(message => {
|
||||
if (message.role !== 'assistant' || message.error || message.hidden) {
|
||||
return message
|
||||
}
|
||||
|
||||
const local = localById.get(message.id)
|
||||
|
||||
if (!local || local.role !== 'assistant' || !local.error || local.hidden) {
|
||||
return message
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
error: local.error,
|
||||
pending: false
|
||||
}
|
||||
})
|
||||
|
||||
const existingIds = new Set(mergedNextMessages.map(message => message.id))
|
||||
const preserveIds = new Set<string>()
|
||||
const normalize = (value: string) => value.replace(/\s+/g, ' ').trim()
|
||||
const tailUserInNext = [...mergedNextMessages].reverse().find(message => message.role === 'user' && !message.hidden)
|
||||
const tailUserText = tailUserInNext ? normalize(chatMessageText(tailUserInNext)) : ''
|
||||
const tailUserRefs = tailUserInNext ? (tailUserInNext.attachmentRefs ?? []).join('\n') : ''
|
||||
|
||||
const matchesTailUserInNext = (candidate: ChatMessage) =>
|
||||
Boolean(tailUserInNext) &&
|
||||
normalize(chatMessageText(candidate)) === tailUserText &&
|
||||
(candidate.attachmentRefs ?? []).join('\n') === tailUserRefs
|
||||
|
||||
for (let index = 0; index < currentMessages.length; index += 1) {
|
||||
const message = currentMessages[index]
|
||||
|
||||
if (message.role !== 'assistant' || !message.error || message.hidden || existingIds.has(message.id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
preserveIds.add(message.id)
|
||||
|
||||
for (let probe = index - 1; probe >= 0; probe -= 1) {
|
||||
const candidate = currentMessages[probe]
|
||||
|
||||
if (candidate.hidden) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (candidate.role === 'user' && !existingIds.has(candidate.id) && !matchesTailUserInNext(candidate)) {
|
||||
preserveIds.add(candidate.id)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (preserveIds.size === 0) {
|
||||
return mergedNextMessages
|
||||
}
|
||||
|
||||
const preserved = currentMessages
|
||||
.filter(message => preserveIds.has(message.id))
|
||||
.map(message => ({ ...message, pending: false }))
|
||||
|
||||
return [...mergedNextMessages, ...preserved]
|
||||
}
|
||||
|
||||
export function branchGroupForUser(userMessage: ChatMessage): string {
|
||||
return `branch:${userMessage.id}`
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue