mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-22 00:01:34 +00:00
feat(sessions): support many pending sessions persisted in IndexedDB (#10076)
* 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> * feat(sessions): focus composer when + reuses the untouched draft When there are no pending changes, `+` reuses the active family's untouched draft instead of creating a new session (unchanged). But when the reused draft is the one already on screen, currentSessionId doesn't change, so nothing navigated and the click gave no feedback. Bump a composerFocusRequest nonce in the reuse branch and have SessionWrapper's focus effect depend on it, so the composer re-focuses and the user can type right away. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sessions): per-session debounce for draft prompt flush A single module-level flush timer let a keystroke in one pending draft cancel a sibling draft's pending first-touch flush, so the earlier draft was never written and its typed prompt vanished on reload. Key the debounce per session so parallel drafts persist independently. Also collapse the touch rationale repeated across the preview-tab/collapse/size setters onto persistTouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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,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<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.
|
||||
// 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<string, ReturnType<typeof setTimeout>>()
|
||||
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<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 +393,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 +454,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 +476,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
|
||||
@@ -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
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user