Q156: approval history pages back and opens each decision in full
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
41359e268a
commit
71ac5085cd
12 files changed
+243
-130
No files matched your search
@@ -69,7 +69,7 @@ export const platformContracts = {
|
||||
refreshComposioAccounts: z.strictObject({ orgId: id }) satisfies z.ZodType<C.RefreshComposioAccountsInput>,
|
||||
grantConnector: z.strictObject({ orgId: id, agentId: id, toolkitSlug: z.string(), accountLabel: z.string(), employeeId: id }) satisfies z.ZodType<C.GrantConnectorInput>,
|
||||
revokeConnector: z.strictObject({ orgId: id, agentId: id, toolkitSlug: z.string() }) satisfies z.ZodType<C.RevokeConnectorInput>,
|
||||
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([
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<string, ApprovalDrift> {
|
||||
/** 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<string, ApprovalDrift> {
|
||||
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<string, ApprovalDrift> = {};
|
||||
for (const row of rows) {
|
||||
const pin = parseApprovalPin(row.subject_snapshot);
|
||||
|
||||
@@ -45,6 +45,12 @@ export const zh: Record<string, string> = {
|
||||
"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": "删除任务",
|
||||
|
||||
@@ -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<RiskLevel, { label: string; color: string }> = {
|
||||
[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<string | null>(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<Record<string, ApprovalDrift>>({});
|
||||
const [driftError, setDriftError] = useState<string | null>(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)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{decided.length > decidedLimit && (
|
||||
<div className="row" style={{ justifyContent: "center", marginTop: 12, gap: 10 }}>
|
||||
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{t("Showing {shown} of {total}", { shown: Math.min(decidedLimit, decided.length), total: decided.length })}</span>
|
||||
<Button size="small" onClick={() => setDecidedLimit((limit) => limit + DECIDED_PAGE)}>{t("Show earlier decisions")}</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ApprovalRecordDialog
|
||||
key={viewing?.id ?? "none"}
|
||||
approval={viewing}
|
||||
requester={viewing ? resolveRequester(viewing, employees, agents) : ""}
|
||||
decider={viewing ? resolveDecider(viewing, employees) : ""}
|
||||
drift={viewing ? drifts[viewing.id] ?? null : null}
|
||||
onClose={() => setViewingId(null)}
|
||||
onOpenTask={openTask}
|
||||
/>
|
||||
|
||||
{/* Keyed by request, so notes typed for one never carry to another or survive closing. */}
|
||||
<ApprovalDetailDialog
|
||||
key={editing?.id ?? "none"}
|
||||
@@ -184,91 +209,6 @@ export function ApprovalsPage({ snapshot, actions, navigate, focus }: PageProps)
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovalDetailDialog({
|
||||
approval,
|
||||
requester,
|
||||
onClose,
|
||||
onDecide,
|
||||
onOpenTask,
|
||||
}: {
|
||||
approval: ApprovalRecord | null;
|
||||
requester: string;
|
||||
onClose: () => void;
|
||||
/** Resolves once the decision is saved; the dialog stays open until then and shows a refusal. */
|
||||
onDecide: (state: "approved" | "declined", notes?: string) => Promise<void>;
|
||||
onOpenTask: (taskId: string) => void;
|
||||
}) {
|
||||
const [notes, setNotes] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
|
||||
<div className="modal" role="dialog" aria-label={approval.title} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<div className="row" style={{ alignItems: "flex-start", gap: 10, marginBottom: 12 }}>
|
||||
<h2 className="modal-title" style={{ margin: 0, flex: 1 }}>{approval.title}</h2>
|
||||
<IconButton icon="x" label={t("Close")} onClick={onClose} />
|
||||
</div>
|
||||
<div className="row" style={{ flexWrap: "wrap", gap: 6, marginBottom: 14 }}>
|
||||
<span style={{ fontSize: 12, color: tone.color, fontWeight: 540, letterSpacing: "0.02em" }}>{tone.label}</span>
|
||||
<span style={{ color: "var(--text-4)" }}>·</span>
|
||||
<Pill>{approval.kind}</Pill>
|
||||
</div>
|
||||
<p style={{ margin: "0 0 16px", color: "var(--text-2)", fontSize: 14, lineHeight: 1.55, whiteSpace: "pre-wrap" }}>
|
||||
{approval.details}
|
||||
</p>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "100px 1fr", gap: "10px 14px", fontSize: 12 }}>
|
||||
<span style={{ color: "var(--text-3)" }}>{t("Requested by")}</span>
|
||||
<span>{requester}</span>
|
||||
{approval.sourceEntityType && (
|
||||
<>
|
||||
<span style={{ color: "var(--text-3)" }}>{t("Source")}</span>
|
||||
{approval.sourceEntityType === "task" && approval.sourceEntityId
|
||||
? (
|
||||
<span>
|
||||
<button type="button" className="link-button" onClick={() => onOpenTask(approval.sourceEntityId!)}>{t("Edit the task")}</button>
|
||||
<span style={{ color: "var(--text-3)" }}> · {t("Changing the task replaces this request with a fresh one.")}</span>
|
||||
</span>
|
||||
)
|
||||
: <span>{humanizeKind(approval.sourceEntityType)}</span>}
|
||||
</>
|
||||
)}
|
||||
<span style={{ color: "var(--text-3)" }}>{t("Requested")}</span>
|
||||
<span>{formatRelative(approval.createdAt)}</span>
|
||||
</div>
|
||||
<textarea
|
||||
className="input"
|
||||
aria-label={t("Decision notes")}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
style={{ marginTop: 14 }}
|
||||
/>
|
||||
{error && <div className="form-error" role="alert">{t("Not decided: {reason}", { reason: error })}</div>}
|
||||
<div className="modal-actions">
|
||||
<Button onClick={() => submit("declined")} disabled={saving}>{t("Decline")}</Button>
|
||||
<Button tone="primary" onClick={() => submit("approved")} disabled={saving}>{t("Approve")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function humanizeKind(value: string): string {
|
||||
const spaced = value.replace(/_/g, " ");
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||
}
|
||||
|
||||
function RiskMenu({ filter, onChange }: { filter: RiskFilter; onChange: (next: RiskFilter) => void }) {
|
||||
return (
|
||||
<select
|
||||
@@ -367,6 +307,7 @@ function DecidedRow({
|
||||
last,
|
||||
focused,
|
||||
onOpenTask,
|
||||
onView,
|
||||
}: {
|
||||
approval: ApprovalRecord;
|
||||
drift: ApprovalDrift | null;
|
||||
@@ -375,6 +316,7 @@ function DecidedRow({
|
||||
last: boolean;
|
||||
focused: boolean;
|
||||
onOpenTask: (taskId: string) => void;
|
||||
onView: () => void;
|
||||
}) {
|
||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -397,7 +339,15 @@ function DecidedRow({
|
||||
}}
|
||||
>
|
||||
<Icon name={approved ? "check" : "x"} size={14} color={approved ? "var(--green)" : superseded ? "var(--text-3)" : "var(--red)"} />
|
||||
<div style={{ flex: 1, fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{approval.title}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="link-button"
|
||||
aria-label={t("View record of {title}", { title: approval.title })}
|
||||
onClick={onView}
|
||||
style={{ flex: 1, fontWeight: 480, color: "var(--text-1)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", textAlign: "left" }}
|
||||
>
|
||||
{approval.title}
|
||||
</button>
|
||||
{approval.sourceEntityType === "task" && approval.sourceEntityId && (
|
||||
<button type="button" className="link-button" style={{ fontSize: 12 }} onClick={() => onOpenTask(approval.sourceEntityId!)}>{t("Open task")}</button>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useState } from "react";
|
||||
import { Button, IconButton, Pill } from "../../shared/ui";
|
||||
import type { ApprovalRecord } from "../../../../shared/domain";
|
||||
import { APPROVAL_STATE } from "../../../../shared/domain";
|
||||
import type { ApprovalDrift } from "../../../../shared/approval-pin";
|
||||
import { formatRelative } from "../types";
|
||||
import { RISK_TONES } from "./risk-tones";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
export function ApprovalDetailDialog({
|
||||
approval,
|
||||
requester,
|
||||
onClose,
|
||||
onDecide,
|
||||
onOpenTask,
|
||||
}: {
|
||||
approval: ApprovalRecord | null;
|
||||
requester: string;
|
||||
onClose: () => void;
|
||||
/** Resolves once the decision is saved; the dialog stays open until then and shows a refusal. */
|
||||
onDecide: (state: "approved" | "declined", notes?: string) => Promise<void>;
|
||||
onOpenTask: (taskId: string) => void;
|
||||
}) {
|
||||
const [notes, setNotes] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
|
||||
<div className="modal" role="dialog" aria-label={approval.title} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<div className="row" style={{ alignItems: "flex-start", gap: 10, marginBottom: 12 }}>
|
||||
<h2 className="modal-title" style={{ margin: 0, flex: 1 }}>{approval.title}</h2>
|
||||
<IconButton icon="x" label={t("Close")} onClick={onClose} />
|
||||
</div>
|
||||
<div className="row" style={{ flexWrap: "wrap", gap: 6, marginBottom: 14 }}>
|
||||
<span style={{ fontSize: 12, color: tone.color, fontWeight: 540, letterSpacing: "0.02em" }}>{tone.label}</span>
|
||||
<span style={{ color: "var(--text-4)" }}>·</span>
|
||||
<Pill>{approval.kind}</Pill>
|
||||
</div>
|
||||
<p style={{ margin: "0 0 16px", color: "var(--text-2)", fontSize: 14, lineHeight: 1.55, whiteSpace: "pre-wrap" }}>
|
||||
{approval.details}
|
||||
</p>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "100px 1fr", gap: "10px 14px", fontSize: 12 }}>
|
||||
<span style={{ color: "var(--text-3)" }}>{t("Requested by")}</span>
|
||||
<span>{requester}</span>
|
||||
{approval.sourceEntityType && (
|
||||
<>
|
||||
<span style={{ color: "var(--text-3)" }}>{t("Source")}</span>
|
||||
{approval.sourceEntityType === "task" && approval.sourceEntityId
|
||||
? (
|
||||
<span>
|
||||
<button type="button" className="link-button" onClick={() => onOpenTask(approval.sourceEntityId!)}>{t("Edit the task")}</button>
|
||||
<span style={{ color: "var(--text-3)" }}> · {t("Changing the task replaces this request with a fresh one.")}</span>
|
||||
</span>
|
||||
)
|
||||
: <span>{humanizeKind(approval.sourceEntityType)}</span>}
|
||||
</>
|
||||
)}
|
||||
<span style={{ color: "var(--text-3)" }}>{t("Requested")}</span>
|
||||
<span>{formatRelative(approval.createdAt)}</span>
|
||||
</div>
|
||||
<textarea
|
||||
className="input"
|
||||
aria-label={t("Decision notes")}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
style={{ marginTop: 14 }}
|
||||
/>
|
||||
{error && <div className="form-error" role="alert">{t("Not decided: {reason}", { reason: error })}</div>}
|
||||
<div className="modal-actions">
|
||||
<Button onClick={() => submit("declined")} disabled={saving}>{t("Decline")}</Button>
|
||||
<Button tone="primary" onClick={() => submit("approved")} disabled={saving}>{t("Approve")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function humanizeKind(value: string): string {
|
||||
const spaced = value.replace(/_/g, " ");
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||
}
|
||||
|
||||
/** A decided request in full: what was asked, the notes given, who decided and when, and whether it changed since. */
|
||||
export function ApprovalRecordDialog({ approval, requester, decider, drift, onClose, onOpenTask }: {
|
||||
approval: ApprovalRecord | null;
|
||||
requester: string;
|
||||
decider: string;
|
||||
drift: ApprovalDrift | null;
|
||||
onClose: () => void;
|
||||
onOpenTask: (taskId: string) => void;
|
||||
}) {
|
||||
if (!approval) return null;
|
||||
const outcome = approval.state === APPROVAL_STATE.APPROVED ? t("Approved") : approval.state === APPROVAL_STATE.SUPERSEDED ? t("Replaced by an edit") : t("Declined");
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
|
||||
<div className="modal" role="dialog" aria-label={t("Record of {title}", { title: approval.title })} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<div className="row" style={{ alignItems: "flex-start", gap: 10, marginBottom: 12 }}>
|
||||
<h2 className="modal-title" style={{ margin: 0, flex: 1 }}>{approval.title}</h2>
|
||||
<IconButton icon="x" label={t("Close")} onClick={onClose} />
|
||||
</div>
|
||||
<div className="row" style={{ flexWrap: "wrap", gap: 6, marginBottom: 14 }}>
|
||||
<Pill>{outcome}</Pill>
|
||||
<Pill>{approval.kind}</Pill>
|
||||
{drift && <Pill tone="amber">{drift === "changed" ? t("Changed since approval") : t("Removed since approval")}</Pill>}
|
||||
</div>
|
||||
<p style={{ margin: "0 0 16px", color: "var(--text-2)", fontSize: 14, lineHeight: 1.55, whiteSpace: "pre-wrap" }}>{approval.details}</p>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "110px 1fr", gap: "10px 14px", fontSize: 12 }}>
|
||||
<span style={{ color: "var(--text-3)" }}>{t("Requested by")}</span>
|
||||
<span>{requester} · {formatRelative(approval.createdAt)}</span>
|
||||
<span style={{ color: "var(--text-3)" }}>{t("Decided by")}</span>
|
||||
<span>{decider}{approval.decidedAt ? ` · ${formatRelative(approval.decidedAt)}` : ""}</span>
|
||||
<span style={{ color: "var(--text-3)" }}>{t("Decision notes")}</span>
|
||||
<span style={{ whiteSpace: "pre-wrap" }}>{approval.decisionNotes ?? t("No notes")}</span>
|
||||
{approval.sourceEntityType === "task" && approval.sourceEntityId && (
|
||||
<>
|
||||
<span style={{ color: "var(--text-3)" }}>{t("Source")}</span>
|
||||
<button type="button" className="link-button" style={{ textAlign: "left" }} onClick={() => onOpenTask(approval.sourceEntityId!)}>{t("Open task")}</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
/** How many decided approvals the page lists. */
|
||||
export const DECIDED_SHOWN = 24;
|
||||
/** How many decided approvals the page lists at first, and adds per "show more". */
|
||||
export const DECIDED_PAGE = 24;
|
||||
|
||||
/** The newest decided approvals, plus the focused one when it is older, so a link to it always lands on it. */
|
||||
export function decidedWindow<T extends { id: string }>(decided: T[], focusedId: string | null): T[] {
|
||||
const shown = decided.slice(0, DECIDED_SHOWN);
|
||||
/** The newest `limit` decided approvals, plus the focused one when it is older, so a link to it always lands on it. */
|
||||
export function decidedWindow<T extends { id: string }>(decided: T[], focusedId: string | null, limit: number): T[] {
|
||||
const shown = decided.slice(0, limit);
|
||||
const focused = focusedId === null ? undefined : decided.find((item) => item.id === focusedId);
|
||||
return focused && !shown.includes(focused) ? [...shown, focused] : shown;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { RISK_LEVEL, type RiskLevel } from "../../../../shared/domain";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
/** How each risk level is labelled and coloured on approval cards and dialogs. */
|
||||
export const RISK_TONES: Record<RiskLevel, { label: string; color: string }> = {
|
||||
[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)" },
|
||||
};
|
||||
@@ -195,7 +195,7 @@ export interface DesktopApi {
|
||||
refreshComposioAccounts(input: RefreshComposioAccountsInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
grantConnector(input: GrantConnectorInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
/** Approved deliverables whose content changed or vanished since approval, checked on disk now. */
|
||||
approvalDrifts(input: { orgId: string }): Promise<DesktopResult<Record<string, ApprovalDrift>>>;
|
||||
approvalDrifts(input: { orgId: string; approvalIds: string[] }): Promise<DesktopResult<Record<string, ApprovalDrift>>>;
|
||||
/** Every model call a task run made, in order: turns, goal audits and model checks. */
|
||||
runRequests(input: { orgId: string; runId: string }): Promise<DesktopResult<RunRequestRecord[]>>;
|
||||
/** One thread in full, beyond the snapshot's recent window. */
|
||||
|
||||
@@ -132,6 +132,13 @@ test("the pinned CEO conversation is the Messages front door", async () => {
|
||||
await expect(page.getByText("Renew the domain")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Approve all low risk" }).click();
|
||||
await expect(page.getByRole("status").filter({ hasText: "Approved" })).toContainText("Approved 2 of 2.");
|
||||
// A decided request can be reopened in full: what was asked, the notes, who decided and when.
|
||||
await page.getByRole("button", { name: "View record of Order stickers" }).click();
|
||||
const record = page.getByRole("dialog", { name: "Record of Order stickers" });
|
||||
await expect(record).toContainText("Small spend.");
|
||||
await expect(record).toContainText("No notes");
|
||||
await expect(record).toContainText("Yang");
|
||||
await record.getByRole("button", { name: "Close" }).click();
|
||||
await page.getByRole("button", { name: "Work" }).click();
|
||||
await page.getByRole("button", { name: /Operationalize goal: Ship the FlowPilot MVP\./ }).first().click();
|
||||
// Blocking says why on the task and stops it; work can also move backwards.
|
||||
|
||||
@@ -5,11 +5,15 @@ describe("the decided approvals list", () => {
|
||||
const decided = Array.from({ length: 30 }, (_, index) => ({ id: `apr_${index}` }));
|
||||
|
||||
test("shows the newest 24", () => {
|
||||
expect(decidedWindow(decided, null).map((item) => item.id)).toEqual(decided.slice(0, 24).map((item) => item.id));
|
||||
expect(decidedWindow(decided, null, 24).map((item) => item.id)).toEqual(decided.slice(0, 24).map((item) => item.id));
|
||||
});
|
||||
|
||||
test("adds an older focused approval so a link to it lands on it", () => {
|
||||
expect(decidedWindow(decided, "apr_27").map((item) => item.id)).toEqual([...decided.slice(0, 24).map((item) => item.id), "apr_27"]);
|
||||
expect(decidedWindow(decided, "apr_3")).toHaveLength(24);
|
||||
expect(decidedWindow(decided, "apr_27", 24).map((item) => item.id)).toEqual([...decided.slice(0, 24).map((item) => item.id), "apr_27"]);
|
||||
expect(decidedWindow(decided, "apr_3", 24)).toHaveLength(24);
|
||||
});
|
||||
|
||||
test("pages further back on request", () => {
|
||||
expect(decidedWindow(decided, null, 48).map((item) => item.id)).toEqual(decided.map((item) => item.id));
|
||||
});
|
||||
});
|
||||
@@ -99,14 +99,17 @@ describe("execution modes", () => {
|
||||
expect(db.prepare("select title, content from documents where org_id = ?").all(h.orgId)).toEqual([{ title: "Launch memo", content: "Ship it." }]);
|
||||
|
||||
// Each approval pins what was approved; content changed or removed later, even outside the app, shows against it.
|
||||
expect(approvalDrifts(db, h.orgId)).toEqual({});
|
||||
const [fileReview, docReview] = reviews.map((review) => review.id);
|
||||
const checked = [fileReview, docReview];
|
||||
expect(approvalDrifts(db, h.orgId, checked)).toEqual({});
|
||||
writeFileSync(join(root, "plan", "business-plan.md"), "# Edited by hand");
|
||||
expect(approvalDrifts(db, h.orgId)).toEqual({ [fileReview]: "changed" });
|
||||
expect(approvalDrifts(db, h.orgId, checked)).toEqual({ [fileReview]: "changed" });
|
||||
// Only the requests asked about are checked, so any record on screen, however old, gets its own answer.
|
||||
expect(approvalDrifts(db, h.orgId, [docReview])).toEqual({});
|
||||
rmSync(join(root, "plan", "business-plan.md"));
|
||||
const docId = (db.prepare("select id from documents where org_id = ?").get(h.orgId) as { id: string }).id;
|
||||
h.connection.transaction(() => new DocumentService(db).update({ orgId: h.orgId, documentId: docId, title: "Launch memo", content: "Ship it later.", status: "draft", reason: "edit", changedByType: "employee", changedById: employeeId }));
|
||||
expect(approvalDrifts(db, h.orgId)).toEqual({ [fileReview]: "removed", [docReview]: "changed" });
|
||||
expect(approvalDrifts(db, h.orgId, checked)).toEqual({ [fileReview]: "removed", [docReview]: "changed" });
|
||||
});
|
||||
|
||||
test("a declined deliverable is never written, and held work keeps its task open", () => {
|
||||
|
||||
Reference in new issue
Block a user