From 1f540fcb78ac9e552e56a21e47edf66e9872b323 Mon Sep 17 00:00:00 2001 From: User Date: Wed, 15 Jul 2026 15:52:22 +0200 Subject: [PATCH] fix(frontend): harden activity split responsiveness --- frontend/src/index.css | 18 +++++ .../src/shell/AppShell.session-mgmt.test.tsx | 73 ++++++++++++++++++- frontend/src/shell/AppShell.tsx | 8 +- .../src/shell/ModelActivityPanel.test.tsx | 19 +++++ frontend/src/shell/ModelActivityPanel.tsx | 15 +++- frontend/src/shell/useActivityPanelResize.ts | 45 ++++++++++-- 6 files changed, 160 insertions(+), 18 deletions(-) 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 1ffdd474..e841a229 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -21,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; }); @@ -30,8 +47,11 @@ 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)), @@ -789,6 +809,7 @@ test("opens an accessible resizable activity split and persists pointer width", 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("384px"); expect(separator).toHaveAttribute("aria-orientation", "vertical"); @@ -818,6 +839,7 @@ test("resizes the activity split with keyboard and exposes responsive drawer cla 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" }); @@ -829,10 +851,53 @@ test("resizes the activity split with keyboard and exposes responsive drawer cla const panel = screen.getByRole("heading", { name: "Model activity" }).closest("aside"); expect(panel).toHaveClass( - "fixed", - "w-[min(90vw,24rem)]", - "lg:static", - "lg:w-[var(--activity-panel-width)]", + "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 83b4a662..a3cc3941 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -33,7 +33,7 @@ import type { CSSProperties } from "react"; * portal's left sidebar, and the workflow phases ride a slim strip above the chat. */ export function AppShell() { - const { containerRef, panelWidth, resizing, separatorProps } = useActivityPanelResize(); + const { containerRef, panelWidth, desktopSplit, resizing, separatorProps } = useActivityPanelResize(); const activityWidthStyle = { "--activity-panel-width": `${panelWidth}px`, } as CSSProperties; @@ -387,9 +387,9 @@ export function AppShell() { onResume={doResume} /> )} - {showActivity && setShowActivity(false)} />} - {showActivity && ( -
+ {showActivity && setShowActivity(false)} />} + {showActivity && desktopSplit && ( +
{ expect(screen.queryByText("bash")).not.toBeInTheDocument(); }); +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", () => { const predicate = (activityPanelModule as unknown as { isVisibleModelActivity?: (entry: ActivityEntry) => boolean; diff --git a/frontend/src/shell/ModelActivityPanel.tsx b/frontend/src/shell/ModelActivityPanel.tsx index 27cbee54..b56c839f 100644 --- a/frontend/src/shell/ModelActivityPanel.tsx +++ b/frontend/src/shell/ModelActivityPanel.tsx @@ -37,7 +37,7 @@ 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)}
); @@ -56,7 +56,13 @@ function ActivityRow({ entry }: { entry: ActivityEntry }) { } /** 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; @@ -70,7 +76,10 @@ export function ModelActivityPanel({ onClose }: { onClose: () => void }) { }, [visibleCount, visibleTail]); return ( -