Files
Sybil-2/web/tests/agent-scroll-fixture.tsx
T
Sybil Agent 5d738f897b
TestFlight / Build and upload (push) Successful in 1m57s
Add inline persistent Agent mentions
2026-10-29 18:28:52 -07:00

207 lines
11 KiB
TypeScript

import { render } from "preact";
import { useRef, useState } from "preact/hooks";
import type { Message } from "../src/lib/api";
import type { AgentAttachment } from "../src/lib/agent-attachments";
import type { AgentRoomHookResult } from "../src/hooks/use-agent-room";
import { createAgentOptimisticOutbox } from "../src/lib/agent-optimistic-outbox";
import { AgentConversation } from "../src/components/agent/agent-conversation";
import "../src/index.css";
const timestamp = "2026-10-29T12:00:00.000Z";
const scrollAway = new URLSearchParams(location.search).get("scrollAway") === "1";
const detailedReply = Array.from({ length: 12 }, (_, index) =>
`Synthetic streamed paragraph ${index + 1} exercises a live Agent message update after the user sends a multiline prompt. ${"The current room snapshot keeps enough content to measure the animated row geometry. ".repeat(2)}`,
).join("\n\n");
const growingReply = `${detailedReply}\n\nAn additional streamed paragraph grows the same stable message identity.`;
const middleReply = Array.from({ length: 7 }, (_, index) =>
`Synthetic intermediate response paragraph ${index + 1} keeps the stable reply measurable during streaming. ${"The row is still in its entrance animation. ".repeat(2)}`,
).join("\n\n");
const shorterReply = [
"The synthetic Agent response is being finalized while its entrance animation is still active.",
"This update changes the stable message's intrinsic height and exercises the same room update path.",
].join("\n\n");
const finalReply = "Final synthetic Agent reply.";
window.addEventListener("error", event => { document.documentElement.dataset.fixtureError = event.message; });
window.addEventListener("unhandledrejection", event => { document.documentElement.dataset.fixtureError = String(event.reason); });
function message(id: string, role: Message["role"], content: string, status = "complete", createdAt = timestamp): Message {
return { id, createdAt, role, content, name: null, metadata: { kind: "message", status } };
}
function Fixture() {
const [revision, setRevision] = useState(0);
const [roomState, setRoomState] = useState(() => ({
messages: Array.from({ length: 12 }, (_, index) => {
const role: Message["role"] = index % 2 ? "assistant" : "user";
return message(`fixture-history-${index}`, role,
`Earlier synthetic thread message ${index + 1}. ${"The history preserves a realistic scrollable Agent transcript. ".repeat(2)}`,
"complete", new Date(Date.parse(timestamp) + index * 1000).toISOString());
}),
activity: null as null | { state: string; isThinking: boolean; queued: number; backgroundWorkers: number; tools: never[]; queuedMessages: Message[] },
}));
const outbox = useRef<ReturnType<typeof createAgentOptimisticOutbox> | null>(null);
if (!outbox.current) {
outbox.current = createAgentOptimisticOutbox({
storage: { read: () => [], write: () => undefined },
newId: () => "fixture-request",
now: () => Date.parse(timestamp) + 100_000,
changed: () => setRevision(value => value + 1),
submit: (_content, requestId) => new Promise(resolve => {
document.documentElement.dataset.agentSubmitCalled = "true";
window.setTimeout(() => {
setRoomState(current => ({ ...current, activity: {
state: "thinking", isThinking: true, queued: 0, backgroundWorkers: 0, tools: [], queuedMessages: [],
} }));
resolve({ status: "queued", messageId: `inbound:sybil:${requestId}` });
}, 70);
window.setTimeout(() => {
setRoomState(current => ({ ...current, messages: [...current.messages,
message("fixture-agent-reply", "assistant", detailedReply, "complete",
new Date(Date.parse(timestamp) + 200_000).toISOString())], activity: null }));
const streamSnapshots = [
[20, growingReply], [45, middleReply], [70, detailedReply],
[95, shorterReply], [130, "A concise streamed final answer with one extra line.\n\nThe last snapshot settles the message height."],
[165, finalReply],
] as const;
for (const [delay, content] of streamSnapshots) window.setTimeout(() => setRoomState(current => ({
...current,
messages: current.messages.map(item => item.id === "fixture-agent-reply" ? { ...item, content } : item),
})), delay);
}, 450);
}),
});
}
void revision;
const queue = outbox.current;
const messages = queue.display(roomState.messages);
const uploadQueue = {
snapshot: () => [], add: () => undefined, remove: () => undefined, ready: () => [],
reselect: async () => undefined, retry: () => undefined, clear: () => undefined,
};
const room = {
messages,
activity: roomState.activity,
unreadCount: 0,
connection: "connected",
error: null,
isLoading: false,
isLoadingOlder: false,
olderError: null,
hasOlder: false,
historyBeforeCursor: null,
attachmentCapability: undefined,
uploadQueue,
uploadAttachment: async () => { throw new Error("No upload in fixture"); },
downloadAttachment: async () => new Blob(),
isTyping: roomState.activity !== null,
sendMessage: (content: string, attachments?: AgentAttachment[], onPersisted?: () => void) => {
document.documentElement.dataset.roomSendCalled = "true";
return queue.send(content, attachments, onPersisted);
},
retryMessage: async () => undefined,
markRead: () => {
const activeEntryAnimation = [...document.querySelectorAll<HTMLElement>("[data-agent-entry-id]")]
.some(entry => entry.getAnimations().some(animation => animation.effect?.target === entry && animation.playState === "running"));
if (activeEntryAnimation) document.documentElement.dataset.readDuringEntryAnimation = "true";
document.documentElement.dataset.readCount = String(Number(document.documentElement.dataset.readCount || "0") + 1);
},
retry: () => undefined,
loadOlder: () => undefined,
unreadCountIsPartial: false,
} as unknown as AgentRoomHookResult;
return <AgentConversation room={room} onOpenSidebar={() => undefined} />;
}
render(<Fixture />, document.getElementById("root")!);
const samples: Array<Record<string, number | string>> = [];
const finished = new Set<string>();
const timelineObserver = new MutationObserver(() => {
for (const [id, label] of [["inbound:sybil:fixture-request", "user"], ["fixture-agent-reply", "agent"]]) {
const entry = document.querySelector<HTMLElement>(`[data-agent-entry-id="${id}"]`);
if (!entry || entry.dataset.geometryCaptured) continue;
const animation = entry.getAnimations().find(candidate => candidate.effect?.target === entry);
if (!animation) continue;
entry.dataset.geometryCaptured = "true";
sample(`${label}-start`, entry);
window.setTimeout(() => sample(`${label}-mid`, entry), 120);
window.setTimeout(() => sample(`${label}-late`, entry), 195);
window.setTimeout(() => sample(`${label}-pre-end`, entry), 225);
animation.finished.then(() => {
sample(`${label}-finish`, entry);
window.setTimeout(() => {
sample(`${label}-stable`, entry);
finished.add(label);
if (finished.size === 2) {
document.getElementById("result")!.textContent = JSON.stringify({ samples,
roomSendCalled: document.documentElement.dataset.roomSendCalled === "true",
agentSubmitCalled: document.documentElement.dataset.agentSubmitCalled === "true",
composerFocused: document.activeElement?.getAttribute("aria-label") === "Message Sybil Agent",
readDuringEntryAnimation: document.documentElement.dataset.readDuringEntryAnimation === "true",
contentVisibility: getComputedStyle(entry).contentVisibility,
scrollAwayState: (window as any).__scrollAwayState || null,
});
document.documentElement.dataset.fixtureComplete = "true";
}
}, 150);
}).catch(() => undefined);
}
});
function sample(label: string, entry: HTMLElement) {
const scroller = document.querySelector<HTMLElement>(".agent-room__scroll")!;
const timeline = document.querySelector<HTMLElement>(".agent-room__timeline")!;
const rect = entry.getBoundingClientRect();
const content = entry.querySelector<HTMLElement>(".agent-room__message")!;
const contentHeight = content.getBoundingClientRect().height;
const entryAnimations = entry.getAnimations().filter(animation => animation.effect?.target === entry);
const anchorState = (window as any).__geometryAnchor as { id: string; offset: number } | undefined;
const anchor = anchorState && [...document.querySelectorAll<HTMLElement>("[data-agent-entry-id]")]
.find(item => item.dataset.agentEntryId === anchorState.id);
samples.push({
label,
time: Number(performance.now().toFixed(1)),
rowHeight: Number(rect.height.toFixed(2)),
rowScrollHeight: entry.scrollHeight,
contentHeight: Number(contentHeight.toFixed(2)),
emptyHeight: Number((rect.height - contentHeight).toFixed(2)),
bottomGap: Number((scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight).toFixed(2)),
...(anchor ? { anchorShift: Number((anchor.getBoundingClientRect().top - scroller.getBoundingClientRect().top - anchorState!.offset).toFixed(2)) } : {}),
entryAnimationCount: entryAnimations.length,
keyframeHeights: entryAnimations.flatMap(animation => animation.effect?.getKeyframes().map(frame => frame.height) || []),
computedHeight: getComputedStyle(entry).height,
overflow: getComputedStyle(entry).overflow,
});
}
const waitForTimeline = window.setInterval(() => {
const timeline = document.querySelector<HTMLElement>(".agent-room__timeline");
if (!timeline) return;
window.clearInterval(waitForTimeline);
timelineObserver.observe(timeline, { childList: true, subtree: true, characterData: true });
window.setTimeout(() => {
document.documentElement.dataset.readCount = "0";
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[aria-label="Message Sybil Agent"]')!;
textarea.value = Array.from({ length: 12 }, (_, index) => `Synthetic sent message line ${index + 1}`).join("\n");
textarea.dispatchEvent(new Event("input", { bubbles: true }));
textarea.form!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
if (scrollAway) window.setTimeout(() => {
const scroller = document.querySelector<HTMLElement>(".agent-room__scroll")!;
const requestedTop = Math.max(0, scroller.scrollTop - 250);
scroller.scrollTop = requestedTop;
scroller.dispatchEvent(new Event("scroll"));
(window as any).__scrollAwayState = { requestedTop, actualTop: scroller.scrollTop,
scrollHeight: scroller.scrollHeight, clientHeight: scroller.clientHeight,
bottomGap: scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight };
const top = scroller.getBoundingClientRect().top;
const anchor = [...scroller.querySelectorAll<HTMLElement>("[data-agent-entry-id]")]
.find(item => item.getBoundingClientRect().bottom > top + 8);
if (anchor) (window as any).__geometryAnchor = {
id: anchor.dataset.agentEntryId,
offset: anchor.getBoundingClientRect().top - top,
};
}, 60);
}, 120);
}, 10);