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.
This commit is contained in:
xxxigm 2026-07-21 07:55:04 +07:00 committed by GitHub
parent 272bbaf792
commit f657840e06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 233 additions and 7 deletions

View file

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

View file

@ -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<string | null> = { current: runtimeSessionId }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: tipBefore }
const navigate = vi.fn()
setSessions([])
stashSessionDraft(tipBefore, 'typed during gap', [])
setSelectedStoredSessionId(tipBefore)
setActiveSessionId(runtimeSessionId)
render(
<StoredIdRotationHarness
activeSessionIdRef={activeSessionIdRef}
getRoutedStoredSessionId={() => 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<string | null> = { current: runtimeSessionId }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { 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(
<StoredIdRotationHarness
activeSessionIdRef={activeSessionIdRef}
getRoutedStoredSessionId={() => 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<string | null> = { current: 'runtime-A' }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: 'stored-A' }

View file

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

View file

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

View file

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

View file

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

View file

@ -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<SessionInfo, '_lineage_root_id' | 'id'>[]
): 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.