mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-18 00:02:27 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9653eab493 | ||
|
|
da4b0211a6 | ||
|
|
4059bf8211 | ||
|
|
26f7ec9661 |
@@ -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)
|
||||
@@ -1861,6 +1864,7 @@ export class AIChatManager {
|
||||
sessionId: this.sessionId,
|
||||
operatingWorkspace: this.operatingWorkspace,
|
||||
artifacts: this.artifacts,
|
||||
tasks: this.tasks,
|
||||
getChatId: () => this.historyManager.getCurrentChatId(),
|
||||
openArtifact: this.openArtifact
|
||||
}
|
||||
@@ -1890,7 +1894,7 @@ export class AIChatManager {
|
||||
this.helpers = baseHelpers
|
||||
}
|
||||
this.systemMessage = systemMessage
|
||||
this.syncArtifactsSession()
|
||||
this.syncSessionStores()
|
||||
}
|
||||
|
||||
refreshGlobalSkills = async (workspace = this.operatingWorkspace ?? '') => {
|
||||
@@ -3526,7 +3530,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 +3572,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() {
|
||||
|
||||
@@ -111,6 +111,8 @@ import { getDucklakeTools } from '../ducklakeTools'
|
||||
import { fileTools } from '../files/fileTools'
|
||||
import type { AttachedFilesStore } from '../files/attachedFiles.svelte'
|
||||
import { artifactTools } from '../artifacts/artifactTools'
|
||||
import { taskTools } from '../tasks/taskTools'
|
||||
import type { SessionTasksStore } from '../tasks/tasksState.svelte'
|
||||
import type { SessionArtifactsStore } from '../artifacts/artifactsState.svelte'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { emptySchema } from '$lib/utils'
|
||||
@@ -1229,7 +1231,8 @@ ${
|
||||
: `- When the user raises how a raw app looks (something is off, or they want the design or layout improved) and their description alone isn't specific enough to pinpoint the problem, ask them to paste or drop a screenshot of it into the chat before changing anything.`
|
||||
}
|
||||
- open_page opens its page as a tab in the side-panel preview next to the chat — the only way to show one of these pages there (open_preview only handles editable items). Changing filters on a page already open updates that same tab; only pass new_tab when the user explicitly asks for a separate tab.
|
||||
- create_artifact saves a persistent markdown document (a planning doc, design write-up, spec, or other longer structured output) shown in the session preview panel. Prefer it over a long inline reply for content the user will revisit; keep brief answers inline. To revise one, call list_artifacts then read_artifact for the current content, then update_artifact to overwrite it — never create a second artifact for the same document. Each content change is saved as a version, keeping the most recent ones: use list_artifact_versions and read_artifact's version argument to recover earlier wording the user asks to go back to, rather than rewriting it from memory. list_artifact_versions is the source of truth for what is still available — do not assume a version that is not listed.`
|
||||
- create_artifact saves a persistent markdown document (a planning doc, design write-up, spec, or other longer structured output) shown in the session preview panel. Prefer it over a long inline reply for content the user will revisit; keep brief answers inline. To revise one, call list_artifacts then read_artifact for the current content, then update_artifact to overwrite it — never create a second artifact for the same document. Each content change is saved as a version, keeping the most recent ones: use list_artifact_versions and read_artifact's version argument to recover earlier wording the user asks to go back to, rather than rewriting it from memory. list_artifact_versions is the source of truth for what is still available — do not assume a version that is not listed.
|
||||
- For work spanning three or more distinct steps, call create_tasks once up front with the whole plan, so the user can follow along. Skip it for a single straightforward change — a task list for trivial work is noise. Mark a task in_progress before you start it (update_task), and completed as soon as it is genuinely done: do not batch completions at the end, and do not mark a task completed if it is partial, its tests fail, or you hit an error you could not resolve — leave it in_progress and say what blocked you. Usually one task runs at a time and you should work in id order, but when you start something that keeps running without you — a test run you let detach into the background — leave it in_progress and mark the next task in_progress too, so the list shows everything actually in flight rather than hiding the backgrounded work. If the plan turns out wrong, revise it (update_task, including status "deleted") rather than silently abandoning it, and call list_tasks to re-read the plan when you have lost track of what is left.`
|
||||
: ''
|
||||
}
|
||||
|
||||
@@ -3452,6 +3455,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
}
|
||||
},
|
||||
...artifactTools,
|
||||
...taskTools,
|
||||
{
|
||||
def: createToolDef(
|
||||
openPreviewSchema,
|
||||
@@ -3648,7 +3652,10 @@ export const SESSION_PREVIEW_TOOL_NAMES = new Set([
|
||||
'update_artifact',
|
||||
'list_artifacts',
|
||||
'read_artifact',
|
||||
'list_artifact_versions'
|
||||
'list_artifact_versions',
|
||||
'create_tasks',
|
||||
'update_task',
|
||||
'list_tasks'
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -3708,6 +3715,8 @@ export type GlobalToolHelpers = SessionToolHelpers & {
|
||||
operatingWorkspace?: string
|
||||
// Wired only for session chats (see AIChatManager): the artifact tools are session-gated.
|
||||
artifacts?: SessionArtifactsStore
|
||||
// Wired only for session chats, like `artifacts`: the task tools are session-gated.
|
||||
tasks?: SessionTasksStore
|
||||
getChatId?: () => string | undefined
|
||||
// Live snapshot of the items this chat modified (`kind:path` mask keys, see
|
||||
// modifiedItemsMask.ts); undefined when the chat doesn't track them (the global
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import { Check, Circle, CircleDashed } from 'lucide-svelte'
|
||||
import type { SessionTasksStore } from './tasksState.svelte'
|
||||
|
||||
// `loading` is the agent's turn state, not the task's: keying the pulse off
|
||||
// `in_progress` would leave a stalled or awaiting-input session animating forever.
|
||||
let { store, loading = false }: { store: SessionTasksStore; loading?: boolean } = $props()
|
||||
|
||||
const tasks = $derived(store.tasks)
|
||||
const done = $derived(tasks.filter((t) => t.status === 'completed').length)
|
||||
const active = $derived(store.activeTasks)
|
||||
const allDone = $derived(tasks.length > 0 && done === tasks.length)
|
||||
|
||||
const label = $derived(
|
||||
`Plan: ${done} of ${tasks.length} done` +
|
||||
(active.length ? `, currently ${active.map((t) => t.subject).join(', ')}` : '')
|
||||
)
|
||||
</script>
|
||||
|
||||
{#if tasks.length > 0}
|
||||
<!-- bottom-end pins the peek's right edge to the trigger's so it grows leftward into
|
||||
the chat. Opening rightward would lay it over the preview panel. -->
|
||||
<Popover
|
||||
openOnHover
|
||||
debounceDelay={50}
|
||||
placement="bottom-end"
|
||||
contentClasses="p-0"
|
||||
class="flex shrink-0 items-center gap-1.5 rounded px-1.5 py-0.5 hover:bg-surface-hover"
|
||||
triggerAttrs={{ 'aria-label': label }}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<!-- Fixed width with the segments flexing inside it: a task added mid-run must
|
||||
not reflow the elastic session name beside it. Past ~8 the gaps would eat
|
||||
more width than the segments, so they tighten. -->
|
||||
<span
|
||||
class="flex w-14 shrink-0 {tasks.length > 8 ? 'gap-px' : 'gap-[1.5px]'}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#each tasks as task (task.seq)}
|
||||
<span
|
||||
class="h-1.5 flex-1 rounded-[1px] {task.status === 'completed'
|
||||
? 'bg-green-500'
|
||||
: task.status === 'in_progress'
|
||||
? `bg-indigo-500 ${loading ? 'motion-safe:animate-pulse' : ''}`
|
||||
: 'bg-gray-200 dark:bg-gray-600'}"
|
||||
></span>
|
||||
{/each}
|
||||
</span>
|
||||
<span
|
||||
class="text-2xs font-medium tabular-nums {allDone
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-secondary'}"
|
||||
>
|
||||
{done}/{tasks.length}
|
||||
</span>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="w-72 max-w-[80vw] p-1">
|
||||
<div
|
||||
class="flex items-center justify-between px-1.5 pb-1 pt-0.5 text-2xs uppercase tracking-wide text-hint"
|
||||
>
|
||||
<span>Plan</span>
|
||||
<span class="tabular-nums">{done} of {tasks.length}</span>
|
||||
</div>
|
||||
<div class="flex max-h-64 flex-col overflow-y-auto">
|
||||
{#each tasks as task (task.seq)}
|
||||
<div
|
||||
class="flex items-center gap-1.5 rounded px-1.5 py-1 text-xs {task.status ===
|
||||
'in_progress'
|
||||
? 'bg-indigo-50 font-medium dark:bg-indigo-500/15'
|
||||
: ''}"
|
||||
>
|
||||
{#if task.status === 'completed'}
|
||||
<Check size={12} class="shrink-0 text-green-500" />
|
||||
{:else if task.status === 'in_progress'}
|
||||
<!-- Solid against the pending dashed ring: at 12px the two must differ in
|
||||
silhouette, not just hue. Pulses with the bar's running segment. -->
|
||||
<Circle
|
||||
size={12}
|
||||
class="shrink-0 fill-indigo-500 text-indigo-500 {loading
|
||||
? 'motion-safe:animate-pulse'
|
||||
: ''}"
|
||||
/>
|
||||
{:else}
|
||||
<CircleDashed size={12} class="shrink-0 text-hint" />
|
||||
{/if}
|
||||
<span class="w-4 shrink-0 text-right text-2xs tabular-nums text-hint">{task.seq}</span
|
||||
>
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate {task.status === 'completed'
|
||||
? 'text-hint line-through'
|
||||
: 'text-primary'}"
|
||||
title={task.description}
|
||||
>
|
||||
<!-- The active row reads in the present continuous, which is what
|
||||
activeForm is for; the others state the task itself. -->
|
||||
{task.status === 'in_progress' ? (task.activeForm ?? task.subject) : task.subject}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { IDBFactory } from 'fake-indexeddb'
|
||||
import type { Tool } from '../shared'
|
||||
|
||||
// The real ../shared pulls the whole component/monaco graph, which the node test env can't
|
||||
// load. taskTools only needs createToolDef to stamp the function name (as artifactTools.test).
|
||||
vi.mock('../shared', () => ({
|
||||
createToolDef: (_schema: unknown, name: string, description: string) => ({
|
||||
type: 'function',
|
||||
function: { name, description, parameters: {} }
|
||||
})
|
||||
}))
|
||||
|
||||
// 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
|
||||
}))
|
||||
|
||||
vi.mock('$lib/stores', async () => {
|
||||
const { writable } = await import('svelte/store')
|
||||
return { userStore: writable(undefined) }
|
||||
})
|
||||
vi.mock('$lib/utils', () => ({ getLocalSetting: () => undefined, storeLocalSetting: () => {} }))
|
||||
|
||||
// sessionId has no default: passing undefined must stay undefined (the no-session case),
|
||||
// not fall back to 's1' as a defaulted parameter would.
|
||||
async function fresh(sessionId: string | undefined) {
|
||||
vi.resetModules()
|
||||
;(globalThis as any).indexedDB = new IDBFactory()
|
||||
;(await import('$lib/stores')).userStore.set({ email: 'a@x.com' } as never)
|
||||
const { SessionTasksStore } = await import('./tasksState.svelte')
|
||||
const { taskTools } = await import('./taskTools')
|
||||
const dbMod = await import('./tasksDB')
|
||||
const store = new SessionTasksStore()
|
||||
if (sessionId) await store.setSession(sessionId)
|
||||
const statuses: any[] = []
|
||||
const helpers = { tasks: store, sessionId }
|
||||
const byName = Object.fromEntries(taskTools.map((t) => [t.def.function.name, t])) as Record<
|
||||
string,
|
||||
Tool<{}>
|
||||
>
|
||||
const call = (name: string, args: any) =>
|
||||
byName[name].fn({
|
||||
args,
|
||||
workspace: 'w',
|
||||
helpers,
|
||||
toolId: 't',
|
||||
toolCallbacks: { setToolStatus: (_id: string, m: any) => statuses.push(m) } as any
|
||||
})
|
||||
return { call, store, dbMod, statuses }
|
||||
}
|
||||
|
||||
const plan = [
|
||||
{ subject: 'Read the flow', description: 'read it' },
|
||||
{ subject: 'Wire the branch', description: 'wire it', activeForm: 'Wiring the branch' }
|
||||
]
|
||||
|
||||
let ctx: Awaited<ReturnType<typeof fresh>>
|
||||
beforeEach(async () => {
|
||||
ctx = await fresh('s1')
|
||||
})
|
||||
|
||||
describe('task tools', () => {
|
||||
it('create_tasks persists the plan and returns ids plus a progress summary', async () => {
|
||||
const res = JSON.parse(await ctx.call('create_tasks', { tasks: plan }))
|
||||
expect(res).toMatchObject({ success: true, ids: [1, 2], summary: '0/2 done' })
|
||||
const stored = await ctx.dbMod.listTasksForSession('s1')
|
||||
expect(stored.map((t) => [t.seq, t.subject, t.status])).toEqual([
|
||||
[1, 'Read the flow', 'pending'],
|
||||
[2, 'Wire the branch', 'pending']
|
||||
])
|
||||
})
|
||||
|
||||
it('update_task reports progress without echoing the task back', async () => {
|
||||
await ctx.call('create_tasks', { tasks: plan })
|
||||
const res = JSON.parse(await ctx.call('update_task', { id: 2, status: 'in_progress' }))
|
||||
// The model authored these; re-sending them every turn is the echo the ai-chat
|
||||
// skill forbids, so the payload stays {success, summary}.
|
||||
expect(Object.keys(res).sort()).toEqual(['success', 'summary'])
|
||||
expect(res.summary).toBe('0/2 done, now: Wire the branch')
|
||||
})
|
||||
|
||||
it('update_task with status deleted removes the task and leaves the rest numbered', async () => {
|
||||
await ctx.call('create_tasks', { tasks: plan })
|
||||
const res = JSON.parse(await ctx.call('update_task', { id: 1, status: 'deleted' }))
|
||||
expect(res.success).toBe(true)
|
||||
const stored = await ctx.dbMod.listTasksForSession('s1')
|
||||
expect(stored.map((t) => t.seq)).toEqual([2])
|
||||
})
|
||||
|
||||
it('update_task fails on an unknown id rather than creating one', async () => {
|
||||
await ctx.call('create_tasks', { tasks: plan })
|
||||
for (const args of [
|
||||
{ id: 99, status: 'completed' },
|
||||
{ id: 99, status: 'deleted' }
|
||||
]) {
|
||||
const res = JSON.parse(await ctx.call('update_task', args))
|
||||
expect(res.success).toBe(false)
|
||||
expect(res.error).toContain('99')
|
||||
}
|
||||
expect(await ctx.dbMod.listTasksForSession('s1')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('list_tasks returns the plan with descriptions, for recovery after compaction', async () => {
|
||||
await ctx.call('create_tasks', { tasks: plan })
|
||||
const res = JSON.parse(await ctx.call('list_tasks', {}))
|
||||
expect(res).toEqual([
|
||||
{ id: 1, subject: 'Read the flow', description: 'read it', status: 'pending' },
|
||||
{ id: 2, subject: 'Wire the branch', description: 'wire it', status: 'pending' }
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects an empty plan and one past the per-call cap', async () => {
|
||||
const empty = JSON.parse(await ctx.call('create_tasks', { tasks: [] }))
|
||||
expect(empty.success).toBe(false)
|
||||
|
||||
const tooMany = Array.from({ length: 21 }, (_, i) => ({ subject: `t${i}`, description: 'd' }))
|
||||
const capped = JSON.parse(await ctx.call('create_tasks', { tasks: tooMany }))
|
||||
expect(capped.success).toBe(false)
|
||||
expect(capped.error).toContain('21')
|
||||
expect(await ctx.dbMod.listTasksForSession('s1')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('fails closed outside a session', async () => {
|
||||
const noSession = await fresh(undefined)
|
||||
for (const [name, args] of [
|
||||
['create_tasks', { tasks: plan }],
|
||||
['update_task', { id: 1, status: 'completed' }],
|
||||
['list_tasks', {}]
|
||||
] as const) {
|
||||
const res = JSON.parse(await noSession.call(name, args))
|
||||
expect(res.success).toBe(false)
|
||||
expect(res.error).toMatch(/only available inside an AI session/i)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import { z } from 'zod'
|
||||
import { createToolDef, type Tool } from '../shared'
|
||||
import { summarizeTasks, type SessionTasksStore } from './tasksState.svelte'
|
||||
|
||||
// The subset of GlobalToolHelpers these tools read. Kept local (not imported from
|
||||
// global/core) so the tools don't pull the whole global tool module — which would be a
|
||||
// circular import, since global/core registers these tools.
|
||||
type TaskToolHelpers = {
|
||||
tasks?: SessionTasksStore
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
const MAX_TASKS_PER_CALL = 20
|
||||
|
||||
const createTasksSchema = z.object({
|
||||
tasks: z
|
||||
.array(
|
||||
z.object({
|
||||
subject: z.string().describe('Brief imperative title, e.g. "Fix the auth redirect".'),
|
||||
description: z.string().describe('What needs to be done.'),
|
||||
activeForm: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Present continuous form shown while this task runs, e.g. "Fixing the auth redirect".'
|
||||
)
|
||||
})
|
||||
)
|
||||
.describe('The tasks to add, in the order they should be worked on.')
|
||||
})
|
||||
|
||||
const updateTaskSchema = z.object({
|
||||
id: z.number().int().describe('Id of the task to update, as returned by create_tasks.'),
|
||||
status: z
|
||||
.enum(['pending', 'in_progress', 'completed', 'deleted'])
|
||||
.optional()
|
||||
.describe('New status. "deleted" drops the task from the plan.'),
|
||||
subject: z.string().optional().describe('New title.'),
|
||||
description: z.string().optional().describe('New description.'),
|
||||
activeForm: z.string().optional().describe('New present continuous form.')
|
||||
})
|
||||
|
||||
const listTasksSchema = z.object({})
|
||||
|
||||
const UNAVAILABLE = 'Tasks are only available inside an AI session.'
|
||||
|
||||
type ToolRun = Parameters<Tool<{}>['fn']>[0]
|
||||
type TaskCtx = {
|
||||
tasks: SessionTasksStore
|
||||
sessionId: string
|
||||
/** Report an error on the transcript card and to the model in one step. */
|
||||
fail: (error: string) => string
|
||||
/** The plan's one-line state, read back after a write. */
|
||||
summary: () => Promise<string>
|
||||
/** Note what the call did on the transcript card. */
|
||||
note: (content: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `body` with the session's task store resolved, or fail closed: these tools are
|
||||
* offered to session chats only, so anywhere else there is no plan to act on.
|
||||
*/
|
||||
function withTasks(body: (ctx: TaskCtx, run: ToolRun) => Promise<string>) {
|
||||
return async (run: ToolRun): Promise<string> => {
|
||||
const { toolId, toolCallbacks } = run
|
||||
const fail = (error: string) => {
|
||||
toolCallbacks.setToolStatus(toolId, { content: error, error })
|
||||
return JSON.stringify({ success: false, error })
|
||||
}
|
||||
const { tasks, sessionId } = (run.helpers ?? {}) as TaskToolHelpers
|
||||
if (!tasks || !sessionId) return fail(UNAVAILABLE)
|
||||
return body(
|
||||
{
|
||||
tasks,
|
||||
sessionId,
|
||||
fail,
|
||||
summary: async () => summarizeTasks(await tasks.listForSession(sessionId)),
|
||||
note: (content) => toolCallbacks.setToolStatus(toolId, { content })
|
||||
},
|
||||
run
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const taskTools: Tool<{}>[] = [
|
||||
{
|
||||
def: createToolDef(
|
||||
createTasksSchema,
|
||||
'create_tasks',
|
||||
"Add tasks to the current session's plan, all at once, in the order they should be worked on. Use for work spanning three or more distinct steps; skip it for a single straightforward change. All tasks start as pending — call update_task to set one in_progress before starting it."
|
||||
),
|
||||
showDetails: true,
|
||||
fn: withTasks(async (ctx, { args }) => {
|
||||
const { tasks } = createTasksSchema.parse(args)
|
||||
if (tasks.length === 0) return ctx.fail('Provide at least one task.')
|
||||
if (tasks.length > MAX_TASKS_PER_CALL) {
|
||||
return ctx.fail(
|
||||
`Too many tasks (${tasks.length}, limit ${MAX_TASKS_PER_CALL}). Plan the next few steps instead.`
|
||||
)
|
||||
}
|
||||
const created = await ctx.tasks.createMany(ctx.sessionId, tasks)
|
||||
ctx.note(`Added ${created.length} task${created.length === 1 ? '' : 's'}`)
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
ids: created.map((t) => t.seq),
|
||||
summary: await ctx.summary()
|
||||
})
|
||||
})
|
||||
},
|
||||
{
|
||||
def: createToolDef(
|
||||
updateTaskSchema,
|
||||
'update_task',
|
||||
"Update one task in the current session's plan: mark it in_progress before starting, completed once it is genuinely done, or revise its wording. Keep it in_progress if you hit an error or could not finish."
|
||||
),
|
||||
showDetails: true,
|
||||
fn: withTasks(async (ctx, { args }) => {
|
||||
const { id, status, ...fields } = updateTaskSchema.parse(args)
|
||||
const notFound = `No task with id ${id} in this session.`
|
||||
|
||||
if (status === 'deleted') {
|
||||
if (!(await ctx.tasks.remove(ctx.sessionId, id))) return ctx.fail(notFound)
|
||||
ctx.note(`Deleted task ${id}`)
|
||||
return JSON.stringify({ success: true, summary: await ctx.summary() })
|
||||
}
|
||||
const updated = await ctx.tasks.update(ctx.sessionId, id, { status, ...fields })
|
||||
if (!updated) return ctx.fail(notFound)
|
||||
ctx.note(`${statusVerb(updated.status)} "${updated.subject}"`)
|
||||
return JSON.stringify({ success: true, summary: await ctx.summary() })
|
||||
})
|
||||
},
|
||||
{
|
||||
def: createToolDef(
|
||||
listTasksSchema,
|
||||
'list_tasks',
|
||||
"Re-read the current session's plan (id, subject, description, status). Use it to recover the plan after a long run, when you are no longer sure what is left."
|
||||
),
|
||||
fn: withTasks(async (ctx) => {
|
||||
const items = await ctx.tasks.listForSession(ctx.sessionId)
|
||||
ctx.note(summarizeTasks(items))
|
||||
return JSON.stringify(
|
||||
items.map((t) => ({
|
||||
id: t.seq,
|
||||
subject: t.subject,
|
||||
description: t.description,
|
||||
status: t.status
|
||||
}))
|
||||
)
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
function statusVerb(status: string): string {
|
||||
return status === 'completed' ? 'Completed' : status === 'in_progress' ? 'Started' : 'Updated'
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,7 @@
|
||||
import WorkspaceScopeTrigger from '$lib/components/WorkspaceScopeTrigger.svelte'
|
||||
import SessionWorkspaceBar from './SessionWorkspaceBar.svelte'
|
||||
import SessionChangesBar from './SessionChangesBar.svelte'
|
||||
import SessionPlanIndicator from '$lib/components/copilot/chat/tasks/SessionPlanIndicator.svelte'
|
||||
import {
|
||||
composerFocusRequest,
|
||||
createSession,
|
||||
@@ -52,12 +53,17 @@
|
||||
|
||||
// headerInset: extra left padding on the chat header so it clears a floating
|
||||
// control (the collapsed-rail launcher) sitting at the screen's top-left.
|
||||
// headerRightInset: the same on the right, for the "Open side panel" launcher the
|
||||
// page floats over the header's right edge while the preview is collapsed. Only
|
||||
// the page knows whether it is showing that button, so it owns the flag.
|
||||
let {
|
||||
sessionId,
|
||||
headerInset = false
|
||||
headerInset = false,
|
||||
headerRightInset = false
|
||||
}: {
|
||||
sessionId: string
|
||||
headerInset?: boolean
|
||||
headerRightInset?: boolean
|
||||
} = $props()
|
||||
|
||||
// Parent keys by sessionId; this wrapper only mounts when the session exists.
|
||||
@@ -334,9 +340,9 @@
|
||||
<Splitpanes horizontal={false} class="flex-1 min-h-0 splitter-hidden">
|
||||
<Pane minSize={25} class="flex flex-col min-h-0 pb-2">
|
||||
<header
|
||||
class="flex flex-row items-center gap-1 {headerInset
|
||||
? 'pl-11'
|
||||
: 'pl-4'} pr-4 py-2 shrink-0"
|
||||
class="flex flex-row items-center gap-1 {headerInset ? 'pl-11' : 'pl-4'} {headerRightInset
|
||||
? 'pr-44'
|
||||
: 'pr-4'} py-2 shrink-0"
|
||||
>
|
||||
<EditableInput
|
||||
bind:this={summaryInput}
|
||||
@@ -415,6 +421,10 @@
|
||||
</NameIdTooltip>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Right-aligned: every other header child sizes to its content, so this
|
||||
spacer is the only flexible one. -->
|
||||
<div class="flex-1 min-w-[0.5rem]"></div>
|
||||
<SessionPlanIndicator store={runtime.manager.tasks} loading={runtime.manager.loading} />
|
||||
</header>
|
||||
<div class="flex-1 min-h-0 w-full flex flex-col {hasFirstUserMessage ? '' : 'pt-8'}">
|
||||
<AIChat
|
||||
|
||||
@@ -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')>()
|
||||
@@ -65,6 +74,32 @@ function session(over: Partial<Session> = {}): Session {
|
||||
}
|
||||
const flush = () => new Promise<void>((r) => setTimeout(r, 0))
|
||||
|
||||
// Poll the stored record until `check` holds. A tick of `flush` is not a write
|
||||
// barrier: the persisting calls are fire-and-forget, so anything that reads the
|
||||
// store back has to wait for the record itself.
|
||||
async function waitForRecord(user: UserExt, id: string, check: (rec: Session | undefined) => void) {
|
||||
const name = `windmill-sessions::${user.email}`
|
||||
// Generous, because the wait covers opening sessionState's own scoped handle as well
|
||||
// as the write, and a loaded machine running the whole suite can take seconds over it.
|
||||
// Callers raise their own test timeout to match.
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
// Opening before the writer has created the database would create it here
|
||||
// instead — at version 1, with no object store, which silently breaks every
|
||||
// later write to it.
|
||||
const exists = (await indexedDB.databases()).some((d) => d.name === name)
|
||||
expect(exists).toBe(true)
|
||||
const db = await openDB(name, 1)
|
||||
try {
|
||||
check((await db.get('sessions' as never, id)) as Session | undefined)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
},
|
||||
{ timeout: 15000 }
|
||||
)
|
||||
}
|
||||
|
||||
// Each test uses a distinct email: the module-level sessionsDb handle gates its
|
||||
// legacy migration to once-per-scoped-name for the process, and fresh emails
|
||||
// also dodge any cross-test cached connection.
|
||||
@@ -160,12 +195,15 @@ describe('sessionState IndexedDB persistence', () => {
|
||||
await putSession(s)
|
||||
|
||||
setSessionPreviewSize('ps1', 42)
|
||||
await flush()
|
||||
// The write is fire-and-forget, and rehydrating reads the store once: start it
|
||||
// before the put lands and the hydrated list is simply stale forever.
|
||||
await waitForRecord(user, 'ps1', (r) => expect(r?.previewSize).toBe(42))
|
||||
|
||||
await rehydrate(user)
|
||||
await flush()
|
||||
expect(sessionState.sessions.find((x) => x.id === 'ps1')?.previewSize).toBe(42)
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(sessionState.sessions.find((x) => x.id === 'ps1')?.previewSize).toBe(42)
|
||||
)
|
||||
}, 20000)
|
||||
|
||||
it('materializeTransient promotes an in-memory draft to a persisted IndexedDB record', async () => {
|
||||
const user = freshUser()
|
||||
@@ -176,11 +214,12 @@ describe('sessionState IndexedDB persistence', () => {
|
||||
sessionState.sessions = [s]
|
||||
materializeTransient('t2')
|
||||
expect(s.transient).toBeUndefined()
|
||||
await waitForRecord(user, 't2', (r) => expect(r).toBeTruthy())
|
||||
|
||||
await rehydrate(user)
|
||||
await vi.waitFor(() => expect(sessionState.sessions.map((x) => x.id)).toEqual(['t2']))
|
||||
expect(sessionState.sessions[0].transient).toBeUndefined()
|
||||
})
|
||||
}, 20000)
|
||||
|
||||
it('round-trips the unsent draft prompt on the session record', async () => {
|
||||
const user = freshUser()
|
||||
@@ -414,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 () => {
|
||||
|
||||
@@ -779,7 +779,10 @@
|
||||
: 'z-0 opacity-0 pointer-events-none'}"
|
||||
aria-hidden={s.id !== activeSession?.id}
|
||||
>
|
||||
<SessionWrapper sessionId={s.id} />
|
||||
<SessionWrapper
|
||||
sessionId={s.id}
|
||||
headerRightInset={previewCollapsed && !fullscreen}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user