Give live feedback that something is happening the moment a session starts processing: the red stop dot in the composer beats with a soft heartbeat + halo while the harness is actively working (running — not waiting at a gate or finalized), and settles the instant the model hands control back to the reviewer or the session ends. Ring/scale only (no layout properties animated); honors prefers-reduced-motion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
280 lines
10 KiB
TypeScript
280 lines
10 KiB
TypeScript
// frontend/src/shell/SteerInput.tsx
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { CornerDownLeft } from "lucide-react";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { postSteer, createSession } from "../api/sessions";
|
|
import { ApiError } from "../api/client";
|
|
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;
|
|
/** 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 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<Settings>) {
|
|
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 (
|
|
<div className="flex items-center justify-between gap-2 border-t border-border/60 px-3 py-1.5">
|
|
<FooterSelect aria-label="Workspace" value={workspace} onChange={(v) => update({ workspace: v })}>
|
|
{workspaces.length === 0 ? (
|
|
<option value="">— workspace —</option>
|
|
) : (
|
|
workspaces.map((w) => (
|
|
<option key={w.name} value={w.name}>
|
|
{w.name}
|
|
</option>
|
|
))
|
|
)}
|
|
</FooterSelect>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<FooterSelect aria-label="Model" value={model} onChange={onModelChange}>
|
|
{models.length === 0 ? (
|
|
<option value={model}>{model || "— model —"}</option>
|
|
) : (
|
|
<>
|
|
{!knownModel && <option value={model}>{model || "— model —"}</option>}
|
|
{models.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 />
|
|
</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>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<span
|
|
title="Context usage"
|
|
aria-label="Context usage"
|
|
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"
|
|
stroke="oklch(var(--primary))"
|
|
strokeWidth="2.25"
|
|
strokeLinecap="round"
|
|
strokeDasharray={circ}
|
|
strokeDashoffset={circ * (1 - Math.min(Math.max(pct, 0), 1))}
|
|
transform="rotate(-90 8 8)"
|
|
/>
|
|
</svg>
|
|
</span>
|
|
);
|
|
}
|