diff --git a/packages/ui/src/components/message-item.tsx b/packages/ui/src/components/message-item.tsx index 6ba7995e..fb8e5c5a 100644 --- a/packages/ui/src/components/message-item.tsx +++ b/packages/ui/src/components/message-item.tsx @@ -15,9 +15,11 @@ interface MessageItemProps { onRevert?: (messageId: string) => void onFork?: (messageId?: string) => void showAgentMeta?: boolean -} + onContentRendered?: () => void + } + + export default function MessageItem(props: MessageItemProps) { -export default function MessageItem(props: MessageItemProps) { const isUser = () => props.record.role === "user" const timestamp = () => { const createdTime = props.messageInfo?.time?.created ?? props.record.createdAt @@ -234,6 +236,7 @@ export default function MessageItem(props: MessageItemProps) { messageType={props.record.role} instanceId={props.instanceId} sessionId={props.sessionId} + onRendered={props.onContentRendered} /> )} diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 749a420a..542ee5af 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -13,8 +13,10 @@ interface MessagePartProps { messageType?: "user" | "assistant" instanceId: string sessionId: string -} -export default function MessagePart(props: MessagePartProps) { + onRendered?: () => void + } + export default function MessagePart(props: MessagePartProps) { + const { isDark } = useTheme() const { preferences } = useConfig() const partType = () => props.part?.type || "" @@ -95,11 +97,17 @@ export default function MessagePart(props: MessagePartProps) {
{plainTextContent()}} - > - - + when={isAssistantMessage()} + fallback={{plainTextContent()}} + > + + +
diff --git a/packages/ui/src/components/message-stream-v2.tsx b/packages/ui/src/components/message-stream-v2.tsx index 1ef395ec..e5abc7c7 100644 --- a/packages/ui/src/components/message-stream-v2.tsx +++ b/packages/ui/src/components/message-stream-v2.tsx @@ -17,7 +17,6 @@ import { useScrollCache } from "../lib/hooks/use-scroll-cache" import { setActiveInstanceId } from "../stores/instances" const SCROLL_SCOPE = "session" -const SCROLL_DIRECTION_THRESHOLD = 10 const USER_SCROLL_INTENT_WINDOW_MS = 600 const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"]) @@ -174,6 +173,7 @@ interface MessageStreamV2Props { loading?: boolean onRevert?: (messageId: string) => void onFork?: (messageId?: string) => void + registerScrollToBottom?: (fn: () => void) => void } interface ContentDisplayItem { @@ -284,15 +284,10 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { }) const changeToken = createMemo(() => { - const revisionValue = sessionRevision() - const ids = messageIds() - if (ids.length === 0) { - return `${revisionValue}:empty` - } - const lastId = ids[ids.length - 1] - const lastRecord = store().getMessage(lastId) - const tailSignature = lastRecord ? `msg:${lastRecord.id}:${lastRecord.revision}` : `msg:${lastId}:missing` - return `${revisionValue}:${tailSignature}` + // Any change that can affect layout (new message, part update, revert, + // etc.) should bump the session revision. We use this as the primary + // signal for auto-scroll decisions. + return String(sessionRevision()) }) const scrollCache = useScrollCache({ @@ -312,6 +307,10 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { let detachScrollIntentListeners: (() => void) | undefined let hasRestoredScroll = false let hasInitialScroll = false + // When the user explicitly clicks "scroll to bottom", we want the + // smooth scroll animation to complete without being immediately + // overridden by the auto-scroll effects that react to new messages. + let suppressAutoScrollOnce = false function markUserScrollIntent() { const now = typeof performance !== "undefined" ? performance.now() : Date.now() @@ -372,15 +371,18 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { function scrollToBottom(immediate = false) { if (!containerRef) return const behavior = immediate ? "auto" : "smooth" - requestAnimationFrame(() => { - if (!containerRef) return - containerRef.scrollTo({ top: containerRef.scrollHeight, behavior }) - setAutoScroll(true) - lastMeasuredScrollHeight = containerRef.scrollHeight - lastKnownScrollTop = containerRef.scrollTop - updateScrollIndicators(containerRef) - scheduleScrollPersist() - }) + if (!immediate) { + // We initiated this scroll (e.g., via the button). Skip the + // next auto-scroll reaction so the smooth animation isn't + // overridden by changeToken/preference effects. + suppressAutoScrollOnce = true + } + containerRef.scrollTo({ top: containerRef.scrollHeight, behavior }) + setAutoScroll(true) + lastMeasuredScrollHeight = containerRef.scrollHeight + lastKnownScrollTop = containerRef.scrollTop + updateScrollIndicators(containerRef) + scheduleScrollPersist() } function scrollToBottomAndClamp(immediate = false) { @@ -396,17 +398,28 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { if (!containerRef) return const behavior = immediate ? "auto" : "smooth" setAutoScroll(false) - requestAnimationFrame(() => { - if (!containerRef) return - containerRef.scrollTo({ top: 0, behavior }) - lastMeasuredScrollHeight = containerRef.scrollHeight - lastKnownScrollTop = containerRef.scrollTop - updateScrollIndicators(containerRef) - scheduleScrollPersist() - }) + containerRef.scrollTo({ top: 0, behavior }) + lastMeasuredScrollHeight = containerRef.scrollHeight + lastKnownScrollTop = containerRef.scrollTop + updateScrollIndicators(containerRef) + scheduleScrollPersist() } + + function handleContentRendered() { + if (!containerRef) return + if (!autoScroll()) return + scrollToBottomAndClamp(true) + } + + createEffect(() => { + if (props.registerScrollToBottom) { + props.registerScrollToBottom(() => scrollToBottomAndClamp(true)) + } + }) - let pendingScrollPersist: number | null = null + let pendingScrollPersist: number | null = null + + function scheduleScrollPersist() { if (pendingScrollPersist !== null) return pendingScrollPersist = requestAnimationFrame(() => { @@ -439,20 +452,22 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { pendingScrollFrame = requestAnimationFrame(() => { pendingScrollFrame = null if (!containerRef) return - const previousTop = lastKnownScrollTop const currentTop = containerRef.scrollTop - const movingUp = currentTop < previousTop - SCROLL_DIRECTION_THRESHOLD - const movingDown = currentTop > previousTop + SCROLL_DIRECTION_THRESHOLD lastKnownScrollTop = currentTop lastMeasuredScrollHeight = containerRef.scrollHeight const atBottom = isNearBottom(containerRef) + if (isUserScroll) { - if (movingUp && !atBottom && autoScroll()) { - setAutoScroll(false) - } else if (movingDown && atBottom && !autoScroll()) { - setAutoScroll(true) + // If the user scrolls and ends near the bottom, enable auto-scroll. + // If they scroll away from the bottom by more than our threshold, + // disable auto-scroll until they explicitly return. + if (atBottom) { + if (!autoScroll()) setAutoScroll(true) + } else { + if (autoScroll()) setAutoScroll(false) } } + updateScrollIndicators(containerRef) scheduleScrollPersist() }) @@ -465,7 +480,7 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { if (!target) return if (loading) return if (hasRestoredScroll) return - + scrollCache.restore(target, { onApplied: (snapshot) => { if (snapshot) { @@ -478,12 +493,12 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { updateScrollIndicators(target) }, }) - + hasRestoredScroll = true }) let previousToken: string | undefined - + createEffect(() => { const token = changeToken() const loading = props.loading @@ -493,19 +508,28 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { return } previousToken = token + if (suppressAutoScrollOnce) { + suppressAutoScrollOnce = false + return + } if (autoScroll()) { scrollToBottomAndClamp(true) } }) - + createEffect(() => { preferenceSignature() if (props.loading) return if (!autoScroll()) { return } + if (suppressAutoScrollOnce) { + suppressAutoScrollOnce = false + return + } scrollToBottomAndClamp(true) }) + createEffect(() => { if (messageIds().length === 0) { @@ -621,18 +645,21 @@ export default function MessageStreamV2(props: MessageStreamV2Props) { {(messageId) => ( preferences().showThinkingBlocks} - thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"} - showUsageMetrics={showUsagePreference} - onRevert={props.onRevert} - onFork={props.onFork} - /> + messageId={messageId()} + instanceId={props.instanceId} + sessionId={props.sessionId} + store={store} + messageIndexMap={messageIndexMap} + lastAssistantIndex={lastAssistantIndex} + showThinking={() => preferences().showThinkingBlocks} + thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"} + showUsageMetrics={showUsagePreference} + onRevert={props.onRevert} + onFork={props.onFork} + onContentRendered={handleContentRendered} + /> + + )} @@ -681,8 +708,10 @@ interface MessageBlockProps { showUsageMetrics: () => boolean onRevert?: (messageId: string) => void onFork?: (messageId?: string) => void + onContentRendered?: () => void } + function MessageBlock(props: MessageBlockProps) { const record = createMemo(() => props.store().getMessage(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) @@ -882,7 +911,9 @@ function MessageBlock(props: MessageBlockProps) { showAgentMeta={(item as ContentDisplayItem).showAgentMeta} onRevert={props.onRevert} onFork={props.onFork} + onContentRendered={props.onContentRendered} /> + {(() => { diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index 19cd7e30..7272752b 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -25,8 +25,10 @@ export const SessionView: Component = (props) => { const session = () => props.activeSessions.get(props.sessionId) const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId)) const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId)) + let scrollToBottomHandle: (() => void) | undefined + + createEffect(() => { - createEffect(() => { const currentSession = session() if (currentSession) { loadMessages(props.instanceId, currentSession.id).catch(console.error) @@ -34,6 +36,9 @@ export const SessionView: Component = (props) => { }) async function handleSendMessage(prompt: string, attachments: Attachment[]) { + if (scrollToBottomHandle) { + scrollToBottomHandle() + } await sendMessage(props.instanceId, props.sessionId, prompt, attachments) } @@ -137,12 +142,16 @@ export const SessionView: Component = (props) => { return (
+ instanceId={props.instanceId} + sessionId={activeSession.id} + loading={messagesLoading()} + onRevert={handleRevert} + onFork={handleFork} + registerScrollToBottom={(fn) => { + scrollToBottomHandle = fn + }} + /> + value + 1) + } } function attachPermissionToToolPart(instanceId: string, permission: Permission, active: boolean): void { diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index 5aace3d0..111d256b 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -439,10 +439,10 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS bufferPendingPart({ messageId: input.messageId, part: input.part, receivedAt: Date.now() }) return } - + const partId = ensurePartId(input.messageId, input.part, message.partIds.length) const cloned = clonePart(input.part) - + setState( "messages", input.messageId, @@ -463,8 +463,13 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS } }), ) + + // Any part update can change the rendered height of the message + // list, so we treat it as a session revision for scroll purposes. + bumpSessionRevision(message.sessionId) } + function flushPendingParts(messageId: string) { const pending = state.pendingParts[messageId] if (!pending || pending.length === 0) { diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 5c6962ff..5c23cb85 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -81,20 +81,25 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes if (event.type === "message.part.updated") { const rawPart = event.properties?.part if (!rawPart) return - + const part = normalizeMessagePart(rawPart) - const sessionId = typeof part.sessionID === "string" ? part.sessionID : undefined - const messageId = typeof part.messageID === "string" ? part.messageID : undefined + const messageInfo = (event as any)?.properties?.message as MessageInfo | undefined + + const fallbackSessionId = typeof messageInfo?.sessionID === "string" ? messageInfo.sessionID : undefined + const fallbackMessageId = typeof messageInfo?.id === "string" ? messageInfo.id : undefined + + const sessionId = typeof part.sessionID === "string" ? part.sessionID : fallbackSessionId + const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId if (!sessionId || !messageId) return - + const session = instanceSessions.get(sessionId) if (!session) return - + const store = messageStoreBus.getOrCreate(instanceId) - const messageInfo = (event as any)?.properties?.message as MessageInfo | undefined const role: MessageRole = resolveMessageRole(messageInfo) const createdAt = typeof messageInfo?.time?.created === "number" ? messageInfo.time.created : Date.now() + let record = store.getMessage(messageId) if (!record) { const pendingId = findPendingMessageId(store, sessionId, role) @@ -119,8 +124,9 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes if (messageInfo) { upsertMessageInfoV2(instanceId, messageInfo, { status: "streaming" }) } + + applyPartUpdateV2(instanceId, { ...part, sessionID: sessionId, messageID: messageId }) - applyPartUpdateV2(instanceId, part) updateSessionInfo(instanceId, sessionId) refreshPermissionsForSession(instanceId, sessionId)