Skip to content
Draft
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
21 changes: 19 additions & 2 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,15 @@ html.has-native-glass .sidebar-glass {
background: var(--color-background-base);
}

.transcript-turn {
/*
* The turn wrapper is the sticky prompt's travel track, so it has to stay free
* of containment: content-visibility implies paint containment, and a sticky
* child of a painted box sticks to that box instead of the scroller — a no-op.
* Virtualization lives on the body, which holds everything under the prompt.
* The 240px fallback now estimates the body alone; the prompt is short text
* that measures itself.
*/
.transcript-turn-body {
content-visibility: auto;
contain-intrinsic-block-size: auto 240px;
}
Expand All @@ -130,10 +138,19 @@ html.has-native-glass .sidebar-glass {
* intrinsic fallback when the first step arrives, which bounces Working
* down and then back up.
*/
.transcript-turn-live {
.transcript-turn-live .transcript-turn-body {
content-visibility: visible;
}

/*
* A pinned prompt has its own reply scrolling underneath it, so the bar it
* turns into must be opaque — the bubble's bg-content/10 is a tint and the
* text below would read straight through it.
*/
.transcript-prompt-stuck {
background: var(--color-background-base);
}

/*
* After a send, the live turn fills the transcript viewport so the prompt sits
* at the top. Switching tabs keeps this. Closing the tab drops it, so opening
Expand Down
69 changes: 69 additions & 0 deletions src/surfaces/AgentTranscript.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
Expand Down Expand Up @@ -55,3 +56,71 @@ describe("AgentTranscript collapsed work", () => {
expect(markup.includes('aria-label="Show the work"')).toBe(false);
});
});

/**
* The prompt-to-top setting is read straight off localStorage while the
* transcript renders, and the node test environment has none. Stand one up for
* the duration of a render so both sides of the toggle are reachable.
*/
function renderWithPromptAnchor(blocks: Block[], anchor: boolean) {
const store = new Map([["monocode.transcriptAnchor", anchor ? "1" : "0"]]);
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => store.get(key) ?? null,
setItem: () => {},
removeItem: () => {},
clear: () => {},
key: () => null,
length: store.size,
},
});
try {
return render(blocks);
} finally {
Reflect.deleteProperty(globalThis, "localStorage");
}
}

const PROMPT_TURN: Block[] = [
{ id: "user", role: "user", text: "Check the project" },
{ id: "answer", role: "assistant", text: "The project checks passed." },
];

describe("AgentTranscript sticky prompt", () => {
it("pins the prompt row while prompts are anchored to the top", () => {
const markup = renderWithPromptAnchor(PROMPT_TURN, true);
// The pinned row has to be a direct child of the turn, ahead of the
// contained body: inside it, sticky would resolve against the body box.
expect(markup).toMatch(
/class="transcript-turn [^"]*"><div class="[^"]*sticky top-0"[^>]*>.*Check the project/,
);
});

it("leaves the prompt row unpinned when the setting is off", () => {
const markup = renderWithPromptAnchor(PROMPT_TURN, false);
expect(markup).toContain("Check the project");
expect(markup.includes("sticky top-0")).toBe(false);
});

it("stacks pinned prompts in transcript order", () => {
const markup = renderWithPromptAnchor(
[...PROMPT_TURN, { id: "user-2", role: "user", text: "And again" }],
true,
);
const zIndexes = [...markup.matchAll(/z-index:(\d+)/g)].map((match) =>
Number(match[1]),
);
expect(zIndexes).toEqual([1, 2]);
});

it("virtualizes the turn body, not the wrapper the prompt sticks to", () => {
const markup = renderWithPromptAnchor(PROMPT_TURN, true);
expect(markup).toContain('class="transcript-turn-body');
const css = readFileSync(new URL("../index.css", import.meta.url), "utf8");
const rule = (selector: string) =>
css.match(new RegExp(`\\${selector} \\{[^}]*\\}`))?.[0] ?? "";
expect(rule(".transcript-turn-body")).toContain("content-visibility: auto");
expect(rule(".transcript-turn")).toBe("");
});
});
Loading