diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 4cffb109..f3a25690 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -202,6 +202,63 @@ ThothII gira in Docker sul server co-locato, **embedded nel portale omics_portal unrelated Pi runtime existed to disturb. The count-only frontend sensitive/error pattern scan was **0**. No live model smoke was run, and settings and sessions were intentionally untouched. +### Resizable activity split + compact CTE rows — LIVE 2026-07-15 + +- **Resolved UI contract.** `activityLog` remains the complete in-memory chronological fold. The + left Model activity panel default-denies every kind except prompt, thinking, and assistant, + labels those entries Question, Reasoning, and Response in source order, and hides status, tool, + gate, lifecycle, and unknown kinds. The desktop panel is pointer/keyboard resizable from 288–576 + px while preserving 512 px centrally, persists its global width in localStorage, and becomes an + overlay drawer below `lg` or whenever the measured app shell is narrower than 800 px. F6 CTE + cards retain their semantic structure and responsive grids; + lateral padding is 12/16 px, header/content edge padding is 8 px, internal section gaps are 12 + px, heading/divider spacing is 4 px, and table/filter rows use 4 px vertical padding with compact + line heights. +- **Source verification (`2026-07-15T15:22:17+02:00`, source HEAD + `f1af1f909b387ae12a10f1b534bf6a529ea42505`).** Frontend tests passed **295/295** across + **44/44** files. `npx tsc -b`, `npm run build`, and `git diff --check` exited 0; the Impeccable + layout detector returned `[]`. The local source build emitted Vite entry + `assets/index-PlvQqhNG.js`. +- **Frontend-only deployment.** The pre-deploy frontend was image + `sha256:39dc47d81abcb13466218500476fbb78d1be66a424f4293470437700c848c26d`, started + `2026-07-15T10:30:30.869532521Z`, serving `index-CihtpQJV.js`. Compose built and force-recreated + only `frontend` with `--no-deps`; the final running frontend is image + `sha256:0947211373784a860c7507d03c0fcf1f901ac3d1547cd0aa62b914d158f15bd5`, started + `2026-07-15T13:21:08.29613952Z`, serving `index-Dn7T524a.js`. Because the entry changed, exactly + `omics_portal-web-1` was restarted: it retained image + `sha256:4580cf2bc85f3656ee515996c24ed02d9096d24ed0d8afe384a2f8bd8cf4bac9` and moved from + start `2026-07-15T10:30:46.369205604Z` to `2026-07-15T13:21:21.968808414Z`. +- **Isolation and final state.** Core remained `running`/`healthy` with exactly its original image + `sha256:edd8f19ef269ee6f45ecaf464ba4053460d94378f9cdc967d2dbcb386f599147` and start + `2026-07-15T00:18:37.040145636Z`; frontend and portal were `running` with no container + healthcheck. Pre/post `docker top` showed only core's supervisor and backend server, so no + unrelated Pi process existed and no Pi process was stopped or steered. The count-only frontend + sensitive/error pattern scan was **0**. No live model smoke was run; settings and sessions were + intentionally untouched. + +### Final activity-split fix — LIVE 2026-07-15 + +- **Source and verification (`2026-07-15T15:56:57+02:00`).** Deployed source commit + `1f540fcb78ac9e552e56a21e47edf66e9872b323` (`1f540fc`). Frontend Vitest passed **298/298** + tests across **44/44** files; `npx tsc -b` and `npm run build` exited 0. The Impeccable detector + scoped to AppShell, ModelActivityPanel, index.css, and CtePlanViewer returned `[]`; `git diff + --check` exited 0. +- **Frontend-only deployment.** Compose built and force-recreated only `frontend` with `--no-deps`. + The frontend image changed from + `sha256:0947211373784a860c7507d03c0fcf1f901ac3d1547cd0aa62b914d158f15bd5` to + `sha256:6e14f55092b7e3aca9a396220394ae484147674d81b051771e394e59b73b1c88`; its active Vite entry + changed from `index-Dn7T524a.js` to `index-BIznZeLH.js`. Therefore exactly + `omics_portal-web-1` was restarted to refresh its manifest cache; it retained image + `sha256:4580cf2bc85f3656ee515996c24ed02d9096d24ed0d8afe384a2f8bd8cf4bac9` and started at + `2026-07-15T13:56:32.786693915Z`. +- **Isolation and final state.** Core retained image + `sha256:edd8f19ef269ee6f45ecaf464ba4053460d94378f9cdc967d2dbcb386f599147` and exact original + start `2026-07-15T00:18:37.040145636Z`, remaining `running`/`healthy`. Final frontend and portal + states are `running` (no healthcheck). Pre/post core process tables contained only the supervisor + and backend server, so Pi was preserved and no Pi process was stopped or steered. The count-only + frontend sensitive/error-pattern scan was **0**. No model smoke was run; settings and sessions + were intentionally untouched. + ## What ThothII is A **human-in-the-loop datamart builder**: it turns a natural-language question into diff --git a/brain/codebase/workflow-ui-contracts.md b/brain/codebase/workflow-ui-contracts.md index c8f3a047..bae08bb0 100644 --- a/brain/codebase/workflow-ui-contracts.md +++ b/brain/codebase/workflow-ui-contracts.md @@ -23,16 +23,14 @@ closes the old EventSource and opens the same session URL again. Failed Resume must not reconnect. - SSE endpoints are intentionally keep-alive. Browser cleanup and one-off probes must explicitly close the EventSource or cancel/abort the response reader after their terminal event. -- `activityLog` remains the complete in-memory chronological fold of prompt, thinking, assistant, - sanitized tool lifecycle, reviewer gates, status, and turn lifecycle. The left Model activity - panel is a strict projection of only thinking and status; prompt, gate, assistant, tool, - lifecycle, and unknown future kinds are hidden. While the model is working, the central body - projects every non-blank assistant transcript line into one bounded, accessible scrolling log; - user-entry echoes, timer/spinner labels, and step messages are not rendered there. Reviewer - widgets, artifacts, store folds, and workflow state continue to consume their existing events. -- F6 CTE cards keep their existing semantic structure and responsive grids while using 8 px - vertical padding, 12 px lateral padding below `sm`, and 16 px lateral padding from `sm` upward - for headers, content, table rows, and filter rows. Divider top padding is 8 px. +- `activityLog` remains the complete in-memory chronological fold. The left Model activity panel + default-denies every kind except prompt, thinking, and assistant, presenting them as Question, + Reasoning, and Response in source order; status, tool, gate, lifecycle, and unknown kinds stay + hidden. Its desktop width is pointer/keyboard resizable from 288–576 px while preserving 512 px + centrally, persists globally in localStorage, and becomes an overlay drawer below `lg`. +- F6 CTE cards keep their semantic structure, responsive grids, 12/16 px lateral padding, and 8 px + header/content edge padding. Internal section gaps are 12 px, headings/dividers use 4 px spacing, + and table/filter rows use 4 px vertical padding with compact line heights. - Pi tool events may cross the backend/client boundary only as call id, tool name, and `running`/`completed`/`failed` status. Tool updates, arguments, partial/final results, commands, raw output, and raw errors remain server-side. diff --git a/frontend/src/index.css b/frontend/src/index.css index 337322d1..f4c6b0e0 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -220,6 +220,24 @@ text-transform: uppercase; color: oklch(var(--muted-foreground)); } + .thot-activity-prose, + .thot-activity-prose p, + .thot-activity-prose li, + .thot-activity-prose a, + .thot-activity-prose code, + .thot-activity-prose th, + .thot-activity-prose td { + overflow-wrap: anywhere; + word-break: break-word; + } + .thot-activity-prose table { table-layout: fixed; } + .thot-activity-prose pre { + overflow-x: hidden; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + } + .thot-activity-prose pre code { white-space: inherit; } } diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx index 3ad419f6..e841a229 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -1,4 +1,4 @@ -import { act, render, screen, waitFor, within } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { http, HttpResponse } from "msw"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -6,6 +6,7 @@ import { server } from "../test/msw"; import { FakeEventSource } from "../test/fakeEventSource"; import { AppShell } from "./AppShell"; import { useSessionStore } from "../store/sessionStore"; +import { ACTIVITY_PANEL_STORAGE_KEY } from "./useActivityPanelResize"; function wrap() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -20,6 +21,23 @@ const LIST = [ const resumeResult = (id: string, alreadyActive = false) => HttpResponse.json({ id, alreadyActive }); +class ControlledResizeObserver { + static instances: ControlledResizeObserver[] = []; + constructor(private readonly callback: ResizeObserverCallback) { + ControlledResizeObserver.instances.push(this); + } + observe = vi.fn(); + disconnect = vi.fn(); + trigger() { + this.callback([], this as unknown as ResizeObserver); + } +} + +function setContainerWidth(element: HTMLElement, width: number) { + Object.defineProperty(element, "clientWidth", { configurable: true, value: width }); + act(() => ControlledResizeObserver.instances.at(-1)?.trigger()); +} + function deferred() { let resolve!: () => void; const promise = new Promise((onResolve) => { resolve = onResolve; }); @@ -27,9 +45,13 @@ function deferred() { } beforeEach(() => { + localStorage.clear(); FakeEventSource.instances = []; + ControlledResizeObserver.instances = []; (globalThis as any).EventSource = FakeEventSource; (globalThis as any).PointerEvent = MouseEvent; + (globalThis as any).ResizeObserver = ControlledResizeObserver; + window.matchMedia = vi.fn().mockReturnValue({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() }); useSessionStore.getState().resetSession(); server.use( http.get("http://localhost:8787/sessions", () => HttpResponse.json(LIST)), @@ -711,11 +733,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 +747,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); }); @@ -777,21 +801,103 @@ test("renaming a group reassigns its members via setSessionGroup", async () => { }); -test("opening Model activity replaces the session rail with a 40/60 activity and chat layout", async () => { +test("opens an accessible resizable activity split and persists pointer width", async () => { server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1"))); wrap(); await userEvent.click(await screen.findByText("Attiva uno")); await userEvent.click(await screen.findByRole("button", { name: /resume/i })); - - expect(screen.getByText("Sessions")).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: /show model activity/i })); const shell = screen.getByTestId("app-shell"); - expect(shell).toHaveAttribute("data-activity-layout", "open"); - expect(screen.getByRole("heading", { name: "Model activity" }).closest("aside")).toHaveClass("w-2/5"); - expect(screen.queryByText("Sessions")).not.toBeInTheDocument(); + setContainerWidth(shell, 1200); + const separator = screen.getByRole("separator", { name: /resize model activity/i }); + expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("384px"); + expect(separator).toHaveAttribute("aria-orientation", "vertical"); + expect(separator).toHaveAttribute("aria-valuemin", "288"); + expect(separator).toHaveAttribute("aria-valuenow", "384"); - await userEvent.click(screen.getByRole("button", { name: /hide model activity/i })); - expect(shell).toHaveAttribute("data-activity-layout", "closed"); - expect(screen.getByText("Sessions")).toBeInTheDocument(); + Object.assign(separator, { + setPointerCapture: vi.fn(), + releasePointerCapture: vi.fn(), + hasPointerCapture: vi.fn(() => true), + }); + fireEvent.pointerDown(separator, { pointerId: 1, clientX: 384 }); + fireEvent.pointerMove(separator, { pointerId: 1, clientX: 484 }); + fireEvent.pointerUp(separator, { pointerId: 1, clientX: 484 }); + + expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("484px"); + expect(localStorage.getItem(ACTIVITY_PANEL_STORAGE_KEY)).toBe("484"); + expect(screen.getByTestId("conversation-column")).toHaveClass("flex-1"); +}); + +test("resizes the activity split with keyboard and exposes responsive drawer classes", async () => { + localStorage.setItem(ACTIVITY_PANEL_STORAGE_KEY, "448"); + server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1"))); + wrap(); + await userEvent.click(await screen.findByText("Attiva uno")); + await userEvent.click(await screen.findByRole("button", { name: /resume/i })); + await userEvent.click(screen.getByRole("button", { name: /show model activity/i })); + + const shell = screen.getByTestId("app-shell"); + setContainerWidth(shell, 1200); + const separator = screen.getByRole("separator", { name: /resize model activity/i }); + expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("448px"); + fireEvent.keyDown(separator, { key: "ArrowLeft" }); + expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("432px"); + fireEvent.keyDown(separator, { key: "ArrowRight", shiftKey: true }); + expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("480px"); + fireEvent.keyDown(separator, { key: "Home" }); + expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("288px"); + + const panel = screen.getByRole("heading", { name: "Model activity" }).closest("aside"); + expect(panel).toHaveClass( + "static", + "w-[var(--activity-panel-width)]", + ); + expect(separator).toHaveClass("touch-none", "cursor-col-resize"); +}); + +test("uses the measured app shell for the desktop activity split", async () => { + localStorage.setItem(ACTIVITY_PANEL_STORAGE_KEY, "576"); + server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1"))); + wrap(); + await userEvent.click(await screen.findByText("Attiva uno")); + await userEvent.click(await screen.findByRole("button", { name: /resume/i })); + await userEvent.click(await screen.findByRole("button", { name: /show model activity/i })); + + const shell = screen.getByTestId("app-shell"); + setContainerWidth(shell, 1200); + expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("576px"); + expect(screen.getByRole("separator", { name: /resize model activity/i })).toBeInTheDocument(); + + setContainerWidth(shell, 1024); + expect(shell.style.getPropertyValue("--activity-panel-width")).toBe("512px"); + + setContainerWidth(shell, 799); + expect(screen.queryByRole("separator", { name: /resize model activity/i })).not.toBeInTheDocument(); + expect(screen.getByTestId("conversation-column")).toHaveClass("flex-1"); + expect(screen.getByRole("heading", { name: "Model activity" }).closest("aside")).toHaveClass("fixed"); +}); + +test("cancels an active resize when the measured shell becomes too narrow", async () => { + localStorage.setItem(ACTIVITY_PANEL_STORAGE_KEY, "576"); + server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1"))); + wrap(); + await userEvent.click(await screen.findByText("Attiva uno")); + await userEvent.click(await screen.findByRole("button", { name: /resume/i })); + await userEvent.click(await screen.findByRole("button", { name: /show model activity/i })); + + const shell = screen.getByTestId("app-shell"); + setContainerWidth(shell, 1200); + const separator = screen.getByRole("separator", { name: /resize model activity/i }); + Object.assign(separator, { + setPointerCapture: vi.fn(), releasePointerCapture: vi.fn(), hasPointerCapture: vi.fn(() => true), + }); + fireEvent.pointerDown(separator, { pointerId: 1, clientX: 576 }); + fireEvent.pointerMove(separator, { pointerId: 1, clientX: 400 }); + setContainerWidth(shell, 799); + fireEvent.pointerUp(separator, { pointerId: 1, clientX: 300 }); + + expect(localStorage.getItem(ACTIVITY_PANEL_STORAGE_KEY)).toBe("576"); + expect(shell).toHaveAttribute("data-activity-resizing", "false"); }); diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index 12963056..a3cc3941 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -3,6 +3,7 @@ import { useSessionStore } from "../store/sessionStore"; import { WidgetHost } from "./WidgetHost"; import { CentralStatus } from "./CentralStatus"; import { ModelActivityPanel } from "./ModelActivityPanel"; +import { useActivityPanelResize } from "./useActivityPanelResize"; import { NavSessions } from "./NavSessions"; import { SessionMenu } from "./SessionMenu"; import { SessionDocumentsPanel } from "./SessionDocumentsPanel"; @@ -23,6 +24,7 @@ import { import type { SessionSummary } from "../api/types"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useRef, useState } from "react"; +import type { CSSProperties } from "react"; /** * The page is designed to live INSIDE the Omics Portal chrome (its left sidebar @@ -31,6 +33,10 @@ import { useEffect, useMemo, useRef, useState } from "react"; * portal's left sidebar, and the workflow phases ride a slim strip above the chat. */ export function AppShell() { + const { containerRef, panelWidth, desktopSplit, resizing, separatorProps } = useActivityPanelResize(); + const activityWidthStyle = { + "--activity-panel-width": `${panelWidth}px`, + } as CSSProperties; const [activeSessionId, setActiveSessionId] = useState(null); const activeSessionIdRef = useRef(null); const resumeInvocationRef = useRef(0); @@ -364,9 +370,15 @@ export function AppShell() { return (
{panelSession && ( )} - {showActivity && setShowActivity(false)} />} + {showActivity && setShowActivity(false)} />} + {showActivity && desktopSplit && ( +
+
+ +
+
+ )} {/* Conversation column */} -
+
{activeSessionId && (
{/* Arrow toggle for the left Model-activity panel: → opens it, ← closes it. */} diff --git a/frontend/src/shell/ModelActivityPanel.test.tsx b/frontend/src/shell/ModelActivityPanel.test.tsx index 8bf9b55c..c11aa08d 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,36 @@ 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 the activity-only Markdown wrapping renderer for long content", () => { + useSessionStore.setState({ + activityLog: [ + { kind: "prompt", phase: "F1", text: "https://example.test/" + "x".repeat(120) }, + { kind: "thinking", phase: "F1", text: "Inline `" + "code".repeat(40) + "`" }, + { kind: "assistant", phase: "F1", text: "| Cell |\n| --- |\n| " + "table".repeat(40) + " |\n\n```sql\n" + "select_".repeat(40) + "\n```" }, + ], + }); + + render(); + + const renderers = document.querySelectorAll(".thot-activity-prose"); + expect(renderers).toHaveLength(3); + expect(renderers[0]).toHaveClass("thot-prose"); + expect(renderers[1].querySelector("code")).not.toBeNull(); + expect(renderers[2].querySelector("td")).not.toBeNull(); + expect(renderers[2].querySelector("pre")).not.toBeNull(); }); test("uses an explicit default-deny activity-kind allowlist", () => { @@ -47,10 +66,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 +78,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 +93,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 +105,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 +137,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 +145,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 +158,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..b56c839f 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); } @@ -30,66 +37,32 @@ export function formatModelActivity(text: string): string { 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} - - )} +
+
+ {ACTIVITY_LABELS[entry.kind]} + {entry.phase && {entry.phase}}
- +
); } /** Left drawer showing the user-facing projection of the in-memory model activity timeline. */ -export function ModelActivityPanel({ onClose }: { onClose: () => void }) { +export function ModelActivityPanel({ + desktopSplit = false, + onClose, +}: { + desktopSplit?: boolean; + onClose: () => void; +}) { const activityLog = useSessionStore((s) => s.activityLog); const visibleActivity = activityLog.filter(isVisibleModelActivity); const visibleCount = visibleActivity.length; @@ -103,8 +76,11 @@ export function ModelActivityPanel({ onClose }: { onClose: () => void }) { }, [visibleCount, visibleTail]); return ( -