diff --git a/coderd/chats.go b/coderd/chats.go index e005848705..b7dc6a8028 100644 --- a/coderd/chats.go +++ b/coderd/chats.go @@ -2518,6 +2518,25 @@ func createChatInputFromParts( MediaType: chatFile.Mimetype, }) fileIDs[len(content)-1] = part.FileID + case string(codersdk.ChatInputPartTypeFileReference): + if part.FileName == "" { + return nil, nil, "", &codersdk.Response{ + Message: "Invalid input part.", + Detail: fmt.Sprintf("%s[%d].file_name cannot be empty for file-reference.", fieldName, i), + } + } + lineRange := fmt.Sprintf("%d", part.StartLine) + if part.StartLine != part.EndLine { + lineRange = fmt.Sprintf("%d-%d", part.StartLine, part.EndLine) + } + var sb strings.Builder + _, _ = fmt.Fprintf(&sb, "[file-reference] %s:%s", part.FileName, lineRange) + if strings.TrimSpace(part.Content) != "" { + _, _ = fmt.Fprintf(&sb, "\n```%s\n%s\n```", part.FileName, strings.TrimSpace(part.Content)) + } + text := sb.String() + content = append(content, fantasy.TextContent{Text: text}) + textParts = append(textParts, text) default: return nil, nil, "", &codersdk.Response{ Message: "Invalid input part.", diff --git a/coderd/chats_test.go b/coderd/chats_test.go index 30a45a23b9..faaeb491b6 100644 --- a/coderd/chats_test.go +++ b/coderd/chats_test.go @@ -1527,6 +1527,372 @@ func TestPostChatMessages(t *testing.T) { }) } +func TestChatMessageWithFileReferences(t *testing.T) { + t.Parallel() + + // createChat is a helper that creates a chat so we can post messages to it. + createChatForTest := func(t *testing.T, client *codersdk.Client) codersdk.Chat { + t.Helper() + ctx := testutil.Context(t, testutil.WaitLong) + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "initial message", + }}, + }) + require.NoError(t, err) + return chat + } + + t.Run("FileReferenceOnly", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + chat := createChatForTest(t, client) + + created, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeFileReference, + FileName: "main.go", + StartLine: 10, + EndLine: 15, + Content: "func broken() {}", + }}, + }) + require.NoError(t, err) + + // The file-reference is stored as a formatted text block. + wantText := "[file-reference] main.go:10-15\n" + + "```main.go\nfunc broken() {}\n```" + + var found bool + require.Eventually(t, func() bool { + chatWithMessages, getErr := client.GetChat(ctx, chat.ID) + if getErr != nil { + return false + } + for _, message := range chatWithMessages.Messages { + if message.Role != "user" { + continue + } + for _, part := range message.Content { + if part.Type == codersdk.ChatMessagePartTypeText && + part.Text == wantText { + found = true + return true + } + } + } + // The message may have been queued. + if created.Queued && created.QueuedMessage != nil { + for _, queued := range chatWithMessages.QueuedMessages { + for _, part := range queued.Content { + if part.Type == codersdk.ChatMessagePartTypeText && + part.Text == wantText { + found = true + return true + } + } + } + } + return false + }, testutil.WaitLong, testutil.IntervalFast) + require.True(t, found, "expected to find file-reference text in stored message") + }) + + t.Run("FileReferenceSingleLine", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + chat := createChatForTest(t, client) + + created, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeFileReference, + FileName: "lib/utils.ts", + StartLine: 42, + EndLine: 42, + Content: "const x = 1;", + }}, + }) + require.NoError(t, err) + + // Single-line range should use "42" not "42-42". + wantText := "[file-reference] lib/utils.ts:42\n" + + "```lib/utils.ts\nconst x = 1;\n```" + + require.Eventually(t, func() bool { + chatWithMessages, getErr := client.GetChat(ctx, chat.ID) + if getErr != nil { + return false + } + for _, msg := range chatWithMessages.Messages { + for _, part := range msg.Content { + if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText { + return true + } + } + } + if created.Queued && created.QueuedMessage != nil { + for _, queued := range chatWithMessages.QueuedMessages { + for _, part := range queued.Content { + if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText { + return true + } + } + } + } + return false + }, testutil.WaitLong, testutil.IntervalFast) + }) + + t.Run("FileReferenceWithoutContent", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + chat := createChatForTest(t, client) + + created, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeFileReference, + FileName: "README.md", + StartLine: 1, + EndLine: 1, + // No code content — just a file reference. + }}, + }) + require.NoError(t, err) + + // No fenced code block when content is empty. + wantText := "[file-reference] README.md:1" + require.Eventually(t, func() bool { + chatWithMessages, getErr := client.GetChat(ctx, chat.ID) + if getErr != nil { + return false + } + for _, msg := range chatWithMessages.Messages { + for _, part := range msg.Content { + if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText { + return true + } + } + } + if created.Queued && created.QueuedMessage != nil { + for _, queued := range chatWithMessages.QueuedMessages { + for _, part := range queued.Content { + if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText { + return true + } + } + } + } + return false + }, testutil.WaitLong, testutil.IntervalFast) + }) + + t.Run("FileReferenceWithCode", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + chat := createChatForTest(t, client) + + created, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeFileReference, + FileName: "server.go", + StartLine: 5, + EndLine: 8, + Content: "func main() {\n\tfmt.Println()\n}", + }}, + }) + require.NoError(t, err) + + wantText := "[file-reference] server.go:5-8\n" + + "```server.go\nfunc main() {\n\tfmt.Println()\n}\n```" + + require.Eventually(t, func() bool { + chatWithMessages, getErr := client.GetChat(ctx, chat.ID) + if getErr != nil { + return false + } + for _, msg := range chatWithMessages.Messages { + for _, part := range msg.Content { + if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText { + return true + } + } + } + if created.Queued && created.QueuedMessage != nil { + for _, queued := range chatWithMessages.QueuedMessages { + for _, part := range queued.Content { + if part.Type == codersdk.ChatMessagePartTypeText && part.Text == wantText { + return true + } + } + } + } + return false + }, testutil.WaitLong, testutil.IntervalFast) + }) + + t.Run("InterleavedTextAndFileReferences", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + chat := createChatForTest(t, client) + + created, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "Please review these two issues:", + }, + { + Type: codersdk.ChatInputPartTypeFileReference, + FileName: "a.go", + StartLine: 1, + EndLine: 3, + Content: "line1\nline2\nline3", + }, + { + Type: codersdk.ChatInputPartTypeText, + Text: "first issue", + }, + { + Type: codersdk.ChatInputPartTypeText, + Text: "and also:", + }, + { + Type: codersdk.ChatInputPartTypeFileReference, + FileName: "b.go", + StartLine: 10, + EndLine: 10, + Content: "return nil", + }, + { + Type: codersdk.ChatInputPartTypeText, + Text: "second issue", + }, + }, + }) + require.NoError(t, err) + + // Verify that all six parts are stored in order. + wantTexts := []string{ + "Please review these two issues:", + "[file-reference] a.go:1-3\n```a.go\nline1\nline2\nline3\n```", + "first issue", + "and also:", + "[file-reference] b.go:10\n```b.go\nreturn nil\n```", + "second issue", + } + + require.Eventually(t, func() bool { + chatWithMessages, getErr := client.GetChat(ctx, chat.ID) + if getErr != nil { + return false + } + + // Check messages and queued messages for the + // interleaved parts in order. + checkParts := func(parts []codersdk.ChatMessagePart) bool { + textParts := make([]string, 0, len(parts)) + for _, part := range parts { + if part.Type == codersdk.ChatMessagePartTypeText { + textParts = append(textParts, part.Text) + } + } + if len(textParts) != len(wantTexts) { + return false + } + for i, want := range wantTexts { + if textParts[i] != want { + return false + } + } + return true + } + + for _, msg := range chatWithMessages.Messages { + if msg.Role == "user" && checkParts(msg.Content) { + return true + } + } + if created.Queued && created.QueuedMessage != nil { + for _, queued := range chatWithMessages.QueuedMessages { + if checkParts(queued.Content) { + return true + } + } + } + return false + }, testutil.WaitLong, testutil.IntervalFast) + }) + + t.Run("EmptyFileName", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + chat := createChatForTest(t, client) + + _, err := client.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeFileReference, + FileName: "", + StartLine: 1, + EndLine: 1, + }}, + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid input part.", sdkErr.Message) + require.Equal(t, "content[0].file_name cannot be empty for file-reference.", sdkErr.Detail) + }) + + t.Run("CreateChatWithFileReference", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + + // File references should also work in the initial CreateChat call. + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeFileReference, + FileName: "bug.py", + StartLine: 7, + EndLine: 7, + Content: "x = None", + }}, + }) + require.NoError(t, err) + require.NotEqual(t, uuid.Nil, chat.ID) + + // Title is derived from the text parts. For file-references + // the formatted text becomes the title source. + require.NotEmpty(t, chat.Title) + }) +} + func TestChatMessageWithFiles(t *testing.T) { t.Parallel() diff --git a/codersdk/chats.go b/codersdk/chats.go index e3b81caa9c..d354495a85 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -72,12 +72,13 @@ type ChatMessageUsage struct { type ChatMessagePartType string const ( - ChatMessagePartTypeText ChatMessagePartType = "text" - ChatMessagePartTypeReasoning ChatMessagePartType = "reasoning" - ChatMessagePartTypeToolCall ChatMessagePartType = "tool-call" - ChatMessagePartTypeToolResult ChatMessagePartType = "tool-result" - ChatMessagePartTypeSource ChatMessagePartType = "source" - ChatMessagePartTypeFile ChatMessagePartType = "file" + ChatMessagePartTypeText ChatMessagePartType = "text" + ChatMessagePartTypeReasoning ChatMessagePartType = "reasoning" + ChatMessagePartTypeToolCall ChatMessagePartType = "tool-call" + ChatMessagePartTypeToolResult ChatMessagePartType = "tool-result" + ChatMessagePartTypeSource ChatMessagePartType = "source" + ChatMessagePartTypeFile ChatMessagePartType = "file" + ChatMessagePartTypeFileReference ChatMessagePartType = "file-reference" ) // ChatMessagePart is a structured chunk of a chat message. @@ -98,14 +99,22 @@ type ChatMessagePart struct { MediaType string `json:"media_type,omitempty"` Data []byte `json:"data,omitempty"` FileID uuid.NullUUID `json:"file_id,omitempty" format:"uuid"` + // The following fields are only set when Type is + // ChatInputPartTypeFileReference. + FileName string `json:"file_name,omitempty"` + StartLine int `json:"start_line,omitempty"` + EndLine int `json:"end_line,omitempty"` + // The code content from the diff that was commented on. + Content string `json:"content,omitempty"` } // ChatInputPartType represents an input part type for user chat input. type ChatInputPartType string const ( - ChatInputPartTypeText ChatInputPartType = "text" - ChatInputPartTypeFile ChatInputPartType = "file" + ChatInputPartTypeText ChatInputPartType = "text" + ChatInputPartTypeFile ChatInputPartType = "file" + ChatInputPartTypeFileReference ChatInputPartType = "file-reference" ) // ChatInputPart is a single user input part for creating a chat. @@ -113,6 +122,13 @@ type ChatInputPart struct { Type ChatInputPartType `json:"type"` Text string `json:"text,omitempty"` FileID uuid.UUID `json:"file_id,omitempty" format:"uuid"` + // The following fields are only set when Type is + // ChatInputPartTypeFileReference. + FileName string `json:"file_name,omitempty"` + StartLine int `json:"start_line,omitempty"` + EndLine int `json:"end_line,omitempty"` + // The code content from the diff that was commented on. + Content string `json:"content,omitempty"` } // CreateChatRequest is the request to create a new chat. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 4efd1153d4..059eaf5a53 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1121,12 +1121,27 @@ export interface ChatInputPart { readonly type: ChatInputPartType; readonly text?: string; readonly file_id?: string; + /** + * The following fields are only set when Type is + * ChatInputPartTypeFileReference. + */ + readonly file_name?: string; + readonly start_line?: number; + readonly end_line?: number; + /** + * The code content from the diff that was commented on. + */ + readonly content?: string; } // From codersdk/chats.go -export type ChatInputPartType = "file" | "text"; +export type ChatInputPartType = "file" | "file-reference" | "text"; -export const ChatInputPartTypes: ChatInputPartType[] = ["file", "text"]; +export const ChatInputPartTypes: ChatInputPartType[] = [ + "file", + "file-reference", + "text", +]; // From codersdk/chats.go /** @@ -1163,11 +1178,23 @@ export interface ChatMessagePart { readonly media_type?: string; readonly data?: string; readonly file_id?: string; + /** + * The following fields are only set when Type is + * ChatInputPartTypeFileReference. + */ + readonly file_name?: string; + readonly start_line?: number; + readonly end_line?: number; + /** + * The code content from the diff that was commented on. + */ + readonly content?: string; } // From codersdk/chats.go export type ChatMessagePartType = | "file" + | "file-reference" | "reasoning" | "source" | "text" @@ -1176,6 +1203,7 @@ export type ChatMessagePartType = export const ChatMessagePartTypes: ChatMessagePartType[] = [ "file", + "file-reference", "reasoning", "source", "text", diff --git a/site/src/components/ChatMessageInput/ChatMessageInput.tsx b/site/src/components/ChatMessageInput/ChatMessageInput.tsx index 77e7cecea9..dcebd74380 100644 --- a/site/src/components/ChatMessageInput/ChatMessageInput.tsx +++ b/site/src/components/ChatMessageInput/ChatMessageInput.tsx @@ -32,6 +32,10 @@ import { useRef, } from "react"; import { cn } from "utils/cn"; +import { + $createFileReferenceNode, + FileReferenceNode, +} from "./FileReferenceNode"; // Blocks Cmd+B/I/U and element formatting shortcuts so the editor // stays plain-text only. @@ -160,7 +164,7 @@ const EnterKeyPlugin: FC<{ onEnter?: () => void }> = memo( // Fires the onChange callback with the editor's plain-text content // on every update. const ContentChangePlugin: FC<{ - onChange?: (content: string) => void; + onChange?: (content: string, hasFileReferences: boolean) => void; }> = memo(function ContentChangePlugin({ onChange }) { const [editor] = useLexicalComposerContext(); @@ -171,7 +175,18 @@ const ContentChangePlugin: FC<{ editorState.read(() => { const root = $getRoot(); const content = root.getTextContent(); - onChange(content); + let hasRefs = false; + for (const child of root.getChildren()) { + if (child.getType() !== "paragraph") continue; + for (const node of (child as ParagraphNode).getChildren()) { + if (node instanceof FileReferenceNode) { + hasRefs = true; + break; + } + } + if (hasRefs) break; + } + onChange(content, hasRefs); }); }); }, [editor, onChange]); @@ -222,18 +237,50 @@ const InsertTextPlugin: FC<{ return null; }); +/** + * Structured data for a file reference extracted from the editor. + */ +interface FileReferenceData { + readonly fileName: string; + readonly startLine: number; + readonly endLine: number; + readonly content: string; +} + +/** + * A content part extracted from the Lexical editor in document order. + * Either a text segment or a file-reference chip. + */ +type EditorContentPart = + | { readonly type: "text"; readonly text: string } + | { + readonly type: "file-reference"; + readonly reference: FileReferenceData; + }; + export interface ChatMessageInputRef { insertText: (text: string) => void; clear: () => void; focus: () => void; getValue: () => string; + /** + * Insert a file reference chip in a single Lexical update + * (atomic for undo/redo). + */ + addFileReference: (ref: FileReferenceData) => void; + /** + * Walk the Lexical tree in document order and return interleaved + * text / file-reference parts. Adjacent text nodes within the same + * paragraph are merged, and paragraphs are separated by newlines. + */ + getContentParts: () => EditorContentPart[]; } interface ChatMessageInputProps extends Omit, "onChange" | "role" | "ref"> { placeholder?: string; initialValue?: string; - onChange?: (content: string) => void; + onChange?: (content: string, hasFileReferences: boolean) => void; rows?: number; onEnter?: () => void; onFilePaste?: (file: File) => void; @@ -279,7 +326,7 @@ const ChatMessageInput = memo( paragraph: "m-0", }, onError: (error: Error) => console.error("Lexical error:", error), - nodes: [], + nodes: [FileReferenceNode], editable: !disabled, }), [disabled], @@ -298,8 +345,8 @@ const ChatMessageInput = memo( }, []); const handleContentChange = useCallback( - (content: string) => { - onChange?.(content); + (content: string, hasFileReferences: boolean) => { + onChange?.(content, hasFileReferences); }, [onChange], ); @@ -379,6 +426,74 @@ const ChatMessageInput = memo( }); return content; }, + addFileReference: (ref: FileReferenceData) => { + const editor = editorRef.current; + if (!editor) return; + + editor.update(() => { + const root = $getRoot(); + let paragraph = root.getFirstChild(); + if (!paragraph || paragraph.getType() !== "paragraph") { + paragraph = $createParagraphNode(); + root.append(paragraph); + } + const chipNode = $createFileReferenceNode( + ref.fileName, + ref.startLine, + ref.endLine, + ref.content, + ); + (paragraph as ParagraphNode).append(chipNode); + chipNode.selectNext(); + }); + }, + getContentParts: () => { + const editor = editorRef.current; + if (!editor) return []; + const parts: EditorContentPart[] = []; + editor.getEditorState().read(() => { + const paragraphs = $getRoot().getChildren(); + for (let i = 0; i < paragraphs.length; i++) { + const para = paragraphs[i]; + if (para.getType() !== "paragraph") continue; + // Separate paragraphs with a newline in the + // preceding text part, just like getTextContent(). + if (i > 0) { + const last = parts[parts.length - 1]; + if (last?.type === "text") { + (last as { text: string }).text += "\n"; + } else { + parts.push({ type: "text", text: "\n" }); + } + } + for (const node of (para as ParagraphNode).getChildren()) { + if (node instanceof FileReferenceNode) { + parts.push({ + type: "file-reference", + reference: { + fileName: node.__fileName, + startLine: node.__startLine, + endLine: node.__endLine, + content: node.__content, + }, + }); + } else { + // Text node (or any other inline) — + // merge into the last text part. + const t = node.getTextContent(); + if (!t) continue; + const last = parts[parts.length - 1]; + if (last?.type === "text") { + (last as { text: string }).text += t; + } else { + parts.push({ type: "text", text: t }); + } + } + } + } + }); + return parts; + }, }), [], ); @@ -397,7 +512,7 @@ const ChatMessageInput = memo( ; + +function FileReferenceChip({ + fileName, + startLine, + endLine, + isSelected, + onRemove, + onClick, +}: { + fileName: string; + startLine: number; + endLine: number; + isSelected?: boolean; + onRemove: () => void; + onClick?: () => void; +}) { + const shortFile = fileName.split("/").pop() || fileName; + const lineLabel = + startLine === endLine ? `L${startLine}` : `L${startLine}–${endLine}`; + + return ( + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick?.(); + } + }} + role="button" + tabIndex={0} + > + + + {shortFile} + :{lineLabel} + + + + ); +} + +export class FileReferenceNode extends DecoratorNode { + __fileName: string; + __startLine: number; + __endLine: number; + __content: string; + + static getType(): string { + return "file-reference"; + } + + static clone(node: FileReferenceNode): FileReferenceNode { + return new FileReferenceNode( + node.__fileName, + node.__startLine, + node.__endLine, + node.__content, + node.__key, + ); + } + + constructor( + fileName: string, + startLine: number, + endLine: number, + content: string, + key?: NodeKey, + ) { + super(key); + this.__fileName = fileName; + this.__startLine = startLine; + this.__endLine = endLine; + this.__content = content; + } + + createDOM(_config: EditorConfig): HTMLElement { + const span = document.createElement("span"); + span.style.display = "inline"; + span.style.userSelect = "none"; + return span; + } + + updateDOM(): boolean { + return false; + } + + exportJSON(): SerializedFileReferenceNode { + return { + type: "file-reference", + version: 1, + fileName: this.__fileName, + startLine: this.__startLine, + endLine: this.__endLine, + content: this.__content, + }; + } + + static importJSON(json: SerializedFileReferenceNode): FileReferenceNode { + return new FileReferenceNode( + json.fileName, + json.startLine, + json.endLine, + json.content, + ); + } + + getTextContent(): string { + return ""; + } + + isInline(): boolean { + return true; + } + + decorate(_editor: LexicalEditor): ReactNode { + return ( + + ); + } +} + +const FileReferenceChipWrapper: FC<{ + editor: LexicalEditor; + nodeKey: NodeKey; + fileName: string; + startLine: number; + endLine: number; +}> = memo(({ editor, nodeKey, fileName, startLine, endLine }) => { + const [isSelected] = useLexicalNodeSelection(nodeKey); + + const handleRemove = () => { + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if (node instanceof FileReferenceNode) { + node.remove(); + } + }); + }; + + const handleClick = () => { + window.dispatchEvent( + new CustomEvent("file-reference-click", { + detail: { fileName, startLine, endLine }, + }), + ); + }; + + return ( + + ); +}); +FileReferenceChipWrapper.displayName = "FileReferenceChipWrapper"; + +export function $createFileReferenceNode( + fileName: string, + startLine: number, + endLine: number, + content: string, +): FileReferenceNode { + return new FileReferenceNode(fileName, startLine, endLine, content); +} diff --git a/site/src/pages/AgentsPage/AgentChatInput.tsx b/site/src/pages/AgentsPage/AgentChatInput.tsx index 13f7f013c8..41ac54fa94 100644 --- a/site/src/pages/AgentsPage/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/AgentChatInput.tsx @@ -97,7 +97,6 @@ interface AgentChatInputProps { uploadStates?: Map; previewUrls?: Map; } - const hasFiniteTokenValue = (value: number | undefined): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0; @@ -350,6 +349,8 @@ export const AgentChatInput = memo( const internalRef = useRef(null); const [previewImage, setPreviewImage] = useState(null); + const [hasFileReferences, setHasFileReferences] = useState(false); + // Merge the external inputRef with our internal ref so both // point to the same ChatMessageInputRef instance. const setRef = useCallback( @@ -426,8 +427,9 @@ export const AgentChatInput = memo( ); const handleContentChange = useCallback( - (content: string) => { + (content: string, hasRefs: boolean) => { setHasContent(Boolean(content.trim())); + setHasFileReferences(hasRefs); onContentChange?.(content); }, [onContentChange], @@ -455,9 +457,8 @@ export const AgentChatInput = memo( !isDisabled && !isLoading && hasModelOptions && - (hasContent || hasUploadedAttachments) && + (hasContent || hasUploadedAttachments || hasFileReferences) && !isUploading; - const handleSubmit = useCallback(() => { const text = internalRef.current?.getValue()?.trim() ?? ""; @@ -466,6 +467,7 @@ export const AgentChatInput = memo( if ( !text && !hasUploadedAttachments && + !hasFileReferences && !isDisabled && !isLoading && queuedMessages.length > 0 && @@ -476,14 +478,13 @@ export const AgentChatInput = memo( } if ( - (!text && !hasUploadedAttachments) || + (!text && !hasUploadedAttachments && !hasFileReferences) || isDisabled || isLoading || !hasModelOptions ) { return; } - onSend(text); internalRef.current?.focus(); }, [ @@ -491,11 +492,11 @@ export const AgentChatInput = memo( isLoading, hasModelOptions, hasUploadedAttachments, + hasFileReferences, onSend, queuedMessages, onPromoteQueuedMessage, ]); - const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { if (editingQueuedMessageID !== null) { @@ -603,7 +604,7 @@ export const AgentChatInput = memo( disabled={isDisabled || isLoading} rows={4} autoFocus - /> + />{" "}
{ clear: mockClear, insertText: vi.fn(), getValue: vi.fn().mockReturnValue(""), - }; - // The hook exposes chatInputRef – assign the mock to it. + addFileReference: vi.fn(), + getContentParts: vi.fn().mockReturnValue([]), + }; // The hook exposes chatInputRef – assign the mock to it. result.current.chatInputRef.current = mockInputRef; await act(async () => { diff --git a/site/src/pages/AgentsPage/AgentDetail.tsx b/site/src/pages/AgentsPage/AgentDetail.tsx index 98e1a23467..6ed30cd741 100644 --- a/site/src/pages/AgentsPage/AgentDetail.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.tsx @@ -70,7 +70,6 @@ import { AgentDetailTopBar } from "./AgentDetail/TopBar"; import { useMessageWindow } from "./AgentDetail/useMessageWindow"; import { useWorkspaceCreationWatcher } from "./AgentDetail/useWorkspaceCreationWatcher"; import type { AgentsOutletContext } from "./AgentsPage"; - import { getModelCatalogStatusMessage, getModelOptionsFromCatalog, @@ -784,13 +783,44 @@ const AgentDetail: FC = () => { fileIds?: string[], editedMessageID?: number, ) => { - const hasContent = message.trim() || (fileIds && fileIds.length > 0); + const chatInputHandle = ( + editing.chatInputRef as React.RefObject + )?.current; + + // Walk the Lexical tree in document order so file-reference + // parts appear at the correct position relative to the + // surrounding text the user typed. + const editorParts = chatInputHandle?.getContentParts() ?? []; + const hasFileReferences = editorParts.some( + (p) => p.type === "file-reference", + ); + const hasContent = + message.trim() || (fileIds && fileIds.length > 0) || hasFileReferences; if (!hasContent || isSubmissionPending || !agentId || !hasModelOptions) { return; } + const content: TypesGen.ChatInputPart[] = []; - if (message.trim()) { - content.push({ type: "text", text: message }); + + // Emit parts in document order — text segments and + // file-reference chips are interleaved as they appear in + // the editor. + for (const part of editorParts) { + if (part.type === "text") { + const trimmed = part.text.trim(); + if (trimmed) { + content.push({ type: "text", text: part.text }); + } + } else { + const r = part.reference; + content.push({ + type: "file-reference", + file_name: r.fileName, + start_line: r.startLine, + end_line: r.endLine, + content: r.content, + }); + } } // Add pre-uploaded file references. @@ -1116,7 +1146,6 @@ const AgentDetail: FC = () => {
); } - return (
{ onToggleSidebarCollapsed={onToggleSidebarCollapsed} chatTitle={chatTitle} diffStatus={diffStatusQuery.data} + chatInputRef={editing.chatInputRef} />
diff --git a/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx b/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx index 127b33ba2e..2f6a6f7cee 100644 --- a/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/AgentDetail/ConversationTimeline.tsx @@ -7,6 +7,7 @@ import { Shimmer, Tool, } from "components/ai-elements"; +import { FileIcon } from "components/FileIcon/FileIcon"; import { ChevronDownIcon, Loader2Icon } from "lucide-react"; import { type FC, @@ -163,6 +164,26 @@ function renderBlockList({ isStreaming={isStreaming} /> ); + case "file-reference": + return ( +
+ {" "} + + {block.fileName}: + {block.startLine === block.endLine + ? block.startLine + : `${block.startLine}\u2013${block.endLine}`} + + {block.text && ( + + {block.text} + + )} +
+ ); case "tool": { const tool = toolByID.get(block.id); if (!tool) { @@ -321,14 +342,96 @@ const ChatMessageItem = memo<{ : undefined } > -
- - {parsed.markdown || ""} - - {isSavingMessage && ( - +
+ + {parsed.markdown || ""} + + {isSavingMessage && ( + + )} +
+ {(() => { + const imageBlocks = parsed.blocks.filter( + (b): b is Extract => + b.type === "file" && b.mediaType.startsWith("image/"), + ); + if (imageBlocks.length === 0) return null; + return ( +
+ {imageBlocks.map((block, i) => { + const src = block.fileId + ? `/api/experimental/chats/files/${block.fileId}` + : `data:${block.mediaType};base64,${block.data}`; + return ( + + ); + })} +
+ ); + })()} + {(() => { + 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 && ( +
)}
@@ -374,7 +477,7 @@ const ChatMessageItem = memo<{ "linear-gradient(to top, hsl(var(--surface-secondary)), transparent)", }} /> - )} + )}{" "} ) : ( diff --git a/site/src/pages/AgentsPage/AgentDetail/messageParsing.test.ts b/site/src/pages/AgentsPage/AgentDetail/messageParsing.test.ts index 279bd29d26..e764d8b16d 100644 --- a/site/src/pages/AgentsPage/AgentDetail/messageParsing.test.ts +++ b/site/src/pages/AgentsPage/AgentDetail/messageParsing.test.ts @@ -263,6 +263,100 @@ describe("parseMessageContent", () => { fileId: undefined, }); }); + + it("parses a file-reference block into blocks", () => { + const result = parseMessageContent([ + { + type: "file-reference", + file_name: "src/main.go", + start_line: 10, + end_line: 15, + content: "some added code lines", + text: "Consider using a constant here.", + }, + ]); + expect(result.blocks).toHaveLength(1); + expect(result.blocks[0]).toEqual({ + type: "file-reference", + fileName: "src/main.go", + startLine: 10, + endLine: 15, + content: "some added code lines", + text: "Consider using a constant here.", + }); + }); + + it("falls back to line_number when start_line and end_line are missing", () => { + const result = parseMessageContent([ + { + type: "file-reference", + file_name: "index.ts", + line_number: 42, + content: "fallback content", + text: "Fallback line.", + }, + ]); + const ref = result.blocks[0] as { startLine: number; endLine: number }; + expect(ref.startLine).toBe(42); + expect(ref.endLine).toBe(42); + }); + + it("uses line_number for end_line when only start_line is provided", () => { + // When start_line is present it is used directly. end_line is + // missing so the fallback chain tries line_number next. + const result = parseMessageContent([ + { + type: "file-reference", + file_name: "foo.ts", + start_line: 5, + line_number: 7, + content: "partial content", + text: "Partial fallback.", + }, + ]); + const ref = result.blocks[0] as { startLine: number; endLine: number }; + expect(ref.startLine).toBe(5); + expect(ref.endLine).toBe(7); + }); + + it("defaults lines to 0 when no line fields are provided", () => { + const result = parseMessageContent([ + { + type: "file-reference", + file_name: "bare.ts", + content: "bare content", + text: "No line info.", + }, + ]); + const ref = result.blocks[0] as { startLine: number; endLine: number }; + expect(ref.startLine).toBe(0); + expect(ref.endLine).toBe(0); + }); + + it("does not affect markdown when file-reference blocks are present", () => { + const result = parseMessageContent([ + { type: "text", text: "Hello" }, + { + type: "file-reference", + file_name: "a.go", + start_line: 1, + end_line: 2, + content: "nit code content", + text: "Nit.", + }, + ]); + expect(result.markdown).toBe("Hello"); + expect(result.blocks).toHaveLength(2); + expect(result.blocks[0]).toEqual({ type: "response", text: "Hello" }); + expect(result.blocks[1]).toEqual({ + type: "file-reference", + fileName: "a.go", + startLine: 1, + endLine: 2, + content: "nit code content", + text: "Nit.", + }); + }); }); describe("mergeTools", () => { diff --git a/site/src/pages/AgentsPage/AgentDetail/messageParsing.ts b/site/src/pages/AgentsPage/AgentDetail/messageParsing.ts index fff9eca30c..09ce73e4dd 100644 --- a/site/src/pages/AgentsPage/AgentDetail/messageParsing.ts +++ b/site/src/pages/AgentsPage/AgentDetail/messageParsing.ts @@ -194,6 +194,24 @@ export const parseMessageContent = (content: unknown): ParsedMessageContent => { parsed.blocks = ensureToolBlock(parsed.blocks, id); break; } + case "file-reference": { + const text = asString(typedBlock.text); + const fileName = asString(typedBlock.file_name); + const startLine = + Number(typedBlock.start_line ?? typedBlock.line_number) || 0; + const endLine = + Number(typedBlock.end_line ?? typedBlock.line_number) || startLine; + const contentStr = asString(typedBlock.content); + parsed.blocks.push({ + type: "file-reference", + fileName, + startLine, + endLine, + content: contentStr, + text, + }); + break; + } case "tool-result": case "toolresult": { const name = diff --git a/site/src/pages/AgentsPage/AgentDetail/types.ts b/site/src/pages/AgentsPage/AgentDetail/types.ts index 7e01d864c2..9232732147 100644 --- a/site/src/pages/AgentsPage/AgentDetail/types.ts +++ b/site/src/pages/AgentsPage/AgentDetail/types.ts @@ -41,6 +41,14 @@ export type RenderBlock = mediaType: string; data?: string; // base64, absent when file_id is available fileId?: string; + } + | { + type: "file-reference"; + fileName: string; + startLine: number; + endLine: number; + content: string; + text: string; }; export type ParsedMessageContent = { diff --git a/site/src/pages/AgentsPage/FilesChangedPanel.stories.tsx b/site/src/pages/AgentsPage/FilesChangedPanel.stories.tsx index 6766138db8..1e9bcedcd8 100644 --- a/site/src/pages/AgentsPage/FilesChangedPanel.stories.tsx +++ b/site/src/pages/AgentsPage/FilesChangedPanel.stories.tsx @@ -241,7 +241,6 @@ const meta: Meta = { component: FilesChangedPanel, args: { chatId: "test-chat", - diffStyle: "unified", }, decorators: [ (Story) => ( diff --git a/site/src/pages/AgentsPage/FilesChangedPanel.tsx b/site/src/pages/AgentsPage/FilesChangedPanel.tsx index 00a354d723..ea3f5ef551 100644 --- a/site/src/pages/AgentsPage/FilesChangedPanel.tsx +++ b/site/src/pages/AgentsPage/FilesChangedPanel.tsx @@ -1,20 +1,156 @@ +import { useTheme } from "@emotion/react"; +import type { + ChangeTypes, + DiffLineAnnotation, + FileDiffMetadata, +} from "@pierre/diffs"; import { parsePatchFiles } from "@pierre/diffs"; +import { FileDiff } from "@pierre/diffs/react"; import { chatDiffContents, chatDiffStatus } from "api/queries/chats"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; import { + DIFFS_FONT_STYLE, + getDiffViewerOptions, +} from "components/ai-elements/tool/utils"; +import { Button } from "components/Button/Button"; +import { FileIcon } from "components/FileIcon/FileIcon"; +import { ScrollArea } from "components/ScrollArea/ScrollArea"; +import { Skeleton } from "components/Skeleton/Skeleton"; +import { + ChevronRightIcon, + Columns2Icon, + CornerDownLeftIcon, ExternalLinkIcon, GitBranchIcon, GitPullRequestIcon, + Rows3Icon, } from "lucide-react"; -import { type FC, useMemo } from "react"; +import { + type ComponentProps, + type FC, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useQuery } from "react-query"; -import { type DiffStyle, DiffViewer } from "./DiffViewer"; +import { cn } from "utils/cn"; +import type { ChatMessageInputRef } from "./AgentChatInput"; interface FilesChangedPanelProps { chatId: string; isExpanded?: boolean; - diffStyle: DiffStyle; + chatInputRef?: React.RefObject; } +/** + * Minimum container width (px) at which the file tree sidebar + * is shown alongside the diff list. + */ +const FILE_TREE_THRESHOLD = 1000; + +/** + * Extra CSS injected via the diff viewer's `unsafeCSS` option to make + * file headers sticky and adjust metadata layout. + */ +const STICKY_HEADER_CSS = [ + "[data-diffs-header] {", + " position: sticky; top: 0; z-index: 10;", + " font-size: 13px;", + " border-bottom: 1px solid hsl(var(--border-default));", + " background-color: hsl(var(--surface-quaternary)) !important;", + "}", + "[data-diffs-header] [data-metadata] { flex-direction: row-reverse; }", + "@media (prefers-color-scheme: dark) {", + " [data-diffs-header] { background-color: hsl(var(--surface-secondary)) !important; }", + "}", +].join(" "); + +type DiffStyle = "unified" | "split"; +const DIFF_STYLE_KEY = "agents.diff-view-style"; + +/** + * Walk the parsed hunks for a file and collect code lines that fall + * within `startLine..endLine` on the given side. For "additions" + * lines are matched against addition line numbers (using + * `hunk.additionStart`); for "deletions" against deletion line + * numbers (using `hunk.deletionStart`). Context lines that fall + * in range are included as well. + */ +function extractDiffContent( + parsedFiles: readonly FileDiffMetadata[], + fileName: string, + startLine: number, + endLine: number, + side: "additions" | "deletions", +): string { + const file = parsedFiles.find((f) => f.name === fileName); + if (!file) return ""; + + const collected: string[] = []; + for (const hunk of file.hunks) { + let addLine = hunk.additionStart; + let delLine = hunk.deletionStart; + + for (const block of hunk.hunkContent) { + if (block.type === "context") { + for (const line of block.lines) { + const ln = side === "additions" ? addLine : delLine; + if (ln >= startLine && ln <= endLine) { + collected.push(line); + } + addLine++; + delLine++; + } + } else { + // ChangeContent block. + if (side === "deletions") { + for (const line of block.deletions) { + if (delLine >= startLine && delLine <= endLine) { + collected.push(line); + } + delLine++; + } + // Addition lines in a change block still advance + // the addition counter. + addLine += block.additions.length; + } else { + // side === "additions" + // Deletion lines in a change block still advance + // the deletion counter. + delLine += block.deletions.length; + for (const line of block.additions) { + if (addLine >= startLine && addLine <= endLine) { + collected.push(line); + } + addLine++; + } + } + } + } + } + + return collected.join("\n"); +} + +function loadDiffStyle(): DiffStyle { + if (typeof window === "undefined") { + return "unified"; + } + const stored = localStorage.getItem(DIFF_STYLE_KEY); + if (stored === "split" || stored === "unified") { + return stored; + } + return "unified"; +} + +/** + * Width of the file tree sidebar in pixels. + */ +const FILE_TREE_WIDTH = 300; + /** * Parses a GitHub PR URL into its components. * Returns null if parsing fails. @@ -35,11 +171,373 @@ function parsePullRequestUrl(url: string): { return null; } +// ------------------------------------------------------------------- +// File tree data model +// ------------------------------------------------------------------- + +/** Maps a diff change type to a Tailwind text-color class. */ +function changeColor(type?: ChangeTypes): string | undefined { + switch (type) { + case "new": + return "text-green-700 dark:text-green-300"; + case "deleted": + return "text-red-700 dark:text-red-300"; + case "rename-pure": + case "rename-changed": + return "text-orange-700 dark:text-orange-300"; + case "change": + return "text-orange-700 dark:text-orange-300"; + default: + return undefined; + } +} + +/** Short letter shown after the filename, matching VS Code style. */ +function changeLabel(type: ChangeTypes): string { + switch (type) { + case "new": + return "A"; + case "deleted": + return "D"; + case "rename-pure": + case "rename-changed": + return "R"; + case "change": + return "M"; + default: + return ""; + } +} + +interface FileTreeNode { + name: string; + fullPath: string; + type: "file" | "directory"; + children: FileTreeNode[]; + fileDiff?: FileDiffMetadata; +} + +/** + * Builds a nested tree from a flat list of file diffs. Directory + * nodes are created for every intermediate path segment. The + * result is sorted with directories first, then alphabetically. + * Single-child directory chains are collapsed so that e.g. + * `src/pages/AgentsPage` renders as one row. + */ +function buildFileTree(files: FileDiffMetadata[]): FileTreeNode[] { + const root: FileTreeNode[] = []; + + for (const file of files) { + const segments = file.name.split("/"); + let children = root; + + // Walk / create intermediate directory nodes. + for (let i = 0; i < segments.length - 1; i++) { + const seg = segments[i]; + let dir = children.find((n) => n.type === "directory" && n.name === seg); + if (!dir) { + dir = { + name: seg, + fullPath: segments.slice(0, i + 1).join("/"), + type: "directory", + children: [], + }; + children.push(dir); + } + children = dir.children; + } + + // Leaf file node. + const fileName = segments[segments.length - 1]; + children.push({ + name: fileName, + fullPath: file.name, + type: "file", + children: [], + fileDiff: file, + }); + } + + const sortNodes = (nodes: FileTreeNode[]): FileTreeNode[] => { + for (const node of nodes) { + if (node.children.length > 0) { + node.children = sortNodes(node.children); + } + } + return nodes.sort((a, b) => { + if (a.type !== b.type) { + return a.type === "directory" ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + }; + + // Collapse single-child directory chains into one node whose + // name uses path separators, e.g. "src/pages/AgentsPage". + const collapse = (nodes: FileTreeNode[]): FileTreeNode[] => { + for (const node of nodes) { + if (node.type === "directory") { + node.children = collapse(node.children); + // If this directory has exactly one child and it is also + // a directory, merge them. + while ( + node.children.length === 1 && + node.children[0].type === "directory" + ) { + const child = node.children[0]; + node.name = `${node.name}/${child.name}`; + node.fullPath = child.fullPath; + node.children = child.children; + } + } + } + return nodes; + }; + + return collapse(sortNodes(root)); +} + +// ------------------------------------------------------------------- +// Tree node renderer +// ------------------------------------------------------------------- + +const FileTreeNodeView: FC<{ + node: FileTreeNode; + depth: number; + activeFile: string | null; + onFileClick: (fullPath: string) => void; +}> = ({ node, depth, activeFile, onFileClick }) => { + const [expanded, setExpanded] = useState(true); + + if (node.type === "directory") { + return ( +
+ + {expanded && + node.children.map((child) => ( + + ))} +
+ ); + } + + const isActive = activeFile === node.fullPath; + + return ( + + ); +}; + +/** + * Inline input rendered as a diff annotation under the selected + * line(s). Supports multiline via Shift+Enter. Enter submits, + * Escape dismisses. + */ +const InlinePromptInput: FC<{ + onSubmit: (text: string) => void; + onCancel: () => void; +}> = ({ onSubmit, onCancel }) => { + const [text, setText] = useState(""); + const textareaRef = useRef(null); + + // Focus the textarea on mount. We use a ref callback via rAF + // rather than autoFocus because the component renders inside + // Shadow DOM where autoFocus is unreliable. + useEffect(() => { + requestAnimationFrame(() => { + textareaRef.current?.focus(); + }); + }, []); + + return ( +
+
+