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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-02 15:39:01 -04:00
parent a479db981f
commit 7247fd6b7c
No known key found for this signature in database
11 changed files with 772 additions and 110 deletions

View file

@ -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<TestRenderer.ReactTestRenderer["toJSON"]>): 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(
<BlockedRunNotice
questionText="Approve the deployment target?"
onCancel={() => {}}
/>,
);
});
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(<BlockedRunNotice onCancel={() => {}} />);
});
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(
<BlockedRunNotice onCancel={() => {
cancelled += 1;
}}
/>,
);
});
const button = tree!.root.findByType("button");
act(() => {
button.props.onClick();
});
expect(cancelled).toBe(1);
});
});

View file

@ -1,34 +0,0 @@
export function BlockedRunNotice({
questionText,
cancelling = false,
onCancel,
}: {
questionText?: string | null;
cancelling?: boolean;
onCancel: () => void;
}) {
return (
<div
role="status"
className="mb-6 rounded-lg border border-amber/30 bg-amber/10 px-4 py-4 text-sm text-fg-2"
>
<p className="font-medium text-fg">This run is waiting for input.</p>
<p className="mt-2 leading-6">
{questionText?.trim()
? questionText
: "Fabro is blocked on a human-in-the-loop question. Answer it from the CLI to continue the run."}
</p>
<p className="mt-2 text-fg-muted">
If you don't want to continue in the CLI, you can cancel the run here instead.
</p>
<button
type="button"
onClick={onCancel}
disabled={cancelling}
className="mt-3 inline-flex min-h-12 items-center rounded-md px-3 text-sm font-medium text-fg-muted transition-colors hover:bg-amber/10 hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60"
>
{cancelling ? "Cancelling…" : "Cancel run anyway."}
</button>
</div>
);
}

View file

@ -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(
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>
{node}
</SWRConfig>,
);
});
return tree!;
}
function textContent(node: ReturnType<TestRenderer.ReactTestRenderer["toJSON"]>): 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<string, TestRenderer.ReactTestInstance> {
const result: Record<string, TestRenderer.ReactTestInstance> = {};
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> = {}): 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(
<InterviewDock runId="run-1" questions={[makeQuestion()]} />,
);
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(
<InterviewDock runId="run-1" questions={[makeQuestion()]} />,
);
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(
<InterviewDock runId="run-1" questions={[question]} />,
);
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(
<InterviewDock runId="run-1" questions={[question]} />,
);
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(
<InterviewDock runId="run-1" questions={[question]} />,
);
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(
<InterviewDock runId="run-1" questions={[question]} />,
);
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(
<InterviewDock
runId="run-1"
questions={[
makeQuestion({ id: "q-1", stage: "stage-a" }),
makeQuestion({ id: "q-2", stage: "stage-b" }),
makeQuestion({ id: "q-3", stage: "stage-c" }),
]}
/>,
);
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(<InterviewDock runId="run-1" questions={[]} />);
expect(tree.toJSON()).toBeNull();
});
test("renders the optional context_display section", () => {
const question = makeQuestion({
context_display: "Plan:\n1. Deploy\n2. Verify",
});
const tree = render(
<InterviewDock runId="run-1" questions={[question]} />,
);
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]");
});
});

View file

