feat(site): add diff line reference and annotation system for agents chat (#22697)

## Summary

Adds a line-reference and annotation system for diffs in the Agents UI.
Users can click line numbers in the Git diff panel to open an inline
prompt input, type a comment, and have a reference chip + text added to
the chat message input.

## Changes

### Backend
- Added `diff-comment` type to `ChatInputPart` and `ChatMessagePart` in
`codersdk/chats.go` with `FileName`, `StartLine`, `EndLine`, `Side`
fields

### Frontend
- **`DiffCommentContext`**: React context/provider managing pending diff
comments with `addReference`, `removeComment`, `restoreComment`,
`clearComments`
- **`DiffCommentNode`**: Lexical `DecoratorNode` rendering inline chips
in the chat input showing file:line references. Chips are clickable
(scroll to line in diff), removable, and support undo/redo via mutation
tracking
- **`InlinePromptInput`**: Textarea annotation rendered inline under
clicked lines in the diff. Supports multiline (Shift+Enter), submit
(Enter), cancel (Escape)
- **`FilesChangedPanel`**: Line click/drag-select handlers open the
inline input. On submit, a badge chip + plain text are inserted into the
Lexical editor
- **`AgentDetail`**: Bidirectional sync between DiffCommentContext and
Lexical editor. Comments are sent as `diff-comment` parts on message
submit
- **`ConversationTimeline`**: Renders `diff-comment` message parts with
file:line labels

## How it works

1. Click a line number in the diff → inline textarea appears below that
line
2. Type a comment and press Enter → reference chip appears in chat input
with your text after it
3. Send the message → diff-comment parts are included alongside the
message text
This commit is contained in:
Kyle Carberry
2026-03-08 15:38:37 -04:00
committed by GitHub
parent 6509fb2574
commit 2ad0e74e67
16 changed files with 2011 additions and 89 deletions
+19
View File
@@ -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.",
+366
View File
@@ -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()