Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
246 changes: 246 additions & 0 deletions apps/web/src/components/AgentsPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>;
}) => (
<div ref={viewportRef} data-slot="scroll-area-viewport">
{children}
</div>
),
}));

vi.mock("~/components/ui/button", () => ({
Button: ({ children }: { children: ReactNode }) => <button>{children}</button>,
}));

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(<AgentsPanel key="env:A" threadKey="env:A" model={ROSTER_MODEL} />),
);
const threadAViewport = viewport(container);
threadAViewport.scrollTop = 420;
threadAViewport.dispatchEvent(new Event("scroll"));

await act(() =>
root.render(<AgentsPanel key="env:B" threadKey="env:B" model={ROSTER_MODEL} />),
);
expect(viewport(container).scrollTop).toBe(0);

await act(() =>
root.render(<AgentsPanel key="env:A" threadKey="env:A" model={ROSTER_MODEL} />),
);
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(
<AgentsPanel key={firstThreadKey} threadKey={firstThreadKey} model={ROSTER_MODEL} />,
),
);
const firstViewport = viewport(container);
firstViewport.scrollTop = 280;
firstViewport.dispatchEvent(new Event("scroll"));

await act(() =>
root.render(
<AgentsPanel key={secondThreadKey} threadKey={secondThreadKey} model={ROSTER_MODEL} />,
),
);
expect(viewport(container).scrollTop).toBe(0);

await act(() =>
root.render(
<AgentsPanel key={firstThreadKey} threadKey={firstThreadKey} model={ROSTER_MODEL} />,
),
);
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(<AgentsPanel key={threadKey} threadKey={threadKey} model={ROSTER_MODEL} />),
);
const initialViewport = viewport(container);
initialViewport.scrollTop = 360;
initialViewport.dispatchEvent(new Event("scroll"));

await act(() => root.render(<div>Other panel state</div>));
await act(() =>
root.render(<AgentsPanel key={threadKey} threadKey={threadKey} model={ROSTER_MODEL} />),
);
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(<AgentsPanel key={threadKey} threadKey={threadKey} model={ROSTER_MODEL} />),
);
const initialViewport = viewport(container);
initialViewport.scrollTop = 510;
initialViewport.dispatchEvent(new Event("scroll"));

await act(() => root.render(<div>Other thread</div>));
await act(() =>
root.render(<AgentsPanel key={threadKey} threadKey={threadKey} model={EMPTY_MODEL} />),
);
expect(findTestNode(container, "data-slot", "scroll-area-viewport")).toBeNull();

await act(() =>
root.render(<AgentsPanel key={threadKey} threadKey={threadKey} model={ROSTER_MODEL} />),
);
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(<AgentsPanel key={threadKey} threadKey={threadKey} model={ROSTER_MODEL} />),
);
const initialViewport = viewport(container);
initialViewport.scrollTop = 510;
initialViewport.dispatchEvent(new Event("scroll"));

await act(() => root.render(<div>Other panel state</div>));
await act(() =>
root.render(<AgentsPanel key={threadKey} threadKey={threadKey} model={ROSTER_MODEL} />),
);
const constrainedViewport = viewport(container);
constrainedViewport.scrollHeight = 700;
constrainedViewport.clientHeight = 300;
constrainedViewport.scrollTop = 399.5;

await act(() => root.render(<div>Original layout</div>));
await act(() =>
root.render(<AgentsPanel key={threadKey} threadKey={threadKey} model={ROSTER_MODEL} />),
);
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(
<Profiler id="agents" onRender={onRender}>
<AgentsPanel threadKey="env:no-rerender" model={ROSTER_MODEL} />
</Profiler>,
),
);
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());
}
});
});
38 changes: 36 additions & 2 deletions apps/web/src/components/AgentsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>();

/**
* In-flight states all present as Working (one steady state, per the
* monitoring-pill design: detail belongs in the activity sub-line, and a
Expand Down Expand Up @@ -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<HTMLDivElement>(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 (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">
Expand All @@ -546,7 +580,7 @@ export function AgentsPanel({

return (
<div className="flex h-full min-h-0 flex-col">
<ScrollArea className="min-h-0 flex-1">
<ScrollArea className="min-h-0 flex-1" viewportRef={viewportRef}>
<div className="flex flex-col gap-2 p-2">
{model.workflows.map((group) => (
<WorkflowSection
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7643,9 +7643,11 @@ export default function ChatView(props: ChatViewProps) {
/>
) : renderedRightPanelSurface?.kind === "agents" ? (
<AgentsPanel
key={activeThreadKey}
model={agentPanelModel}
environmentId={activeThreadRef?.environmentId ?? null}
threadId={activeThreadRef?.threadId ?? null}
threadKey={activeThreadKey}
/>
) : (renderedRightPanelSurface?.kind === "files" ||
renderedRightPanelSurface?.kind === "file") &&
Expand Down
52 changes: 52 additions & 0 deletions apps/web/src/components/ui/scroll-area.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement, ComponentPropsWithoutRef<"div">>((props, ref) => (
<div ref={ref} data-primitive="root" {...props} />
)),
Viewport: forwardRef<HTMLDivElement, ComponentPropsWithoutRef<"div">>((props, ref) => (
<div ref={ref} data-primitive="viewport" {...props} />
)),
Scrollbar: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Thumb: () => <div />,
Corner: () => <div />,
},
}));

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<HTMLDivElement>();
const viewportRef = createRef<HTMLDivElement>();

try {
await act(() =>
root.render(
<ScrollArea ref={rootRef} viewportRef={viewportRef} hideScrollbars>
Content
</ScrollArea>,
),
);

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());
}
});
});
Loading
Loading