diff --git a/apps/desktop/renderer/src/App.tsx b/apps/desktop/renderer/src/App.tsx index 02ee266f..cdaed0a3 100644 --- a/apps/desktop/renderer/src/App.tsx +++ b/apps/desktop/renderer/src/App.tsx @@ -355,6 +355,7 @@ function AppInner({ requestedAt: a.requestedAt, expiresAt: a.expiresAt, decision: a.status === "granted" ? { kind: "granted", scope: a.decision?.scope ?? "once", decidedAt: a.decision?.decidedAt, decidedBy: a.decision?.decidedBy, reason: a.decision?.reason } : a.status === "denied" ? { kind: "denied", reason: a.decision?.reason, decidedAt: a.decision?.decidedAt, decidedBy: a.decision?.decidedBy } : { kind: a.status }, scopeAllowed: a.context?.scope.allowed ?? ["once"], + batchMaxItems: a.batch?.maxItems, canDecide: a.canDecide, busy: approvalState.busy.has(a.id), error: approvalState.errors[a.id], unavailableReason: a.unavailableReason, executionPhase: a.execution.phase, requestReason: a.requestReason, context: a.context, policyProgress: a.progress, source: a.source, executionTurnId: a.execution.turnId, executionErrorCode: a.execution.errorCode, @@ -2064,6 +2065,7 @@ function AppInner({ onNavigateToOrg={() => setActiveModule("org")} onApprove={(id, reason, scope) => { void approvalState.decide(id, "granted", reason, scope); }} onDeny={(id, reason) => { void approvalState.decide(id, "denied", reason); }} + onApproveBatch={(ids) => { void approvalState.decideBatch(ids); }} onOpenSource={openApprovalSource} onOpenEvidence={openApprovalEvidence} /> diff --git a/apps/desktop/renderer/src/approvals/ApprovalQueue.tsx b/apps/desktop/renderer/src/approvals/ApprovalQueue.tsx index 2e2a1ea3..8d60aef5 100644 --- a/apps/desktop/renderer/src/approvals/ApprovalQueue.tsx +++ b/apps/desktop/renderer/src/approvals/ApprovalQueue.tsx @@ -7,7 +7,7 @@ * conversation and constructs the resume turn; the UI never chooses it. */ import { useEffect, useMemo, useRef, useState } from "react"; -import { Alert, Button, Input, List, Select, Segmented, Tag, Tooltip } from "antd"; +import { Alert, Button, Checkbox, Input, List, Select, Segmented, Tag, Tooltip } from "antd"; import { ArrowRight, Clock3, ShieldAlert, ShieldCheck } from "lucide-react"; import { useOwbLocale, useT, type OwbT } from "@roleweave/ui"; import { @@ -105,6 +105,7 @@ export function ApprovalQueue({ onDeny, onOpenSource, onOpenEvidence, + onApproveBatch, }: ApprovalQueueProps) { const t = useT(); const [filter, setFilter] = useState(defaultFilter); @@ -116,6 +117,7 @@ export function ApprovalQueue({ const [fromDate, setFromDate] = useState(""); const [toDate, setToDate] = useState(""); const [selectedId, setSelectedId] = useState(null); + const [batchSelection, setBatchSelection] = useState>(new Set()); const [now, setNow] = useState(() => Date.now()); const [notificationPermission, setNotificationPermission] = useState(desktopNotificationPermission); const notificationSnapshot = useRef }>>(); @@ -207,6 +209,23 @@ export function ApprovalQueue({ () => items.find((item) => item.approvalId === selectedId) ?? null, [items, selectedId], ); + const batchSelectedItems = useMemo(() => items.filter(item => batchSelection.has(item.approvalId)), [batchSelection, items]); + const batchSource = batchSelectedItems[0]?.source; + const batchSourceLabel = batchSource ? `${batchSource.positionId} · ${batchSource.conversationId}` : ""; + const canBatchItem = (item: ApprovalQueueItem) => item.batchMaxItems !== undefined && item.canDecide !== false && + item.busy !== true && isActionablePending(item, now) && item.source !== undefined && item.source.kind !== "group"; + const selectBatchItem = (item: ApprovalQueueItem, checked: boolean) => { + if (!canBatchItem(item)) return; + setBatchSelection(current => { + const next = new Set(current); + if (!checked) { next.delete(item.approvalId); return next; } + const selected = items.find(candidate => current.has(candidate.approvalId)); + if (selected?.source && item.source && (selected.source.kind !== item.source.kind || selected.source.positionId !== item.source.positionId || selected.source.conversationId !== item.source.conversationId || selected.source.turnId !== item.source.turnId || selected.source.runId !== item.source.runId || selected.source.engine !== item.source.engine)) return current; + const maximum = Math.min(item.batchMaxItems ?? 0, ...(selected ? [selected.batchMaxItems ?? 0] : [])); + if (next.size >= maximum) return current; + next.add(item.approvalId); return next; + }); + }; return (
@@ -354,20 +373,35 @@ export function ApprovalQueue({ onNavigateToOrg={onNavigateToOrg} /> ) : ( - item.approvalId} - renderItem={(item) => ( - - setSelectedId(item.approvalId)} - /> - - )} - /> + <> + {batchSelectedItems.length > 0 ? ( + onApproveBatch?.(batchSelectedItems.map(item => item.approvalId))}>{t("apr.batchApprove")}} + /> + ) : null} + item.approvalId} + renderItem={(item) => ( + + 0 && batchSource !== undefined && (!item.source || item.source.kind !== batchSource.kind || item.source.positionId !== batchSource.positionId || item.source.conversationId !== batchSource.conversationId || item.source.turnId !== batchSource.turnId || item.source.runId !== batchSource.runId || item.source.engine !== batchSource.engine))} + onBatchChange={(checked) => selectBatchItem(item, checked)} + onOpen={() => setSelectedId(item.approvalId)} + /> + + )} + /> + )} void; + batchSelected: boolean; + batchDisabled: boolean; + onBatchChange: (checked: boolean) => void; } -function ApprovalCard({ item, now, onOpen }: ApprovalCardProps) { +function ApprovalCard({ item, now, onOpen, batchSelected, batchDisabled, onBatchChange }: ApprovalCardProps) { const t = useT(); const localeTag = useOwbLocale() === "en" ? "en-US" : "zh-CN"; const positionName = decodeEscapedUnicode(item.positionName ?? t("apr.unknownPosition")); @@ -475,6 +512,7 @@ function ApprovalCard({ item, now, onOpen }: ApprovalCardProps) { data-decided={decided ? "true" : "false"} onClick={onOpen} > + {item.batchMaxItems !== undefined ? event.stopPropagation()} onChange={(event) => onBatchChange(event.target.checked)} aria-label={t("apr.batchSelect")} /> : null}