From 3ca89ac79678427f3d9c6ca850e07e915c288e4d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:45:37 +0800 Subject: [PATCH] fix(app): autocomplete configured references (#34308) Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: opencode-agent[bot] Co-authored-by: Brendan Allan --- packages/app/src/components/prompt-input.tsx | 59 ++++++++++++++++--- .../prompt-input/build-request-parts.test.ts | 35 +++++++++++ .../prompt-input/build-request-parts.ts | 4 +- .../components/prompt-input/slash-popover.tsx | 18 ++++++ .../src/context/global-sync/bootstrap.test.ts | 2 + .../app/src/context/global-sync/bootstrap.ts | 9 +++ .../src/context/global-sync/child-store.ts | 4 ++ .../src/context/global-sync/event-reducer.ts | 5 ++ packages/app/src/context/global-sync/types.ts | 2 + packages/app/src/context/prompt.tsx | 10 +++- packages/app/src/context/server-sync.tsx | 5 ++ 11 files changed, 141 insertions(+), 12 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 6b5fd8844f..2058f1c527 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -68,6 +68,7 @@ import { promptPlaceholder } from "./prompt-input/placeholder" import { createPromptInputTransientState } from "./prompt-input/transient-state" import { showToast } from "@/utils/toast" import { ImagePreview } from "@opencode-ai/ui/image-preview" +import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" export type PromptInputState = ReturnType @@ -640,6 +641,23 @@ export const PromptInput: Component = (props) => { }) } + const referenceDescription = (reference: ReferenceInfo) => + reference.source.type === "git" ? reference.source.repository : reference.source.path + + const referenceList = createMemo(() => + sync() + .data.reference.filter((reference) => !reference.hidden) + .map( + (reference): AtOption => ({ + type: "reference", + name: reference.name, + path: reference.path, + display: reference.name, + description: reference.description ?? referenceDescription(reference), + }), + ), + ) + const agentList = createMemo(() => props.controls.agents.available .filter((agent) => !agent.hidden && agent.mode !== "primary") @@ -650,14 +668,28 @@ export const PromptInput: Component = (props) => { if (!option) return if (option.type === "agent") { addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 }) - } else { - addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) + return } + if (option.type === "reference") { + addPart({ + type: "file", + path: option.path, + content: "@" + option.name, + start: 0, + end: 0, + mime: "application/x-directory", + filename: option.name, + }) + return + } + addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) } const atKey = (x: AtOption | undefined) => { if (!x) return "" - return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}` + if (x.type === "agent") return `agent:${x.name}` + if (x.type === "reference") return `reference:${x.name}` + return `file:${x.path}` } const { @@ -668,30 +700,33 @@ export const PromptInput: Component = (props) => { onKeyDown: atOnKeyDown, } = useFilteredList({ items: async (query) => { + const references = referenceList() const agents = agentList() const open = recent() const seen = new Set(open) const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) - if (!query.trim()) return [...agents, ...pinned] + if (!query.trim()) return [...references, ...agents, ...pinned] const paths = await files.searchFilesAndDirectories(query) const fileOptions: AtOption[] = paths .filter((path) => !seen.has(path)) .map((path) => ({ type: "file", path, display: path })) - return [...agents, ...pinned, ...fileOptions] + return [...references, ...agents, ...pinned, ...fileOptions] }, key: atKey, filterKeys: ["display"], skipFilter: (item) => item.type === "file" && !item.recent, groupBy: (item) => { + if (item.type === "reference") return "reference" if (item.type === "agent") return "agent" if (item.recent) return "recent" return "file" }, sortGroupsBy: (a, b) => { const rank = (category: string) => { - if (category === "agent") return 0 - if (category === "recent") return 1 - return 2 + if (category === "reference") return 0 + if (category === "agent") return 1 + if (category === "recent") return 2 + return 3 } return rank(a.category) - rank(b.category) }, @@ -757,7 +792,11 @@ export const PromptInput: Component = (props) => { const pill = document.createElement("span") pill.textContent = part.content pill.setAttribute("data-type", part.type) - if (part.type === "file") pill.setAttribute("data-path", part.path) + if (part.type === "file") { + pill.setAttribute("data-path", part.path) + if (part.mime) pill.setAttribute("data-mime", part.mime) + if (part.filename) pill.setAttribute("data-filename", part.filename) + } if (part.type === "agent") pill.setAttribute("data-name", part.name) pill.setAttribute("contenteditable", "false") pill.style.userSelect = "text" @@ -878,6 +917,8 @@ export const PromptInput: Component = (props) => { content, start: position, end: position + content.length, + ...(file.dataset.mime ? { mime: file.dataset.mime } : {}), + ...(file.dataset.filename ? { filename: file.dataset.filename } : {}), }) position += content.length } 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 099200f23f..ab84cb6eae 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,41 @@ describe("buildRequestParts", () => { ) }) + test("preserves reference aliases as directory file parts", () => { + const result = buildRequestParts({ + prompt: [ + { + type: "file", + path: "/repo/../docs", + content: "@docs", + start: 0, + end: 5, + mime: "application/x-directory", + filename: "docs", + }, + ], + context: [], + images: [], + text: "@docs", + messageID: "msg_reference", + sessionID: "ses_reference", + sessionDirectory: "/repo/app", + }) + + const filePart = result.requestParts.find((part) => part.type === "file") + expect(filePart).toBeDefined() + if (filePart?.type === "file") { + expect(filePart.mime).toBe("application/x-directory") + expect(filePart.filename).toBe("docs") + expect(filePart.url).toBe("file:///repo/../docs") + expect(filePart.source?.type).toBe("file") + if (filePart.source?.type === "file") { + expect(filePart.source.path).toBe("/repo/../docs") + expect(filePart.source.text.value).toBe("@docs") + } + } + }) + test("deduplicates context files when prompt already includes same path", () => { 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 043425356a..2654d1442f 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -102,9 +102,9 @@ export function buildRequestParts(input: BuildRequestPartsInput) { return { id: Identifier.ascending("part"), type: "file", - mime: "text/plain", + mime: attachment.mime ?? "text/plain", url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`, - filename: getFilename(attachment.path), + filename: attachment.filename ?? getFilename(attachment.path), source: { type: "file", text: { diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index aa56b07961..e179dc63ff 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -7,6 +7,7 @@ import { getDirectory, getFilename } from "@opencode-ai/core/util/path" export type AtOption = | { type: "agent"; name: string; display: string } + | { type: "reference"; name: string; path: string; display: string; description: string } | { type: "file"; path: string; display: string; recent?: boolean } export interface SlashCommand { @@ -103,6 +104,23 @@ export const PromptPopover: Component = (props) => { ) } + if (item.type === "reference") { + return ( + + ) + } + const isDirectory = item.path.endsWith("/") const directory = isDirectory ? item.path : getDirectory(item.path) const filename = isDirectory ? "" : getFilename(item.path) diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index 2e85f4850a..137649c024 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -16,6 +16,7 @@ describe("bootstrapDirectory", () => { status: "loading", agent: [], command: [], + reference: [], project: "", projectMeta: undefined, icon: undefined, @@ -67,6 +68,7 @@ describe("bootstrapDirectory", () => { }, permission: { list: async () => ({ data: [] }) }, question: { list: async () => ({ data: [] }) }, + v2: { reference: { list: async () => ({ data: { data: [] } }) } }, mcp: { status: async () => { mcpReads.push("status") diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 5da05d19cf..b162034830 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -6,6 +6,7 @@ import type { Project, ProviderAuthResponse, QuestionRequest, + ReferenceInfo, Session, } from "@opencode-ai/sdk/v2/client" import { showToast } from "@/utils/toast" @@ -195,6 +196,13 @@ export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)), }) +export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => + queryOptions({ + queryKey: [scope, directory, "references"] as const, + queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []), + placeholderData: [], + }) + export async function bootstrapDirectory(input: { directory: string scope: ServerScope @@ -277,6 +285,7 @@ export async function bootstrapDirectory(input: { }), ), input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))), + () => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)), () => retry(() => input.sdk.permission.list().then((x) => { diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 65011e8b09..3e7af23309 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -187,6 +187,7 @@ export function createChildStoreManager(input: { const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() })) const lspQuery = useQuery(() => input.queryOptions.lsp(key)) const providerQuery = useQuery(() => input.queryOptions.providers(key)) + const referenceQuery = useQuery(() => input.queryOptions.references(key)) const child = createStore({ project: "", @@ -210,6 +211,9 @@ export function createChildStoreManager(input: { status: "loading" as const, agent: [], command: [], + get reference() { + return referenceQuery.isLoading ? [] : (referenceQuery.data ?? []) + }, session: [], sessionTotal: 0, session_status: {}, diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index bca3f85720..15d125df39 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -112,6 +112,7 @@ export function applyDirectoryEvent(input: { push: (directory: string) => void directory: string loadLsp: () => void + loadReferences?: () => void vcsCache?: VcsCache setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void retainedLimit?: number @@ -404,5 +405,9 @@ export function applyDirectoryEvent(input: { input.loadLsp() break } + case "reference.updated": { + input.loadReferences?.() + break + } } } diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index 9fb86d6d35..c3b38cd315 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -9,6 +9,7 @@ import type { Path, PermissionRequest, QuestionRequest, + ReferenceInfo, Session, SessionStatus, SnapshotFileDiff, @@ -34,6 +35,7 @@ export type State = { status: "loading" | "partial" | "complete" agent: Agent[] command: Command[] + reference: ReferenceInfo[] project: string projectMeta: ProjectMeta | undefined icon: string | undefined diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 014352506a..5c00d02a98 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -27,6 +27,8 @@ export interface FileAttachmentPart extends PartBase { type: "file" path: string selection?: FileSelection + mime?: string + filename?: string } export interface AgentPart extends PartBase { @@ -73,7 +75,13 @@ function isPartEqual(partA: ContentPart, partB: ContentPart) { case "text": return partB.type === "text" && partA.content === partB.content case "file": - return partB.type === "file" && partA.path === partB.path && isSelectionEqual(partA.selection, partB.selection) + return ( + partB.type === "file" && + partA.path === partB.path && + partA.mime === partB.mime && + partA.filename === partB.filename && + isSelectionEqual(partA.selection, partB.selection) + ) case "agent": return partB.type === "agent" && partA.name === partB.name case "image": diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index f2ee8869ac..b01a704ba7 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -15,6 +15,7 @@ import { loadPathQuery, loadProjectsQuery, loadProvidersQuery, + loadReferencesQuery, } from "./global-sync/bootstrap" import { createChildStoreManager } from "./global-sync/child-store" import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" @@ -75,6 +76,7 @@ function makeQueryOptionsApi( path: (directory: PathKey | null) => loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)), agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)), + references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)), mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)), lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)), sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }), @@ -396,6 +398,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { loadLsp: () => { void queryClient.fetchQuery(queryOptionsApi.lsp(key)) }, + loadReferences: () => { + void queryClient.fetchQuery(queryOptionsApi.references(key)) + }, }) })