mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
feat: render file references inline in user messages (#23131)
This commit is contained in:
@@ -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={{
|
||||
|
||||
Reference in New Issue
Block a user