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:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 02:01:21 -04:00
1 parent 41359e268a
commit 71ac5085cd
12 files changed
+243 -130

No files matched your search

+1 -1
View File
@@ -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([
+1 -1
View File
@@ -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));
}
+4 -6
View File
@@ -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);
+6
View File
@@ -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": "删除任务",
+60 -110
View File
@@ -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)" },
};
+1 -1
View File
@@ -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. */
+7
View File
@@ -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.
+7 -3
View File
@@ -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));
});
});
+6 -3
View File
@@ -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", () => {