Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 4 additions & 13 deletions apps/web/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,8 @@
import type { ApiCard, ExecuteResult, ScanCostSnapshot, ScanProgressEvent } from "./types.js";

const base = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace(
/\/$/,
"",
) ?? "";
const base = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace(/\/$/, "") ?? "";

async function request<T>(
path: string,
init?: RequestInit,
): Promise<T> {
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${base}${path}`, {
...init,
headers: {
Expand Down Expand Up @@ -36,11 +30,7 @@ export async function startScan(input: {
}> {
const driver =
(import.meta.env.VITE_SCAN_DRIVER as
| "fixture"
| "trueforge"
| "record"
| "replay"
| undefined) ??
"fixture" | "trueforge" | "record" | "replay" | undefined) ??
input.driver ??
"fixture";
return request("/scans", {
Expand Down Expand Up @@ -127,6 +117,7 @@ export function subscribeScanStream(
es.addEventListener("snapshot", (e) => forward("snapshot", (e as MessageEvent).data));
const types = [
"scan.started",
"subagent.queued",
"subagent.started",
"subagent.progress",
"subagent.done",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export type ScanProgressEvent = {
export interface SubagentState {
systemId: string;
displayName: string;
status: "running" | "done";
status: "queued" | "scanning" | "reconciling" | "failed" | "done";
found: number;
startedAt: string;
}
Expand Down
40 changes: 22 additions & 18 deletions apps/web/src/components/AgentActivity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,7 @@ export function AgentActivity({
<StatusPill status={activity.status} />
</div>
{activity.person ? (
<p className="mt-1 font-mono text-[12px] text-[var(--color-mute)]">
{activity.person}
</p>
<p className="mt-1 font-mono text-[12px] text-[var(--color-mute)]">{activity.person}</p>
) : null}
</header>

Expand All @@ -47,9 +45,7 @@ export function AgentActivity({
<div className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.06em]">
<span
className={`inline-block h-1.5 w-1.5 ${
activity.sandbox.active
? "animate-pulse bg-white"
: "bg-[var(--color-mute)]"
activity.sandbox.active ? "animate-pulse bg-white" : "bg-[var(--color-mute)]"
}`}
/>
Sandbox
Expand All @@ -69,35 +65,43 @@ export function AgentActivity({
</h3>
<ul className="mt-2 space-y-1">
{subagents.length === 0 ? (
<li className="py-2 text-[12px] text-[var(--color-faint)]">
Waiting for systems…
</li>
<li className="py-2 text-[12px] text-[var(--color-faint)]">Waiting for systems…</li>
) : (
subagents.map((s) => (
<li
key={s.systemId}
className="flex items-center justify-between gap-2 border border-[var(--color-line)] bg-[var(--color-surface)] px-2.5 py-2"
>
<div className="min-w-0">
<div className="truncate text-[13px] font-medium">
{s.displayName}
</div>
<div className="truncate text-[13px] font-medium">{s.displayName}</div>
<div className="font-mono text-[11px] text-[var(--color-faint)]">
{s.systemId}
</div>
</div>
<div className="shrink-0 text-right">
<div
className={`text-[11px] font-medium uppercase tracking-wide ${
s.status === "running"
s.status === "scanning" || s.status === "reconciling"
? "text-[var(--color-ink)]"
: "text-[var(--color-ok)]"
: s.status === "queued"
? "text-[var(--color-faint)]"
: s.status === "failed"
? "text-[var(--color-irrev)]"
: "text-[var(--color-ok)]"
}`}
>
{s.status === "running" ? "Scanning" : "Done"}
{s.status === "queued"
? "Queued"
: s.status === "scanning"
? "Scanning"
: s.status === "reconciling"
? "Reconciling"
: s.status === "failed"
? "Failed"
: "Done"}
</div>
<div className="font-mono text-[12px] text-[var(--color-mute)]">
{s.found}
{s.found} found
</div>
</div>
</li>
Expand Down Expand Up @@ -193,8 +197,8 @@ function StartForm({
{!compact ? (
<>
<p className="text-[13px] leading-relaxed text-[var(--color-mute)]">
Start an access audit. The agent fans out one subagent per connected
system, reconciles identities in the sandbox, then fills the queue.
Start an access audit. The agent fans out one subagent per connected system, reconciles
identities in the sandbox, then fills the queue.
</p>
<label className="mt-6 block text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-faint)]">
Person
Expand Down
34 changes: 11 additions & 23 deletions apps/web/src/components/ApprovalQueue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,10 @@ export function ApprovalQueue({
});

async function bulk(decision: "approve" | "reject") {
const targets = ordered.filter(
(c) => checked.has(c.id) && c.status === "pending",
);
const targets = ordered.filter((c) => checked.has(c.id) && c.status === "pending");
const protectedSkipped =
decision === "approve"
? targets.filter((c) => c.protected === true)
: [];
const runnable =
decision === "approve"
? targets.filter((c) => c.protected !== true)
: targets;
decision === "approve" ? targets.filter((c) => c.protected === true) : [];
const runnable = decision === "approve" ? targets.filter((c) => c.protected !== true) : targets;
for (const card of runnable) {
await decide(card, decision, undefined, decision === "approve");
}
Expand Down Expand Up @@ -127,6 +120,7 @@ export function ApprovalQueue({

const approved = ordered.filter((c) => c.status === "approved");
const pendingCount = ordered.filter((c) => c.status === "pending").length;
const heldCount = ordered.filter((c) => c.status === "held").length;

return (
<section className="flex h-full min-h-0 flex-col bg-[var(--color-surface)]">
Expand All @@ -136,14 +130,12 @@ export function ApprovalQueue({
<div className="text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-faint)]">
Approval queue
</div>
<h2 className="mt-1 text-[18px] font-semibold tracking-tight">
Review access
</h2>
<h2 className="mt-1 text-[18px] font-semibold tracking-tight">Review access</h2>
<p className="mt-1 text-[12px] text-[var(--color-mute)]">
{pendingCount} pending · {approved.length} approved ·{" "}
<span className="font-mono">j/k</span> move ·{" "}
<span className="font-mono">a/h/r</span> decide ·{" "}
<span className="font-mono">x</span> select
{pendingCount} pending · {heldCount} held · {approved.length} approved ·{" "}
{ordered.length} total · <span className="font-mono">j/k</span> move ·{" "}
<span className="font-mono">a/h/r</span> decide · <span className="font-mono">x</span>{" "}
select
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
Expand All @@ -153,9 +145,7 @@ export function ApprovalQueue({
className="border border-[var(--color-line-strong)] bg-white px-2.5 py-1.5 text-[12px] font-medium text-[var(--color-mute)] hover:border-[var(--color-ink)] hover:text-[var(--color-ink)]"
onClick={() =>
setChecked(
new Set(
ordered.filter((c) => c.status === "pending").map((c) => c.id),
),
new Set(ordered.filter((c) => c.status === "pending").map((c) => c.id)),
)
}
>
Expand Down Expand Up @@ -324,9 +314,7 @@ function SectionHeading({
>
{title}
</h3>
<p className="mt-0.5 max-w-xl text-[12px] text-[var(--color-mute)]">
{subtitle}
</p>
<p className="mt-0.5 max-w-xl text-[12px] text-[var(--color-mute)]">{subtitle}</p>
</div>
<span className="font-mono text-[12px] text-[var(--color-faint)]">{count}</span>
</div>
Expand Down
37 changes: 37 additions & 0 deletions apps/web/src/hooks/useScanSession.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";

import { applyEvent, emptyActivity } from "./useScanSession.js";

const event = (type: string, systemId?: string) => ({
type,
scanId: "scan-1",
at: "2026-08-29T00:00:00.000Z",
...(systemId ? { systemId, displayName: systemId } : {}),
});

describe("scan session activity reducer", () => {
it("keeps failed subagents failed while successful siblings reconcile", () => {
let activity = emptyActivity();
activity = applyEvent(activity, event("subagent.queued", "failed-system"));
activity = applyEvent(activity, event("subagent.queued", "healthy-system"));
activity = applyEvent(activity, event("subagent.started", "failed-system"));
activity = applyEvent(activity, event("subagent.started", "healthy-system"));
activity = applyEvent(activity, {
...event("subagent.failed", "failed-system"),
error: "Connector unavailable",
});
activity = applyEvent(activity, event("subagent.done", "healthy-system"));

const duringReconcile = applyEvent(activity, event("reconcile.started"));
expect(duringReconcile.subagents["failed-system"]?.status).toBe("failed");
expect(duringReconcile.subagents["healthy-system"]?.status).toBe("reconciling");

const afterReconcile = applyEvent(duringReconcile, {
...event("reconcile.done"),
clusters: 1,
unknown: 0,
});
expect(afterReconcile.subagents["failed-system"]?.status).toBe("failed");
expect(afterReconcile.subagents["healthy-system"]?.status).toBe("done");
});
});
Loading
Loading