Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 28 additions & 9 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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={<Navigate href="/" />}>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
</ServerSDKProvider>
Expand All @@ -135,16 +147,23 @@ 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 LegacyTargetSessionRedirect() {
const params = useParams<{ id: string }>()
const navigate = useNavigate()
Expand Down
6 changes: 4 additions & 2 deletions packages/app/src/context/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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,
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/context/notification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/context/permission.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
})

Expand Down
4 changes: 1 addition & 3 deletions packages/app/src/context/prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) => {
Expand Down
12 changes: 7 additions & 5 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -158,14 +158,15 @@ 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 (
// Settings must keep the target-server SDK, sync, and models context and remain registered
// 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>
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
)
},
Expand Down
13 changes: 9 additions & 4 deletions packages/app/src/pages/session/timeline/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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`)
Expand Down Expand Up @@ -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),
)
}

Expand Down
21 changes: 17 additions & 4 deletions packages/app/src/utils/session-route.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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 undefined 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", () => {
Expand Down
9 changes: 7 additions & 2 deletions packages/app/src/utils/session-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions packages/ui/src/components/select-defer.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 5 additions & 0 deletions packages/ui/src/components/select-defer.ts
Original file line number Diff line number Diff line change
@@ -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))
}
3 changes: 2 additions & 1 deletion packages/ui/src/components/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/v2/components/select-v2.tsx
Original file line number Diff line number Diff line change
@@ -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[] }[] {
Expand Down Expand Up @@ -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) => {
Expand Down
Loading