diff --git a/apps/fabro-web/app/components/steer-composer.tsx b/apps/fabro-web/app/components/steer-composer.tsx new file mode 100644 index 000000000..e27c8c77b --- /dev/null +++ b/apps/fabro-web/app/components/steer-composer.tsx @@ -0,0 +1,157 @@ +import { useEffect, useRef, useState } from "react"; + +import { ApiError } from "../lib/api-client"; +import { useSteerRun } from "../lib/mutations"; + +interface SteerComposerProps { + runId: string; + open: boolean; + onClose: () => void; +} + +/** + * Modal composer for sending a mid-run steering message to a running run. + * + * Renders a textarea + two action buttons: + * - Send: appends to the steering queue (default). + * - Interrupt: cancels the in-flight LLM stream / tool calls and delivers + * the message as the next user turn. + * + * Surfaces 409 errors inline so users see the rejection reason (e.g., the + * `cli_agent_not_steerable` code). + */ +export function SteerComposer({ runId, open, onClose }: SteerComposerProps) { + const [text, setText] = useState(""); + const [errorMessage, setErrorMessage] = useState(null); + const textareaRef = useRef(null); + const { trigger, isMutating } = useSteerRun(runId); + + // Autofocus when opening; reset state when closing. + useEffect(() => { + if (open) { + requestAnimationFrame(() => textareaRef.current?.focus()); + } else { + setText(""); + setErrorMessage(null); + } + }, [open]); + + // Close on Escape. + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + if (!open) return null; + + const trimmed = text.trim(); + const canSubmit = trimmed.length > 0 && !isMutating; + + async function send(interrupt: boolean) { + if (!canSubmit) return; + setErrorMessage(null); + try { + await trigger({ text: trimmed, interrupt }); + onClose(); + } catch (err) { + if (err instanceof ApiError) { + // Try to surface the well-known 409 codes inline. + const body = err.body as { code?: string; detail?: string } | null; + if (body?.code === "cli_agent_not_steerable") { + setErrorMessage( + "All running agent stages are CLI-mode and can't be steered.", + ); + } else if (body?.code === "use_answer_endpoint") { + setErrorMessage( + "Run is blocked on a question; answer the question first.", + ); + } else { + setErrorMessage(body?.detail ?? err.message ?? "Steer failed."); + } + } else { + setErrorMessage("Steer failed; try again."); + } + } + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + void send(false); + } + } + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+
Steer agent
+