feat(app): add server-keyed session routes (#32570)

This commit is contained in:
Brendan Allan 2026-06-23 14:19:18 +08:00 committed by GitHub
commit 0c4f508c50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 630 additions and 378 deletions

View file

@ -21,6 +21,7 @@ const words = [
"vector", "vector",
] ]
const serverKey = "http://127.0.0.1:4096"
const sourceID = "ses_smoke_source" const sourceID = "ses_smoke_source"
const targetID = "ses_smoke_target" const targetID = "ses_smoke_target"
const directory = "C:/OpenCode/SmokeProject" const directory = "C:/OpenCode/SmokeProject"
@ -240,6 +241,7 @@ function orderedParts(message: Message) {
export const fixture = { export const fixture = {
directory, directory,
serverKey,
project: { project: {
id: projectID, id: projectID,
worktree: directory, worktree: directory,

View file

@ -718,7 +718,8 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin
} }
async function switchTitlebarSession(page: Page, sessionID: string, title: string) { async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
const href = `/${base64Encode(fixture.directory)}/session/${sessionID}` console.log(process.env)
const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first() const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
await expect(tab).toBeVisible() await expect(tab).toBeVisible()
await tab.click() await tab.click()

View file

@ -10,7 +10,7 @@ import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router" import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { keepPreviousData, QueryClient, QueryClientProvider, useQuery } from "@tanstack/solid-query"
import { Effect } from "effect" import { Effect } from "effect"
import { import {
type Component, type Component,
@ -30,7 +30,7 @@ import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/context/command" import { CommandProvider } 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 } from "@/context/server-sync" import { ServerSyncProvider } from "@/context/server-sync"
import { GlobalProvider } from "@/context/global" import { GlobalProvider } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights" import { HighlightsProvider } from "@/context/highlights"
@ -47,11 +47,14 @@ import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider, useSDK } from "@/context/sdk" import { SDKProvider, useSDK } from "@/context/sdk"
import { WslServersProvider } from "@/wsl/context" import { WslServersProvider } from "@/wsl/context"
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout" import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
import Layout from "@/pages/layout" import LegacyLayout from "@/pages/layout"
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, requireServerKey, rootSession, sessionHref } from "./utils/session-route"
const HomeRoute = lazy(() => import("@/pages/home")) const LegacyHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.LegacyHome })))
const NewHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.NewHome })))
const Session = lazy(() => import("@/pages/session")) const Session = lazy(() => import("@/pages/session"))
const NewSession = lazy(() => import("@/pages/new-session")) const NewSession = lazy(() => import("@/pages/new-session"))
@ -64,6 +67,10 @@ const SessionRoute = Object.assign(
const server = useServer() const server = useServer()
const tabs = useTabs() const tabs = useTabs()
if (params.id && settings.general.newLayoutDesigns()) {
return <Navigate href={sessionHref(server.key, params.id)} />
}
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id) // When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
// is replaced by a draft at /new-session?draftId=… // is replaced by a draft at /new-session?draftId=…
createEffect(() => { createEffect(() => {
@ -82,29 +89,55 @@ const SessionRoute = Object.assign(
{ preload: Session.preload }, { preload: Session.preload },
) )
const TargetSessionRoute = Object.assign(
() => {
const sdk = useSDK()
const serverSDK = useServerSDK()
return (
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
<SessionProviders>
<Session />
</SessionProviders>
</Show>
)
},
{ preload: Session.preload },
)
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected // Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/ // server via ServerKey, then provide the server-scoped shell (Permission/Layout/
// Notification/Models + the visual Layout) for that server. // Notification/Models + the visual Layout) for that server.
function SelectedServerLayout(props: ParentProps) { function SelectedServerProviders(props: ParentProps) {
return ( return (
<ServerKey> <ServerKey>
<ServerSDKProvider> <ServerSDKProvider>
<ServerSyncProvider> <ServerSyncProvider>{props.children}</ServerSyncProvider>
<ServerScopedShell>{props.children}</ServerScopedShell>
</ServerSyncProvider>
</ServerSDKProvider> </ServerSDKProvider>
</ServerKey> </ServerKey>
) )
} }
function LegacyServerLayout(props: ParentProps) {
return (
<SelectedServerProviders>
<LegacyServerScopedShell>{props.children}</LegacyServerScopedShell>
</SelectedServerProviders>
)
}
// Wraps /new-session. It resolves the draft's target server and provides the // Wraps /new-session. It resolves the draft's target server and provides the
// server-scoped shell for that server — without ServerKey, so the page never depends // server-scoped shell for that server — without ServerKey, so the page never depends
// on the globally "selected" server. // on the globally "selected" server.
function DraftServerLayout(props: ParentProps) { function TargetServerLayout(props: ParentProps) {
const server = useServer() const server = useServer()
const tabs = useTabs() const tabs = useTabs()
const params = useParams<{ serverKey?: string }>()
const [search] = useSearchParams<{ draftId?: string }>() const [search] = useSearchParams<{ draftId?: string }>()
const conn = createMemo(() => { const conn = createMemo(() => {
if (params.serverKey) {
const key = requireServerKey(params.serverKey)
return server.list.find((item) => ServerConnection.key(item) === key)
}
const id = search.draftId const id = search.draftId
if (!id) return undefined if (!id) return undefined
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id) const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id)
@ -115,48 +148,98 @@ function DraftServerLayout(props: ParentProps) {
return ( return (
<ServerSDKProvider server={conn}> <ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}> <ServerSyncProvider server={conn}>
<ServerScopedShell>{props.children}</ServerScopedShell> <TargetDirectoryLayout>{props.children}</TargetDirectoryLayout>
</ServerSyncProvider> </ServerSyncProvider>
</ServerSDKProvider> </ServerSDKProvider>
) )
} }
function TargetDirectoryLayout(props: ParentProps) {
const params = useParams<{ serverKey?: string; id?: string }>()
const [search] = useSearchParams<{ draftId?: string }>()
const settings = useSettings()
const tabs = useTabs()
const serverSDK = useServerSDK()
const serverKey = createMemo(() => {
if (params.serverKey) return requireServerKey(params.serverKey)
if (!search.draftId) return undefined
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.server
})
const resolved = useQuery(() => ({
queryKey: [serverSDK().scope, "session-route", params.id] as const,
enabled: !!params.serverKey && !!params.id,
placeholderData: keepPreviousData,
queryFn: async () => {
const session = (await serverSDK().client.session.get({ sessionID: params.id! })).data!
const root = await rootSession(session, (sessionID) =>
serverSDK()
.client.session.get({ sessionID })
.then((result) => result.data!),
)
return { session, rootID: root.id }
},
}))
const resolvedDirectory = createMemo(() => {
if (params.serverKey) return resolved.data?.session.directory
if (!search.draftId) return undefined
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.directory
})
const directory = createMemo<string | undefined>((prev) => prev ?? resolvedDirectory())
const home = () => !params.serverKey && !search.draftId
const targetDirectory = () => directory()!
createEffect(() => {
const current = resolved.data
const key = serverKey()
if (!current || !key) return
tabs.addSessionTab({
server: key,
sessionId: current.rootID,
})
})
return (
<NewServerScopedShell directory={() => (home() ? undefined : directory())} sessionID={() => params.id}>
<Show when={!home()} fallback={props.children}>
<Show when={!resolved.error} fallback={<ErrorPage error={resolved.error} />}>
<Show when={directory()}>
<Show
when={!params.serverKey || settings.general.newLayoutDesigns()}
fallback={<Navigate href={legacySessionHref(directory()!, params.id!)} />}
>
<SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<Show when={!params.serverKey || (resolved.data && !resolved.isPlaceholderData)}>
{props.children}
</Show>
</DirectoryDataProvider>
</SDKProvider>
</Show>
</Show>
</Show>
</Show>
</NewServerScopedShell>
)
}
function DraftRoute() { function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>() const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs() const tabs = useTabs()
return ( return (
<Show when={tabs.ready()}> <Show when={tabs.ready()}>
<Show when={search.draftId} keyed fallback={<Navigate href="/" />}> <Show when={search.draftId} keyed fallback={<Navigate href="/" />}>
{(draftID) => <ResolvedDraftRoute draftID={draftID} />} <ResolvedDraftRoute />
</Show> </Show>
</Show> </Show>
) )
} }
function ResolvedDraftRoute(props: { draftID: string }) { function ResolvedDraftRoute() {
const tabs = useTabs()
const draft = createMemo(() =>
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
)
// Key on the directory so retargeting the draft's project re-instantiates the
// directory-scoped providers while keeping the same draft id. The draft's target
// server is provided by DraftServerLayout, so changing only the server updates the
// SDK/sync hooks without remounting the composer.
const directory = () => draft()?.directory
return ( return (
<Show when={directory()} keyed> <DraftProviders>
{(dir) => ( <NewSession />
<SDKProvider directory={dir}> </DraftProviders>
<DirectoryDataProvider directory={dir} draftID={props.draftID}>
<DraftProviders>
<NewSession />
</DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
)}
</Show>
) )
} }
@ -210,32 +293,51 @@ function BodyDesignClass() {
// shell (router root) so they stay mounted regardless of the active server/route. // shell (router root) so they stay mounted regardless of the active server/route.
function SharedProviders(props: ParentProps) { function SharedProviders(props: ParentProps) {
return ( return (
<SettingsProvider> <>
<BodyDesignClass /> <BodyDesignClass />
<CommandProvider> <CommandProvider>
<HighlightsProvider>{props.children}</HighlightsProvider> <HighlightsProvider>{props.children}</HighlightsProvider>
</CommandProvider> </CommandProvider>
</SettingsProvider> </>
) )
} }
// Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside // Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside
// each per-route server layout so they resolve to that route's server (selected vs // each per-route server layout so they resolve to that route's server (selected vs
// draft). The Layout remounts when crossing between those groups. // draft). The Layout remounts when crossing between those groups.
function ServerScopedShell(props: ParentProps) { type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined
sessionID?: () => string | undefined
}>
function ServerScopedProviders(props: ServerScopedShellProps) {
return ( return (
<PermissionProvider> <PermissionProvider directory={props.directory}>
<LayoutProvider> <LayoutProvider>
<NotificationProvider> <NotificationProvider directory={props.directory} sessionID={props.sessionID}>
<ModelsProvider> <ModelsProvider>{props.children}</ModelsProvider>
<Layout>{props.children}</Layout>
</ModelsProvider>
</NotificationProvider> </NotificationProvider>
</LayoutProvider> </LayoutProvider>
</PermissionProvider> </PermissionProvider>
) )
} }
function LegacyServerScopedShell(props: ServerScopedShellProps) {
return (
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
<LegacyLayout>{props.children}</LegacyLayout>
</ServerScopedProviders>
)
}
function NewServerScopedShell(props: ServerScopedShellProps) {
return (
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
<NewLayout>{props.children}</NewLayout>
</ServerScopedProviders>
)
}
function SessionProviders(props: ParentProps) { function SessionProviders(props: ParentProps) {
return ( return (
<TerminalProvider> <TerminalProvider>
@ -439,28 +541,61 @@ export function AppInterface(props: {
servers={props.servers} servers={props.servers}
> >
<GlobalProvider> <GlobalProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}> <SettingsProvider>
<Dynamic <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
component={props.router ?? Router} <Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
root={(routerProps) => ( <Dynamic
<TabsProvider> component={props.router ?? Router}
<ServerShell>{routerProps.children}</ServerShell> root={(routerProps) => (
</TabsProvider> <TabsProvider>
)} <ServerShell>{routerProps.children}</ServerShell>
> </TabsProvider>
<Route component={SelectedServerLayout}> )}
<Route path="/" component={HomeRoute} /> >
<Route path="/:dir" component={DirectoryLayout}> <Routes />
<Route path="/" component={() => <Navigate href="session" />} /> </Dynamic>
<Route path="/session/:id?" component={SessionRoute} /> </Show>
</Route> </ConnectionGate>
</Route> </SettingsProvider>
<Route component={DraftServerLayout}>
<Route path="/new-session" component={DraftRoute} />
</Route>
</Dynamic>
</ConnectionGate>
</GlobalProvider> </GlobalProvider>
</ServerProvider> </ServerProvider>
) )
} }
function Routes() {
const settings = useSettings()
return (
<>
<Route component={LegacyServerLayout}>
<Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Route>
<Route component={TargetServerLayout}>
<Show when={settings.general.newLayoutDesigns()}>
{
<>
<Route path="/" component={NewHome} />
<Route path="/:dir" component={DirectoryLayout}>
<Route
path="/session/:id"
component={() => {
const server = useServer()
const { id } = useParams()
return <Navigate href={`/server/${server.key}/session/${id}`} />
}}
/>
</Route>
</>
}
</Show>
<Route path="/new-session" component={DraftRoute} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
</Route>
</>
)
}

View file

@ -388,12 +388,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
local.session.promote(sessionDirectory, session.id) local.session.promote(sessionDirectory, session.id)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
const draftID = search.draftId const draftID = search.draftId
if (draftID) if (draftID) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id })
tabs.promoteDraft(draftID, {
server: server.key,
dirBase64: base64Encode(sessionDirectory),
sessionId: session.id,
})
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
} }
} }

