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) => (