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

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>
opencode-agent[bot] 2 месяцев назад
Родитель
Сommit
3ca89ac796

+ 50 - 9
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<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
     }

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

+ 2 - 2
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: {

+ 18 - 0
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<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)

+ 2 - 0
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")

+ 9 - 0
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<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) => {

+ 4 - 0
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<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: {},

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

+ 2 - 0
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

+ 9 - 1
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":

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