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 <p> 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 15:53:31 +02:00
co-authored by Claude Sonnet 5
parent 7c417d4cf1
commit 023c69d752
6 changed files with 86 additions and 48 deletions
@@ -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 }));
+28 -18
View File
@@ -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 (
<div className="flex flex-col gap-3">
@@ -55,19 +59,25 @@ export function CentralStatus({
)}
{working && (
<div className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
<button
type="button"
onClick={onOpenActivity}
aria-label="Show model activity"
title="Model activity"
className="grid size-5 shrink-0 place-items-center rounded text-primary transition-colors hover:bg-muted"
>
<WorkingSpinner spinning className="size-3.5" />
</button>
<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 className="flex flex-col gap-1">
<div className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
<button
type="button"
onClick={onOpenActivity}
aria-label="Show model activity"
title="Model activity"
className="grid size-5 shrink-0 place-items-center rounded text-primary transition-colors hover:bg-muted"
>
<WorkingSpinner spinning className="size-3.5" />
</button>
<span className="shrink-0 tabular-nums">{elapsed}s</span>
<span className="sr-only">ThothII is working</span>
</div>
{tail && (
<p className="line-clamp-5 whitespace-pre-wrap break-words pl-7 text-sm text-muted-foreground/70">
{tail}
</p>
)}
</div>
)}
+25 -7
View File
@@ -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(<ModelActivityPanel onClose={vi.fn()} />);
expect(screen.getByText("fibrillazione atriale").tagName).toBe("STRONG");
});
test("close button calls onClose", async () => {
const onClose = vi.fn();
render(<ModelActivityPanel onClose={onClose} />);
@@ -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(<ModelActivityPanel onClose={vi.fn()} />);
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(<ModelActivityPanel onClose={vi.fn()} />);
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(<ModelActivityPanel onClose={vi.fn()} />);
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);
});
+21 -15
View File
@@ -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 (
<aside className="flex w-[380px] shrink-0 flex-col border-r border-border bg-sidebar">
@@ -46,10 +54,8 @@ export function ModelActivityPanel({ onClose }: { onClose: () => void }) {
{shown.length === 0 ? (
<p className="text-sm text-muted-foreground">No activity yet.</p>
) : (
<div className="flex flex-col gap-1 font-mono text-xs leading-relaxed text-foreground/90">
{shown.map((l, i) => (
<p key={i} className="whitespace-pre-wrap break-words">{l}</p>
))}
<div className="prose prose-sm dark:prose-invert max-w-none text-foreground/90">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{source}</ReactMarkdown>
</div>
)}
</div>
+1 -1
View File
@@ -32,7 +32,7 @@ export function WorkflowBar() {
return (
<nav
aria-label="Workflow progress"
className="flex items-center gap-0 overflow-x-auto py-0.5"
className="flex items-center justify-center gap-0 overflow-x-auto py-0.5"
>
{PHASES.map((p, i) => {
const isActive = currentPhase === p.id;
+8 -5
View File
@@ -172,10 +172,13 @@ function phaseMeta(ctx) {
_phaseMetaCache = meta;
return meta;
}
function phaseName(ctx, num) {
// Returns the workflow.yaml phase id (e.g. "F1"), not the descriptive name — this is
// what feeds the widget's `phase` field, which the frontend WorkflowBar matches against
// its own "F1".."F8" ids to light up the stepper pills.
function phaseId(ctx, num) {
const meta = phaseMeta(ctx);
const p = meta.phases.find((x) => x.num === num);
return p ? p.name : "?";
return p ? p.id : "?";
}
function currentPhase(ctx, session) {
const out = tht(ctx, ["phase", "show", "--session", session]);
@@ -433,7 +436,7 @@ export default function (pi) {
async execute(_id, params, _signal, _onUpdate, ctx) {
lockActive = true;
const { session, title, options: opts, intro, advance } = params;
const phase = phaseName(ctx, currentPhase(ctx, session));
const phase = phaseId(ctx, currentPhase(ctx, session));
const recommended = opts.find((o) => o.recommended)?.id ?? null;
const widget = buildSelectRequest({
@@ -505,7 +508,7 @@ export default function (pi) {
async execute(_id, params, _signal, _onUpdate, ctx) {
lockActive = true;
const { session, title, options: opts, advance } = params;
const phase = phaseName(ctx, currentPhase(ctx, session));
const phase = phaseId(ctx, currentPhase(ctx, session));
const toAdd = [];
const widget = buildMultiselectRequest({
id: `u${Date.now()}`,
@@ -571,7 +574,7 @@ export default function (pi) {
async execute(_id, params, _signal, _onUpdate, ctx) {
lockActive = true;
const { session, kind, title, artifact } = params;
const phase = phaseName(ctx, currentPhase(ctx, session));
const phase = phaseId(ctx, currentPhase(ctx, session));
const widget = buildArtifactGate({
id: `u${Date.now()}`,
phase,