382 lines
14 KiB
TypeScript
382 lines
14 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 } 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 { 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<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);
|
|
|
|
// 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;
|
|
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 : undefined;
|
|
onSessionCreateFailed?.(alert);
|
|
} finally {
|
|
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 browser-local workspace/model/thinking selectors.
|
|
*/
|
|
export function ComposerFooter() {
|
|
const { data: settings } = useQuery({ queryKey: ["settings"], queryFn: getSettings });
|
|
const { data: workspaces = [], isLoading: workspacesLoading } = 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 { data: workspaceRecord, isError: workspacePolicyError } = useQuery({
|
|
queryKey: ["workspace", workspace],
|
|
queryFn: () => getWorkspace(workspace),
|
|
// Legacy metadata responses do not carry a registry revision, so retain the
|
|
// existing selector behavior without issuing an incompatible detail request.
|
|
enabled: Boolean(selectedWorkspace?.revision),
|
|
});
|
|
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 (selectedWorkspace?.revision) {
|
|
workspacePolicyGate.select(workspace);
|
|
} else {
|
|
workspacePolicyGate.allowLegacy(workspace);
|
|
}
|
|
}, [selectedWorkspace, workspace, 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?.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.map((w) => (
|
|
<option key={w.name} value={w.name}>
|
|
{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>
|
|
);
|
|
}
|