瀏覽代碼

fix(app): stabilize server routes and selects

Brendan Allan 2 周之前
父節點
當前提交
8be2027a2a

+ 45 - 9
packages/app/src/app.tsx

@@ -42,7 +42,7 @@ import { makeEventListener } from "@solid-primitives/event-listener"
 import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
 import { CommentsProvider } from "@/context/comments"
 import { FileProvider } from "@/context/file"
-import { ServerSDKProvider } from "@/context/server-sdk"
+import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
 import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
 import { GlobalProvider, useGlobal } from "@/context/global"
 import { HighlightsProvider } from "@/context/highlights"
@@ -63,7 +63,13 @@ import LegacyLayout from "@/pages/layout"
 import NewLayout from "@/pages/layout-new"
 import { ErrorPage } from "./pages/error"
 import { useCheckServerHealth } from "./utils/server-health"
-import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
+import {
+  legacySessionHref,
+  legacySessionServer,
+  parseServerKey,
+  retainServerKey,
+  sessionHref,
+} from "./utils/session-route"
 import { createSessionLineage } from "@/pages/session/session-lineage"
 
 import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
@@ -111,16 +117,22 @@ const SessionRoute = () => {
 function TargetServerRoute(props: ParentProps) {
   const params = useParams<{ serverKey: string; id: string }>()
   const global = useGlobal()
+  const initial = parseServerKey(params.serverKey)
+  const key = createMemo<ServerConnection.Key | undefined>(
+    (previous) => retainServerKey(previous, params.serverKey),
+    initial,
+  )
   const conn = createMemo(() => {
-    const key = requireServerKey(params.serverKey)
-    return global.servers.list().find((item) => ServerConnection.key(item) === key)
+    const current = key()
+    if (!current) return undefined
+    return global.servers.list().find((item) => ServerConnection.key(item) === current)
   })
 
   return (
     // Owns the server-identity remount. Session changes must NOT remount this
     // subtree (SessionRouteErrorBoundary resets and createSessionLineage
     // re-resolves reactively instead); both rely on this key for server changes.
-    <Show when={requireServerKey(params.serverKey)} keyed>
+    <Show when={key()} keyed fallback={<InvalidServerRoute />}>
       <ServerSDKProvider server={conn}>
         <ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
       </ServerSDKProvider>
@@ -135,16 +147,40 @@ const TargetSessionRoute = () => (
 )
 
 function LegacyTargetSessionRoute() {
-  const params = useParams<{ serverKey: string; id: string }>()
   return (
     <TargetServerRoute>
-      <SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
-        <LegacyTargetSessionRedirect />
-      </SessionRouteErrorBoundary>
+      <LegacyTargetSessionRouteContent />
     </TargetServerRoute>
   )
 }
 
+function LegacyTargetSessionRouteContent() {
+  const params = useParams<{ id: string }>()
+  const serverSDK = useServerSDK()
+  return (
+    <SessionRouteErrorBoundary sessionID={params.id} serverKey={ServerConnection.key(serverSDK().server)}>
+      <LegacyTargetSessionRedirect />
+    </SessionRouteErrorBoundary>
+  )
+}
+
+function InvalidServerRoute() {
+  const language = useLanguage()
+  const navigate = useNavigate()
+  return (
+    <div class="flex-1 min-h-0 flex flex-col items-center justify-center gap-3 text-center">
+      <div class="text-16-medium text-text-strong">Invalid server route</div>
+      <button
+        type="button"
+        class="text-13-regular text-text-interactive-base"
+        onClick={() => navigate("/", { replace: true })}
+      >
+        {language.t("home.title")}
+      </button>
+    </div>
+  )
+}
+
 function LegacyTargetSessionRedirect() {
   const params = useParams<{ id: string }>()
   const navigate = useNavigate()

+ 4 - 2
packages/app/src/context/layout.tsx

@@ -18,7 +18,7 @@ import { createPathHelpers } from "./file/path"
 import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
 import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
 import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
-import { requireServerKey } from "@/utils/session-route"
+import { parseServerKey } from "@/utils/session-route"
 import { type DraftTab, useTabs } from "./tabs"
 import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs"
 
@@ -138,10 +138,12 @@ export const currentRoute = (pathname: string, search: string): LayoutRoute => {
   }
 
   if (parts[0] === "server" && parts[2] === "session" && parts[3]) {
+    const server = parseServerKey(parts[1])
+    if (!server) return { type: "home" }
     return {
       type: "session",
       sessionId: parts[3],
-      server: requireServerKey(parts[1]),
+      server,
     }
   }
 

+ 2 - 2
packages/app/src/context/notification.tsx

@@ -15,7 +15,7 @@ import { playSoundById } from "@/utils/sound"
 import { useGlobal } from "./global"
 import { ServerConnection, useServer } from "./server"
 import { type DraftTab, useTabs } from "./tabs"
-import { requireServerKey } from "@/utils/session-route"
+import { parseServerKey } from "@/utils/session-route"
 import type { ServerScope } from "@/utils/server-scope"
 
 type NotificationBase = {
@@ -125,7 +125,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
     const states = new Map<ServerScope, { dispose: () => void; state: NotificationState }>()
 
     const activeServer = createMemo(() => {
-      if (params.serverKey) return requireServerKey(params.serverKey)
+      if (params.serverKey) return parseServerKey(params.serverKey) ?? server.key
       if (search.draftId) {
         const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
         if (draft) return draft.server

+ 2 - 2
packages/app/src/context/permission.tsx

@@ -11,7 +11,7 @@ import { useGlobal } from "./global"
 import { ServerConnection, useServer } from "./server"
 import { type DraftTab, useTabs } from "./tabs"
 import { useSettings } from "./settings"
-import { requireServerKey } from "@/utils/session-route"
+import { parseServerKey } from "@/utils/session-route"
 import type { ServerScope } from "@/utils/server-scope"
 import { normalizePermissionRequest } from "./global-sync/utils"
 import {
@@ -71,7 +71,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
     })
 
     const activeServer = createMemo(() => {
-      if (params.serverKey && settings.general.newLayoutDesigns()) return requireServerKey(params.serverKey)
+      if (params.serverKey && settings.general.newLayoutDesigns()) return parseServerKey(params.serverKey) ?? server.key
       return activeDraft()?.server ?? server.key
     })
 

+ 1 - 3
packages/app/src/context/prompt.tsx

@@ -2,7 +2,6 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useParams, useSearchParams } from "@solidjs/router"
 import { createMemo, createResource, createRoot, getOwner, onCleanup } from "solid-js"
-import { requireServerKey } from "@/utils/session-route"
 import { ServerConnection } from "./server"
 import { useServerSDK } from "./server-sdk"
 import { useSettings } from "./settings"
@@ -100,8 +99,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
     }
 
     const owner = getOwner()
-    const serverKey = () =>
-      params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
+    const serverKey = () => ServerConnection.key(serverSDK().server)
     const scope = (): PromptScope =>
       search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
     const load = (scope: PromptScope) => {

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

@@ -99,7 +99,7 @@ import { diffs as list } from "@/utils/diffs"
 import { Persist, persisted } from "@/utils/persist"
 import { extractPromptFromParts } from "@/utils/prompt"
 import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
-import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
+import { legacySessionHref, sessionHref } from "@/utils/session-route"
 import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
 import { createSessionOwnership } from "./session/session-ownership"
 import { createSessionLineage } from "./session/session-lineage"
@@ -158,6 +158,7 @@ export function SessionPage() {
 // workspace-scoped state (terminal, directory providers) lives below.
 export function TargetSessionRouteContent() {
   const params = useParams<{ serverKey: string; id: string }>()
+  const serverSDK = useServerSDK()
   const serverSync = useServerSync()
   const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.directory)
   return (
@@ -165,7 +166,7 @@ export function TargetSessionRouteContent() {
     // when session content falls back to the route error boundary.
     <TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
       <TargetSessionSettingsCommand />
-      <SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
+      <SessionRouteErrorBoundary sessionID={params.id} serverKey={ServerConnection.key(serverSDK().server)} padded>
         <ResolvedTargetSessionRoute />
       </SessionRouteErrorBoundary>
     </TargetServerScopedProviders>
@@ -244,10 +245,11 @@ function SessionErrorFallback(props: { error: unknown; sessionID?: string; serve
 }
 
 function ResolvedTargetSessionRoute() {
-  const params = useParams<{ serverKey: string; id: string }>()
+  const params = useParams<{ id: string }>()
+  const serverSDK = useServerSDK()
   const tabs = useTabs()
   const sync = useServerSync()
-  const serverKey = createMemo(() => requireServerKey(params.serverKey))
+  const serverKey = createMemo(() => ServerConnection.key(serverSDK().server))
   const current = createSessionLineage(
     () => params.id,
     () => sync().session.lineage,
@@ -2161,7 +2163,7 @@ export default function Page() {
               if (!id) return
               navigate(
                 params.serverKey
-                  ? sessionHref(requireServerKey(params.serverKey), id)
+                  ? sessionHref(ServerConnection.key(serverSDK().server), id)
                   : legacySessionHref(sdk().directory, id),
               )
             },

+ 9 - 4
packages/app/src/pages/session/timeline/message-timeline.tsx

@@ -63,10 +63,11 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useLanguage } from "@/context/language"
 import { useSessionKey } from "@/pages/session/session-layout"
 import { useServerSDK } from "@/context/server-sdk"
+import { ServerConnection } from "@/context/server"
 import { usePlatform } from "@/context/platform"
 import { useSettings } from "@/context/settings"
 import { useTabs } from "@/context/tabs"
-import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
+import { legacySessionHref, sessionHref } from "@/utils/session-route"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
 import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
@@ -793,7 +794,9 @@ export function MessageTimeline(props: {
   const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
     if (params.id !== sessionID) return
     const href = (id: string) =>
-      params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id)
+      params.serverKey
+        ? sessionHref(ServerConnection.key(serverSDK().server), id)
+        : legacySessionHref(sdk().directory, id)
     if (parentID) {
       navigate(href(parentID))
       return
@@ -803,7 +806,7 @@ export function MessageTimeline(props: {
       return
     }
     if (params.serverKey) {
-      tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
+      tabs.newDraft({ server: ServerConnection.key(serverSDK().server), directory: sdk().directory })
       return
     }
     navigate(`/${params.dir}/session`)
@@ -907,7 +910,9 @@ export function MessageTimeline(props: {
     const id = parentID()
     if (!id) return
     navigate(
-      params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id),
+      params.serverKey
+        ? sessionHref(ServerConnection.key(serverSDK().server), id)
+        : legacySessionHref(sdk().directory, id),
     )
   }
 

+ 17 - 4
packages/app/src/utils/session-route.test.ts

@@ -1,6 +1,13 @@
 import { describe, expect, test } from "bun:test"
 import { ServerConnection } from "@/context/server"
-import { legacySessionHref, legacySessionServer, requireServerKey, rootSession, sessionHref } from "./session-route"
+import {
+  legacySessionHref,
+  legacySessionServer,
+  parseServerKey,
+  retainServerKey,
+  rootSession,
+  sessionHref,
+} from "./session-route"
 
 describe("session routes", () => {
   test("uses the unique persisted server for a legacy session route", () => {
@@ -31,11 +38,17 @@ describe("session routes", () => {
     const href = sessionHref(server, "session-1")
 
     expect(href).toBe("/server/aHR0cHM6Ly9leGFtcGxlLmNvbTo0MDk2/session/session-1")
-    expect(requireServerKey(href.split("/")[2])).toBe(server)
+    expect(parseServerKey(href.split("/")[2])).toBe(server)
   })
 
-  test("rejects malformed server keys", () => {
-    expect(() => requireServerKey("not-base64")).toThrow("Invalid server route")
+  test("returns an invalid state for malformed server routes", () => {
+    expect(parseServerKey("not-base64")).toBeUndefined()
+  })
+
+  test("retains the current server while a route is being removed", () => {
+    const server = ServerConnection.Key.make("https://example.com:4096")
+    expect(retainServerKey(server, undefined)).toBe(server)
+    expect(retainServerKey(server, "not-base64")).toBeUndefined()
   })
 
   test("builds the legacy directory-keyed route", () => {

+ 7 - 2
packages/app/src/utils/session-route.ts

@@ -10,12 +10,17 @@ export function legacySessionHref(directory: string, sessionID: string) {
   return `/${base64Encode(directory)}/session/${sessionID}`
 }
 
-export function requireServerKey(segment: string | undefined) {
+export function parseServerKey(segment: string | undefined) {
   const key = decode64(segment)
-  if (!key || base64Encode(key) !== segment) throw new Error("Invalid server route")
+  if (!key || base64Encode(key) !== segment) return undefined
   return ServerConnection.Key.make(key)
 }
 
+export function retainServerKey(previous: ServerConnection.Key | undefined, segment: string | undefined) {
+  if (segment === undefined) return previous
+  return parseServerKey(segment)
+}
+
 export function legacySessionServer(
   tabs: readonly { type: "session"; server: ServerConnection.Key; sessionId: string }[],
   sessionID: string,

+ 12 - 0
packages/ui/src/components/select-defer.test.ts

@@ -0,0 +1,12 @@
+import { expect, test } from "bun:test"
+import { deferSelect } from "./select-defer"
+
+test("defers controlled select updates until the current selection closes", async () => {
+  const order: string[] = []
+  deferSelect(() => order.push("update"), "theme")
+  order.push("close")
+
+  expect(order).toEqual(["close"])
+  await Promise.resolve()
+  expect(order).toEqual(["close", "update"])
+})

+ 5 - 0
packages/ui/src/components/select-defer.ts

@@ -0,0 +1,5 @@
+export function deferSelect<T>(callback: ((value: T) => void) | undefined, value: T) {
+  // Kobalte closes the popup after onChange returns. Controlled updates must wait
+  // so they cannot rebuild the value or options during portal cleanup.
+  queueMicrotask(() => callback?.(value))
+}

+ 2 - 1
packages/ui/src/components/select.tsx

@@ -3,6 +3,7 @@ import { createMemo, onCleanup, splitProps, type ComponentProps, type JSX } from
 import { pipe, groupBy, entries, map } from "remeda"
 import { Button, ButtonProps } from "./button"
 import { Icon } from "./icon"
+import { deferSelect } from "./select-defer"
 
 export type SelectProps<T> = Omit<ComponentProps<typeof Kobalte<T>>, "value" | "onSelect" | "children"> & {
   placeholder?: string
@@ -124,7 +125,7 @@ export function Select<T>(props: SelectProps<T> & Omit<ButtonProps, "children">)
         </Kobalte.Item>
       )}
       onChange={(v) => {
-        local.onSelect?.(v ?? undefined)
+        deferSelect(local.onSelect, v ?? undefined)
         stop()
       }}
       onOpenChange={(open) => {

+ 2 - 1
packages/ui/src/v2/components/select-v2.tsx

@@ -1,5 +1,6 @@
 import { Select as Kobalte } from "@kobalte/core/select"
 import { Show, createMemo, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js"
+import { deferSelect } from "../../components/select-defer"
 import "./select-v2.css"
 
 function groupOptions<T>(options: T[], groupBy?: (x: T) => string): { category: string; options: T[] }[] {
@@ -165,7 +166,7 @@ export function SelectV2<T>(props: SelectV2Props<T>) {
       )}
       onChange={(next) => {
         const v = next == null ? null : Array.isArray(next) ? ((next[0] as T) ?? null) : (next as T)
-        local.onSelect?.(v)
+        deferSelect(local.onSelect, v)
         stop()
       }}
       onOpenChange={(open) => {