mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site/src/pages/AgentsPage/components/ChatConversation): jump between user prompts via arrow buttons (#25336)
Add prev/next chevron buttons to the action row under each user message
in the agent chat transcript. Clicking jumps the scroll container to the
neighbouring user prompt's sticky sentinel (smooth scroll, no composer
mutation). Arrows disable rather than wrap when at the ends.
## Why
When a chat gets long, scrolling back to a previous prompt to see the
question that produced an answer is annoying. The transcript already has
a stable per-prompt anchor (`data-user-sentinel`) used by the
sticky-message logic, so reusing it for navigation is cheap and
consistent with the existing scroll model.
## Implementation
- `ChatMessageItem` accepts three optional props (`prevUserMessageId`,
`nextUserMessageId`, `onJumpToUserMessage`) and renders the two chevron
buttons inside the existing `message-actions` row when the message is a
user role.
- `StickyUserMessage` forwards the props to both copies of
`ChatMessageItem` (flow + sticky overlay).
- `ConversationTimeline` derives the ordered list of visible user
message IDs using the same `deriveMessageDisplayState` predicate that
controls visibility, builds a neighbour map, and supplies the jump
handler. The handler resolves the target via
`[data-user-sentinel][data-user-message-id="..."]` and smooth-scrolls
the closest `.overflow-y-auto` ancestor by the sentinel's offset
(mirroring the existing edit-flow scroll helper).
- New `data-user-message-id` attribute on the sentinel `div` to make the
lookup direct.
- New Storybook story `UserMessageJumpArrows` covers: arrow counts,
disabled-at-ends, and that clicking Next scrolls the next user sentinel
to the top of the scroller. JSDOM doesn't animate smooth scroll, so the
play function monkey-patches `scrollBy` to apply the requested top
offset synchronously.
No API, DB, or audit-table changes. Frontend only.
## Test
- `pnpm test:storybook
src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx`
— 46 passed (incl. new story).
- `pnpm test:storybook
src/pages/AgentsPage/components/ChatConversation/` — 67 passed.
- `pnpm lint:types`, `pnpm lint:fix`, `pnpm lint:compiler`, `pnpm
format:check` — all clean.
- Local `make pre-commit` ran via the pre-commit hook on commit.
<details>
<summary>Implementation plan</summary>
Plan lives at
`/home/coder/.coder/plans/PLAN-41b442d8-05bc-4b62-b1ba-155a7cef09bc.md`
in the agent workspace. Summary:
1. Add three optional props (`prevUserMessageId`, `nextUserMessageId`,
`onJumpToUserMessage`) to `ChatMessageItem` and render
`ChevronLeft`/`ChevronRight` buttons inside the existing actions row
when the message is a user role. Disable each button when its neighbour
is undefined.
2. Forward those props through `StickyUserMessage` to both
`ChatMessageItem` instances (flow + sticky overlay).
3. In `ConversationTimeline`, build the ordered list of visible user IDs
using the same `deriveMessageDisplayState` predicate, derive a neighbour
map, and implement `handleJumpToUserMessage` that looks up
`[data-user-sentinel][data-user-message-id="${id}"]`, finds the closest
`.overflow-y-auto` ancestor, and smooth-scrolls by the sentinel's
offset.
4. Add `data-user-message-id` to the sentinel so the lookup is direct.
5. Cover the behaviour with a `UserMessageJumpArrows` Storybook play
function.
</details>
---
*This PR was authored by Coder Agents on behalf of @ibetitsmike.*
This commit is contained in:
+119
@@ -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) => (
|
||||
<div
|
||||
className="overflow-y-auto mx-auto w-full max-w-3xl"
|
||||
style={{ height: 320 }}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
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<HTMLElement>("[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: {
|
||||
|
||||
@@ -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<string, string>;
|
||||
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<{
|
||||
<TooltipContent side="bottom">Edit message</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isUser &&
|
||||
onJumpToUserMessage &&
|
||||
(prevUserMessageId !== undefined ||
|
||||
nextUserMessageId !== undefined) && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
className="size-6"
|
||||
aria-label="Jump to previous user message"
|
||||
disabled={prevUserMessageId === undefined}
|
||||
onClick={() => {
|
||||
if (prevUserMessageId !== undefined) {
|
||||
onJumpToUserMessage(prevUserMessageId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span className="sr-only">
|
||||
Jump to previous user message
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Jump to previous user message
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
className="size-6"
|
||||
aria-label="Jump to next user message"
|
||||
disabled={nextUserMessageId === undefined}
|
||||
onClick={() => {
|
||||
if (nextUserMessageId !== undefined) {
|
||||
onJumpToUserMessage(nextUserMessageId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
<span className="sr-only">
|
||||
Jump to next user message
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Jump to next user message
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{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<HTMLDivElement>(null);
|
||||
const messageId = message.id;
|
||||
const setSentinelRef = (el: HTMLDivElement | null) => {
|
||||
sentinelRef.current = el;
|
||||
registerSentinel?.(messageId, el);
|
||||
};
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const updateFnRef = useRef<(() => void) | null>(null);
|
||||
|
||||
@@ -880,7 +959,7 @@ const StickyUserMessage = memo<{
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={sentinelRef} className="h-0" data-user-sentinel />
|
||||
<div ref={setSentinelRef} className="h-0" data-user-sentinel />
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
@@ -909,6 +988,9 @@ const StickyUserMessage = memo<{
|
||||
onEditUserMessage={handleEditUserMessage}
|
||||
editingMessageId={editingMessageId}
|
||||
isAfterEditingMessage={isAfterEditingMessage}
|
||||
prevUserMessageId={prevUserMessageId}
|
||||
nextUserMessageId={nextUserMessageId}
|
||||
onJumpToUserMessage={onJumpToUserMessage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -951,6 +1033,9 @@ const StickyUserMessage = memo<{
|
||||
onEditUserMessage={handleEditUserMessage}
|
||||
editingMessageId={editingMessageId}
|
||||
isAfterEditingMessage={isAfterEditingMessage}
|
||||
prevUserMessageId={prevUserMessageId}
|
||||
nextUserMessageId={nextUserMessageId}
|
||||
onJumpToUserMessage={onJumpToUserMessage}
|
||||
fadeFromBottom
|
||||
/>
|
||||
</div>
|
||||
@@ -1022,6 +1107,21 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
|
||||
hasActiveStream,
|
||||
isAwaitingFirstStreamChunk,
|
||||
}) => {
|
||||
const sentinelsRef = useRef<Map<number, HTMLDivElement>>(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<ConversationTimelineProps>(
|
||||
}
|
||||
}
|
||||
|
||||
// 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<string, string>();
|
||||
@@ -1106,6 +1234,10 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user