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
8 changes: 6 additions & 2 deletions frontend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,12 @@ Search the code and `/lab` first. The kit and its homes:
- Native approvals, questions, child turns, cancel and reconnect render through
the shared session components for every engine; never a per-engine copy.
Engine pickers and engine-specific UI read `GET /api/config` engines and
capability flags, never `engine === "..."`. Gateway approval cards:
`components/chat/gateway-approval-card.tsx` over `components/ai/approval-card.tsx`.
capability flags, never `engine === "..."`. Every human approval (gateway and
provider-native) renders through one card, `components/ai/approval-card.tsx`,
which owns the presentation, focus and Escape / Cmd-Enter shortcuts; the lane
wrappers `components/chat/gateway-approval-card.tsx` and
`components/chat/native-approval-card.tsx` own their decision semantics, never
a second layout.
- A thread has one stream: the thread-scoped events endpoint through
`use-thread-stream.ts` into the shared store and timeline builder. Never a
second reducer, stream controller or poll.
Expand Down
55 changes: 40 additions & 15 deletions frontend/app/lab/component-lab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
} from "@remixicon/react";
import Link from "next/link";
import * as React from "react";
import { ApprovalCard } from "@/components/ai/approval-card";
import { ContextCardStack } from "@/components/ai/context-card";
import { DiffTable } from "@/components/ai/diff-table";
import { FilterTable } from "@/components/ai/filter-table";
Expand Down Expand Up @@ -49,6 +48,8 @@ import {
} from "@/components/base/table/table";
import { Tab, TabList, TabPanel, Tabs } from "@/components/base/tabs/tabs";
import { type OrbState, ThinkingOrb } from "@/components/base/thinking-orb";
import { GatewayApprovalCard } from "@/components/chat/gateway-approval-card";
import { NativeApprovalCard } from "@/components/chat/native-approval-card";
import { AsteriskMark } from "@/components/foundations/brand/asterisk-mark";
import * as Modal from "@/components/base/modal/modal";
import { ARTIFACT_CAPABILITY_ROWS } from "./artifact-capability-matrix";
Expand Down Expand Up @@ -465,20 +466,44 @@ export function ComponentLab() {
<LoadingState label="Loading runs" />
</Section>

<Section label="AI kit · Approval card">
<ApprovalCard
question="Allow network access to install dependencies?"
options={[
{
label: "Approve for this run",
detail: "Reach the npm registry until the run finishes",
},
{ label: "Approve once", detail: "A single install, then revoke access" },
]}
allowCustom
onApprove={() => {}}
onDeny={() => {}}
/>
<Section label="Chat · Approval cards (one card, two lanes)">
<div className="flex flex-col gap-3">
<NativeApprovalCard
request={{
id: "lab-native",
sessionId: "lab",
requestKind: "command",
detail: "rm -rf ./dist && bun run build",
}}
submitting={false}
error={null}
onRespond={() => {}}
/>
<GatewayApprovalCard
approval={{
id: "lab-gateway-pending",
runId: "lab",
toolName: "deploy_service",
arguments: { service: "billing", command: "deploy --prod" },
status: "pending",
requestedAt: new Date().toISOString(),
resolvedAt: null,
resolvedBy: null,
}}
/>
<GatewayApprovalCard
approval={{
id: "lab-gateway-approved",
runId: "lab",
toolName: "deploy_service",
arguments: { service: "billing" },
status: "approved",
requestedAt: new Date(Date.now() - 60_000).toISOString(),
resolvedAt: new Date().toISOString(),
resolvedBy: "dana",
}}
/>
</div>
</Section>

<Section label="AI kit · Context cards">
Expand Down
173 changes: 173 additions & 0 deletions frontend/components/ai/approval-card.interaction.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// The interaction half of the approval-card tests: register happy-dom's
// globals BEFORE react-dom/client loads, mount the real lane wrappers, press
// keys and click buttons, and read focus and the resulting layout off the live
// DOM. The static-markup tests beside this file lock what renders; these lock
// what the keyboard and the two-step deny actually do.
import { afterEach, describe, expect, test } from "bun:test";
import { GlobalRegistrator } from "@happy-dom/global-registrator";

if (typeof window === "undefined") GlobalRegistrator.register();

// Tell React these tests drive it through act(), so state flushes synchronously.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

// react-aria (the reason Input) touches a couple of browser APIs happy-dom
// does not ship; stub the ones it reaches during mount.
if (typeof HTMLElement !== "undefined" && !("scrollIntoView" in HTMLElement.prototype)) {
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
value: () => {},
configurable: true,
writable: true,
});
}
if (typeof window !== "undefined" && typeof window.matchMedia !== "function") {
window.matchMedia = (media: string) =>
({
matches: false,
media,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}) as unknown as MediaQueryList;
}
if (typeof globalThis.ResizeObserver === "undefined") {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
}

