Skip to content
Open
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: 13 additions & 0 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1996,6 +1996,8 @@ function TranscriptContext({

export function VirtualTranscript({
rows,
earlier,
loadThrough,
sessionId,
onToggleActivity,
onOpenDiff,
Expand Down Expand Up @@ -2028,6 +2030,8 @@ export function VirtualTranscript({
showMinimap = true,
}: {
rows: AcpmuxRow[];
earlier?: import("./threadMinimap/earlierPrompts").EarlierPromptsStore;
loadThrough?: (seq: number) => Promise<void>;
sessionId?: string;
/// Where the transcript opens instead of the latest message, when that row is loaded.
openAt?: ScrollAnchor;
Expand Down Expand Up @@ -3966,6 +3970,8 @@ export function VirtualTranscript({
{showMinimap && (
<ThreadMinimap
rows={rows}
earlier={earlier}
loadThrough={loadThrough}
sessionId={sessionId}
layout={layout}
scroller={ref}
Expand Down Expand Up @@ -8547,6 +8553,13 @@ function AcpmuxPane() {
{(find) => (
<VirtualTranscript
rows={transcriptRows}
earlier={!childRows ? directClient.current?.earlier : undefined}
loadThrough={
!childRows
? (seq) =>
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.
Expand Down
56 changes: 50 additions & 6 deletions webviews/src/agent-session/acpmux/threadMinimap/ThreadMinimap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -104,6 +120,8 @@ const bubbleOf = (row: HTMLElement | undefined) => row?.querySelector<HTMLElemen

export function ThreadMinimap({
rows,
earlier = EMPTY_EARLIER,
loadThrough,
sessionId,
layout,
scroller,
Expand All @@ -116,6 +134,8 @@ export function ThreadMinimap({
change,
}: {
rows: AcpmuxRow[];
earlier?: EarlierPromptsStore;
loadThrough?: (seq: number) => Promise<void>;
sessionId?: string;
layout: ConversationLayout;
scroller: React.RefObject<HTMLElement | null>;
Expand All @@ -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.
Expand All @@ -151,11 +172,16 @@ export function ThreadMinimap({
const lastTurns = useRef<MinimapTurns | undefined>(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<number | undefined>(undefined);
/// The row keyboard focus is on.
Expand Down Expand Up @@ -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
Expand All @@ -236,6 +262,7 @@ export function ThreadMinimap({
sessionId,
shown,
hovered,
loadedStart,
});
useLayoutEffect(() => {
live.current = {
Expand All @@ -249,6 +276,7 @@ export function ThreadMinimap({
sessionId,
shown,
hovered,
loadedStart,
};
});

Expand All @@ -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
Expand All @@ -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 () => {
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -671,7 +715,7 @@ export function ThreadMinimap({
return (
<ScrollMap
rows={rows}
turns={turns}
turns={loadedTurns}
layout={layout}
scroller={scroller}
viewportHeight={viewportHeight}
Expand Down
101 changes: 89 additions & 12 deletions webviews/src/agent-session/acpmux/threadMinimap/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,15 @@ export type MinimapTurns = {
sessionId: string;
turns: readonly MinimapTurn[];
ticks: readonly MinimapTick[];
/// Prompts indexed by the rail but not materialized in the transcript yet.
earlierRows?: readonly AcpmuxRow[];
/// The loaded portion is kept separately so prepending indexed prompts never changes the
/// incremental O(changed rows) work for a streamed reply.
loadedTurns?: readonly MinimapTurn[];
loadedTicks?: readonly MinimapTick[];
loadedStart?: number;
earlierTurns?: readonly MinimapTurn[];
spareCombined?: MinimapTurn[];
/// The list the next changed turns are written into (the one before `turns`), never `turns`.
spare?: MinimapTurn[];
};
Expand All @@ -310,6 +319,7 @@ const agreed = new WeakMap<readonly MinimapTurn[], { list: readonly MinimapTurn[
/// it once per snapshot for everything that reads the rows (VirtualTranscript's arriving rows): the
/// rows before `from` in `rows` are the rows of `base`.
export type RowsChange = { base: readonly AcpmuxRow[]; rows: readonly AcpmuxRow[]; from: number };
const EMPTY_EARLIER_ROWS: readonly AcpmuxRow[] = [];

/// The transcript's turns. A turn that ends before the first row that differs from
/// `previous.rows` (by object) is kept, and `turns` stays `previous.turns` when every turn is.
Expand All @@ -332,8 +342,16 @@ export function minimapTurns(
sessionId = "",
previous?: MinimapTurns,
change?: RowsChange,
earlierRows: readonly AcpmuxRow[] = EMPTY_EARLIER_ROWS,
): MinimapTurns {
const reuse = previous?.sessionId === sessionId ? previous : undefined;
const prior = previous?.sessionId === sessionId ? previous : undefined;
const reuse = prior
? {
...prior,
turns: prior.loadedTurns ?? prior.turns.filter((turn) => 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;
Expand Down Expand Up @@ -422,23 +440,77 @@ 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]!;
list.length = keep;
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
Expand Down Expand Up @@ -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;
Expand All @@ -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 };
Expand Down
Loading
Loading