diff --git a/web/src/components/structured/ApprovalView.test.tsx b/web/src/components/structured/ApprovalView.test.tsx new file mode 100644 index 0000000..3c3c404 --- /dev/null +++ b/web/src/components/structured/ApprovalView.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ApprovalView } from "./ApprovalView.js"; + +afterEach(cleanup); + +const ALL_OPTIONS = [ + { optionId: "a1", name: "Allow once", kind: "allow_once" as const }, + { optionId: "a2", name: "Allow always", kind: "allow_always" as const }, + { optionId: "r1", name: "Reject", kind: "reject_once" as const }, +]; + +function buttons(): string[] { + return [...document.querySelectorAll(".st-approval-acts button")].map((b) => b.textContent ?? ""); +} + +describe("ApprovalView", () => { + it("asks the operator with the three decisions the daemon exposes", () => { + render( + Promise.resolve()} + />, + ); + expect(buttons()).toEqual(["Allow once", "Allow for session", "Deny"]); + expect(screen.getByText("Write(a.ts)")).toBeTruthy(); + expect(screen.getByText("/repo/a.ts")).toBeTruthy(); + }); + + it("submits the decision the operator picked", async () => { + const onDecide = vi.fn().mockResolvedValue(undefined); + render(); + fireEvent.click(screen.getByText("Deny")); + await waitFor(() => expect(onDecide).toHaveBeenCalledWith("deny")); + }); + + it("disables every button once one is pressed, so a decision is sent once", () => { + render( new Promise(() => {})} />); + fireEvent.click(screen.getByText("Allow once")); + const all = [...document.querySelectorAll(".st-approval-acts button")]; + expect(all.every((b) => b.disabled)).toBe(true); + }); + + it("restores the controls when the decision failed and this approval is still pending", async () => { + const onDecide = vi.fn().mockRejectedValue(new Error("nope")); + render(); + fireEvent.click(screen.getByText("Allow once")); + await waitFor(() => { + const all = [...document.querySelectorAll(".st-approval-acts button")]; + expect(all.every((b) => !b.disabled)).toBe(true); + }); + }); + + it("offers no button for a decision the request cannot back", () => { + render( + Promise.resolve()} + />, + ); + expect(buttons()).toEqual(["Allow once"]); + }); + + it("says so when the request offers no decision at all", () => { + render( Promise.resolve()} />); + expect(screen.getByText("no decision offered")).toBeTruthy(); + }); + + it("renders a resolved approval as a one-liner, with no controls", () => { + render( + Promise.resolve()} + />, + ); + expect(screen.getByText("Write(a.ts) — approved")).toBeTruthy(); + expect(document.querySelector(".st-approval-acts")).toBeNull(); + }); +}); diff --git a/web/src/components/structured/ApprovalView.tsx b/web/src/components/structured/ApprovalView.tsx new file mode 100644 index 0000000..26056d0 --- /dev/null +++ b/web/src/components/structured/ApprovalView.tsx @@ -0,0 +1,80 @@ +// An `Approval` row. The daemon exposes exactly three decisions +// (docs/STRUCTURED-VIEW.md) and mints the nonce; this only solicits one of +// them. A decision the request offers no matching ACP option for is not shown +// — its button would "allow" something that silently resolves `Cancelled`. + +import { useState } from "react"; + +import type { AcpPermissionOption, AcpToolCallLike, StructuredApprovalDecision } from "../../types.js"; +import { offeredDecisions, toolKindLabel } from "./model.js"; + +const SPECS: [StructuredApprovalDecision, string, string][] = [ + ["allow_once", "Allow once", "primary"], + ["allow_for_session", "Allow for session", ""], + ["deny", "Deny", "deny"], +]; + +export interface ApprovalViewProps { + /** The `RequestPermissionRequest` the row carries — serialized without its + * nonce, which never leaves the daemon's live wire. */ + request: { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] }; + /** Whether this is the one live approval. A restored/answered row is not. */ + pending: boolean; + /** Resolves once the decision reached the daemon; rejects only while this + * approval is still the pending one, so the buttons can come back. */ + onDecide: (decision: StructuredApprovalDecision) => Promise; +} + +export function ApprovalView({ request, pending, onDecide }: ApprovalViewProps) { + const [busy, setBusy] = useState(false); + const toolCall = request.toolCall ?? {}; + const what = toolCall.title ?? toolKindLabel(toolCall.kind); + + // Resolved approvals render as a compact one-liner inside a tool group (or + // are dropped entirely when standalone — see model.ts's transcriptItems). + if (!pending) { + return
{`${what} — approved`}
; + } + + const offered = offeredDecisions(request.options ?? []); + const path = toolCall.locations?.[0]?.path; + + const decide = (decision: StructuredApprovalDecision) => { + setBusy(true); + onDecide(decision).catch(() => { + // Rejects only while this approval is still pending — restore the + // controls so the operator can retry. The error itself is surfaced by + // the pane's banner. + setBusy(false); + }); + }; + + return ( +
+
+
+ permission requested +
+
{what}
+ {path &&
{path}
} + {offered.length > 0 ? ( +
+ {SPECS.filter(([decision]) => offered.includes(decision)).map(([decision, label, cls]) => ( + + ))} +
+ ) : ( +
no decision offered
+ )} +
+
+ ); +} diff --git a/web/src/components/structured/Composer.tsx b/web/src/components/structured/Composer.tsx new file mode 100644 index 0000000..aa01705 --- /dev/null +++ b/web/src/components/structured/Composer.tsx @@ -0,0 +1,391 @@ +// The composer: draft, attachments, `/` command autocomplete off the live +// `available_commands_update` notices, the session-settings menu, and the +// send/cancel pair. Sending is the pane's job — this only hands it a draft and +// the files that go with it. + +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type ChangeEvent, + type ClipboardEvent, + type DragEvent, + type FormEvent, + type KeyboardEvent, +} from "react"; + +import type { AcpSessionConfigChoice, AcpSessionConfigOption } from "../../types.js"; +import { + applyCommand, + commandTokenAt, + filterCommands, + orderConfigOptions, + type AvailableCommand, +} from "./model.js"; + +/** Per-message ceilings the daemon enforces on uploads + * (docs/STRUCTURED-VIEW.md, "Prompt and cancel"). */ +export const MAX_ATTACHMENTS = 10; +export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024; +/** Pasted text at or above either bound becomes a `.txt` attachment instead of + * going inline. */ +const PASTE_TO_FILE_CHARS = 4_000; +const PASTE_TO_FILE_LINES = 40; + +export interface ComposerProps { + /** Files chosen but not yet sent. Owned by the pane so a failed send can put + * them back. */ + attachments: File[]; + onAttachmentsChange(files: File[]): void; + commands: AvailableCommand[]; + configOptions: AcpSessionConfigOption[]; + /** A turn is in flight — the cancel control replaces nothing, it appears + * beside send (a queued message is still allowed). */ + turnActive: boolean; + /** An upload/POST is in flight: the attachment controls are frozen. */ + uploading: boolean; + configBusy: boolean; + /** Restored into the box when a send fails. */ + draft: string; + onDraftChange(draft: string): void; + onSubmit(text: string, attachments: File[]): void; + onCancel(): void; + onConfigChange(option: AcpSessionConfigOption, value: string | boolean): void; + onError(message: string): void; + menuId: string; +} + +function configChoices(option: AcpSessionConfigOption): AcpSessionConfigChoice[] { + return (option.options ?? []).flatMap((choice) => ("options" in choice ? choice.options : [choice])); +} + +function currentConfigLabel(option: AcpSessionConfigOption): string { + if (typeof option.currentValue === "boolean") return option.currentValue ? "On" : "Off"; + return configChoices(option).find((choice) => choice.value === option.currentValue)?.name + ?? String(option.currentValue); +} + +export function Composer({ + attachments, + onAttachmentsChange, + commands, + configOptions, + turnActive, + uploading, + configBusy, + draft, + onDraftChange, + onSubmit, + onCancel, + onConfigChange, + onError, + menuId, +}: ComposerProps) { + const inputRef = useRef(null); + const fileRef = useRef(null); + const configRef = useRef(null); + const [caret, setCaret] = useState(0); + const [activeCommand, setActiveCommand] = useState(0); + const [configOpen, setConfigOpen] = useState(false); + /** Escape closes the menu until the draft or caret moves again. */ + const [menuDismissed, setMenuDismissed] = useState(false); + const pasteCount = useRef(0); + + // Grow with the draft, up to the CSS max-height; past that it scrolls. + useLayoutEffect(() => { + const input = inputRef.current; + if (!input) return; + input.style.height = "auto"; + input.style.overflowY = "hidden"; + input.style.height = `${Math.min(input.scrollHeight, 160)}px`; + if (input.scrollHeight > 160) input.style.overflowY = "auto"; + }, [draft]); + + useEffect(() => { + if (!configOpen) return; + const onPointerDown = (event: PointerEvent) => { + if (!configRef.current?.contains(event.target as Node)) setConfigOpen(false); + }; + document.addEventListener("pointerdown", onPointerDown); + return () => document.removeEventListener("pointerdown", onPointerDown); + }, [configOpen]); + + const token = commandTokenAt(draft, caret, caret); + const matches = token && commands.length > 0 ? filterCommands(commands, token.query) : []; + const menuOpen = matches.length > 0 && !menuDismissed; + const activeIndex = menuOpen ? Math.min(Math.max(activeCommand, 0), matches.length - 1) : -1; + + const addFiles = useCallback( + (files: File[]) => { + if (uploading) return; + const next = [...attachments]; + for (const file of files) { + if (file.size > MAX_ATTACHMENT_BYTES) { + onError(`${file.name} exceeds 20 MiB. Choose a smaller file.`); + } else if (next.length >= MAX_ATTACHMENTS) { + onError(`Attach up to ${MAX_ATTACHMENTS} files per message.`); + } else { + next.push(file); + } + } + onAttachmentsChange(next); + }, + [attachments, onAttachmentsChange, onError, uploading], + ); + + const syncCaret = () => { + setMenuDismissed(false); + setCaret(inputRef.current?.selectionStart ?? 0); + }; + + const chooseCommand = (command: AvailableCommand) => { + const input = inputRef.current; + if (!input) return; + const current = commandTokenAt(draft, input.selectionStart, input.selectionEnd); + if (!current) return; + const applied = applyCommand(draft, current, command.name); + onDraftChange(applied.value); + setActiveCommand(0); + // The caret has to move after React has written the new value. + requestAnimationFrame(() => { + input.focus(); + input.setSelectionRange(applied.cursor, applied.cursor); + setCaret(applied.cursor); + }); + }; + + const submit = () => { + if ((!draft.trim() && attachments.length === 0) || uploading) return; + onSubmit(draft, attachments); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (menuOpen) { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + const direction = event.key === "ArrowDown" ? 1 : -1; + setActiveCommand((index) => (index + direction + matches.length) % matches.length); + return; + } + if ((event.key === "Enter" && !event.shiftKey) || event.key === "Tab") { + event.preventDefault(); + chooseCommand(matches[activeIndex]); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + setMenuDismissed(true); + return; + } + } + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + submit(); + } + }; + + const onPaste = (event: ClipboardEvent) => { + const clipboard = event.clipboardData; + if (!clipboard || uploading) return; + if (clipboard.files.length) { + event.preventDefault(); + addFiles(Array.from(clipboard.files)); + return; + } + const text = clipboard.getData("text/plain"); + if (text.length < PASTE_TO_FILE_CHARS && text.split("\n").length < PASTE_TO_FILE_LINES) return; + // No slot for it, or too big to upload: leave it inline rather than + // dropping it on the floor. + if (attachments.length >= MAX_ATTACHMENTS || new Blob([text]).size > MAX_ATTACHMENT_BYTES) return; + event.preventDefault(); + addFiles([new File([text], `pasted-${++pasteCount.current}.txt`, { type: "text/plain" })]); + }; + + const ordered = orderConfigOptions(configOptions); + const summary = ordered.map((option) => `${option.name}: ${currentConfigLabel(option)}`).join("\n"); + + return ( +
{ + event.preventDefault(); + submit(); + }} + onDragOver={(event: DragEvent) => { + if (event.dataTransfer?.types.includes("Files")) event.preventDefault(); + }} + onDrop={(event: DragEvent) => { + if (!event.dataTransfer?.files.length) return; + event.preventDefault(); + addFiles(Array.from(event.dataTransfer.files)); + }} + > + + ) => { + addFiles(Array.from(event.target.files ?? [])); + event.target.value = ""; + }} + /> +
+ + +