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 a4b48064b1: 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.
This commit is contained in:
Brooklyn Nicholson
2026-09-29 15:33:36 -05:00
committed by brooklyn!
parent 0e6600f3f8
commit 88bbbbf6f2
6 changed files with 139 additions and 36 deletions

View File

@@ -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(<ProbeHarness activeQueueSessionKey={secondKey} onLayoutSnapshot={() => 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(
<ProbeHarness activeQueueSessionKey={key} onLayoutSnapshot={() => 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(
<ProbeHarness
activeQueueSessionKey="session-created"
onLayoutSnapshot={() => 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(
<ProbeHarness activeQueueSessionKey={key} onLayoutSnapshot={() => 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='

View File

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

View File

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

View File

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

View File

@@ -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<string>(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__:<uuid>`, #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')

View File

@@ -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<ChatMessage[]>) => updateAtom($messages, next)
export const setFreshDraftReady = (next: Updater<boolean>) => 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<string | null>) => updateAtom($resumeFailedSessionId, next)