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;