mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-07-10 00:14:27 +00:00
fix(ui): stabilize strict follow autoscroll (#576)
## Summary - replace message stream follow behavior with a strict following/escaped state model - keep explicit submit bottom pinning deterministic while preserving user scroll-up escape intent - prevent repeated pending question/permission polling from rewriting identical store entries and snapping back to bottom - isolate pending request dedupe logic in a raw-node-testable helper Fixes #574 ## Validation - node --test "packages/ui/src/components/virtual-follow-behavior.test.ts" "packages/ui/src/components/session/session-bottom-pin-intent.test.ts" "packages/ui/src/stores/message-v2/pending-request-dedupe.test.ts" - npm run typecheck --workspace @codenomad/ui - npm run build --workspace @codenomad/ui - git diff --check
This commit is contained in:
parent
275770b51b
commit
bce975d940
17 changed files with 890 additions and 1552 deletions
|
|
@ -33,7 +33,6 @@ function DeleteUpToIcon() {
|
|||
const USER_BORDER_COLOR = "var(--message-user-border)"
|
||||
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
|
||||
const NO_STEP_BORDER = "none"
|
||||
const REASONING_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
|
||||
const LazyToolCall = lazy(() => import("./tool-call"))
|
||||
|
||||
|
|
@ -1419,7 +1418,6 @@ function ReasoningStreamOutput(props: {
|
|||
const followScroll = createFollowScroll({
|
||||
getScrollTopSnapshot: props.scrollTopSnapshot,
|
||||
setScrollTopSnapshot: props.setScrollTopSnapshot,
|
||||
sentinelMarginPx: REASONING_SCROLL_SENTINEL_MARGIN_PX,
|
||||
sentinelClassName: "reasoning-scroll-sentinel",
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import BrandedEmptyState from "./branded-empty-state"
|
|||
import MessageBlock from "./message-block"
|
||||
import { getMessageAnchorId } from "./message-anchors"
|
||||
import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline"
|
||||
import VirtualFollowList, { type VirtualFollowBottomIntent, type VirtualFollowListApi, type VirtualFollowListState, type VirtualFollowScrollSnapshot } from "./virtual-follow-list"
|
||||
import VirtualFollowList, { type VirtualExplicitBottomPinIntent, type VirtualFollowListApi, type VirtualFollowListState, type VirtualFollowScrollSnapshot } from "./virtual-follow-list"
|
||||
import { isSnapshotAutoFollowing } from "./virtual-follow-behavior"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { getSessionInfo } from "../stores/sessions"
|
||||
|
|
@ -24,7 +24,6 @@ import { buildSessionSearchMatches } from "../lib/session-search"
|
|||
import type { SessionSearchMatch } from "../lib/session-search"
|
||||
import { resolveThinkingExpansionDefault } from "./tool-call/tool-registry"
|
||||
|
||||
const SCROLL_SENTINEL_MARGIN_PX = 8
|
||||
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
|
||||
const QUOTE_SELECTION_MAX_LENGTH = 2000
|
||||
const STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX = 8
|
||||
|
|
@ -49,7 +48,8 @@ export interface MessageSectionProps {
|
|||
onReloadMessages?: () => void
|
||||
isActive?: boolean
|
||||
sessionStreamingActive?: boolean
|
||||
bottomFollowIntent?: VirtualFollowBottomIntent | null
|
||||
explicitBottomPinIntent?: VirtualExplicitBottomPinIntent | null
|
||||
onExplicitBottomPinCancelled?: () => void
|
||||
}
|
||||
|
||||
export default function MessageSection(props: MessageSectionProps) {
|
||||
|
|
@ -735,16 +735,6 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
return
|
||||
}
|
||||
|
||||
const element = streamElement()
|
||||
if (!allowCapture || !canCapture) return
|
||||
if (!element) return
|
||||
const scrollTop = element.scrollTop
|
||||
const maxScrollTop = Math.max(element.scrollHeight - element.clientHeight, 0)
|
||||
const scrollRatio = maxScrollTop > 0 ? scrollTop / maxScrollTop : 0
|
||||
const atBottom = element.scrollHeight - (element.scrollTop + element.clientHeight) <= 48
|
||||
const snapshot = { scrollTop, scrollRatio, maxScrollTop, atBottom }
|
||||
setLastGoodScrollSnapshot(sessionId, snapshot)
|
||||
store().setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, snapshot)
|
||||
}
|
||||
|
||||
// Persist scroll position when switching sessions. This effect's cleanup runs
|
||||
|
|
@ -783,7 +773,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
setScrollControlsOpen(false)
|
||||
}
|
||||
|
||||
function openScrollControlsFromTrigger(event: PointerEvent) {
|
||||
function openScrollControlsFromTrigger(event: MouseEvent) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (scrollControlsOpen()) return
|
||||
|
|
@ -795,7 +785,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
action()
|
||||
setScrollControlsHoverSuppressed(event.pointerType !== "mouse")
|
||||
setScrollControlsHoverSuppressed(false)
|
||||
closeScrollControls()
|
||||
}
|
||||
|
||||
|
|
@ -836,7 +826,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
const api = listApi()
|
||||
if (!api) return
|
||||
if (props.registerScrollToBottom) {
|
||||
props.registerScrollToBottom(() => api.scrollToBottom({ immediate: true, suppressHold: true }))
|
||||
props.registerScrollToBottom(() => api.scrollToBottom({ immediate: true }))
|
||||
onCleanup(() => props.registerScrollToBottom?.(null))
|
||||
}
|
||||
})
|
||||
|
|
@ -1264,7 +1254,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
if (!match || !isSearchOpen()) return
|
||||
if (match.id === lastScrolledSearchMatchId) return
|
||||
lastScrolledSearchMatchId = match.id
|
||||
listApi()?.scrollToKey(match.messageId, { behavior: "smooth", block: "start", setAutoScroll: false })
|
||||
listApi()?.scrollToKey(match.messageId, { behavior: "smooth", block: "start" })
|
||||
})
|
||||
|
||||
|
||||
|
|
@ -1367,8 +1357,6 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
getKey={(messageId) => messageId}
|
||||
getAnchorId={getMessageAnchorId}
|
||||
overscanPx={800}
|
||||
scrollSentinelMarginPx={SCROLL_SENTINEL_MARGIN_PX}
|
||||
suspendMeasurements={() => !isActive()}
|
||||
streamingActive={streamingActive}
|
||||
isActive={isActive}
|
||||
scrollToBottomOnActivate={() => false}
|
||||
|
|
@ -1376,7 +1364,8 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
initialAutoScroll={initialAutoScroll}
|
||||
resetKey={() => props.sessionId}
|
||||
followToken={followToken}
|
||||
forceBottomFollowIntent={() => props.bottomFollowIntent ?? null}
|
||||
explicitBottomPinIntent={() => props.explicitBottomPinIntent ?? null}
|
||||
onExplicitBottomPinCancelled={props.onExplicitBottomPinCancelled}
|
||||
autoPinHoldEnabled={holdLongAssistantRepliesEnabled}
|
||||
autoPinHoldTargetKey={autoPinHoldTargetKey}
|
||||
autoPinHoldTopThresholdPx={STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX}
|
||||
|
|
@ -1429,7 +1418,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
<button
|
||||
type="button"
|
||||
class="message-scroll-button message-scroll-controls-trigger"
|
||||
onPointerUp={openScrollControlsFromTrigger}
|
||||
onClick={openScrollControlsFromTrigger}
|
||||
aria-label={t("messageSection.scroll.showControlsAriaLabel")}
|
||||
title={t("messageSection.scroll.showControlsAriaLabel")}
|
||||
>
|
||||
|
|
@ -1472,7 +1461,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
<button
|
||||
type="button"
|
||||
class="message-scroll-button"
|
||||
onPointerUp={(event) => runScrollControlAction(event, () => api.scrollToBottom({ suppressHold: true }))}
|
||||
onPointerUp={(event) => runScrollControlAction(event, () => api.scrollToBottom())}
|
||||
aria-label={t("messageSection.scroll.toLatestAriaLabel")}
|
||||
>
|
||||
<span class="message-scroll-icon" aria-hidden="true">
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import { resolveSessionBottomFollowIntent, shouldClearSessionBottomFollowIntent } from "./session-bottom-follow-intent.ts"
|
||||
|
||||
describe("session bottom follow intent", () => {
|
||||
it("only exposes submit follow intent to the matching session", () => {
|
||||
const intent = { sessionId: "session-a", token: 3, minItemCount: 12 }
|
||||
|
||||
assert.deepEqual(resolveSessionBottomFollowIntent(intent, "session-a"), {
|
||||
token: 3,
|
||||
minItemCount: 12,
|
||||
})
|
||||
assert.equal(resolveSessionBottomFollowIntent(intent, "session-b"), null)
|
||||
})
|
||||
|
||||
it("clears submit follow intent only after the submitted exchange has rendered and stopped streaming", () => {
|
||||
const intent = { sessionId: "session-a", token: 3, minItemCount: 12 }
|
||||
|
||||
assert.equal(
|
||||
shouldClearSessionBottomFollowIntent(intent, {
|
||||
sessionId: "session-a",
|
||||
messageCount: 11,
|
||||
streamingActive: false,
|
||||
}),
|
||||
false,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
shouldClearSessionBottomFollowIntent(intent, {
|
||||
sessionId: "session-a",
|
||||
messageCount: 12,
|
||||
streamingActive: true,
|
||||
}),
|
||||
false,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
shouldClearSessionBottomFollowIntent(intent, {
|
||||
sessionId: "session-a",
|
||||
messageCount: 12,
|
||||
streamingActive: false,
|
||||
}),
|
||||
true,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
shouldClearSessionBottomFollowIntent(intent, {
|
||||
sessionId: "session-b",
|
||||
messageCount: 12,
|
||||
streamingActive: false,
|
||||
}),
|
||||
false,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import type { VirtualFollowBottomIntent } from "../virtual-follow-list"
|
||||
|
||||
export interface SessionBottomFollowIntent extends VirtualFollowBottomIntent {
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export function resolveSessionBottomFollowIntent(
|
||||
intent: SessionBottomFollowIntent | null,
|
||||
sessionId: string,
|
||||
): VirtualFollowBottomIntent | null {
|
||||
if (!intent || intent.sessionId !== sessionId) return null
|
||||
return { token: intent.token, minItemCount: intent.minItemCount }
|
||||
}
|
||||
|
||||
export function shouldClearSessionBottomFollowIntent(
|
||||
intent: SessionBottomFollowIntent | null,
|
||||
state: { sessionId: string; messageCount: number; streamingActive: boolean },
|
||||
) {
|
||||
if (!intent) return false
|
||||
if (intent.sessionId !== state.sessionId) return false
|
||||
return state.messageCount >= (intent.minItemCount ?? 0) && !state.streamingActive
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import { getSubmitBottomPinTargetCount, resolveSessionBottomPinIntent, shouldClearSessionBottomPinIntent } from "./session-bottom-pin-intent.ts"
|
||||
|
||||
describe("session bottom pin intent", () => {
|
||||
it("targets only the queued user prompt when submitting during active streaming", () => {
|
||||
assert.equal(getSubmitBottomPinTargetCount(10, true), 11)
|
||||
assert.equal(getSubmitBottomPinTargetCount(10, false), 12)
|
||||
})
|
||||
|
||||
it("only exposes submit bottom pin intent to the matching session", () => {
|
||||
const intent = { sessionId: "session-a", token: 3, minItemCount: 12, createdMessageCount: 10, observedStreaming: false }
|
||||
|
||||
assert.deepEqual(resolveSessionBottomPinIntent(intent, "session-a"), {
|
||||
token: 3,
|
||||
minItemCount: 12,
|
||||
})
|
||||
assert.equal(resolveSessionBottomPinIntent(intent, "session-b"), null)
|
||||
})
|
||||
|
||||
it("clears submit bottom pin intent after the submitted exchange has rendered and stopped streaming", () => {
|
||||
const intent = { sessionId: "session-a", token: 3, minItemCount: 12, createdMessageCount: 10, observedStreaming: false }
|
||||
|
||||
assert.equal(
|
||||
shouldClearSessionBottomPinIntent(intent, {
|
||||
sessionId: "session-a",
|
||||
messageCount: 11,
|
||||
streamingActive: false,
|
||||
}),
|
||||
false,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
shouldClearSessionBottomPinIntent(intent, {
|
||||
sessionId: "session-a",
|
||||
messageCount: 12,
|
||||
streamingActive: true,
|
||||
}),
|
||||
false,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
shouldClearSessionBottomPinIntent(intent, {
|
||||
sessionId: "session-a",
|
||||
messageCount: 12,
|
||||
streamingActive: false,
|
||||
}),
|
||||
true,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
shouldClearSessionBottomPinIntent(intent, {
|
||||
sessionId: "session-b",
|
||||
messageCount: 12,
|
||||
streamingActive: false,
|
||||
}),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it("clears a submitted turn that stopped streaming before the optimistic count was reached", () => {
|
||||
const intent = { sessionId: "session-a", token: 3, minItemCount: 12, createdMessageCount: 10, observedStreaming: true }
|
||||
|
||||
assert.equal(
|
||||
shouldClearSessionBottomPinIntent(intent, {
|
||||
sessionId: "session-a",
|
||||
messageCount: 11,
|
||||
streamingActive: false,
|
||||
}),
|
||||
true,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import type { VirtualExplicitBottomPinIntent } from "../virtual-follow-list"
|
||||
|
||||
export interface SessionBottomPinIntent extends VirtualExplicitBottomPinIntent {
|
||||
sessionId: string
|
||||
createdMessageCount: number
|
||||
observedStreaming: boolean
|
||||
}
|
||||
|
||||
export function getSubmitBottomPinTargetCount(messageCount: number, streamingActive: boolean) {
|
||||
return messageCount + (streamingActive ? 1 : 2)
|
||||
}
|
||||
|
||||
export function resolveSessionBottomPinIntent(
|
||||
intent: SessionBottomPinIntent | null,
|
||||
sessionId: string,
|
||||
): VirtualExplicitBottomPinIntent | null {
|
||||
if (!intent || intent.sessionId !== sessionId) return null
|
||||
return { token: intent.token, minItemCount: intent.minItemCount }
|
||||
}
|
||||
|
||||
export function shouldClearSessionBottomPinIntent(
|
||||
intent: SessionBottomPinIntent | null,
|
||||
state: { sessionId: string; messageCount: number; streamingActive: boolean },
|
||||
) {
|
||||
if (!intent) return false
|
||||
if (intent.sessionId !== state.sessionId) return false
|
||||
if (state.streamingActive) return false
|
||||
if (state.messageCount >= (intent.minItemCount ?? 0)) return true
|
||||
return intent.observedStreaming && state.messageCount > intent.createdMessageCount
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ import { useConfig } from "../../stores/preferences"
|
|||
import { closeSessionPreview, getSessionPreview, showSessionChat } from "../../stores/session-previews"
|
||||
import { SessionPreviewView } from "../session-preview-view"
|
||||
import { isSnapshotAutoFollowing } from "../virtual-follow-behavior"
|
||||
import { resolveSessionBottomFollowIntent, shouldClearSessionBottomFollowIntent, type SessionBottomFollowIntent } from "./session-bottom-follow-intent"
|
||||
import { getSubmitBottomPinTargetCount, resolveSessionBottomPinIntent, shouldClearSessionBottomPinIntent, type SessionBottomPinIntent } from "./session-bottom-pin-intent"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
|
|
@ -81,8 +81,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
let scrollToBottomHandle: (() => void) | undefined
|
||||
let rootRef: HTMLDivElement | undefined
|
||||
const pendingIdleSeenTimers = new Set<string>()
|
||||
const [submitBottomFollowIntent, setSubmitBottomFollowIntent] = createSignal<SessionBottomFollowIntent | null>(null)
|
||||
let submitBottomFollowIntentSequence = 0
|
||||
const [submitBottomPinIntent, setSubmitBottomPinIntent] = createSignal<SessionBottomPinIntent | null>(null)
|
||||
let submitBottomPinIntentSequence = 0
|
||||
|
||||
function shouldScrollToBottomOnActivate() {
|
||||
const current = session()
|
||||
|
|
@ -105,23 +105,45 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
return true
|
||||
}
|
||||
|
||||
function startSubmitBottomFollowIntent(minItemCount: number) {
|
||||
submitBottomFollowIntentSequence += 1
|
||||
setSubmitBottomFollowIntent({ sessionId: props.sessionId, token: submitBottomFollowIntentSequence, minItemCount })
|
||||
function startSubmitBottomPinIntent(
|
||||
minItemCount: number,
|
||||
options?: { createdMessageCount?: number; preserveObservedStreaming?: boolean },
|
||||
) {
|
||||
submitBottomPinIntentSequence += 1
|
||||
const previous = submitBottomPinIntent()
|
||||
const createdMessageCount = options?.createdMessageCount ?? messageStore().getSessionMessageIds(props.sessionId).length
|
||||
const shouldPreserveObservedStreaming = Boolean(
|
||||
options?.preserveObservedStreaming &&
|
||||
previous?.sessionId === props.sessionId &&
|
||||
previous.createdMessageCount === createdMessageCount,
|
||||
)
|
||||
const intent: SessionBottomPinIntent = {
|
||||
sessionId: props.sessionId,
|
||||
token: submitBottomPinIntentSequence,
|
||||
minItemCount,
|
||||
createdMessageCount,
|
||||
observedStreaming: shouldPreserveObservedStreaming ? previous?.observedStreaming === true : false,
|
||||
}
|
||||
setSubmitBottomPinIntent(intent)
|
||||
return intent
|
||||
}
|
||||
|
||||
function forceSubmittedExchangeToBottom(minItemCount: number) {
|
||||
startSubmitBottomFollowIntent(minItemCount)
|
||||
function forceSubmittedExchangeToBottom(
|
||||
minItemCount: number,
|
||||
options?: { createdMessageCount?: number; preserveObservedStreaming?: boolean },
|
||||
) {
|
||||
const intent = startSubmitBottomPinIntent(minItemCount, options)
|
||||
scrollToBottomHandle?.()
|
||||
return intent
|
||||
}
|
||||
|
||||
const activeSubmitBottomFollowIntent = createMemo(() => {
|
||||
const intent = submitBottomFollowIntent()
|
||||
const activeSubmitBottomPinIntent = createMemo(() => {
|
||||
const intent = submitBottomPinIntent()
|
||||
const currentSession = session()
|
||||
if (!intent || !currentSession) return null
|
||||
|
||||
const messageCount = messageStore().getSessionMessageIds(currentSession.id).length
|
||||
if (shouldClearSessionBottomFollowIntent(intent, {
|
||||
if (shouldClearSessionBottomPinIntent(intent, {
|
||||
sessionId: currentSession.id,
|
||||
messageCount,
|
||||
streamingActive: sessionStreamingActive(),
|
||||
|
|
@ -129,7 +151,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
return null
|
||||
}
|
||||
|
||||
return resolveSessionBottomFollowIntent(intent, currentSession.id)
|
||||
return resolveSessionBottomPinIntent(intent, currentSession.id)
|
||||
})
|
||||
|
||||
function getSeenIdleEntries(currentSession: Session, keepUnseenSubagentIdleStatus: boolean): Array<{ id: string; idleSince: number }> {
|
||||
|
|
@ -151,6 +173,14 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
return entries
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.sessionId,
|
||||
() => setSubmitBottomPinIntent(null),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.isActive,
|
||||
|
|
@ -164,17 +194,22 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
)
|
||||
|
||||
createEffect(() => {
|
||||
const intent = submitBottomFollowIntent()
|
||||
const intent = submitBottomPinIntent()
|
||||
const currentSession = session()
|
||||
if (!intent || !currentSession) return
|
||||
|
||||
if (sessionStreamingActive() && intent.sessionId === currentSession.id && !intent.observedStreaming) {
|
||||
setSubmitBottomPinIntent({ ...intent, observedStreaming: true })
|
||||
return
|
||||
}
|
||||
|
||||
const messageCount = messageStore().getSessionMessageIds(currentSession.id).length
|
||||
if (shouldClearSessionBottomFollowIntent(intent, {
|
||||
if (shouldClearSessionBottomPinIntent(intent, {
|
||||
sessionId: currentSession.id,
|
||||
messageCount,
|
||||
streamingActive: sessionStreamingActive(),
|
||||
})) {
|
||||
setSubmitBottomFollowIntent(null)
|
||||
setSubmitBottomPinIntent(null)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -309,14 +344,21 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
|
||||
async function handleSendMessage(prompt: string, attachments: Attachment[]) {
|
||||
const messageCount = messageStore().getSessionMessageIds(props.sessionId).length
|
||||
const submittedExchangeTargetCount = messageCount + 2
|
||||
forceSubmittedExchangeToBottom(submittedExchangeTargetCount)
|
||||
const submittedExchangeTargetCount = getSubmitBottomPinTargetCount(messageCount, sessionStreamingActive())
|
||||
const initialPinIntent = forceSubmittedExchangeToBottom(submittedExchangeTargetCount, { createdMessageCount: messageCount })
|
||||
try {
|
||||
await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
|
||||
const latestMessageCount = messageStore().getSessionMessageIds(props.sessionId).length
|
||||
forceSubmittedExchangeToBottom(Math.max(submittedExchangeTargetCount, latestMessageCount))
|
||||
if (latestMessageCount < submittedExchangeTargetCount && !sessionStreamingActive()) {
|
||||
setSubmitBottomPinIntent(null)
|
||||
} else if (submitBottomPinIntent()?.token === initialPinIntent.token) {
|
||||
forceSubmittedExchangeToBottom(Math.max(submittedExchangeTargetCount, latestMessageCount), {
|
||||
createdMessageCount: messageCount,
|
||||
preserveObservedStreaming: true,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
setSubmitBottomFollowIntent(null)
|
||||
setSubmitBottomPinIntent(null)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
|
@ -481,7 +523,8 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
loadError={messagesLoadError()}
|
||||
onReloadMessages={handleReloadMessages}
|
||||
sessionStreamingActive={sessionStreamingActive()}
|
||||
bottomFollowIntent={activeSubmitBottomFollowIntent()}
|
||||
explicitBottomPinIntent={activeSubmitBottomPinIntent()}
|
||||
onExplicitBottomPinCancelled={() => setSubmitBottomPinIntent(null)}
|
||||
onRevert={handleRevert}
|
||||
onDeleteMessagesUpTo={handleDeleteMessagesUpTo}
|
||||
onFork={handleFork}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ const log = getLogger("session")
|
|||
type ToolState = import("@opencode-ai/sdk/v2").ToolState
|
||||
|
||||
const TOOL_CALL_CACHE_SCOPE = "tool-call"
|
||||
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
|
||||
function makeRenderCacheKey(
|
||||
toolCallId?: string | null,
|
||||
|
|
@ -194,7 +193,6 @@ function ToolCallDetails(props: {
|
|||
const followScroll = createFollowScroll({
|
||||
getScrollTopSnapshot: props.scrollTopSnapshot,
|
||||
setScrollTopSnapshot: props.setScrollTopSnapshot,
|
||||
sentinelMarginPx: TOOL_SCROLL_SENTINEL_MARGIN_PX,
|
||||
sentinelClassName: "tool-call-scroll-sentinel",
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -2,214 +2,92 @@ import assert from "node:assert/strict"
|
|||
import { describe, it } from "node:test"
|
||||
|
||||
import {
|
||||
BOTTOM_FOLLOW_EPSILON_PX,
|
||||
VirtualScrollController,
|
||||
isAtBottom,
|
||||
isAutoFollowing,
|
||||
isSnapshotAutoFollowing,
|
||||
resolveAutoPinHoldElement,
|
||||
shouldSuspendAutoPinToBottomForHold,
|
||||
restoreFollowModeFromSnapshot,
|
||||
transitionFollowMode,
|
||||
type FollowMode,
|
||||
type ScrollControllerMetrics,
|
||||
} from "./virtual-follow-behavior.ts"
|
||||
|
||||
const userScroll = (direction: "up" | "down" | null, atBottom: boolean, canPinToBottom = false) =>
|
||||
({ type: "user-scroll", direction, atBottom, canPinToBottom }) as const
|
||||
const userScroll = (direction: "up" | "down" | null, atBottom: boolean) =>
|
||||
({ type: "user-scroll", direction, atBottom }) as const
|
||||
|
||||
function metrics(offset: number, scrollHeight = 3000, clientHeight = 600): ScrollControllerMetrics {
|
||||
return {
|
||||
offset,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
sentinelMarginPx: 48,
|
||||
}
|
||||
function metrics(offset: number, scrollHeight = 3000, clientHeight = 600, sentinelMarginPx = BOTTOM_FOLLOW_EPSILON_PX): ScrollControllerMetrics {
|
||||
return { offset, scrollHeight, clientHeight, sentinelMarginPx }
|
||||
}
|
||||
|
||||
describe("virtual follow behavior", () => {
|
||||
it("escapes follow on upward user scroll", () => {
|
||||
const next = transitionFollowMode({ type: "following" }, userScroll("up", false))
|
||||
it("escapes follow on any upward user intent", () => {
|
||||
const next = transitionFollowMode({ type: "following" }, userScroll("up", true))
|
||||
|
||||
assert.deepEqual(next.mode, { type: "escaped" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("does not rejoin follow when escaped user scrolls down above bottom without pin permission", () => {
|
||||
it("does not rejoin follow from downward movement above the exact bottom", () => {
|
||||
const next = transitionFollowMode({ type: "escaped" }, userScroll("down", false))
|
||||
|
||||
assert.deepEqual(next.mode, { type: "escaped" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("does not rejoin follow above bottom even with pin permission", () => {
|
||||
const next = transitionFollowMode({ type: "escaped" }, userScroll("down", false, true))
|
||||
|
||||
assert.deepEqual(next.mode, { type: "escaped" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("rejoins follow when escaped user scrolls to the bottom", () => {
|
||||
it("rejoins follow only at the exact bottom", () => {
|
||||
const next = transitionFollowMode({ type: "escaped" }, userScroll("down", true))
|
||||
|
||||
assert.deepEqual(next.mode, { type: "following" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("keeps hold latched when the user scrolls down above bottom", () => {
|
||||
const next = transitionFollowMode({ type: "holding", key: "message-1" }, userScroll("down", false, true))
|
||||
|
||||
assert.deepEqual(next.mode, { type: "holding", key: "message-1" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("does not rejoin follow for directionless scroll above bottom", () => {
|
||||
const next = transitionFollowMode({ type: "escaped" }, userScroll(null, false, true))
|
||||
|
||||
assert.deepEqual(next.mode, { type: "escaped" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("does not rejoin follow on upward scroll at bottom", () => {
|
||||
const next = transitionFollowMode({ type: "escaped" }, userScroll("up", true, true))
|
||||
|
||||
assert.deepEqual(next.mode, { type: "escaped" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("keeps hold latched for directionless user scroll away from bottom", () => {
|
||||
const next = transitionFollowMode({ type: "holding", key: "message-1" }, userScroll(null, false, true))
|
||||
|
||||
assert.deepEqual(next.mode, { type: "holding", key: "message-1" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("pins content growth while following but not while escaped", () => {
|
||||
const escaped = transitionFollowMode({ type: "escaped" }, { type: "content-grew", canPinToBottom: true })
|
||||
const following = transitionFollowMode({ type: "following" }, { type: "content-grew", canPinToBottom: true })
|
||||
|
||||
assert.deepEqual(escaped.effect, { type: "none" })
|
||||
assert.deepEqual(following.effect, { type: "scroll-bottom", immediate: true, suppressHold: false })
|
||||
assert.deepEqual(following.effect, { type: "scroll-bottom", immediate: true })
|
||||
})
|
||||
|
||||
it("does not align or pin while held content grows", () => {
|
||||
const next = transitionFollowMode({ type: "holding", key: "message-1" }, { type: "content-grew", canPinToBottom: true })
|
||||
|
||||
assert.deepEqual(next.mode, { type: "holding", key: "message-1" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("enters hold mode for a valid hold candidate", () => {
|
||||
const next = transitionFollowMode({ type: "following" }, { type: "hold-candidate", key: "message-1", shouldHold: true })
|
||||
|
||||
assert.deepEqual(next.mode, { type: "holding", key: "message-1" })
|
||||
assert.deepEqual(next.effect, { type: "align-hold", key: "message-1" })
|
||||
})
|
||||
|
||||
it("keeps hold latched when the hold target disappears", () => {
|
||||
const next = transitionFollowMode({ type: "holding", key: "message-1" }, { type: "hold-target-changed", key: null, canPinToBottom: true })
|
||||
|
||||
assert.deepEqual(next.mode, { type: "holding", key: "message-1" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("keeps hold latched when a later hold target is reported", () => {
|
||||
const next = transitionFollowMode({ type: "holding", key: "message-1" }, { type: "hold-target-changed", key: "message-2", canPinToBottom: true })
|
||||
|
||||
assert.deepEqual(next.mode, { type: "holding", key: "message-1" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("explicit bottom jumps leave hold and suppress the next hold", () => {
|
||||
const next = transitionFollowMode({ type: "holding", key: "message-1" }, { type: "jump-bottom", immediate: true, explicit: true })
|
||||
it("does not pin content growth when the integration gate is closed", () => {
|
||||
const next = transitionFollowMode({ type: "following" }, { type: "content-grew", canPinToBottom: false })
|
||||
|
||||
assert.deepEqual(next.mode, { type: "following" })
|
||||
assert.deepEqual(next.effect, { type: "scroll-bottom", immediate: true, suppressHold: true })
|
||||
})
|
||||
|
||||
it("prompt submission overrides a stale hold latch and returns to bottom follow", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.holdCandidate("old-assistant-answer", true)
|
||||
|
||||
const result = controller.jumpBottom(true, true)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "following" })
|
||||
assert.deepEqual(result.effect, { type: "scroll-bottom", immediate: true, suppressHold: true })
|
||||
assert.equal(controller.isAutoFollowing(), true)
|
||||
})
|
||||
|
||||
it("clears an existing hold latch when hold targeting is disabled", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.holdCandidate("old-assistant-answer", true)
|
||||
|
||||
const result = controller.clearHold(true, true, true)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "following" })
|
||||
assert.deepEqual(result.effect, { type: "scroll-bottom", immediate: true, suppressHold: true })
|
||||
})
|
||||
|
||||
it("keeps submitted prompt content growth in bottom-follow after clearing stale hold", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.holdCandidate("old-assistant-answer", true)
|
||||
controller.jumpBottom(true, true)
|
||||
|
||||
const result = controller.contentRendered(metrics(2400), true)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "following" })
|
||||
assert.deepEqual(result.effect, { type: "scroll-bottom", immediate: true, suppressHold: false })
|
||||
})
|
||||
|
||||
it("ignores stale previous assistant hold target changes after a submit bottom jump", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.holdCandidate("previous-assistant-answer", true)
|
||||
controller.jumpBottom(true, true)
|
||||
|
||||
const targetChanged = controller.holdTargetChanged("previous-assistant-answer", true)
|
||||
const contentRendered = controller.contentRendered(metrics(2400), true)
|
||||
|
||||
assert.deepEqual(targetChanged.state.mode, { type: "following" })
|
||||
assert.deepEqual(targetChanged.effect, { type: "none" })
|
||||
assert.deepEqual(contentRendered.state.mode, { type: "following" })
|
||||
assert.deepEqual(contentRendered.effect, { type: "scroll-bottom", immediate: true, suppressHold: false })
|
||||
})
|
||||
|
||||
it("keeps escaped-mode streaming detached until actual bottom", () => {
|
||||
const suspend = shouldSuspendAutoPinToBottomForHold({
|
||||
externalSuspend: false,
|
||||
activeHoldTargetKey: null,
|
||||
eligibleHoldTargetKey: "streaming-assistant-answer",
|
||||
})
|
||||
|
||||
const next = transitionFollowMode({ type: "escaped" }, userScroll("down", false, !suspend))
|
||||
|
||||
assert.equal(suspend, false)
|
||||
assert.deepEqual(next.mode, { type: "escaped" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("keeps auto-pin suspended while a hold target is actively latched", () => {
|
||||
const suspend = shouldSuspendAutoPinToBottomForHold({
|
||||
externalSuspend: false,
|
||||
activeHoldTargetKey: "streaming-assistant-answer",
|
||||
eligibleHoldTargetKey: "streaming-assistant-answer",
|
||||
})
|
||||
it("explicit bottom jumps enter follow mode", () => {
|
||||
const next = transitionFollowMode({ type: "escaped" }, { type: "jump-bottom", immediate: true, explicit: true })
|
||||
|
||||
const next = transitionFollowMode({ type: "holding", key: "streaming-assistant-answer" }, userScroll("down", false, !suspend))
|
||||
|
||||
assert.equal(suspend, true)
|
||||
assert.deepEqual(next.mode, { type: "holding", key: "streaming-assistant-answer" })
|
||||
assert.deepEqual(next.effect, { type: "none" })
|
||||
assert.deepEqual(next.mode, { type: "following" })
|
||||
assert.deepEqual(next.effect, { type: "scroll-bottom", immediate: true })
|
||||
})
|
||||
|
||||
it("key jumps can opt into follow or escape mode", () => {
|
||||
const follow = transitionFollowMode({ type: "escaped" }, { type: "jump-key", key: "a", block: "start", smooth: false, followAfter: true })
|
||||
const escape = transitionFollowMode({ type: "following" }, { type: "jump-key", key: "b", block: "center", smooth: true, followAfter: false })
|
||||
it("explicit bottom jumps override stale upward user intent", () => {
|
||||
const controller = new VirtualScrollController(false)
|
||||
controller.recordProgrammaticOffset(2200, false)
|
||||
controller.setUserIntent("up", 700)
|
||||
|
||||
assert.deepEqual(follow.mode, { type: "following" })
|
||||
assert.deepEqual(escape.mode, { type: "escaped" })
|
||||
const jump = controller.jumpBottom(true, true)
|
||||
const observed = controller.observeViewport(metrics(2400), 100, true)
|
||||
|
||||
assert.deepEqual(jump.state.mode, { type: "following" })
|
||||
assert.deepEqual(observed.state.mode, { type: "following" })
|
||||
})
|
||||
|
||||
it("derives auto-follow from modes", () => {
|
||||
it("key jumps always escape follow mode", () => {
|
||||
const fromEscaped = transitionFollowMode({ type: "escaped" }, { type: "jump-key", key: "a", block: "start", smooth: false })
|
||||
const fromFollowing = transitionFollowMode({ type: "following" }, { type: "jump-key", key: "b", block: "center", smooth: true })
|
||||
|
||||
assert.deepEqual(fromEscaped.mode, { type: "escaped" })
|
||||
assert.deepEqual(fromFollowing.mode, { type: "escaped" })
|
||||
})
|
||||
|
||||
it("derives auto-follow from the two modes", () => {
|
||||
const modes: Array<[FollowMode, boolean]> = [
|
||||
[{ type: "following" }, true],
|
||||
[{ type: "holding", key: "message-1" }, false],
|
||||
[{ type: "escaped" }, false],
|
||||
]
|
||||
|
||||
|
|
@ -218,56 +96,6 @@ describe("virtual follow behavior", () => {
|
|||
}
|
||||
})
|
||||
|
||||
it("pins content growth instead of escaping on transient upward render movement", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.recordProgrammaticOffset(2400, true)
|
||||
|
||||
const result = controller.contentRendered(metrics(2200), true)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "following" })
|
||||
assert.deepEqual(result.effect, { type: "scroll-bottom", immediate: true, suppressHold: false })
|
||||
})
|
||||
|
||||
it("does not align or pin when held content renders", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.holdCandidate("message-1", true)
|
||||
|
||||
const result = controller.contentRendered(metrics(2200), true)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "holding", key: "message-1" })
|
||||
assert.deepEqual(result.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("does not resume or snap when a held target disappears", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.holdCandidate("message-1", true)
|
||||
|
||||
const result = controller.holdTargetChanged(null, true)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "holding", key: "message-1" })
|
||||
assert.deepEqual(result.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("lets fresh user upward movement escape even during a programmatic window", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.recordProgrammaticOffset(2400, true)
|
||||
controller.setUserIntent("up", 700)
|
||||
|
||||
const result = controller.observeViewport(metrics(2200), 100, true)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "escaped" })
|
||||
})
|
||||
|
||||
it("does not escape for owned programmatic upward movement", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.recordProgrammaticOffset(2400, true)
|
||||
|
||||
const result = controller.observeViewport(metrics(2200), 100, true)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "following" })
|
||||
assert.deepEqual(result.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("does not resume follow on directionless scroll above bottom", () => {
|
||||
const controller = new VirtualScrollController(false)
|
||||
controller.recordProgrammaticOffset(2220, false)
|
||||
|
|
@ -289,18 +117,7 @@ describe("virtual follow behavior", () => {
|
|||
assert.deepEqual(result.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("does not magnet to bottom above bottom even with integration pin permission", () => {
|
||||
const controller = new VirtualScrollController(false)
|
||||
controller.recordProgrammaticOffset(2100, false)
|
||||
controller.setUserIntent("down", 700)
|
||||
|
||||
const result = controller.observeViewport(metrics(2220), 100, false, true)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "escaped" })
|
||||
assert.deepEqual(result.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("resumes follow only when downward movement reaches actual bottom", () => {
|
||||
it("resumes follow only when downward movement reaches exact bottom", () => {
|
||||
const controller = new VirtualScrollController(false)
|
||||
controller.recordProgrammaticOffset(2300, false)
|
||||
controller.setUserIntent("down", 700)
|
||||
|
|
@ -311,31 +128,33 @@ describe("virtual follow behavior", () => {
|
|||
assert.deepEqual(result.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("keeps hold latched until downward movement reaches actual bottom", () => {
|
||||
it("lets fresh user upward movement escape even during a programmatic window", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.holdCandidate("message-1", true)
|
||||
controller.recordProgrammaticOffset(2100, false)
|
||||
controller.setUserIntent("down", 700)
|
||||
controller.recordProgrammaticOffset(2400, true)
|
||||
controller.setUserIntent("up", 700)
|
||||
|
||||
const nearBottom = controller.observeViewport(metrics(2220), 100, false, true)
|
||||
|
||||
assert.deepEqual(nearBottom.state.mode, { type: "holding", key: "message-1" })
|
||||
assert.deepEqual(nearBottom.effect, { type: "none" })
|
||||
|
||||
controller.setUserIntent("down", 800)
|
||||
const atBottom = controller.observeViewport(metrics(2400), 200, false, true)
|
||||
|
||||
assert.deepEqual(atBottom.state.mode, { type: "following" })
|
||||
assert.deepEqual(atBottom.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("still escapes follow on upward movement at bottom", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.recordProgrammaticOffset(1200, false)
|
||||
|
||||
const result = controller.observeViewport(metrics(1100), 100, false)
|
||||
const result = controller.observeViewport(metrics(2200), 100, true)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "escaped" })
|
||||
})
|
||||
|
||||
it("keeps fresh upward intent escaped even if a programmatic scroll later moves down to bottom", () => {
|
||||
const controller = new VirtualScrollController(false)
|
||||
controller.recordProgrammaticOffset(2200, false)
|
||||
controller.setUserIntent("up", 700)
|
||||
|
||||
const result = controller.observeViewport(metrics(2400), 100, true)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "escaped" })
|
||||
})
|
||||
|
||||
it("does not escape for owned programmatic upward movement", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.recordProgrammaticOffset(2400, true)
|
||||
|
||||
const result = controller.observeViewport(metrics(2200), 100, true)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "following" })
|
||||
assert.deepEqual(result.effect, { type: "none" })
|
||||
})
|
||||
|
||||
|
|
@ -349,33 +168,27 @@ describe("virtual follow behavior", () => {
|
|||
assert.deepEqual(result.effect, { type: "none" })
|
||||
})
|
||||
|
||||
it("does not pin content growth when the integration gate is closed", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
it("uses a small bottom follow tolerance", () => {
|
||||
const bottomOffset = 2400
|
||||
|
||||
const result = controller.contentRendered(metrics(2400), false)
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "following" })
|
||||
assert.deepEqual(result.effect, { type: "none" })
|
||||
assert.equal(isAtBottom(metrics(bottomOffset - BOTTOM_FOLLOW_EPSILON_PX - 0.1)), false)
|
||||
assert.equal(isAtBottom(metrics(bottomOffset - BOTTOM_FOLLOW_EPSILON_PX)), true)
|
||||
assert.equal(isAtBottom(metrics(bottomOffset)), true)
|
||||
})
|
||||
|
||||
it("blocks pre-pin upward reconciliation while restoring", () => {
|
||||
const controller = new VirtualScrollController(true)
|
||||
controller.recordProgrammaticOffset(2400, true)
|
||||
controller.setRestoring(true)
|
||||
it("treats fractional distance inside the tolerance as at-bottom", () => {
|
||||
const bottomOffset = 2400
|
||||
|
||||
const result = controller.beforeBottomPin(metrics(2200))
|
||||
|
||||
assert.deepEqual(result.state.mode, { type: "following" })
|
||||
assert.deepEqual(result.effect, { type: "none" })
|
||||
assert.equal(isAtBottom(metrics(bottomOffset - BOTTOM_FOLLOW_EPSILON_PX - 0.5)), false)
|
||||
assert.equal(isAtBottom(metrics(bottomOffset - 0.5)), true)
|
||||
})
|
||||
|
||||
it("distinguishes close-to-bottom from at-bottom metrics", () => {
|
||||
const closeButNotAtBottom = metrics(2351)
|
||||
|
||||
assert.equal(isAtBottom(closeButNotAtBottom), false)
|
||||
it("does not restore follow from an off-bottom snapshot", () => {
|
||||
assert.equal(isSnapshotAutoFollowing({ atBottom: false, followModeType: "following" }), false)
|
||||
assert.deepEqual(restoreFollowModeFromSnapshot({ atBottom: false, followModeType: "following" }), { type: "escaped" })
|
||||
})
|
||||
|
||||
it("excludes reasoning-only hold targets while preserving Assistant text eligibility", () => {
|
||||
it("keeps hold element resolution as a DOM concern", () => {
|
||||
const itemWrapper = { id: "message-wrapper" } as unknown as HTMLElement
|
||||
const assistantAnswerText = { id: "assistant-answer-text" } as unknown as HTMLElement
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +1,19 @@
|
|||
export type FollowMode =
|
||||
| { type: "following" }
|
||||
| { type: "escaped" }
|
||||
| { type: "holding"; key: string }
|
||||
export type FollowMode = { type: "following" } | { type: "escaped" }
|
||||
|
||||
export const BOTTOM_FOLLOW_EPSILON_PX = 48
|
||||
|
||||
export type FollowEffect =
|
||||
| { type: "none" }
|
||||
| { type: "scroll-top"; immediate: boolean }
|
||||
| { type: "scroll-bottom"; immediate: boolean; suppressHold: boolean }
|
||||
| { type: "scroll-bottom"; immediate: boolean }
|
||||
| { type: "scroll-key"; key: string; block: ScrollLogicalPosition; smooth: boolean }
|
||||
| { type: "align-hold"; key: string }
|
||||
|
||||
export type FollowEvent =
|
||||
| { type: "user-scroll"; direction: "up" | "down" | null; atBottom: boolean; canPinToBottom: boolean }
|
||||
| { type: "user-scroll"; direction: "up" | "down" | null; atBottom: boolean }
|
||||
| { type: "jump-top"; immediate: boolean }
|
||||
| { type: "jump-bottom"; immediate: boolean; explicit: boolean }
|
||||
| { type: "jump-key"; key: string; block: ScrollLogicalPosition; smooth: boolean; followAfter: boolean }
|
||||
| { type: "jump-key"; key: string; block: ScrollLogicalPosition; smooth: boolean }
|
||||
| { type: "content-grew"; canPinToBottom: boolean }
|
||||
| { type: "hold-candidate"; key: string; shouldHold: boolean }
|
||||
| { type: "hold-target-changed"; key: string | null; canPinToBottom: boolean }
|
||||
| { type: "clear-hold"; follow: boolean; canPinToBottom: boolean; suppressHold: boolean }
|
||||
| { type: "set-follow"; enabled: boolean }
|
||||
| { type: "reset"; follow: boolean }
|
||||
|
||||
|
|
@ -61,8 +56,6 @@ export interface ScrollControllerSnapshot {
|
|||
|
||||
export interface FollowSnapshotState {
|
||||
followModeType?: FollowMode["type"]
|
||||
heldKey?: string
|
||||
holdAnchorSuspended?: boolean
|
||||
}
|
||||
|
||||
export type HoldTargetElementResolver = (itemWrapper: HTMLElement, key: string) => HTMLElement | null | undefined
|
||||
|
|
@ -73,10 +66,6 @@ export function isAutoFollowing(mode: FollowMode) {
|
|||
return mode.type === "following"
|
||||
}
|
||||
|
||||
export function getHeldKey(mode: FollowMode) {
|
||||
return mode.type === "holding" ? mode.key : null
|
||||
}
|
||||
|
||||
export function resolveAutoPinHoldElement(
|
||||
itemWrapper: HTMLElement | null | undefined,
|
||||
key: string,
|
||||
|
|
@ -89,79 +78,25 @@ export function resolveAutoPinHoldElement(
|
|||
return resolved === undefined ? itemWrapper : resolved
|
||||
}
|
||||
|
||||
export function shouldSuspendAutoPinToBottomForHold(state: {
|
||||
externalSuspend: boolean
|
||||
activeHoldTargetKey: string | null
|
||||
eligibleHoldTargetKey?: string | null
|
||||
}) {
|
||||
return state.externalSuspend || state.activeHoldTargetKey !== null
|
||||
}
|
||||
|
||||
export function shouldTrackHeldAnchor(state: {
|
||||
activeHoldTargetKey: string | null
|
||||
eligibleHoldTargetKey: string | null
|
||||
suspendedByUser: boolean
|
||||
}) {
|
||||
return !state.suspendedByUser && state.activeHoldTargetKey !== null && state.activeHoldTargetKey === state.eligibleHoldTargetKey
|
||||
}
|
||||
|
||||
export function isSnapshotAutoFollowing(snapshot: { atBottom: boolean; followModeType?: FollowMode["type"] } | null | undefined) {
|
||||
if (!snapshot) return true
|
||||
if (snapshot.followModeType) return snapshot.followModeType === "following"
|
||||
return snapshot.atBottom
|
||||
return snapshot.atBottom && snapshot.followModeType !== "escaped"
|
||||
}
|
||||
|
||||
export function getFollowSnapshotState(mode: FollowMode, holdAnchorSuspended: boolean): FollowSnapshotState {
|
||||
if (mode.type === "holding") {
|
||||
return {
|
||||
followModeType: "holding",
|
||||
heldKey: mode.key,
|
||||
holdAnchorSuspended: holdAnchorSuspended || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function getFollowSnapshotState(mode: FollowMode): FollowSnapshotState {
|
||||
return { followModeType: mode.type }
|
||||
}
|
||||
|
||||
export function restoreFollowModeFromSnapshot(state: {
|
||||
atBottom: boolean
|
||||
followModeType?: FollowMode["type"]
|
||||
heldKey?: string
|
||||
hasHeldKeyItem?: boolean
|
||||
}): FollowMode {
|
||||
if (state.followModeType === "holding" && state.heldKey && state.hasHeldKeyItem) {
|
||||
return { type: "holding", key: state.heldKey }
|
||||
}
|
||||
if (state.followModeType === "following") {
|
||||
return { type: "following" }
|
||||
}
|
||||
if (state.followModeType === "escaped") {
|
||||
return { type: "escaped" }
|
||||
}
|
||||
export function restoreFollowModeFromSnapshot(state: { atBottom: boolean; followModeType?: FollowMode["type"] }): FollowMode {
|
||||
if (state.followModeType === "escaped") return { type: "escaped" }
|
||||
return state.atBottom ? { type: "following" } : { type: "escaped" }
|
||||
}
|
||||
|
||||
export function transitionFollowMode(mode: FollowMode, event: FollowEvent): FollowTransition {
|
||||
switch (event.type) {
|
||||
case "user-scroll": {
|
||||
if (mode.type === "holding") {
|
||||
if (event.atBottom && event.direction !== "up") {
|
||||
return { mode: { type: "following" }, effect: noFollowEffect }
|
||||
}
|
||||
return { mode, effect: noFollowEffect }
|
||||
}
|
||||
if (event.direction === "up") {
|
||||
return { mode: { type: "escaped" }, effect: noFollowEffect }
|
||||
}
|
||||
if (mode.type === "escaped" && event.direction === "down" && event.atBottom && event.canPinToBottom) {
|
||||
return {
|
||||
mode: { type: "following" },
|
||||
effect: { type: "scroll-bottom", immediate: true, suppressHold: false },
|
||||
}
|
||||
}
|
||||
if (event.atBottom) {
|
||||
return { mode: { type: "following" }, effect: noFollowEffect }
|
||||
}
|
||||
if (event.direction === "up") return { mode: { type: "escaped" }, effect: noFollowEffect }
|
||||
if (event.atBottom) return { mode: { type: "following" }, effect: noFollowEffect }
|
||||
return { mode, effect: noFollowEffect }
|
||||
}
|
||||
|
||||
|
|
@ -171,42 +106,21 @@ export function transitionFollowMode(mode: FollowMode, event: FollowEvent): Foll
|
|||
case "jump-bottom":
|
||||
return {
|
||||
mode: { type: "following" },
|
||||
effect: { type: "scroll-bottom", immediate: event.immediate, suppressHold: event.explicit },
|
||||
effect: { type: "scroll-bottom", immediate: event.immediate },
|
||||
}
|
||||
|
||||
case "jump-key":
|
||||
return {
|
||||
mode: event.followAfter ? { type: "following" } : { type: "escaped" },
|
||||
mode: { type: "escaped" },
|
||||
effect: { type: "scroll-key", key: event.key, block: event.block, smooth: event.smooth },
|
||||
}
|
||||
|
||||
case "content-grew":
|
||||
if (mode.type === "following" && event.canPinToBottom) {
|
||||
return { mode, effect: { type: "scroll-bottom", immediate: true, suppressHold: false } }
|
||||
return { mode, effect: { type: "scroll-bottom", immediate: true } }
|
||||
}
|
||||
return { mode, effect: noFollowEffect }
|
||||
|
||||
case "hold-candidate":
|
||||
if (mode.type === "following" && event.shouldHold) {
|
||||
return { mode: { type: "holding", key: event.key }, effect: { type: "align-hold", key: event.key } }
|
||||
}
|
||||
return { mode, effect: noFollowEffect }
|
||||
|
||||
case "hold-target-changed":
|
||||
return { mode, effect: noFollowEffect }
|
||||
|
||||
case "clear-hold":
|
||||
if (mode.type !== "holding") {
|
||||
return { mode, effect: noFollowEffect }
|
||||
}
|
||||
return {
|
||||
mode: event.follow ? { type: "following" } : { type: "escaped" },
|
||||
effect:
|
||||
event.follow && event.canPinToBottom
|
||||
? { type: "scroll-bottom", immediate: true, suppressHold: event.suppressHold }
|
||||
: noFollowEffect,
|
||||
}
|
||||
|
||||
case "set-follow":
|
||||
return { mode: event.enabled ? { type: "following" } : { type: "escaped" }, effect: noFollowEffect }
|
||||
|
||||
|
|
@ -245,10 +159,6 @@ export class VirtualScrollController {
|
|||
return isAutoFollowing(this.state.mode)
|
||||
}
|
||||
|
||||
heldKey() {
|
||||
return this.state.mode.type === "holding" ? this.state.mode.key : null
|
||||
}
|
||||
|
||||
setUserIntent(direction: ScrollDirection, until: number) {
|
||||
this.state.userIntentDirection = direction
|
||||
this.state.userIntentUntil = until
|
||||
|
|
@ -293,42 +203,22 @@ export class VirtualScrollController {
|
|||
}
|
||||
|
||||
jumpBottom(immediate: boolean, explicit: boolean): ScrollControllerResult {
|
||||
if (explicit) {
|
||||
this.state.userIntentDirection = null
|
||||
this.state.userIntentUntil = 0
|
||||
}
|
||||
const next = transitionFollowMode(this.state.mode, { type: "jump-bottom", immediate, explicit })
|
||||
this.state.mode = next.mode
|
||||
return this.result(next.effect)
|
||||
}
|
||||
|
||||
jumpKey(key: string, block: ScrollLogicalPosition, smooth: boolean, followAfter: boolean): ScrollControllerResult {
|
||||
const next = transitionFollowMode(this.state.mode, { type: "jump-key", key, block, smooth, followAfter })
|
||||
jumpKey(key: string, block: ScrollLogicalPosition, smooth: boolean): ScrollControllerResult {
|
||||
const next = transitionFollowMode(this.state.mode, { type: "jump-key", key, block, smooth })
|
||||
this.state.mode = next.mode
|
||||
return this.result(next.effect)
|
||||
}
|
||||
|
||||
holdCandidate(key: string, shouldHold: boolean): ScrollControllerResult {
|
||||
const next = transitionFollowMode(this.state.mode, { type: "hold-candidate", key, shouldHold })
|
||||
this.state.mode = next.mode
|
||||
return this.result(next.effect)
|
||||
}
|
||||
|
||||
holdTargetChanged(key: string | null, canPinToBottom: boolean): ScrollControllerResult {
|
||||
const next = transitionFollowMode(this.state.mode, { type: "hold-target-changed", key, canPinToBottom })
|
||||
this.state.mode = next.mode
|
||||
return this.result(next.effect)
|
||||
}
|
||||
|
||||
clearHold(follow: boolean, canPinToBottom: boolean, suppressHold: boolean): ScrollControllerResult {
|
||||
const next = transitionFollowMode(this.state.mode, { type: "clear-hold", follow, canPinToBottom, suppressHold })
|
||||
this.state.mode = next.mode
|
||||
return this.result(next.effect)
|
||||
}
|
||||
|
||||
observeViewport(
|
||||
metrics: ScrollControllerMetrics,
|
||||
now: number,
|
||||
programmatic: boolean,
|
||||
canPinToBottom = false,
|
||||
forceEscapeFromUpScroll = false,
|
||||
): ScrollControllerResult {
|
||||
observeViewport(metrics: ScrollControllerMetrics, now: number, programmatic: boolean): ScrollControllerResult {
|
||||
const previousOffset = this.state.lastObservedOffset
|
||||
const offset = metrics.offset
|
||||
const scrolledUp = offset < previousOffset - 1
|
||||
|
|
@ -340,66 +230,39 @@ export class VirtualScrollController {
|
|||
this.clearExpiredUserIntent(now)
|
||||
|
||||
const hasFreshIntent = now <= this.state.userIntentUntil
|
||||
if (
|
||||
scrolledUp &&
|
||||
this.isAutoFollowing() &&
|
||||
(forceEscapeFromUpScroll || !atBottom) &&
|
||||
this.heldKey() === null &&
|
||||
(!programmatic || hasFreshIntent)
|
||||
) {
|
||||
const actualDirection: ScrollDirection = scrolledUp ? "up" : scrolledDown ? "down" : null
|
||||
if (hasFreshIntent && this.state.userIntentDirection === "up") {
|
||||
return this.setFollow(false)
|
||||
}
|
||||
|
||||
const actualDirection: ScrollDirection = scrolledUp ? "up" : scrolledDown ? "down" : null
|
||||
const direction = actualDirection ?? this.state.userIntentDirection
|
||||
|
||||
if (!hasFreshIntent && (!actualDirection || programmatic)) {
|
||||
return this.result(noFollowEffect)
|
||||
}
|
||||
|
||||
const direction = actualDirection ?? this.state.userIntentDirection
|
||||
const canMagnetToBottom = hasFreshIntent && direction === "down" && canPinToBottom
|
||||
const next = transitionFollowMode(this.state.mode, {
|
||||
type: "user-scroll",
|
||||
direction,
|
||||
atBottom,
|
||||
canPinToBottom: canMagnetToBottom,
|
||||
})
|
||||
if (direction === "up" && (!programmatic || hasFreshIntent)) {
|
||||
return this.setFollow(false)
|
||||
}
|
||||
|
||||
const next = transitionFollowMode(this.state.mode, { type: "user-scroll", direction, atBottom })
|
||||
this.state.mode = next.mode
|
||||
this.state.lastObservedAtBottom = this.isAutoFollowing() && atBottom
|
||||
return this.result(next.effect)
|
||||
}
|
||||
|
||||
contentRendered(metrics: ScrollControllerMetrics, canPinToBottom: boolean): ScrollControllerResult {
|
||||
contentRendered(_metrics: ScrollControllerMetrics, canPinToBottom: boolean): ScrollControllerResult {
|
||||
if (this.state.restoring) return this.result(noFollowEffect)
|
||||
if (!canPinToBottom || !this.isAutoFollowing()) {
|
||||
const reconcile = this.reconcileUpwardDomMovement(metrics)
|
||||
if (reconcile.effect.type !== "none") return reconcile
|
||||
}
|
||||
|
||||
const next = transitionFollowMode(this.state.mode, { type: "content-grew", canPinToBottom })
|
||||
this.state.mode = next.mode
|
||||
return this.result(next.effect)
|
||||
}
|
||||
|
||||
beforeBottomPin(metrics: ScrollControllerMetrics): ScrollControllerResult {
|
||||
if (this.state.restoring) return this.result(noFollowEffect)
|
||||
return this.reconcileUpwardDomMovement(metrics)
|
||||
}
|
||||
|
||||
recordProgrammaticOffset(offset: number, atBottom: boolean) {
|
||||
this.state.lastObservedOffset = offset
|
||||
this.state.lastObservedAtBottom = this.isAutoFollowing() && atBottom
|
||||
}
|
||||
|
||||
private reconcileUpwardDomMovement(metrics: ScrollControllerMetrics): ScrollControllerResult {
|
||||
if (!this.isAutoFollowing()) return this.result(noFollowEffect)
|
||||
if (this.heldKey() !== null) return this.result(noFollowEffect)
|
||||
if (isAtBottom(metrics)) return this.result(noFollowEffect)
|
||||
if (metrics.offset >= this.state.lastObservedOffset - 1) return this.result(noFollowEffect)
|
||||
this.state.lastObservedOffset = metrics.offset
|
||||
this.state.lastObservedAtBottom = false
|
||||
return this.setFollow(false)
|
||||
}
|
||||
|
||||
private result(effect: FollowEffect): ScrollControllerResult {
|
||||
return { effect, state: this.snapshot() }
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,5 @@
|
|||
import { createEffect, createSignal, onCleanup, type Accessor, type JSXElement } from "solid-js"
|
||||
import { BOTTOM_FOLLOW_EPSILON_PX, VirtualScrollController, isAutoFollowing, type ScrollControllerMetrics } from "../components/virtual-follow-behavior"
|
||||
|
||||
const DEFAULT_SCROLL_INTENT_WINDOW_MS = 600
|
||||
const DEFAULT_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
||||
|
|
@ -6,7 +7,6 @@ const DEFAULT_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "P
|
|||
interface FollowScrollOptions {
|
||||
getScrollTopSnapshot: Accessor<number>
|
||||
setScrollTopSnapshot: (next: number) => void
|
||||
sentinelMarginPx: number
|
||||
sentinelClassName: string
|
||||
intentWindowMs?: number
|
||||
intentKeys?: ReadonlySet<string>
|
||||
|
|
@ -24,16 +24,14 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
|
|||
const [scrollContainer, setScrollContainer] = createSignal<HTMLDivElement | undefined>()
|
||||
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
|
||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
|
||||
const scrollController = new VirtualScrollController(true)
|
||||
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
let detachScrollIntentListeners: (() => void) | undefined
|
||||
|
||||
let pendingScrollFrame: number | null = null
|
||||
let pendingAnchorScroll: number | null = null
|
||||
let userScrollIntentUntil = 0
|
||||
let lastKnownScrollTop = options.getScrollTopSnapshot()
|
||||
let pointerInteractionActive = false
|
||||
let suppressNextScrollHandling = false
|
||||
|
||||
function restoreScrollPosition(forceBottom = false) {
|
||||
|
|
@ -44,8 +42,10 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
|
|||
container.scrollTop = container.scrollHeight
|
||||
lastKnownScrollTop = container.scrollTop
|
||||
options.setScrollTopSnapshot(lastKnownScrollTop)
|
||||
scrollController.recordProgrammaticOffset(lastKnownScrollTop, true)
|
||||
} else {
|
||||
container.scrollTop = lastKnownScrollTop
|
||||
scrollController.recordProgrammaticOffset(lastKnownScrollTop, isAtBottom(container))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -55,17 +55,9 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
|
|||
options.setScrollTopSnapshot(lastKnownScrollTop)
|
||||
}
|
||||
|
||||
function markUserScrollIntent() {
|
||||
function markUserScrollIntent(direction: "up" | "down" | null) {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
userScrollIntentUntil = now + (options.intentWindowMs ?? DEFAULT_SCROLL_INTENT_WINDOW_MS)
|
||||
}
|
||||
|
||||
function hasUserScrollIntent() {
|
||||
if (pointerInteractionActive) {
|
||||
return true
|
||||
}
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
return now <= userScrollIntentUntil
|
||||
scrollController.setUserIntent(direction, now + (options.intentWindowMs ?? DEFAULT_SCROLL_INTENT_WINDOW_MS))
|
||||
}
|
||||
|
||||
function attachScrollIntentListeners(element: HTMLDivElement) {
|
||||
|
|
@ -74,42 +66,27 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
|
|||
detachScrollIntentListeners = undefined
|
||||
}
|
||||
const intentKeys = options.intentKeys ?? DEFAULT_SCROLL_INTENT_KEYS
|
||||
const handlePointerIntent = () => {
|
||||
pointerInteractionActive = true
|
||||
markUserScrollIntent()
|
||||
}
|
||||
const clearPointerIntent = () => {
|
||||
pointerInteractionActive = false
|
||||
const handlePointerIntent = (event: WheelEvent | PointerEvent | TouchEvent) => {
|
||||
markUserScrollIntent(event instanceof WheelEvent ? (event.deltaY < 0 ? "up" : event.deltaY > 0 ? "down" : null) : null)
|
||||
}
|
||||
const handleKeyIntent = (event: KeyboardEvent) => {
|
||||
if (intentKeys.has(event.key)) {
|
||||
markUserScrollIntent()
|
||||
const direction =
|
||||
event.key === "ArrowUp" || event.key === "PageUp" || event.key === "Home" || (event.shiftKey && (event.key === " " || event.key === "Spacebar"))
|
||||
? "up"
|
||||
: "down"
|
||||
markUserScrollIntent(direction)
|
||||
}
|
||||
}
|
||||
element.addEventListener("wheel", handlePointerIntent, { passive: true })
|
||||
element.addEventListener("pointerdown", handlePointerIntent)
|
||||
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
|
||||
element.addEventListener("keydown", handleKeyIntent)
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("pointerup", clearPointerIntent)
|
||||
window.addEventListener("pointercancel", clearPointerIntent)
|
||||
window.addEventListener("mouseup", clearPointerIntent)
|
||||
window.addEventListener("touchend", clearPointerIntent)
|
||||
window.addEventListener("touchcancel", clearPointerIntent)
|
||||
}
|
||||
detachScrollIntentListeners = () => {
|
||||
element.removeEventListener("wheel", handlePointerIntent)
|
||||
element.removeEventListener("pointerdown", handlePointerIntent)
|
||||
element.removeEventListener("touchstart", handlePointerIntent)
|
||||
element.removeEventListener("keydown", handleKeyIntent)
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener("pointerup", clearPointerIntent)
|
||||
window.removeEventListener("pointercancel", clearPointerIntent)
|
||||
window.removeEventListener("mouseup", clearPointerIntent)
|
||||
window.removeEventListener("touchend", clearPointerIntent)
|
||||
window.removeEventListener("touchcancel", clearPointerIntent)
|
||||
}
|
||||
pointerInteractionActive = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -126,18 +103,28 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
|
|||
pendingAnchorScroll = null
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const sentinelRect = sentinel.getBoundingClientRect()
|
||||
const delta = sentinelRect.bottom - containerRect.bottom + options.sentinelMarginPx
|
||||
const delta = sentinelRect.bottom - containerRect.bottom
|
||||
if (Math.abs(delta) > 1) {
|
||||
suppressNextScrollHandling = true
|
||||
container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" })
|
||||
}
|
||||
lastKnownScrollTop = container.scrollTop
|
||||
options.setScrollTopSnapshot(lastKnownScrollTop)
|
||||
scrollController.recordProgrammaticOffset(lastKnownScrollTop, isAtBottom(container))
|
||||
})
|
||||
}
|
||||
|
||||
function getMetrics(container: HTMLDivElement): ScrollControllerMetrics {
|
||||
return {
|
||||
offset: container.scrollTop,
|
||||
scrollHeight: container.scrollHeight,
|
||||
clientHeight: container.clientHeight,
|
||||
sentinelMarginPx: BOTTOM_FOLLOW_EPSILON_PX,
|
||||
}
|
||||
}
|
||||
|
||||
function isAtBottom(container: HTMLDivElement) {
|
||||
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= options.sentinelMarginPx
|
||||
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_EPSILON_PX
|
||||
}
|
||||
|
||||
function updateFollowModeFromScroll(containerOverride?: HTMLDivElement) {
|
||||
|
|
@ -147,17 +134,8 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
|
|||
suppressNextScrollHandling = false
|
||||
return
|
||||
}
|
||||
const isUserScroll = hasUserScrollIntent()
|
||||
const atBottomFromScroll = isAtBottom(container)
|
||||
const atBottom = atBottomFromScroll || bottomSentinelVisible()
|
||||
|
||||
if (isUserScroll || !atBottom) {
|
||||
if (atBottom) {
|
||||
if (!autoScroll()) setAutoScroll(true)
|
||||
} else if (autoScroll()) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
}
|
||||
const result = scrollController.observeViewport(getMetrics(container), typeof performance !== "undefined" ? performance.now() : Date.now(), false)
|
||||
setAutoScroll(isAutoFollowing(result.state.mode))
|
||||
}
|
||||
|
||||
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
|
|
@ -166,7 +144,7 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
|
|||
}
|
||||
|
||||
const registerContainer = (element: HTMLDivElement | null | undefined, config?: { disableTracking?: boolean }) => {
|
||||
const next = element || undefined
|
||||
const next = config?.disableTracking ? undefined : element || undefined
|
||||
if (next === scrollContainerRef) {
|
||||
return
|
||||
}
|
||||
|
|
@ -185,10 +163,13 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
|
|||
|
||||
const restoreAfterRender = () => {
|
||||
const container = scrollContainerRef
|
||||
if (container && hasUserScrollIntent() && !isAtBottom(container)) {
|
||||
if (autoScroll()) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
if (!container) return
|
||||
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||
const result = scrollController.observeViewport(getMetrics(container), now, false)
|
||||
setAutoScroll(isAutoFollowing(result.state.mode))
|
||||
const hasFreshUpwardEscape = now <= result.state.userIntentUntil && result.state.userIntentDirection === "up" && result.state.mode.type === "escaped"
|
||||
if (hasFreshUpwardEscape) {
|
||||
requestAnimationFrame(() => {
|
||||
restoreScrollPosition(false)
|
||||
})
|
||||
|
|
@ -219,24 +200,6 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
|
|||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const container = scrollContainer()
|
||||
const sentinel = bottomSentinel()
|
||||
if (!container || !sentinel) return
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.target === sentinel) {
|
||||
setBottomSentinelVisible(entry.isIntersecting)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ root: container, threshold: 0, rootMargin: `0px 0px ${options.sentinelMarginPx}px 0px` },
|
||||
)
|
||||
observer.observe(sentinel)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (pendingScrollFrame !== null) {
|
||||
cancelAnimationFrame(pendingScrollFrame)
|
||||
|
|
|
|||
|
|
@ -40,4 +40,5 @@ describe("message-v2 permission state", () => {
|
|||
assert.equal(store.state.permissions.active?.permission.id, "permission-2")
|
||||
assert.equal(store.getPermissionState(undefined, "permission-2")?.active, true)
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
import type { ClientPart, MessageInfo } from "../../types/message"
|
||||
import { mergePermissionRequest } from "../../types/permission"
|
||||
import { clearRecordDisplayCacheForMessages } from "./record-display-cache"
|
||||
import { mergePendingRequestEntry, shouldSkipPendingRequestUpsert } from "./pending-request-dedupe"
|
||||
import type {
|
||||
InstanceMessageState,
|
||||
LatestTodoSnapshot,
|
||||
|
|
@ -958,6 +959,23 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||
const entry = mergePermissionEntry(input)
|
||||
const messageKey = entry.messageId ?? "__global__"
|
||||
const partKey = entry.partId ?? entry.permission?.id ?? "__global__"
|
||||
const existing = state.permissions.queue.find((item) => item.permission.id === entry.permission.id)
|
||||
const existingAtLocation = state.permissions.byMessage[messageKey]?.[partKey]
|
||||
const expectedActiveId = state.permissions.queue[0]?.permission.id
|
||||
if (shouldSkipPendingRequestUpsert({
|
||||
existing,
|
||||
existingAtLocationId: existingAtLocation?.permission.id,
|
||||
expectedActiveId,
|
||||
activeId: state.permissions.active?.permission.id,
|
||||
incomingId: entry.permission.id,
|
||||
incomingMessageId: entry.messageId,
|
||||
incomingPartId: entry.partId,
|
||||
incomingEnqueuedAt: entry.enqueuedAt,
|
||||
existingValue: existing?.permission,
|
||||
incomingValue: entry.permission,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
setState(
|
||||
"permissions",
|
||||
|
|
@ -1019,13 +1037,47 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||
return { entry, active }
|
||||
}
|
||||
|
||||
function upsertQuestion(entry: QuestionEntry) {
|
||||
function mergeQuestionEntry(entry: QuestionEntry): QuestionEntry {
|
||||
const existing = state.questions.queue.find((item) => item.request.id === entry.request.id)
|
||||
return mergePendingRequestEntry(entry, existing)
|
||||
}
|
||||
|
||||
function upsertQuestion(input: QuestionEntry) {
|
||||
const entry = mergeQuestionEntry(input)
|
||||
const messageKey = entry.messageId ?? "__global__"
|
||||
const partKey = entry.partId ?? entry.request?.id ?? "__global__"
|
||||
const existing = state.questions.queue.find((item) => item.request.id === entry.request.id)
|
||||
const existingAtLocation = state.questions.byMessage[messageKey]?.[partKey]
|
||||
const expectedActiveId = state.questions.queue[0]?.request.id
|
||||
if (shouldSkipPendingRequestUpsert({
|
||||
existing,
|
||||
existingAtLocationId: existingAtLocation?.request.id,
|
||||
expectedActiveId,
|
||||
activeId: state.questions.active?.request.id,
|
||||
incomingId: entry.request.id,
|
||||
incomingMessageId: entry.messageId,
|
||||
incomingPartId: entry.partId,
|
||||
incomingEnqueuedAt: entry.enqueuedAt,
|
||||
existingValue: existing?.request,
|
||||
incomingValue: entry.request,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
setState(
|
||||
"questions",
|
||||
produce((draft) => {
|
||||
Object.keys(draft.byMessage).forEach((existingMessageKey) => {
|
||||
const partEntries = draft.byMessage[existingMessageKey]
|
||||
Object.keys(partEntries).forEach((existingPartKey) => {
|
||||
if (partEntries[existingPartKey].request.id === entry.request.id) {
|
||||
delete partEntries[existingPartKey]
|
||||
}
|
||||
})
|
||||
if (Object.keys(partEntries).length === 0) {
|
||||
delete draft.byMessage[existingMessageKey]
|
||||
}
|
||||
})
|
||||
draft.byMessage[messageKey] = draft.byMessage[messageKey] ?? {}
|
||||
draft.byMessage[messageKey][partKey] = entry
|
||||
const existingIndex = draft.queue.findIndex((item) => item.request.id === entry.request.id)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import { mergePendingRequestEntry, shouldSkipPendingRequestUpsert } from "./pending-request-dedupe.ts"
|
||||
|
||||
describe("pending request dedupe", () => {
|
||||
it("skips unchanged polling updates at the same attachment location", () => {
|
||||
const existing = {
|
||||
messageId: "message-1",
|
||||
partId: "part-1",
|
||||
enqueuedAt: 1_000,
|
||||
}
|
||||
const request = { id: "question-1", sessionID: "session-1", questions: [{ header: "Confirm", question: "Continue?", options: [] }] }
|
||||
|
||||
assert.equal(shouldSkipPendingRequestUpsert({
|
||||
existing,
|
||||
existingAtLocationId: "question-1",
|
||||
expectedActiveId: "question-1",
|
||||
activeId: "question-1",
|
||||
incomingId: "question-1",
|
||||
incomingMessageId: "message-1",
|
||||
incomingPartId: "part-1",
|
||||
incomingEnqueuedAt: 1_000,
|
||||
existingValue: request,
|
||||
incomingValue: { ...request, questions: [...request.questions] },
|
||||
}), true)
|
||||
})
|
||||
|
||||
it("does not skip when polling resolves a request from global state to a tool part", () => {
|
||||
const existing = {
|
||||
enqueuedAt: 1_000,
|
||||
}
|
||||
const request = { id: "question-1", sessionID: "session-1", questions: [{ header: "Confirm", question: "Continue?", options: [] }] }
|
||||
|
||||
assert.equal(shouldSkipPendingRequestUpsert({
|
||||
existing,
|
||||
existingAtLocationId: undefined,
|
||||
expectedActiveId: "question-1",
|
||||
activeId: "question-1",
|
||||
incomingId: "question-1",
|
||||
incomingMessageId: "message-1",
|
||||
incomingPartId: "part-1",
|
||||
incomingEnqueuedAt: 1_000,
|
||||
existingValue: request,
|
||||
incomingValue: request,
|
||||
}), false)
|
||||
})
|
||||
|
||||
it("keeps the earliest queue time while preserving resolved attachment ids", () => {
|
||||
const merged = mergePendingRequestEntry(
|
||||
{ messageId: "message-1", partId: "part-1", enqueuedAt: 3_000 },
|
||||
{ enqueuedAt: 1_000 },
|
||||
)
|
||||
|
||||
assert.deepEqual(merged, { messageId: "message-1", partId: "part-1", enqueuedAt: 1_000 })
|
||||
})
|
||||
})
|
||||
50
packages/ui/src/stores/message-v2/pending-request-dedupe.ts
Normal file
50
packages/ui/src/stores/message-v2/pending-request-dedupe.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
export interface PendingRequestEntryLike {
|
||||
messageId?: string
|
||||
partId?: string
|
||||
enqueuedAt: number
|
||||
}
|
||||
|
||||
export interface PendingRequestSkipInput {
|
||||
existing: PendingRequestEntryLike | undefined
|
||||
existingAtLocationId: string | undefined
|
||||
expectedActiveId: string | undefined
|
||||
activeId: string | undefined
|
||||
incomingId: string
|
||||
incomingMessageId: string | undefined
|
||||
incomingPartId: string | undefined
|
||||
incomingEnqueuedAt: number
|
||||
existingValue: unknown
|
||||
incomingValue: unknown
|
||||
}
|
||||
|
||||
export function areStructuredValuesEqual(left: unknown, right: unknown): boolean {
|
||||
if (left === right) return true
|
||||
try {
|
||||
return JSON.stringify(left) === JSON.stringify(right)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function mergePendingRequestEntry<T extends PendingRequestEntryLike>(entry: T, existing: T | undefined): T {
|
||||
if (!existing) return entry
|
||||
return {
|
||||
...entry,
|
||||
messageId: entry.messageId ?? existing.messageId,
|
||||
partId: entry.partId ?? existing.partId,
|
||||
enqueuedAt: Math.min(existing.enqueuedAt, entry.enqueuedAt),
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldSkipPendingRequestUpsert(input: PendingRequestSkipInput): boolean {
|
||||
const existing = input.existing
|
||||
return Boolean(
|
||||
existing &&
|
||||
input.existingAtLocationId === input.incomingId &&
|
||||
input.activeId === input.expectedActiveId &&
|
||||
existing.messageId === input.incomingMessageId &&
|
||||
existing.partId === input.incomingPartId &&
|
||||
existing.enqueuedAt === input.incomingEnqueuedAt &&
|
||||
areStructuredValuesEqual(input.existingValue, input.incomingValue),
|
||||
)
|
||||
}
|
||||
|
|
@ -82,9 +82,7 @@ export interface ScrollSnapshot {
|
|||
anchorKey?: string
|
||||
anchorOffset?: number
|
||||
atBottom: boolean
|
||||
followModeType?: "following" | "escaped" | "holding"
|
||||
heldKey?: string
|
||||
holdAnchorSuspended?: boolean
|
||||
followModeType?: "following" | "escaped"
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue