Brendan Allan пре 2 недеља
родитељ
комит
9ca2b44235

+ 101 - 0
packages/app/e2e/user-story/model-selection-flow.spec.ts

@@ -0,0 +1,101 @@
+import { expect, test } from "@playwright/test"
+import { mockOpenCodeServer } from "../utils/mock-server"
+import { expectAppVisible } from "../utils/waits"
+
+const directory = "C:/OpenCode/NewProject"
+
+test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => {
+  let connectedGo = false
+  let pendingGo = false
+  const connections: Array<{ integrationID: string; body: unknown }> = []
+
+  await mockOpenCodeServer(page, {
+    directory,
+    project: {
+      id: "proj_model_selection_flow",
+      worktree: directory,
+      vcs: "git",
+      name: "NewProject",
+      time: { created: 1_700_000_000_000, updated: 1_700_000_000_000 },
+      sandboxes: [],
+    },
+    provider: () => ({
+      all: [
+        {
+          id: "opencode",
+          name: "OpenCode",
+          models: {
+            "free-model": {
+              id: "free-model",
+              name: "Free Model",
+              cost: { input: 0, output: 0 },
+              limit: { context: 200_000 },
+            },
+          },
+        },
+        {
+          id: "opencode-go",
+          name: "OpenCode Go",
+          models: {
+            "go-model-1": {
+              id: "go-model-1",
+              name: "Go Model 1",
+              cost: { input: 1, output: 1 },
+              limit: { context: 200_000 },
+            },
+          },
+        },
+      ],
+      connected: connectedGo ? ["opencode", "opencode-go"] : ["opencode"],
+      default: { providerID: "opencode", modelID: "free-model" },
+    }),
+    integrationMethods: { "opencode-go": [{ type: "api", label: "API key" }] },
+    onConnectKey: (input) => {
+      connections.push(input)
+      if (input.integrationID === "opencode-go") pendingGo = true
+    },
+    onInstanceDispose: () => {
+      if (pendingGo) connectedGo = true
+    },
+    sessions: [],
+    pageMessages: () => ({ items: [] }),
+    fileList: (path) =>
+      path
+        ? []
+        : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }],
+    findFiles: () => ["NewProject"],
+  })
+  await page.addInitScript(() => {
+    localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
+    localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
+  })
+
+  await page.goto("/")
+  const addProject = page.locator('[data-action="home-add-project-row"]')
+  await expectAppVisible(addProject)
+  await addProject.click()
+  await page.locator("[data-directory-path]").click()
+
+  await page.locator('[data-action="home-new-session"]').click()
+  await expectAppVisible(page.locator('[data-component="prompt-input-v2"]'))
+
+  const modelControl = page.locator('[data-action="prompt-model"]')
+  await modelControl.click()
+  await expect(page.locator('[data-section="free-models"]')).toContainText("Free models provided by OpenCode")
+
+  await page.locator('[data-provider-id="opencode-go"]').click()
+  await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key")
+  await page.locator('[data-action="provider-connect-submit"]').click()
+  await expect(page.locator('[data-component="dialog-v2"]')).toHaveCount(0)
+  expect(connections).toEqual([
+    { integrationID: "opencode-go", body: { type: "api", key: "mock-go-api-key" } },
+  ])
+
+  await expect(modelControl).toHaveAttribute("data-control-type", "popover")
+  await modelControl.click()
+  const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
+  await expect(goModel).toBeVisible()
+  await goModel.click()
+
+  await expect(modelControl).toContainText("Go Model 1")
+})

+ 20 - 3
packages/app/e2e/utils/mock-server.ts

@@ -5,7 +5,10 @@ const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mc
 
 export interface MockServerConfig {
   protocol?: "v1" | "v2"
-  provider: unknown
+  provider: unknown | (() => unknown)
+  integrationMethods?: Record<string, unknown[]>
+  onConnectKey?: (input: { integrationID: string; body: unknown }) => void
+  onInstanceDispose?: () => void
   directory: string
   project: unknown
   sessions: ({ id: string } & Record<string, unknown>)[]
@@ -31,7 +34,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
   const cursors = new Map<string, string>()
   let nextCursor = 0
   const staticRoutes: Record<string, unknown> = {
-    "/provider": config.provider,
     "/path": {
       state: config.directory,
       config: config.directory,
@@ -75,6 +77,18 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
     if (path === "/api/health" && config.protocol === "v2")
       return json(route, { healthy: true, version: "2.0.0", pid: 1 })
     if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
+    if (path === "/provider")
+      return json(route, typeof config.provider === "function" ? config.provider() : config.provider)
+    if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
+    const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1]
+    if (legacyAuth && route.request().method() === "PUT") {
+      config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() })
+      return json(route, true)
+    }
+    if (path === "/instance/dispose" && route.request().method() === "POST") {
+      config.onInstanceDispose?.()
+      return json(route, true)
+    }
     if (path === "/permission")
       return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
     if (path === "/question")
@@ -130,8 +144,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
         location: location(config),
         data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
       })
-    if (/^\/api\/integration\/[^/]+\/connect\/key$/.test(path) && route.request().method() === "POST")
+    const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1]
+    if (integrationConnect && route.request().method() === "POST") {
+      config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
       return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
+    }
     if (path === "/api/project") return json(route, [config.project])
     if (path === "/api/project/current")
       return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })

+ 2 - 1
packages/app/src/components/dialog-connect-provider.tsx

@@ -857,6 +857,7 @@ function ProviderConnection(props: {
                 ref={apiKey}
                 class="!w-full"
                 name="apiKey"
+                data-input="provider-api-key"
                 placeholder={language.t("provider.connect.apiKey.placeholder")}
                 value={formStore.value}
                 invalid={formStore.error !== undefined}
@@ -873,7 +874,7 @@ function ProviderConnection(props: {
                 </div>
               )}
             </Show>
-            <ButtonV2 type="submit" variant="contrast">
+            <ButtonV2 type="submit" variant="contrast" data-action="provider-connect-submit">
               {language.t("common.continue")}
             </ButtonV2>
           </form>

+ 1 - 0
packages/app/src/components/dialog-select-directory-v2.tsx

@@ -329,6 +329,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
                 {(suggestion, index) => (
                   <button
                     id={`directory-picker-v2-suggestion-${index()}`}
+                    data-directory-path={suggestion.absolute}
                     role="option"
                     aria-selected={index() === activeSuggestion()}
                     data-active={index() === activeSuggestion() ? "" : undefined}

+ 2 - 2
packages/app/src/components/dialog-select-directory.tsx

@@ -164,7 +164,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
           const path = displayPickerPath(item.absolute, filter(), home())
           if (path === "~") {
             return (
-              <div class="w-full flex items-center justify-between rounded-md">
+              <div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
                 <div class="flex items-center gap-x-3 grow min-w-0">
                   <FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
                   <div class="flex items-center text-14-regular min-w-0">
@@ -176,7 +176,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
             )
           }
           return (
-            <div class="w-full flex items-center justify-between rounded-md">
+            <div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
               <div class="flex items-center gap-x-3 grow min-w-0">
                 <FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
                 <div class="flex items-center text-14-regular min-w-0">

+ 2 - 1
packages/app/src/components/dialog-select-model-unpaid-v2.tsx

@@ -76,7 +76,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
       </DialogHeader>
       <DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">
         <div ref={listEl} class="flex min-h-0 flex-col">
-          <div class="flex w-full flex-col items-start pb-3">
+          <div data-section="free-models" class="flex w-full flex-col items-start pb-3">
             <div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2">
               <div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
                 {language.t("dialog.model.unpaid.freeModels.title")}
@@ -134,6 +134,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
                   {(provider) => (
                     <button
                       type="button"
+                      data-provider-id={provider.id}
                       class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none"
                       classList={{
                         "border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":

+ 2 - 0
packages/app/src/components/prompt-input-v2.tsx

@@ -521,6 +521,7 @@ function PromptInputV2ModelControl(props: {
           fallback={
             <ButtonV2
               data-action="prompt-model"
+              data-control-type="dialog"
               variant="ghost-muted"
               size="normal"
               class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
@@ -542,6 +543,7 @@ function PromptInputV2ModelControl(props: {
               class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
               classList: { "animate-in fade-in": shouldAnimate() },
               "data-action": "prompt-model",
+              "data-control-type": "popover",
             }}
             onClose={props.onClose}
           >