diff --git a/frontend/src/shell/AppShell.new-session.test.tsx b/frontend/src/shell/AppShell.new-session.test.tsx index 2ec69026..5ad18438 100644 --- a/frontend/src/shell/AppShell.new-session.test.tsx +++ b/frontend/src/shell/AppShell.new-session.test.tsx @@ -63,6 +63,11 @@ test("shows the question and creation timer before POST /sessions resolves, then expect(await screen.findByText("How many patients?", { selector: "p" })).toBeInTheDocument(); expect(screen.getByText("Creating session…")).toBeInTheDocument(); expect(screen.getByText(/\d+s/)).toBeInTheDocument(); + expect(useSessionStore.getState().activityLog[0]).toEqual({ + kind: "prompt", + phase: "F1", + text: "How many patients?", + }); expect(FakeEventSource.instances).toHaveLength(0); releaseCreate(); diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx index efe09953..0d3ced94 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -78,6 +78,11 @@ test("Resume from the panel activates the session and closes the panel", async ( await userEvent.click(await screen.findByText("Attiva uno")); await screen.findByText("Domanda originale"); // panel open await userEvent.click(screen.getByRole("button", { name: /resume/i })); + expect(useSessionStore.getState().activityLog).toContainEqual({ + kind: "lifecycle", + phase: null, + text: "Resuming session", + }); await waitFor(() => expect(resumed).toBe("s1")); await waitFor(() => expect(screen.queryByText("Domanda originale")).not.toBeInTheDocument()); // panel closed }); @@ -206,23 +211,45 @@ test("a failed resume keeps the panel open and does not activate the session", a await waitFor(() => expect(screen.getByText("Domanda originale")).toBeInTheDocument()); }); -test("WIP icon toggles the Model activity panel and shows streamed reasoning", async () => { +test("closing and reopening Model activity preserves the complete activity log", async () => { wrap(); - // Activate a session by opening + resuming is heavy; instead drive the store directly. - // The WIP icon only renders with an active session, so simulate one via Resume. 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 - const SIX_LINES = ["FIRSTLINE step one", "step two", "step three", "step four", "step five", "step six"].join("\n"); - act(() => { useSessionStore.getState().applyEvent({ type: "activity_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 the last 5 lines of tail, by design). + await userEvent.click(await screen.findByText("Attiva uno")); + await userEvent.click(await screen.findByRole("button", { name: /resume/i })); + act(() => { + const store = useSessionStore.getState(); + store.setPhase("F4"); + store.setLastUserEntry({ kind: "input", text: "Inspect patient cohort" }); + store.applyEvent({ + type: "activity_event", + activity: { kind: "tool", toolCallId: "tool-1", toolName: "bash", status: "running" }, + }); + store.applyEvent({ + type: "activity_event", + activity: { kind: "tool", toolCallId: "tool-1", toolName: "bash", status: "completed" }, + }); + store.applyEvent({ type: "text_delta", text: "Cohort ready" }); + }); + const beforeClose = useSessionStore.getState().activityLog; + 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 })); - // 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(); + const panel = (await screen.findByRole("heading", { name: /model activity/i })).closest("aside"); + expect(panel).not.toBeNull(); + expect(within(panel!).getByText("Inspect patient cohort")).toBeInTheDocument(); + expect(within(panel!).getByText("bash")).toBeInTheDocument(); + expect(within(panel!).getByText("Cohort ready")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: /close model activity/i })); + expect(screen.queryByRole("heading", { name: /model activity/i })).not.toBeInTheDocument(); + expect(useSessionStore.getState().activityLog).toEqual(beforeClose); + + await userEvent.click(screen.getByRole("button", { name: /show model activity/i })); + const reopened = (await screen.findByRole("heading", { name: /model activity/i })).closest("aside"); + expect(reopened).not.toBeNull(); + expect(within(reopened!).getByText("Inspect patient cohort")).toBeInTheDocument(); + expect(within(reopened!).getByText("bash")).toBeInTheDocument(); + expect(within(reopened!).getByText("Cohort ready")).toBeInTheDocument(); }); test("session finalization shows the completion banner and returns to landing", async () => { diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index ec6dcc7f..9f77a0c4 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -107,6 +107,7 @@ export function AppShell() { // working spinner shows straight away; the backend calls run after. setPanelSession(null); resetSession(); + recordLifecycle("Resuming session"); setAwaitingQuestion(false); // Optimistic: the resume POST is about to hand the ball to the harness. setAgentActive(true); @@ -211,6 +212,7 @@ export function AppShell() { // leave the spinner on forever. const pendingWidget = useSessionStore((s) => s.pendingWidget); const resetSession = useSessionStore((s) => s.resetSession); + const recordLifecycle = useSessionStore((s) => s.recordLifecycle); const setPhase = useSessionStore((s) => s.setPhase); const setAgentActive = useSessionStore((s) => s.setAgentActive); const lastSystemEvent = useSessionStore((s) => s.lastSystemEvent); diff --git a/frontend/src/shell/ModelActivityPanel.test.tsx b/frontend/src/shell/ModelActivityPanel.test.tsx index 637cd266..79f16063 100644 --- a/frontend/src/shell/ModelActivityPanel.test.tsx +++ b/frontend/src/shell/ModelActivityPanel.test.tsx @@ -1,80 +1,162 @@ -import { render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, vi } from "vitest"; +import type { ActivityEntry } from "../api/types"; import { useSessionStore } from "../store/sessionStore"; -import { ModelActivityPanel } from "./ModelActivityPanel"; +import { isNearBottom, ModelActivityPanel } from "./ModelActivityPanel"; beforeEach(() => useSessionStore.getState().resetSession()); -test("renders the streamed model activity", () => { - useSessionStore.getState().applyEvent({ type: "activity_delta", text: "Promoting table dim_patient." }); - render(); - expect(screen.getByText(/Promoting table dim_patient/)).toBeInTheDocument(); -}); +test("renders every row in a non-reasoning activity sequence", () => { + const store = useSessionStore.getState(); + store.setPhase("F1"); + store.setLastUserEntry({ kind: "input", text: "How many patients?" }); + store.applyEvent({ type: "system_event", event: "agent_start" }); + store.applyEvent({ + type: "activity_event", + activity: { kind: "tool", toolCallId: "tool-1", toolName: "bash", status: "running" }, + }); + store.applyEvent({ + type: "activity_event", + activity: { kind: "tool", toolCallId: "tool-1", toolName: "bash", status: "completed" }, + }); + store.applyEvent({ type: "text_delta", text: "Found **42 patients**." }); + store.applyEvent({ + type: "ui_request", + ui_request: { id: "gate-1", widget: "select", phase: "F1_review", title: "Confirm cohort" }, + }); -test("does not treat final assistant text as model reasoning", () => { - useSessionStore.getState().applyEvent({ type: "text_delta", text: "Final answer" }); render(); - expect(screen.queryByText("Final answer")).not.toBeInTheDocument(); - expect(screen.getByText("No activity yet.")).toBeInTheDocument(); + expect(screen.getAllByRole("article")).toHaveLength(5); + expect(screen.getByText("How many patients?")).toBeInTheDocument(); + expect(screen.getByText("Agent started")).toBeInTheDocument(); + expect(screen.getByText("bash")).toBeInTheDocument(); + expect(screen.getByText("42 patients").tagName).toBe("STRONG"); + expect(screen.getByText("Confirm cohort")).toBeInTheDocument(); + expect(screen.getAllByText("F1")).toHaveLength(5); }); -test("renders markdown emphasis instead of literal asterisks", () => { - useSessionStore.getState().applyEvent({ type: "activity_delta", text: "Chiarimento su **fibrillazione atriale**." }); +test("keeps all timeline entries visible without an expansion control", () => { + const activityLog: ActivityEntry[] = Array.from({ length: 8 }, (_, index) => ({ + kind: "lifecycle", + phase: `F${index + 1}`, + text: `timeline-${index + 1}`, + })); + useSessionStore.setState({ activityLog }); + render(); - expect(screen.getByText("fibrillazione atriale").tagName).toBe("STRONG"); + + for (const entry of activityLog) expect(screen.getByText(entry.text)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /show (more|less)/i })).not.toBeInTheDocument(); }); -test("close button calls onClose", async () => { +test("shows only the tool name and completion status", () => { + useSessionStore.setState({ + activityLog: [ + { + kind: "tool", + phase: "F4", + text: "bash", + toolCallId: "tool-1", + status: "completed", + }, + ], + }); + + render(); + + const row = screen.getByText("bash").closest("article"); + expect(row).not.toBeNull(); + expect(within(row!).getByText("completed")).toBeInTheDocument(); + expect(within(row!).queryByRole("button")).not.toBeInTheDocument(); + expect(screen.queryByText(/raw details/i)).not.toBeInTheDocument(); +}); + +test("labels a warning in text instead of relying on its semantic color", () => { + useSessionStore.setState({ + activityLog: [ + { kind: "status", phase: "F3", text: "Retrying retrieval", level: "warning" }, + ], + }); + + render(); + + const row = screen.getByText("Retrying retrieval").closest("article"); + expect(row).not.toBeNull(); + expect(within(row!).getByText("warning")).toBeInTheDocument(); +}); + +test("renders assistant and thinking markdown as separate entries", () => { + useSessionStore.setState({ + activityLog: [ + { kind: "thinking", phase: "F2", text: "Considering **history**." }, + { kind: "assistant", phase: "F2", text: "Use *current* records." }, + ], + }); + + render(); + + expect(screen.getByText("history").tagName).toBe("STRONG"); + expect(screen.getByText("current").tagName).toBe("EM"); + expect(screen.getAllByRole("article")).toHaveLength(2); +}); + +test("close calls onClose without mutating the activity log", async () => { const onClose = vi.fn(); + const activityLog: ActivityEntry[] = [ + { kind: "prompt", phase: "F1", text: "Keep this row" }, + ]; + useSessionStore.setState({ activityLog }); render(); - await userEvent.click(screen.getByRole("button", { name: /close/i })); - expect(onClose).toHaveBeenCalled(); + + await userEvent.click(screen.getByRole("button", { name: /close model activity/i })); + + expect(onClose).toHaveBeenCalledOnce(); + expect(useSessionStore.getState().activityLog).toEqual(activityLog); }); -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 paragraphs of the model-stream tail", () => { - useSessionStore.getState().applyEvent({ type: "activity_delta", text: EIGHT }); - render(); - 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("isNearBottom includes the 48px boundary", () => { + expect(isNearBottom({ scrollHeight: 500, clientHeight: 100, scrollTop: 352 })).toBe(true); + expect(isNearBottom({ scrollHeight: 500, clientHeight: 100, scrollTop: 351 })).toBe(false); + expect(isNearBottom({ scrollHeight: 500, clientHeight: 100, scrollTop: 400 })).toBe(true); }); -test("expanding reveals the full stream", async () => { - useSessionStore.getState().applyEvent({ type: "activity_delta", text: EIGHT }); - render(); - await userEvent.click(screen.getByRole("button", { name: /show more/i })); - expect(screen.getByText("para-01")).toBeInTheDocument(); -}); +function setScrollGeometry( + element: HTMLElement, + values: { scrollHeight: number; clientHeight: number; scrollTop: number }, +) { + Object.defineProperties(element, { + scrollHeight: { configurable: true, value: values.scrollHeight }, + clientHeight: { configurable: true, value: values.clientHeight }, + scrollTop: { configurable: true, writable: true, value: values.scrollTop }, + }); +} -test("paragraphs stay separated as distinct blocks", () => { - useSessionStore - .getState() - .applyEvent({ type: "activity_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); -}); - - -test("separates activity updates concatenated after sentence punctuation", () => { - useSessionStore.getState().applyEvent({ - type: "activity_delta", - text: "Ambiguità principale risolta.Finestra temporale risolta.Terza ambiguità risolta.", +test("follows appended activity while the viewport is near the bottom", () => { + useSessionStore.setState({ + activityLog: [{ kind: "lifecycle", phase: "F1", text: "First" }], }); render(); + const viewport = screen.getByTestId("activity-scroll"); + setScrollGeometry(viewport, { scrollHeight: 400, clientHeight: 100, scrollTop: 300 }); - const first = screen.getByText("Ambiguità principale risolta."); - const second = screen.getByText("Finestra temporale risolta."); - const third = screen.getByText("Terza ambiguità risolta."); - expect(first.tagName).toBe("P"); - expect(second.tagName).toBe("P"); - expect(third.tagName).toBe("P"); + act(() => useSessionStore.getState().recordLifecycle("Second")); + + expect(viewport.scrollTop).toBe(400); + expect(viewport).toHaveAttribute("tabindex", "0"); +}); + +test("preserves manual scroll position when appended activity arrives", () => { + useSessionStore.setState({ + activityLog: [{ kind: "lifecycle", phase: "F1", text: "First" }], + }); + render(); + const viewport = screen.getByTestId("activity-scroll"); + setScrollGeometry(viewport, { scrollHeight: 400, clientHeight: 100, scrollTop: 100 }); + fireEvent.scroll(viewport); + + act(() => useSessionStore.getState().recordLifecycle("Second")); + + expect(viewport.scrollTop).toBe(100); }); diff --git a/frontend/src/shell/ModelActivityPanel.tsx b/frontend/src/shell/ModelActivityPanel.tsx index b9b60afc..14c75dc2 100644 --- a/frontend/src/shell/ModelActivityPanel.tsx +++ b/frontend/src/shell/ModelActivityPanel.tsx @@ -1,11 +1,17 @@ -import { useState } from "react"; -import { ChevronDown, ChevronUp, X } from "lucide-react"; +import { useLayoutEffect, useRef } from "react"; +import { X } from "lucide-react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import type { ActivityEntry } from "../api/types"; import { useSessionStore } from "../store/sessionStore"; import { Button } from "../components/ui/button"; -const TAIL_PARAGRAPHS = 5; +export function isNearBottom( + el: Pick, + threshold = 48, +): boolean { + return el.scrollHeight - el.clientHeight - el.scrollTop <= threshold; +} export function formatModelActivity(text: string): string { return text @@ -18,57 +24,102 @@ export function formatModelActivity(text: string): string { .trim(); } -/** 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. */ +function MarkdownBody({ entry }: { entry: ActivityEntry }) { + const tone = entry.kind === "thinking" ? "text-muted-foreground" : "text-foreground"; + return ( +
+ {formatModelActivity(entry.text)} +
+ ); +} + +function ActivityBody({ entry }: { entry: ActivityEntry }) { + if (entry.kind === "assistant" || entry.kind === "thinking") { + return ; + } + + if (entry.kind === "status") { + const tone = + entry.level === "error" + ? "text-destructive" + : entry.level === "warning" + ? "text-amber-600" + : "text-foreground"; + return

{entry.text}

; + } + + if (entry.kind === "lifecycle") { + return

{entry.text}

; + } + + return

{entry.text}

; +} + +function ActivityRow({ entry }: { entry: ActivityEntry }) { + return ( +
+
+ {entry.phase && {entry.phase}} + {entry.kind} + {entry.status && ( + + {entry.status} + + )} + {entry.level && ( + + {entry.level} + + )} +
+ +
+ ); +} + +/** Left drawer showing the complete in-memory model activity timeline. */ export function ModelActivityPanel({ onClose }: { onClose: () => void }) { const activityLog = useSessionStore((s) => s.activityLog); - const activity = activityLog.filter((entry) => entry.kind === "thinking"); - const [expanded, setExpanded] = useState(false); + const scrollRef = useRef(null); + const followRef = useRef(true); - // 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 = activity - .map((e) => e.text) - .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 = formatModelActivity(shown.join("\n\n")); + useLayoutEffect(() => { + const viewport = scrollRef.current; + if (viewport && followRef.current) viewport.scrollTop = viewport.scrollHeight; + }, [activityLog]); return (