diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx
index eae240fd54..ead619fb7e 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx
+++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx
@@ -1252,6 +1252,125 @@ export const StickyUserMessageStructure: Story = {
},
};
+/**
+ * Each user message exposes left/right chevron buttons in its
+ * action row so users can jump the transcript between user prompts.
+ * Disabled at the ends of the conversation; otherwise the click
+ * smooth-scrolls the bubble's `data-user-sentinel` to the top of
+ * the scroller.
+ */
+export const UserMessageJumpArrows: Story = {
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ args: {
+ ...defaultArgs,
+ parsedMessages: buildMessages([
+ {
+ ...baseMessage,
+ id: 1,
+ role: "user",
+ content: [{ type: "text", text: "First prompt" }],
+ },
+ {
+ ...baseMessage,
+ id: 2,
+ role: "assistant",
+ content: [
+ {
+ type: "text",
+ text: "a".repeat(800),
+ },
+ ],
+ },
+ {
+ ...baseMessage,
+ id: 3,
+ role: "user",
+ content: [{ type: "text", text: "Second prompt" }],
+ },
+ {
+ ...baseMessage,
+ id: 4,
+ role: "assistant",
+ content: [
+ {
+ type: "text",
+ text: "b".repeat(800),
+ },
+ ],
+ },
+ {
+ ...baseMessage,
+ id: 5,
+ role: "user",
+ content: [{ type: "text", text: "Third prompt" }],
+ },
+ ]),
+ onEditUserMessage: fn(),
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ // Reveal the hover-only action rows so we can interact with
+ // the chevron buttons without dispatching real hover events.
+ for (const el of canvasElement.querySelectorAll("[class]")) {
+ if (
+ el instanceof HTMLElement &&
+ el.className.includes("group-hover/msg:opacity-100")
+ ) {
+ el.style.opacity = "1";
+ }
+ }
+
+ const prevButtons = canvas.getAllByRole("button", {
+ name: "Jump to previous user message",
+ });
+ const nextButtons = canvas.getAllByRole("button", {
+ name: "Jump to next user message",
+ });
+ expect(prevButtons).toHaveLength(3);
+ expect(nextButtons).toHaveLength(3);
+
+ // First user prompt: previous disabled, next enabled.
+ expect(prevButtons[0]).toBeDisabled();
+ expect(nextButtons[0]).toBeEnabled();
+
+ // Middle user prompt: both directions enabled.
+ expect(prevButtons[1]).toBeEnabled();
+ expect(nextButtons[1]).toBeEnabled();
+
+ // Last user prompt: previous enabled, next disabled.
+ expect(prevButtons[2]).toBeEnabled();
+ expect(nextButtons[2]).toBeDisabled();
+
+ // Clicking Next on the first prompt scrolls the second user
+ // prompt's sentinel into view via its registered ref.
+ const sentinels = Array.from(
+ canvasElement.querySelectorAll("[data-user-sentinel]"),
+ );
+ expect(sentinels).toHaveLength(3);
+ const targetSpy = spyOn(sentinels[1], "scrollIntoView");
+
+ await userEvent.click(nextButtons[0]);
+
+ await waitFor(() => {
+ expect(targetSpy).toHaveBeenCalledTimes(1);
+ });
+ expect(targetSpy).toHaveBeenCalledWith({
+ behavior: "smooth",
+ block: "start",
+ });
+ },
+};
+
/** Copy + edit actions appear below user messages on hover. */
export const UserMessageCopyButton: Story = {
args: {
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx
index 9ea957a0ee..d78fb89bf8 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx
+++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx
@@ -1,4 +1,9 @@
-import { ChevronDownIcon, PencilIcon } from "lucide-react";
+import {
+ ChevronDownIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+ PencilIcon,
+} from "lucide-react";
import {
type FC,
Fragment,
@@ -502,6 +507,9 @@ const ChatMessageItem = memo<{
latestAskUserQuestionToolId?: string;
askUserQuestionResponseTextByToolId?: ReadonlyMap;
hasUserResponseAfterAskQuestion?: boolean;
+ prevUserMessageId?: number;
+ nextUserMessageId?: number;
+ onJumpToUserMessage?: (messageId: number) => void;
}>(
({
message,
@@ -519,6 +527,9 @@ const ChatMessageItem = memo<{
latestAskUserQuestionToolId,
askUserQuestionResponseTextByToolId,
hasUserResponseAfterAskQuestion = false,
+ prevUserMessageId,
+ nextUserMessageId,
+ onJumpToUserMessage,
urlTransform,
mcpServers,
@@ -644,6 +655,61 @@ const ChatMessageItem = memo<{
Edit message
)}
+ {isUser &&
+ onJumpToUserMessage &&
+ (prevUserMessageId !== undefined ||
+ nextUserMessageId !== undefined) && (
+ <>
+
+
+ {
+ if (prevUserMessageId !== undefined) {
+ onJumpToUserMessage(prevUserMessageId);
+ }
+ }}
+ >
+
+
+ Jump to previous user message
+
+
+
+
+ Jump to previous user message
+
+
+
+
+ {
+ if (nextUserMessageId !== undefined) {
+ onJumpToUserMessage(nextUserMessageId);
+ }
+ }}
+ >
+
+
+ Jump to next user message
+
+
+
+
+ Jump to next user message
+
+
+ >
+ )}
)}
{displayState.needsAssistantBottomSpacer && (
@@ -678,6 +744,10 @@ const StickyUserMessage = memo<{
) => void;
editingMessageId?: number | null;
isAfterEditingMessage?: boolean;
+ prevUserMessageId?: number;
+ nextUserMessageId?: number;
+ onJumpToUserMessage?: (messageId: number) => void;
+ registerSentinel?: (messageId: number, el: HTMLDivElement | null) => void;
}>(
({
message,
@@ -685,11 +755,20 @@ const StickyUserMessage = memo<{
onEditUserMessage,
editingMessageId,
isAfterEditingMessage = false,
+ prevUserMessageId,
+ nextUserMessageId,
+ onJumpToUserMessage,
+ registerSentinel,
}) => {
const [isStuck, setIsStuck] = useState(false);
const [isReady, setIsReady] = useState(false);
const [isTooTall, setIsTooTall] = useState(false);
const sentinelRef = useRef(null);
+ const messageId = message.id;
+ const setSentinelRef = (el: HTMLDivElement | null) => {
+ sentinelRef.current = el;
+ registerSentinel?.(messageId, el);
+ };
const containerRef = useRef(null);
const updateFnRef = useRef<(() => void) | null>(null);
@@ -880,7 +959,7 @@ const StickyUserMessage = memo<{
return (
<>
-
+
@@ -951,6 +1033,9 @@ const StickyUserMessage = memo<{
onEditUserMessage={handleEditUserMessage}
editingMessageId={editingMessageId}
isAfterEditingMessage={isAfterEditingMessage}
+ prevUserMessageId={prevUserMessageId}
+ nextUserMessageId={nextUserMessageId}
+ onJumpToUserMessage={onJumpToUserMessage}
fadeFromBottom
/>
@@ -1022,6 +1107,21 @@ export const ConversationTimeline = memo(
hasActiveStream,
isAwaitingFirstStreamChunk,
}) => {
+ const sentinelsRef = useRef>(new Map());
+ const registerSentinel = (messageId: number, el: HTMLDivElement | null) => {
+ if (el) {
+ sentinelsRef.current.set(messageId, el);
+ } else {
+ sentinelsRef.current.delete(messageId);
+ }
+ };
+ const jumpToUserMessage = (messageId: number) => {
+ sentinelsRef.current.get(messageId)?.scrollIntoView({
+ behavior: "smooth",
+ block: "start",
+ });
+ };
+
const lastInChainFlags = computeLastInChainFlags(parsedMessages);
if (parsedMessages.length === 0) {
@@ -1044,6 +1144,34 @@ export const ConversationTimeline = memo(
}
}
+ // Ordered list of visible user message IDs, used to drive the
+ // per-bubble prev/next arrow buttons that jump the transcript
+ // to the neighbouring user prompt.
+ const visibleUserMessageIds: number[] = [];
+ for (const { message, parsed } of parsedMessages) {
+ if (message.role !== "user") continue;
+ const { shouldHide } = deriveMessageDisplayState({
+ message,
+ parsed,
+ hideActions: false,
+ hasActiveStream: false,
+ isAwaitingFirstStreamChunk: false,
+ });
+ if (!shouldHide) visibleUserMessageIds.push(message.id);
+ }
+ const userNeighborsById = new Map<
+ number,
+ { prevId?: number; nextId?: number }
+ >();
+ for (let i = 0; i < visibleUserMessageIds.length; i++) {
+ userNeighborsById.set(visibleUserMessageIds[i], {
+ prevId: i > 0 ? visibleUserMessageIds[i - 1] : undefined,
+ nextId:
+ i < visibleUserMessageIds.length - 1
+ ? visibleUserMessageIds[i + 1]
+ : undefined,
+ });
+ }
let latestAskUserQuestionToolId: string | undefined;
let hasUserResponseAfterAskQuestion = false;
const askUserQuestionResponseTextByToolId = new Map();
@@ -1106,6 +1234,10 @@ export const ConversationTimeline = memo(
onEditUserMessage={onEditUserMessage}
editingMessageId={editingMessageId}
isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
+ prevUserMessageId={userNeighborsById.get(message.id)?.prevId}
+ nextUserMessageId={userNeighborsById.get(message.id)?.nextId}
+ onJumpToUserMessage={jumpToUserMessage}
+ registerSentinel={registerSentinel}
/>
);
}