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 (
);
}
return (
);
}
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;
placeholder: string;
submitLabel: string;
pendingLabel?: string;
submitting: boolean;
disabled?: boolean;
ariaLabel: string;
className?: string;
maxLength?: number;
textareaRef?: Ref;
}
/**
* 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) {
event.preventDefault();
void send();
}
function handleKeyDown(event: KeyboardEvent) {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
void send();
}
}
return (
);
}