fix(chat): rework prompt navigator rail into sliding tape with hover preview (#2185)

* fix(chat): rework prompt navigator rail into sliding tape with hover preview

- Pin the active indicator to the target during programmatic scrolls so the
  scroll spy's intermediate reports don't drag it backwards mid-animation
- Replace the visibility-ratio active-turn picker with a stable reading-line
  rule (last turn whose top is above the line), dropping IntersectionObserver
- Replace the list panel with a Codex-style gutter: the whole strip is one
  hover/click target mapped to the nearest tick, with a per-prompt preview
  card that follows the cursor
- Cap the rail at a fixed window of ticks; hovering the edges carousels
  through the rest, with gradient masks hinting at more content
- Render ticks as a tape that glides to keep the active prompt centered,
  remounting on history prepend to avoid spurious slide animations
- Keep load-earlier as a compact button aligned over the tick column

* fix(chat): shrink navigator gutter when message column sits under it

On narrow windows the centered message column extends under the rail's
full-width invisible hover zone, which swallowed clicks on the right edge
of user bubbles — including the expand/collapse control. Measure the
column against the gutter and switch to a narrow hit zone when they
overlap.

* fix(sync): stop runaway history auto-load on sessions with empty assistant messages

An assistant message fetched with zero parts (e.g. a run aborted before any
output) was stored as absence — indistinguishable from parts that were never
fetched. getSessionMaterializationStatus therefore reported the session as
never renderable, so the ensure-renderable effects (ChatContainer,
ModelControls) retried syncSession forever; each retry refetched the whole
grown window and fired another background prepend, progressively loading the
entire history of large sessions on open.

Commit an explicit empty [] snapshot for assistant messages so fetched-empty
counts as renderable, while non-assistant messages keep the absent
representation and its no-op commit behavior.

Reproduced and verified headless against a real 857-message session: before,
20 message fetches escalating to limit=857; after, one initial page and a
single progressive-mount prepend.
This commit is contained in:
Bohdan Triapitsyn
2026-07-13 12:45:23 +03:00
committed by GitHub
parent 697b180532
commit 799904f0f4
4 changed files with 485 additions and 406 deletions
@@ -1,10 +1,4 @@
export type VisibleTurn = {
id: string;
ratio: number;
top: number;
};
export type OffsetTurn = {
type OffsetTurn = {
id: string;
top: number;
};
@@ -13,32 +7,14 @@ type ScrollSpyInput = {
onActive: (id: string) => void;
raf?: (cb: FrameRequestCallback) => number;
caf?: (id: number) => void;
IntersectionObserver?: typeof globalThis.IntersectionObserver;
ResizeObserver?: typeof globalThis.ResizeObserver;
MutationObserver?: typeof globalThis.MutationObserver;
};
const pickVisibleTurnId = (list: VisibleTurn[], line: number): string | undefined => {
if (list.length === 0) {
return undefined;
}
const sorted = [...list].sort((a, b) => {
if (b.ratio !== a.ratio) {
return b.ratio - a.ratio;
}
const distanceA = Math.abs(a.top - line);
const distanceB = Math.abs(b.top - line);
if (distanceA !== distanceB) {
return distanceA - distanceB;
}
return a.top - b.top;
});
return sorted[0]?.id;
};
// Reading line offset below the container top. The active turn is the last
// one whose top edge sits at or above this line — a monotonic rule that stays
// stable while scrolling inside a long turn (no visibility-ratio flip-flop).
const READ_LINE_OFFSET_PX = 100;
const pickOffsetTurnId = (list: OffsetTurn[], cutoff: number): string | undefined => {
if (list.length === 0) {
@@ -71,12 +47,10 @@ const pickOffsetTurnId = (list: OffsetTurn[], cutoff: number): string | undefine
export const createScrollSpy = (input: ScrollSpyInput) => {
const raf = input.raf ?? requestAnimationFrame;
const caf = input.caf ?? cancelAnimationFrame;
const CtorIO = input.IntersectionObserver ?? globalThis.IntersectionObserver;
const CtorRO = input.ResizeObserver ?? globalThis.ResizeObserver;
const CtorMO = input.MutationObserver ?? globalThis.MutationObserver;
let root: HTMLDivElement | undefined;
let io: IntersectionObserver | undefined;
let ro: ResizeObserver | undefined;
let mo: MutationObserver | undefined;
let frame: number | undefined;
@@ -85,8 +59,6 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
let dirty = true;
const nodes = new Map<string, HTMLElement>();
const idByElement = new WeakMap<HTMLElement, string>();
const visible = new Map<string, { ratio: number; top: number }>();
let offsets: OffsetTurn[] = [];
const schedule = () => {
@@ -122,23 +94,11 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
return;
}
const line = container.getBoundingClientRect().top + 100;
const next =
pickVisibleTurnId(
[...visible].map(([id, value]) => ({
id,
ratio: value.ratio,
top: value.top,
})),
line,
)
?? (() => {
if (dirty) {
refreshOffsets();
}
return pickOffsetTurnId(offsets, container.scrollTop + 100);
})();
if (dirty) {
refreshOffsets();
}
const next = pickOffsetTurnId(offsets, container.scrollTop + READ_LINE_OFFSET_PX);
if (!next || next === active) {
return;
}
@@ -153,52 +113,6 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
return;
}
io?.disconnect();
io = undefined;
if (CtorIO) {
try {
io = new CtorIO(
(entries) => {
for (const entry of entries) {
const element = entry.target;
if (!(element instanceof HTMLElement)) {
continue;
}
const key = idByElement.get(element);
if (!key) {
continue;
}
if (!entry.isIntersecting || entry.intersectionRatio <= 0) {
visible.delete(key);
continue;
}
visible.set(key, {
ratio: entry.intersectionRatio,
top: entry.boundingClientRect.top,
});
}
schedule();
},
{
root: container,
threshold: [0, 0.25, 0.5, 0.75, 1],
},
);
} catch {
io = undefined;
}
}
if (io) {
for (const element of nodes.values()) {
io.observe(element);
}
}
clearTimeout(roDebounce);
roDebounce = undefined;
ro?.disconnect();
@@ -245,7 +159,6 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
}
root = element;
visible.clear();
active = undefined;
observe();
};
@@ -253,15 +166,10 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
const register = (element: HTMLElement, key: string) => {
const previous = nodes.get(key);
if (previous && previous !== element) {
io?.unobserve(previous);
ro?.unobserve(previous);
}
nodes.set(key, element);
idByElement.set(element, key);
if (io) {
io.observe(element);
}
if (ro) {
ro.observe(element);
}
@@ -275,10 +183,8 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
return;
}
io?.unobserve(element);
ro?.unobserve(element);
nodes.delete(key);
visible.delete(key);
dirty = true;
schedule();
};
@@ -290,12 +196,10 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
const clear = () => {
for (const element of nodes.values()) {
io?.unobserve(element);
ro?.unobserve(element);
}
nodes.clear();
visible.clear();
offsets = [];
active = undefined;
dirty = true;
@@ -309,10 +213,8 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
clearTimeout(roDebounce);
roDebounce = undefined;
clear();
io?.disconnect();
ro?.disconnect();
mo?.disconnect();
io = undefined;
ro = undefined;
mo = undefined;
root = undefined;