Skip to content

Commit bce975d

Browse files
authored
fix(ui): stabilize strict follow autoscroll (NeuralNomadsAI#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 NeuralNomadsAI#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
1 parent 275770b commit bce975d

17 files changed

Lines changed: 851 additions & 1513 deletions

packages/ui/src/components/message-block.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ function DeleteUpToIcon() {
3333
const USER_BORDER_COLOR = "var(--message-user-border)"
3434
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
3535
const NO_STEP_BORDER = "none"
36-
const REASONING_SCROLL_SENTINEL_MARGIN_PX = 48
3736

3837
const LazyToolCall = lazy(() => import("./tool-call"))
3938

@@ -1419,7 +1418,6 @@ function ReasoningStreamOutput(props: {
14191418
const followScroll = createFollowScroll({
14201419
getScrollTopSnapshot: props.scrollTopSnapshot,
14211420
setScrollTopSnapshot: props.setScrollTopSnapshot,
1422-
sentinelMarginPx: REASONING_SCROLL_SENTINEL_MARGIN_PX,
14231421
sentinelClassName: "reasoning-scroll-sentinel",
14241422
})
14251423

packages/ui/src/components/message-section.tsx

Lines changed: 11 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import BrandedEmptyState from "./branded-empty-state"
55
import MessageBlock from "./message-block"
66
import { getMessageAnchorId } from "./message-anchors"
77
import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline"
8-
import VirtualFollowList, { type VirtualFollowBottomIntent, type VirtualFollowListApi, type VirtualFollowListState, type VirtualFollowScrollSnapshot } from "./virtual-follow-list"
8+
import VirtualFollowList, { type VirtualExplicitBottomPinIntent, type VirtualFollowListApi, type VirtualFollowListState, type VirtualFollowScrollSnapshot } from "./virtual-follow-list"
99
import { isSnapshotAutoFollowing } from "./virtual-follow-behavior"
1010
import { useConfig } from "../stores/preferences"
1111
import { getSessionInfo } from "../stores/sessions"
@@ -24,7 +24,6 @@ import { buildSessionSearchMatches } from "../lib/session-search"
2424
import type { SessionSearchMatch } from "../lib/session-search"
2525
import { resolveThinkingExpansionDefault } from "./tool-call/tool-registry"
2626

27-
const SCROLL_SENTINEL_MARGIN_PX = 8
2827
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
2928
const QUOTE_SELECTION_MAX_LENGTH = 2000
3029
const STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX = 8
@@ -49,7 +48,8 @@ export interface MessageSectionProps {
4948
onReloadMessages?: () => void
5049
isActive?: boolean
5150
sessionStreamingActive?: boolean
52-
bottomFollowIntent?: VirtualFollowBottomIntent | null
51+
explicitBottomPinIntent?: VirtualExplicitBottomPinIntent | null
52+
onExplicitBottomPinCancelled?: () => void
5353
}
5454

5555
export default function MessageSection(props: MessageSectionProps) {
@@ -735,16 +735,6 @@ export default function MessageSection(props: MessageSectionProps) {
735735
return
736736
}
737737

738-
const element = streamElement()
739-
if (!allowCapture || !canCapture) return
740-
if (!element) return
741-
const scrollTop = element.scrollTop
742-
const maxScrollTop = Math.max(element.scrollHeight - element.clientHeight, 0)
743-
const scrollRatio = maxScrollTop > 0 ? scrollTop / maxScrollTop : 0
744-
const atBottom = element.scrollHeight - (element.scrollTop + element.clientHeight) <= 48
745-
const snapshot = { scrollTop, scrollRatio, maxScrollTop, atBottom }
746-
setLastGoodScrollSnapshot(sessionId, snapshot)
747-
store().setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, snapshot)
748738
}
749739

750740
// Persist scroll position when switching sessions. This effect's cleanup runs
@@ -783,7 +773,7 @@ export default function MessageSection(props: MessageSectionProps) {
783773
setScrollControlsOpen(false)
784774
}
785775

786-
function openScrollControlsFromTrigger(event: PointerEvent) {
776+
function openScrollControlsFromTrigger(event: MouseEvent) {
787777
event.preventDefault()
788778
event.stopPropagation()
789779
if (scrollControlsOpen()) return
@@ -795,7 +785,7 @@ export default function MessageSection(props: MessageSectionProps) {
795785
event.preventDefault()
796786
event.stopPropagation()
797787
action()
798-
setScrollControlsHoverSuppressed(event.pointerType !== "mouse")
788+
setScrollControlsHoverSuppressed(false)
799789
closeScrollControls()
800790
}
801791

@@ -836,7 +826,7 @@ export default function MessageSection(props: MessageSectionProps) {
836826
const api = listApi()
837827
if (!api) return
838828
if (props.registerScrollToBottom) {
839-
props.registerScrollToBottom(() => api.scrollToBottom({ immediate: true, suppressHold: true }))
829+
props.registerScrollToBottom(() => api.scrollToBottom({ immediate: true }))
840830
onCleanup(() => props.registerScrollToBottom?.(null))
841831
}
842832
})
@@ -1264,7 +1254,7 @@ export default function MessageSection(props: MessageSectionProps) {
12641254
if (!match || !isSearchOpen()) return
12651255
if (match.id === lastScrolledSearchMatchId) return
12661256
lastScrolledSearchMatchId = match.id
1267-
listApi()?.scrollToKey(match.messageId, { behavior: "smooth", block: "start", setAutoScroll: false })
1257+
listApi()?.scrollToKey(match.messageId, { behavior: "smooth", block: "start" })
12681258
})
12691259

12701260

@@ -1367,16 +1357,15 @@ export default function MessageSection(props: MessageSectionProps) {
13671357
getKey={(messageId) => messageId}
13681358
getAnchorId={getMessageAnchorId}
13691359
overscanPx={800}
1370-
scrollSentinelMarginPx={SCROLL_SENTINEL_MARGIN_PX}
1371-
suspendMeasurements={() => !isActive()}
13721360
streamingActive={streamingActive}
13731361
isActive={isActive}
13741362
scrollToBottomOnActivate={() => false}
13751363
initialScrollToBottom={() => false}
13761364
initialAutoScroll={initialAutoScroll}
13771365
resetKey={() => props.sessionId}
13781366
followToken={followToken}
1379-
forceBottomFollowIntent={() => props.bottomFollowIntent ?? null}
1367+
explicitBottomPinIntent={() => props.explicitBottomPinIntent ?? null}
1368+
onExplicitBottomPinCancelled={props.onExplicitBottomPinCancelled}
13801369
autoPinHoldEnabled={holdLongAssistantRepliesEnabled}
13811370
autoPinHoldTargetKey={autoPinHoldTargetKey}
13821371
autoPinHoldTopThresholdPx={STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX}
@@ -1429,7 +1418,7 @@ export default function MessageSection(props: MessageSectionProps) {
14291418
<button
14301419
type="button"
14311420
class="message-scroll-button message-scroll-controls-trigger"
1432-
onPointerUp={openScrollControlsFromTrigger}
1421+
onClick={openScrollControlsFromTrigger}
14331422
aria-label={t("messageSection.scroll.showControlsAriaLabel")}
14341423
title={t("messageSection.scroll.showControlsAriaLabel")}
14351424
>
@@ -1472,7 +1461,7 @@ export default function MessageSection(props: MessageSectionProps) {
14721461
<button
14731462
type="button"
14741463
class="message-scroll-button"
1475-
onPointerUp={(event) => runScrollControlAction(event, () => api.scrollToBottom({ suppressHold: true }))}
1464+
onPointerUp={(event) => runScrollControlAction(event, () => api.scrollToBottom())}
14761465
aria-label={t("messageSection.scroll.toLatestAriaLabel")}
14771466
>
14781467
<span class="message-scroll-icon" aria-hidden="true">

packages/ui/src/components/session/session-bottom-follow-intent.test.ts

Lines changed: 0 additions & 56 deletions
This file was deleted.

packages/ui/src/components/session/session-bottom-follow-intent.ts

Lines changed: 0 additions & 22 deletions
This file was deleted.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import assert from "node:assert/strict"
2+
import { describe, it } from "node:test"
3+
4+
import { getSubmitBottomPinTargetCount, resolveSessionBottomPinIntent, shouldClearSessionBottomPinIntent } from "./session-bottom-pin-intent.ts"
5+
6+
describe("session bottom pin intent", () => {
7+
it("targets only the queued user prompt when submitting during active streaming", () => {
8+
assert.equal(getSubmitBottomPinTargetCount(10, true), 11)
9+
assert.equal(getSubmitBottomPinTargetCount(10, false), 12)
10+
})
11+
12+
it("only exposes submit bottom pin intent to the matching session", () => {
13+
const intent = { sessionId: "session-a", token: 3, minItemCount: 12, createdMessageCount: 10, observedStreaming: false }
14+
15+
assert.deepEqual(resolveSessionBottomPinIntent(intent, "session-a"), {
16+
token: 3,
17+
minItemCount: 12,
18+
})
19+
assert.equal(resolveSessionBottomPinIntent(intent, "session-b"), null)
20+
})
21+
22+
it("clears submit bottom pin intent after the submitted exchange has rendered and stopped streaming", () => {
23+
const intent = { sessionId: "session-a", token: 3, minItemCount: 12, createdMessageCount: 10, observedStreaming: false }
24+
25+
assert.equal(
26+
shouldClearSessionBottomPinIntent(intent, {
27+
sessionId: "session-a",
28+
messageCount: 11,
29+
streamingActive: false,
30+
}),
31+
false,
32+
)
33+
34+
assert.equal(
35+
shouldClearSessionBottomPinIntent(intent, {
36+
sessionId: "session-a",
37+
messageCount: 12,
38+
streamingActive: true,
39+
}),
40+
false,
41+
)
42+
43+
assert.equal(
44+
shouldClearSessionBottomPinIntent(intent, {
45+
sessionId: "session-a",
46+
messageCount: 12,
47+
streamingActive: false,
48+
}),
49+
true,
50+
)
51+
52+
assert.equal(
53+
shouldClearSessionBottomPinIntent(intent, {
54+
sessionId: "session-b",
55+
messageCount: 12,
56+
streamingActive: false,
57+
}),
58+
false,
59+
)
60+
})
61+
62+
it("clears a submitted turn that stopped streaming before the optimistic count was reached", () => {
63+
const intent = { sessionId: "session-a", token: 3, minItemCount: 12, createdMessageCount: 10, observedStreaming: true }
64+
65+
assert.equal(
66+
shouldClearSessionBottomPinIntent(intent, {
67+
sessionId: "session-a",
68+
messageCount: 11,
69+
streamingActive: false,
70+
}),
71+
true,
72+
)
73+
})
74+
})
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { VirtualExplicitBottomPinIntent } from "../virtual-follow-list"
2+
3+
export interface SessionBottomPinIntent extends VirtualExplicitBottomPinIntent {
4+
sessionId: string
5+
createdMessageCount: number
6+
observedStreaming: boolean
7+
}
8+
9+
export function getSubmitBottomPinTargetCount(messageCount: number, streamingActive: boolean) {
10+
return messageCount + (streamingActive ? 1 : 2)
11+
}
12+
13+
export function resolveSessionBottomPinIntent(
14+
intent: SessionBottomPinIntent | null,
15+
sessionId: string,
16+
): VirtualExplicitBottomPinIntent | null {
17+
if (!intent || intent.sessionId !== sessionId) return null
18+
return { token: intent.token, minItemCount: intent.minItemCount }
19+
}
20+
21+
export function shouldClearSessionBottomPinIntent(
22+
intent: SessionBottomPinIntent | null,
23+
state: { sessionId: string; messageCount: number; streamingActive: boolean },
24+
) {
25+
if (!intent) return false
26+
if (intent.sessionId !== state.sessionId) return false
27+
if (state.streamingActive) return false
28+
if (state.messageCount >= (intent.minItemCount ?? 0)) return true
29+
return intent.observedStreaming && state.messageCount > intent.createdMessageCount
30+
}

0 commit comments

Comments
 (0)