fix: show task approvals in their task cards

Task Center previously separated task approvals from their tasks and showed an empty approval section. Associated approvals now appear with allow-once and deny actions inside task cards, and those tasks appear under Active. Only unassociated approvals retain a separate section; no approval section or badge is shown when there are no pending requests.

Release note: Review and handle pending approvals directly in their Task Center cards. Tasks awaiting approval appear under Active, while requests without a matching task remain separately accessible.
This commit is contained in:
mesalogo
2026-09-16 14:31:28 +08:00
parent cb245a6402
commit da23810205
8 changed files with 179 additions and 44 deletions
+39 -1
View File
@@ -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(<App />);
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();
+4
View File
@@ -502,6 +502,7 @@ type Conversation = Omit<ConversationListSnapshot, "messages"> & {
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
+1
View File
@@ -65,6 +65,7 @@ export type Message = {
subagents?: SubagentActivity[]
approval?: {
id: string
taskId?: string
title: string
description: string
toolName?: string
@@ -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: [
+53 -40
View File
@@ -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 (
<article
aria-label={`${t('sidebar.tasks.approvalsTitle')}: ${approval.toolName ?? approval.title}`}
className="assistant-sidebar__approval"
>
<strong>{approval.title}</strong>
<p>{approval.description}</p>
{approval.toolName && <code>{approval.toolName}</code>}
<div className="assistant-sidebar__approval-actions">
<button className="secondary-button" onClick={() => onRespondApproval(approval, 'deny')} type="button">
{t('sidebar.tasks.deny')}
</button>
<button className="primary-button" onClick={() => onRespondApproval(approval, 'once')} type="button">
{t('sidebar.tasks.allowOnce')}
</button>
</div>
</article>
)
}
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<string, PendingSidebarApproval[]>()
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({
</p>
) : (
<>
{unassociatedApprovals.length > 0 && <>
<h3>
<ShieldAlert size={15} />
{t('sidebar.tasks.approvalsTitle')}
</h3>
{scopedApprovals.length === 0 ? (
<p className="assistant-sidebar__empty">
{t('sidebar.tasks.noApprovals')}
</p>
) : (
scopedApprovals.map((approval) => (
<article
aria-label={`${t('sidebar.tasks.approvalsTitle')}: ${
approval.toolName ?? approval.title
}`}
className="assistant-sidebar__approval"
key={approval.approvalId}
>
<strong>{approval.title}</strong>
<p>{approval.description}</p>
{approval.toolName && <code>{approval.toolName}</code>}
<div className="assistant-sidebar__approval-actions">
<button
className="secondary-button"
onClick={() =>
onRespondApproval(approval, 'deny')
}
type="button"
>
{t('sidebar.tasks.deny')}
</button>
<button
className="primary-button"
onClick={() =>
onRespondApproval(approval, 'once')
}
type="button"
>
{t('sidebar.tasks.allowOnce')}
</button>
</div>
</article>
))
)}
{unassociatedApprovals.map((approval) => (
<SidebarApproval key={approval.approvalId} approval={approval} onRespondApproval={onRespondApproval} />
))}
</>}
<div className="task-center__index-heading">
<h3>
@@ -2058,6 +2068,9 @@ export function RightAssistantSidebar({
})
: t('sidebar.tasks.notStarted'))}
</p>
{approvalsByTask.get(task.id)?.map((approval) => (
<SidebarApproval key={approval.approvalId} approval={approval} onRespondApproval={onRespondApproval} />
))}
{schedule && (
<div className="task-center__actions">
<TaskScheduleActions