diff --git a/frontend/src/lib/components/sessions/SessionPicker.svelte b/frontend/src/lib/components/sessions/SessionPicker.svelte index e3d1c5d21e..fa68cdfd3b 100644 --- a/frontend/src/lib/components/sessions/SessionPicker.svelte +++ b/frontend/src/lib/components/sessions/SessionPicker.svelte @@ -145,7 +145,8 @@ // total, and keyboard navigation. const visibleSessions = $derived( sessionState.sessions.filter((s) => { - if (s.transient) return false + // Pending (unsent) sessions show like any other, so several drafts can be + // set up in parallel; they group by pending_workspace_id via sessionRootOf. // The open session always stays in the list, ignoring both filters. if (s.id === sessionState.currentSessionId) return true if (s.archived && !showArchived.val) return false @@ -309,10 +310,8 @@ async function createAndOpen() { const fresh = createSession() // A new session opened from a Windmill page adopts that page as its first - // preview tab (resetSessionPreviewTabs handles a reused transient whose - // tabs still show a previous destination). Skip when already on the - // sessions page (nothing meaningful to capture) so the preview starts - // empty until the chat opens something. + // preview tab. Skip when already on the sessions page (nothing meaningful to + // capture) so the preview starts empty until the chat opens something. if (!onSessionsPage) { const url = page.url.pathname + page.url.search resetSessionPreviewTabs(fresh.id, url) diff --git a/frontend/src/lib/components/sessions/SessionWrapper.svelte b/frontend/src/lib/components/sessions/SessionWrapper.svelte index f5fa09c6d0..4f94dc2888 100644 --- a/frontend/src/lib/components/sessions/SessionWrapper.svelte +++ b/frontend/src/lib/components/sessions/SessionWrapper.svelte @@ -28,13 +28,14 @@ import SessionWorkspaceBar from './SessionWorkspaceBar.svelte' import SessionChangesBar from './SessionChangesBar.svelte' import { + composerFocusRequest, createSession, deleteSessionsForWorkspace, getEffectiveWorkspaceId, moveSessionToNewFork, moveSessionToWorkspace, - peekTransientDraftPrompt, - queueTransientDraftPrompt, + getSessionDraftPrompt, + setSessionDraftPrompt, reconcileAfterWorkspaceChange, renameSession, selectSession, @@ -69,9 +70,9 @@ // Reactive session reference (mutations to summary/target propagate via the $state proxy) const session = $derived(sessionState.sessions.find((s) => s.id === sessionId)) - // Seed the composer with the unsent prompt a reload preserved in the - // transient draft slot (script-init: AIChatInput reads it once at mount). - const restoredDraftPrompt = peekTransientDraftPrompt(sessionId) + // Seed the composer with the unsent prompt a reload preserved on the session + // record (script-init: AIChatInput reads it once at mount). + const restoredDraftPrompt = getSessionDraftPrompt(sessionId) // The workspace the session acts on, shown in the header "Acting on" strip via the shared // WorkspaceScopeTrigger chip. `targetId` is also the workspace the chip's ellipsis menu targets. @@ -232,6 +233,11 @@ // loading. let aiChat: AIChat | undefined = $state(undefined) $effect(() => { + // Focus the composer when this session becomes active, or on an explicit + // focus request — the latter covers `+` reusing the untouched draft you're + // already viewing, where currentSessionId doesn't change so activation alone + // wouldn't re-run this. + void composerFocusRequest.nonce if (sessionState.currentSessionId !== sessionId) return if (!aiChat) return if (!$copilotInfo.enabled) return @@ -415,7 +421,7 @@ hideModeSelector wideLayout initialInstructions={restoredDraftPrompt} - onDraftChange={(text) => queueTransientDraftPrompt(sessionId, text)} + onDraftChange={(text) => setSessionDraftPrompt(sessionId, text)} forceDisabled={isUnavailable || !!session.archived} forceDisabledMessage={isUnavailable ? 'This session is linked to a workspace that no longer exists. Move it or discard it from the banner above to keep working.' diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 18d7366134..9f2b750989 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -403,8 +403,7 @@ function createRuntime(session: Session): SessionRuntime { // Hydrate the preview-tab owner from the session record (the durable backing); // from here on the owner is the single live copy and writes back through the // adapter. setSessionTabs / setSessionPreviewCollapsed stay the low-level record - // writers (a transient session's writes land in the localStorage draft slot - // until it materialises). + // writers (opening/moving a tab is a touch that persists an in-memory draft). const previewTabs = new SessionPreviewTabs(hydratePreviewTabs(session), { persist: (snap) => { setSessionTabs(session.id, snap.tabs, snap.activeId) diff --git a/frontend/src/lib/components/sessions/sessionState.svelte.ts b/frontend/src/lib/components/sessions/sessionState.svelte.ts index 79af616918..8a88b10afa 100644 --- a/frontend/src/lib/components/sessions/sessionState.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionState.svelte.ts @@ -35,7 +35,7 @@ export function syncWorkspaceTo(workspaceId: string | undefined): void { import { WorkspaceService } from '$lib/gen' import { sendUserToast } from '$lib/toast' import type HistoryManager from '$lib/components/copilot/chat/HistoryManager.svelte' -import { onUserChange, scopedKey } from '$lib/userScopedStorage' +import { onUserChange } from '$lib/userScopedStorage' // A destination the session preview can open as an editor: a workspace item // (`path`) for flow/script/raw_app, or — for 'pipeline' — a folder name (not an @@ -99,10 +99,13 @@ export type Session = { // archived (not by the user). Lets reconciliation auto-unarchive the session // when the workspace is unarchived, while leaving user-archived sessions be. archivedByWorkspace?: boolean - // In-memory-only flag: the session exists but isn't written to - // IndexedDB until the user sends their first message. Avoids - // piling abandoned drafts across `+` clicks — createSession reuses - // the existing transient if one is already open. + // In-memory-only flag: the session exists but hasn't been written to + // IndexedDB yet. Set at creation, cleared on the first genuine user touch + // (typed prompt, workspace/fork pick, preview tab, rename) which persists + // the record. Decoupled from "unsent" — a pending session is unsent while + // `workspace_id` is undefined, whether or not it has been persisted. An + // untouched draft never persists, so idle `+` clicks vanish on reload + // instead of littering the sidebar. transient?: boolean // Per-session unread watermark: the displayMessages count the last time // the user was on this session's page. Compared against the runtime's @@ -120,6 +123,10 @@ export type Session = { // Preview split size (preview pane %, 0-100) the user dragged for this session. // Per-session so each session restores its own layout. previewSize?: number + // Unsent composer text for a pending (uncommitted) session, persisted with + // the record so each parallel draft restores its own typed-but-unsent prompt. + // Only tracked while unsent; cleared once the workspace commits at first send. + draftPrompt?: string } // One preview tab: `url` is the URL we command the iframe to load, `loc` the @@ -136,11 +143,6 @@ export type SessionPreviewTab = { id: string; url: string; loc: string; friendly const SESSIONS_DB = 'windmill-sessions' const LEGACY_SESSIONS_KEY = 'windmill_sessions' const LEGACY_LAST_SEEN_KEY = 'windmill_sessions_last_seen_counts' -// The single unsent (transient) draft, kept in localStorage (user-scoped) so a -// reload doesn't lose what the user set up before their first message: name, -// workspace/fork choice, editor target, preview tabs and the typed-but-unsent -// prompt. -const TRANSIENT_DRAFT_KEY = 'wm_session_transient_draft' interface SessionSchema extends DBSchema { sessions: { key: string; value: Session } @@ -292,79 +294,62 @@ export const sessionState = $state<{ hydrated: false }) -type TransientDraft = Session & { - prompt?: string -} - -// The unsent prompt for the current transient session, held here so every -// draft write (which snapshots only the Session record) can carry it along. -let transientPrompt: { sessionId: string; text: string } | undefined - -function writeTransientDraft(s: Session): void { - const key = scopedKey(TRANSIENT_DRAFT_KEY) - if (!key) return - const draft: TransientDraft = { - ...($state.snapshot(s) as Session), - prompt: transientPrompt?.sessionId === s.id ? transientPrompt.text : undefined - } - storeLocalSetting(key, JSON.stringify(draft)) -} - -function readTransientDraft(): TransientDraft | undefined { - const key = scopedKey(TRANSIENT_DRAFT_KEY) - if (!key) return undefined - const raw = getLocalSetting(key) - if (!raw) return undefined - try { - const d = JSON.parse(raw) - if (!d || typeof d.id !== 'string' || typeof d.name !== 'string') return undefined - return d as TransientDraft - } catch { - return undefined - } -} - -function clearTransientDraft(): void { - const key = scopedKey(TRANSIENT_DRAFT_KEY) - if (key) storeLocalSetting(key, undefined) - transientPrompt = undefined -} - -// Debounced write-behind of the chat input for a transient session, so the -// typed-but-unsent prompt survives a reload with the rest of the draft. -let transientPromptFlushHandle: ReturnType | undefined -export function queueTransientDraftPrompt(sessionId: string, text: string): void { +// Debounced write-behind of the composer text for a pending (uncommitted) +// session, so a typed-but-unsent prompt survives a reload as part of the record. +// Keyed per session: a single shared timer would let a keystroke in one draft +// cancel a sibling draft's pending flush, dropping that draft's first-touch write. +const draftPromptFlushHandles = new Map>() +export function setSessionDraftPrompt(sessionId: string, text: string): void { const s = sessionState.sessions.find((x) => x.id === sessionId) - if (!s?.transient) return - transientPrompt = { sessionId, text } - clearTimeout(transientPromptFlushHandle) - transientPromptFlushHandle = setTimeout(() => writeTransientDraft(s), 400) + if (!s || s.workspace_id) return + // No-op on an unchanged prompt. Crucially, this treats the composer's + // mount-time onDraftChange('') as a non-touch (draftPrompt is undefined), + // so merely opening an untouched draft never persists it. + if ((s.draftPrompt ?? '') === text) return + s.draftPrompt = text + clearTimeout(draftPromptFlushHandles.get(sessionId)) + draftPromptFlushHandles.set( + sessionId, + setTimeout(() => { + draftPromptFlushHandles.delete(sessionId) + persistTouched(s) + }, 400) + ) } -// Read back the restored draft prompt when the session's runtime (and its chat -// manager) is created. Peek, not take: later draft writes keep carrying it. -export function peekTransientDraftPrompt(sessionId: string): string | undefined { - return transientPrompt?.sessionId === sessionId ? transientPrompt.text : undefined +// Read back the persisted composer text when a pending session's chat mounts. +// Returns nothing once the session is committed (its draft prompt was consumed). +export function getSessionDraftPrompt(sessionId: string): string | undefined { + const s = sessionState.sessions.find((x) => x.id === sessionId) + if (!s || s.workspace_id) return undefined + return s.draftPrompt } -// Write-behind a single session record. Transient (unsent) sessions are not -// written to IndexedDB — they live in memory plus a single localStorage draft -// slot until materializeTransient() promotes them at first send. -// Awaits DB-open so a write racing hydration still lands; no-ops (degrades to -// in-memory) when the DB can't be opened. In-memory $state is the read surface, -// so callers fire-and-forget. +// Persist a session on a genuine user edit, promoting an in-memory-only +// (transient) pending session to a durable IndexedDB record on first touch. +// Non-touch writers (runtime chatId seeding, unread watermark) call putSession +// directly, so an untouched draft stays in memory and vanishes on reload. +function persistTouched(s: Session): void { + if (s.transient) delete s.transient + void putSession(s) +} + +// Write-behind a single session record. Transient sessions are in-memory only +// (not yet touched) and are not written to IndexedDB; materializeTransient() / +// persistTouched() clear the flag first. Awaits DB-open so a write racing +// hydration still lands; no-ops (degrades to in-memory) when the DB can't be +// opened. In-memory $state is the read surface, so callers fire-and-forget. export async function putSession(s: Session): Promise { if (!BROWSER) return - if (s.transient) { - writeTransientDraft(s) - return - } - // Never resurrect a session whose committed workspace is gone. A live runtime - // can still write through here after reconciliation deletes its record (chatId - // seed, unread watermark), so guard once the workspace list is loaded. - if (s.workspace_id) { + if (s.transient) return + // Never resurrect a session whose workspace is gone — committed (workspace_id) + // or pre-send (pending_workspace_id). A live runtime can still write through + // here after reconciliation deletes its record (chatId seed, unread watermark), + // so guard once the workspace list is loaded. + const boundWs = s.workspace_id ?? s.pending_workspace_id + if (boundWs) { const all = get(userWorkspaces) - if (all.length > 0 && !all.some((w) => w.id === s.workspace_id)) return + if (all.length > 0 && !all.some((w) => w.id === boundWs)) return } ensureSessionRootId(s) const db = await sessionsDb.whenReady() @@ -408,19 +393,8 @@ async function hydrateSessions({ dropTransients = false } = {}): Promise { const changed = all.filter((s) => ensureSessionRootId(s)) for (const s of changed) await db.put('sessions', s) all.sort((a, b) => b.createdAt - a.createdAt) - // Restore the (user-scoped) unsent draft, unless it already materialised - // (present in the DB — e.g. sent from another browser tab) or the same - // draft is still live in memory. - const draft = readTransientDraft() - if (draft) { - if (all.some((s) => s.id === draft.id)) { - clearTransientDraft() - } else if (!transients.some((s) => s.id === draft.id)) { - const { prompt, ...rec } = draft - transients.push({ ...rec, transient: true }) - if (prompt) transientPrompt = { sessionId: rec.id, text: prompt } - } - } + // In-memory (untouched) drafts are prepended, newest-first as createSession + // maintains; persisted sessions follow, sorted by createdAt. sessionState.sessions = [...transients, ...all] } catch (e) { console.error('Failed to load sessions from IndexedDB', e) @@ -480,7 +454,13 @@ export async function reconcileSessionsLifecycle(): Promise { if (!db) return const wsIds = new Set() const sessions = await db.getAll('sessions') - for (const s of sessions) if (s.workspace_id) wsIds.add(s.workspace_id) + // Committed sessions reconcile on workspace_id; persisted pending drafts on + // their pre-send pending_workspace_id, so a workspace deleted/archived under + // an unsent draft applies the same never-orphaned rule to the draft. + for (const s of sessions) { + const ws = s.workspace_id ?? s.pending_workspace_id + if (ws) wsIds.add(ws) + } if (wsIds.size === 0) return let status: Record @@ -496,8 +476,9 @@ export async function reconcileSessionsLifecycle(): Promise { const deletedIds = new Set() try { for (const s of sessions) { - if (!s.workspace_id) continue - const { action, patch } = decideSessionLifecycle(s, status[s.workspace_id]) + const ws = s.workspace_id ?? s.pending_workspace_id + if (!ws) continue + const { action, patch } = decideSessionLifecycle(s, status[ws]) if (action === 'delete') { await db.delete('sessions', s.id) // GC linked files too, matching deleteSession — a record-only delete @@ -636,23 +617,33 @@ export function findSessionByName(name: string): Session | undefined { return sessionState.sessions.find((s) => s.name === name) } +// Bumped to ask the active session's composer to re-focus even when +// `currentSessionId` doesn't change — the `+` reuse path lands you back on the +// untouched draft you're already viewing, so nothing navigates, but the click +// should still drop the cursor in the composer. SessionWrapper's focus effect +// depends on `nonce`. +export const composerFocusRequest = $state<{ nonce: number }>({ nonce: 0 }) +export function requestComposerFocus(): void { + composerFocusRequest.nonce++ +} + export function createSession(): Session { - // Reuse the existing transient session (if any) so the user can hit - // the "+" button repeatedly without piling drafts. The transient - // becomes a real session at first-message-send time. Only a transient - // from the active workspace family qualifies — reusing one left over - // from another family would hand the user a session still acting on - // that family. A cross-family leftover is dropped instead (it was - // never sent, so only the draft slot holds it). - const existingTransient = sessionState.sessions.find((s) => s.transient) - if (existingTransient) { - if (sessionInCurrentFamily(existingTransient)) { - sessionState.currentSessionId = existingTransient.id - return existingTransient - } - sessionState.sessions = sessionState.sessions.filter((s) => s.id !== existingTransient.id) - clearTransientDraft() + // Reuse an existing untouched draft from the active family rather than pile a + // blank entry on every `+`. "Untouched" is exactly `transient`: a pending + // session leaves the in-memory-only state the moment the user touches it + // (types a prompt, picks a workspace, opens the panel, renames), at which + // point it persists and is its own session — so several pending sessions can + // still be built up in parallel, one touch at a time. A cross-family leftover + // draft is dropped instead of reused (reusing it would act on that family). + const reusable = sessionState.sessions.find((s) => s.transient && sessionInCurrentFamily(s)) + if (reusable) { + sessionState.currentSessionId = reusable.id + // Reusing an already-active draft doesn't change currentSessionId, so ask + // the composer to focus explicitly — the caller still navigates/redirects. + requestComposerFocus() + return reusable } + sessionState.sessions = sessionState.sessions.filter((s) => !s.transient) const existingNumbers = sessionState.sessions .map((s) => /^session-(\d+)$/.exec(s.name)?.[1]) .map((n) => (n ? parseInt(n, 10) : 0)) @@ -691,23 +682,20 @@ export function createSession(): Session { } sessionState.sessions = [session, ...sessionState.sessions] sessionState.currentSessionId = session.id - // Transient until first send: no DB record yet, but the draft slot keeps it - // (name, workspace/fork choice, prompt) across reloads. - writeTransientDraft(session) + // Transient until first touch: no DB record yet. Persisting is deferred to the + // first user edit (the mutation helpers below route through persistTouched). return session } -// Promote an in-memory transient session to a persisted one. No-op when -// the session isn't transient. Called by the chat manager's beforeSend -// hook so the session is only written to localStorage once the user -// commits to it by sending their first message. +// Promote an in-memory transient session to a persisted IndexedDB record. +// No-op when the session isn't transient (already persisted by a prior touch). +// Called on the first genuine user touch (via persistTouched) and, idempotently, +// from the chat manager's beforeSend so a send always hits a persisted record. export function materializeTransient(id: string): void { const s = sessionState.sessions.find((x) => x.id === id) if (!s || !s.transient) return delete s.transient void putSession(s) - // Promoted to IndexedDB — the localStorage draft slot is now stale. - clearTransientDraft() } export function setSessionPendingWorkspace(id: string, workspace_id: string) { @@ -717,7 +705,7 @@ export function setSessionPendingWorkspace(id: string, workspace_id: string) { s.pending_workspace_id = workspace_id // Picking an existing workspace cancels any pending fork intent. s.pending_fork = undefined - if (changed) void putSession(s) + if (changed) persistTouched(s) } // Records the user's intent to create a new fork without firing the API @@ -727,7 +715,7 @@ export function setSessionPendingFork(id: string, fork: PendingFork) { if (!s) return s.pending_fork = { ...fork } s.pending_workspace_id = fork.parent_workspace_id - void putSession(s) + persistTouched(s) } // One-shot commit: locks in workspace_id at first user-message send. @@ -742,6 +730,9 @@ export async function commitSessionWorkspace( const s = sessionState.sessions.find((x) => x.id === id) if (!s) return undefined if (s.workspace_id) return s.workspace_id + // A commit is a send: the record must be durable regardless of prior touches + // (a draft sent without ever being touched is still transient here). + if (s.transient) delete s.transient if (s.pending_fork) { const fork = s.pending_fork @@ -774,6 +765,8 @@ export async function commitSessionWorkspace( s.pending_fork = undefined s.pending_workspace_id = undefined s.workspace_root_id = workspaceRootId(newId, get(userWorkspaces)) ?? newId + // The draft prompt has been consumed as the first message. + delete s.draftPrompt await putSession(s) // The global workspaceStore is intentionally left untouched: the session // chat targets its own workspace via AIChatManager.operatingWorkspace, so @@ -786,6 +779,8 @@ export async function commitSessionWorkspace( s.workspace_id = ws s.pending_workspace_id = undefined s.workspace_root_id = workspaceRootId(ws, get(userWorkspaces)) ?? ws + // The draft prompt has been consumed as the first message. + delete s.draftPrompt await putSession(s) // The global workspaceStore is intentionally left untouched (see the fork // branch above): the session chat reads its committed workspace through the @@ -800,32 +795,29 @@ export function getEffectiveWorkspaceId(session: Session): string | undefined { return session.workspace_id ?? session.pending_workspace_id } -// Persist the session's preview tabs. Fire-and-forget write-behind (transient -// sessions land in the localStorage draft slot). +// Persist the session's preview tabs (a touch — see persistTouched). export function setSessionTabs(id: string, tabs: SessionPreviewTab[], activeTabId: string): void { const s = sessionState.sessions.find((x) => x.id === id) if (!s) return s.previewTabs = tabs.map((t) => ({ ...t })) s.activePreviewTabId = activeTabId - void putSession(s) + persistTouched(s) } -// Persist whether the preview panel is collapsed for this session. Fire-and-forget -// write-behind (transient sessions land in the localStorage draft slot). +// Persist whether the preview panel is collapsed for this session (a touch). 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 - void putSession(s) + persistTouched(s) } -// Persist the preview split size the user dragged for this session. Fire-and-forget -// write-behind (transient sessions land in the localStorage draft slot). +// Persist the preview split size the user dragged for this session (a touch). 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 - void putSession(s) + persistTouched(s) } export function selectSession(id: string) { @@ -838,7 +830,7 @@ export function renameSession(id: string, newSummary: string) { if (!s) return s.summary = trimmed.length > 0 ? trimmed : undefined s.summarySource = 'manual' - void putSession(s) + persistTouched(s) } export function setGeneratedSessionSummary( @@ -944,13 +936,12 @@ export function setSessionArchived(id: string, archived: boolean) { delete s.archived delete s.archivedByWorkspace } - void putSession(s) + persistTouched(s) } export function deleteSession(id: string) { const s = sessionState.sessions.find((x) => x.id === id) if (!s) return - if (s.transient) clearTransientDraft() sessionState.sessions = sessionState.sessions.filter((x) => x.id !== id) if (sessionState.currentSessionId === id) { sessionState.currentSessionId = sessionState.sessions[0]?.id diff --git a/frontend/src/lib/components/sessions/sessionState.test.ts b/frontend/src/lib/components/sessions/sessionState.test.ts index 43694e9f3f..1a74f7c6ab 100644 --- a/frontend/src/lib/components/sessions/sessionState.test.ts +++ b/frontend/src/lib/components/sessions/sessionState.test.ts @@ -338,33 +338,34 @@ describe('sessionInCurrentFamily', () => { }) }) -describe('createSession — transient reuse is family-scoped', () => { - it('reuses a transient from the active family', () => { +describe('createSession — reuses an untouched draft, family-scoped', () => { + it('reuses an untouched (transient) draft from the active family', () => { const restore = withTwoFamilies('rootA') const prevCurrent = sessionState.currentSessionId - const transient = session({ - id: 'transient-same-family', + const untouched = session({ + id: 'untouched-same-family', name: 'session-901', pending_workspace_id: 'forkA', transient: true }) - sessionState.sessions.push(transient) + sessionState.sessions.push(untouched) try { const created = createSession() - expect(created.id).toBe('transient-same-family') - expect(sessionState.currentSessionId).toBe('transient-same-family') + // No new entry piled up: `+` switched back to the pristine draft. + expect(created.id).toBe('untouched-same-family') + expect(sessionState.currentSessionId).toBe('untouched-same-family') } finally { - sessionState.sessions = sessionState.sessions.filter((s) => s.id !== 'transient-same-family') + sessionState.sessions = sessionState.sessions.filter((s) => s.id !== 'untouched-same-family') sessionState.currentSessionId = prevCurrent restore() } }) - it('drops a transient left over from another family and starts in the active workspace', () => { + it('drops an untouched draft left over from another family and starts in the active workspace', () => { const restore = withTwoFamilies('rootB') const prevCurrent = sessionState.currentSessionId const stale = session({ - id: 'transient-other-family', + id: 'untouched-other-family', name: 'session-902', pending_workspace_id: 'forkA', transient: true @@ -374,12 +375,40 @@ describe('createSession — transient reuse is family-scoped', () => { try { const created = createSession() createdId = created.id - expect(created.id).not.toBe('transient-other-family') + expect(created.id).not.toBe('untouched-other-family') expect(created.pending_workspace_id).toBe('rootB') - expect(sessionState.sessions.some((s) => s.id === 'transient-other-family')).toBe(false) + expect(sessionState.sessions.some((s) => s.id === 'untouched-other-family')).toBe(false) } finally { sessionState.sessions = sessionState.sessions.filter( - (s) => s.id !== 'transient-other-family' && s.id !== createdId + (s) => s.id !== 'untouched-other-family' && s.id !== createdId + ) + sessionState.currentSessionId = prevCurrent + restore() + } + }) + + it('does not reuse a touched (persisted) pending session — those spawn a fresh draft', () => { + const restore = withTwoFamilies('rootA') + const prevCurrent = sessionState.currentSessionId + // Touched pending session: persisted (no transient flag), same family. + const touched = session({ + id: 'touched-same-family', + name: 'session-903', + pending_workspace_id: 'rootA', + draftPrompt: 'already typed' + }) + sessionState.sessions.push(touched) + let createdId: string | undefined + try { + const created = createSession() + createdId = created.id + expect(created.id).not.toBe('touched-same-family') + expect(created.transient).toBe(true) + // Both coexist: a touched draft stays put, the new blank is its own entry. + expect(sessionState.sessions.some((s) => s.id === 'touched-same-family')).toBe(true) + } finally { + sessionState.sessions = sessionState.sessions.filter( + (s) => s.id !== 'touched-same-family' && s.id !== createdId ) sessionState.currentSessionId = prevCurrent restore() diff --git a/frontend/src/lib/components/sessions/sessionStateIndexedDb.test.ts b/frontend/src/lib/components/sessions/sessionStateIndexedDb.test.ts index 2e2fd9e21f..1e96bb8010 100644 --- a/frontend/src/lib/components/sessions/sessionStateIndexedDb.test.ts +++ b/frontend/src/lib/components/sessions/sessionStateIndexedDb.test.ts @@ -38,8 +38,9 @@ import { archiveSessionsForWorkspace, deleteSessionsForWorkspace, materializeTransient, - peekTransientDraftPrompt, - queueTransientDraftPrompt, + getSessionDraftPrompt, + setSessionDraftPrompt, + setSessionTabs, reconcileSessionsLifecycle, setSessionArchived, setSessionPreviewSize, @@ -94,49 +95,43 @@ describe('sessionState IndexedDB persistence', () => { await vi.waitFor(() => expect(sessionState.sessions.map((s) => s.id)).toEqual(['s2', 's1'])) }) - it('keeps a transient session as a user-scoped localStorage draft, not in IndexedDB', async () => { + it('does not persist a transient (untouched) session — it is in-memory only', async () => { const user = freshUser() userStore.set(user) await flush() - await putSession(session({ id: 't1', transient: true })) - // Same user reload: the draft is restored, still transient (i.e. it came - // from the localStorage slot — an IndexedDB record would have the flag - // stripped by materialisation). - await rehydrate(user) - await flush() - expect(sessionState.sessions.map((s) => ({ id: s.id, transient: s.transient }))).toEqual([ - { id: 't1', transient: true } - ]) - - // The slot is user-scoped: another user sees nothing. - await rehydrate(freshUser()) - await flush() - expect(sessionState.sessions).toEqual([]) + const s = session({ id: 't1', transient: true, pending_workspace_id: 'wsA' }) + sessionState.sessions = [s] + // putSession no-ops for a transient session: nothing reaches IndexedDB. + await putSession(s) + const db = await openDB(`windmill-sessions::${user.email}`, 1) + const all = (await db.getAll('sessions' as never)) as Session[] + db.close() + expect(all).toEqual([]) }) - it('round-trips a transient session preview state through the draft slot', async () => { + it('persists a pending session to IndexedDB on first touch, keeping its pending workspace and tabs', async () => { const user = freshUser() userStore.set(user) await flush() - await putSession( - session({ - id: 't1b', - transient: true, - previewTabs: [{ id: 'session', url: '/x', loc: '/x' }], - activePreviewTabId: 'session', - previewCollapsed: false, - previewSize: 70 - }) - ) - await rehydrate(user) - await flush() - const restored = sessionState.sessions.find((s) => s.id === 't1b') - expect(restored?.previewTabs).toEqual([{ id: 'session', url: '/x', loc: '/x' }]) - expect(restored?.activePreviewTabId).toBe('session') - expect(restored?.previewCollapsed).toBe(false) - expect(restored?.previewSize).toBe(70) + const s = session({ id: 't1b', transient: true, pending_workspace_id: 'wsA' }) + sessionState.sessions = [s] + // A genuine touch (opening a preview tab) promotes the draft out of the + // in-memory-only state and writes it through. + setSessionTabs('t1b', [{ id: 'session', url: '/x', loc: '/x' }], 'session') + expect(s.transient).toBeUndefined() + + const db = await openDB(`windmill-sessions::${user.email}`, 1) + const rec = await vi.waitFor(async () => { + const r = (await db.get('sessions' as never, 't1b')) as Session | undefined + expect(r).toBeTruthy() + return r! + }) + db.close() + expect(rec.transient).toBeUndefined() + expect(rec.pending_workspace_id).toBe('wsA') + expect(rec.previewTabs).toEqual([{ id: 'session', url: '/x', loc: '/x' }]) }) it('setSessionPreviewSize persists a dragged width and round-trips it', async () => { @@ -156,52 +151,68 @@ describe('sessionState IndexedDB persistence', () => { expect(sessionState.sessions.find((x) => x.id === 'ps1')?.previewSize).toBe(42) }) - it('materializeTransient promotes the draft to IndexedDB and clears the slot', async () => { + it('materializeTransient promotes an in-memory draft to a persisted IndexedDB record', async () => { const user = freshUser() userStore.set(user) await flush() const s = session({ id: 't2', transient: true }) sessionState.sessions = [s] - await putSession(s) - expect(localStorage.getItem(`wm_session_transient_draft::${user.email}`)).not.toBeNull() - materializeTransient('t2') - await flush() - expect(localStorage.getItem(`wm_session_transient_draft::${user.email}`)).toBeNull() + expect(s.transient).toBeUndefined() await rehydrate(user) await vi.waitFor(() => expect(sessionState.sessions.map((x) => x.id)).toEqual(['t2'])) expect(sessionState.sessions[0].transient).toBeUndefined() }) - it('round-trips the unsent prompt through the draft slot', async () => { + it('round-trips the unsent draft prompt on the session record', async () => { const user = freshUser() userStore.set(user) await flush() - const s = session({ id: 't3', transient: true }) + const s = session({ id: 't3', transient: true, pending_workspace_id: 'wsA' }) sessionState.sessions = [s] - await putSession(s) - queueTransientDraftPrompt('t3', 'draft prompt') - // The prompt write-behind debounces 400ms. - await new Promise((r) => setTimeout(r, 450)) + // Typing is a touch: it sets draftPrompt and persists (debounced 400ms). + setSessionDraftPrompt('t3', 'draft prompt') + await new Promise((r) => setTimeout(r, 500)) await rehydrate(user) - await flush() - expect(sessionState.sessions.map((x) => x.id)).toEqual(['t3']) - expect(peekTransientDraftPrompt('t3')).toBe('draft prompt') + await vi.waitFor(() => expect(sessionState.sessions.map((x) => x.id)).toEqual(['t3'])) + expect(getSessionDraftPrompt('t3')).toBe('draft prompt') }) - it('deleteSession discards the transient draft', async () => { + it('persists parallel drafts independently — a keystroke in one never cancels another', async () => { + const user = freshUser() + userStore.set(user) + await flush() + + const a = session({ id: 'da', transient: true, pending_workspace_id: 'wsA' }) + const b = session({ id: 'db', transient: true, pending_workspace_id: 'wsA' }) + sessionState.sessions = [a, b] + // Interleave within the 400ms debounce window: b's keystroke must not clear + // a's pending flush (guards the per-session timer against a shared handle). + setSessionDraftPrompt('da', 'alpha') + setSessionDraftPrompt('db', 'beta') + await new Promise((r) => setTimeout(r, 500)) + + await rehydrate(user) + await vi.waitFor(() => + expect(sessionState.sessions.map((x) => x.id).sort()).toEqual(['da', 'db']) + ) + expect(getSessionDraftPrompt('da')).toBe('alpha') + expect(getSessionDraftPrompt('db')).toBe('beta') + }) + + it('deleteSession removes an in-memory transient draft', async () => { const user = freshUser() userStore.set(user) await flush() const s = session({ id: 't4', transient: true }) sessionState.sessions = [s] - await putSession(s) deleteSession('t4') + expect(sessionState.sessions).toEqual([]) await rehydrate(user) await flush() @@ -248,8 +259,7 @@ describe('sessionState IndexedDB persistence', () => { // A starts an unsent draft — transient, in-memory only, never persisted. sessionState.sessions = [session({ id: 'a-draft', transient: true }), ...sessionState.sessions] - // Switch to B: A's transient must not bleed into B's list (it would - // otherwise be reused by createSession and inherit A's pending state). + // Switch to B: A's in-memory draft must not bleed into B's list. userStore.set(b) await vi.waitFor(() => { expect(sessionState.sessions.some((s) => s.id === 'a-draft')).toBe(false) @@ -458,6 +468,52 @@ describe('sessionState IndexedDB persistence', () => { }) }) + it('deletes a persisted pending draft when its pending workspace is deleted', async () => { + const user = freshUser() + usersWorkspaceStore.set({ + email: user.email, + workspaces: [{ id: 'pending-ws', name: 'pending', disabled: false }] as never + }) + userStore.set(user) + await flush() + // A touched (persisted) but still-unsent draft scoped to its pre-send workspace. + await putSession(session({ id: 'draft', createdAt: 1, pending_workspace_id: 'pending-ws' })) + + // Reconcile keyed on pending_workspace_id: a deleted pre-send workspace deletes + // the draft. Read the DB directly — reconcile works off it, not in-memory state. + vi.mocked(WorkspaceService.getSessionWorkspaceStatus).mockResolvedValueOnce({ + 'pending-ws': 'deleted' + } as never) + await reconcileSessionsLifecycle() + + const db = await openDB(`windmill-sessions::${user.email}`, 1) + const rec = await db.get('sessions' as never, 'draft') + db.close() + expect(rec).toBeUndefined() + }) + + it('archives a persisted pending draft (tagged) when its pending workspace is archived', async () => { + const user = freshUser() + usersWorkspaceStore.set({ + email: user.email, + workspaces: [{ id: 'pending-ws2', name: 'pending', disabled: false }] as never + }) + userStore.set(user) + await flush() + await putSession(session({ id: 'draft2', createdAt: 1, pending_workspace_id: 'pending-ws2' })) + + vi.mocked(WorkspaceService.getSessionWorkspaceStatus).mockResolvedValueOnce({ + 'pending-ws2': 'archived' + } as never) + await reconcileSessionsLifecycle() + + const db = await openDB(`windmill-sessions::${user.email}`, 1) + const rec = (await db.get('sessions' as never, 'draft2')) as Session + db.close() + expect(rec.archived).toBe(true) + expect(rec.archivedByWorkspace).toBe(true) + }) + it('clears the in-memory list on logout', async () => { const user = freshUser() userStore.set(user) diff --git a/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts b/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts index 45d72ae3ae..38ca3bf6dd 100644 --- a/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts @@ -75,10 +75,9 @@ export async function openEditorInSession( target: SessionTarget, workspaceId?: string ): Promise { - // createSession() reuses an existing transient draft, whose preview tabs - // (persisted with the draft and/or held by a live runtime) may still show a - // different item — so seed the preview with a single tab on `target`, resetting - // whatever it was showing. + // Seed the fresh session's preview with a single tab on `target` so it opens + // straight onto the editor 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) const url = sessionTargetHref(target)