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:
@@ -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 }));
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user