From 8be2027a2a380c728ebe82c332c42b0309fd6057 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 29 Jul 2026 06:55:12 +0000 Subject: [PATCH 1/3] fix(app): stabilize server routes and selects --- packages/app/src/app.tsx | 54 +++++++++++++++---- packages/app/src/context/layout.tsx | 6 ++- packages/app/src/context/notification.tsx | 4 +- packages/app/src/context/permission.tsx | 4 +- packages/app/src/context/prompt.tsx | 4 +- packages/app/src/pages/session.tsx | 12 +++-- .../session/timeline/message-timeline.tsx | 13 +++-- packages/app/src/utils/session-route.test.ts | 21 ++++++-- packages/app/src/utils/session-route.ts | 9 +++- .../ui/src/components/select-defer.test.ts | 12 +++++ packages/ui/src/components/select-defer.ts | 5 ++ packages/ui/src/components/select.tsx | 3 +- packages/ui/src/v2/components/select-v2.tsx | 3 +- 13 files changed, 115 insertions(+), 35 deletions(-) create mode 100644 packages/ui/src/components/select-defer.test.ts create mode 100644 packages/ui/src/components/select-defer.ts diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index f47c432e4206..05a8f25c50c0 100644 --- a/packages/app/src/app.tsx +++ b/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( + (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. - + }> {props.children} @@ -135,16 +147,40 @@ const TargetSessionRoute = () => ( ) function LegacyTargetSessionRoute() { - const params = useParams<{ serverKey: string; id: string }>() return ( - - - + ) } +function LegacyTargetSessionRouteContent() { + const params = useParams<{ id: string }>() + const serverSDK = useServerSDK() + return ( + + + + ) +} + +function InvalidServerRoute() { + const language = useLanguage() + const navigate = useNavigate() + return ( +
+
Invalid server route
+ +
+ ) +} + function LegacyTargetSessionRedirect() { const params = useParams<{ id: string }>() const navigate = useNavigate() diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 6235f35c45ae..6d223467f7c3 100644 --- a/packages/app/src/context/layout.tsx +++ b/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, } } diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 9bfa13d854db..2af5e28a8979 100644 --- a/packages/app/src/context/notification.tsx +++ b/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 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 diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index d6d8019262a4..aec10daab93c 100644 --- a/packages/app/src/context/permission.tsx +++ b/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 }) diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 41a99f3b67e1..b215787a0e9b 100644 --- a/packages/app/src/context/prompt.tsx +++ b/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) => { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index c6e3a5fcbb71..8bdf08a133ae 100644 --- a/packages/app/src/pages/session.tsx +++ b/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. params.id}> - + @@ -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), ) }, diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index dc9153536cf2..bdb2992ad93b 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/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), ) } diff --git a/packages/app/src/utils/session-route.test.ts b/packages/app/src/utils/session-route.test.ts index 6a87e366cf8b..20d14fd82bde 100644 --- a/packages/app/src/utils/session-route.test.ts +++ b/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", () => { diff --git a/packages/app/src/utils/session-route.ts b/packages/app/src/utils/session-route.ts index 4721611d2eae..3a85a80e1878 100644 --- a/packages/app/src/utils/session-route.ts +++ b/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, diff --git a/packages/ui/src/components/select-defer.test.ts b/packages/ui/src/components/select-defer.test.ts new file mode 100644 index 000000000000..6f14d999c090 --- /dev/null +++ b/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"]) +}) diff --git a/packages/ui/src/components/select-defer.ts b/packages/ui/src/components/select-defer.ts new file mode 100644 index 000000000000..2fdad59f3e8d --- /dev/null +++ b/packages/ui/src/components/select-defer.ts @@ -0,0 +1,5 @@ +export function deferSelect(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)) +} diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index 67becf2d9c4d..6670f5eb09ad 100644 --- a/packages/ui/src/components/select.tsx +++ b/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 = Omit>, "value" | "onSelect" | "children"> & { placeholder?: string @@ -124,7 +125,7 @@ export function Select(props: SelectProps & Omit) )} onChange={(v) => { - local.onSelect?.(v ?? undefined) + deferSelect(local.onSelect, v ?? undefined) stop() }} onOpenChange={(open) => { diff --git a/packages/ui/src/v2/components/select-v2.tsx b/packages/ui/src/v2/components/select-v2.tsx index 57348ed39209..e0bd26efd6b7 100644 --- a/packages/ui/src/v2/components/select-v2.tsx +++ b/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(options: T[], groupBy?: (x: T) => string): { category: string; options: T[] }[] { @@ -165,7 +166,7 @@ export function SelectV2(props: SelectV2Props) { )} 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) => { From 154d1e7f702af51cb13060e8c334dc7487707f48 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 29 Jul 2026 07:10:14 +0000 Subject: [PATCH 2/3] fix(app): fill invalid route state --- packages/app/src/app.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 05a8f25c50c0..ebc9202ae0c3 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -168,7 +168,7 @@ function InvalidServerRoute() { const language = useLanguage() const navigate = useNavigate() return ( -
+
Invalid server route
-
- ) -} - function LegacyTargetSessionRedirect() { const params = useParams<{ id: string }>() const navigate = useNavigate() diff --git a/packages/app/src/utils/session-route.test.ts b/packages/app/src/utils/session-route.test.ts index 20d14fd82bde..0cc6204ec347 100644 --- a/packages/app/src/utils/session-route.test.ts +++ b/packages/app/src/utils/session-route.test.ts @@ -41,7 +41,7 @@ describe("session routes", () => { expect(parseServerKey(href.split("/")[2])).toBe(server) }) - test("returns an invalid state for malformed server routes", () => { + test("returns undefined for malformed server routes", () => { expect(parseServerKey("not-base64")).toBeUndefined() })