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:
committed by
GitHub
parent
697b180532
commit
799904f0f4
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user