From a6ac57525270d12c35cf1e29410f7780ad39b34f Mon Sep 17 00:00:00 2001 From: QAyong Date: Thu, 8 Oct 2026 00:45:00 +0800 Subject: [PATCH 1/2] =?UTF-8?q?perf(buddy):=20=E4=BC=9A=E8=AF=9D=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E5=90=8E=E5=8D=B3=E6=97=B6=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/instantTaskSwitch.spec.ts | 30 +++++++++++++++++++ .../workbench/registerDesktopContributions.ts | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 apps/buddy/src/app/workbench/__tests__/instantTaskSwitch.spec.ts diff --git a/apps/buddy/src/app/workbench/__tests__/instantTaskSwitch.spec.ts b/apps/buddy/src/app/workbench/__tests__/instantTaskSwitch.spec.ts new file mode 100644 index 00000000..6436de77 --- /dev/null +++ b/apps/buddy/src/app/workbench/__tests__/instantTaskSwitch.spec.ts @@ -0,0 +1,30 @@ +import type { WorkingCopyService } from '@/workbench/services/WorkingCopyService' +import { describe, expect, it } from 'vitest' +import { ViewRendererRegistry } from '@/workbench/browser/ViewRendererRegistry' +import { ContributionRegistry } from '@/workbench/services/ContributionRegistry' +import { WorkbenchController } from '@/workbench/services/WorkbenchController' +import { registerDesktopContributions } from '../registerDesktopContributions' + +describe('instant task view switching', () => { + it('commits a regular task replacement without waiting for the view to become ready', async () => { + const controller = new WorkbenchController(new ContributionRegistry()) + const copies = {} as WorkingCopyService + registerDesktopContributions(controller, new ViewRendererRegistry(), copies, () => 'en-US') + + try { + expect(controller.registry.views.get('tasks.editor')?.prepareBeforeOpen).toBe(false) + + const first = await controller.open({ scheme: 'task', id: 'conversation-a', data: {} }, 'Conversation A') + const second = await controller.open({ scheme: 'task', id: 'conversation-b', data: {} }, 'Conversation B') + + expect(second).not.toBeNull() + expect(controller.pane(controller.layout.activePane)?.view).toBe(second) + expect(controller.layout.views[first!]).toBeUndefined() + expect(controller.navigation.entries.size).toBe(0) + } + finally { + await controller.dispose() + controller.registry.dispose() + } + }) +}) diff --git a/apps/buddy/src/app/workbench/registerDesktopContributions.ts b/apps/buddy/src/app/workbench/registerDesktopContributions.ts index 6861879a..ca2edd5f 100644 --- a/apps/buddy/src/app/workbench/registerDesktopContributions.ts +++ b/apps/buddy/src/app/workbench/registerDesktopContributions.ts @@ -14,7 +14,7 @@ export function registerDesktopContributions(controller: WorkbenchController, re const labels = () => presentation.value controller.registry.register('lexora.tasks', (scope) => { scope.cleanup(renderers.register('tasks.editor', DesktopTaskContribution)) - scope.view({ id: 'tasks.editor', renderer: 'tasks.editor', locations: ['main'], label: 'Task', supports: resource => ['task', 'draft'].includes(resource.scheme), multiple: false, prepareBeforeOpen: true }) + scope.view({ id: 'tasks.editor', renderer: 'tasks.editor', locations: ['main'], label: 'Task', supports: resource => ['task', 'draft'].includes(resource.scheme), multiple: false, prepareBeforeOpen: false }) }) controller.registry.register('lexora.files', (scope) => { scope.cleanup(renderers.register('files.view', DesktopFileContribution)) From bf557f24fad52dc1aadb2ea768914bc37b403991 Mon Sep 17 00:00:00 2001 From: shanyuhai123 <864299347@qq.com> Date: Thu, 8 Oct 2026 15:40:06 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(buddy):=20=E4=BC=98=E5=8C=96=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=8D=B3=E6=97=B6=E5=88=87=E6=8D=A2=E4=B8=8E=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/__tests__/workbench.e2e.mjs | 115 ++++++++++ .../src/app/bootstrap/DesktopAppProvider.vue | 4 +- .../src/app/workbench/ActiveTaskProjection.ts | 3 + .../app/workbench/DesktopTaskContribution.vue | 32 ++- .../src/app/workbench/DesktopTaskLoading.vue | 57 +++++ .../app/workbench/DesktopWorkbenchArea.vue | 2 +- .../src/app/workbench/TaskWorkspacePool.ts | 11 +- .../__tests__/instantTaskSwitch.spec.ts | 201 ++++++++++++++++-- .../src/app/workbench/useDesktopWorkbench.ts | 3 +- .../app/workbench/useTaskInputLifecycle.ts | 5 +- .../state/__tests__/useTaskCapability.spec.ts | 32 ++- .../modules/tasks/state/useTaskLifecycle.ts | 31 ++- 12 files changed, 441 insertions(+), 55 deletions(-) create mode 100644 apps/buddy/src/app/workbench/DesktopTaskLoading.vue diff --git a/.playwright/scripts/__tests__/workbench.e2e.mjs b/.playwright/scripts/__tests__/workbench.e2e.mjs index ea4b7824..1df6021e 100644 --- a/.playwright/scripts/__tests__/workbench.e2e.mjs +++ b/.playwright/scripts/__tests__/workbench.e2e.mjs @@ -72,3 +72,118 @@ test('builtin working copies back up continued edits, veto closure and recover a await page.screenshot({ path: path.join(instance.artifactDirectory, 'recovered-and-saved.png'), animations: 'disabled' }) expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) }) + +test('task switching stays responsive during loading, failure and notification navigation', async ({ buddy }, testInfo) => { + const instance = await buddy.createInstance('task-switch') + const { app, page, diagnostics } = await instance.launch() + await app.evaluate((_electron, databasePath) => { + const { DatabaseSync } = process.getBuiltinModule('node:sqlite') + const db = new DatabaseSync(databasePath) + try { + const now = new Date().toISOString() + for (const id of ['a', 'b', 'c']) { + db.prepare('INSERT INTO conversations (id, title, active_branch_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)').run(id, `Task ${id.toUpperCase()}`, `branch-${id}`, now, now) + db.prepare('INSERT INTO conversation_branches (id, conversation_id, created_at) VALUES (?, ?, ?)').run(`branch-${id}`, id, now) + db.prepare('INSERT INTO messages (id, conversation_id, branch_id, role, content_json, created_at) VALUES (?, ?, ?, ?, ?, ?)').run(`message-${id}`, id, `branch-${id}`, 'user', JSON.stringify({ text: `Content ${id.toUpperCase()}` }), now) + } + db.prepare('INSERT INTO conversation_branches (id, conversation_id, created_at) VALUES (?, ?, ?)').run('notified-branch', 'b', now) + db.prepare('INSERT INTO messages (id, conversation_id, branch_id, role, content_json, created_at) VALUES (?, ?, ?, ?, ?, ?)').run('notified-message', 'b', 'notified-branch', 'user', JSON.stringify({ text: 'Notification target content' }), now) + db.prepare('INSERT INTO runs (id, conversation_id, branch_id, triggering_message_id, provider, model, purpose, status, started_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run('notified-run', 'b', 'notified-branch', 'notified-message', 'fixture', 'fixture', 'chat', 'completed', now, now) + } + finally { db.close() } + }, path.join(instance.home, 'buddy/buddy.sqlite3')) + await page.reload() + const row = id => page.locator(`[data-task-id="${id}"] .desktop-task-sidebar__task`) + const editor = page.locator('.desktop-chat-composer__prosemirror:visible') + const title = page.locator('.workbench-pane-title:visible') + await row('a').click() + await expect(editor).toBeVisible() + await editor.fill('Saved while switching') + await app.evaluate(({ ipcMain }) => { + const probe = globalThis.taskSwitchProbe = { events: [], gates: new Map(), fail: null } + for (const [stage, channel] of [ + ['get', 'lexora:buddy:conversations:get'], + ['draft', 'lexora:buddy:composer-drafts:open'], + ['branches', 'lexora:buddy:conversations:list-branches'], + ['timeline', 'lexora:buddy:conversations:list-timeline'], + ]) { + const handler = ipcMain._invokeHandlers.get(channel) + ipcMain.removeHandler(channel) + ipcMain.handle(channel, async (event, input) => { + const id = input.conversationId ?? input.scope?.conversationId + probe.events.push({ id, stage, phase: 'start', at: Date.now() }) + await probe.gates.get(`${stage}:${id}`)?.promise + if (stage === 'get' && probe.fail === id) + throw new Error('Isolated task read failure') + const result = await handler(event, input) + probe.events.push({ id, stage, phase: 'end', at: Date.now() }) + return result + }) + } + }) + const block = key => app.evaluate((_electron, key) => { + globalThis.taskSwitchProbe.gates.set(key, Promise.withResolvers()) + }, key) + const release = key => app.evaluate((_electron, key) => { + const gates = globalThis.taskSwitchProbe.gates + gates.get(key)?.resolve() + gates.delete(key) + }, key) + await block('draft:b') + const clickedAt = Date.now() + await row('b').click() + await expect(title).toHaveText('Task B') + await expect(row('b')).toHaveClass(/is-active/) + await expect(page.getByTestId('task-loading')).toHaveAttribute('aria-busy', 'true') + const loadingVisibleMs = Date.now() - clickedAt + await expect.poll(() => app.evaluate(() => globalThis.taskSwitchProbe.events.some(event => event.id === 'b' && event.stage === 'timeline' && event.phase === 'end'))).toBe(true) + expect(await app.evaluate(() => globalThis.taskSwitchProbe.events.some(event => event.id === 'b' && event.stage === 'draft' && event.phase === 'end'))).toBe(false) + await page.screenshot({ path: path.join(instance.artifactDirectory, 'task-loading-light.png'), animations: 'disabled' }) + await page.evaluate(() => window.lexoraDesktop.settings.update({ desktop: { theme: 'dark' } })) + await page.screenshot({ path: path.join(instance.artifactDirectory, 'task-loading-dark.png'), animations: 'disabled' }) + await page.evaluate(() => window.lexoraDesktop.settings.update({ desktop: { theme: 'light' } })) + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(980, 640)) + await expect.poll(() => page.evaluate(() => window.innerWidth)).toBe(980) + expect(await page.getByTestId('task-loading').evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + await page.screenshot({ path: path.join(instance.artifactDirectory, 'task-loading-narrow.png'), animations: 'disabled' }) + await row('c').click() + await expect(title).toHaveText('Task C') + await expect(editor).toBeVisible() + await release('draft:b') + await expect.poll(() => app.evaluate(() => globalThis.taskSwitchProbe.events.some(event => event.id === 'b' && event.stage === 'draft' && event.phase === 'end'))).toBe(true) + await expect(title).toHaveText('Task C') + await row('a').click() + await expect(editor).toHaveText('Saved while switching') + + await app.evaluate(() => { + globalThis.taskSwitchProbe.fail = 'b' + }) + await row('b').click() + const failed = page.getByTestId('task-loading').getByRole('alert') + await expect(failed).toContainText('视图加载失败') + await row('a').click() + await expect(editor).toHaveText('Saved while switching') + await row('b').click() + await expect(failed).toBeVisible() + await app.evaluate(() => { + globalThis.taskSwitchProbe.fail = null + }) + await failed.getByRole('button', { name: '重试', exact: true }).click() + await expect(editor).toBeVisible() + await expect(page.getByTestId('task-loading')).toHaveCount(0) + + await row('a').click() + await expect(editor).toHaveText('Saved while switching') + await block('get:b') + await app.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0].webContents.send('lexora:app:open-target', { conversationId: 'b', runId: 'notified-run' }) + }) + await expect(title).toHaveText('Task B') + await expect(page.getByTestId('task-loading')).toBeVisible() + await release('get:b') + await expect.poll(() => page.evaluate(async () => (await window.lexoraDesktop.localChat.conversations.get('b')).activeBranchId)).toBe('notified-branch') + await expect(page.locator('.desktop-chat-page')).toContainText('Notification target content') + await page.screenshot({ path: path.join(instance.artifactDirectory, 'task-notification-target.png'), animations: 'disabled' }) + await testInfo.attach('task-load-timing', { body: JSON.stringify({ loadingVisibleMs, requests: await app.evaluate(() => globalThis.taskSwitchProbe.events) }, null, 2), contentType: 'application/json' }) + expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) +}) diff --git a/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue b/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue index 14624c75..4fa35375 100644 --- a/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue +++ b/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue @@ -105,7 +105,7 @@ useProvideExtensionContext({ settingsLocation: settingsRegistry.extensionLocatio onScopeDispose(workbench.controller.subscribe(() => void nextTick(extensionViews.layout))) const selectedTask = workbench.activeTask const resources = useTaskResourcePanel({ - activeConversationId: computed(() => selectedTask.value?.workspace.session.activeConversationId.value ?? null), + activeConversationId: workbench.activeTaskId, activeDraftId: computed(() => selectedTask.value?.workspace.composer.draftId.value ?? null), activeBranchId: computed(() => selectedTask.value?.workspace.session.activeBranchId.value ?? null), activeRunId: computed(() => selectedTask.value?.workspace.execution.activeRun.value?.id @@ -183,7 +183,7 @@ const navigation = useDesktopNavigation({ router, ready, session: { - activeTaskId: computed(() => selectedTask.value?.session.activeTaskId.value ?? null), + activeTaskId: workbench.activeTaskId, spaceId: computed(() => selectedTask.value?.session.spaceId.value ?? null), navigationVersion: () => workbench.navigationVersion, openTask: (id, signal) => workbench.openTask(id, signal), diff --git a/apps/buddy/src/app/workbench/ActiveTaskProjection.ts b/apps/buddy/src/app/workbench/ActiveTaskProjection.ts index f0d53253..d614ad88 100644 --- a/apps/buddy/src/app/workbench/ActiveTaskProjection.ts +++ b/apps/buddy/src/app/workbench/ActiveTaskProjection.ts @@ -4,6 +4,8 @@ import type { WorkbenchController } from '@/workbench/services/WorkbenchControll import { computed, shallowRef } from 'vue' export class ActiveTaskProjection { + readonly #taskId = shallowRef(null) + readonly taskId = computed(() => this.#taskId.value) readonly #active = shallowRef(null) readonly current = computed(() => this.#active.value) readonly #subscriptions: (() => void)[] @@ -23,6 +25,7 @@ export class ActiveTaskProjection { reconcile(): void { const pane = this.controller.pane(this.controller.layout.activePane) const view = pane?.view ? this.controller.layout.views[pane.view] : null + this.#taskId.value = view?.resource.scheme === 'task' ? view.resource.id : null const task = view ? this.pool.peek(view.resource) : undefined this.#active.value = task?.workspace.restoration.state.value === 'ready' ? task : null } diff --git a/apps/buddy/src/app/workbench/DesktopTaskContribution.vue b/apps/buddy/src/app/workbench/DesktopTaskContribution.vue index f29f5fbe..eca27ae9 100644 --- a/apps/buddy/src/app/workbench/DesktopTaskContribution.vue +++ b/apps/buddy/src/app/workbench/DesktopTaskContribution.vue @@ -5,14 +5,13 @@ import { useMessage } from 'naive-ui' import { computed, onScopeDispose, shallowRef, watch } from 'vue' import { useTaskEnvironment } from '@/modules/tasks' import { DesktopTaskEditor, DesktopTaskViewProvider } from '@/modules/tasks/ui' -import { useWorkbench } from '@/workbench/browser/workbenchContext' import WorkbenchPaneActions from '@/workbench/browser/WorkbenchPaneActions.vue' import WorkbenchPaneTitle from '@/workbench/browser/WorkbenchPaneTitle.vue' +import DesktopTaskLoading from './DesktopTaskLoading.vue' import { useDesktopWorkbenchContext } from './desktopWorkbenchContext' const props = defineProps<{ view: WorkbenchView, visible: boolean }>() const workbench = useDesktopWorkbenchContext() -const { labels } = useWorkbench() const environment = useTaskEnvironment() const task = shallowRef(null) const failed = shallowRef(false) @@ -30,20 +29,21 @@ watch(() => task.value?.session.spaceId.value, (spaceId) => { if (props.view.resource.scheme === 'draft' && task.value && props.view.resource.data.spaceId !== spaceId) workbench.controller.updateView(props.view.id, { resource: { ...props.view.resource, data: { spaceId: spaceId ?? null } } }) }) -let disposed = false -onScopeDispose(() => disposed = true) +let loadVersion = 0 +onScopeDispose(() => loadVersion++) async function load() { if (!taskResource.value) return + const version = ++loadVersion failed.value = false try { const loaded = await workbench.pool.open(props.view.resource) - if (disposed) + if (version !== loadVersion) return task.value = loaded } catch { - if (!disposed) { + if (version === loadVersion) { failed.value = true workbench.controller.navigation.fail(props.view.id) } @@ -67,16 +67,12 @@ watch(() => task.value?.session.currentTitle.value, (title) => { -
- {{ failed ? labels.failed : labels.loading }} -
+ + + + - - diff --git a/apps/buddy/src/app/workbench/DesktopTaskLoading.vue b/apps/buddy/src/app/workbench/DesktopTaskLoading.vue new file mode 100644 index 00000000..bbcdec9c --- /dev/null +++ b/apps/buddy/src/app/workbench/DesktopTaskLoading.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/apps/buddy/src/app/workbench/DesktopWorkbenchArea.vue b/apps/buddy/src/app/workbench/DesktopWorkbenchArea.vue index d5ec59ec..47a8f15c 100644 --- a/apps/buddy/src/app/workbench/DesktopWorkbenchArea.vue +++ b/apps/buddy/src/app/workbench/DesktopWorkbenchArea.vue @@ -52,7 +52,7 @@ function focusContext() {
diff --git a/apps/buddy/src/app/workbench/TaskWorkspacePool.ts b/apps/buddy/src/app/workbench/TaskWorkspacePool.ts index d25fae51..0ed19d15 100644 --- a/apps/buddy/src/app/workbench/TaskWorkspacePool.ts +++ b/apps/buddy/src/app/workbench/TaskWorkspacePool.ts @@ -17,7 +17,7 @@ export class TaskWorkspacePool { readonly #changes = new Emitter(() => console.error('TASK_WORKSPACE_OBSERVER_FAILED')) readonly onDidChange = this.#changes.event readonly #options: Omit - readonly #entries = new Map() + readonly #entries = new Map() readonly #leases = new Map() readonly #loading = new Map>() readonly #adopt: (previous: ResourceRef, task: TaskCapability, id: string) => void @@ -30,7 +30,8 @@ export class TaskWorkspacePool { } peek(resource: ResourceRef): TaskCapability | undefined { - return this.#entries.get(resourceKey(resource))?.task + const entry = this.#entries.get(resourceKey(resource)) + return entry?.ready ? entry.task : undefined } open(resource: ResourceRef): Promise { @@ -77,17 +78,19 @@ export class TaskWorkspacePool { const previous = currentResource currentResource = { scheme: 'task', id, data: {} } this.#entries.delete(resourceKey(previous)) - this.#entries.set(resourceKey(currentResource), { task, scope }) + this.#entries.set(resourceKey(currentResource), { task, scope, ready: true }) this.#options.onDraftCommitted?.(draftId, id) this.#adopt(previous, task, id) this.#changes.fire(Object.freeze({ kind: 'adopted', resource: currentResource, previous })) }, }))! - this.#entries.set(resourceKey(resource), { task, scope }) + const entry = { task, scope, ready: false } + this.#entries.set(resourceKey(resource), entry) try { await task.initialize() if (this.#disposed || this.#entries.get(resourceKey(currentResource))?.task !== task || task.workspace.restoration.state.value !== 'ready') throw new Error('TASK_RESTORATION_FAILED') + entry.ready = true this.#changes.fire(Object.freeze({ kind: 'ready', resource: currentResource })) return task } diff --git a/apps/buddy/src/app/workbench/__tests__/instantTaskSwitch.spec.ts b/apps/buddy/src/app/workbench/__tests__/instantTaskSwitch.spec.ts index 6436de77..6bf616b7 100644 --- a/apps/buddy/src/app/workbench/__tests__/instantTaskSwitch.spec.ts +++ b/apps/buddy/src/app/workbench/__tests__/instantTaskSwitch.spec.ts @@ -1,30 +1,195 @@ +import type { LexoraDesktopApi } from '@buddy-electron/shared/desktopApi' +import type { TaskCapability, UseTaskCapabilityOptions } from '@/modules/tasks' +import type { TaskResourcePanel } from '@/modules/tasks/contracts' +import type { WorkbenchPersistence } from '@/workbench/services/WorkbenchPersistence' import type { WorkingCopyService } from '@/workbench/services/WorkingCopyService' -import { describe, expect, it } from 'vitest' +import { deferred } from '@buddy-tests/deferred' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computed, effectScope, shallowRef } from 'vue' +import { createMemoryHistory, createRouter } from 'vue-router' +import { useDesktopNavigation } from '@/app/bootstrap/useDesktopNavigation' +import { DESKTOP_ROUTE_NAMES } from '@/shared/navigation/desktopRoutes' import { ViewRendererRegistry } from '@/workbench/browser/ViewRendererRegistry' import { ContributionRegistry } from '@/workbench/services/ContributionRegistry' import { WorkbenchController } from '@/workbench/services/WorkbenchController' +import { ActiveTaskProjection } from '../ActiveTaskProjection' import { registerDesktopContributions } from '../registerDesktopContributions' +import { TaskWorkspacePool } from '../TaskWorkspacePool' +import { useTaskInputLifecycle } from '../useTaskInputLifecycle' -describe('instant task view switching', () => { - it('commits a regular task replacement without waiting for the view to become ready', async () => { - const controller = new WorkbenchController(new ContributionRegistry()) - const copies = {} as WorkingCopyService - registerDesktopContributions(controller, new ViewRendererRegistry(), copies, () => 'en-US') +vi.mock('../DesktopTaskContribution.vue', () => ({ default: {} })) +vi.mock('../DesktopFileContribution.vue', () => ({ default: {} })) +const factory = vi.hoisted(() => ({ create: (_options: unknown): unknown => null })) +vi.mock('@/modules/tasks', () => ({ useTaskCapability: (options: unknown) => factory.create(options) })) +const cleanups: Array<() => void | Promise> = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup() +}) +async function fixture() { + const gate = deferred() + let delayed = false + let failed = false + let canClose = true + const initialized = new Set() + const failures = new Set() + const errors: unknown[] = [] + factory.create = (raw) => { + const options = raw as UseTaskCapabilityOptions + const id = options.initialTarget.conversationId! + const state = shallowRef('ready') + return { + initialize: async () => { + initialized.add(id) + if (id === 'b' && delayed) + await gate.promise + if (id === 'b' && failed) + throw new Error('TASK_READ_FAILED') + }, + dispose: () => {}, + prepareClose: async () => canClose, + cancelClose: () => {}, + session: { activeTaskId: shallowRef(id), spaceId: shallowRef(null) }, + workspace: { restoration: { state }, session: { activeConversationId: shallowRef(id) } }, + } as unknown as TaskCapability + } + const api = { localChat: { + conversations: { get: async (id: string) => ({ id, activeBranchId: `${id}-branch`, spaceId: null, deletedAt: null }) }, + composerDrafts: {}, + } } as unknown as LexoraDesktopApi + const index = { data: { + conversations: shallowRef(['a', 'b', 'c'].map(id => ({ id }))), + applyConversation: () => {}, + refreshConversations: async () => {}, + } } + const pool = new TaskWorkspacePool({ api, index } as unknown as Omit, () => {}, async () => {}) + let inputs: ReturnType + const controller = new WorkbenchController(new ContributionRegistry(), async (view) => { try { - expect(controller.registry.views.get('tasks.editor')?.prepareBeforeOpen).toBe(false) - - const first = await controller.open({ scheme: 'task', id: 'conversation-a', data: {} }, 'Conversation A') - const second = await controller.open({ scheme: 'task', id: 'conversation-b', data: {} }, 'Conversation B') - - expect(second).not.toBeNull() - expect(controller.pane(controller.layout.activePane)?.view).toBe(second) - expect(controller.layout.views[first!]).toBeUndefined() - expect(controller.navigation.entries.size).toBe(0) + return await inputs.prepareClose(view) } - finally { - await controller.dispose() - controller.registry.dispose() + catch (error) { + errors.push(error) + return false } }) + registerDesktopContributions(controller, new ViewRendererRegistry(), {} as WorkingCopyService, () => 'en-US') + inputs = useTaskInputLifecycle({ api, controller, pool, persistence: {} as WorkbenchPersistence, resources: () => ({}) as TaskResourcePanel, onError: error => errors.push(error) }) + const projection = new ActiveTaskProjection(controller, pool) + const loads = new Map>() + function render() { + pool.retain(controller.renderedViews.map(view => view.resource)) + for (const view of controller.renderedViews) { + if (!loads.has(view.id)) { + loads.set(view.id, pool.open(view.resource).catch(() => { + failures.add(view.resource.id) + return null + })) + } + } + } + const subscription = controller.onDidChangeLayout(render) + const open = (id: string) => controller.open({ scheme: 'task', id, data: {} }, id) + const first = (await open('a'))! + await loads.get(first) + cleanups.push(async () => { + gate.resolve() + subscription.dispose() + projection.dispose() + await controller.dispose() + controller.registry.dispose() + pool.dispose() + }) + return { controller, pool, projection, gate, initialized, failures, errors, loads, open, first, delay: () => { + delayed = true + }, fail: () => { + failed = true + }, vetoClose: () => { + canClose = false + } } +} + +describe('instant task switching', () => { + it('selects the target immediately and supersedes its load without allowing the late result to replace the next task', async () => { + const f = await fixture() + f.delay() + const b = (await f.open('b'))! + await vi.waitFor(() => expect(f.initialized.has('b')).toBe(true)) + expect(f.projection.taskId.value).toBe('b') + expect(f.projection.current.value).toBeNull() + expect(f.pool.peek({ scheme: 'task', id: 'b', data: {} })).toBeUndefined() + expect(f.controller.layout.views[f.first]).toBeUndefined() + const c = (await f.open('c'))! + await f.loads.get(c) + expect(f.projection.current.value?.session.activeTaskId.value).toBe('c') + f.gate.resolve() + expect(await f.loads.get(b)).toBeNull() + expect(f.projection.taskId.value).toBe('c') + expect(f.projection.current.value?.session.activeTaskId.value).toBe('c') + }) + + it('can leave an initialization failure without retrying it as a close prerequisite', async () => { + const f = await fixture() + f.fail() + const b = (await f.open('b'))! + expect(await f.loads.get(b)).toBeNull() + expect(f.projection.taskId.value).toBe('b') + const c = await f.open('c') + expect(c).not.toBeNull() + expect(f.controller.context.view?.resource.id).toBe('c') + expect(f.errors).toEqual([]) + }) + + it('keeps an initialized task when its unsaved input vetoes closure', async () => { + const f = await fixture() + f.vetoClose() + expect(await f.open('b')).toBeNull() + expect(f.controller.context.view?.id).toBe(f.first) + expect(f.projection.current.value?.session.activeTaskId.value).toBe('a') + }) + + it('keeps notification branch and message targeting through the loading state', async () => { + const f = await fixture() + f.delay() + const router = createRouter({ history: createMemoryHistory(), routes: [ + { name: DESKTOP_ROUTE_NAMES.tasks, path: '/tasks', component: {} }, + ] }) + await router.push('/tasks') + let version = 0 + const branch = shallowRef('current-branch') + const scope = effectScope() + cleanups.push(() => scope.stop()) + const navigation = scope.run(() => useDesktopNavigation({ + router, + ready: Promise.resolve(), + notifications: { markSeen: async () => true }, + openUpdate: async () => {}, + onError: error => f.errors.push(error), + getRun: async () => ({ conversationId: 'b', branchId: 'notified-branch', triggeringMessageId: 'target-message' }), + activateRunBranch: async (run) => { + branch.value = run.branchId + return true + }, + session: { + activeTaskId: f.projection.taskId, + spaceId: computed(() => f.projection.current.value?.session.spaceId.value ?? null), + navigationVersion: () => version, + startTask: async () => {}, + openTask: async (id, signal) => { + version += 1 + const resource = { scheme: 'task', id, data: {} } + const viewId = await f.controller.open(resource, id, { signal }) + if (viewId && !signal?.aborted) + await f.pool.open(resource) + }, + }, + }))! + const opening = navigation.openTarget({ conversationId: 'b', runId: 'run' }) + await vi.waitFor(() => expect(f.initialized.has('b')).toBe(true)) + expect(f.projection.taskId.value).toBe('b') + f.gate.resolve() + await opening + expect(branch.value).toBe('notified-branch') + expect(navigation.notificationTarget.value).toEqual({ conversationId: 'b', messageId: 'target-message' }) + }) }) diff --git a/apps/buddy/src/app/workbench/useDesktopWorkbench.ts b/apps/buddy/src/app/workbench/useDesktopWorkbench.ts index 18fe6741..c2f237b4 100644 --- a/apps/buddy/src/app/workbench/useDesktopWorkbench.ts +++ b/apps/buddy/src/app/workbench/useDesktopWorkbench.ts @@ -71,6 +71,7 @@ export function useDesktopWorkbench(options: { api: LexoraDesktopApi, events: Ap const initialized = shallowRef(false) const activity = new ActiveTaskProjection(controller, pool) const activeTask = activity.current + const activeTaskId = activity.taskId const contextTabs = new ContextTabProjection(controller, options.resources, () => initialized.value) const resourceLifetime = new WorkbenchResourceLifetime(controller, pool, copies, () => initialized.value) const projections = { reconcile: () => { @@ -543,7 +544,7 @@ export function useDesktopWorkbench(options: { api: LexoraDesktopApi, events: Ap await inputs.flush().catch(options.onError) return saved } - return { api, renderers, fileToolbarTargets, fileView, closeContextFiles, readingPositions, discardTask, prepareTaskDeletion, activeTask, backupError, controller, copies, models, pool, persistence, initialize, flush, dispose, openTask, newTask, startTaskWithSkill, openFile, dropResource, language, get initialized() { + return { api, renderers, fileToolbarTargets, fileView, closeContextFiles, readingPositions, discardTask, prepareTaskDeletion, activeTask, activeTaskId, backupError, controller, copies, models, pool, persistence, initialize, flush, dispose, openTask, newTask, startTaskWithSkill, openFile, dropResource, language, get initialized() { return initialized.value }, get navigationVersion() { return navigationVersion diff --git a/apps/buddy/src/app/workbench/useTaskInputLifecycle.ts b/apps/buddy/src/app/workbench/useTaskInputLifecycle.ts index 891e41a8..dcc68f8c 100644 --- a/apps/buddy/src/app/workbench/useTaskInputLifecycle.ts +++ b/apps/buddy/src/app/workbench/useTaskInputLifecycle.ts @@ -19,7 +19,10 @@ export function useTaskInputLifecycle(options: { const cleanup = new TaskInputCleanup(controller, persistence, api.localChat.composerDrafts, id => options.resources().allTabs.value.some(tab => tab.scope === `draft:${id}`)) async function prepareClose(view: WorkbenchView): Promise { - const task = await options.pool.open(view.resource) + const loaded = options.pool.peek(view.resource) + if (view.resource.scheme === 'task' && !loaded) + return true + const task = loaded ?? await options.pool.open(view.resource) const releaseWorkspace = options.pool.acquire(view.resource) const cancel = () => { try { diff --git a/apps/buddy/src/modules/tasks/state/__tests__/useTaskCapability.spec.ts b/apps/buddy/src/modules/tasks/state/__tests__/useTaskCapability.spec.ts index ccb39ee4..4edba009 100644 --- a/apps/buddy/src/modules/tasks/state/__tests__/useTaskCapability.spec.ts +++ b/apps/buddy/src/modules/tasks/state/__tests__/useTaskCapability.spec.ts @@ -9,7 +9,7 @@ import type { UseTaskCapabilityOptions } from '../useTaskCapability' import { ServiceHost } from '@buddy-shared/lifecycle/ServiceHost' import { deferred } from '@buddy-tests/deferred' import { describe, expect, it, vi } from 'vitest' -import { computed } from 'vue' +import { computed, effectScope } from 'vue' import { createBuddyUserContent } from '../../../../../shared/conversation/buddyUserContent' import { useDesktopAppState } from '../../../../app/bootstrap/useDesktopAppState' @@ -17,6 +17,36 @@ import { useTaskIndex } from '../task-index/useTaskIndex' import { useTaskCapability } from '../useTaskCapability' describe('useTaskCapability', () => { + it.each([false, true])('loads conversation content while input restores, including branch failure: %s', async (branchFailure) => { + const api = createBranchingDesktopApi() + vi.stubGlobal('window', Object.assign(globalThis, { lexoraDesktop: api })) + const gate = deferred() + const open = vi.mocked(api.localChat.composerDrafts.open).getMockImplementation()! + vi.mocked(api.localChat.composerDrafts.open).mockImplementation(async (input) => { + await gate.promise + return open(input) + }) + if (branchFailure) + vi.mocked(api.localChat.conversations.listBranches).mockRejectedValue(new Error('BRANCH_READ_FAILED')) + const scope = effectScope() + const chat = scope.run(() => createTestTask(api, { conversationId: 'conversation-1', branchId: 'branch-root', spaceId: null }))! + let completed = false + const initialization = chat.initialize().then(() => { + completed = true + }) + try { + await vi.waitFor(() => expect(chat.workspace.transcript.messages.value.map(message => message.id)).toEqual(['user-1', 'assistant-1', 'user-2', 'assistant-2'])) + expect(chat.workspace.restoration.state.value).toBe('restoring') + expect(completed).toBe(false) + } + finally { + gate.resolve() + await initialization + chat.dispose() + scope.stop() + } + }) + it('refreshes committed content for the active task without inventing a run output', async () => { const api = createBranchingDesktopApi() vi.stubGlobal('window', Object.assign(globalThis, { lexoraDesktop: api })) diff --git a/apps/buddy/src/modules/tasks/state/useTaskLifecycle.ts b/apps/buddy/src/modules/tasks/state/useTaskLifecycle.ts index 561d99cf..673c9b34 100644 --- a/apps/buddy/src/modules/tasks/state/useTaskLifecycle.ts +++ b/apps/buddy/src/modules/tasks/state/useTaskLifecycle.ts @@ -47,8 +47,15 @@ export function useTaskLifecycle(options: TaskLifecycleOptions) { const modelSelection = JSON.stringify(options.taskModels.currentSelection()) try { const navigationReady = options.taskIndex.initialize() - if (await options.workspacePersistence.restore(navigationReady)) - await refreshActiveConversation(navigation, modelSelection) + const [restored] = await Promise.all([ + options.workspacePersistence.restore(navigationReady), + navigationReady.then(() => refreshConversationContent(navigation)).catch((error) => { + if (!isDisposed) + options.onError(error) + }), + ]) + if (restored) + restoreConversationModel(navigation, modelSelection) } catch (error) { if (!isDisposed) @@ -77,8 +84,10 @@ export function useTaskLifecycle(options: TaskLifecycleOptions) { const modelSelection = JSON.stringify(options.taskModels.currentSelection()) try { const restored = options.workspacePersistence.restore() - if (await restored) - await refreshActiveConversation(navigation, modelSelection) + if (await restored) { + restoreConversationModel(navigation, modelSelection) + await refreshConversationContent(navigation) + } } catch (error) { if (!isDisposed) @@ -86,13 +95,17 @@ export function useTaskLifecycle(options: TaskLifecycleOptions) { } } - async function refreshActiveConversation(navigation: number, modelSelection: string) { + function restoreConversationModel(navigation: number, modelSelection: string) { + if (!isDisposed && options.session.isCurrent(navigation) && options.session.activeConversationId.value + && JSON.stringify(options.taskModels.currentSelection()) === modelSelection) { + options.restoreScopeModel(options.activeConversation.value?.modelSelection ?? null) + } + } + + async function refreshConversationContent(navigation: number) { if (isDisposed || !options.session.isCurrent(navigation) || !options.session.activeConversationId.value) return - const conversation = options.activeConversation.value - options.session.setActiveBranch(conversation?.activeBranchId ?? null) - if (JSON.stringify(options.taskModels.currentSelection()) === modelSelection) - options.restoreScopeModel(conversation?.modelSelection ?? null) + options.session.setActiveBranch(options.activeConversation.value?.activeBranchId ?? null) await Promise.all([ options.refreshBranches(), options.runSync.refreshActiveConversation(),