mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
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>
479 lines
13 KiB
TypeScript
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.";
|
|
}
|