This commit is contained in:
+15
-1
@@ -94,6 +94,7 @@ import {
|
||||
createStreamingAssistantMetadata,
|
||||
createStreamingAttemptState,
|
||||
finalizeStreamingAssistant,
|
||||
markStreamingAssistantAsAgentAside,
|
||||
promoteStreamingAssistantTrace,
|
||||
setStreamingAssistantSegment,
|
||||
transitionStreamingToolCall,
|
||||
@@ -2628,6 +2629,7 @@ export default function App() {
|
||||
...baseChat.messages
|
||||
.filter((message) => !isToolCallLogMessage(message))
|
||||
.map((message) => ({
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
...(message.name ? { name: message.name } : {}),
|
||||
@@ -2640,7 +2642,7 @@ export default function App() {
|
||||
},
|
||||
];
|
||||
|
||||
if (shouldRequestChatTitle(baseChat, pendingTitleGenerationRef.current.has(chatId))) {
|
||||
if (shouldRequestChatTitle(baseChat, pendingTitleGenerationRef.current.has(chatId)) && !content.toLowerCase().includes("@sybil")) {
|
||||
pendingTitleGenerationRef.current.add(chatId);
|
||||
const titleSeed = content || buildAttachmentSummary(attachments) || "Uploaded files";
|
||||
void suggestChatTitle({ chatId, content: titleSeed })
|
||||
@@ -2676,6 +2678,18 @@ export default function App() {
|
||||
{
|
||||
onMeta: (payload) => {
|
||||
if (payload.chatId !== chatId) return;
|
||||
if (payload.agentAside) {
|
||||
setPendingChatStates((current) => {
|
||||
const pendingState = current[chatId];
|
||||
if (!pendingState) return current;
|
||||
return {
|
||||
...current,
|
||||
[chatId]: {
|
||||
messages: markStreamingAssistantAsAgentAside(pendingState.messages, "temp-assistant-"),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
onToolCall: (payload) => {
|
||||
const transition = transitionStreamingToolCall(streamingAttempt, payload);
|
||||
|
||||
@@ -50,18 +50,19 @@ function toolName(value: unknown) {
|
||||
return typeof value === "string" ? value.replace(/_/g, " ") : "Tool";
|
||||
}
|
||||
|
||||
const AgentBubble = memo(function AgentBubble({ message, animate, onRetry, download }: { message: Message; animate: boolean; onRetry: (id: string) => void; download: AgentRoomHookResult["downloadAttachment"] }) {
|
||||
export const AgentBubble = memo(function AgentBubble({ message, animate, onRetry, download, showThinkingIndicator = false }: { message: Message; animate: boolean; onRetry: (id: string) => void; download: AgentRoomHookResult["downloadAttachment"]; showThinkingIndicator?: boolean }) {
|
||||
const own = message.role === "user";
|
||||
const info = metadata(message);
|
||||
const status = typeof info.status === "string" ? info.status : "complete";
|
||||
const failed = status === "failed" || status === "unconfirmed";
|
||||
const thinking = showThinkingIndicator && !own && status === "sending" && !message.content.trim();
|
||||
const date = dateOf(message.createdAt);
|
||||
return (
|
||||
<article className={`agent-room__message ${own ? "is-own" : "is-agent"}`} data-agent-message-id={message.id} data-message-status={status} data-animation={animate ? "enter" : undefined} aria-label={own ? "Your message" : "Sybil's message"}>
|
||||
{!own ? <span className="agent-room__message-avatar" aria-hidden="true"><img src="/character-still.png" alt="" draggable={false} /></span> : null}
|
||||
<div className="agent-room__message-content">
|
||||
<div className={`message-bubble${own ? " message-bubble--user" : ""}`}>
|
||||
{own ? <p className="agent-room__plain-message">{message.content}</p> : <MarkdownContent markdown={message.content} openLinksInNewTab allowImages={false} />}
|
||||
{own ? <p className="agent-room__plain-message">{message.content}</p> : thinking ? <span className="inline-flex items-center gap-1" aria-label="Sybil is thinking" role="status"><span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" /><span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" /><span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:280ms]" /></span> : <MarkdownContent markdown={message.content} openLinksInNewTab allowImages={false} />}
|
||||
{agentAttachments(message.metadata, message.role).map(file => <AgentAttachmentCard key={file.id} file={file} download={download} inline={message.role === "assistant"} />)}
|
||||
</div>
|
||||
{own && failed && typeof info.error === "string" ? <div className="agent-attachment__error" role="alert">{info.error}</div> : null}
|
||||
@@ -96,7 +97,7 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
||||
const seenMessageIdsRef = useRef(new Set(room.messages.map(message => message.id)));
|
||||
const enteringMessageIdsRef = useRef(new Set<string>());
|
||||
const animatedEntryIdsRef = useRef(new Set<string>());
|
||||
const entryAnimationsRef = useRef(new Set<Animation>());
|
||||
const entryAnimationsRef = useRef(new Map<Animation, { entry: HTMLElement; observer: ResizeObserver; contentVisibility: string }>());
|
||||
const typingHeightRef = useRef(0);
|
||||
const historyReadyRef = useRef(!room.isLoading);
|
||||
const historyBeforeRef = useRef(room.historyBeforeCursor);
|
||||
@@ -188,7 +189,12 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
||||
if (animatedEntryIdsRef.current.has(id) || !enteringMessageIdsRef.current.has(id)) continue;
|
||||
animatedEntryIdsRef.current.add(id);
|
||||
if (reduceMotion) continue;
|
||||
const contentVisibility = entry.style.contentVisibility;
|
||||
entry.style.contentVisibility = "visible";
|
||||
const height = entry.getBoundingClientRect().height;
|
||||
const content = entry.querySelector<HTMLElement>(".agent-room__message");
|
||||
const contentHeight = content?.getBoundingClientRect().height ?? height;
|
||||
const fixedHeight = height - contentHeight;
|
||||
const spacing = entry.previousElementSibling ? gap : 0;
|
||||
const isReply = Boolean(entry.querySelector(".agent-room__message.is-agent"));
|
||||
const initialHeight = isReply ? Math.min(replacedTypingHeight, height + spacing) : 0;
|
||||
@@ -196,15 +202,53 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
||||
// Include the inter-message gap in the reveal. Clipping keeps the bubble's
|
||||
// positive-Y transform from prematurely extending the scrollable area.
|
||||
entry.style.overflow = "clip";
|
||||
const duration = 240;
|
||||
const endTime = performance.now() + duration;
|
||||
let targetHeight = height;
|
||||
const animation = entry.animate([
|
||||
{ height: `${initialHeight}px`, marginTop: `${-spacing}px` },
|
||||
{ height: `${height}px`, marginTop: "0px" },
|
||||
], { duration: 240, easing: "cubic-bezier(.2,.7,.25,1)" });
|
||||
entryAnimationsRef.current.add(animation);
|
||||
animation.onfinish = () => {
|
||||
entry.style.overflow = "";
|
||||
entryAnimationsRef.current.delete(animation);
|
||||
], { duration, easing: "cubic-bezier(.2,.7,.25,1)" });
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (!content) return;
|
||||
const nextHeight = fixedHeight + content.getBoundingClientRect().height;
|
||||
const remaining = endTime - performance.now();
|
||||
if (Math.abs(nextHeight - targetHeight) < 1 || remaining < 1) return;
|
||||
const progress = Math.max(0, Math.min(1, Number(animation.currentTime ?? 0) / duration));
|
||||
if (progress >= 1) return;
|
||||
const effect = animation.effect as KeyframeEffect | null;
|
||||
if (progress === 0) {
|
||||
effect?.setKeyframes([
|
||||
{ height: `${initialHeight}px`, marginTop: `${-spacing}px`, easing: "cubic-bezier(.2,.7,.25,1)" },
|
||||
{ height: `${nextHeight}px`, marginTop: "0px" },
|
||||
]);
|
||||
} else {
|
||||
const currentHeight = entry.getBoundingClientRect().height;
|
||||
const currentMarginTop = getComputedStyle(entry).marginTop;
|
||||
effect?.setKeyframes([
|
||||
{ height: `${initialHeight}px`, marginTop: `${-spacing}px`, offset: 0, easing: "cubic-bezier(.2,.7,.25,1)" },
|
||||
{ height: `${currentHeight}px`, marginTop: currentMarginTop, offset: progress, easing: "cubic-bezier(0,0,0,1)" },
|
||||
{ height: `${nextHeight}px`, marginTop: "0px", offset: 1 },
|
||||
]);
|
||||
}
|
||||
targetHeight = nextHeight;
|
||||
restoreViewport();
|
||||
});
|
||||
if (content) observer.observe(content);
|
||||
entryAnimationsRef.current.set(animation, { entry, observer, contentVisibility });
|
||||
const cleanup = () => {
|
||||
entry.style.overflow = "";
|
||||
entry.style.contentVisibility = contentVisibility;
|
||||
observer.disconnect();
|
||||
entryAnimationsRef.current.delete(animation);
|
||||
};
|
||||
animation.onfinish = () => {
|
||||
cleanup();
|
||||
restoreViewport();
|
||||
};
|
||||
animation.oncancel = () => {
|
||||
cleanup();
|
||||
if (mountedRef.current) restoreViewport();
|
||||
};
|
||||
}
|
||||
}, [room.messages, room.isTyping]);
|
||||
@@ -224,7 +268,12 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
observer.disconnect();
|
||||
for (const animation of entryAnimationsRef.current) animation.cancel();
|
||||
for (const [animation, state] of entryAnimationsRef.current) {
|
||||
state.observer.disconnect();
|
||||
state.entry.style.overflow = "";
|
||||
state.entry.style.contentVisibility = state.contentVisibility;
|
||||
animation.cancel();
|
||||
}
|
||||
entryAnimationsRef.current.clear();
|
||||
window.removeEventListener("focus", onVisibility);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cn } from "@/lib/utils";
|
||||
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
||||
import { getMessageAttachments, type Message } from "@/lib/api";
|
||||
import { asStreamingAssistantMetadata } from "@/lib/chat-stream-presentation";
|
||||
import { AgentBubble } from "@/components/agent/agent-conversation";
|
||||
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
||||
import { ChevronDown, ChevronUp, Globe2, Link2, Wrench } from "lucide-preact";
|
||||
|
||||
@@ -95,6 +96,10 @@ type ToolStackContainerStyle = JSX.CSSProperties & {
|
||||
};
|
||||
type ToolStackMotionDirection = "expand" | "collapse" | null;
|
||||
|
||||
const noAgentAttachmentDownload = async (): Promise<Blob> => {
|
||||
throw new Error("No Agent attachment is available in this ordinary chat reply.");
|
||||
};
|
||||
|
||||
const COLLAPSED_TOOL_STACK_LIMIT = 4;
|
||||
const TOOL_STACK_CARD_HEIGHT = 62;
|
||||
const TOOL_STACK_CARD_GAP = 10;
|
||||
@@ -594,6 +599,15 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
|
||||
}
|
||||
|
||||
const isUser = message.role === "user";
|
||||
const metadata = message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata)
|
||||
? message.metadata as Record<string, unknown> : {};
|
||||
if (message.role === "assistant" && metadata.agentAside === true) {
|
||||
return (
|
||||
<div key={message.id} className="flex justify-start" data-agent-aside="true">
|
||||
<AgentBubble message={message} animate={message.id.startsWith("temp-assistant-")} onRetry={() => {}} download={noAgentAttachmentDownload} showThinkingIndicator />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const streamingAssistantMetadata = asStreamingAssistantMetadata(message.metadata);
|
||||
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending && message.content.trim().length === 0;
|
||||
const attachments = getMessageAttachments(message.metadata);
|
||||
|
||||
+2
-1
@@ -149,6 +149,7 @@ export type SearchRunRequest = {
|
||||
};
|
||||
|
||||
export type CompletionRequestMessage = {
|
||||
id?: string;
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
content: string;
|
||||
name?: string;
|
||||
@@ -186,7 +187,7 @@ type CompletionResponse = {
|
||||
};
|
||||
|
||||
type CompletionStreamHandlers = {
|
||||
onMeta?: (payload: { chatId: string | null; callId: string | null; provider: Provider; model: string }) => void;
|
||||
onMeta?: (payload: { chatId: string | null; callId: string | null; provider: Provider; model: string; agentAside?: boolean }) => void;
|
||||
onToolCall?: (payload: ToolCallEvent) => void;
|
||||
onDelta?: (payload: { text: string }) => void;
|
||||
onDone?: (payload: { text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }) => void;
|
||||
|
||||
@@ -6,6 +6,8 @@ export type StreamingAssistantMetadata = {
|
||||
kind: typeof STREAMING_ASSISTANT_METADATA_KIND;
|
||||
ephemeralTrace: string | null;
|
||||
traceRevision: number;
|
||||
agentAside?: true;
|
||||
status?: "sending" | "complete";
|
||||
};
|
||||
|
||||
export type StreamingAttemptState = {
|
||||
@@ -39,6 +41,8 @@ export function asStreamingAssistantMetadata(value: unknown): StreamingAssistant
|
||||
typeof record.traceRevision === "number" && Number.isFinite(record.traceRevision)
|
||||
? Math.max(0, Math.floor(record.traceRevision))
|
||||
: 0,
|
||||
...(record.agentAside === true ? { agentAside: true as const } : {}),
|
||||
...(record.status === "sending" || record.status === "complete" ? { status: record.status } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,6 +102,7 @@ export function setStreamingAssistantSegment(messages: Message[], assistantMessa
|
||||
metadata: {
|
||||
...metadata,
|
||||
ephemeralTrace: null,
|
||||
...(metadata.agentAside ? { status: "complete" as const } : {}),
|
||||
} satisfies StreamingAssistantMetadata,
|
||||
}));
|
||||
}
|
||||
@@ -128,6 +133,14 @@ export function finalizeStreamingAssistant(messages: Message[], assistantMessage
|
||||
}));
|
||||
}
|
||||
|
||||
export function markStreamingAssistantAsAgentAside(messages: Message[], assistantMessagePrefix: string) {
|
||||
return updateStreamingAssistant(messages, assistantMessagePrefix, (message, metadata) => ({
|
||||
...message,
|
||||
name: "Sybil Agent",
|
||||
metadata: { ...metadata, agentAside: true, status: "sending" },
|
||||
}));
|
||||
}
|
||||
|
||||
export function clearStreamingAssistantTrace(messages: Message[], assistantMessagePrefix: string) {
|
||||
return updateStreamingAssistant(messages, assistantMessagePrefix, (message, metadata) => ({
|
||||
...message,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Agent send scroll geometry fixture</title>
|
||||
<style>html,body,#root{height:100%;margin:0}body{overflow:hidden}#root{display:flex;min-height:0}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<pre id="result"></pre>
|
||||
<script type="module" src="/tests/agent-scroll-fixture.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,206 @@
|
||||
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);
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createServer } from 'vite';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
||||
const width = Number(process.env.GEOMETRY_WIDTH || 1200);
|
||||
const height = Number(process.env.GEOMETRY_HEIGHT || 900);
|
||||
const scrollAway = process.env.SCROLL_AWAY === '1';
|
||||
const browser = process.env.CHROME_EXECUTABLE || [
|
||||
'/usr/bin/chromium', '/usr/bin/google-chrome', '/usr/bin/chromium-browser',
|
||||
].find(existsSync) || 'chromium';
|
||||
const profile = await mkdtemp(`${tmpdir()}/sybil-agent-send-geometry-`);
|
||||
const server = await createServer({ configFile: resolve(root, 'vite.config.ts'), root,
|
||||
logLevel: 'error', server: { host: '127.0.0.1', port: 0, strictPort: false } });
|
||||
let child;
|
||||
let socket;
|
||||
|
||||
try {
|
||||
await server.listen();
|
||||
const address = server.httpServer.address();
|
||||
assert.ok(address && typeof address === 'object');
|
||||
const debugPort = await new Promise((resolvePort, reject) => {
|
||||
const probe = spawn(browser, ['--headless', '--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--window-size=1200,900',
|
||||
'--remote-debugging-port=0', '--remote-allow-origins=*', `--user-data-dir=${profile}`, '--no-first-run']);
|
||||
child = probe;
|
||||
let diagnostics = '';
|
||||
const timeout = setTimeout(() => reject(new Error(`Chrome startup failed: ${diagnostics.slice(-600)}`)), 10000);
|
||||
probe.stderr.setEncoding('utf8').on('data', chunk => {
|
||||
diagnostics += chunk;
|
||||
const match = diagnostics.match(/DevTools listening on ws:\/\/127\.0\.0\.1:(\d+)\//);
|
||||
if (match) { clearTimeout(timeout); resolvePort(Number(match[1])); }
|
||||
});
|
||||
probe.once('error', error => { clearTimeout(timeout); reject(error); });
|
||||
probe.once('close', code => { clearTimeout(timeout); reject(new Error(`Chrome exited (${code}): ${diagnostics.slice(-600)}`)); });
|
||||
});
|
||||
const targetResponse = await fetch(`http://127.0.0.1:${debugPort}/json/new?http://127.0.0.1:${address.port}/tests/agent-scroll-fixture.html`, { method: 'PUT' });
|
||||
assert.equal(targetResponse.status, 200);
|
||||
const target = await targetResponse.json();
|
||||
socket = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((resolveOpen, reject) => {
|
||||
socket.addEventListener('open', resolveOpen, { once: true });
|
||||
socket.addEventListener('error', reject, { once: true });
|
||||
});
|
||||
let commandId = 0;
|
||||
const pending = new Map();
|
||||
socket.addEventListener('message', event => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (!message.id) return;
|
||||
const request = pending.get(message.id);
|
||||
if (!request) return;
|
||||
pending.delete(message.id);
|
||||
if (message.error) request.reject(new Error(`${request.method}: ${message.error.message}`));
|
||||
else request.resolve(message.result);
|
||||
});
|
||||
const call = (method, params = {}) => new Promise((resolveCall, reject) => {
|
||||
const id = ++commandId;
|
||||
pending.set(id, { resolve: resolveCall, reject, method });
|
||||
socket.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
await call('Page.enable');
|
||||
await call('Runtime.enable');
|
||||
await call('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 768 });
|
||||
await call('Page.navigate', { url: `http://127.0.0.1:${address.port}/tests/agent-scroll-fixture.html?scrollAway=${scrollAway ? '1' : '0'}` });
|
||||
const evaluated = await call('Runtime.evaluate', {
|
||||
expression: `new Promise(resolve => { const deadline = Date.now() + 12000; const check = () => { if (document.documentElement.dataset.fixtureComplete === 'true') resolve({ result: JSON.parse(document.getElementById('result').textContent) }); else if (Date.now() >= deadline) resolve({ timedOut: true, error: document.documentElement.dataset.fixtureError || null, hasTimeline: Boolean(document.querySelector('.agent-room__timeline')), textAreaCount: document.querySelectorAll('textarea').length, roomSendCalled: document.documentElement.dataset.roomSendCalled || false, agentSubmitCalled: document.documentElement.dataset.agentSubmitCalled || false, entries: Array.from(document.querySelectorAll('[data-agent-entry-id]')).filter(entry => entry.dataset.agentEntryId.includes('fixture')).map(entry => ({ id: entry.dataset.agentEntryId, captured: entry.dataset.geometryCaptured || false, animations: entry.getAnimations().map(animation => ({ state: animation.playState, time: animation.currentTime, target: animation.effect?.target?.className || 'other' })) })), reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches, resultText: document.getElementById('result')?.textContent }); else setTimeout(check, 25); }; check(); })`,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
});
|
||||
assert.equal(evaluated.exceptionDetails, undefined, evaluated.exceptionDetails?.text);
|
||||
const page = evaluated.result.value;
|
||||
assert.equal(page.timedOut, undefined, `fixture timed out: ${JSON.stringify(page)}`);
|
||||
const result = page.result;
|
||||
if (process.env.BASELINE_GEOMETRY !== '1') {
|
||||
const byLabel = Object.fromEntries(result.samples.map(sample => [sample.label, sample]));
|
||||
for (const label of ['user', 'agent']) {
|
||||
assert.ok(byLabel[`${label}-start`] && byLabel[`${label}-mid`] && byLabel[`${label}-late`] && byLabel[`${label}-pre-end`] && byLabel[`${label}-finish`] && byLabel[`${label}-stable`], JSON.stringify(result.samples));
|
||||
const endpoint = Number(String(byLabel[`${label}-start`].keyframeHeights[1]).replace('px', ''));
|
||||
assert.ok(endpoint + 1 >= byLabel[`${label}-start`].contentHeight,
|
||||
`${label} animation used a culling placeholder instead of its content height: ${JSON.stringify(result.samples)}`);
|
||||
assert.equal(byLabel[`${label}-mid`].entryAnimationCount, 1, `${label} entrance animation stopped before the intermediate sample`);
|
||||
assert.ok(Math.abs(byLabel[`${label}-finish`].rowHeight - byLabel[`${label}-stable`].rowHeight) < 1,
|
||||
`${label} height moved after animation finish: ${JSON.stringify(result.samples)}`);
|
||||
assert.ok(byLabel[`${label}-stable`].entryAnimationCount === 0, `${label} entry animation did not clean up`);
|
||||
assert.ok(byLabel[`${label}-finish`].time - byLabel[`${label}-start`].time < 330,
|
||||
`${label} entrance animation was prolonged by streaming updates: ${JSON.stringify(result.samples)}`);
|
||||
if (scrollAway) {
|
||||
assert.ok(Math.abs(Number(byLabel[`${label}-stable`].anchorShift)) < 1, `${label} reader anchor moved: ${JSON.stringify(result.samples)}`);
|
||||
assert.ok(byLabel[`${label}-stable`].bottomGap > 100, `${label} reader was pulled to bottom: ${JSON.stringify(result.samples)}`);
|
||||
} else assert.ok(byLabel[`${label}-stable`].bottomGap <= 1, `${label} pinned viewport left a gap: ${JSON.stringify(result.samples)}`);
|
||||
}
|
||||
assert.ok(byLabel['agent-late'].emptyHeight < 40, `Agent spacer persisted late in the entrance animation: ${JSON.stringify(result.samples)}`);
|
||||
const retargetedEndpoint = Number(String(byLabel['agent-mid'].keyframeHeights.at(-1)).replace('px', ''));
|
||||
assert.ok(byLabel['agent-mid'].keyframeHeights.length >= 3 && Math.abs(retargetedEndpoint - byLabel['agent-mid'].contentHeight) < 1,
|
||||
`Agent animation did not retarget to the streamed message height: ${JSON.stringify(result.samples)}`);
|
||||
assert.ok(Math.abs(byLabel['agent-pre-end'].emptyHeight) < 2, `Agent row changed height as the entrance animation finished: ${JSON.stringify(result.samples)}`);
|
||||
assert.ok(Math.abs(byLabel['agent-stable'].emptyHeight) < 1, `Agent reply left an empty spacer: ${JSON.stringify(result.samples)}`);
|
||||
assert.equal(result.roomSendCalled, true, 'composer did not use the real room send boundary');
|
||||
assert.equal(result.agentSubmitCalled, true, 'optimistic outbox did not invoke its submission boundary');
|
||||
assert.equal(result.composerFocused, true, 'composer focus was lost after send');
|
||||
assert.equal(result.readDuringEntryAnimation, false, 'read state advanced during an entry animation');
|
||||
assert.equal(byLabel['user-stable'].emptyHeight, 0, 'sent user message retained an empty spacer');
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify({ browser, width, height, scrollAway, baseline: process.env.BASELINE_GEOMETRY === '1', result }, null, 2)}\n`);
|
||||
} finally {
|
||||
socket?.close();
|
||||
if (child && child.exitCode === null) {
|
||||
const closed = new Promise(resolveClose => child.once('close', resolveClose));
|
||||
child.kill('SIGTERM');
|
||||
await closed;
|
||||
}
|
||||
await server.close();
|
||||
await rm(profile, { recursive: true, force: true });
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createStreamingAssistantMetadata,
|
||||
createStreamingAttemptState,
|
||||
finalizeStreamingAssistant,
|
||||
markStreamingAssistantAsAgentAside,
|
||||
promoteStreamingAssistantTrace,
|
||||
setStreamingAssistantSegment,
|
||||
transitionStreamingToolCall,
|
||||
@@ -111,6 +112,20 @@ test("a fresh per-attempt accumulator can replay and reclassify an already-seen
|
||||
assert.equal(metadata(messages)?.ephemeralTrace, "Replayed trace");
|
||||
});
|
||||
|
||||
test("Agent aside stream keeps identity through loading, markdown deltas, and completion", () => {
|
||||
let messages = [assistantMessage()];
|
||||
messages = markStreamingAssistantAsAgentAside(messages, "temp-assistant-");
|
||||
assert.equal(messages.at(-1).name, "Sybil Agent");
|
||||
assert.equal(asStreamingAssistantMetadata(messages.at(-1).metadata).agentAside, true);
|
||||
assert.equal(asStreamingAssistantMetadata(messages.at(-1).metadata).status, "sending");
|
||||
|
||||
messages = setStreamingAssistantSegment(messages, "temp-assistant-", "**Inline** reply");
|
||||
messages = finalizeStreamingAssistant(messages, "temp-assistant-", "**Inline** reply");
|
||||
assert.equal(asStreamingAssistantMetadata(messages.at(-1).metadata).agentAside, true);
|
||||
assert.equal(asStreamingAssistantMetadata(messages.at(-1).metadata).status, "complete");
|
||||
assert.equal(messages.at(-1).content, "**Inline** reply");
|
||||
});
|
||||
|
||||
test("message helpers preserve identity when no matching temporary assistant exists", () => {
|
||||
const messages = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user