From 4f60b38ca2f197df78e6fc095df568d37c906483 Mon Sep 17 00:00:00 2001 From: mptyl Date: Mon, 29 Jun 2026 22:15:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20central=20'working'=20indicat?= =?UTF-8?q?or=20=E2=80=94=20elapsed=20time=20+=20model-stream=20tail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While the model works (active session, no pending widget), CentralStatus shows a Claude-Code-style live status: a pulsing dot, elapsed seconds (ticking), and a short tail of the model's current output — so the centre no longer looks stuck during the long F1 loop. Verbose stream stays in the left panel. No tokens yet (would need the SessionBridge to forward Pi usage). Co-Authored-By: Claude Opus 4.8 --- .../src/shell/AppShell.session-mgmt.test.tsx | 12 +++-- frontend/src/shell/AppShell.tsx | 2 +- frontend/src/shell/CentralStatus.test.tsx | 21 +++++++-- frontend/src/shell/CentralStatus.tsx | 47 +++++++++++++++++-- 4 files changed, 70 insertions(+), 12 deletions(-) diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx index 224fb0b7..a8a18185 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -86,11 +86,15 @@ test("WIP icon toggles the Model activity panel and shows the streamed text", as server.use(http.post("http://localhost:8787/sessions/:id/resume", () => new HttpResponse(null, { status: 204 }))); await userEvent.click(await screen.findByText("Attiva uno")); // opens docs panel await userEvent.click(await screen.findByRole("button", { name: /resume/i })); // active session - act(() => { useSessionStore.getState().applyEvent({ type: "text_delta", text: "Looking at dim_patient." }); }); - // Model activity hidden until the WIP icon is clicked - expect(screen.queryByText(/Looking at dim_patient/)).not.toBeInTheDocument(); + act(() => { useSessionStore.getState().applyEvent({ type: "text_delta", text: "FIRSTLINE alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo" }); }); + // The verbose stream stays in the (closed) left panel: its START is not shown centrally + // (the central working indicator shows only a short tail of last words, by design). + expect(screen.queryByRole("heading", { name: /model activity/i })).not.toBeInTheDocument(); + expect(screen.queryByText(/FIRSTLINE/)).not.toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: /model activity/i })); - expect(await screen.findByText(/Looking at dim_patient/)).toBeInTheDocument(); + // Panel open: the full streamed text is visible inside it. + expect(await screen.findByRole("heading", { name: /model activity/i })).toBeInTheDocument(); + expect(screen.getByText(/FIRSTLINE/)).toBeInTheDocument(); }); test("renaming a group reassigns its members via setSessionGroup", async () => { diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index eadbf652..afb01de7 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -176,7 +176,7 @@ export function AppShell() {
{activeSessionId ? ( <> - + ) : ( diff --git a/frontend/src/shell/CentralStatus.test.tsx b/frontend/src/shell/CentralStatus.test.tsx index b72f8dfe..61c5e151 100644 --- a/frontend/src/shell/CentralStatus.test.tsx +++ b/frontend/src/shell/CentralStatus.test.tsx @@ -5,8 +5,8 @@ import { CentralStatus } from "./CentralStatus"; beforeEach(() => useSessionStore.getState().resetSession()); -test("renders nothing when there is no user entry and no step messages", () => { - const { container } = render(); +test("renders nothing when not working and there is no user entry or step messages", () => { + const { container } = render(); expect(container).toBeEmptyDOMElement(); }); @@ -14,7 +14,22 @@ test("echoes the last user entry and the step messages", () => { const st = useSessionStore.getState(); st.setLastUserEntry({ kind: "input", text: "how many patients?" }); st.applyEvent({ type: "info", level: "info", text: "Searching the schema…" }); - render(); + render(); expect(screen.getByText("how many patients?")).toBeInTheDocument(); expect(screen.getByText("Searching the schema…")).toBeInTheDocument(); }); + +test("shows a working indicator with elapsed time and the model-stream tail while working", () => { + useSessionStore.getState().applyEvent({ type: "text_delta", text: "Reading the schema for dim_patient" }); + render(); + // elapsed time (e.g. "0s") + expect(screen.getByText(/\d+s/)).toBeInTheDocument(); + // a short tail of the model's current output + expect(screen.getByText(/Reading the schema for dim_patient/)).toBeInTheDocument(); +}); + +test("no working indicator when not working, even with a transcript", () => { + useSessionStore.getState().applyEvent({ type: "text_delta", text: "background narration" }); + render(); + expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument(); +}); diff --git a/frontend/src/shell/CentralStatus.tsx b/frontend/src/shell/CentralStatus.tsx index ffc7dcc4..7d24b9b5 100644 --- a/frontend/src/shell/CentralStatus.tsx +++ b/frontend/src/shell/CentralStatus.tsx @@ -1,11 +1,40 @@ +import { useEffect, useReducer, useState } from "react"; import { useSessionStore } from "../store/sessionStore"; -/** The minimal central view: the user's last input/choice + the gate's curated - * messages for the current step. The verbose model stream lives in the left panel. */ -export function CentralStatus() { +/** Last few words of a string — a compact "what the model is doing now" tail. */ +function lastWords(s: string, n = 8): string { + const w = s.trim().split(/\s+/).filter(Boolean); + return w.slice(-n).join(" "); +} + +/** 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 }: { working: boolean }) { const lastUserEntry = useSessionStore((s) => s.lastUserEntry); const stepMessages = useSessionStore((s) => s.stepMessages); - if (!lastUserEntry && stepMessages.length === 0) return null; + const transcript = useSessionStore((s) => s.transcript); + + const [startedAt, setStartedAt] = useState(null); + const [, tick] = useReducer((x: number) => x + 1, 0); + + // Start the per-step timer when work begins; tick once a second so the elapsed + // display updates; clear it when work stops. + useEffect(() => { + if (!working) { + setStartedAt(null); + return; + } + setStartedAt((prev) => prev ?? Date.now()); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [working]); + + if (!working && !lastUserEntry && stepMessages.length === 0) return null; + + const elapsed = startedAt ? Math.max(0, Math.floor((Date.now() - startedAt) / 1000)) : 0; + const tail = lastWords(transcript.at(-1)?.text ?? ""); return (
@@ -17,6 +46,16 @@ export function CentralStatus() {

{lastUserEntry.text}

)} + + {working && ( +
+ + {elapsed}s + {tail && · {tail}} + ThothII is working +
+ )} + {stepMessages.map((m, i) => (