diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 1faa9c003b..bf85b16510 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -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() { diff --git a/frontend/src/lib/components/copilot/chat/tasks/tasksDB.ts b/frontend/src/lib/components/copilot/chat/tasks/tasksDB.ts new file mode 100644 index 0000000000..8960dd3cef --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/tasks/tasksDB.ts @@ -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('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 + +/** + * 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 { + 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 { + 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 { + 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 { + 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 { + 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) + } +} diff --git a/frontend/src/lib/components/copilot/chat/tasks/tasksState.svelte.ts b/frontend/src/lib/components/copilot/chat/tasks/tasksState.svelte.ts new file mode 100644 index 0000000000..44ce3aa355 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/tasks/tasksState.svelte.ts @@ -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([]) + + #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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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}` : '') +} diff --git a/frontend/src/lib/components/copilot/chat/tasks/tasksState.test.ts b/frontend/src/lib/components/copilot/chat/tasks/tasksState.test.ts new file mode 100644 index 0000000000..38c42d4904 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/tasks/tasksState.test.ts @@ -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()), + 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') + }) +}) diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index aaf0173140..c45c150f39 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -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 — diff --git a/frontend/src/lib/components/sessions/sessionState.svelte.ts b/frontend/src/lib/components/sessions/sessionState.svelte.ts index bfd157c1b7..30ee63c49a 100644 --- a/frontend/src/lib/components/sessions/sessionState.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionState.svelte.ts @@ -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 { // 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 !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 }) } diff --git a/frontend/src/lib/components/sessions/sessionStateIndexedDb.test.ts b/frontend/src/lib/components/sessions/sessionStateIndexedDb.test.ts index 565ab751e9..412770bdd8 100644 --- a/frontend/src/lib/components/sessions/sessionStateIndexedDb.test.ts +++ b/frontend/src/lib/components/sessions/sessionStateIndexedDb.test.ts @@ -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() @@ -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 () => {