From 0eeb3f7522cd268277d3fcab8f996220d1e1b0c6 Mon Sep 17 00:00:00 2001 From: mptyl Date: Tue, 30 Jun 2026 16:26:43 +0200 Subject: [PATCH] feat(frontend): rotating activity icon + 5-line expandable activity panel - CentralStatus: replace the red pulsing dot at the start of the "working" line with the rotating WorkingSpinner, now a button that opens the model-activity panel (onOpenActivity). WorkingSpinner is extracted to its own module (was local to AppShell). - ModelActivityPanel: show the last 5 lines of the model-stream tail (refreshing as the stream grows) with an expand toggle to the full stream, replacing the unbounded full transcript. - AppShell: drop the separate spinner button (the inline icon is now the single trigger) and the local WorkingSpinner def. - Remove the now-orphaned Transcript.tsx (its only consumer was the panel). TDD: CentralStatus + ModelActivityPanel tests RED->GREEN; frontend suite 87/87; tsc clean. Live visual verification pending (to do with the B/C frontend pass). Co-Authored-By: Claude Opus 4.8 --- frontend/src/shell/AppShell.tsx | 32 +---------- frontend/src/shell/CentralStatus.test.tsx | 11 +++- frontend/src/shell/CentralStatus.tsx | 19 ++++++- .../src/shell/ModelActivityPanel.test.tsx | 18 +++++++ frontend/src/shell/ModelActivityPanel.tsx | 53 ++++++++++++++++--- frontend/src/shell/Transcript.tsx | 28 ---------- frontend/src/shell/WorkingSpinner.tsx | 24 +++++++++ 7 files changed, 115 insertions(+), 70 deletions(-) delete mode 100644 frontend/src/shell/Transcript.tsx create mode 100644 frontend/src/shell/WorkingSpinner.tsx diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index afb01de7..cb925c48 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -176,7 +176,7 @@ export function AppShell() {
{activeSessionId ? ( <> - + ) : ( @@ -188,20 +188,6 @@ export function AppShell() { {/* Sticky composer: the box stays pinned while the conversation scrolls above. */}
- {activeSessionId && ( -
- -
- )}
- - - - ); -} - function EmptyState() { return (
diff --git a/frontend/src/shell/CentralStatus.test.tsx b/frontend/src/shell/CentralStatus.test.tsx index 61c5e151..23a11e10 100644 --- a/frontend/src/shell/CentralStatus.test.tsx +++ b/frontend/src/shell/CentralStatus.test.tsx @@ -1,10 +1,19 @@ import { render, screen } from "@testing-library/react"; -import { beforeEach } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, vi } 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(); + 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); +}); + test("renders nothing when not working and there is no user entry or step messages", () => { const { container } = render(); expect(container).toBeEmptyDOMElement(); diff --git a/frontend/src/shell/CentralStatus.tsx b/frontend/src/shell/CentralStatus.tsx index 7d24b9b5..c45f8e78 100644 --- a/frontend/src/shell/CentralStatus.tsx +++ b/frontend/src/shell/CentralStatus.tsx @@ -1,5 +1,6 @@ import { useEffect, useReducer, useState } from "react"; import { useSessionStore } from "../store/sessionStore"; +import { WorkingSpinner } from "./WorkingSpinner"; /** Last few words of a string — a compact "what the model is doing now" tail. */ function lastWords(s: string, n = 8): string { @@ -11,7 +12,13 @@ function lastWords(s: string, n = 8): string { * (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 }: { working: boolean }) { +export function CentralStatus({ + working, + onOpenActivity, +}: { + working: boolean; + onOpenActivity?: () => void; +}) { const lastUserEntry = useSessionStore((s) => s.lastUserEntry); const stepMessages = useSessionStore((s) => s.stepMessages); const transcript = useSessionStore((s) => s.transcript); @@ -49,7 +56,15 @@ export function CentralStatus({ working }: { working: boolean }) { {working && (
- + {elapsed}s {tail && · {tail}} ThothII is working diff --git a/frontend/src/shell/ModelActivityPanel.test.tsx b/frontend/src/shell/ModelActivityPanel.test.tsx index 8e0e0b1b..b6b104a4 100644 --- a/frontend/src/shell/ModelActivityPanel.test.tsx +++ b/frontend/src/shell/ModelActivityPanel.test.tsx @@ -18,3 +18,21 @@ test("close button calls onClose", async () => { await userEvent.click(screen.getByRole("button", { name: /close/i })); expect(onClose).toHaveBeenCalled(); }); + +const EIGHT = ["line-01", "line-02", "line-03", "line-04", "line-05", "line-06", "line-07", "line-08"].join("\n"); + +test("collapsed shows only the last 5 lines of the model-stream tail", () => { + useSessionStore.getState().applyEvent({ type: "text_delta", text: EIGHT }); + render(); + expect(screen.getByText("line-08")).toBeInTheDocument(); + expect(screen.getByText("line-04")).toBeInTheDocument(); + expect(screen.queryByText("line-03")).not.toBeInTheDocument(); + expect(screen.queryByText("line-01")).not.toBeInTheDocument(); +}); + +test("expanding reveals the full stream", async () => { + useSessionStore.getState().applyEvent({ type: "text_delta", text: EIGHT }); + render(); + await userEvent.click(screen.getByRole("button", { name: /show more/i })); + expect(screen.getByText("line-01")).toBeInTheDocument(); +}); diff --git a/frontend/src/shell/ModelActivityPanel.tsx b/frontend/src/shell/ModelActivityPanel.tsx index 3052d324..d6ce8ac6 100644 --- a/frontend/src/shell/ModelActivityPanel.tsx +++ b/frontend/src/shell/ModelActivityPanel.tsx @@ -1,20 +1,57 @@ -import { X } from "lucide-react"; -import { Transcript } from "./Transcript"; +import { useState } from "react"; +import { ChevronDown, ChevronUp, X } from "lucide-react"; +import { useSessionStore } from "../store/sessionStore"; import { Button } from "../components/ui/button"; -/** Left drawer showing the active session's streamed model text ("model activity"), - * opened on demand from the work-in-progress icon. */ +const TAIL_LINES = 5; + +/** Left drawer showing "what's happening": the tail of the model's streamed output. + * Collapsed it shows the last few lines (refreshing as the stream grows); the expand + * toggle reveals the full stream. Opened on demand from the rotating activity icon. */ export function ModelActivityPanel({ onClose }: { onClose: () => void }) { + const transcript = useSessionStore((s) => s.transcript); + const [expanded, setExpanded] = useState(false); + + const lines = transcript + .map((e) => e.text) + .join("\n") + .split("\n") + .map((l) => l.trimEnd()) + .filter((l) => l.trim() !== ""); + const shown = expanded ? lines : lines.slice(-TAIL_LINES); + const canExpand = lines.length > TAIL_LINES; + return ( ); diff --git a/frontend/src/shell/Transcript.tsx b/frontend/src/shell/Transcript.tsx deleted file mode 100644 index b1751e8e..00000000 --- a/frontend/src/shell/Transcript.tsx +++ /dev/null @@ -1,28 +0,0 @@ -// frontend/src/shell/Transcript.tsx -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import { useSessionStore } from "../store/sessionStore"; - -/** - * Renders the streamed assistant conversation. The store accumulates assistant - * text deltas in `transcript`; this is the surface that shows them. - */ -export function Transcript() { - const transcript = useSessionStore((s) => s.transcript); - if (transcript.length === 0) return null; - - return ( -
- {transcript.map((entry, i) => ( -
- - ThothII - -
- {entry.text} -
-
- ))} -
- ); -} diff --git a/frontend/src/shell/WorkingSpinner.tsx b/frontend/src/shell/WorkingSpinner.tsx new file mode 100644 index 00000000..d31f57ca --- /dev/null +++ b/frontend/src/shell/WorkingSpinner.tsx @@ -0,0 +1,24 @@ +/** The rotating "assistant is working" icon. Spins while `spinning`; used as the + * inline activity trigger in CentralStatus. */ +export function WorkingSpinner({ + className, + spinning = true, +}: { + className?: string; + spinning?: boolean; +}) { + return ( + + + + + ); +}