From 5110803d3e8e0200316297faf17caa4acc0af30a Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Fri, 9 Oct 2026 16:09:11 -0400 Subject: [PATCH] Fix V2 incoming message scroll position Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../v2_ui/src/components/chat/MessageList.tsx | 125 ++--- .../components/chat/PendingActionSlots.tsx | 12 +- .../src/components/chat/useMessageScroll.ts | 264 ++++++++++ application/v2_ui/src/lib/messageScroll.ts | 52 ++ application/v2_ui/src/stores/chatStore.ts | 10 + .../V2_CHAT_MESSAGE_SCROLL_ANCHOR_FIX.md | 118 +++++ docs/guides/collaborate-in-a-conversation.md | 12 + docs/reference/chat-controls.md | 23 +- .../test_v2_chat_message_scroll.mjs | 45 ++ ui_tests/test_v2_chat_message_scroll.py | 460 ++++++++++++++++++ 11 files changed, 1040 insertions(+), 83 deletions(-) create mode 100644 application/v2_ui/src/components/chat/useMessageScroll.ts create mode 100644 application/v2_ui/src/lib/messageScroll.ts create mode 100644 docs/explanation/fixes/V2_CHAT_MESSAGE_SCROLL_ANCHOR_FIX.md create mode 100644 functional_tests/test_v2_chat_message_scroll.mjs create mode 100644 ui_tests/test_v2_chat_message_scroll.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 3ba6aef1f..463e5952d 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.317" +VERSION = "0.261.318" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/v2_ui/src/components/chat/MessageList.tsx b/application/v2_ui/src/components/chat/MessageList.tsx index f92942f97..0e34c8f80 100644 --- a/application/v2_ui/src/components/chat/MessageList.tsx +++ b/application/v2_ui/src/components/chat/MessageList.tsx @@ -52,6 +52,7 @@ import { PlanReplaySaveCard } from './PlanReplaySaveCard'; import { WorkflowDeliveryFooter } from './WorkflowDeliveryFooter'; import { MessageInspector, type InspectorSection } from './MessageInspector'; import { ThoughtsList, ThoughtsProgressCard } from './ThoughtsList'; +import { useMessageScroll } from './useMessageScroll'; import { OrchestrationPlanCard } from './OrchestrationPlanCard'; import { ElicitationCard } from './ElicitationCard'; import { @@ -1354,7 +1355,7 @@ function StreamingBubble() { } return ( -
+
{/* Shown while connecting even when partial content is already on screen: without it, a response that stopped mid-sentence just looks frozen. */} @@ -1532,78 +1533,12 @@ export function MessageList() { streamError, streamAuthUrl, activeConversationId, + completedReply, } = useChatStore(); const safeStreamAuthUrl = normalizeFoundryAuthUrl(streamAuthUrl); const appTitle = useBootstrapStore((state) => state.data?.branding?.app_title); const chatWidth = useUiStore((state) => state.chatWidth); - const scrollRef = useRef(null); - - /** - * Whether the reader is at the bottom of the thread. - * - * A ref rather than state on purpose. Held in state, every scroll that crossed the - * threshold re-rendered the list and with it every message's markdown, which is expensive - * enough to stall a thread containing a large diagram. Nothing on screen depends on it, so - * nothing needs to re-render when it changes. - */ - const pinnedRef = useRef(true); - - const scrollToBottom = useCallback(() => { - const element = scrollRef.current; - if (element) { - // Set directly rather than through `scrollIntoView`, which also scrolls every - // scrollable ancestor and can drag the page itself around. - element.scrollTop = element.scrollHeight; - } - }, []); - - // Auto-scroll only while the user is already at the bottom, so reading back through a - // long answer is not interrupted by incoming tokens. - useEffect(() => { - if (pinnedRef.current) { - scrollToBottom(); - } - }, [messages, streamingContent, scrollToBottom]); - - /** - * Follow content that grows after it was laid out. - * - * A diagram renders asynchronously: a 96px placeholder is replaced by a panel that can be - * several hundred pixels tall, long after the scroll that was meant to land at the bottom. - * Nothing re-ran, so the reader was left above the end of the thread, chasing a target that - * moved every time another diagram finished. - */ - useEffect(() => { - const element = scrollRef.current; - if (!element || typeof ResizeObserver === 'undefined') { - return; - } - // The content, not the viewport: the viewport's own size changing is a window resize, - // which should not yank the reader to the bottom. - const content = element.firstElementChild; - if (!content) { - return; - } - const observer = new ResizeObserver(() => { - if (pinnedRef.current) { - scrollToBottom(); - } - }); - observer.observe(content); - return () => observer.disconnect(); - }, [scrollToBottom]); - - const onScroll = () => { - const element = scrollRef.current; - if (!element) { - return; - } - const distanceFromBottom = - element.scrollHeight - element.scrollTop - element.clientHeight; - pinnedRef.current = distanceFromBottom < 80; - }; - const isEmpty = useMemo( () => messages.length === 0 && !streaming && !messagesLoading, [messages.length, streaming, messagesLoading], @@ -1659,10 +1594,27 @@ export function MessageList() { return { threadMessages: visible, proposalImagesByMessage: grouped }; }, [messages]); - return ( + const { + scrollRef, hasNewMessages, onScroll, onWheel, onKeyDown, onTouchStart, onPointerDown, jumpToNewest, pauseFollowing, + } = useMessageScroll({ + conversationId: activeConversationId, + messages: threadMessages, + loading: messagesLoading, + streaming, + content: streamingContent, + completedReply, + }); + + const messagePane = (
@@ -1696,14 +1648,15 @@ export function MessageList() { {/* Streaming output is announced politely so screen reader users are told a response arrived without every token interrupting them. */} - +
{threadMessages.map((message) => ( - +
+ +
))} {streaming && } @@ -1739,4 +1692,26 @@ export function MessageList() {
); + + return ( +
+ {messagePane} + + {hasNewMessages ? 'New messages below.' : ''} + + {hasNewMessages && ( +
+ +
+ )} +
+ ); } diff --git a/application/v2_ui/src/components/chat/PendingActionSlots.tsx b/application/v2_ui/src/components/chat/PendingActionSlots.tsx index bec031f19..23cd2a013 100644 --- a/application/v2_ui/src/components/chat/PendingActionSlots.tsx +++ b/application/v2_ui/src/components/chat/PendingActionSlots.tsx @@ -178,7 +178,7 @@ function releaseHighlight(ref: { current: Highlight | null }) { * moment and focused, and the request is spent. If the card is not in the list it is fetched by * id; only when that also fails does the person hear that it is unavailable. */ -function usePendingActionFocus(scrollRef: RefObject, pinnedRef: { current: boolean }) { +function usePendingActionFocus(scrollRef: RefObject, pauseFollowing: () => void) { const api = usePendingActionsStoreApi(); const request = usePendingActions((state) => state.focusRequest); const conversationId = usePendingActions((state) => state.conversationId); @@ -207,7 +207,7 @@ function usePendingActionFocus(scrollRef: RefObject, pinnedRef: { c if (node) { // Leave auto-scroll alone: it would otherwise pull the thread back to the bottom. - pinnedRef.current = false; + pauseFollowing(); const rootBox = root.getBoundingClientRect(); const nodeBox = node.getBoundingClientRect(); const margin = Math.max(16, (root.clientHeight - nodeBox.height) / 2); @@ -246,7 +246,7 @@ function usePendingActionFocus(scrollRef: RefObject, pinnedRef: { c setUnavailableFor(state.conversationId); state.clearFocus(); }); - }, [api, conversationId, listStatus, messagesLoading, pinnedRef, placement, present, referenceLoading, request, scrollRef]); + }, [api, conversationId, listStatus, messagesLoading, pauseFollowing, placement, present, referenceLoading, request, scrollRef]); // Only on unmount: clearing the focus request re-runs the effect above, which must not // cancel the highlight it just applied. @@ -266,10 +266,10 @@ function usePendingActionFocus(scrollRef: RefObject, pinnedRef: { c */ export function PendingActionsConversationSection({ scrollRef, - pinnedRef, + pauseFollowing, }: { scrollRef: RefObject; - pinnedRef: { current: boolean }; + pauseFollowing: () => void; }) { const api = usePendingActionsStoreApi(); const headingId = useId(); @@ -283,7 +283,7 @@ export function PendingActionsConversationSection({ const listLoading = usePendingActions((state) => state.listStatus === 'loading'); usePendingActionsLifecycle(activeConversationId); - const { unavailable, dismissUnavailable } = usePendingActionFocus(scrollRef, pinnedRef); + const { unavailable, dismissUnavailable } = usePendingActionFocus(scrollRef, pauseFollowing); // Held back while history loads, so a card is not shown here for an instant and then jump // under its message. diff --git a/application/v2_ui/src/components/chat/useMessageScroll.ts b/application/v2_ui/src/components/chat/useMessageScroll.ts new file mode 100644 index 000000000..fa3e31448 --- /dev/null +++ b/application/v2_ui/src/components/chat/useMessageScroll.ts @@ -0,0 +1,264 @@ +// useMessageScroll.ts +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { + isMessageNearBottom, + messageMatchesScrollId, + messageScrollAliases, + messageStartScrollTop, + newestMessageArrival, + type CompletedReply, +} from '../../lib/messageScroll'; +import type { ChatMessage } from '../../lib/types'; +import type { KeyboardEvent, WheelEvent } from 'react'; + +interface ScrollInput { + conversationId: string | null; + messages: ChatMessage[]; + loading: boolean; + streaming: boolean; + content: string; + completedReply: CompletedReply | null; +} + +interface ReadingAnchor { + node: HTMLElement; + offset: number; +} + +export function useMessageScroll(input: ScrollInput) { + const scrollRef = useRef(null); + const previous = useRef(null); + const latest = useRef(input); + latest.current = input; + const mode = useRef<'following' | 'reading' | 'manual'>('following'); + const pinnedRef = useRef(true); + const caughtUp = useRef(true); + const locked = useRef(false); + const expectedTop = useRef(null); + const lastTop = useRef(0); + const anchor = useRef(null); + const liveAcknowledged = useRef(false); + const unseenLive = useRef(false); + const [hasNewMessages, setHasNewMessages] = useState(false); + + const writeTop = useCallback((top: number) => { + const root = scrollRef.current; + if (!root) return; + root.scrollTop = top; + expectedTop.current = root.scrollTop; + lastTop.current = root.scrollTop; + caughtUp.current = isMessageNearBottom(root.scrollTop, root.scrollHeight, root.clientHeight); + }, []); + + const findMessage = useCallback((id: string) => { + const message = latest.current.messages.find((item) => messageMatchesScrollId(item, id)); + return message ? Array.from( + scrollRef.current?.querySelectorAll('[data-scroll-message-id]') ?? [], + ).find((node) => node.dataset.scrollMessageId === message.id) ?? null : null; + }, []); + + const liveNode = useCallback(() => + scrollRef.current?.querySelector('[data-scroll-live-reply]') ?? null, []); + + const captureAnchor = useCallback(() => { + const root = scrollRef.current; + if (!root) return; + const box = root.getBoundingClientRect(); + const node = Array.from(root.querySelectorAll( + '[data-scroll-message-id], [data-scroll-live-reply]', + )).find((item) => item.getBoundingClientRect().bottom > box.top); + anchor.current = node ? { node, offset: node.getBoundingClientRect().top - box.top } : null; + }, []); + + const pauseFollowing = useCallback(() => { + pinnedRef.current = false; + mode.current = 'manual'; + locked.current = true; + anchor.current = null; + }, []); + + const readMessage = useCallback((node: HTMLElement, explicit = false) => { + const root = scrollRef.current; + if (!root) return; + pinnedRef.current = false; + mode.current = 'reading'; + locked.current = explicit; + writeTop(messageStartScrollTop(root.scrollTop, root.getBoundingClientRect().top, node.getBoundingClientRect().top)); + anchor.current = { node, offset: node.getBoundingClientRect().top - root.getBoundingClientRect().top }; + setHasNewMessages(false); + unseenLive.current = false; + }, [writeTop]); + + const newestNode = useCallback(() => { + const state = latest.current; + if (state.streaming && state.content) return liveNode(); + const message = state.messages.at(-1); + return message ? findMessage(message.id) : null; + }, [findMessage, liveNode]); + + const jumpToNewest = useCallback(() => { + const node = newestNode(); + if (!node) return; + liveAcknowledged.current = true; + readMessage(node, true); + }, [newestNode, readMessage]); + + const onScroll = useCallback(() => { + const root = scrollRef.current; + if (!root) return; + const top = root.scrollTop; + if (expectedTop.current !== null && Math.abs(top - expectedTop.current) < 2) { + expectedTop.current = null; + return; + } + expectedTop.current = null; + const movingUp = top < lastTop.current; + caughtUp.current = isMessageNearBottom(top, root.scrollHeight, root.clientHeight); + pinnedRef.current = !locked.current && !movingUp && caughtUp.current; + mode.current = pinnedRef.current ? 'following' : 'manual'; + lastTop.current = top; + if (pinnedRef.current) { + anchor.current = null; + liveAcknowledged.current = false; + setHasNewMessages(false); + } else { + captureAnchor(); + const node = newestNode(); + if (node) { + const start = node.getBoundingClientRect().top; + const box = root.getBoundingClientRect(); + if (start >= box.top && start < box.bottom) { + liveAcknowledged.current = true; + setHasNewMessages(false); + } + } + } + }, [captureAnchor, newestNode]); + + const onWheel = useCallback((event: WheelEvent) => { + locked.current = false; + if (event.deltaY < 0) { + pinnedRef.current = false; + mode.current = 'manual'; + captureAnchor(); + } + }, [captureAnchor]); + + const onKeyDown = useCallback((event: KeyboardEvent) => { + if (['ArrowUp', 'PageUp', 'Home', 'ArrowDown', 'PageDown', 'End', ' '].includes(event.key)) { + locked.current = false; + if (['ArrowUp', 'PageUp', 'Home'].includes(event.key)) { + pinnedRef.current = false; + mode.current = 'manual'; + captureAnchor(); + } + } + }, [captureAnchor]); + + useLayoutEffect(() => { + const root = scrollRef.current; + const before = previous.current; + previous.current = input; + if (!root) return; + const assignedFirstId = before?.conversationId === null && input.conversationId !== null + && !input.loading && (before.streaming || before.messages.some((item) => item.id.startsWith('pending-user-'))); + const changedConversation = before && before.conversationId !== input.conversationId && !assignedFirstId; + if (!before || changedConversation || before.loading) { + mode.current = 'following'; + pinnedRef.current = true; + locked.current = false; + anchor.current = null; + liveAcknowledged.current = false; + unseenLive.current = false; + setHasNewMessages(false); + if (!input.loading) writeTop(root.scrollHeight); + return; + } + if (input.loading) return; + + if (anchor.current && !anchor.current.node.isConnected && !before.streaming) { + const oldId = anchor.current.node.dataset.scrollMessageId; + const oldMessage = before.messages.find((message) => message.id === oldId); + const replacement = oldMessage ? messageScrollAliases(oldMessage) + .map((alias) => findMessage(alias.substring(3))).find((node) => node !== null) : null; + if (replacement) anchor.current.node = replacement; + } + const following = pinnedRef.current || (mode.current === 'reading' && !locked.current && caughtUp.current); + const completedReply = input.completedReply; + const completion = completedReply && completedReply !== before.completedReply + && completedReply.conversationId === input.conversationId; + const arrival = input.messages !== before.messages ? newestMessageArrival(before.messages, input.messages) : null; + if (input.streaming && !before.streaming) { + liveAcknowledged.current = false; + unseenLive.current = false; + if (following) { + pinnedRef.current = true; + mode.current = 'following'; + anchor.current = null; + } + } + + if (completion && completedReply) { + const node = findMessage(completedReply.messageId); + if (node && following) { + readMessage(node); + } else if (node) { + if (anchor.current && !anchor.current.node.isConnected) anchor.current.node = node; + if (!liveAcknowledged.current) setHasNewMessages(true); + } + } else if (before.streaming && !input.streaming) { + // Stopping or failing a stream is not successful completion. Keep the partial + // reply at the same reading offset rather than jumping to an older answer. + if (anchor.current && !anchor.current.node.isConnected) { + const node = newestNode(); + anchor.current = node ? { node, offset: anchor.current.offset } : null; + } + pinnedRef.current = false; + mode.current = 'reading'; + if (!arrival && unseenLive.current) setHasNewMessages(false); + } else if (arrival && !input.streaming) { + const node = findMessage(arrival.id); + if (node && following) readMessage(node); + else if (node) { + unseenLive.current = false; + setHasNewMessages(true); + } + } else if (arrival && !following) { + unseenLive.current = false; + setHasNewMessages(true); + } + + if (input.streaming && pinnedRef.current) { + writeTop(root.scrollHeight); + } else if (input.streaming && input.content !== before.content && input.content && !liveAcknowledged.current) { + unseenLive.current = true; + setHasNewMessages(true); + } + }, [input.conversationId, input.messages, input.loading, input.streaming, input.content, input.completedReply, + findMessage, newestNode, readMessage, writeTop]); + + useEffect(() => { + const root = scrollRef.current; + const content = root?.firstElementChild; + if (!root || !content) return; + const observer = new ResizeObserver((entries) => { + if (pinnedRef.current && entries.some((entry) => entry.target === content)) { + writeTop(root.scrollHeight); + } else if (!pinnedRef.current && anchor.current?.node.isConnected) { + const delta = anchor.current.node.getBoundingClientRect().top + - root.getBoundingClientRect().top - anchor.current.offset; + if (Math.abs(delta) > 1) writeTop(root.scrollTop + delta); + } + caughtUp.current = isMessageNearBottom(root.scrollTop, root.scrollHeight, root.clientHeight); + }); + observer.observe(content); + observer.observe(root); + return () => observer.disconnect(); + }, [writeTop]); + + return { + scrollRef, hasNewMessages, onScroll, onWheel, onKeyDown, jumpToNewest, pauseFollowing, + onTouchStart: () => { locked.current = false; }, + onPointerDown: () => { locked.current = false; }, + }; +} diff --git a/application/v2_ui/src/lib/messageScroll.ts b/application/v2_ui/src/lib/messageScroll.ts new file mode 100644 index 000000000..0be3cab14 --- /dev/null +++ b/application/v2_ui/src/lib/messageScroll.ts @@ -0,0 +1,52 @@ +// messageScroll.ts +import type { ChatMessage } from './types'; + +export const MESSAGE_READING_MARGIN = 24; +export const MESSAGE_FOLLOW_THRESHOLD = 80; + +export interface CompletedReply { + conversationId: string; + messageId: string; +} + +export function messageScrollAliases(message: ChatMessage): string[] { + const aliases = [`id:${message.id}`]; + const source = message.metadata?.source_message_id; + if (typeof source === 'string' && source) aliases.push(`id:${source}`); + return aliases; +} + +function threadScrollKey(message: ChatMessage): string | null { + const thread = message.metadata?.thread_info; + return thread && typeof thread === 'object' && 'thread_id' in thread && typeof thread.thread_id === 'string' + ? `${message.role}:${thread.thread_id}` : null; +} + +export function messageMatchesScrollId(message: ChatMessage, id: string): boolean { + return messageScrollAliases(message).includes(`id:${id}`); +} + +/** Updates, attempt switches and optimistic acknowledgements are not new tail messages. */ +export function newestMessageArrival(previous: ChatMessage[], current: ChatMessage[]): ChatMessage | null { + const known = new Set(previous.flatMap(messageScrollAliases)); + const currentAliases = new Set(current.flatMap(messageScrollAliases)); + const replaced = previous.filter((message) => !messageScrollAliases(message).some((alias) => currentAliases.has(alias))); + const pending = replaced.filter((message) => message.id.startsWith('pending-user-')); + const replacedThreads = new Set(replaced.map(threadScrollKey).filter((key) => key !== null)); + const recognized = current.map((message) => + messageScrollAliases(message).some((alias) => known.has(alias)) + || replacedThreads.has(threadScrollKey(message) ?? '') + || pending.some((item) => item.role === message.role && item.content === message.content), + ); + if (previous.length > 0 && !recognized.some(Boolean)) return null; + const newest = current.at(-1); + return newest && !recognized.at(-1) ? newest : null; +} + +export function messageStartScrollTop(scrollTop: number, viewportTop: number, messageTop: number): number { + return Math.max(0, scrollTop + messageTop - viewportTop - MESSAGE_READING_MARGIN); +} + +export function isMessageNearBottom(scrollTop: number, scrollHeight: number, viewportHeight: number): boolean { + return scrollHeight - scrollTop - viewportHeight < MESSAGE_FOLLOW_THRESHOLD; +} diff --git a/application/v2_ui/src/stores/chatStore.ts b/application/v2_ui/src/stores/chatStore.ts index 01d0f5274..e0a41ddd5 100644 --- a/application/v2_ui/src/stores/chatStore.ts +++ b/application/v2_ui/src/stores/chatStore.ts @@ -354,6 +354,8 @@ interface ChatState { streaming: boolean; streamingContent: string; + /** Identifies the reply that actually completed, even if its shared broadcast arrived first. */ + completedReply: { conversationId: string; messageId: string } | null; streamingReasoningAdjustments: ReasoningResolution[]; thoughts: ThoughtEntry[]; streamError: string | null; @@ -1434,6 +1436,7 @@ function buildStreamHandlers( state.messages, finalMessage as CollaborationMessage, ), + completedReply: { conversationId, messageId: finalMessage.id }, streaming: false, streamingContent: '', streamingReasoningAdjustments: [], @@ -1492,6 +1495,7 @@ function buildStreamHandlers( set({ streaming: false, streamingContent: '', + completedReply: null, streamingReasoningAdjustments: [], reconnectPhase: null, streamError: refusal?.message ?? message, @@ -2215,6 +2219,7 @@ export const useChatStore = create((set, get) => ({ streaming: false, streamingContent: '', + completedReply: null, thoughts: [], streamingReasoningAdjustments: [], streamError: null, @@ -2325,6 +2330,7 @@ export const useChatStore = create((set, get) => ({ analysisTurnRevision: analysisConversationChanged ? null : get().analysisTurnRevision, messages: [], messagesError: null, + completedReply: null, // Anything streaming belonged to the conversation being left. An orchestration turn // still running in this tab for the one being opened gets its Thinking state and // Stop back, which also keeps the composer from sending into a busy turn; its later @@ -2544,6 +2550,7 @@ export const useChatStore = create((set, get) => ({ // behind loading placeholders instead of its empty state. messagesLoading: false, messagesError: null, + completedReply: null, // Cleared here and not only by `detachActiveStream`, which knows about chat streams // alone. An orchestration turn holds this flag without one, and its settle is skipped // once its conversation is off screen, so the new chat kept the old turn's Thinking @@ -3644,6 +3651,9 @@ export const useChatStore = create((set, get) => ({ messages, finalMessage as CollaborationMessage, ), + ...(outcome.status === 'completed' + ? { completedReply: { conversationId, messageId: finalMessage.id } } + : {}), streaming: false, orchestrationSurface: null, streamingContent: '', diff --git a/docs/explanation/fixes/V2_CHAT_MESSAGE_SCROLL_ANCHOR_FIX.md b/docs/explanation/fixes/V2_CHAT_MESSAGE_SCROLL_ANCHOR_FIX.md new file mode 100644 index 000000000..74ad41115 --- /dev/null +++ b/docs/explanation/fixes/V2_CHAT_MESSAGE_SCROLL_ANCHOR_FIX.md @@ -0,0 +1,118 @@ +# V2 Chat Message Scroll Anchoring Fix (v0.261.318) + +## Issue + +A long incoming message took the reader to its end instead of its beginning. +After an AI response finished streaming, reading it required scrolling backward. +Readers looking through earlier history had no V2 new-message arrow to return +to the current discussion without losing their place automatically. + +## Fixed/Implemented in version: **0.261.318** + +The application version is recorded in `application/single_app/config.py`. +This fix applies to React V2 personal and collaborative conversations, including +shared group conversations. The classic interface is unchanged. + +## Root cause + +`MessageList.tsx` followed `scrollHeight` whenever the messages array or live +content changed. Its resize observer repeated that bottom-scroll when an image, +diagram, or card grew after rendering. A completed long reply therefore stayed +at the bottom, and changing just the message effect would not fix delayed growth. + +Array changes also describe edits, optimistic ID acknowledgements, attempt +switches, and shared-message echoes. They do not necessarily mean a new message +has arrived. + +## Behavior + +| Situation | Reading behavior | +| --- | --- | +| A completed message arrives while the reader is caught up | Its beginning appears with a small reading margin. Short messages remain visible within the available scroll range. | +| An AI reply is streaming and the reader is following | The latest streaming line stays visible. | +| The reply finishes while the reader is still following | The pane returns once to the beginning of that completed reply. | +| The reader scrolls upward during a stream or reads earlier history | New content and completion preserve their reading position. | +| New content arrives while the reader is looking earlier | **New messages**, with a down arrow, appears above the composer. | +| The reader activates the arrow | The newest rendered message opens at its beginning. The indication clears and live output does not immediately drag the reader down again. | + +Manually scrolling back to the bottom resumes live following. The indicator is +local to the message pane, not a server unread count, and does not announce every +streaming token. Loading saved history retains the existing history-opening +behavior. Changing conversations or starting a new chat clears the local state. + +## Technical details + +| File | Change | +| --- | --- | +| `application/v2_ui/src/lib/messageScroll.ts` | Typed arrival identity, message-start geometry, and the existing 80-pixel follow threshold. Updates, source-message echoes, removed optimistic placeholders, and replaced thread attempts are not new arrivals. | +| `application/v2_ui/src/components/chat/useMessageScroll.ts` | Separate live-following, message-reading, and manual-reading modes. Ref-based geometry and reading anchors keep delayed content growth from forcing a bottom jump. | +| `application/v2_ui/src/components/chat/MessageList.tsx` | Stable rendered-message/live-reply targets and a keyboard-operable new-content arrow using existing local icons and theme tokens. Message bubbles remain memoized. | +| `application/v2_ui/src/stores/chatStore.ts` | A client-only completed-reply reference identifies the actual saved reply for normal and orchestrated stream completion, even when its shared broadcast arrived first. | +| `application/v2_ui/src/components/chat/PendingActionSlots.tsx` | Explicit notification-card navigation pauses automatic following before moving and focusing the card. | +| `application/single_app/config.py` | Application patch version to `0.261.318`. | + +Scrolling is contained within the chat pane. Automatic positioning does not move +keyboard focus or scroll the page. Jumps are immediate, including with reduced +motion enabled. No backend endpoints, SSE payload contracts, permissions, server +read receipts, capability settings, dependencies, or deployment versions change. + +An interrupted, stopped, or planning-only stream is not treated as successful +answer completion. Partial saved replies remain readable without navigating to +an older reply. + +## Testing and validation + +`functional_tests/test_v2_chat_message_scroll.mjs` executes the production pure +helpers for identity reconciliation, thread replacement, viewport offsets, and +the exact near-bottom threshold. + +`ui_tests/test_v2_chat_message_scroll.py` mounts the production message list, +composer, stores, and CSS. It checks real viewport geometry for long human and AI +messages, incremental streaming, completion, manual override, arrow navigation, +shared terminal/broadcast ordering, ID reconciliation, delayed growth, loading, +conversation resets, short messages, and non-answer terminal states. It also +exercises the production personal SSE reader with controlled frames and the +actual orchestration completion handler. + +The shared Playwright connection fixture supports local Chromium or a configured +Azure Playwright workspace without provisioning resources or storing credentials. +Desktop/mobile and light/dark checks verify that the arrow remains inside the +message pane above the composer. Related streaming, collaboration, and outgoing +action-card suites protect existing behavior. + +### Commands + +```powershell +node .\functional_tests\test_v2_chat_message_scroll.mjs +npm --prefix .\application\v2_ui run build -- --outDir ..\..\ui_tests\artifacts\orchestration-plan-editor +python -m pytest .\ui_tests\test_v2_chat_message_scroll.py .\ui_tests\test_v2_orchestration_streaming_bubble.py .\ui_tests\test_v2_collaboration_ux.py .\ui_tests\test_v2_m365_pending_action_cards.py -q -k "not composer_mentions_become_chips_and_are_sent_as_mentions" +python -m pytest .\functional_tests\test_v2_stream_reconnect.py .\functional_tests\test_v2_stream_leave_without_cancel.py .\functional_tests\test_v2_m365_pending_actions.py -q +python .\scripts\build_docs_inventory.py +python .\functional_tests\test_docs_app_surface_coverage.py +python .\functional_tests\test_docs_site_quality.py +``` + +### Results + +| Check | Result | +| --- | --- | +| Production scroll-policy helpers | Passed | +| TypeScript/Vite build | Passed; existing bundle-size advisory | +| Targeted V2 browser suite | 47 passed, one known baseline test deselected | +| Stream lifecycle and pending-action functional contracts | 39 passed; existing return-value warnings | +| Documentation coverage | 7/7 passed; regenerated inventory has no semantic changes | +| Documentation site quality | 6/6 passed | +| XSS boundary check for the five changed TypeScript/TSX files | Passed | +| Whitespace check | Passed | + +The existing collaboration mention-chip test +`test_composer_mentions_become_chips_and_are_sent_as_mentions` can time out when +typing the next mention immediately after selection: a scheduled caret reset +can produce `Resp@` instead of `@Resp`. This was reproduced using the unchanged +`HEAD` message list, pending-action slots and chat store in a separate generated +harness. It is not caused by the scroll fix and is left out of scope. + +The generated control inventory still describes classic-template controls. +The V2 arrow is documented separately in the +[chat controls reference](../../reference/chat-controls.md) and the +[collaboration guide](../../guides/collaborate-in-a-conversation.md). diff --git a/docs/guides/collaborate-in-a-conversation.md b/docs/guides/collaborate-in-a-conversation.md index 08f25e227..7674e8835 100644 --- a/docs/guides/collaborate-in-a-conversation.md +++ b/docs/guides/collaborate-in-a-conversation.md @@ -61,6 +61,18 @@ Use a shared conversation when the discussion, prompts, AI responses, and genera The same shared conversations work in the V2 interface, with the controls in different places. +### Reading incoming messages + +Fixed/Implemented in version: **0.261.318** (`application/single_app/config.py`). +When you are caught up, a new participant's message opens at its beginning. +AI replies follow the live streaming line and return to the beginning when +finished. If you scroll up to read earlier content, incoming messages and AI +completion leave you where you are. **New messages**, with a down arrow above +the composer, brings you to the beginning of the most current message when you +are ready. This indication is local to your open chat, not a participant read receipt. + +### Shared conversation controls + - **Share a conversation, or manage who is in one**: the people button in the chat header, or **Share** in the conversation's menu in the left rail. Both open the same panel, which also promotes members to admin, removes people, and lets you leave the conversation or delete it for everyone. - **Accept an invitation**: a prompt above the conversation offers **Join** and **Decline**. You can read an invited conversation before joining, but the composer stays disabled until you do. - **Mention somebody**: type `@` in the composer. The menu lists the people already in the conversation, then the models and agents you can address, then people you could add. Use the arrow keys and press **Tab** or **Enter** to accept the highlighted suggestion. diff --git a/docs/reference/chat-controls.md b/docs/reference/chat-controls.md index 14a2a2701..624f9e04a 100644 --- a/docs/reference/chat-controls.md +++ b/docs/reference/chat-controls.md @@ -4,7 +4,7 @@ title: "Chat interface controls" description: "Reference for every documented control in the SimpleChat chat interface." section: "Reference" audience: user -version: "0.261.310" +version: "0.261.318" --- ## How to use this reference @@ -63,6 +63,27 @@ open navigation, and Escape closes the panel before the navigation. Since **0.261.236**, a workflow alert that needs attention pops up from the bell as well. See [Manage notifications]({{ '/guides/manage-notifications/' | relative_url }}). +### React V2 reading new messages + +Fixed/Implemented in version: **0.261.318** (`application/single_app/config.py`). +A completed incoming message opens at its beginning when you are caught up. +AI replies continue following the latest line while they stream, then return to +the beginning of the completed reply so you can read downward. + +Scrolling up takes priority: your position stays unchanged as other people +write or an AI reply grows and finishes. **New messages**, with a down arrow, +appears above the composer when new content arrives while you are looking earlier. +Activate it to open the **newest message at its beginning**, not at its last line. +The indication clears, even if that message is too long to fit on screen. +Scrolling back to the bottom resumes live following. + +| V2 control | Purpose | Availability | +| --- | --- | --- | +| New messages (down arrow) | Shows that new content arrived while you were reading earlier messages and takes you to the beginning of the newest rendered message or live reply. | Appears when there is new content to reach in the open conversation. | + +This is separate from the classic `scroll-to-bottom-btn`, whose behavior is unchanged. +See [V2 message scroll anchoring]({{ '/explanation/fixes/V2_CHAT_MESSAGE_SCROLL_ANCHOR_FIX/' | relative_url }}). + ### Foundry sign-in requests From version **0.261.093**, when a called Foundry agent needs delegated sign-in diff --git a/functional_tests/test_v2_chat_message_scroll.mjs b/functional_tests/test_v2_chat_message_scroll.mjs new file mode 100644 index 000000000..7115e21c0 --- /dev/null +++ b/functional_tests/test_v2_chat_message_scroll.mjs @@ -0,0 +1,45 @@ +// test_v2_chat_message_scroll.mjs +// Version: 0.261.318 +// Implemented in: 0.261.318 +// Verify production arrival identity, message-start geometry, and the live-follow threshold. + +import assert from 'node:assert/strict'; +import './test_support/tsResolve.mjs'; +import { + isMessageNearBottom, + messageMatchesScrollId, + messageStartScrollTop, + newestMessageArrival, +} from '../application/v2_ui/src/lib/messageScroll.ts'; + +const message = (id, extra = {}) => ({ id, role: 'assistant', content: id, conversation_id: 'chat', ...extra }); +const first = message('first'); +const second = message('second'); + +assert.equal(newestMessageArrival([first], [first, second]), second); +assert.equal(newestMessageArrival([], [first]), first); +assert.equal(newestMessageArrival([first], [{ ...first, content: 'Edited' }]), null); +assert.equal(newestMessageArrival([first, second], [first]), null); +assert.equal(newestMessageArrival([first], [second]), null); +assert.equal(newestMessageArrival([first], [second, first]), null); +assert.equal(newestMessageArrival([first, second], [first, { ...second, metadata: { masked: true } }]), null); + +const pending = message('pending-user-1', { role: 'user', content: 'Question' }); +assert.equal(newestMessageArrival([first, pending], [first, { ...pending, id: 'saved-user' }]), null); +const mirrored = message('shared-answer', { metadata: { source_message_id: first.id } }); +assert.equal(newestMessageArrival([first], [mirrored]), null); +assert.equal(newestMessageArrival([mirrored], [first]), null); +assert.equal(messageMatchesScrollId(mirrored, first.id), true); +assert.equal(messageMatchesScrollId(mirrored, 'not-the-answer'), false); + +const attempt = message('attempt-one', { metadata: { thread_info: { thread_id: 'turn-1', thread_attempt: 1 } } }); +const retry = message('attempt-two', { metadata: { thread_info: { thread_id: 'turn-1', thread_attempt: 2 } } }); +assert.equal(newestMessageArrival([first, attempt], [first, retry]), null); +assert.equal(newestMessageArrival([first, attempt], [first, attempt, retry]), retry); +assert.equal(newestMessageArrival([first], [first, message('malformed', { metadata: { thread_info: [] } })]).id, 'malformed'); + +assert.equal(messageStartScrollTop(1000, 200, 700), 1476); +assert.equal(messageStartScrollTop(0, 200, 210), 0); +assert.equal(isMessageNearBottom(921, 1500, 500), true); +assert.equal(isMessageNearBottom(920, 1500, 500), false); +assert.equal(isMessageNearBottom(0, 100, 500), true); diff --git a/ui_tests/test_v2_chat_message_scroll.py b/ui_tests/test_v2_chat_message_scroll.py new file mode 100644 index 000000000..60daf11b9 --- /dev/null +++ b/ui_tests/test_v2_chat_message_scroll.py @@ -0,0 +1,460 @@ +# test_v2_chat_message_scroll.py +""" +Real-component browser regression for V2 chat message reading positions. +Version: 0.261.318 +Implemented in: 0.261.318 + +Production MessageList, chat store, completion handling and CSS run in Chromium. +Only HTTP boundaries are stubbed. Measure message starts, reading offsets and +live-following geometry rather than using the arrow's presence as a proxy. +The shared browser fixture supports local and Azure Playwright connections. + +Build: npm --prefix .\\application\\v2_ui run build -- --outDir ..\\..\\ui_tests\\artifacts\\orchestration-plan-editor +Run: python -m pytest .\\ui_tests\\test_v2_chat_message_scroll.py -q +""" + +from pathlib import Path +from urllib.parse import urlsplit + +import pytest +from playwright.sync_api import expect + +from test_v2_orchestration_plan_editor import ( # noqa: F401 + HARNESS, + ORIGIN, + connect_options, + editor_assets, + editor_browser, +) +from v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload +from test_v2_orchestration_streaming_bubble import INSTALL_STREAMS + + +pytestmark = pytest.mark.ui +CHAT = "reading-chat" +ARROW = "Go to the beginning of the newest message" +LONG = "\n\n".join(f"Reading paragraph {index}. " + "Detailed content for reading. " * 8 for index in range(35)) + + +def message(message_id, content=LONG, role="assistant", **extra): + return {"id": message_id, "role": role, "conversation_id": CHAT, "content": content, **extra} + + +class ScrollApi: + def __init__(self, assets): + self.assets = assets + self.errors = [] + + def handle(self, route): + request = route.request + path = urlsplit(request.url).path + if request.method == "GET" and path in self.assets: + route.fulfill(path=str(self.assets[path])) + elif is_pending_actions_list(request.method, path): + route.fulfill(json=pending_actions_payload()) + elif path == "/favicon.ico": + route.fulfill(status=204) + else: + self.errors.append(f"Unexpected request: {request.method} {path}") + route.fulfill(status=404, json={"error": "Unexpected test request."}) + + +@pytest.fixture +def scroll_ui(editor_browser, editor_assets): + context = editor_browser.new_context(viewport={"width": 1280, "height": 800}, reduced_motion="reduce") + page = context.new_page() + api = ScrollApi(editor_assets) + page.route("**/*", api.handle) + page.on("pageerror", lambda error: api.errors.append(str(error))) + page.on("console", lambda entry: api.errors.append(entry.text) if entry.type == "error" else None) + try: + yield page, api + finally: + context.close() + assert not api.errors, api.errors + + +def mount(page, api, *, kind="personal", messages=None, loading=False): + page.goto(ORIGIN + HARNESS) + page.wait_for_function("() => Boolean(window.OrchHarness)") + for url in api.assets: + if url.endswith(".css"): + page.add_style_tag(url=ORIGIN + url) + page.evaluate( + """(spec) => { + const H = window.OrchHarness; + H.reset(); + H.stores.bootstrap.useBootstrapStore.setState({data: { + features: {}, settings: {}, branding: {app_title: 'SimpleChat'}, + user: {id: 'tester', display_name: 'Tester'}, catalogs: {models: [], agents: [], prompts: []}, + scope: {groups: [], public_workspaces: []}, orchestration: {enabled: false, capabilities: []}, + }}); + H.stores.chat.useChatStore.setState({ + activeConversationId: spec.chat, activeConversationKind: spec.kind, + messages: spec.messages, messagesLoading: spec.loading, messagesError: null, + streaming: false, streamingContent: '', completedReply: null, streamError: null, thoughts: [], + orchestrationSurface: null, metadata: null, attemptsByThread: {}, + }); + H.mount('mount-a', 'PromptExperience', {}, {strictMode: true, initialEntries: ['/chat']}); + }""", + {"chat": CHAT, "kind": kind, "messages": messages or [message("history")], "loading": loading}, + ) + frames(page) + + +def frames(page): + page.evaluate("() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))") + + +def root(page): + return page.locator('[data-tour="message-list"]') + + +def geometry(page, message_id=None): + return root(page).evaluate( + """(node, id) => { + const box = node.getBoundingClientRect(); + const item = Array.from(node.querySelectorAll('[data-scroll-message-id]')) + .find(item => item.dataset.scrollMessageId === id); + return {top: node.scrollTop, remaining: node.scrollHeight - node.scrollTop - node.clientHeight, + height: node.clientHeight, offset: item ? item.getBoundingClientRect().top - box.top : null, + pageTop: document.scrollingElement.scrollTop}; + }""", + message_id, + ) + + +def update(page, changes): + page.evaluate("(changes) => window.OrchHarness.stores.chat.useChatStore.setState(changes)", changes) + frames(page) + + +def append(page, incoming): + page.evaluate( + """(message) => { + const H = window.OrchHarness; + H.stores.chat.useChatStore.setState(state => ({ + messages: H.stores.chat.mergeCollaborationMessage(state.messages, message), + })); + }""", + incoming, + ) + frames(page) + + +def scroll_back(page, top=100): + root(page).evaluate("(node, top) => { node.scrollTop = top; }", top) + frames(page) + + +def start_stream(page): + update(page, {"streaming": True, "streamingContent": ""}) + + +def push_content(page, content): + page.evaluate( + "([id, content]) => window.OrchHarness.stores.chat.useChatStore.getState().pushOrchestrationContent(id, content)", + [CHAT, content], + ) + frames(page) + + +def complete(page, content=LONG, message_id="reply"): + page.evaluate( + """(spec) => window.OrchHarness.stores.chat.useChatStore.getState().settleOrchestrationTurn( + spec.chat, {status: 'completed', accumulated: spec.content, + event: {message_id: spec.id, full_content: spec.content, role: 'assistant'}})""", + {"chat": CHAT, "content": content, "id": message_id}, + ) + frames(page) + + +def assert_start(page, message_id): + measured = geometry(page, message_id) + assert measured["offset"] == pytest.approx(24, abs=2), measured + assert measured["remaining"] > measured["height"], measured + assert measured["pageTop"] == 0, measured + + +@pytest.mark.parametrize("kind", ["personal", "collaborative"]) +@pytest.mark.parametrize("mobile", [False, True]) +@pytest.mark.parametrize("role", ["assistant", "user"]) +def test_long_arrival_opens_at_start(scroll_ui, kind, mobile, role): + page, api = scroll_ui + if mobile: + page.set_viewport_size({"width": 390, "height": 740}) + mount(page, api, kind=kind) + append(page, message("new-long", role=role)) + assert_start(page, "new-long") + expect(page.get_by_role("button", name=ARROW)).to_have_count(0) + + +def test_stream_follows_then_returns_to_the_completed_reply_once(scroll_ui): + page, api = scroll_ui + mount(page, api) + start_stream(page) + for paragraphs in (10, 20, 35): + push_content(page, "\n\n".join(LONG.split("\n\n")[:paragraphs])) + measured = geometry(page) + assert measured["remaining"] == pytest.approx(0, abs=2), measured + complete(page) + assert_start(page, "reply") + scroll_back(page, geometry(page)["top"] + 300) + before = geometry(page)["top"] + append(page, message("reply")) + assert geometry(page)["top"] == pytest.approx(before, abs=2) + expect(page.get_by_role("button", name=ARROW)).to_have_count(0) + + +@pytest.mark.parametrize("broadcast_first", [False, True]) +def test_shared_terminal_and_broadcast_do_not_double_jump(scroll_ui, broadcast_first): + page, api = scroll_ui + mount(page, api, kind="collaborative") + start_stream(page) + push_content(page, LONG) + if broadcast_first: + append(page, message("reply")) + complete(page) + assert_start(page, "reply") + before = geometry(page)["top"] + append(page, message("shared-reply", metadata={"source_message_id": "reply"})) + assert geometry(page)["top"] == pytest.approx(before, abs=2) + expect(page.get_by_role("button", name=ARROW)).to_have_count(0) + page.locator("#message-history").evaluate("(node) => { node.style.paddingTop = '300px'; }") + frames(page) + assert_start(page, "shared-reply") + + +def test_personal_sse_frames_use_the_actual_completed_reply(scroll_ui): + page, api = scroll_ui + mount(page, api) + endpoint = "/api/chat/stream" + page.evaluate(INSTALL_STREAMS, [endpoint]) + page.evaluate( + "() => { void window.OrchHarness.stores.chat.useChatStore.getState()" + ".sendMessage('A new question', {modelDeployment: 'test-model'}); }" + ) + page.wait_for_function("(path) => Boolean(window.__streams[path])", arg=endpoint) + page.evaluate( + "([path, event]) => window.emitFrame(path, event)", + [endpoint, {"type": "user_message_persisted", "user_message_id": "saved-question"}], + ) + for content in (LONG[:len(LONG) // 2], LONG[len(LONG) // 2:]): + page.evaluate( + "([path, content]) => window.emitFrame(path, {content})", [endpoint, content] + ) + frames(page) + assert geometry(page)["remaining"] == pytest.approx(0, abs=2) + page.evaluate( + "([path, event]) => window.emitFrame(path, event)", + [endpoint, {"done": True, "message_id": "actual-sse-reply", "full_content": LONG}], + ) + page.wait_for_function("() => !window.OrchHarness.stores.chat.useChatStore.getState().streaming") + frames(page) + assert_start(page, "actual-sse-reply") + page.evaluate("(path) => window.endStream(path)", endpoint) + + +def test_manual_reading_survives_streaming_and_completion(scroll_ui): + page, api = scroll_ui + mount(page, api) + start_stream(page) + push_content(page, LONG[:len(LONG) // 2]) + scroll_back(page) + before = geometry(page, "history") + push_content(page, LONG) + complete(page) + after = geometry(page, "history") + assert after["offset"] == pytest.approx(before["offset"], abs=2), (before, after) + expect(page.get_by_role("button", name=ARROW)).to_be_visible() + page.get_by_role("button", name=ARROW).click() + assert_start(page, "reply") + expect(page.get_by_role("button", name=ARROW)).to_have_count(0) + + +def test_arrow_chooses_the_most_current_message_and_supports_keyboard(scroll_ui): + page, api = scroll_ui + mount(page, api, kind="collaborative") + scroll_back(page) + before = geometry(page, "history") + append(page, message("first-unseen")) + append(page, message("most-current")) + assert geometry(page, "history")["offset"] == pytest.approx(before["offset"], abs=2) + arrow = page.get_by_role("button", name=ARROW) + arrow.focus() + arrow.press("Enter") + assert_start(page, "most-current") + expect(arrow).to_have_count(0) + append(page, message("another-arrival")) + expect(arrow).to_be_visible() + + +def test_jump_to_live_start_does_not_resume_following_or_announce_each_token(scroll_ui): + page, api = scroll_ui + mount(page, api) + scroll_back(page) + start_stream(page) + push_content(page, LONG[:len(LONG) // 2]) + arrow = page.get_by_role("button", name=ARROW) + arrow.click() + before = geometry(page)["top"] + push_content(page, LONG) + assert geometry(page)["top"] == pytest.approx(before, abs=2) + expect(arrow).to_have_count(0) + complete(page) + assert geometry(page)["top"] == pytest.approx(before, abs=2) + expect(arrow).to_have_count(0) + + +def test_even_a_small_manual_upward_scroll_overrides_live_following(scroll_ui): + page, api = scroll_ui + mount(page, api) + start_stream(page) + push_content(page, LONG[:len(LONG) // 2]) + scroll_back(page, geometry(page)["top"] - 10) + before = geometry(page)["top"] + push_content(page, LONG) + complete(page) + assert geometry(page)["top"] == pytest.approx(before, abs=2) + expect(page.get_by_role("button", name=ARROW)).to_be_visible() + + +def test_scrolling_back_down_resumes_following_and_later_upward_reading_gets_an_arrow(scroll_ui): + page, api = scroll_ui + mount(page, api) + start_stream(page) + push_content(page, LONG[:len(LONG) // 2]) + scroll_back(page) + root(page).evaluate("(node) => { node.scrollTop = node.scrollHeight; }") + frames(page) + push_content(page, LONG) + assert geometry(page)["remaining"] == pytest.approx(0, abs=2) + scroll_back(page) + push_content(page, LONG + "\n\nAnother live paragraph.") + expect(page.get_by_role("button", name=ARROW)).to_be_visible() + + +def test_shared_arrivals_are_indicated_even_when_a_stream_has_not_emitted_another_frame(scroll_ui): + page, api = scroll_ui + mount(page, api, kind="collaborative") + start_stream(page) + scroll_back(page) + append(page, message("human-during-stream", role="user")) + expect(page.get_by_role("button", name=ARROW)).to_be_visible() + + +def test_delayed_growth_keeps_the_message_beginning_and_manual_anchor(scroll_ui): + page, api = scroll_ui + mount(page, api) + append(page, message("new-long")) + assert_start(page, "new-long") + page.locator("#message-history").evaluate( + "(node) => { const delayed = document.createElement('div'); delayed.style.height = '400px'; node.prepend(delayed); }" + ) + page.locator("#message-new-long").evaluate( + "(node) => { const delayed = document.createElement('div'); delayed.style.height = '500px'; node.append(delayed); }" + ) + frames(page) + assert_start(page, "new-long") + scroll_back(page, geometry(page)["top"] + 200) + before = geometry(page, "new-long")["offset"] + page.locator("#message-history").evaluate("(node) => { node.firstElementChild.style.height = '700px'; }") + frames(page) + assert geometry(page, "new-long")["offset"] == pytest.approx(before, abs=2) + + +def test_loading_and_conversation_resets_are_not_new_arrivals(scroll_ui): + page, api = scroll_ui + mount(page, api, loading=True) + update(page, {"messagesLoading": False, "messages": [message("loaded-history")]}) + assert geometry(page)["remaining"] == pytest.approx(0, abs=2) + scroll_back(page) + append(page, message("unseen")) + expect(page.get_by_role("button", name=ARROW)).to_be_visible() + update(page, {"activeConversationId": "other", "messages": [message("other-history")]}) + assert geometry(page)["remaining"] == pytest.approx(0, abs=2) + expect(page.get_by_role("button", name=ARROW)).to_have_count(0) + page.evaluate("() => window.OrchHarness.stores.chat.useChatStore.getState().startNewConversation()") + frames(page) + expect(page.get_by_role("button", name=ARROW)).to_have_count(0) + expect(page.get_by_text("Start a conversation with SimpleChat")).to_be_visible() + + +def test_delayed_growth_does_not_leave_a_stale_caught_up_state(scroll_ui): + page, api = scroll_ui + mount(page, api) + append(page, message("short", "A short reply with delayed content.")) + page.locator("#message-short").evaluate( + "(node) => { const delayed = document.createElement('div'); delayed.style.height = '2000px'; node.append(delayed); }" + ) + frames(page) + before = geometry(page, "short")["offset"] + append(page, message("next-reply")) + assert geometry(page, "short")["offset"] == pytest.approx(before, abs=2) + expect(page.get_by_role("button", name=ARROW)).to_be_visible() + + +def test_short_arrivals_and_identity_updates_do_not_create_fake_space_or_badges(scroll_ui): + page, api = scroll_ui + mount(page, api, messages=[message("pending-user-1", "A question", "user")]) + append(page, message("saved-user", "A question", "user", sender={"user_id": "tester"})) + append(page, message("short", "A short answer")) + measured = geometry(page) + assert measured["top"] == 0 and measured["remaining"] <= 0, measured + expect(page.get_by_role("button", name=ARROW)).to_have_count(0) + append(page, message("short", "Updated answer")) + expect(page.get_by_role("button", name=ARROW)).to_have_count(0) + + +@pytest.mark.parametrize("terminal", ["failed", "cancelled", "planned"]) +def test_non_answer_terminal_states_do_not_jump_to_a_previous_reply(scroll_ui, terminal): + page, api = scroll_ui + mount(page, api) + start_stream(page) + push_content(page, LONG) + scroll_back(page) + before = geometry(page, "history")["offset"] + outcome = {"status": terminal, "accumulated": LONG, "error": "Unable to finish this reply."} + page.evaluate( + "([id, outcome]) => window.OrchHarness.stores.chat.useChatStore.getState().settleOrchestrationTurn(id, outcome)", + [CHAT, outcome], + ) + frames(page) + assert geometry(page, "history")["offset"] == pytest.approx(before, abs=2) + if terminal != "cancelled": + expect(page.get_by_role("button", name=ARROW)).to_have_count(0) + + +def test_first_conversation_id_assignment_preserves_manual_position(scroll_ui): + page, api = scroll_ui + mount(page, api, messages=[message("pending-user-1", role="user")]) + update(page, {"activeConversationId": None, "streaming": True}) + push_content(page, LONG) + scroll_back(page) + before = geometry(page)["top"] + update(page, {"activeConversationId": CHAT}) + assert geometry(page)["top"] == pytest.approx(before, abs=2) + complete(page) + assert geometry(page)["top"] == pytest.approx(before, abs=2) + + +@pytest.mark.parametrize("mobile", [False, True]) +def test_indicator_theme_layout_and_composer_clearance(scroll_ui, mobile): + page, api = scroll_ui + if mobile: + page.set_viewport_size({"width": 390, "height": 740}) + mount(page, api) + if mobile: + page.evaluate("() => document.documentElement.classList.add('dark')") + scroll_back(page) + append(page, message("newest")) + arrow = page.get_by_role("button", name=ARROW) + expect(arrow).to_be_visible() + button_box = arrow.bounding_box() + pane_box = root(page).bounding_box() + assert button_box["y"] + button_box["height"] <= pane_box["y"] + pane_box["height"] + assert button_box["x"] >= 0 and button_box["x"] + button_box["width"] <= page.viewport_size["width"] + assert geometry(page)["pageTop"] == 0 + artifacts = Path(__file__).parent / "artifacts" / "chat-message-scroll" + artifacts.mkdir(parents=True, exist_ok=True) + page.screenshot(path=str(artifacts / ("mobile.png" if mobile else "desktop.png")))