Files
ThothII/frontend/src/shell/SteerInput.tsx
T
marcopanandClaude Opus 4.8 d6b4729ddf feat(shell): yellow active session, growing composer, phase title, refined activity log, arrow panel toggle
Fix six GUI defects, verified live against the real stack:

- NavSessions: mark the in-progress (active) session in yellow (warning
  token) — both its status dot and its row background.
- SteerInput: the composer is now an auto-growing textarea that wraps and
  grows vertically (caps at 160px, then scrolls); Enter sends, Shift+Enter
  inserts a newline.
- WorkflowBar: show a synthetic title of the current phase under the 8 dots
  from static EN/IT strings (no LLM); English is displayed to match the chrome.
- CentralStatus: reformat the 5-line system-message tail as a structured,
  monospace list with per-line markers and an emphasized last line.
- AppShell + CentralStatus: move the left Model-activity panel toggle off the
  working spinner (now a pure status indicator) onto a dedicated arrow button
  (→ opens, ← closes).
- gate: rename the phase-confirm button "Conferma e prosegui" → "Salva e
  procedi" (builders.js + its L1 test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:32:01 +02:00

239 lines
8.4 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 { 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<HTMLTextAreaElement>;
}
export function SteerInput({ sessionId, onSessionCreated, onStop, inputRef }: 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);
try {
if (sessionId) {
await postSteer(sessionId, trimmed);
} else {
const { id } = await createSession({ question: trimmed });
setPhase("F1"); // optimistic: paint F1 yellow during the cold start, before the first gate
onSessionCreated?.(id);
}
setLastUserEntry({ kind: "input", text: trimmed });
setText("");
} 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" />
</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"}
className="max-h-40 flex-1 resize-none bg-transparent px-1 py-1 text-sm leading-relaxed outline-none placeholder:text-muted-foreground"
/>
<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-md 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>
);
}