From 2d8fabdca5ea165f006f97a28140fcfa32e82d1c Mon Sep 17 00:00:00 2001 From: probe Date: Tue, 20 Oct 2026 00:44:03 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(buddy):=20=E6=94=AF=E6=8C=81=E6=8A=8A?= =?UTF-8?q?=E6=97=A0=E7=A9=BA=E9=97=B4=E4=BB=BB=E5=8A=A1=E7=A7=BB=E5=85=A5?= =?UTF-8?q?=E7=A9=BA=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 conversations.moveToSpace:条件更新任务的 space_id(任务无空间、未删除、没有运行中的回合、目标空间可用),释放该任务已打开的智能体会话并发布 space 类元数据变更。移入前生成的产物按其记录的授权目录继续可作为引用。 侧栏在任务菜单中增加“移动到空间”(任务行和空间行也可右键弹出菜单),并支持把“任务”分组里的任务拖到空间行上;二级菜单里的空间名限宽并省略。 Co-Authored-By: Claude Sonnet 5.5 --- .../electron/main/local-chat/conversation.ts | 2 + .../preload/local-chat/conversation.ts | 1 + apps/buddy/electron/shared/localChatApi.ts | 2 + apps/buddy/service/src/BuddyService.ts | 1 + .../attachments/ComposerResourceService.ts | 14 ++--- .../ConversationMetadataService.ts | 26 ++++++++- .../ConversationMetadataService.spec.ts | 28 +++++++++- .../conversations/registerConversationRpc.ts | 3 +- .../src/storage/conversationRepository.ts | 11 ++++ .../shared/conversation/conversationApi.ts | 2 + apps/buddy/src/i18n/locales/en-US/tasks.ts | 2 + apps/buddy/src/i18n/locales/zh-CN/tasks.ts | 2 + apps/buddy/src/modules/tasks/contracts.ts | 1 + .../state/conversations/useChatSession.ts | 5 ++ .../tasks/state/task-index/useTaskIndex.ts | 12 +++++ .../modules/tasks/state/useTaskCapability.ts | 6 +++ .../widgets/task-index/DesktopTaskIndex.vue | 17 ++++-- .../widgets/task-index/DesktopTaskRow.vue | 39 +++++++++++++- .../task-index/DesktopTaskSpaceRow.vue | 53 ++++++++++++++++++- .../widgets/task-index/useTaskMenuActions.ts | 27 +++++++++- .../workspace/DesktopTaskIndexView.vue | 2 +- 21 files changed, 239 insertions(+), 17 deletions(-) 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 3151d8436..47931dc3a 100644 --- a/apps/buddy/electron/shared/localChatApi.ts +++ b/apps/buddy/electron/shared/localChatApi.ts @@ -165,6 +165,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', @@ -480,6 +481,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 ea9e757c0..00cd88aa4 100644 --- a/apps/buddy/service/src/BuddyService.ts +++ b/apps/buddy/service/src/BuddyService.ts @@ -547,6 +547,7 @@ export async function startBuddyService( runInputs, runs, repository: conversations, + spaces: spacesRepository, sessions: { invalidateConversation: id => sessions.invalidateConversationWithResult(id), applyPermissionSettings: conversation => sessions.applyConversationPermissions(conversation.id, conversation), diff --git a/apps/buddy/service/src/attachments/ComposerResourceService.ts b/apps/buddy/service/src/attachments/ComposerResourceService.ts index fb88fd91e..f2ffb997a 100644 --- a/apps/buddy/service/src/attachments/ComposerResourceService.ts +++ b/apps/buddy/service/src/attachments/ComposerResourceService.ts @@ -549,13 +549,15 @@ export class ComposerResourceService { .filter(grant => grant !== null) : this.#conversationGrants?.listActive(conversation.id) ?? [] const ownerId = conversation.spaceId ?? conversation.id + // 任务移入空间前生成的产物仍记录在任务自己的工作区里,授权 ID 是任务 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/conversations/ConversationMetadataService.ts b/apps/buddy/service/src/conversations/ConversationMetadataService.ts index 3e2975c28..bbb9571c4 100644 --- a/apps/buddy/service/src/conversations/ConversationMetadataService.ts +++ b/apps/buddy/service/src/conversations/ConversationMetadataService.ts @@ -5,14 +5,16 @@ 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 { 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' 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 +25,8 @@ export interface ConversationMetadataServiceOptions { buddies?: Pick runInputs?: Pick runs?: Pick - repository: Pick + repository: Pick + spaces?: Pick resolveModelSelection: (selection: ConversationModelSelection) => Promise sessions: { invalidateConversation: (conversationId: string) => Promise<{ pending: number, degraded: number }> @@ -116,6 +119,25 @@ 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 current = this.#requireActive(input.id) + if (!this.#options.spaces || current.spaceId !== null || this.#options.runs?.findActiveForConversation(input.id)) + throw new BuddyServiceError('VALIDATION_FAILED') + requireActiveSpace(this.#options.spaces.findById(input.spaceId)) + const conversation = this.#options.repository.setSpace({ ...input, updatedAt: new Date().toISOString() }) + if (!conversation) + throw new BuddyServiceError('VALIDATION_FAILED') + this.#publish('space', conversation) + const result = await this.#options.sessions.invalidateConversation(input.id) + if (result.pending || result.degraded) + throw new ConversationSettingsApplicationError(result.degraded ? 'CONVERSATION_SETTINGS_FAILED' : 'CONVERSATION_SETTINGS_PENDING') + 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/__tests__/ConversationMetadataService.spec.ts b/apps/buddy/service/src/conversations/__tests__/ConversationMetadataService.spec.ts index a1f3a4a8f..f86e26d51 100644 --- a/apps/buddy/service/src/conversations/__tests__/ConversationMetadataService.spec.ts +++ b/apps/buddy/service/src/conversations/__tests__/ConversationMetadataService.spec.ts @@ -7,6 +7,7 @@ import { createConversationMemberRepository } from '../../storage/conversationMe import { createConversationRepository } from '../../storage/conversationRepository' import { openBuddyDatabase } from '../../storage/database' import { createRunRepository } from '../../storage/runRepository' +import { createSpaceRepository } from '../../storage/spaceRepository' import { ConversationMetadataService } from '../ConversationMetadataService' const databases: DatabaseSync[] = [] @@ -152,6 +153,27 @@ 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: 'VALIDATION_FAILED' }) + expect(f.repository.findById('task-1')?.spaceId).toBe('space-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: 'VALIDATION_FAILED' }) + expect(f.repository.findById('task-1')?.spaceId).toBeNull() + expect(f.invalidate).not.toHaveBeenCalled() + }) + 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 +203,10 @@ 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 } }) + 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 service = new ConversationMetadataService({ buddies, runs, repository, spaces: createSpaceRepository(database), resolveModelSelection: resolveModel, sessions: { invalidateConversation: invalidate } }) return { database, runs, service, repository, invalidate, resolveModel, buddies, members: createConversationMemberRepository(database) } } diff --git a/apps/buddy/service/src/conversations/registerConversationRpc.ts b/apps/buddy/service/src/conversations/registerConversationRpc.ts index c2b59b440..33534d668 100644 --- a/apps/buddy/service/src/conversations/registerConversationRpc.ts +++ b/apps/buddy/service/src/conversations/registerConversationRpc.ts @@ -43,7 +43,7 @@ export interface RegisterConversationRpcOptions { attachments: Pick changes: Pick conversations: ConversationRpcRepository - metadata: Pick + metadata: Pick deleteConversation: (conversationId: string) => Promise eventLog: { listForRuns: (runIds: readonly string[]) => BuddyRunEvent[] } isDeleting: (conversationId: string) => boolean @@ -81,6 +81,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/storage/conversationRepository.ts b/apps/buddy/service/src/storage/conversationRepository.ts index 2b9c3ad46..d729888de 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,11 @@ 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 + `) const history = createConversationHistoryStore(database) const index = createConversationIndexRepository(database) const timeline = createConversationTimelineRepository(database, history.lineage) @@ -248,6 +254,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).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/src/i18n/locales/en-US/tasks.ts b/apps/buddy/src/i18n/locales/en-US/tasks.ts index 88fc5e584..0b4595d1e 100644 --- a/apps/buddy/src/i18n/locales/en-US/tasks.ts +++ b/apps/buddy/src/i18n/locales/en-US/tasks.ts @@ -75,6 +75,8 @@ 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.taskReference': 'Task reference', 'desktop.tasks.taskId': 'Task ID', 'desktop.tasks.title': 'Title', diff --git a/apps/buddy/src/i18n/locales/zh-CN/tasks.ts b/apps/buddy/src/i18n/locales/zh-CN/tasks.ts index 3d0a2d978..dedae5815 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/tasks.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/tasks.ts @@ -73,6 +73,8 @@ export default { 'desktop.tasks.moreActions': '更多操作', 'desktop.tasks.renameTask': '重命名', 'desktop.tasks.copy': '复制', + 'desktop.tasks.moveToSpace': '移动到空间', + 'desktop.tasks.moveToSpaceEmpty': '暂无可用空间', '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 95ff3f1ec..ca008fe0d 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/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..20ce2ff55 100644 --- a/apps/buddy/src/modules/tasks/state/task-index/useTaskIndex.ts +++ b/apps/buddy/src/modules/tasks/state/task-index/useTaskIndex.ts @@ -118,6 +118,18 @@ export function useTaskIndex(options: TaskIndexOptions) { return false } }, + async moveTaskToSpace(id, spaceId) { + try { + const conversation = await api.conversations.moveToSpace(id, spaceId) + if (!disposed) + data.applyConversation(conversation) + return true + } + catch (error) { + setError(error) + return false + } + }, 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 d47778bf5..49bb4abcc 100644 --- a/apps/buddy/src/modules/tasks/state/useTaskCapability.ts +++ b/apps/buddy/src/modules/tasks/state/useTaskCapability.ts @@ -78,6 +78,11 @@ export function useTaskCapability(options: UseTaskCapabilityOptions): TaskCapabi branches, spaceId, } = chatSession + // 任务被移入空间后,当前会话的空间随之更新。 + const stopSpaceFollow = api.localChat.conversations.onChanged((conversation) => { + if (conversation.id === activeConversationId.value) + chatSession.setSpaceId(conversation.spaceId) + }) const isSelectingFiles = shallowRef(false) const isClosing = shallowRef(false) const errorMessage = shallowRef(null) @@ -497,6 +502,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 419122eb4..463c18a23 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' @@ -44,6 +44,7 @@ const props = defineProps<{ const emit = defineEmits<{ deleteSpace: [spaceId: string] deleteTask: [conversationId: string] + moveTask: [conversationId: string, spaceId: string] newTask: [spaceId: string | null] openSpaceDirectory: [spaceId: string] openTask: [conversationId: string] @@ -66,6 +67,15 @@ function markBindings(conversationId: string) { onManageMarks: () => marksOpen.value = true, } } +// 只有无空间的任务可以移入空间;运行中或等待确认时置灰。 +const moveTargets = computed(() => props.spaces + .filter(space => !space.revokedAt) + .map(space => ({ id: space.id, name: space.name }))) +function moveBindings(task: LocalConversationSummary) { + return task.spaceId === null + ? { moveTargets: moveTargets.value, movable: task.activity === 'idle', onMoveToSpace: (spaceId: string) => emit('moveTask', task.id, spaceId) } + : {} +} const sidebarLayoutStyle = { '--buddy-task-sidebar-row-height': `${DESKTOP_TASK_SIDEBAR_ROW_HEIGHT}px`, '--buddy-task-sidebar-row-size': `${DESKTOP_TASK_SIDEBAR_ROW_SIZE}px`, @@ -214,7 +224,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), ...moveBindings(item.task) }" :dragging="item.pinnedTopLevel && draggedPinnedItemKey === item.pinKey" :drop-position="item.pinnedTopLevel ? getPinnedDropPosition(item.pinKey) : undefined" :language="language" @@ -259,6 +269,7 @@ function openSearchSpace(spaceId: string) { :activity="spaceActivities.get(item.space.id)" :space="item.space" @menu="selectSpaceMenuAction(item.space, $event)" + @move-task="emit('moveTask', $event, item.space.id)" @pin="pinSpace(item.space.id)" @toggle="toggleSpace(item.space.id)" /> @@ -297,7 +308,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), ...moveBindings(task) }" :language="language" :now="relativeTimeNow" :occurred-at="task.automationOccurrence?.scheduledFor ?? task.updatedAt" 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..40916b89d 100644 --- a/apps/buddy/src/modules/tasks/widgets/task-index/DesktopTaskRow.vue +++ b/apps/buddy/src/modules/tasks/widgets/task-index/DesktopTaskRow.vue @@ -28,6 +28,9 @@ const props = defineProps<{ markState?: LocalTaskMarkState marksBusy: boolean activity: LocalConversationSummary['activity'] + /** 仅无空间任务传入。 */ + moveTargets?: ReadonlyArray<{ id: string, name: string }> + movable?: boolean dragging?: boolean dropPosition?: DesktopTaskPinnedDropPosition language: BuddyLocale @@ -46,6 +49,7 @@ const emit = defineEmits<{ clearMarks: [] setRead: [read: boolean] manageMarks: [] + moveToSpace: [spaceId: string] dragEnd: [] dragOver: [position: DesktopTaskPinnedDropPosition] dragStart: [] @@ -85,6 +89,9 @@ const { actions: taskActions, select: selectTaskAction } = useTaskMenuActions({ clearMarks: () => emit('clearMarks'), setRead: (_, read) => emit('setRead', read), manageMarks: () => emit('manageMarks'), + moveTargets: () => props.moveTargets, + movable: () => props.movable, + moveToSpace: (_, spaceId) => emit('moveToSpace', spaceId), }) const actions = computed(() => [ ...taskActions.value, @@ -100,6 +107,19 @@ 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 handleContextAction(action: string | number) { + contextMenuOpen.value = false + handleAction(action) +} + const dragId = useId() const element = useTemplateRef('row') const handle = useTemplateRef('handle') @@ -108,7 +128,12 @@ 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: {} }, + // 置顶区的任务拖拽用于排序,不参与“拖入空间”。 + moveTaskId: props.moveTargets && props.movable && !props.reorderable ? props.taskId : null, + }), 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" > +
() @@ -102,6 +104,18 @@ function handleMenuAction(action: string | number): void { emit('menu', action) } +// 右键空间行弹出与“更多”相同的菜单;位置单独保存,关闭时才会在原位消失。 +const contextMenuOpen = shallowRef(false) +const contextMenuPosition = shallowRef({ x: 0, y: 0 }) +function openContextMenu(event: MouseEvent): void { + contextMenuPosition.value = { x: event.clientX, y: event.clientY } + contextMenuOpen.value = true +} +function handleContextMenuAction(action: string | number): void { + contextMenuOpen.value = false + handleMenuAction(action) +} + const dragId = useId() const element = useTemplateRef('row') const handle = useTemplateRef('handle') @@ -119,6 +133,23 @@ useTaskHistoryDrag({ over: position => emit('dragOver', position), drop: position => emit('drop', position), }) + +// 把无空间任务拖到空间行上松手即移入该空间;置顶区的空间行已用于排序,不接收。 +const moveDropId = `move-to:${props.space.id}` +const { isDropTarget: isMoveTarget } = useDroppable({ + id: moveDropId, + element, + // dnd-kit 的选项按 MaybeRefOrGetter 解包,函数会被当成 getter 调用,所以要再包一层返回过滤函数。 + accept: () => (source: { type?: unknown, data?: Record }) => source.type === 'workbench-task' && typeof source.data?.moveTaskId === 'string', + disabled: () => !!props.reorderTarget, +}) +useDragDropMonitor({ + onDragEnd(event) { + const taskId = event.operation.source?.data?.moveTaskId + if (!event.canceled && event.operation.target?.id === moveDropId && typeof taskId === 'string') + emit('moveTask', taskId) + }, +})