diff --git a/.playwright/scripts/__tests__/task-space.e2e.mjs b/.playwright/scripts/__tests__/task-space.e2e.mjs new file mode 100644 index 000000000..85ea10064 --- /dev/null +++ b/.playwright/scripts/__tests__/task-space.e2e.mjs @@ -0,0 +1,105 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { expect, test } from '../fixtures/electron.mjs' + +test('moving a task requires confirmation, preserves its draft and previous files, and survives restart', async ({ buddy }) => { + const instance = await buddy.createInstance('task-space') + const target = path.join(instance.home, 'project') + const previous = path.join(instance.home, 'buddy/conversations/movable/workspace') + await fs.mkdir(target, { recursive: true }) + await fs.mkdir(previous, { recursive: true }) + await fs.writeFile(path.join(previous, 'unfinished.txt'), 'Original unfinished work') + let { app, page, diagnostics } = await instance.launch() + await app.evaluate(({ dialog }, { target, databasePath }) => { + dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [target] }) + const { DatabaseSync } = process.getBuiltinModule('node:sqlite') + const db = new DatabaseSync(databasePath) + try { + const now = new Date().toISOString() + for (const id of ['movable', 'paused']) { + db.prepare('INSERT INTO conversations (id, title, active_branch_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)').run(id, id === 'movable' ? '归入项目的任务' : '保留待发消息的任务', `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: 'Existing task history' }), now) + } + const queued = { conversationId: 'paused', branchId: 'branch-paused', buddyId: 'default', approvalPolicy: 'policy', executionProfile: 'workspace_write', spaceId: null, queueRouting: { mode: 'current_actor' }, runInput: { attachmentIds: [] }, userMessageContent: { userContent: { version: 1, body: [{ type: 'paragraph', content: [{ type: 'text', text: 'Preserved queued input' }] }], panelResourceIds: [] }, resourceSnapshots: [] } } + db.prepare(`INSERT INTO chat_queue (id, conversation_id, branch_id, request_id, request_fingerprint, prepared_json, state, created_at) VALUES ('pending', 'paused', 'branch-paused', 'pending-request', 'pending-fingerprint', ?, 'paused', ?)`).run(JSON.stringify(queued), now) + } + finally { db.close() } + }, { target, databasePath: path.join(instance.home, 'buddy/buddy.sqlite3') }) + await page.reload() + const spaces = page.locator('.desktop-task-sidebar__spaces') + await spaces.locator('.desktop-task-sidebar__section-heading').hover() + await spaces.locator('.desktop-task-sidebar__section-add').click() + const spaceDialog = page.locator('.desktop-space-dialog') + await spaceDialog.getByPlaceholder('输入空间名称').fill('目标项目') + await spaceDialog.getByRole('button', { name: '选择目录', exact: true }).click() + await spaceDialog.getByRole('button', { name: '确定', exact: true }).click() + await expect(spaceDialog).toBeHidden() + const spaceId = await page.evaluate(async () => (await window.lexoraDesktop.localChat.spaces.list()).find(space => space.name === '目标项目').id) + let rowSelector = '[data-task-id="movable"]' + const row = () => page.locator(rowSelector) + await row().locator('.desktop-task-sidebar__task').click() + const editor = () => page.locator('.desktop-chat-composer__prosemirror:visible') + await editor().fill('移入后仍需保留的草稿') + const openMoveDialog = async () => { + await row().hover() + await row().getByRole('button', { name: '更多操作', exact: true }).click() + await page.locator('.n-dropdown-menu:visible').getByText('移入空间', { exact: true }).click() + const dialog = page.locator('.desktop-task-move-dialog') + await expect(dialog).toBeVisible() + await dialog.locator('.n-base-selection').click() + await page.locator('.n-base-select-menu:visible').getByText('目标项目', { exact: true }).click() + await expect(dialog).toContainText('文件不搬迁') + return dialog + } + let moveDialog = await openMoveDialog() + await moveDialog.getByRole('button', { name: '取消', exact: true }).click() + expect((await page.evaluate(() => window.lexoraDesktop.localChat.conversations.get('movable'))).spaceId).toBeNull() + await expect(editor()).toContainText('移入后仍需保留的草稿') + moveDialog = await openMoveDialog() + await page.screenshot({ path: path.join(instance.artifactDirectory, '01-move-confirmation.png'), animations: 'disabled' }) + await moveDialog.getByRole('button', { name: '移入', exact: true }).click() + await expect(moveDialog).toBeHidden() + await expect.poll(() => page.evaluate(() => window.lexoraDesktop.localChat.conversations.get('movable'))).toMatchObject({ spaceId, activeBranchId: 'branch-movable' }) + await expect(row()).toBeVisible() + await expect(editor()).toContainText('移入后仍需保留的草稿') + expect(await fs.readFile(path.join(previous, 'unfinished.txt'), 'utf8')).toBe('Original unfinished work') + expect(await fs.readdir(target)).toEqual([]) + const expectMoveHidden = async () => { + await row().hover() + await row().getByRole('button', { name: '更多操作', exact: true }).click() + await expect(page.locator('.n-dropdown-menu:visible')).toBeVisible() + await expect(page.locator('.n-dropdown-menu:visible').getByText('移入空间', { exact: true })).toHaveCount(0) + await editor().click() + await expect(page.locator('.n-dropdown-menu:visible')).toHaveCount(0) + await row().click({ button: 'right' }) + await expect(page.locator('.n-dropdown-menu:visible')).toBeVisible() + await expect(page.locator('.n-dropdown-menu:visible').getByText('移入空间', { exact: true })).toHaveCount(0) + await editor().click() + await expect(page.locator('.n-dropdown-menu:visible')).toHaveCount(0) + } + await expectMoveHidden() + const spaceRow = page.locator('.desktop-task-space-row').filter({ hasText: '目标项目' }) + await spaceRow.hover() + await spaceRow.getByRole('button', { name: '置顶', exact: true }).click() + rowSelector = '.desktop-task-sidebar__pinned [data-task-id="movable"]' + await expect(row()).toBeVisible() + await expectMoveHidden() + const queueError = await page.evaluate(async (spaceId) => { + try { + await window.lexoraDesktop.localChat.conversations.moveToSpace('paused', spaceId) + return null + } + catch (error) { return error.message } + }, spaceId) + expect(queueError).toContain('CONVERSATION_QUEUE_NOT_EMPTY') + await page.screenshot({ path: path.join(instance.artifactDirectory, '02-task-in-space.png'), animations: 'disabled' }) + expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) + await instance.stop() + ;({ app, page, diagnostics } = await instance.launch()) + await expect(row()).toBeVisible() + await expect(editor()).toContainText('移入后仍需保留的草稿') + expect((await page.evaluate(() => window.lexoraDesktop.localChat.conversations.get('movable'))).spaceId).toBe(spaceId) + expect(await fs.readFile(path.join(previous, 'unfinished.txt'), 'utf8')).toBe('Original unfinished work') + expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) +}) diff --git a/apps/buddy/electron/main/local-chat/conversation.ts b/apps/buddy/electron/main/local-chat/conversation.ts index 255fd11ec..b89c3d218 100644 --- a/apps/buddy/electron/main/local-chat/conversation.ts +++ b/apps/buddy/electron/main/local-chat/conversation.ts @@ -51,6 +51,8 @@ export function registerConversationIpc(context: LocalChatIpcContext): void { handle(LOCAL_CHAT_IPC_CHANNELS.conversationsSetBuddy, (_event, input) => request(conversationsRpc.setBuddy, conversationRequestSchemas.conversationBuddy.parse(input))) + handle(LOCAL_CHAT_IPC_CHANNELS.conversationsMoveToSpace, (_event, input) => request(conversationsRpc.moveToSpace, conversationRequestSchemas.conversationMoveToSpace.parse(input))) + handle(LOCAL_CHAT_IPC_CHANNELS.conversationsSetModelSelection, (_event, input) => request(conversationsRpc.setModelSelection, conversationRequestSchemas.conversationModelSelection.parse(input))) handle(LOCAL_CHAT_IPC_CHANNELS.conversationsListTimeline, (_event, input) => request(conversationsRpc.listTimeline, conversationRequestSchemas.conversationTimeline.parse(input))) diff --git a/apps/buddy/electron/preload/local-chat/conversation.ts b/apps/buddy/electron/preload/local-chat/conversation.ts index 9287f90b7..74a3c2d73 100644 --- a/apps/buddy/electron/preload/local-chat/conversation.ts +++ b/apps/buddy/electron/preload/local-chat/conversation.ts @@ -43,6 +43,7 @@ export function createConversationApi(): Pick ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.conversationsAddMember, { conversationId, buddyId }), removeMember: (conversationId, buddyId) => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.conversationsRemoveMember, { conversationId, buddyId }), setBuddy: (conversationId, buddyId) => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.conversationsSetBuddy, { conversationId, buddyId }), + moveToSpace: (conversationId, spaceId) => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.conversationsMoveToSpace, { conversationId, spaceId }), setModelSelection: (conversationId, modelSelection, target) => ipcRenderer.invoke( LOCAL_CHAT_IPC_CHANNELS.conversationsSetModelSelection, { conversationId, modelSelection, ...(target?.actorId ? { actorId: target.actorId } : {}), ...(target?.runId ? { targetRunId: target.runId } : {}) }, diff --git a/apps/buddy/electron/shared/localChatApi.ts b/apps/buddy/electron/shared/localChatApi.ts index 125bb4e16..c76840f72 100644 --- a/apps/buddy/electron/shared/localChatApi.ts +++ b/apps/buddy/electron/shared/localChatApi.ts @@ -168,6 +168,7 @@ export const LOCAL_CHAT_IPC_CHANNELS = { conversationsAddMember: 'lexora:buddy:conversations:add-member', conversationsRemoveMember: 'lexora:buddy:conversations:remove-member', conversationsSetBuddy: 'lexora:local-chat:conversations:set-buddy', + conversationsMoveToSpace: 'lexora:buddy:conversations:move-to-space', conversationsSetModelSelection: 'lexora:buddy:conversations:set-model-selection', conversationsListTimeline: 'lexora:buddy:conversations:list-timeline', notificationsChanged: 'lexora:buddy:notifications:changed', @@ -483,6 +484,7 @@ export interface LocalChatApi { addMember: (conversationId: string, buddyId: string) => Promise removeMember: (conversationId: string, buddyId: string) => Promise setBuddy: (conversationId: string, buddyId: string) => Promise + moveToSpace: (conversationId: string, spaceId: string) => Promise setModelSelection: ( conversationId: string, modelSelection: LocalConversation['modelSelection'], diff --git a/apps/buddy/service/src/BuddyService.ts b/apps/buddy/service/src/BuddyService.ts index 2de2e83ba..9e12fb3df 100644 --- a/apps/buddy/service/src/BuddyService.ts +++ b/apps/buddy/service/src/BuddyService.ts @@ -9,6 +9,7 @@ import type { BuddyRuntime } from './BuddyRuntime' import type { RunEventLogPort } from './events/RunEventPorts' import type { BuddyServiceRpcServer } from './rpc/BuddyServiceRpcServer' import type { BuddyServiceErrorCode } from './rpc/runtimeRequest' +import type { ConversationRecord } from './storage/conversationRecord' import { randomUUID } from 'node:crypto' import { mkdir } from 'node:fs/promises' import { join } from 'node:path' @@ -114,6 +115,7 @@ import { ContextUsageSnapshotService } from './context/ContextUsageSnapshotServi import { registerContextRpc } from './context/registerContextRpc' import { ConversationLifecycleService } from './conversations/ConversationLifecycleService' import { ConversationMetadataService } from './conversations/ConversationMetadataService' +import { ConversationMutationGuard } from './conversations/ConversationMutationGuard' import { createExtensionTaskCapabilities } from './conversations/extensionTaskCapabilities' import { ExtensionTaskContext } from './conversations/ExtensionTaskContext' import { registerConversationRpc } from './conversations/registerConversationRpc' @@ -256,6 +258,20 @@ export async function startBuddyService( const conversations = createConversationRepository(options.database) const conversationHistory = createConversationHistoryStore(options.database) const runs = createRunRepository(options.database) + const conversationMutations = new ConversationMutationGuard() + const conversationAvailability = { + assertConversationWritable: (id: string) => { + conversationMutations.assertWritable(id) + options.eventLog.assertConversationWritable(id) + }, + assertRunExecutable: (id: string) => { + const run = runs.findById(id) + if (run) + conversationMutations.assertWritable(run.conversationId) + options.eventLog.assertRunExecutable(id) + }, + } + const chatQueue = createChatQueueRepository(options.database) const executionRecords = createExecutionRepository(options.database) const threads = createThreadBindingRepository(options.database) const treeRepository = createConversationTreeRepository(options.database) @@ -301,7 +317,7 @@ export async function startBuddyService( const usageRepository = createUsageRepository(options.database) const workspace = createWorkspaceRepository(options.database) const turnRequests = await host.start('runtime.turn_requests', ({ defer }) => { - const service = new TurnRequestService(createTurnRequestRepository(options.database), () => record({ event: 'observer.failed', component: 'runtime.turn_requests', level: 'warn' }), options.eventLog) + const service = new TurnRequestService(createTurnRequestRepository(options.database), () => record({ event: 'observer.failed', component: 'runtime.turn_requests', level: 'warn' }), conversationAvailability) const notifications = service.onDidCommit((event) => { const conversation = conversations.findById(event.conversationId) if (conversation) @@ -567,6 +583,13 @@ export async function startBuddyService( runInputs, runs, repository: conversations, + spaces: spacesRepository, + spaceMoves: { + mutations: conversationMutations, + hasPendingMessages: id => chatQueue.hasPending(id), + hasActiveExecution: id => runner.hasActiveExecution(id) || runner.hasDegradedCleanup(id), + validateTarget: validateSpaceMoveTarget, + }, sessions: { invalidateConversation: id => sessions.invalidateConversationWithResult(id), applyPermissionSettings: conversation => sessions.applyConversationPermissions(conversation.id, conversation), @@ -958,6 +981,18 @@ export async function startBuddyService( spaces: spacesRepository, skills: skillService, }) + async function validateSpaceMoveTarget(conversation: ConversationRecord, spaceId: string): Promise { + if (!conversation.activeBranchId) + throw new BuddyServiceError('VALIDATION_FAILED') + await sessionBlueprints.createForConversation({ + approvalPolicy: conversation.approvalPolicy, + branchId: conversation.activeBranchId, + conversationId: conversation.id, + executionProfile: conversation.executionProfile, + sessionMode: 'interactive', + spaceId, + }) + } const sessionRecovery = new BuddySessionRecoveryService({ usage: usageRepository, attachments: attachmentService, @@ -1006,7 +1041,7 @@ export async function startBuddyService( } const executionCoordinator = await host.start('runtime.execution_tree', ({ defer }) => { const service = new ExecutionCoordinator({ - availability: options.eventLog, + availability: conversationAvailability, hostShellSupervision: currentPlatform.hostShellSupervision, executions: executionRecords, mailbox: createMailboxRepository(options.database), @@ -1149,6 +1184,7 @@ export async function startBuddyService( const service = new ConversationLifecycleService({ conversations, deletion: createConversationDeletionRepository(options.database), + assertWritable: id => conversationMutations.assertWritable(id), runner, cancelQueuedRuns: async (conversationId) => { try { @@ -1246,7 +1282,7 @@ export async function startBuddyService( }) const chatCommandService = await host.start('runtime.chat_commands', ({ defer }) => { const service = new ChatCommandService({ - availability: options.eventLog, + availability: conversationAvailability, commands: commandRequests, conversationLifecycle, conversations, @@ -1318,7 +1354,7 @@ export async function startBuddyService( return service }) chatQueueService = await host.start('runtime.chat-queue', async ({ defer }) => { - const service = new ChatQueueService({ availability: options.eventLog, queue: createChatQueueRepository(options.database), turns: chatTurnService, requests: turnRequests, launcher: turnLauncher, runner, runs, runInputs, getRunPermissionCeiling, eventLog: options.eventLog, onObserverError: () => record({ event: 'queue.observer_failed', level: 'error' }) }) + const service = new ChatQueueService({ availability: conversationAvailability, queue: chatQueue, turns: chatTurnService, requests: turnRequests, launcher: turnLauncher, runner, runs, runInputs, getRunPermissionCeiling, eventLog: options.eventLog, onObserverError: () => record({ event: 'queue.observer_failed', level: 'error' }) }) const actionInputs = service.onDidChange((event) => { if (event.committed?.messageId && event.committed.runId) extensionActions.dispatch({ type: 'task:input:committed', data: { conversationId: event.scope.conversationId, branchId: event.scope.branchId, runId: event.committed.runId, messageId: event.committed.messageId, commitId: event.committed.commitId } }) diff --git a/apps/buddy/service/src/__tests__/buddyService.integration.spec.ts b/apps/buddy/service/src/__tests__/buddyService.integration.spec.ts index 955cc761a..c863e4f9a 100644 --- a/apps/buddy/service/src/__tests__/buddyService.integration.spec.ts +++ b/apps/buddy/service/src/__tests__/buddyService.integration.spec.ts @@ -1336,6 +1336,52 @@ describe('buddy runtime cross-subsystem contract', () => { } }) + it('continues the same Pi history in the new space without moving previous work files', async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'lexora-buddy-move-space-'))) + directories.push(root) + const buddyHome = join(root, 'buddy') + const target = join(root, 'project') + await mkdir(target) + await writeFile(join(target, 'AGENTS.md'), 'Target project instruction marker: TARGET_PROJECT_CONTEXT_400') + const database = openTrackedDatabase(join(buddyHome, 'buddy.sqlite3')) + const eventLog = createRunEventLog({ conversationsDirectory: join(buddyHome, 'conversations'), database }) + const provider = await startLocalModelProvider() + const harness = createRuntimeRpcHarness({ 'offline-provider': { key: 'test-api-key', type: 'api_key' } }) + const diagnostics: ApplicationDiagnostic[] = [] + const service = await startBuddyService({ buddyHome, builtinSkillsDirectories: [], database, eventLog, rpc: harness.rpc, record: event => diagnostics.push(event) }) + try { + await harness.invoke('providers.upsertCustom', offlineProviderConfig(provider.baseUrl)) + const first = await service.runtime.startTurn(await turnRequest(harness, { content: 'Remember the original task history', requestId: 'before-space-move' })) + expect((await waitForTerminalRun(harness, first.runId)).status).toBe('completed') + await expect.poll(() => diagnostics.find(event => event.event === 'execution.settled' && event.runId === first.runId)).toMatchObject({ level: 'info' }) + const originalRun = createRunRepository(database).findById(first.runId)! + const journal = await readFile(originalRun.piSessionFile!, 'utf8') + const previous = join(buddyHome, 'conversations', first.conversationId, 'workspace') + await writeFile(join(previous, 'unfinished.txt'), 'Unpublished work') + const timestamp = new Date().toISOString() + createSpaceRepository(database).create({ id: 'target-space', name: 'Target', memoryScope: 'space_only', createdAt: timestamp, primaryDirectory: { id: 'target-directory', root: target, canonicalRoot: target, accessGrantedAt: timestamp, resourcesTrustedAt: timestamp }, additionalDirectories: [] }) + const moved = await harness.invoke('conversations.moveToSpace', { conversationId: first.conversationId, spaceId: 'target-space' }) + expect(moved).toMatchObject({ id: first.conversationId, activeBranchId: first.branchId, spaceId: 'target-space' }) + expect(await readFile(originalRun.piSessionFile!, 'utf8')).toBe(journal) + const second = await service.runtime.startTurn(await turnRequest(harness, { conversationId: first.conversationId, branchId: first.branchId, content: 'Continue the same task in this project', requestId: 'after-space-move' })) + expect((await waitForTerminalRun(harness, second.runId)).status).toBe('completed') + expect(second).toMatchObject({ conversationId: first.conversationId, branchId: first.branchId }) + expect(provider.requests).toHaveLength(2) + const request = JSON.stringify(provider.requests[1]) + expect(request).toContain('Remember the original task history') + expect(request).toContain('TARGET_PROJECT_CONTEXT_400') + expect(request).toContain(JSON.stringify(target).slice(1, -1)) + expect(JSON.stringify(provider.requests[0])).not.toContain('TARGET_PROJECT_CONTEXT_400') + expect(await readFile(join(previous, 'unfinished.txt'), 'utf8')).toBe('Unpublished work') + expect(createRunRepository(database).findById(first.runId)).toEqual(originalRun) + expect((await readFile(originalRun.piSessionFile!, 'utf8')).startsWith(journal)).toBe(true) + } + finally { + await service.dispose() + await eventLog.close() + } + }) + it('forks edited and regenerated chat turns while preserving idempotent replay', async () => { const root = await realpath(await mkdtemp(join(tmpdir(), 'lexora-buddy-chat-turns-'))) directories.push(root) diff --git a/apps/buddy/service/src/attachments/ComposerResourceService.ts b/apps/buddy/service/src/attachments/ComposerResourceService.ts index fb88fd91e..7e9afe413 100644 --- a/apps/buddy/service/src/attachments/ComposerResourceService.ts +++ b/apps/buddy/service/src/attachments/ComposerResourceService.ts @@ -541,21 +541,23 @@ export class ComposerResourceService { const conversation = this.#conversations?.findById(source.conversationId) if (!artifact || !conversation) throw new AttachmentError('ATTACHMENT_NOT_FOUND') + const conversationGrants = this.#conversationGrants?.listActive(conversation.id) ?? [] const grants = conversation.spaceId ? (() => { const space = requireActiveSpace(this.#spaces?.findById(conversation.spaceId) ?? null) - return [space.primaryDirectory, ...space.additionalDirectories] + return [space.primaryDirectory, ...space.additionalDirectories, ...conversationGrants.filter(grant => grant.id === artifact.directoryGrantId)] })() .filter(grant => grant !== null) - : this.#conversationGrants?.listActive(conversation.id) ?? [] + : conversationGrants const ownerId = conversation.spaceId ?? conversation.id + const ownedRoot = artifact.directoryGrantId === conversation.id + ? this.#paths.conversationWorkspace(conversation.id) + : artifact.directoryGrantId === ownerId + ? conversation.spaceId ? this.#paths.spaceWorkspace(ownerId) : this.#paths.conversationWorkspace(ownerId) + : null const grant = grants.find(item => item.id === artifact.directoryGrantId) ?? ( - artifact.directoryGrantId === ownerId - ? { - canonicalRoot: await realpath(conversation.spaceId ? this.#paths.spaceWorkspace(ownerId) : this.#paths.conversationWorkspace(ownerId)), - id: ownerId, - root: conversation.spaceId ? this.#paths.spaceWorkspace(ownerId) : this.#paths.conversationWorkspace(ownerId), - } + ownedRoot + ? { canonicalRoot: await realpath(ownedRoot), id: artifact.directoryGrantId, root: ownedRoot } : null ) if (!grant) diff --git a/apps/buddy/service/src/attachments/__tests__/ComposerResourceService.spec.ts b/apps/buddy/service/src/attachments/__tests__/ComposerResourceService.spec.ts index a43299d71..e225999a5 100644 --- a/apps/buddy/service/src/attachments/__tests__/ComposerResourceService.spec.ts +++ b/apps/buddy/service/src/attachments/__tests__/ComposerResourceService.spec.ts @@ -13,6 +13,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { BUDDY_ATTACHMENT_COUNT_LIMIT } from '../../../../shared/conversation/attachmentPolicy' import { createBuddyUserContent } from '../../../../shared/conversation/buddyUserContent' import { createBuddyInputReference } from '../../agent/context/BuddyInputReference' +import { BuddySessionBlueprintService } from '../../agent/sessions/BuddySessionBlueprintService' import { BuddySessionRecoveryService } from '../../agent/sessions/recovery/BuddySessionRecoveryService' import { BuddyConversationTree } from '../../agent/sessions/tree/BuddyConversationTree' import { ArtifactService } from '../../artifacts/ArtifactService' @@ -716,6 +717,44 @@ describe('composer resource import', () => { expect(afterRevocation.files.map(item => item.category)).toEqual(['history']) }) + it.each(['owned', 'external'])('retains %s outputs after moving without extending the new execution grants', async (location) => { + const fixture = await setup() + const timestamp = '2026-09-06T00:00:00.000Z' + fixture.conversations.create({ id: 'conversation-1', branchId: 'branch-1', spaceId: null, title: 'Original', approvalPolicy: 'policy', executionProfile: 'workspace_write', createdAt: timestamp }) + const owned = fixture.paths.conversationWorkspace('conversation-1') + const external = join(fixture.root, 'external') + const target = join(fixture.root, 'target') + for (const path of [owned, external, target]) + await mkdir(path, { recursive: true }) + await writeFile(join(owned, 'unpublished.txt'), 'unfinished work') + fixture.conversationGrants.grant({ id: 'old-grant', conversationId: 'conversation-1', root: external, canonicalRoot: external, createdAt: timestamp }) + const root = location === 'owned' ? owned : external + const artifactPath = join(root, 'result.txt') + await writeFile(artifactPath, 'old output') + fixture.run() + const [artifact] = await fixture.artifacts.presentOutputs({ conversationId: 'conversation-1', cwd: root, grants: [{ canonicalRoot: root, root, grantId: location === 'owned' ? 'conversation-1' : 'old-grant', kind: 'workspace' }], paths: [artifactPath] }) + fixture.publish({ createdAt: timestamp, payload: { artifactIds: [artifact!.id], sourceToolCallId: 'output', sourceToolName: 'lexora_output_present' }, runId: 'run-1', sequence: 1, type: 'output.produced' }) + fixture.spaces.create({ id: 'target', name: 'Target', memoryScope: 'space_only', createdAt: timestamp, primaryDirectory: { id: 'target-directory', root: target, canonicalRoot: target, accessGrantedAt: timestamp, resourcesTrustedAt: timestamp }, additionalDirectories: [] }) + fixture.conversations.setSpace({ id: 'conversation-1', spaceId: 'target', updatedAt: timestamp }) + const scope = { branchId: 'branch-1', conversationId: 'conversation-1', draftId: 'moved-draft', query: '', spaceId: 'target' } + const option = (await fixture.service.listSources(scope)).files.find(item => item.category === 'artifact')! + expect(option.path).toBe(artifactPath) + const selected = await fixture.service.selectSource({ draftId: scope.draftId, resourceId: 'old-result', source: option.source }) + expect(await resolveInputs(fixture.service, scope.draftId, { ...createBuddyUserContent(), panelResourceIds: [selected.resourceId] }, scope)).toEqual([{ resourceId: selected.resourceId, localReference: { kind: 'file', mimeType: 'text/plain', name: 'result.txt', path: artifactPath, sizeBytes: 10 } }]) + const blueprint = await new BuddySessionBlueprintService({ paths: fixture.paths, conversationGrants: fixture.conversationGrants, spaces: fixture.spaces, skills: { materializeForSpace: async () => [], loadForSpace: async spaceId => ({ diagnostics: [], readRoots: [], references: [], paths: [], skills: [], revision: `skills-${spaceId}` }) } }).createForConversation({ branchId: 'branch-1', conversationId: 'conversation-1', approvalPolicy: 'policy', executionProfile: 'workspace_write', sessionMode: 'interactive', spaceId: 'target' }) + expect(blueprint.canonicalRoot).toBe(target) + expect(blueprint.grants.map(grant => grant.canonicalRoot)).toEqual([target, fixture.paths.spaceWorkspace('target')]) + expect(await readFile(join(owned, 'unpublished.txt'), 'utf8')).toBe('unfinished work') + expect(await readFile(artifactPath, 'utf8')).toBe('old output') + expect(await readdir(target)).toEqual([]) + if (location === 'external') { + fixture.conversationGrants.revokeAll('conversation-1', timestamp) + expect((await fixture.service.listSources(scope)).files.filter(item => item.category === 'artifact')).toEqual([]) + await expect(fixture.service.selectSource({ draftId: scope.draftId, resourceId: 'revoked', source: option.source })).rejects.toMatchObject({ code: 'DIRECTORY_NOT_AUTHORIZED' }) + expect((await fixture.artifacts.readText(artifact!.id)).text).toBe('old output') + } + }) + it.each(['conversation', 'space', 'space-with-primary'])('keeps owned Artifacts and historical inputs available in %s', async (owner) => { const fixture = await setup() const spaceId = owner === 'conversation' ? null : 'space-1' diff --git a/apps/buddy/service/src/conversations/ConversationLifecycleService.ts b/apps/buddy/service/src/conversations/ConversationLifecycleService.ts index 121094b25..c2edfab3d 100644 --- a/apps/buddy/service/src/conversations/ConversationLifecycleService.ts +++ b/apps/buddy/service/src/conversations/ConversationLifecycleService.ts @@ -11,6 +11,7 @@ const conversationIdentityPattern = /^[A-Z0-9][\w-]{0,127}$/i export interface ConversationLifecycleServiceOptions { conversations: Pick deletion: ConversationDeletionRepository + assertWritable?: (conversationId: string) => void runner: { cancelAndWaitForConversation: (conversationId: string) => Promise } cancelQueuedRuns: (conversationId: string) => Promise sessions: { invalidateConversation: (conversationId: string) => Promise<{ pending: number, degraded: number }> } @@ -52,6 +53,7 @@ export class ConversationLifecycleService { return current if (this.#completed.has(conversationId)) return Promise.resolve(false) + this.#options.assertWritable?.(conversationId) const result = Promise.withResolvers() this.#deleting.set(conversationId, result.promise) void this.#delete(conversationId, reason).then((deleted) => { diff --git a/apps/buddy/service/src/conversations/ConversationMetadataService.ts b/apps/buddy/service/src/conversations/ConversationMetadataService.ts index 3e2975c28..58f2650f0 100644 --- a/apps/buddy/service/src/conversations/ConversationMetadataService.ts +++ b/apps/buddy/service/src/conversations/ConversationMetadataService.ts @@ -5,14 +5,18 @@ import type { ConversationRepository, RenameConversationInput, SetConversationPe import type { RunInputRepository } from '../storage/runInputRepository' import type { RunRecord } from '../storage/runRecord' import type { RunRepository } from '../storage/runRepository' +import type { SpaceRepository } from '../storage/spaceRepository' +import type { ConversationMutationGuard } from './ConversationMutationGuard' import { randomUUID } from 'node:crypto' import { Emitter } from '../../../shared/events/Emitter' import { copyEventSnapshot } from '../../../shared/events/eventSnapshot' import { BuddyServiceError } from '../rpc/runtimeRequest' +import { requireActiveSpace } from '../spaces/requireActiveSpace' +import { createSpaceExecutionContext, matchesSpaceExecutionContext } from '../spaces/spaceExecutionContext' export type ConversationMetadataCommit = EventSnapshot<{ commitId: string - kind: 'title' | 'permissions' | 'model' | 'branch' | 'buddy' + kind: 'title' | 'permissions' | 'model' | 'branch' | 'buddy' | 'space' conversation: ConversationRecord actorId?: string titleRevision?: number @@ -23,7 +27,14 @@ export interface ConversationMetadataServiceOptions { buddies?: Pick runInputs?: Pick runs?: Pick - repository: Pick + repository: Pick + spaces?: Pick + spaceMoves?: { + mutations: ConversationMutationGuard + hasPendingMessages: (conversationId: string) => boolean + hasActiveExecution: (conversationId: string) => boolean + validateTarget: (conversation: ConversationRecord, spaceId: string) => Promise + } resolveModelSelection: (selection: ConversationModelSelection) => Promise sessions: { invalidateConversation: (conversationId: string) => Promise<{ pending: number, degraded: number }> @@ -116,6 +127,48 @@ export class ConversationMetadataService { return conversation } + moveToSpace(input: { id: string, spaceId: string }): Promise { + return this.#run(() => this.#moveToSpace(input)) + } + + async #moveToSpace(input: { id: string, spaceId: string }): Promise { + const moves = this.#options.spaceMoves + const spaces = this.#options.spaces + if (!moves || !spaces) + throw new BuddyServiceError('VALIDATION_FAILED') + return moves.mutations.run(input.id, async () => { + const current = this.#requireActive(input.id) + requireActiveSpace(spaces.findById(input.spaceId)) + if (current.spaceId === input.spaceId) + return current + if (current.spaceId !== null) + throw new BuddyServiceError('CONVERSATION_SPACE_CONFLICT') + const assertIdle = () => { + if (this.#options.runs?.findActiveForConversation(input.id) || moves.hasActiveExecution(input.id)) + throw new BuddyServiceError('CONVERSATION_BUSY') + if (moves.hasPendingMessages(input.id)) + throw new BuddyServiceError('CONVERSATION_QUEUE_NOT_EMPTY') + } + assertIdle() + const target = createSpaceExecutionContext(requireActiveSpace(spaces.findById(input.spaceId))) + await moves.validateTarget(current, input.spaceId) + const result = await this.#options.sessions.invalidateConversation(input.id) + if (result.degraded) + throw new BuddyServiceError('MUTATION_RESOURCE_UNSAFE') + if (result.pending) + throw new BuddyServiceError('CONVERSATION_BUSY') + this.#requireActive(input.id) + assertIdle() + if (!matchesSpaceExecutionContext(requireActiveSpace(spaces.findById(input.spaceId)), target)) + throw new BuddyServiceError('DIRECTORY_NOT_AUTHORIZED') + const conversation = this.#options.repository.setSpace({ ...input, updatedAt: new Date().toISOString() }) + if (!conversation) + throw new BuddyServiceError('CONVERSATION_SPACE_CONFLICT') + this.#publish('space', conversation) + return conversation + }) + } + getModelControls(conversationId: string, actorId?: string) { const conversation = this.#requireActive(conversationId) const run = this.#options.runs?.findActiveForConversation(conversationId) diff --git a/apps/buddy/service/src/conversations/ConversationMutationGuard.ts b/apps/buddy/service/src/conversations/ConversationMutationGuard.ts new file mode 100644 index 000000000..ab114afa8 --- /dev/null +++ b/apps/buddy/service/src/conversations/ConversationMutationGuard.ts @@ -0,0 +1,21 @@ +import { BuddyServiceError } from '../rpc/runtimeRequest' + +export class ConversationMutationGuard { + readonly #changing = new Set() + + assertWritable(conversationId: string): void { + if (this.#changing.has(conversationId)) + throw new BuddyServiceError('CONVERSATION_BUSY') + } + + async run(conversationId: string, operation: () => Promise): Promise { + this.assertWritable(conversationId) + this.#changing.add(conversationId) + try { + return await operation() + } + finally { + this.#changing.delete(conversationId) + } + } +} diff --git a/apps/buddy/service/src/conversations/__tests__/ConversationMetadataService.spec.ts b/apps/buddy/service/src/conversations/__tests__/ConversationMetadataService.spec.ts index a1f3a4a8f..bd8db6b13 100644 --- a/apps/buddy/service/src/conversations/__tests__/ConversationMetadataService.spec.ts +++ b/apps/buddy/service/src/conversations/__tests__/ConversationMetadataService.spec.ts @@ -3,11 +3,14 @@ import type { ConversationMetadataCommit } from '../ConversationMetadataService' import { afterEach, describe, expect, it, vi } from 'vitest' import { buddySettingsSchema } from '../../../../shared/buddies/buddySettings' import { createBuddyRepository } from '../../storage/buddyRepository' +import { createChatQueueRepository } from '../../storage/chatQueueRepository' import { createConversationMemberRepository } from '../../storage/conversationMemberRepository' import { createConversationRepository } from '../../storage/conversationRepository' import { openBuddyDatabase } from '../../storage/database' import { createRunRepository } from '../../storage/runRepository' +import { createSpaceRepository } from '../../storage/spaceRepository' import { ConversationMetadataService } from '../ConversationMetadataService' +import { ConversationMutationGuard } from '../ConversationMutationGuard' const databases: DatabaseSync[] = [] afterEach(() => { @@ -152,6 +155,70 @@ describe('conversation metadata ownership', () => { expect(f.runs.findById('run-1')).toMatchObject({ status: 'completed', modelOverride: selection }) }) + it('moves a space-less task into an active space once and releases its sessions', async () => { + const f = fixture() + const changes: ConversationMetadataCommit[] = [] + f.service.onDidCommit(event => changes.push(event)) + await expect(f.service.moveToSpace({ id: 'task-1', spaceId: 'space-1' })).resolves.toMatchObject({ id: 'task-1', spaceId: 'space-1' }) + expect(f.repository.findById('task-1')?.spaceId).toBe('space-1') + expect(changes.map(event => event.kind)).toEqual(['space']) + expect(f.invalidate).toHaveBeenCalledWith('task-1') + await expect(f.service.moveToSpace({ id: 'task-1', spaceId: 'space-2' })).rejects.toMatchObject({ code: 'CONVERSATION_SPACE_CONFLICT' }) + expect(f.repository.findById('task-1')?.spaceId).toBe('space-1') + await expect(f.service.moveToSpace({ id: 'task-1', spaceId: 'space-1' })).resolves.toMatchObject({ spaceId: 'space-1' }) + expect(changes).toHaveLength(1) + }) + + it('refuses to move a task into a revoked space or while it has an active run', async () => { + const f = fixture() + await expect(f.service.moveToSpace({ id: 'task-1', spaceId: 'revoked' })).rejects.toMatchObject({ code: 'SPACE_UNAVAILABLE' }) + f.runs.create({ id: 'run-1', conversationId: 'task-1', branchId: 'branch-1', triggeringMessageId: 'message-1', provider: 'fixture', model: 'model-1', purpose: 'chat', status: 'running', piSessionFile: null, approvalPolicy: 'policy', executionProfile: 'workspace_write', startedAt: '2026-10-11T00:00:00.000Z' }) + await expect(f.service.moveToSpace({ id: 'task-1', spaceId: 'space-1' })).rejects.toMatchObject({ code: 'CONVERSATION_BUSY' }) + expect(f.repository.findById('task-1')?.spaceId).toBeNull() + expect(f.invalidate).not.toHaveBeenCalled() + }) + + it.each(['waiting', 'paused'])('keeps %s queue inputs unchanged and refuses to move across any branch', async (state) => { + const f = fixture() + f.database.prepare(`INSERT INTO chat_queue (id, conversation_id, branch_id, request_id, request_fingerprint, prepared_json, state, created_at) VALUES ('queued', 'task-1', 'branch-1', 'request', 'fingerprint', '{"spaceId":null}', ?, 'now')`).run(state) + await expect(f.service.moveToSpace({ id: 'task-1', spaceId: 'space-1' })).rejects.toMatchObject({ code: 'CONVERSATION_QUEUE_NOT_EMPTY' }) + expect(f.database.prepare('SELECT state, prepared_json FROM chat_queue').get()).toEqual({ state, prepared_json: '{"spaceId":null}' }) + expect(f.repository.findById('task-1')?.spaceId).toBeNull() + }) + + it('does not commit or publish a move when session cleanup fails and permits a safe retry', async () => { + const f = fixture() + const changes: ConversationMetadataCommit[] = [] + f.service.onDidCommit(event => changes.push(event)) + f.invalidate.mockRejectedValueOnce(new Error('Cleanup failed')) + await expect(f.service.moveToSpace({ id: 'task-1', spaceId: 'space-1' })).rejects.toThrow('Cleanup failed') + expect(f.repository.findById('task-1')?.spaceId).toBeNull() + expect(changes).toEqual([]) + await expect(f.service.moveToSpace({ id: 'task-1', spaceId: 'space-1' })).resolves.toMatchObject({ spaceId: 'space-1' }) + expect(changes).toHaveLength(1) + }) + + it('holds the shared admission barrier until cleanup and revalidates a revoked target before committing', async () => { + const f = fixture() + const gate = Promise.withResolvers<{ pending: number, degraded: number }>() + f.invalidate.mockImplementationOnce(() => gate.promise) + const pending = f.service.moveToSpace({ id: 'task-1', spaceId: 'space-1' }) + expect(() => f.mutations.assertWritable('task-1')).toThrow(expect.objectContaining({ code: 'CONVERSATION_BUSY' })) + expect(() => f.mutations.assertWritable('unrelated')).not.toThrow() + f.database.prepare('UPDATE spaces SET revoked_at = ? WHERE id = ?').run('now', 'space-1') + gate.resolve({ pending: 0, degraded: 0 }) + await expect(pending).rejects.toMatchObject({ code: 'SPACE_UNAVAILABLE' }) + expect(f.repository.findById('task-1')?.spaceId).toBeNull() + expect(() => f.mutations.assertWritable('task-1')).not.toThrow() + }) + + it('refuses to switch while a terminal run still owns execution resources', async () => { + const f = fixture() + f.hasActiveExecution.mockReturnValueOnce(true) + await expect(f.service.moveToSpace({ id: 'task-1', spaceId: 'space-1' })).rejects.toMatchObject({ code: 'CONVERSATION_BUSY' }) + expect(f.repository.findById('task-1')?.spaceId).toBeNull() + }) + it('revalidates a target disabled during model validation and records explicit provenance for an unchanged legacy value', async () => { const f = fixture() const other = f.buddies.create({ name: 'Other' }) @@ -181,6 +248,12 @@ function fixture() { const resolveModel = vi.fn(async (selection: NonNullable>['modelSelection']) => selection!) const runs = createRunRepository(database) const buddies = createBuddyRepository(database) - const service = new ConversationMetadataService({ buddies, runs, repository, resolveModelSelection: resolveModel, sessions: { invalidateConversation: invalidate } }) - return { database, runs, service, repository, invalidate, resolveModel, buddies, members: createConversationMemberRepository(database) } + const seedSpace = database.prepare(`INSERT INTO spaces (id, name, memory_scope, revoked_at, created_at, updated_at) VALUES (?, ?, 'personal_and_space', ?, '2026-09-06T00:00:00.000Z', '2026-09-06T00:00:00.000Z')`) + seedSpace.run('space-1', 'Space 1', null) + seedSpace.run('space-2', 'Space 2', null) + seedSpace.run('revoked', 'Revoked', '2026-09-07T00:00:00.000Z') + const mutations = new ConversationMutationGuard() + const hasActiveExecution = vi.fn(() => false) + const service = new ConversationMetadataService({ buddies, runs, repository, spaces: createSpaceRepository(database), spaceMoves: { mutations, hasPendingMessages: id => createChatQueueRepository(database).hasPending(id), hasActiveExecution, validateTarget: async () => {} }, resolveModelSelection: resolveModel, sessions: { invalidateConversation: invalidate } }) + return { database, runs, service, repository, invalidate, mutations, hasActiveExecution, resolveModel, buddies, members: createConversationMemberRepository(database) } } diff --git a/apps/buddy/service/src/conversations/registerConversationRpc.ts b/apps/buddy/service/src/conversations/registerConversationRpc.ts index c1754a11a..c50d5a916 100644 --- a/apps/buddy/service/src/conversations/registerConversationRpc.ts +++ b/apps/buddy/service/src/conversations/registerConversationRpc.ts @@ -44,7 +44,7 @@ export interface RegisterConversationRpcOptions { attachments: Pick changes: Pick conversations: ConversationRpcRepository - metadata: Pick + metadata: Pick deleteConversation: (conversationId: string) => Promise eventLog: { listForRuns: (runIds: readonly string[]) => BuddyRunEvent[] } & Partial> isDeleting: (conversationId: string) => boolean @@ -82,6 +82,7 @@ export function registerConversationRpc(options: RegisterConversationRpcOptions) }) })) disposers.push(registerRuntimeRequest(options.rpc, conversationsRpc.setBuddy, input => options.metadata.setBuddy({ id: input.conversationId, buddyId: input.buddyId }))) + disposers.push(registerRuntimeRequest(options.rpc, conversationsRpc.moveToSpace, input => options.metadata.moveToSpace({ id: input.conversationId, spaceId: input.spaceId }))) disposers.push(registerRuntimeRequest(options.rpc, conversationsRpc.setModelSelection, async (input) => { return options.metadata.setModelSelection({ id: input.conversationId, diff --git a/apps/buddy/service/src/rpc/runtimeRequest.ts b/apps/buddy/service/src/rpc/runtimeRequest.ts index ccbdaa02b..47b7e1c6c 100644 --- a/apps/buddy/service/src/rpc/runtimeRequest.ts +++ b/apps/buddy/service/src/rpc/runtimeRequest.ts @@ -33,6 +33,10 @@ export type BuddyServiceErrorCode | 'BUDDY_DEFAULT_PROTECTED' | 'BUDDY_RESET_UNAVAILABLE' | 'DRAFT_CONFLICT' + | 'CONVERSATION_BUSY' + | 'CONVERSATION_QUEUE_NOT_EMPTY' + | 'CONVERSATION_SPACE_CONFLICT' + | 'MUTATION_RESOURCE_UNSAFE' | 'DIRECTORY_NOT_AUTHORIZED' | 'MODEL_INPUT_UNSUPPORTED' | 'MODEL_INPUT_TOO_LARGE' diff --git a/apps/buddy/service/src/storage/__tests__/chatQueueRepository.spec.ts b/apps/buddy/service/src/storage/__tests__/chatQueueRepository.spec.ts index 6104f54f9..67f636f67 100644 --- a/apps/buddy/service/src/storage/__tests__/chatQueueRepository.spec.ts +++ b/apps/buddy/service/src/storage/__tests__/chatQueueRepository.spec.ts @@ -49,6 +49,17 @@ function fixture() { } describe('chat queue ownership and delivery', () => { + it('rejects an input prepared before a space move without consuming its draft', () => { + const f = fixture() + const prepared = f.input('late') + const draft = f.drafts.findById('draft') + f.database.exec(`INSERT INTO spaces (id, name, memory_scope, created_at, updated_at) VALUES ('target', 'Target', 'space_only', 'now', 'now')`) + f.database.exec(`UPDATE conversations SET space_id = 'target'`) + expect(() => f.queue.enqueue(prepared)).toThrow(expect.objectContaining({ code: 'CONVERSATION_SPACE_CONFLICT' })) + expect(f.queue.hasPending('conversation')).toBe(false) + expect(f.drafts.findById('draft')).toEqual(draft) + }) + it('atomically rejects changed definition scope even when actor and model still match the running input', () => { const f = fixture() const buddies = createBuddyRepository(f.database) diff --git a/apps/buddy/service/src/storage/chatQueueRepository.ts b/apps/buddy/service/src/storage/chatQueueRepository.ts index baeadd86a..451a5de4a 100644 --- a/apps/buddy/service/src/storage/chatQueueRepository.ts +++ b/apps/buddy/service/src/storage/chatQueueRepository.ts @@ -80,6 +80,9 @@ export function createChatQueueRepository(database: DatabaseSync) { enqueue(input: PrepareTurnRequestInput) { return withTransaction(database, () => { assertScope(input) + const conversation = database.prepare('SELECT space_id FROM conversations WHERE id = ?').get(input.conversationId) + if (conversation?.space_id !== input.spaceId) + throw new BuddyServiceError('CONVERSATION_SPACE_CONFLICT') assertTurnAttachmentBindings(input) const id = input.userMessageId if (input.followup) @@ -122,6 +125,9 @@ export function createChatQueueRepository(database: DatabaseSync) { }) } }) }, + hasPending(conversationId: string): boolean { + return Boolean(database.prepare('SELECT 1 FROM chat_queue WHERE conversation_id = ? AND state IN (\'waiting\', \'paused\') LIMIT 1').get(conversationId)) + }, continuationScopes(): LocalChatQueueScope[] { return database.prepare(`SELECT DISTINCT q.conversation_id AS conversationId, q.branch_id AS branchId FROM chat_queue q JOIN conversations c ON c.id = q.conversation_id diff --git a/apps/buddy/service/src/storage/conversationRepository.ts b/apps/buddy/service/src/storage/conversationRepository.ts index 2b9c3ad46..bb90e3c46 100644 --- a/apps/buddy/service/src/storage/conversationRepository.ts +++ b/apps/buddy/service/src/storage/conversationRepository.ts @@ -75,6 +75,7 @@ export interface ConversationRepository addMember: (input: { id: string, buddyId: string, updatedAt: string }) => void removeMember: (input: { id: string, buddyId: string, updatedAt: string }) => void setBuddy: (input: { id: string, buddyId: string, updatedAt: string }) => ConversationRecord | null + setSpace: (input: { id: string, spaceId: string, updatedAt: string }) => ConversationRecord | null setModelSelection: ( input: SetConversationModelSelectionInput, ) => ConversationRecord | null @@ -110,6 +111,14 @@ export function createConversationRepository(database: DatabaseSync): Conversati SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL `) + const moveToSpace = database.prepare(` + UPDATE conversations + SET space_id = ?, updated_at = ? + WHERE id = ? AND space_id IS NULL AND deleted_at IS NULL + AND EXISTS (SELECT 1 FROM spaces WHERE spaces.id = ? AND revoked_at IS NULL) + AND NOT EXISTS (SELECT 1 FROM runs WHERE conversation_id = conversations.id AND status IN ('queued', 'running')) + AND NOT EXISTS (SELECT 1 FROM chat_queue WHERE conversation_id = conversations.id AND state IN ('waiting', 'paused')) + `) const history = createConversationHistoryStore(database) const index = createConversationIndexRepository(database) const timeline = createConversationTimelineRepository(database, history.lineage) @@ -248,6 +257,11 @@ export function createConversationRepository(database: DatabaseSync): Conversati return requireConversationRecord(findConversation.get(input.id), input.id) }) }, + setSpace(input) { + if (!Number(moveToSpace.run(input.spaceId, input.updatedAt, input.id, input.spaceId).changes)) + return null + return requireConversationRecord(findConversation.get(input.id), input.id) + }, setModelSelection(input) { const current = findConversation.get(input.id) as ConversationRow | undefined if (!current || current.deleted_at !== null) diff --git a/apps/buddy/shared/conversation/conversationApi.ts b/apps/buddy/shared/conversation/conversationApi.ts index ff7b425cd..f2136cd3b 100644 --- a/apps/buddy/shared/conversation/conversationApi.ts +++ b/apps/buddy/shared/conversation/conversationApi.ts @@ -146,6 +146,7 @@ export const conversationRequestSchemas = { executionProfile: executionProfileSchema, }).strict(), conversationBuddy: z.object({ conversationId: idSchema, buddyId: idSchema }).strict(), + conversationMoveToSpace: z.object({ conversationId: idSchema, spaceId: idSchema }).strict(), conversationModelSelection: z.object({ conversationId: idSchema, actorId: idSchema.optional(), @@ -192,6 +193,7 @@ export const conversationsRpc = { rename: { method: 'conversations.rename', input: conversationRequestSchemas.conversationRename, response: conversationResponseSchemas.conversation }, setPermissionSettings: { method: 'conversations.setPermissionSettings', input: conversationRequestSchemas.conversationPermissionSettings, response: conversationResponseSchemas.conversation }, setBuddy: { method: 'conversations.setBuddy', input: conversationRequestSchemas.conversationBuddy, response: conversationResponseSchemas.conversation }, + moveToSpace: { method: 'conversations.moveToSpace', input: conversationRequestSchemas.conversationMoveToSpace, response: conversationResponseSchemas.conversation }, setModelSelection: { method: 'conversations.setModelSelection', input: conversationRequestSchemas.conversationModelSelection, response: conversationResponseSchemas.conversation }, listTimeline: { method: 'conversations.listTimeline', input: conversationRequestSchemas.conversationTimeline, response: conversationResponseSchemas.timelinePage }, } as const satisfies Record diff --git a/apps/buddy/shared/runtime/localChatError.ts b/apps/buddy/shared/runtime/localChatError.ts index ee2f32eb8..fd7f90fc7 100644 --- a/apps/buddy/shared/runtime/localChatError.ts +++ b/apps/buddy/shared/runtime/localChatError.ts @@ -28,6 +28,9 @@ export type LocalChatErrorCode | 'BUDDY_DEFAULT_PROTECTED' | 'BUDDY_RESET_UNAVAILABLE' | 'DRAFT_CONFLICT' + | 'CONVERSATION_BUSY' + | 'CONVERSATION_QUEUE_NOT_EMPTY' + | 'CONVERSATION_SPACE_CONFLICT' | 'EVENT_LOG_CORRUPTED' | 'MUTATION_RESOURCE_UNSAFE' | 'FILE_CONFLICT' @@ -102,6 +105,9 @@ const LOCAL_CHAT_ERROR_CODES = new Set([ 'BUDDY_DEFAULT_PROTECTED', 'BUDDY_RESET_UNAVAILABLE', 'DRAFT_CONFLICT', + 'CONVERSATION_BUSY', + 'CONVERSATION_QUEUE_NOT_EMPTY', + 'CONVERSATION_SPACE_CONFLICT', 'EVENT_LOG_CORRUPTED', 'MUTATION_RESOURCE_UNSAFE', 'FILE_CONFLICT', diff --git a/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue b/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue index b8dbb5afa..70a13b0a8 100644 --- a/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue +++ b/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue @@ -70,6 +70,7 @@ const taskIndex = useTaskIndex({ ready: computed(() => stores.runtimeSupervisor.runtimeState.value.status === 'ready'), onTaskDeleted, beforeTaskDelete, + beforeTaskMove, onSpaceCreated, }) const diagnostics = createRendererDiagnostics(api.app.logs) @@ -191,6 +192,9 @@ const capabilities = createDesktopCapabilities({ api, stores, selectedModel: com function onSpaceCreated(id: string) { return workbench.newTask({ spaceId: id }) } +async function beforeTaskMove(id: string) { + return await workbench.pool.peek({ scheme: 'task', id, data: {} })?.flushDrafts() ?? true +} function beforeTaskDelete(id: string) { return workbench.prepareTaskDeletion(id) } diff --git a/apps/buddy/src/i18n/locales/en-US/diagnostics.ts b/apps/buddy/src/i18n/locales/en-US/diagnostics.ts index 3a06bf3d9..055a84789 100644 --- a/apps/buddy/src/i18n/locales/en-US/diagnostics.ts +++ b/apps/buddy/src/i18n/locales/en-US/diagnostics.ts @@ -86,6 +86,9 @@ export default { 'desktop.error.modelSyncFailed': 'Could not sync models. Check the API endpoint and authentication, then retry.', 'desktop.error.modelSyncUnsupported': 'This API protocol does not support model synchronization.', 'desktop.error.pathOutsideDirectory': 'The target is outside the authorized directory', + 'desktop.error.conversationBusy': 'This task is running or finishing up. Try again later.', + 'desktop.error.conversationQueueNotEmpty': 'Send or cancel the pending messages first.', + 'desktop.error.conversationSpaceConflict': 'This task already belongs to another space.', 'desktop.error.spaceHasActiveRuns': 'This space still has active tasks. Stop them before changing directories or deleting the space.', 'desktop.error.spaceUnavailable': 'This space is unavailable. Choose another space or check its configuration.', 'desktop.error.providerHasActiveRuns': 'A task is using this model service. Wait for it to finish or stop it first.', diff --git a/apps/buddy/src/i18n/locales/en-US/tasks.ts b/apps/buddy/src/i18n/locales/en-US/tasks.ts index a77b40652..1f0b9d330 100644 --- a/apps/buddy/src/i18n/locales/en-US/tasks.ts +++ b/apps/buddy/src/i18n/locales/en-US/tasks.ts @@ -78,6 +78,15 @@ export default { 'desktop.tasks.moreActions': 'More actions', 'desktop.tasks.renameTask': 'Rename', 'desktop.tasks.copy': 'Copy', + 'desktop.tasks.moveToSpace': 'Move to space', + 'desktop.tasks.moveToSpaceEmpty': 'No spaces available', + 'desktop.tasks.moveToSpaceTask': '{title}', + 'desktop.tasks.moveToSpaceTarget': 'Target space', + 'desktop.tasks.moveToSpaceDescription': 'Future runs use this space’s environment and directory permissions instead of the task’s previous grants. Conversations and artifacts stay available; files are not moved. Moving out is not supported yet.', + 'desktop.tasks.moveToSpaceBusy': 'The task is running. Try again later.', + 'desktop.tasks.moveToSpaceUnavailable': 'This task can no longer be moved.', + 'desktop.tasks.moveToSpaceConfirm': 'Move', + 'desktop.tasks.moveToSpaceFailed': 'Move failed. Try again.', 'desktop.tasks.taskReference': 'Task reference', 'desktop.tasks.taskId': 'Task ID', 'desktop.tasks.title': 'Title', diff --git a/apps/buddy/src/i18n/locales/zh-CN/diagnostics.ts b/apps/buddy/src/i18n/locales/zh-CN/diagnostics.ts index dce8c7163..8dc8dea82 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/diagnostics.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/diagnostics.ts @@ -84,6 +84,9 @@ export default { 'desktop.error.modelSyncFailed': '模型同步失败,请检查 API 地址和认证信息后重试', 'desktop.error.modelSyncUnsupported': '当前 API 协议不支持模型同步', 'desktop.error.pathOutsideDirectory': '目标路径超出已授权目录', + 'desktop.error.conversationBusy': '任务正在执行或收尾,请稍后再试', + 'desktop.error.conversationQueueNotEmpty': '请先发送或取消待发消息', + 'desktop.error.conversationSpaceConflict': '任务已属于其他空间', 'desktop.error.spaceHasActiveRuns': '空间仍有运行中的任务,暂不能更改目录或删除空间', 'desktop.error.spaceUnavailable': '空间不可用,请重新选择或检查空间配置', 'desktop.error.providerHasActiveRuns': '当前有任务正在使用此模型服务,请先等待完成或停止任务', diff --git a/apps/buddy/src/i18n/locales/zh-CN/tasks.ts b/apps/buddy/src/i18n/locales/zh-CN/tasks.ts index 747148fea..4279df5e3 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/tasks.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/tasks.ts @@ -76,6 +76,15 @@ export default { 'desktop.tasks.moreActions': '更多操作', 'desktop.tasks.renameTask': '重命名', 'desktop.tasks.copy': '复制', + 'desktop.tasks.moveToSpace': '移入空间', + 'desktop.tasks.moveToSpaceEmpty': '暂无可用空间', + 'desktop.tasks.moveToSpaceTask': '{title}', + 'desktop.tasks.moveToSpaceTarget': '目标空间', + 'desktop.tasks.moveToSpaceDescription': '后续使用该空间的工作环境和目录权限,原目录授权不再沿用。对话和产物保留,文件不搬迁。暂不支持移出空间。', + 'desktop.tasks.moveToSpaceBusy': '任务正在执行,请稍后再试', + 'desktop.tasks.moveToSpaceUnavailable': '任务已不可移入', + 'desktop.tasks.moveToSpaceConfirm': '移入', + 'desktop.tasks.moveToSpaceFailed': '移入失败,请重试', 'desktop.tasks.taskReference': '任务引用', 'desktop.tasks.taskId': '任务 ID', 'desktop.tasks.title': '标题', diff --git a/apps/buddy/src/modules/tasks/contracts.ts b/apps/buddy/src/modules/tasks/contracts.ts index 63d8b782c..f702fc6a0 100644 --- a/apps/buddy/src/modules/tasks/contracts.ts +++ b/apps/buddy/src/modules/tasks/contracts.ts @@ -68,6 +68,7 @@ export interface TaskIndex { deleteSpace: (spaceId: string) => Promise deleteTask: (taskId: string) => Promise renameTask: (taskId: string, title: string) => Promise + moveTaskToSpace: (taskId: string, spaceId: string) => Promise selectSpaceDirectory: () => Promise openSpaceDirectory: (spaceId: string) => Promise refresh: () => Promise 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 7e6e63648..f376d91b5 100644 --- a/apps/buddy/src/modules/tasks/state/__tests__/useTaskCapability.spec.ts +++ b/apps/buddy/src/modules/tasks/state/__tests__/useTaskCapability.spec.ts @@ -17,6 +17,25 @@ import { useTaskIndex } from '../task-index/useTaskIndex' import { useTaskCapability } from '../useTaskCapability' describe('useTaskCapability', () => { + it('updates the loaded conversation scope after a move without replacing its composer draft', async () => { + const api = createBranchingDesktopApi() + vi.stubGlobal('window', Object.assign(globalThis, { lexoraDesktop: api })) + const chat = createTestTask(api, { conversationId: 'conversation-1', branchId: 'branch-root', spaceId: null }) + await chat.initialize() + chat.workspace.composer.updateComposerContent('Unsent work', { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Unsent work' }] }] }) + await chat.flushDrafts() + const draftId = chat.workspace.composer.draftId.value + const current = chat.session.activeTask.value! + const listeners = vi.mocked(api.localChat.conversations.onChanged).mock.calls.map(call => call[0]) + for (const listener of listeners) + listener({ ...current, spaceId: 'target-space' }) + expect(chat.session.spaceId.value).toBe('target-space') + expect(chat.session.activeTask.value?.spaceId).toBe('target-space') + expect(chat.workspace.composer.draftId.value).toBe(draftId) + expect(JSON.stringify(chat.workspace.composer.composerContent.value)).toContain('Unsent work') + chat.dispose() + }) + it('loads conversation content while input restores even when branch loading fails', async () => { const api = createBranchingDesktopApi() vi.stubGlobal('window', Object.assign(globalThis, { lexoraDesktop: api })) @@ -1136,7 +1155,7 @@ function createDesktopApi() { }, conversations: { onTimelineChanged: vi.fn(() => () => {}), - onChanged: () => () => {}, + onChanged: vi.fn(() => () => {}), activateBranch: vi.fn(), delete: vi.fn(), list: conversationsList, diff --git a/apps/buddy/src/modules/tasks/state/conversations/useChatSession.ts b/apps/buddy/src/modules/tasks/state/conversations/useChatSession.ts index 8a46ac756..610574b16 100644 --- a/apps/buddy/src/modules/tasks/state/conversations/useChatSession.ts +++ b/apps/buddy/src/modules/tasks/state/conversations/useChatSession.ts @@ -38,6 +38,10 @@ export function useChatSession() { branches.value = [] } + function setSpaceId(nextSpaceId: string | null) { + spaceId.value = nextSpaceId + } + function acceptTurn(conversationId: string, branchId: string) { activeConversationId.value = conversationId activeBranchId.value = branchId @@ -86,6 +90,7 @@ export function useChatSession() { spaceId: readonly(spaceId), replaceBranches, setActiveBranch, + setSpaceId, upsertBranch, } } diff --git a/apps/buddy/src/modules/tasks/state/task-index/useTaskIndex.ts b/apps/buddy/src/modules/tasks/state/task-index/useTaskIndex.ts index 7f1dd9790..e886d0337 100644 --- a/apps/buddy/src/modules/tasks/state/task-index/useTaskIndex.ts +++ b/apps/buddy/src/modules/tasks/state/task-index/useTaskIndex.ts @@ -18,6 +18,7 @@ interface TaskIndexOptions { applicationSettings: ApplicationSettings ready: Readonly> beforeTaskDelete?: (id: string) => Promise + beforeTaskMove?: (id: string) => Promise onTaskDeleted?: (id: string) => void onSpaceCreated?: (id: string) => Promise } @@ -118,6 +119,14 @@ export function useTaskIndex(options: TaskIndexOptions) { return false } }, + async moveTaskToSpace(id, spaceId) { + if (options.beforeTaskMove && !await options.beforeTaskMove(id)) + return false + const conversation = await api.conversations.moveToSpace(id, spaceId) + if (!disposed) + data.applyConversation(conversation) + return true + }, async selectSpaceDirectory() { try { return await api.spaces.selectDirectory() diff --git a/apps/buddy/src/modules/tasks/state/useTaskCapability.ts b/apps/buddy/src/modules/tasks/state/useTaskCapability.ts index c122fed37..75090de8e 100644 --- a/apps/buddy/src/modules/tasks/state/useTaskCapability.ts +++ b/apps/buddy/src/modules/tasks/state/useTaskCapability.ts @@ -216,6 +216,12 @@ export function useTaskCapability(options: UseTaskCapabilityOptions): TaskCapabi selectDefaultModel: () => draftModelBinding.restoreScope(), session: chatSession, }) + const stopSpaceFollow = api.localChat.conversations.onChanged((conversation) => { + if (conversation.id !== activeConversationId.value) + return + applyConversation(conversation) + chatSession.setSpaceId(conversation.spaceId) + }) const workspacePersistence = useTaskWorkspacePersistence({ beforePersist: composerResources.whenAccepted, onDraftRestored: draftModelBinding.restoreOpenedDraft, @@ -503,6 +509,7 @@ export function useTaskCapability(options: UseTaskCapabilityOptions): TaskCapabi } function dispose() { + stopSpaceFollow() composerResources.dispose() lifecycle.dispose() workspacePersistence.dispose() diff --git a/apps/buddy/src/modules/tasks/widgets/task-index/DesktopTaskIndex.vue b/apps/buddy/src/modules/tasks/widgets/task-index/DesktopTaskIndex.vue index c6db3824c..bb08db0e6 100644 --- a/apps/buddy/src/modules/tasks/widgets/task-index/DesktopTaskIndex.vue +++ b/apps/buddy/src/modules/tasks/widgets/task-index/DesktopTaskIndex.vue @@ -9,7 +9,7 @@ import type { BuddyLocale } from '@/i18n/buddyI18n' import type { TaskSpaceInput } from '@/modules/tasks/state/task-index/typing' import { Add20Regular, Tag20Regular } from '@vicons/fluent' import { NAlert, NButton, NInput, NModal, NTooltip } from 'naive-ui' -import { shallowRef, toRef } from 'vue' +import { computed, shallowRef, toRef } from 'vue' import { useBuddyI18n } from '@/i18n/buddyI18n' import DesktopSpaceDialog from '@/modules/tasks/widgets/task-index/DesktopSpaceDialog.vue' import DesktopTaskRow from '@/modules/tasks/widgets/task-index/DesktopTaskRow.vue' @@ -26,6 +26,7 @@ import { useTaskIndexController } from '@/modules/tasks/widgets/task-index/useTa import DesktopIcon from '@/shared/ui/icon/DesktopIcon.vue' import DesktopWorkspaceSidebarIdentity from '@/shared/ui/workspace-sidebar/DesktopWorkspaceSidebarIdentity.vue' import DesktopTaskMarkManager from './DesktopTaskMarkManager.vue' +import DesktopTaskMoveToSpaceDialog from './DesktopTaskMoveToSpaceDialog.vue' const props = defineProps<{ activeConversationId: string | null @@ -38,6 +39,7 @@ const props = defineProps<{ spaces: ReadonlyArray selectSpaceDirectory: () => Promise createSpace: (input: TaskSpaceInput) => Promise + moveTaskToSpace: (taskId: string, spaceId: string) => Promise updateSpace: (input: TaskSpaceInput & { spaceId: string }) => Promise tasks: ReadonlyArray }>() @@ -66,6 +68,24 @@ function markBindings(conversationId: string) { onManageMarks: () => marksOpen.value = true, } } +const taskMoveTargetId = shallowRef(null) +const taskMoveTarget = computed(() => props.tasks.find(task => task.id === taskMoveTargetId.value) ?? null) +const taskMoveBusy = computed(() => Boolean(taskMoveTargetId.value && props.pendingConversationIds?.includes(taskMoveTargetId.value))) +const moveTargets = computed(() => props.spaces + .filter(space => space.revokedAt === null) + .map(space => ({ id: space.id, name: space.name }))) +function taskMenuBindings(task: LocalConversationSummary) { + return task.spaceId === null + ? { + moveToSpaceAvailable: moveTargets.value.length > 0 && task.activity === 'idle' && !props.pendingConversationIds?.includes(task.id), + onMoveToSpace: () => taskMoveTargetId.value = task.id, + } + : {} +} +function revealMovedTask(spaceId: string) { + void props.sidebar.setSectionExpanded('spaces', true) + void props.sidebar.setSpaceExpanded(spaceId, true) +} const sidebarLayoutStyle = { '--buddy-task-sidebar-row-height': `${DESKTOP_TASK_SIDEBAR_ROW_HEIGHT}px`, '--buddy-task-sidebar-row-size': `${DESKTOP_TASK_SIDEBAR_ROW_SIZE}px`, @@ -215,7 +235,7 @@ function openSearchSpace(spaceId: string) { :task-id="item.task.id" :active="item.task.id === activeConversationId" :activity="item.task.activity" - v-bind="markBindings(item.task.id)" + v-bind="{ ...markBindings(item.task.id), ...taskMenuBindings(item.task) }" :dragging="item.pinnedTopLevel && draggedPinnedItemKey === item.pinKey" :drop-position="item.pinnedTopLevel ? getPinnedDropPosition(item.pinKey) : undefined" :language="language" @@ -273,7 +293,7 @@ function openSearchSpace(spaceId: string) { :now="relativeTimeNow" :occurred-at="item.task.automationOccurrence?.scheduledFor ?? item.task.updatedAt" :space-task="item.spaceTask" - v-bind="markBindings(item.task.id)" + v-bind="{ ...markBindings(item.task.id), ...taskMenuBindings(item.task) }" :title="getTaskTitle(item.task)" @delete="requestTaskDelete(item.task)" @open="emit('openTask', item.task.id)" @@ -298,7 +318,7 @@ function openSearchSpace(spaceId: string) { :task-id="task.id" :active="task.id === activeConversationId" :activity="task.activity" - v-bind="markBindings(task.id)" + v-bind="{ ...markBindings(task.id), ...taskMenuBindings(task) }" :language="language" :now="relativeTimeNow" :occurred-at="task.automationOccurrence?.scheduledFor ?? task.updatedAt" @@ -325,6 +345,17 @@ function openSearchSpace(spaceId: string) { + + +import type { LocalConversationSummary } from '@buddy-shared/conversation/conversationApi' +import type { BuddyLocale } from '@/i18n/buddyI18n' +import { NAlert, NButton, NForm, NFormItem, NModal, NSelect, useMessage } from 'naive-ui' +import { computed, onScopeDispose, shallowRef, useId, watch } from 'vue' +import { useBuddyI18n } from '@/i18n/buddyI18n' +import { resolveLocalChatErrorMessage } from '@/shared/lib/localChatError' + +const props = defineProps<{ + busy: boolean + language: BuddyLocale + moveTaskToSpace: (taskId: string, spaceId: string) => Promise + show: boolean + spaces: readonly { id: string, name: string }[] + task: LocalConversationSummary | null +}>() +const emit = defineEmits<{ + 'update:show': [show: boolean] + 'moved': [spaceId: string] +}>() +const { t } = useBuddyI18n(() => props.language) +const notification = useMessage() +const targetLabelId = useId() +const selectedSpaceId = shallowRef(null) +const saving = shallowRef(false) +const spaceOptions = computed(() => props.spaces.map(space => ({ label: space.name, value: space.id }))) +const selectedSpace = computed(() => props.spaces.find(space => space.id === selectedSpaceId.value)) +const taskUnavailable = computed(() => !props.task || props.task.spaceId !== null) +const taskBusy = computed(() => props.busy || (props.task && props.task.activity !== 'idle')) +const canMove = computed(() => props.show && !taskUnavailable.value && !taskBusy.value && Boolean(selectedSpace.value) && !saving.value) +let session = 0 +watch([() => props.show, () => props.task?.id], () => { + session += 1 + selectedSpaceId.value = null + saving.value = false +}, { flush: 'sync' }) +onScopeDispose(() => { + session += 1 +}) + +function updateShow(show: boolean) { + if (!saving.value) + emit('update:show', show) +} + +async function confirmMove() { + const task = props.task + const space = selectedSpace.value + if (!canMove.value || !task || !space) + return + const current = session + saving.value = true + try { + const moved = await props.moveTaskToSpace(task.id, space.id) + if (current !== session) + return + if (!moved) { + notification.error(t('desktop.tasks.moveToSpaceFailed')) + return + } + emit('moved', space.id) + emit('update:show', false) + } + catch (error) { + if (current === session) + notification.error(resolveLocalChatErrorMessage(error, props.language)) + } + finally { + if (current === session) + saving.value = false + } +} + + + diff --git a/apps/buddy/src/modules/tasks/widgets/task-index/DesktopTaskRow.vue b/apps/buddy/src/modules/tasks/widgets/task-index/DesktopTaskRow.vue index 7169a0ca2..1fc865b3f 100644 --- a/apps/buddy/src/modules/tasks/widgets/task-index/DesktopTaskRow.vue +++ b/apps/buddy/src/modules/tasks/widgets/task-index/DesktopTaskRow.vue @@ -20,7 +20,7 @@ import { formatTaskRelativeTime } from './taskRelativeTime' import { useTaskHistoryDrag } from './useTaskHistoryDrag' import { useTaskMenuActions } from './useTaskMenuActions' -const props = defineProps<{ +const props = withDefaults(defineProps<{ taskId: string active: boolean loading?: boolean @@ -28,6 +28,7 @@ const props = defineProps<{ markState?: LocalTaskMarkState marksBusy: boolean activity: LocalConversationSummary['activity'] + moveToSpaceAvailable?: boolean dragging?: boolean dropPosition?: DesktopTaskPinnedDropPosition language: BuddyLocale @@ -38,7 +39,9 @@ const props = defineProps<{ reorderable?: boolean reorderTarget?: boolean title: string -}>() +}>(), { + moveToSpaceAvailable: undefined, +}) const emit = defineEmits<{ delete: [] @@ -46,6 +49,7 @@ const emit = defineEmits<{ clearMarks: [] setRead: [read: boolean] manageMarks: [] + moveToSpace: [] dragEnd: [] dragOver: [position: DesktopTaskPinnedDropPosition] dragStart: [] @@ -85,6 +89,8 @@ const { actions: taskActions, select: selectTaskAction } = useTaskMenuActions({ clearMarks: () => emit('clearMarks'), setRead: (_, read) => emit('setRead', read), manageMarks: () => emit('manageMarks'), + moveToSpaceAvailable: () => props.moveToSpaceAvailable, + moveToSpace: () => emit('moveToSpace'), }) const actions = computed(() => [ ...taskActions.value, @@ -100,6 +106,22 @@ function handleAction(action: string | number) { emit('delete') } +// 右键行弹出与“更多”相同的菜单,位置跟随鼠标。 +// 位置单独保存:关闭时保留最后的坐标,菜单才会在原位消失,而不是飞到左上角。 +const contextMenuOpen = shallowRef(false) +const contextMenuPosition = shallowRef({ x: 0, y: 0 }) +function openContextMenu(event: MouseEvent) { + contextMenuPosition.value = { x: event.clientX, y: event.clientY } + contextMenuOpen.value = true +} +function closeContextMenu() { + contextMenuOpen.value = false +} +function handleContextAction(action: string | number) { + handleAction(action) + closeContextMenu() +} + const dragId = useId() const element = useTemplateRef('row') const handle = useTemplateRef('handle') @@ -108,7 +130,10 @@ useTaskHistoryDrag({ element, handle, type: 'workbench-task', - data: () => ({ title: props.title, resource: { scheme: 'task', id: props.taskId, data: {} } }), + data: () => ({ + title: props.title, + resource: { scheme: 'task', id: props.taskId, data: {} }, + }), disabled: () => false, reorderable: () => !!props.reorderable, reorderTarget: () => !!props.reorderTarget, @@ -133,7 +158,19 @@ useTaskHistoryDrag({ 'pl-[var(--buddy-task-sidebar-scrollbar-gutter,_0)]': !spaceTask, 'is-reorderable cursor-grab': reorderable, }" + @contextmenu.prevent="openContextMenu" > +
('row') const handle = useTemplateRef('handle') @@ -131,7 +143,20 @@ useTaskHistoryDrag({ 'is-drop-before': dropPosition === 'before', 'is-reorderable': reorderable, }" + @contextmenu.prevent="openContextMenu" > +