@ -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<string | null>(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<SubmitInterviewAnswerArg, "questionId">) => {
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 (
<div
role="region"
aria-label="Interview question"
className="pointer-events-none fixed inset-x-0 bottom-0 z-30"
>
<div className="bg-linear-to-t from-page via-page/80 to-transparent pt-10">
<div className="pointer-events-auto mx-auto max-w-5xl px-4 sm:px-6 lg:px-8">
<div className="overflow-hidden rounded-t-2xl bg-panel shadow-[0_-12px_40px_-8px_rgba(0,0,0,0.5)] ring-1 ring-line-strong">
<DockHeader
stage={question.stage}
moreCount={moreCount}
onCycle={() =>
setActiveIndex((index) => (index + 1) % questions.length)
}
/>
<div className="space-y-5 p-5 sm:p-6">
<div>
<p className="text-pretty text-base/6 font-medium text-fg">
{question.text}
</p>
<p className="mt-1 text-xs/5 text-fg-muted">
{questionTypeLabel(question.question_type)}
</p>
</div>
{question.context_display && (
<ContextPanel text={question.context_display} />
)}
<QuestionBody
question={question}
submitting={submitting}
onSubmit={submit}
/>
{error && (
<p
role="alert"
className="rounded-md bg-coral/10 px-3 py-2 text-sm/5 text-fg-2 outline-1 -outline-offset-1 outline-coral/40"
>
{error}
</p>
)}
</div>
</div>
</div>
</div>
</div>
);
}
function DockHeader({
stage,
moreCount,
onCycle,
}: {
stage: string;
moreCount: number;
onCycle: () => void;
}) {
return (
<div className="flex items-center justify-between gap-3 border-b border-line px-5 py-2.5">
<div className="flex min-w-0 items-center gap-2 text-sm">
<PulseDot />
<span className="font-medium text-fg-2">Awaiting input</span>
{stage && (
<>
<span className="text-fg-muted" aria-hidden="true">
·
</span>
<span className="truncate font-mono text-xs text-fg-3">{stage}</span>
</>
)}
</div>
{moreCount > 0 && (
<button
type="button"
onClick={onCycle}
className="inline-flex shrink-0 items-center gap-1 rounded-md bg-overlay px-2 py-1 text-xs font-medium text-fg-2 outline-1 -outline-offset-1 outline-line-strong hover:bg-overlay-strong hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500"
>
<span className="tabular-nums">{moreCount}</span> more pending
<ArrowRightIcon className="size-3" aria-hidden="true" />
</button>
)}
</div>
);
}
function PulseDot() {
return (
<span className="relative flex size-2 items-center justify-center" aria-hidden="true">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-amber/60" />
<span className="relative inline-flex size-2 rounded-full bg-amber" />
</span>
);
}
function ContextPanel({ text }: { text: string }) {
return (
<div className="rounded-lg bg-panel-alt p-4 outline-1 -outline-offset-1 outline-line">
<p className="mb-1.5 font-mono text-[0.6875rem] tracking-wide text-fg-muted uppercase">
Context from preceding stage
</p>
<div className="max-h-40 overflow-y-auto text-sm/6 text-fg-2">
<pre className="font-sans whitespace-pre-wrap">{text}</pre>
</div>
</div>
);
}
function QuestionBody({
question,
submitting,
onSubmit,
}: {
question: ApiQuestion;
submitting: boolean;
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
}) {
switch (question.question_type) {
case QuestionType.YES_NO:
return <YesNoBody submitting={submitting} onSubmit={onSubmit} />;
case QuestionType.CONFIRMATION:
return <ConfirmationBody submitting={submitting} onSubmit={onSubmit} />;
case QuestionType.MULTI_SELECT:
return (
<MultiSelectBody
options={question.options}
submitting={submitting}
onSubmit={onSubmit}
/>
);
case QuestionType.MULTIPLE_CHOICE:
return (
<ChoiceBody
options={question.options}
allowFreeform={question.allow_freeform}
submitting={submitting}
onSubmit={onSubmit}
/>
);
case QuestionType.FREEFORM:
return (
<FreeformBody
submitting={submitting}
onSubmit={onSubmit}
autoFocus
placeholder="Write your response…"
submitLabel="Send"
/>
);
default:
return null;
}
}
function YesNoBody({
submitting,
onSubmit,
}: {
submitting: boolean;
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
}) {
return (
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
disabled={submitting}
onClick={() => void onSubmit({ value: "no" })}
className={CHOICE_BUTTON}
>
No
</button>
<button
type="button"
disabled={submitting}
onClick={() => void onSubmit({ value: "yes" })}
className={PRIMARY_BUTTON}
>
{submitting ? <Spinner /> : <CheckIcon className="size-4" aria-hidden="true" />}
Yes
</button>
</div>
);
}
function ConfirmationBody({
submitting,
onSubmit,
}: {
submitting: boolean;
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
}) {
return (
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
disabled={submitting}
onClick={() => void onSubmit({ value: "yes" })}
className={PRIMARY_BUTTON}
>
{submitting ? <Spinner /> : <CheckIcon className="size-4" aria-hidden="true" />}
Confirm
</button>
</div>
);
}
function ChoiceBody({
options,
allowFreeform,
submitting,
onSubmit,
}: {
options: ApiQuestionOption[];
allowFreeform: boolean;
submitting: boolean;
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
}) {
return (
<div className="space-y-4">
{options.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
{options.map((option) => (
<button
key={option.key}
type="button"
disabled={submitting}
onClick={() => void onSubmit({ selected_option_key: option.key })}
className={CHOICE_BUTTON}
>
{displayLabel(option.label)}
</button>
))}
</div>
)}
{allowFreeform && (
<FreeformBody
submitting={submitting}
onSubmit={onSubmit}
placeholder={
options.length > 0
? "Or write a custom response…"
: "Write your response…"
}
submitLabel="Send"
divider={options.length > 0}
/>
)}
</div>
);
}
function MultiSelectBody({
options,
submitting,
onSubmit,
}: {
options: ApiQuestionOption[];
submitting: boolean;
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
}) {
const [selected, setSelected] = useState<Set<string>>(new Set());
function toggle(key: string) {
setSelected((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
const selectedKeys = useMemo(
() => options.map((o) => o.key).filter((key) => selected.has(key)),
[options, selected],
);
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
{options.map((option) => {
const isSelected = selected.has(option.key);
return (
<button
key={option.key}
type="button"
disabled={submitting}
aria-pressed={isSelected}
onClick={() => toggle(option.key)}
className={isSelected ? CHOICE_BUTTON_SELECTED : CHOICE_BUTTON}
>
{isSelected && <CheckIcon className="size-3.5" aria-hidden="true" />}
{displayLabel(option.label)}
</button>
);
})}
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-fg-muted tabular-nums">
{selectedKeys.length} selected
</p>
<button
type="button"
disabled={submitting || selectedKeys.length === 0}
onClick={() => void onSubmit({ selected_option_keys: selectedKeys })}
className={PRIMARY_BUTTON}
>
{submitting ? <Spinner /> : <CheckIcon className="size-4" aria-hidden="true" />}
Submit selection
</button>
</div>
</div>
);
}
function FreeformBody({
submitting,
onSubmit,
placeholder,
submitLabel,
autoFocus = false,
divider = false,
}: {
submitting: boolean;
onSubmit: (arg: Omit<SubmitInterviewAnswerArg, "questionId">) => Promise<void>;
placeholder: string;
submitLabel: string;
autoFocus?: boolean;
divider?: boolean;
}) {
const [value, setValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
if (autoFocus) textareaRef.current?.focus();
}, [autoFocus]);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmed = value.trim();
if (!trimmed || submitting) return;
await onSubmit({ value: trimmed });
setValue("");
}
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
const form = event.currentTarget.form;
if (form) form.requestSubmit();
}
}
const disabled = submitting || value.trim().length === 0;
return (
<form onSubmit={handleSubmit} className="space-y-2">
{divider && (
<div className="flex items-center gap-3" aria-hidden="true">
<span className="h-px flex-1 bg-line" />
<span className="text-xs text-fg-muted">or</span>
<span className="h-px flex-1 bg-line" />
</div>
)}
<div className="flex items-end gap-2">
<label className="sr-only" htmlFor="interview-freeform-answer">
Your response
</label>
<textarea
ref={textareaRef}
id="interview-freeform-answer"
name="answer"
rows={1}
value={value}
onChange={(event) => setValue(event.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={submitting}
className="block w-full resize-none rounded-lg bg-panel-alt px-3.5 py-2.5 text-base/6 text-fg outline-1 -outline-offset-1 outline-line-strong placeholder:text-fg-muted focus:outline-2 focus:-outline-offset-1 focus:outline-teal-500 disabled:opacity-60 sm:text-sm/5"
/>
<button type="submit" disabled={disabled} className={PRIMARY_BUTTON}>
{submitting ? (
<Spinner />
) : (
<ArrowUturnLeftIcon
className="size-3.5 -scale-x-100"
aria-hidden="true"
/>
)}
{submitLabel}
</button>
</div>
<p className="text-xs text-fg-muted">
Press <kbd className="rounded bg-overlay px-1 font-mono text-[0.6875rem]">Enter</kbd> to
send · <kbd className="rounded bg-overlay px-1 font-mono text-[0.6875rem]">Shift</kbd>+
<kbd className="rounded bg-overlay px-1 font-mono text-[0.6875rem]">Enter</kbd> for a new line
</p>
</form>
);
}
function Spinner() {
return <ArrowPathIcon className="size-4 animate-spin" aria-hidden="true" />;
}
function questionTypeLabel(type: QuestionType): string {
switch (type) {
case QuestionType.YES_NO:
return "Yes or no";
case QuestionType.CONFIRMATION:
return "Confirmation required";
case QuestionType.MULTIPLE_CHOICE:
return "Pick one";
case QuestionType.MULTI_SELECT:
return "Pick one or more";
case QuestionType.FREEFORM:
return "Freeform response";
default:
return "";
}
}
export function displayLabel(label: string): string {
const trimmed = label.trim();
const stripped = trimmed
.replace(/^\[[^\]]+\]\s*/, "")
.replace(/^[A-Za-z0-9]+\)\s*/, "")
.replace(/^[A-Za-z0-9]+\s*-\s+/, "")
.trim();
return stripped || trimmed;
}

