mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
448 lines
15 KiB
TypeScript
448 lines
15 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { h, type ComponentChild as UiNode } from "preact";
|
|
import { act } from "preact/test-utils";
|
|
|
|
import {
|
|
MessageStreamFlowFrame,
|
|
type MessageStreamScrollCommand,
|
|
type MessageStreamScrollControllerBinding,
|
|
type MessageStreamScrollPort,
|
|
} from "../../../../../src/features/chat/ui/message-stream/flow-scroll";
|
|
import { MESSAGE_CONTENT_RENDERED_EVENT } from "../../../../../src/features/chat/ui/message-stream/content-events";
|
|
import { renderUiRoot } from "../../../../../src/shared/ui/ui-root";
|
|
import { installObsidianDomShims } from "../../../../support/dom";
|
|
|
|
installObsidianDomShims();
|
|
|
|
describe("message stream flow scrolling", () => {
|
|
beforeEach(() => {
|
|
resizeObserverCallbacks = [];
|
|
animationFrameCallbacks = [];
|
|
window.ResizeObserver = TestResizeObserver;
|
|
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
|
animationFrameCallbacks.push(callback);
|
|
return animationFrameCallbacks.length;
|
|
}) as typeof window.requestAnimationFrame;
|
|
window.cancelAnimationFrame = (() => undefined) as typeof window.cancelAnimationFrame;
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.body.replaceChildren();
|
|
});
|
|
|
|
it("pins to the DOM scroll end and follows appended content while pinned", () => {
|
|
const { controller, messages, render } = renderFlowMessageStream(["first"], { first: 300 });
|
|
|
|
void act(() => {
|
|
controller.dispatch({ kind: "show-latest" });
|
|
});
|
|
expect(messages.scrollTop).toBe(200);
|
|
|
|
render(["first", "second"], { first: 300, second: 180 });
|
|
|
|
expect(messages.scrollTop).toBe(380);
|
|
});
|
|
|
|
it("keeps the current reading position when content is appended after the user scrolls away", () => {
|
|
const { controller, messages, render } = renderFlowMessageStream(["first"], { first: 300 });
|
|
void act(() => {
|
|
controller.dispatch({ kind: "show-latest" });
|
|
});
|
|
messages.scrollTop = 80;
|
|
messages.dispatchEvent(new Event("scroll"));
|
|
|
|
render(["first", "second"], { first: 300, second: 180 });
|
|
|
|
expect(messages.scrollTop).toBe(80);
|
|
});
|
|
|
|
it("preserves the visible block when older history is prepended", () => {
|
|
const { messages, render } = renderFlowMessageStream(["first", "second"], { first: 300, second: 300 });
|
|
messages.scrollTop = 320;
|
|
messages.dispatchEvent(new Event("scroll"));
|
|
|
|
render(["older", "first", "second"], { older: 180, first: 300, second: 300 });
|
|
|
|
expect(messages.scrollTop).toBe(500);
|
|
});
|
|
|
|
it("preserves the visible block when older history is inserted after the history bar", () => {
|
|
const { messages, render } = renderFlowMessageStream(["history-bar", "current", "next"], {
|
|
"history-bar": 40,
|
|
current: 300,
|
|
next: 300,
|
|
});
|
|
messages.scrollTop = 80;
|
|
messages.dispatchEvent(new Event("scroll"));
|
|
|
|
render(["history-bar", "older", "current", "next"], {
|
|
"history-bar": 40,
|
|
older: 180,
|
|
current: 300,
|
|
next: 300,
|
|
});
|
|
|
|
expect(messages.scrollTop).toBe(260);
|
|
});
|
|
|
|
it("does not force the end when delayed content changes while the user is reading", () => {
|
|
const { messages, blockElement, setHeights } = renderFlowMessageStream(["first", "second", "third"], {
|
|
first: 300,
|
|
second: 300,
|
|
third: 300,
|
|
});
|
|
messages.scrollTop = 360;
|
|
messages.dispatchEvent(new Event("scroll"));
|
|
|
|
setHeights({ first: 500, second: 300, third: 300 });
|
|
void act(() => {
|
|
blockElement("first").dispatchEvent(new Event(MESSAGE_CONTENT_RENDERED_EVENT, { bubbles: true }));
|
|
});
|
|
|
|
expect(messages.scrollTop).toBe(360);
|
|
});
|
|
|
|
it("keeps hidden message streams pinned when the viewport size returns", () => {
|
|
const restoreInitialMetrics = installMessageViewportPrototypeMetrics({ scrollHeight: 600, viewport: { width: 0, height: 0 } });
|
|
const { messages, resizeViewport } = renderFlowMessageStream(
|
|
["first", "second"],
|
|
{ first: 300, second: 300 },
|
|
{
|
|
viewport: { width: 0, height: 0 },
|
|
},
|
|
);
|
|
restoreInitialMetrics();
|
|
|
|
expect(messages.scrollTop).toBe(600);
|
|
|
|
resizeViewport({ width: 240, height: 100 });
|
|
triggerResizeObserver();
|
|
flushAnimationFrame();
|
|
|
|
expect(messages.scrollTop).toBe(500);
|
|
});
|
|
|
|
it("does not double-adjust when the browser anchors a prepended block before effects run", () => {
|
|
let nativeAnchoringApplied = false;
|
|
const { messages, render } = renderFlowMessageStream(
|
|
["first", "second"],
|
|
{ first: 300, second: 300 },
|
|
{
|
|
blockNode(key) {
|
|
if (key !== "older") return h("div", null, key);
|
|
return h("div", {
|
|
ref(element: HTMLDivElement | null) {
|
|
if (!element || nativeAnchoringApplied) return;
|
|
nativeAnchoringApplied = true;
|
|
const viewport = element.closest<HTMLElement>(".codex-panel__messages");
|
|
if (viewport) viewport.scrollTop += 180;
|
|
},
|
|
});
|
|
},
|
|
},
|
|
);
|
|
messages.scrollTop = 320;
|
|
messages.dispatchEvent(new Event("scroll"));
|
|
|
|
render(["older", "first", "second"], { older: 180, first: 300, second: 300 });
|
|
|
|
expect(messages.scrollTop).toBe(500);
|
|
});
|
|
|
|
it("returns to the DOM scroll end after delayed message content renders while pinned", () => {
|
|
const { controller, messages, blockElement, setHeights } = renderFlowMessageStream(["message"], { message: 300 });
|
|
void act(() => {
|
|
controller.dispatch({ kind: "show-latest" });
|
|
});
|
|
expect(messages.scrollTop).toBe(200);
|
|
|
|
setHeights({ message: 420 });
|
|
void act(() => {
|
|
blockElement("message").dispatchEvent(new Event(MESSAGE_CONTENT_RENDERED_EVENT, { bubbles: true }));
|
|
});
|
|
|
|
expect(messages.scrollTop).toBe(320);
|
|
});
|
|
|
|
it("scrolls by composer text-line and page commands", () => {
|
|
const { controller, messages } = renderFlowMessageStream(["first", "second"], { first: 300, second: 300 });
|
|
messages.style.lineHeight = "20px";
|
|
messages.scrollTop = 240;
|
|
messages.dispatchEvent(new Event("scroll"));
|
|
|
|
void act(() => {
|
|
controller.dispatch({ kind: "scroll-by", amount: "text-lines", direction: -1 });
|
|
});
|
|
expect(messages.scrollTop).toBe(200);
|
|
|
|
void act(() => {
|
|
controller.dispatch({ kind: "scroll-by", amount: "page", direction: 1 });
|
|
});
|
|
expect(messages.scrollTop).toBe(280);
|
|
});
|
|
|
|
it("scrolls to stream edges from composer commands", () => {
|
|
const { controller, messages } = renderFlowMessageStream(["first", "second"], { first: 300, second: 300 });
|
|
messages.scrollTop = 240;
|
|
messages.dispatchEvent(new Event("scroll"));
|
|
|
|
void act(() => {
|
|
controller.dispatch({ kind: "scroll-to", edge: "start" });
|
|
});
|
|
expect(messages.scrollTop).toBe(0);
|
|
|
|
void act(() => {
|
|
controller.dispatch({ kind: "scroll-to", edge: "end" });
|
|
});
|
|
expect(messages.scrollTop).toBe(500);
|
|
});
|
|
});
|
|
|
|
interface TestMessageStreamScrollController extends MessageStreamScrollControllerBinding {
|
|
dispatch(command: MessageStreamScrollCommand): void;
|
|
}
|
|
|
|
function createTestMessageStreamScrollController(): TestMessageStreamScrollController {
|
|
let port: MessageStreamScrollPort | null = null;
|
|
return {
|
|
mountScrollPort(nextPort) {
|
|
port = nextPort;
|
|
return () => {
|
|
if (port === nextPort) port = null;
|
|
};
|
|
},
|
|
dispatch(command) {
|
|
port?.dispatchScrollCommand(command);
|
|
},
|
|
};
|
|
}
|
|
|
|
function renderFlowMessageStream(
|
|
keys: readonly string[],
|
|
heights: Record<string, number>,
|
|
options: {
|
|
blockNode?: (key: string) => UiNode;
|
|
viewport?: { width: number; height: number };
|
|
} = {},
|
|
): {
|
|
controller: TestMessageStreamScrollController;
|
|
messages: HTMLElement;
|
|
render: (nextKeys: readonly string[], nextHeights: Record<string, number>) => void;
|
|
setHeights: (nextHeights: Record<string, number>) => void;
|
|
resizeViewport: (nextViewport: { width: number; height: number }) => void;
|
|
blockElement: (key: string) => HTMLElement;
|
|
} {
|
|
const parent = document.createElement("div");
|
|
document.body.append(parent);
|
|
const controller = createTestMessageStreamScrollController();
|
|
let currentHeights = heights;
|
|
let currentKeys = keys;
|
|
let viewport = options.viewport ?? { width: 240, height: 100 };
|
|
|
|
const render = (nextKeys: readonly string[], nextHeights: Record<string, number>) => {
|
|
currentKeys = nextKeys;
|
|
currentHeights = nextHeights;
|
|
void act(() => {
|
|
renderUiRoot(
|
|
parent,
|
|
h(MessageStreamFlowFrame, {
|
|
blocks: nextKeys.map((key) => ({ key })),
|
|
scrollController: controller,
|
|
renderBlockContent: (block) => options.blockNode?.(block.key) ?? h("div", null, block.key),
|
|
}),
|
|
);
|
|
});
|
|
};
|
|
|
|
render(keys, heights);
|
|
const messages = messageViewport(parent);
|
|
installFlowMetrics(
|
|
messages,
|
|
() => currentKeys,
|
|
() => currentHeights,
|
|
() => viewport,
|
|
);
|
|
|
|
return {
|
|
controller,
|
|
messages,
|
|
render,
|
|
setHeights(nextHeights) {
|
|
currentHeights = nextHeights;
|
|
},
|
|
resizeViewport(nextViewport) {
|
|
viewport = nextViewport;
|
|
},
|
|
blockElement(key) {
|
|
const element = parent.querySelector<HTMLElement>(`[data-codex-panel-block-key="${key}"]`);
|
|
if (!element) throw new Error(`Missing block ${key}`);
|
|
return element;
|
|
},
|
|
};
|
|
}
|
|
|
|
function messageViewport(parent: HTMLElement): HTMLElement {
|
|
const element = parent.querySelector<HTMLElement>(".codex-panel__messages");
|
|
if (!element) throw new Error("Expected message viewport.");
|
|
return element;
|
|
}
|
|
|
|
function installFlowMetrics(
|
|
messages: HTMLElement,
|
|
keys: () => readonly string[],
|
|
heights: () => Record<string, number>,
|
|
viewport: () => { width: number; height: number },
|
|
): void {
|
|
const initialScrollTop = messages.scrollTop;
|
|
Object.defineProperties(messages, {
|
|
clientHeight: { get: () => viewport().height, configurable: true },
|
|
offsetHeight: { get: () => viewport().height, configurable: true },
|
|
clientWidth: { get: () => viewport().width, configurable: true },
|
|
offsetWidth: { get: () => viewport().width, configurable: true },
|
|
scrollHeight: {
|
|
get: () => keys().reduce((total, key) => total + (heights()[key] ?? 0), 0),
|
|
configurable: true,
|
|
},
|
|
});
|
|
|
|
Object.defineProperty(messages, "scrollTop", {
|
|
get: () => messageScrollTop.get(messages) ?? 0,
|
|
set: (value: number) => {
|
|
const max = Math.max(0, messages.scrollHeight - messages.clientHeight);
|
|
messageScrollTop.set(messages, Math.max(0, Math.min(value, max)));
|
|
},
|
|
configurable: true,
|
|
});
|
|
messages.scrollTop = initialScrollTop;
|
|
|
|
messages.getBoundingClientRect = () => messageRect(0, viewport().height);
|
|
Array.from(messages.querySelectorAll<HTMLElement>(".codex-panel__message-block")).forEach((element) => {
|
|
installBlockRect(element, messages, heights);
|
|
});
|
|
}
|
|
|
|
const messageScrollTop = new WeakMap<HTMLElement, number>();
|
|
let resizeObserverCallbacks: ResizeObserverCallback[] = [];
|
|
let animationFrameCallbacks: FrameRequestCallback[] = [];
|
|
|
|
class TestResizeObserver implements ResizeObserver {
|
|
private readonly callback: ResizeObserverCallback;
|
|
|
|
constructor(callback: ResizeObserverCallback) {
|
|
this.callback = callback;
|
|
resizeObserverCallbacks.push(callback);
|
|
}
|
|
|
|
observe(): void {
|
|
// Test callback triggering is explicit.
|
|
}
|
|
|
|
unobserve(): void {
|
|
// Test callback triggering is explicit.
|
|
}
|
|
|
|
disconnect(): void {
|
|
resizeObserverCallbacks = resizeObserverCallbacks.filter((callback) => callback !== this.callback);
|
|
}
|
|
}
|
|
|
|
function triggerResizeObserver(): void {
|
|
void act(() => {
|
|
for (const callback of resizeObserverCallbacks) callback([], {} as ResizeObserver);
|
|
});
|
|
}
|
|
|
|
function flushAnimationFrame(): void {
|
|
const callbacks = animationFrameCallbacks.splice(0);
|
|
void act(() => {
|
|
for (const callback of callbacks) callback(0);
|
|
});
|
|
}
|
|
|
|
function installMessageViewportPrototypeMetrics(metrics: {
|
|
scrollHeight: number;
|
|
viewport: { width: number; height: number };
|
|
}): () => void {
|
|
const scrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight");
|
|
const clientHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
|
|
const clientWidthDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientWidth");
|
|
const offsetHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight");
|
|
const offsetWidthDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth");
|
|
|
|
Object.defineProperties(HTMLElement.prototype, {
|
|
scrollHeight: {
|
|
get() {
|
|
return this instanceof HTMLElement && this.classList.contains("codex-panel__messages") ? metrics.scrollHeight : 0;
|
|
},
|
|
configurable: true,
|
|
},
|
|
clientHeight: {
|
|
get() {
|
|
return this instanceof HTMLElement && this.classList.contains("codex-panel__messages") ? metrics.viewport.height : 0;
|
|
},
|
|
configurable: true,
|
|
},
|
|
clientWidth: {
|
|
get() {
|
|
return this instanceof HTMLElement && this.classList.contains("codex-panel__messages") ? metrics.viewport.width : 0;
|
|
},
|
|
configurable: true,
|
|
},
|
|
offsetHeight: {
|
|
get() {
|
|
return this instanceof HTMLElement && this.classList.contains("codex-panel__messages") ? metrics.viewport.height : 0;
|
|
},
|
|
configurable: true,
|
|
},
|
|
offsetWidth: {
|
|
get() {
|
|
return this instanceof HTMLElement && this.classList.contains("codex-panel__messages") ? metrics.viewport.width : 0;
|
|
},
|
|
configurable: true,
|
|
},
|
|
});
|
|
|
|
return () => {
|
|
restorePrototypeProperty(HTMLElement.prototype, "scrollHeight", scrollHeightDescriptor);
|
|
restorePrototypeProperty(HTMLElement.prototype, "clientHeight", clientHeightDescriptor);
|
|
restorePrototypeProperty(HTMLElement.prototype, "clientWidth", clientWidthDescriptor);
|
|
restorePrototypeProperty(HTMLElement.prototype, "offsetHeight", offsetHeightDescriptor);
|
|
restorePrototypeProperty(HTMLElement.prototype, "offsetWidth", offsetWidthDescriptor);
|
|
};
|
|
}
|
|
|
|
function restorePrototypeProperty(object: object, key: PropertyKey, descriptor: PropertyDescriptor | undefined): void {
|
|
if (descriptor) {
|
|
Object.defineProperty(object, key, descriptor);
|
|
} else {
|
|
Reflect.deleteProperty(object, key);
|
|
}
|
|
}
|
|
|
|
function installBlockRect(element: HTMLElement, messages: HTMLElement, heights: () => Record<string, number>): void {
|
|
element.getBoundingClientRect = () => {
|
|
const key = element.dataset["codexPanelBlockKey"] ?? "";
|
|
const siblings = Array.from(messages.querySelectorAll<HTMLElement>(".codex-panel__message-block"));
|
|
const index = siblings.indexOf(element);
|
|
const top = siblings
|
|
.slice(0, Math.max(0, index))
|
|
.reduce((total, candidate) => total + (heights()[candidate.dataset["codexPanelBlockKey"] ?? ""] ?? 0), -messages.scrollTop);
|
|
return messageRect(top, heights()[key] ?? 0);
|
|
};
|
|
}
|
|
|
|
function messageRect(top: number, height: number): DOMRect {
|
|
return {
|
|
x: 0,
|
|
y: top,
|
|
top,
|
|
right: 240,
|
|
bottom: top + height,
|
|
left: 0,
|
|
width: 240,
|
|
height,
|
|
toJSON: () => ({}),
|
|
};
|
|
}
|