From 7247fd6b7cd181abac8a4cb87bf86a3df661a9d2 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 2 May 2026 15:39:01 -0400 Subject: [PATCH] feat(web): add interview dock for answering blocked runs from the UI Replaces the read-only BlockedRunNotice with a viewport-fixed dock that lets users answer pending human-in-the-loop questions without dropping to the CLI. Supports YesNo, Confirmation, MultipleChoice, MultiSelect, and Freeform question types, plus the allow_freeform fallback for choice-with-write-in. Multiple pending questions surface a "+N more" pill so a parallel-handler run can be drained from one place. The dock subscribes to interview.* SSE events for auto-refresh and posts answers via the existing /runs/{id}/questions/{qid}/answer endpoint. Cancel is consolidated into the page header (now shown for blocked runs) so the dock chrome stays focused on the conversation. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/blocked-run-notice.test.tsx | 56 -- .../app/components/blocked-run-notice.tsx | 34 -- .../app/components/interview-dock.test.tsx | 203 +++++++ .../app/components/interview-dock.tsx | 512 ++++++++++++++++++ apps/fabro-web/app/lib/mutations.ts | 23 + apps/fabro-web/app/lib/queries.ts | 11 +- apps/fabro-web/app/lib/run-actions.test.ts | 2 +- apps/fabro-web/app/lib/run-actions.ts | 1 + apps/fabro-web/app/lib/run-events.ts | 13 + apps/fabro-web/app/routes/run-detail.test.ts | 3 +- apps/fabro-web/app/routes/run-detail.tsx | 24 +- 11 files changed, 772 insertions(+), 110 deletions(-) delete mode 100644 apps/fabro-web/app/components/blocked-run-notice.test.tsx delete mode 100644 apps/fabro-web/app/components/blocked-run-notice.tsx create mode 100644 apps/fabro-web/app/components/interview-dock.test.tsx create mode 100644 apps/fabro-web/app/components/interview-dock.tsx diff --git a/apps/fabro-web/app/components/blocked-run-notice.test.tsx b/apps/fabro-web/app/components/blocked-run-notice.test.tsx deleted file mode 100644 index f331e449d..000000000 --- a/apps/fabro-web/app/components/blocked-run-notice.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import TestRenderer, { act } from "react-test-renderer"; - -import { BlockedRunNotice } from "./blocked-run-notice"; - -function textFromNode(node: ReturnType): string { - if (!node) return ""; - if (typeof node === "string") return node; - if (Array.isArray(node)) return node.map(textFromNode).join(""); - return (node.children ?? []).map(textFromNode).join(""); -} - -describe("BlockedRunNotice", () => { - test("renders the question text when provided", () => { - let tree: TestRenderer.ReactTestRenderer | undefined; - act(() => { - tree = TestRenderer.create( - {}} - />, - ); - }); - - expect(textFromNode(tree!.toJSON())).toContain("Approve the deployment target?"); - }); - - test("renders fallback copy when no question is available", () => { - let tree: TestRenderer.ReactTestRenderer | undefined; - act(() => { - tree = TestRenderer.create( {}} />); - }); - - expect(textFromNode(tree!.toJSON())).toContain("Fabro is blocked on a human-in-the-loop question."); - }); - - test("fires the secondary cancel action", () => { - let cancelled = 0; - let tree: TestRenderer.ReactTestRenderer | undefined; - act(() => { - tree = TestRenderer.create( - { - cancelled += 1; - }} - />, - ); - }); - - const button = tree!.root.findByType("button"); - act(() => { - button.props.onClick(); - }); - - expect(cancelled).toBe(1); - }); -}); diff --git a/apps/fabro-web/app/components/blocked-run-notice.tsx b/apps/fabro-web/app/components/blocked-run-notice.tsx deleted file mode 100644 index 892b61692..000000000 --- a/apps/fabro-web/app/components/blocked-run-notice.tsx +++ /dev/null @@ -1,34 +0,0 @@ -export function BlockedRunNotice({ - questionText, - cancelling = false, - onCancel, -}: { - questionText?: string | null; - cancelling?: boolean; - onCancel: () => void; -}) { - return ( -
-

This run is waiting for input.

-

- {questionText?.trim() - ? questionText - : "Fabro is blocked on a human-in-the-loop question. Answer it from the CLI to continue the run."} -

-

- If you don't want to continue in the CLI, you can cancel the run here instead. -

- -
- ); -} diff --git a/apps/fabro-web/app/components/interview-dock.test.tsx b/apps/fabro-web/app/components/interview-dock.test.tsx new file mode 100644 index 000000000..9ac272ee8 --- /dev/null +++ b/apps/fabro-web/app/components/interview-dock.test.tsx @@ -0,0 +1,203 @@ +import { describe, expect, test } from "bun:test"; +import TestRenderer, { act } from "react-test-renderer"; +import { SWRConfig } from "swr"; +import { + type ApiQuestion, + QuestionType, +} from "@qltysh/fabro-api-client"; + +import { InterviewDock, displayLabel } from "./interview-dock"; + +function render(node: React.ReactNode): TestRenderer.ReactTestRenderer { + let tree: TestRenderer.ReactTestRenderer | undefined; + act(() => { + tree = TestRenderer.create( + new Map(), dedupingInterval: 0 }}> + {node} + , + ); + }); + return tree!; +} + +function textContent(node: ReturnType): string { + if (!node) return ""; + if (typeof node === "string") return node; + if (Array.isArray(node)) return node.map(textContent).join(""); + return (node.children ?? []).map(textContent).join(""); +} + +function instanceText(instance: TestRenderer.ReactTestInstance): string { + const parts: string[] = []; + for (const child of instance.children) { + if (typeof child === "string") parts.push(child); + else parts.push(instanceText(child)); + } + return parts.join(""); +} + +function buttonsByText( + tree: TestRenderer.ReactTestRenderer, +): Record { + const result: Record = {}; + for (const button of tree.root.findAllByType("button")) { + const label = instanceText(button).trim(); + if (label) result[label] = button; + } + return result; +} + +function makeQuestion(overrides: Partial = {}): ApiQuestion { + return { + id: "q-1", + text: "Approve the deployment plan?", + stage: "approve_plan", + question_type: QuestionType.YES_NO, + options: [], + allow_freeform: false, + timeout_seconds: null, + context_display: null, + ...overrides, + }; +} + +describe("InterviewDock", () => { + test("renders question text and stage in the header", () => { + const tree = render( + , + ); + const text = textContent(tree.toJSON()); + expect(text).toContain("Approve the deployment plan?"); + expect(text).toContain("approve_plan"); + expect(text).toContain("Awaiting input"); + }); + + test("yes/no question shows two buttons", () => { + const tree = render( + , + ); + const buttons = buttonsByText(tree); + expect(buttons.Yes).toBeDefined(); + expect(buttons.No).toBeDefined(); + }); + + test("multiple choice question renders option buttons with stripped accelerator prefixes", () => { + const question = makeQuestion({ + question_type: QuestionType.MULTIPLE_CHOICE, + options: [ + { key: "A", label: "[A] Approve" }, + { key: "R", label: "[R] Revise" }, + ], + }); + const tree = render( + , + ); + const buttons = buttonsByText(tree); + expect(buttons.Approve).toBeDefined(); + expect(buttons.Revise).toBeDefined(); + }); + + test("freeform question renders a textarea and disables send when empty", () => { + const question = makeQuestion({ + question_type: QuestionType.FREEFORM, + }); + const tree = render( + , + ); + const textareas = tree.root.findAllByType("textarea"); + expect(textareas).toHaveLength(1); + const sendButton = tree.root.findByProps({ type: "submit" }); + expect(sendButton.props.disabled).toBe(true); + }); + + test("multi-select shows submit button disabled until at least one option is selected", () => { + const question = makeQuestion({ + question_type: QuestionType.MULTI_SELECT, + options: [ + { key: "a", label: "[A] Apples" }, + { key: "b", label: "[B] Bananas" }, + ], + }); + const tree = render( + , + ); + const buttons = buttonsByText(tree); + const submit = buttons["Submit selection"]; + expect(submit).toBeDefined(); + expect(submit.props.disabled).toBe(true); + + act(() => { + buttons.Apples.props.onClick(); + }); + const submitAfter = buttonsByText(tree)["Submit selection"]; + expect(submitAfter.props.disabled).toBe(false); + }); + + test("multiple choice with allow_freeform renders both buttons and a textarea", () => { + const question = makeQuestion({ + question_type: QuestionType.MULTIPLE_CHOICE, + allow_freeform: true, + options: [{ key: "A", label: "[A] Approve" }], + }); + const tree = render( + , + ); + expect(buttonsByText(tree).Approve).toBeDefined(); + expect(tree.root.findAllByType("textarea")).toHaveLength(1); + }); + + test("shows '+N more pending' pill when multiple questions are queued", () => { + const tree = render( + , + ); + const text = textContent(tree.toJSON()); + expect(text).toContain("2"); + expect(text).toContain("more pending"); + }); + + test("renders nothing when questions list is empty", () => { + const tree = render(); + expect(tree.toJSON()).toBeNull(); + }); + + test("renders the optional context_display section", () => { + const question = makeQuestion({ + context_display: "Plan:\n1. Deploy\n2. Verify", + }); + const tree = render( + , + ); + const text = textContent(tree.toJSON()); + expect(text).toContain("Context from preceding stage"); + expect(text).toContain("1. Deploy"); + }); +}); + +describe("displayLabel", () => { + test("strips bracketed accelerator", () => { + expect(displayLabel("[A] Approve")).toBe("Approve"); + }); + + test("strips parenthesis accelerator", () => { + expect(displayLabel("Y) Yes, deploy")).toBe("Yes, deploy"); + }); + + test("strips dash accelerator", () => { + expect(displayLabel("Y - Yes, deploy")).toBe("Yes, deploy"); + }); + + test("returns original label when no accelerator pattern matches", () => { + expect(displayLabel("Plain label")).toBe("Plain label"); + }); + + test("falls back to original label when stripping yields empty string", () => { + expect(displayLabel("[A]")).toBe("[A]"); + }); +}); diff --git a/apps/fabro-web/app/components/interview-dock.tsx b/apps/fabro-web/app/components/interview-dock.tsx new file mode 100644 index 000000000..4407207e1 --- /dev/null +++ b/apps/fabro-web/app/components/interview-dock.tsx @@ -0,0 +1,512 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type FormEvent, + type KeyboardEvent, +} from "react"; +import { + ArrowPathIcon, + ArrowRightIcon, + ArrowUturnLeftIcon, + CheckIcon, +} from "@heroicons/react/20/solid"; +import { QuestionType } from "@qltysh/fabro-api-client"; +import type { + ApiQuestion, + ApiQuestionOption, +} from "@qltysh/fabro-api-client"; + +import { + useSubmitInterviewAnswer, + type SubmitInterviewAnswerArg, +} from "../lib/mutations"; + +const PRIMARY_BUTTON = + "inline-flex items-center justify-center gap-1.5 rounded-lg bg-teal-500 px-3.5 py-2 text-sm font-medium text-on-primary transition-colors hover:bg-teal-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-teal-500"; + +const CHOICE_BUTTON = + "inline-flex items-center justify-center gap-1.5 rounded-lg bg-overlay px-3.5 py-2 text-sm font-medium text-fg-2 outline-1 -outline-offset-1 outline-line-strong transition-colors hover:bg-overlay-strong hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60"; + +const CHOICE_BUTTON_SELECTED = + "inline-flex items-center justify-center gap-1.5 rounded-lg bg-teal-500/15 px-3.5 py-2 text-sm font-medium text-fg outline-1 -outline-offset-1 outline-teal-500/60 transition-colors hover:bg-teal-500/20 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500"; + +export interface InterviewDockProps { + runId: string; + questions: ApiQuestion[]; +} + +export function InterviewDock({ runId, questions }: InterviewDockProps) { + const [activeIndex, setActiveIndex] = useState(0); + const submitMutation = useSubmitInterviewAnswer(runId); + const [error, setError] = useState(null); + + const safeIndex = activeIndex < questions.length ? activeIndex : 0; + const question = questions[safeIndex]; + + useEffect(() => { + setError(null); + submitMutation.reset(); + }, [question?.id, submitMutation.reset]); + + useEffect(() => { + if (safeIndex !== activeIndex) { + setActiveIndex(safeIndex); + } + }, [activeIndex, safeIndex]); + + const submit = useCallback( + async (arg: Omit) => { + if (!question) return; + setError(null); + try { + await submitMutation.trigger({ ...arg, questionId: question.id }); + } catch (caught) { + setError( + caught instanceof Error ? caught.message : "Couldn't submit your answer.", + ); + } + }, + [question, submitMutation], + ); + + if (!question) return null; + + const moreCount = questions.length - 1; + const submitting = submitMutation.isMutating; + + return ( +
+
+
+
+ + setActiveIndex((index) => (index + 1) % questions.length) + } + /> +
+
+

+ {question.text} +

+

+ {questionTypeLabel(question.question_type)} +

+
+ + {question.context_display && ( + + )} + + + + {error && ( +

+ {error} +

+ )} +
+
+
+
+
+ ); +} + +function DockHeader({ + stage, + moreCount, + onCycle, +}: { + stage: string; + moreCount: number; + onCycle: () => void; +}) { + return ( +
+
+ + Awaiting input + {stage && ( + <> + + {stage} + + )} +
+ {moreCount > 0 && ( + + )} +
+ ); +} + +function PulseDot() { + return ( +