From 023c69d75262b3f5faaf1ad0c048db6546819774 Mon Sep 17 00:00:00 2001 From: mptyl Date: Thu, 2 Jul 2026 15:53:31 +0200 Subject: [PATCH] fix(frontend,harness): phase-stepper wiring, central tail, activity-panel markdown - WorkflowBar: center the phase pills (add justify-center). - tht-gate.js: the gate was sending the descriptive phase name (e.g. "chiarimento") instead of the workflow.yaml id ("F1") in the widget's `phase` field, so the frontend's F1..F8 match never hit and pills never lit up. Rename phaseName -> phaseId, return p.id. - CentralStatus: expand the live "working" tail from a single truncated line to up to 5 lines (line-clamp-5 safety net for unbroken paragraphs). - ModelActivityPanel: render the streamed tail through react-markdown + remark-gfm instead of raw per-line

tags, so emphasis/headings render and blank-line paragraph breaks (previously stripped) are preserved; tail-cut now operates on paragraphs instead of physical lines. Updated tests accordingly (WorkflowBar/CentralStatus/ModelActivityPanel/ AppShell.session-mgmt); full frontend suite (98/98) + harness gate node tests (34/34) + tsc -b pass. Co-Authored-By: Claude Sonnet 5 --- .../src/shell/AppShell.session-mgmt.test.tsx | 5 +- frontend/src/shell/CentralStatus.tsx | 46 +++++++++++-------- .../src/shell/ModelActivityPanel.test.tsx | 32 ++++++++++--- frontend/src/shell/ModelActivityPanel.tsx | 36 +++++++++------ frontend/src/shell/WorkflowBar.tsx | 2 +- harness/.pi/extensions/tht-gate.js | 13 ++++-- 6 files changed, 86 insertions(+), 48 deletions(-) diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx index bc47645a..a8384491 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -99,9 +99,10 @@ 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: "FIRSTLINE alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo" }); }); + const SIX_LINES = ["FIRSTLINE step one", "step two", "step three", "step four", "step five", "step six"].join("\n"); + act(() => { useSessionStore.getState().applyEvent({ type: "text_delta", text: SIX_LINES }); }); // 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). + // (the central working indicator shows only the last 5 lines of tail, 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 })); diff --git a/frontend/src/shell/CentralStatus.tsx b/frontend/src/shell/CentralStatus.tsx index c45f8e78..a7e3318b 100644 --- a/frontend/src/shell/CentralStatus.tsx +++ b/frontend/src/shell/CentralStatus.tsx @@ -2,10 +2,14 @@ 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 { - const w = s.trim().split(/\s+/).filter(Boolean); - return w.slice(-n).join(" "); +/** 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 + .split("\n") + .map((l) => l.trimEnd()) + .filter((l) => l.trim() !== ""); + return lines.slice(-n).join("\n"); } /** The minimal central view: the user's last input/choice, a live "working" indicator @@ -41,7 +45,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 = lastWords(transcript.at(-1)?.text ?? ""); + const tail = tailLines(transcript.at(-1)?.text ?? ""); return (

@@ -55,19 +59,25 @@ export function CentralStatus({ )} {working && ( -
- - {elapsed}s - {tail && · {tail}} - ThothII is working +
+
+ + {elapsed}s + ThothII is working +
+ {tail && ( +

+ {tail} +

+ )}
)} diff --git a/frontend/src/shell/ModelActivityPanel.test.tsx b/frontend/src/shell/ModelActivityPanel.test.tsx index b6b104a4..0c1073be 100644 --- a/frontend/src/shell/ModelActivityPanel.test.tsx +++ b/frontend/src/shell/ModelActivityPanel.test.tsx @@ -12,6 +12,12 @@ test("renders the streamed model transcript", () => { expect(screen.getByText(/Promoting table dim_patient/)).toBeInTheDocument(); }); +test("renders markdown emphasis instead of literal asterisks", () => { + useSessionStore.getState().applyEvent({ type: "text_delta", text: "Chiarimento su **fibrillazione atriale**." }); + render(); + expect(screen.getByText("fibrillazione atriale").tagName).toBe("STRONG"); +}); + test("close button calls onClose", async () => { const onClose = vi.fn(); render(); @@ -19,20 +25,32 @@ test("close button calls onClose", async () => { expect(onClose).toHaveBeenCalled(); }); -const EIGHT = ["line-01", "line-02", "line-03", "line-04", "line-05", "line-06", "line-07", "line-08"].join("\n"); +const EIGHT = ["para-01", "para-02", "para-03", "para-04", "para-05", "para-06", "para-07", "para-08"].join("\n\n"); -test("collapsed shows only the last 5 lines of the model-stream tail", () => { +test("collapsed shows only the last 5 paragraphs 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(); + expect(screen.getByText("para-08")).toBeInTheDocument(); + expect(screen.getByText("para-04")).toBeInTheDocument(); + expect(screen.queryByText("para-03")).not.toBeInTheDocument(); + expect(screen.queryByText("para-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(); + expect(screen.getByText("para-01")).toBeInTheDocument(); +}); + +test("paragraphs stay separated as distinct blocks", () => { + useSessionStore + .getState() + .applyEvent({ type: "text_delta", text: "Primo paragrafo.\n\nSecondo paragrafo." }); + render(); + const first = screen.getByText("Primo paragrafo."); + const second = screen.getByText("Secondo paragrafo."); + expect(first.tagName).toBe("P"); + expect(second.tagName).toBe("P"); + expect(first).not.toBe(second); }); diff --git a/frontend/src/shell/ModelActivityPanel.tsx b/frontend/src/shell/ModelActivityPanel.tsx index d6ce8ac6..197624bd 100644 --- a/frontend/src/shell/ModelActivityPanel.tsx +++ b/frontend/src/shell/ModelActivityPanel.tsx @@ -1,25 +1,33 @@ import { useState } from "react"; import { ChevronDown, ChevronUp, X } from "lucide-react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; import { useSessionStore } from "../store/sessionStore"; import { Button } from "../components/ui/button"; -const TAIL_LINES = 5; +const TAIL_PARAGRAPHS = 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. */ +/** Left drawer showing "what's happening": the tail of the model's streamed markdown + * output, rendered as markdown (not raw text) so headings/emphasis/lists come through + * and paragraphs stay visually separated. Collapsed it shows the last few paragraphs + * (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 + // Blank lines are markdown's paragraph separator — split on them (not on every "\n", + // which would also break single soft-wrapped lines apart) so each block below is a + // real paragraph and the tail cut lands on paragraph boundaries. + const paragraphs = 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; + .join("\n\n") + .split(/\n{2,}/) + .map((p) => p.trim()) + .filter((p) => p !== ""); + const shown = expanded ? paragraphs : paragraphs.slice(-TAIL_PARAGRAPHS); + const canExpand = paragraphs.length > TAIL_PARAGRAPHS; + const source = shown.join("\n\n"); return (