const { act } = await import("react");
const { createRoot } = await import("react-dom/client");
const { NativeApprovalCard } = await import("../chat/native-approval-card");
const { GatewayApprovalCard } = await import("../chat/gateway-approval-card");

import type { ReactElement } from "react";
import type { GatewayApproval } from "@/lib/gateway-approvals";
import type { PendingApproval } from "../chat/approval-state";

let roots: Array<{ root: ReturnType<typeof createRoot>; container: HTMLElement }> = [];

function mount(ui: ReactElement): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(ui));
roots.push({ root, container });
return container;
}

afterEach(() => {
for (const { root, container } of roots) {
act(() => root.unmount());
container.remove();
}
roots = [];
(document.activeElement as HTMLElement | null)?.blur?.();
});

function press(el: Element, key: string, mods: { ctrl?: boolean; meta?: boolean } = {}) {
act(() => {
el.dispatchEvent(
new KeyboardEvent("keydown", {
key,
bubbles: true,
cancelable: true,
ctrlKey: !!mods.ctrl,
metaKey: !!mods.meta,
}),
);
});
}

function button(container: HTMLElement, text: string): HTMLButtonElement | undefined {
return [...container.querySelectorAll("button")].find((b) => b.textContent?.trim() === text) as
| HTMLButtonElement
| undefined;
}

const request: PendingApproval = {
id: "n1",
sessionId: "s",
requestKind: "command",
detail: "git status",
};

function approval(overrides: Partial<GatewayApproval> = {}): GatewayApproval {
return {
id: "g1",
runId: "r1",
toolName: "deploy_service",
arguments: { service: "billing" },
status: "pending",
requestedAt: "2026-01-01T00:00:00Z",
resolvedAt: null,
resolvedBy: null,
...overrides,
};
}

describe("native approval card interaction", () => {
test("takes focus on appear, and the keyboard approves and declines", () => {
const decisions: string[] = [];
const container = mount(
<NativeApprovalCard
request={request}
submitting={false}
error={null}
onRespond={(d) => {
decisions.push(d);
}}
/>,
);
const card = container.querySelector('[data-testid="native-approval-card"]');
if (!card) throw new Error("native card did not render");
expect(document.activeElement).toBe(card);

press(card, "Enter", { meta: true });
expect(decisions).toContain("accept");

press(card, "Escape");
expect(decisions).toContain("decline");
});
});

