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:
committed by
brooklyn!
parent
0e6600f3f8
commit
88bbbbf6f2
@@ -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='
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user