diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index 55071f7b..4f2bee70 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -11,7 +11,7 @@ import { DeleteConfirmDialog } from "./DeleteConfirmDialog"; import { StopConfirmDialog } from "./StopConfirmDialog"; import { SteerInput, ComposerFooter } from "./SteerInput"; import { WorkflowBar } from "./WorkflowBar"; -import { Pencil } from "lucide-react"; +import { Pencil, ArrowLeft, ArrowRight } from "lucide-react"; import { Button } from "../components/ui/button"; import { Toaster } from "../components/ui/sonner"; import { toast } from "sonner"; @@ -34,7 +34,7 @@ export function AppShell() { const { data: sessions = [] } = useQuery({ queryKey: ["sessions"], queryFn: listSessions, refetchInterval: 10_000, }); - const composerRef = useRef(null); + const composerRef = useRef(null); const queryClient = useQueryClient(); const [panelSession, setPanelSession] = useState(null); @@ -194,7 +194,18 @@ export function AppShell() { {/* Conversation column */}
{activeSessionId && ( -
+
+ {/* Arrow toggle for the left Model-activity panel: → opens it, ← closes it. */} +
{activeSessionId ? ( <> - + ) : ( diff --git a/frontend/src/shell/CentralStatus.test.tsx b/frontend/src/shell/CentralStatus.test.tsx index 23a11e10..90ba4dbe 100644 --- a/frontend/src/shell/CentralStatus.test.tsx +++ b/frontend/src/shell/CentralStatus.test.tsx @@ -1,17 +1,16 @@ import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { beforeEach, vi } from "vitest"; +import { beforeEach } from "vitest"; import { useSessionStore } from "../store/sessionStore"; import { CentralStatus } from "./CentralStatus"; beforeEach(() => useSessionStore.getState().resetSession()); -test("the working activity icon opens the model-activity panel", async () => { - const onOpenActivity = vi.fn(); +test("the working spinner is a pure status indicator (no activity toggle on it)", () => { useSessionStore.getState().applyEvent({ type: "text_delta", text: "reading the schema" }); - render(); - await userEvent.click(screen.getByRole("button", { name: /model activity/i })); - expect(onOpenActivity).toHaveBeenCalledTimes(1); + render(); + // The spinner no longer opens the panel — that moved to the header arrow toggle. + expect(screen.queryByRole("button", { name: /model activity/i })).not.toBeInTheDocument(); + expect(screen.getByRole("status")).toBeInTheDocument(); }); test("renders nothing when not working and there is no user entry or step messages", () => { diff --git a/frontend/src/shell/CentralStatus.tsx b/frontend/src/shell/CentralStatus.tsx index a7e3318b..21a71617 100644 --- a/frontend/src/shell/CentralStatus.tsx +++ b/frontend/src/shell/CentralStatus.tsx @@ -2,27 +2,22 @@ import { useEffect, useReducer, useState } from "react"; import { useSessionStore } from "../store/sessionStore"; import { WorkingSpinner } from "./WorkingSpinner"; -/** Last few non-blank lines of a string — a compact multi-line "what the model is - * doing now" tail (mirrors ModelActivityPanel's tail-line logic, at a smaller size). */ -function tailLines(s: string, n = 5): string { - const lines = s +/** Last few non-blank lines of a string — a compact "what the model is doing now" + * tail (mirrors ModelActivityPanel's tail-line logic, at a smaller size). Returned + * as an array so each line renders as its own formatted log row. */ +function tailLines(s: string, n = 5): string[] { + return s .split("\n") .map((l) => l.trimEnd()) - .filter((l) => l.trim() !== ""); - return lines.slice(-n).join("\n"); + .filter((l) => l.trim() !== "") + .slice(-n); } /** The minimal central view: the user's last input/choice, a live "working" indicator * (elapsed time + a short tail of the model stream) while the model is busy, and the * gate's curated messages for the current step. The full verbose stream lives in the * left Model-activity panel. */ -export function CentralStatus({ - working, - onOpenActivity, -}: { - working: boolean; - onOpenActivity?: () => void; -}) { +export function CentralStatus({ working }: { working: boolean }) { const lastUserEntry = useSessionStore((s) => s.lastUserEntry); const stepMessages = useSessionStore((s) => s.stepMessages); const transcript = useSessionStore((s) => s.transcript); @@ -45,7 +40,7 @@ export function CentralStatus({ if (!working && !lastUserEntry && stepMessages.length === 0) return null; const elapsed = startedAt ? Math.max(0, Math.floor((Date.now() - startedAt) / 1000)) : 0; - const tail = tailLines(transcript.at(-1)?.text ?? ""); + const tailRows = tailLines(transcript.at(-1)?.text ?? ""); return (
@@ -59,24 +54,42 @@ export function CentralStatus({ )} {working && ( -
+
- + {elapsed}s ThothII is working
- {tail && ( -

- {tail} -

+ {tailRows.length > 0 && ( +
    + {tailRows.map((line, i) => { + const isLast = i === tailRows.length - 1; + return ( +
  1. + + › + + {line} +
  2. + ); + })} +
)}
)} diff --git a/frontend/src/shell/NavSessions.tsx b/frontend/src/shell/NavSessions.tsx index 5eac03ee..aaae9611 100644 --- a/frontend/src/shell/NavSessions.tsx +++ b/frontend/src/shell/NavSessions.tsx @@ -40,7 +40,9 @@ export function NavSessions({ sessions, activeSessionId, onOpenPanel, menuFor }: }} className={[ "group flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40", - active ? "bg-[oklch(var(--primary)/0.12)]" : "hover:bg-accent", + // The in-progress (active) session is marked in yellow — both its dot + // and its row background — so it stands out from the rest of the rail. + active ? "bg-[oklch(var(--warning)/0.22)]" : "hover:bg-accent", ].join(" ")} > {label} diff --git a/frontend/src/shell/SteerInput.tsx b/frontend/src/shell/SteerInput.tsx index 6dd47327..4b16f728 100644 --- a/frontend/src/shell/SteerInput.tsx +++ b/frontend/src/shell/SteerInput.tsx @@ -1,5 +1,5 @@ // frontend/src/shell/SteerInput.tsx -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { CornerDownLeft } from "lucide-react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { postSteer, createSession } from "../api/sessions"; @@ -18,7 +18,7 @@ interface Props { /** Interrupt the running session, persisting its state. */ onStop?: () => void; /** Lets the parent focus the composer (e.g. on "New session"). */ - inputRef?: React.Ref; + inputRef?: React.Ref; } export function SteerInput({ sessionId, onSessionCreated, onStop, inputRef }: Props) { @@ -26,6 +26,23 @@ export function SteerInput({ sessionId, onSessionCreated, onStop, inputRef }: Pr const [busy, setBusy] = useState(false); const setLastUserEntry = useSessionStore((s) => s.setLastUserEntry); const setPhase = useSessionStore((s) => s.setPhase); + const taRef = useRef(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).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(); @@ -46,15 +63,16 @@ export function SteerInput({ sessionId, onSessionCreated, onStop, inputRef }: Pr } } - function handleKeyDown(e: React.KeyboardEvent) { - if (e.key === "Enter") { + function handleKeyDown(e: React.KeyboardEvent) { + // Enter sends; Shift+Enter inserts a newline (the textarea grows to fit it). + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); } } return ( -
+
{sessionId && ( )} - 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="flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground" + className="max-h-40 flex-1 resize-none bg-transparent px-1 py-1 text-sm leading-relaxed outline-none placeholder:text-muted-foreground" />