// frontend/src/shell/SteerInput.tsx import { useState } from "react"; import { CornerDownLeft } from "lucide-react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { postSteer, createSession } from "../api/sessions"; import { getSettings, putSettings, type Settings } from "../api/settings"; import { listWorkspaces } from "../api/workspaces"; import { listModels } from "../api/models"; import { useSessionStore } from "../store/sessionStore"; 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; /** Interrupt the running session, persisting its state. */ onStop?: () => void; /** Lets the parent focus the composer (e.g. on "New session"). */ inputRef?: React.Ref; } export function SteerInput({ sessionId, onSessionCreated, onStop, inputRef }: Props) { const [text, setText] = useState(""); const [busy, setBusy] = useState(false); const setLastUserEntry = useSessionStore((s) => s.setLastUserEntry); async function submit() { const trimmed = text.trim(); if (!trimmed || busy) return; setBusy(true); try { if (sessionId) { await postSteer(sessionId, trimmed); } else { const { id } = await createSession({ question: trimmed }); onSessionCreated?.(id); } setLastUserEntry({ kind: "input", text: trimmed }); setText(""); } finally { setBusy(false); } } function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Enter") { e.preventDefault(); submit(); } } return (
{sessionId && ( )} setText(e.target.value)} onKeyDown={handleKeyDown} placeholder={sessionId ? "Free steering (!)…" : "Ask a question to start a new session…"} aria-label={sessionId ? "Steering" : "New question"} className="flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground" />
); } /** * Status strip beneath the composer. Left: the active workspace selector. * Right: live model + thinking-level selectors (these replace the old Settings * dialog and persist through PUT /settings) plus a context-usage gauge * (placeholder until the backend reports token usage). */ export function ComposerFooter() { const qc = useQueryClient(); const { data: settings } = useQuery({ queryKey: ["settings"], queryFn: getSettings }); const { data: workspaces = [] } = useQuery({ queryKey: ["workspaces"], queryFn: listWorkspaces }); const { data: modelsData } = useQuery({ queryKey: ["models"], queryFn: listModels }); const models = modelsData?.models ?? []; const workspace = settings?.workspace ?? ""; const model = settings?.model ?? ""; const thinking = settings?.thinking ?? "medium"; // PUT /settings replaces the whole object, so always send the merged settings. async function update(patch: Partial) { await putSettings({ workspace, provider: settings?.provider, model, thinking, ...patch }); qc.invalidateQueries({ queryKey: ["settings"] }); } function onModelChange(id: string) { const m = models.find((x) => x.id === id); update({ model: id, provider: m?.provider }); } const knownModel = models.some((m) => m.id === model); return (
update({ workspace: v })}> {workspaces.length === 0 ? ( ) : ( workspaces.map((w) => ( )) )}
{models.length === 0 ? ( ) : ( <> {!knownModel && } {models.map((m) => ( ))} )} update({ thinking: v })} className="capitalize"> {THINKING_LEVELS.map((l) => ( ))}
); } /** Compact, theme-styled native select used in the composer footer. */ function FooterSelect({ value, onChange, children, className, ...rest }: { value: string; onChange: (value: string) => void; children: React.ReactNode; className?: string; } & Pick, "aria-label">) { return ( ); } /** Circular context-usage gauge. `pct` in [0,1]; 0 renders an empty track. */ function ContextGauge({ pct = 0 }: { pct?: number }) { const r = 6; const circ = 2 * Math.PI * r; return ( ); }