fix(desktop): rebuild thread autoscroll on use-stick-to-bottom

This commit is contained in:
Brooklyn Nicholson 2026-06-13 01:14:07 -05:00
parent a856276124
commit 76b93869d8
17 changed files with 512 additions and 761 deletions

View file

@ -99,6 +99,7 @@
"unicode-animations": "^1.0.3",
"unified": "^11.0.5",
"unist-util-visit-parents": "^6.0.2",
"use-stick-to-bottom": "^1.1.6",
"vfile": "^6.0.3",
"web-haptics": "^0.0.6"
},

View file

@ -165,8 +165,13 @@ interface ChatRuntimeBoundaryProps {
onEdit: (message: AppendMessage) => Promise<void>
onReload: (parentId: string | null) => Promise<void>
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
/** Route points at an unloaded session render empty until resume swaps in
* the new transcript, so the previous session's messages don't linger. */
suppressMessages: boolean
}
const NO_MESSAGES: ChatMessage[] = []
/**
* Owns the $messages subscription and the assistant-ui external-store runtime.
*
@ -183,9 +188,11 @@ function ChatRuntimeBoundary({
onCancel,
onEdit,
onReload,
onThreadMessagesChange
onThreadMessagesChange,
suppressMessages
}: ChatRuntimeBoundaryProps) {
const messages = useStore($messages)
const storeMessages = useStore($messages)
const messages = suppressMessages ? NO_MESSAGES : storeMessages
const runtimeMessageCacheRef = useRef(new WeakMap<ChatMessage, ThreadMessage>())
const runtimeMessageRepository = useMemo(() => {
@ -286,7 +293,14 @@ export function ChatView({
const messagesEmpty = useStore($messagesEmpty)
const lastVisibleIsUser = useStore($lastVisibleMessageIsUser)
const selectedSessionId = useStore($selectedStoredSessionId)
const isRoutedSessionView = Boolean(routeSessionId(location.pathname))
const routedSessionId = routeSessionId(location.pathname)
const isRoutedSessionView = Boolean(routedSessionId)
// The URL points at a session the store hasn't loaded yet (sidebar / cmd-K /
// direct nav). Derived in render so the swap reads instantly: the same frame
// the id changes we drop the old transcript and show the loader, instead of
// waiting for the resume effect (which paints a frame later) to clear them.
const routeSessionMismatch = isRoutedSessionView && routedSessionId !== selectedSessionId
const showIntro = freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty
@ -295,7 +309,7 @@ export function ChatView({
// session exists — even if it has zero messages (a brand-new routed
// session). The flicker where `busy` flips true briefly during hydrate
// is handled by `threadLoadingState`'s last-visible-user gate.
const loadingSession = isRoutedSessionView && messagesEmpty && !activeSessionId
const loadingSession = isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId))
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleIsUser)
const showChatBar = !loadingSession
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
@ -401,6 +415,7 @@ export function ChatView({
onEdit={onEdit}
onReload={onReload}
onThreadMessagesChange={onThreadMessagesChange}
suppressMessages={routeSessionMismatch}
>
<Thread
clampToComposer={showChatBar}

View file

@ -118,6 +118,10 @@ const paletteFilter = (value: string, search: string, keywords?: string[]): numb
return needle.split(/\s+/).every(term => haystack.includes(term)) ? 1 : 0
}
// Hermes session ids: <YYYYMMDD>_<HHMMSS>_<6 hex>. Used to offer a direct
// "Go to session id" jump for ids that aren't in the recent-200 list.
const SESSION_ID_RE = /^\d{8}_\d{6}_[a-f0-9]{6}$/
type SessionRow = Awaited<ReturnType<typeof listAllProfileSessions>>['sessions'][number]
const toSessionEntry = (session: SessionRow): SessionEntry => ({
@ -413,6 +417,24 @@ export function CommandPalette() {
const result: PaletteGroup[] = []
// Paste a raw session id → jump straight to it, even if it predates the
// recent-200 window the lists below are built from.
const directId = search.trim()
if (SESSION_ID_RE.test(directId)) {
result.push({
items: [
{
icon: MessageCircle,
id: `goto-${directId}`,
keywords: ['session', 'id', 'go to', directId],
label: `${t.commandCenter.goToSession} ${directId}`,
run: go(sessionRoute(directId))
}
]
})
}
if (sessions.length > 0) {
result.push({
heading: t.commandCenter.sections.sessions,

View file

@ -2,7 +2,7 @@ import type { MutableRefObject } from 'react'
import { useCallback, useRef } from 'react'
import type { NavigateFunction } from 'react-router-dom'
import { deleteSession, getSessionMessages, listAllProfileSessions, setSessionArchived } from '@/hermes'
import { deleteSession, getSession, getSessionMessages, setSessionArchived } from '@/hermes'
import { useI18n } from '@/i18n'
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
import { normalizePersonalityValue } from '@/lib/chat-runtime'
@ -12,7 +12,7 @@ import { clearQueuedPrompts } from '@/store/composer-queue'
import { $pinnedSessionIds } from '@/store/layout'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import {
$currentCwd,
$messages,
@ -236,18 +236,42 @@ async function resolveStoredSession(storedSessionId: string): Promise<SessionInf
return cached
}
// Direct by-id on the live backend — one row lookup, no list scan. Covers
// single-profile users and any id on the active profile (e.g. an old session
// past the sidebar's recent window). 404 just means it's not on this profile.
try {
const result = await listAllProfileSessions(500, 0, 'include', 'recent', 'all')
const resolved = result.sessions.find(session => sessionMatchesStoredId(session, storedSessionId))
const session = await getSession(storedSessionId)
if (resolved) {
upsertResolvedSession(resolved, storedSessionId)
}
upsertResolvedSession(session, storedSessionId)
return resolved
return session
} catch {
return undefined
// Not on the active profile — fall through to the cross-profile probe.
}
// Multi-profile only: probe each other profile by id (still one cheap lookup
// each) rather than pulling every profile's recent sessions. The first hit
// carries its owning `profile`, which routes the resume to the right backend.
const activeKey = normalizeProfileKey($activeGatewayProfile.get())
const otherProfiles = $profiles
.get()
.map(profile => normalizeProfileKey(profile.name))
.filter(key => key !== activeKey)
for (const profile of otherProfiles) {
try {
const session = await getSession(storedSessionId, profile)
upsertResolvedSession(session, storedSessionId)
return session
} catch {
// Not on this profile; try the next.
}
}
return undefined
}
type SessionRuntimeStatePatch = Partial<
@ -523,8 +547,31 @@ export function useSessionActions({
const isCurrentResume = () =>
resumeRequestRef.current === requestId && selectedStoredSessionIdRef.current === storedSessionId
// Paint the click before the profile-resolve / gateway-swap awaits below,
// so there's zero dead air: highlight the row instantly (the sidebar reads
// $selectedStoredSessionId) and, for a cold target, drop the previous
// transcript so the thread shows its loader instead of the old session
// lingering until resume lands. A warm-cached target keeps its transcript —
// the cached fast-path repaints it this same tick. Setting the ref here is
// also what use-route-resume's self-heal assumes ("set synchronously at
// resume entry").
setFreshDraftReady(false)
clearNotifications()
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
const warmRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
if (!warmRuntimeId || !sessionStateByRuntimeIdRef.current.get(warmRuntimeId)) {
setActiveSessionId(null)
activeSessionIdRef.current = null
setMessages([])
}
// Swap the single live gateway to this session's profile before any
// gateway call (no-op when it's already on that profile / single-profile).
// resolveStoredSession finds the row by id (cheap), so an uncached pasted
// id loads as fast as a sidebar click instead of hanging on a list scan.
const storedForProfile = await resolveStoredSession(storedSessionId)
const sessionProfile = storedForProfile?.profile

View file

@ -58,9 +58,9 @@ Element.prototype.animate = function animate() {
} as unknown as Animation
}
// jsdom returns 0 for offset*; the virtualizer reads those to size its
// jsdom returns 0 for offset*; some layout code reads those to size the
// viewport. Fall through to client* (which tests can override) or a sane
// default so virtualized items render.
// default so message rows render with non-zero dimensions.
function stubOffsetDimension(
prop: 'offsetHeight' | 'offsetWidth',
clientProp: 'clientHeight' | 'clientWidth',
@ -254,20 +254,6 @@ function StreamingHarness() {
)
}
function StaticThreadHarness() {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [userMessage(), assistantMessage('complete response', false)],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
function TodoHarness({ message }: { message: ThreadMessage }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [message],
@ -409,222 +395,11 @@ describe('assistant-ui streaming renderer', () => {
expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).')
})
it('does not pull the viewport back down after the user scrolls up during streaming', async () => {
const { container } = render(<StreamingHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
const viewport = content.parentElement as HTMLDivElement
let scrollHeight = 1_000
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
Object.defineProperty(viewport, 'scrollHeight', {
configurable: true,
get: () => scrollHeight
})
await wait(80)
await act(async () => {
viewport.scrollTop = 800
fireEvent.scroll(viewport)
})
await wait(0)
await act(async () => {
fireEvent.wheel(viewport, { deltaY: -120 })
viewport.scrollTop = 420
fireEvent.scroll(viewport)
})
scrollHeight = 1_200
await act(async () => {
for (const observer of resizeObservers) {
observer.trigger(1_200)
}
})
await wait(0)
expect(viewport.scrollTop).toBe(420)
})
it('does not auto-follow idle layout shifts', async () => {
const { container } = render(<StaticThreadHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
const viewport = content.parentElement as HTMLDivElement
let scrollHeight = 1_000
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
Object.defineProperty(viewport, 'scrollHeight', {
configurable: true,
get: () => scrollHeight
})
await wait(80)
await act(async () => {
viewport.scrollTop = 420
fireEvent.scroll(viewport)
})
scrollHeight = 1_200
await act(async () => {
for (const observer of resizeObservers) {
observer.trigger(1_200)
}
})
await wait(0)
expect(viewport.scrollTop).toBe(420)
})
it('does not follow streaming content growth even while parked at the bottom', async () => {
const { container } = render(<StreamingHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
const viewport = content.parentElement as HTMLDivElement
let clientHeight = 200
let scrollHeight = 1_000
Object.defineProperty(viewport, 'clientHeight', {
configurable: true,
get: () => clientHeight
})
Object.defineProperty(viewport, 'scrollHeight', {
configurable: true,
get: () => scrollHeight
})
await wait(80)
// Park the user at the bottom of the current content.
await act(async () => {
viewport.scrollTop = 800
fireEvent.scroll(viewport)
})
clientHeight = 240
await act(async () => {
viewport.scrollTop = 760
fireEvent.scroll(viewport)
})
// Content grows as tokens stream in. Streaming auto-follow is removed, so
// the viewport must NOT chase the new bottom — it stays where the user
// last left it.
scrollHeight = 1_200
await act(async () => {
for (const observer of resizeObservers) {
observer.trigger(1_200)
}
})
await wait(0)
expect(viewport.scrollTop).toBe(760)
})
it('honors the first upward wheel scroll even when a programmatic bottom-pin scroll event is still pending', async () => {
const { container } = render(<StreamingHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
const viewport = content.parentElement as HTMLDivElement
let scrollHeight = 1_000
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
Object.defineProperty(viewport, 'scrollHeight', {
configurable: true,
get: () => scrollHeight
})
await wait(80)
await wait(0)
await act(async () => {
fireEvent.wheel(viewport, { deltaY: -120 })
viewport.scrollTop = 420
fireEvent.scroll(viewport)
})
scrollHeight = 1_200
await act(async () => {
for (const observer of resizeObservers) {
observer.trigger(1_200)
}
})
await wait(0)
expect(viewport.scrollTop).toBe(420)
})
it('does not snap to the bottom on final code-highlight growth after a run completes', async () => {
const { container } = render(<StreamingHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
const viewport = content.parentElement as HTMLDivElement
let scrollHeight = 1_000
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
Object.defineProperty(viewport, 'scrollHeight', {
configurable: true,
get: () => scrollHeight
})
await wait(80)
await act(async () => {
viewport.scrollTop = 800
fireEvent.scroll(viewport)
})
await wait(650)
// Completion re-measures (Shiki highlight) and grows the content. The
// post-run bottom lock is removed, so the viewport stays put instead of
// snapping to the new bottom.
scrollHeight = 1_700
await wait(0)
expect(viewport.scrollTop).toBe(800)
})
it('does not restart bottom-follow after completion when the user scrolled up', async () => {
const { container } = render(<StreamingHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
const viewport = content.parentElement as HTMLDivElement
let scrollHeight = 1_000
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
Object.defineProperty(viewport, 'scrollHeight', {
configurable: true,
get: () => scrollHeight
})
await wait(80)
await act(async () => {
viewport.scrollTop = 800
fireEvent.scroll(viewport)
})
await act(async () => {
fireEvent.wheel(viewport, { deltaY: -120 })
viewport.scrollTop = 420
fireEvent.scroll(viewport)
})
await wait(650)
scrollHeight = 1_700
await wait(0)
expect(viewport.scrollTop).toBe(420)
})
// Scroll behavior (follow-at-bottom, escape-on-scroll-up, re-engage) is owned
// by the use-stick-to-bottom library and covered by its own test suite. We
// don't re-assert its scrollTop mechanics here — doing so in jsdom (no real
// layout, spring animation via rAF) only produces brittle change-detector
// tests. The rendering/streaming-content tests below remain the contract.
it('renders an incomplete streaming fenced code block as a code card', async () => {
const { container } = render(<RunningMessageHarness message={assistantMessage('```ts\nconst answer = 42\n')} />)

View file

@ -0,0 +1,307 @@
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
import {
type ComponentProps,
type FC,
memo,
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState
} from 'react'
import { useStickToBottom } from 'use-stick-to-bottom'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import {
onScrollToBottomRequest,
onThreadEditClose,
onThreadEditOpen,
resetThreadScroll,
setThreadAtBottom
} from '@/store/thread-scroll'
import { MessageRenderBoundary } from './message-render-boundary'
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
type MessageGroup = { id: string; weight: number } & (
| { index: number; kind: 'standalone' }
| { indices: number[]; kind: 'turn' }
)
// DOM is bounded by a rendered-PART budget, not a message/turn count: a single
// assistant message folds every tool call into a part, so heavy sessions are
// ~40 turns / ~100 messages but ~1000 parts — and parts are what drive node
// count. "Show earlier" prepends another page; whole turns stay intact so the
// sticky human bubble never loses its turn. This is the long-session perf lever
// WITHOUT a virtualizer — pure rendering, never touches scrollTop, so it can't
// fight use-stick-to-bottom (the single scroll owner).
const RENDER_BUDGET = 300
interface ThreadMessageListProps {
clampToComposer: boolean
components: ThreadMessageComponents
emptyPlaceholder?: ReactNode
loadingIndicator?: ReactNode
sessionKey?: string | null
}
// Group each user message with the assistant turn(s) that follow it so the
// human bubble can `position: sticky` against the scroller across its whole
// turn (see StickyHumanMessageContainer in thread.tsx).
function buildGroups(signature: string): MessageGroup[] {
if (!signature) {
return []
}
const messages = signature.split('\n').map(row => {
const [index, id, role, weight] = row.split(':')
return { id, index: Number(index), role, weight: Number(weight) || 1 }
})
const groups: MessageGroup[] = []
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
if (message.role !== 'user') {
groups.push({ id: message.id, index: message.index, kind: 'standalone', weight: message.weight })
continue
}
const indices = [message.index]
let weight = message.weight
while (i + 1 < messages.length && messages[i + 1].role !== 'user') {
weight += messages[++i].weight
indices.push(messages[i].index)
}
groups.push({ id: message.id, indices, kind: 'turn', weight })
}
return groups
}
const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
clampToComposer,
components,
emptyPlaceholder,
loadingIndicator,
sessionKey
}) => {
const messageSignature = useAuiState(s =>
s.thread.messages
.map((message, index) => `${index}:${message.id}:${message.role}:${message.content?.length ?? 1}`)
.join('\n')
)
const { t } = useI18n()
const groups = buildGroups(messageSignature)
const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder)
// use-stick-to-bottom owns scrollTop (single writer): follow while locked,
// escape on user scroll-up, re-lock at bottom. Snap instantly, not spring — a
// spring can't tell live-token growth from a session-switch bulk relayout, and
// chasing the latter reads as the view scrolling to random spots before
// settling. Its refs hang off our own DOM so the sticky human bubbles survive.
const { scrollRef, contentRef, isAtBottom, scrollToBottom, stopScroll } = useStickToBottom({
initial: 'instant',
resize: 'instant'
})
const [renderBudget, setRenderBudget] = useState(RENDER_BUDGET)
// Walk turns newest-first, summing their part weights until the budget is met;
// everything before that first kept turn is hidden.
let firstVisible = groups.length
for (let i = groups.length - 1, weight = 0; i >= 0; i--) {
weight += groups[i].weight
firstVisible = i
if (weight >= renderBudget) {
break
}
}
const hiddenCount = firstVisible
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
const restoreFromBottomRef = useRef<number | null>(null)
useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom])
useEffect(() => () => resetThreadScroll(), [])
// Floating jump button (outside this subtree) → return to the bottom.
useEffect(() => onScrollToBottomRequest(() => void scrollToBottom()), [scrollToBottom])
const endEditHold = useCallback(() => {
scrollRef.current?.removeAttribute('data-editing')
}, [scrollRef])
// Inline edit grows a sticky bubble. Escape before focus/layout so the
// resize-follow can't snap scrollTop; native anchoring holds the viewport.
const beginEditHold = useCallback(() => {
const el = scrollRef.current
if (!el) {
return
}
endEditHold()
stopScroll()
el.setAttribute('data-editing', 'true')
}, [endEditHold, scrollRef, stopScroll])
useEffect(() => onThreadEditOpen(beginEditHold), [beginEditHold])
useEffect(() => onThreadEditClose(endEditHold), [endEditHold])
useEffect(() => () => endEditHold(), [endEditHold])
// New run → snap to the latest turn.
useAuiEvent('thread.runStart', () => void scrollToBottom())
// Reset the cap and pin to bottom on mount + every session switch (messages
// swap in place on a long-lived runtime, so sessionKey is the only signal).
// The swap is multi-step and lays out over many frames; letting the library
// follow re-pins every frame to a moving target — visible as ~10 scroll jumps.
// Instead: quiet it, glue to the true bottom until the height holds steady,
// then hand back locked. Live streaming afterward uses the normal resize follow.
useLayoutEffect(() => {
setRenderBudget(RENDER_BUDGET)
const el = scrollRef.current
if (!el) {
return
}
stopScroll()
el.scrollTop = el.scrollHeight
let frame = 0
let stableFrames = 0
let lastHeight = el.scrollHeight
const settle = () => {
const node = scrollRef.current
if (!node) {
return
}
const height = node.scrollHeight
stableFrames = height === lastHeight ? stableFrames + 1 : 0
lastHeight = height
node.scrollTop = height
// ~5 steady frames ≈ layout has settled; the frame cap bounds slow loads.
if (stableFrames >= 5 || ++frame > 90) {
void scrollToBottom('instant')
return
}
rafId = requestAnimationFrame(settle)
}
let rafId = requestAnimationFrame(settle)
return () => cancelAnimationFrame(rafId)
}, [scrollRef, scrollToBottom, sessionKey, stopScroll])
// Prepend an older page while preserving the on-screen position. The user is
// scrolled up (reading history) so the stick-to-bottom lock is escaped and
// won't fight this manual restore.
const showEarlier = useCallback(() => {
const el = scrollRef.current
restoreFromBottomRef.current = el ? el.scrollHeight - el.scrollTop : null
setRenderBudget(budget => budget + RENDER_BUDGET)
}, [scrollRef])
useLayoutEffect(() => {
const el = scrollRef.current
if (el && restoreFromBottomRef.current != null) {
el.scrollTop = el.scrollHeight - restoreFromBottomRef.current
restoreFromBottomRef.current = null
}
}, [scrollRef, renderBudget])
return (
<div
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
style={{ height: clampToComposer ? 'var(--thread-viewport-height)' : '100%' }}
>
<div
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
data-following={isAtBottom ? 'true' : 'false'}
data-slot="aui_thread-viewport"
ref={scrollRef as React.RefCallback<HTMLDivElement>}
>
{renderEmpty ? (
<div
className="mx-auto grid h-full w-full max-w-(--composer-width) grid-rows-[minmax(0,1fr)_auto] min-w-0 gap-(--conversation-turn-gap) px-6 py-8"
data-slot="aui_thread-content"
>
{emptyPlaceholder}
</div>
) : (
<div
className={cn(
'mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6 pt-[calc(var(--titlebar-height)+1.5rem)]'
)}
data-slot="aui_thread-content"
ref={contentRef as React.RefCallback<HTMLDivElement>}
>
{hiddenCount > 0 && (
<button
className="mx-auto mb-(--conversation-turn-gap) rounded-full border border-border/65 bg-(--composer-fill) px-3 py-1 text-xs text-muted-foreground hover:text-foreground"
onClick={showEarlier}
type="button"
>
{t.assistant.thread.showEarlier}
</button>
)}
{visibleGroups.map(group => (
<div
className="flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)"
key={group.id}
>
<MessageRenderBoundary resetKey={messageSignature}>
{group.kind === 'turn' ? (
<div
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
data-slot="aui_turn-pair"
>
{group.indices.map(index => (
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
))}
</div>
) : (
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
)}
</MessageRenderBoundary>
</div>
))}
{loadingIndicator}
{clampToComposer && (
<div
aria-hidden="true"
className="shrink-0"
data-slot="aui_composer-clearance"
style={{ height: 'var(--thread-last-message-clearance)' }}
/>
)}
</div>
)}
</div>
</div>
)
}
export const ThreadMessageList = memo(ThreadMessageListInner)

View file

@ -1,481 +0,0 @@
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual'
import {
type ComponentProps,
type FC,
memo,
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef
} from 'react'
import { setMutableRef } from '@/lib/mutable-ref'
import { cn } from '@/lib/utils'
import {
onScrollToBottomRequest,
resetThreadScroll,
setThreadJumpButtonVisible,
setThreadScrolledUp
} from '@/store/thread-scroll'
import { MessageRenderBoundary } from './message-render-boundary'
const ESTIMATED_ITEM_HEIGHT = 220
const OVERSCAN = 4
const AT_BOTTOM_THRESHOLD = 4
// Reveal the floating jump button only once scrolled meaningfully away — above
// AT_BOTTOM_THRESHOLD so a sub-pixel settle never flashes it.
const JUMP_BUTTON_THRESHOLD = 10
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
type MessageGroup = { id: string; index: number; kind: 'standalone' } | { id: string; indices: number[]; kind: 'turn' }
interface VirtualizedThreadProps {
clampToComposer: boolean
components: ThreadMessageComponents
emptyPlaceholder?: ReactNode
loadingIndicator?: ReactNode
sessionKey?: string | null
}
function buildGroups(signature: string): MessageGroup[] {
if (!signature) {
return []
}
const messages = signature.split('\n').map(row => {
const [index, id, role] = row.split(':')
return { id, index: Number(index), role }
})
const groups: MessageGroup[] = []
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
if (message.role !== 'user') {
groups.push({ id: message.id, index: message.index, kind: 'standalone' })
continue
}
const indices = [message.index]
while (i + 1 < messages.length && messages[i + 1].role !== 'user') {
indices.push(messages[++i].index)
}
groups.push({ id: message.id, indices, kind: 'turn' })
}
return groups
}
const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
clampToComposer,
components,
emptyPlaceholder,
loadingIndicator,
sessionKey
}) => {
const messageSignature = useAuiState(s =>
s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n')
)
const isRunning = useAuiState(s => s.thread.isRunning)
const groups = useMemo(() => buildGroups(messageSignature), [messageSignature])
const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder)
const scrollerRef = useRef<HTMLDivElement | null>(null)
// Shared ref so scrollToFn can check whether the user is parked at the
// bottom without needing a ref from inside useThreadScrollAnchor.
const stickyBottomRef = useRef(true)
const virtualizer = useVirtualizer({
count: groups.length,
estimateSize: () => ESTIMATED_ITEM_HEIGHT,
getItemKey: index => groups[index]?.id ?? index,
getScrollElement: () => scrollerRef.current,
// Seed the rect so the initial range mounts something before
// `observeElementRect` reports the real layout (it overrides this).
initialRect: { height: 600, width: 800 },
overscan: OVERSCAN,
// When the virtualizer adjusts scroll due to item measurement changes,
// skip the adjustment if the user is at the bottom. Our ResizeObserver +
// pinToBottom loop handles scroll anchoring; letting the virtualizer also
// adjust creates a feedback loop where the two fight each other,
// producing visible rubber-banding (the view snaps to the composer
// then jumps back up).
scrollToFn: (offset, _options, instance) => {
const el = instance.scrollElement
if (!el) {
return
}
if (stickyBottomRef.current) {
const maxScroll = el.scrollHeight - el.clientHeight
const distFromBottom = maxScroll - el.scrollTop
if (distFromBottom <= AT_BOTTOM_THRESHOLD && offset < maxScroll) {
return
}
}
;(el as HTMLElement).scrollTo(0, offset)
}
})
useThreadScrollAnchor({
enabled: !renderEmpty,
groupCount: groups.length,
isRunning,
scrollerRef,
sessionKey: sessionKey ?? null,
stickyBottomRef,
virtualizer
})
const virtualItems = virtualizer.getVirtualItems()
const totalSize = virtualizer.getTotalSize()
const paddingTop = virtualItems[0]?.start ?? 0
const paddingBottom = Math.max(0, totalSize - (virtualItems.at(-1)?.end ?? 0))
return (
<div
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
style={{ height: clampToComposer ? 'var(--thread-viewport-height)' : '100%' }}
>
<div
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
data-slot="aui_thread-viewport"
ref={scrollerRef}
>
{renderEmpty ? (
<div
className="mx-auto grid h-full w-full max-w-(--composer-width) grid-rows-[minmax(0,1fr)_auto] min-w-0 gap-(--conversation-turn-gap) px-6 py-8"
data-slot="aui_thread-content"
>
{emptyPlaceholder}
</div>
) : (
<div
className={cn(
'mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6 pt-[calc(var(--titlebar-height)+1.5rem)]'
)}
data-slot="aui_thread-content"
>
{/* Natural-flow virtualization: mounted items render as normal
flex siblings so `position: sticky` on the human bubble
resolves against the scroller without transform interference.
Padding spacers reserve scroll space for unmounted items. */}
<div style={{ paddingBottom: `${paddingBottom}px`, paddingTop: `${paddingTop}px` }}>
{virtualItems.map(virtualItem => {
const group = groups[virtualItem.index]
if (!group) {
return null
}
return (
<div
className="flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)"
data-index={virtualItem.index}
key={virtualItem.key}
ref={virtualizer.measureElement}
>
<MessageRenderBoundary resetKey={messageSignature}>
{group.kind === 'turn' ? (
<div
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
data-slot="aui_turn-pair"
>
{group.indices.map(index => (
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
))}
</div>
) : (
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
)}
</MessageRenderBoundary>
</div>
)
})}
</div>
{loadingIndicator}
{clampToComposer && (
<div
aria-hidden="true"
className="shrink-0"
data-slot="aui_composer-clearance"
style={{ height: 'var(--thread-last-message-clearance)' }}
/>
)}
</div>
)}
</div>
</div>
)
}
export const VirtualizedThread = memo(VirtualizedThreadInner)
function scrollElementToBottom(el: HTMLDivElement) {
el.scrollTop = el.scrollHeight
}
interface ScrollAnchorOptions {
enabled: boolean
groupCount: number
isRunning: boolean
scrollerRef: React.RefObject<HTMLDivElement | null>
sessionKey: string | null
stickyBottomRef: React.MutableRefObject<boolean>
virtualizer: Virtualizer<HTMLDivElement, Element>
}
function useThreadScrollAnchor({
enabled,
groupCount,
isRunning,
scrollerRef,
sessionKey,
stickyBottomRef,
virtualizer
}: ScrollAnchorOptions) {
// `stickyBottomRef` = parked at bottom, content growth should follow. Cleared on
// user-driven upward scroll; re-armed when they reach bottom again.
// This is a shared ref — scrollToFn reads it to prevent the virtualizer's
// measurement adjustments from fighting our pinToBottom.
const lastTopRef = useRef(0)
const lastHeightRef = useRef(0)
const lastClientHeightRef = useRef(0)
// Counter that tracks how many scroll events we expect to be ours rather
// than the user's. `pinToBottom` writes `el.scrollTop`, which fires an
// async `scroll` event; without this guard the on-scroll handler can race
// with the programmatic write (because content also grew, the *resulting*
// scrollTop can be lower than `lastTopRef` from the previous frame) and
// misread the programmatic pin as the user scrolling up — which disarms
// sticky-bottom and the user's just-submitted message slides above the
// fold. See `apps/desktop/scripts/measure-jump.mjs` for the repro
// (distFromBottom 0 → 49 within one frame, sticking forever).
const programmaticScrollPendingRef = useRef(0)
const prevSessionKeyRef = useRef(sessionKey)
const prevGroupCountRef = useRef(0)
const pinToBottom = useCallback(() => {
const el = scrollerRef.current
if (!el) {
return
}
// Already parked at the bottom: writing `scrollTop` is a no-op and the
// browser fires NO scroll event, so arming the programmatic gate here would
// leave it permanently set. Repeated pins (streaming heartbeats, the
// post-run lock loop) then accumulate the gate, and the next genuine user
// scroll-up is misread as one of our programmatic scrolls — re-arming
// sticky-bottom and yanking the viewport back down. Refresh trackers, bail.
const distFromBottom = el.scrollHeight - (el.scrollTop + el.clientHeight)
if (distFromBottom <= AT_BOTTOM_THRESHOLD) {
lastTopRef.current = el.scrollTop
lastHeightRef.current = el.scrollHeight
lastClientHeightRef.current = el.clientHeight
return
}
// Hold the disarm gate across the scroll event the next line will fire.
// Set to 1 rather than incrementing: coalesced writes within a frame fire a
// single scroll event, so a counter > 1 can never drain and would swallow a
// later real user scroll.
programmaticScrollPendingRef.current = 1
scrollElementToBottom(el)
lastTopRef.current = el.scrollTop
lastHeightRef.current = el.scrollHeight
lastClientHeightRef.current = el.clientHeight
}, [scrollerRef])
const jumpToBottom = useCallback(() => {
setMutableRef(stickyBottomRef, true)
if (groupCount > 0) {
virtualizer.scrollToIndex(groupCount - 1, { align: 'end', behavior: 'auto' })
}
requestAnimationFrame(() => {
if (stickyBottomRef.current) {
pinToBottom()
}
})
}, [groupCount, pinToBottom, stickyBottomRef, virtualizer])
useEffect(() => () => resetThreadScroll(), [])
// Track at-bottom state, dim composer when scrolled up, disarm on user
// scroll/wheel/touch.
useEffect(() => {
const el = scrollerRef.current
if (!el) {
return undefined
}
const disarm = () => {
setMutableRef(stickyBottomRef, false)
programmaticScrollPendingRef.current = 0
}
// Dim the composer the instant we leave the bottom; reveal the jump button
// only once scrolled meaningfully away.
const publishScrollDistance = (dist: number) => {
setThreadScrolledUp(dist > AT_BOTTOM_THRESHOLD)
setThreadJumpButtonVisible(dist > JUMP_BUTTON_THRESHOLD)
}
const onScroll = () => {
const top = el.scrollTop
// If this scroll event is the consequence of `pinToBottom` writing
// `el.scrollTop`, treat it as ours: don't disarm. The RO + rAF pin
// loop will re-pin on the next frame if the browser clamped us
// short of bottom (because content grew in the same frame).
// Without this guard the post-pin scrollTop gets misread as the
// user scrolling up, disarming sticky-bottom permanently and
// leaving the just-submitted message below the fold.
if (programmaticScrollPendingRef.current > 0) {
programmaticScrollPendingRef.current -= 1
lastTopRef.current = top
lastHeightRef.current = el.scrollHeight
lastClientHeightRef.current = el.clientHeight
// Always re-arm — sticky-bottom should hold through clamp races.
setMutableRef(stickyBottomRef, true)
publishScrollDistance(el.scrollHeight - (top + el.clientHeight))
return
}
// Disarm on ANY upward movement (even 1px), but only while content +
// viewport height are stable — virtualizer measurement, streaming
// markdown, and composer/window resize all shift scrollTop as a layout
// side effect. Wheel-up and touchmove disarm immediately too (below).
const heightGrew = el.scrollHeight > lastHeightRef.current
const clientHeightChanged = Math.abs(el.clientHeight - lastClientHeightRef.current) > 1
if (!heightGrew && !clientHeightChanged && top < lastTopRef.current) {
setMutableRef(stickyBottomRef, false)
}
lastTopRef.current = top
lastHeightRef.current = el.scrollHeight
lastClientHeightRef.current = el.clientHeight
const distFromBottom = el.scrollHeight - (top + el.clientHeight)
// Re-arm follow only once genuinely back at the bottom.
if (distFromBottom <= AT_BOTTOM_THRESHOLD) {
setMutableRef(stickyBottomRef, true)
}
publishScrollDistance(distFromBottom)
}
const onWheel = (event: WheelEvent) => {
if (event.deltaY < 0) {
disarm()
}
}
el.addEventListener('scroll', onScroll, { passive: true })
el.addEventListener('wheel', onWheel, { passive: true })
el.addEventListener('touchmove', disarm, { passive: true })
return () => {
el.removeEventListener('scroll', onScroll)
el.removeEventListener('wheel', onWheel)
el.removeEventListener('touchmove', disarm)
}
}, [scrollerRef, stickyBottomRef])
// No streaming auto-follow: chasing content growth while parked at the bottom
// rubber-bands (the tail and the virtualizer's own measurement adjustments
// fight for scrollTop). The one-time new-turn jump below already lands a fresh
// message in view; from there the viewport stays put unless the user jumps.
// The floating jump button asks us to return to the bottom; same re-arm + pin
// path as a new turn.
useEffect(() => onScrollToBottomRequest(jumpToBottom), [jumpToBottom])
// Jump to bottom on session change OR when an empty thread first gets
// content. Both share the same intent and the same effect.
useEffect(() => {
const sessionChanged = prevSessionKeyRef.current !== sessionKey
const becameNonEmpty = prevGroupCountRef.current === 0 && groupCount > 0
prevSessionKeyRef.current = sessionKey
prevGroupCountRef.current = groupCount
if (enabled && (sessionChanged || becameNonEmpty)) {
jumpToBottom()
}
}, [enabled, groupCount, jumpToBottom, sessionKey])
// Pre-paint pin: when groupCount increases while armed (a new turn arriving
// from the user submit or assistant turn start), pin BEFORE the browser
// commits the layout to screen. Using useLayoutEffect rather than useEffect
// so this runs synchronously after React commits the DOM mutation but before
// the browser paints. Without this, there's a ~50ms visual window where the
// new message sits below the fold.
//
// We pin TWICE in this critical path — once synchronously, then once on
// the next rAF. The second pin catches the case where React mounts the
// new message in the second commit (after our layout effect ran), which
// grows scrollHeight again; without the rAF pin the user briefly sees a
// ~15 px gap below the new message. This fires once per user submit / new
// turn arrival — it is NOT streaming-token follow (that path is removed
// above), so a turn that streams a long response after this initial jump
// will not chase the bottom.
const prevGroupCountForLayoutRef = useRef(groupCount)
useLayoutEffect(() => {
if (!enabled) {
return
}
if (groupCount > prevGroupCountForLayoutRef.current && stickyBottomRef.current) {
// Defer to rAF so that browser scroll/wheel events from the current
// frame are processed first. Without this deferral, a trackpad
// scroll-up during streaming can race with this effect: the wheel
// event hasn't fired yet so stickyBottomRef is still true, and the
// immediate pinToBottom() would snap the viewport back to bottom
// against the user's intent.
requestAnimationFrame(() => {
if (stickyBottomRef.current) {
pinToBottom()
}
})
}
prevGroupCountForLayoutRef.current = groupCount
}, [enabled, groupCount, pinToBottom, stickyBottomRef])
// Intentionally NO post-run bottom lock. Earlier builds kept pinning to
// the bottom for POST_RUN_BOTTOM_LOCK_MS after `isRunning` flipped false to
// chase final Shiki re-highlight measurement. With streaming follow gone,
// re-pinning at completion would yank the viewport back to the bottom even
// though the user is reading earlier content — the opposite of what's
// wanted. The one-time submit / new-turn jump already covers landing a
// fresh message in view.
const prevIsRunningForLayoutRef = useRef(isRunning)
useLayoutEffect(() => {
prevIsRunningForLayoutRef.current = isRunning
}, [isRunning])
useAuiEvent('thread.runStart', jumpToBottom)
}

