diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index 247008562f..cff162d2c7 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -712,6 +712,7 @@ export function placeCaretAtOffset(editor: HTMLElement, offset: number) { * cloning the draft on every input flush makes ordinary typing needlessly costly. */ function removeComposerJunk(editor: HTMLElement, node: ChildNode) { const selected = composerCollapsedSelectionContainer(editor) + const offset = document.activeElement === editor && selected && node.contains(selected) ? caretOffsetInEditor(editor) : null diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/completion-boundaries.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/completion-boundaries.test.tsx index 9a10d3e641..de6d09b7fc 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/completion-boundaries.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/completion-boundaries.test.tsx @@ -14,8 +14,8 @@ import { $messages, setActiveSessionId, setMessages } from '@/store/session' import { $sessionStates } from '@/store/session-states' import { usePromptActions } from '../use-prompt-actions' -import { useSessionStateCache } from '../use-session-state-cache' import { reconcileDurableHistory } from '../use-session-actions/utils' +import { useSessionStateCache } from '../use-session-state-cache' import { useMessageStream } from './index' @@ -109,12 +109,14 @@ function mount(rpc = requestGateway) { it('binds a late submit acknowledgement to its exact optimistic prompt without reviving the turn', async () => { const laterUser: ChatMessage = { id: 'later-user', role: 'user', parts: [{ type: 'text', text: 'Give the answer.' }] } + const h = mount(async (): Promise => { await h.send('message.start') await h.send('message.delta', { text: ANSWER }) await h.send('message.complete', { text: ANSWER }) h.update(state => ({ ...state, messages: [...state.messages, laterUser], needsInput: true })) await flush() + return { status: 'started', user_row_id: 71 } as T }) @@ -139,6 +141,7 @@ it.each([true, false, undefined])( const assistantId = h.state().streamId const queued: ChatMessage = { id: `user-queued-${SID}`, role: 'user', parts: [{ type: 'text', text: 'Next' }] } h.update(state => ({ ...state, messages: [...state.messages, queued] })) + const receipt = complete === undefined ? undefined @@ -168,6 +171,7 @@ it.each([true, false, undefined])( ['user', 'Next'] ]) } + h.dispose() } ) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts index bd7fb82756..14f4c374bc 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts @@ -737,6 +737,7 @@ describe('preserveLocalPendingTurnMessages', () => { const answer = msg('stored-answer', 'assistant', 'Completed.', { rowId: 30, timestamp: 5 }) const folded = { ...answer, rowId: 20, parts: [{ ...textPart('Completed.'), sourceRowId: 30 }] } + for (const next of [[answer], [msg('stored-followup', 'user', 'Follow-up request'), answer], [folded]]) { expect(preserveLocalPendingTurnMessages(next, previous)).toEqual(next) } diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index 7d517c2908..f285e73a36 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -58,6 +58,7 @@ import { reportBackendContract, reportInstallMethodWarning } from '@/store/updat import type { SessionCreateResponse, SessionInfo, SessionResumeResult, SessionRuntimeInfo } from '@/types/hermes' import type { ClientSessionState } from '../../../types' + import { acknowledgedTranscriptBoundary, conflictingTranscriptIdentity, @@ -686,12 +687,14 @@ export function preserveLocalPendingTurnMessages( } crossedUserBoundary ||= message.role === 'user' || message.role === 'system' + // A second segment of the acknowledged turn can still be folded into its // final row. A new prompt closes that ownership; identical later replies // must not be consumed by the already-acknowledged prefix. const candidates = (crossedUserBoundary ? remainingNext : acknowledgedTurn).filter( candidate => !conflictingTranscriptIdentity(message, candidate) ) + const ordinal = previousRoleCounts.get(message.role) ?? 0 previousRoleCounts.set(message.role, ordinal + 1) @@ -739,6 +742,7 @@ export function preserveLocalPendingTurnMessages( } const ordinalMatch = nextByRoleOrdinal.get(`${message.role}:${ordinal}`) + const authoritative = ordinalMatch && !conflictingTranscriptIdentity(message, ordinalMatch) ? ordinalMatch : undefined