diff --git a/docs/features/assistant-workbar/prd.md b/docs/features/assistant-workbar/prd.md index fdbfbfc2..322cbadb 100644 --- a/docs/features/assistant-workbar/prd.md +++ b/docs/features/assistant-workbar/prd.md @@ -1063,7 +1063,8 @@ todo/plan,本次不为其增加工具。也不增加 plan 工作模式、计 - 建立实例级跟随、固定和失效目标语义。 - 保留现有任务中心、工作区、浏览器和成果行为;附件与知识库继续由 Composer 管理。 - 将 Task Center 明确为 Task 的单例索引,并补齐范围、状态、最近进展、需要关注和直接打开 Conversation。 -- 审批在所属任务或 Runtime 中持续可见,不新增独立审批面板。 +- 审批在所属任务或 Runtime 中持续可见;任务中心只为未关联任务的审批保留按需显示的处理 + 区域,具体规则见 [Task Center 交互](../task-and-job/task-center-prd.md#5-交互)。 - 智能心跳菜单入口承接完整配置和范围后,再从任务中心移除重复表单;不得移除任务中心本身。 ### 阶段 1:成果与 Runtime 可观测性 diff --git a/docs/features/assistant-workbar/progress.md b/docs/features/assistant-workbar/progress.md index 827bbb06..8280f3d8 100644 --- a/docs/features/assistant-workbar/progress.md +++ b/docs/features/assistant-workbar/progress.md @@ -1115,6 +1115,20 @@ src/agent-daemon/runtime-composition.test.ts src/main/ipc.test.ts` 为 179 项 测试应用与具名浏览器会话已退出,所属 Electron/Node 进程及 fixture 监听端口均为 0; 临时截图和隔离 profile 保留在本机测试目录,不进入仓库。 +## 2026-09-16 任务卡内审批 + +- 任务中心按 [Task Center 交互规则](../task-and-job/task-center-prd.md#5-交互) 展示卡内审批, + 未关联审批保留处理入口;没有审批时不渲染空审批区域。 +- 当前定时运行从队列 `taskId` 记录到 `ActiveRun`,审批事件再写入消息的临时审批数据; + 历史消息使用明确的 `message.task.id`。Sidebar 按任务 ID 匹配当前范围内顶层任务, + 并核对会话 ID 和项目 ID,不按会话、标题、时间或计划猜测运行归属。 +- `RightAssistantSidebar.resize.test.tsx` 的 54 项测试通过,覆盖项目范围、状态筛选、 + 卡内处理、无审批、普通会话审批及关联不一致。App 使用 `-t "approval|scheduled instructions"` + 定向运行的 14 项测试通过,验证历史与当前定时审批的映射、允许/拒绝接口调用及成功移除。 +- `npm run typecheck`、`npm run lint` 通过。本次仅修改 Renderer 展示与临时关联数据, + 本机和远程审批继续使用原有接口;未改 Main、Agent 或存储协议。未进行真实 Electron + 窗口和模型调用验收;按本次要求未运行全量测试及无关数据库迁移测试。 + ## 2026-09-16 任务中心范围简化 - 任务中心仅保留“当前项目”和“所有项目”;无项目任务在后者中标注“未绑定项目”。具体规则见 diff --git a/docs/features/task-and-job/task-center-prd.md b/docs/features/task-and-job/task-center-prd.md index 6b573a00..2d7f2a67 100644 --- a/docs/features/task-and-job/task-center-prd.md +++ b/docs/features/task-and-job/task-center-prd.md @@ -76,7 +76,11 @@ Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run - 进行中包含 `idle`、`queued`、`running`、`waiting_approval`、`failed` 和 `interrupted`; 暂停对应 `paused`,已结束包含 `completed` 和 `cancelled`。分类只影响列表筛选, 不改变任务状态语义;任务行保留具体状态、错误信息和原有操作。 -- 等待审批区域和审批徽标按项目范围展示,不受状态筛选影响,保留拒绝和允许一次操作。 +- 关联当前范围内顶层任务的审批直接显示在任务卡内,可拒绝或仅此次允许;有待审批的任务 + 归入“进行中”,审批随任务卡参与状态筛选,不再重复显示独立审批分类或区域。 +- 普通会话或无法关联到当前范围内任务的审批保留独立处理区域,不受任务状态筛选影响; + 该区域仅在存在此类审批时显示。没有审批时不显示审批标题、空提示或徽标。 +- 审批徽标统计当前项目范围内全部待审批请求,不受状态筛选影响。 - 支持立即运行、暂停、恢复、取消、编辑和删除。 - 后台变化更新状态和徽标,不自动抢占当前页面。 - 立即运行、重试和恢复在 UI 上都是 Task 操作,内部 Job/Run 不单独显示。 diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index c1ebe52e..4a0d9b29 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -2540,7 +2540,14 @@ describe("App", () => { expect(screen.getByText("仅此次")).toBeInTheDocument(); expect(screen.getByLabelText("任务结果:发布任务")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理"); - fireEvent.click(screen.getByRole("button", { name: "仅此次" })); + fireEvent.click(screen.getByRole("button", { name: "切换助手工作栏" })); + fireEvent.click(screen.getByRole("tab", { name: /任务中心/u })); + const taskRow = screen.getByRole("button", { name: /发布任务发布审批会话/u }).closest("article")!; + expect(within(taskRow).getByLabelText("等待审批: write_file")).toBeVisible(); + expect(screen.queryByRole("heading", { name: "等待审批" })).not.toBeInTheDocument(); + fireEvent.click(within(taskRow).getByRole("button", { name: "仅此次允许" })); + await waitFor(() => expect(api.agent.respondApproval).toHaveBeenCalledWith("00000000-0000-4000-8000-000000000834", "once")); + await waitFor(() => expect(within(taskRow).queryByLabelText("等待审批: write_file")).not.toBeInTheDocument()); await waitFor(() => expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个运行中")); expect(screen.getByRole("button", { name: /全项目活动/u })).not.toHaveTextContent("待处理"); }); @@ -5882,6 +5889,37 @@ describe("App", () => { expect(runtimeButton).toBeEnabled(); }); + it("associates live scheduled approvals using the dispatched task rather than another task in the conversation", async () => { + const conversationId = crypto.randomUUID(); + const taskId = crypto.randomUUID(); + const runId = crypto.randomUUID(); + const approvalId = crypto.randomUUID(); + vi.mocked(api.conversations.list).mockResolvedValue([{ + id: conversationId, projectId, title: "Scheduled approval target", updatedAt: Date.now(), messages: [], + }]); + const task = { id: taskId, conversationId, projectId, title: "Dispatched task", instructions: "Write", origin: "schedule" as const, status: "idle" as const, createdAt: new Date().toISOString() }; + vi.mocked(api.tasks.list).mockResolvedValue([task, { ...task, id: crypto.randomUUID(), title: "Another task" }]); + render(); + await waitFor(() => expect(api.conversationQueue.ready).toHaveBeenCalledWith(conversationId)); + act(() => conversationQueueDispatchListener?.({ + scheduled: true, + item: { id: runId, scheduleRunId: runId, taskId, conversationId, source: "schedule", label: "Timed message", createdAt: new Date().toISOString() }, + input: { conversationId, projectId, prompt: "Write a scheduled file" }, + })); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + act(() => agentListener?.({ requestId: runId, type: "approval", approvalId, title: "Scheduled write", description: "Confirm write", toolName: "write_file", argumentSummary: "file.md", allowPermanent: false })); + fireEvent.click(screen.getByRole("button", { name: "切换助手工作栏" })); + fireEvent.click(screen.getByRole("tab", { name: /任务中心/u })); + const row = screen.getByRole("button", { name: /Dispatched taskScheduled approval target/u }).closest("article")!; + expect(within(row).getByLabelText("等待审批: write_file")).toBeVisible(); + const otherRow = screen.getByRole("button", { name: /Another taskScheduled approval target/u }).closest("article")!; + expect(within(otherRow).queryByLabelText("等待审批: write_file")).not.toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "等待审批" })).not.toBeInTheDocument(); + fireEvent.click(within(row).getByRole("button", { name: "拒绝" })); + await waitFor(() => expect(api.agent.respondApproval).toHaveBeenCalledWith(approvalId, "deny")); + await waitFor(() => expect(within(row).queryByLabelText("等待审批: write_file")).not.toBeInTheDocument()); + }); + it("sends scheduled instructions with the target conversation's current settings and history", async () => { const conversationId = crypto.randomUUID(); const runId = crypto.randomUUID(); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 47bd5f3b..ecd52386 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -502,6 +502,7 @@ type Conversation = Omit & { type ActiveRun = { conversationId: string; messageId: string; + taskId?: string; projectId?: string; runtimeSelectionKey: string; }; @@ -3979,6 +3980,7 @@ function App(): React.JSX.Element { conversationId: conversation.id, projectId: conversation.projectId, messageId: message.id, + taskId: message.approval.taskId ?? message.task?.id, approvalId: message.approval.id, title: message.approval.title, description: message.approval.description, @@ -4864,6 +4866,7 @@ function App(): React.JSX.Element { status: undefined, approval: { id: event.approvalId, + taskId: run.taskId, title: event.title, description: event.description, toolName: event.toolName, @@ -7502,6 +7505,7 @@ function App(): React.JSX.Element { activeRuns.current.set(requestId, { conversationId, messageId: assistantMessage.id, + taskId: queuedDispatch?.scheduled ? queuedDispatch.item.taskId : undefined, projectId: projectIdSnapshot, runtimeSelectionKey: agentRuntimeSelectionKey( runtimeSettings diff --git a/src/renderer/src/ChatTimeline.tsx b/src/renderer/src/ChatTimeline.tsx index 3e4c19d3..49765f63 100644 --- a/src/renderer/src/ChatTimeline.tsx +++ b/src/renderer/src/ChatTimeline.tsx @@ -65,6 +65,7 @@ export type Message = { subagents?: SubagentActivity[] approval?: { id: string + taskId?: string title: string description: string toolName?: string diff --git a/src/renderer/src/RightAssistantSidebar.resize.test.tsx b/src/renderer/src/RightAssistantSidebar.resize.test.tsx index b7b61563..3f21445a 100644 --- a/src/renderer/src/RightAssistantSidebar.resize.test.tsx +++ b/src/renderer/src/RightAssistantSidebar.resize.test.tsx @@ -555,7 +555,8 @@ describe('RightAssistantSidebar resizing', () => { it('keeps the product Task index in the task center', () => { renderSidebar({ tab: 'tasks' }) - expect(screen.getByText('等待审批')).toBeInTheDocument() + expect(screen.queryByText('等待审批')).not.toBeInTheDocument() + expect(screen.queryByText(i18n.t('sidebar.tasks.noApprovals', { ns: 'workspace' }))).not.toBeInTheDocument() const taskIndexHeading = screen.getByRole('heading', { name: '任务索引' }) @@ -631,6 +632,65 @@ describe('RightAssistantSidebar resizing', () => { expect(onRespondApproval).toHaveBeenLastCalledWith(approval, 'deny') }) + it('embeds matched approvals only in active task cards and preserves their actions', () => { + const task: AssistantTask = { + id: 'scheduled-task', conversationId: 'conversation-1', projectId: currentProject.id, + title: 'Scheduled task', instructions: '', origin: 'schedule', status: 'paused', + createdAt: '2026-09-16T00:00:00Z' + } + const approval: PendingSidebarApproval = { + taskId: task.id, conversationId: task.conversationId!, projectId: task.projectId, + messageId: 'message-1', approvalId: 'approval-1', title: 'Confirm write', description: 'write file' + } + const onRespondApproval = vi.fn() + renderSidebar({ tasks: [task], approvals: [approval], onRespondApproval }) + const row = screen.getByText(task.title).closest('article')! + expect(within(row).getByLabelText('等待审批: Confirm write')).toBeVisible() + expect(screen.queryByRole('heading', { name: '等待审批' })).not.toBeInTheDocument() + fireEvent.click(within(row).getByRole('button', { name: '仅此次允许' })) + expect(onRespondApproval).toHaveBeenLastCalledWith(approval, 'once') + fireEvent.click(within(row).getByRole('button', { name: '拒绝' })) + expect(onRespondApproval).toHaveBeenLastCalledWith(approval, 'deny') + for (const filter of ['暂停', '已结束']) { + fireEvent.click(screen.getByRole('button', { name: filter })) + expect(screen.queryByText(task.title)).not.toBeInTheDocument() + expect(screen.queryByLabelText('等待审批: Confirm write')).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: '等待审批' })).not.toBeInTheDocument() + expect(screen.getByLabelText('等待审批: 1')).toBeVisible() + } + fireEvent.click(screen.getByRole('button', { name: '进行中' })) + expect(screen.getByLabelText('等待审批: Confirm write')).toBeVisible() + }) + + it('keeps unmatched approvals accessible without guessing by conversation or crossing projects', () => { + const task: AssistantTask = { + id: 'task-1', conversationId: 'conversation-1', projectId: currentProject.id, + title: 'Task one', instructions: '', origin: 'schedule', status: 'running', + createdAt: '2026-09-16T00:00:00Z' + } + const approvals: PendingSidebarApproval[] = [ + { taskId: task.id, conversationId: task.conversationId!, projectId: task.projectId }, + { taskId: undefined, conversationId: task.conversationId!, projectId: task.projectId }, + { taskId: 'other-run', conversationId: task.conversationId!, projectId: task.projectId }, + { taskId: task.id, conversationId: 'other-conversation', projectId: task.projectId }, + { taskId: task.id, conversationId: task.conversationId!, projectId: 'other-project' }, + { taskId: 'child', conversationId: task.conversationId!, projectId: task.projectId } + ].map((identity, index) => ({ ...identity, messageId: `message-${index}`, approvalId: `approval-${index}`, title: `Approval ${index}`, description: '' })) + renderSidebar({ tasks: [task, { ...task, id: 'child', parentTaskId: task.id }], approvals }) + const row = screen.getByText(task.title).closest('article')! + expect(within(row).getAllByRole('article')).toHaveLength(1) + expect(within(row).getByText('Approval 0')).toBeVisible() + for (const index of [1, 2, 3, 5]) { + expect(screen.getByText(`Approval ${index}`)).toBeVisible() + expect(within(row).queryByText(`Approval ${index}`)).not.toBeInTheDocument() + } + expect(screen.queryByText('Approval 4')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '所有项目' })) + expect(screen.getByText('Approval 4')).toBeVisible() + expect(within(row).queryByText('Approval 4')).not.toBeInTheDocument() + expect(screen.getAllByText('Approval 0')).toHaveLength(1) + }) + it('labels approval counts and cards without duplicate live regions', () => { renderSidebar({ approvals: [ diff --git a/src/renderer/src/RightAssistantSidebar.tsx b/src/renderer/src/RightAssistantSidebar.tsx index b156c2c9..0d75b678 100644 --- a/src/renderer/src/RightAssistantSidebar.tsx +++ b/src/renderer/src/RightAssistantSidebar.tsx @@ -93,6 +93,7 @@ export type SidebarArtifact = { export type PendingSidebarApproval = { conversationId: string + taskId?: string projectId?: string messageId: string approvalId: string @@ -163,6 +164,31 @@ const minimumPaneWidth = 160 const keyboardResizeStep = 16 const workbarStorageKey = 'goodbuddy.workbar-layout.v1' +function SidebarApproval({ approval, onRespondApproval }: { + approval: PendingSidebarApproval + onRespondApproval: RightAssistantSidebarProps['onRespondApproval'] +}): React.JSX.Element { + const { t } = useTranslation('workspace') + return ( +
+ {approval.title} +

{approval.description}

+ {approval.toolName && {approval.toolName}} +
+ + +
+
+ ) +} + function loadPersistedWorkbarLayout(): ReturnType< typeof workbarLayoutPreferencesSchema.parse > | undefined { @@ -942,9 +968,27 @@ export function RightAssistantSidebar({ ), [taskMatchesScope, tasks] ) + const { approvalsByTask, unassociatedApprovals } = useMemo(() => { + const tasksById = new Map(topLevelTasks.map((task) => [task.id, task])) + const approvalsByTask = new Map() + const unassociatedApprovals: PendingSidebarApproval[] = [] + for (const approval of scopedApprovals) { + const task = approval.taskId ? tasksById.get(approval.taskId) : undefined + // A shared conversation alone cannot identify a task or a scheduled run. + if (task && task.conversationId === approval.conversationId && task.projectId === approval.projectId) { + const pending = approvalsByTask.get(task.id) ?? [] + pending.push(approval) + approvalsByTask.set(task.id, pending) + } else { + unassociatedApprovals.push(approval) + } + } + return { approvalsByTask, unassociatedApprovals } + }, [scopedApprovals, topLevelTasks]) const filteredTasks = useMemo( () => topLevelTasks.filter((task) => { + if (approvalsByTask.has(task.id)) return taskFilter === 'active' if (taskFilter === 'active') { return ( task.status === 'idle' || @@ -960,7 +1004,7 @@ export function RightAssistantSidebar({ } return task.status === 'completed' || task.status === 'cancelled' }), - [taskFilter, topLevelTasks] + [approvalsByTask, taskFilter, topLevelTasks] ) useEffect(() => { @@ -1894,49 +1938,15 @@ export function RightAssistantSidebar({

) : ( <> + {unassociatedApprovals.length > 0 && <>

{t('sidebar.tasks.approvalsTitle')}

- {scopedApprovals.length === 0 ? ( -

- {t('sidebar.tasks.noApprovals')} -

- ) : ( - scopedApprovals.map((approval) => ( -
- {approval.title} -

{approval.description}

- {approval.toolName && {approval.toolName}} -
- - -
-
- )) - )} + {unassociatedApprovals.map((approval) => ( + + ))} + }

@@ -2058,6 +2068,9 @@ export function RightAssistantSidebar({ }) : t('sidebar.tasks.notStarted'))}

+ {approvalsByTask.get(task.id)?.map((approval) => ( + + ))} {schedule && (