View file

@ -3,6 +3,7 @@ import { useSWRConfig } from "swr";
import type {
PreviewUrlResponse,
RunStatusResponse,
SubmitAnswerRequest,
} from "@qltysh/fabro-api-client";
import { apiJsonMutation } from "./api-client";
@ -93,6 +94,28 @@ function useLifecycleMutation(
);
}
export type SubmitInterviewAnswerArg = SubmitAnswerRequest & { questionId: string };
export function useSubmitInterviewAnswer(runId: string | undefined) {
const { mutate } = useSWRConfig();
return useSWRMutation(
runId ? `interview-answer:${runId}` : null,
async (_key: string, { arg }: { arg: SubmitInterviewAnswerArg }) => {
if (!runId) throw new Error("runId is required");
const { questionId, ...body } = arg;
const path = `/api/v1/runs/${encodeURIComponent(runId)}/questions/${encodeURIComponent(questionId)}/answer`;
await apiJsonMutation<void, SubmitAnswerRequest>(path, { arg: body });
},
{
onSuccess: () => {
if (!runId) return;
void mutate(queryKeys.runs.questions(runId, 25, 0));
void mutate(queryKeys.runs.detail(runId));
},
},
);
}
export function useToggleDemoMode() {
const { mutate } = useSWRConfig();
return useSWRMutation(

View file

@ -1,5 +1,6 @@
import useSWR, { type SWRConfiguration } from "swr";
import type {
ApiQuestion,
PaginatedBoardRunList,
PaginatedEventList,
PaginatedRunFileList,
@ -130,12 +131,12 @@ export function useRunBilling(id: string | undefined) {
return useSWR<RunBilling>(id ? queryKeys.runs.billing(id) : null, apiFetcher);
}
export function useRunQuestionText(id: string | undefined, enabled: boolean) {
return useSWR<string | null>(
id && enabled ? queryKeys.runs.questions(id, 1, 0) : null,
export function useRunQuestions(id: string | undefined, enabled: boolean) {
return useSWR<ApiQuestion[]>(
id && enabled ? queryKeys.runs.questions(id, 25, 0) : null,
async (key) => {
const payload = await apiNullableFetcher<{ data: { text?: string | null }[] }>(key);
return payload?.data[0]?.text ?? null;
const payload = await apiNullableFetcher<{ data: ApiQuestion[] }>(key);
return payload?.data ?? [];
},
);
}

View file

@ -150,7 +150,7 @@ describe("run lifecycle actions", () => {
expect(canCancel("starting")).toBe(true);
expect(canCancel("running")).toBe(true);
expect(canCancel("paused")).toBe(true);
expect(canCancel("blocked")).toBe(false);
expect(canCancel("blocked")).toBe(true);
expect(canCancel("archived")).toBe(false);
expect(canArchive("succeeded")).toBe(true);

View file

@ -17,6 +17,7 @@ const CANCELABLE_STATUSES = new Set<RunStatus>([
"starting",
"running",
"paused",
"blocked",
]);
const ARCHIVABLE_STATUSES = new Set<RunStatus>([

View file

@ -34,6 +34,12 @@ const RUN_SUMMARY_EVENTS = new Set([
]);
const STAGE_EVENTS = new Set(["stage.started", "stage.completed", "stage.failed"]);
const COMMAND_EVENTS = new Set(["command.started", "command.completed"]);
const INTERVIEW_EVENTS = new Set([
"interview.started",
"interview.completed",
"interview.timeout",
"interview.interrupted",
]);
export function queryKeysForRunEvent(
runId: string,
@ -59,6 +65,13 @@ export function queryKeysForRunEvent(
return [queryKeys.runs.detail(runId)];
}
if (INTERVIEW_EVENTS.has(event)) {
return [
queryKeys.runs.questions(runId, 25, 0),
queryKeys.runs.detail(runId),
];
}
if (STAGE_EVENTS.has(event)) {
const keys = [
queryKeys.runs.stages(runId),

View file

@ -14,7 +14,7 @@ describe("lifecycleActionVisibility", () => {
expect(lifecycleActionVisibility("starting").showPrimaryCancel).toBe(true);
expect(lifecycleActionVisibility("running").showPrimaryCancel).toBe(true);
expect(lifecycleActionVisibility("paused").showPrimaryCancel).toBe(true);
expect(lifecycleActionVisibility("blocked").showPrimaryCancel).toBe(false);
expect(lifecycleActionVisibility("blocked").showPrimaryCancel).toBe(true);
expect(lifecycleActionVisibility("succeeded").showPrimaryCancel).toBe(false);
expect(lifecycleActionVisibility("failed").showPrimaryCancel).toBe(false);
expect(lifecycleActionVisibility("dead").showPrimaryCancel).toBe(false);
@ -28,7 +28,6 @@ describe("lifecycleActionVisibility", () => {
expect(lifecycleActionVisibility("archived").showArchive).toBe(false);
expect(lifecycleActionVisibility("archived").showUnarchive).toBe(true);
expect(lifecycleActionVisibility("running").showUnarchive).toBe(false);
expect(lifecycleActionVisibility("blocked").showBlockedNotice).toBe(true);
});
});

View file

@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import { ArrowPathIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
import { Link, Outlet, useLocation } from "react-router";
import { BlockedRunNotice } from "../components/blocked-run-notice";
import { InterviewDock } from "../components/interview-dock";
import { ErrorState } from "../components/state";
import { useToast } from "../components/toast";
import { PRIMARY_BUTTON_CLASS, SECONDARY_BUTTON_CLASS } from "../components/ui";
@ -22,7 +22,7 @@ import {
type PreviewMutationResult,
} from "../lib/mutations";
import { useRunEvents } from "../lib/run-events";
import { useRun, useRunQuestionText } from "../lib/queries";
import { useRun, useRunQuestions } from "../lib/queries";
import {
canArchive,
canCancel,
@ -74,7 +74,6 @@ export function lifecycleActionVisibility(status: string | null | undefined) {
showPrimaryCancel: canCancel(status),
showArchive: canArchive(status),
showUnarchive: canUnarchive(status),
showBlockedNotice: status === "blocked",
};
}
@ -104,7 +103,9 @@ export default function RunDetail({ params }: { params: { id: string } }) {
const runQuery = useRun(params.id);
const run = runQuery.data ? buildRunDetailRun(runQuery.data) : null;
const statusKind = runQuery.data?.status?.kind;
const blockedQuestion = useRunQuestionText(params.id, statusKind === "blocked");
const isBlocked = statusKind === "blocked";
const questionsQuery = useRunQuestions(params.id, isBlocked);
const pendingQuestions = questionsQuery.data ?? [];
const { pathname } = useLocation();
const basePath = `/runs/${params.id}`;
const previewMutation = usePreviewRun(params.id);
@ -264,14 +265,6 @@ export default function RunDetail({ params }: { params: { id: string } }) {
</div>
</div>
{visibility.showBlockedNotice && (
<BlockedRunNotice
questionText={blockedQuestion.data ?? null}
cancelling={cancelPending}
onCancel={() => void cancelMutation.trigger()}
/>
)}
<div className="border-b border-line">
<nav className="-mb-px flex gap-6">
{tabs.map((tab) => {
@ -306,6 +299,13 @@ export default function RunDetail({ params }: { params: { id: string } }) {
<div className="mt-6">
<Outlet />
</div>
{isBlocked && pendingQuestions.length > 0 && (
<>
<div aria-hidden="true" className="h-72" />
<InterviewDock runId={params.id} questions={pendingQuestions} />
</>
)}
</div>
);
}