Jelajahi Sumber

app: wrap provider data in Map to avoid store (#28765)

Brendan Allan 3 bulan lalu
induk
melakukan
1f0390cfbb

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

@@ -41,9 +41,7 @@ export function DialogConnectProvider(props: { provider: string }) {
   })
 
   const provider = createMemo(
-    () =>
-      providers.all().find((x) => x.id === props.provider) ??
-      globalSync.data.provider.all.find((x) => x.id === props.provider)!,
+    () => providers.all().get(props.provider) ?? globalSync.data.provider.all.get(props.provider)!,
   )
   const fallback = createMemo<ProviderAuthMethod[]>(() => [
     {

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

@@ -106,7 +106,7 @@ export function DialogCustomProvider(props: Props) {
       form,
       t: language.t,
       disabledProviders: globalSync.data.config.disabled_providers ?? [],
-      existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
+      existingProviderIDs: new Set(globalSync.data.provider.all.keys()),
     })
     batch(() => {
       setForm("err", output.err)

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

@@ -91,7 +91,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
             <div class="w-full">
               <List
                 class="w-full px-0"
-                key={(x) => x?.id}
+                key={(p) => p.id}
                 items={providers.popular}
                 activeIcon="plus-small"
                 sortBy={(a, b) => {

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

@@ -35,7 +35,7 @@ export const DialogSelectProvider: Component = () => {
         key={(x) => x?.id}
         items={() => {
           language.locale()
-          return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all()]
+          return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
         }}
         filterKeys={["id", "name"]}
         groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}

+ 1 - 1
packages/app/src/components/session-context-usage.tsx

@@ -52,7 +52,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
       }),
   )
 
-  const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
+  const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
   const context = createMemo(() => metrics().context)
   const cost = createMemo(() => {
     return usd().format(metrics().totalCost)

+ 1 - 1
packages/app/src/components/session/session-context-tab.tsx

@@ -132,7 +132,7 @@ export function SessionContextTab() {
       }),
   )
 
-  const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
+  const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
   const ctx = createMemo(() => metrics().context)
   const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
 

+ 4 - 11
packages/app/src/context/global-sync.tsx

@@ -1,12 +1,4 @@
-import type {
-  Config,
-  OpencodeClient,
-  Path,
-  Project,
-  ProviderAuthResponse,
-  ProviderListResponse,
-  Todo,
-} from "@opencode-ai/sdk/v2/client"
+import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
 import { showToast } from "@opencode-ai/ui/toast"
 import { getFilename } from "@opencode-ai/core/util/path"
 import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
@@ -37,6 +29,7 @@ import { createRefreshQueue } from "./global-sync/queue"
 import { directoryKey } from "./global-sync/utils"
 import { PathKey } from "@/utils/path-key"
 import { createDirSyncContext } from "./directory-sync"
+import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
 
 type GlobalStore = {
   ready: boolean
@@ -46,7 +39,7 @@ type GlobalStore = {
   session_todo: {
     [sessionID: string]: Todo[]
   }
-  provider: ProviderListResponse
+  provider: NormalizedProviderListResponse
   provider_auth: ProviderAuthResponse
   config: Config
   reload: undefined | "pending" | "complete"
@@ -121,7 +114,7 @@ function createGlobalSync() {
       return pathQuery.data ?? EMPTY
     },
     get provider() {
-      const EMPTY = { all: [], connected: [], default: {} }
+      const EMPTY = { all: new Map(), connected: [], default: {} }
       if (providerQuery.isLoading) return EMPTY
       return providerQuery.data ?? EMPTY
     },

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

@@ -5,7 +5,6 @@ import type {
   PermissionRequest,
   Project,
   ProviderAuthResponse,
-  ProviderListResponse,
   QuestionRequest,
   Session,
   Todo,
@@ -20,6 +19,7 @@ import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
 import { formatServerError } from "@/utils/server-errors"
 import { QueryClient, queryOptions } from "@tanstack/solid-query"
 import { loadMcpQuery } from "../global-sync"
+import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
 
 type GlobalStore = {
   ready: boolean
@@ -28,7 +28,7 @@ type GlobalStore = {
   session_todo: {
     [sessionID: string]: Todo[]
   }
-  provider: ProviderListResponse
+  provider: NormalizedProviderListResponse
   provider_auth: ProviderAuthResponse
   config: Config
   reload: undefined | "pending" | "complete"
@@ -208,7 +208,7 @@ export async function bootstrapDirectory(input: {
     config: Config
     path: Path
     project: Project[]
-    provider: ProviderListResponse
+    provider: NormalizedProviderListResponse
   }
   queryClient: QueryClient
 }) {
@@ -220,7 +220,6 @@ 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)
@@ -327,7 +326,5 @@ export async function bootstrapDirectory(input: {
         description: formatServerError(slowErrs[0], input.translate),
       })
     }
-
-    if (loading && slowErrs.length === 0) input.setStore("status", "complete")
   })()
 }

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

@@ -1,7 +1,7 @@
 import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
 import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
 import { Persist, persisted } from "@/utils/persist"
-import type { ProviderListResponse, VcsInfo } from "@opencode-ai/sdk/v2/client"
+import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
 import {
   DIR_IDLE_TTL_MS,
   MAX_DIR_STORES,
@@ -17,6 +17,7 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
 import { useQueries } from "@tanstack/solid-query"
 import { QueryOptionsApi } from "../global-sync"
 import { directoryKey, type DirectoryKey } from "./utils"
+import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
 
 export function createChildStoreManager(input: {
   owner: Owner
@@ -27,7 +28,7 @@ export function createChildStoreManager(input: {
   translate: (key: string, vars?: Record<string, string | number>) => string
   queryOptions: QueryOptionsApi
   global: {
-    provider: ProviderListResponse
+    provider: NormalizedProviderListResponse
   }
 }) {
   const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
@@ -190,10 +191,9 @@ export function createChildStoreManager(input: {
               return !providerQuery.isLoading
             },
             get provider() {
-              const EMPTY = { all: [], connected: [], default: {} }
+              const EMPTY = { all: new Map(), connected: [], default: {} }
               if (providerQuery.isLoading) return EMPTY
-              if (providerQuery.data?.all.length === 0 && input.global.provider.all.length > 0)
-                return input.global.provider
+              if (providerQuery.data?.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider
               return providerQuery.data ?? EMPTY
             },
             config: {},
@@ -202,7 +202,7 @@ export function createChildStoreManager(input: {
                 return { state: "", config: "", worktree: "", directory: "", home: "" }
               return pathQuery.data
             },
-            status: "loading" as const,
+            status: "complete" as const,
             agent: [],
             command: [],
             session: [],

+ 2 - 2
packages/app/src/context/global-sync/types.ts

@@ -8,7 +8,6 @@ import type {
   Part,
   Path,
   PermissionRequest,
-  ProviderListResponse,
   QuestionRequest,
   Session,
   SessionStatus,
@@ -16,6 +15,7 @@ import type {
   Todo,
   VcsInfo,
 } from "@opencode-ai/sdk/v2/client"
+import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
 import type { Accessor } from "solid-js"
 import type { SetStoreFunction, Store } from "solid-js/store"
 
@@ -38,7 +38,7 @@ export type State = {
   projectMeta: ProjectMeta | undefined
   icon: string | undefined
   provider_ready: boolean
-  provider: ProviderListResponse
+  provider: NormalizedProviderListResponse
   config: Config
   path: Path
   session: Session[]

+ 16 - 5
packages/app/src/context/global-sync/utils.ts

@@ -1,4 +1,5 @@
 import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
+import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
 export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
 
 export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
@@ -17,13 +18,23 @@ export function normalizeAgentList(input: unknown): Agent[] {
   return Object.values(input).filter(isAgent)
 }
 
-export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
+export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse {
   return {
     ...input,
-    all: input.all.map((provider) => ({
-      ...provider,
-      models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")),
-    })),
+    all: new Map(
+      input.all.map(
+        (provider) =>
+          [
+            provider.id,
+            {
+              ...provider,
+              models: Object.fromEntries(
+                Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
+              ),
+            },
+          ] as const,
+      ),
+    ),
   }
 }
 

+ 1 - 1
packages/app/src/context/local.tsx

@@ -90,7 +90,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
     })
 
     const validModel = (model: ModelKey) => {
-      const provider = providers.all().find((item) => item.id === model.providerID)
+      const provider = providers.all().get(model.providerID)
       return !!provider?.models[model.modelID] && connected().has(model.providerID)
     }
 

+ 22 - 5
packages/app/src/hooks/use-providers.ts

@@ -1,6 +1,7 @@
 import { useGlobalSync } from "@/context/global-sync"
 import { decode64 } from "@/utils/base64"
 import { useParams } from "@solidjs/router"
+import { Iterable, pipe } from "effect"
 import { createMemo } from "solid-js"
 
 export const popularProviders = [
@@ -29,16 +30,32 @@ export function useProviders() {
   return {
     all: () => providers().all,
     default: () => providers().default,
-    popular: () => providers().all.filter((p) => popularProviderSet.has(p.id)),
+    popular: () =>
+      pipe(
+        providers().all,
+        Iterable.map(([, p]) => p),
+        Iterable.filter((p) => popularProviderSet.has(p.id)),
+        (v) => Array.from(v),
+      ),
     connected: () => {
       const connected = new Set(providers().connected)
-      return providers().all.filter((p) => connected.has(p.id))
+      return pipe(
+        providers().all,
+        Iterable.map(([, p]) => p),
+        Iterable.filter((p) => connected.has(p.id)),
+        (v) => Array.from(v),
+      )
     },
     paid: () => {
       const connected = new Set(providers().connected)
-      return providers().all.filter(
-        (p) => connected.has(p.id) && (p.id !== "opencode" || Object.values(p.models).some((m) => m.cost?.input)),
-      )
+      return [
+        ...Iterable.filter(
+          providers().all,
+          ([id]) =>
+            connected.has(id) &&
+            (id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
+        ),
+      ]
     },
   }
 }

+ 1 - 1
packages/app/src/pages/layout.tsx

@@ -2303,7 +2303,7 @@ export default function Layout(props: ParentProps) {
         <div
           class="shrink-0 px-3 py-3"
           classList={{
-            hidden: store.gettingStartedDismissed || !(providers.all().length > 0 && providers.paid().length === 0),
+            hidden: store.gettingStartedDismissed || !(providers.all().size > 0 && providers.paid().length === 0),
           }}
         >
           <div class="rounded-xl bg-background-base shadow-xs-border-base" data-component="getting-started">

+ 5 - 4
packages/app/src/pages/session.tsx

@@ -262,8 +262,9 @@ export default function Page() {
 
   const isDesktop = createMediaQuery("(min-width: 768px)")
   const size = createSizing()
-  const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
-  const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
+  const isV2NewSessionPage = () => import.meta.env.VITE_OPENCODE_CHANNEL === "prod" || !params.id
+  const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened() && !isV2NewSessionPage())
+  const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened() && !isV2NewSessionPage())
   const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
   const sessionPanelWidth = createMemo(() => {
     if (!desktopSidePanelOpen()) return "100%"
@@ -1733,12 +1734,12 @@ export default function Page() {
           </Tabs>
         </Show>
 
-        {/* Session panel */}
         <div
           classList={{
             "@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger flex-1 md:flex-none": true,
-            "transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
+            "duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
               !size.active() && !ui.reviewSnap,
+            "transition-[width]": !isV2NewSessionPage(),
           }}
           style={{
             width: sessionPanelWidth(),

+ 2 - 2
packages/app/src/pages/session/session-side-panel.tsx

@@ -55,7 +55,7 @@ export function SessionSidePanel(props: {
   const language = useLanguage()
   const command = useCommand()
   const dialog = useDialog()
-  const { sessionKey, tabs, view } = useSessionLayout()
+  const { sessionKey, tabs, view, params } = useSessionLayout()
 
   const isDesktop = createMediaQuery("(min-width: 768px)")
   const shown = createMemo(
@@ -207,7 +207,7 @@ export function SessionSidePanel(props: {
   })
 
   return (
-    <Show when={isDesktop()}>
+    <Show when={isDesktop() && !(import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && !params.id)}>
       <aside
         id="review-panel"
         aria-label={language.t("session.panel.reviewAndFiles")}

+ 2 - 2
packages/ui/src/components/message-part.tsx

@@ -1063,7 +1063,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
     const providerID = props.message.model?.providerID
     const modelID = props.message.model?.modelID
     if (!providerID || !modelID) return ""
-    const match = data.store.provider?.all?.find((p) => p.id === providerID)
+    const match = data.store.provider?.all?.get(providerID)
     return match?.models?.[modelID]?.name ?? modelID
   })
   const timefmt = createMemo(() => new Intl.DateTimeFormat(i18n.locale(), { timeStyle: "short" }))
@@ -1458,7 +1458,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
   const model = createMemo(() => {
     if (props.message.role !== "assistant") return ""
     const message = props.message as AssistantMessage
-    const match = data.store.provider?.all?.find((p) => p.id === message.providerID)
+    const match = data.store.provider?.all?.get(message.providerID)
     return match?.models?.[message.modelID]?.name ?? message.modelID
   })
 

+ 10 - 2
packages/ui/src/context/data.tsx

@@ -1,13 +1,21 @@
-import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, ProviderListResponse } from "@opencode-ai/sdk/v2"
+import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, Provider } from "@opencode-ai/sdk/v2"
 import { createSimpleContext } from "./helper"
 import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
 
+export type NormalizedProviderListResponse = {
+  all: Map<string, Provider>
+  default: {
+    [key: string]: string
+  }
+  connected: Array<string>
+}
+
 type Data = {
   agent?: {
     name: string
     color?: string
   }[]
-  provider?: ProviderListResponse
+  provider?: NormalizedProviderListResponse
   session: Session[]
   session_status: {
     [sessionID: string]: SessionStatus