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:
Brooklyn Nicholson 2026-05-16 20:33:17 -05:00
parent d67a438fec
commit f9908af1a0
8 changed files with 627 additions and 165 deletions

View file

@ -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
)

View file

@ -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,

View file

@ -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(

View file

@ -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()) {

View file

@ -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(

View file

@ -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>

View file

@ -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' }])

View file

@ -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}`
}