From 88bbbbf6f28c670959e1117d263cd2ee09482cdd Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 29 Sep 2026 15:33:36 -0500 Subject: [PATCH] fix(desktop): re-home fresh drafts through the active lifecycle key Adapt the per-instance fresh draft keys to the announcement-based re-homing seams landed in a4b48064b1b: draftKey(null) resolves to the active fresh lifecycle's key so stash/read/migrate/adopt/undo all address the bucket the composer is actually composing into, and the swap effect treats a fresh scope as the pre-session state for adoptNewSessionDraft. --- .../hooks/use-composer-draft.test.tsx | 83 +++++++++++++++++++ .../chat/composer/hooks/use-composer-draft.ts | 24 +++--- .../hooks/use-session-actions.test.tsx | 2 +- apps/desktop/src/sdk/composer.ts | 4 +- apps/desktop/src/store/composer.ts | 40 ++++++++- apps/desktop/src/store/session.ts | 22 +---- 6 files changed, 139 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.test.tsx index cea1cbc3cd..8b111fdf93 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.test.tsx +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.test.tsx @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { PaneVisibleContext } from '@/components/pane-shell/pane-visibility' import { + $freshDraftKey, $restoredDraftNotice, announceGoneSessionDraft, announceNewSessionDraftKey, @@ -11,6 +12,8 @@ import { type ComposerAttachment, dismissRestoredDraftNotice, mainComposerScope, + NEW_SESSION_DRAFT_KEY, + rotateFreshDraftKey, stashSessionDraft, takeSessionDraft } from '@/store/composer' @@ -68,6 +71,12 @@ describe('useComposerDraft — attachment scope stays coherent with the committe mainComposerScope.clear() clearSessionDraft('session-A') clearSessionDraft('session-B') + // Fresh-draft lifecycles rotate per test; the afterEach must sweep the + // whole map or one test's abandoned bucket leaks into the next. + for (const scope of ['session-created', NEW_SESSION_DRAFT_KEY, $freshDraftKey.get()]) { + clearSessionDraft(scope) + } + rotateFreshDraftKey() delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop vi.unstubAllGlobals() $connection.set(null) @@ -184,6 +193,80 @@ describe('useComposerDraft — attachment scope stays coherent with the committe clearSessionDraft(null) }) + it('isolates two concurrent new-chat lifecycles: the second fresh draft never shows the first one\'s text (#66662)', () => { + const firstKey = rotateFreshDraftKey() + const secondKey = rotateFreshDraftKey() + + expect(firstKey).not.toBe(secondKey) + + // First new chat: type unsent text under its own lifecycle key. + stashSessionDraft(firstKey, 'first unsent chat', []) + expect(takeSessionDraft(firstKey).text).toBe('first unsent chat') + + // A second New Chat rotated the key; its composer must restore empty — + // the first chat's text is invisible until the user goes back. + render( undefined} sessionId="" />) + + expect(takeSessionDraft(secondKey)).toEqual({ attachments: [], text: '' }) + + // The abandoned lifecycle keeps its text — no consumer of the second + // lifecycle's scope can see or clobber it. + expect(takeSessionDraft(firstKey).text).toBe('first unsent chat') + + clearSessionDraft(firstKey) + clearSessionDraft(secondKey) + }) + + it('re-homes the ACTIVE lifecycle\'s draft onto the session its first send creates (#66662)', () => { + const key = rotateFreshDraftKey() + + // The user typed in the current new chat; the swap cleanup stashed it + // under the lifecycle key (null scope resolves to it). + stashSessionDraft(null, 'typed before first send', []) + + const { rerender } = render( + undefined} sessionId="" /> + ) + + expect(takeSessionDraft(key).text).toBe('typed before first send') + + // First send: session.create assigns the stored id; the composer's scope + // swap follows the announcement and moves THIS lifecycle's bucket. + announceNewSessionDraftKey('session-created') + act(() => { + rerender( + undefined} + sessionId="session-created" + /> + ) + }) + + expect(takeSessionDraft('session-created').text).toBe('typed before first send') + expect(takeSessionDraft(key).text).toBe('') + + clearSessionDraft('session-created') + }) + + it('keys a fresh chat\'s live stash under its lifecycle key, not the shared bucket (#66662)', () => { + const key = rotateFreshDraftKey() + + const { unmount } = render( + undefined} sessionId="" /> + ) + + // Stash through the null scope the way the swap cleanup does when the + // user types and navigates away mid-debounce. + stashSessionDraft(null, 'typed in this lifecycle', []) + + expect(takeSessionDraft(key).text).toBe('typed in this lifecycle') + expect(takeSessionDraft(NEW_SESSION_DRAFT_KEY).text).toBe('') + + unmount() + clearSessionDraft(key) + }) + it('applies a delayed image preview when it resolves while its attachment draft is inactive', async () => { const fullResolution = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+GkZcAAAAASUVORK5CYII=' diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts index c88a91c891..85265a5db7 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -18,7 +18,8 @@ import { adoptNewSessionDraft, type ComposerAttachment, type ComposerDraftSyncMode, - NEW_SESSION_DRAFT_KEY, + freshDraftScope, + isFreshDraftScope, onComposerDraftSyncRequest, reloadPersistedDrafts, stashSessionDraft, @@ -300,8 +301,9 @@ export function useComposerDraft({ const ids = [sessionIdRef.current, activeQueueSessionKeyRef.current].filter((id): id is string => Boolean(id)) // A surface with no session yet IS the new-chat draft (the stash keys - // it '__new__'); once one opens, the new-chat draft belongs elsewhere. - return ids.length ? ids : [NEW_SESSION_DRAFT_KEY] + // it by the fresh-draft key); once one opens, the new-chat draft + // belongs elsewhere. + return ids.length ? ids : [freshDraftScope()] }, isActive: () => getActiveComposer() === target }, @@ -510,14 +512,14 @@ export function useComposerDraft({ window.clearTimeout(draftPersistTimerRef.current) pendingDraftPersistRef.current = null - // A new chat writes to the shared pre-session bucket until its stored id - // arrives; the assigning site announces that id (store/composer.ts). Move - // the bucket at this handoff — after the outgoing cleanup stashed the live - // editor text under it, before the incoming scope is restored — so the - // text the user kept typing follows the chat instead of vanishing. - // Keyed on the scope alone: the runtime id can land a resume later than - // the route flips the scope, so it is not a usable signal here. - if (!draftScopeRef.current && activeQueueSessionKey) { + // A new chat writes to its own pre-session bucket (the per-lifecycle fresh + // key) until its stored id arrives; the assigning site announces that id + // (store/composer.ts). Move the bucket at this handoff — after the outgoing + // cleanup stashed the live editor text under it, before the incoming scope + // is restored — so the text the user kept typing follows the chat instead + // of vanishing. Keyed on the scope alone: the runtime id can land a resume + // later than the route flips the scope, so it is not a usable signal here. + if ((isFreshDraftScope(draftScopeRef.current) || !draftScopeRef.current) && activeQueueSessionKey) { adoptNewSessionDraft(activeQueueSessionKey) } else if (!activeQueueSessionKey) { // The reverse handoff: a session the user was typing into turned out 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 8d422274a9..0e5ebd44a4 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 @@ -5264,7 +5264,6 @@ describe('createBackendSessionForSend workspace target', () => { expect(params).toMatchObject({ cwd: '/clicked-workspace' }) }) -D it('does not inherit a stale cwd when Home is the active project scope', async () => { const params = await createWith( () => { @@ -6116,6 +6115,7 @@ describe('routed fresh chat keeps its exact owner across turns', () => { expect(vi.mocked(requestGatewayForAgent).mock.calls.filter(call => call[2] === 'session.close')).toEqual([]) expect(ambientRequest).not.toHaveBeenCalledWith('session.close', expect.anything()) expect(getSessionOwnerHint(STORED)).toEqual(route) + }) it('can preserve the current fresh draft key when explicitly requested', async () => { let handle: HarnessHandle | null = null diff --git a/apps/desktop/src/sdk/composer.ts b/apps/desktop/src/sdk/composer.ts index 4ef571e095..565db36838 100644 --- a/apps/desktop/src/sdk/composer.ts +++ b/apps/desktop/src/sdk/composer.ts @@ -7,7 +7,7 @@ import { requestComposerSetDraft, requestComposerSubmit } from '@/app/chat/composer/focus' -import { NEW_SESSION_DRAFT_KEY, takeSessionDraft } from '@/store/composer' +import { freshDraftScope, takeSessionDraft } from '@/store/composer' import { $activeSessionId, $selectedStoredSessionId } from '@/store/session' import { $sessionStates } from '@/store/session-states' @@ -37,7 +37,7 @@ const resolveComposerAddress = ( if (id === 'new') { const primaryIsNewDraft = !$activeSessionId.get() && !$selectedStoredSessionId.get() - return { ids: [NEW_SESSION_DRAFT_KEY], stored: NEW_SESSION_DRAFT_KEY, target: primaryIsNewDraft ? 'main' : null } + return { ids: [freshDraftScope()], stored: freshDraftScope(), target: primaryIsNewDraft ? 'main' : null } } const stored = $sessionStates.get()[id]?.storedSessionId ?? id diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts index 0e9907bcaf..628b54450d 100644 --- a/apps/desktop/src/store/composer.ts +++ b/apps/desktop/src/store/composer.ts @@ -2,6 +2,7 @@ import { atom } from 'nanostores' import { deriveDraftTitle } from '@/lib/draft-title' import { triggerHaptic } from '@/lib/haptics' +import { persistString, storedString } from '@/lib/storage' import { recordDislike, recordFriction } from './desktop-metrics' @@ -245,7 +246,38 @@ export interface SessionDraft { text: string } -const draftKey = (scope: string | null | undefined) => scope?.trim() || NEW_SESSION_DRAFT_KEY +// Stable only for the lifetime of the current sessionless chat (#66662). The +// legacy behavior mapped every unsaved chat onto the single NEW_SESSION_DRAFT_KEY +// bucket, so a second New Chat inherited the first one's unsent text. Persisting +// the key (rather than just its text) lets a reload restore that exact fresh +// draft; starting another new chat rotates the key so abandoned unsent drafts +// cannot bleed into the next lifecycle. +const FRESH_DRAFT_STORAGE_KEY = 'hermes.desktop.freshDraftKey' + +const createFreshDraftKey = (): string => + `__new__:${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}` + +export const $freshDraftKey = atom(storedString(FRESH_DRAFT_STORAGE_KEY) ?? NEW_SESSION_DRAFT_KEY) + +export const freshDraftScope = (): string => $freshDraftKey.get() + +export const rotateFreshDraftKey = (): string => { + const key = createFreshDraftKey() + $freshDraftKey.set(key) + persistString(FRESH_DRAFT_STORAGE_KEY, key) + + return key +} + +// A draft key belongs to a fresh-chat lifecycle when it is the legacy shared +// bucket or one of its per-instance successors (`__new__:`, #66662). +export const isFreshDraftScope = (key: string | null | undefined): boolean => + typeof key === 'string' && (key === NEW_SESSION_DRAFT_KEY || (key.startsWith(NEW_SESSION_DRAFT_KEY) && key.length > NEW_SESSION_DRAFT_KEY.length)) + +// A null/empty scope IS the current fresh-chat lifecycle — resolve it to that +// lifecycle's own key so every stash/read/migrate consumer below addresses the +// active fresh bucket instead of the shared legacy one. +const draftKey = (scope: string | null | undefined) => scope?.trim() || freshDraftScope() /** Inline "Restored your unsent message" notice for the fresh draft (see * `adoptGoneSessionDraft`). `null` = nothing to show. */ @@ -460,7 +492,7 @@ export function stashSessionDraft(scope: string | null | undefined, text: string if (text.trim() || attachments.length > 0) { draftsBySession.set(key, cloneDraft({ attachments, text })) - } else if (key === NEW_SESSION_DRAFT_KEY) { + } else if (isFreshDraftScope(key)) { // The fresh draft was sent or emptied — a restore notice has nothing left // to undo. $restoredDraftNotice.set(null) @@ -586,7 +618,7 @@ export function adoptGoneSessionDraft(): boolean { return false } - const dest = draftsBySession.get(NEW_SESSION_DRAFT_KEY) + const dest = draftsBySession.get(freshDraftScope()) if (dest && (dest.text.trim() || dest.attachments.length > 0)) { return false @@ -620,7 +652,7 @@ export function undoRestoredDraft(liveText: string): boolean { return false } - const current = draftsBySession.get(NEW_SESSION_DRAFT_KEY) + const current = draftsBySession.get(freshDraftScope()) stashSessionDraft(notice.fromKey, notice.text, current?.attachments ?? []) clearSessionDraft(null) recordDislike('undo', 'restored_draft') diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index d4b65e6319..6f848f5fc5 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -325,16 +325,6 @@ export function setRememberedRoute(path: null | string, profile: string): void { persistString(profileNavigationKey(LAST_ROUTE_KEY, profile), path) } -// Stable only for the lifetime of the current sessionless chat. Persisting the -// key (rather than just its text) lets a reload restore that exact fresh draft; -// starting another new chat rotates the key so abandoned unsent drafts cannot -// bleed into the next lifecycle. -const FRESH_DRAFT_KEY = 'hermes.desktop.freshDraftKey' -const LEGACY_FRESH_DRAFT_SCOPE = '__new__' - -const createFreshDraftKey = (): string => - `__new__:${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}` - let configuredDefaultProjectDir = '' function workspaceCwdKey(connection: HermesConnection | null = $connection.get()): string { @@ -1150,7 +1140,6 @@ export const $messagesEmpty = computed($messages, messages => messages.length == export const $lastVisibleMessageIsUser = computed($messages, lastVisibleMessageIsUser) export const $freshDraftReady = atom(false) -export const $freshDraftKey = atom(storedString(FRESH_DRAFT_KEY) ?? LEGACY_FRESH_DRAFT_SCOPE) export const $busy = atom(false) export const $awaitingResponse = atom(false) // Stored-session id whose most recent resume FAILED terminally (the gateway RPC @@ -1595,13 +1584,10 @@ export const markSessionRead = (storedSessionId: string | null | undefined) => { export const setMessages = (next: Updater) => updateAtom($messages, next) export const setFreshDraftReady = (next: Updater) => updateAtom($freshDraftReady, next) -export const rotateFreshDraftKey = (): string => { - const key = createFreshDraftKey() - $freshDraftKey.set(key) - persistString(FRESH_DRAFT_KEY, key) - - return key -} +// The fresh-draft identity lives in store/composer.ts with the draft stash it +// keys; re-exported here because session.ts is where new-chat lifecycles rotate +// it (startFreshSessionDraft) and where most call sites already import from. +export { $freshDraftKey, rotateFreshDraftKey } from './composer' export const setResumeFailedSessionId = (next: Updater) => updateAtom($resumeFailedSessionId, next)