mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-19 15:34:12 +00:00
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:
parent
2196f1731e
commit
7a81034f76
17 changed files with 191 additions and 84 deletions
2
.github/workflows/pr-build.yml
vendored
2
.github/workflows/pr-build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
38
packages/ui/src/lib/height-delta.test.ts
Normal file
38
packages/ui/src/lib/height-delta.test.ts
Normal 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
|
||||
}
|
||||
})
|
||||
33
packages/ui/src/lib/height-delta.ts
Normal file
33
packages/ui/src/lib/height-delta.ts
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
51
packages/ui/src/lib/scroll-gesture-routing.test.ts
Normal file
51
packages/ui/src/lib/scroll-gesture-routing.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue