feat(sessions): support many pending sessions persisted in IndexedDB

Allow several unsent AI sessions to be set up in parallel. Split the
transient flag into "in-memory, not yet persisted" (unsent is derived
from workspace_id), persist a pending session to IndexedDB on first
touch with its own draftPrompt, show pending sessions in the sidebar
under the family filter, and reconcile them by pending_workspace_id.
The + button reuses the untouched draft in the active family so idle
clicks don't pile blank entries; touching one spawns a fresh blank.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-07-14 09:11:01 +02:00
co-authored by Claude Opus 4.8
parent 22b47c8823
commit 6f6651d33a
7 changed files with 246 additions and 211 deletions
@@ -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)
@@ -33,8 +33,8 @@
getEffectiveWorkspaceId,
moveSessionToNewFork,
moveSessionToWorkspace,
peekTransientDraftPrompt,
queueTransientDraftPrompt,
getSessionDraftPrompt,
setSessionDraftPrompt,
reconcileAfterWorkspaceChange,
renameSession,
selectSession,
@@ -69,9 +69,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.
@@ -415,7 +415,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.'
@@ -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)
@@ -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,56 @@ 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<typeof setTimeout> | 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.
// Only tracked while the session is unsent (no committed workspace); the first
// keystroke is a genuine touch, so persistTouched promotes an in-memory draft.
let draftPromptFlushHandle: ReturnType<typeof setTimeout> | undefined
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(draftPromptFlushHandle)
draftPromptFlushHandle = setTimeout(() => 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<void> {
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 +387,8 @@ async function hydrateSessions({ dropTransients = false } = {}): Promise<void> {
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 +448,13 @@ export async function reconcileSessionsLifecycle(): Promise<void> {
if (!db) return
const wsIds = new Set<string>()
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<string, 'active' | 'archived' | 'deleted'>
@@ -496,8 +470,9 @@ export async function reconcileSessionsLifecycle(): Promise<void> {
const deletedIds = new Set<string>()
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
@@ -637,22 +612,19 @@ export function findSessionByName(name: string): Session | undefined {
}
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
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 +663,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 +686,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 +696,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 +711,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 +746,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 +760,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 +776,32 @@ 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. Fire-and-forget write-behind; opening/
// moving a tab is a touch, so it persists an untouched pending session.
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).
// write-behind; collapsing is a touch, so it persists an untouched pending session.
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).
// write-behind; dragging is a touch, so it persists an untouched pending session.
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 +814,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 +920,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
@@ -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()
@@ -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,46 @@ 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('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 +237,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 +446,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)
@@ -75,10 +75,9 @@ export async function openEditorInSession(
target: SessionTarget,
workspaceId?: string
): Promise<void> {
// 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)