Files
ThothII/frontend/src/shell/SteerInput.tsx
T

405 lines
15 KiB
TypeScript

// 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<HTMLTextAreaElement>;
/** 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<HTMLTextAreaElement | null>(null);
const operationEpochRef = useRef(0);
useEffect(() => () => { operationEpochRef.current += 1; }, []);
// 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<HTMLTextAreaElement | null>).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<HTMLTextAreaElement>) {
// Enter sends; Shift+Enter inserts a newline (the textarea grows to fit it).
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}
return (
<div className="flex items-end gap-2">
{sessionId && (
<button
type="button"
onClick={onStop}
title="Stop & save session"
aria-label="Stop and save session"
className="grid size-7 shrink-0 place-items-center rounded-md border border-border text-primary transition-colors hover:bg-muted"
>
<span
className={["block size-2.5 rounded-full bg-primary", working && "thot-pulse"]
.filter(Boolean).join(" ")}
/>
</button>
)}
<textarea
ref={attachRef}
rows={1}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={sessionId ? "Free steering (!)…" : "Ask a question to start a new session…"}
aria-label={sessionId ? "Steering" : "New question"}
data-awaiting-input={awaitingInput ? "true" : undefined}
className={["max-h-40 flex-1 resize-none rounded-lg bg-card px-1 py-1 text-sm leading-relaxed outline-none placeholder:text-muted-foreground", awaitingInput && "thot-awaiting-input"].filter(Boolean).join(" ")}
/>
<button
type="button"
onClick={submit}
disabled={!text.trim() || busy}
aria-label="Send"
title="Send"
className="grid size-7 shrink-0 place-items-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
>
<CornerDownLeft className="size-3.5" />
</button>
</div>
);
}
/**
* Status strip beneath the composer. Left: the active workspace selector.
* Right: live context usage plus ephemeral workspace/model/thinking selectors.
*/
export function ComposerFooter() {
const { data: settings } = useQuery({ queryKey: ["settings"], queryFn: getSettings });
const { data: workspaces = [], isLoading: workspacesLoading, isError: workspaceSummariesError } = useQuery({
queryKey: ["workspaces"], queryFn: listWorkspaces,
});
const { data: modelsData } = useQuery({ queryKey: ["models"], queryFn: listModels });
const models = modelsData?.models ?? [];
const tokenUsage = useSessionStore((state) => state.tokenUsage);
const [preferences, setPreferences] = useState<WorkspacePreference>(() => workspacePreferences.load());
useEffect(() => {
if (!settings) return;
setPreferences(workspacePreferences.migrate({
workspaceId: settings.workspace,
provider: settings.provider,
model: settings.model,
thinking: settings.thinking,
}));
}, [settings]);
const workspace = preferences.workspaceId ?? settings?.workspace ?? "";
const selectedWorkspace = workspaces.find((candidate) => candidate.id === workspace);
const selectedWorkspaceHasRevision = Boolean(selectedWorkspace?.revision);
const { data: workspaceRecord, isError: workspacePolicyError } = useQuery({
queryKey: ["workspace", workspace],
queryFn: () => getWorkspace(workspace),
enabled: selectedWorkspaceHasRevision,
});
const model = preferences.model ?? settings?.model ?? "";
const thinking = preferences.thinking ?? settings?.thinking ?? "medium";
const allowedModels = workspaceRecord?.workspace.llm_policy.allowed;
const policyModels = allowedModels
? models.filter((candidate) => allowedModels.includes(`${candidate.provider}/${candidate.id}`))
: models;
useEffect(() => {
if (!workspace) {
workspacePolicyGate.clear();
} else if (workspacesLoading) {
workspacePolicyGate.beginSummary(workspace);
} else if (workspaceSummariesError) {
workspacePolicyGate.rejectSummary(workspace);
} else if (selectedWorkspace && !selectedWorkspaceHasRevision) {
workspacePolicyGate.rejectSummary(workspace);
} else if (selectedWorkspace?.revision) {
workspacePolicyGate.select(workspace);
} else {
workspacePolicyGate.allowLegacy(workspace);
}
}, [selectedWorkspace, workspace, workspaceSummariesError, workspacesLoading]);
useEffect(() => {
if (!allowedModels?.length) return;
const selected = preferences.provider && preferences.model
? `${preferences.provider}/${preferences.model}`
: undefined;
if (selected && allowedModels.some((allowed) => allowed === selected)) return;
const replacement = workspaceRecord?.workspace.llm_policy.default
&& allowedModels.includes(workspaceRecord.workspace.llm_policy.default)
? workspaceRecord.workspace.llm_policy.default
: allowedModels[0];
const separator = replacement.indexOf("/");
if (separator <= 0 || separator === replacement.length - 1) return;
const next = {
...preferences,
provider: replacement.slice(0, separator),
model: replacement.slice(separator + 1),
};
workspacePreferences.save(next);
setPreferences(next);
}, [allowedModels, preferences, workspaceRecord]);
useEffect(() => {
if (!selectedWorkspace?.revision) return;
if (workspaceRecord) workspacePolicyGate.resolve(workspace);
else if (workspacePolicyError) workspacePolicyGate.reject(workspace);
}, [selectedWorkspace, workspace, workspacePolicyError, workspaceRecord]);
function update(patch: WorkspacePreference) {
if (patch.workspaceId && patch.workspaceId !== workspace) {
const selected = workspaces.find((candidate) => candidate.id === patch.workspaceId);
if (selected && !selected.revision) workspacePolicyGate.rejectSummary(patch.workspaceId);
else if (selected?.revision) workspacePolicyGate.select(patch.workspaceId);
else workspacePolicyGate.allowLegacy(patch.workspaceId);
}
const next = workspacePreferences.save({ ...preferences, ...patch });
setPreferences(next);
}
function onModelChange(id: string) {
const m = policyModels.find((x) => x.id === id);
update({ model: id, provider: m?.provider });
}
const knownModel = policyModels.some((m) => m.id === model);
const showModelFallback = !allowedModels && !knownModel;
const contextPct = tokenUsage && tokenUsage.contextWindow > 0
? tokenUsage.totalTokens / tokenUsage.contextWindow
: 0;
return (
<div className="flex items-center justify-between gap-2 border-t border-border/60 px-3 py-1.5">
<div className="flex items-center gap-2">
<FooterSelect aria-label="Workspace" value={workspace} onChange={(v) => update({ workspaceId: v })}>
{workspaces.length === 0 ? (
<option value="">— workspace —</option>
) : (
workspaces.filter((w) => w.revision).map((w) => (
<option key={w.id} value={w.id}>
{w.name}
</option>
))
)}
</FooterSelect>
<span
className="whitespace-nowrap font-mono text-[0.7rem] tabular-nums text-muted-foreground"
aria-label="Input non-cached, cached, and output tokens"
>
{formatTokenUsage(tokenUsage)}
</span>
</div>
<div className="flex items-center gap-2">
<FooterSelect aria-label="Model" value={model} onChange={onModelChange}>
{policyModels.length === 0 ? (
<option value={model}>{model || "— model —"}</option>
) : (
<>
{showModelFallback && <option value={model}>{model || "— model —"}</option>}
{policyModels.map((m) => (
<option key={`${m.provider}/${m.id}`} value={m.id}>
{m.name}
</option>
))}
</>
)}
</FooterSelect>
<FooterSelect aria-label="Thinking level" value={thinking} onChange={(v) => update({ thinking: v })} className="capitalize">
{THINKING_LEVELS.map((l) => (
<option key={l} value={l}>
{l}
</option>
))}
</FooterSelect>
<ContextGauge pct={contextPct} />
</div>
</div>
);
}
/** 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<React.SelectHTMLAttributes<HTMLSelectElement>, "aria-label">) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
{...rest}
className={[
"max-w-[10rem] cursor-pointer truncate rounded-lg border border-border bg-card px-1.5 py-0.5 text-[0.7rem] text-foreground/80 outline-none transition-colors hover:bg-muted focus:border-primary/40 focus:ring-2 focus:ring-ring/15",
className,
].filter(Boolean).join(" ")}
>
{children}
</select>
);
}
function formatThousands(tokens: number): string {
return `${Math.round(Math.max(tokens, 0) / 1_000)}k`;
}
export function formatTokenUsage(usage: {
input: number;
cacheRead: number;
output: number;
} | null): string {
return [usage?.input ?? 0, usage?.cacheRead ?? 0, usage?.output ?? 0]
.map(formatThousands)
.join("/");
}
/** Circular context-usage gauge. `pct` in [0,1]; 0 renders an empty track. */
export function ContextGauge({ pct = 0 }: { pct?: number }) {
const r = 6;
const circ = 2 * Math.PI * r;
const clamped = Math.min(Math.max(pct, 0), 1);
const level = clamped < 0.7 ? "green" : clamped <= 0.85 ? "yellow" : "red";
const progressClass = level === "green"
? "stroke-emerald-500"
: level === "yellow"
? "stroke-amber-400"
: "stroke-red-500";
return (
<span
title="Context usage"
aria-label={`Context usage ${Math.round(clamped * 100)}%`}
data-level={level}
className="inline-flex items-center"
>
<svg width="16" height="16" viewBox="0 0 16 16" className="opacity-80">
<circle cx="8" cy="8" r={r} fill="none" stroke="oklch(var(--border))" strokeWidth="2.25" />
<circle
cx="8"
cy="8"
r={r}
fill="none"
className={progressClass}
strokeWidth="2.25"
strokeLinecap="round"
strokeDasharray={circ}
strokeDashoffset={circ * (1 - clamped)}
transform="rotate(-90 8 8)"
/>
</svg>
</span>
);
}