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>
239 lines
8.4 KiB
TypeScript
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>
|
|
);
|
|
}
|