View file

@ -29,7 +29,6 @@ import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { WindowsAppMenu } from "./windows-app-menu" import { WindowsAppMenu } from "./windows-app-menu"
import { applyPath, backPath, forwardPath } from "./titlebar-history" import { applyPath, backPath, forwardPath } from "./titlebar-history"
import { useServerSync } from "@/context/server-sync"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers" import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
@ -38,10 +37,11 @@ import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events" import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { decode64 } from "@/utils/base64"
import { ServerConnection, useServer } from "@/context/server" import { ServerConnection, useServer } from "@/context/server"
import { tabHref, useTabs, type Tab } from "@/context/tabs" import { tabHref, useTabs } from "@/context/tabs"
import "./titlebar.css" import "./titlebar.css"
import { useServerSDK } from "@/context/server-sdk"
import { Session } from "@opencode-ai/sdk/v2"
type TauriDesktopWindow = { type TauriDesktopWindow = {
startDragging?: () => Promise<void> startDragging?: () => Promise<void>
@ -252,7 +252,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<Switch> <Switch>
<Match when={useV2Titlebar()}> <Match when={useV2Titlebar()}>
{(_) => { {(_) => {
const serverSync = useServerSync() const serverSdk = useServerSDK()
const navigate = useNavigate() const navigate = useNavigate()
const layout = useLayout() const layout = useLayout()
@ -268,6 +268,17 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const tabs = useTabs() const tabs = useTabs()
const tabsStore = tabs.store const tabsStore = tabs.store
const tabsStoreActions = tabs const tabsStoreActions = tabs
const [session] = createResource(
() => {
const route = layout.route()
return route.type === "session" ? route : undefined
},
(route) =>
serverSdk()
.client.session.get({ sessionID: route.sessionId })
.then((x) => x.data)
.catch(() => {}),
)
const matchRoute = (route: LayoutRoute) => { const matchRoute = (route: LayoutRoute) => {
if (route.type === "home") return if (route.type === "home") return
@ -280,10 +291,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId, item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
) )
if (main) return main if (main) return main
const sync = serverSync().createDirSyncContext(route.dir) const s = session()
const session = sync.session.get(route.sessionId) if (s?.parentID) {
if (session?.parentID) { const parentID = s.parentID
const parentID = session.parentID
const parent = tabsStore.find( const parent = tabsStore.find(
(item) => item.type === "session" && item.server === route.server && item.sessionId === parentID, (item) => item.type === "session" && item.server === route.server && item.sessionId === parentID,
) )
@ -304,15 +314,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
} }
if (route.type === "session") { if (route.type === "session") {
const sync = serverSync().createDirSyncContext(route.dir) const s = session()
const session = sync.session.get(route.sessionId) if (!s) return
if (!session) return const sessionId = s.parentID ?? s.id
const sessionId = session.parentID ?? session.id const next = { server: route.server ?? server.key, sessionId }
const next = {
server: route.server ?? server.key,
dirBase64: route.dirBase64,
sessionId,
}
tabsStoreActions.addSessionTab(next) tabsStoreActions.addSessionTab(next)
} }
}) })
@ -495,25 +500,38 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
) )
} }
const [session] = createResource(
() => tab.sessionId,
(sessionID) =>
serverSdk()
.client.session.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined),
)
return ( return (
<> <>
{divider()} {divider()}
<TabNavItem <Show when={session()}>
ref={ref} {(session) => (
href={tabHref(tab)} <TabNavItem
server={tab.server} ref={ref}
directory={decode64(tab.dirBase64)!} href={tabHref(tab)}
sessionId={tab.sessionId} server={tab.server}
onNavigate={() => { sessionId={tab.sessionId}
tabs.select(tab) session={session()}
onNavigate={() => {
tabs.select(tab)
ref.scrollIntoView({ behavior: "instant" }) ref.scrollIntoView({ behavior: "instant" })
}} }}
onClose={() => tabsStoreActions.removeTab(i())} onClose={() => tabsStoreActions.removeTab(i())}
active={currentTab() === tab} active={currentTab() === tab}
activeServer={tab.server === server.key} activeServer={tab.server === server.key}
forceTruncate={tabsAreOverflowing()} forceTruncate={tabsAreOverflowing()}
/> />
)}
</Show>
</> </>
) )
}} }}
@ -793,7 +811,6 @@ function TabNavItem(props: {
ref?: HTMLDivElement ref?: HTMLDivElement
href: string href: string
server: ServerConnection.Key server: ServerConnection.Key
directory: string
sessionId?: string sessionId?: string
hideClose?: boolean hideClose?: boolean
onClose: () => void onClose: () => void
@ -801,31 +818,19 @@ function TabNavItem(props: {
active?: boolean active?: boolean
activeServer: boolean activeServer: boolean
forceTruncate?: boolean forceTruncate?: boolean
session: Session
}) { }) {
const closeTab = (event: MouseEvent) => { const closeTab = (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
props.onClose() props.onClose()
} }
const global = useGlobal() const global = useGlobal()
const serverCtx = createMemo(() => { const serverCtx = createMemo(() => {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server) const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
if (conn) return global.createServerCtx(conn) if (conn) return global.createServerCtx(conn)
}) })
const dirSyncCtx = createMemo(() => serverCtx()?.sync.createDirSyncContext(props.directory))
const [session] = createResource(
() => {
const ctx = dirSyncCtx()
if (!ctx || !props.sessionId) return
return [props.sessionId, ctx] as const
},
async ([sessionId, dirSyncCtx]) => {
await dirSyncCtx.session.sync(sessionId).catch(() => {})
return dirSyncCtx.session.get(sessionId)
},
{ initialValue: props.sessionId ? dirSyncCtx()?.session.get(props.sessionId) : undefined },
)
return ( return (
<div <div
@ -837,7 +842,7 @@ function TabNavItem(props: {
closeTab(event) closeTab(event)
}} }}
> >
<Show when={session.latest}> <Show when={props.session}>
{(session) => { {(session) => {
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? [])) const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
@ -853,7 +858,7 @@ function TabNavItem(props: {
<span data-slot="project-avatar-slot"> <span data-slot="project-avatar-slot">
<ProjectTabAvatar <ProjectTabAvatar
project={project()} project={project()}
directory={props.directory} directory={session().directory}
sessionId={session().id} sessionId={session().id}
activeServer={props.activeServer} activeServer={props.activeServer}
/> />

View file

@ -2,12 +2,14 @@ import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "./server-sdk" import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import { createScopedCache } from "@/utils/scoped-cache" import { createScopedCache } from "@/utils/scoped-cache"
import { uuid } from "@/utils/uuid" import { uuid } from "@/utils/uuid"
import type { SelectedLineRange } from "@/context/file" import type { SelectedLineRange } from "@/context/file"
import { useSDK } from "./sdk"
export type LineComment = { export type LineComment = {
id: string id: string
@ -202,6 +204,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
gate: false, gate: false,
init: () => { init: () => {
const params = useParams() const params = useParams()
const sdk = useSDK()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const cache = createScopedCache( const cache = createScopedCache(
(key) => { (key) => {
@ -228,7 +231,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
return cache.get(key).value return cache.get(key).value
} }
const session = createMemo(() => load(params.dir!, params.id)) const session = createMemo(() => load(base64Encode(sdk().directory), params.id))
return { return {
ready: () => session().ready(), ready: () => session().ready(),

View file

@ -3,6 +3,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useSync } from "./sync" import { useSync } from "./sync"
@ -65,7 +66,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const scope = createMemo(() => sdk().directory) const scope = createMemo(() => sdk().directory)
const path = createPathHelpers(scope) const path = createPathHelpers(scope)
const tabs = layout.tabs(() => const tabs = layout.tabs(() =>
SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(params.dir, params.id)), SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(base64Encode(sdk().directory), params.id)),
) )
const inflight = new Map<string, Promise<void>>() const inflight = new Map<string, Promise<void>>()

View file

@ -16,6 +16,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"
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
@ -79,7 +80,7 @@ export type LayoutRoute =
| { type: "home" } | { type: "home" }
| { type: "draft"; draftID: string; server?: ServerConnection.Key } | { type: "draft"; draftID: string; server?: ServerConnection.Key }
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key } | { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
| { type: "session"; dir: string; dirBase64: string; sessionId: string; server?: ServerConnection.Key } | { type: "session"; sessionId: string; server?: ServerConnection.Key }
function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs { function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
const all = current?.all ?? [] const all = current?.all ?? []
@ -131,6 +132,14 @@ const currentRoute = (pathname: string, search: string): LayoutRoute => {
return { type: "draft", draftID } return { type: "draft", draftID }
} }
if (parts[0] === "server" && parts[2] === "session" && parts[3]) {
return {
type: "session",
sessionId: parts[3],
server: requireServerKey(parts[1]),
}
}
const dirBase64 = parts[0] const dirBase64 = parts[0]
const dir = decode64(dirBase64) const dir = decode64(dirBase64)
if (!dir) return { type: "home" } if (!dir) return { type: "home" }
@ -138,7 +147,7 @@ const currentRoute = (pathname: string, search: string): LayoutRoute => {
if (parts[1] !== "session") return { type: "home" } if (parts[1] !== "session") return { type: "home" }
const id = parts[2] const id = parts[2]
if (id) return { type: "session", dir, dirBase64, sessionId: id } if (id) return { type: "session", sessionId: id }
return { type: "dir-new-sesssion", dir, dirBase64 } return { type: "dir-new-sesssion", dir, dirBase64 }
} }
@ -154,6 +163,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const route = createMemo(() => { const route = createMemo(() => {
const value = currentRoute(location.pathname, location.search) const value = currentRoute(location.pathname, location.search)
if (value.type === "home") return value if (value.type === "home") return value
if (value.server) return value
return { ...value, server: server.key } return { ...value, server: server.key }
}) })
@ -572,7 +582,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
handoff: { handoff: {
tabs: createMemo(() => store.handoff?.tabs), tabs: createMemo(() => store.handoff?.tabs),
setTabs(dir: string, id: string) { setTabs(dir: string, id: string) {
setStore("handoff", "tabs", { scope: server.scope(), dir, id, at: Date.now() }) setStore("handoff", "tabs", { scope: serverSdk().scope, dir, id, at: Date.now() })
}, },
clearTabs() { clearTabs() {
if (!store.handoff?.tabs) return if (!store.handoff?.tabs) return

View file

@ -1,5 +1,5 @@
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup } from "solid-js" import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useServerSDK } from "./server-sdk" import { useServerSDK } from "./server-sdk"
@ -108,7 +108,7 @@ function buildNotificationIndex(list: Notification[]) {
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification", name: "Notification",
gate: false, gate: false,
init: () => { init: (props: { directory?: Accessor<string | undefined>; sessionID?: Accessor<string | undefined> }) => {
const params = useParams() const params = useParams()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const serverSync = useServerSync() const serverSync = useServerSync()
@ -119,10 +119,10 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const empty: Notification[] = [] const empty: Notification[] = []
const currentDirectory = createMemo(() => { const currentDirectory = createMemo(() => {
return decode64(params.dir) return props.directory?.() ?? decode64(params.dir)
}) })
const currentSession = createMemo(() => params.id) const currentSession = createMemo(() => props.sessionID?.() ?? params.id)
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]), Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),

View file

@ -1,4 +1,4 @@
import { createEffect, createMemo, onCleanup } from "solid-js" import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
@ -47,13 +47,13 @@ function hasPermissionPromptRules(permission: unknown) {
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({ export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
name: "Permission", name: "Permission",
gate: false, gate: false,
init: () => { init: (props: { directory?: Accessor<string | undefined> }) => {
const params = useParams() const params = useParams()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const serverSync = useServerSync() const serverSync = useServerSync()
const permissionsEnabled = createMemo(() => { const permissionsEnabled = createMemo(() => {
const directory = decode64(params.dir) const directory = props.directory?.() ?? decode64(params.dir)
if (!directory) return false if (!directory) return false
const [store] = serverSync().child(directory) const [store] = serverSync().child(directory)
return hasPermissionPromptRules(store.config.permission) return hasPermissionPromptRules(store.config.permission)
@ -85,7 +85,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
// When config has permission: "allow", auto-enable directory-level auto-accept // When config has permission: "allow", auto-enable directory-level auto-accept
createEffect(() => { createEffect(() => {
if (!ready()) return if (!ready()) return
const directory = decode64(params.dir) const directory = props.directory?.() ?? decode64(params.dir)
if (!directory) return if (!directory) return
const [childStore] = serverSync().child(directory) const [childStore] = serverSync().child(directory)
const perm = childStore.config.permission const perm = childStore.config.permission

View file

@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { checksum } from "@opencode-ai/core/util/encode" import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useParams, useSearchParams } from "@solidjs/router" import { useParams, useSearchParams } from "@solidjs/router"
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store" import { createStore, type SetStoreFunction } from "solid-js/store"
@ -7,6 +7,7 @@ import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "./server-sdk" import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import { useSDK } from "./sdk"
interface PartBase { interface PartBase {
content: string content: string
@ -256,6 +257,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
gate: false, gate: false,
init: () => { init: () => {
const params = useParams() const params = useParams()
const sdk = useSDK()
const [search] = useSearchParams<{ draftId?: string }>() const [search] = useSearchParams<{ draftId?: string }>()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const cache = new Map<string, PromptCacheEntry>() const cache = new Map<string, PromptCacheEntry>()
@ -303,7 +305,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
} }
const session = createMemo(() => const session = createMemo(() =>
load(search.draftId ? { draftID: search.draftId } : { dir: params.dir!, id: params.id }), load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }),
) )
const pick = (scope?: Scope) => (scope ? load(scope) : session()) const pick = (scope?: Scope) => (scope ? load(scope) : session())

View file

@ -1,6 +1,5 @@
import type { Session } from "@opencode-ai/sdk/v2/client" import type { Session } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist" import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
import { ServerConnection, useServer } from "./server" import { ServerConnection, useServer } from "./server"
@ -9,11 +8,11 @@ import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { uuid } from "@/utils/uuid" import { uuid } from "@/utils/uuid"
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events" import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
import { sessionHref } from "@/utils/session-route"
export type SessionTab = { export type SessionTab = {
type: "session" type: "session"
server: ServerConnection.Key server: ServerConnection.Key
dirBase64: string
sessionId: string sessionId: string
} }
@ -34,16 +33,12 @@ type RecentTab = {
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}` export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
export const tabHref = (tab: Tab) => export const tabHref = (tab: Tab) =>
tab.type === "draft" ? draftHref(tab.draftID) : `/${tab.dirBase64}/session/${tab.sessionId}` tab.type === "draft" ? draftHref(tab.draftID) : sessionHref(tab.server, tab.sessionId)
export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`) export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) { export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
const dirBase64 = base64Encode(session.directory) return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id)
return tabs.some(
(tab) =>
tab.type === "session" && tab.server === server && tab.dirBase64 === dirBase64 && tab.sessionId === session.id,
)
} }
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
@ -105,14 +100,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const navigateTab = (tab: Tab) => { const navigateTab = (tab: Tab) => {
const href = tabHref(tab) const href = tabHref(tab)
setRecentKey(tabKey(tab)) setRecentKey(tabKey(tab))
if (tab.server === server.key) { navigate(href)
navigate(href)
return
}
void startTransition(() => {
server.setActive(tab.server)
navigate(href)
})
} }
const actions = { const actions = {
@ -196,10 +184,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const removed = store const removed = store
.filter( .filter(
(tab) => (tab) =>
tab.type === "session" && tab.type === "session" && tab.server === server.key && input.sessionIDs.includes(tab.sessionId),
tab.server === server.key &&
atob(tab.dirBase64) === input.directory &&
input.sessionIDs.includes(tab.sessionId),
) )
.map(tabKey) .map(tabKey)
void startTransition(() => { void startTransition(() => {
@ -211,7 +196,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
? tabHref({ ? tabHref({
type: "session", type: "session",
server: server.key, server: server.key,
dirBase64: params.dir,
sessionId: params.id, sessionId: params.id,
}) })
: undefined : undefined
@ -224,14 +208,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const removedCurrent = const removedCurrent =
currentTab?.type === "session" && currentTab?.type === "session" &&
currentTab.server === server.key && currentTab.server === server.key &&
atob(currentTab.dirBase64) === input.directory &&
sessionIDs.has(currentTab.sessionId) sessionIDs.has(currentTab.sessionId)
for (let i = tabs.length - 1; i >= 0; i--) { for (let i = tabs.length - 1; i >= 0; i--) {
const tab = tabs[i] const tab = tabs[i]
if (!tab || tab.type !== "session") continue if (!tab || tab.type !== "session") continue
if (tab.server !== server.key) continue if (tab.server !== server.key) continue
if (atob(tab.dirBase64) !== input.directory) continue
if (!sessionIDs.has(tab.sessionId)) continue if (!sessionIDs.has(tab.sessionId)) continue
tabs.splice(i, 1) tabs.splice(i, 1)
} }

View file

@ -4,7 +4,8 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { useSDK, type DirectorySDK } from "./sdk" import { useSDK, type DirectorySDK } from "./sdk"
import type { Platform } from "./platform" import type { Platform } from "./platform"
import { useServer } from "./server" import { useServerSDK } from "./server-sdk"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { defaultTitle, titleNumber } from "./terminal-title" import { defaultTitle, titleNumber } from "./terminal-title"
import { Persist, persisted, removePersisted } from "@/utils/persist" import { Persist, persisted, removePersisted } from "@/utils/persist"
import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope" import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope"
@ -374,10 +375,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
gate: false, gate: false,
init: () => { init: () => {
const sdk = useSDK() const sdk = useSDK()
const server = useServer() const serverSDK = useServerSDK()
const params = useParams() const params = useParams()
const cache = new Map<string, TerminalCacheEntry>() const cache = new Map<string, TerminalCacheEntry>()
const scope = server.scope() const scope = () => serverSDK().scope
const directory = createMemo(() => base64Encode(sdk().directory))
caches.add(cache) caches.add(cache)
onCleanup(() => caches.delete(cache)) onCleanup(() => caches.delete(cache))
@ -421,11 +423,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return entry.value return entry.value
} }
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope)) const workspace = createMemo(() => loadWorkspace(directory(), params.id, scope()))
createEffect( createEffect(
on( on(
() => ({ dir: params.dir, id: params.id, scope }), () => ({ dir: directory(), id: params.id, scope: scope() }),
(next, prev) => { (next, prev) => {
if (!prev?.dir) return if (!prev?.dir) return
if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return

View file

@ -2,26 +2,40 @@ import { DataProvider } from "@opencode-ai/ui/context"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js" import { type Accessor, createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { LocalProvider } from "@/context/local" import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk" import { SDKProvider } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { Schema } from "effect" import { Schema } from "effect"
import type { ServerConnection } from "@/context/server"
import { sessionHref } from "@/utils/session-route"
export function DirectoryDataProvider(props: ParentProps<{ directory: string; draftID?: string }>) { export function DirectoryDataProvider(
props: ParentProps<{
directory: string | Accessor<string>
draftID?: string
server?: Accessor<ServerConnection.Key | undefined>
}>,
) {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const params = useParams() const params = useParams()
const sync = useSync() const sync = useSync()
const slug = createMemo(() => base64Encode(props.directory)) const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory)
const slug = createMemo(() => base64Encode(directory()))
const href = (sessionID: string) => {
const server = props.server?.()
if (server) return sessionHref(server, sessionID)
return `/${slug()}/session/${sessionID}`
}
createEffect(() => { createEffect(() => {
// A draft lives at /new-session?draftId=… and has no directory segment to normalize. // A draft lives at /new-session?draftId=… and has no directory segment to normalize.
if (props.draftID) return if (props.draftID || props.server?.()) return
const next = sync().data.path.directory const next = sync().data.path.directory
if (!next || next === props.directory) return if (!next || next === directory()) return
const path = location.pathname.slice(slug().length + 1) const path = location.pathname.slice(slug().length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
}) })
@ -37,9 +51,9 @@ export function DirectoryDataProvider(props: ParentProps<{ directory: string; dr
return ( return (
<DataProvider <DataProvider
data={sync().data} data={sync().data}
directory={props.directory} directory={directory()}
onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)} onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`} onSessionHref={href}
> >
<LocalProvider>{props.children}</LocalProvider> <LocalProvider>{props.children}</LocalProvider>
</DataProvider> </DataProvider>

View file

@ -43,7 +43,6 @@ import { sessionTitle } from "@/utils/session-title"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import { useGlobal } from "@/context/global" import { useGlobal } from "@/context/global"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useSettings } from "@/context/settings"
import { ServerRowMenu } from "@/components/server/server-row-menu" import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row" import { ServerHealthIndicator } from "@/components/server/server-row"
import { type ServerHealth } from "@/utils/server-health" import { type ServerHealth } from "@/utils/server-health"
@ -113,16 +112,7 @@ function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.directory)}:${record.session.id}` return `${pathKey(record.session.directory)}:${record.session.id}`
} }
export default function Home() { export function NewHome() {
const settings = useSettings()
return (
<Show when={settings.general.newLayoutDesigns()} fallback={<LegacyHome />}>
<HomeDesign />
</Show>
)
}
function HomeDesign() {
const sync = useServerSync() const sync = useServerSync()
const layout = useLayout() const layout = useLayout()
const platform = usePlatform() const platform = usePlatform()
@ -313,7 +303,7 @@ function HomeDesign() {
const ctx = global.createServerCtx(conn) const ctx = global.createServerCtx(conn)
ctx.projects.open(directory) ctx.projects.open(directory)
ctx.projects.touch(directory) ctx.projects.touch(directory)
navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`) navigateOnServer(conn, `/server/${base64Encode(ServerConnection.key(conn))}/session/${session.id}`)
} }
function chooseProject(conn: ServerConnection.Any) { function chooseProject(conn: ServerConnection.Any) {
@ -416,7 +406,7 @@ function HomeDesign() {
record={record} record={record}
server={state.selection.server} server={state.selection.server}
activeServer={state.selection.server === server.key} activeServer={state.selection.server === server.key}
openSession={openSession} onClick={() => openSession(record.session)}
/> />
)} )}
</For> </For>
@ -1024,7 +1014,7 @@ function HomeSessionRow(props: {
record: HomeSessionRecord record: HomeSessionRecord
server: ServerConnection.Key server: ServerConnection.Key
activeServer: boolean activeServer: boolean
openSession: (session: Session) => void onClick: () => void
}) { }) {
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
@ -1033,7 +1023,7 @@ function HomeSessionRow(props: {
type="button" type="button"
data-component="home-session-row" data-component="home-session-row"
class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`} class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`}
onClick={() => props.openSession(props.record.session)} onClick={props.onClick}
> >
<HomeSessionLeading <HomeSessionLeading
project={props.record.project} project={props.record.project}
@ -1093,7 +1083,7 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
].filter((group) => group.sessions.length > 0) ].filter((group) => group.sessions.length > 0)
} }
function LegacyHome() { export function LegacyHome() {
const sync = useServerSync() const sync = useServerSync()
const platform = usePlatform() const platform = usePlatform()
const pickDirectory = useDirectoryPicker() const pickDirectory = useDirectoryPicker()

View file

@ -0,0 +1,38 @@
import { createEffect, type ParentProps } from "solid-js"
import { useNavigate } from "@solidjs/router"
import { DebugBar } from "@/components/debug-bar"
import { HelpButton } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click"
import { setV2Toast, ToastRegion } from "@/utils/toast"
export default function NewLayout(props: ParentProps) {
const platform = usePlatform()
const navigate = useNavigate()
setNavigate(navigate)
createEffect(() => setV2Toast(true))
const update: TitlebarUpdate = {
version: () => {
const state = platform.updater?.state()
if (state?.status !== "ready") return
return state.version
},
installing: () => platform.updater?.state().status === "installing",
install: () => void platform.updater?.install(),
}
return (
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<Titlebar update={update} />
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
{props.children}
</main>
{import.meta.env.DEV && <DebugBar />}
<HelpButton />
<ToastRegion v2 />
</div>
)
}

View file

@ -13,7 +13,7 @@ import {
type Accessor, type Accessor,
} from "solid-js" } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { useLayout, LocalProject } from "@/context/layout" import { useLayout, LocalProject } from "@/context/layout"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
@ -92,7 +92,7 @@ import {
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project" import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
import { SidebarContent } from "./layout/sidebar-shell" import { SidebarContent } from "./layout/sidebar-shell"
export default function Layout(props: ParentProps) { export default function LegacyLayout(props: ParentProps) {
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const [store, setStore, , ready] = persisted( const [store, setStore, , ready] = persisted(
Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]), Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]),
@ -131,10 +131,8 @@ export default function Layout(props: ParentProps) {
const command = useCommand() const command = useCommand()
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
const newDesign = createMemo(() => settings.general.newLayoutDesigns()) createEffect(() => setV2Toast(false))
createEffect(() => setV2Toast(newDesign()))
const initialDirectory = decode64(params.dir) const initialDirectory = decode64(params.dir)
const location = useLocation()
const route = createMemo(() => { const route = createMemo(() => {
const slug = params.dir const slug = params.dir
if (!slug) return { slug, dir: "" } if (!slug) return { slug, dir: "" }
@ -158,7 +156,7 @@ export default function Layout(props: ParentProps) {
const currentDir = createMemo(() => route().dir) const currentDir = createMemo(() => route().dir)
const [state, setState] = createStore({ const [state, setState] = createStore({
autoselect: !initialDirectory && !newDesign(), autoselect: !initialDirectory,
busyWorkspaces: {} as Record<string, boolean>, busyWorkspaces: {} as Record<string, boolean>,
hoverProject: undefined as string | undefined, hoverProject: undefined as string | undefined,
scrollSessionKey: undefined as string | undefined, scrollSessionKey: undefined as string | undefined,
@ -996,7 +994,7 @@ export default function Layout(props: ParentProps) {
id: "sidebar.toggle", id: "sidebar.toggle",
title: language.t("command.sidebar.toggle"), title: language.t("command.sidebar.toggle"),
category: language.t("command.category.view"), category: language.t("command.category.view"),
keybind: newDesign() ? undefined : "mod+b", keybind: "mod+b",
onSelect: () => layout.sidebar.toggle(), onSelect: () => layout.sidebar.toggle(),
}, },
{ {
@ -1134,20 +1132,19 @@ export default function Layout(props: ParentProps) {
}, },
] ]
if (!newDesign()) Array.from({ length: 9 }, (_, i) => {
Array.from({ length: 9 }, (_, i) => { const index = i
const index = i const number = index + 1
const number = index + 1 commands.push({
commands.push({ id: `project.${number}`,
id: `project.${number}`, category: language.t("command.category.project"),
category: language.t("command.category.project"), title: `Open Project {number}`,
title: `Open Project {number}`, keybind: `mod+${number}`,
keybind: `mod+${number}`, disabled: layout.projects.list().length <= index,
disabled: layout.projects.list().length <= index, hidden: true,
hidden: true, onSelect: () => navigateToProjectIndex(index),
onSelect: () => navigateToProjectIndex(index),
})
}) })
})
for (const [id] of availableThemeEntries()) { for (const [id] of availableThemeEntries()) {
commands.push({ commands.push({
@ -1812,7 +1809,7 @@ export default function Layout(props: ParentProps) {
createEffect(() => { createEffect(() => {
document.documentElement.style.setProperty( document.documentElement.style.setProperty(
"--dialog-left-margin", "--dialog-left-margin",
newDesign() ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`, `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
) )
}) })
@ -2355,176 +2352,158 @@ export default function Layout(props: ParentProps) {
) )
return ( return (
<Show <div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
when={!newDesign()} {autoselecting() ?? ""}
fallback={ <Titlebar update={titlebarUpdate} />
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"> <Show when={updateVersion() !== undefined}>
{autoselecting() ?? ""} <UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
<Titlebar update={titlebarUpdate} /> </Show>
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict"> <div class="flex-1 min-h-0 min-w-0 flex">
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}> <div class="flex-1 min-h-0 relative">
{props.children} <div class="size-full relative overflow-x-hidden">
</Show> <nav
</main> aria-label={language.t("sidebar.nav.projectsAndSessions")}
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />} data-component="sidebar-nav-desktop"
<HelpButton /> classList={{
<ToastRegion v2={newDesign()} /> "hidden xl:block": true,
</div> "absolute inset-y-0 left-0": true,
} "z-10": true,
> }}
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"> style={{ width: `${side()}px` }}
{autoselecting() ?? ""} ref={(el) => {
<Titlebar update={titlebarUpdate} /> setState("nav", el)
<Show when={updateVersion() !== undefined}> }}
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} /> onMouseEnter={() => {
</Show> disarm()
<div class="flex-1 min-h-0 min-w-0 flex"> }}
<div class="flex-1 min-h-0 relative"> onMouseLeave={() => {
<div class="size-full relative overflow-x-hidden"> aim.reset()
<nav if (!sidebarHovering()) return
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-desktop"
classList={{
"hidden xl:block": true,
"absolute inset-y-0 left-0": true,
"z-10": true,
}}
style={{ width: `${side()}px` }}
ref={(el) => {
setState("nav", el)
}}
onMouseEnter={() => {
disarm()
}}
onMouseLeave={() => {
aim.reset()
if (!sidebarHovering()) return
arm() arm()
}} }}
> >
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div> <div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
</nav> </nav>
<Show when={layout.sidebar.opened()}>
<div
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
style={{ left: `${side()}px` }}
onPointerDown={() => setState("sizing", true)}
>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
/>
</div>
</Show>
<Show when={layout.sidebar.opened()}>
<div <div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base" class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
style={{ left: "calc(4rem + 12px)" }} style={{ left: `${side()}px` }}
/> onPointerDown={() => setState("sizing", true)}
>
<div class="xl:hidden"> <ResizeHandle
<div direction="horizontal"
classList={{ size={layout.sidebar.width()}
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true, min={244}
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(), max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(), onResize={(w) => {
}} setState("sizing", true)
onClick={(e) => { if (sizet !== undefined) clearTimeout(sizet)
if (e.target === e.currentTarget) layout.mobileSidebar.hide() sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}} }}
/> />
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-mobile"
classList={{
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onClick={(e) => e.stopPropagation()}
>
{sidebarContent(true)}
</nav>
</div> </div>
</Show>
<div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }}
/>
<div class="xl:hidden">
<div <div
classList={{ classList={{
"absolute inset-0": true, "fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true, "opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"z-20": true, "opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
!state.sizing,
}} }}
style={{ onClick={(e) => {
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem", if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}} }}
> />
<main <nav
classList={{ aria-label={language.t("sidebar.nav.projectsAndSessions")}
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true, data-component="sidebar-nav-mobile"
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
</div>
<div
classList={{ classList={{
"hidden xl:flex absolute inset-y-0 left-16 z-30": true, "@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(), "translate-x-0": layout.mobileSidebar.opened(),
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(), "-translate-x-full": !layout.mobileSidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}} }}
onMouseMove={disarm} onClick={(e) => e.stopPropagation()}
onMouseEnter={() => { >
disarm() {sidebarContent(true)}
aim.reset() </nav>
}} </div>
onPointerDown={disarm}
onMouseLeave={() => { <div
arm() classList={{
"absolute inset-0": true,
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
"z-20": true,
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
!state.sizing,
}}
style={{
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
}}
>
<main
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
}} }}
> >
<Show when={peekProject()}> <Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
<SidebarPanel project={peekProject} merged={false} /> {props.children}
</Show> </Show>
</div> </main>
</div>
<div <div
classList={{ classList={{
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true, "hidden xl:flex absolute inset-y-0 left-16 z-30": true,
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(), "opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(), "opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true, "transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(), "duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(), "duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}} }}
style={{ left: `calc(4rem + ${panel()}px)` }} onMouseMove={disarm}
> onMouseEnter={() => {
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} /> disarm()
</div> aim.reset()
}}
onPointerDown={disarm}
onMouseLeave={() => {
arm()
}}
>
<Show when={peekProject()}>
<SidebarPanel project={peekProject} merged={false} />
</Show>
</div>
<div
classList={{
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
style={{ left: `calc(4rem + ${panel()}px)` }}
>
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
</div> </div>
</div> </div>
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
</div> </div>
<HelpButton /> {import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
<ToastRegion v2={newDesign()} />
</div> </div>
</Show> <HelpButton />
<ToastRegion v2={false} />
</div>
) )
} }

View file

@ -28,7 +28,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { checksum } from "@opencode-ai/core/util/encode" import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useLocation, useSearchParams } from "@solidjs/router" import { useLocation, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session" import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
@ -56,7 +56,6 @@ import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
import { createTimelineModel } from "@/pages/session/timeline/model" import { createTimelineModel } from "@/pages/session/timeline/model"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { useServer } from "@/context/server"
import { syncSessionModel } from "@/pages/session/session-model-helpers" import { syncSessionModel } from "@/pages/session/session-model-helpers"
import { SessionSidePanel } from "@/pages/session/session-side-panel" import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel" import { TerminalPanel } from "@/pages/session/terminal-panel"
@ -92,7 +91,6 @@ export default function Page() {
const prompt = usePrompt() const prompt = usePrompt()
const comments = useComments() const comments = useComments()
const terminal = useTerminal() const terminal = useTerminal()
const server = useServer()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation() const location = useLocation()
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout() const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
@ -137,11 +135,11 @@ export default function Page() {
layout.handoff.clearTabs() layout.handoff.clearTabs()
return return
} }
if (pending.scope !== server.scope()) return if (pending.scope !== serverSDK().scope) return
if (pending.id !== id) return if (pending.id !== id) return
layout.handoff.clearTabs() layout.handoff.clearTabs()
if (pending.dir !== (params.dir ?? "")) return if (pending.dir !== base64Encode(sdk().directory)) return
const from = workspaceTabs().tabs() const from = workspaceTabs().tabs()
if (from.all.length === 0 && !from.active) return if (from.all.length === 0 && !from.active) return
@ -247,7 +245,7 @@ export default function Page() {
createEffect( createEffect(
on( on(
() => ({ dir: params.dir, id: params.id }), () => ({ dir: sdk().directory, id: params.id }),
(next, prev) => { (next, prev) => {
if (!prev) return if (!prev) return
if (next.dir === prev.dir && next.id === prev.id) return if (next.dir === prev.dir && next.id === prev.id) return
@ -575,7 +573,7 @@ export default function Page() {
createEffect( createEffect(
on( on(
() => params.dir, () => sdk().directory,
(dir) => { (dir) => {
if (!dir) return if (!dir) return
setStore("newSessionWorktree", "main") setStore("newSessionWorktree", "main")

View file

@ -29,6 +29,7 @@ import { useServer } from "@/context/server"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { useDirectoryPicker } from "@/components/directory-picker" import { useDirectoryPicker } from "@/components/directory-picker"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
export function SessionComposerRegion(props: { export function SessionComposerRegion(props: {
state: SessionComposerState state: SessionComposerState
@ -200,7 +201,11 @@ export function SessionComposerRegion(props: {
const openParent = () => { const openParent = () => {
const id = parentID() const id = parentID()
if (!id) return if (!id) return
navigate(`/${route.params.dir}/session/${id}`) navigate(
route.params.serverKey
? sessionHref(requireServerKey(route.params.serverKey), id)
: legacySessionHref(sdk().directory, id),
)
} }
createEffect(() => { createEffect(() => {

View file

@ -1,15 +1,19 @@
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useServer } from "@/context/server"
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope" import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { base64Encode } from "@opencode-ai/core/util/encode"
export const useSessionKey = () => { export const useSessionKey = () => {
const params = useParams() const params = useParams()
const server = useServer() const sdk = useSDK()
const scope = createMemo(() => server.scope()) const serverSDK = useServerSDK()
const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir))) const scope = createMemo(() => serverSDK().scope)
const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir, params.id))) const directory = createMemo(() => base64Encode(sdk().directory))
const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(directory())))
const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(directory(), params.id)))
return { params, sessionKey, workspaceKey } return { params, sessionKey, workspaceKey }
} }

View file

@ -16,6 +16,7 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useTerminal } from "@/context/terminal" import { useTerminal } from "@/context/terminal"
import { useSDK } from "@/context/sdk"
import { terminalTabLabel } from "@/pages/session/terminal-label" import { terminalTabLabel } from "@/pages/session/terminal-label"
import { createSizing, focusTerminalById } from "@/pages/session/helpers" import { createSizing, focusTerminalById } from "@/pages/session/helpers"
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
@ -25,10 +26,11 @@ export function TerminalPanel() {
const delays = [120, 240] const delays = [120, 240]
const layout = useLayout() const layout = useLayout()
const terminal = useTerminal() const terminal = useTerminal()
const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
const command = useCommand() const command = useCommand()
const settings = useSettings() const settings = useSettings()
const { params, workspaceKey, view } = useSessionLayout() const { workspaceKey, view } = useSessionLayout()
const opened = createMemo(() => view().terminal.opened()) const opened = createMemo(() => view().terminal.opened())
const size = createSizing() const size = createSizing()
@ -122,7 +124,7 @@ export function TerminalPanel() {
}) })
createEffect(() => { createEffect(() => {
const dir = params.dir const dir = sdk().directory
if (!dir) return if (!dir) return
if (!terminal.ready()) return if (!terminal.ready()) return
language.locale() language.locale()
@ -140,7 +142,7 @@ export function TerminalPanel() {
}) })
const handoff = createMemo(() => { const handoff = createMemo(() => {
const dir = params.dir const dir = sdk().directory
if (!dir) return [] if (!dir) return []
return getTerminalHandoff(workspaceKey()) ?? [] return getTerminalHandoff(workspaceKey()) ?? []
}) })

View file

@ -62,6 +62,8 @@ import { useSessionKey } from "@/pages/session/session-layout"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
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 { legacySessionHref, requireServerKey, 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"
@ -260,6 +262,7 @@ export function MessageTimeline(props: {
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const settings = useSettings() const settings = useSettings()
const tabs = useTabs()
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const { params, sessionKey } = useSessionKey() const { params, sessionKey } = useSessionKey()
@ -757,12 +760,18 @@ 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) =>
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id)
if (parentID) { if (parentID) {
navigate(`/${params.dir}/session/${parentID}`) navigate(href(parentID))
return return
} }
if (nextSessionID) { if (nextSessionID) {
navigate(`/${params.dir}/session/${nextSessionID}`) navigate(href(nextSessionID))
return
}
if (params.serverKey) {
tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
return return
} }
navigate(`/${params.dir}/session`) navigate(`/${params.dir}/session`)
@ -864,7 +873,9 @@ export function MessageTimeline(props: {
const navigateParent = () => { const navigateParent = () => {
const id = parentID() const id = parentID()
if (!id) return if (!id) return
navigate(`/${params.dir}/session/${id}`) navigate(
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id),
)
} }
function DialogDeleteSession(props: { sessionID: string }) { function DialogDeleteSession(props: { sessionID: string }) {

View file

@ -18,6 +18,8 @@ import { createSessionTabs } from "@/pages/session/helpers"
import { extractPromptFromParts } from "@/utils/prompt" import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2" import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { useTabs } from "@/context/tabs"
import { requireServerKey } from "@/utils/session-route"
export type SessionCommandContext = { export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void navigateMessageByOffset: (offset: number) => void
@ -45,6 +47,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const settings = useSettings() const settings = useSettings()
const sync = useSync() const sync = useSync()
const terminal = useTerminal() const terminal = useTerminal()
const sessionTabs = useTabs()
const layout = useLayout() const layout = useLayout()
const navigate = useNavigate() const navigate = useNavigate()
const { params, tabs, view } = useSessionLayout() const { params, tabs, view } = useSessionLayout()
@ -381,7 +384,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.new"), title: language.t("command.session.new"),
keybind: "mod+shift+s", keybind: "mod+shift+s",
slash: "new", slash: "new",
onSelect: () => navigate(`/${params.dir}/session`), onSelect: () => {
if (params.serverKey) {
sessionTabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
return
}
navigate(`/${params.dir}/session`)
},
}), }),
sessionCommand({ sessionCommand({
id: "session.undo", id: "session.undo",

View file

@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/server"
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./session-route"
describe("session routes", () => {
test("builds and decodes a server-keyed session route", () => {
const server = ServerConnection.Key.make("https://example.com:4096")
const href = sessionHref(server, "session-1")
expect(href).toBe("/server/aHR0cHM6Ly9leGFtcGxlLmNvbTo0MDk2/session/session-1")
expect(requireServerKey(href.split("/")[2])).toBe(server)
})
test("rejects malformed server keys", () => {
expect(() => requireServerKey("not-base64")).toThrow("Invalid server route")
})
test("builds the legacy directory-keyed route", () => {
expect(legacySessionHref("/Users/example/project", "session-1")).toBe(
"/L1VzZXJzL2V4YW1wbGUvcHJvamVjdA/session/session-1",
)
})
test("resolves the root session", async () => {
const sessions: Record<string, { id: string; parentID?: string }> = {
child: { id: "child", parentID: "parent" },
parent: { id: "parent", parentID: "root" },
root: { id: "root" },
}
expect(
await rootSession(sessions.child, async (id) => {
const session = sessions[id]
if (!session) throw new Error(`Missing session: ${id}`)
return session
}),
).toBe(sessions.root)
})
})

View file

@ -0,0 +1,25 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { ServerConnection } from "@/context/server"
import { decode64 } from "@/utils/base64"
export function sessionHref(server: ServerConnection.Key, sessionID: string) {
return `/server/${base64Encode(server)}/session/${sessionID}`
}
export function legacySessionHref(directory: string, sessionID: string) {
return `/${base64Encode(directory)}/session/${sessionID}`
}
export function requireServerKey(segment: string | undefined) {
const key = decode64(segment)
if (!key || base64Encode(key) !== segment) throw new Error("Invalid server route")
return ServerConnection.Key.make(key)
}
type SessionParent = { id: string; parentID?: string }
export async function rootSession(session: SessionParent, get: (sessionID: string) => Promise<SessionParent>) {
let current = session
while (current.parentID) current = await get(current.parentID)
return current
}