diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 753665e9f774..fa592aac71ad 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -1996,6 +1996,8 @@ function TranscriptContext({ export function VirtualTranscript({ rows, + earlier, + loadThrough, sessionId, onToggleActivity, onOpenDiff, @@ -2028,6 +2030,8 @@ export function VirtualTranscript({ showMinimap = true, }: { rows: AcpmuxRow[]; + earlier?: import("./threadMinimap/earlierPrompts").EarlierPromptsStore; + loadThrough?: (seq: number) => Promise; sessionId?: string; /// Where the transcript opens instead of the latest message, when that row is loaded. openAt?: ScrollAnchor; @@ -3966,6 +3970,8 @@ export function VirtualTranscript({ {showMinimap && ( ( + directClient.current?.loadThrough(seq) ?? Promise.resolve() + : undefined + } sourceRows={childRows ? undefined : sourceRows} working={snapshot.isWorking} // Its stage positions Jump to latest; the Quick Composer's panel has no room for it. diff --git a/webviews/src/agent-session/acpmux/threadMinimap/ThreadMinimap.tsx b/webviews/src/agent-session/acpmux/threadMinimap/ThreadMinimap.tsx index 5a82e69f9c52..d4fea6644614 100644 --- a/webviews/src/agent-session/acpmux/threadMinimap/ThreadMinimap.tsx +++ b/webviews/src/agent-session/acpmux/threadMinimap/ThreadMinimap.tsx @@ -7,7 +7,17 @@ // transcript's layout (`tops`), so nothing here measures a transcript row; the rail reads only its // own list (during a scrub) and the card's height. Motion is transform only (threadMinimap.css); // the numbers are in model.ts. -import React, { memo, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; +import React, { + memo, + useCallback, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { transcriptColumn, type AcpmuxRow, type ConversationLayout } from "../model"; import { useT } from "../i18n"; import { MinimapTick } from "../../../ui/MinimapTick"; @@ -23,6 +33,7 @@ import { MARK_OPACITY, threadMinimapMark } from "./mark.experiment"; import { experimentArm } from "../../../experiments/experiment"; import { tickRanges } from "./tickWindow"; import { useThreadMinimapArm } from "../chatDisplay"; +import type { EarlierPromptsStore } from "./earlierPrompts"; import { CARD_DELAY, cardOffset, @@ -63,6 +74,11 @@ const SCRUB_SLOP = 3; const gutter = (width: number) => transcriptColumn(width).inset; type Jump = "click" | "step" | "scrub" | "bookmark"; +const EMPTY_EARLIER_ROWS: readonly AcpmuxRow[] = []; +const EMPTY_EARLIER: EarlierPromptsStore = { + subscribe: () => () => {}, + get: () => EMPTY_EARLIER_ROWS, +}; /// A row the rail's state names: the index the state holds for it, and its prompt's key. type KeyedRow = { index: number; key: string }; @@ -104,6 +120,8 @@ const bubbleOf = (row: HTMLElement | undefined) => row?.querySelector Promise; sessionId?: string; layout: ConversationLayout; scroller: React.RefObject; @@ -135,6 +155,7 @@ export function ThreadMinimap({ change?: RowsChange; }) { const t = useT(); + const earlierRows = useSyncExternalStore(earlier.subscribe, earlier.get, earlier.get); /// The full-height map (ScrollMap.tsx), the compact Codex rail below, or none (`off`: the turn /// steps below still run), read once per pane. The Chat Minimap setting /// (`agentPane.display.minimap`) wins when set, live. @@ -151,11 +172,16 @@ export function ThreadMinimap({ const lastTurns = useRef(undefined); // Every streamed token is a new `rows`: `turns` then changes with the live reply (the card reads // it), while `ticks` stays as long as the prompts do, so the tick list below renders nothing. - const read = useMemo(() => minimapTurns(rows, sessionId, lastTurns.current, change), [rows, sessionId, change]); + const read = useMemo( + () => minimapTurns(rows, sessionId, lastTurns.current, change, earlierRows), + [rows, sessionId, change, earlierRows], + ); useLayoutEffect(() => { lastTurns.current = read; }, [read]); const { turns, ticks } = read; + const loadedTurns = read.loadedTurns ?? turns.filter((turn) => turn.index >= 0); + const loadedStart = read.loadedStart ?? 0; /// The row under the pointer, or the one a scrub is on. const [hovered, setHovered] = useState(undefined); /// The row keyboard focus is on. @@ -215,7 +241,7 @@ export function ThreadMinimap({ restoring.current = { from: seen, by: (layout.tops[anchored.index] ?? 0) - anchor.current!.layoutTop }; const shift = restoring.current && restoring.current.from === seen ? restoring.current.by : 0; const top = (seen ?? scrollTop) + shift; - const onScreen = visibleTurns(turns, layout.tops, layout.totalHeight, top, viewportHeight); + const onScreen = visibleTurns(turns, layout.tops, layout.totalHeight, top, viewportHeight, loadedStart); /// Never shorter than the prompts on screen, so the band always draws whole (model.ts railHeight). const height = railHeight(turns.length, windowHeight, viewportHeight, foot, onScreen.last - onScreen.first + 1); // A view with no room for a single row (the transcript is at least 96 px tall) draws no rail. A @@ -236,6 +262,7 @@ export function ThreadMinimap({ sessionId, shown, hovered, + loadedStart, }); useLayoutEffect(() => { live.current = { @@ -249,6 +276,7 @@ export function ThreadMinimap({ sessionId, shown, hovered, + loadedStart, }; }); @@ -263,7 +291,7 @@ export function ThreadMinimap({ restoring.current = undefined; // The full-height map moves its own bar; no band to draw here. if (!live.current.shown) return; - const next = visibleTurns(all, tops, total, node.scrollTop, height); + const next = visibleTurns(all, tops, total, node.scrollTop, height, live.current.loadedStart); if (next.first !== drawn.first || next.last !== drawn.last) setBandMoves((count) => count + 1); }; // Captured, so it runs before the transcript's own scroll handler and that one's render finds the @@ -284,7 +312,7 @@ export function ThreadMinimap({ domTop.current = { sessionId: session, top: node.scrollTop }; restoring.current = undefined; if (!live.current.shown) return; - const next = visibleTurns(all, tops, total, node.scrollTop, height); + const next = visibleTurns(all, tops, total, node.scrollTop, height, live.current.loadedStart); if (next.first !== drawn.first || next.last !== drawn.last) setBandMoves((count) => count + 1); }; return () => { @@ -305,10 +333,18 @@ export function ThreadMinimap({ /// at once when it is not (or under Reduce Motion, or while scrubbing). It lands the top of the /// prompt's row (its sent images, else its bubble) LANDING_OFFSET down. A click or a bookmark jump /// flashes the prompt's bubble where it lands. Returns where the scroll goes. + const waiting = useRef<{ id: string; how: Jump } | undefined>(undefined); const [jump] = useState(() => (index: number, how: Jump): number | undefined => { const node = scroller.current; const turn = live.current.turns[index]; if (!node || !turn) return undefined; + if (turn.index < 0) { + const seq = Number(turn.id.slice(turn.id.indexOf("-") + 1)); + if (!Number.isFinite(seq) || !loadThrough) return undefined; + waiting.current = { id: turn.id, how }; + void loadThrough(seq).catch(() => undefined); + return undefined; + } const target = () => Math.min(turnScrollTop(turn, live.current.tops), Math.max(0, live.current.total - node.clientHeight)); const row = mountedRow(node, turn.id); @@ -345,6 +381,14 @@ export function ThreadMinimap({ } return to; }); + useLayoutEffect(() => { + const pending = waiting.current; + if (!pending) return; + const index = turns.findIndex((turn) => turn.id === pending.id && turn.index >= 0); + if (index < 0) return; + waiting.current = undefined; + jump(index, pending.how); + }, [turns, jump]); // Previous Turn and Next Turn (the app's Ctrl-Opt-Up and Ctrl-Opt-Down) and Previous and Next // Bookmarked Prompt (palette actions) are bridge commands that raise these events. They work @@ -671,7 +715,7 @@ export function ThreadMinimap({ return ( turn.index >= 0), + ticks: prior.loadedTicks ?? prior.ticks.filter((_, index) => prior.turns[index]?.index >= 0), + } + : undefined; let from = 0; if (reuse && change && change.base === reuse.rows && change.rows === rows) from = change.from; else if (reuse) while (from < rows.length && from < reuse.rows.length && rows[from] === reuse.rows[from]) from += 1; @@ -422,9 +440,68 @@ export function minimapTurns( // two last differed, so a streamed token whose reply preview is still filling writes the live turn // alone, not a copy of every turn (#24058 review round 5), and `previous` stays the committed rows' // (round 6). A dropped render wrote only the spare, and its agreement says how far it still holds. - if (!old) return { rows, sessionId, turns: tail, ticks: ticksOf(tail, oldTicks) }; - if (sameTurns) - return { rows, sessionId, turns: old, ticks: sameTicks ? oldTicks! : ticksOf(old, oldTicks), spare: reuse!.spare }; + const withEarlier = ( + loadedTurns: readonly MinimapTurn[], + loadedTicks: readonly MinimapTick[], + spare?: MinimapTurn[], + ): MinimapTurns => { + if ( + prior && + prior.earlierRows === earlierRows && + prior.loadedTurns === loadedTurns && + prior.loadedTicks === loadedTicks + ) + return prior; + const sameEarlier = prior?.earlierRows === earlierRows; + const earlier = + (sameEarlier && prior?.earlierTurns) || + earlierRows.map((row): MinimapTurn => ({ + index: -1, + id: row.id, + key: turnKey(sessionId, row), + prompt: (row.text ?? "").replace(/\s+/g, " ").trim(), + reply: [], + })); + let turns = loadedTurns; + let spareCombined: MinimapTurn[] | undefined; + if (earlier.length) { + const combined = prior?.spareCombined ?? []; + if (!sameEarlier) { + combined.length = earlier.length; + for (let index = 0; index < earlier.length; index += 1) combined[index] = earlier[index]!; + } else if (combined.length < earlier.length) { + combined.length = earlier.length; + for (let index = 0; index < earlier.length; index += 1) combined[index] = earlier[index]!; + } + combined.length = earlier.length; + for (const turn of loadedTurns) combined.push(turn); + turns = combined; + spareCombined = prior?.turns as MinimapTurn[] | undefined; + } + return { + rows, + sessionId, + turns, + ticks: + earlier.length && sameEarlier && prior?.loadedTicks === loadedTicks + ? prior.ticks + : earlier.length + ? ticksOf(turns, prior?.ticks) + : loadedTicks, + earlierRows, + loadedTurns, + loadedTicks, + loadedStart: earlier.length, + earlierTurns: earlier, + ...(spareCombined ? { spareCombined } : {}), + ...(spare ? { spare } : {}), + }; + }; + if (!old) { + const loaded = ticksOf(tail, oldTicks); + return withEarlier(tail, loaded); + } + if (sameTurns) return withEarlier(old, sameTicks ? oldTicks! : ticksOf(old, oldTicks), reuse!.spare); const list = reuse!.spare ?? []; const agreement = agreed.get(list); for (let at = agreement?.list === old ? Math.min(agreement.same, keep) : 0; at < keep; at += 1) list[at] = old[at]!; @@ -432,13 +509,8 @@ export function minimapTurns( for (const turn of tail) list.push(turn); agreed.set(list, { list: old, same: keep }); agreed.set(old, { list, same: keep }); - return { - rows, - sessionId, - turns: list, - ticks: sameTicks ? oldTicks! : ticksOf(list, oldTicks), - spare: old as MinimapTurn[], - }; + const loaded = sameTicks ? oldTicks! : ticksOf(list, oldTicks); + return withEarlier(list, loaded, old as MinimapTurn[]); } /// A turn's key: its prompt's promptId, which the row keeps from the moment this pane sends it @@ -512,10 +584,13 @@ export function currentTurn( tops: Float64Array, scrollTop: number, viewportHeight: number, + firstLoaded = 0, ): number { + if (firstLoaded >= turns.length) return 0; const anchor = scrollTop + viewportHeight / 3; let low = 0; let high = turns.length; + low = firstLoaded; while (low < high) { const middle = (low + high) >>> 1; if ((tops[turns[middle].index] ?? 0) <= anchor) low = middle + 1; @@ -537,9 +612,11 @@ export function visibleTurns( totalHeight: number, scrollTop: number, viewportHeight: number, + firstLoaded = 0, ): { first: number; last: number } { + if (firstLoaded >= turns.length) return { first: 0, last: 0 }; const bottom = scrollTop + viewportHeight - THREAD_PADDING_TOP; - const first = currentTurn(turns, tops, scrollTop + LANDING_OFFSET, 0); + const first = currentTurn(turns, tops, scrollTop + LANDING_OFFSET, 0, firstLoaded); let last = first; while (last + 1 < turns.length && (tops[turns[last + 1].index] ?? totalHeight) < bottom) last += 1; return { first, last }; diff --git a/webviews/test/support/papercutChat.ts b/webviews/test/support/papercutChat.ts index aea7be45e6f0..cbbb4ace3b44 100644 --- a/webviews/test/support/papercutChat.ts +++ b/webviews/test/support/papercutChat.ts @@ -108,22 +108,39 @@ export async function papercutPane( ready?: string; /// Test-only Vite plugins, kept optional so existing papercut lanes stay unchanged. vitePlugins?: PluginOption[]; + /// Attach only the newest records so the prompt index has older pages to discover. + page?: number; }, ) { const { createServer } = await import("vite"); const webviews = path.resolve(import.meta.dir, "../.."); const { events, session } = papercutEvents(options); + const attachPage = options.page ?? events.length; const methods: string[] = []; - const answer = (method: string): unknown => { + const answer = (method: string, params: Record = {}): unknown => { switch (method) { case "initialize": return { protocolVersion: 1, _meta: { acpmux: { origin: "local", extensions: [], operations: [] } } }; case "_acpmux/watch": return { sessions: [session] }; case "_acpmux/attach": - return { session, lastSeq: events.length, events, hasMore: false }; - case "_acpmux/events": - return { events: [], hasMore: false, lastSeq: events.length }; + return { + session, + lastSeq: events.length, + events: events.slice(-attachPage), + hasMore: attachPage < events.length, + }; + case "_acpmux/events": { + const kinds = Array.isArray(params.kinds) ? params.kinds : undefined; + const matching = events.filter( + (event) => + !kinds || kinds.some((kind: string) => kind === "all" || kind === "transcript" || kind === event.kind), + ); + const limit = Number(params.limit ?? 5000); + const before = matching.filter((event) => event.seq < (params.beforeSeq ?? Infinity)); + const page = before.slice(Math.max(0, before.length - limit)); + return { events: page, hasMore: before.length > limit, lastSeq: events.length }; + } default: return null; } diff --git a/webviews/test/thread-rail-earlier-prompts.test.ts b/webviews/test/thread-rail-earlier-prompts.test.ts index 457eff2fcce1..9e4fd39e7f48 100644 --- a/webviews/test/thread-rail-earlier-prompts.test.ts +++ b/webviews/test/thread-rail-earlier-prompts.test.ts @@ -7,7 +7,7 @@ // reads one history both ways and compares. import { expect, test } from "bun:test"; import type { AcpmuxRow } from "../src/agent-session/acpmux/model"; -import { turnKey } from "../src/agent-session/acpmux/threadMinimap/model"; +import { minimapTurns, turnKey } from "../src/agent-session/acpmux/threadMinimap/model"; import { opensTurn } from "../src/agent-session/acpmux/turnStart"; import { EarlierPrompts, @@ -236,6 +236,16 @@ test("loadThrough materializes an indexed prompt through history paging", async expect(pane.client.loadThrough.toString()).not.toContain("scrollTop"); }); +test("earlier indexed rows lead minimap turns with unloaded index -1", () => { + const loaded = [prompt(10, "Question 10"), { ...prompt(10), id: "assistant-10", kind: "assistant", text: "Answer" }]; + const indexed = [prompt(2, "Question 2"), prompt(6, "Question 6")]; + const result = minimapTurns(loaded, "s", undefined, undefined, indexed); + expect(result.turns.slice(0, 2).map((turn) => turn.index)).toEqual([-1, -1]); + expect(result.turns.slice(0, 2).map((turn) => turn.prompt)).toEqual(["Question 2", "Question 6"]); + expect(result.turns[2]!.index).toBe(0); + expect(result.ticks).toHaveLength(3); +}); + // The transcript's own reading of one history against the rail's: a chat with steers, prompts sent // now that go on with the turn they stopped (one whose stop lands at a page's edge), one removed // before it ran, a failed turn, and addressed messages from other chats. diff --git a/webviews/test/thread-rail-every-prompt.test.ts b/webviews/test/thread-rail-every-prompt.test.ts new file mode 100644 index 000000000000..e6175bd057dd --- /dev/null +++ b/webviews/test/thread-rail-every-prompt.test.ts @@ -0,0 +1,47 @@ +// cmux-web-test-timeout: 240 +// l10n-allow-file: test fixture prompts, not shipped UI. +// The windowed rail counts prompts discovered by the index and materializes one through the +// existing history/anchor path when its unloaded tick is clicked (Real Chromium). +import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { chromium, type Browser } from "playwright"; +import { papercutPane } from "./support/papercutChat"; +import { requireBrowserLane } from "./support/requireBrowserLane"; + +await requireBrowserLane("thread-rail-every-prompt.test.ts", async () => { + setDefaultTimeout(180_000); + try { + await (await chromium.launch({ headless: true })).close(); + } catch { + console.warn("thread-rail-every-prompt: skipping (run `bunx playwright install chromium`)"); + describe.skip("rail every prompt", () => test("chromium", () => {})); + return; + } + + const TURNS = 100; + let browser: Browser | undefined; + let pane: Awaited> | undefined; + beforeAll(async () => { + browser = await chromium.launch({ headless: true }); + pane = await papercutPane(() => browser!, { turns: TURNS, running: false, page: 40 }); + }); + afterAll(async () => { + await pane?.close(); + await browser?.close(); + }); + + test("counts indexed prompts and loads an unloaded tick through history", async () => { + const page = await pane!.open({ width: 1200, height: 800 }); + const first = page.locator(".acpmux-minimap__tick").first(); + await first.waitFor({ state: "visible", timeout: 20_000 }); + expect(await first.locator("xpath=ancestor::li[1]").getAttribute("aria-setsize")).toBe(String(TURNS)); + expect(pane!.methods.filter((method) => method === "_acpmux/events").length).toBeGreaterThan(0); + + await first.click(); + await page.waitForFunction(() => !!document.querySelector('.acpmux-row[data-row-id="user-1"]'), undefined, { + timeout: 20_000, + }); + expect(await page.locator('.acpmux-row[data-row-id="user-1"]').count()).toBe(1); + expect(pane!.methods).toContain("_acpmux/events"); + await page.close(); + }); +});