From f657840e06e03b9552cf2d28175a1e4e4af0210b Mon Sep 17 00:00:00 2001 From: xxxigm <54813621+xxxigm@users.noreply.github.com> Date: Tue, 21 Jul 2026 07:55:04 +0700 Subject: [PATCH] fix(desktop): keep composer draft across compression tip rotation (#68079) * fix(desktop): keep composer draft across compression tip rotation Auto-compression swaps the live stored session id while the user may still be typing. Scope the composer/queue key on the lineage root and migrate any tip-keyed draft/queue entries onto that durable key when the tip rotates so the in-progress prompt does not vanish when the response lands. * test(desktop): cover draft survival across compression tip rotation Add regression coverage for migrateSessionDraft, lineage-scoped composer keys, and the rotation path that previously wiped an in-progress draft. --- apps/desktop/src/app/chat/index.tsx | 26 +++++- .../hooks/use-session-actions.test.tsx | 84 +++++++++++++++++++ .../hooks/use-session-actions/index.ts | 32 +++++-- apps/desktop/src/store/composer.test.ts | 26 ++++++ apps/desktop/src/store/composer.ts | 35 ++++++++ apps/desktop/src/store/session.test.ts | 16 ++++ apps/desktop/src/store/session.ts | 21 +++++ 7 files changed, 233 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index b47f0dfa9907..f135b0aae959 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -2,7 +2,7 @@ import { type AppendMessage, AssistantRuntimeProvider, type ThreadMessage } from import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import type * as React from 'react' -import { Suspense, useCallback, useMemo } from 'react' +import { Suspense, useCallback, useEffect, useMemo } from 'react' import { useLocation } from 'react-router-dom' import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils' @@ -24,6 +24,8 @@ import { $pinnedSessionIds } from '@/store/layout' import { $petActive } from '@/store/pet' import { $petOverlayActive } from '@/store/pet-overlay' import { $gatewaySwapTarget, $profiles } from '@/store/profile' +import { migrateSessionDraft } from '@/store/composer' +import { migrateQueuedPrompts } from '@/store/composer-queue' import { $contextSuggestions, $freshDraftReady, @@ -32,6 +34,7 @@ import { $introSeed, $resumeExhaustedSessionId, $sessions, + resolveComposerSessionKey, sessionMatchesStoredId, sessionPinId } from '@/store/session' @@ -276,7 +279,26 @@ export function ChatView({ const messagesEmpty = useStore(view.$messagesEmpty) const lastVisibleIsUser = useStore(view.$lastVisibleIsUser) const selectedSessionId = useStore(view.$storedId) + const sessions = useStore($sessions) const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId) + // Durable composer/queue scope (lineage root) so auto-compression tip rotation + // does not wipe an in-progress draft or orphan /queue entries. + const queueSessionKey = useMemo( + () => resolveComposerSessionKey(selectedSessionId, sessions), + [selectedSessionId, sessions] + ) + + // When the tip row arrives after compression, migrate any tip-keyed stash onto + // the durable lineage key before the composer remounts onto that key. + useEffect(() => { + if (!selectedSessionId || !queueSessionKey || selectedSessionId === queueSessionKey) { + return + } + + migrateSessionDraft(selectedSessionId, queueSessionKey) + migrateQueuedPrompts(selectedSessionId, queueSessionKey) + }, [queueSessionKey, selectedSessionId]) + // A tile IS its session — no route involved, never "mismatched". const routedSessionId = isPrimary ? routeSessionId(location.pathname) : selectedSessionId const isRoutedSessionView = Boolean(routedSessionId) @@ -524,7 +546,7 @@ export function ChatView({ onSteer={onSteer} onSubmit={onSubmit} onTranscribeAudio={onTranscribeAudio} - queueSessionKey={selectedSessionId} + queueSessionKey={queueSessionKey} sessionId={activeSessionId} state={chatBarState} /> diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index a06e642d6e0c..854a0085925d 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -7,6 +7,7 @@ import { getSessionMessages, type SessionInfo } from '@/hermes' import { createClientSessionState } from '@/lib/chat-runtime' import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile } from '@/store/profile' import { $projectScope, $projectTree, ALL_PROJECTS } from '@/store/projects' +import { clearSessionDraft, stashSessionDraft, takeSessionDraft } from '@/store/composer' import { $activeSessionId, $activeSessionStoredIdRotation, @@ -196,6 +197,89 @@ describe('active stored-session id rotation routing', () => { expect($activeSessionStoredIdRotation.get()).toBeNull() }) + it('keeps draft on the previous tip when the new tip row is not loaded yet', async () => { + const tipBefore = 'tip-root' + const tipAfter = 'tip-new-unloaded' + const runtimeSessionId = 'runtime-gap' + const activeSessionIdRef: MutableRefObject = { current: runtimeSessionId } + const selectedStoredSessionIdRef: MutableRefObject = { current: tipBefore } + const navigate = vi.fn() + + setSessions([]) + stashSessionDraft(tipBefore, 'typed during gap', []) + setSelectedStoredSessionId(tipBefore) + setActiveSessionId(runtimeSessionId) + + render( + tipBefore} + navigate={navigate} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + /> + ) + + act(() => { + setActiveSessionStoredIdRotation({ + nextStoredSessionId: tipAfter, + previousStoredSessionId: tipBefore, + runtimeSessionId + }) + }) + + await waitFor(() => expect($selectedStoredSessionId.get()).toBe(tipAfter)) + expect(takeSessionDraft(tipBefore).text).toBe('typed during gap') + expect(takeSessionDraft(tipAfter).text).toBe('') + + clearSessionDraft(tipBefore) + clearSessionDraft(tipAfter) + setActiveSessionId(null) + }) + + it('parks an in-progress composer draft on the lineage root across tip rotation', async () => { + // Desktop draft must stay on the durable composer key (lineage root), not + // move onto the fresh tip — ChatBar scopes drafts via resolveComposerSessionKey. + const tipBefore = '20260720_062637_ad96b3' + const tipAfter = '20260720_071049_a28905' + const runtimeSessionId = 'runtime-desktop-thinking' + const activeSessionIdRef: MutableRefObject = { current: runtimeSessionId } + const selectedStoredSessionIdRef: MutableRefObject = { current: tipBefore } + const navigate = vi.fn() + const typedWhileThinking = 'follow up I am still typing during thinking' + + setSessions([storedSession({ id: tipAfter, message_count: 2, _lineage_root_id: tipBefore })]) + stashSessionDraft(tipBefore, typedWhileThinking, []) + setSelectedStoredSessionId(tipBefore) + setActiveSessionId(runtimeSessionId) + + render( + tipBefore} + navigate={navigate} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + /> + ) + + act(() => { + setActiveSessionStoredIdRotation({ + nextStoredSessionId: tipAfter, + previousStoredSessionId: tipBefore, + runtimeSessionId + }) + }) + + await waitFor(() => expect($selectedStoredSessionId.get()).toBe(tipAfter)) + // Durable key remains the lineage root — same scope ChatBar will keep using. + expect(takeSessionDraft(tipBefore).text).toBe(typedWhileThinking) + expect(takeSessionDraft(tipAfter).text).toBe('') + + clearSessionDraft(tipBefore) + clearSessionDraft(tipAfter) + setActiveSessionId(null) + setSessions([]) + }) + it('does not overwrite a newer route intent before its resume effect has synchronized selection', async () => { const activeSessionIdRef: MutableRefObject = { current: 'runtime-A' } const selectedStoredSessionIdRef: MutableRefObject = { current: 'stored-A' } diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 6934d00208c5..2eaa406adf3a 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -8,7 +8,8 @@ import { useI18n } from '@/i18n' import { type ChatMessage, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages' import { isMissingRpcMethod } from '@/lib/gateway-rpc' import { setSessionYolo } from '@/lib/yolo-session' -import { clearQueuedPrompts } from '@/store/composer-queue' +import { migrateSessionDraft } from '@/store/composer' +import { clearQueuedPrompts, migrateQueuedPrompts } from '@/store/composer-queue' import { $pinnedSessionIds } from '@/store/layout' import { clearNotifications, notify, notifyError } from '@/store/notifications' import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' @@ -25,6 +26,7 @@ import { $sessions, $yoloActive, type NewChatWorkspaceTarget, + resolveComposerSessionKey, sessionPinId, setActiveSessionId, setActiveSessionStoredIdRotation, @@ -233,14 +235,34 @@ export function useSessionActions({ return } - setSelectedStoredSessionId(storedIdRotation.nextStoredSessionId) - selectedStoredSessionIdRef.current = storedIdRotation.nextStoredSessionId + // Park unsent draft/queue on the durable lineage key (not the new tip). + // ChatBar scopes composer state on resolveComposerSessionKey(); migrating + // onto the tip while the composer is still bound to the root can lose newer + // live editor text on a brief remount. If the new tip row is not in + // $sessions yet, resolveComposerSessionKey falls back to the tip id — prefer + // the previous id (usually the lineage root) in that gap. + const previousId = storedIdRotation.previousStoredSessionId + const nextId = storedIdRotation.nextStoredSessionId + const sessions = $sessions.get() + const resolvedNext = resolveComposerSessionKey(nextId, sessions) + const durableKey = + resolvedNext && resolvedNext !== nextId + ? resolvedNext + : (resolveComposerSessionKey(previousId, sessions) ?? previousId) + + migrateSessionDraft(previousId, durableKey) + migrateSessionDraft(nextId, durableKey) + migrateQueuedPrompts(previousId, durableKey) + migrateQueuedPrompts(nextId, durableKey) + + setSelectedStoredSessionId(nextId) + selectedStoredSessionIdRef.current = nextId // A route overlay/page has no routed session id, but the underlying selected // chat still needs to follow the continuation. Update that selection in // place without navigating out of the surface the user deliberately opened. - if (routedStoredSessionId === storedIdRotation.previousStoredSessionId) { - navigate(sessionRoute(storedIdRotation.nextStoredSessionId), { replace: true }) + if (routedStoredSessionId === previousId) { + navigate(sessionRoute(nextId), { replace: true }) } }, [activeSessionIdRef, getRoutedStoredSessionId, navigate, selectedStoredSessionIdRef, storedIdRotation]) diff --git a/apps/desktop/src/store/composer.test.ts b/apps/desktop/src/store/composer.test.ts index b57242052db5..1b3a174ba9f4 100644 --- a/apps/desktop/src/store/composer.test.ts +++ b/apps/desktop/src/store/composer.test.ts @@ -5,6 +5,7 @@ import { addComposerAttachment, clearSessionDraft, type ComposerAttachment, + migrateSessionDraft, removeComposerAttachment, SESSION_DRAFTS_STORAGE_KEY, stashSessionDraft, @@ -106,4 +107,29 @@ describe('session drafts', () => { expect(takeSessionDraft('session-a').attachments[0]?.label).toBe('doc.pdf') }) + + it('migrates a tip-keyed draft onto the post-compression tip', () => { + const tipBefore = '20260720_062637_ad96b3' + const tipAfter = '20260720_071049_a28905' + + stashSessionDraft(tipBefore, 'half typed while thinking', []) + + expect(migrateSessionDraft(tipBefore, tipAfter)).toBe(true) + expect(takeSessionDraft(tipAfter).text).toBe('half typed while thinking') + expect(takeSessionDraft(tipBefore).text).toBe('') + + clearSessionDraft(tipAfter) + }) + + it('does not overwrite a non-empty destination draft during migration', () => { + stashSessionDraft('from', 'old tip draft', []) + stashSessionDraft('to', 'already typed on new tip', []) + + expect(migrateSessionDraft('from', 'to')).toBe(false) + expect(takeSessionDraft('to').text).toBe('already typed on new tip') + expect(takeSessionDraft('from').text).toBe('old tip draft') + + clearSessionDraft('from') + clearSessionDraft('to') + }) }) diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts index 156517c33053..b984fe8bfc3b 100644 --- a/apps/desktop/src/store/composer.ts +++ b/apps/desktop/src/store/composer.ts @@ -171,6 +171,41 @@ export function takeSessionDraft(scope: string | null | undefined): SessionDraft export const clearSessionDraft = (scope: string | null | undefined) => stashSessionDraft(scope, '', []) +/** + * Move a stashed composer draft from one session key onto another. + * + * Auto-compression rotates the live stored tip id (root → continuation) while + * the user may still be typing. Drafts keyed on the obsolete tip would otherwise + * vanish from the composer when selection follows the new tip. No-op unless both + * keys resolve, differ, and the source has content. Does not overwrite a + * non-empty destination draft. + */ +export function migrateSessionDraft(fromKey: string | null | undefined, toKey: string | null | undefined): boolean { + const from = draftKey(fromKey) + const to = draftKey(toKey) + + if (!fromKey || !toKey || from === to) { + return false + } + + const source = draftsBySession.get(from) + + if (!source || (!source.text.trim() && source.attachments.length === 0)) { + return false + } + + const dest = draftsBySession.get(to) + + if (dest && (dest.text.trim() || dest.attachments.length > 0)) { + return false + } + + stashSessionDraft(toKey, source.text, source.attachments) + clearSessionDraft(fromKey) + + return true +} + export function setComposerDraft(value: string) { $composerDraft.set(value) } diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts index a3c816c762f6..e4b9b08b66d0 100644 --- a/apps/desktop/src/store/session.test.ts +++ b/apps/desktop/src/store/session.test.ts @@ -12,6 +12,7 @@ import { $unreadFinishedSessionIds, applyConfiguredDefaultProjectDir, mergeSessionPage, + resolveComposerSessionKey, sessionPinId, setCurrentCwd, setSelectedStoredSessionId, @@ -85,6 +86,21 @@ describe('sessionPinId', () => { }) }) +describe('resolveComposerSessionKey', () => { + it('keeps the lineage root across compression tip rotation', () => { + const tipBefore = '20260720_062637_ad96b3' + const tipAfter = '20260720_071049_a28905' + const sessions = [session({ id: tipAfter, _lineage_root_id: tipBefore })] + + expect(resolveComposerSessionKey(tipBefore, [session({ id: tipBefore })])).toBe(tipBefore) + expect(resolveComposerSessionKey(tipAfter, sessions)).toBe(tipBefore) + }) + + it('falls back to the live id when the tip row is not loaded yet', () => { + expect(resolveComposerSessionKey('tip-new', [])).toBe('tip-new') + }) +}) + describe('mergeSessionPage', () => { it('returns the server page untouched when there is nothing to keep', () => { const previous = [session({ id: 'a' }), session({ id: 'b' })] diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 9d596a3b822e..3c8ea0b714e5 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -144,6 +144,27 @@ export const sessionMatchesStoredId = ( storedSessionId: string ): boolean => session.id === storedSessionId || session._lineage_root_id === storedSessionId +/** + * Stable composer + `/queue` scope for a selected stored session. + * + * Same durability rule as {@link sessionPinId}: prefer the lineage root so + * auto-compression tip rotation does not remount the composer onto an empty + * draft/queue key mid-keystroke. Falls back to the live id when the row is + * not in the in-memory list yet. + */ +export function resolveComposerSessionKey( + selectedSessionId: string | null | undefined, + sessions: readonly Pick[] +): string | null { + if (!selectedSessionId) { + return null + } + + const row = sessions.find(session => sessionMatchesStoredId(session, selectedSessionId)) + + return row ? sessionPinId(row) : selectedSessionId +} + /** Merge a fresh server session page into the in-memory list, keeping any * row the server omitted that we still want visible — both still-"working" * sessions and pinned sessions.