diff --git a/src/features/chat/ui/MessageTimeline.tsx b/src/features/chat/ui/MessageTimeline.tsx index 7df321a45..84beec01b 100644 --- a/src/features/chat/ui/MessageTimeline.tsx +++ b/src/features/chat/ui/MessageTimeline.tsx @@ -40,6 +40,8 @@ import { MessageTimelineJumpToResponseStartGutterButton, REDUCED_MOTION_QUERY, RESPONSE_START_HINT_HIDE_DELAY_MS, + getTimelineMessageIdentity, + getVoiceSubmissionKey, isResponseStartHintInRelevanceBand, useStickyFlag, type MessageBubbleCallbacks, @@ -933,9 +935,30 @@ export function MessageTimeline({ const latestVisibleMessage = visibleMessages.at(-1); const latestVisibleMessageId = latestVisibleMessage?.id; + const visibleMessageIdentities = useMemo( + () => visibleMessages.map(getTimelineMessageIdentity), + [visibleMessages], + ); + const visibleVoiceSubmissionKeys = useMemo( + () => + visibleMessages + .map(getVoiceSubmissionKey) + .filter((key): key is string => key !== null), + [visibleMessages], + ); + const seenVoiceSubmissionKeysRef = useRef( + new Set(visibleVoiceSubmissionKeys), + ); + const previousVisibleTailIdentityRef = useRef( + visibleMessageIdentities.at(-1), + ); useEffect(() => { - if (!latestVisibleMessageId || latestVisibleMessage?.role !== "user") { + if ( + !latestVisibleMessageId || + latestVisibleMessage?.role !== "user" || + latestVisibleMessage.metadata?.origin === "voice_conversation" + ) { return; } @@ -945,11 +968,48 @@ export function MessageTimeline({ }, [ clearProgrammaticFollowResumeSuppression, latestVisibleMessageId, + latestVisibleMessage?.metadata?.origin, latestVisibleMessage?.role, scrollToBottom, setDetachedFromLatest, ]); + useEffect(() => { + let latestUnseenVoiceSubmissionIndex = -1; + for (let index = 0; index < visibleMessages.length; index += 1) { + const key = getVoiceSubmissionKey(visibleMessages[index]); + if (key && !seenVoiceSubmissionKeysRef.current.has(key)) { + latestUnseenVoiceSubmissionIndex = index; + } + } + const previousTailIdentity = previousVisibleTailIdentityRef.current; + const previousTailIndex = previousTailIdentity + ? visibleMessageIdentities.indexOf(previousTailIdentity) + : -1; + for (const key of visibleVoiceSubmissionKeys) { + seenVoiceSubmissionKeysRef.current.add(key); + } + previousVisibleTailIdentityRef.current = visibleMessageIdentities.at(-1); + if ( + latestUnseenVoiceSubmissionIndex < 0 || + (previousTailIdentity && + (previousTailIndex < 0 || + latestUnseenVoiceSubmissionIndex <= previousTailIndex)) + ) { + return; + } + clearProgrammaticFollowResumeSuppression(); + setDetachedFromLatest(false); + schedulePinnedBottomBurst(); + }, [ + clearProgrammaticFollowResumeSuppression, + schedulePinnedBottomBurst, + setDetachedFromLatest, + visibleMessageIdentities, + visibleMessages, + visibleVoiceSubmissionKeys, + ]); + const scheduleResponseStartHint = useCallback( (messageId: string) => { if (responseStartHintAnimationFrameRef.current != null) { diff --git a/src/features/chat/ui/VirtualMessageTimeline.tsx b/src/features/chat/ui/VirtualMessageTimeline.tsx index facc6fd49..1e6a3592e 100644 --- a/src/features/chat/ui/VirtualMessageTimeline.tsx +++ b/src/features/chat/ui/VirtualMessageTimeline.tsx @@ -73,6 +73,8 @@ import { MessageTimelineJumpToResponseStartGutterButton, REDUCED_MOTION_QUERY, RESPONSE_START_HINT_HIDE_DELAY_MS, + getTimelineMessageIdentity, + getVoiceSubmissionKey, isResponseStartHintInRelevanceBand, useStickyFlag, type MessageBubbleCallbacks, @@ -2435,6 +2437,34 @@ function VirtualMessageTimelineSession({ }, [stableMessageByRowId, stableRows]); const latestMessage = latestMessageEntry?.message; const latestMessageId = latestMessageEntry?.messageId; + const timelineMessages = useMemo(() => { + const result: Message[] = []; + for (const row of stableRows) { + if (!isMessageTurnRow(row)) { + continue; + } + const message = stableMessageByRowId.get(row.rowId); + if (message) { + result.push(message); + } + } + return result; + }, [stableMessageByRowId, stableRows]); + const timelineMessageIdentities = useMemo( + () => timelineMessages.map(getTimelineMessageIdentity), + [timelineMessages], + ); + const voiceSubmissionKeys = useMemo( + () => + timelineMessages + .map(getVoiceSubmissionKey) + .filter((key): key is string => key !== null), + [timelineMessages], + ); + const seenVoiceSubmissionKeysRef = useRef(new Set(voiceSubmissionKeys)); + const previousTimelineTailIdentityRef = useRef( + timelineMessageIdentities.at(-1), + ); const latestAssistantMessageEntry = useMemo(() => { for (let index = stableRows.length - 1; index >= 0; index -= 1) { const row = stableRows[index]; @@ -2909,7 +2939,11 @@ function VirtualMessageTimelineSession({ }, [pulsingMessageId]); useEffect(() => { - if (!latestMessageId || latestMessage?.role !== "user") { + if ( + !latestMessageId || + latestMessage?.role !== "user" || + latestMessage.metadata?.origin === "voice_conversation" + ) { return; } @@ -2925,12 +2959,51 @@ function VirtualMessageTimelineSession({ }, [ clearProgrammaticFollowResumeSuppression, latestMessageId, + latestMessage?.metadata?.origin, latestMessage?.role, sessionId, scrollToBottom, setDetachedFromLatest, ]); + useEffect(() => { + let latestUnseenVoiceSubmissionIndex = -1; + for (let index = 0; index < timelineMessages.length; index += 1) { + const key = getVoiceSubmissionKey(timelineMessages[index]); + if (key && !seenVoiceSubmissionKeysRef.current.has(key)) { + latestUnseenVoiceSubmissionIndex = index; + } + } + const previousTailIdentity = previousTimelineTailIdentityRef.current; + const previousTailIndex = previousTailIdentity + ? timelineMessageIdentities.indexOf(previousTailIdentity) + : -1; + for (const key of voiceSubmissionKeys) { + seenVoiceSubmissionKeysRef.current.add(key); + } + previousTimelineTailIdentityRef.current = timelineMessageIdentities.at(-1); + if ( + latestUnseenVoiceSubmissionIndex < 0 || + (previousTailIdentity && + (previousTailIndex < 0 || + latestUnseenVoiceSubmissionIndex <= previousTailIndex)) + ) { + return; + } + clearProgrammaticFollowResumeSuppression(); + setDetachedFromLatest(false); + scrollToBottom("auto"); + requestBottomScroll(); + }, [ + clearProgrammaticFollowResumeSuppression, + requestBottomScroll, + scrollToBottom, + setDetachedFromLatest, + timelineMessageIdentities, + timelineMessages, + voiceSubmissionKeys, + ]); + const requestMcpAppAutoScroll = useCallback( (element: HTMLElement | null) => { const container = containerRef.current; diff --git a/src/features/chat/ui/__tests__/MessageTimeline.test.tsx b/src/features/chat/ui/__tests__/MessageTimeline.test.tsx index ab03411de..5e0e27179 100644 --- a/src/features/chat/ui/__tests__/MessageTimeline.test.tsx +++ b/src/features/chat/ui/__tests__/MessageTimeline.test.tsx @@ -1481,6 +1481,127 @@ describe("MessageTimeline", () => { }); }); + it("follows a new voice user turn like a composer submission", async () => { + const messages = [ + message("user-1", "user", "Question"), + message("assistant-1", "assistant", "Answer"), + ]; + const { rerender } = renderWithProviders( + , + ); + const scroller = getTimelineScroller(); + setScrollMetrics(scroller, { scrollTop: 500 }); + const scrollTo = attachScrollTo(scroller); + fireEvent.wheel(scroller, { deltaY: -120 }); + scroller.scrollTop = 100; + fireEvent.scroll(scroller); + expect( + await screen.findByRole("button", { name: "Jump to latest" }), + ).toBeInTheDocument(); + scrollTo.mockClear(); + const voiceMessage = { + ...message("voice-local", "user", "Spoken follow-up"), + metadata: { + userVisible: true, + origin: "voice_conversation" as const, + voiceConversationLifecycleId: "lifecycle-1", + voiceUtteranceId: "utterance-1", + voiceConversationRevision: 0, + }, + }; + + rerender(); + + await waitFor(() => + expect( + screen.queryByRole("button", { name: "Jump to latest" }), + ).not.toBeInTheDocument(), + ); + expect(scrollTo).toHaveBeenCalledWith({ + top: 500, + behavior: "auto", + }); + + fireEvent.wheel(scroller, { deltaY: -120 }); + scroller.scrollTop = 100; + fireEvent.scroll(scroller); + expect( + await screen.findByRole("button", { name: "Jump to latest" }), + ).toBeInTheDocument(); + scrollTo.mockClear(); + + rerender( + , + ); + + await waitFor(() => expect(scroller.scrollTop).toBe(100)); + expect(scrollTo).not.toHaveBeenCalled(); + expect( + screen.getByRole("button", { name: "Jump to latest" }), + ).toBeInTheDocument(); + }); + + it("follows a new voice turn appended with an assistant continuation", async () => { + const messages = [ + message("user-1", "user", "Question"), + message("assistant-1", "assistant", "Answer"), + ]; + const { rerender } = renderWithProviders( + , + ); + const scroller = getTimelineScroller(); + setScrollMetrics(scroller, { + scrollTop: 500, + scrollHeight: 1000, + clientHeight: 500, + }); + const scrollTo = attachScrollTo(scroller); + fireEvent.wheel(scroller, { deltaY: -120 }); + scroller.scrollTop = 100; + fireEvent.scroll(scroller); + expect( + await screen.findByRole("button", { name: "Jump to latest" }), + ).toBeInTheDocument(); + scrollTo.mockClear(); + + setScrollMetrics(scroller, { + scrollTop: 100, + scrollHeight: 1200, + clientHeight: 500, + }); + rerender( + , + ); + + await waitFor(() => + expect(scrollTo).toHaveBeenCalledWith({ + top: 700, + behavior: "auto", + }), + ); + expect( + screen.queryByRole("button", { name: "Jump to latest" }), + ).not.toBeInTheDocument(); + }); + it("keeps manual position stable and shows Jump when resize leaves latest behind", () => { const animationFrame = mockRequestAnimationFrame(); const messages = [ diff --git a/src/features/chat/ui/__tests__/VirtualMessageTimeline.test.tsx b/src/features/chat/ui/__tests__/VirtualMessageTimeline.test.tsx index 3bda8c152..5b32945f0 100644 --- a/src/features/chat/ui/__tests__/VirtualMessageTimeline.test.tsx +++ b/src/features/chat/ui/__tests__/VirtualMessageTimeline.test.tsx @@ -1789,6 +1789,142 @@ describe("VirtualMessageTimeline", () => { await waitFor(() => expect(scroller.scrollTop).toBe(detachedScrollTop)); }); + it("follows a new voice user turn like a composer submission", async () => { + mockTranscriptElementMeasurements(); + const messages = [ + textMessage("user-1", "user", "Question"), + textMessage( + "assistant-1", + "assistant", + `${longText("history", 80)}\n[height:900]`, + ), + ]; + const { rerender } = renderWithProviders( + , + ); + const scroller = screen.getByTestId("message-timeline-scroll"); + attachScrollTo(scroller); + setScrollMetrics(scroller, { + scrollTop: 700, + scrollHeight: 1000, + clientHeight: 300, + }); + fireEvent.scroll(scroller); + + fireEvent.wheel(scroller, { deltaY: -300 }); + setScrollMetrics(scroller, { + scrollTop: 200, + scrollHeight: 1000, + clientHeight: 300, + }); + fireEvent.scroll(scroller); + expect( + await screen.findByRole("button", { name: "Jump to latest" }), + ).toBeInTheDocument(); + const voiceMessage = textMessage( + "voice-local", + "user", + "Spoken follow-up", + { + userVisible: true, + origin: "voice_conversation", + voiceConversationLifecycleId: "lifecycle-1", + voiceUtteranceId: "utterance-1", + voiceConversationRevision: 0, + }, + ); + rerender( + , + ); + + await waitFor(() => expect(scroller.scrollTop).toBeGreaterThan(200)); + expect( + screen.queryByRole("button", { name: "Jump to latest" }), + ).not.toBeInTheDocument(); + + fireEvent.wheel(scroller, { deltaY: -300 }); + setScrollMetrics(scroller, { + scrollTop: 200, + scrollHeight: 1000, + clientHeight: 300, + }); + fireEvent.scroll(scroller); + expect( + await screen.findByRole("button", { name: "Jump to latest" }), + ).toBeInTheDocument(); + + rerender( + , + ); + + await waitFor(() => expect(scroller.scrollTop).toBe(200)); + expect( + screen.getByRole("button", { name: "Jump to latest" }), + ).toBeInTheDocument(); + }); + + it("follows a new voice turn appended with an assistant continuation", async () => { + mockTranscriptElementMeasurements(); + const messages = [ + textMessage("user-1", "user", "Question"), + textMessage( + "assistant-1", + "assistant", + `${longText("history", 80)}\n[height:900]`, + ), + ]; + const { rerender } = renderWithProviders( + , + ); + const scroller = screen.getByTestId("message-timeline-scroll"); + attachScrollTo(scroller); + setScrollMetrics(scroller, { + scrollTop: 700, + scrollHeight: 1000, + clientHeight: 300, + }); + fireEvent.scroll(scroller); + fireEvent.wheel(scroller, { deltaY: -300 }); + setScrollMetrics(scroller, { + scrollTop: 200, + scrollHeight: 1000, + clientHeight: 300, + }); + fireEvent.scroll(scroller); + expect( + await screen.findByRole("button", { name: "Jump to latest" }), + ).toBeInTheDocument(); + + rerender( + , + ); + + await waitFor(() => expect(scroller.scrollTop).toBeGreaterThan(200)); + expect( + screen.queryByRole("button", { name: "Jump to latest" }), + ).not.toBeInTheDocument(); + }); + it("keeps following latest after an intent-less upward scroll correction", async () => { const messages = [ textMessage("user-1", "user", "Question"), diff --git a/src/features/chat/ui/messageTimelineShared.tsx b/src/features/chat/ui/messageTimelineShared.tsx index ee5ec55e6..88e9a60c0 100644 --- a/src/features/chat/ui/messageTimelineShared.tsx +++ b/src/features/chat/ui/messageTimelineShared.tsx @@ -11,8 +11,38 @@ import { } from "@/shared/ui/tooltip"; import { cn } from "@/shared/lib/cn"; import { SIDEBAR_GROUP_LABEL_TEXT_CLASS } from "@/shared/ui/sidebar-tokens"; +import type { Message } from "@/shared/types/messages"; import type { McpAppMessageHandler } from "./mcpAppTypes"; +export function getVoiceSubmissionKey( + message: Message | undefined, +): string | null { + if ( + message?.role !== "user" || + message.metadata?.origin !== "voice_conversation" + ) { + return null; + } + + const utteranceId = message.metadata.voiceUtteranceId; + if (!utteranceId) { + return message.id; + } + + return [ + message.metadata.voiceConversationLifecycleId ?? "", + utteranceId, + message.metadata.voiceConversationRevision ?? "", + ].join(":"); +} + +export function getTimelineMessageIdentity(message: Message): string { + const voiceSubmissionKey = getVoiceSubmissionKey(message); + return voiceSubmissionKey + ? `voice:${voiceSubmissionKey}` + : `message:${message.id}`; +} + export interface MessageBubbleCallbacks { onRetryMessage?: (messageId: string) => void; onEditMessage?: (messageId: string) => void;