summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src
diff options
context:
space:
mode:
authorShoubhit Dash <[email protected]>2026-04-01 16:11:57 +0530
committerGitHub <[email protected]>2026-04-01 16:11:57 +0530
commita3a6cf1c075c40c87980dda181d586a1d06ea304 (patch)
tree71b3c3d7144f7c38eb73d7e173852858313fc4bf /packages/ui/src
parent47a676111a3532aebed01110494742e536b7e5b4 (diff)
downloadopencode-a3a6cf1c075c40c87980dda181d586a1d06ea304.tar.gz
opencode-a3a6cf1c075c40c87980dda181d586a1d06ea304.zip
feat(comments): support file mentions (#20447)
Diffstat (limited to 'packages/ui/src')
-rw-r--r--packages/ui/src/components/line-comment-annotations.tsx8
-rw-r--r--packages/ui/src/components/line-comment-styles.ts52
-rw-r--r--packages/ui/src/components/line-comment.tsx137
-rw-r--r--packages/ui/src/components/session-review.tsx3
4 files changed, 198 insertions, 2 deletions
diff --git a/packages/ui/src/components/line-comment-annotations.tsx b/packages/ui/src/components/line-comment-annotations.tsx
index a4870074d..80018d3dd 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<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) => {
diff --git a/packages/ui/src/components/line-comment-styles.ts b/packages/ui/src/components/line-comment-styles.ts
index 8fd02f088..59af66041 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 bc47ad940..f0e29a485 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<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")}
diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx
index 83d2980f6..5000fcdc4 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<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()