From 7b0aa31b55d82bb00c136baeea95179c99b922e7 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 16 Mar 2026 17:17:23 -0400 Subject: [PATCH] feat: render file references inline in user messages (#23131) --- .../ChatMessageInput/FileReferenceNode.tsx | 32 ++++---- .../ConversationTimeline.stories.tsx | 76 +++++++++++++++++++ .../AgentDetail/ConversationTimeline.tsx | 72 ++++++++---------- 3 files changed, 124 insertions(+), 56 deletions(-) diff --git a/site/src/components/ChatMessageInput/FileReferenceNode.tsx b/site/src/components/ChatMessageInput/FileReferenceNode.tsx index e735948e72..0ab0196084 100644 --- a/site/src/components/ChatMessageInput/FileReferenceNode.tsx +++ b/site/src/components/ChatMessageInput/FileReferenceNode.tsx @@ -23,7 +23,7 @@ type SerializedFileReferenceNode = Spread< SerializedLexicalNode >; -function FileReferenceChip({ +export function FileReferenceChip({ fileName, startLine, endLine, @@ -35,7 +35,7 @@ function FileReferenceChip({ startLine: number; endLine: number; isSelected?: boolean; - onRemove: () => void; + onRemove?: () => void; onClick?: () => void; }) { const shortFile = fileName.split("/").pop() || fileName; @@ -66,19 +66,21 @@ function FileReferenceChip({ {shortFile} :{lineLabel} - + {onRemove && ( + + )} ); } diff --git a/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.stories.tsx index 4d7470ee2b..c59e3b2a99 100644 --- a/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.stories.tsx @@ -244,3 +244,79 @@ export const UserMessageWithImagesAndFileRefs: Story = { expect(canvas.getByText(/main\.go/)).toBeInTheDocument(); }, }; + +/** File references render inline with text, matching the chat input style. */ +export const UserMessageWithInlineFileRef: Story = { + args: { + ...defaultArgs, + parsedSections: buildSections([ + { + ...baseMessage, + id: 1, + role: "user", + content: [ + { type: "text", text: "Can you refactor " }, + { + type: "file-reference", + file_name: "site/src/components/Button.tsx", + start_line: 42, + end_line: 42, + content: "export const Button = ...", + }, + { type: "text", text: " to use the new API?" }, + ], + }, + { + ...baseMessage, + id: 2, + role: "assistant", + content: [{ type: "text", text: "Sure, I'll update that component." }], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // File ref chip is inline, rendered as a button + expect(canvas.getByText(/Button\.tsx/)).toBeInTheDocument(); + // Surrounding text is present + expect(canvas.getByText(/Can you refactor/)).toBeInTheDocument(); + expect(canvas.getByText(/to use the new API/)).toBeInTheDocument(); + }, +}; + +/** Multiple file references render inline, no separate section. */ +export const UserMessageWithMultipleInlineFileRefs: Story = { + args: { + ...defaultArgs, + parsedSections: buildSections([ + { + ...baseMessage, + id: 1, + role: "user", + content: [ + { type: "text", text: "Compare " }, + { + type: "file-reference", + file_name: "api/handler.go", + start_line: 1, + end_line: 50, + content: "...", + }, + { type: "text", text: " with " }, + { + type: "file-reference", + file_name: "api/handler_test.go", + start_line: 10, + end_line: 30, + content: "...", + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText(/handler\.go/)).toBeInTheDocument(); + expect(canvas.getByText(/handler_test\.go/)).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx b/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx index 401b598f18..eff6c3d9b9 100644 --- a/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx @@ -8,11 +8,12 @@ import { Tool, } from "components/ai-elements"; import { WebSearchSources } from "components/ai-elements/tool"; -import { FileIcon } from "components/FileIcon/FileIcon"; +import { FileReferenceChip } from "components/ChatMessageInput/FileReferenceNode"; import { Spinner } from "components/Spinner/Spinner"; import { ChevronDownIcon } from "lucide-react"; import { type FC, + Fragment, memo, type ReactNode, type RefObject, @@ -337,6 +338,20 @@ const ChatMessageItem = memo<{ parsed.blocks.length > 0 || parsed.tools.length > 0 || parsed.sources.length > 0; + + // Pre-compute the inline content for user messages so we + // avoid a filter + map inside the JSX return path. + const userInlineContent = isUser + ? parsed.blocks.filter( + ( + b, + ): b is + | Extract + | Extract => + b.type === "response" || b.type === "file-reference", + ) + : []; + const conversationItemProps: { role: "user" | "assistant" } = { role: isUser ? "user" : "assistant", }; @@ -392,7 +407,20 @@ const ChatMessageItem = memo<{
- {parsed.markdown || ""} + {userInlineContent.length > 0 + ? userInlineContent.map((block, i) => + block.type === "response" ? ( + {block.text} + ) : ( + + ), + ) + : parsed.markdown || ""} {isSavingMessage && ( ); })()} - {(() => { - const fileRefBlocks = parsed.blocks.filter( - ( - b, - ): b is Extract< - RenderBlock, - { type: "file-reference" } - > => b.type === "file-reference", - ); - if (fileRefBlocks.length === 0) return null; - return ( -
- {fileRefBlocks.map((dc, i) => ( -
- - - {dc.fileName.split("/").pop()}: - {dc.startLine === dc.endLine - ? dc.startLine - : `${dc.startLine}\u2013${dc.endLine}`} - - {dc.text && ( - - {dc.text} - - )} -
- ))} -
- ); - })()} {fadeFromBottom && ( + {fadeFromBottom && (