fabro/apps/fabro-web/app/components/interview-dock.tsx
Bryan Helmkamp e6cf174578
Merge origin/main into feat/human-review-target
Conflict in apps/fabro-web/app/components/interview-dock.tsx. Main moved
the dock onto the shared collapsible `RunDockShell` and replaced the
local button constants with shared ones.

Kept main's structure whole and re-applied the review target rendering
onto it: the question paragraph in the shell's `body` becomes the linked
`ReviewTargetQuestion` when the target passes `safeReviewTarget`, and
plain text otherwise. Both now share main's paragraph classes through
`QUESTION_TEXT`, so the two renderings stay visually identical.

`peek` keeps using `question.text`, which is the correct plain-text
collapsed summary for a review target question.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 16:48:02 -04:00

479 lines
13 KiB
TypeScript

import { useCallback, useState } from "react";
import {
ArrowRightIcon,
CheckIcon,
ChevronRightIcon,
} from "@heroicons/react/20/solid";
import { QuestionType } from "@qltysh/fabro-api-client";
import type {
ApiQuestion,
InterviewOption,
} from "@qltysh/fabro-api-client";
import {
useSubmitInterviewAnswer,
type SubmitInterviewAnswerArg,
} from "../lib/mutations";
import { ApiError } from "../lib/api-client";
import { displayLabel } from "./interview-label";
import {
ReviewTargetQuestion,
safeReviewTarget,
} from "./review-target-question";
import {
DockComposer,
RunDockShell,
DOCK_CHOICE_BUTTON,
DOCK_CHOICE_BUTTON_SELECTED,
DOCK_HEADER_BUTTON,
} from "./run-dock";
import { Spinner } from "./state";
import {
ErrorMessage,
PRIMARY_BUTTON_CLASS,
} from "./ui";
/**
* Options stack into a list once a label is long enough that a row of pills
* would wrap mid-sentence.
*/
const STACK_LABEL_LENGTH = 40;
/** Shared by the plain question text and the review target rendering. */
const QUESTION_TEXT = "max-w-[78ch] text-base/6 font-medium text-pretty text-fg";
type SubmitInterviewAnswer = SubmitInterviewAnswerArg["answer"];
export interface InterviewDockProps {
runId: string;
questions: ApiQuestion[];
}
export function InterviewDock({ runId, questions }: InterviewDockProps) {
const [activeIndex, setActiveIndex] = useState(0);
const safeIndex = activeIndex < questions.length ? activeIndex : 0;
const question = questions[safeIndex];
if (!question) return null;
const moreCount = questions.length - 1;
return (
// Keyed by question id, so a new question always arrives expanded with an
// empty composer. A collapsed panel can never silently block a run.
<InterviewQuestionDock
key={question.id}
runId={runId}
question={question}
moreCount={moreCount}
onCycle={() =>
setActiveIndex((index) => (index + 1) % questions.length)
}
/>
);
}
function InterviewQuestionDock({
runId,
question,
moreCount,
onCycle,
}: {
runId: string;
question: ApiQuestion;
moreCount: number;
onCycle: () => void;
}) {
const submitMutation = useSubmitInterviewAnswer(runId);
const [error, setError] = useState<string | null>(null);
const [collapsed, setCollapsed] = useState(false);
const submitting = submitMutation.isMutating;
const reviewTarget = safeReviewTarget(question.review_target);
const submit = useCallback(
async (answer: SubmitInterviewAnswer) => {
setError(null);
try {
await submitMutation.trigger({ questionId: question.id, answer });
return true;
} catch (caught) {
setError(interviewSubmitErrorMessage(caught));
return false;
}
},
[question.id, submitMutation],
);
return (
<RunDockShell
label="Interview question"
tone="waiting"
status="Awaiting input"
stage={question.stage}
peek={question.text}
collapsed={collapsed}
onCollapsedChange={setCollapsed}
headerActions={
moreCount > 0 && (
<button
type="button"
onClick={onCycle}
className={DOCK_HEADER_BUTTON}
>
<span className="tabular-nums">{moreCount}</span> more pending
<ArrowRightIcon className="size-3" aria-hidden="true" />
</button>
)
}
body={
<>
{reviewTarget ? (
<ReviewTargetQuestion
target={reviewTarget}
className={QUESTION_TEXT}
/>
) : (
<p className={QUESTION_TEXT}>{question.text}</p>
)}
{question.context_display && (
<ContextPanel text={question.context_display} />
)}
</>
}
actions={
<>
<QuestionBody
question={question}
submitting={submitting}
onSubmit={submit}
/>
{error && <ErrorMessage message={error} />}
</>
}
/>
);
}
/**
* Context arrives collapsed. It repeats material the operator has usually
* already read in the stage stream above, so it earns a line rather than a
* standing panel.
*/
function ContextPanel({ text }: { text: string }) {
return (
<details className="group rounded-lg bg-panel-alt outline-1 -outline-offset-1 outline-line">
<summary className="flex cursor-pointer list-none items-center gap-1.5 rounded-lg px-3 py-1.5 font-mono text-[0.6875rem] tracking-wide text-fg-muted uppercase transition-colors hover:text-fg-3 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500 [&::-webkit-details-marker]:hidden">
<ChevronRightIcon
className="size-3 shrink-0 transition-transform group-open:rotate-90"
aria-hidden="true"
/>
Context from preceding stage
<span className="ml-auto truncate pl-3 font-sans text-xs tracking-normal normal-case group-open:hidden">
{contextPreview(text)}
</span>
</summary>
<div className="px-3 pb-2.5 text-sm/6 text-fg-2">
<pre className="font-sans whitespace-pre-wrap">{text}</pre>
</div>
</details>
);
}
/** First line of the context, for the collapsed summary. */
export function contextPreview(text: string): string {
let lineStart = 0;
while (lineStart < text.length) {
const newline = text.indexOf("\n", lineStart);
const lineEnd = newline === -1 ? text.length : newline;
const line = text.slice(lineStart, lineEnd).trim();
if (line) {
return line.length > 60 ? `${line.slice(0, 60).trimEnd()}` : line;
}
if (newline === -1) break;
lineStart = newline + 1;
}
return "";
}
function QuestionBody({
question,
submitting,
onSubmit,
}: {
question: ApiQuestion;
submitting: boolean;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<boolean>;
}) {
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 (
<FreeformAnswer
submitting={submitting}
onSubmit={onSubmit}
placeholder="Write your response…"
/>
);
default:
return null;
}
}
function YesNoBody({
submitting,
onSubmit,
}: {
submitting: boolean;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<boolean>;
}) {
return (
<div className="flex flex-wrap items-center gap-2">
{/* react-doctor-disable-next-line react-doctor/design-no-vague-button-label -- Yes/no interview answers conventionally use the literal answer as the visible button label. */}
<button
type="button"
aria-label="Answer no"
disabled={submitting}
onClick={() => void onSubmit({ kind: "no" })}
className={DOCK_CHOICE_BUTTON}
>
No
</button>
<button
type="button"
aria-label="Answer yes"
disabled={submitting}
onClick={() => void onSubmit({ kind: "yes" })}
className={PRIMARY_BUTTON_CLASS}
>
{submitting ? (
<Spinner className="size-4" />
) : (
<CheckIcon className="size-4" aria-hidden="true" />
)}
Yes
</button>
</div>
);
}
function ConfirmationBody({
submitting,
onSubmit,
}: {
submitting: boolean;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<boolean>;
}) {
return (
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
disabled={submitting}
onClick={() => void onSubmit({ kind: "yes" })}
className={PRIMARY_BUTTON_CLASS}
>
{submitting ? (
<Spinner className="size-4" />
) : (
<CheckIcon className="size-4" aria-hidden="true" />
)}
Confirm
</button>
</div>
);
}
/**
* Long labels wrap badly as pills, so they become a stacked list instead.
*/
export function shouldStackOptions(options: InterviewOption[]): boolean {
return options.some(
(option) =>
option.label.length > STACK_LABEL_LENGTH || Boolean(option.description),
);
}
function optionListClass(stacked: boolean): string {
return stacked
? "flex flex-col items-stretch gap-2"
: "flex flex-wrap items-center gap-2";
}
function ChoiceBody({
options,
allowFreeform,
submitting,
onSubmit,
}: {
options: InterviewOption[];
allowFreeform: boolean;
submitting: boolean;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<boolean>;
}) {
const stacked = shouldStackOptions(options);
return (
<div className="space-y-2.5">
{options.length > 0 && (
<div className={optionListClass(stacked)}>
{options.map((option) => (
<button
key={option.key}
type="button"
disabled={submitting}
onClick={() => void onSubmit({ kind: "selected", option_key: option.key })}
className={
stacked ? `${DOCK_CHOICE_BUTTON} justify-start` : DOCK_CHOICE_BUTTON
}
>
<OptionLabel option={option} />
</button>
))}
</div>
)}
{allowFreeform && (
<FreeformAnswer
submitting={submitting}
onSubmit={onSubmit}
placeholder={
options.length > 0
? "Or write a custom response…"
: "Write your response…"
}
/>
)}
</div>
);
}
function MultiSelectBody({
options,
submitting,
onSubmit,
}: {
options: InterviewOption[];
submitting: boolean;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<boolean>;
}) {
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: string[] = [];
for (const option of options) {
if (selected.has(option.key)) selectedKeys.push(option.key);
}
const stacked = shouldStackOptions(options);
return (
<div className="space-y-2.5">
<div className={optionListClass(stacked)}>
{options.map((option) => {
const isSelected = selected.has(option.key);
const base = isSelected ? DOCK_CHOICE_BUTTON_SELECTED : DOCK_CHOICE_BUTTON;
return (
<button
key={option.key}
type="button"
disabled={submitting}
aria-pressed={isSelected}
onClick={() => toggle(option.key)}
className={stacked ? `${base} justify-start` : base}
>
{isSelected && <CheckIcon className="size-3.5" aria-hidden="true" />}
<OptionLabel option={option} />
</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({ kind: "multi_selected", option_keys: selectedKeys })}
className={PRIMARY_BUTTON_CLASS}
>
{submitting ? (
<Spinner className="size-4" />
) : (
<CheckIcon className="size-4" aria-hidden="true" />
)}
Submit selection
</button>
</div>
</div>
);
}
function FreeformAnswer({
submitting,
onSubmit,
placeholder,
}: {
submitting: boolean;
onSubmit: (answer: SubmitInterviewAnswer) => Promise<boolean>;
placeholder: string;
}) {
return (
<DockComposer
onSubmit={(text) => onSubmit({ kind: "text", text })}
placeholder={placeholder}
submitLabel="Send"
submitting={submitting}
ariaLabel="Interview answer"
/>
);
}
function OptionLabel({ option }: { option: InterviewOption }) {
return (
<span className="text-left">
<span className="block">{displayLabel(option.label)}</span>
{option.description && (
<span className="mt-0.5 block text-xs/5 font-normal text-fg-muted">
{option.description}
</span>
)}
</span>
);
}
function interviewSubmitErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
return error.requestId
? `${error.message} Request ID: ${error.requestId}`
: error.message;
}
return error instanceof Error ? error.message : "Couldn't submit your answer.";
}