// frontend/src/shell/SteerInput.tsx import { useEffect, useRef, useState } from "react"; import { CornerDownLeft } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { postSteer, createSession } from "../api/sessions"; import { ApiError, apiErrorMessage } from "../api/client"; import { getSettings } from "../api/settings"; import { getWorkspace, listWorkspaces } from "../api/workspaces"; import { listModels } from "../api/models"; import { useSessionStore } from "../store/sessionStore"; import { WorkspaceSelectionError, workspacePolicyGate, workspacePreferences, type WorkspacePreference, } from "../workspaces/drafts"; import { captureAuthOperation, isAuthOperationCurrent } from "../auth/authOperation"; const THINKING_LEVELS = ["low", "medium", "high"] as const; interface Props { /** Active session, or null when no session has been started yet. */ sessionId: string | null; /** Called with the new session id when the box starts a fresh session. */ onSessionCreated?: (id: string) => void; /** Paint the provisional session view before the create request completes. */ onSessionCreating?: (question: string) => void; /** Revert the provisional view when session creation fails. */ onSessionCreateFailed?: (message?: string) => void; /** Interrupt the running session, persisting its state. */ onStop?: () => void; /** Lets the parent focus the composer (e.g. on "New session"). */ inputRef?: React.Ref; /** The workflow is explicitly waiting for text in this composer. */ awaitingInput?: boolean; /** The harness is actively working: pulse the stop dot to signal live activity. */ working?: boolean; } export function SteerInput({ sessionId, onSessionCreated, onSessionCreating, onSessionCreateFailed, onStop, inputRef, awaitingInput = false, working = false, }: Props) { const [text, setText] = useState(""); const [busy, setBusy] = useState(false); const setLastUserEntry = useSessionStore((s) => s.setLastUserEntry); const setPhase = useSessionStore((s) => s.setPhase); const taRef = useRef(null); const operationEpochRef = useRef(0); useEffect(() => () => { operationEpochRef.current += 1; }, []); useEffect(() => { // A new active session owns a new composer operation scope. Invalidate any held request // before it can write the old session's completion into the new target. operationEpochRef.current += 1; setBusy(false); }, [sessionId]); // Merge our own ref (for autosizing) with the forwarded one (parent focus). function attachRef(el: HTMLTextAreaElement | null) { taRef.current = el; if (typeof inputRef === "function") inputRef(el); else if (inputRef) (inputRef as React.MutableRefObject).current = el; } // Grow the textarea to fit its content (up to a cap, then it scrolls), and shrink // back when the text is cleared. Runs after every value change so height tracks text. useEffect(() => { const el = taRef.current; if (!el) return; el.style.height = "auto"; el.style.height = `${Math.min(el.scrollHeight, 160)}px`; }, [text]); async function submit() { const trimmed = text.trim(); if (!trimmed || busy) return; const operation = captureAuthOperation({ sessionId, disposalEpoch: operationEpochRef.current }); if (!operation) return; setBusy(true); if (sessionId) { try { await postSteer(sessionId, trimmed); if (!isAuthOperationCurrent(operation, { sessionId, disposalEpoch: operationEpochRef.current })) return; setLastUserEntry({ kind: "input", text: trimmed }); setText(""); } finally { if (isAuthOperationCurrent(operation, { sessionId, disposalEpoch: operationEpochRef.current })) { setBusy(false); } } return; } // Session creation can include cold-start work. Hand the UI state to the // provisional session view before awaiting it so feedback is immediate. setPhase("F1"); setLastUserEntry({ kind: "input", text: trimmed }); onSessionCreating?.(trimmed); try { const { id } = await createSession({ question: trimmed }, { operation, isCurrent: () => isAuthOperationCurrent(operation, { sessionId: null, disposalEpoch: operationEpochRef.current, }), }); if (!isAuthOperationCurrent(operation, { sessionId: null, disposalEpoch: operationEpochRef.current })) return; onSessionCreated?.(id); setText(""); } catch (error) { if (!isAuthOperationCurrent(operation, { sessionId: null, disposalEpoch: operationEpochRef.current })) return; // Keep the question in the composer so retrying does not require retyping. // ApiError messages are derived from the local status/code allowlist; // never surface response-body text from the server. const alert = error instanceof ApiError && error.code === "dwh_unreachable" ? apiErrorMessage(error) : error instanceof WorkspaceSelectionError ? error.message : undefined; onSessionCreateFailed?.(alert); } finally { if (isAuthOperationCurrent(operation, { sessionId: null, disposalEpoch: operationEpochRef.current })) { setBusy(false); } } } function handleKeyDown(e: React.KeyboardEvent) { // Enter sends; Shift+Enter inserts a newline (the textarea grows to fit it). if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); } } return (
{sessionId && ( )}