refactor(ui): share nested-scroller and height-delta primitives, drop dead scroll state (#973)

Gesture routing exposes findNestedScroller (edge test before style resolution, one ancestor walk per wheel; routing decisions unchanged). Bottom anchoring on resize is a shared trackHeightDelta helper; the timeline rail anchors through the timeline list's existing ResizeObserver (initial measurement untracked because that observer now owns the anchor). The message-stream snapshot scope and the 600 ms intent window are each defined once and imported by the transcript section and virtual list; the duplicate ScrollControllerSnapshot type is merged into ScrollControllerState (the dead lastObservedAtBottom field was already removed by #971). New unit tests (wired into the PR unit job) catch a scrolling root counted as nested and a tracker acting after disposal; a browser regression keeps the resized timeline's bottom edge anchored. Gatekeeper rounds 1-3 resolved to zero findings.
This commit is contained in:
Pascal André 2026-10-15 22:04:05 +02:00 • committed by GitHub
parent 2196f1731e
commit 7a81034f76
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 191 additions and 84 deletions

View file

@ -299,6 +299,8 @@ jobs:
packages/ui/src/components/virtual-follow-behavior.test.ts
packages/ui/src/components/virtual-follow-measurements.test.ts
packages/ui/src/components/virtual-reader-settlement.test.ts
packages/ui/src/lib/height-delta.test.ts
packages/ui/src/lib/scroll-gesture-routing.test.ts
packages/ui/src/lib/client-identity.test.ts
packages/ui/src/lib/filesystem-events.test.ts
packages/ui/src/lib/global-cache.test.ts

View file

@ -16,7 +16,7 @@ import VirtualFollowList, { type VirtualExplicitBottomPinIntent, type VirtualFol
import { isScrollRestoreGenerationCurrent, isSnapshotAutoFollowing } from "./virtual-follow-behavior"
import { isScrollTraceEnabled, traceScroll } from "./virtual-scroll-trace"
import { useConfig } from "../stores/preferences"
import { messageStoreBus } from "../stores/message-v2/bus"
import { MESSAGE_STREAM_SCROLL_SCOPE as MESSAGE_SCROLL_CACHE_SCOPE, messageStoreBus } from "../stores/message-v2/bus"
import { useI18n } from "../lib/i18n"
import { copyToClipboard } from "../lib/clipboard"
import { showToastNotification } from "../lib/notifications"
@ -44,7 +44,6 @@ import { getFormQueue } from "../stores/forms"
import { resolveFormToolTarget } from "./form-request-tool-target"
import { getTechnicalCleanupParts, getTechnicalGroupKind, isTechnicalGroupingVisiblePart, isVisibleStepFinish, projectTranscriptTechnicalGroups, reasoningHasRenderableContent, technicalPartKey, type TechnicalCleanupTranscriptItem } from "../lib/message-part-grouping"
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
const QUOTE_SELECTION_MAX_LENGTH = 2000
const STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX = 8
const SEARCH_DEBOUNCE_MS = 250

View file

@ -9,7 +9,6 @@ import { buildRecordDisplayData } from "../stores/message-v2/record-display-cach
import { getPartCharCount } from "../lib/token-utils"
import { User as UserIcon, Bot as BotIcon, FoldVertical } from "lucide-solid"
import { useI18n } from "../lib/i18n"
import { getBottomAnchoredViewportOffset } from "./virtual-follow-behavior"
import { getMessageContentIcon } from "./message-content-icons"
import { Markdown } from "./markdown"
import { createTimelinePreviews } from "../stores/timeline-previews"
@ -448,37 +447,6 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
onCleanup(() => { observer.disconnect(); window.removeEventListener("resize", clearHoverPreview) })
})
createEffect(() => {
const element = scrollElement()
if (!element || typeof ResizeObserver === "undefined") return
let previousHeight = element.clientHeight
let pendingHeightDelta = 0
let pendingFrame: number | null = null
const observer = new ResizeObserver(() => {
const nextHeight = element.clientHeight
if (nextHeight === previousHeight) return
if (previousHeight <= 0 || nextHeight <= 0) {
previousHeight = nextHeight
return
}
pendingHeightDelta += previousHeight - nextHeight
previousHeight = nextHeight
if (pendingFrame !== null) return
pendingFrame = requestAnimationFrame(() => {
pendingFrame = null
const offset = getBottomAnchoredViewportOffset(element.scrollTop, pendingHeightDelta)
pendingHeightDelta = 0
const maxOffset = Math.max(element.scrollHeight - element.clientHeight, 0)
element.scrollTop = Math.min(offset, maxOffset)
})
})
observer.observe(element)
onCleanup(() => {
observer.disconnect()
if (pendingFrame !== null) cancelAnimationFrame(pendingFrame)
})
})
// Pre-computed set of messageIds that have at least one tool segment.
// Used by groupRole() inside <For> to avoid O(n) .some() per segment → O(1) .has().
const messagesWithTools = createMemo(() => {

View file

@ -7,7 +7,7 @@ import MessageSection from "../message-section"
import { MissionReader } from "../mission-reader"
import { missionProjectView, updateMissionProjectView } from "../../stores/mission-view-state"
import { missionStore } from "../../stores/missions"
import { messageStoreBus } from "../../stores/message-v2/bus"
import { MESSAGE_STREAM_SCROLL_SCOPE, messageStoreBus } from "../../stores/message-v2/bus"
import PromptInput from "../prompt-input"
import PromptAttachmentsBar from "../prompt-input/PromptAttachmentsBar"
import PromptContextControls from "../prompt-input/PromptContextControls"
@ -132,8 +132,6 @@ export const SessionView: Component<SessionViewProps> = (props) => {
}, { defer: true }))
createEffect(on(readingMission, reading => { if (reading) closeFilePreview(props.instanceId) }))
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
let promptInputApi: PromptInputApi | null = null
let pendingPromptText: string | null = null
let pendingQueuedPayload: SessionInboxUserPayload | undefined
@ -165,7 +163,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
function shouldScrollToBottomOnActivate() {
const current = session()
if (!current) return true
const snapshot = messageStore().getScrollSnapshot(current.id, MESSAGE_SCROLL_CACHE_SCOPE)
const snapshot = messageStore().getScrollSnapshot(current.id, MESSAGE_STREAM_SCROLL_SCOPE)
return isSnapshotAutoFollowing(snapshot)
}

