From a3a6cf1c075c40c87980dda181d586a1d06ea304 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 1 Apr 2026 16:11:57 +0530 Subject: [PATCH] feat(comments): support file mentions (#20447) --- .../prompt-input/build-request-parts.test.ts | 24 +++ .../prompt-input/build-request-parts.ts | 26 ++++ packages/app/src/pages/session.tsx | 3 + packages/app/src/pages/session/file-tabs.tsx | 3 + packages/app/src/pages/session/review-tab.tsx | 4 + .../components/line-comment-annotations.tsx | 8 +- .../ui/src/components/line-comment-styles.ts | 52 +++++++ packages/ui/src/components/line-comment.tsx | 137 +++++++++++++++++- packages/ui/src/components/session-review.tsx | 3 + 9 files changed, 258 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts index ce09ae9217..06c3773310 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.test.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts @@ -100,6 +100,30 @@ describe("buildRequestParts", () => { expect(synthetic).toHaveLength(1) }) + test("adds file parts for @mentions inside comment text", () => { + const result = buildRequestParts({ + prompt: [{ type: "text", content: "look", start: 0, end: 4 }], + context: [ + { + key: "ctx:comment-mention", + type: "file", + path: "src/review.ts", + comment: "Compare with @src/shared.ts and @src/review.ts.", + }, + ], + images: [], + text: "look", + messageID: "msg_comment_mentions", + sessionID: "ses_comment_mentions", + sessionDirectory: "/repo", + }) + + const files = result.requestParts.filter((part) => part.type === "file") + expect(files).toHaveLength(2) + expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/review.ts")).toBe(true) + expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true) + }) + test("handles Windows paths correctly (simulated on macOS)", () => { const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }] diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts index 4146fb4847..a1076e60ca 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -39,6 +39,16 @@ const absolute = (directory: string, path: string) => { const fileQuery = (selection: FileSelection | undefined) => selection ? `?start=${selection.startLine}&end=${selection.endLine}` : "" +const mention = /(^|[\s([{"'])@(\S+)/g + +const parseCommentMentions = (comment: string) => { + return Array.from(comment.matchAll(mention)).flatMap((match) => { + const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "") + if (!path) return [] + return [path] + }) +} + const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file" const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent" @@ -138,6 +148,21 @@ export function buildRequestParts(input: BuildRequestPartsInput) { if (!comment) return [filePart] + const mentions = parseCommentMentions(comment).flatMap((path) => { + const url = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}` + if (used.has(url)) return [] + used.add(url) + return [ + { + id: Identifier.ascending("part"), + type: "file", + mime: "text/plain", + url, + filename: getFilename(path), + } satisfies PromptRequestPart, + ] + }) + return [ { id: Identifier.ascending("part"), @@ -153,6 +178,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) { }), } satisfies PromptRequestPart, filePart, + ...mentions, ] }) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 917de35b1f..18bae6e2d0 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1046,6 +1046,9 @@ export default function Page() { onLineCommentUpdate={updateCommentInContext} onLineCommentDelete={removeCommentFromContext} lineCommentActions={reviewCommentActions()} + commentMentions={{ + items: file.searchFilesAndDirectories, + }} comments={comments.all()} focusedComment={comments.focus()} onFocusedCommentChange={comments.setFocus} diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index 8208b6c998..9430b70253 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -302,6 +302,9 @@ export function FileTabContent(props: { tab: string }) { comments: fileComments, label: language.t("ui.lineComment.submit"), draftKey: () => path() ?? props.tab, + mention: { + items: file.searchFilesAndDirectories, + }, state: { opened: () => note.openedComment, setOpened: (id) => setNote("openedComment", id), diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index c073e62147..76b65a2210 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -30,6 +30,9 @@ export interface SessionReviewTabProps { onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void focusedFile?: string onScrollRef?: (el: HTMLDivElement) => void + commentMentions?: { + items: (query: string) => string[] | Promise + } classes?: { root?: string header?: string @@ -162,6 +165,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) { onLineCommentUpdate={props.onLineCommentUpdate} onLineCommentDelete={props.onLineCommentDelete} lineCommentActions={props.lineCommentActions} + lineCommentMention={props.commentMentions} comments={props.comments} focusedComment={props.focusedComment} onFocusedCommentChange={props.onFocusedCommentChange} diff --git a/packages/ui/src/components/line-comment-annotations.tsx b/packages/ui/src/components/line-comment-annotations.tsx index a4870074d9..80018d3dda 100644 --- a/packages/ui/src/components/line-comment-annotations.tsx +++ b/packages/ui/src/components/line-comment-annotations.tsx @@ -5,7 +5,7 @@ import { render as renderSolid } from "solid-js/web" import { useI18n } from "../context/i18n" import { createHoverCommentUtility } from "../pierre/comment-hover" import { cloneSelectedLineRange, formatSelectedLineLabel, lineInSelectedRange } from "../pierre/selection-bridge" -import { LineComment, LineCommentEditor } from "./line-comment" +import { LineComment, LineCommentEditor, type LineCommentEditorProps } from "./line-comment" export type LineCommentAnnotationMeta = | { kind: "comment"; key: string; comment: T } @@ -55,6 +55,7 @@ type LineCommentControllerProps = { comments: Accessor draftKey: Accessor label: string + mention?: LineCommentEditorProps["mention"] state: LineCommentStateProps onSubmit: (input: { comment: string; selection: SelectedLineRange }) => void onUpdate?: (input: { id: string; comment: string; selection: SelectedLineRange }) => void @@ -85,6 +86,7 @@ type CommentProps = { type DraftProps = { value: string selection: JSX.Element + mention?: LineCommentEditorProps["mention"] onInput: (value: string) => void onCancel: VoidFunction onSubmit: (value: string) => void @@ -148,6 +150,7 @@ export function createLineCommentAnnotationRenderer(props: { onPopoverFocusOut={view().editor!.onPopoverFocusOut} cancelLabel={view().editor!.cancelLabel} submitLabel={view().editor!.submitLabel} + mention={view().editor!.mention} /> ) @@ -167,6 +170,7 @@ export function createLineCommentAnnotationRenderer(props: { onCancel={view().onCancel} onSubmit={view().onSubmit} onPopoverFocusOut={view().onPopoverFocusOut} + mention={view().mention} /> ) }, host) @@ -389,6 +393,7 @@ export function createLineCommentController( return note.draft() }, selection: formatSelectedLineLabel(comment.selection, i18n.t), + mention: props.mention, onInput: note.setDraft, onCancel: note.cancelDraft, onSubmit: (value: string) => { @@ -415,6 +420,7 @@ export function createLineCommentController( return note.draft() }, selection: formatSelectedLineLabel(range, i18n.t), + mention: props.mention, onInput: note.setDraft, onCancel: note.cancelDraft, onSubmit: (comment) => { diff --git a/packages/ui/src/components/line-comment-styles.ts b/packages/ui/src/components/line-comment-styles.ts index 8fd02f0881..59af660419 100644 --- a/packages/ui/src/components/line-comment-styles.ts +++ b/packages/ui/src/components/line-comment-styles.ts @@ -178,6 +178,58 @@ export const lineCommentStyles = ` box-shadow: var(--shadow-xs-border-select); } +[data-component="line-comment"] [data-slot="line-comment-mention-list"] { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 180px; + overflow: auto; + padding: 4px; + border: 1px solid var(--border-base); + border-radius: var(--radius-md); + background: var(--surface-base); +} + +[data-component="line-comment"] [data-slot="line-comment-mention-item"] { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; + padding: 6px 8px; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-strong); + text-align: left; +} + +[data-component="line-comment"] [data-slot="line-comment-mention-item"][data-active] { + background: var(--surface-raised-base-hover); +} + +[data-component="line-comment"] [data-slot="line-comment-mention-path"] { + display: flex; + align-items: center; + min-width: 0; + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + line-height: var(--line-height-large); +} + +[data-component="line-comment"] [data-slot="line-comment-mention-dir"] { + min-width: 0; + color: var(--text-weak); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +[data-component="line-comment"] [data-slot="line-comment-mention-file"] { + color: var(--text-strong); + white-space: nowrap; +} + [data-component="line-comment"] [data-slot="line-comment-actions"] { display: flex; align-items: center; diff --git a/packages/ui/src/components/line-comment.tsx b/packages/ui/src/components/line-comment.tsx index bc47ad9405..f0e29a485d 100644 --- a/packages/ui/src/components/line-comment.tsx +++ b/packages/ui/src/components/line-comment.tsx @@ -1,5 +1,8 @@ -import { createEffect, createSignal, onMount, Show, splitProps, type JSX } from "solid-js" +import { useFilteredList } from "@opencode-ai/ui/hooks" +import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { createEffect, createSignal, For, onMount, Show, splitProps, type JSX } from "solid-js" import { Button } from "./button" +import { FileIcon } from "./file-icon" import { Icon } from "./icon" import { installLineCommentStyles } from "./line-comment-styles" import { useI18n } from "../context/i18n" @@ -183,6 +186,9 @@ export type LineCommentEditorProps = Omit string[] | Promise + } } export const LineCommentEditor = (props: LineCommentEditorProps) => { @@ -198,12 +204,46 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => { "autofocus", "cancelLabel", "submitLabel", + "mention", ]) const refs = { textarea: undefined as HTMLTextAreaElement | undefined, } const [text, setText] = createSignal(split.value) + const [open, setOpen] = createSignal(false) + + function selectMention(item: { path: string } | undefined) { + if (!item) return + + const textarea = refs.textarea + const query = currentMention() + if (!textarea || !query) return + + const value = `${text().slice(0, query.start)}@${item.path} ${text().slice(query.end)}` + const cursor = query.start + item.path.length + 2 + + setText(value) + split.onInput(value) + closeMention() + + requestAnimationFrame(() => { + textarea.focus() + textarea.setSelectionRange(cursor, cursor) + }) + } + + const mention = useFilteredList<{ path: string }>({ + items: async (query) => { + if (!split.mention) return [] + if (!query.trim()) return [] + const paths = await split.mention.items(query) + return paths.map((path) => ({ path })) + }, + key: (item) => item.path, + filterKeys: ["path"], + onSelect: selectMention, + }) const focus = () => refs.textarea?.focus() const hold: JSX.EventHandlerUnion = (e) => { @@ -221,6 +261,46 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => { setText(split.value) }) + const closeMention = () => { + setOpen(false) + mention.clear() + } + + const currentMention = () => { + const textarea = refs.textarea + if (!textarea) return + if (!split.mention) return + if (textarea.selectionStart !== textarea.selectionEnd) return + + const end = textarea.selectionStart + const match = textarea.value.slice(0, end).match(/@(\S*)$/) + if (!match) return + + return { + query: match[1] ?? "", + start: end - match[0].length, + end, + } + } + + const syncMention = () => { + const item = currentMention() + if (!item) { + closeMention() + return + } + + setOpen(true) + mention.onInput(item.query) + } + + const selectActiveMention = () => { + const items = mention.flat() + if (items.length === 0) return + const active = mention.active() + selectMention(items.find((item) => item.path === active) ?? items[0]) + } + const submit = () => { const value = text().trim() if (!value) return @@ -247,11 +327,38 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => { const value = (e.currentTarget as HTMLTextAreaElement).value setText(value) split.onInput(value) + syncMention() }} + on:click={() => syncMention()} + on:select={() => syncMention()} on:keydown={(e) => { const event = e as KeyboardEvent if (event.isComposing || event.keyCode === 229) return event.stopPropagation() + if (open()) { + if (e.key === "Escape") { + event.preventDefault() + closeMention() + return + } + + if (e.key === "Tab") { + if (mention.flat().length === 0) return + event.preventDefault() + selectActiveMention() + return + } + + const nav = e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter" + const ctrlNav = + event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && (e.key === "n" || e.key === "p") + if ((nav || ctrlNav) && mention.flat().length > 0) { + mention.onKeyDown(event) + event.preventDefault() + return + } + } + if (e.key === "Escape") { event.preventDefault() e.currentTarget.blur() @@ -264,6 +371,34 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => { submit() }} /> + 0}> +
+ + {(item) => { + const directory = item.path.endsWith("/") ? item.path : getDirectory(item.path) + const name = item.path.endsWith("/") ? "" : getFilename(item.path) + return ( + + ) + }} + +
+
{i18n.t("ui.lineComment.editorLabel.prefix")} diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 83d2980f61..5000fcdc49 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -23,6 +23,7 @@ import { Dynamic } from "solid-js/web" import { mediaKindFromPath } from "../pierre/media" import { cloneSelectedLineRange, previewSelectedLines } from "../pierre/selection-bridge" import { createLineCommentController } from "./line-comment-annotations" +import type { LineCommentEditorProps } from "./line-comment" const MAX_DIFF_CHANGED_LINES = 500 @@ -88,6 +89,7 @@ export interface SessionReviewProps { diffs: ReviewDiff[] onViewFile?: (file: string) => void readFile?: (path: string) => Promise + lineCommentMention?: LineCommentEditorProps["mention"] } function ReviewCommentMenu(props: { @@ -327,6 +329,7 @@ export const SessionReview = (props: SessionReviewProps) => { comments, label: i18n.t("ui.lineComment.submit"), draftKey: () => file, + mention: props.lineCommentMention, state: { opened: () => { const current = opened()