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 (
{/* 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. */}
{ if ((event.target as HTMLElement | null)?.closest?.("button")) { return; } onCollapsedChange(false); } : undefined } > {/* A live region: the dock changing to a state that needs the operator has to reach assistive tech, not only the eye. */} {status} {stage && ( <> {stage} )} {collapsed && peek ? ( · {peek} ) : ( )} {headerActions}
{/* 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. */}
{body && (
{body}
)}
{actions}
); } function StatusDot({ tone }: { tone: DockTone }) { if (tone === "idle") { return (