Skip to content

Commit a3a6cf1

Browse files
authored
feat(comments): support file mentions (#20447)
1 parent 47a6761 commit a3a6cf1

9 files changed

Lines changed: 258 additions & 2 deletions

File tree

packages/app/src/components/prompt-input/build-request-parts.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,30 @@ describe("buildRequestParts", () => {
100100
expect(synthetic).toHaveLength(1)
101101
})
102102

103+
test("adds file parts for @mentions inside comment text", () => {
104+
const result = buildRequestParts({
105+
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
106+
context: [
107+
{
108+
key: "ctx:comment-mention",
109+
type: "file",
110+
path: "src/review.ts",
111+
comment: "Compare with @src/shared.ts and @src/review.ts.",
112+
},
113+
],
114+
images: [],
115+
text: "look",
116+
messageID: "msg_comment_mentions",
117+
sessionID: "ses_comment_mentions",
118+
sessionDirectory: "/repo",
119+
})
120+
121+
const files = result.requestParts.filter((part) => part.type === "file")
122+
expect(files).toHaveLength(2)
123+
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/review.ts")).toBe(true)
124+
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true)
125+
})
126+
103127
test("handles Windows paths correctly (simulated on macOS)", () => {
104128
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
105129

packages/app/src/components/prompt-input/build-request-parts.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ const absolute = (directory: string, path: string) => {
3939
const fileQuery = (selection: FileSelection | undefined) =>
4040
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
4141

42+
const mention = /(^|[\s([{"'])@(\S+)/g
43+
44+
const parseCommentMentions = (comment: string) => {
45+
return Array.from(comment.matchAll(mention)).flatMap((match) => {
46+
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
47+
if (!path) return []
48+
return [path]
49+
})
50+
}
51+
4252
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
4353
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
4454

@@ -138,6 +148,21 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
138148

139149
if (!comment) return [filePart]
140150

151+
const mentions = parseCommentMentions(comment).flatMap((path) => {
152+
const url = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
153+
if (used.has(url)) return []
154+
used.add(url)
155+
return [
156+
{
157+
id: Identifier.ascending("part"),
158+
type: "file",
159+
mime: "text/plain",
160+
url,
161+
filename: getFilename(path),
162+
} satisfies PromptRequestPart,
163+
]
164+
})
165+
141166
return [
142167
{
143168
id: Identifier.ascending("part"),
@@ -153,6 +178,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
153178
}),
154179
} satisfies PromptRequestPart,
155180
filePart,
181+
...mentions,
156182
]
157183
})
158184

packages/app/src/pages/session.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,6 +1046,9 @@ export default function Page() {
10461046
onLineCommentUpdate={updateCommentInContext}
10471047
onLineCommentDelete={removeCommentFromContext}
10481048
lineCommentActions={reviewCommentActions()}
1049+
commentMentions={{
1050+
items: file.searchFilesAndDirectories,
1051+
}}
10491052
comments={comments.all()}
10501053
focusedComment={comments.focus()}
10511054
onFocusedCommentChange={comments.setFocus}

packages/app/src/pages/session/file-tabs.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,9 @@ export function FileTabContent(props: { tab: string }) {
302302
comments: fileComments,
303303
label: language.t("ui.lineComment.submit"),
304304
draftKey: () => path() ?? props.tab,
305+
mention: {
306+
items: file.searchFilesAndDirectories,
307+
},
305308
state: {
306309
opened: () => note.openedComment,
307310
setOpened: (id) => setNote("openedComment", id),

packages/app/src/pages/session/review-tab.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ export interface SessionReviewTabProps {
3030
onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
3131
focusedFile?: string
3232
onScrollRef?: (el: HTMLDivElement) => void
33+
commentMentions?: {
34+
items: (query: string) => string[] | Promise<string[]>
35+
}
3336
classes?: {
3437
root?: string
3538
header?: string
@@ -162,6 +165,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
162165
onLineCommentUpdate={props.onLineCommentUpdate}
163166
onLineCommentDelete={props.onLineCommentDelete}
164167
lineCommentActions={props.lineCommentActions}
168+
lineCommentMention={props.commentMentions}
165169
comments={props.comments}
166170
focusedComment={props.focusedComment}
167171
onFocusedCommentChange={props.onFocusedCommentChange}

packages/ui/src/components/line-comment-annotations.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { render as renderSolid } from "solid-js/web"
55
import { useI18n } from "../context/i18n"
66
import { createHoverCommentUtility } from "../pierre/comment-hover"
77
import { cloneSelectedLineRange, formatSelectedLineLabel, lineInSelectedRange } from "../pierre/selection-bridge"
8-
import { LineComment, LineCommentEditor } from "./line-comment"
8+
import { LineComment, LineCommentEditor, type LineCommentEditorProps } from "./line-comment"
99

1010
export type LineCommentAnnotationMeta<T> =
1111
| { kind: "comment"; key: string; comment: T }
@@ -55,6 +55,7 @@ type LineCommentControllerProps<T extends LineCommentShape> = {
5555
comments: Accessor<T[]>
5656
draftKey: Accessor<string>
5757
label: string
58+
mention?: LineCommentEditorProps["mention"]
5859
state: LineCommentStateProps<string>
5960
onSubmit: (input: { comment: string; selection: SelectedLineRange }) => void
6061
onUpdate?: (input: { id: string; comment: string; selection: SelectedLineRange }) => void
@@ -85,6 +86,7 @@ type CommentProps = {
8586
type DraftProps = {
8687
value: string
8788
selection: JSX.Element
89+
mention?: LineCommentEditorProps["mention"]
8890
onInput: (value: string) => void
8991
onCancel: VoidFunction
9092
onSubmit: (value: string) => void
@@ -148,6 +150,7 @@ export function createLineCommentAnnotationRenderer<T>(props: {
148150
onPopoverFocusOut={view().editor!.onPopoverFocusOut}
149151
cancelLabel={view().editor!.cancelLabel}
150152
submitLabel={view().editor!.submitLabel}
153+
mention={view().editor!.mention}
151154
/>
152155
</Show>
153156
)
@@ -167,6 +170,7 @@ export function createLineCommentAnnotationRenderer<T>(props: {
167170
onCancel={view().onCancel}
168171
onSubmit={view().onSubmit}
169172
onPopoverFocusOut={view().onPopoverFocusOut}
173+
mention={view().mention}
170174
/>
171175
)
172176
}, host)
@@ -389,6 +393,7 @@ export function createLineCommentController<T extends LineCommentShape>(
389393
return note.draft()
390394
},
391395
selection: formatSelectedLineLabel(comment.selection, i18n.t),
396+
mention: props.mention,
392397
onInput: note.setDraft,
393398
onCancel: note.cancelDraft,
394399
onSubmit: (value: string) => {
@@ -415,6 +420,7 @@ export function createLineCommentController<T extends LineCommentShape>(
415420
return note.draft()
416421
},
417422
selection: formatSelectedLineLabel(range, i18n.t),
423+
mention: props.mention,
418424
onInput: note.setDraft,
419425
onCancel: note.cancelDraft,
420426
onSubmit: (comment) => {

packages/ui/src/components/line-comment-styles.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,58 @@ export const lineCommentStyles = `
178178
box-shadow: var(--shadow-xs-border-select);
179179
}
180180
181+
[data-component="line-comment"] [data-slot="line-comment-mention-list"] {
182+
display: flex;
183+
flex-direction: column;
184+
gap: 4px;
185+
max-height: 180px;
186+
overflow: auto;
187+
padding: 4px;
188+
border: 1px solid var(--border-base);
189+
border-radius: var(--radius-md);
190+
background: var(--surface-base);
191+
}
192+
193+
[data-component="line-comment"] [data-slot="line-comment-mention-item"] {
194+
display: flex;
195+
align-items: center;
196+
gap: 8px;
197+
width: 100%;
198+
min-width: 0;
199+
padding: 6px 8px;
200+
border: 0;
201+
border-radius: var(--radius-sm);
202+
background: transparent;
203+
color: var(--text-strong);
204+
text-align: left;
205+
}
206+
207+
[data-component="line-comment"] [data-slot="line-comment-mention-item"][data-active] {
208+
background: var(--surface-raised-base-hover);
209+
}
210+
211+
[data-component="line-comment"] [data-slot="line-comment-mention-path"] {
212+
display: flex;
213+
align-items: center;
214+
min-width: 0;
215+
font-family: var(--font-family-sans);
216+
font-size: var(--font-size-small);
217+
line-height: var(--line-height-large);
218+
}
219+
220+
[data-component="line-comment"] [data-slot="line-comment-mention-dir"] {
221+
min-width: 0;
222+
color: var(--text-weak);
223+
white-space: nowrap;
224+
overflow: hidden;
225+
text-overflow: ellipsis;
226+
}
227+
228+
[data-component="line-comment"] [data-slot="line-comment-mention-file"] {
229+
color: var(--text-strong);
230+
white-space: nowrap;
231+
}
232+
181233
[data-component="line-comment"] [data-slot="line-comment-actions"] {
182234
display: flex;
183235
align-items: center;

0 commit comments

Comments
 (0)