From 76b93869d8edddab860e225b11ef9e04df33ba63 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 13 Jun 2026 01:14:07 -0500 Subject: [PATCH] fix(desktop): rebuild thread autoscroll on use-stick-to-bottom --- apps/desktop/package.json | 1 + apps/desktop/src/app/chat/index.tsx | 23 +- .../desktop/src/app/command-palette/index.tsx | 22 + .../app/session/hooks/use-session-actions.ts | 65 ++- .../assistant-ui/streaming.test.tsx | 239 +-------- .../components/assistant-ui/thread-list.tsx | 307 +++++++++++ .../assistant-ui/thread-virtualizer.tsx | 481 ------------------ .../src/components/assistant-ui/thread.tsx | 14 +- apps/desktop/src/hermes.ts | 13 + apps/desktop/src/i18n/en.ts | 2 + apps/desktop/src/i18n/ja.ts | 2 + apps/desktop/src/i18n/types.ts | 2 + apps/desktop/src/i18n/zh-hant.ts | 2 + apps/desktop/src/i18n/zh.ts | 2 + apps/desktop/src/store/thread-scroll.ts | 51 +- apps/desktop/src/styles.css | 31 +- package-lock.json | 16 + 17 files changed, 512 insertions(+), 761 deletions(-) create mode 100644 apps/desktop/src/components/assistant-ui/thread-list.tsx delete mode 100644 apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6fed75f563..52be586f01 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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" }, diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index f830eddf5b..f890a5bfe6 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -165,8 +165,13 @@ interface ChatRuntimeBoundaryProps { onEdit: (message: AppendMessage) => Promise onReload: (parentId: string | null) => Promise 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()) 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} > haystack.includes(term)) ? 1 : 0 } +// Hermes session ids: __<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>['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, diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index 4e19c63795..9ce2ff1a8f 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -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 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 diff --git a/apps/desktop/src/components/assistant-ui/streaming.test.tsx b/apps/desktop/src/components/assistant-ui/streaming.test.tsx index 423a6f862e..34ddc58fe8 100644 --- a/apps/desktop/src/components/assistant-ui/streaming.test.tsx +++ b/apps/desktop/src/components/assistant-ui/streaming.test.tsx @@ -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({ - messages: [userMessage(), assistantMessage('complete response', false)], - isRunning: false, - onNew: async () => {} - }) - - return ( - - - - ) -} - function TodoHarness({ message }: { message: ThreadMessage }) { const runtime = useExternalStoreRuntime({ 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() - - 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() - - 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() - - 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() - - 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() - - 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() - - 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() diff --git a/apps/desktop/src/components/assistant-ui/thread-list.tsx b/apps/desktop/src/components/assistant-ui/thread-list.tsx new file mode 100644 index 0000000000..397ed2aa9b --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread-list.tsx @@ -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['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 = ({ + 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(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 ( +
+
} + > + {renderEmpty ? ( +
+ {emptyPlaceholder} +
+ ) : ( +
} + > + {hiddenCount > 0 && ( + + )} + {visibleGroups.map(group => ( +
+ + {group.kind === 'turn' ? ( +
+ {group.indices.map(index => ( + + ))} +
+ ) : ( + + )} +
+
+ ))} + {loadingIndicator} + {clampToComposer && ( + + )} +
+
+ ) +} + +export const ThreadMessageList = memo(ThreadMessageListInner) diff --git a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx deleted file mode 100644 index 03bc9082a4..0000000000 --- a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx +++ /dev/null @@ -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['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 = ({ - 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(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 ( -
-
- {renderEmpty ? ( -
- {emptyPlaceholder} -
- ) : ( -
- {/* 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. */} -
- {virtualItems.map(virtualItem => { - const group = groups[virtualItem.index] - - if (!group) { - return null - } - - return ( -
- - {group.kind === 'turn' ? ( -
- {group.indices.map(index => ( - - ))} -
- ) : ( - - )} -
-
- ) - })} -
- {loadingIndicator} - {clampToComposer && ( - - )} -
-
- ) -} - -export const VirtualizedThread = memo(VirtualizedThreadInner) - -function scrollElementToBottom(el: HTMLDivElement) { - el.scrollTop = el.scrollHeight -} - -interface ScrollAnchorOptions { - enabled: boolean - groupCount: number - isRunning: boolean - scrollerRef: React.RefObject - sessionKey: string | null - stickyBottomRef: React.MutableRefObject - virtualizer: Virtualizer -} - -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) -} diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index f2a574d475..b76de4123a 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -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 (
- -
+ {/* 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. */} +
@@ -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 = ({ 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 = ({ 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', diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index b765390f01..52e86722f6 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -210,6 +210,19 @@ export function searchSessions(query: string): Promise { }) } +// 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 { + const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : '' + + return window.hermesDesktop.api({ + ...(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 diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 26c125ee24..269af0c3cf 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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}`, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 1fd67a558c..17e7d0076b 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -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}`, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 9b348581a6..d877567b57 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index aa477b482b..4b60f4242c 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -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}`, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 19c107b088..4daab207d0 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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}`, diff --git a/apps/desktop/src/store/thread-scroll.ts b/apps/desktop/src/store/thread-scroll.ts index c0a9afd741..976cf4b4e1 100644 --- a/apps/desktop/src/store/thread-scroll.ts +++ b/apps/desktop/src/store/thread-scroll.ts @@ -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) => (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()) diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 105c3ebdbb..493b935a50 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -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; diff --git a/package-lock.json b/package-lock.json index 717f7a12c2..97e35d7ab5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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",