mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
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:
parent
a479db981f
commit
7247fd6b7c
11 changed files with 772 additions and 110 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
203
apps/fabro-web/app/components/interview-dock.test.tsx
Normal file
203
apps/fabro-web/app/components/interview-dock.test.tsx
Normal 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]");
|
||||
});
|
||||
});
|
||||
512
apps/fabro-web/app/components/interview-dock.tsx
Normal file
512
apps/fabro-web/app/components/interview-dock.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 ?? [];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const CANCELABLE_STATUSES = new Set<RunStatus>([
|
|||
"starting",
|
||||
"running",
|
||||
"paused",
|
||||
"blocked",
|
||||
]);
|
||||
|
||||
const ARCHIVABLE_STATUSES = new Set<RunStatus>([
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue