Files
kandev/apps/web/components/task/task-chat-panel.scroll-target.test.tsx
2026-09-20 11:33:30 +01:00

1454 lines
52 KiB
TypeScript

/* eslint-disable max-lines -- related prompt-target lifecycle regressions share one focused harness. */
import { StrictMode } from "react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MessageListHandle } from "./chat/message-list-shared";
import type { LoadMessageWindowResult } from "@/hooks/domains/session/load-message-window";
const { mockDockviewState, mockAppStoreState, mockAppStoreApi } = vi.hoisted(() => {
const state = {
scrollTarget: null as null | {
sessionId: string;
messageId: string;
token: number;
hostPanelId: string;
},
clearScrollTarget: vi.fn((token: number) => {
if (state.scrollTarget?.token === token) state.scrollTarget = null;
}),
clearScrollTargetForOwner: vi.fn((sessionId: string, hostPanelId: string) => {
if (
state.scrollTarget?.sessionId === sessionId &&
state.scrollTarget.hostPanelId === hostPanelId
) {
state.scrollTarget = null;
}
}),
clearScrollTargetForSession: vi.fn((sessionId: string) => {
if (state.scrollTarget?.sessionId === sessionId) state.scrollTarget = null;
}),
};
const appStoreState = {
messages: { bySession: { "session-1": [{ id: "message-1" }] } },
};
return {
mockDockviewState: state,
mockAppStoreState: appStoreState,
mockAppStoreApi: {
getState: () => ({ messages: appStoreState.messages, mergeMessages: vi.fn() }),
},
};
});
vi.mock("@/lib/state/dockview-store", () => ({
useDockviewStore: Object.assign(
(selector: (state: typeof mockDockviewState) => unknown) => selector(mockDockviewState),
{ getState: () => mockDockviewState },
),
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: vi.fn(),
useAppStoreApi: () => mockAppStoreApi,
}));
vi.mock("@/hooks/domains/session/load-message-window", () => ({
loadMessageWindowAround: vi.fn(),
}));
import {
isMessageRowRendered,
usePendingMessageScroll,
usePendingScrollToStart,
useScrollTargetConsumption,
type PendingMessageScrollTarget,
} from "./task-chat-panel";
import type { RenderItem } from "@/hooks/use-processed-messages";
import { loadMessageWindowAround } from "@/hooks/domains/session/load-message-window";
describe("isMessageRowRendered", () => {
it("only treats direct message items as scrollable rows", () => {
const items = [
{
type: "turn_group",
id: "group-1",
turnId: "turn-1",
messages: [{ id: "grouped-prompt" }],
},
{ type: "message", message: { id: "direct-prompt" } },
] as unknown as RenderItem[];
expect(isMessageRowRendered(items, "grouped-prompt")).toBe(false);
expect(isMessageRowRendered(items, "direct-prompt")).toBe(true);
});
});
const FIRST_PROMPT_MESSAGE_ID = "first-prompt";
describe("usePendingScrollToStart", () => {
it("retries after the first prompt row is not mounted yet", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const scrollToMessage = vi.fn().mockReturnValueOnce(false).mockReturnValue(true);
const messageListRef = { current: { scrollToMessage } };
const onComplete = vi.fn();
renderHook(() =>
usePendingScrollToStart({
messageListRef,
firstMessageId: FIRST_PROMPT_MESSAGE_ID,
hasMore: false,
pending: true,
requestKey: 1,
onComplete,
}),
);
await flushFrames();
expect(scrollToMessage).toHaveBeenCalledTimes(1);
expect(onComplete).not.toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(50);
});
expect(scrollToMessage).toHaveBeenCalledTimes(2);
expect(scrollToMessage).toHaveBeenLastCalledWith(FIRST_PROMPT_MESSAGE_ID, {
align: "start",
});
expect(onComplete).toHaveBeenCalledWith(true);
});
it("waits for pagination to finish before attempting the target", async () => {
const messageListRef = { current: scrollHandle(true) };
const onComplete = vi.fn();
const { rerender } = renderHook(
({ hasMore }: { hasMore: boolean }) =>
usePendingScrollToStart({
messageListRef,
firstMessageId: FIRST_PROMPT_MESSAGE_ID,
hasMore,
pending: true,
requestKey: 1,
onComplete,
}),
{ initialProps: { hasMore: true } },
);
await flushFrames();
expect(messageListRef.current?.scrollToMessage).not.toHaveBeenCalled();
rerender({ hasMore: false });
await flushFrames();
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledWith(FIRST_PROMPT_MESSAGE_ID, {
align: "start",
});
expect(onComplete).toHaveBeenCalledWith(true);
});
});
let pendingFrames: Array<(() => void) | undefined> = [];
/** Runs two rounds of pending requestAnimationFrame callbacks inside act. */
async function flushFrames() {
await act(async () => {
const frames = pendingFrames.splice(0);
for (const frame of frames) frame?.();
});
await act(async () => {
const frames = pendingFrames.splice(0);
for (const frame of frames) frame?.();
});
}
type ScrollTarget = NonNullable<typeof mockDockviewState.scrollTarget>;
/** Builds a dockview scroll-target object with defaults, merged with the given overrides. */
function target(overrides: Partial<ScrollTarget> = {}): ScrollTarget {
return {
sessionId: "session-1",
messageId: "message-1",
token: 7,
hostPanelId: "panel-a",
...overrides,
};
}
const DELETED_MESSAGE_ID = "deleted";
const DELETED_TARGET_RESULT: LoadMessageWindowResult = {
kind: "deleted-target",
merged: false,
current: true,
targetFound: false,
};
/** Builds a MessageListHandle whose scrollToMessage mock returns the given value. */
function scrollHandle(returns: boolean): MessageListHandle {
return { scrollToMessage: vi.fn(() => returns) };
}
const PROPS = {
resolvedSessionId: "session-1",
isVisible: true,
panelId: "panel-a",
isInitialMessagesLoading: false,
renderedMessageCount: 0,
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(loadMessageWindowAround).mockReset();
pendingFrames = [];
vi.stubGlobal("requestAnimationFrame", (callback: () => void) => {
pendingFrames.push(callback);
return pendingFrames.length;
});
vi.stubGlobal("cancelAnimationFrame", (id: number) => {
pendingFrames[id - 1] = undefined;
});
mockDockviewState.scrollTarget = null;
mockAppStoreState.messages.bySession["session-1"] = [{ id: "message-1" }];
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
afterEach(() => {
act(() => {});
});
describe("useScrollTargetConsumption — success-path consumption", () => {
it("scrolls the target row and clears by token on a successful scroll", async () => {
mockDockviewState.scrollTarget = target();
const messageListRef = { current: scrollHandle(true) };
renderHook(() => useScrollTargetConsumption({ ...PROPS, messageListRef }));
await flushFrames();
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledWith("message-1", {
align: "start",
});
expect(mockDockviewState.clearScrollTarget).toHaveBeenCalledWith(7);
expect(mockDockviewState.scrollTarget).toBeNull();
});
it("does not consume when another panel owns the target (exact-owner gate)", () => {
mockDockviewState.scrollTarget = target({ hostPanelId: "panel-b" });
const messageListRef = { current: scrollHandle(true) };
renderHook(() => useScrollTargetConsumption({ ...PROPS, messageListRef }));
expect(messageListRef.current?.scrollToMessage).not.toHaveBeenCalled();
expect(mockDockviewState.clearScrollTarget).not.toHaveBeenCalled();
});
it("does not let a delayed consumer of request A clear or scroll request B", async () => {
// Task 03 supersession: B lands while A's deferred consumer is pending.
// A's stale consumer must not scroll A's row nor clear B; the token
// re-check in the deferred callback bails.
mockDockviewState.scrollTarget = target(); // A: token 7
const messageListRef = { current: scrollHandle(true) };
const { rerender } = renderHook(() => useScrollTargetConsumption({ ...PROPS, messageListRef }));
// B supersedes A before A's deferred frame executes.
mockDockviewState.scrollTarget = target({ messageId: "message-b", token: 8 });
await flushFrames();
expect(messageListRef.current?.scrollToMessage).not.toHaveBeenCalled();
expect(mockDockviewState.clearScrollTarget).not.toHaveBeenCalled();
expect(mockDockviewState.scrollTarget?.token).toBe(8);
// When the owner of B re-renders, B consumes normally.
rerender();
await flushFrames();
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledWith("message-b", {
align: "start",
});
expect(mockDockviewState.clearScrollTarget).toHaveBeenCalledWith(8);
expect(mockDockviewState.scrollTarget).toBeNull();
});
it("lets only the exact-owner host consume when a canonical chat and a session panel are both mounted and active", async () => {
// Task 03 race: `session:<id>` and the canonical `chat` host are mounted
// simultaneously (a real transient layout state), both reporting
// isVisible=true. Only the host whose panelId equals the target's
// hostPanelId may scroll and clear; the hidden canonical duplicate must
// never consume the target meant for the visible transcript.
mockDockviewState.scrollTarget = target();
const sessionHostRef = { current: scrollHandle(true) };
const canonicalHostRef = { current: scrollHandle(true) };
renderHook(() => useScrollTargetConsumption({ ...PROPS, messageListRef: sessionHostRef }));
renderHook(() =>
useScrollTargetConsumption({
...PROPS,
panelId: "chat",
messageListRef: canonicalHostRef,
}),
);
await flushFrames();
expect(sessionHostRef.current?.scrollToMessage).toHaveBeenCalledWith("message-1", {
align: "start",
});
expect(mockDockviewState.clearScrollTarget).toHaveBeenCalledWith(7);
expect(canonicalHostRef.current?.scrollToMessage).not.toHaveBeenCalled();
expect(mockDockviewState.clearScrollTargetForSession).not.toHaveBeenCalled();
});
it("does not consume when the target belongs to another session", () => {
mockDockviewState.scrollTarget = target({ sessionId: "session-2" });
const messageListRef = { current: scrollHandle(true) };
renderHook(() => useScrollTargetConsumption({ ...PROPS, messageListRef }));
expect(messageListRef.current?.scrollToMessage).not.toHaveBeenCalled();
expect(mockDockviewState.clearScrollTarget).not.toHaveBeenCalled();
});
});
// eslint-disable-next-line max-lines-per-function -- pending-target request and timer regressions share one fixture.
describe("usePendingMessageScroll — non-Dockview target loading", () => {
it("loads an absent target and reasserts it after the transcript merge", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const targetMessage = { id: "target" };
vi.mocked(loadMessageWindowAround).mockImplementationOnce(async () => {
mockAppStoreState.messages.bySession["session-1"] = [targetMessage];
return { kind: "merged", merged: true, current: true, targetFound: true };
});
const messageListRef = { current: scrollHandle(true) };
const onConsumed = vi.fn();
const { rerender } = renderHook(
({ readinessKey }) =>
usePendingMessageScroll({
messageListRef,
sessionId: "session-1",
messageId: "target",
onConsumed,
readinessKey,
isInitialMessagesLoading: false,
}),
{ initialProps: { readinessKey: "0" } },
);
messageListRef.current = scrollHandle(false);
await flushFrames();
expect(loadMessageWindowAround).toHaveBeenCalledWith(
"session-1",
"target",
expect.any(Function),
expect.anything(),
);
messageListRef.current = scrollHandle(true);
rerender({ readinessKey: "1" });
await flushFrames();
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledWith("target", {
align: "start",
behavior: "auto",
});
expect(onConsumed).not.toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledTimes(2);
expect(onConsumed).toHaveBeenCalledWith("target");
vi.useRealTimers();
});
it("consumes a deleted mobile target when around loading settles before scroll", async () => {
const pending = Promise.withResolvers<LoadMessageWindowResult>();
vi.mocked(loadMessageWindowAround).mockReturnValue(pending.promise);
mockAppStoreState.messages.bySession["session-1"] = [];
const messageListRef = { current: scrollHandle(false) };
const onConsumed = vi.fn();
const { rerender } = renderHook(
({ readinessKey }) =>
usePendingMessageScroll({
messageListRef,
sessionId: "session-1",
messageId: DELETED_MESSAGE_ID,
onConsumed,
readinessKey,
isInitialMessagesLoading: false,
}),
{ initialProps: { readinessKey: "a" } },
);
await flushFrames();
pending.resolve(DELETED_TARGET_RESULT);
await act(async () => {
await Promise.resolve();
});
messageListRef.current = scrollHandle(true);
rerender({ readinessKey: "b" });
await flushFrames();
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledTimes(1);
expect(onConsumed).toHaveBeenCalledWith(DELETED_MESSAGE_ID);
});
it("handles mobile around results that settle after the first scroll", async () => {
const pending = Promise.withResolvers<LoadMessageWindowResult>();
vi.mocked(loadMessageWindowAround).mockReturnValue(pending.promise);
mockAppStoreState.messages.bySession["session-1"] = [];
const scrollToMessage = vi.fn().mockReturnValueOnce(false).mockReturnValue(true);
const messageListRef = { current: { scrollToMessage } };
const onConsumed = vi.fn();
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const { rerender } = renderHook(
({ readinessKey }) =>
usePendingMessageScroll({
messageListRef,
sessionId: "session-1",
messageId: "target",
onConsumed,
readinessKey,
isInitialMessagesLoading: false,
}),
{ initialProps: { readinessKey: "a" } },
);
await flushFrames();
messageListRef.current = { scrollToMessage };
scrollToMessage.mockReturnValue(true);
rerender({ readinessKey: "b" });
await flushFrames();
expect(scrollToMessage).toHaveBeenCalledTimes(2);
pending.resolve({ kind: "merged", merged: true, current: true, targetFound: true });
await act(async () => {
await Promise.resolve();
});
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
expect(scrollToMessage).toHaveBeenCalledTimes(3);
expect(onConsumed).toHaveBeenCalledWith("target");
vi.useRealTimers();
});
it("consumes a deleted mobile target after the first scroll", async () => {
const pending = Promise.withResolvers<LoadMessageWindowResult>();
vi.mocked(loadMessageWindowAround).mockReturnValue(pending.promise);
mockAppStoreState.messages.bySession["session-1"] = [];
const scrollToMessage = vi.fn().mockReturnValueOnce(false).mockReturnValue(true);
const messageListRef = { current: { scrollToMessage } };
const onConsumed = vi.fn();
const { rerender } = renderHook(
({ readinessKey }) =>
usePendingMessageScroll({
messageListRef,
sessionId: "session-1",
messageId: DELETED_MESSAGE_ID,
onConsumed,
readinessKey,
isInitialMessagesLoading: false,
}),
{ initialProps: { readinessKey: "a" } },
);
await flushFrames();
messageListRef.current = { scrollToMessage };
scrollToMessage.mockReturnValue(true);
rerender({ readinessKey: "b" });
await flushFrames();
pending.resolve(DELETED_TARGET_RESULT);
await act(async () => {
await Promise.resolve();
});
expect(scrollToMessage).toHaveBeenCalledTimes(2);
expect(onConsumed).toHaveBeenCalledWith(DELETED_MESSAGE_ID);
});
it("ignores an obsolete mobile around-window settlement after target replacement", async () => {
const first = Promise.withResolvers<LoadMessageWindowResult>();
const second = Promise.withResolvers<LoadMessageWindowResult>();
vi.mocked(loadMessageWindowAround)
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
mockAppStoreState.messages.bySession["session-1"] = [];
const targetA = {
sessionId: "session-1",
messageId: "message-a",
token: 1,
hostPanelId: "mobile-chat",
};
const targetB = { ...targetA, messageId: "message-b", token: 2 };
const messageListRef = { current: scrollHandle(false) };
const onConsumed = vi.fn();
const { rerender } = renderHook(
({ target, readinessKey }) =>
usePendingMessageScroll({
messageListRef,
sessionId: "session-1",
messageId: null,
target,
onConsumed,
readinessKey,
isInitialMessagesLoading: false,
}),
{ initialProps: { target: targetA, readinessKey: "a" } },
);
await flushFrames();
rerender({ target: targetB, readinessKey: "b" });
await flushFrames();
expect(loadMessageWindowAround).toHaveBeenCalledTimes(2);
first.resolve({ kind: "merged", merged: true, current: true, targetFound: true });
await act(async () => {
await Promise.resolve();
});
expect(onConsumed).not.toHaveBeenCalled();
second.resolve(DELETED_TARGET_RESULT);
await act(async () => {
await Promise.resolve();
});
expect(onConsumed).toHaveBeenCalledWith("message-b");
});
it("consumes a mobile target after a failed delayed reassertion", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
vi.mocked(loadMessageWindowAround).mockImplementationOnce(async () => {
mockAppStoreState.messages.bySession["session-1"] = [{ id: "message-a" }];
return { kind: "merged", merged: true, current: true, targetFound: true };
});
mockAppStoreState.messages.bySession["session-1"] = [];
const scrollToMessage = vi
.fn()
.mockReturnValueOnce(false)
.mockReturnValueOnce(true)
.mockReturnValue(false);
const messageListRef = { current: { scrollToMessage } };
const onConsumed = vi.fn();
const { rerender } = renderHook(
({ readinessKey, messageId }) =>
usePendingMessageScroll({
messageListRef,
sessionId: "session-1",
messageId,
onConsumed,
readinessKey,
isInitialMessagesLoading: false,
}),
{ initialProps: { readinessKey: "a", messageId: "message-a" as string | null } },
);
await flushFrames();
await act(async () => {
await Promise.resolve();
});
rerender({ readinessKey: "b", messageId: "message-a" });
await flushFrames();
expect(scrollToMessage).toHaveBeenCalledTimes(2);
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
expect(scrollToMessage).toHaveBeenCalledTimes(3);
expect(onConsumed).toHaveBeenCalledWith("message-a");
rerender({ readinessKey: "c", messageId: null });
await flushFrames();
expect(scrollToMessage).toHaveBeenCalledTimes(3);
});
it("consumes an already loaded target in one pass", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const messageListRef = { current: scrollHandle(true) };
const onConsumed = vi.fn();
renderHook(() =>
usePendingMessageScroll({
messageListRef,
sessionId: "session-1",
messageId: "message-1",
onConsumed,
readinessKey: "0",
isInitialMessagesLoading: false,
}),
);
await flushFrames();
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledTimes(1);
expect(onConsumed).toHaveBeenCalledWith("message-1");
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledTimes(1);
});
it("remains active after StrictMode effect replay", async () => {
const messageListRef = { current: scrollHandle(true) };
const onConsumed = vi.fn();
renderHook(
() =>
usePendingMessageScroll({
messageListRef,
sessionId: "session-1",
messageId: "message-1",
onConsumed,
readinessKey: "0",
isInitialMessagesLoading: false,
}),
{ wrapper: StrictMode },
);
await flushFrames();
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledTimes(1);
expect(onConsumed).toHaveBeenCalledWith("message-1");
});
it("does not scroll or load while a pending host is hidden", async () => {
const messageListRef = { current: scrollHandle(false) };
const onConsumed = vi.fn();
const { rerender } = renderHook(
({ isVisible }) =>
usePendingMessageScroll({
messageListRef,
sessionId: "session-1",
messageId: "missing",
onConsumed,
readinessKey: "0",
isInitialMessagesLoading: false,
isVisible,
}),
{ initialProps: { isVisible: false } },
);
await flushFrames();
expect(messageListRef.current?.scrollToMessage).not.toHaveBeenCalled();
expect(loadMessageWindowAround).not.toHaveBeenCalled();
messageListRef.current = scrollHandle(true);
rerender({ isVisible: true });
await flushFrames();
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledWith("missing", {
align: "start",
behavior: "auto",
});
});
it("defers a pending around request until initial transcript loading settles", async () => {
mockAppStoreState.messages.bySession["session-1"] = [];
vi.mocked(loadMessageWindowAround).mockReturnValue(new Promise(() => {}));
const messageListRef = { current: scrollHandle(false) };
const { rerender } = renderHook(
({ isInitialMessagesLoading }) =>
usePendingMessageScroll({
messageListRef,
sessionId: "session-1",
messageId: "missing",
readinessKey: "0",
onConsumed: vi.fn(),
isInitialMessagesLoading,
}),
{ initialProps: { isInitialMessagesLoading: true } },
);
await flushFrames();
expect(loadMessageWindowAround).not.toHaveBeenCalled();
rerender({ isInitialMessagesLoading: false });
await flushFrames();
expect(loadMessageWindowAround).toHaveBeenCalledTimes(1);
});
it("rejects a target owned by another host session", async () => {
const messageListRef = { current: scrollHandle(false) };
const onConsumed = vi.fn();
const target: PendingMessageScrollTarget = {
sessionId: "session-a",
messageId: "message-a",
token: 3,
hostPanelId: "mobile-chat",
};
renderHook(() =>
usePendingMessageScroll({
messageListRef,
sessionId: "session-b",
messageId: null,
target,
onConsumed,
readinessKey: "0",
isInitialMessagesLoading: false,
}),
);
await flushFrames();
expect(messageListRef.current?.scrollToMessage).not.toHaveBeenCalled();
expect(loadMessageWindowAround).not.toHaveBeenCalled();
expect(onConsumed).toHaveBeenCalledWith("message-a");
});
it("cancels a failed around request without leaving a stuck mobile target", async () => {
vi.mocked(loadMessageWindowAround).mockRejectedValueOnce(new Error("offline"));
const messageListRef = { current: scrollHandle(false) };
const onConsumed = vi.fn();
renderHook(() =>
usePendingMessageScroll({
messageListRef,
sessionId: "session-1",
messageId: "target",
onConsumed,
readinessKey: "0",
isInitialMessagesLoading: false,
}),
);
await flushFrames();
await waitFor(() => expect(onConsumed).toHaveBeenCalledWith("target"));
expect(loadMessageWindowAround).toHaveBeenCalledTimes(1);
});
it("keeps the request guard valid when transcript readiness changes mid-request", async () => {
const pending = Promise.withResolvers<LoadMessageWindowResult>();
vi.mocked(loadMessageWindowAround).mockReturnValueOnce(pending.promise);
const messageListRef = { current: scrollHandle(false) };
const onConsumed = vi.fn();
const { rerender } = renderHook(
({ readinessKey }) =>
usePendingMessageScroll({
messageListRef,
sessionId: "session-1",
messageId: "target",
onConsumed,
readinessKey,
isInitialMessagesLoading: false,
}),
{ initialProps: { readinessKey: "0" } },
);
await flushFrames();
rerender({ readinessKey: "1" });
await flushFrames();
expect(loadMessageWindowAround).toHaveBeenCalledTimes(1);
pending.reject(new Error("offline"));
await waitFor(() => expect(onConsumed).toHaveBeenCalledWith("target"));
});
});
describe("useScrollTargetConsumption — non-dockview hosts (no panelId)", () => {
it("does not consume without a panelId (non-dockview hosts stay unchanged)", () => {
mockDockviewState.scrollTarget = target();
const messageListRef = { current: scrollHandle(true) };
renderHook(() => useScrollTargetConsumption({ ...PROPS, panelId: null, messageListRef }));
expect(messageListRef.current?.scrollToMessage).not.toHaveBeenCalled();
});
it("does not clear a dockview target when a non-dockview host unmounts", () => {
// Task 03: preview/center/mobile hosts (panelId=null) mount and unmount
// freely; their unmount cleanup must not clear a target a dockview host
// is waiting to consume.
mockDockviewState.scrollTarget = target();
const messageListRef = { current: scrollHandle(false) };
const { unmount } = renderHook(() =>
useScrollTargetConsumption({ ...PROPS, panelId: null, messageListRef }),
);
unmount();
expect(mockDockviewState.clearScrollTargetForSession).not.toHaveBeenCalled();
expect(mockDockviewState.scrollTarget).not.toBeNull();
});
it("does not clear a dockview target when a non-dockview host swaps sessions", () => {
mockDockviewState.scrollTarget = target();
const messageListRef = { current: scrollHandle(false) };
const { rerender } = renderHook(
({ resolvedSessionId }) =>
useScrollTargetConsumption({
...PROPS,
panelId: null,
resolvedSessionId,
messageListRef,
}),
{ initialProps: { resolvedSessionId: "session-1" } },
);
rerender({ resolvedSessionId: "session-2" });
expect(mockDockviewState.clearScrollTargetForSession).not.toHaveBeenCalled();
expect(mockDockviewState.scrollTarget).not.toBeNull();
});
it("lets the dockview owner consume the retained target after non-dockview churn", async () => {
mockDockviewState.scrollTarget = target();
const nonDockviewRef = { current: scrollHandle(false) };
const ownerRef = { current: scrollHandle(true) };
const { unmount } = renderHook(() =>
useScrollTargetConsumption({ ...PROPS, panelId: null, messageListRef: nonDockviewRef }),
);
unmount();
renderHook(() => useScrollTargetConsumption({ ...PROPS, messageListRef: ownerRef }));
await flushFrames();
expect(ownerRef.current?.scrollToMessage).toHaveBeenCalledWith("message-1", {
align: "start",
});
expect(mockDockviewState.clearScrollTarget).toHaveBeenCalledWith(7);
expect(mockDockviewState.scrollTarget).toBeNull();
});
});
it("reasserts a Dockview target when merged loading settles before scroll", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
mockAppStoreState.messages.bySession["session-1"] = [];
const pending = Promise.withResolvers<LoadMessageWindowResult>();
vi.mocked(loadMessageWindowAround).mockReturnValue(pending.promise);
mockDockviewState.scrollTarget = target({ messageId: "target" });
const scrollToMessage = vi.fn().mockReturnValue(false);
const messageListRef = { current: { scrollToMessage } };
const { rerender } = renderHook(
({ renderedMessageCount }) =>
useScrollTargetConsumption({
...PROPS,
renderedMessageCount,
messageListRef,
}),
{ initialProps: { renderedMessageCount: 0 } },
);
await flushFrames();
pending.resolve({ kind: "merged", merged: true, current: true, targetFound: true });
await act(async () => {
await Promise.resolve();
});
scrollToMessage.mockReturnValue(true);
rerender({ renderedMessageCount: 1 });
await flushFrames();
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
expect(scrollToMessage).toHaveBeenCalledTimes(3);
expect(mockDockviewState.clearScrollTarget).toHaveBeenCalledWith(7);
vi.useRealTimers();
});
it("loads a target that is cached but not rendered in the transcript", async () => {
const pending = new Promise<LoadMessageWindowResult>(() => {});
vi.mocked(loadMessageWindowAround).mockReturnValue(pending);
mockDockviewState.scrollTarget = target({ messageId: "message-1" });
const messageListRef = { current: scrollHandle(false) };
renderHook(() =>
useScrollTargetConsumption({
...PROPS,
messageListRef,
}),
);
await flushFrames();
expect(loadMessageWindowAround).toHaveBeenCalledWith(
"session-1",
"message-1",
expect.any(Function),
expect.anything(),
);
});
it("defers an unloaded target while initial loading is active despite a populated cache", async () => {
const pending = new Promise<LoadMessageWindowResult>(() => {});
vi.mocked(loadMessageWindowAround).mockReturnValue(pending);
mockAppStoreState.messages.bySession["session-1"] = [{ id: "newest" }];
mockDockviewState.scrollTarget = target({ messageId: "older-target" });
const messageListRef = { current: scrollHandle(false) };
const { rerender } = renderHook(
({ isInitialMessagesLoading }) =>
useScrollTargetConsumption({
...PROPS,
isInitialMessagesLoading,
messageListRef,
}),
{ initialProps: { isInitialMessagesLoading: true } },
);
await flushFrames();
expect(loadMessageWindowAround).not.toHaveBeenCalled();
rerender({ isInitialMessagesLoading: false });
await flushFrames();
expect(loadMessageWindowAround).toHaveBeenCalledTimes(1);
});
it("clears a deleted Dockview target when loading settles before scroll", async () => {
mockAppStoreState.messages.bySession["session-1"] = [];
const pending = Promise.withResolvers<LoadMessageWindowResult>();
vi.mocked(loadMessageWindowAround).mockReturnValue(pending.promise);
mockDockviewState.scrollTarget = target({ messageId: DELETED_MESSAGE_ID });
const messageListRef = { current: scrollHandle(false) };
renderHook(() => useScrollTargetConsumption({ ...PROPS, messageListRef }));
await flushFrames();
pending.resolve(DELETED_TARGET_RESULT);
await act(async () => {
await Promise.resolve();
});
expect(mockDockviewState.clearScrollTarget).toHaveBeenCalledWith(7);
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledTimes(1);
});
it("reasserts a Dockview target when merged loading settles after scroll", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
mockAppStoreState.messages.bySession["session-1"] = [];
const pending = Promise.withResolvers<LoadMessageWindowResult>();
vi.mocked(loadMessageWindowAround).mockReturnValue(pending.promise);
mockDockviewState.scrollTarget = target({ messageId: "target" });
const scrollToMessage = vi.fn().mockReturnValueOnce(false).mockReturnValue(true);
const messageListRef = { current: { scrollToMessage } };
const { rerender } = renderHook(
({ renderedMessageCount }) =>
useScrollTargetConsumption({
...PROPS,
renderedMessageCount,
messageListRef,
}),
{ initialProps: { renderedMessageCount: 0 } },
);
await flushFrames();
scrollToMessage.mockReturnValue(true);
rerender({ renderedMessageCount: 1 });
await flushFrames();
expect(scrollToMessage).toHaveBeenCalledTimes(2);
pending.resolve({ kind: "merged", merged: true, current: true, targetFound: true });
await act(async () => {
await Promise.resolve();
});
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
expect(scrollToMessage).toHaveBeenCalledTimes(3);
expect(mockDockviewState.clearScrollTarget).toHaveBeenCalledWith(7);
vi.useRealTimers();
});
it("clears a deleted Dockview target after the first scroll", async () => {
mockAppStoreState.messages.bySession["session-1"] = [];
const pending = Promise.withResolvers<LoadMessageWindowResult>();
vi.mocked(loadMessageWindowAround).mockReturnValue(pending.promise);
mockDockviewState.scrollTarget = target({ messageId: DELETED_MESSAGE_ID });
const scrollToMessage = vi.fn().mockReturnValueOnce(false).mockReturnValue(true);
const messageListRef = { current: { scrollToMessage } };
const { rerender } = renderHook(
({ renderedMessageCount }) =>
useScrollTargetConsumption({
...PROPS,
renderedMessageCount,
messageListRef,
}),
{ initialProps: { renderedMessageCount: 0 } },
);
await flushFrames();
scrollToMessage.mockReturnValue(true);
rerender({ renderedMessageCount: 1 });
await flushFrames();
pending.resolve(DELETED_TARGET_RESULT);
await act(async () => {
await Promise.resolve();
});
expect(scrollToMessage).toHaveBeenCalledTimes(2);
expect(mockDockviewState.clearScrollTarget).toHaveBeenCalledWith(7);
});
it("defers an absent around request until initial transcript loading settles", async () => {
mockAppStoreState.messages.bySession["session-1"] = [];
mockDockviewState.scrollTarget = target();
vi.mocked(loadMessageWindowAround).mockReturnValue(new Promise(() => {}));
const messageListRef = { current: scrollHandle(false) };
const { rerender } = renderHook(
({ isInitialMessagesLoading }) =>
useScrollTargetConsumption({
...PROPS,
isInitialMessagesLoading,
messageListRef,
}),
{ initialProps: { isInitialMessagesLoading: true } },
);
await flushFrames();
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledTimes(1);
expect(loadMessageWindowAround).not.toHaveBeenCalled();
rerender({ isInitialMessagesLoading: false });
await flushFrames();
expect(loadMessageWindowAround).toHaveBeenCalledTimes(1);
});
it("does not duplicate an in-flight around request across same-token revisions", async () => {
mockAppStoreState.messages.bySession["session-1"] = [];
mockDockviewState.scrollTarget = target();
const pending = Promise.withResolvers<LoadMessageWindowResult>();
vi.mocked(loadMessageWindowAround).mockReturnValue(pending.promise);
const messageListRef = { current: scrollHandle(false) };
const { rerender } = renderHook(
({ renderedMessageCount }) =>
useScrollTargetConsumption({
...PROPS,
renderedMessageCount,
messageListRef,
}),
{ initialProps: { renderedMessageCount: 0 } },
);
await flushFrames();
rerender({ renderedMessageCount: 1 });
await flushFrames();
rerender({ renderedMessageCount: 2 });
await flushFrames();
expect(loadMessageWindowAround).toHaveBeenCalledTimes(1);
pending.resolve(DELETED_TARGET_RESULT);
await act(async () => {
await Promise.resolve();
});
});
it("settles stale request loading independently from the replacement target", async () => {
mockAppStoreState.messages.bySession["session-1"] = [];
const pendingA = Promise.withResolvers<LoadMessageWindowResult>();
const pendingB = Promise.withResolvers<LoadMessageWindowResult>();
vi.mocked(loadMessageWindowAround).mockImplementation((_sessionId, messageId) =>
messageId === "message-a" ? pendingA.promise : pendingB.promise,
);
mockDockviewState.scrollTarget = target({ messageId: "message-a", token: 7 });
const messageListRef = { current: scrollHandle(false) };
const { result, rerender } = renderHook(
({ renderedMessageCount }) =>
useScrollTargetConsumption({
...PROPS,
renderedMessageCount,
messageListRef,
}),
{ initialProps: { renderedMessageCount: 0 } },
);
await flushFrames();
mockDockviewState.scrollTarget = target({ messageId: "message-b", token: 8 });
rerender({ renderedMessageCount: 1 });
await flushFrames();
expect(loadMessageWindowAround).toHaveBeenCalledTimes(2);
expect(result.current).toBe(true);
pendingA.resolve({ kind: "merged", merged: true, current: true, targetFound: true });
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(result.current).toBe(true);
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledTimes(2);
expect(mockDockviewState.clearScrollTarget).not.toHaveBeenCalled();
expect(mockDockviewState.scrollTarget?.messageId).toBe("message-b");
pendingB.resolve({ kind: "merged", merged: true, current: true, targetFound: true });
await waitFor(() => expect(result.current).toBe(false));
});
it("ignores a stale Dockview around settlement without clearing the active target", async () => {
mockAppStoreState.messages.bySession["session-1"] = [];
const pending = Promise.withResolvers<LoadMessageWindowResult>();
vi.mocked(loadMessageWindowAround).mockReturnValue(pending.promise);
mockDockviewState.scrollTarget = target();
const messageListRef = { current: scrollHandle(false) };
renderHook(() => useScrollTargetConsumption({ ...PROPS, messageListRef }));
await flushFrames();
pending.resolve({ kind: "stale", merged: false, current: false, targetFound: false });
await act(async () => {
await Promise.resolve();
});
expect(mockDockviewState.clearScrollTarget).not.toHaveBeenCalled();
expect(mockDockviewState.scrollTarget).not.toBeNull();
});
it("cancels the delayed pass when the Dockview host becomes hidden", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
mockAppStoreState.messages.bySession["session-1"] = [];
vi.mocked(loadMessageWindowAround).mockImplementationOnce(async () => {
mockAppStoreState.messages.bySession["session-1"] = [{ id: "message-1" }];
return { kind: "merged", merged: true, current: true, targetFound: true };
});
mockDockviewState.scrollTarget = target();
const messageListRef = { current: scrollHandle(false) };
const { rerender } = renderHook(
({ isVisible, renderedMessageCount }) =>
useScrollTargetConsumption({
...PROPS,
isVisible,
renderedMessageCount,
messageListRef,
}),
{ initialProps: { isVisible: true, renderedMessageCount: 0 } },
);
await flushFrames();
await act(async () => {
await Promise.resolve();
});
messageListRef.current = scrollHandle(true);
rerender({ isVisible: true, renderedMessageCount: 1 });
await flushFrames();
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledTimes(1);
rerender({ isVisible: false, renderedMessageCount: 1 });
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledTimes(1);
expect(mockDockviewState.clearScrollTarget).toHaveBeenCalledWith(7);
expect(mockDockviewState.scrollTarget).toBeNull();
});
it("does not consume a mobile target after leaving and returning to Chat", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
mockAppStoreState.messages.bySession["session-1"] = [];
vi.mocked(loadMessageWindowAround).mockImplementationOnce(async () => {
mockAppStoreState.messages.bySession["session-1"] = [{ id: "mobile-target" }];
return { kind: "merged", merged: true, current: true, targetFound: true };
});
const mobileTarget = {
sessionId: "session-1",
messageId: "mobile-target",
token: 11,
hostPanelId: "mobile-chat",
};
const messageListRef = { current: scrollHandle(false) };
const onConsumed = vi.fn();
const { rerender } = renderHook(
({ target, isVisible, readinessKey }) =>
usePendingMessageScroll({
messageListRef,
sessionId: "session-1",
messageId: null,
target,
onConsumed,
readinessKey,
isInitialMessagesLoading: false,
isVisible,
}),
{
initialProps: {
target: mobileTarget as PendingMessageScrollTarget | null,
isVisible: true,
readinessKey: "0",
},
},
);
await flushFrames();
await act(async () => {
await Promise.resolve();
});
messageListRef.current = scrollHandle(true);
rerender({ target: mobileTarget, isVisible: true, readinessKey: "1" });
await flushFrames();
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledTimes(1);
rerender({ target: null, isVisible: false, readinessKey: "2" });
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
rerender({ target: null, isVisible: true, readinessKey: "3" });
await flushFrames();
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledTimes(1);
expect(onConsumed).not.toHaveBeenCalled();
});
describe("useScrollTargetConsumption — delayed-DOM retry", () => {
it("retains the target when the scroll no-ops and consumes once the list is ready", async () => {
mockDockviewState.scrollTarget = target();
const messageListRef = { current: scrollHandle(false) };
const { rerender } = renderHook(
({ isInitialMessagesLoading }) =>
useScrollTargetConsumption({
...PROPS,
isInitialMessagesLoading,
messageListRef,
}),
{ initialProps: { isInitialMessagesLoading: true } },
);
await flushFrames();
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalled();
expect(mockDockviewState.clearScrollTarget).not.toHaveBeenCalled();
expect(mockDockviewState.scrollTarget).not.toBeNull();
// The message list renders the row; the readiness flip retries.
messageListRef.current = scrollHandle(true);
rerender({ isInitialMessagesLoading: false });
await flushFrames();
expect(mockDockviewState.clearScrollTarget).toHaveBeenCalledWith(7);
expect(mockDockviewState.scrollTarget).toBeNull();
});
it("retries when rendered message count changes after loading is complete", async () => {
mockDockviewState.scrollTarget = target();
const messageListRef = { current: scrollHandle(false) };
const { rerender } = renderHook(
({ renderedMessageCount }) =>
useScrollTargetConsumption({
...PROPS,
renderedMessageCount,
targetRendered: true,
messageListRef,
}),
{ initialProps: { renderedMessageCount: 0 } },
);
await flushFrames();
expect(mockDockviewState.scrollTarget).not.toBeNull();
messageListRef.current = scrollHandle(true);
rerender({ renderedMessageCount: 1 });
await flushFrames();
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledWith("message-1", {
align: "start",
});
expect(mockDockviewState.clearScrollTarget).toHaveBeenCalledWith(7);
expect(mockDockviewState.scrollTarget).toBeNull();
});
});
describe("useScrollTargetConsumption — activation gating", () => {
it("retains the target while inactive and consumes on activation without clearing the session", async () => {
mockDockviewState.scrollTarget = target();
const messageListRef = { current: scrollHandle(true) };
const { rerender } = renderHook(
({ isVisible }) => useScrollTargetConsumption({ ...PROPS, isVisible, messageListRef }),
{ initialProps: { isVisible: false } },
);
expect(messageListRef.current?.scrollToMessage).not.toHaveBeenCalled();
expect(mockDockviewState.clearScrollTargetForSession).not.toHaveBeenCalled();
rerender({ isVisible: true });
await flushFrames();
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalled();
expect(mockDockviewState.clearScrollTarget).toHaveBeenCalledWith(7);
expect(mockDockviewState.clearScrollTargetForSession).not.toHaveBeenCalled();
});
});
describe("useScrollTargetConsumption — session-swap invalidation", () => {
it("clears a delayed target for the previous session when the canonical chat swaps sessions", () => {
mockDockviewState.scrollTarget = target();
const messageListRef = { current: scrollHandle(false) };
const { rerender } = renderHook(
({ resolvedSessionId }) =>
useScrollTargetConsumption({ ...PROPS, resolvedSessionId, messageListRef }),
{ initialProps: { resolvedSessionId: "session-1" } },
);
expect(mockDockviewState.scrollTarget).not.toBeNull();
// Canonical chat resolves session B: the A-target must be invalidated.
rerender({ resolvedSessionId: "session-2" });
expect(mockDockviewState.clearScrollTargetForOwner).toHaveBeenCalledWith(
"session-1",
"panel-a",
);
expect(mockDockviewState.scrollTarget).toBeNull();
// Returning to A must NOT re-scroll (the target was cleared).
messageListRef.current = scrollHandle(true);
rerender({ resolvedSessionId: "session-1" });
expect(messageListRef.current?.scrollToMessage).not.toHaveBeenCalled();
});
});
it("retains a pre-existing owned target through StrictMode replay", async () => {
mockDockviewState.scrollTarget = target();
const messageListRef = { current: scrollHandle(true) };
renderHook(() => useScrollTargetConsumption({ ...PROPS, messageListRef }), {
wrapper: StrictMode,
});
await flushFrames();
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledWith("message-1", {
align: "start",
});
});
// eslint-disable-next-line max-lines-per-function -- Canonical-host lifecycle regressions share one focused harness.
describe("useScrollTargetConsumption — canonical-host unmount ownership", () => {
it("clears by the last resolved session and exact panel owner on unmount", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
mockDockviewState.scrollTarget = target();
const messageListRef = { current: scrollHandle(false) };
const { unmount } = renderHook(() => useScrollTargetConsumption({ ...PROPS, messageListRef }));
expect(mockDockviewState.scrollTarget).not.toBeNull();
unmount();
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(mockDockviewState.clearScrollTarget).toHaveBeenCalledWith(7);
expect(mockDockviewState.scrollTarget).toBeNull();
});
it("preserves the same target when its Dockview owner immediately remounts", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
mockDockviewState.scrollTarget = target();
const messageListRef = { current: scrollHandle(false) };
const first = renderHook(() => useScrollTargetConsumption({ ...PROPS, messageListRef }));
first.unmount();
const second = renderHook(() => useScrollTargetConsumption({ ...PROPS, messageListRef }));
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(mockDockviewState.clearScrollTarget).not.toHaveBeenCalled();
expect(mockDockviewState.scrollTarget).not.toBeNull();
second.unmount();
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(mockDockviewState.clearScrollTarget).toHaveBeenCalledWith(7);
});
it("does not let deferred cleanup clear a replacement target", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
mockDockviewState.scrollTarget = target({ token: 7 });
const first = renderHook(() =>
useScrollTargetConsumption({ ...PROPS, messageListRef: { current: scrollHandle(false) } }),
);
first.unmount();
mockDockviewState.scrollTarget = target({ token: 8, messageId: "replacement" });
renderHook(() =>
useScrollTargetConsumption({ ...PROPS, messageListRef: { current: scrollHandle(false) } }),
);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(mockDockviewState.clearScrollTarget).toHaveBeenCalledWith(7);
expect(mockDockviewState.scrollTarget?.token).toBe(8);
});
it("does not clear a target owned by another panel on unmount", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
mockDockviewState.scrollTarget = target({ hostPanelId: "panel-a" });
const messageListRef = { current: scrollHandle(false) };
const { unmount } = renderHook(() =>
useScrollTargetConsumption({ ...PROPS, panelId: "panel-b", messageListRef }),
);
unmount();
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(mockDockviewState.clearScrollTarget).not.toHaveBeenCalled();
expect(mockDockviewState.scrollTarget).not.toBeNull();
});
it("reasserts an unloaded target once after the initial around-window placement", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
vi.mocked(loadMessageWindowAround).mockImplementationOnce(async () => {
mockAppStoreState.messages.bySession["session-1"] = [{ id: "message-1" }];
return { kind: "merged", merged: true, current: true, targetFound: true };
});
mockAppStoreState.messages.bySession["session-1"] = [];
mockDockviewState.scrollTarget = target();
const messageListRef = { current: scrollHandle(false) };
const { rerender } = renderHook(
({ renderedMessageCount }) =>
useScrollTargetConsumption({
...PROPS,
renderedMessageCount,
messageListRef,
}),
{ initialProps: { renderedMessageCount: 0 } },
);
await flushFrames();
expect(loadMessageWindowAround).toHaveBeenCalledTimes(1);
await act(async () => {
await Promise.resolve();
});
messageListRef.current = scrollHandle(true);
rerender({ renderedMessageCount: 1 });
await flushFrames();
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledTimes(1);
expect(mockDockviewState.scrollTarget).not.toBeNull();
// Transcript revisions must not create a second delayed pass.
rerender({ renderedMessageCount: 2 });
await flushFrames();
rerender({ renderedMessageCount: 3 });
await flushFrames();
await act(async () => {
await vi.advanceTimersByTimeAsync(249);
});
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(1);
});
expect(messageListRef.current?.scrollToMessage).toHaveBeenCalledTimes(2);
expect(mockDockviewState.clearScrollTarget).toHaveBeenCalledWith(7);
expect(mockDockviewState.scrollTarget).toBeNull();
vi.useRealTimers();
});
it("does not retry a failed delayed reassertion after later transcript revisions", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
vi.mocked(loadMessageWindowAround).mockImplementationOnce(async () => {
mockAppStoreState.messages.bySession["session-1"] = [{ id: "message-1" }];
return { kind: "merged", merged: true, current: true, targetFound: true };
});
mockAppStoreState.messages.bySession["session-1"] = [];
mockDockviewState.scrollTarget = target();
const scrollToMessage = vi
.fn()
.mockReturnValueOnce(false)
.mockReturnValueOnce(true)
.mockReturnValue(false);
const messageListRef = { current: { scrollToMessage } };
const { rerender } = renderHook(
({ renderedMessageCount }) =>
useScrollTargetConsumption({
...PROPS,
renderedMessageCount,
messageListRef,
}),
{ initialProps: { renderedMessageCount: 0 } },
);
await flushFrames();
await act(async () => {
await Promise.resolve();
});
rerender({ renderedMessageCount: 1 });
await flushFrames();
expect(scrollToMessage).toHaveBeenCalledTimes(2);
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
expect(scrollToMessage).toHaveBeenCalledTimes(3);
rerender({ renderedMessageCount: 2 });
await flushFrames();
expect(scrollToMessage).toHaveBeenCalledTimes(3);
expect(mockDockviewState.clearScrollTarget).toHaveBeenCalledWith(7);
expect(mockDockviewState.scrollTarget).toBeNull();
});
});