diff --git a/src/index.css b/src/index.css
index e257f7d0..b400b6b3 100644
--- a/src/index.css
+++ b/src/index.css
@@ -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;
}
@@ -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
diff --git a/src/surfaces/AgentTranscript.test.ts b/src/surfaces/AgentTranscript.test.ts
index 47d25c2f..b096b0da 100644
--- a/src/surfaces/AgentTranscript.test.ts
+++ b/src/surfaces/AgentTranscript.test.ts
@@ -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";
@@ -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 [^"]*">
]*>.*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("");
+ });
+});
diff --git a/src/surfaces/AgentTranscript.tsx b/src/surfaces/AgentTranscript.tsx
index b10bc82f..38bc5650 100644
--- a/src/surfaces/AgentTranscript.tsx
+++ b/src/surfaces/AgentTranscript.tsx
@@ -416,7 +416,9 @@ function AgentTranscriptComponent({
key={item.block.id}
block={item.block}
layout={transcriptLayout}
- stickyIndex={firstVisibleTurn + turnIndex + 1}
+ // A prompt only reports itself pinned while it can be pinned,
+ // and it watches the scroller it is pinned to.
+ scroller={promptAnchor ? scrollerEl : null}
// Prose reads the same wherever it lands: under the fold
// line at the top of the turn, or under the work it follows.
underLine={
@@ -449,6 +451,14 @@ function AgentTranscriptComponent({
/>
);
+ // groupTurns opens a turn at its prompt, so the user message is
+ // always the first item. It is rendered outside the turn body: the
+ // body is the contained, virtualized block, and a sticky row inside
+ // it would stick to the body rather than to the scroller.
+ const promptItem =
+ items[0]?.type === "block" && items[0].block.role === "user"
+ ? items[0]
+ : undefined;
return (
- {items.flatMap((item, itemIndex) => {
- const inFold =
- !!fold && itemIndex >= fold.start && itemIndex <= fold.end;
- if (inFold) {
- if (itemIndex !== fold.start) return [];
- return [
- foldLineRow,
-
- {() =>
- items
- .slice(fold.start, fold.end + 1)
- .map((entry, offset) => (
-
- {renderItem(entry, fold.start + offset)}
-
- ))
- }
- ,
- ];
- }
- const row = (
-
- {renderItem(item, itemIndex)}
-
- );
- if (itemIndex !== foldLineAt) return row;
- return [foldLineRow, row];
- })}
- {foldLineAt >= items.length ? foldLineRow : null}
- {durationMs != null && settled ? (
-
onSecondOpinion(target, turn, model)
- : undefined
- }
- onHandoff={
- onHandoff
- ? (target, model) => onHandoff(target, turn, model)
+ {promptItem ? (
+ // Pinned, the prompt travels the whole height of its turn and
+ // the next turn's prompt pushes it out. The ascending z-index
+ // keeps the arriving prompt above the leaving one during that
+ // handoff.
+
+ >
+ {renderItem(promptItem, 0)}
+
) : null}
+
+ {items.flatMap((item, itemIndex) => {
+ if (item === promptItem) return [];
+ const inFold =
+ !!fold && itemIndex >= fold.start && itemIndex <= fold.end;
+ if (inFold) {
+ if (itemIndex !== fold.start) return [];
+ return [
+ foldLineRow,
+
+ {() =>
+ items
+ .slice(fold.start, fold.end + 1)
+ .map((entry, offset) => (
+
+ {renderItem(entry, fold.start + offset)}
+
+ ))
+ }
+ ,
+ ];
+ }
+ const row = (
+
+ {renderItem(item, itemIndex)}
+
+ );
+ if (itemIndex !== foldLineAt) return row;
+ return [foldLineRow, row];
+ })}
+ {foldLineAt >= items.length ? foldLineRow : null}
+ {durationMs != null && settled ? (
+
+ onSecondOpinion(target, turn, model)
+ : undefined
+ }
+ onHandoff={
+ onHandoff
+ ? (target, model) => onHandoff(target, turn, model)
+ : undefined
+ }
+ />
+ ) : null}
+
);
})}
@@ -755,7 +787,7 @@ function SaveNoteButton({
const TranscriptBlock = memo(function TranscriptBlock({
block,
layout,
- stickyIndex,
+ scroller,
underLine = false,
cwd,
onApproval,
@@ -769,7 +801,8 @@ const TranscriptBlock = memo(function TranscriptBlock({
}: {
block: Block;
layout: TranscriptLayout;
- stickyIndex: number;
+ /** The transcript scroller a pinned prompt reports against, or null. */
+ scroller: HTMLElement | null;
/** True when something already sits directly above this in the turn. */
underLine?: boolean;
cwd?: string;
@@ -784,11 +817,7 @@ const TranscriptBlock = memo(function TranscriptBlock({
}) {
if (block.role === "user") {
return (
-
+
);
}
@@ -893,16 +922,18 @@ const TranscriptBlock = memo(function TranscriptBlock({
function UserMessageBlock({
block,
layout,
- stickyIndex,
+ scroller,
}: {
block: Block;
layout: TranscriptLayout;
- stickyIndex: number;
+ scroller: HTMLElement | null;
}) {
const [expanded, setExpanded] = useState(false);
const [overflows, setOverflows] = useState(false);
const [singleLine, setSingleLine] = useState(false);
+ const [stuck, setStuck] = useState(false);
const textRef = useRef(null);
+ const rowRef = useRef(null);
const card = block.secondOpinion;
const note = block.noteCard;
const text = card && card.kind !== "handoff" ? "" : block.text;
@@ -950,15 +981,46 @@ function UserMessageBlock({
return () => observer.disconnect();
}, [text, roundsSingleLine, expanded]);
+ // WKWebView has neither `:stuck` nor scroll-state container queries, so the
+ // pinned state is observed instead: inset the scroller's top edge by a pixel
+ // and a prompt resting on it stops being fully visible. One observer per user
+ // message, the same cost as the resize observer above.
+ useEffect(() => {
+ const el = rowRef.current;
+ if (!scroller || !el) {
+ setStuck(false);
+ return;
+ }
+ const observer = new IntersectionObserver(
+ ([entry]) =>
+ // Leaving through the bottom of the viewport is the same "not fully
+ // visible" report, so pinned is the case where the message has left
+ // through the top: its own top sits at or above the inset edge.
+ setStuck(
+ !entry.isIntersecting &&
+ entry.boundingClientRect.top <= (entry.rootBounds?.top ?? 0),
+ ),
+ { root: scroller, rootMargin: "-1px 0px 0px 0px", threshold: 1 },
+ );
+ observer.observe(el);
+ return () => {
+ observer.disconnect();
+ setStuck(false);
+ };
+ }, [scroller]);
+
const toggle = () => {
if (overflows) setExpanded((value) => !value);
};
return (
{block.attachments?.length ? (
@@ -991,7 +1052,12 @@ function UserMessageBlock({
{text ? (
{text}
diff --git a/src/surfaces/SettingsView.tsx b/src/surfaces/SettingsView.tsx
index a2b46524..04a68c44 100644
--- a/src/surfaces/SettingsView.tsx
+++ b/src/surfaces/SettingsView.tsx
@@ -427,7 +427,7 @@ function GeneralPage({