From f46b3a7505158a35794f7ca186aed0e944a909da Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 28 Oct 2026 17:40:34 +0100 Subject: [PATCH] fix: keep a new app's AI session unsaved until it is started (#12065) * fix: keep a new app's AI session unsaved until it is started Co-Authored-By: Claude Opus 5.5 * fix: never drop a held session while another draft is created Co-Authored-By: Claude Opus 5.5 * fix: forward untilStarted through openSourceInSession overrides Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../sessions/sessionState.svelte.ts | 29 ++++++++++++++--- .../components/sessions/sessionState.test.ts | 32 +++++++++++++++++++ .../sessions/sessionSwitch.svelte.ts | 19 +++++++++-- .../apps_raw/edit/[...path]/+page.svelte | 7 +++- 4 files changed, 79 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/components/sessions/sessionState.svelte.ts b/frontend/src/lib/components/sessions/sessionState.svelte.ts index 5e955924d5..4a8d10ea68 100644 --- a/frontend/src/lib/components/sessions/sessionState.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionState.svelte.ts @@ -129,6 +129,10 @@ export type Session = { // untouched draft never persists, so idle `+` clicks vanish on reload // instead of littering the sidebar. transient?: boolean + // In memory only, on a transient session opened onto an item that lives on its own (a new + // app's draft): its preview writes are not a touch, so a session the user never starts + // vanishes on reload instead of reopening onto the item. Cleared with `transient`. + untilStarted?: boolean // Epoch ms of the last write to this record — typing, renaming, archiving, // changing preview tabs, or catching up on new messages. Stamped by the two // write funnels (persistTouched, markSessionSeen), so it tracks use rather @@ -475,10 +479,17 @@ export function takeSessionAutoSend(sessionId: string): boolean { // stays in memory and vanishes on reload. function persistTouched(s: Session): void { if (s.transient) delete s.transient + delete s.untilStarted s.lastActivityAt = Date.now() void putSession(s) } +// Preview state (tabs, collapse, width) is a touch except on an `untilStarted` session. +function persistPreviewTouch(s: Session): void { + if (s.transient && s.untilStarted) return + persistTouched(s) +} + // When the session was last used. Pre-dates-the-field records report their // creation time, which is the earliest activity they could have had. export function sessionLastActivityAt(s: Session): number { @@ -1072,8 +1083,10 @@ export function requestComposerFocus(): void { // drop. `draftPrompt === undefined` (never edited), not falsiness: a draft typed // then erased to '' still has a pending flush and is a real session. Every other // touch clears `transient` synchronously, so only the draft prompt needs checking. +// An `untilStarted` session is not blank: reused it would reopen its item, and dropped +// while on screen it would leave the page showing a session that is gone. function isDiscardableDraft(s: Session): boolean { - return !!s.transient && s.draftPrompt === undefined + return !!s.transient && s.draftPrompt === undefined && !s.untilStarted } // Somewhere empty to put the user, for a URL naming a session this browser @@ -1084,6 +1097,7 @@ export function findEmptyLandingSession(): Session | undefined { return sessionState.sessions.find( (s) => !!s.transient && + !s.untilStarted && !s.archived && !s.workspace_id && // Falsiness, not `=== undefined`: we only navigate into the session, so a @@ -1194,6 +1208,7 @@ export function materializeTransient(id: string): void { const s = sessionState.sessions.find((x) => x.id === id) if (!s || !s.transient) return delete s.transient + delete s.untilStarted void putSession(s) } @@ -1214,6 +1229,12 @@ export function stopFollowingHome(id: string) { if (!s.transient) void putSession(s) } +// Keep a just-created session in memory until the user starts it (see `untilStarted`). +export function holdSessionUntilStarted(id: string) { + const s = sessionState.sessions.find((x) => x.id === id) + if (s?.transient) s.untilStarted = true +} + // Park a just-created session on a workspace. Deliberately does not persist a // still-transient draft: picking where it will live is part of creating it, not a // user touch, so an unused draft still vanishes on reload (see `transient`). @@ -1332,7 +1353,7 @@ export function setSessionTabs(id: string, tabs: SessionPreviewTab[], activeTabI if (!s) return s.previewTabs = tabs.map((t) => ({ ...t })) s.activePreviewTabId = activeTabId - persistTouched(s) + persistPreviewTouch(s) } // Persist whether the preview panel is collapsed for this session (a touch). @@ -1340,7 +1361,7 @@ export function setSessionPreviewCollapsed(id: string, collapsed: boolean): void const s = sessionState.sessions.find((x) => x.id === id) if (!s || !!s.previewCollapsed === collapsed) return s.previewCollapsed = collapsed - persistTouched(s) + persistPreviewTouch(s) } // Persist the preview split size the user dragged for this session (a touch). @@ -1348,7 +1369,7 @@ export function setSessionPreviewSize(id: string, size: number): void { const s = sessionState.sessions.find((x) => x.id === id) if (!s || s.previewSize === size) return s.previewSize = size - persistTouched(s) + persistPreviewTouch(s) } // Persist the session's Use or Build choice (a touch). Plan is left out: a reload ends the diff --git a/frontend/src/lib/components/sessions/sessionState.test.ts b/frontend/src/lib/components/sessions/sessionState.test.ts index 5dd3570c6b..d7ab1d7e62 100644 --- a/frontend/src/lib/components/sessions/sessionState.test.ts +++ b/frontend/src/lib/components/sessions/sessionState.test.ts @@ -7,6 +7,7 @@ import { decideSessionLifecycle, findEmptyLandingSession, getSessionDraftAttachments, + holdSessionUntilStarted, homeStarterSession, isForkSession, isTearingDownOpenSession, @@ -16,6 +17,7 @@ import { setGeneratedSessionSummary, setSessionDraftAttachments, setSessionDraftPrompt, + setSessionTabs, sessionState, withOpenSessionTeardown, type Session @@ -489,6 +491,36 @@ describe('createSession — reuses an untouched draft, family-scoped', () => { } }) + it('keeps a session held until started in memory, and does not reuse it', () => { + const restore = withTwoFamilies('rootA') + const prevCurrent = sessionState.currentSessionId + const held = session({ + id: 'held-on-app', + name: 'session-905', + pending_workspace_id: 'forkA', + transient: true + }) + sessionState.sessions.push(held) + let createdId: string | undefined + try { + holdSessionUntilStarted('held-on-app') + const tabs = [ + { id: 'session', url: '/apps_raw/edit/u/me/app', loc: '/apps_raw/edit/u/me/app' } + ] + setSessionTabs('held-on-app', tabs, 'session') + expect(held.transient).toBe(true) + createdId = createSession().id + expect(createdId).not.toBe('held-on-app') + expect(sessionState.sessions).toContain(held) + } finally { + sessionState.sessions = sessionState.sessions.filter( + (s) => s.id !== 'held-on-app' && s.id !== createdId + ) + sessionState.currentSessionId = prevCurrent + restore() + } + }) + it('clears the recovery notice off the draft it reuses, so `+` is not answered with "not found"', () => { const restore = withTwoFamilies('rootA') const prevCurrent = sessionState.currentSessionId diff --git a/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts b/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts index 2ef63bacfc..cb61cce413 100644 --- a/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts @@ -3,6 +3,7 @@ import { goto } from '$lib/navigation' import { workspaceStore } from '$lib/stores' import { createSession, + holdSessionUntilStarted, selectSession, sessionPageHref, sessionInCurrentFamily, @@ -10,6 +11,7 @@ import { sessionState, setSessionAutoSend, setSessionDraftPrompt, + setNewSessionWorkspace, setSessionPendingWorkspace, type Session, type SessionTarget @@ -23,7 +25,14 @@ import type { OpenInSessionSource } from './OpenInSessionButton.svelte' import type { AppDomSelectorElement } from '../copilot/chat/context' // `seedDomElement` rides only with `autoSend`: it is the chip of the prompt being sent. -type SeedOpts = { seedPrompt?: string; autoSend?: boolean; seedDomElement?: AppDomSelectorElement } +// `untilStarted` keeps the session out of storage until the user starts it, for an item that +// is kept on its own (a new app's draft) and so loses nothing when the session goes. +type SeedOpts = { + seedPrompt?: string + autoSend?: boolean + seedDomElement?: AppDomSelectorElement + untilStarted?: boolean +} // The session/navigation switch turns the global rail into either the workspace // navigation (navigation mode) or the sessions sidebar (session mode). Session @@ -222,7 +231,10 @@ async function sessionHrefFor( // straight onto what the caller wants (resetSessionPreviewTabs also writes // through a live runtime if one already exists for this id). const session = createSession() - if (workspaceId) setSessionPendingWorkspace(session.id, workspaceId) + if (opts?.untilStarted) { + holdSessionUntilStarted(session.id) + if (workspaceId) setNewSessionWorkspace(session.id, workspaceId) + } else if (workspaceId) setSessionPendingWorkspace(session.id, workspaceId) if (opts?.seedPrompt) { setSessionDraftPrompt(session.id, opts.seedPrompt) if (opts.autoSend) setSessionAutoSend(session.id, opts.seedDomElement) @@ -253,7 +265,8 @@ export async function openSourceInSession( const opts = { seedPrompt: overrides?.seedPrompt ?? source.seedPrompt, autoSend: overrides?.autoSend ?? source.autoSend, - seedDomElement: overrides?.seedDomElement + seedDomElement: overrides?.seedDomElement, + untilStarted: overrides?.untilStarted } if (source.target) { await openEditorInSession( diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index aa51d62ea8..9b0bb1bacd 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -576,7 +576,12 @@ if (saved.state === 'failed') { throw new Error(saved.failureMessage ?? 'the app could not be saved') } - await openEditorInSession({ kind: 'raw_app', path }, workspace, { new_draft: 'true' }) + await openEditorInSession( + { kind: 'raw_app', path }, + workspace, + { new_draft: 'true' }, + { untilStarted: true } + ) return } catch (e) { sendUserToast(`Could not open the AI session: ${e?.body ?? e?.message ?? e}`, true)