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"
>
+