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) && ( + <> + + + + + + Jump to previous 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} /> ); }