207 lines
11 KiB
TypeScript
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);
|