Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
1504 lines
48 KiB
TypeScript
1504 lines
48 KiB
TypeScript
import { act, renderHook } from "@testing-library/react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import type { Mock } from "vitest";
|
|
import type { MutableRefObject, RefObject } from "react";
|
|
import type { AgentConversationVM } from "../../../shared/agentConversation/contracts/agentConversationVM";
|
|
import type { AgentTranscriptVirtualScrollController } from "../../../shared/agentConversation/components/AgentTranscriptView";
|
|
import type { AgentGUINodeViewModel } from "../model/agentGuiNodeTypes";
|
|
import type { AgentGUINodeViewProps } from "../AgentGUINodeView";
|
|
import { useAgentGUIDetailScroll } from "./useAgentGUIDetailScroll";
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe("useAgentGUIDetailScroll", () => {
|
|
it("starts a newly selected conversation at the bottom", () => {
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const { rerender } = renderHook(
|
|
({ activeConversationId }) =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({ activeConversationId, showTimelineSkeleton: false })
|
|
),
|
|
{ initialProps: { activeConversationId: "conversation-a" } }
|
|
);
|
|
|
|
expect(harness.timeline.scrollTop).toBe(4_900);
|
|
act(() => {
|
|
harness.timeline.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
|
|
harness.timeline.scrollTop = 2_000;
|
|
harness.timeline.dispatchEvent(new Event("scroll"));
|
|
});
|
|
expect(harness.timeline.scrollTop).toBe(2_000);
|
|
|
|
harness.setScrollHeight(8_000);
|
|
rerender({ activeConversationId: "conversation-b" });
|
|
|
|
expect(harness.timeline.scrollTop).toBe(7_900);
|
|
});
|
|
|
|
it("restores a conversation's detached scroll position when it is reselected", () => {
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const { result, rerender } = renderHook(
|
|
({ activeConversationId }) =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({ activeConversationId, showTimelineSkeleton: false })
|
|
),
|
|
{ initialProps: { activeConversationId: "conversation-a" } }
|
|
);
|
|
|
|
act(() => {
|
|
harness.timeline.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
|
|
harness.timeline.scrollTop = 2_000;
|
|
harness.timeline.dispatchEvent(new Event("scroll"));
|
|
});
|
|
expect(result.current.isTimelineScrolledToBottom).toBe(false);
|
|
|
|
harness.setScrollHeight(8_000);
|
|
rerender({ activeConversationId: "conversation-b" });
|
|
expect(harness.timeline.scrollTop).toBe(7_900);
|
|
expect(result.current.isTimelineScrolledToBottom).toBe(true);
|
|
|
|
harness.setScrollHeight(5_000);
|
|
rerender({ activeConversationId: "conversation-a" });
|
|
expect(harness.timeline.scrollTop).toBe(2_000);
|
|
expect(result.current.isTimelineScrolledToBottom).toBe(false);
|
|
});
|
|
|
|
it("keeps a detached position while the reselected conversation hydrates", () => {
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const { result, rerender } = renderHook(
|
|
({ activeConversationId, showTimelineSkeleton }) =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({ activeConversationId, showTimelineSkeleton })
|
|
),
|
|
{
|
|
initialProps: {
|
|
activeConversationId: "conversation-a",
|
|
showTimelineSkeleton: false
|
|
}
|
|
}
|
|
);
|
|
|
|
act(() => {
|
|
harness.timeline.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
|
|
harness.timeline.scrollTop = 2_000;
|
|
harness.timeline.dispatchEvent(new Event("scroll"));
|
|
});
|
|
|
|
harness.setScrollHeight(8_000);
|
|
rerender({
|
|
activeConversationId: "conversation-b",
|
|
showTimelineSkeleton: false
|
|
});
|
|
expect(harness.timeline.scrollTop).toBe(7_900);
|
|
|
|
rerender({
|
|
activeConversationId: "conversation-a",
|
|
showTimelineSkeleton: true
|
|
});
|
|
expect(harness.timeline.scrollTop).toBe(7_900);
|
|
expect(result.current.isTimelineScrolledToBottom).toBe(false);
|
|
|
|
harness.setScrollHeight(5_000);
|
|
rerender({
|
|
activeConversationId: "conversation-a",
|
|
showTimelineSkeleton: false
|
|
});
|
|
expect(harness.timeline.scrollTop).toBe(2_000);
|
|
expect(result.current.isTimelineScrolledToBottom).toBe(false);
|
|
});
|
|
|
|
it("restores a virtualized conversation's detached scroll position", () => {
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const firstController = virtualScrollController("conversation-a", false);
|
|
harness.virtualScrollControllerRef.current = firstController;
|
|
const { result, rerender } = renderHook(
|
|
({ activeConversationId }) =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({ activeConversationId, showTimelineSkeleton: false })
|
|
),
|
|
{ initialProps: { activeConversationId: "conversation-a" } }
|
|
);
|
|
|
|
act(() => {
|
|
harness.timeline.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
|
|
harness.timeline.scrollTop = 2_000;
|
|
harness.timeline.dispatchEvent(new Event("scroll"));
|
|
});
|
|
expect(result.current.isTimelineScrolledToBottom).toBe(false);
|
|
|
|
const secondController = virtualScrollController("conversation-b");
|
|
harness.virtualScrollControllerRef.current = secondController;
|
|
rerender({ activeConversationId: "conversation-b" });
|
|
expect(secondController.scrollToEnd).toHaveBeenCalledWith({
|
|
behavior: "auto"
|
|
});
|
|
|
|
harness.virtualScrollControllerRef.current = firstController;
|
|
firstController.scrollToEnd.mockClear();
|
|
rerender({ activeConversationId: "conversation-a" });
|
|
|
|
expect(firstController.scrollToOffset).toHaveBeenCalledWith(2_000, {
|
|
behavior: "auto"
|
|
});
|
|
expect(firstController.scrollToEnd).not.toHaveBeenCalled();
|
|
expect(result.current.isTimelineScrolledToBottom).toBe(false);
|
|
});
|
|
|
|
it("reads timeline geometry once for a semantic conversation switch", () => {
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const { rerender } = renderHook(
|
|
({ activeConversationId }) =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({ activeConversationId, showTimelineSkeleton: false })
|
|
),
|
|
{ initialProps: { activeConversationId: "conversation-a" } }
|
|
);
|
|
|
|
expect(harness.geometryReadCounts()).toEqual({
|
|
clientHeight: 1,
|
|
scrollHeight: 1,
|
|
scrollTop: 0
|
|
});
|
|
|
|
harness.resetGeometryReadCounts();
|
|
harness.setScrollHeight(8_000);
|
|
rerender({ activeConversationId: "conversation-b" });
|
|
|
|
expect(harness.geometryReadCounts()).toEqual({
|
|
clientHeight: 1,
|
|
scrollHeight: 1,
|
|
scrollTop: 0
|
|
});
|
|
});
|
|
|
|
it("delegates a virtualized conversation switch and bottom request to TanStack", () => {
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const controller = virtualScrollController("conversation-virtualized");
|
|
harness.virtualScrollControllerRef.current = controller;
|
|
const { result } = renderHook(() =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-virtualized",
|
|
showTimelineSkeleton: false
|
|
})
|
|
)
|
|
);
|
|
|
|
expect(controller.scrollToEnd).toHaveBeenCalledWith({ behavior: "auto" });
|
|
expect(harness.scrollTopWriteCount()).toBe(0);
|
|
expect(harness.geometryReadCounts()).toEqual({
|
|
clientHeight: 0,
|
|
scrollHeight: 0,
|
|
scrollTop: 0
|
|
});
|
|
controller.scrollToEnd.mockClear();
|
|
|
|
act(() => result.current.scrollTimelineToBottom());
|
|
|
|
expect(controller.scrollToEnd).toHaveBeenCalledWith({
|
|
behavior: "smooth"
|
|
});
|
|
expect(harness.scrollTopWriteCount()).toBe(0);
|
|
});
|
|
|
|
it("positions a virtualized conversation when its controller becomes ready after layout", () => {
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const controller = virtualScrollController("conversation-virtualized");
|
|
const { result } = renderHook(() =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-virtualized",
|
|
showTimelineSkeleton: false
|
|
})
|
|
)
|
|
);
|
|
|
|
act(() => result.current.setVirtualScrollController(controller));
|
|
|
|
expect(controller.scrollToEnd).toHaveBeenCalledWith({ behavior: "auto" });
|
|
});
|
|
|
|
it("does not use a previous Session virtual controller", () => {
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const staleController = virtualScrollController("conversation-previous");
|
|
harness.virtualScrollControllerRef.current = staleController;
|
|
const { result } = renderHook(() =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-current",
|
|
showTimelineSkeleton: false
|
|
})
|
|
)
|
|
);
|
|
|
|
act(() => result.current.scrollTimelineToBottom());
|
|
|
|
expect(staleController.scrollToEnd).not.toHaveBeenCalled();
|
|
expect(harness.scrollTopWriteCount()).toBe(0);
|
|
});
|
|
|
|
it("explicitly returns a submitted prompt to the virtualized end", () => {
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const controller = virtualScrollController("conversation-submit");
|
|
harness.virtualScrollControllerRef.current = controller;
|
|
const { rerender } = renderHook(
|
|
({ conversation }) =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-submit",
|
|
conversation,
|
|
showTimelineSkeleton: false
|
|
})
|
|
),
|
|
{ initialProps: { conversation: conversationVM("first") } }
|
|
);
|
|
controller.scrollToEnd.mockClear();
|
|
harness.submittedPromptScrollConversationRef.current =
|
|
"conversation-submit";
|
|
|
|
rerender({ conversation: conversationVM("second") });
|
|
|
|
expect(controller.scrollToEnd).toHaveBeenCalledWith({ behavior: "auto" });
|
|
});
|
|
|
|
it("leaves virtualized prepend compensation to TanStack", () => {
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const controller = virtualScrollController("conversation-prepend", false);
|
|
harness.virtualScrollControllerRef.current = controller;
|
|
const { rerender } = renderHook(
|
|
({ isLoadingOlderMessages }) =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-prepend",
|
|
isLoadingOlderMessages,
|
|
showTimelineSkeleton: false
|
|
})
|
|
),
|
|
{ initialProps: { isLoadingOlderMessages: false } }
|
|
);
|
|
controller.scrollToEnd.mockClear();
|
|
harness.timeline.scrollTop = 200;
|
|
harness.pendingPrependScrollAnchorRef.current = {
|
|
conversationId: "conversation-prepend",
|
|
scrollHeight: 5_000,
|
|
scrollTop: 200
|
|
};
|
|
harness.setScrollHeight(6_000);
|
|
harness.resetScrollTopWriteCount();
|
|
|
|
rerender({ isLoadingOlderMessages: true });
|
|
|
|
expect(controller.scrollToEnd).not.toHaveBeenCalled();
|
|
expect(harness.timeline.scrollTop).toBe(200);
|
|
expect(harness.scrollTopWriteCount()).toBe(0);
|
|
});
|
|
|
|
it("does not re-follow virtualized content after user scroll-away intent", () => {
|
|
const resizeObservers = installResizeObserverMock();
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const controller = virtualScrollController("conversation-away", false);
|
|
harness.virtualScrollControllerRef.current = controller;
|
|
renderHook(() =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-away",
|
|
showTimelineSkeleton: false
|
|
})
|
|
)
|
|
);
|
|
controller.scrollToEnd.mockClear();
|
|
const timelineObserver = resizeObservers.find((observer) =>
|
|
observer.observed.has(harness.timelineContent)
|
|
);
|
|
|
|
act(() => {
|
|
harness.timeline.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
|
|
harness.timeline.dispatchEvent(
|
|
new KeyboardEvent("keydown", { key: "PageUp" })
|
|
);
|
|
harness.timeline.dispatchEvent(new Event("pointerdown"));
|
|
harness.timeline.scrollTop = 2_000;
|
|
harness.timeline.dispatchEvent(new Event("scroll"));
|
|
window.dispatchEvent(new Event("pointerup"));
|
|
timelineObserver?.callback([], timelineObserver);
|
|
});
|
|
|
|
expect(controller.scrollToEnd).not.toHaveBeenCalled();
|
|
expect(harness.timeline.scrollTop).toBe(2_000);
|
|
});
|
|
|
|
it("releases virtualized bottom lock after one upward mouse-wheel step", () => {
|
|
const resizeObservers = installResizeObserverMock();
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const controller = virtualScrollController("conversation-wheel-step");
|
|
harness.virtualScrollControllerRef.current = controller;
|
|
const { result, rerender } = renderHook(
|
|
({ conversation }) =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-wheel-step",
|
|
conversation,
|
|
showTimelineSkeleton: false
|
|
})
|
|
),
|
|
{ initialProps: { conversation: conversationVM("first") } }
|
|
);
|
|
controller.scrollToEnd.mockClear();
|
|
const timelineObserver = resizeObservers.find((observer) =>
|
|
observer.observed.has(harness.timelineContent)
|
|
);
|
|
|
|
act(() => {
|
|
harness.timeline.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
|
|
harness.timeline.scrollTop = 4_899;
|
|
harness.timeline.dispatchEvent(new Event("scroll"));
|
|
timelineObserver?.callback([], timelineObserver);
|
|
});
|
|
|
|
expect(controller.scrollToEnd).not.toHaveBeenCalled();
|
|
expect(harness.timeline.scrollTop).toBe(4_899);
|
|
expect(result.current.isTimelineScrolledToBottom).toBe(false);
|
|
|
|
rerender({ conversation: conversationVM("second") });
|
|
act(() => timelineObserver?.callback([], timelineObserver));
|
|
|
|
expect(controller.scrollToEnd).not.toHaveBeenCalled();
|
|
expect(harness.timeline.scrollTop).toBe(4_899);
|
|
expect(result.current.isTimelineScrolledToBottom).toBe(false);
|
|
});
|
|
|
|
it("releases and restores the DOM bottom lock around a mouse-wheel step", () => {
|
|
const resizeObservers = installResizeObserverMock();
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const { result, rerender } = renderHook(
|
|
({ conversation }) =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-dom-wheel-step",
|
|
conversation,
|
|
showTimelineSkeleton: false
|
|
})
|
|
),
|
|
{ initialProps: { conversation: conversationVM("first") } }
|
|
);
|
|
const timelineObserver = resizeObservers.find((observer) =>
|
|
observer.observed.has(harness.timelineContent)
|
|
);
|
|
harness.resetScrollTopWriteCount();
|
|
|
|
act(() => {
|
|
harness.timeline.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 }));
|
|
timelineObserver?.callback([], timelineObserver);
|
|
harness.timeline.scrollTop = 4_899;
|
|
harness.timeline.dispatchEvent(new Event("scroll"));
|
|
});
|
|
|
|
expect(harness.scrollTopWriteCount()).toBe(1);
|
|
expect(harness.timeline.scrollTop).toBe(4_899);
|
|
expect(result.current.isTimelineScrolledToBottom).toBe(false);
|
|
|
|
harness.setScrollHeight(5_100);
|
|
rerender({ conversation: conversationVM("second") });
|
|
act(() => timelineObserver?.callback([], timelineObserver));
|
|
|
|
expect(harness.scrollTopWriteCount()).toBe(1);
|
|
expect(harness.timeline.scrollTop).toBe(4_899);
|
|
expect(result.current.isTimelineScrolledToBottom).toBe(false);
|
|
|
|
act(() => {
|
|
harness.timeline.dispatchEvent(new WheelEvent("wheel", { deltaY: 1 }));
|
|
harness.timeline.scrollTop = 5_000;
|
|
harness.timeline.dispatchEvent(new Event("scroll"));
|
|
});
|
|
|
|
expect(result.current.isTimelineScrolledToBottom).toBe(true);
|
|
});
|
|
|
|
it("does not scroll the retained previous timeline when selection changes", () => {
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const { rerender } = renderHook(
|
|
({ activeConversationId, timelineConversationId }) =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId,
|
|
showTimelineSkeleton: false,
|
|
timelineConversationId
|
|
})
|
|
),
|
|
{
|
|
initialProps: {
|
|
activeConversationId: "conversation-a",
|
|
timelineConversationId: "conversation-a"
|
|
}
|
|
}
|
|
);
|
|
|
|
act(() => {
|
|
harness.timeline.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
|
|
harness.timeline.scrollTop = 2_000;
|
|
harness.timeline.dispatchEvent(new Event("scroll"));
|
|
});
|
|
rerender({
|
|
activeConversationId: "conversation-b",
|
|
timelineConversationId: "conversation-a"
|
|
});
|
|
expect(harness.timeline.scrollTop).toBe(2_000);
|
|
|
|
harness.setScrollHeight(8_000);
|
|
rerender({
|
|
activeConversationId: "conversation-b",
|
|
timelineConversationId: "conversation-b"
|
|
});
|
|
expect(harness.timeline.scrollTop).toBe(7_900);
|
|
});
|
|
|
|
it("restores bottom lock when a retained conversation is reselected", () => {
|
|
const resizeObservers = installResizeObserverMock();
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const { rerender } = renderHook(
|
|
({ activeConversationId }) =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId,
|
|
showTimelineSkeleton: false,
|
|
timelineConversationId: "conversation-a"
|
|
})
|
|
),
|
|
{ initialProps: { activeConversationId: "conversation-a" } }
|
|
);
|
|
expect(harness.timeline.scrollTop).toBe(4_900);
|
|
|
|
rerender({ activeConversationId: "conversation-b" });
|
|
rerender({ activeConversationId: "conversation-a" });
|
|
harness.setScrollHeight(6_000);
|
|
const timelineObserver = resizeObservers.find((observer) =>
|
|
observer.observed.has(harness.timelineContent)
|
|
);
|
|
expect(timelineObserver).toBeDefined();
|
|
act(() => {
|
|
timelineObserver?.callback([], timelineObserver);
|
|
});
|
|
|
|
expect(harness.timeline.scrollTop).toBe(5_900);
|
|
});
|
|
|
|
it("defers conversation-switch geometry until the timeline skeleton resolves", () => {
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const { rerender, result } = renderHook(
|
|
({ activeConversationId, showTimelineSkeleton }) =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId,
|
|
showTimelineSkeleton
|
|
})
|
|
),
|
|
{
|
|
initialProps: {
|
|
activeConversationId: "conversation-a",
|
|
showTimelineSkeleton: false
|
|
}
|
|
}
|
|
);
|
|
|
|
expect(harness.timeline.scrollTop).toBe(4_900);
|
|
act(() => {
|
|
harness.timeline.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
|
|
harness.timeline.scrollTop = 2_000;
|
|
harness.timeline.dispatchEvent(new Event("scroll"));
|
|
});
|
|
expect(result.current.isTimelineScrolledToTop).toBe(false);
|
|
expect(result.current.isTimelineScrolledToBottom).toBe(false);
|
|
harness.resetGeometryReadCounts();
|
|
|
|
harness.setScrollHeight(100);
|
|
rerender({
|
|
activeConversationId: "conversation-b",
|
|
showTimelineSkeleton: true
|
|
});
|
|
|
|
expect(harness.geometryReadCounts()).toEqual({
|
|
clientHeight: 0,
|
|
scrollHeight: 0,
|
|
scrollTop: 0
|
|
});
|
|
expect(result.current.isTimelineScrolledToTop).toBe(true);
|
|
expect(result.current.isTimelineScrolledToBottom).toBe(true);
|
|
expect(harness.timeline.scrollTop).toBe(2_000);
|
|
|
|
harness.setScrollHeight(8_000);
|
|
rerender({
|
|
activeConversationId: "conversation-b",
|
|
showTimelineSkeleton: false
|
|
});
|
|
|
|
expect(harness.geometryReadCounts()).toEqual({
|
|
clientHeight: 1,
|
|
scrollHeight: 1,
|
|
scrollTop: 1
|
|
});
|
|
expect(harness.timeline.scrollTop).toBe(7_900);
|
|
});
|
|
|
|
it("does not let a previous conversation bottom frame override newer user scroll", () => {
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const animationFrames: FrameRequestCallback[] = [];
|
|
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
|
animationFrames.push(callback);
|
|
return animationFrames.length;
|
|
});
|
|
const { rerender } = renderHook(
|
|
({ activeConversationId, showTimelineSkeleton }) =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId,
|
|
showTimelineSkeleton
|
|
})
|
|
),
|
|
{
|
|
initialProps: {
|
|
activeConversationId: "conversation-a",
|
|
showTimelineSkeleton: false
|
|
}
|
|
}
|
|
);
|
|
expect(animationFrames).toHaveLength(1);
|
|
|
|
rerender({
|
|
activeConversationId: "conversation-b",
|
|
showTimelineSkeleton: true
|
|
});
|
|
harness.setScrollHeight(5_000);
|
|
rerender({
|
|
activeConversationId: "conversation-b",
|
|
showTimelineSkeleton: false
|
|
});
|
|
act(() => {
|
|
harness.timeline.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
|
|
harness.timeline.scrollTop = 4_000;
|
|
harness.timeline.dispatchEvent(new Event("scroll"));
|
|
});
|
|
act(() => animationFrames[0]?.(0));
|
|
|
|
expect(harness.timeline.scrollTop).toBe(4_000);
|
|
});
|
|
|
|
it("starts observing content growth after the timeline skeleton resolves", () => {
|
|
const resizeObservers = installResizeObserverMock();
|
|
const harness = createHarness({ scrollHeight: 100 });
|
|
|
|
const { rerender } = renderHook(
|
|
({ showTimelineSkeleton }) =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-growing",
|
|
showTimelineSkeleton
|
|
})
|
|
),
|
|
{ initialProps: { showTimelineSkeleton: true } }
|
|
);
|
|
|
|
let timelineObserver = resizeObservers.find((observer) =>
|
|
observer.observed.has(harness.timelineContent)
|
|
);
|
|
expect(timelineObserver).toBeDefined();
|
|
expect(harness.geometryReadCounts()).toEqual({
|
|
clientHeight: 0,
|
|
scrollHeight: 0,
|
|
scrollTop: 0
|
|
});
|
|
|
|
harness.setScrollHeight(5_000);
|
|
act(() => {
|
|
timelineObserver?.callback([], timelineObserver);
|
|
});
|
|
expect(harness.geometryReadCounts()).toEqual({
|
|
clientHeight: 0,
|
|
scrollHeight: 0,
|
|
scrollTop: 0
|
|
});
|
|
expect(harness.timeline.scrollTop).toBe(0);
|
|
|
|
rerender({ showTimelineSkeleton: false });
|
|
|
|
expect(harness.geometryReadCounts()).toEqual({
|
|
clientHeight: 1,
|
|
scrollHeight: 1,
|
|
scrollTop: 1
|
|
});
|
|
expect(harness.timeline.scrollTop).toBe(4_900);
|
|
|
|
timelineObserver = resizeObservers.find((observer) =>
|
|
observer.observed.has(harness.timelineContent)
|
|
);
|
|
harness.setScrollHeight(6_000);
|
|
act(() => {
|
|
timelineObserver?.callback([], timelineObserver);
|
|
});
|
|
expect(harness.timeline.scrollTop).toBe(5_900);
|
|
});
|
|
|
|
it("keeps the bottom lock through virtualizer-driven scroll changes", () => {
|
|
const resizeObservers = installResizeObserverMock();
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
|
|
const { result } = renderHook(() =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-virtualized",
|
|
hasOlderMessages: true,
|
|
showTimelineSkeleton: false
|
|
})
|
|
)
|
|
);
|
|
const timelineObserver = resizeObservers.find((observer) =>
|
|
observer.observed.has(harness.timelineContent)
|
|
);
|
|
expect(timelineObserver).toBeDefined();
|
|
|
|
act(() => {
|
|
harness.timeline.scrollTop = 100;
|
|
harness.timeline.dispatchEvent(new Event("scroll"));
|
|
});
|
|
|
|
expect(harness.timeline.scrollTop).toBe(4_900);
|
|
expect(result.current.isTimelineScrolledToBottom).toBe(true);
|
|
expect(harness.loadOlderConversationMessages).not.toHaveBeenCalled();
|
|
|
|
harness.setScrollHeight(6_000);
|
|
act(() => {
|
|
timelineObserver?.callback([], timelineObserver);
|
|
});
|
|
|
|
expect(harness.timeline.scrollTop).toBe(5_900);
|
|
});
|
|
|
|
it("releases the bottom lock after the user scrolls upward", () => {
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
|
|
renderHook(() =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-user-scroll",
|
|
showTimelineSkeleton: false
|
|
})
|
|
)
|
|
);
|
|
|
|
act(() => {
|
|
harness.timeline.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
|
|
harness.timeline.scrollTop = 4_000;
|
|
harness.timeline.dispatchEvent(new Event("scroll"));
|
|
});
|
|
expect(harness.timeline.scrollTop).toBe(4_000);
|
|
|
|
harness.setScrollHeight(6_000);
|
|
act(() => {
|
|
harness.timeline.dispatchEvent(new Event("scroll"));
|
|
});
|
|
|
|
expect(harness.timeline.scrollTop).toBe(4_000);
|
|
});
|
|
|
|
it("releases the bottom lock during pointer-driven scrolling", () => {
|
|
const resizeObservers = installResizeObserverMock();
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
|
|
renderHook(() =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-pointer-scroll",
|
|
showTimelineSkeleton: false
|
|
})
|
|
)
|
|
);
|
|
const timelineObserver = resizeObservers.find((observer) =>
|
|
observer.observed.has(harness.timelineContent)
|
|
);
|
|
expect(timelineObserver).toBeDefined();
|
|
|
|
act(() => {
|
|
harness.timeline.dispatchEvent(new Event("pointerdown"));
|
|
harness.timeline.scrollTop = 4_000;
|
|
harness.timeline.dispatchEvent(new Event("scroll"));
|
|
window.dispatchEvent(new Event("pointerup"));
|
|
});
|
|
|
|
harness.setScrollHeight(6_000);
|
|
act(() => {
|
|
timelineObserver?.callback([], timelineObserver);
|
|
});
|
|
|
|
expect(harness.timeline.scrollTop).toBe(4_000);
|
|
});
|
|
|
|
it("releases the bottom lock when a locator initiates scrolling", () => {
|
|
const resizeObservers = installResizeObserverMock();
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const locator = document.createElement("button");
|
|
locator.setAttribute("data-agent-transcript-scroll-away-intent", "");
|
|
harness.timeline.appendChild(locator);
|
|
|
|
renderHook(() =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-locator-scroll",
|
|
showTimelineSkeleton: false
|
|
})
|
|
)
|
|
);
|
|
const timelineObserver = resizeObservers.find((observer) =>
|
|
observer.observed.has(harness.timelineContent)
|
|
);
|
|
expect(timelineObserver).toBeDefined();
|
|
|
|
act(() => {
|
|
locator.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
harness.timeline.scrollTop = 2_000;
|
|
harness.timeline.dispatchEvent(new Event("scroll"));
|
|
});
|
|
|
|
harness.setScrollHeight(6_000);
|
|
act(() => {
|
|
timelineObserver?.callback([], timelineObserver);
|
|
});
|
|
|
|
expect(harness.timeline.scrollTop).toBe(2_000);
|
|
});
|
|
|
|
it("does not synchronously read full timeline geometry during scrolling", () => {
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
|
|
renderHook(() =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-scroll-hot-path",
|
|
showTimelineSkeleton: false
|
|
})
|
|
)
|
|
);
|
|
harness.resetGeometryReadCounts();
|
|
|
|
act(() => {
|
|
harness.timeline.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
|
|
harness.timeline.scrollTop = 4_000;
|
|
harness.timeline.dispatchEvent(new Event("scroll"));
|
|
});
|
|
|
|
expect(harness.geometryReadCounts()).toEqual({
|
|
clientHeight: 0,
|
|
scrollHeight: 0,
|
|
scrollTop: 1
|
|
});
|
|
});
|
|
|
|
it("does not synchronously read timeline geometry for a streaming update", () => {
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const { rerender } = renderHook(
|
|
({ conversation }) =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-streaming",
|
|
conversation,
|
|
showTimelineSkeleton: false
|
|
})
|
|
),
|
|
{ initialProps: { conversation: conversationVM("first") } }
|
|
);
|
|
harness.resetGeometryReadCounts();
|
|
|
|
rerender({ conversation: conversationVM("second") });
|
|
|
|
expect(harness.geometryReadCounts()).toEqual({
|
|
clientHeight: 0,
|
|
scrollHeight: 0,
|
|
scrollTop: 0
|
|
});
|
|
});
|
|
|
|
it("prefetches older messages from the initialized anchor without rereading scrollTop", () => {
|
|
const harness = createHarness({ scrollHeight: 100 });
|
|
harness.renderTranscriptRow();
|
|
|
|
renderHook(() =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-prefetch",
|
|
hasOlderMessages: true,
|
|
showTimelineSkeleton: false
|
|
})
|
|
)
|
|
);
|
|
|
|
expect(harness.loadOlderConversationMessages).toHaveBeenCalledOnce();
|
|
expect(harness.geometryReadCounts()).toEqual({
|
|
clientHeight: 1,
|
|
scrollHeight: 1,
|
|
scrollTop: 0
|
|
});
|
|
});
|
|
|
|
it("waits for the timeline skeleton to resolve before filling the viewport", () => {
|
|
const harness = createHarness({ scrollHeight: 100 });
|
|
harness.renderTranscriptRow();
|
|
const { rerender } = renderHook(
|
|
({ showTimelineSkeleton }) =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-skeleton-prefetch",
|
|
hasOlderMessages: true,
|
|
showTimelineSkeleton
|
|
})
|
|
),
|
|
{ initialProps: { showTimelineSkeleton: true } }
|
|
);
|
|
|
|
expect(harness.loadOlderConversationMessages).not.toHaveBeenCalled();
|
|
|
|
rerender({ showTimelineSkeleton: false });
|
|
|
|
expect(harness.loadOlderConversationMessages).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("restores a prepend anchor from one timeline geometry snapshot", () => {
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const { rerender } = renderHook(
|
|
({ isLoadingOlderMessages }) =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-prepend",
|
|
isLoadingOlderMessages,
|
|
showTimelineSkeleton: false
|
|
})
|
|
),
|
|
{ initialProps: { isLoadingOlderMessages: false } }
|
|
);
|
|
|
|
act(() => {
|
|
harness.timeline.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
|
|
harness.timeline.scrollTop = 200;
|
|
harness.timeline.dispatchEvent(new Event("scroll"));
|
|
});
|
|
harness.pendingPrependScrollAnchorRef.current = {
|
|
conversationId: "conversation-prepend",
|
|
scrollHeight: 5_000,
|
|
scrollTop: 200
|
|
};
|
|
harness.setScrollHeight(6_000);
|
|
harness.resetGeometryReadCounts();
|
|
|
|
rerender({ isLoadingOlderMessages: true });
|
|
|
|
expect(harness.geometryReadCounts()).toEqual({
|
|
clientHeight: 1,
|
|
scrollHeight: 1,
|
|
scrollTop: 0
|
|
});
|
|
expect(harness.timeline.scrollTop).toBe(1_200);
|
|
});
|
|
|
|
it("reads timeline geometry once for an explicit scroll to bottom", () => {
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const { result } = renderHook(() =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-scroll-bottom",
|
|
showTimelineSkeleton: false
|
|
})
|
|
)
|
|
);
|
|
harness.timeline.scrollTop = 2_000;
|
|
harness.resetGeometryReadCounts();
|
|
|
|
act(() => result.current.scrollTimelineToBottom());
|
|
|
|
expect(harness.geometryReadCounts()).toEqual({
|
|
clientHeight: 1,
|
|
scrollHeight: 1,
|
|
scrollTop: 0
|
|
});
|
|
expect(harness.timeline.scrollTop).toBe(4_900);
|
|
});
|
|
|
|
it("keeps the bottom lock through observed streaming content growth", () => {
|
|
const resizeObservers = installResizeObserverMock();
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
|
|
renderHook(() =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-observed",
|
|
showTimelineSkeleton: false
|
|
})
|
|
)
|
|
);
|
|
const timelineObserver = resizeObservers.find((observer) =>
|
|
observer.observed.has(harness.timelineContent)
|
|
);
|
|
expect(timelineObserver).toBeDefined();
|
|
|
|
harness.setScrollHeight(6_000);
|
|
act(() => {
|
|
timelineObserver?.callback([], timelineObserver);
|
|
});
|
|
|
|
expect(harness.timeline.scrollTop).toBe(5_900);
|
|
});
|
|
|
|
it("preserves user scroll-away through observed streaming content growth", () => {
|
|
const resizeObservers = installResizeObserverMock();
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
|
|
renderHook(() =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-observed-away",
|
|
showTimelineSkeleton: false
|
|
})
|
|
)
|
|
);
|
|
const timelineObserver = resizeObservers.find((observer) =>
|
|
observer.observed.has(harness.timelineContent)
|
|
);
|
|
expect(timelineObserver).toBeDefined();
|
|
act(() => {
|
|
harness.timeline.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
|
|
harness.timeline.scrollTop = 4_000;
|
|
harness.timeline.dispatchEvent(new Event("scroll"));
|
|
});
|
|
|
|
harness.setScrollHeight(6_000);
|
|
act(() => {
|
|
timelineObserver?.callback([], timelineObserver);
|
|
});
|
|
|
|
expect(harness.timeline.scrollTop).toBe(4_000);
|
|
});
|
|
|
|
it("moves floating dock controls above a growing composer without reserving timeline space", () => {
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const composerInputShell = document.createElement("div");
|
|
const promptInputArea = document.createElement("div");
|
|
const clippedEditorContent = document.createElement("div");
|
|
composerInputShell.className = "agent-gui-node__composer-input-shell";
|
|
promptInputArea.className = "agent-gui-node__composer-prompt-input-area";
|
|
promptInputArea.appendChild(clippedEditorContent);
|
|
composerInputShell.appendChild(promptInputArea);
|
|
harness.bottomDock.appendChild(composerInputShell);
|
|
harness.bottomDock.getBoundingClientRect = vi.fn(() =>
|
|
mockRect({ top: 400, bottom: 500, width: 600, height: 100 })
|
|
);
|
|
composerInputShell.getBoundingClientRect = vi.fn(() =>
|
|
mockRect({ top: 320, bottom: 500, width: 600, height: 180 })
|
|
);
|
|
promptInputArea.getBoundingClientRect = vi.fn(() =>
|
|
mockRect({ top: 320, bottom: 450, width: 600, height: 130 })
|
|
);
|
|
clippedEditorContent.getBoundingClientRect = vi.fn(() =>
|
|
mockRect({ top: 240, bottom: 440, width: 560, height: 200 })
|
|
);
|
|
|
|
renderHook(() =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-growing-composer",
|
|
showTimelineSkeleton: false
|
|
})
|
|
)
|
|
);
|
|
|
|
expect(
|
|
harness.timeline.style.getPropertyValue(
|
|
"--agent-gui-bottom-dock-safe-area"
|
|
)
|
|
).toBe("0px");
|
|
expect(
|
|
harness.bottomDock.style.getPropertyValue(
|
|
"--agent-gui-bottom-dock-floating-safe-area"
|
|
)
|
|
).toBe("80px");
|
|
});
|
|
|
|
it("reuses dock safe-area geometry across a conversation switch", () => {
|
|
const resizeObservers = installResizeObserverMock();
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const animationFrames: FrameRequestCallback[] = [];
|
|
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
|
animationFrames.push(callback);
|
|
return animationFrames.length;
|
|
});
|
|
const dockRect = vi
|
|
.spyOn(harness.bottomDock, "getBoundingClientRect")
|
|
.mockReturnValue(
|
|
mockRect({ top: 400, bottom: 500, width: 600, height: 100 })
|
|
);
|
|
const { rerender } = renderHook(
|
|
({ activeConversationId }) =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId,
|
|
showTimelineSkeleton: false
|
|
})
|
|
),
|
|
{ initialProps: { activeConversationId: "conversation-a" } }
|
|
);
|
|
expect(dockRect).toHaveBeenCalledOnce();
|
|
const dockObserver = resizeObservers.find((observer) =>
|
|
observer.observed.has(harness.bottomDock)
|
|
);
|
|
expect(dockObserver).toBeDefined();
|
|
act(() => {
|
|
dockObserver?.callback([], dockObserver);
|
|
});
|
|
expect(dockRect).toHaveBeenCalledTimes(2);
|
|
dockRect.mockClear();
|
|
|
|
harness.setScrollHeight(8_000);
|
|
rerender({ activeConversationId: "conversation-b" });
|
|
|
|
expect(dockRect).not.toHaveBeenCalled();
|
|
expect(
|
|
resizeObservers.filter((observer) =>
|
|
observer.observed.has(harness.bottomDock)
|
|
)
|
|
).toEqual([dockObserver]);
|
|
expect(harness.timeline.scrollTop).toBe(7_900);
|
|
act(() => animationFrames.at(-1)?.(0));
|
|
expect(harness.timeline.scrollTop).toBe(7_900);
|
|
|
|
harness.setScrollHeight(9_000);
|
|
const timelineObserver = resizeObservers.find((observer) =>
|
|
observer.observed.has(harness.timelineContent)
|
|
);
|
|
expect(timelineObserver).toBeDefined();
|
|
act(() => {
|
|
timelineObserver?.callback([], timelineObserver);
|
|
});
|
|
|
|
expect(harness.timeline.scrollTop).toBe(8_900);
|
|
expect(dockRect).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("disconnects dock observation when the timeline conversation clears", () => {
|
|
const resizeObservers = installResizeObserverMock();
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const { rerender } = renderHook(
|
|
({ timelineConversationId }: { timelineConversationId: string | null }) =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-cleared",
|
|
showTimelineSkeleton: false,
|
|
timelineConversationId
|
|
})
|
|
),
|
|
{
|
|
initialProps: {
|
|
timelineConversationId: "conversation-cleared" as string | null
|
|
}
|
|
}
|
|
);
|
|
const dockObserver = resizeObservers.find((observer) =>
|
|
observer.observed.has(harness.bottomDock)
|
|
);
|
|
expect(dockObserver).toBeDefined();
|
|
|
|
rerender({ timelineConversationId: null });
|
|
|
|
expect(dockObserver?.observed.size).toBe(0);
|
|
expect(
|
|
resizeObservers.some((observer) =>
|
|
observer.observed.has(harness.bottomDock)
|
|
)
|
|
).toBe(false);
|
|
});
|
|
|
|
it("disconnects layout observation while fully occluded and catches up when exposed", () => {
|
|
const resizeObservers = installResizeObserverMock();
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const { rerender } = renderHook(
|
|
({ conversation, isVisible }) =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-visibility",
|
|
conversation,
|
|
isVisible,
|
|
showTimelineSkeleton: false
|
|
})
|
|
),
|
|
{
|
|
initialProps: {
|
|
conversation: conversationVM("initial"),
|
|
isVisible: true
|
|
}
|
|
}
|
|
);
|
|
const visibleDockObserver = resizeObservers.find((observer) =>
|
|
observer.observed.has(harness.bottomDock)
|
|
);
|
|
const visibleTimelineObserver = resizeObservers.find((observer) =>
|
|
observer.observed.has(harness.timelineContent)
|
|
);
|
|
expect(visibleDockObserver).toBeDefined();
|
|
expect(visibleTimelineObserver).toBeDefined();
|
|
|
|
rerender({ conversation: conversationVM("hidden"), isVisible: false });
|
|
|
|
expect(visibleDockObserver?.observed.size).toBe(0);
|
|
expect(visibleTimelineObserver?.observed.size).toBe(0);
|
|
harness.resetGeometryReadCounts();
|
|
harness.resetScrollTopWriteCount();
|
|
harness.setScrollHeight(8_000);
|
|
rerender({ conversation: conversationVM("streaming"), isVisible: false });
|
|
expect(harness.geometryReadCounts()).toEqual({
|
|
clientHeight: 0,
|
|
scrollHeight: 0,
|
|
scrollTop: 0
|
|
});
|
|
expect(harness.scrollTopWriteCount()).toBe(0);
|
|
|
|
rerender({ conversation: conversationVM("streaming"), isVisible: true });
|
|
|
|
const resumedTimelineObserver = resizeObservers.find(
|
|
(observer) =>
|
|
observer !== visibleTimelineObserver &&
|
|
observer.observed.has(harness.timelineContent)
|
|
);
|
|
expect(resumedTimelineObserver).toBeDefined();
|
|
act(() => {
|
|
resumedTimelineObserver?.callback([], resumedTimelineObserver);
|
|
});
|
|
expect(harness.timeline.scrollTop).toBe(7_900);
|
|
expect(harness.geometryReadCounts()).toEqual({
|
|
clientHeight: 1,
|
|
scrollHeight: 1,
|
|
scrollTop: 2
|
|
});
|
|
expect(harness.scrollTopWriteCount()).toBe(1);
|
|
});
|
|
|
|
it("restores a following virtualized conversation after exposure", () => {
|
|
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
|
callback(0);
|
|
return 1;
|
|
});
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const controller = virtualScrollController("conversation-exposure");
|
|
harness.virtualScrollControllerRef.current = controller;
|
|
const hiddenConversation = conversationVM("hidden-update");
|
|
const { rerender } = renderHook(
|
|
({ conversation, isVisible }) =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-exposure",
|
|
conversation,
|
|
isVisible,
|
|
showTimelineSkeleton: false
|
|
})
|
|
),
|
|
{
|
|
initialProps: {
|
|
conversation: conversationVM("initial"),
|
|
isVisible: true
|
|
}
|
|
}
|
|
);
|
|
controller.scrollToEnd.mockClear();
|
|
|
|
rerender({ conversation: hiddenConversation, isVisible: false });
|
|
rerender({ conversation: hiddenConversation, isVisible: true });
|
|
|
|
expect(controller.scrollToEnd).toHaveBeenCalledWith({ behavior: "auto" });
|
|
});
|
|
|
|
it("remeasures dock safe-area after store and ResizeObserver invalidation", () => {
|
|
const resizeObservers = installResizeObserverMock();
|
|
const harness = createHarness({ scrollHeight: 5_000 });
|
|
const liftedChrome = document.createElement("div");
|
|
harness.bottomDock.appendChild(liftedChrome);
|
|
let liftedTop = 350;
|
|
harness.bottomDock.getBoundingClientRect = vi.fn(() =>
|
|
mockRect({ top: 400, bottom: 500, width: 600, height: 100 })
|
|
);
|
|
const liftedRect = vi
|
|
.spyOn(liftedChrome, "getBoundingClientRect")
|
|
.mockImplementation(() =>
|
|
mockRect({
|
|
top: liftedTop,
|
|
bottom: liftedTop + 40,
|
|
width: 600,
|
|
height: 40
|
|
})
|
|
);
|
|
const { rerender } = renderHook(
|
|
({ bottomDockStoreRevision }) =>
|
|
useAgentGUIDetailScroll(
|
|
harness.input({
|
|
activeConversationId: "conversation-dock-invalidation",
|
|
bottomDockStoreRevision,
|
|
showTimelineSkeleton: false
|
|
})
|
|
),
|
|
{ initialProps: { bottomDockStoreRevision: "first" } }
|
|
);
|
|
|
|
expect(
|
|
harness.timeline.style.getPropertyValue(
|
|
"--agent-gui-bottom-dock-safe-area"
|
|
)
|
|
).toBe("50px");
|
|
liftedRect.mockClear();
|
|
liftedTop = 320;
|
|
|
|
rerender({ bottomDockStoreRevision: "second" });
|
|
|
|
expect(liftedRect).toHaveBeenCalledOnce();
|
|
expect(
|
|
harness.timeline.style.getPropertyValue(
|
|
"--agent-gui-bottom-dock-safe-area"
|
|
)
|
|
).toBe("80px");
|
|
|
|
liftedRect.mockClear();
|
|
liftedTop = 300;
|
|
const dockObserver = resizeObservers.find((observer) =>
|
|
observer.observed.has(harness.bottomDock)
|
|
);
|
|
expect(dockObserver).toBeDefined();
|
|
act(() => {
|
|
dockObserver?.callback([], dockObserver);
|
|
});
|
|
|
|
expect(liftedRect).toHaveBeenCalledOnce();
|
|
expect(
|
|
harness.timeline.style.getPropertyValue(
|
|
"--agent-gui-bottom-dock-safe-area"
|
|
)
|
|
).toBe("100px");
|
|
});
|
|
});
|
|
|
|
function mockRect(input: {
|
|
top: number;
|
|
bottom: number;
|
|
width: number;
|
|
height: number;
|
|
}): DOMRect {
|
|
return {
|
|
...input,
|
|
left: 0,
|
|
right: input.width,
|
|
x: 0,
|
|
y: input.top,
|
|
toJSON: () => ({})
|
|
} as DOMRect;
|
|
}
|
|
|
|
function createHarness(input: { scrollHeight: number }) {
|
|
const timeline = document.createElement("div");
|
|
const timelineContent = document.createElement("div");
|
|
const bottomDock = document.createElement("div");
|
|
let scrollTop = 0;
|
|
let scrollHeight = input.scrollHeight;
|
|
let clientHeightReadCount = 0;
|
|
let scrollHeightReadCount = 0;
|
|
let scrollTopReadCount = 0;
|
|
let scrollTopWriteCount = 0;
|
|
Object.defineProperties(timeline, {
|
|
clientHeight: {
|
|
configurable: true,
|
|
get: () => {
|
|
clientHeightReadCount += 1;
|
|
return 100;
|
|
}
|
|
},
|
|
scrollHeight: {
|
|
configurable: true,
|
|
get: () => {
|
|
scrollHeightReadCount += 1;
|
|
return scrollHeight;
|
|
}
|
|
},
|
|
scrollTop: {
|
|
configurable: true,
|
|
get: () => {
|
|
scrollTopReadCount += 1;
|
|
return scrollTop;
|
|
},
|
|
set: (value: number) => {
|
|
scrollTopWriteCount += 1;
|
|
scrollTop = value;
|
|
}
|
|
}
|
|
});
|
|
timeline.scrollTo = ((options: ScrollToOptions) => {
|
|
scrollTop = options.top ?? scrollTop;
|
|
}) as typeof timeline.scrollTo;
|
|
|
|
const timelineScrollAnchorRef = mutableRef<{
|
|
conversationId: string;
|
|
scrollHeight: number;
|
|
scrollTop: number;
|
|
clientHeight: number;
|
|
} | null>(null);
|
|
const pendingPrependScrollAnchorRef = mutableRef<{
|
|
conversationId: string;
|
|
scrollHeight: number;
|
|
scrollTop: number;
|
|
} | null>(null);
|
|
const submittedPromptScrollConversationRef = mutableRef<string | null>(null);
|
|
const virtualScrollControllerRef =
|
|
mutableRef<AgentTranscriptVirtualScrollController | null>(null);
|
|
const loadOlderConversationMessages = vi.fn();
|
|
const actions = {
|
|
loadOlderConversationMessages
|
|
} as unknown as AgentGUINodeViewProps["actions"];
|
|
|
|
return {
|
|
bottomDock,
|
|
loadOlderConversationMessages,
|
|
pendingPrependScrollAnchorRef,
|
|
submittedPromptScrollConversationRef,
|
|
timeline,
|
|
timelineContent,
|
|
virtualScrollControllerRef,
|
|
resetGeometryReadCounts() {
|
|
clientHeightReadCount = 0;
|
|
scrollHeightReadCount = 0;
|
|
scrollTopReadCount = 0;
|
|
},
|
|
resetScrollTopWriteCount() {
|
|
scrollTopWriteCount = 0;
|
|
},
|
|
renderTranscriptRow() {
|
|
const row = document.createElement("div");
|
|
row.dataset.agentTranscriptRow = "row";
|
|
timelineContent.append(row);
|
|
},
|
|
scrollTopWriteCount() {
|
|
return scrollTopWriteCount;
|
|
},
|
|
geometryReadCounts() {
|
|
return {
|
|
clientHeight: clientHeightReadCount,
|
|
scrollHeight: scrollHeightReadCount,
|
|
scrollTop: scrollTopReadCount
|
|
};
|
|
},
|
|
setScrollHeight(value: number) {
|
|
scrollHeight = value;
|
|
},
|
|
input(options: {
|
|
activeConversationId: string;
|
|
bottomDockStoreRevision?: string;
|
|
conversation?: AgentConversationVM;
|
|
hasOlderMessages?: boolean;
|
|
isLoadingOlderMessages?: boolean;
|
|
isVisible?: boolean;
|
|
showTimelineSkeleton: boolean;
|
|
timelineConversationId?: string | null;
|
|
}) {
|
|
return {
|
|
actions,
|
|
bottomDockRef: ref(bottomDock),
|
|
bottomDockStoreRevision: options.bottomDockStoreRevision ?? "stable",
|
|
conversation: options.conversation ?? null,
|
|
isVisible: options.isVisible ?? true,
|
|
pendingPrependScrollAnchorRef,
|
|
showTimelineSkeleton: options.showTimelineSkeleton,
|
|
submittedPromptScrollConversationRef,
|
|
timelineConversationId:
|
|
options.timelineConversationId === undefined
|
|
? options.activeConversationId
|
|
: options.timelineConversationId,
|
|
timelineContentRef: ref(timelineContent),
|
|
timelineRef: ref(timeline),
|
|
timelineScrollAnchorRef,
|
|
virtualScrollControllerRef,
|
|
viewModel: viewModel(
|
|
options.activeConversationId,
|
|
options.hasOlderMessages,
|
|
options.isLoadingOlderMessages
|
|
)
|
|
};
|
|
}
|
|
};
|
|
}
|
|
|
|
function conversationVM(id: string): AgentConversationVM {
|
|
return { id } as unknown as AgentConversationVM;
|
|
}
|
|
|
|
function virtualScrollController(
|
|
agentSessionId: string,
|
|
atEnd = true
|
|
): AgentTranscriptVirtualScrollController & {
|
|
isAtEnd: Mock<() => boolean>;
|
|
scrollToEnd: Mock<(options?: { behavior?: ScrollBehavior }) => void>;
|
|
scrollToOffset: Mock<
|
|
(offset: number, options?: { behavior?: ScrollBehavior }) => void
|
|
>;
|
|
} {
|
|
return {
|
|
agentSessionId,
|
|
enabled: true,
|
|
isAtEnd: vi.fn(() => atEnd),
|
|
scrollToEnd: vi.fn<(options?: { behavior?: ScrollBehavior }) => void>(),
|
|
scrollToOffset:
|
|
vi.fn<(offset: number, options?: { behavior?: ScrollBehavior }) => void>()
|
|
};
|
|
}
|
|
|
|
interface ResizeObserverMock extends ResizeObserver {
|
|
readonly callback: ResizeObserverCallback;
|
|
readonly observed: Set<Element>;
|
|
}
|
|
|
|
function installResizeObserverMock(): ResizeObserverMock[] {
|
|
const observers: ResizeObserverMock[] = [];
|
|
class TestResizeObserver implements ResizeObserverMock {
|
|
readonly observed = new Set<Element>();
|
|
|
|
constructor(readonly callback: ResizeObserverCallback) {
|
|
observers.push(this);
|
|
}
|
|
|
|
observe(target: Element): void {
|
|
this.observed.add(target);
|
|
}
|
|
|
|
unobserve(target: Element): void {
|
|
this.observed.delete(target);
|
|
}
|
|
|
|
disconnect(): void {
|
|
this.observed.clear();
|
|
}
|
|
}
|
|
vi.stubGlobal("ResizeObserver", TestResizeObserver);
|
|
return observers;
|
|
}
|
|
|
|
function viewModel(
|
|
activeConversationId: string,
|
|
hasOlderMessages = false,
|
|
isLoadingOlderMessages = false
|
|
): AgentGUINodeViewModel {
|
|
return {
|
|
rail: { activeConversationId },
|
|
detail: {
|
|
hasOlderMessages,
|
|
isLoadingOlderMessages
|
|
}
|
|
} as unknown as AgentGUINodeViewModel;
|
|
}
|
|
|
|
function mutableRef<T>(current: T): MutableRefObject<T> {
|
|
return { current };
|
|
}
|
|
|
|
function ref<T>(current: T): RefObject<T> {
|
|
return { current };
|
|
}
|