Compare commits

...

2 Commits

Author SHA1 Message Date
Brendan Allan a0f4e8546c remove console log 2026-06-24 12:13:30 +08:00
Brendan Allan d8c5c6f877 refactor(app): simplify layout hierarchy
Remove TargetServerLayout and TargetDirectoryLayout, moving directory
resolution and SDK providers into route-specific components. NewLayout
now renders from the router root so it stays mounted across home,
draft, and session routes. Titlebar resolves session metadata from
LayoutRoute.server instead of the surrounding server provider.
2026-06-24 12:04:37 +08:00
3 changed files with 147 additions and 156 deletions
@@ -718,7 +718,6 @@ 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) {
console.log(process.env)
const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}` 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()
+139 -149
View File
@@ -91,19 +91,88 @@ const SessionRoute = Object.assign(
const TargetSessionRoute = Object.assign( const TargetSessionRoute = Object.assign(
() => { () => {
const sdk = useSDK() const params = useParams<{ serverKey: string }>()
const serverSDK = useServerSDK() const server = useServer()
const conn = createMemo(() => {
const key = requireServerKey(params.serverKey)
return server.list.find((item) => ServerConnection.key(item) === key)
})
return ( return (
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed> <ServerSDKProvider server={conn}>
<SessionProviders> <ServerSyncProvider server={conn}>
<Session /> <ResolvedTargetSessionRoute />
</SessionProviders> </ServerSyncProvider>
</Show> </ServerSDKProvider>
) )
}, },
{ preload: Session.preload }, { preload: Session.preload },
) )
function ResolvedTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
const settings = useSettings()
const tabs = useTabs()
const serverSDK = useServerSDK()
const serverKey = createMemo(() => requireServerKey(params.serverKey))
const resolved = useQuery(() => ({
queryKey: [serverSDK().scope, "session-route", params.id] as const,
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 directory = createMemo<string | undefined>((prev) => prev ?? resolved.data?.session.directory)
const targetDirectory = () => directory()!
createEffect(() => {
const current = resolved.data
if (!current) return
tabs.addSessionTab({
server: serverKey(),
sessionId: current.rootID,
})
})
return (
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
<Show when={!resolved.error} fallback={<ErrorPage error={resolved.error} />}>
<Show when={directory()}>
<Show
when={settings.general.newLayoutDesigns()}
fallback={<Navigate href={legacySessionHref(directory()!, params.id)} />}
>
<SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<Show when={resolved.data && !resolved.isPlaceholderData}>
<TargetSessionPage />
</Show>
</DirectoryDataProvider>
</SDKProvider>
</Show>
</Show>
</Show>
</TargetServerScopedProviders>
)
}
function TargetSessionPage() {
const sdk = useSDK()
const serverSDK = useServerSDK()
return (
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
<SessionProviders>
<Session />
</SessionProviders>
</Show>
)
}
// 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.
@@ -125,125 +194,42 @@ function LegacyServerLayout(props: ParentProps) {
) )
} }
// 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
// on the globally "selected" server.
function TargetServerLayout(props: ParentProps) {
const server = useServer()
const tabs = useTabs()
const params = useParams<{ serverKey?: string }>()
const [search] = useSearchParams<{ draftId?: string }>()
const conn = createMemo(() => {
if (params.serverKey) {
const key = requireServerKey(params.serverKey)
return server.list.find((item) => ServerConnection.key(item) === key)
}
const id = search.draftId
if (!id) return undefined
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id)
if (!draft) return undefined
return server.list.find((c) => ServerConnection.key(c) === draft.server)
})
return (
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<TargetDirectoryLayout>{props.children}</TargetDirectoryLayout>
</ServerSyncProvider>
</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(() => {
const id = params.id
return {
queryKey: [serverSDK().scope, "session-route", id] as const,
enabled: !!params.serverKey && !!params.id,
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) =>
search.draftId ? resolvedDirectory() : (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
<ResolvedDraftRoute /> when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
keyed
fallback={<Navigate href="/" />}
>
{(draft) => <ResolvedDraftRoute draft={draft} />}
</Show> </Show>
</Show> </Show>
) )
} }
function ResolvedDraftRoute() { function ResolvedDraftRoute(props: { draft: DraftTab }) {
const server = useServer()
const conn = createMemo(() => server.list.find((item) => ServerConnection.key(item) === props.draft.server))
const directory = () => props.draft.directory
const serverKey = () => props.draft.server
return ( return (
<DraftProviders> <ServerSDKProvider server={conn}>
<NewSession /> <ServerSyncProvider server={conn}>
</DraftProviders> <TargetServerScopedProviders directory={directory}>
<SDKProvider directory={directory}>
<DirectoryDataProvider directory={directory} server={serverKey}>
<DraftProviders>
<NewSession />
</DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
</TargetServerScopedProviders>
</ServerSyncProvider>
</ServerSDKProvider>
) )
} }
@@ -306,9 +292,7 @@ function SharedProviders(props: ParentProps) {
) )
} }
// Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside // Server-scoped providers shared by the legacy shell and the top-level new shell.
// each per-route server layout so they resolve to that route's server (selected vs
// draft). The Layout remounts when crossing between those groups.
type ServerScopedShellProps = ParentProps<{ type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined directory?: () => string | undefined
sessionID?: () => string | undefined sessionID?: () => string | undefined
@@ -334,11 +318,23 @@ function LegacyServerScopedShell(props: ServerScopedShellProps) {
) )
} }
function NewServerScopedShell(props: ServerScopedShellProps) { function NewAppLayout(props: ParentProps) {
return ( return (
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}> <SelectedServerProviders>
<NewLayout>{props.children}</NewLayout> <ServerScopedProviders>
</ServerScopedProviders> <NewLayout>{props.children}</NewLayout>
</ServerScopedProviders>
</SelectedServerProviders>
)
}
function TargetServerScopedProviders(props: ServerScopedShellProps) {
return (
<PermissionProvider directory={props.directory}>
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
<ModelsProvider>{props.children}</ModelsProvider>
</NotificationProvider>
</PermissionProvider>
) )
} }
@@ -524,11 +520,9 @@ export function AppInterface(props: {
router?: Component<BaseRouterProps> router?: Component<BaseRouterProps>
disableHealthCheck?: boolean disableHealthCheck?: boolean
}) { }) {
// The shared shell holds only server-agnostic providers (QueryClient + Settings/ // The visual new layout lives in the router root so it remains mounted across
// Command/Highlights) and stays mounted across every route. The server-scoped // route changes. Draft and session routes override only their server-bound data
// providers and the visual Layout live in the per-route layouts below, so they // providers beneath it.
// resolve to that route's server (selected for most routes, the draft's server for
// /new-session). appChildren is server-agnostic, so it renders here once.
const ServerShell = (shellProps: ParentProps) => ( const ServerShell = (shellProps: ParentProps) => (
<QueryProvider> <QueryProvider>
<SharedProviders> <SharedProviders>
@@ -552,7 +546,11 @@ export function AppInterface(props: {
component={props.router ?? Router} component={props.router ?? Router}
root={(routerProps) => ( root={(routerProps) => (
<TabsProvider> <TabsProvider>
<ServerShell>{routerProps.children}</ServerShell> <ServerShell>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
<NewAppLayout>{routerProps.children}</NewAppLayout>
</Show>
</ServerShell>
</TabsProvider> </TabsProvider>
)} )}
> >
@@ -578,28 +576,20 @@ function Routes() {
<Route path="/session/:id?" component={SessionRoute} /> <Route path="/session/:id?" component={SessionRoute} />
</Route> </Route>
</Route> </Route>
<Route component={TargetServerLayout}> <Show when={settings.general.newLayoutDesigns()}>
<Show when={settings.general.newLayoutDesigns()}> <Route path="/" component={NewHome} />
{ <Route
<> path="/:dir/session/:id"
<Route path="/" component={NewHome} /> component={() => {
<Route path="/:dir" component={DirectoryLayout}> const server = useServer()
<Route const { id } = useParams()
path="/session/:id"
component={() => {
const server = useServer()
const { id } = useParams()
return <Navigate href={`/server/${server.key}/session/${id}`} /> return <Navigate href={`/server/${server.key}/session/${id}`} />
}} }}
/> />
</Route> </Show>
</> <Route path="/new-session" component={DraftRoute} />
} <Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
</Show>
<Route path="/new-session" component={DraftRoute} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
</Route>
</> </>
) )
} }
+8 -6
View File
@@ -39,7 +39,6 @@ import { useGlobal } from "@/context/global"
import { ServerConnection, useServer } from "@/context/server" import { ServerConnection, useServer } from "@/context/server"
import { tabHref, useTabs } 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" import { Session } from "@opencode-ai/sdk/v2"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { createTabPromptState } from "@/context/prompt" import { createTabPromptState } from "@/context/prompt"
@@ -262,7 +261,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<Switch> <Switch>
<Match when={useV2Titlebar()}> <Match when={useV2Titlebar()}>
{(_) => { {(_) => {
const serverSdk = useServerSDK()
const navigate = useNavigate() const navigate = useNavigate()
const layout = useLayout() const layout = useLayout()
const global = useGlobal() const global = useGlobal()
@@ -273,11 +271,15 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const [session] = createResource( const [session] = createResource(
() => { () => {
const route = layout.route() const route = layout.route()
return route.type === "session" ? route : undefined if (route.type !== "session") return undefined
const conn = global.servers
.list()
.find((item) => ServerConnection.key(item) === (route.server ?? server.key))
return conn ? { route, sdk: global.createServerCtx(conn).sdk } : undefined
}, },
(route) => ({ route, sdk }) =>
serverSdk() sdk.client.session
.client.session.get({ sessionID: route.sessionId }) .get({ sessionID: route.sessionId })
.then((x) => x.data) .then((x) => x.data)
.catch(() => {}), .catch(() => {}),
) )