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 <noreply@anthropic.com>

* fix: never drop a held session while another draft is created

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: forward untilStarted through openSourceInSession overrides

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-10-28 17:40:34 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 7765913064
commit f46b3a7505
4 changed files with 79 additions and 8 deletions
@@ -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
@@ -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
@@ -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(
@@ -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)