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

fix(app): restore desktop prod legacy flows (#28919)

Luke Parker 2 месяцев назад
Родитель
Сommit
3bf054c1d9

+ 78 - 0
packages/app/src/context/global-sync/bootstrap.test.ts

@@ -0,0 +1,78 @@
+import { describe, expect, test } from "bun:test"
+import { createStore } from "solid-js/store"
+import { QueryClient } from "@tanstack/solid-query"
+import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
+import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
+import { bootstrapDirectory } from "./bootstrap"
+import type { State, VcsCache } from "./types"
+
+const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
+
+describe("bootstrapDirectory", () => {
+  test("marks a loading directory partial during bootstrap and complete after success", async () => {
+    const [store, setStore] = createStore<State>({
+      status: "loading",
+      agent: [],
+      command: [],
+      project: "",
+      projectMeta: undefined,
+      icon: undefined,
+      provider_ready: true,
+      provider,
+      config: {},
+      path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
+      session: [],
+      sessionTotal: 0,
+      session_status: {},
+      session_working(id: string) {
+        return this.session_status[id]?.type !== "idle"
+      },
+      session_diff: {},
+      todo: {},
+      permission: {},
+      question: {},
+      mcp_ready: true,
+      mcp: {},
+      lsp_ready: true,
+      lsp: [],
+      vcs: undefined,
+      limit: 5,
+      message: {},
+      part: {},
+      part_text_accum_delta: {},
+    })
+
+    await bootstrapDirectory({
+      directory: "/project",
+      global: {
+        config: {} satisfies Config,
+        path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
+        project: [{ id: "project", worktree: "/project" } as Project],
+        provider,
+      },
+      sdk: {
+        app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
+        config: { get: async () => ({ data: {} }) },
+        session: { status: async () => ({ data: {} }) },
+        vcs: { get: async () => ({ data: undefined }) },
+        command: { list: async () => ({ data: [] }) },
+        permission: { list: async () => ({ data: [] }) },
+        question: { list: async () => ({ data: [] }) },
+        mcp: { status: async () => ({ data: {} }) },
+        provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
+      } as unknown as OpencodeClient,
+      store,
+      setStore,
+      vcsCache: { setStore() {} } as unknown as VcsCache,
+      loadSessions() {},
+      translate: (key) => key,
+      queryClient: new QueryClient(),
+    })
+
+    expect(store.status).toBe("partial")
+
+    await new Promise((resolve) => setTimeout(resolve, 80))
+
+    expect(store.status).toBe("complete")
+  })
+})

+ 3 - 0
packages/app/src/context/global-sync/bootstrap.ts

@@ -220,6 +220,7 @@ export async function bootstrapDirectory(input: {
   if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
     input.setStore("config", reconcile(input.global.config, { merge: false }))
   }
+  if (loading) input.setStore("status", "partial")
 
   const rev = (providerRev.get(input.directory) ?? 0) + 1
   providerRev.set(input.directory, rev)
@@ -326,5 +327,7 @@ export async function bootstrapDirectory(input: {
         description: formatServerError(slowErrs[0], input.translate),
       })
     }
+
+    if (loading && slowErrs.length === 0) input.setStore("status", "complete")
   })()
 }

+ 89 - 5
packages/app/src/context/global-sync/child-store.test.ts

@@ -1,10 +1,63 @@
-import { describe, expect, test } from "bun:test"
-import { createRoot, getOwner } from "solid-js"
+import { beforeAll, describe, expect, mock, test } from "bun:test"
+import { createRoot, getOwner, type Owner } from "solid-js"
 import { createStore } from "solid-js/store"
+import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
 import type { State } from "./types"
-import { createChildStoreManager } from "./child-store"
+import type { QueryOptionsApi } from "../global-sync"
+
+let createChildStoreManager: typeof import("./child-store").createChildStoreManager
 
 const child = () => createStore({} as State)
