Просмотр исходного кода

feat(comments): support file mentions (#20447)

Shoubhit Dash 2 недель назад
Родитель
Сommit
a3a6cf1c07

+ 24 - 0
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 }]
 

+ 26 - 0
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,
     ]
   })
 

+ 3 - 0
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}

+ 3 - 0
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),

+ 4 - 0
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<string[]>
+  }
   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}

+ 7 - 1
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<T> =
   | { kind: "comment"; key: string; comment: T }
@@ -55,6 +55,7 @@ type LineCommentControllerProps<T extends LineCommentShape> = {
   comments: Accessor<T[]>
   draftKey: Accessor<string>
   label: string
+  mention?: LineCommentEditorProps["mention"]
   state: LineCommentStateProps<string>
   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<T>(props: {
               onPopoverFocusOut={view().editor!.onPopoverFocusOut}
               cancelLabel={view().editor!.cancelLabel}
               submitLabel={view().editor!.submitLabel}
+              mention={view().editor!.mention}
             />
           </Show>
         )
@@ -167,6 +170,7 @@ export function createLineCommentAnnotationRenderer<T>(props: {
           onCancel={view().onCancel}
           onSubmit={view().onSubmit}
           onPopoverFocusOut={view().onPopoverFocusOut}
+          mention={view().mention}
         />
       )
     }, host)
@@ -389,6 +393,7 @@ export function createLineCommentController<T extends LineCommentShape>(
                   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<T extends LineCommentShape>(
         return note.draft()
       },
       selection: formatSelectedLineLabel(range, i18n.t),
+      mention: props.mention,
       onInput: note.setDraft,
       onCancel: note.cancelDraft,
       onSubmit: (comment) => {

+ 52 - 0
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;

+ 136 - 1
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<LineCommentAnchorProps, "children" | "
   autofocus?: boolean
   cancelLabel?: string
   submitLabel?: string
+  mention?: {
+    items: (query: string) => string[] | Promise<string[]>
+  }
 }
 
 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<HTMLButtonElement, MouseEvent> = (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()
           }}
         />
+        <Show when={open() && mention.flat().length > 0}>
+          <div data-slot="line-comment-mention-list">
+            <For each={mention.flat().slice(0, 10)}>
+              {(item) => {
+                const directory = item.path.endsWith("/") ? item.path : getDirectory(item.path)
+                const name = item.path.endsWith("/") ? "" : getFilename(item.path)
+                return (
+                  <button
+                    type="button"
+                    data-slot="line-comment-mention-item"
+                    data-active={mention.active() === item.path ? "" : undefined}
+                    onMouseDown={(event) => event.preventDefault()}
+                    onMouseEnter={() => mention.setActive(item.path)}
+                    onClick={() => selectMention(item)}
+                  >
+                    <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
+                    <div data-slot="line-comment-mention-path">
+                      <span data-slot="line-comment-mention-dir">{directory}</span>
+                      <Show when={name}>
+                        <span data-slot="line-comment-mention-file">{name}</span>
+                      </Show>
+                    </div>
+                  </button>
+                )
+              }}
+            </For>
+          </div>
+        </Show>
         <div data-slot="line-comment-actions">
           <div data-slot="line-comment-editor-label">
             {i18n.t("ui.lineComment.editorLabel.prefix")}

+ 3 - 0
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<FileContent | undefined>
+  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()