Files
windmill/frontend/src/lib/components/sessions/sessionState.svelte.ts
T
hugocasaandClaude Opus 5.5 8dd46a4d14 feat: build, test and run standalone ai agents from ai sessions (#11954)
* feat: guidance for building standalone ai agents in ai sessions

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

* fix: agent guidance for resource-only sessions, no flow tools

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

* fix: tell the user to deploy a drafted tool script with its agent

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

* fix: scope plain agent values, document tool_inputs, list agents in get_instructions

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

* feat: test and run ai agents from ai sessions, open agents in their editor

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

* fix: full-bleed agent editor tabs, back to the run form for agent tests

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

* fix: describe read-and-run as running agents too, pin agent run links

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

* feat: host the agent editor natively in session tabs

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

* fix: switch the agent editor to tabs below 900px

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

* fix: name an agent's session tab after its summary

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

* feat: show deployed agents in session tabs, open and run them from the chat

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

* feat: deploy agents from the chat, report agent tabs as the active editor

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

* feat: pick agents from the tab picker, name their tabs, scope the agent page to its workspace

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

* fix: leave open editors alone on agent writes, read the agent page's workspace user

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

* fix: refresh an agent's deployed page when the chat deploys or deletes it

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

* fix: name agent tabs before they load, mark agents in the changes drawer, stay on a deleted agent's tab

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

* fix: keep a cleared agent summary out of its tab name

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

* fix: keep the resource drawer's json option that the resources list still passes

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

* feat: open an agent in an AI session from its pages, fix review findings on agent tabs

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

* docs: name the reserved agent tool names in the agent guidance

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

* fix: reload an agent's editor on a discard, open a new agent in a session

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

* fix: show agents as agents on compare and deploy, name a never-deployed one by its path

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

* fix: keep a deployed agent's type in its draft diff

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

* fix: link a fork row to the agent editor only on the side that holds the agent

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

* fix: guard agent page paths, mark drafted agents in the picker, side-aware agent rows on fork compare

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:46:43 +01:00

1661 lines
70 KiB
TypeScript

import { BROWSER } from 'esm-env'
import { get } from 'svelte/store'
import { createLongHash } from '$lib/editorLangUtils'
import { random_adj } from '$lib/components/random_positive_adjetive'
import {
enterpriseLicense,
superadmin,
userWorkspaces,
usersWorkspaceStore,
workspaceStore,
type UserWorkspace
} from '$lib/stores'
import { switchWorkspace } from '$lib/storeUtils'
import { getLocalSetting, storeLocalSetting } from '$lib/utils'
import { logFeatureUsage } from '$lib/utils/featureUsage'
import { isReadOnlySessionWorkspace } from '../copilot/chat/global/sessionAccess'
import { AIWorkMode } from '../copilot/chat/workMode'
import { workspaceRootId } from './sessionScope.svelte'
import { clearSessionRecovered } from './sessionRecoveryNotice.svelte'
import { type DBSchema, type IDBPDatabase } from 'idb'
import { userScopedDb } from '$lib/userScopedDb'
import { emailOfScopedKey, scopedKeyFor } from '$lib/userScopedStorage'
import { deleteItemsForSession } from '../copilot/chat/files/attachedFilesDB'
import { deleteArtifactsForSession } from '../copilot/chat/artifacts/artifactsDB'
import { deleteSessionChats } from '../copilot/chat/HistoryManager.svelte'
import type { DraftAttachments } from '../copilot/chat/messageDraft.svelte'
import type { AppDomSelectorElement } from '../copilot/chat/context'
import {
markSessionDirty,
markSessionRemoved,
sessionSwept,
sessionsLockName
} from './sessionMirrorSignal'
// Switch the global workspace iff the target differs from the active one
// and is non-empty. Centralises the "session needs its workspace in focus"
// rule so picker, deep-link, and workspace-bar paths agree on the same
// semantic. No-op for `undefined` / empty.
export function syncWorkspaceTo(workspaceId: string | undefined): void {
if (!workspaceId) return
if (workspaceId === get(workspaceStore)) return
switchWorkspace(workspaceId)
}
import { WorkspaceService } from '$lib/gen'
import { createWorkspaceForkAndWait, waitForForkCreation } from '$lib/utils/forkCreation'
import { sendUserToast } from '$lib/toast'
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
// item path), which resolves to the data-pipeline graph editor for that folder.
// Legacy drag-and-drop apps aren't previewable; only code-based 'raw_app' apps.
export type SessionTarget = { kind: 'flow' | 'script' | 'raw_app' | 'pipeline'; path: string }
// Whether the session points at a workspace that is itself a fork (i.e.
// has a parent). Used by the sidebar to pick between a root (Building)
// icon and a fork icon.
//
// Sessions whose committed workspace is no longer in the user's list are
// still treated as forks (the "unavailable" terminal state) so we don't
// flip them back to Building once access is lost.
export function isForkSession(session: Session, allWorkspaces: UserWorkspace[]): boolean {
const wsId = session.workspace_id ?? session.pending_workspace_id
if (!wsId) return false
const ws = allWorkspaces.find((w) => w.id === wsId)
if (!ws) return !!session.workspace_id
return !!ws.parent_workspace_id
}
export type PendingFork = {
// Existing workspace to fork from (drives routing/scope pre-send).
parent_workspace_id: string
// Slug the new fork will use, e.g. `wm-fork-foo`.
id: string
// Display name shown in the workspace bar.
name: string
// The background creation this fork started, so a reload mid-copy waits for it rather than
// requesting the fork again, which the server refuses while that creation runs.
creation_id?: string
}
export type SessionSummarySource = 'placeholder' | 'generated' | 'manual'
export type Session = {
id: string
// Per-browser `session-N` label that older sessions-page URLs carried; only read
// to resolve those links.
name?: string
// Committed strictly at first user-message send. Undefined for drafts
// that have never been sent — those scope by `pending_workspace_id`
// instead and don't show the fork bar.
workspace_id?: string
// Pre-send draft workspace, picked via SessionWorkspaceBar. Drives
// scope/editor/display while workspace_id is undefined; gets copied
// into workspace_id at first send and then becomes irrelevant.
pending_workspace_id?: string
// Pre-send intent to create a new fork. The actual API call is
// deferred to first send (via commitSessionWorkspace) so cancelling
// the draft doesn't leave an orphan fork behind.
pending_fork?: PendingFork
// Home's draft: it acts on whatever workspace Home shows, so a switch made on another page
// moves it on Home's next mount. Cleared by a pick made on the session page, whose picker
// leaves the navigation workspace where it is.
followsHome?: boolean
// Stable root workspace id used only for sidebar grouping. Lifecycle follows
// workspace_id, not this field. Root sessions store the same id in both fields.
workspace_root_id?: string
chatId?: string
summary?: string
summarySource?: SessionSummarySource
createdAt: number
// User-archived sessions are hidden from the sidebar by default
// (toggleable via the picker filter). Archive is reversible — distinct
// from delete, which removes the session entirely.
archived?: boolean
// Set when `archived` was applied because the session's workspace was
// archived (not by the user). Lets reconciliation auto-unarchive the session
// when the workspace is unarchived, while leaving user-archived sessions be.
archivedByWorkspace?: boolean
// How many times the session moved to another workspace. The backup keeps it
// with the session's marker, so a restore that finds a copy in two workspaces
// (moved, the old copy not yet removed) takes the later one without a clock.
moves?: number
// 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
// Epoch ms of the last write to this record — typing, renaming, archiving,
// changing preview tabs, or catching up on new messages. Stamped by the two
// write funnels (persistTouched, markSessionSeen), so it tracks use rather
// than creation; merely opening a session without reading anything new is not
// a write and does not bump it.
// Absent on records last written before the field existed; readers fall back
// to createdAt via sessionLastActivityAt.
lastActivityAt?: number
// When this browser restored the session from its backup, by this browser's clock.
// The restore sets `lastActivityAt` to the backup's time, the storage's clock; the
// retention counts from whichever is later, so a browser clock ahead of the storage's
// never deletes a session it just brought back. Not backed up.
restoredAt?: number
// Per-session unread watermark: the displayMessages count the last time
// the user was on this session's page. Compared against the runtime's
// current message count to derive the unread badge (see sessionUnread).
// Travels on the record so it is scoped, migrated, and deleted with it.
lastSeenCount?: number
// Preview tabs the user opened in the sessions page, restored when the
// session is reopened.
previewTabs?: SessionPreviewTab[]
// Which preview tab was active. Falls back to the first tab on restore.
activePreviewTabId?: string
// Whether the user collapsed the preview panel for this session (to give the
// chat full width). Per-session so each session restores its own layout.
previewCollapsed?: boolean
// Preview split size (preview pane %, 0-100) the user dragged for this session.
// Per-session so each session restores its own layout.
previewSize?: number
// Use or Build, as the chat's mode picker last left it. Absent reads as Build.
workMode?: AIWorkMode.USE | AIWorkMode.BUILD
// 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
// When `draftPrompt` should be sent on arrival rather than parked in the
// composer. Set by hand-offs whose click already stated the intent ("AI Fix",
// a typed step or app description), so they land mid-answer rather than
// waiting for a second Enter. Holds the arming time, not a flag: `goto`
// resolves even when a `beforeNavigate` cancels it, so an abandoned hand-off
// would otherwise leave a session armed forever and fire on some later visit.
// Consumed exactly once, by takeSessionAutoSend.
autoSendDraftAt?: number
// The element a preview pick hands over with the auto-sent prompt, sent as its
// chip. Lives and dies with `autoSendDraftAt`: a prompt like "make this bigger"
// must never go out without the element it names.
autoSendDomElement?: AppDomSelectorElement
}
// One preview tab: `url` is the URL we command the iframe to load, `loc` the
// last observed location (see the sessions page for the url/loc split).
// `friendlyLabel` / `friendlyPath` are transient overrides the live editor
// stamps; not persisted (hydrate rebuilds tabs field-by-field), recomputed on
// next mount. `friendlyLabel` is the item's display name — its summary, or the
// typed/auto name of a never-deployed item parked at
// `…/draft_<uuid>`. `friendlyPath` is the item's full
// staged path whenever it differs from the tab's route path — draft-parked OR
// a deployed item with an undeployed rename — and scopes the breadcrumb
// picker into the folder the picker tree displays the item under.
export type SessionPreviewTab = {
id: string
url: string
loc: string
friendlyLabel?: string
friendlyPath?: string
// True once this tab's live editor has loaded its item and reported a name —
// or reported that it has none. Until then the sessions page names the tab
// from the workspace listing; after, the editor is the only source, else
// clearing a summary would fall back to the listing's stale copy of it.
editorNamed?: boolean
// Stamped by an agent's editor in a resource tab, which is what makes that tab the chat's
// ACTIVE EDITOR rather than a page it previews.
agentEditor?: boolean
}
// Sessions live in one per-user IndexedDB, one record per session in the
// `sessions` store keyed by `id`. IndexedDB is the sole store — no localStorage
// fallback. The bare localStorage keys below are the oldest (pre-namespacing)
// source, claimed once during the legacy migration.
const SESSIONS_DB = 'windmill-sessions'
const LEGACY_SESSIONS_KEY = 'windmill_sessions'
const LEGACY_LAST_SEEN_KEY = 'windmill_sessions_last_seen_counts'
interface SessionSchema extends DBSchema {
sessions: { key: string; value: Session }
}
// Normalise legacy localStorage records in place: drop empty-string
// workspace_id (older drafts used '' as a missing marker), drop the retired
// `target` field (the preview is tab-driven now), and coerce unknown
// summarySource values. Operates on raw parsed JSON, so the record is loosely typed.
function normalizeLegacySession(s: Record<string, any>): void {
if (s.workspace_id === '') delete s.workspace_id
delete s.target
if (
s.summarySource !== undefined &&
s.summarySource !== 'placeholder' &&
s.summarySource !== 'generated' &&
s.summarySource !== 'manual'
) {
s.summarySource = 'manual'
}
}
// One-shot claim of the pre-namespacing localStorage data into the per-user DB.
// Runs once per scoped DB (via userScopedDb's migrate gate) and only when the
// DB is still empty — the first user to connect on a previously single-user
// browser inherits the sessions; later users start clean. The bare watermark
// map is folded onto each record as lastSeenCount. Both bare keys are deleted
// so nothing is left to re-claim. Intermediate `::email` keys (this branch's
// throwaway dev data) are intentionally not migrated.
async function migrateSessionsFromLocalStorage(db: IDBPDatabase<SessionSchema>): Promise<void> {
// Only claim into a still-empty DB, but ALWAYS fall through to delete the bare
// keys below — even when the DB is already populated. Otherwise a partially
// failed prior migration (puts committed, deletes didn't) would leave the bare
// keys for a different empty-DB user to later claim: the exact leak this closes.
const alreadyPopulated = (await db.count('sessions')) > 0
const raw = getLocalSetting(LEGACY_SESSIONS_KEY)
if (!alreadyPopulated && raw) {
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed) && parsed.length > 0) {
let watermarks: Record<string, number> = {}
const wmRaw = getLocalSetting(LEGACY_LAST_SEEN_KEY)
if (wmRaw) {
try {
watermarks = JSON.parse(wmRaw)
} catch {
/* ignore malformed watermark map */
}
}
const tx = db.transaction('sessions', 'readwrite')
for (const s of parsed) {
if (!s || typeof s.id !== 'string' || s.transient) continue
normalizeLegacySession(s)
const count = watermarks[s.id]
if (typeof count === 'number') s.lastSeenCount = count
tx.store.put(s as Session)
}
await tx.done
}
} catch (e) {
console.error('Failed to migrate legacy sessions into IndexedDB', e)
}
}
// Drop the bare keys unconditionally (claimed, empty, malformed, or stale
// leftovers from a partially-failed prior migration) so a later different user
// on this browser never re-inherits them.
storeLocalSetting(LEGACY_SESSIONS_KEY, undefined)
storeLocalSetting(LEGACY_LAST_SEEN_KEY, undefined)
}
function sessionRootId(s: Session): string | undefined {
return (
s.workspace_root_id ??
workspaceRootId(s.workspace_id ?? s.pending_workspace_id, get(userWorkspaces)) ??
s.workspace_id ??
s.pending_workspace_id
)
}
// Whether a session belongs to the active workspace's family. Only in-memory
// drafts (transient) may lack a root and still count as in-family — they follow
// the user until a workspace is picked. A persisted session with no workspace
// binding must fail closed: counting it as in-family would surface it in every
// family, including freshly created workspaces.
export function sessionInCurrentFamily(s: Session): boolean {
const currentRoot = workspaceRootId(get(workspaceStore) ?? undefined, get(userWorkspaces))
if (!currentRoot) return true
const root = sessionRootId(s)
if (root === undefined) return !!s.transient
return root === currentRoot
}
function ensureSessionRootId(s: Session): boolean {
if (s.workspace_root_id || s.transient) return false
const workspaceId = s.workspace_id ?? s.pending_workspace_id
if (workspaceId && get(userWorkspaces).length === 0) return false
const root = sessionRootId(s)
if (!root) return false
s.workspace_root_id = root
return true
}
// Recompute workspace_root_id from the live parent chain, replacing a stale
// stored value. A fork family is re-rooted when an ancestor is deleted: the
// FK's ON DELETE SET NULL nulls the child's parent_workspace_id, so the topmost
// member shifts down. Without this, a sub-fork session keeps a root pointing at
// the deleted ancestor and drops out of the sidebar (grouped under a dead root)
// even though its own workspace is still alive. Only re-roots when the
// workspace resolves in the live list — otherwise workspaceRootId falls back to
// the id itself and would clobber a valid root for a merely-unavailable workspace.
function refreshSessionRootId(s: Session): boolean {
if (s.transient) return false
const workspaceId = s.workspace_id ?? s.pending_workspace_id
if (!workspaceId) return false
const all = get(userWorkspaces)
if (!all.some((w) => w.id === workspaceId)) return false
const root = workspaceRootId(workspaceId, all)
if (!root || root === s.workspace_root_id) return false
s.workspace_root_id = root
return true
}
const sessionsDb = userScopedDb<SessionSchema>(SESSIONS_DB, {
version: 1,
upgrade(db) {
if (!db.objectStoreNames.contains('sessions')) {
db.createObjectStore('sessions', { keyPath: 'id' })
}
},
async migrate(db) {
await migrateSessionsFromLocalStorage(db)
}
})
// Starts empty: the list is hydrated from the user's IndexedDB by the
// onUserChange handler below once the logged-in email resolves (async, after
// layout load). Opening eagerly here would touch a DB before we know who is
// logged in.
export const sessionState = $state<{
sessions: Session[]
currentSessionId: string | undefined
// False until the first IndexedDB hydration completes. Consumers gate their
// "not found" / empty states on it — before that, an empty list only means
// "still loading", not "the user has no sessions".
hydrated: boolean
}>({
sessions: [],
currentSessionId: undefined,
hydrated: false
})
// 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 || s.workspace_id) return
// No-op on an unchanged prompt, so opening an untouched draft never persists
// it. Writes of '' are real edits (a draft typed then erased is still a
// session), which is why the composer reports edits only — see AIChatInput.
if ((s.draftPrompt ?? '') === text) return
// Keep `transient` (means "in-memory only") set until the flush persists the
// draft, so hydrateSessions preserves it across a reconcile inside this window;
// isDiscardableDraft, not `transient`, is what stops createSession reusing a
// typed draft. Only the IndexedDB write is debounced.
s.draftPrompt = text
clearTimeout(draftPromptFlushHandles.get(sessionId))
draftPromptFlushHandles.set(
sessionId,
setTimeout(() => {
draftPromptFlushHandles.delete(sessionId)
persistTouched(s)
}, 400)
)
}
// 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
}
// The rest of an unsent draft (collapsed pastes, images, files), so a composer mounted again
// for the same session, on Home or the session page, gets the draft whole: a collapsed paste
// is a token in `draftPrompt` whose content lives here. In memory only, like the attachments
// themselves before a send, so a reload drops them as it always has.
const draftAttachments = new Map<string, DraftAttachments>()
export function setSessionDraftAttachments(sessionId: string, attachments: DraftAttachments): void {
const empty = Object.values(attachments).every((lane) => lane.length === 0)
if (empty) {
draftAttachments.delete(sessionId)
return
}
const s = sessionState.sessions.find((x) => x.id === sessionId)
if (!s || s.workspace_id) return
draftAttachments.set(sessionId, attachments)
// Attaching is a touch, as typing is: a draft holding only an image would otherwise still
// read as a blank, be reused by the next hand-off with the image in it, or be dropped.
if (s.transient) persistTouched(s)
}
export function getSessionDraftAttachments(sessionId: string): DraftAttachments | undefined {
const s = sessionState.sessions.find((x) => x.id === sessionId)
if (!s || s.workspace_id) return undefined
return draftAttachments.get(sessionId)
}
// A hand-off arrives within a navigation; anything older than this is the debris
// of one that never landed, and must not fire at whatever the user does next.
const AUTO_SEND_TTL_MS = 5 * 60_000
// Mark this session's draft prompt for sending on arrival. Written straight to
// the record (not via setSessionDraftPrompt's debounce) because the navigation
// that follows must not outrun it.
export function setSessionAutoSend(sessionId: string, domElement?: AppDomSelectorElement): void {
const s = sessionState.sessions.find((x) => x.id === sessionId)
if (!s) return
s.autoSendDraftAt = Date.now()
s.autoSendDomElement = domElement
persistTouched(s)
}
// The element armed with the auto-send intent; read before takeSessionAutoSend clears both.
export function getSessionAutoSendDomElement(sessionId: string): AppDomSelectorElement | undefined {
return sessionState.sessions.find((x) => x.id === sessionId)?.autoSendDomElement
}
function autoSendIsFresh(s: Session | undefined): boolean {
return !!s?.autoSendDraftAt && Date.now() - s.autoSendDraftAt < AUTO_SEND_TTL_MS
}
// Whether a claim would be honoured, without consuming it. The composer asks
// before deciding to stay empty: suppressing the text for an intent that then
// goes stale would leave the prompt neither sent nor shown, and the composer's
// own empty-draft write would erase it from the record.
export function peekSessionAutoSend(sessionId: string): boolean {
return autoSendIsFresh(sessionState.sessions.find((x) => x.id === sessionId))
}
// Claim the auto-send intent, clearing it so a remount (or a second wrapper for
// the same session) cannot fire the same prompt twice. A stale claim is dropped
// rather than honoured, but still cleared — it has no other consumer.
export function takeSessionAutoSend(sessionId: string): boolean {
const s = sessionState.sessions.find((x) => x.id === sessionId)
if (!s?.autoSendDraftAt) return false
const fresh = autoSendIsFresh(s)
delete s.autoSendDraftAt
delete s.autoSendDomElement
persistTouched(s)
return fresh
}
// 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 writes via patchStoredSessionChatId, the
// unread watermark via putSession) persist directly, so an untouched draft
// stays in memory and vanishes on reload.
function persistTouched(s: Session): void {
if (s.transient) delete s.transient
s.lastActivityAt = Date.now()
void putSession(s)
}
// When the session was last used. Pre-dates-the-field records report their
// creation time, which is the earliest activity they could have had.
export function sessionLastActivityAt(s: Session): number {
return s.lastActivityAt ?? s.createdAt
}
// Sessions whose record has been removed from IndexedDB. Ids come from createLongHash
// and are never reused, so an entry can only ever match the record it was recorded for.
// In-memory by design: a reload re-runs reconciliation, which re-derives the set.
const deletedSessionIds = new Set<string>()
// Test-only: module state outlives a suite's per-test reset of the stores.
export function __resetDeletedSessionIdsForTesting(): void {
deletedSessionIds.clear()
}
// The one way to remove a session's record. Tombstones BEFORE awaiting the delete so a
// putSession racing this transaction cannot commit its write behind it — a direct
// db.delete elsewhere would silently reopen that window.
async function deleteSessionRow(
db: IDBPDatabase<SessionSchema>,
id: string,
// The retention sweep passes false. It holds the in-use lock exclusively, so no write can
// race its delete, and the backup may still hold the session: a tombstone would refuse the
// restore that is meant to bring it back.
tombstone = true
): Promise<void> {
if (tombstone) deletedSessionIds.add(id)
await db.delete('sessions', id)
}
// The way a session record is written (patchStoredSessionChatId is the one
// exception: it re-checks the tombstone inline to stay inside its own
// transaction). Every caller reaches its write across an await, so the
// tombstone has to be consulted here, not only at the entry points.
async function putSessionRow(db: IDBPDatabase<SessionSchema>, s: Session): Promise<void> {
if (deletedSessionIds.has(s.id)) return
await db.put('sessions', s)
markSessionDirty(s.id, undefined, emailOfScopedKey(SESSIONS_DB, db.name))
}
// 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) return
// A record removed because its workspace is gone had its files and artifacts GC'd with
// it, so writing it back resurrects an empty husk. Callers reach here holding a
// reference captured before the delete: find-then-write callers race the
// re-hydrate, and any writer that awaits between the find and the write.
if (deletedSessionIds.has(s.id)) return
// Separately, keep a UI action (e.g. unarchive) from persisting into a workspace that
// has gone unavailable but not yet reconciled. `userWorkspaces` answers that only for
// a confirmed non-superadmin — hence `=== false`, the store being `undefined` until
// the role resolves — since a superadmin reaches workspaces they have no `usr` row in.
const boundWs = s.workspace_id ?? s.pending_workspace_id
if (boundWs && get(superadmin) === false) {
const all = get(userWorkspaces)
if (all.length > 0 && !all.some((w) => w.id === boundWs)) return
}
ensureSessionRootId(s)
const db = await sessionsDb.whenReady()
if (!db) return
try {
await putSessionRow(db, $state.snapshot(s))
} catch (e) {
console.error('Failed to persist session', e)
}
}
export async function deleteSessionRecord(id: string): Promise<void> {
if (!BROWSER) return
const db = await sessionsDb.whenReady()
if (!db) return
try {
await deleteSessionRow(db, id)
} catch (e) {
console.error('Failed to delete session record', e)
}
}
// Load the in-memory list from the user's DB. getAll() returns records in key
// (id) order; sort by createdAt descending to reproduce the newest-first order
// createSession() maintains (it prepends). whenReady() reopens for the current
// user automatically, so this also handles user switch; an absent DB (logged
// out / open failed) yields an empty list.
async function hydrateSessions({ dropTransients = false } = {}): Promise<void> {
// Transient (unsent) drafts live only in memory and belong to the current
// user; preserve them across an intra-user reconcile, but drop them on a user
// change so one user's draft (and its pending fork/workspace state) never
// bleeds into the next user's list.
const transients = dropTransients ? [] : sessionState.sessions.filter((s) => s.transient)
const db = await sessionsDb.whenReady()
if (!db) {
sessionState.sessions = transients
return
}
try {
const all = await db.getAll('sessions')
const changed = all.filter((s) => ensureSessionRootId(s))
for (const s of changed) await putSessionRow(db, s)
all.sort((a, b) => b.createdAt - a.createdAt)
// 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)
sessionState.sessions = transients
}
}
export type WorkspaceLifecycleStatus = 'active' | 'archived' | 'deleted'
// The never-orphaned rule as a pure function of (session, its workspace's
// status) — no IO, so the whole truth table is unit-testable. An `undefined`
// status (workspace absent from the queried set) is a no-op, never a delete.
// deleted → delete the session
// archived → archive it, tagged archivedByWorkspace (idempotent)
// active → auto-unarchive iff WE archived it (has archivedByWorkspace)
export function decideSessionLifecycle(
session: Session,
status: WorkspaceLifecycleStatus | undefined
): { action: 'delete' | 'archive' | 'unarchive' | 'noop'; patch?: Partial<Session> } {
if (status === 'deleted') return { action: 'delete' }
if (status === 'archived') {
return session.archived
? { action: 'noop' }
: { action: 'archive', patch: { archived: true, archivedByWorkspace: true } }
}
if (status === 'active' && session.archivedByWorkspace) {
return { action: 'unarchive', patch: { archived: undefined, archivedByWorkspace: undefined } }
}
return { action: 'noop' }
}
// Apply a decision patch in place: `undefined` removes the key (the unarchive
// path needs the flags gone, not set to undefined), any other value assigns.
function applyLifecyclePatch(session: Session, patch: Partial<Session>): void {
for (const [k, v] of Object.entries(patch)) {
if (v === undefined) delete (session as Record<string, unknown>)[k]
else (session as Record<string, unknown>)[k] = v
}
}
// Workspace switches reconcile too (to catch a workspace deleted/archived on
// another device), but throttled so rapid switching doesn't spam the status
// endpoint. Mutation-driven reconciles are unthrottled.
let lastReconcileAt = 0
const RECONCILE_THROTTLE_MS = 30_000
// Reconcile every stored session against its workspace's lifecycle. Sessions are
// client-only, so the backend can't delete/archive them directly — instead the
// client asks the backend for the status of every workspace its sessions
// reference and applies the rule (see decideSessionLifecycle) the user can't see
// happen otherwise. Reached via reconcileAfterWorkspaceChange (mutations),
// load, and a throttled workspace switch.
export async function reconcileSessionsLifecycle(): Promise<void> {
if (!BROWSER) return
lastReconcileAt = Date.now()
const db = await sessionsDb.whenReady()
if (!db) return
const wsIds = new Set<string>()
const sessions = await db.getAll('sessions')
// 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'>
try {
status = await WorkspaceService.getSessionWorkspaceStatus({
requestBody: { workspace_ids: [...wsIds] }
})
} catch (e) {
console.error('Failed to reconcile session lifecycle', e)
return
}
const deletedIds = new Set<string>()
try {
for (const s of sessions) {
const ws = s.workspace_id ?? s.pending_workspace_id
if (!ws) continue
const { action, patch } = decideSessionLifecycle(s, status[ws])
if (action === 'delete') {
await deleteSessionRow(db, s.id)
// GC linked files too, matching deleteSession — a record-only delete
// here would orphan the session's attached-file blobs/handles.
void deleteItemsForSession(s.id)
void deleteArtifactsForSession(s.id)
deletedIds.add(s.id)
continue
}
let changed = action === 'archive' || action === 'unarchive'
if (changed && patch) applyLifecyclePatch(s, patch)
// Re-root surviving sessions whose family topmost member shifted (an
// ancestor was deleted); fall back to backfilling a missing root.
if (refreshSessionRootId(s) || ensureSessionRootId(s)) changed = true
if (changed) await putSessionRow(db, s)
}
} catch (e) {
// The connection can go stale mid-loop (user switch reopens the per-user
// DB); this pass is best-effort — the next reconcile trigger retries.
console.error('Failed to reconcile session lifecycle', e)
return
}
await hydrateSessions()
// If the session the user was on lived in a now-deleted workspace, it was just
// removed — drop the dangling pointer so the page falls back to "no session
// selected" instead of a ghost.
if (sessionState.currentSessionId && deletedIds.has(sessionState.currentSessionId)) {
sessionState.currentSessionId = undefined
}
}
// --- Retention ---
const DAY_MS = 24 * 60 * 60 * 1000
// Past the retention by this browser's clock, counted from the later of the session's last
// activity and its restore here: a restored session carries the backup's time, the storage's
// clock, so without `restoredAt` a browser running ahead would delete what it just brought
// back. Archived sessions count like any other.
function isSessionExpired(
session: Session,
retentionDays: number | undefined,
now: number
): boolean {
if (retentionDays === undefined || !(retentionDays >= 1)) return false
const since = Math.max(sessionLastActivityAt(session), session.restoredAt ?? 0)
return since < now - retentionDays * DAY_MS
}
// What the server last told this browser, and when. It decides whether the sweep asks again,
// and nothing else: a retention raised or cleared since must not delete a session, and a
// persisted unsent draft has no backup to come back from.
const RETENTION_DAYS = 'windmill_sessions_retention_days'
// Nothing remembered for longer than this is trusted even to say there is nothing to ask
// about, so a retention lowered while this browser saw nothing expiring still takes effect.
const RETENTION_STALE_MS = 24 * 60 * 60 * 1000
interface RememberedRetention {
at: number
days: Record<string, number>
}
function rememberRetention(email: string, days: Record<string, number>): void {
try {
const remembered: RememberedRetention = { at: Date.now(), days }
localStorage.setItem(scopedKeyFor(RETENTION_DAYS, email), JSON.stringify(remembered))
} catch {}
}
function rememberedRetention(email: string): RememberedRetention | undefined {
try {
const stored = localStorage.getItem(scopedKeyFor(RETENTION_DAYS, email))
const remembered = stored ? JSON.parse(stored) : undefined
if (remembered?.days && typeof remembered.days === 'object') {
return remembered as RememberedRetention
}
} catch {}
return undefined
}
// How long the sweep waits for the retention of the workspaces it is about to sweep in. The
// tab reads its sessions after the sweep, so a request nothing answers costs the list this
// much and no more, and only in a tab that had something to delete.
const RETENTION_ASK_MS = 5000
// The retention the server gives now, or undefined when this browser could not be told: a
// session is deleted only on an answer of the moment.
async function askRetention(workspaceIds: string[]): Promise<Record<string, number> | undefined> {
try {
return await Promise.race([
WorkspaceService.getSessionWorkspaceRetention({
requestBody: { workspace_ids: workspaceIds }
}),
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), RETENTION_ASK_MS))
])
} catch (e) {
console.error('Failed to read the AI session retention of the workspaces', e)
return undefined
}
}
// One key per session this browser swept whose pieces are not all deleted yet.
const RETENTION_PENDING = 'windmill_sessions_retention_pending'
function retentionPendingPrefix(email: string): string {
return `${scopedKeyFor(RETENTION_PENDING, email)}::`
}
function forgetRetentionPending(email: string, id: string): void {
try {
localStorage.removeItem(retentionPendingPrefix(email) + id)
} catch {}
}
function retentionPending(email: string): string[] {
const prefix = retentionPendingPrefix(email)
const ids: string[] = []
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key?.startsWith(prefix)) ids.push(key.slice(prefix.length))
}
} catch {}
return ids
}
function webLocks(): LockManager | undefined {
return typeof navigator === 'undefined' ? undefined : (navigator as { locks?: LockManager }).locks
}
// Held, shared, by every tab from before it reads the user's sessions until it stops using
// them: the stores are shared, and each tab keeps copies of the sessions in memory, so the
// sweep deletes only while holding this exclusively.
function sessionsInUseLockName(email: string): string {
return `${sessionsLockName(email)}::in-use`
}
interface InUseHold {
email: string
released: boolean
release?: () => void
done?: Promise<unknown>
}
let inUse: InUseHold | undefined
// Resolves once the hold is granted, which waits for the sweep another tab is running. A
// request the browser refuses (a document that is not fully active) resolves it too, without
// a hold: the tab reads its sessions unguarded, as it does where Web Locks do not exist, and
// never sits waiting for a grant that is not coming.
async function holdSessionsInUse(email: string): Promise<void> {
const locks = webLocks()
if (!locks || inUse?.email === email) return
await releaseSessionsInUse()
const hold: InUseHold = { email, released: false }
inUse = hold
await new Promise<void>((granted) => {
hold.done = locks
.request(sessionsInUseLockName(email), { mode: 'shared' }, () => {
granted()
return hold.released ? undefined : new Promise<void>((resolve) => (hold.release = resolve))
})
.catch((e) => {
console.error('Could not hold the AI sessions this tab is reading', e)
if (inUse === hold) inUse = undefined
granted()
})
})
}
// Resolves once the hold is let go of, so an exclusive request made next can be granted.
async function releaseSessionsInUse(): Promise<void> {
const hold = inUse
if (!hold) return
inUse = undefined
hold.released = true
hold.release?.()
await hold.done?.catch(() => {})
}
// Deletes one expired session: its record first, so nothing plans a push for it afterwards,
// then its pieces. The pending key, written before the record and removed once every piece
// is gone, is what a later sweep finishes a failed deletion from.
async function sweepSession(
db: IDBPDatabase<SessionSchema>,
id: string,
email: string
): Promise<void> {
try {
localStorage.setItem(retentionPendingPrefix(email) + id, '1')
} catch {
return
}
await deleteSessionRow(db, id, false)
await sessionSwept(id, email)
if (await deleteSessionPieces(id, email)) forgetRetentionPending(email, id)
}
// Chats with their images, artifacts and attached files. False when any of them could not
// be deleted.
async function deleteSessionPieces(id: string, email: string): Promise<boolean> {
const chats = await deleteSessionChats(id, email)
const artifacts = await deleteArtifactsForSession(id, email)
const files = await deleteItemsForSession(id)
return chats && artifacts && files
}
// The workspace a session's retention comes from: persisted unsent drafts count by the one
// they are waiting on.
function retentionWorkspaceOf(session: Session): string | undefined {
return session.workspace_id ?? session.pending_workspace_id
}
// Deletes this browser's copies of the sessions past their workspace's retention, and the
// pieces of the ones an earlier sweep could not finish (docs/ai-session-backups.md). Deleting
// one record at a time, without re-reading it, is safe only under the in-use lock held
// exclusively, granted exactly when no tab has the sessions loaded — hence the call site.
async function sweepExpiredSessions(email: string): Promise<void> {
const locks = webLocks()
if (!locks || inUse) return
try {
await locks.request(sessionsInUseLockName(email), { ifAvailable: true }, async (idle) => {
if (!idle) return
// The flush and the restore run under this one: neither must see a session half
// deleted, or plan a push from it.
await locks.request(sessionsLockName(email), { ifAvailable: true }, async (mirror) => {
if (!mirror) return
const db = await sessionsDb.whenReady()
if (!db || db.name !== scopedKeyFor(SESSIONS_DB, email)) return
for (const id of retentionPending(email)) {
// A restore brought the session back: its pieces are that copy's now.
const back = (await db.getKey('sessions', id)) !== undefined
if (back || (await deleteSessionPieces(id, email))) forgetRetentionPending(email, id)
}
const stored = await db.getAll('sessions')
const remembered = rememberedRetention(email)
const now = Date.now()
const workspaces = new Set<string>()
let expired = false
for (const s of stored) {
const ws = retentionWorkspaceOf(s)
if (ws === undefined) continue
workspaces.add(ws)
expired ||= isSessionExpired(s, remembered?.days[ws], now)
}
// Nothing to sweep in, or nothing old enough by an answer recent enough to be
// believed about that: this load costs no request.
const fresh = remembered !== undefined && now - remembered.at < RETENTION_STALE_MS
if (workspaces.size === 0 || (!expired && fresh)) return
const retention = await askRetention([...workspaces])
// Asked and not told: the sessions wait for the next load rather than go on an
// answer this browser does not have.
if (!retention) return
rememberRetention(email, retention)
for (const s of stored) {
const ws = retentionWorkspaceOf(s)
if (ws === undefined || !isSessionExpired(s, retention[ws], Date.now())) continue
await sweepSession(db, s.id, email)
}
})
})
} catch (e) {
console.error('Failed to sweep the sessions past their retention', e)
}
}
// The single seam for "a workspace just changed — bring sessions back in sync."
// Refresh the workspace list FIRST — both reconcile and the putSession guard
// read it, so it must reflect the change before reconcile runs — then reconcile.
// Call this from every workspace create/delete/archive/unarchive site.
export async function reconcileAfterWorkspaceChange(): Promise<void> {
if (!BROWSER) return
try {
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
} catch (e) {
console.error('Failed to refresh workspace list before reconcile', e)
}
await reconcileSessionsLifecycle()
}
// Matches on `workspace_id ?? pending_workspace_id` so persisted unsent drafts
// count too; reconcileSessionsLifecycle tears them down with committed sessions on
// archive/delete, so this pre-teardown confirmation count must match.
export async function countSessionsForWorkspace(workspaceId: string): Promise<number> {
if (!BROWSER) return 0
const db = await sessionsDb.whenReady()
if (!db) return 0
try {
const all = await db.getAll('sessions')
return all.filter(
(s) => (s.workspace_id ?? s.pending_workspace_id) === workspaceId && !s.transient
).length
} catch {
return 0
}
}
export async function archiveSessionsForWorkspace(workspaceId: string): Promise<void> {
if (!BROWSER || !workspaceId) return
const db = await sessionsDb.whenReady()
if (!db) return
const all = await db.getAll('sessions')
for (const s of all) {
// Skip sessions the user already archived: tagging them archivedByWorkspace
// would make a later workspace-unarchive auto-restore them, discarding the
// user's manual archive. Mirrors decideSessionLifecycle's already-archived noop.
if (s.transient || s.workspace_id !== workspaceId || s.archived) continue
s.archived = true
s.archivedByWorkspace = true
ensureSessionRootId(s)
await putSessionRow(db, s)
}
for (const s of sessionState.sessions) {
if (s.transient || s.workspace_id !== workspaceId || s.archived) continue
s.archived = true
s.archivedByWorkspace = true
}
}
export async function deleteSessionsForWorkspace(workspaceId: string): Promise<void> {
if (!BROWSER || !workspaceId) return
const db = await sessionsDb.whenReady()
if (!db) return
const all = await db.getAll('sessions')
const ids = new Set(
all.filter((s) => s.workspace_id === workspaceId && !s.transient).map((s) => s.id)
)
for (const id of ids) {
await deleteSessionRow(db, id)
// GC linked files too (matches deleteSession) so a workspace teardown
// doesn't leave the sessions' attached-file blobs/handles orphaned.
void deleteItemsForSession(id)
void deleteArtifactsForSession(id)
}
sessionState.sessions = sessionState.sessions.filter((s) => !ids.has(s.id))
if (sessionState.currentSessionId && ids.has(sessionState.currentSessionId)) {
sessionState.currentSessionId = undefined
}
}
// Re-hydrate on user (email) change. The new user's persisted sessions are
// re-read from their own scoped DB (different email → different DB name); the
// in-memory list is rebuilt from scratch — including dropping the previous
// user's transient drafts — and the active-session pointer is cleared, so one
// user's sessions never bleed into another.
onUserChange(async (email, prevEmail) => {
if (!BROWSER) return
// The retention sweep runs here and nowhere else: this tab holds none of the new user's
// sessions yet, and letting go of the hold it had leaves it holding none of anyone's. The
// new hold is taken before the sessions are read, so another tab's sweep never deletes
// what this tab is about to load, and one already running is waited for.
await releaseSessionsInUse()
if (email) {
await sweepExpiredSessions(email)
await holdSessionsInUse(email)
}
await hydrateSessions({ dropTransients: prevEmail !== email })
// onUserChange also fires at registration time, before the email resolves —
// that hydration is an empty no-op and must not clear the loading state.
if (email !== undefined) sessionState.hydrated = true
if (prevEmail !== undefined && prevEmail !== email) {
sessionState.currentSessionId = undefined
}
// Load-time reconcile: catch workspaces archived/deleted while away.
void reconcileSessionsLifecycle()
})
// Workspace switches do not reload or clear the active session. The sidebar
// filters the single loaded session list by workspace_root_id, while the current
// chat survives workspace switches.
if (BROWSER) {
let lastWorkspace: string | undefined
let initialized = false
workspaceStore.subscribe((ws) => {
const isSwitch = initialized && ws !== lastWorkspace
initialized = true
lastWorkspace = ws
if (isSwitch && Date.now() - lastReconcileAt > RECONCILE_THROTTLE_MS) {
void reconcileSessionsLifecycle()
}
})
}
export function sessionPageHref(id: string): string {
return `/sessions?session=${encodeURIComponent(id)}`
}
// 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++
}
// An untouched in-memory blank that `+` may reuse and createSession may silently
// drop. `draftPrompt === undefined` (never edited), not falsiness: a draft typed
// then erased to '' still has a pending flush and is a real session. Every other
// touch clears `transient` synchronously, so only the draft prompt needs checking.
function isDiscardableDraft(s: Session): boolean {
return !!s.transient && s.draftPrompt === undefined
}
// Somewhere empty to put the user, for a URL naming a session this browser
// doesn't hold. Only `transient` makes "empty" trustworthy: a persisted session
// can hold a conversation while every field below stays untouched — its chat and
// attached files key off `!transient`, not off these.
export function findEmptyLandingSession(): Session | undefined {
return sessionState.sessions.find(
(s) =>
!!s.transient &&
!s.archived &&
!s.workspace_id &&
// Falsiness, not `=== undefined`: we only navigate into the session, so a
// draft erased back to '' is still an empty composer to land on.
!s.draftPrompt?.trim() &&
!s.pending_fork &&
sessionInCurrentFamily(s)
)
}
export function createSession(): Session {
// Reuse an existing untouched draft from the active family rather than pile a
// blank entry on every `+`, so several pending sessions can still be built up
// in parallel, one touch at a time. A cross-family leftover blank is dropped
// instead of reused (reusing it would act on that family).
const reusable = sessionState.sessions.find(
(s) => isDiscardableDraft(s) && sessionInCurrentFamily(s)
)
if (reusable) {
// It may be Home's draft; asked for here, it is the session page's now, and callers
// park it on a workspace of their own that Home must not move it off.
delete reusable.followsHome
sessionState.currentSessionId = reusable.id
// The blank recovery just landed on is exactly what this reuses, so `+`
// would otherwise hand back a session still carrying the recovery notice:
// asking for a new session must not be answered with "we couldn't find it".
clearSessionRecovered(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
}
const session = newTransientSession()
sessionState.currentSessionId = session.id
return session
}
// Told which never-touched drafts were dropped, so their runtimes go with them. Set by
// sessionRuntime, which imports this module and so cannot be imported back. Home builds a
// runtime for its draft on every mount, so a dropped draft would otherwise leave its
// manager and its history database handle open.
let onDraftsDiscarded: ((ids: string[]) => void) | undefined
export function setDraftsDiscardedHandler(handler: (ids: string[]) => void): void {
onDraftsDiscarded = handler
}
function newTransientSession(): Session {
const discarded = sessionState.sessions.filter(isDiscardableDraft).map((s) => s.id)
sessionState.sessions = sessionState.sessions.filter((s) => !isDiscardableDraft(s))
if (discarded.length > 0) onDraftsDiscarded?.(discarded)
// Start in the workspace you're in, a locked production root included: the session is then
// read-only, and points the user to a new session in the dev workspace for changes.
const pending = get(workspaceStore)
// Friendly default summary so the header reads like "Zippy session"
// rather than "Untitled session" — assigned at create time, the user
// can still rename it (or it gets overwritten by an editor target).
const adj = random_adj()
const summary = `${adj.charAt(0).toUpperCase() + adj.slice(1)} session`
const session: Session = {
id: createLongHash(),
summary,
summarySource: 'placeholder',
pending_workspace_id: pending && pending.length > 0 ? pending : undefined,
createdAt: Date.now(),
transient: true
}
sessionState.sessions = [session, ...sessionState.sessions]
// 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
}
/** The unsent session Home's composer writes into, so a prompt half-typed on Home is the
* one the empty session page shows, and the reverse. Never selects it: merely opening Home
* must not change which session the AI Sessions switch resumes. A session opened on an
* item (it carries preview tabs) is about that item, and one the session page picked onto
* another workspace of the family is about that workspace: Home acts on the workspace it
* shows, so it starts its own instead of moving either. */
export function homeStarterSession(): Session {
const homeWs = get(workspaceStore)
const homes = (s: Session) =>
(s.followsHome || s.pending_workspace_id === homeWs) && sessionInCurrentFamily(s)
const unsent = (s: Session) =>
!s.archived && !s.workspace_id && !s.previewTabs?.length && homes(s)
const current = sessionState.sessions.find((s) => s.id === sessionState.currentSessionId)
// Stored but never sent: a draft the user touched, by typing or by picking where it acts.
// Either is theirs to come back to, and leaving it would strand it in the sidebar.
const touched = sessionState.sessions
.filter((s) => unsent(s) && !s.transient)
.sort((a, b) => sessionLastActivityAt(b) - sessionLastActivityAt(a))[0]
const draft =
(current && unsent(current) ? current : undefined) ??
touched ??
sessionState.sessions.find((s) => isDiscardableDraft(s) && homes(s)) ??
newTransientSession()
if (!draft.followsHome) {
draft.followsHome = true
if (!draft.transient) void putSession(draft)
}
return draft
}
// 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)
}
export function setSessionPendingWorkspace(id: string, workspace_id: string) {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return
const changed = s.pending_workspace_id !== workspace_id || s.pending_fork !== undefined
s.pending_workspace_id = workspace_id
// Picking an existing workspace cancels any pending fork intent.
s.pending_fork = undefined
if (changed) persistTouched(s)
}
export function stopFollowingHome(id: string) {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s?.followsHome) return
delete s.followsHome
if (!s.transient) void putSession(s)
}
// Park a just-created session on a workspace. Deliberately does not persist a
// still-transient draft: picking where it will live is part of creating it, not a
// user touch, so an unused draft still vanishes on reload (see `transient`).
export function setNewSessionWorkspace(id: string, workspace_id: string) {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return
s.pending_workspace_id = workspace_id
s.pending_fork = undefined
if (!s.transient) persistTouched(s)
}
// Records the user's intent to create a new fork without firing the API
// call yet. Routing/scope stay on the parent workspace until commit.
export function setSessionPendingFork(id: string, fork: PendingFork) {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return
s.pending_fork = { ...fork }
s.pending_workspace_id = fork.parent_workspace_id
persistTouched(s)
}
// One-shot commit: locks in workspace_id at first user-message send.
// If a pending fork is set, materialises it via the API first, then
// switches the global workspace to the freshly created fork. Falls back
// to the pending pick, then the active workspace. Clears pending so it
// doesn't shadow later reads.
export async function commitSessionWorkspace(
id: string,
fallback?: string
): Promise<string | undefined> {
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
// Defense-in-depth against a stale pending_fork (e.g. staged before
// another workspace was created, or any future entry point): a fork is a
// new workspace, and community edition caps the number of non-'admins'
// workspaces (backend _check_nb_of_workspaces). An enterprise license
// lifts the cap. Block the commit with an explicit error rather than
// letting materializeFork hit a backend rejection. Keep the pending fork
// set so the block persists until the user picks a non-fork workspace
// (setSessionPendingWorkspace clears it).
const CE_MAX_NON_ADMIN_WORKSPACES = 2
const nonAdminWorkspaceCount = get(userWorkspaces).filter((w) => w.id !== 'admins').length
if (!get(enterpriseLicense) && nonAdminWorkspaceCount >= CE_MAX_NON_ADMIN_WORKSPACES) {
throw new Error(
`Community edition is limited to ${CE_MAX_NON_ADMIN_WORKSPACES + 1} workspaces — archive a workspace or pick one to run in`
)
}
const newId = await materializeFork(fork, (creation_id) => {
s.pending_fork = { ...fork, creation_id }
void putSession(s)
})
if (!newId) {
// Real failure (not a recovered duplicate). Drop the pending
// fork so the session falls through to the workspace-pick
// fallback on the next call and the unavailable-banner UX
// can take over instead of looping on the same broken intent.
s.pending_fork = undefined
void putSession(s)
return undefined
}
s.workspace_id = newId
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)
logFeatureUsage('ai_session', 'created', { key: 'fork', entityId: s.id, workspace: newId })
// The global workspaceStore is intentionally left untouched: the session
// chat targets its own workspace via AIChatManager.operatingWorkspace, so
// committing must not yank the user's active (navigation-mode) workspace.
return newId
}
const ws = s.pending_workspace_id ?? fallback
if (!ws) return undefined
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)
// A picked workspace can itself be an existing fork — classify by root. A read-only root is
// counted apart, to see whether production sessions are used.
const sessionId = s.id
void isReadOnlySessionWorkspace(ws).then((readOnly) =>
logFeatureUsage('ai_session', 'created', {
key: readOnly ? 'read_only' : ws === s.workspace_root_id ? 'root' : 'fork',
entityId: sessionId,
workspace: ws
})
)
// The global workspaceStore is intentionally left untouched (see the fork
// branch above): the session chat reads its committed workspace through the
// manager's workspace resolver, not the active workspaceStore.
return ws
}
// Effective workspace for scope/routing — committed if set, otherwise the
// pre-send pending pick (which defaults to the workspace at create time).
// Pending forks route via their parent until creation lands.
export function getEffectiveWorkspaceId(session: Session): string | undefined {
return session.workspace_id ?? session.pending_workspace_id
}
// 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
persistTouched(s)
}
// 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
persistTouched(s)
}
// 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
persistTouched(s)
}
// Persist the session's Use or Build choice (a touch). Plan is left out: a reload ends the
// planning round, and the session reopens in the mode it was entered from.
export function setSessionWorkMode(id: string, mode: AIWorkMode): void {
if (mode === AIWorkMode.PLAN) return
const s = sessionState.sessions.find((x) => x.id === id)
if (!s || (s.workMode ?? AIWorkMode.BUILD) === mode) return
s.workMode = mode
persistTouched(s)
}
export function selectSession(id: string) {
sessionState.currentSessionId = id
}
export function renameSession(id: string, newSummary: string) {
const trimmed = newSummary.trim()
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return
s.summary = trimmed.length > 0 ? trimmed : undefined
s.summarySource = 'manual'
persistTouched(s)
}
export function setGeneratedSessionSummary(
id: string,
newSummary: string,
expectedChatId?: string
): boolean {
const trimmed = newSummary.trim()
if (!trimmed) return false
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return false
if (expectedChatId && s.chatId !== expectedChatId) return false
if (s.summarySource !== 'placeholder') return false
s.summary = trimmed
s.summarySource = 'generated'
void putSession(s)
return true
}
// A resumed attempt's polls fail for good once it is gone, so they are not waited out as long as a
// fresh creation's, whose failures a rolling deploy clears; a resume cut short by such a deploy
// falls back to requesting the fork, which then waits for it below.
const RESUMED_FORK_MAX_FAILING_MS = 10_000
// How long a fork another request is still creating is waited for.
const IN_FLIGHT_FORK_WAIT_MS = 3 * 60 * 1000
// Make sure the fork exists, refresh the user-workspaces store, and return
// the fork id. Used by both the first-send commit path
// (commitSessionWorkspace) and the move-session-to-a-new-fork path in the
// unavailable-session banner. Returns undefined on failure (a user-facing
// toast is already emitted).
//
// Self-heal: a fork already present in the user-workspaces store was
// created by an earlier request whose response was lost, and is adopted
// without requesting it again; so is one the API reports as existing. A
// fork whose `creation_id` is set waits for that creation (started before a
// reload), and one the API reports as still being created, by a creation
// whose id was lost, is waited for until it shows up among the user's
// workspaces. `onCreationStarted` receives the id of a new creation.
export async function materializeFork(
fork: PendingFork,
onCreationStarted?: (creationId: string) => void
): Promise<string | undefined> {
if (get(userWorkspaces).some((w) => w.id === fork.id)) return fork.id
try {
let resumed = false
if (fork.creation_id) {
// An attempt that failed or is gone is requested again below.
resumed = await waitForForkCreation(
fork.parent_workspace_id,
fork.creation_id,
undefined,
RESUMED_FORK_MAX_FAILING_MS
).then(
() => true,
() => false
)
}
if (!resumed) {
await createWorkspaceForkAndWait(
fork.parent_workspace_id,
{ id: fork.id, name: fork.name },
{ onCreationStarted }
)
}
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
sendUserToast(`Created fork ${fork.name}`)
return fork.id
} catch (e: any) {
const msg = String(e?.body ?? e?.message ?? e)
if (msg.includes('is already being created') && (await forkJoinedWorkspaces(fork.id))) {
sendUserToast(`Created fork ${fork.name}`)
return fork.id
}
if (/workspace_pkey|duplicate key|already exists/i.test(msg)) {
// Self-heal: the create likely already succeeded. Refresh + adopt the
// existing row. Guard this refresh — a second network failure here must
// NOT rethrow out of materializeFork (callers rely on the
// toast-and-return-undefined contract; an uncaught throw would bypass
// it and propagate up through commitSessionWorkspace/beforeSend).
try {
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
if (get(userWorkspaces).some((w) => w.id === fork.id)) return fork.id
} catch (refreshErr) {
console.error('Failed to refresh workspaces during fork self-heal', refreshErr)
}
}
sendUserToast(`Could not create fork: ${msg}`, true)
return undefined
}
}
// Whether the fork shows up among the user's workspaces within IN_FLIGHT_FORK_WAIT_MS.
async function forkJoinedWorkspaces(forkId: string): Promise<boolean> {
const deadline = Date.now() + IN_FLIGHT_FORK_WAIT_MS
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 1500))
const listed = await WorkspaceService.listUserWorkspaces().catch(() => undefined)
if (listed?.workspaces?.some((w) => w.id === forkId)) {
usersWorkspaceStore.set(listed)
return true
}
}
return false
}
// Re-assign a committed session to a different workspace. Used to rescue
// sessions whose original workspace was deleted / archived / had access
// revoked — the chat history (stored in IndexedDB keyed by session id) is
// preserved; only the workspace pointer changes. Drops pending fields
// since the session is already past the draft stage by definition.
export async function moveSessionToWorkspace(id: string, newWorkspaceId: string): Promise<void> {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return
if (s.workspace_id === newWorkspaceId) return
if (s.workspace_id !== undefined) s.moves = (s.moves ?? 0) + 1
s.workspace_id = newWorkspaceId
delete s.pending_workspace_id
delete s.pending_fork
s.workspace_root_id = workspaceRootId(newWorkspaceId, get(userWorkspaces)) ?? newWorkspaceId
await putSession(s)
}
// Create a brand-new fork and re-assign a committed session to it. Used
// by the unavailable-session banner's "Create new fork" path in the
// move dropdown. On success the global workspace is switched to the
// freshly created fork.
export async function moveSessionToNewFork(
id: string,
fork: PendingFork
): Promise<string | undefined> {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return undefined
const newId = await materializeFork(fork)
if (!newId) return undefined
// Persist the session before switching so the target workspace can show it
// immediately in the root-filtered sidebar.
await moveSessionToWorkspace(id, newId)
if (get(workspaceStore) !== newId) switchWorkspace(newId)
return newId
}
export function setSessionArchived(id: string, archived: boolean) {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return
const next = archived ? true : undefined
if (s.archived === next && (archived || !s.archivedByWorkspace)) return
if (archived) {
s.archived = true
logFeatureUsage('ai_session', 'archived', { entityId: s.id, workspace: s.workspace_id })
} else {
delete s.archived
delete s.archivedByWorkspace
}
persistTouched(s)
}
// A counter rather than a flag: an inner teardown finishing must not reopen the
// gate while an outer one is still running. Released in a finally, so a delete
// that throws can't wedge it shut.
let openSessionTeardowns = $state(0)
export function isTearingDownOpenSession(): boolean {
return openSessionTeardowns > 0
}
export async function withOpenSessionTeardown<T>(run: () => Promise<T>): Promise<T> {
openSessionTeardowns++
try {
return await run()
} finally {
openSessionTeardowns--
}
}
export function deleteSession(id: string) {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return
// 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)
draftAttachments.delete(id)
sessionState.sessions = sessionState.sessions.filter((x) => x.id !== id)
if (sessionState.currentSessionId === id) {
sessionState.currentSessionId = sessionState.sessions[0]?.id
}
void deleteSessionRecord(id)
// GC any linked files and artifacts persisted for this session.
void deleteItemsForSession(id)
void deleteArtifactsForSession(id)
// Only a delete the user asked for takes the backup with it: the workspace-lifecycle
// removals above keep theirs, so a session dropped by a wrong reconcile can be restored.
markSessionRemoved(id, s.workspace_id)
logFeatureUsage('ai_session', 'deleted', { entityId: id, workspace: s.workspace_id })
}
// --- Session backup support (sessionMirror) ---
export function isSessionTombstoned(id: string): boolean {
return deletedSessionIds.has(id)
}
// Every stored record of the named user, or undefined when the store is unavailable or
// already serves someone else: the backup captures its user up front and must not follow
// an in-place account switch.
export async function readStoredSessions(email: string): Promise<Session[] | undefined> {
if (!BROWSER) return undefined
const db = await sessionsDb.whenReady()
if (!db || db.name !== scopedKeyFor(SESSIONS_DB, email)) return undefined
try {
return await db.getAll('sessions')
} catch (e) {
console.error('Failed to read sessions from IndexedDB', e)
return undefined
}
}
// Add restored records for sessions this browser does not have, and re-hydrate the list.
// A record that exists, or was deleted here, is left alone: the local copy is the newer
// one. Returns the ids written.
export async function importSessions(records: Session[], email: string): Promise<string[]> {
if (!BROWSER) return []
const db = await sessionsDb.whenReady()
if (!db || db.name !== scopedKeyFor(SESSIONS_DB, email)) return []
const imported: string[] = []
try {
const tx = db.transaction('sessions', 'readwrite')
const existing = new Set((await tx.store.getAllKeys()).map(String))
const restoredAt = Date.now()
for (const r of records) {
if (existing.has(r.id) || deletedSessionIds.has(r.id)) continue
const record: Session = { ...r, restoredAt }
delete record.transient
delete record.workspace_root_id
ensureSessionRootId(record)
await tx.store.put(record)
imported.push(record.id)
}
await tx.done
} catch (e) {
console.error('Failed to import sessions', e)
return []
}
if (imported.length > 0) await hydrateSessions()
return imported
}
export function setSessionChatId(sessionId: string, chatId: string) {
const s = sessionState.sessions.find((x) => x.id === sessionId)
if (s && s.chatId !== chatId) {
s.chatId = chatId
void patchStoredSessionChatId(s, chatId)
}
}
// Persists the pointer through the STORED row, not this tab's copy: another
// tab may have written newer fields (summary, tabs, archive state) since this
// tab last read the record, and a whole-object put would roll them back — a
// watcher adopting the driver's rotation reaches here with exactly that copy.
async function patchStoredSessionChatId(s: Session, chatId: string): Promise<void> {
if (!BROWSER || s.transient || deletedSessionIds.has(s.id)) return
const db = await sessionsDb.whenReady()
if (!db) return
try {
const tx = db.transaction('sessions', 'readwrite')
const stored = await tx.store.get(s.id)
// Inline tombstone re-check in place of putSessionRow's: routing through
// it would put outside this transaction and lose the read's atomicity.
if (stored && !deletedSessionIds.has(s.id)) {
stored.chatId = chatId
await tx.store.put(stored)
await tx.done
markSessionDirty(s.id, undefined, emailOfScopedKey(SESSIONS_DB, db.name))
return
}
await tx.done
// No stored row: either the record is not yet persisted — its own
// materialization writes it later with the chatId already set in memory —
// or another tab deleted it, and an upsert here would resurrect it. No
// write either way.
} catch (e) {
console.error('Failed to persist session chat id', e)
}
}