Skip to content

Commit 010b456

Browse files
authored
feat(app): scope sdk/sync hooks per-route so /new-session targets its draft server (anomalyco#32290)
1 parent c81cd32 commit 010b456

53 files changed

Lines changed: 524 additions & 458 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/app/src/app.tsx

Lines changed: 91 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@ const SessionRoute = Object.assign(
6969
createEffect(() => {
7070
if (!settings.general.newLayoutDesigns()) return
7171
if (params.id || search.draftId) return
72-
if (!tabs.ready() || !sdk.directory) return
73-
tabs.newDraft({ server: server.key, directory: sdk.directory }, search.prompt)
72+
if (!tabs.ready() || !sdk().directory) return
73+
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
7474
})
7575

7676
return (
@@ -82,6 +82,45 @@ const SessionRoute = Object.assign(
8282
{ preload: Session.preload },
8383
)
8484

85+
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
86+
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
87+
// Notification/Models + the visual Layout) for that server.
88+
function SelectedServerLayout(props: ParentProps) {
89+
return (
90+
<ServerKey>
91+
<ServerSDKProvider>
92+
<ServerSyncProvider>
93+
<ServerScopedShell>{props.children}</ServerScopedShell>
94+
</ServerSyncProvider>
95+
</ServerSDKProvider>
96+
</ServerKey>
97+
)
98+
}
99+
100+
// Wraps /new-session. It resolves the draft's target server and provides the
101+
// server-scoped shell for that server — without ServerKey, so the page never depends
102+
// on the globally "selected" server.
103+
function DraftServerLayout(props: ParentProps) {
104+
const server = useServer()
105+
const tabs = useTabs()
106+
const [search] = useSearchParams<{ draftId?: string }>()
107+
const conn = createMemo(() => {
108+
const id = search.draftId
109+
if (!id) return undefined
110+
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id)
111+
if (!draft) return undefined
112+
return server.list.find((c) => ServerConnection.key(c) === draft.server)
113+
})
114+
115+
return (
116+
<ServerSDKProvider server={conn}>
117+
<ServerSyncProvider server={conn}>
118+
<ServerScopedShell>{props.children}</ServerScopedShell>
119+
</ServerSyncProvider>
120+
</ServerSDKProvider>
121+
)
122+
}
123+
85124
function DraftRoute() {
86125
const [search] = useSearchParams<{ draftId?: string }>()
87126
const tabs = useTabs()
@@ -95,19 +134,15 @@ function DraftRoute() {
95134
}
96135

97136
function ResolvedDraftRoute(props: { draftID: string }) {
98-
const server = useServer()
99137
const tabs = useTabs()
100138
const draft = createMemo(() =>
101139
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
102140
)
103141

104-
createEffect(() => {
105-
const current = draft()
106-
if (current && current.server !== server.key) server.setActive(current.server)
107-
})
108-
109142
// Key on the directory so retargeting the draft's project re-instantiates the
110-
// SDK/data providers for the new directory while keeping the same draft id.
143+
// directory-scoped providers while keeping the same draft id. The draft's target
144+
// server is provided by DraftServerLayout, so changing only the server updates the
145+
// SDK/sync hooks without remounting the composer.
111146
const directory = () => draft()?.directory
112147

113148
return (
@@ -171,27 +206,36 @@ function BodyDesignClass() {
171206
return null
172207
}
173208

174-
function AppShellProviders(props: ParentProps) {
209+
// Server-agnostic providers shared across every route. These live in the shared
210+
// shell (router root) so they stay mounted regardless of the active server/route.
211+
function SharedProviders(props: ParentProps) {
175212
return (
176213
<SettingsProvider>
177214
<BodyDesignClass />
178-
<PermissionProvider>
179-
<LayoutProvider>
180-
<NotificationProvider>
181-
<ModelsProvider>
182-
<CommandProvider>
183-
<HighlightsProvider>
184-
<Layout>{props.children}</Layout>
185-
</HighlightsProvider>
186-
</CommandProvider>
187-
</ModelsProvider>
188-
</NotificationProvider>
189-
</LayoutProvider>
190-
</PermissionProvider>
215+
<CommandProvider>
216+
<HighlightsProvider>{props.children}</HighlightsProvider>
217+
</CommandProvider>
191218
</SettingsProvider>
192219
)
193220
}
194221

222+
// Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside
223+
// each per-route server layout so they resolve to that route's server (selected vs
224+
// draft). The Layout remounts when crossing between those groups.
225+
function ServerScopedShell(props: ParentProps) {
226+
return (
227+
<PermissionProvider>
228+
<LayoutProvider>
229+
<NotificationProvider>
230+
<ModelsProvider>
231+
<Layout>{props.children}</Layout>
232+
</ModelsProvider>
233+
</NotificationProvider>
234+
</LayoutProvider>
235+
</PermissionProvider>
236+
)
237+
}
238+
195239
function SessionProviders(props: ParentProps) {
196240
return (
197241
<TerminalProvider>
@@ -216,17 +260,6 @@ function DraftProviders(props: ParentProps) {
216260
)
217261
}
218262

219-
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
220-
return (
221-
<AppShellProviders>
222-
{/*<Suspense fallback={<Loading />}>*/}
223-
{props.appChildren}
224-
{props.children}
225-
{/*</Suspense>*/}
226-
</AppShellProviders>
227-
)
228-
}
229-
230263
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
231264
return (
232265
<MetaProvider>
@@ -385,6 +418,20 @@ export function AppInterface(props: {
385418
router?: Component<BaseRouterProps>
386419
disableHealthCheck?: boolean
387420
}) {
421+
// The shared shell holds only server-agnostic providers (QueryClient + Settings/
422+
// Command/Highlights) and stays mounted across every route. The server-scoped
423+
// providers and the visual Layout live in the per-route layouts below, so they
424+
// resolve to that route's server (selected for most routes, the draft's server for
425+
// /new-session). appChildren is server-agnostic, so it renders here once.
426+
const ServerShell = (shellProps: ParentProps) => (
427+
<QueryProvider>
428+
<SharedProviders>
429+
{props.children}
430+
{shellProps.children}
431+
</SharedProviders>
432+
</QueryProvider>
433+
)
434+
388435
return (
389436
<ServerProvider
390437
defaultServer={props.defaultServer}
@@ -397,23 +444,19 @@ export function AppInterface(props: {
397444
component={props.router ?? Router}
398445
root={(routerProps) => (
399446
<TabsProvider>
400-
<ServerKey>
401-
<QueryProvider>
402-
<ServerSDKProvider>
403-
<ServerSyncProvider>
404-
<RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>
405-
</ServerSyncProvider>
406-
</ServerSDKProvider>
407-
</QueryProvider>
408-
</ServerKey>
447+
<ServerShell>{routerProps.children}</ServerShell>
409448
</TabsProvider>
410449
)}
411450
>
412-
<Route path="/" component={HomeRoute} />
413-
<Route path="/new-session" component={DraftRoute} />
414-
<Route path="/:dir" component={DirectoryLayout}>
415-
<Route path="/" component={() => <Navigate href="session" />} />
416-
<Route path="/session/:id?" component={SessionRoute} />
451+
<Route component={SelectedServerLayout}>
452+
<Route path="/" component={HomeRoute} />
453+
<Route path="/:dir" component={DirectoryLayout}>
454+
<Route path="/" component={() => <Navigate href="session" />} />
455+
<Route path="/session/:id?" component={SessionRoute} />
456+
</Route>
457+
</Route>
458+
<Route component={DraftServerLayout}>
459+
<Route path="/new-session" component={DraftRoute} />
417460
</Route>
418461
</Dynamic>
419462
</ConnectionGate>

packages/app/src/components/dialog-connect-provider.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export function DialogConnectProvider(props: { provider: string }) {
4141
})
4242

4343
const provider = createMemo(
44-
() => providers.all().get(props.provider) ?? serverSync.data.provider.all.get(props.provider)!,
44+
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
4545
)
4646
const fallback = createMemo<ProviderAuthMethod[]>(() => [
4747
{
@@ -52,16 +52,16 @@ export function DialogConnectProvider(props: { provider: string }) {
5252
const [auth] = createResource(
5353
() => props.provider,
5454
async () => {
55-
const cached = serverSync.data.provider_auth[props.provider]
55+
const cached = serverSync().data.provider_auth[props.provider]
5656
if (cached) return cached
57-
const res = await serverSDK.client.provider.auth()
57+
const res = await serverSDK().client.provider.auth()
5858
if (!alive.value) return fallback()
59-
serverSync.set("provider_auth", res.data ?? {})
59+
serverSync().set("provider_auth", res.data ?? {})
6060
return res.data?.[props.provider] ?? fallback()
6161
},
6262
)
63-
const loading = createMemo(() => auth.loading && !serverSync.data.provider_auth[props.provider])
64-
const methods = createMemo(() => auth.latest ?? serverSync.data.provider_auth[props.provider] ?? fallback())
63+
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
64+
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
6565
const [store, setStore] = createStore({
6666
methodIndex: undefined as undefined | number,
6767
authorization: undefined as undefined | ProviderAuthAuthorization,
@@ -158,7 +158,7 @@ export function DialogConnectProvider(props: { provider: string }) {
158158
}
159159
dispatch({ type: "auth.pending" })
160160
const start = Date.now()
161-
await serverSDK.client.provider.oauth
161+
await serverSDK().client.provider.oauth
162162
.authorize(
163163
{
164164
providerID: props.provider,
@@ -331,7 +331,7 @@ export function DialogConnectProvider(props: { provider: string }) {
331331
})
332332

333333
async function complete() {
334-
await serverSDK.client.global.dispose()
334+
await serverSDK().client.global.dispose()
335335
dialog.close()
336336
showToast({
337337
variant: "success",
@@ -409,7 +409,7 @@ export function DialogConnectProvider(props: { provider: string }) {
409409
}
410410

411411
setFormStore("error", undefined)
412-
await serverSDK.client.auth.set({
412+
await serverSDK().client.auth.set({
413413
providerID: props.provider,
414414
auth: {
415415
type: "api",
@@ -480,7 +480,7 @@ export function DialogConnectProvider(props: { provider: string }) {
480480
}
481481

482482
setFormStore("error", undefined)
483-
const result = await serverSDK.client.provider.oauth
483+
const result = await serverSDK().client.provider.oauth
484484
.callback({
485485
providerID: props.provider,
486486
method: store.methodIndex,
@@ -533,7 +533,7 @@ export function DialogConnectProvider(props: { provider: string }) {
533533

534534
onMount(() => {
535535
void (async () => {
536-
const result = await serverSDK.client.provider.oauth
536+
const result = await serverSDK().client.provider.oauth
537537
.callback({
538538
providerID: props.provider,
539539
method: store.methodIndex,

packages/app/src/components/dialog-custom-provider.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,8 @@ export function DialogCustomProvider(props: Props) {
105105
const output = validateCustomProvider({
106106
form,
107107
t: language.t,
108-
disabledProviders: serverSync.data.config.disabled_providers ?? [],
109-
existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
108+
disabledProviders: serverSync().data.config.disabled_providers ?? [],
109+
existingProviderIDs: new Set(serverSync().data.provider.all.keys()),
110110
})
111111
batch(() => {
112112
setForm("err", output.err)
@@ -118,11 +118,11 @@ export function DialogCustomProvider(props: Props) {
118118

119119
const saveMutation = useMutation(() => ({
120120
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
121-
const disabledProviders = serverSync.data.config.disabled_providers ?? []
121+
const disabledProviders = serverSync().data.config.disabled_providers ?? []
122122
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
123123

124124
if (result.key) {
125-
await serverSDK.client.auth.set({
125+
await serverSDK().client.auth.set({
126126
providerID: result.providerID,
127127
auth: {
128128
type: "api",
@@ -131,7 +131,7 @@ export function DialogCustomProvider(props: Props) {
131131
})
132132
}
133133

134-
await serverSync.updateConfig({
134+
await serverSync().updateConfig({
135135
provider: { [result.providerID]: result.config },
136136
disabled_providers: nextDisabled,
137137
})

packages/app/src/components/dialog-fork.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,13 @@ export const DialogFork: Component = () => {
3535
const sessionID = params.id
3636
if (!sessionID) return []
3737

38-
const msgs = sync.data.message[sessionID] ?? []
38+
const msgs = sync().data.message[sessionID] ?? []
3939
const result: ForkableMessage[] = []
4040

4141
for (const message of msgs) {
4242
if (message.role !== "user") continue
4343

44-
const parts = sync.data.part[message.id] ?? []
44+
const parts = sync().data.part[message.id] ?? []
4545
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
4646
if (!textPart) continue
4747

@@ -61,14 +61,14 @@ export const DialogFork: Component = () => {
6161
const sessionID = params.id
6262
if (!sessionID) return
6363

64-
const parts = sync.data.part[item.id] ?? []
64+
const parts = sync().data.part[item.id] ?? []
6565
const restored = extractPromptFromParts(parts, {
66-
directory: sdk.directory,
66+
directory: sdk().directory,
6767
attachmentName: language.t("common.attachment"),
6868
})
69-
const dir = base64Encode(sdk.directory)
69+
const dir = base64Encode(sdk().directory)
7070

71-
sdk.client.session
71+
sdk().client.session
7272
.fork({ sessionID, messageID: item.id })
7373
.then((forked) => {
7474
if (!forked.data) {

packages/app/src/components/dialog-select-file.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
99
import { useNavigate } from "@solidjs/router"
1010
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
1111
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
12-
import { useServerSDK } from "@/context/server-sdk"
12+
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
1313
import { useServerSync } from "@/context/server-sync"
1414
import { useLayout } from "@/context/layout"
1515
import { useFile } from "@/context/file"
@@ -175,7 +175,7 @@ function createFileEntries(props: {
175175
function createSessionEntries(props: {
176176
workspaces: () => string[]
177177
label: (directory: string) => string
178-
serverSDK: ReturnType<typeof useServerSDK>
178+
serverSDK: ServerSDK
179179
language: ReturnType<typeof useLanguage>
180180
}) {
181181
const state: {
@@ -292,21 +292,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
292292
if (directory && !dirs.includes(directory)) return [...dirs, directory]
293293
return dirs
294294
})
295-
const homedir = createMemo(() => serverSync.data.path.home)
295+
const homedir = createMemo(() => serverSync().data.path.home)
296296
const label = (directory: string) => {
297297
const current = project()
298298
const kind =
299299
current && directory === current.worktree
300300
? language.t("workspace.type.local")
301301
: language.t("workspace.type.sandbox")
302-
const [store] = serverSync.child(directory, { bootstrap: false })
302+
const [store] = serverSync().child(directory, { bootstrap: false })
303303
const home = homedir()
304304
const path = home ? directory.replace(home, "~") : directory
305305
const name = store.vcs?.branch ?? getFilename(directory)
306306
return `${kind} : ${name || path}`
307307
}
308308

309-
const { sessions } = createSessionEntries({ workspaces, label, serverSDK, language })
309+
const { sessions } = createSessionEntries({ workspaces, label, serverSDK: serverSDK(), language })
310310

311311
const items = async (text: string) => {
312312
const query = text.trim()

0 commit comments

Comments
 (0)