// 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 } 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"; 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); // 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; setBusy(true); if (sessionId) { try { await postSteer(sessionId, trimmed); setLastUserEntry({ kind: "input", text: trimmed }); setText(""); } finally { 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 }); onSessionCreated?.(id); setText(""); } catch (error) { // Keep the question in the composer so retrying does not require retyping. // A DWH-unreachable precheck (local dev, VPN down) carries a specific code; // surface its message as the alert instead of the generic retry hint. const payload = error instanceof ApiError ? (error.payload as { code?: string; error?: string } | undefined) : undefined; const alert = payload?.code === "dwh_unreachable" ? payload.error : error instanceof WorkspaceSelectionError ? error.message : undefined; onSessionCreateFailed?.(alert); } finally { 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 && ( )}