From 72085990ef8465e5378b0b2442310a90b17b4e4e Mon Sep 17 00:00:00 2001 From: User Date: Wed, 15 Jul 2026 03:24:22 +0200 Subject: [PATCH] fix(frontend): hide model activity execution noise --- .../src/shell/AppShell.session-mgmt.test.tsx | 9 +- .../src/shell/ModelActivityPanel.test.tsx | 109 ++++++++++++------ frontend/src/shell/ModelActivityPanel.tsx | 24 +++- 3 files changed, 95 insertions(+), 47 deletions(-) diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx index a12b58e9..8d4dda81 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -714,8 +714,8 @@ test("closing and reopening Model activity preserves the complete activity log", 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(); + 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(); @@ -725,8 +725,9 @@ test("closing and reopening Model activity preserves the complete activity log", 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(); + expect(within(reopened!).queryByText("bash")).not.toBeInTheDocument(); + expect(within(reopened!).queryByText("Cohort ready")).not.toBeInTheDocument(); + expect(useSessionStore.getState().activityLog).toEqual(beforeClose); }); test("session finalization shows the completion banner and returns to landing", async () => { diff --git a/frontend/src/shell/ModelActivityPanel.test.tsx b/frontend/src/shell/ModelActivityPanel.test.tsx index c50e1c08..80d27742 100644 --- a/frontend/src/shell/ModelActivityPanel.test.tsx +++ b/frontend/src/shell/ModelActivityPanel.test.tsx @@ -1,13 +1,14 @@ 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 type { ActivityEntry, ActivityKind } from "../api/types"; import { useSessionStore } from "../store/sessionStore"; +import * as activityPanelModule from "./ModelActivityPanel"; import { isNearBottom, ModelActivityPanel } from "./ModelActivityPanel"; beforeEach(() => useSessionStore.getState().resetSession()); -test("renders every row in a non-reasoning activity sequence", () => { +test("renders only prompt, thinking, status, and gate from a mixed F1 sequence", () => { const store = useSessionStore.getState(); store.setPhase("F1"); store.setLastUserEntry({ kind: "input", text: "How many patients?" }); @@ -20,7 +21,9 @@ test("renders every row in a non-reasoning activity sequence", () => { 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: "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,18 +31,54 @@ test("renders every row in a non-reasoning activity sequence", () => { render(); - expect(screen.getAllByRole("article")).toHaveLength(5); + expect(screen.getAllByRole("article")).toHaveLength(4); 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("schema").tagName).toBe("STRONG"); + expect(screen.getByText("Retrying schema lookup")).toBeInTheDocument(); expect(screen.getByText("Confirm cohort")).toBeInTheDocument(); - expect(screen.getAllByText("F1")).toHaveLength(5); + expect(screen.queryByText("Agent started")).not.toBeInTheDocument(); + expect(screen.queryByText("bash")).not.toBeInTheDocument(); + expect(screen.queryByText("completed")).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", () => { + const predicate = (activityPanelModule as unknown as { + isVisibleModelActivity?: (entry: ActivityEntry) => boolean; + }).isVisibleModelActivity; + expect(predicate).toBeTypeOf("function"); + if (!predicate) return; + + for (const kind of ["prompt", "thinking", "status", "gate"] satisfies ActivityKind[]) { + expect(predicate({ kind, phase: "F1", text: kind })).toBe(true); + } + for (const kind of ["assistant", "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); +}); + +test("shows the empty state when the raw log contains only hidden entries", () => { + useSessionStore.setState({ + activityLog: [ + { 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" }, + ], + }); + + render(); + + expect(screen.getByText("No activity yet.")).toBeInTheDocument(); + expect(screen.queryAllByRole("article")).toHaveLength(0); }); test("keeps all timeline entries visible without an expansion control", () => { const activityLog: ActivityEntry[] = Array.from({ length: 8 }, (_, index) => ({ - kind: "lifecycle", + kind: "status", phase: `F${index + 1}`, text: `timeline-${index + 1}`, })); @@ -51,28 +90,6 @@ test("keeps all timeline entries visible without an expansion control", () => { expect(screen.queryByRole("button", { name: /show (more|less)/i })).not.toBeInTheDocument(); }); -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: [ @@ -87,7 +104,7 @@ test("labels a warning in text instead of relying on its semantic color", () => expect(within(row!).getByText("warning")).toBeInTheDocument(); }); -test("renders assistant and thinking markdown as separate entries", () => { +test("renders thinking markdown without assistant narration", () => { useSessionStore.setState({ activityLog: [ { kind: "thinking", phase: "F2", text: "Considering **history**." }, @@ -98,8 +115,8 @@ test("renders assistant and thinking markdown as separate entries", () => { render(); expect(screen.getByText("history").tagName).toBe("STRONG"); - expect(screen.getByText("current").tagName).toBe("EM"); - expect(screen.getAllByRole("article")).toHaveLength(2); + expect(screen.queryByText("current")).not.toBeInTheDocument(); + expect(screen.getAllByRole("article")).toHaveLength(1); }); test("close calls onClose without mutating the activity log", async () => { @@ -136,13 +153,13 @@ function setScrollGeometry( test("follows appended activity while the viewport is near the bottom", () => { useSessionStore.setState({ - activityLog: [{ kind: "lifecycle", phase: "F1", text: "First" }], + activityLog: [{ kind: "prompt", phase: "F1", text: "First" }], }); render(); const viewport = screen.getByTestId("activity-scroll"); setScrollGeometry(viewport, { scrollHeight: 400, clientHeight: 100, scrollTop: 300 }); - act(() => useSessionStore.getState().recordLifecycle("Second")); + act(() => useSessionStore.getState().applyEvent({ type: "info", text: "Second" })); expect(viewport.scrollTop).toBe(400); expect(viewport).toHaveAttribute("tabindex", "0"); @@ -150,14 +167,30 @@ test("follows appended activity while the viewport is near the bottom", () => { test("preserves manual scroll position when appended activity arrives", () => { useSessionStore.setState({ - activityLog: [{ kind: "lifecycle", phase: "F1", text: "First" }], + activityLog: [{ kind: "prompt", 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")); + act(() => useSessionStore.getState().applyEvent({ type: "info", text: "Second" })); expect(viewport.scrollTop).toBe(100); }); + +test("does not bottom-follow when only a hidden event arrives", () => { + useSessionStore.setState({ + activityLog: [{ kind: "prompt", phase: "F1", text: "Visible prompt" }], + }); + render(); + const viewport = screen.getByTestId("activity-scroll"); + setScrollGeometry(viewport, { scrollHeight: 400, clientHeight: 100, scrollTop: 300 }); + + act(() => useSessionStore.getState().applyEvent({ + type: "activity_event", + activity: { kind: "tool", toolCallId: "tool-2", toolName: "bash", status: "running" }, + })); + + expect(viewport.scrollTop).toBe(300); +}); diff --git a/frontend/src/shell/ModelActivityPanel.tsx b/frontend/src/shell/ModelActivityPanel.tsx index 14c75dc2..ff1f38a0 100644 --- a/frontend/src/shell/ModelActivityPanel.tsx +++ b/frontend/src/shell/ModelActivityPanel.tsx @@ -2,10 +2,21 @@ 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 type { ActivityEntry, ActivityKind } from "../api/types"; import { useSessionStore } from "../store/sessionStore"; import { Button } from "../components/ui/button"; +const VISIBLE_MODEL_ACTIVITY_KINDS: ReadonlySet = new Set([ + "prompt", + "thinking", + "status", + "gate", +]); + +export function isVisibleModelActivity(entry: ActivityEntry): boolean { + return VISIBLE_MODEL_ACTIVITY_KINDS.has(entry.kind); +} + export function isNearBottom( el: Pick, threshold = 48, @@ -85,16 +96,19 @@ function ActivityRow({ entry }: { entry: ActivityEntry }) { ); } -/** Left drawer showing the complete in-memory model activity timeline. */ +/** Left drawer showing the user-facing projection of the in-memory model activity timeline. */ export function ModelActivityPanel({ onClose }: { onClose: () => void }) { const activityLog = useSessionStore((s) => s.activityLog); + const visibleActivity = activityLog.filter(isVisibleModelActivity); + const visibleCount = visibleActivity.length; + const visibleTail = visibleActivity.at(-1); const scrollRef = useRef(null); const followRef = useRef(true); useLayoutEffect(() => { const viewport = scrollRef.current; if (viewport && followRef.current) viewport.scrollTop = viewport.scrollHeight; - }, [activityLog]); + }, [visibleCount, visibleTail]); return (