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
+23 -5
View File
@@ -40,7 +40,10 @@ function sortParts(parts: Part[], skipPartTypes: ReadonlySet<string>) {
}
function haveEquivalentPartSnapshots(left: Part[] | undefined, right: Part[]): boolean {
if (!left) return right.length === 0
// `undefined` means "parts never fetched", which is NOT equivalent to a
// fetched-empty snapshot — the empty array must be committed so
// getSessionMaterializationStatus can tell the two apart.
if (!left) return false
if (left.length !== right.length) return false
for (let index = 0; index < left.length; index += 1) {
@@ -175,18 +178,29 @@ export function materializeSessionSnapshots(
const messageID = record.info.id
if (isPrepend && nextPartState[messageID]) continue
const isAssistant = record.info.role === "assistant"
const existing = nextPartState[messageID]
const nextParts = mergeMaterializedParts(
existing,
sortParts(record.parts ?? [], skipPartTypes),
skipPartTypes,
record.info.role === "assistant",
isAssistant,
)
if (haveEquivalentPartSnapshots(existing, nextParts)) continue
// For non-assistant messages an empty snapshot keeps the old "absent"
// representation; only assistant messages need the explicit [] marker
// (getSessionMaterializationStatus checks only assistant messages).
const equivalent = existing
? haveEquivalentPartSnapshots(existing, nextParts)
: nextParts.length === 0 && !isAssistant
if (equivalent) continue
if (nextParts.length === 0) {
if (nextParts.length === 0 && !isAssistant) {
delete nextPartState[messageID]
} else {
// Store fetched-empty as an explicit [] (not absence): an assistant
// message the server returned with zero parts (e.g. aborted before any
// output) is authoritatively empty and must count as renderable, or
// the ensure-renderable effects retry syncSession forever.
nextPartState[messageID] = nextParts
}
partsChanged = true
@@ -213,8 +227,12 @@ export function getSessionMaterializationStatus(
const missingPartMessageIDs: string[] = []
for (const message of messages) {
if (message.role !== "assistant") continue
// `undefined` = parts never fetched (not renderable yet). An explicit []
// is a fetched-empty snapshot (e.g. aborted assistant turn) and counts
// as renderable — otherwise sessions containing such a message can never
// reach renderable state and ensure-renderable callers loop forever.
const parts = state.part[message.id]
if (!parts || parts.length === 0) {
if (!parts) {
missingPartMessageIDs.push(message.id)
}
}