Skip to content
Merged
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
1 change: 1 addition & 0 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2598,6 +2598,7 @@ export function VirtualTranscript({
scroller={ref}
scrollTop={renderScroll.top}
viewportHeight={height - dockSpace}
scrolls={layout.totalHeight - height > 0.5}
width={width}
placed={minimapPlaced}
/>
Expand Down
42 changes: 34 additions & 8 deletions webviews/src/agent-session/acpmux/threadMinimap/ScrollMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ const AGENT_LENGTH = [5, 15] as const;
type PromptMarks = ReturnType<typeof usePromptMarks>;
type Press = { pointer: number; y: number; moved: boolean; mark?: string };

/// The first and last place in `marks` (top to bottom) whose y lies in [top, bottom].
function marksIn(marks: readonly MapMark[], top: number, bottom: number) {
let first = -1;
let last = -1;
marks.forEach((mark, index) => {
if (mark.y < top || mark.y > bottom) return;
if (first < 0) first = index;
last = index;
});
return first < 0 ? undefined : { first, last };
}

const within = (element: Element | null, to: EventTarget | null) =>
!!element && !!to && "nodeType" in to && element.contains(to as Node);

Expand All @@ -57,6 +69,7 @@ export function ScrollMap({
layout,
scroller,
viewportHeight,
scrolls,
width,
marks: bookmarks,
jump,
Expand All @@ -66,6 +79,7 @@ export function ScrollMap({
layout: ConversationLayout;
scroller: React.RefObject<HTMLElement | null>;
viewportHeight: number;
scrolls: boolean;
width: number;
marks: PromptMarks;
/// The compact rail's jump to a prompt (ThreadMinimap), so a click lands and flashes the same.
Expand Down Expand Up @@ -100,21 +114,26 @@ export function ScrollMap({
const press = useRef<Press | undefined>(undefined);
const shown =
turns.length >= MAP_MIN_PROMPTS &&
layout.totalHeight > viewportHeight &&
scrolls &&
height > 4 * MAP_MARGIN &&
transcriptColumn(width).inset >= MIN_GUTTER;

const live = useRef({ height, total: layout.totalHeight, viewportHeight });
live.current = { height, total: layout.totalHeight, viewportHeight };
/// The marks the view bar covers (first and last place in `marks`), for their "current" tone.
/// The bar moves by style on each scroll frame; this renders only when the covered marks change.
const [inView, setInView] = useState<{ first: number; last: number } | undefined>(undefined);
const live = useRef({ height, total: layout.totalHeight, viewportHeight, marks });
live.current = { height, total: layout.totalHeight, viewportHeight, marks };
// The bar follows the scroll on the scroll's own frame, by style alone.
const [placeThumb] = useState(() => () => {
const node = scroller.current;
const bar = thumb.current;
if (!node || !bar) return;
const { height: rail, total, viewportHeight: view } = live.current;
const { height: rail, total, viewportHeight: view, marks: all } = live.current;
const at = mapThumb(node.scrollTop, view, total, rail);
bar.style.transform = `translateY(${at.top}px)`;
bar.style.height = `${at.height}px`;
const covered = marksIn(all, at.top, at.top + at.height);
setInView((was) => (was?.first === covered?.first && was?.last === covered?.last ? was : covered));
});
useLayoutEffect(placeThumb);
useEffect(() => {
Expand Down Expand Up @@ -181,7 +200,8 @@ export function ScrollMap({
const at = keyOf(marks[markAt(marks, y) ?? -1]);
setPointerY(Math.round(y));
setHovered(at);
if (at === undefined) return;
// A scrub moves the transcript, not the card: it opens again once the drag ends.
if (at === undefined || press.current?.moved) return;
setCardMark(at);
intend("map");
};
Expand All @@ -198,7 +218,8 @@ export function ScrollMap({
if (state.mark !== undefined) activate(state.mark);
else seek(railY(event.clientY), true);
};
const active = hovered ?? focused;
// The previewed message stays lit while the pointer is on its card.
const active = hovered ?? focused ?? (showCard ? cardMark : undefined);
const focusTick = (turn: number) =>
rail.current
?.querySelector<HTMLButtonElement>(`[data-tick="${Math.max(0, Math.min(turns.length - 1, turn))}"]`)
Expand Down Expand Up @@ -249,6 +270,7 @@ export function ScrollMap({
if (state && !state.moved && Math.abs(event.clientY - state.y) >= SCRUB_SLOP) {
state.moved = true;
setScrubbing(true);
dismiss();
try {
event.currentTarget.setPointerCapture?.(state.pointer);
} catch {}
Expand Down Expand Up @@ -296,7 +318,7 @@ export function ScrollMap({
}}
>
<div ref={thumb} className="acpmux-scrollmap__view" aria-hidden />
{marks.map((mark) => {
{marks.map((mark, index) => {
const turn = mark.kind === "user" ? turns[mark.turn] : undefined;
const key = keyOf(mark)!;
return (
Expand All @@ -306,6 +328,7 @@ export function ScrollMap({
mark={mark}
progress={pointerY !== undefined ? mapProgress(mark.y - pointerY) : key === focused ? 1 : 0}
active={key === active}
current={inView !== undefined && index >= inView.first && index <= inView.last}
bookmarked={!!turn && bookmarks.has(turn)}
describedBy={showCard && cardMark === key ? cardId : undefined}
prompt={turn?.prompt}
Expand Down Expand Up @@ -359,6 +382,7 @@ const Mark = memo(function Mark({
mark,
progress,
active,
current,
bookmarked,
describedBy,
prompt,
Expand All @@ -370,6 +394,8 @@ const Mark = memo(function Mark({
mark: MapMark;
progress: number;
active: boolean;
/// Under the view bar: on screen.
current: boolean;
bookmarked: boolean;
describedBy?: string;
/// A prompt's mark: its prompt, for the label.
Expand All @@ -381,7 +407,7 @@ const Mark = memo(function Mark({
const [rest, grown] = mark.kind === "user" ? USER_LENGTH : AGENT_LENGTH;
const length = rest + (grown - rest) * progress;
const style = { transform: `translateY(${mark.y - 5}px)` };
const tone = active ? "active" : "rest";
const tone = active ? "active" : current ? "current" : "rest";
const label =
prompt === undefined
? ""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export function ThreadMinimap({
scroller,
scrollTop,
viewportHeight,
scrolls,
width,
placed,
}: {
Expand All @@ -95,6 +96,8 @@ export function ThreadMinimap({
scroller: React.RefObject<HTMLElement | null>;
scrollTop: number;
viewportHeight: number;
/// Whether the transcript scrolls at all (its layout, dock space included, is taller than it).
scrolls: boolean;
width: number;
/// The transcript calls what the rail puts here once each commit's placement pass has set its
/// offset (VirtualTranscript), so the band reads where the transcript put the reader.
Expand Down Expand Up @@ -479,6 +482,7 @@ export function ThreadMinimap({
layout={layout}
scroller={scroller}
viewportHeight={viewportHeight}
scrolls={scrolls}
width={width}
marks={marks}
jump={jump}
Expand All @@ -504,7 +508,8 @@ export function ThreadMinimap({
event.currentTarget.releasePointerCapture(state.pointer);
};
const railTop = Math.round((viewportHeight - height) / 2);
const active = hovered ?? focused;
// The previewed prompt stays lit while the pointer is on its card, as ChatGPT's rail does.
const active = hovered ?? focused ?? (showCard ? cardRow : undefined);
const cardMarked = cardTurn ? marks.has(cardTurn) : false;
const cardTop =
cardRow === undefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,10 +285,25 @@
height: 1px;
opacity: 0.35;
}
/* Marks under the view bar: where the reader is, brighter than the rest, and still while the
pointer is on the rail (unlike the compact rail). The hovered or previewed mark is the text. */
.acpmux-scrollmap__mark[data-tone="current"],
.acpmux-minimap__rail[data-hovering] .acpmux-scrollmap__mark[data-tone="current"] {
color: var(--acpmux-minimap-text);
}
.acpmux-scrollmap__mark[data-tone="current"] .acpmux-minimap__bar,
.acpmux-minimap__rail[data-hovering] .acpmux-scrollmap__mark[data-tone="current"] .acpmux-minimap__bar {
opacity: 0.8;
}
.acpmux-scrollmap__mark[data-kind="agent"][data-tone="current"] .acpmux-minimap__bar,
.acpmux-minimap__rail[data-hovering] .acpmux-scrollmap__mark[data-kind="agent"][data-tone="current"] .acpmux-minimap__bar {
opacity: 0.55;
}
.acpmux-scrollmap__mark[data-tone="active"] .acpmux-minimap__bar {
opacity: 1;
}
/* A bookmarked prompt's tick stays at full opacity on the full map too. */
.acpmux-scrollmap__mark[data-bookmarked] .acpmux-minimap__bar {
.acpmux-scrollmap__mark[data-bookmarked] .acpmux-minimap__bar,
.acpmux-minimap__rail[data-hovering] .acpmux-scrollmap__mark[data-bookmarked] .acpmux-minimap__bar {
opacity: 1;
}
1 change: 1 addition & 0 deletions webviews/test/thread-bookmarks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ async function mount() {
scroller,
scrollTop: 0,
viewportHeight: 600,
scrolls: true,
width: 1040,
}),
),
Expand Down
41 changes: 41 additions & 0 deletions webviews/test/thread-scroll-map-rail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,44 @@ test("a focused prompt keeps its card when earlier turns arrive above it", async
await view.unmount();
}
});

test("a chat that fits its pane above the composer has no map", async () => {
// Two short turns: the transcript is shorter than the 600 px viewport.
const view = await mount(2);
try {
expect(document.querySelector(".acpmux-scrollmap")).toBeNull();
} finally {
await view.unmount();
}
});

test("the marks under the view bar are current, at the top and at the bottom", async () => {
const view = await mount(12);
try {
const current = () => ticks().flatMap((node, index) => (node.dataset.tone === "current" ? [index] : []));
expect(current()[0]).toBe(0);
expect(current()).not.toContain(11);
// The bottom: as far as the transcript scrolls.
view.scroller.scrollTop =
parseFloat(document.querySelector<HTMLElement>(".acpmux-spacer")!.style.height) - VIEWPORT;
await view.scrolled();
expect(current()).toContain(11);
expect(current()).not.toContain(0);
} finally {
await view.unmount();
}
});

test("the previewed prompt stays lit while focus is on its card", async () => {
const view = await mount(12);
try {
await act(async () => tick(6).focus());
expect(tick(6).dataset.tone).toBe("active");
const bookmark = card()!.querySelector<HTMLButtonElement>(".acpmux-minimap__bookmark")!;
await act(async () => bookmark.focus());
expect(card()?.textContent).toContain("Question 6");
expect(tick(6).dataset.tone).toBe("active");
} finally {
await view.unmount();
}
});