Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion src/features/chat/ui/MessageTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import {
MessageTimelineJumpToResponseStartGutterButton,
REDUCED_MOTION_QUERY,
RESPONSE_START_HINT_HIDE_DELAY_MS,
getTimelineMessageIdentity,
getVoiceSubmissionKey,
isResponseStartHintInRelevanceBand,
useStickyFlag,
type MessageBubbleCallbacks,
Expand Down Expand Up @@ -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;
}

Expand All @@ -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) {
Expand Down
75 changes: 74 additions & 1 deletion src/features/chat/ui/VirtualMessageTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ import {
MessageTimelineJumpToResponseStartGutterButton,
REDUCED_MOTION_QUERY,
RESPONSE_START_HINT_HIDE_DELAY_MS,
getTimelineMessageIdentity,
getVoiceSubmissionKey,
isResponseStartHintInRelevanceBand,
useStickyFlag,
type MessageBubbleCallbacks,
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -2909,7 +2939,11 @@ function VirtualMessageTimelineSession({
}, [pulsingMessageId]);

useEffect(() => {
if (!latestMessageId || latestMessage?.role !== "user") {
if (
!latestMessageId ||
latestMessage?.role !== "user" ||
latestMessage.metadata?.origin === "voice_conversation"
) {
return;
}

Expand All @@ -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;
Expand Down
121 changes: 121 additions & 0 deletions src/features/chat/ui/__tests__/MessageTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MessageTimeline messages={messages} />,
);
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(<MessageTimeline messages={[...messages, voiceMessage]} />);

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(
<MessageTimeline
messages={[...messages, { ...voiceMessage, id: "voice-backend" }]}
/>,
);

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(
<MessageTimeline messages={messages} />,
);
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(
<MessageTimeline
messages={[
...messages,
{
...message("voice-local", "user", "Spoken follow-up"),
metadata: {
userVisible: true,
origin: "voice_conversation",
voiceConversationLifecycleId: "lifecycle-1",
voiceUtteranceId: "utterance-1",
voiceConversationRevision: 0,
},
},
message("assistant-2", "assistant", "Working"),
]}
streamingMessageId="assistant-2"
/>,
);

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 = [
Expand Down
Loading