From 30e76a968d754f37617718d5b6ffd946f0db787e Mon Sep 17 00:00:00 2001 From: User Date: Wed, 15 Jul 2026 14:55:57 +0200 Subject: [PATCH] fix(frontend): restore readable model activity timeline --- .../src/shell/AppShell.session-mgmt.test.tsx | 14 +-- .../src/shell/ModelActivityPanel.test.tsx | 85 ++++++------------- frontend/src/shell/ModelActivityPanel.tsx | 61 +++---------- 3 files changed, 48 insertions(+), 112 deletions(-) diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx index 3ad419f6..8f18be4c 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -711,11 +711,12 @@ test("closing and reopening Model activity preserves the complete activity log", await userEvent.click(screen.getByRole("button", { name: /model activity/i })); const panel = (await screen.findByRole("heading", { name: /model activity/i })).closest("aside"); expect(panel).not.toBeNull(); + expect(panel).toHaveTextContent("Inspect patient cohort"); + expect(panel).toHaveTextContent("Inspecting cohort"); expect(within(panel!).getByText("cohort").tagName).toBe("STRONG"); - expect(within(panel!).getByText("Cohort status")).toBeInTheDocument(); - expect(within(panel!).queryByText("Inspect patient cohort")).not.toBeInTheDocument(); + expect(within(panel!).getByText("Cohort ready")).toBeInTheDocument(); + expect(within(panel!).queryByText("Cohort status")).not.toBeInTheDocument(); expect(within(panel!).queryByText("bash")).not.toBeInTheDocument(); - expect(within(panel!).queryByText("Cohort ready")).not.toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: /close model activity/i })); expect(screen.queryByRole("heading", { name: /model activity/i })).not.toBeInTheDocument(); @@ -724,11 +725,12 @@ test("closing and reopening Model activity preserves the complete activity log", 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(reopened).toHaveTextContent("Inspect patient cohort"); + expect(reopened).toHaveTextContent("Inspecting cohort"); expect(within(reopened!).getByText("cohort").tagName).toBe("STRONG"); - expect(within(reopened!).getByText("Cohort status")).toBeInTheDocument(); - expect(within(reopened!).queryByText("Inspect patient cohort")).not.toBeInTheDocument(); + expect(within(reopened!).getByText("Cohort ready")).toBeInTheDocument(); + expect(within(reopened!).queryByText("Cohort status")).not.toBeInTheDocument(); expect(within(reopened!).queryByText("bash")).not.toBeInTheDocument(); - expect(within(reopened!).queryByText("Cohort ready")).not.toBeInTheDocument(); expect(useSessionStore.getState().activityLog).toEqual(beforeClose); }); diff --git a/frontend/src/shell/ModelActivityPanel.test.tsx b/frontend/src/shell/ModelActivityPanel.test.tsx index 8bf9b55c..fb58bb8b 100644 --- a/frontend/src/shell/ModelActivityPanel.test.tsx +++ b/frontend/src/shell/ModelActivityPanel.test.tsx @@ -9,18 +9,17 @@ import { ModelActivityPanel } from "./ModelActivityPanel"; beforeEach(() => useSessionStore.getState().resetSession()); -test("renders only thinking and status from a mixed F1 sequence", () => { +test("renders question, reasoning, and response in chronological order", () => { const store = useSessionStore.getState(); store.setPhase("F1"); - store.setLastUserEntry({ kind: "input", text: "How many patients?" }); - store.applyEvent({ type: "system_event", event: "agent_start" }); + store.setLastUserEntry({ kind: "input", text: "How many **patients**?" }); + store.applyEvent({ type: "info", level: "warning", text: "Retrying schema lookup" }); + store.applyEvent({ type: "activity_delta", text: "Inspecting **schema**." }); + store.applyEvent({ type: "text_delta", text: "I found *42* patients." }); store.applyEvent({ type: "activity_event", - activity: { kind: "tool", toolCallId: "tool-1", toolName: "bash", status: "running" }, + activity: { kind: "tool", toolCallId: "tool-1", toolName: "bash", status: "completed" }, }); - store.applyEvent({ type: "text_delta", text: "Let me run another bash search." }); - store.applyEvent({ type: "activity_delta", text: "Inspecting **schema**." }); - store.applyEvent({ type: "info", level: "warning", text: "Retrying schema lookup" }); store.applyEvent({ type: "ui_request", ui_request: { id: "gate-1", widget: "select", phase: "F1_review", title: "Confirm cohort" }, @@ -28,16 +27,17 @@ test("renders only thinking and status from a mixed F1 sequence", () => { render(); - expect(screen.getAllByRole("article")).toHaveLength(2); - expect(screen.getByText("schema").tagName).toBe("STRONG"); - expect(screen.getByText("Retrying schema lookup")).toBeInTheDocument(); - expect(screen.queryByText("How many patients?")).not.toBeInTheDocument(); + const rows = screen.getAllByRole("article"); + expect(rows).toHaveLength(3); + expect(within(rows[0]).getByText("Question")).toBeInTheDocument(); + expect(within(rows[0]).getByText("patients").tagName).toBe("STRONG"); + expect(within(rows[1]).getByText("Reasoning")).toBeInTheDocument(); + expect(within(rows[1]).getByText("schema").tagName).toBe("STRONG"); + expect(within(rows[2]).getByText("Response")).toBeInTheDocument(); + expect(within(rows[2]).getByText("42").tagName).toBe("EM"); + expect(screen.queryByText("Retrying schema lookup")).not.toBeInTheDocument(); expect(screen.queryByText("Confirm cohort")).not.toBeInTheDocument(); expect(screen.queryByText("bash")).not.toBeInTheDocument(); - expect(screen.queryByText("Let me run another bash search.")).not.toBeInTheDocument(); - expect(useSessionStore.getState().transcript).toEqual([ - { role: "assistant", text: "Let me run another bash search." }, - ]); }); test("uses an explicit default-deny activity-kind allowlist", () => { @@ -47,10 +47,10 @@ test("uses an explicit default-deny activity-kind allowlist", () => { expect(predicate).toBeTypeOf("function"); if (!predicate) return; - for (const kind of ["thinking", "status"] satisfies ActivityKind[]) { + for (const kind of ["prompt", "thinking", "assistant"] satisfies ActivityKind[]) { expect(predicate({ kind, phase: "F1", text: kind })).toBe(true); } - for (const kind of ["prompt", "gate", "assistant", "tool", "lifecycle"] satisfies ActivityKind[]) { + for (const kind of ["status", "gate", "tool", "lifecycle"] satisfies ActivityKind[]) { expect(predicate({ kind, phase: "F1", text: kind })).toBe(false); } expect(predicate({ kind: "future-kind" as ActivityKind, phase: "F1", text: "future" })).toBe(false); @@ -59,9 +59,8 @@ test("uses an explicit default-deny activity-kind allowlist", () => { test("shows the empty state when the raw log contains only hidden entries", () => { useSessionStore.setState({ activityLog: [ - { kind: "prompt", phase: "F1", text: "How many patients?" }, + { kind: "status", phase: "F1", text: "Status" }, { kind: "gate", phase: "F1", text: "Confirm cohort" }, - { kind: "assistant", phase: "F1", text: "Let me try another command." }, { kind: "tool", phase: "F1", text: "bash", toolCallId: "tool-1", status: "completed" }, { kind: "lifecycle", phase: "F1", text: "Turn end" }, ], @@ -75,7 +74,7 @@ test("shows the empty state when the raw log contains only hidden entries", () = test("keeps all timeline entries visible without an expansion control", () => { const activityLog: ActivityEntry[] = Array.from({ length: 8 }, (_, index) => ({ - kind: "status", + kind: "thinking", phase: `F${index + 1}`, text: `timeline-${index + 1}`, })); @@ -87,39 +86,10 @@ test("keeps all timeline entries visible without an expansion control", () => { expect(screen.queryByRole("button", { name: /show (more|less)/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 thinking markdown without assistant narration", () => { - 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.queryByText("current")).not.toBeInTheDocument(); - expect(screen.getAllByRole("article")).toHaveLength(1); -}); - test("close calls onClose without mutating the activity log", async () => { const onClose = vi.fn(); const activityLog: ActivityEntry[] = [ - { kind: "status", phase: "F1", text: "Keep this row" }, + { kind: "thinking", phase: "F1", text: "Keep this row" }, ]; useSessionStore.setState({ activityLog }); render(); @@ -148,7 +118,7 @@ function setScrollGeometry( }); } -test("follows appended visible activity while near the bottom", () => { +test("follows appended visible response while near the bottom", () => { useSessionStore.setState({ activityLog: [{ kind: "thinking", phase: "F1", text: "First" }], }); @@ -156,10 +126,8 @@ test("follows appended visible activity while near the bottom", () => { const viewport = screen.getByTestId("activity-scroll"); setScrollGeometry(viewport, { scrollHeight: 400, clientHeight: 100, scrollTop: 300 }); - act(() => useSessionStore.getState().applyEvent({ type: "info", text: "Second" })); - + act(() => useSessionStore.getState().applyEvent({ type: "text_delta", text: "Second" })); expect(viewport.scrollTop).toBe(400); - expect(viewport).toHaveAttribute("tabindex", "0"); }); test("preserves manual scroll position when visible activity arrives", () => { @@ -171,20 +139,19 @@ test("preserves manual scroll position when visible activity arrives", () => { setScrollGeometry(viewport, { scrollHeight: 400, clientHeight: 100, scrollTop: 100 }); fireEvent.scroll(viewport); - act(() => useSessionStore.getState().applyEvent({ type: "info", text: "Second" })); + act(() => useSessionStore.getState().applyEvent({ type: "text_delta", text: "Second" })); expect(viewport.scrollTop).toBe(100); }); -test("does not bottom-follow when only a hidden prompt arrives", () => { +test("does not bottom-follow when only a hidden status arrives", () => { useSessionStore.setState({ - activityLog: [{ kind: "status", phase: "F1", text: "Visible status" }], + activityLog: [{ kind: "thinking", phase: "F1", text: "Visible reasoning" }], }); render(); const viewport = screen.getByTestId("activity-scroll"); setScrollGeometry(viewport, { scrollHeight: 400, clientHeight: 100, scrollTop: 300 }); - act(() => useSessionStore.getState().setLastUserEntry({ kind: "input", text: "Hidden prompt" })); - + act(() => useSessionStore.getState().applyEvent({ type: "info", text: "Hidden status" })); expect(viewport.scrollTop).toBe(300); }); diff --git a/frontend/src/shell/ModelActivityPanel.tsx b/frontend/src/shell/ModelActivityPanel.tsx index 1d4ccf0c..2e8609b6 100644 --- a/frontend/src/shell/ModelActivityPanel.tsx +++ b/frontend/src/shell/ModelActivityPanel.tsx @@ -8,10 +8,17 @@ import { Button } from "../components/ui/button"; import { isNearBottom } from "./activityScroll"; const VISIBLE_MODEL_ACTIVITY_KINDS: ReadonlySet = new Set([ + "prompt", "thinking", - "status", + "assistant", ]); +const ACTIVITY_LABELS: Readonly>> = { + prompt: "Question", + thinking: "Reasoning", + assistant: "Response", +}; + export function isVisibleModelActivity(entry: ActivityEntry): boolean { return VISIBLE_MODEL_ACTIVITY_KINDS.has(entry.kind); } @@ -36,54 +43,14 @@ function MarkdownBody({ entry }: { entry: ActivityEntry }) { ); } -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} - - )} +
+
+ {ACTIVITY_LABELS[entry.kind]} + {entry.phase && {entry.phase}}
- +
); } @@ -104,7 +71,7 @@ export function ModelActivityPanel({ onClose }: { onClose: () => void }) { return (