describe("gateway approval card interaction", () => {
test("a decided card never takes focus", () => {
mount(
<GatewayApprovalCard
approval={approval({
status: "approved",
resolvedAt: "2026-01-01T00:01:00Z",
resolvedBy: "dana",
})}
/>,
);
expect(document.activeElement).toBe(document.body);
});

test("two-step deny: Escape opens the reason box and focuses it; Keep reviewing returns focus", () => {
const container = mount(<GatewayApprovalCard approval={approval()} />);
const card = container.querySelector('[data-testid="gateway-approval-card"]') as HTMLElement;
expect(document.activeElement).toBe(card);
// No reason box until the user chooses to deny.
expect(card.querySelector('[aria-label="Reason for denying"]')).toBeNull();

// Escape enters the deny flow; focus moves to the reason box.
press(card, "Escape");
const reason = card.querySelector('[aria-label="Reason for denying"]');
expect(reason).not.toBeNull();
expect(document.activeElement).toBe(reason);
expect(button(container, "Keep reviewing")).toBeTruthy();

// Backing out removes the reason box and returns focus to the card, so the
// shortcuts keep working.
act(() => button(container, "Keep reviewing")?.click());
expect(card.querySelector('[aria-label="Reason for denying"]')).toBeNull();
expect(document.activeElement).toBe(card);
});
});
86 changes: 86 additions & 0 deletions frontend/components/ai/approval-card.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { type ApprovalAction, ApprovalCard, approvalShortcut } from "./approval-card";

// The keyboard contract is pure, so it is locked here without a DOM: every
// shortcut the card binds, and every press that is NOT a shortcut. The
// interaction half (real key presses, focus, the two-step deny) lives in
// approval-card.interaction.test.tsx.
describe("approvalShortcut", () => {
const noMods = { ctrl: false, meta: false };

test("Escape rejects", () => {
expect(approvalShortcut("Escape", noMods)).toBe("reject");
});

test("Cmd+Enter and Ctrl+Enter approve", () => {
expect(approvalShortcut("Enter", { ctrl: false, meta: true })).toBe("approve");
expect(approvalShortcut("Enter", { ctrl: true, meta: false })).toBe("approve");
});

test("a bare Enter is not a shortcut (plain typing stays safe)", () => {
expect(approvalShortcut("Enter", noMods)).toBeNull();
});

test("other keys are not shortcuts", () => {
expect(approvalShortcut("a", noMods)).toBeNull();
expect(approvalShortcut(" ", { ctrl: true, meta: false })).toBeNull();
});
});

const ACTIONS: ApprovalAction[] = [
{ id: "deny", label: "Deny", tone: "secondary", onSelect: () => {} },
{ id: "approve", label: "Approve", tone: "primary", onSelect: () => {} },
];

const render = (props: Partial<Parameters<typeof ApprovalCard>[0]> = {}) =>
renderToStaticMarkup(
<ApprovalCard
testId="card-under-test"
ariaLabel="Approval"
title="Tool approval: deploy"
actions={ACTIONS}
approveActionId="approve"
rejectActionId="deny"
{...props}
/>,
);

describe("ApprovalCard", () => {
test("pending renders the title, actions, and a focusable group", () => {
const html = render();
expect(html).toContain('data-testid="card-under-test"');
expect(html).toContain('role="group"');
expect(html).toContain('aria-label="Approval"');
expect(html).toContain(">Deny<");
expect(html).toContain(">Approve<");
});

test("busy disables every action (the submitting state)", () => {
const html = render({ busy: true });
// Both footer buttons carry the native disabled attribute while in flight.
expect(html.match(/disabled/g)?.length ?? 0).toBeGreaterThanOrEqual(2);
});

test("error renders the actionable error line (the submission-error state)", () => {
const html = render({ error: "Could not send your decision - try again" });
expect(html).toContain("Could not send your decision - try again");
});

test("note and chip render when supplied; reason slot renders its content", () => {
const html = render({
note: "Approved by dana",
chip: { color: "lime", label: "Approved" },
reason: <span data-testid="reason-slot">reason</span>,
});
expect(html).toContain("Approved by dana");
expect(html).toContain(">Approved<");
expect(html).toContain('data-testid="reason-slot"');
});

test("no actions renders no footer buttons (a resolved card)", () => {
const html = render({ actions: [] });
expect(html).not.toContain(">Deny<");
expect(html).not.toContain(">Approve<");
});
});
Loading
Loading