+const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
+
+const queryOptionsApi = {
+  globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }),
+  projects: () => ({ queryKey: ["projects"], queryFn: async () => [] }),
+  providers: (directory: string | null) => ({ queryKey: [directory, "providers"], queryFn: async () => provider }),
+  path: (directory: string | null) => ({
+    queryKey: [directory, "path"],
+    queryFn: async () => ({
+      state: "",
+      config: "",
+      worktree: "",
+      directory: directory ?? "",
+      home: "",
+    }),
+  }),
+  agents: (directory: string) => ({ queryKey: [directory, "agents"], queryFn: async () => [] }),
+  mcp: (directory: string) => ({ queryKey: [directory, "mcp"], queryFn: async () => ({}) }),
+  lsp: (directory: string) => ({ queryKey: [directory, "lsp"], queryFn: async () => [] }),
+  sessions: (directory: string) => ({ queryKey: [directory, "loadSessions"] as const }),
+} as unknown as QueryOptionsApi
+
+function createOwner(callback: (owner: Owner) => void) {
+  return createRoot((dispose) => {
+    const owner = getOwner()
+    if (!owner) throw new Error("owner required")
+    callback(owner)
+
+    return dispose
+  })
+}
+
+beforeAll(async () => {
+  mock.module("@/utils/persist", () => ({
+    Persist: {
+      workspace: (...parts: string[]) => parts.join(":"),
+    },
+    persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true],
+  }))
+  mock.module("@tanstack/solid-query", () => ({
+    useQueries: () => [
+      { isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } },
+      { isLoading: false, data: {} },
+      { isLoading: false, data: [] },
+      { isLoading: false, data: provider },
+    ],
+  }))
+
+  createChildStoreManager = (await import("./child-store")).createChildStoreManager
+})
 
 describe("createChildStoreManager", () => {
   test("does not evict the active directory during mark", () => {
@@ -22,8 +75,8 @@ describe("createChildStoreManager", () => {
       onBootstrap() {},
       onDispose() {},
       translate: (key) => key,
-      queryOptions: {} as any,
-      global: { provider: null! },
+      queryOptions: queryOptionsApi,
+      global: { provider },
     })
 
     Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
@@ -37,4 +90,35 @@ describe("createChildStoreManager", () => {
 
     expect(manager.children[directory]).toBeDefined()
   })
+
+  test("starts new child stores as loading and bootstraps them on first access", () => {
+    const bootstraps: string[] = []
+    let manager: ReturnType<typeof createChildStoreManager> | undefined
+
+    const dispose = createOwner((owner) => {
+      manager = createChildStoreManager({
+        owner,
+        isBooting: () => false,
+        isLoadingSessions: () => false,
+        onBootstrap(directory) {
+          bootstraps.push(directory)
+        },
+        onDispose() {},
+        translate: (key) => key,
+        queryOptions: queryOptionsApi,
+        global: { provider },
+      })
+    })
+
+    try {
+      if (!manager) throw new Error("manager required")
+
+      const [store] = manager.child("/project")
+
+      expect(store.status).toBe("loading")
+      expect(bootstraps).toEqual(["/project"])
+    } finally {
+      dispose()
+    }
+  })
 })

+ 1 - 1
packages/app/src/context/global-sync/child-store.ts

@@ -202,7 +202,7 @@ export function createChildStoreManager(input: {
                 return { state: "", config: "", worktree: "", directory: "", home: "" }
               return pathQuery.data
             },
-            status: "complete" as const,
+            status: "loading" as const,
             agent: [],
             command: [],
             session: [],

+ 54 - 178
packages/app/src/pages/home.tsx

@@ -1,5 +1,5 @@
 import type { Session } from "@opencode-ai/sdk/v2/client"
-import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
+import { createMemo, For, Match, Show, Switch } from "solid-js"
 import { createStore } from "solid-js/store"
 import { useQuery } from "@tanstack/solid-query"
 import { Button } from "@opencode-ai/ui/button"
@@ -18,7 +18,6 @@ import { DateTime } from "luxon"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { DialogSelectDirectory } from "@/components/dialog-select-directory"
 import { DialogSelectServer } from "@/components/dialog-select-server"
-import { DialogSelectModel } from "@/components/dialog-select-model"
 import { useServer } from "@/context/server"
 import { useGlobalSync } from "@/context/global-sync"
 import { useLanguage } from "@/context/language"
@@ -467,11 +466,6 @@ function LegacyHome() {
   const navigate = useNavigate()
   const server = useServer()
   const language = useLanguage()
-
-  const [promptText, setPromptText] = createSignal("")
-  const [selectedAgent, setSelectedAgent] = createSignal("frontend-specialist")
-  const [showProjectsDropdown, setShowProjectsDropdown] = createSignal(false)
-
   const homedir = createMemo(() => sync.data.path.home)
   const recent = createMemo(() => {
     return sync.data.project
@@ -480,8 +474,6 @@ function LegacyHome() {
       .slice(0, 5)
   })
 
-  const currentProject = createMemo(() => recent()[0]?.worktree)
-
   const serverDotClass = createMemo(() => {
     const healthy = server.healthy()
     if (healthy === true) return "bg-icon-success-base"
@@ -520,185 +512,69 @@ function LegacyHome() {
     }
   }
 
-  function handleModelSelect() {
-    dialog.show(() => <DialogSelectModel />)
-  }
-
-  function toggleAgent() {
-    const agents = ["frontend-specialist", "build", "general"]
-    const nextIndex = (agents.indexOf(selectedAgent()) + 1) % agents.length
-    setSelectedAgent(agents[nextIndex])
-  }
-
-  function handleSubmit() {
-    const projectToOpen = currentProject()
-    if (projectToOpen) {
-      openProject(projectToOpen)
-    } else {
-      chooseProject()
-    }
-  }
-
-  const activeModelName = createMemo(() => {
-    const model = sync.data.config.model
-    if (!model) return "GPT-5.7 Pro"
-    const parts = model.split("/")
-    return parts[parts.length - 1]
-  })
-
   return (
-    <div class="mx-auto mt-24 w-full max-w-2xl px-6 flex flex-col items-center">
-      <div class="flex flex-col items-center gap-3 mb-10">
-        <div onClick={chooseProject} class="cursor-pointer hover:opacity-25 transition-opacity duration-200">
-          <Logo class="w-48 opacity-15" />
-        </div>
-        <Button
-          size="normal"
-          variant="ghost"
-          class="text-12-regular text-text-weak px-3"
-          onClick={() => dialog.show(() => <DialogSelectServer />)}
-        >
-          <div
-            classList={{
-              "size-1.5 rounded-full mr-2": true,
-              [serverDotClass()]: true,
-            }}
-          />
-          {server.name}
-        </Button>
-      </div>
-
+    <div class="mx-auto mt-55 w-full md:w-auto px-4">
+      <Logo class="md:w-xl opacity-12" />
+      <Button
+        size="large"
+        variant="ghost"
+        class="mt-4 mx-auto text-14-regular text-text-weak"
+        onClick={() => dialog.show(() => <DialogSelectServer />)}
+      >
+        <div
+          classList={{
+            "size-2 rounded-full": true,
+            [serverDotClass()]: true,
+          }}
+        />
+        {server.name}
+      </Button>
       <Switch>
-        <Match when={recent().length > 0}>
-          <div class="w-full flex flex-col items-center gap-6">
-            <div class="text-20-medium text-text-strong text-center">{language.t("session.new.title")}</div>
-
-            <div class="w-full bg-surface-base border border-border-base rounded-xl p-4 flex flex-col gap-3 shadow-md relative">
-              <textarea
-                class="bg-transparent border-none outline-none text-14-regular text-text-base placeholder-text-weak w-full resize-none h-20 focus:outline-none"
-                placeholder="Ask anything, / for commands, @ for context..."
-                value={promptText()}
-                onInput={(e) => setPromptText(e.currentTarget.value)}
-                onKeyDown={(e) => {
-                  if (e.key === "Enter" && !e.shiftKey) {
-                    e.preventDefault()
-                    handleSubmit()
-                  }
-                }}
-              />
-
-              <div class="flex flex-wrap items-center gap-2 pt-3 border-t border-border-weak-base">
-                <Button
-                  size="small"
-                  variant="ghost"
-                  class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
-                  onClick={toggleAgent}
-                >
-                  <Icon name="sliders" size="small" class="shrink-0" />
-                  <span>Agent: {selectedAgent()}</span>
-                </Button>
-
-                <Button
-                  size="small"
-                  variant="ghost"
-                  class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
-                  onClick={handleModelSelect}
-                >
-                  <Icon name="brain" size="small" class="shrink-0" />
-                  <span>Model: {activeModelName()}</span>
-                </Button>
-
-                <div class="relative">
+        <Match when={sync.data.project.length > 0}>
+          <div class="mt-20 w-full flex flex-col gap-4">
+            <div class="flex gap-2 items-center justify-between pl-3">
+              <div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
+              <Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
+                {language.t("command.project.open")}
+              </Button>
+            </div>
+            <ul class="flex flex-col gap-2">
+              <For each={recent()}>
+                {(project) => (
                   <Button
-                    size="small"
+                    size="large"
                     variant="ghost"
-                    class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
-                    onClick={() => setShowProjectsDropdown(!showProjectsDropdown())}
+                    class="text-14-mono text-left justify-between px-3"
+                    onClick={() => openProject(project.worktree)}
                   >
-                    <Icon name="folder" size="small" class="shrink-0" />
-                    <span>Project: {currentProject() ? getFilename(currentProject()) : "Select Project"}</span>
-                  </Button>
-
-                  <Show when={showProjectsDropdown()}>
-                    <div class="absolute left-0 mt-1 w-64 bg-surface-raised-base border border-border-base rounded-lg p-2 shadow-lg z-50 flex flex-col gap-1">
-                      <div class="text-10-semibold text-text-weak px-2 py-1 uppercase tracking-wider">
-                        {language.t("home.recentProjects")}
-                      </div>
-                      <For each={recent()}>
-                        {(project) => (
-                          <button
-                            class="text-12-mono text-left px-2 py-1.5 hover:bg-surface-raised-base-hover rounded flex items-center justify-between w-full"
-                            onClick={() => {
-                              openProject(project.worktree)
-                              setShowProjectsDropdown(false)
-                            }}
-                          >
-                            <span class="truncate">{getFilename(project.worktree)}</span>
-                            <span class="text-10-regular text-text-weak shrink-0 pl-2">
-                              {DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
-                            </span>
-                          </button>
-                        )}
-                      </For>
-                      <div class="border-t border-border-weak-base my-1" />
-                      <button
-                        class="text-12-medium text-text-strong text-left px-2 py-1.5 hover:bg-surface-raised-base-hover rounded flex items-center gap-2 w-full"
-                        onClick={() => {
-                          setShowProjectsDropdown(false)
-                          chooseProject()
-                        }}
-                      >
-                        <Icon name="folder-add-left" size="small" />
-                        {language.t("command.project.open")}
-                      </button>
+                    {project.worktree.replace(homedir(), "~")}
+                    <div class="text-14-regular text-text-weak">
+                      {DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
                     </div>
-                  </Show>
-                </div>
-
-                <Button
-                  size="small"
-                  variant="ghost"
-                  class="text-12-medium text-text-weak flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base border border-border-weak-base rounded-md cursor-default pointer-events-none"
-                >
-                  <Icon name="branch" size="small" class="shrink-0" />
-                  <span>Branch: dev</span>
-                </Button>
-              </div>
-            </div>
+                  </Button>
+                )}
+              </For>
+            </ul>
+          </div>
+        </Match>
+        <Match when={!sync.ready}>
+          <div class="mt-30 mx-auto flex flex-col items-center gap-3">
+            <div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
+            <Button class="px-3" onClick={chooseProject}>
+              {language.t("command.project.open")}
+            </Button>
           </div>
         </Match>
-
         <Match when={true}>
-          <div class="w-full flex flex-col items-center gap-6">
-            <div class="text-20-medium text-text-strong text-center">{language.t("home.empty.title")}</div>
-
-            <div class="w-full bg-surface-base border border-border-base rounded-xl p-4 flex flex-col gap-3 shadow-md">
-              <div class="text-14-regular text-text-weak w-full min-h-[4rem] cursor-pointer" onClick={chooseProject}>
-                Ask anything, / for commands, @ for context...
-              </div>
-
-              <div class="flex flex-wrap items-center gap-2 pt-3 border-t border-border-weak-base">
-                <Button
-                  size="small"
-                  variant="ghost"
-                  class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
-                  onClick={chooseProject}
-                >
-                  <Icon name="folder" size="small" class="shrink-0" />
-                  <span>Open project</span>
-                </Button>
-
-                <Button
-                  size="small"
-                  variant="ghost"
-                  class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
-                  onClick={handleModelSelect}
-                >
-                  <Icon name="brain" size="small" class="shrink-0" />
-                  <span>Model: {activeModelName()}</span>
-                </Button>
-              </div>
+          <div class="mt-30 mx-auto flex flex-col items-center gap-3">
+            <Icon name="folder-add-left" size="large" />
+            <div class="flex flex-col gap-1 items-center justify-center">
+              <div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
+              <div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
             </div>
+            <Button class="px-3 mt-1" onClick={chooseProject}>
+              {language.t("command.project.open")}
+            </Button>
           </div>
         </Match>
       </Switch>

+ 3 - 1
packages/app/src/pages/session.tsx

@@ -59,6 +59,7 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel"
 import { TerminalPanel } from "@/pages/session/terminal-panel"
 import { useSessionCommands } from "@/pages/session/use-session-commands"
 import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
+import { shouldUseV2NewSessionPage } from "@/pages/session/new-session-layout"
 import { Identifier } from "@/utils/id"
 import { diffs as list } from "@/utils/diffs"
 import { Persist, persisted } from "@/utils/persist"
@@ -263,7 +264,8 @@ export default function Page() {
 
   const isDesktop = createMediaQuery("(min-width: 768px)")
   const size = createSizing()
-  const isV2NewSessionPage = () => import.meta.env.VITE_OPENCODE_CHANNEL === "prod" || !params.id
+  const isV2NewSessionPage = () =>
+    shouldUseV2NewSessionPage({ channel: import.meta.env.VITE_OPENCODE_CHANNEL, sessionID: params.id })
   const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened() && !isV2NewSessionPage())
   const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened() && !isV2NewSessionPage())
   const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())

+ 14 - 0
packages/app/src/pages/session/new-session-layout.test.ts

@@ -0,0 +1,14 @@
+import { describe, expect, test } from "bun:test"
+import { shouldUseV2NewSessionPage } from "./new-session-layout"
+
+describe("shouldUseV2NewSessionPage", () => {
+  test("keeps prod session pages on the legacy layout", () => {
+    expect(shouldUseV2NewSessionPage({ channel: "prod", sessionID: "ses_123" })).toBe(false)
+    expect(shouldUseV2NewSessionPage({ channel: "prod" })).toBe(false)
+  })
+
+  test("uses the v2 layout only for non-prod new-session pages", () => {
+    expect(shouldUseV2NewSessionPage({ channel: "dev" })).toBe(true)
+    expect(shouldUseV2NewSessionPage({ channel: "dev", sessionID: "ses_123" })).toBe(false)
+  })
+})

+ 3 - 0
packages/app/src/pages/session/new-session-layout.ts

@@ -0,0 +1,3 @@
+export function shouldUseV2NewSessionPage(input: { channel?: "dev" | "beta" | "prod"; sessionID?: string }) {
+  return input.channel !== "prod" && !input.sessionID
+}