diff --git a/src/main/app/ipc-contracts/platform.ts b/src/main/app/ipc-contracts/platform.ts index 3af74530..c9ce3f6e 100644 --- a/src/main/app/ipc-contracts/platform.ts +++ b/src/main/app/ipc-contracts/platform.ts @@ -69,7 +69,7 @@ export const platformContracts = { refreshComposioAccounts: z.strictObject({ orgId: id }) satisfies z.ZodType, grantConnector: z.strictObject({ orgId: id, agentId: id, toolkitSlug: z.string(), accountLabel: z.string(), employeeId: id }) satisfies z.ZodType, revokeConnector: z.strictObject({ orgId: id, agentId: id, toolkitSlug: z.string() }) satisfies z.ZodType, - approvalDrifts: z.strictObject({ orgId: id }), + approvalDrifts: z.strictObject({ orgId: id, approvalIds: z.array(id).max(1000) }), runRequests: z.strictObject({ orgId: id, runId: id }), chatThreadHistory: z.strictObject({ orgId: id, threadId: id }), chatSpaceHistory: z.union([ diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index 289ba74f..859ae91f 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -420,7 +420,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { handle("chatSpaceHistory", (input) => ok(spaceHistory(deps.connection.db, input.orgId, input.spaceId, input.beforeMessageId, "limit" in input ? { limit: input.limit } : { fromMessageId: input.fromMessageId }))); handle("approvalDrifts", (input) => { try { - return ok(approvalDrifts(deps.connection.db, input.orgId)); + return ok(approvalDrifts(deps.connection.db, input.orgId, input.approvalIds)); } catch (error) { return fail("approval_drift_failed", error instanceof Error ? error.message : String(error)); } diff --git a/src/main/services/approval-drift.ts b/src/main/services/approval-drift.ts index 9f113c84..fd9d074e 100644 --- a/src/main/services/approval-drift.ts +++ b/src/main/services/approval-drift.ts @@ -3,15 +3,13 @@ import { existsSync, readFileSync } from "node:fs"; import type { DatabaseSync } from "node:sqlite"; import { parseApprovalPin, type ApprovalDrift, type ApprovalPin } from "../../shared/approval-pin"; -const CHECKED_APPROVALS = 24; - -/** Approved deliverables whose content no longer matches what was approved, read from disk and the library now. */ -export function approvalDrifts(db: DatabaseSync, orgId: string): Record { +/** Of the given approvals (those the reader has on screen), the approved deliverables whose content no longer matches what was approved. */ +export function approvalDrifts(db: DatabaseSync, orgId: string, approvalIds: string[]): Record { const rows = db.prepare(` select id, subject_snapshot from approvals where org_id = ? and state = 'approved' and kind = 'deliverable_review' and subject_snapshot is not null - order by decided_at desc limit ? - `).all(orgId, CHECKED_APPROVALS) as Array<{ id: string; subject_snapshot: string }>; + and id in (select value from json_each(?)) + `).all(orgId, JSON.stringify(approvalIds)) as Array<{ id: string; subject_snapshot: string }>; const drifts: Record = {}; for (const row of rows) { const pin = parseApprovalPin(row.subject_snapshot); diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index d00e348f..3f32d9c8 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -45,6 +45,12 @@ export const zh: Record = { "Approved {n} of {total}.": "已批准 {total} 项中的 {n} 项。", "Not decided: {reason}": "未能决定:{reason}", "Dismiss": "关闭", + "View record of {title}": "查看 {title} 的记录", + "Record of {title}": "{title} 的记录", + "Decided by": "决定人", + "No notes": "无备注", + "Showing {shown} of {total}": "显示 {total} 项中的 {shown} 项", + "Show earlier decisions": "显示更早的决定", "Edit the task": "编辑任务", "Changing the task replaces this request with a fresh one.": "修改任务会用新的申请替换这一项。", "Delete task": "删除任务", diff --git a/src/renderer/src/pages/ApprovalsPage.tsx b/src/renderer/src/pages/ApprovalsPage.tsx index 223039fb..58bff8c7 100644 --- a/src/renderer/src/pages/ApprovalsPage.tsx +++ b/src/renderer/src/pages/ApprovalsPage.tsx @@ -1,7 +1,9 @@ import { decideEach, type BatchResult } from "./approvals/decide-each"; -import { decidedWindow } from "./approvals/decided-window"; +import { DECIDED_PAGE, decidedWindow } from "./approvals/decided-window"; +import { ApprovalDetailDialog, ApprovalRecordDialog } from "./approvals/ApprovalDialogs"; +import { RISK_TONES } from "./approvals/risk-tones"; import { useEffect, useMemo, useRef, useState } from "react"; -import { Avatar, Button, Icon, IconButton, Pill } from "../shared/ui"; +import { Avatar, Button, Icon, Pill } from "../shared/ui"; import { TopbarActions } from "../shared/TopbarActions"; import type { AgentRecord, @@ -17,6 +19,9 @@ import { t } from "../i18n/i18n"; type RiskFilter = "all" | RiskLevel; +/** Ids per drift check; the IPC contract allows up to 1000. */ +const DRIFT_BATCH = 500; + const RISK_FILTERS: ReadonlyArray<{ id: RiskFilter; label: string }> = [ { id: "all", label: t("All risk") }, { id: RISK_LEVEL.HIGH, label: t("High") }, @@ -24,12 +29,6 @@ const RISK_FILTERS: ReadonlyArray<{ id: RiskFilter; label: string }> = [ { id: RISK_LEVEL.LOW, label: t("Low") }, ]; -const RISK_TONES: Record = { - [RISK_LEVEL.HIGH]: { label: t("High risk"), color: "var(--red)" }, - [RISK_LEVEL.MEDIUM]: { label: t("Medium risk"), color: "var(--amber)" }, - [RISK_LEVEL.LOW]: { label: t("Low risk"), color: "var(--text-3)" }, -}; - export function ApprovalsPage({ snapshot, actions, navigate, focus }: PageProps) { const orgId = activeOrgId(snapshot); const employee = requireActiveEmployee(snapshot); @@ -46,7 +45,31 @@ export function ApprovalsPage({ snapshot, actions, navigate, focus }: PageProps) const pending = approvals.filter((approval) => approval.state === APPROVAL_STATE.PENDING); const decided = approvals.filter((approval) => approval.state !== APPROVAL_STATE.PENDING); - const shownDecided = decidedWindow(decided, focusedApproval); + const [decidedLimit, setDecidedLimit] = useState(DECIDED_PAGE); + const shownDecided = decidedWindow(decided, focusedApproval, decidedLimit); + const [viewingId, setViewingId] = useState(null); + const viewing = viewingId ? decided.find((approval) => approval.id === viewingId) ?? null : null; + // Drift is checked for exactly the decided requests on screen, and again when a record opens, since files change outside the app. + const checkedIds = [...shownDecided.map((approval) => approval.id), ...(viewing && !shownDecided.includes(viewing) ? [viewing.id] : [])].join(","); + useEffect(() => { + let cancelled = false; + const approvalIds = checkedIds ? checkedIds.split(",") : []; + // The check takes at most DRIFT_BATCH ids per call, so a long history is checked in batches. + const batches = Array.from({ length: Math.ceil(approvalIds.length / DRIFT_BATCH) }, (_, index) => approvalIds.slice(index * DRIFT_BATCH, (index + 1) * DRIFT_BATCH)); + void Promise.all(batches.map((batch) => window.agentCompany.approvalDrifts({ orgId, approvalIds: batch }))).then((results) => { + if (cancelled) return; + const failed = results.find((result) => !result.ok); + if (failed && !failed.ok) { + setDriftError(failed.error.message); + return; + } + setDrifts(Object.assign({}, ...results.map((result) => (result.ok ? result.data : {})))); + setDriftError(null); + }); + return () => { + cancelled = true; + }; + }, [orgId, approvals, checkedIds, viewingId]); const filteredPending = filter === "all" ? pending : pending.filter((approval) => approval.risk === filter); const weeklyDecided = decided.filter((approval) => withinDays(approval.decidedAt, 7)); const approved7d = weeklyDecided.filter((approval) => approval.state === APPROVAL_STATE.APPROVED).length; @@ -58,21 +81,6 @@ export function ApprovalsPage({ snapshot, actions, navigate, focus }: PageProps) // Drift is read from disk in the main process, again whenever the approvals change. const [drifts, setDrifts] = useState>({}); const [driftError, setDriftError] = useState(null); - useEffect(() => { - let cancelled = false; - void window.agentCompany.approvalDrifts({ orgId }).then((result) => { - if (cancelled) return; - if (result.ok) { - setDrifts(result.data); - setDriftError(null); - } else { - setDriftError(result.error.message); - } - }); - return () => { - cancelled = true; - }; - }, [orgId, approvals]); const decide = async (approval: ApprovalRecord, state: "approved" | "declined", notes?: string) => { await actions.run(() => window.agentCompany.decideApproval({ @@ -159,12 +167,29 @@ export function ApprovalsPage({ snapshot, actions, navigate, focus }: PageProps) last={index === shownDecided.length - 1} focused={approval.id === focusedApproval} onOpenTask={openTask} + onView={() => setViewingId(approval.id)} /> ))} + {decided.length > decidedLimit && ( +
+ {t("Showing {shown} of {total}", { shown: Math.min(decidedLimit, decided.length), total: decided.length })} + +
+ )} )} + setViewingId(null)} + onOpenTask={openTask} + /> + {/* Keyed by request, so notes typed for one never carry to another or survive closing. */} void; - /** Resolves once the decision is saved; the dialog stays open until then and shows a refusal. */ - onDecide: (state: "approved" | "declined", notes?: string) => Promise; - onOpenTask: (taskId: string) => void; -}) { - const [notes, setNotes] = useState(""); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const submit = (state: "approved" | "declined") => { - setSaving(true); - setError(null); - onDecide(state, notes || undefined).catch((reason: unknown) => { - setSaving(false); - setError(reason instanceof Error ? reason.message : String(reason)); - }); - }; - if (!approval) return null; - const tone = RISK_TONES[approval.risk]; - if (!tone) throw new Error("Approval risk tone is missing."); - return ( -
-
event.stopPropagation()}> -
-

{approval.title}

- -
-
- {tone.label} - · - {approval.kind} -
-

- {approval.details} -

-
- {t("Requested by")} - {requester} - {approval.sourceEntityType && ( - <> - {t("Source")} - {approval.sourceEntityType === "task" && approval.sourceEntityId - ? ( - - - · {t("Changing the task replaces this request with a fresh one.")} - - ) - : {humanizeKind(approval.sourceEntityType)}} - - )} - {t("Requested")} - {formatRelative(approval.createdAt)} -
-