feat: render file references inline in user messages (#23131)

This commit is contained in:
Kyle Carberry
2026-03-16 17:17:23 -04:00
committed by GitHub
parent 93b9d70a9b
commit 7b0aa31b55
3 changed files with 124 additions and 56 deletions
@@ -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}
<span className="text-content-link">:{lineLabel}</span>
</span>
<button
type="button"
className="ml-auto inline-flex size-4 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-content-secondary transition-colors hover:text-content-primary cursor-pointer"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove();
}}
aria-label="Remove reference"
tabIndex={-1}
>
<XIcon className="size-2" />
</button>
{onRemove && (
<button
type="button"
className="ml-auto inline-flex size-4 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-content-secondary transition-colors hover:text-content-primary cursor-pointer"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove();
}}
aria-label="Remove reference"
tabIndex={-1}
>
<XIcon className="size-2" />
</button>
)}
</span>
);
}
@@ -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();
},
};
@@ -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<RenderBlock, { type: "response" }>
| Extract<RenderBlock, { type: "file-reference" }> =>
b.type === "response" || b.type === "file-reference",
)
: [];
const conversationItemProps: { role: "user" | "assistant" } = {
role: isUser ? "user" : "assistant",
};
@@ -392,7 +407,20 @@ const ChatMessageItem = memo<{
<div className="flex flex-col gap-1.5">
<div className="flex items-start gap-2">
<span className="min-w-0 flex-1">
{parsed.markdown || ""}
{userInlineContent.length > 0
? userInlineContent.map((block, i) =>
block.type === "response" ? (
<Fragment key={i}>{block.text}</Fragment>
) : (
<FileReferenceChip
key={i}
fileName={block.fileName}
startLine={block.startLine}
endLine={block.endLine}
/>
),
)
: parsed.markdown || ""}
</span>
{isSavingMessage && (
<Spinner
@@ -435,45 +463,7 @@ const ChatMessageItem = memo<{
</div>
);
})()}
{(() => {
const fileRefBlocks = parsed.blocks.filter(
(
b,
): b is Extract<
RenderBlock,
{ type: "file-reference" }
> => b.type === "file-reference",
);
if (fileRefBlocks.length === 0) return null;
return (
<div className="flex flex-col gap-1 border-t border-border-default pt-1.5">
{fileRefBlocks.map((dc, i) => (
<div
key={i}
className="flex items-start gap-2 rounded border border-content-link/20 bg-content-link/5 px-2 py-1"
>
<FileIcon
fileName={
dc.fileName.split("/").pop() || dc.fileName
}
className="shrink-0"
/>
<span className="shrink-0 text-2xs font-mono font-medium text-content-link">
{dc.fileName.split("/").pop()}:
{dc.startLine === dc.endLine
? dc.startLine
: `${dc.startLine}\u2013${dc.endLine}`}
</span>
{dc.text && (
<span className="text-2xs text-content-primary">
{dc.text}
</span>
)}
</div>
))}
</div>
);
})()} {fadeFromBottom && (
{fadeFromBottom && (
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-1/2 max-h-12"
style={{