mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-20 00:02:28 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c97920ae2 | ||
|
|
491b01c9ca | ||
|
|
e78cf5289f | ||
|
|
6f6651d33a |
@@ -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,17 @@ 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()
|
||||
// Cancel any pending draft-prompt flush: left running, its persistTouched
|
||||
// would write the record back to IndexedDB after we delete it, resurrecting
|
||||
// a draft deleted inside the debounce window.
|
||||
clearTimeout(draftPromptFlushHandles.get(id))
|
||||
draftPromptFlushHandles.delete(id)
|
||||
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,86 @@ 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()
|
||||
expect(sessionState.sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('deleting a draft inside the debounce window does not resurrect it', async () => {
|
||||
const user = freshUser()
|
||||
userStore.set(user)
|
||||
await flush()
|
||||
|
||||
const s = session({ id: 't4b', transient: true, pending_workspace_id: 'wsA' })
|
||||
sessionState.sessions = [s]
|
||||
// Schedule a flush, then delete before the 400ms timer fires. The cancelled
|
||||
// timer must not write the record back to IndexedDB.
|
||||
setSessionDraftPrompt('t4b', 'typed then deleted')
|
||||
deleteSession('t4b')
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
|
||||
await rehydrate(user)
|
||||
await flush()
|
||||
@@ -248,8 +277,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 +486,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