mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-09 22:33:37 +00:00
335 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|