feat(frontend): central 'working' indicator — elapsed time + model-stream tail
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -176,7 +176,7 @@ export function AppShell() {
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
||||
{activeSessionId ? (
|
||||
<>
|
||||
<CentralStatus />
|
||||
<CentralStatus working={working} />
|
||||
<WidgetHost sessionId={activeSessionId} />
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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(<CentralStatus />);
|
||||
test("renders nothing when not working and there is no user entry or step messages", () => {
|
||||
const { container } = render(<CentralStatus working={false} />);
|
||||
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(<CentralStatus />);
|
||||
render(<CentralStatus working={false} />);
|
||||
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(<CentralStatus working />);
|
||||
// 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(<CentralStatus working={false} />);
|
||||
expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -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<number | null>(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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -17,6 +46,16 @@ export function CentralStatus() {
|
||||
<p className="mt-0.5 text-sm text-foreground">{lastUserEntry.text}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{working && (
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="size-1.5 shrink-0 animate-pulse rounded-full bg-primary" aria-hidden />
|
||||
<span className="shrink-0 tabular-nums">{elapsed}s</span>
|
||||
{tail && <span className="min-w-0 truncate text-muted-foreground/70">· {tail}</span>}
|
||||
<span className="sr-only">ThothII is working</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stepMessages.map((m, i) => (
|
||||
<p
|
||||
key={i}
|
||||
|
||||
Reference in New Issue
Block a user