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,13 +1,15 @@
|
|||||||
import { useSessionStream } from "../stream/useSessionStream";
|
import { useSessionStream } from "../stream/useSessionStream";
|
||||||
|
import { useSessionStore } from "../store/sessionStore";
|
||||||
import { WidgetHost } from "./WidgetHost";
|
import { WidgetHost } from "./WidgetHost";
|
||||||
import { Transcript } from "./Transcript";
|
import { Transcript } from "./Transcript";
|
||||||
import { NavSessions } from "./NavSessions";
|
import { NavSessions } from "./NavSessions";
|
||||||
import { NewSessionDialog } from "./NewSessionDialog";
|
|
||||||
import { SettingsDialog } from "./SettingsDialog";
|
import { SettingsDialog } from "./SettingsDialog";
|
||||||
import { SteerInput } from "./SteerInput";
|
import { SteerInput, ComposerFooter } from "./SteerInput";
|
||||||
import { WorkflowBar } from "./WorkflowBar";
|
import { WorkflowBar } from "./WorkflowBar";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
import { Toaster } from "../components/ui/sonner";
|
import { Toaster } from "../components/ui/sonner";
|
||||||
import { useState } from "react";
|
import { closeSession } from "../api/sessions";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The page is designed to live INSIDE the Omics Portal chrome (its left sidebar
|
* The page is designed to live INSIDE the Omics Portal chrome (its left sidebar
|
||||||
@@ -17,9 +19,32 @@ import { useState } from "react";
|
|||||||
*/
|
*/
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||||
|
const composerRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// The harness "holds the ball" whenever a session is live and no widget is
|
||||||
|
// waiting on the human; once a widget appears, input is back in the user's court.
|
||||||
|
const pendingWidget = useSessionStore((s) => s.pendingWidget);
|
||||||
|
const resetSession = useSessionStore((s) => s.resetSession);
|
||||||
|
const working = Boolean(activeSessionId) && !pendingWidget;
|
||||||
|
|
||||||
useSessionStream(activeSessionId);
|
useSessionStream(activeSessionId);
|
||||||
|
|
||||||
|
function startNewSession() {
|
||||||
|
resetSession();
|
||||||
|
setActiveSessionId(null);
|
||||||
|
requestAnimationFrame(() => composerRef.current?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopSession() {
|
||||||
|
if (!activeSessionId) return;
|
||||||
|
try {
|
||||||
|
await closeSession(activeSessionId);
|
||||||
|
} finally {
|
||||||
|
resetSession();
|
||||||
|
setActiveSessionId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background text-foreground">
|
<div className="flex h-screen bg-background text-foreground">
|
||||||
{/* Conversation column */}
|
{/* Conversation column */}
|
||||||
@@ -45,28 +70,49 @@ export function AppShell() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{activeSessionId && (
|
{/* Sticky composer: the box stays pinned while the conversation scrolls above. */}
|
||||||
<div className="shrink-0 px-6 pb-6 pt-2">
|
<div className="shrink-0 px-6 pb-6 pt-2">
|
||||||
<div className="mx-auto max-w-3xl">
|
<div className="mx-auto max-w-3xl">
|
||||||
<SteerInput sessionId={activeSessionId} />
|
<div className="rounded-2xl border border-input bg-card shadow-sm transition-colors focus-within:border-primary/40 focus-within:ring-3 focus-within:ring-ring/15">
|
||||||
|
<div className="px-2.5 py-2">
|
||||||
|
<SteerInput
|
||||||
|
sessionId={activeSessionId}
|
||||||
|
onSessionCreated={setActiveSessionId}
|
||||||
|
onStop={stopSession}
|
||||||
|
inputRef={composerRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ComposerFooter />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right session rail — symmetric to the portal's left sidebar */}
|
{/* Right session rail — symmetric to the portal's left sidebar */}
|
||||||
<aside className="flex w-[280px] shrink-0 flex-col border-l border-border bg-sidebar">
|
<aside className="flex w-[280px] shrink-0 flex-col border-l border-border bg-sidebar">
|
||||||
<div className="px-4 pb-3 pt-5">
|
<div className="relative px-4 pb-3 pt-5 text-center">
|
||||||
|
{working && (
|
||||||
|
<WorkingSpinner className="absolute left-4 top-[1.35rem]" />
|
||||||
|
)}
|
||||||
<h1 className="font-heading text-xl font-semibold leading-none tracking-tight text-foreground">
|
<h1 className="font-heading text-xl font-semibold leading-none tracking-tight text-foreground">
|
||||||
Thoth<span className="text-primary">II</span>
|
Thoth<span className="text-primary">II</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1.5 text-[0.7rem] uppercase tracking-[0.14em] text-muted-foreground">
|
<p className="mt-1.5 text-[0.7rem] uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
Datamart Builder with Human In The Loop
|
Datamart Builder with
|
||||||
|
<br />
|
||||||
|
Human In The Loop
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 px-4 pb-3">
|
<div className="flex flex-col gap-2 px-4 pb-3">
|
||||||
<NewSessionDialog onCreated={setActiveSessionId} />
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={startNewSession}
|
||||||
|
>
|
||||||
|
New session
|
||||||
|
</Button>
|
||||||
<SettingsDialog />
|
<SettingsDialog />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -88,6 +134,22 @@ export function AppShell() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Brand-tinted spinner shown while the assistant/harness is working. */
|
||||||
|
function WorkingSpinner({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
role="status"
|
||||||
|
aria-label="Assistant is working"
|
||||||
|
className={["size-4 animate-spin text-primary", className].filter(Boolean).join(" ")}
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="3" opacity="0.2" />
|
||||||
|
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function EmptyState() {
|
function EmptyState() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center">
|
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center">
|
||||||
@@ -96,8 +158,8 @@ function EmptyState() {
|
|||||||
</span>
|
</span>
|
||||||
<p className="mt-4 max-w-md text-balance text-[0.95rem] leading-relaxed text-muted-foreground">
|
<p className="mt-4 max-w-md text-balance text-[0.95rem] leading-relaxed text-muted-foreground">
|
||||||
Build datamarts from your data warehouse through a guided,
|
Build datamarts from your data warehouse through a guided,
|
||||||
human-in-the-loop workflow. Start a new question from the panel on the
|
human-in-the-loop workflow. Type your question in the box below to begin
|
||||||
right to begin a session.
|
a session.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function NavSessions({ activeSessionId, onSessionSelect }: Props) {
|
|||||||
if (sessions.length === 0) {
|
if (sessions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p className="px-1 py-6 text-center text-xs text-muted-foreground">
|
<p className="px-1 py-6 text-center text-xs text-muted-foreground">
|
||||||
No sessions yet. Start with <span className="font-medium text-foreground">New question</span>.
|
No sessions yet. Start with <span className="font-medium text-foreground">New session</span>.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function NewSessionDialog({ onCreated }: Props) {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
>
|
>
|
||||||
New question
|
New session
|
||||||
</Button>
|
</Button>
|
||||||
<DialogContent showCloseButton={false}>
|
<DialogContent showCloseButton={false}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -1,21 +1,40 @@
|
|||||||
// frontend/src/shell/SteerInput.tsx
|
// frontend/src/shell/SteerInput.tsx
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ArrowUpIcon } from "lucide-react";
|
import { CornerDownLeft, Mic } from "lucide-react";
|
||||||
import { postSteer } from "../api/sessions";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Button } from "../components/ui/button";
|
import { postSteer, createSession } from "../api/sessions";
|
||||||
|
import { getSettings } from "../api/settings";
|
||||||
|
|
||||||
interface Props {
|
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 [text, setText] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed || busy) return;
|
||||||
await postSteer(sessionId, trimmed);
|
setBusy(true);
|
||||||
setText("");
|
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>) {
|
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||||
@@ -26,24 +45,99 @@ export function SteerInput({ sessionId }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<input
|
||||||
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => setText(e.target.value)}
|
onChange={(e) => setText(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Free steering (!)…"
|
placeholder={sessionId ? "Free steering (!)…" : "Ask a question to start a new session…"}
|
||||||
aria-label="Steering"
|
aria-label={sessionId ? "Steering" : "New question"}
|
||||||
className="flex-1 bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground"
|
className="flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
<Button
|
<button
|
||||||
size="icon-sm"
|
type="button"
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={!text.trim()}
|
disabled={!text.trim() || busy}
|
||||||
aria-label="Send"
|
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 />
|
<CornerDownLeft className="size-3.5" />
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,13 +30,10 @@ test("F1: create session -> widget via SSE -> respond -> POST /response", async
|
|||||||
);
|
);
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
// Open the "Nuova domanda" dialog
|
// Type the first question directly in the sticky composer and send it
|
||||||
await userEvent.click(screen.getByRole("button", { name: /new/i }));
|
const composer = screen.getByRole("textbox", { name: /new question/i });
|
||||||
|
await userEvent.type(composer, "Quante vendite?");
|
||||||
// Fill in the question and submit
|
await userEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||||
const textarea = await screen.findByLabelText(/question/i);
|
|
||||||
await userEvent.type(textarea, "Quante vendite?");
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: /^create$/i }));
|
|
||||||
|
|
||||||
// Wait for the session to be created and SSE to connect
|
// Wait for the session to be created and SSE to connect
|
||||||
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
||||||
|
|||||||
Reference in New Issue
Block a user