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:
2026-06-28 20:36:08 +02:00
co-authored by Claude Opus 4.8
parent bcaf6708de
commit d67409ff16
5 changed files with 192 additions and 39 deletions
+75 -13
View File
@@ -1,13 +1,15 @@
import { useSessionStream } from "../stream/useSessionStream";
import { useSessionStore } from "../store/sessionStore";
import { WidgetHost } from "./WidgetHost";
import { Transcript } from "./Transcript";
import { NavSessions } from "./NavSessions";
import { NewSessionDialog } from "./NewSessionDialog";
import { SettingsDialog } from "./SettingsDialog";
import { SteerInput } from "./SteerInput";
import { SteerInput, ComposerFooter } from "./SteerInput";
import { WorkflowBar } from "./WorkflowBar";
import { Button } from "../components/ui/button";
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
@@ -17,9 +19,32 @@ import { useState } from "react";
*/
export function AppShell() {
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);
function startNewSession() {
resetSession();
setActiveSessionId(null);
requestAnimationFrame(() => composerRef.current?.focus());
}
async function stopSession() {
if (!activeSessionId) return;
try {
await closeSession(activeSessionId);
} finally {
resetSession();
setActiveSessionId(null);
}
}
return (
<div className="flex h-screen bg-background text-foreground">
{/* Conversation column */}
@@ -45,28 +70,49 @@ export function AppShell() {
</div>
</main>
{activeSessionId && (
<div className="shrink-0 px-6 pb-6 pt-2">
<div className="mx-auto max-w-3xl">
<SteerInput sessionId={activeSessionId} />
{/* Sticky composer: the box stays pinned while the conversation scrolls above. */}
<div className="shrink-0 px-6 pb-6 pt-2">
<div className="mx-auto max-w-3xl">
<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>
{/* 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">
<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">
Thoth<span className="text-primary">II</span>
</h1>
<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>
</div>
<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 />
</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() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center">
@@ -96,8 +158,8 @@ function EmptyState() {
</span>
<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,
human-in-the-loop workflow. Start a new question from the panel on the
right to begin a session.
human-in-the-loop workflow. Type your question in the box below to begin
a session.
</p>
</div>
);