Compare commits

...
Sign in to create a new pull request.

3 commits

Author SHA1 Message Date
Brendan Allan
366614537e fix(app): redirect invalid server routes 2026-07-29 07:12:13 +00:00
Brendan Allan
154d1e7f70 fix(app): fill invalid route state 2026-07-29 07:10:14 +00:00
Brendan Allan
8be2027a2a fix(app): stabilize server routes and selects 2026-07-29 06:55:12 +00:00
13 changed files with 98 additions and 35 deletions

View file

@ -42,7 +42,7 @@ import { makeEventListener } from "@solid-primitives/event-listener"
import { CommandProvider, useCommand, type CommandOption } from "@/context/command" import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
import { CommentsProvider } from "@/context/comments" import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file" 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 { ServerSyncProvider, useServerSync } from "@/context/server-sync"
import { GlobalProvider, useGlobal } from "@/context/global" import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights" import { HighlightsProvider } from "@/context/highlights"
@ -63,7 +63,13 @@ import LegacyLayout from "@/pages/layout"
import NewLayout from "@/pages/layout-new" import NewLayout from "@/pages/layout-new"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health" 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 { createSessionLineage } from "@/pages/session/session-lineage"
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session" import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
@ -111,16 +117,22 @@ const SessionRoute = () => {
function TargetServerRoute(props: ParentProps) { function TargetServerRoute(props: ParentProps) {
const params = useParams<{ serverKey: string; id: string }>() const params = useParams<{ serverKey: string; id: string }>()
const global = useGlobal() const global = useGlobal()
const initial = parseServerKey(params.serverKey)
const key = createMemo<ServerConnection.Key | undefined>(
(previous) => retainServerKey(previous, params.serverKey),
initial,
)
const conn = createMemo(() => { const conn = createMemo(() => {
const key = requireServerKey(params.serverKey) const current = key()
return global.servers.list().find((item) => ServerConnection.key(item) === key) if (!current) return undefined
return global.servers.list().find((item) => ServerConnection.key(item) === current)
}) })
return ( return (
// Owns the server-identity remount. Session changes must NOT remount this // Owns the server-identity remount. Session changes must NOT remount this
// subtree (SessionRouteErrorBoundary resets and createSessionLineage // subtree (SessionRouteErrorBoundary resets and createSessionLineage
// re-resolves reactively instead); both rely on this key for server changes. // re-resolves reactively instead); both rely on this key for server changes.
<Show when={requireServerKey(params.serverKey)} keyed> <Show when={key()} keyed fallback={<Navigate href="/" />}>
<ServerSDKProvider server={conn}> <ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider> <ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
</ServerSDKProvider> </ServerSDKProvider>
@ -135,13 +147,20 @@ const TargetSessionRoute = () => (
) )
function LegacyTargetSessionRoute() { function LegacyTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
return ( return (
<TargetServerRoute> <TargetServerRoute>
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}> <LegacyTargetSessionRouteContent />
</TargetServerRoute>
)
}
function LegacyTargetSessionRouteContent() {
const params = useParams<{ id: string }>()
const serverSDK = useServerSDK()
return (
<SessionRouteErrorBoundary sessionID={params.id} serverKey={ServerConnection.key(serverSDK().server)}>
<LegacyTargetSessionRedirect /> <LegacyTargetSessionRedirect />
</SessionRouteErrorBoundary> </SessionRouteErrorBoundary>
</TargetServerRoute>
) )
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,13 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/server" 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", () => { describe("session routes", () => {
test("uses the unique persisted server for a legacy session route", () => { test("uses the unique persisted server for a legacy session route", () => {
@ -31,11 +38,17 @@ describe("session routes", () => {
const href = sessionHref(server, "session-1") const href = sessionHref(server, "session-1")
expect(href).toBe("/server/aHR0cHM6Ly9leGFtcGxlLmNvbTo0MDk2/session/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", () => { test("returns undefined for malformed server routes", () => {
expect(() => requireServerKey("not-base64")).toThrow("Invalid server route") 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", () => { test("builds the legacy directory-keyed route", () => {

View file

@ -10,12 +10,17 @@ export function legacySessionHref(directory: string, sessionID: string) {
return `/${base64Encode(directory)}/session/${sessionID}` return `/${base64Encode(directory)}/session/${sessionID}`
} }
export function requireServerKey(segment: string | undefined) { export function parseServerKey(segment: string | undefined) {
const key = decode64(segment) 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) 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( export function legacySessionServer(
tabs: readonly { type: "session"; server: ServerConnection.Key; sessionId: string }[], tabs: readonly { type: "session"; server: ServerConnection.Key; sessionId: string }[],
sessionID: string, sessionID: string,

View file

@ -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"])
})

View file

@ -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))
}

View file

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

View file

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