feat: persist a per-session AI task list in IndexedDB

This commit is contained in:
AlexRV12
2026-08-11 13:59:12 +02:00
parent 26f7ec9661
commit 4059bf8211
7 changed files with 480 additions and 7 deletions
@@ -133,6 +133,7 @@ import { scopedKey, onUserChange, migrateLegacyLocalStorage } from '$lib/userSco
import { getLocalSetting, storeLocalSetting } from '$lib/utils'
import { AttachedFilesStore } from './files/attachedFiles.svelte'
import { SessionArtifactsStore } from './artifacts/artifactsState.svelte'
import { SessionTasksStore } from './tasks/tasksState.svelte'
import { appendAttachedFilesRoster } from './files/fileTools'
// SSR and users who prefer reduced motion get no typewriter pacing.
@@ -348,6 +349,8 @@ export class AIChatManager {
attachedFiles = new AttachedFilesStore()
/** Markdown artifacts the copilot created for the current session. */
artifacts = new SessionArtifactsStore()
/** The task list the copilot is working through for the current session. */
tasks = new SessionTasksStore()
abortController: AbortController | undefined = undefined
inlineAbortController: AbortController | undefined = undefined
// Flag to skip Responses API if it's not available (e.g., Azure region doesn't support it)
@@ -1890,7 +1893,7 @@ export class AIChatManager {
this.helpers = baseHelpers
}
this.systemMessage = systemMessage
this.syncArtifactsSession()
this.syncSessionStores()
}
refreshGlobalSkills = async (workspace = this.operatingWorkspace ?? '') => {
@@ -3526,7 +3529,7 @@ export class AIChatManager {
if (!this.isSessionChat) this.attachedFiles.clear()
// Message-attached rows belong to the conversation just left in every case.
this.#syncMessageFiles()
this.syncArtifactsSession()
this.syncSessionStores()
this.onChatRotated?.(this.historyManager.getCurrentChatId())
}
@@ -3568,13 +3571,15 @@ export class AIChatManager {
// readable (and the previous chat's are pruned).
this.#syncMessageFiles()
this.#automaticScroll = true
this.syncArtifactsSession()
this.syncSessionStores()
this.onChatRotated?.(id)
}
}
private syncArtifactsSession = () => {
void this.artifacts.setSession(this.isSessionChat ? this.sessionId : undefined)
private syncSessionStores = () => {
const sessionId = this.isSessionChat ? this.sessionId : undefined
void this.artifacts.setSession(sessionId)
void this.tasks.setSession(sessionId)
}
get automaticScroll() {
@@ -0,0 +1,134 @@
// Scoped by sessionId (fixed for the session's life), not chatId: a session follows its
// active chat's rotation, so chatId-keying would drop the plan mid-run.
import { type DBSchema as IDBSchema } from 'idb'
import { userScopedDb } from '$lib/userScopedDb'
export type TaskStatus = 'pending' | 'in_progress' | 'completed'
export interface PersistedTask {
sessionId: string
// 1-based per session. This is also the id the model handles: it echoes an id on
// every update_task call, so an integer costs a character where a UUID costs 36.
seq: number
subject: string
description: string
/** Present continuous ("Fixing auth redirect"), shown while the task is in_progress. */
activeForm?: string
status: TaskStatus
createdAt: number
updatedAt: number
}
interface TasksSchema extends IDBSchema {
items: {
// Compound so the store key stays globally unique while the model-facing id
// remains the bare `seq`.
key: [string, number]
value: PersistedTask
indexes: { 'by-session': string }
}
}
// User-scoped like the chat-history store these are keyed against: no cross-user
// co-residency on a shared browser.
const dbh = userScopedDb<TasksSchema>('copilot-tasks', {
version: 1,
upgrade(db) {
const store = db.createObjectStore('items', { keyPath: ['sessionId', 'seq'] })
store.createIndex('by-session', 'sessionId')
}
})
function getDB() {
return dbh.whenReady()
}
/** A task before it has been assigned its session-scoped sequence number. */
export type NewTask = Omit<PersistedTask, 'sessionId' | 'seq'>
/**
* Append tasks, numbering them from the highest `seq` present when the write runs.
*
* The allocation happens INSIDE the readwrite transaction on purpose. Deriving the
* next seq from an in-memory snapshot instead lets two tabs on the same session pick
* the same number, and since `seq` is half the primary key, the second `put` silently
* replaces the first tab's task rather than failing. IndexedDB serialises overlapping
* readwrite transactions on a store, so reading the max and writing in one transaction
* closes that window.
*
* Returns undefined when IndexedDB is unavailable or the write failed — the caller
* then numbers in memory, where there is no other tab to contend with.
*/
export async function appendTasks(
sessionId: string,
drafts: NewTask[]
): Promise<PersistedTask[] | undefined> {
const db = await getDB()
if (!db) return undefined
try {
const tx = db.transaction('items', 'readwrite')
let next = 0
let cursor = await tx.store.index('by-session').openCursor(IDBKeyRange.only(sessionId))
while (cursor) {
next = Math.max(next, cursor.value.seq)
cursor = await cursor.continue()
}
const created = drafts.map((draft) => ({ ...draft, sessionId, seq: ++next }))
for (const task of created) await tx.store.put(task)
await tx.done
return created
} catch (err) {
console.error('Could not persist tasks', err)
return undefined
}
}
export async function putTask(task: PersistedTask): Promise<void> {
const db = await getDB()
if (!db) return
try {
// A rejected write (most likely QuotaExceededError) leaves the task usable for the
// session but unpersisted — degrade like the reads rather than throwing at the caller.
await db.put('items', task)
} catch (err) {
console.error('Could not persist task', err)
}
}
export async function listTasksForSession(sessionId: string): Promise<PersistedTask[]> {
const db = await getDB()
if (!db) return []
try {
return await db.getAllFromIndex('items', 'by-session', sessionId)
} catch (err) {
console.error('Could not read tasks', err)
return []
}
}
export async function deleteTask(sessionId: string, seq: number): Promise<void> {
const db = await getDB()
if (!db) return
try {
await db.delete('items', [sessionId, seq])
} catch (err) {
console.error('Could not delete task', err)
}
}
export async function deleteTasksForSession(sessionId: string): Promise<void> {
const db = await getDB()
if (!db) return
try {
const tx = db.transaction('items', 'readwrite')
const index = tx.store.index('by-session')
let cursor = await index.openCursor(sessionId)
while (cursor) {
await cursor.delete()
cursor = await cursor.continue()
}
await tx.done
} catch (err) {
console.error('Could not delete tasks for session', err)
}
}
@@ -0,0 +1,167 @@
import {
appendTasks,
deleteTask,
listTasksForSession,
putTask,
type NewTask,
type PersistedTask,
type TaskStatus
} from './tasksDB'
export interface CreateTaskInput {
subject: string
description: string
activeForm?: string
}
export interface UpdateTaskInput {
subject?: string
description?: string
activeForm?: string
status?: TaskStatus
}
/**
* Reactive view of the active session's task list, owned by AIChatManager (like
* SessionArtifactsStore). The consumer drives which session is loaded via setSession();
* the tools mutate through createMany/update/remove, which persist and update the
* in-memory list in one step.
*
* Ordered by `seq` — creation order is plan order, and the sequential tool loop can only
* honor precedence expressed as ordering.
*/
export class SessionTasksStore {
tasks = $state<PersistedTask[]>([])
#sessionId: string | undefined
// A later load always wins, even if an earlier DB read resolves after it.
#loadToken = 0
/** Load the given session's tasks into the reactive list, if it changed. */
async setSession(sessionId: string | undefined): Promise<void> {
// Skip same-id resyncs: in-memory owns the loaded session, so a DB reload would
// drop tasks whose best-effort persist failed.
if (sessionId === this.#sessionId) return
this.#sessionId = sessionId
await this.#load()
}
async #load(): Promise<void> {
const token = ++this.#loadToken
const id = this.#sessionId
if (!id) {
this.tasks = []
return
}
const items = await listTasksForSession(id)
if (token !== this.#loadToken) return
this.tasks = sortBySeq(items)
}
// Bump the token so an in-flight #load, whose snapshot predates this write, cannot
// clobber it.
#applyWrite(next: PersistedTask[]): void {
this.#loadToken++
this.tasks = next
}
async listForSession(sessionId: string): Promise<PersistedTask[]> {
if (sessionId === this.#sessionId) return [...this.tasks]
return sortBySeq(await listTasksForSession(sessionId))
}
/**
* Every task currently being worked on. Plural on purpose: a test run detaches into
* the background after DETACH_AFTER_MS (or immediately, when the model asks), which
* frees the chat loop to work on the next task while the job is still running — so
* more than one task is genuinely in flight.
*/
get activeTasks(): PersistedTask[] {
return this.tasks.filter((t) => t.status === 'in_progress')
}
/**
* Append tasks to `sessionId`'s plan. Batch because a plan created in one call is
* one transcript card rather than N. Sequence numbers are assigned by the store
* layer inside the write transaction (see appendTasks) so a second tab on the same
* session cannot allocate the same number and overwrite this write.
*/
async createMany(sessionId: string, inputs: CreateTaskInput[]): Promise<PersistedTask[]> {
const now = Date.now()
const drafts: NewTask[] = inputs.map((input) => ({
subject: input.subject,
description: input.description,
activeForm: input.activeForm,
status: 'pending' as TaskStatus,
createdAt: now,
updatedAt: now
}))
// Without IndexedDB the tasks live only in this store, so numbering from its own
// list is both sufficient and the only option.
let created = await appendTasks(sessionId, drafts)
if (!created) {
const existing = sessionId === this.#sessionId ? this.tasks : []
let next = existing.reduce((max, t) => Math.max(max, t.seq), 0)
created = drafts.map((draft) => ({ ...draft, sessionId, seq: ++next }))
}
if (sessionId === this.#sessionId) {
this.#applyWrite(sortBySeq([...this.tasks, ...created]))
}
return created
}
/** Merge changes into one task. Returns undefined if the session has no such seq. */
async update(
sessionId: string,
seq: number,
input: UpdateTaskInput
): Promise<PersistedTask | undefined> {
const source = sessionId === this.#sessionId ? this.tasks : await listTasksForSession(sessionId)
const existing = source.find((t) => t.seq === seq)
if (!existing) return undefined
const updated: PersistedTask = {
...existing,
subject: input.subject ?? existing.subject,
description: input.description ?? existing.description,
activeForm: input.activeForm ?? existing.activeForm,
status: input.status ?? existing.status,
updatedAt: Date.now()
}
await putTask(updated)
if (sessionId === this.#sessionId) {
this.#applyWrite(this.tasks.map((t) => (t.seq === seq ? updated : t)))
}
return updated
}
/** Drop one task. Returns false if the session has no such seq. */
async remove(sessionId: string, seq: number): Promise<boolean> {
const source = sessionId === this.#sessionId ? this.tasks : await listTasksForSession(sessionId)
if (!source.some((t) => t.seq === seq)) return false
await deleteTask(sessionId, seq)
if (sessionId === this.#sessionId) {
this.#applyWrite(this.tasks.filter((t) => t.seq !== seq))
}
return true
}
}
function sortBySeq(items: PersistedTask[]): PersistedTask[] {
return [...items].sort((a, b) => a.seq - b.seq)
}
/**
* One-line state of the plan, returned on every write so the model tracks progress
* without the tool echoing back the list it just authored.
*/
export function summarizeTasks(tasks: PersistedTask[]): string {
if (tasks.length === 0) return 'No tasks.'
const done = tasks.filter((t) => t.status === 'completed').length
// Names every running task, not just the first — with backgrounded jobs several
// can be in flight, and reporting one would tell the model its own plan is
// narrower than it is.
const active = tasks.filter((t) => t.status === 'in_progress').map((t) => t.subject)
const now =
active.length > 3 ? `${active.slice(0, 3).join(', ')} +${active.length - 3}` : active.join(', ')
return `${done}/${tasks.length} done` + (now ? `, now: ${now}` : '')
}
@@ -0,0 +1,149 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IDBFactory } from 'fake-indexeddb'
import { SessionTasksStore, summarizeTasks } from './tasksState.svelte'
// The user-scoping subscription is BROWSER-gated; the node test env reports false.
vi.mock('esm-env', async (orig) => ({
...(await orig<typeof import('esm-env')>()),
BROWSER: true
}))
// Stub $lib/stores + $lib/utils (userScopedStorage's only deps here) to keep their heavy
// svelte/app-store graphs out of the per-test cold transform.
vi.mock('$lib/stores', async () => {
const { writable } = await import('svelte/store')
return { userStore: writable(undefined) }
})
vi.mock('$lib/utils', () => ({ getLocalSetting: () => undefined, storeLocalSetting: () => {} }))
// The DB module memoises its handle at module scope. A fresh IDBFactory per test only
// isolates data once the handle is reset, so reset modules and re-import both together.
// The DB is namespaced by email, so seed a user.
async function fresh() {
vi.resetModules()
;(globalThis as any).indexedDB = new IDBFactory()
;(await import('$lib/stores')).userStore.set({ email: 'a@x.com' } as never)
const { SessionTasksStore: Store } = await import('./tasksState.svelte')
const db = await import('./tasksDB')
return { store: new Store(), makeStore: () => new Store(), db }
}
let store: SessionTasksStore
let makeStore: () => SessionTasksStore
let db: typeof import('./tasksDB')
beforeEach(async () => {
;({ store, makeStore, db } = await fresh())
})
const mk = (subject: string) => ({ subject, description: `do ${subject}` })
describe('SessionTasksStore', () => {
it('numbers tasks per session, continuing past the highest existing seq', async () => {
await store.setSession('s1')
expect((await store.createMany('s1', [mk('a'), mk('b')])).map((t) => t.seq)).toEqual([1, 2])
expect((await store.createMany('s1', [mk('c')])).map((t) => t.seq)).toEqual([3])
// A second session numbers from 1 again — seq is only unique within a session,
// which is what makes the compound [sessionId, seq] key necessary.
await store.setSession('s2')
expect((await store.createMany('s2', [mk('x')])).map((t) => t.seq)).toEqual([1])
expect(store.tasks.map((t) => t.subject)).toEqual(['x'])
})
it('keeps seq order regardless of update order, and scopes updates by session', async () => {
await store.setSession('s1')
await store.createMany('s1', [mk('a'), mk('b'), mk('c')])
await store.createMany('s2', [mk('other')])
await store.update('s1', 1, { status: 'completed' })
expect(store.tasks.map((t) => t.seq)).toEqual([1, 2, 3])
expect(store.activeTasks).toEqual([])
// Several at once: a backgrounded job leaves its task running while the agent
// starts the next one, so this must not collapse to the first.
await store.update('s1', 2, { status: 'in_progress' })
await store.update('s1', 3, { status: 'in_progress' })
expect(store.activeTasks.map((t) => t.subject)).toEqual(['b', 'c'])
// s2's seq 1 is a different task; updating it must not touch s1's. Assert on the
// subject: s1's seq 1 is already `completed`, so a status assertion would pass
// even if s2's task had replaced it wholesale.
expect(await store.update('s2', 1, { subject: 'renamed in s2' })).toBeDefined()
expect(store.tasks.find((t) => t.seq === 1)?.subject).toBe('a')
expect(await store.update('s1', 99, { status: 'completed' })).toBeUndefined()
})
// Two stores over one session = the same session open in two tabs. Numbering from
// each store's own snapshot made both pick seq 1, and since seq is half the primary
// key the second put silently destroyed the first tab's task.
it('does not lose a task when two stores append to one session', async () => {
const tabA = store
const tabB = makeStore()
await tabA.setSession('s1')
await tabB.setSession('s1')
await tabA.createMany('s1', [mk('from tab A')])
// tabB's in-memory list predates tabA's write.
await tabB.createMany('s1', [mk('from tab B')])
const persisted = await db.listTasksForSession('s1')
expect(persisted.map((t) => t.subject).sort()).toEqual(['from tab A', 'from tab B'])
expect(new Set(persisted.map((t) => t.seq)).size).toBe(2)
})
it('survives a reload of the same session', async () => {
await store.setSession('s1')
await store.createMany('s1', [mk('a'), mk('b')])
await store.update('s1', 2, { status: 'in_progress' })
await store.setSession(undefined)
await store.setSession('s1')
expect(store.tasks.map((t) => [t.seq, t.status])).toEqual([
[1, 'pending'],
[2, 'in_progress']
])
})
it('removes a task without renumbering the rest', async () => {
await store.setSession('s1')
await store.createMany('s1', [mk('a'), mk('b'), mk('c')])
await store.remove('s1', 2)
expect(store.tasks.map((t) => t.seq)).toEqual([1, 3])
// The next created task must not collide with the surviving seq 3.
expect((await store.createMany('s1', [mk('d')])).map((t) => t.seq)).toEqual([4])
})
})
describe('summarizeTasks', () => {
const task = (seq: number, subject: string, status: any) => ({
sessionId: 's',
seq,
subject,
description: '',
status,
createdAt: 0,
updatedAt: 0
})
it('reports progress and every running task', () => {
expect(summarizeTasks([])).toBe('No tasks.')
expect(
summarizeTasks([
task(1, 'a', 'completed'),
task(2, 'b', 'in_progress'),
task(3, 'c', 'pending')
])
).toBe('1/3 done, now: b')
expect(summarizeTasks([task(1, 'a', 'completed')])).toBe('1/1 done')
// Concurrent work (a detached job plus the next task) must all be named —
// reporting only the first would understate what is in flight.
expect(
summarizeTasks([
task(1, 'a', 'in_progress'),
task(2, 'b', 'in_progress'),
task(3, 'c', 'pending')
])
).toBe('0/3 done, now: a, b')
})
})
@@ -485,8 +485,9 @@ function createRuntime(session: Session): SessionRuntime {
previewTabs.open({ type: 'artifact', id, name })
}
manager.closeArtifact = (id) => previewTabs.closeArtifact(id)
// Key the store before any configureGlobalMode runs, so a new session's first create shows at once.
// Key the stores before any configureGlobalMode runs, so a new session's first create shows at once.
void manager.artifacts.setSession(session.id)
void manager.tasks.setSession(session.id)
// Pipeline target state lives on the runtime (not the PipelineEditorView
// component) so the in-session drafts survive hide/show of the editor pane —
@@ -25,6 +25,7 @@ import { type DBSchema, type IDBPDatabase } from 'idb'
import { userScopedDb } from '$lib/userScopedDb'
import { deleteItemsForSession } from '../copilot/chat/files/attachedFilesDB'
import { deleteArtifactsForSession } from '../copilot/chat/artifacts/artifactsDB'
import { deleteTasksForSession } from '../copilot/chat/tasks/tasksDB'
// Switch the global workspace iff the target differs from the active one
// and is non-empty. Centralises the "session needs its workspace in focus"
@@ -540,6 +541,7 @@ export async function reconcileSessionsLifecycle(): Promise<void> {
// here would orphan the session's attached-file blobs/handles.
void deleteItemsForSession(s.id)
void deleteArtifactsForSession(s.id)
void deleteTasksForSession(s.id)
deletedIds.add(s.id)
continue
}
@@ -632,6 +634,7 @@ export async function deleteSessionsForWorkspace(workspaceId: string): Promise<v
// doesn't leave the sessions' attached-file blobs/handles orphaned.
void deleteItemsForSession(id)
void deleteArtifactsForSession(id)
void deleteTasksForSession(id)
}
sessionState.sessions = sessionState.sessions.filter((s) => !ids.has(s.id))
if (sessionState.currentSessionId && ids.has(sessionState.currentSessionId)) {
@@ -1031,6 +1034,7 @@ export function deleteSession(id: string) {
// GC any linked files and artifacts persisted for this session.
void deleteItemsForSession(id)
void deleteArtifactsForSession(id)
void deleteTasksForSession(id)
logFeatureUsage('ai_session', 'deleted', { entityId: id, workspace: s.workspace_id })
}
@@ -23,6 +23,15 @@ vi.mock('../copilot/chat/artifacts/artifactsDB', async (orig) => ({
deleteArtifactsForSession: deleteArtifactsForSessionMock
}))
const { deleteTasksForSessionMock } = vi.hoisted(() => ({
deleteTasksForSessionMock: vi.fn()
}))
// Bare (no `orig()` spread): sessionState imports only this one symbol, and pulling the
// real module in would drag $lib/userScopedDb's store graph into this suite.
vi.mock('../copilot/chat/tasks/tasksDB', () => ({
deleteTasksForSession: deleteTasksForSessionMock
}))
// sessionState imports WorkspaceService; these tests don't touch the network.
vi.mock('$lib/gen', async (orig) => {
const actual = await orig<typeof import('$lib/gen')>()
@@ -444,13 +453,17 @@ describe('sessionState IndexedDB persistence', () => {
deleteItemsForSessionMock.mockClear()
deleteArtifactsForSessionMock.mockClear()
deleteTasksForSessionMock.mockClear()
await deleteSessionsForWorkspace('wsX')
// Both deleted sessions' linked files and artifacts must be GC'd, not just their records.
// Both deleted sessions' linked files, artifacts and task plans must be GC'd,
// not just their records.
const cleaned = deleteItemsForSessionMock.mock.calls.map((c) => c[0]).sort()
expect(cleaned).toEqual(['f1', 'f2'])
const cleanedArtifacts = deleteArtifactsForSessionMock.mock.calls.map((c) => c[0]).sort()
expect(cleanedArtifacts).toEqual(['f1', 'f2'])
const cleanedTasks = deleteTasksForSessionMock.mock.calls.map((c) => c[0]).sort()
expect(cleanedTasks).toEqual(['f1', 'f2'])
})
it('does not persist a per-session unarchive when the workspace is gone (resurrection guard)', async () => {