View file

@ -63,7 +63,7 @@ import { uploadComposerAttachment } from '@/app/session/hooks/use-prompt-actions
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { DirectiveContent, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
import { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer'
import { ThreadMessageList } from '@/components/assistant-ui/thread-list'
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback'
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
import { UserMessageText } from '@/components/assistant-ui/user-message-text'
@ -100,6 +100,7 @@ import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
import { $connection } from '@/store/session'
import { notifyThreadEditClose, notifyThreadEditOpen } from '@/store/thread-scroll'
import { $voicePlayback } from '@/store/voice-playback'
type ThreadLoadingState = 'response' | 'session'
@ -202,7 +203,7 @@ export const Thread: FC<{
return (
<GeneratedImageProvider>
<div className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
<VirtualizedThread
<ThreadMessageList
clampToComposer={clampToComposer}
components={messageComponents}
emptyPlaceholder={emptyPlaceholder}
@ -956,7 +957,10 @@ const UserMessage: FC<{
// backtick `code` and ``` fenced ``` blocks, with directive chips
// (`@file:` etc.) still resolved inside the plain-text spans.
<div className="sticky-human-clamp" data-clamped={bodyClamped ? 'true' : undefined}>
<div ref={clampInnerRef}>
{/* Match the edit composer's collapsed line box (min-h-[1.25rem]) so
clicking to edit can't grow the bubble by a sub-pixel and reflow the
turn 1px. */}
<div className="min-h-[1.25rem]" ref={clampInnerRef}>
<UserMessageText className="wrap-anywhere" text={messageText} />
</div>
</div>
@ -986,6 +990,7 @@ const UserMessage: FC<{
aria-label={copy.editMessage}
className={bubbleClassName}
onClick={() => triggerHaptic('selection')}
onPointerDown={() => notifyThreadEditOpen()}
title={copy.editMessage}
type="button"
>
@ -1175,6 +1180,8 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
const at = useAtCompletions({ cwd, gateway, sessionId })
const slash = useSlashCompletions({ gateway })
useEffect(() => () => notifyThreadEditClose(), [])
const focusEditor = useCallback(() => {
const editor = editorRef.current
@ -1700,7 +1707,6 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
aria-label={copy.editMessage}
autoCapitalize="off"
autoCorrect="off"
autoFocus
className={cn(
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 outline-none',
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',

View file

@ -210,6 +210,19 @@ export function searchSessions(query: string): Promise<SessionSearchResponse> {
})
}
// Resolves a single session row by id on one backend (the active profile, or
// the given `profile`). The backend resolves exact ids and unique prefixes and
// 404s when the id isn't on that profile — so a cheap by-id lookup replaces the
// cross-profile list scan when locating an unknown id's owner.
export function getSession(id: string, profile?: string | null): Promise<SessionInfo> {
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
return window.hermesDesktop.api<SessionInfo>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}${suffix}`
})
}
// Reads another profile's transcript. For a remote profile Electron reroutes
// this GET to the remote backend (which serves its own state.db); for a local
// profile the primary opens that profile's state.db via ?profile=. Omit for

View file

@ -643,6 +643,7 @@ export const en: Translations = {
back: 'Back',
searchPlaceholder: 'Search sessions, views, and actions',
goTo: 'Go to',
goToSession: 'Go to session',
commandCenter: 'Command Center',
appearance: 'Appearance',
settings: 'Settings',
@ -1655,6 +1656,7 @@ export const en: Translations = {
assistant: {
thread: {
loadingSession: 'Loading session',
showEarlier: 'Show earlier messages',
loadingResponse: 'Hermes is loading a response',
thinking: 'Thinking',
today: time => `Today, ${time}`,

View file

@ -773,6 +773,7 @@ export const ja = defineLocale({
back: '戻る',
searchPlaceholder: 'セッション、ビュー、アクションを検索',
goTo: '移動',
goToSession: 'セッションへ移動',
commandCenter: 'コマンドセンター',
appearance: '外観',
settings: '設定',
@ -1796,6 +1797,7 @@ export const ja = defineLocale({
assistant: {
thread: {
loadingSession: 'セッションを読み込み中',
showEarlier: '以前のメッセージを表示',
loadingResponse: 'Hermes が応答を読み込み中',
thinking: '考え中',
today: time => `今日 ${time}`,

View file

@ -540,6 +540,7 @@ export interface Translations {
back: string
searchPlaceholder: string
goTo: string
goToSession: string
commandCenter: string
appearance: string
settings: string
@ -1314,6 +1315,7 @@ export interface Translations {
assistant: {
thread: {
loadingSession: string
showEarlier: string
loadingResponse: string
thinking: string
today: (time: string) => string

View file

@ -748,6 +748,7 @@ export const zhHant = defineLocale({
back: '返回',
searchPlaceholder: '搜尋工作階段、檢視和動作',
goTo: '前往',
goToSession: '前往工作階段',
commandCenter: '命令中心',
appearance: '外觀',
settings: '設定',
@ -1740,6 +1741,7 @@ export const zhHant = defineLocale({
assistant: {
thread: {
loadingSession: '正在載入工作階段',
showEarlier: '顯示較早的訊息',
loadingResponse: 'Hermes 正在載入回覆',
thinking: '思考中',
today: time => `今天,${time}`,

View file

@ -835,6 +835,7 @@ export const zh: Translations = {
back: '返回',
searchPlaceholder: '搜索会话、视图与操作',
goTo: '前往',
goToSession: '前往会话',
commandCenter: '命令中心',
appearance: '外观',
settings: '设置',
@ -1835,6 +1836,7 @@ export const zh: Translations = {
assistant: {
thread: {
loadingSession: '正在加载会话',
showEarlier: '显示更早的消息',
loadingResponse: 'Hermes 正在加载回复',
thinking: '思考中',
today: time => `今天,${time}`,

View file

@ -1,8 +1,13 @@
import { atom, type WritableAtom } from 'nanostores'
// `$threadScrolledUp` flips the instant the viewport leaves the bottom (dims the
// composer / status stack). `$threadJumpButtonVisible` trips a little further up
// (~10px) so the floating jump control only shows once meaningfully away.
// "Is the thread parked at the bottom" is owned by use-stick-to-bottom inside
// ThreadMessageList (the scroll container). That state lives only in that
// subtree, so ThreadMessageList mirrors it into these atoms for the composer,
// status stack, and floating jump button — all of which render OUTSIDE the thread.
//
// `$threadScrolledUp` dims the composer / status stack; `$threadJumpButtonVisible`
// shows the floating jump control. Both track `!isAtBottom` today, but stay
// separate so their thresholds can diverge again without touching consumers.
export const $threadScrolledUp = atom(false)
export const $threadJumpButtonVisible = atom(false)
@ -13,17 +18,19 @@ const setter = (target: WritableAtom<boolean>) => (value: boolean) => {
}
}
export const setThreadScrolledUp = setter($threadScrolledUp)
export const setThreadJumpButtonVisible = setter($threadJumpButtonVisible)
const setScrolledUp = setter($threadScrolledUp)
const setJumpButtonVisible = setter($threadJumpButtonVisible)
export const resetThreadScroll = () => {
setThreadScrolledUp(false)
setThreadJumpButtonVisible(false)
export const setThreadAtBottom = (isAtBottom: boolean) => {
setScrolledUp(!isAtBottom)
setJumpButtonVisible(!isAtBottom)
}
// Cross-component bridge: the jump button lives by the composer, the re-arm +
// pin machinery lives in the virtualizer. The virtualizer registers a handler;
// the button fires it. Mirrors the composer focus/insert emitter pattern.
export const resetThreadScroll = () => setThreadAtBottom(true)
// Cross-component bridge: the jump button lives by the composer, the viewport's
// `scrollToBottom` lives inside the thread. The bridge registers a handler; the
// button fires it. Mirrors the composer focus/insert emitter pattern.
const handlers = new Set<() => void>()
export const onScrollToBottomRequest = (handler: () => void) => {
@ -33,3 +40,25 @@ export const onScrollToBottomRequest = (handler: () => void) => {
}
export const requestScrollToBottom = () => handlers.forEach(handler => handler())
// Inline edit grows a sticky human bubble. Fire on pointerdown so the viewport
// escapes stick-to-bottom before focus/layout; close clears the edit flag when
// the inline composer unmounts.
const editOpenHandlers = new Set<() => void>()
const editCloseHandlers = new Set<() => void>()
export const onThreadEditOpen = (handler: () => void) => {
editOpenHandlers.add(handler)
return () => void editOpenHandlers.delete(handler)
}
export const notifyThreadEditOpen = () => editOpenHandlers.forEach(handler => handler())
export const onThreadEditClose = (handler: () => void) => {
editCloseHandlers.add(handler)
return () => void editCloseHandlers.delete(handler)
}
export const notifyThreadEditClose = () => editCloseHandlers.forEach(handler => handler())

View file

@ -895,11 +895,9 @@ canvas {
}
/* Sticky human bubbles clamp to ~2 lines with a soft bottom fade so a long
prompt doesn't dominate the viewport. The clamp lifts on focus only (clicking
opens the edit composer, which shows the full text) not on hover, so the
bubble doesn't jump as the pointer passes over it. No transition: the lift
happens in the same click that swaps in the edit composer, so animating it
just flashes a half-expanded bubble on the way in. */
prompt doesn't dominate the viewport. The clamp lifts only in the edit
composer; expanding on read-only :focus-within ran on mousedown (before the
swap) and fought stick-to-bottom when parked at the bottom. */
.sticky-human-clamp {
cursor: pointer;
max-height: calc(2 * var(--dt-line-height) * var(--conversation-text-font-size) + 0.15rem);
@ -911,25 +909,18 @@ canvas {
mask-image: linear-gradient(to bottom, #000 55%, transparent);
}
.composer-human-message:focus-within .sticky-human-clamp {
max-height: min(var(--human-msg-full, 24rem), 24rem);
overflow-y: auto;
-webkit-mask-image: none;
mask-image: none;
}
/* The thread renders items in natural document flow (padding spacers, not
transforms) and @tanstack/react-virtual already adjusts scrollTop itself
when an off-screen turn is measured and its real height differs from the
220px estimate. The browser's native scroll anchoring (overflow-anchor:
auto) would adjust scrollTop for that SAME size delta, so the two
double-correct and the view lurches most visibly on Windows mouse wheels,
whose coarse notches mount/measure several under-estimated turns per tick.
Opt out of native anchoring so only the virtualizer compensates. */
/* Stick-to-bottom owns scrollTop while following. Once escaped, native anchoring
is safe and keeps sticky human edits from shoving the viewport; data-editing
enables that path before React swaps in the inline editor. */
[data-slot='aui_thread-viewport'] {
overflow-anchor: none;
}
[data-slot='aui_thread-viewport'][data-following='false'],
[data-slot='aui_thread-viewport'][data-editing='true'] {
overflow-anchor: auto;
}
[data-slot='aui_thread-content'] {
max-width: var(--composer-width);
padding-inline: 1.5rem;

16
package-lock.json generated
View file

@ -128,6 +128,7 @@
"unicode-animations": "^1.0.3",
"unified": "^11.0.5",
"unist-util-visit-parents": "^6.0.2",
"use-stick-to-bottom": "^1.1.6",
"vfile": "^6.0.3",
"web-haptics": "^0.0.6"
},
@ -20658,6 +20659,21 @@
}
}
},
"node_modules/use-stick-to-bottom": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/use-stick-to-bottom/-/use-stick-to-bottom-1.1.6.tgz",
"integrity": "sha512-z3Up8jYQGTkUCsGBnwg6/wj70KgXoW5Kz1AAc1j8MtQuYMBo6ZsdhrIXoegxa7gaMMilgQYyTohTrt3p94jHog==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/samdenty"
}
],
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",