diff --git a/apps/web/src/components/AgentsPanel.test.tsx b/apps/web/src/components/AgentsPanel.test.tsx new file mode 100644 index 000000000000..4441134d6e91 --- /dev/null +++ b/apps/web/src/components/AgentsPanel.test.tsx @@ -0,0 +1,246 @@ +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("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 = 399.5; + + 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"); + 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..97513cda5c3f 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,45 @@ 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; + } + + 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 - 1 + ) { + return; + } + 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 +580,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..533dd0818a77 --- /dev/null +++ b/apps/web/src/test/reactTestDom.ts @@ -0,0 +1,134 @@ +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 = {}; + clientHeight = 0; + scrollHeight = 0; + 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; +}