feat(frontend): rework chat composer (sticky input, inline new session, status footer)
- Center ThothII logo + tagline; break "Human In The Loop" onto a second line - Rename "New question" → "New session"; the button now clears the screen and focuses the sticky bottom composer instead of opening a dialog - Composer is always rendered and pinned at the bottom; typing the first message there creates the session (no modal) - Add inline stop control (square + inner circle) that interrupts and saves the session via /close - Always-present send affordance (CornerDownLeft ↵) on the right of the box - Status footer under the box: mic (inert), model name, thinking level, and a context-usage gauge (placeholder) - Brand-tinted spinner beside the logo while the harness/backend holds the ball; hidden once a widget needs human input Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,40 @@
|
||||
// frontend/src/shell/SteerInput.tsx
|
||||
import { useState } from "react";
|
||||
import { ArrowUpIcon } from "lucide-react";
|
||||
import { postSteer } from "../api/sessions";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { CornerDownLeft, Mic } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { postSteer, createSession } from "../api/sessions";
|
||||
import { getSettings } from "../api/settings";
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
/** 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<HTMLInputElement>;
|
||||
}
|
||||
|
||||
export function SteerInput({ sessionId }: Props) {
|
||||
export function SteerInput({ sessionId, onSessionCreated, onStop, inputRef }: Props) {
|
||||
const [text, setText] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit() {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
await postSteer(sessionId, trimmed);
|
||||
setText("");
|
||||
if (!trimmed || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (sessionId) {
|
||||
await postSteer(sessionId, trimmed);
|
||||
} else {
|
||||
const { id } = await createSession({ question: trimmed });
|
||||
onSessionCreated?.(id);
|
||||
}
|
||||
setText("");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
@@ -26,24 +45,99 @@ export function SteerInput({ sessionId }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-input bg-card px-2 py-1.5 shadow-sm transition-colors focus-within:border-primary/40 focus-within:ring-3 focus-within:ring-ring/15">
|
||||
<div className="flex items-center 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>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Free steering (!)…"
|
||||
aria-label="Steering"
|
||||
className="flex-1 bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground"
|
||||
placeholder={sessionId ? "Free steering (!)…" : "Ask a question to start a new session…"}
|
||||
aria-label={sessionId ? "Steering" : "New question"}
|
||||
className="flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={!text.trim()}
|
||||
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"
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</Button>
|
||||
<CornerDownLeft className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status strip beneath the composer: voice input (inert for now), the active
|
||||
* model + thinking level pulled from the global settings, and a context-usage
|
||||
* gauge (placeholder until the backend reports token usage).
|
||||
*/
|
||||
export function ComposerFooter() {
|
||||
const { data: settings } = useQuery({ queryKey: ["settings"], queryFn: getSettings });
|
||||
const model = settings?.model?.trim() || "Default model";
|
||||
const thinking = settings?.thinking?.trim() || "medium";
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between border-t border-border/60 px-3 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
title="Voice input (coming soon)"
|
||||
aria-label="Voice input (coming soon)"
|
||||
className="grid size-6 place-items-center rounded-md text-muted-foreground/60"
|
||||
>
|
||||
<Mic className="size-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2.5 text-[0.7rem] text-muted-foreground">
|
||||
<span className="font-medium text-foreground/70">{model}</span>
|
||||
<span className="size-1 rounded-full bg-border" aria-hidden />
|
||||
<span className="capitalize">{thinking}</span>
|
||||
<ContextGauge />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user