fix(app): autocomplete configured references (#34308)

Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
opencode-agent[bot] 2026-06-30 18:45:37 +08:00 committed by GitHub
parent 3aa2860058
commit 3ca89ac796
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 141 additions and 12 deletions

View file

@ -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<typeof usePrompt>
@ -640,6 +641,23 @@ export const PromptInput: Component<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (props) => {
onKeyDown: atOnKeyDown,
} = useFilteredList<AtOption>({
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<PromptInputProps> = (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<PromptInputProps> = (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
}

View file

@ -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 }]

View file

@ -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: {

View file

@ -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<PromptPopoverProps> = (props) => {
)
}
if (item.type === "reference") {
return (
<button
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
onClick={() => props.onAtSelect(item)}
onMouseEnter={() => props.setAtActive(key)}
>
<FileIcon node={{ path: item.path, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-strong whitespace-nowrap">@{item.name}</span>
<span class="text-text-weak whitespace-nowrap truncate min-w-0 ml-2">{item.description}</span>
</div>
</button>
)
}
const isDirectory = item.path.endsWith("/")
const directory = isDirectory ? item.path : getDirectory(item.path)
const filename = isDirectory ? "" : getFilename(item.path)

View file

@ -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")

View file

@ -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<ReferenceInfo[]>({
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) => {

View file

@ -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<State>({
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: {},

View file

@ -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
}
}
}

View file

@ -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

View file

@ -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":

View file

@ -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))
},
})
})