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({