View file

@ -1,4 +1,6 @@
import { For, createEffect, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
import { For, createEffect, createMemo, createSignal, onCleanup, untrack, type JSX } from "solid-js"
import { trackHeightDelta } from "../lib/height-delta"
import { getBottomAnchoredViewportOffset } from "./virtual-follow-behavior"
export interface TimelineListHandle {
scrollToIndex: (index: number) => void
@ -53,13 +55,19 @@ export default function TimelineVirtualList<T>(props: {
if (next.marker > 0 && (next.marker !== size().marker || next.gap !== size().gap)) setSize(next)
readViewport()
}
const observer = new ResizeObserver(measure)
// A resized rail keeps its bottom marker in place.
const bottomAnchor = trackHeightDelta(element, delta => {
element.scrollTop = Math.min(getBottomAnchoredViewportOffset(element.scrollTop, delta), Math.max(element.scrollHeight - element.clientHeight, 0))
})
const observer = new ResizeObserver(() => { bottomAnchor.update(); measure() })
observer.observe(element)
observer.observe(marker)
observer.observe(gap)
element.addEventListener("scroll", readViewport, { passive: true })
measure()
onCleanup(() => { observer.disconnect(); element.removeEventListener("scroll", readViewport) })
// Untracked: a marker geometry change must not rebuild this observer, which
// also owns the bottom anchor and would drop its pending delta.
untrack(measure)
onCleanup(() => { observer.disconnect(); bottomAnchor.dispose(); element.removeEventListener("scroll", readViewport) })
})
props.register({ scrollToIndex: index => {
const element = props.scrollElement, row = layout().rows[index]

View file

@ -1,6 +1,8 @@
export type FollowMode = { type: "following" } | { type: "escaped" }
export const BOTTOM_FOLLOW_EPSILON_PX = 48
/** How long a user gesture keeps ownership of the scroll events it causes. */
export const USER_SCROLL_INTENT_WINDOW_MS = 600
export const ANCHOR_RESTORE_MAX_FRAMES = 300
export const ANCHOR_RESTORE_STABLE_FRAMES = 10
export const ANCHOR_RESTORE_REISSUE_INTERVAL_FRAMES = 12
@ -293,14 +295,6 @@ export interface ScrollControllerResult {
state: ScrollControllerState
}
export interface ScrollControllerSnapshot {
mode: FollowMode
lastObservedOffset: number
userIntentDirection: ScrollDirection
userIntentUntil: number
restoring: boolean
}
export interface FollowSnapshotState {
followModeType?: FollowMode["type"]
}
@ -411,7 +405,7 @@ export class VirtualScrollController {
}
}
snapshot(): ScrollControllerSnapshot {
snapshot(): ScrollControllerState {
return { ...this.state, mode: { ...this.state.mode } }
}

View file

@ -3,7 +3,7 @@ import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
import { attachScrollGestureRouting, getElementZoom, POINTER_CONTROL_SELECTOR, wheelDeltaUnit } from "../lib/scroll-gesture-routing"
import { remapVirtualMeasurements } from "./virtual-follow-measurements"
import { createVirtualReaderSettlement } from "./virtual-reader-settlement"
import { advanceBottomPinSettlement, AnchorRestoreStabilizer, BOTTOM_FOLLOW_EPSILON_PX, canScrollInDirection, classifyVirtualItemKeyChange, describeKeyTarget, getBottomAnchoredViewportOffset, getFollowSnapshotState, getKeyboardScrollIntent, getPrimaryPointerDragDirection, isAtBottomForMode, isAutoFollowing, isMiddleButtonScrollIntent, isScrollRestoreMeasurementReady, resolveAutoPinHoldElement, restoreFollowModeFromSnapshot, SCROLL_INTENT_KEYS, ScrollRestoreTokenGuard, selectTopViewportAnchor, shouldNavigateAtBoundary, VirtualScrollController, type FollowEffect, type FollowEvent, type FollowMode, type HoldTargetElementResolver, type ScrollControllerMetrics, type ScrollControllerResult } from "./virtual-follow-behavior.ts"
import { advanceBottomPinSettlement, AnchorRestoreStabilizer, BOTTOM_FOLLOW_EPSILON_PX, canScrollInDirection, classifyVirtualItemKeyChange, describeKeyTarget, getBottomAnchoredViewportOffset, getFollowSnapshotState, getKeyboardScrollIntent, getPrimaryPointerDragDirection, isAtBottomForMode, isAutoFollowing, isMiddleButtonScrollIntent, isScrollRestoreMeasurementReady, resolveAutoPinHoldElement, restoreFollowModeFromSnapshot, SCROLL_INTENT_KEYS, USER_SCROLL_INTENT_WINDOW_MS, ScrollRestoreTokenGuard, selectTopViewportAnchor, shouldNavigateAtBoundary, VirtualScrollController, type FollowEffect, type FollowEvent, type FollowMode, type HoldTargetElementResolver, type ScrollControllerMetrics, type ScrollControllerResult } from "./virtual-follow-behavior.ts"
import { isScrollTraceEnabled, traceScroll, type ScrollReason, type ScrollTraceEntry, type ScrollTraceKind } from "./virtual-scroll-trace"
const DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX = 8
@ -11,7 +11,6 @@ const EXPLICIT_BOTTOM_PIN_SETTLE_FRAMES = 2
const MEASUREMENT_PROBE_COUNT = 8
const TOP_SCROLL_EPSILON_PX = 0
const EXPLICIT_BOTTOM_PIN_MAX_FRAMES = 90
const USER_SCROLL_INTENT_WINDOW_MS = 600
const PROGRAMMATIC_SCROLL_WINDOW_MS = 120
const SCROLL_RESTORE_MEASUREMENT_MAX_FRAMES = 90
const ELEMENT_ALIGNMENT_FRAMES = 30

View file

@ -1,14 +1,11 @@
import { createEffect, createSignal, onCleanup, type Accessor, type JSXElement } from "solid-js"
import { BOTTOM_FOLLOW_EPSILON_PX, SCROLL_INTENT_KEYS, VirtualScrollController, describeKeyTarget, getKeyboardScrollIntent, isAutoFollowing, isMiddleButtonScrollIntent, type ScrollControllerMetrics } from "../components/virtual-follow-behavior"
import { BOTTOM_FOLLOW_EPSILON_PX, SCROLL_INTENT_KEYS, USER_SCROLL_INTENT_WINDOW_MS, VirtualScrollController, describeKeyTarget, getKeyboardScrollIntent, isAutoFollowing, isMiddleButtonScrollIntent, type ScrollControllerMetrics } from "../components/virtual-follow-behavior"
import { getElementZoom, wheelDeltaUnit } from "./scroll-gesture-routing"
const DEFAULT_SCROLL_INTENT_WINDOW_MS = 600
interface FollowScrollOptions {
getScrollTopSnapshot: Accessor<number>
setScrollTopSnapshot: (next: number) => void
sentinelClassName: string
intentWindowMs?: number
}
export interface FollowScrollHelpers {
@ -41,11 +38,10 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
container.scrollTop = container.scrollHeight
lastKnownScrollTop = container.scrollTop
options.setScrollTopSnapshot(lastKnownScrollTop)
scrollController.recordProgrammaticOffset(lastKnownScrollTop)
} else {
container.scrollTop = lastKnownScrollTop
scrollController.recordProgrammaticOffset(lastKnownScrollTop)
}
scrollController.recordProgrammaticOffset(lastKnownScrollTop)
}
function persistScrollSnapshot(element?: HTMLElement | null) {
@ -60,7 +56,7 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
// Upward intent takes ownership immediately, even if native middle-button
// autoscroll waits longer than the intent deadline before its first scroll
// event; a downward gesture reaching the bottom resumes follow (shared rule).
const result = scrollController.userGesture(direction, now + (options.intentWindowMs ?? DEFAULT_SCROLL_INTENT_WINDOW_MS), container ? getMetrics(container) : null, distance)
const result = scrollController.userGesture(direction, now + USER_SCROLL_INTENT_WINDOW_MS, container ? getMetrics(container) : null, distance)
setAutoScroll(isAutoFollowing(result.state.mode))
if (direction === "up") {
suppressNextScrollHandling = false

View file

@ -0,0 +1,38 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { trackHeightDelta } from "./height-delta.ts"
test("height changes coalesce into one per-frame delta and zero heights only rebase", () => {
const previousFrame = globalThis.requestAnimationFrame, previousCancel = globalThis.cancelAnimationFrame
const frames = new Map<number, FrameRequestCallback>()
let sequence = 0
globalThis.requestAnimationFrame = fn => { frames.set(++sequence, fn); return sequence }
globalThis.cancelAnimationFrame = id => { frames.delete(id) }
const flush = () => { const pending = [...frames.values()]; frames.clear(); pending.forEach(fn => fn(0)) }
try {
const element = { clientHeight: 500 }
const deltas: number[] = []
const tracker = trackHeightDelta(element as HTMLElement, delta => deltas.push(delta))
tracker.update()
assert.equal(frames.size, 0, "An unchanged height is no change")
element.clientHeight = 450; tracker.update()
element.clientHeight = 420; tracker.update()
assert.equal(frames.size, 1, "One frame per burst")
flush(); assert.deepEqual(deltas, [80], "A shrink is a positive delta")
element.clientHeight = 0; tracker.update()
element.clientHeight = 600; tracker.update()
assert.equal(frames.size, 0, "Hiding and mounting only rebase")
element.clientHeight = 650; tracker.update(); flush()
assert.deepEqual(deltas, [80, -50])
element.clientHeight = 600; tracker.update(); tracker.dispose(); flush()
element.clientHeight = 500; tracker.update(); flush()
assert.deepEqual(deltas, [80, -50], "Disposal drops the pending delta and ignores later changes")
assert.equal(frames.size, 0)
} finally {
globalThis.requestAnimationFrame = previousFrame
globalThis.cancelAnimationFrame = previousCancel
}
})

View file

@ -0,0 +1,33 @@
/**
* Coalesces an element's client-height changes into one `previous - next` delta
* per frame (positive when it shrinks), to keep its bottom anchored on resize.
* Changes from or to a zero height (hidden, mounting) only rebase.
*/
export function trackHeightDelta(element: HTMLElement, onDelta: (delta: number) => void) {
let previous = element.clientHeight
let pending = 0
let frame: number | null = null
let disposed = false
return {
/** Call from the owner's ResizeObserver. */
update() {
const next = element.clientHeight
if (disposed || next === previous) return
if (previous > 0 && next > 0) {
pending += previous - next
frame ??= requestAnimationFrame(() => {
frame = null
const delta = pending
pending = 0
onDelta(delta)
})
}
previous = next
},
dispose() {
disposed = true
if (frame !== null) cancelAnimationFrame(frame)
frame = null
},
}
}

View file

@ -26,7 +26,7 @@ import {
getSessionDraftPromptsForInstance, getSessions,
hasAuthoritativeSessionSelection,
} from "../../stores/sessions"
import { messageStoreBus } from "../../stores/message-v2/bus"
import { MESSAGE_STREAM_SCROLL_SCOPE, messageStoreBus } from "../../stores/message-v2/bus"
import type { ScrollSnapshot } from "../../stores/message-v2/types"
import { getAuthoritativeAttachmentSessionIdsForInstance, getSessionAttachmentsForInstance } from "../../stores/attachments"
import { serializeDraftAttachments } from "../../stores/client-state-attachments-codec"
@ -39,13 +39,12 @@ import {
hydrateRestoredWorkspaceState, NO_SESSION_DRAFT_SESSION_ID,
} from "../../stores/app-session-workspace-hydration"
const log = getLogger("actions")
const MESSAGE_SCROLL_SCOPE = "message-stream"
const MAX_CAPTURED_SCROLL_SNAPSHOTS = 96
const CAPTURE_DEBOUNCE_MS = 100
function captureScrollSnapshots(instanceId: string): Record<string, ScrollSnapshot> {
const store = messageStoreBus.getInstance(instanceId)
if (!store) return {}
const suffix = `:${MESSAGE_SCROLL_SCOPE}`
const suffix = `:${MESSAGE_STREAM_SCROLL_SCOPE}`
return Object.fromEntries(Object.entries(store.state.scrollState)
.filter(([key]) => key.endsWith(suffix))
.map(([key, snapshot]) => ({ sessionId: key.slice(0, -suffix.length), snapshot }))
@ -220,7 +219,7 @@ export function useAppSessionCapture() {
}
const cleanups = [
messageStoreBus.onScrollSnapshotChanged((instanceId, sessionId, scope) => {
if (scope === MESSAGE_SCROLL_SCOPE) markScrollAuthority(instanceId, sessionId)
if (scope === MESSAGE_STREAM_SCROLL_SCOPE) markScrollAuthority(instanceId, sessionId)
}),
messageStoreBus.onSessionCleared(markScrollAuthority),
messageStoreBus.onInstanceDestroyed((instanceId) => {

View file

@ -0,0 +1,51 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { findNestedScroller } from "./scroll-gesture-routing.ts"
class FakeElement {
overflowY = "visible"
scrollTop = 0
scrollHeight = 100
clientHeight = 100
scrollWidth = 100
clientWidth = 100
constructor(public parentElement: FakeElement | null = null) {}
}
test("the innermost nested scroller that can still move consumes the gesture", () => {
const globals = globalThis as Record<string, unknown>
const previous = { Element: globals.Element, HTMLElement: globals.HTMLElement, getComputedStyle: globals.getComputedStyle }
let styleReads = 0
Object.assign(globals, {
Element: FakeElement,
HTMLElement: FakeElement,
getComputedStyle: (node: FakeElement) => { styleReads++; return { overflowX: "visible", overflowY: node.overflowY } },
})
try {
// The root scrolls too: only its descendants count as nested.
const root = Object.assign(new FakeElement(), { overflowY: "auto", scrollHeight: 1000 })
const outer = Object.assign(new FakeElement(root), { overflowY: "auto", scrollHeight: 400 })
// Overflowing content of a non-scroller still reports a larger scrollHeight.
const visible = Object.assign(new FakeElement(outer), { scrollHeight: 300 })
const inner = Object.assign(new FakeElement(visible), { overflowY: "scroll", scrollHeight: 300 })
const target = new FakeElement(inner)
const find = (node: unknown, direction: "up" | "down") =>
findNestedScroller(root as unknown as HTMLElement, node as EventTarget, direction)
assert.equal(find(target, "down"), inner)
assert.equal(find(target, "up"), null, "Scrollers at their top pass an upward gesture to the root")
inner.scrollTop = 200
assert.equal(find(target, "down"), outer, "An exhausted scroller chains to its scrolling ancestor")
assert.equal(find(target, "up"), inner)
outer.scrollTop = 300
assert.equal(find(target, "down"), null)
assert.equal(find(root, "down"), null, "The root itself is never nested")
assert.equal(find(null, "down"), null)
styleReads = 0
find(new FakeElement(new FakeElement(new FakeElement(root))), "down")
assert.equal(styleReads, 0, "Ancestors that cannot move skip style resolution")
} finally {
Object.assign(globals, previous)
}
})

View file

@ -15,15 +15,15 @@ function scrollAxes(node: HTMLElement) {
}
}
function wheelTarget(root: HTMLElement, target: EventTarget | null, dy: number): HTMLElement {
let node = target instanceof Element ? target : null
while (node && node !== root) {
if (node instanceof HTMLElement) {
if (scrollAxes(node).y && canScrollInDirection(node, dy < 0 ? "up" : "down")) return node
}
node = node.parentElement
/** Nested-scroller test for gesture routing: the innermost scroller from `target`
* up to, excluding, `root` that can still scroll vertically in `direction` consumes
* the gesture. The transcript's own intent check still walks ancestors separately. */
export function findNestedScroller(root: HTMLElement, target: EventTarget | null, direction: "up" | "down"): HTMLElement | null {
for (let node = target instanceof Element ? target : null; node && node !== root; node = node.parentElement) {
// The edge test first skips style resolution for most ancestors.
if (node instanceof HTMLElement && canScrollInDirection(node, direction) && scrollAxes(node).y) return node
}
return root
return null
}
/** Effective CSS zoom of `element`. The app zooms natively (1 here); CSS zoom still scales input. */
@ -67,11 +67,13 @@ export function attachScrollGestureRouting(root: HTMLElement): () => void {
return
}
const now = performance.now()
const target = wheelTarget(root, event.target, event.deltaY)
if (!wheelOwner?.isConnected || !root.contains(wheelOwner) || now - wheelAt > WHEEL_GESTURE_IDLE_MS) wheelOwner = target
const direction = event.deltaY < 0 ? "up" : "down"
const target = findNestedScroller(root, event.target, direction) ?? root
// At an edge, chain only to an ancestor of the owner, never a new shell
// passing under the cursor during the same gesture.
wheelOwner = wheelTarget(root, wheelOwner, event.deltaY)
wheelOwner = !wheelOwner?.isConnected || !root.contains(wheelOwner) || now - wheelAt > WHEEL_GESTURE_IDLE_MS
? target
: findNestedScroller(root, wheelOwner, direction) ?? root
wheelAt = now
const owner = wheelOwner
if (owner === target) return

View file

@ -3,7 +3,7 @@ import { getUnavailableRestoredSessionIds, resolveRestoredSessionSelection } fro
import { getAbortReason } from "./app-session-restore-timeout"
import { getRestoredSessionIds } from "./app-session-restored-session-ids"
import { hydrateWorkspacePromptState } from "./app-session-prompt-hydration"
import { messageStoreBus, type MessageScrollSnapshotSeed } from "./message-v2/bus"
import { MESSAGE_STREAM_SCROLL_SCOPE, messageStoreBus, type MessageScrollSnapshotSeed } from "./message-v2/bus"
import { getSessionAncestorIdsFromMap } from "./session-tree"
import { seedRestoredSessionSelection } from "./session-state"
import { seedSessionOutlineIndexes } from "./session-outline"
@ -13,7 +13,6 @@ import {
hydrateRestoredSessionChain, hydrateSessionExpansion, hydrateSessionGenerationRecovery, hydrateSessionIdleMarkers,
} from "./sessions"
const MESSAGE_SCROLL_SCOPE = "message-stream"
export const NO_SESSION_DRAFT_SESSION_ID = "__no_session_draft__"
export function seedRestoredWorkspaceState(
@ -21,7 +20,7 @@ export function seedRestoredWorkspaceState(
snapshot: RestorableWorkspaceTabState,
): void {
const scrollSeeds: MessageScrollSnapshotSeed[] = Object.entries(snapshot.scrollSnapshots)
.map(([sessionId, scrollSnapshot]) => ({ sessionId, scope: MESSAGE_SCROLL_SCOPE, snapshot: scrollSnapshot }))
.map(([sessionId, scrollSnapshot]) => ({ sessionId, scope: MESSAGE_STREAM_SCROLL_SCOPE, snapshot: scrollSnapshot }))
messageStoreBus.seedScrollSnapshots(instanceId, scrollSeeds)
seedSessionOutlineIndexes(instanceId, snapshot.outlineIndexes)
seedSessionCatalog(instanceId, snapshot.sessionCatalog)

View file

@ -6,6 +6,9 @@ import type { ScrollSnapshot } from "./types"
const log = getLogger("session")
/** Scroll-snapshot scope of a session's transcript. */
export const MESSAGE_STREAM_SCROLL_SCOPE = "message-stream"
export interface MessageScrollSnapshotSeed {
sessionId: string
scope: string

View file

@ -69,7 +69,7 @@ import { DEFAULT_MODEL_OUTPUT_LIMIT, getActiveCatalogLocation, getDefaultModel,
import { normalizeSessionMessage } from "./message-v2/normalizers"
import { updateSessionInfo } from "./message-v2/session-info"
import { seedSessionMessagesV2, reconcilePendingPermissionsV2 } from "./message-v2/bridge"
import { messageStoreBus } from "./message-v2/bus"
import { MESSAGE_STREAM_SCROLL_SCOPE, messageStoreBus } from "./message-v2/bus"
import { clearCacheForSession } from "../lib/global-cache"
import { getLogger } from "../lib/logger"
import { getOpencodeErrorMessage } from "../lib/opencode-api"
@ -121,7 +121,6 @@ const messagePageRequests = new Map<string, MessagePageRequest>()
const messageHistoryAuthorities = new Map<string, object>()
const createSessionAuthorities = new Map<string, object>()
const deleteSessionAuthorities = new Map<string, object>()
const MESSAGE_STREAM_SCOPE = "message-stream"
const MAX_LATEST_WINDOW_REVISION_RETRIES = 3
const LATEST_WINDOW_RETRY_DELAY_MS = 50
const MESSAGE_CURSOR_SEEK_LIMIT = 1000
@ -1508,7 +1507,7 @@ async function loadProviders(instanceId: string, location: LocationRef): Promise
function currentMessageWindow(instanceId: string, sessionId: string): MessageWindowState {
const store = messageStoreBus.getOrCreate(instanceId)
return store.getMessageWindow(sessionId) ?? windowFromSnapshot(store.getScrollSnapshot(sessionId, MESSAGE_STREAM_SCOPE))
return store.getMessageWindow(sessionId) ?? windowFromSnapshot(store.getScrollSnapshot(sessionId, MESSAGE_STREAM_SCROLL_SCOPE))
}
function planMessageWindowLoad(
@ -1535,10 +1534,10 @@ function commitMessageWindow(
) {
const store = messageStoreBus.getOrCreate(instanceId)
store.setMessageWindow(sessionId, window)
const existing = store.getScrollSnapshot(sessionId, MESSAGE_STREAM_SCOPE)
const existing = store.getScrollSnapshot(sessionId, MESSAGE_STREAM_SCROLL_SCOPE)
const preservePosition = intent === "open"
const atBottom = intent === "older" || intent === "latest"
store.setScrollSnapshot(sessionId, MESSAGE_STREAM_SCOPE, {
store.setScrollSnapshot(sessionId, MESSAGE_STREAM_SCROLL_SCOPE, {
scrollTop: preservePosition ? existing?.scrollTop ?? 0 : 0,
atBottom: preservePosition ? existing?.atBottom ?? window.kind === "latest" : atBottom,
scrollRatio: preservePosition ? existing?.scrollRatio : undefined,
@ -1580,7 +1579,7 @@ async function loadMessages(
const store = messageStoreBus.getOrCreate(instanceId)
const storedWindow = store.getMessageWindow(sessionId)
let expectedWindow = storedWindow
const snapshot = store.getScrollSnapshot(sessionId, MESSAGE_STREAM_SCOPE)
const snapshot = store.getScrollSnapshot(sessionId, MESSAGE_STREAM_SCROLL_SCOPE)
const currentWindow = storedWindow ?? windowFromSnapshot(snapshot)
const planned: ReturnType<typeof planMessageWindowLoad> = options?.anchorMessageId
? { cursor: historyWindowCursor({ kind: "around", messageID: options.anchorMessageId }), next: currentWindow }

View file

@ -356,6 +356,25 @@ test("resident streaming keeps a distant timeline marker mounted", async () => {
} finally { await f.close() }
})
test("a resized timeline keeps its bottom edge anchored", async () => {
const f = await fixture()
try {
const rail = f.page.locator(".message-timeline")
await rail.hover()
await rail.evaluate(element => { element.scrollTop = element.scrollHeight / 2 })
const before = await rail.evaluate(element => ({ height: element.clientHeight, bottom: element.scrollTop + element.clientHeight }))
// Markers resize in the same step (zoom-like): the anchor must survive the geometry update.
await rail.evaluate(element => {
document.head.appendChild(document.createElement("style")).textContent = ".message-timeline-segment { height: 26px !important }"
element.style.maxHeight = `${element.clientHeight - 120}px`
})
await f.page.waitForTimeout(300)
const after = await rail.evaluate(element => ({ height: element.clientHeight, bottom: element.scrollTop + element.clientHeight }))
assert(after.height < before.height && Math.abs(after.bottom - before.bottom) < 2, JSON.stringify({ before, after }))
assert.deepEqual(f.errors, [])
} finally { await f.close() }
})
test("timeline previews render bounded Markdown for historical and resident messages", async () => {
const f = await fixture(true)
try {