Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -750,7 +750,7 @@ const defaultRegistry: NativeRegistry = {
[SUBAGENT_OF]: SubagentOfPillRow,
};

/// A row's height as the page drew it, valid while the row's content version and width hold.
/// A row's height as the page drew it at a width. It places the row until its next version is drawn.
type DrawnHeight = { sessionId?: string; version: number; width: number; height: number };
type ReportDrawn = (id: string, version: number, height: number) => void;

Expand Down Expand Up @@ -1398,14 +1398,13 @@ export function VirtualTranscript({
? estimated.layout
: placeRows(
estimated.layout,
// A row keeps its last drawn height until its new version is drawn: a streaming reply's
// text appears after the version that holds it, so an estimate of the version ran ahead
// of the drawn text, and its drawn height then pulled the view back a line or more at
// every chunk (agent-pane-chat-column.test.ts under load). Growth comes from what is drawn.
(index) => {
const known = drawn.get(rows[index].id);
return known &&
known.sessionId === sessionId &&
known.version === rows[index].version &&
known.width === rowWidth
? known.height
: undefined;
return known && known.sessionId === sessionId && known.width === rowWidth ? known.height : undefined;
},
last && kept > 0 ? { layout: last.layout, kept } : undefined,
);
Expand Down
6 changes: 5 additions & 1 deletion webviews/src/pages/home-channels/cloudHome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,11 @@ export class CloudHome {
if (id === LOCAL_USER) return undefined;
for (const [conversation, head] of this.heads) {
const people = head.participants;
if (Array.isArray(people) && people.some((person) => (person as Json | null)?.id === id && (person as Json).left_at === undefined)) return conversation;
if (
Array.isArray(people) &&
people.some((person) => (person as Json | null)?.id === id && (person as Json).left_at === undefined)
)
return conversation;
}
for (const [conversation, entry] of this.entries) if (entry.dm_peer === id) return conversation;
return undefined;
Expand Down
63 changes: 51 additions & 12 deletions webviews/test/agent-pane-chat-column.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,14 @@ await requireBrowserLane("agent-pane-chat-column.test.ts", async () => {
await daemon?.stop(true);
});

async function open(chat: Chat, width: number, height = 700): Promise<Page> {
/// The page runs as on a loaded CI runner: rows draw a frame or more after the reply grows, so the
/// view must follow what is drawn, not an estimate a later frame takes back (cx-next-ghostty-config).
const LOADED = 6;

async function open(chat: Chat, width: number, height = 700, loaded = false): Promise<Page> {
const page = await browser!.newPage({ viewport: { width, height } });
if (loaded)
await (await page.context().newCDPSession(page)).send("Emulation.setCPUThrottlingRate", { rate: LOADED });
const { port } = vite!.httpServer!.address() as { port: number };
const endpoint = `ws://127.0.0.1:${daemon!.port}/`;
await page.goto(`http://127.0.0.1:${port}/#endpoint=${encodeURIComponent(endpoint)}&token=t&session=${chat.id}`);
Expand All @@ -170,14 +176,31 @@ await requireBrowserLane("agent-pane-chat-column.test.ts", async () => {

/// What a painted frame shows: how far the view sits above the transcript's end, where it is
/// scrolled to, and how far the last line of text sits above the view's bottom; or, given
/// `text`, the top of the row that holds it.
type Frame = { fromEnd: number; scrollTop: number; lastLine: number; top: number | null; glides: number };
/// `text`, the top of the row that holds it, and where the row is placed under the thread's glide.
/// `glided` sums the glides the thread started since the frame before.
type Frame = {
fromEnd: number;
scrollTop: number;
lastLine: number;
top: number | null;
placed: number | null;
glides: number;
glided: number;
};
/// Reads every painted frame until `stop`.
const watch = (page: Page, text?: string) =>
page.evaluate((text) => {
const scroller = document.querySelector<HTMLElement>(".acpmux-scroll")!;
let glided = 0;
const animate = Element.prototype.animate;
Element.prototype.animate = function (keyframes, options) {
if (this.classList.contains("acpmux-thread") && Array.isArray(keyframes))
glided += Number(/translateY\((-?[\d.]+)px\)/.exec(String(keyframes[0]?.transform))?.[1] ?? 0);
return animate.call(this, keyframes, options);
};
const sample = () => {
const view = scroller.getBoundingClientRect();
const thread = document.querySelector<HTMLElement>(".acpmux-thread");
const lines = [...scroller.querySelectorAll("p")];
const holds = (node: Element) => node.textContent?.includes(text!) ?? false;
const row = text
Expand All @@ -189,14 +212,23 @@ await requireBrowserLane("agent-pane-chat-column.test.ts", async () => {
scrollTop: Math.round(scroller.scrollTop),
lastLine: Math.round(view.bottom - (lines.at(-1)?.getBoundingClientRect().bottom ?? view.bottom)),
top: row ? Math.round(row.getBoundingClientRect().top) : null,
glides: document.querySelector(".acpmux-thread")?.getAnimations().length ?? 0,
placed: row ? row.getBoundingClientRect().top - new DOMMatrix(getComputedStyle(thread!).transform).m42 : null,
glides: thread?.getAnimations().length ?? 0,
glided: glided - (glided = 0),
};
};
const frames: unknown[] = [];
(window as any).__frames = frames;
// A frame paints at the viewport its animation frame saw: the rendering steps apply a
// resize before that callback, and no task runs between it and the paint. A resize that
// lands after it is read here by a forced layout the page never painted (the rows rewrapped,
// not yet placed), so that read is not a frame.
const tick = () => {
if ((window as any).__frames !== frames) return;
setTimeout(() => frames.push(sample()), 0);
const painted = `${innerWidth}x${innerHeight}`;
setTimeout(() => {
if (`${innerWidth}x${innerHeight}` === painted) frames.push(sample());
}, 0);
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
Expand All @@ -209,6 +241,14 @@ await requireBrowserLane("agent-pane-chat-column.test.ts", async () => {
return frames as never;
});
};
/// The frames whose row moved by more than the glides started with it: a step drawn at once.
/// A step under half a pixel does not glide, and a frame can take two (estimate, drawn height).
const snapped = (frames: Frame[]) =>
frames.flatMap((frame, index) => {
const before = frames[index - 1];
if (!before || before.placed === null || frame.placed === null) return [];
return Math.abs(before.placed - frame.placed - frame.glided) > 1 ? [[before, frame]] : [];
});
/// The frames whose text moved down on screen from the frame before: a jump back.
const movedDown = (frames: Frame[]) =>
frames.flatMap((frame, index) =>
Expand Down Expand Up @@ -308,7 +348,7 @@ await requireBrowserLane("agent-pane-chat-column.test.ts", async () => {

test("a streaming reply keeps the view at its end, through a growing command and a queue", async () => {
const chat = newChat();
const page = await open(chat, 900);
const page = await open(chat, 900, 700, true);
const atRest = await restingLastLine(page);
await watch(page, "Paragraph 20.");
for (let index = 25; index <= 34; index++) {
Expand Down Expand Up @@ -371,7 +411,7 @@ await requireBrowserLane("agent-pane-chat-column.test.ts", async () => {

test("a reply streamed a few words at a time scrolls smoothly: its last line holds still at the bottom", async () => {
const chat = newChat();
const page = await open(chat, 900);
const page = await open(chat, 900, 700, true);
const atRest = await restingLastLine(page);
await watch(page, "Paragraph 20.");
const words = (paragraph(25) + paragraph(26) + paragraph(27)).split(/(?<= )/);
Expand All @@ -381,12 +421,11 @@ await requireBrowserLane("agent-pane-chat-column.test.ts", async () => {
}
await page.getByText("Paragraph 27.", { exact: false }).waitFor();
const frames = await stop(page);
// Each new line glides the text up (acp-streaming.md "Scroll"): never down and back, never more
// than a line in one frame, and the newest line comes to rest where the last one did.
// Each new line glides the text up (acp-streaming.md "Scroll"): never down and back, never
// drawn at once, at any frame rate, and the newest line comes to rest where the last one did.
expect(movedDown(frames)).toEqual([]);
expect(backwards(frames)).toEqual([]);
const steps = frames.slice(1).map((frame, index) => (frames[index]!.top ?? 0) - (frame.top ?? 0));
expect(Math.max(...steps)).toBeLessThanOrEqual(23);
expect(snapped(frames)).toEqual([]);
// The view comes to rest where it started; the last frames read show where it was going.
const rest = await restingLastLine(page);
expect({
Expand All @@ -405,7 +444,7 @@ await requireBrowserLane("agent-pane-chat-column.test.ts", async () => {
// their drawn heights at the new width before it paints. That is the resize, not growth, so
// nothing slides: the last line sits where it rests in every painted frame.
const chat = newChat();
const page = await open(chat, 760);
const page = await open(chat, 760, 700, true);
const atRest = await restingLastLine(page);
await watch(page);
for (const widths of [
Expand Down
Loading