fabro/apps/fabro-web/app/components/run-dock.tsx
2026-08-01 09:24:34 -04:00

335 lines
11 KiB
TypeScript

import {
useId,
useState,
type FormEvent,
type KeyboardEvent,
type ReactNode,
type Ref,
} from "react";
import {
ArrowUturnLeftIcon,
ChevronUpIcon,
} from "@heroicons/react/20/solid";
import { classNames } from "../lib/class-names";
import { Spinner } from "./state";
import {
INPUT_CLASS,
PRIMARY_BUTTON_CLASS,
} from "./ui";
/**
* Shared chrome for the two controls docked at the bottom of the run detail
* route: the interview question panel and the steering composer.
*
* The shell is three zones. The header is always visible and doubles as the
* collapsed bar. The body scrolls. The actions stay pinned, so the controls
* needed to answer or send never scroll out of reach.
*
* Collapsed state is owned by the caller. Each dock has its own rule for when
* a collapsed panel must reopen — a new question for the interview, a run
* waiting for steering for the composer — and those rules are clearer next to
* the state they depend on.
*/
/** Ceiling on the expanded dock, so a long body cannot take the page. */
const DOCK_MAX_HEIGHT = "max-h-[60vh]";
export const DOCK_HEADER_BUTTON =
"inline-flex shrink-0 items-center gap-1.5 rounded-md bg-overlay px-2 py-1 text-xs 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-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-overlay disabled:hover:text-fg-2";
export const DOCK_CHOICE_BUTTON =
"inline-flex items-center justify-center gap-1.5 rounded-lg bg-overlay px-3.5 py-2 text-left 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";
export const DOCK_CHOICE_BUTTON_SELECTED =
"inline-flex items-center justify-center gap-1.5 rounded-lg bg-teal-500/15 px-3.5 py-2 text-left 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";
/**
* How the dock signals its state.
*
* - `waiting` pulses amber: the run is blocked on the operator, as expected.
* - `alert` pulses amber and colors the label too, for a run that went off
* its normal path and is stuck until someone acts.
* - `idle` is a resting control with no pending demand.
*/
export type DockTone = "waiting" | "alert" | "idle";
export interface RunDockShellProps {
/** Accessible name for the docked region. */
label: string;
className?: string;
tone: DockTone;
/** Short state phrase, e.g. "Awaiting input". */
status: string;
/** Stage name shown in mono beside the status. */
stage?: string | null;
/** One-line summary shown only while collapsed. */
peek?: string | null;
/** Run-level controls, rendered beside the collapse toggle. */
headerActions?: ReactNode;
/** Scrolling zone. Omit when there is nothing to scroll. */
body?: ReactNode;
/** Pinned zone. */
actions: ReactNode;
collapsed: boolean;
onCollapsedChange: (collapsed: boolean) => void;
}
export function RunDockShell({
label,
className,
tone,
status,
stage,
peek,
headerActions,
body,
actions,
collapsed,
onCollapsedChange,
}: RunDockShellProps) {
const contentId = useId();
return (
<section
aria-label={label}
className={classNames(
"flex flex-col",
!collapsed && DOCK_MAX_HEIGHT,
className,
)}
>
{/* While collapsed, the whole bar expands on click. Clicks that land on
a button (Interrupt, the chevron) keep their own behavior. The
chevron button stays the keyboard/assistive-tech toggle. */}
<div
className={classNames(
"flex shrink-0 items-center gap-2.5 px-5 py-2 sm:px-6",
collapsed && "cursor-pointer",
)}
onClick={
collapsed
? (event) => {
if ((event.target as HTMLElement | null)?.closest?.("button")) {
return;
}
onCollapsedChange(false);
}
: undefined
}
>
<StatusDot tone={tone} />
{/* A live region: the dock changing to a state that needs the
operator has to reach assistive tech, not only the eye. */}
<span
role="status"
className={`shrink-0 text-sm font-medium ${
tone === "alert" ? "text-amber" : "text-fg-2"
}`}
>
{status}
</span>
{stage && (
<>
<span className="shrink-0 text-fg-muted" aria-hidden="true">
·
</span>
<span className="min-w-0 truncate font-mono text-xs text-fg-3">
{stage}
</span>
</>
)}
{collapsed && peek ? (
<span className="min-w-0 flex-1 truncate text-sm text-fg-3">
· {peek}
</span>
) : (
<span className="flex-1" />
)}
{headerActions}
<button
type="button"
onClick={() => onCollapsedChange(!collapsed)}
aria-expanded={!collapsed}
aria-controls={contentId}
aria-label={collapsed ? `Expand ${label}` : `Collapse ${label}`}
className="inline-flex size-6.5 shrink-0 items-center justify-center rounded-md text-fg-3 transition-colors hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500"
>
<ChevronUpIcon
className={`size-4 transition-transform duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] ${
collapsed ? "rotate-180" : ""
}`}
aria-hidden="true"
/>
</button>
</div>
{/* Hidden rather than unmounted, so a half-written message survives a
collapse. The display utility is swapped rather than layered, so two
display classes cannot collide in the cascade. */}
<div
id={contentId}
className={collapsed ? "hidden" : "flex min-h-0 flex-1 flex-col"}
>
{body && (
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto border-t border-line px-5 pt-3.5 pb-1 sm:px-6">
{body}
</div>
)}
<div
className={classNames(
"flex max-h-[50%] shrink-0 flex-col gap-2.5 overflow-y-auto px-5 pt-2.5 pb-3.5 sm:px-6",
!body && "border-t border-line",
)}
>
{actions}
</div>
</div>
</section>
);
}
function StatusDot({ tone }: { tone: DockTone }) {
if (tone === "idle") {
return (
<span
className="size-2 shrink-0 rounded-full bg-fg-muted"
aria-hidden="true"
/>
);
}
return (
<span
className="relative flex size-2 shrink-0 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>
);
}
export interface DockComposerProps {
/**
* Sends the trimmed text. Resolve `true` to clear the box; resolve `false`
* to keep what the operator typed, so a failed send is not lost.
*/
onSubmit: (text: string) => Promise<boolean>;
placeholder: string;
submitLabel: string;
pendingLabel?: string;
submitting: boolean;
disabled?: boolean;
ariaLabel: string;
className?: string;
maxLength?: number;
textareaRef?: Ref<HTMLTextAreaElement>;
}
/**
* The single composer used by both docks: the interview's freeform answer and
* the steering message. Enter sends, Shift+Enter breaks the line, and the
* hint for that only appears on focus — inside the row, so revealing it does
* not shift the layout.
*/
export function DockComposer({
onSubmit,
placeholder,
submitLabel,
pendingLabel,
submitting,
disabled = false,
ariaLabel,
className,
maxLength,
textareaRef,
}: DockComposerProps) {
const [value, setValue] = useState("");
const fieldId = useId();
const instructionId = `${fieldId}-instruction`;
const trimmed = value.trim();
const composerDisabled = disabled || submitting;
const canSend = trimmed.length > 0 && !composerDisabled;
async function send() {
if (!canSend) return;
const cleared = await onSubmit(trimmed);
if (cleared) setValue("");
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
void send();
}
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
void send();
}
}
return (
<form
onSubmit={handleSubmit}
className={classNames("group flex items-end gap-2", className)}
>
<label className="sr-only" htmlFor={fieldId}>
{ariaLabel}
</label>
<p id={instructionId} className="sr-only">
Press Enter to send. Press Shift+Enter for a new line.
</p>
<textarea
id={fieldId}
ref={textareaRef}
aria-label={ariaLabel}
aria-describedby={instructionId}
rows={1}
value={value}
maxLength={maxLength}
onChange={(event) => setValue(event.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={composerDisabled}
className={`${INPUT_CLASS} min-w-0 flex-1 resize-none disabled:opacity-60`}
/>
<p
aria-hidden="true"
className="pointer-events-none hidden shrink-0 items-center gap-1 pb-2.5 text-xs whitespace-nowrap text-fg-muted opacity-0 transition-opacity group-focus-within:opacity-100 md:flex"
>
<kbd className="rounded bg-overlay px-1 font-mono text-[0.6875rem]">
Enter
</kbd>
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>
newline
</p>
<button
type="submit"
disabled={!canSend}
className={PRIMARY_BUTTON_CLASS}
>
{submitting ? (
<Spinner className="size-4" />
) : (
<ArrowUturnLeftIcon
className="size-3.5 -scale-x-100"
aria-hidden="true"
/>
)}
{submitting && pendingLabel ? pendingLabel : submitLabel}
</button>
</form>
);
}