From 3133efcccc902d6acaa940fb4d728862c7fde10f Mon Sep 17 00:00:00 2001 From: F4llen Date: Fri, 4 Sep 2026 05:38:43 -0400 Subject: [PATCH 1/3] fix(web): preserve Agents panel scroll position Remember each thread's Agents viewport offset for the lifetime of the renderer so switching threads, tabs, visibility, or panel layouts no longer returns users to the top. Keep thread lifetimes isolated by the existing environment-qualified thread key and avoid React updates while scrolling. Verification: 8 focused tests, web typecheck, targeted lint and formatting. --- apps/web/src/components/AgentsPanel.test.tsx | 212 ++++++++++++++++++ apps/web/src/components/AgentsPanel.tsx | 28 ++- apps/web/src/components/ChatView.tsx | 2 + .../src/components/ui/scroll-area.test.tsx | 52 +++++ apps/web/src/components/ui/scroll-area.tsx | 4 + apps/web/src/test/reactTestDom.ts | 132 +++++++++++ 6 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/AgentsPanel.test.tsx create mode 100644 apps/web/src/components/ui/scroll-area.test.tsx create mode 100644 apps/web/src/test/reactTestDom.ts diff --git a/apps/web/src/components/AgentsPanel.test.tsx b/apps/web/src/components/AgentsPanel.test.tsx new file mode 100644 index 000000000000..2f3a3791bc5e --- /dev/null +++ b/apps/web/src/components/AgentsPanel.test.tsx @@ -0,0 +1,212 @@ +import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { act, Profiler, type ReactNode, type Ref } from "react"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { findTestNode, installReactTestDom, type ReactTestNode } from "~/test/reactTestDom"; + +vi.mock("lucide-react", () => ({ + Bot: () => null, + Braces: () => null, + Check: () => null, + ChevronDown: () => null, + ChevronRight: () => null, + X: () => null, +})); + +vi.mock("~/components/ui/scroll-area", () => ({ + ScrollArea: ({ + children, + viewportRef, + }: { + children: ReactNode; + viewportRef?: Ref; + }) => ( +
+ {children} +
+ ), +})); + +vi.mock("~/components/ui/button", () => ({ + Button: ({ children }: { children: ReactNode }) => , +})); + +import { AgentsPanel } from "./AgentsPanel"; + +const EMPTY_MODEL: AgentPanelModel = { + workflows: [], + directAgents: [], + runningCount: 0, + waitingCount: 0, + idleCount: 0, + settledCount: 0, + totalTokens: 0, + hasAgents: false, + liveCount: 0, +}; + +const ROSTER_MODEL: AgentPanelModel = { ...EMPTY_MODEL, hasAgents: true }; + +function viewport(container: ReactTestNode): ReactTestNode { + const node = findTestNode(container, "data-slot", "scroll-area-viewport"); + if (node === null) throw new Error("Agents scroll viewport was not rendered"); + return node; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("AgentsPanel scroll position", () => { + it("isolates thread positions and restores them when returning", async () => { + const document = installReactTestDom(); + const container = document.createElement("div"); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(container as unknown as Element); + + try { + await act(() => + root.render(), + ); + const threadAViewport = viewport(container); + threadAViewport.scrollTop = 420; + threadAViewport.dispatchEvent(new Event("scroll")); + + await act(() => + root.render(), + ); + expect(viewport(container).scrollTop).toBe(0); + + await act(() => + root.render(), + ); + expect(viewport(container).scrollTop).toBe(420); + } finally { + await act(() => root.unmount()); + } + }); + + it("isolates identical thread ids in different environments", async () => { + const document = installReactTestDom(); + const container = document.createElement("div"); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(container as unknown as Element); + const threadId = ThreadId.make("same-thread"); + const firstThreadKey = scopedThreadKey(scopeThreadRef(EnvironmentId.make("env-1"), threadId)); + const secondThreadKey = scopedThreadKey(scopeThreadRef(EnvironmentId.make("env-2"), threadId)); + + try { + await act(() => + root.render( + , + ), + ); + const firstViewport = viewport(container); + firstViewport.scrollTop = 280; + firstViewport.dispatchEvent(new Event("scroll")); + + await act(() => + root.render( + , + ), + ); + expect(viewport(container).scrollTop).toBe(0); + + await act(() => + root.render( + , + ), + ); + expect(viewport(container).scrollTop).toBe(280); + } finally { + await act(() => root.unmount()); + } + }); + + it.each([ + ["another right-panel tab", "tab"], + ["a hidden right panel", "hidden"], + ["the sheet layout", "sheet"], + ])("restores after remounting from %s", async (_transition, keySuffix) => { + const document = installReactTestDom(); + const container = document.createElement("div"); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(container as unknown as Element); + const threadKey = `env:remount-thread-${keySuffix}`; + + try { + await act(() => + root.render(), + ); + const initialViewport = viewport(container); + initialViewport.scrollTop = 360; + initialViewport.dispatchEvent(new Event("scroll")); + + await act(() => root.render(
Other panel state
)); + await act(() => + root.render(), + ); + expect(viewport(container).scrollTop).toBe(360); + } finally { + await act(() => root.unmount()); + } + }); + + it("restores when an initially empty model gains its roster", async () => { + const document = installReactTestDom(); + const container = document.createElement("div"); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(container as unknown as Element); + const threadKey = "env:late-roster"; + + try { + await act(() => + root.render(), + ); + const initialViewport = viewport(container); + initialViewport.scrollTop = 510; + initialViewport.dispatchEvent(new Event("scroll")); + + await act(() => root.render(
Other thread
)); + await act(() => + root.render(), + ); + expect(findTestNode(container, "data-slot", "scroll-area-viewport")).toBeNull(); + + await act(() => + root.render(), + ); + expect(viewport(container).scrollTop).toBe(510); + } finally { + await act(() => root.unmount()); + } + }); + + it("does not rerender when capturing scroll", async () => { + const document = installReactTestDom(); + const container = document.createElement("div"); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(container as unknown as Element); + const onRender = vi.fn(); + + try { + await act(() => + root.render( + + + , + ), + ); + const renderCount = onRender.mock.calls.length; + const agentsViewport = viewport(container); + agentsViewport.scrollTop = 170; + agentsViewport.dispatchEvent(new Event("scroll")); + + expect(onRender).toHaveBeenCalledTimes(renderCount); + } finally { + await act(() => root.unmount()); + } + }); +}); diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index 459506efc78c..34a012a20cfb 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -23,13 +23,15 @@ import { } from "@t3tools/client-runtime/state/subagentRuntime"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { Bot, Braces, Check, ChevronDown, ChevronRight, X } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { cn } from "~/lib/utils"; import { orchestrationEnvironment } from "~/state/orchestration"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Button } from "~/components/ui/button"; +const agentsScrollTopByThreadKey = new Map(); + /** * In-flight states all present as Working (one steady state, per the * monitoring-pill design: detail belongs in the activity sub-line, and a @@ -524,13 +526,35 @@ function WorkflowSection({ export function AgentsPanel({ model, + threadKey, environmentId = null, threadId = null, }: { model: AgentPanelModel; + threadKey: string | null; environmentId?: EnvironmentId | null; threadId?: ThreadId | null; }) { + const viewportRef = useRef(null); + + useLayoutEffect(() => { + const viewport = viewportRef.current; + if (!model.hasAgents || threadKey === null || viewport === null) { + return; + } + + viewport.scrollTop = agentsScrollTopByThreadKey.get(threadKey) ?? 0; + + const captureScrollTop = () => { + agentsScrollTopByThreadKey.set(threadKey, viewport.scrollTop); + }; + viewport.addEventListener("scroll", captureScrollTop, { passive: true }); + return () => { + captureScrollTop(); + viewport.removeEventListener("scroll", captureScrollTop); + }; + }, [model.hasAgents, threadKey]); + if (!model.hasAgents) { return (
@@ -546,7 +570,7 @@ export function AgentsPanel({ return (
- +
{model.workflows.map((group) => ( ) : renderedRightPanelSurface?.kind === "agents" ? ( ) : (renderedRightPanelSurface?.kind === "files" || renderedRightPanelSurface?.kind === "file") && diff --git a/apps/web/src/components/ui/scroll-area.test.tsx b/apps/web/src/components/ui/scroll-area.test.tsx new file mode 100644 index 000000000000..41694a0d7de2 --- /dev/null +++ b/apps/web/src/components/ui/scroll-area.test.tsx @@ -0,0 +1,52 @@ +import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from "react"; +import { act, createRef } from "react"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { installReactTestDom } from "~/test/reactTestDom"; + +vi.mock("@base-ui/react/scroll-area", () => ({ + ScrollArea: { + Root: forwardRef>((props, ref) => ( +
+ )), + Viewport: forwardRef>((props, ref) => ( +
+ )), + Scrollbar: ({ children }: { children: ReactNode }) =>
{children}
, + Thumb: () =>
, + Corner: () =>
, + }, +})); + +import { ScrollArea } from "./scroll-area"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("ScrollArea refs", () => { + it("keeps the root ref on the root and sends viewportRef to the scrolling viewport", async () => { + const document = installReactTestDom(); + const container = document.createElement("div"); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(container as unknown as Element); + const rootRef = createRef(); + const viewportRef = createRef(); + + try { + await act(() => + root.render( + + Content + , + ), + ); + + expect(rootRef.current?.getAttribute("data-primitive")).toBe("root"); + expect(viewportRef.current?.getAttribute("data-primitive")).toBe("viewport"); + expect(viewportRef.current).not.toBe(rootRef.current); + } finally { + await act(() => root.unmount()); + } + }); +}); diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx index bfc10825b460..f10b90f67f6a 100644 --- a/apps/web/src/components/ui/scroll-area.tsx +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -1,6 +1,7 @@ "use client"; import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"; +import type { Ref } from "react"; import { cn } from "~/lib/utils"; @@ -28,12 +29,14 @@ function ScrollArea({ scrollbarGutter = false, hideScrollbars = false, chainVerticalScroll = false, + viewportRef, ...props }: ScrollAreaPrimitive.Root.Props & { scrollFade?: boolean; scrollbarGutter?: boolean; hideScrollbars?: boolean; chainVerticalScroll?: boolean; + viewportRef?: Ref; }) { return ( {children} diff --git a/apps/web/src/test/reactTestDom.ts b/apps/web/src/test/reactTestDom.ts new file mode 100644 index 000000000000..ffbfe0466d59 --- /dev/null +++ b/apps/web/src/test/reactTestDom.ts @@ -0,0 +1,132 @@ +import { vi } from "vite-plus/test"; + +export class ReactTestNode { + parentNode: ReactTestNode | null = null; + childNodes: ReactTestNode[] = []; + readonly nodeName: string; + readonly tagName: string; + readonly namespaceURI = "http://www.w3.org/1999/xhtml"; + readonly style = {}; + scrollTop = 0; + nodeValue: string | null = null; + private readonly attributes = new Map(); + private readonly listeners = new Map>(); + + constructor( + name: string, + readonly ownerDocument: ReactTestNode | null = null, + readonly nodeType = 1, + ) { + this.nodeName = name.toUpperCase(); + this.tagName = this.nodeName; + } + + get firstChild(): ReactTestNode | null { + return this.childNodes[0] ?? null; + } + + set textContent(value: string) { + this.childNodes = []; + this.nodeValue = value; + } + + appendChild(child: ReactTestNode): ReactTestNode { + child.parentNode = this; + this.childNodes.push(child); + return child; + } + + insertBefore(child: ReactTestNode, before: ReactTestNode | null): ReactTestNode { + child.parentNode = this; + if (before === null) { + this.childNodes.push(child); + return child; + } + const index = this.childNodes.indexOf(before); + this.childNodes.splice(index, 0, child); + return child; + } + + removeChild(child: ReactTestNode): ReactTestNode { + this.childNodes.splice(this.childNodes.indexOf(child), 1); + child.parentNode = null; + return child; + } + + createElement(name: string): ReactTestNode { + return new ReactTestNode(name, this); + } + + createTextNode(value: string): ReactTestNode { + const node = new ReactTestNode("#text", this, 3); + node.nodeValue = value; + return node; + } + + addEventListener(type: string, listener: EventListenerOrEventListenerObject | null): void { + if (listener === null) return; + const listeners = this.listeners.get(type) ?? new Set(); + listeners.add(listener); + this.listeners.set(type, listeners); + } + + removeEventListener(type: string, listener: EventListenerOrEventListenerObject | null): void { + if (listener === null) return; + this.listeners.get(type)?.delete(listener); + } + + dispatchEvent(event: Event): boolean { + for (const listener of this.listeners.get(event.type) ?? []) { + if (typeof listener === "function") { + listener(event); + } else { + listener.handleEvent(event); + } + } + return true; + } + + setAttribute(name: string, value: string): void { + this.attributes.set(name, value); + } + + removeAttribute(name: string): void { + this.attributes.delete(name); + } + + getAttribute(name: string): string | null { + return this.attributes.get(name) ?? null; + } +} + +export function installReactTestDom(): ReactTestNode { + const document = new ReactTestNode("#document", null, 9); + const window = { + document, + HTMLIFrameElement: ReactTestNode, + setInterval: globalThis.setInterval, + clearInterval: globalThis.clearInterval, + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", window); + vi.stubGlobal("HTMLIFrameElement", window.HTMLIFrameElement); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + return document; +} + +export function findTestNode( + node: ReactTestNode, + attribute: string, + value: string, +): ReactTestNode | null { + if (node.getAttribute(attribute) === value) return node; + for (const child of node.childNodes) { + const match = findTestNode(child, attribute, value); + if (match !== null) return match; + } + return null; +} From 2e521b9d6c089dd63afd148b42d24b0e3f2e2e06 Mon Sep 17 00:00:00 2001 From: F4llen Date: Fri, 4 Sep 2026 05:46:32 -0400 Subject: [PATCH 2/3] fix(web): retain scroll offsets through viewport clamping Keep the saved Agents offset when a remounted layout has a smaller scroll range and the browser clamps the viewport. This lets the original layout restore its deeper position instead of inheriting the temporary maximum. Verification: 9 focused tests, web typecheck, targeted lint and formatting. --- apps/web/src/components/AgentsPanel.test.tsx | 34 ++++++++++++++++++++ apps/web/src/components/AgentsPanel.tsx | 12 ++++++- apps/web/src/test/reactTestDom.ts | 2 ++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/AgentsPanel.test.tsx b/apps/web/src/components/AgentsPanel.test.tsx index 2f3a3791bc5e..58f4bec8cb95 100644 --- a/apps/web/src/components/AgentsPanel.test.tsx +++ b/apps/web/src/components/AgentsPanel.test.tsx @@ -184,6 +184,40 @@ describe("AgentsPanel scroll position", () => { } }); + it("preserves an offset clamped by a smaller remounted viewport", async () => { + const document = installReactTestDom(); + const container = document.createElement("div"); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(container as unknown as Element); + const threadKey = "env:clamped-remount"; + + try { + await act(() => + root.render(), + ); + const initialViewport = viewport(container); + initialViewport.scrollTop = 510; + initialViewport.dispatchEvent(new Event("scroll")); + + await act(() => root.render(
Other panel state
)); + await act(() => + root.render(), + ); + const constrainedViewport = viewport(container); + constrainedViewport.scrollHeight = 700; + constrainedViewport.clientHeight = 300; + constrainedViewport.scrollTop = 400; + + await act(() => root.render(
Original layout
)); + await act(() => + root.render(), + ); + expect(viewport(container).scrollTop).toBe(510); + } finally { + await act(() => root.unmount()); + } + }); + it("does not rerender when capturing scroll", async () => { const document = installReactTestDom(); const container = document.createElement("div"); diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index 34a012a20cfb..5996e4709a7c 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -543,9 +543,19 @@ export function AgentsPanel({ return; } - viewport.scrollTop = agentsScrollTopByThreadKey.get(threadKey) ?? 0; + const restoredScrollTop = agentsScrollTopByThreadKey.get(threadKey) ?? 0; + viewport.scrollTop = restoredScrollTop; const captureScrollTop = () => { + const maxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight); + const savedScrollTop = agentsScrollTopByThreadKey.get(threadKey); + if ( + savedScrollTop !== undefined && + savedScrollTop > maxScrollTop && + viewport.scrollTop === maxScrollTop + ) { + return; + } agentsScrollTopByThreadKey.set(threadKey, viewport.scrollTop); }; viewport.addEventListener("scroll", captureScrollTop, { passive: true }); diff --git a/apps/web/src/test/reactTestDom.ts b/apps/web/src/test/reactTestDom.ts index ffbfe0466d59..533dd0818a77 100644 --- a/apps/web/src/test/reactTestDom.ts +++ b/apps/web/src/test/reactTestDom.ts @@ -7,6 +7,8 @@ export class ReactTestNode { readonly tagName: string; readonly namespaceURI = "http://www.w3.org/1999/xhtml"; readonly style = {}; + clientHeight = 0; + scrollHeight = 0; scrollTop = 0; nodeValue: string | null = null; private readonly attributes = new Map(); From 72f4d0f3e185747f724342b17e13b7f630738df5 Mon Sep 17 00:00:00 2001 From: F4llen Date: Fri, 4 Sep 2026 06:37:30 -0400 Subject: [PATCH 3/3] fix(web): tolerate fractional scroll clamping Treat viewport positions within one pixel of the calculated scroll maximum as clamped so browser rounding cannot replace a deeper saved Agents offset. Verification: 9 focused tests, web typecheck, targeted lint and formatting. --- apps/web/src/components/AgentsPanel.test.tsx | 2 +- apps/web/src/components/AgentsPanel.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/AgentsPanel.test.tsx b/apps/web/src/components/AgentsPanel.test.tsx index 58f4bec8cb95..4441134d6e91 100644 --- a/apps/web/src/components/AgentsPanel.test.tsx +++ b/apps/web/src/components/AgentsPanel.test.tsx @@ -206,7 +206,7 @@ describe("AgentsPanel scroll position", () => { const constrainedViewport = viewport(container); constrainedViewport.scrollHeight = 700; constrainedViewport.clientHeight = 300; - constrainedViewport.scrollTop = 400; + constrainedViewport.scrollTop = 399.5; await act(() => root.render(
Original layout
)); await act(() => diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index 5996e4709a7c..97513cda5c3f 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -552,7 +552,7 @@ export function AgentsPanel({ if ( savedScrollTop !== undefined && savedScrollTop > maxScrollTop && - viewport.scrollTop === maxScrollTop + viewport.scrollTop >= maxScrollTop - 1 ) { return; }