Prechádzať zdrojové kódy

feat(app): improve desktop multi-server support (#30678)

Co-authored-by: Brendan Allan <git@brendonovich.dev>
Luke Parker 2 mesiacov pred
rodič
commit
7f33576f46
62 zmenil súbory, kde vykonal 1523 pridanie a 841 odobranie
  1. 2 1
      packages/app/e2e/regression/prompt-thinking-level.spec.ts
  2. 3 2
      packages/app/e2e/regression/session-list-path-loading.spec.ts
  3. 6 5
      packages/app/e2e/regression/session-timeline-collapse-state.spec.ts
  4. 4 3
      packages/app/e2e/regression/session-timeline-context-resize.spec.ts
  5. 8 4
      packages/app/e2e/smoke/session-timeline.spec.ts
  6. 11 0
      packages/app/e2e/utils/waits.ts
  7. 37 34
      packages/app/src/app.tsx
  8. 10 8
      packages/app/src/components/dialog-edit-project.tsx
  9. 7 2
      packages/app/src/components/dialog-select-server.tsx
  10. 1 0
      packages/app/src/components/prompt-input/submit.test.ts
  11. 11 8
      packages/app/src/components/prompt-input/submit.ts
  12. 2 2
      packages/app/src/components/settings-general.tsx
  13. 6 6
      packages/app/src/components/settings-v2/general.tsx
  14. 10 10
      packages/app/src/components/settings-v2/providers.tsx
  15. 71 99
      packages/app/src/components/titlebar.tsx
  16. 6 3
      packages/app/src/context/comments.tsx
  17. 5 4
      packages/app/src/context/directory-sync.ts
  18. 5 2
      packages/app/src/context/file.tsx
  19. 5 4
      packages/app/src/context/file/view-cache.ts
  20. 14 1
      packages/app/src/context/global-sync/bootstrap.test.ts
  21. 26 22
      packages/app/src/context/global-sync/bootstrap.ts
  22. 15 6
      packages/app/src/context/global-sync/child-store.test.ts
  23. 9 6
      packages/app/src/context/global-sync/child-store.ts
  24. 28 14
      packages/app/src/context/global-sync/session-prefetch.test.ts
  25. 22 15
      packages/app/src/context/global-sync/session-prefetch.ts
  26. 26 124
      packages/app/src/context/global.tsx
  27. 1 0
      packages/app/src/context/language.tsx
  28. 24 10
      packages/app/src/context/layout.tsx
  29. 9 6
      packages/app/src/context/local.tsx
  30. 1 0
      packages/app/src/context/models.tsx
  31. 2 1
      packages/app/src/context/notification.tsx
  32. 2 1
      packages/app/src/context/permission.tsx
  33. 6 3
      packages/app/src/context/prompt.tsx
  34. 14 0
      packages/app/src/context/server-sdk.test.ts
  35. 25 8
      packages/app/src/context/server-sdk.tsx
  36. 28 20
      packages/app/src/context/server-sync.tsx
  37. 71 1
      packages/app/src/context/server.test.ts
  38. 106 65
      packages/app/src/context/server.tsx
  39. 1 0
      packages/app/src/context/settings.tsx
  40. 140 0
      packages/app/src/context/tabs.tsx
  41. 6 42
      packages/app/src/context/terminal.test.ts
  42. 24 37
      packages/app/src/context/terminal.tsx
  43. 1 0
      packages/app/src/entry.tsx
  44. 267 176
      packages/app/src/pages/home.tsx
  45. 28 20
      packages/app/src/pages/layout.tsx
  46. 86 0
      packages/app/src/pages/layout/helpers.test.ts
  47. 39 1
      packages/app/src/pages/layout/helpers.ts
  48. 4 2
      packages/app/src/pages/layout/project-avatar-state.ts
  49. 8 4
      packages/app/src/pages/session.tsx
  50. 8 4
      packages/app/src/pages/session/composer/session-question-dock.tsx
  51. 9 3
      packages/app/src/pages/session/session-layout.ts
  52. 3 3
      packages/app/src/pages/session/terminal-panel.tsx
  53. 26 0
      packages/app/src/utils/persist.test.ts
  54. 22 9
      packages/app/src/utils/persist.ts
  55. 25 0
      packages/app/src/utils/server-health.test.ts
  56. 5 2
      packages/app/src/utils/server-health.ts
  57. 57 0
      packages/app/src/utils/server-scope.test.ts
  58. 70 0
      packages/app/src/utils/server-scope.ts
  59. 24 12
      packages/app/src/utils/worktree.test.ts
  60. 24 21
      packages/app/src/utils/worktree.ts
  61. 1 0
      packages/desktop/src/main/server.ts
  62. 6 5
      packages/ui/src/context/helper.tsx

+ 2 - 1
packages/app/e2e/regression/prompt-thinking-level.spec.ts

@@ -1,6 +1,7 @@
 import { expect, test, type Page } from "@playwright/test"
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { mockOpenCodeServer } from "../utils/mock-server"
+import { expectAppVisible } from "../utils/waits"
 
 const directory = "C:/OpenCode/PromptThinkingLevelRegression"
 const projectID = "proj_prompt_thinking_level_regression"
@@ -56,7 +57,7 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
   const composer = page.locator('[data-component="session-composer"]')
   const input = composer.locator('[data-component="prompt-input"]')
   const control = composer.locator('[data-component="prompt-variant-control"]')
-  await expect(composer).toBeVisible()
+  await expectAppVisible(composer)
 
   await idleComposer(page)
   await expect(control).toBeHidden()

+ 3 - 2
packages/app/e2e/regression/session-list-path-loading.spec.ts

@@ -1,6 +1,7 @@
-import { expect, test } from "@playwright/test"
+import { test } from "@playwright/test"
 import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
 import { mockOpenCodeServer } from "../utils/mock-server"
+import { expectAppVisible } from "../utils/waits"
 
 test("shows loaded sessions before the directory path request resolves", async ({ page }) => {
   await mockOpenCodeServer(page, {
@@ -33,7 +34,7 @@ test("shows loaded sessions before the directory path request resolves", async (
 
   await page.goto("/")
   try {
-    await expect(page.getByText(fixture.expected.sourceTitle).first()).toBeVisible({ timeout: 5_000 })
+    await expectAppVisible(page.getByText(fixture.expected.sourceTitle).first())
   } finally {
     releasePath()
   }

+ 6 - 5
packages/app/e2e/regression/session-timeline-collapse-state.spec.ts

@@ -1,5 +1,6 @@
 import { expect, test, type Locator, type Page } from "@playwright/test"
 import { mockOpenCodeServer } from "../utils/mock-server"
+import { expectAppVisible, expectSessionTitle } from "../utils/waits"
 
 const directory = "C:/OpenCode/TimelineStateRegression"
 const projectID = "proj_timeline_state_regression"
@@ -106,10 +107,10 @@ test.describe("regression: session timeline local row state", () => {
     await configurePage(page)
 
     await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
-    await expect(page.getByRole("heading", { name: title })).toBeVisible()
+    await expectSessionTitle(page, title)
 
     const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
-    await expect(wrapper).toBeVisible()
+    await expectAppVisible(wrapper)
     await expectExpanded(wrapper, true)
 
     await wrapper.evaluate((element) => {
@@ -142,11 +143,11 @@ test.describe("regression: session timeline local row state", () => {
     await configurePage(page)
 
     await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
-    await expect(page.getByRole("heading", { name: title })).toBeVisible()
+    await expectSessionTitle(page, title)
 
     const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
-    await expect(wrapper).toBeVisible()
-    await expect(wrapper.locator('[data-component="file"][data-mode="diff"]').first()).toBeVisible()
+    await expectAppVisible(wrapper)
+    await expectAppVisible(wrapper.locator('[data-component="file"][data-mode="diff"]').first())
     await markDiffProbe(page)
 
     events.push({

+ 4 - 3
packages/app/e2e/regression/session-timeline-context-resize.spec.ts

@@ -1,5 +1,6 @@
 import { expect, test, type Page } from "@playwright/test"
 import { mockOpenCodeServer } from "../utils/mock-server"
+import { expectAppVisible, expectSessionTitle } from "../utils/waits"
 
 const directory = "C:/OpenCode/ContextResizeRegression"
 const projectID = "proj_context_resize_regression"
@@ -23,9 +24,9 @@ test.describe("regression: session timeline context group resize", () => {
     await configurePage(page)
 
     await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
-    await expect(page.getByRole("heading", { name: title })).toBeVisible()
-    await expect(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()).toBeVisible()
-    await expect(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first()).toBeVisible()
+    await expectSessionTitle(page, title)
+    await expectAppVisible(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first())
+    await expectAppVisible(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first())
     await settle(page)
 
     const samples = await sampleExpansion(page)

+ 8 - 4
packages/app/e2e/smoke/session-timeline.spec.ts

@@ -3,6 +3,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
 import { fixture, pageMessages } from "./session-timeline.fixture"
 import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
 import { mockOpenCodeServer } from "../utils/mock-server"
+import { APP_READY_TIMEOUT, expectAppVisible, expectSessionTitle } from "../utils/waits"
 
 const forbiddenText = ["Load details", "Show earlier steps"]
 
@@ -411,18 +412,21 @@ function expectCompleteScroll(
 
 async function selectHomeProject(page: Page, projectName: string) {
   await page.goto("/")
-  await page
+  const row = page
     .locator('[data-component="home-project-row"]')
     .filter({ hasText: new RegExp(projectName, "i") })
-    .click()
+    .first()
+  await expectAppVisible(row)
+  await row.click()
+  await expect(row).toHaveAttribute("data-selected", "", { timeout: APP_READY_TIMEOUT })
   await expect(page).toHaveURL(/\/$/)
 }
 
 async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) {
   await page.goto(`/${base64Encode(directory)}/session/${sessionId}`)
-  await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible()
+  await expectSessionTitle(page, expectedTitle)
 }
 
 async function expectSessionReady(page: Page) {
-  await expect(page.getByRole("textbox", { name: /Ask anything/i })).toBeVisible()
+  await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
 }

+ 11 - 0
packages/app/e2e/utils/waits.ts

@@ -0,0 +1,11 @@
+import { expect, type Locator, type Page } from "@playwright/test"
+
+export const APP_READY_TIMEOUT = 30_000
+
+export async function expectAppVisible(locator: Locator) {
+  await expect(locator).toBeVisible({ timeout: APP_READY_TIMEOUT })
+}
+
+export async function expectSessionTitle(page: Page, title: string) {
+  await expectAppVisible(page.getByRole("heading", { name: title }))
+}

+ 37 - 34
packages/app/src/app.tsx

@@ -25,7 +25,6 @@ import {
   onCleanup,
   type ParentProps,
   Show,
-  Suspense,
 } from "solid-js"
 import { Dynamic } from "solid-js/web"
 import { CommandProvider } from "@/context/command"
@@ -44,6 +43,7 @@ import { PromptProvider } from "@/context/prompt"
 import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
 import { SettingsProvider, useSettings } from "@/context/settings"
 import { TerminalProvider } from "@/context/terminal"
+import { TabsProvider } from "@/context/tabs"
 import DirectoryLayout from "@/pages/directory-layout"
 import Layout from "@/pages/layout"
 import { ErrorPage } from "./pages/error"
@@ -211,26 +211,21 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
           Effect.runPromise,
         ),
   )
+  const checking = createMemo(
+    () => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state),
+  )
 
   return (
-    <Suspense
+    <Show
+      when={!checking()}
       fallback={
         <div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
           <Splash class="w-16 h-20 opacity-50 animate-pulse" />
         </div>
       }
     >
-      {/*<Show
-        when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
-        fallback={
-          <div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
-            <Splash class="w-16 h-20 opacity-50 animate-pulse" />
-          </div>
-        }
-      >*/}
-      {checkMode() === "blocking" ? startupHealthCheck() : startupHealthCheck.latest}
       <Show
-        when={startupHealthCheck()}
+        when={startupHealthCheck.latest}
         fallback={
           <ConnectionError
             onRetry={() => {
@@ -246,8 +241,7 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
       >
         {props.children}
       </Show>
-      {/*</Show>*/}
-    </Suspense>
+    </Show>
   )
 }
 
@@ -310,32 +304,41 @@ function ServerKey(props: ParentProps) {
 export function AppInterface(props: {
   children?: JSX.Element
   defaultServer: ServerConnection.Key
+  canonicalLocalServer?: ServerConnection.Key
   servers?: Array<ServerConnection.Any>
   router?: Component<BaseRouterProps>
   disableHealthCheck?: boolean
 }) {
   return (
-    <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
-      <GlobalProvider defaultServer={props.defaultServer} servers={props.servers}>
+    <ServerProvider
+      defaultServer={props.defaultServer}
+      canonicalLocalServer={props.canonicalLocalServer}
+      servers={props.servers}
+    >
+      <GlobalProvider>
         <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
-          <ServerKey>
-            <QueryProvider>
-              <ServerSDKProvider>
-                <ServerSyncProvider>
-                  <Dynamic
-                    component={props.router ?? Router}
-                    root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
-                  >
-                    <Route path="/" component={HomeRoute} />
-                    <Route path="/:dir" component={DirectoryLayout}>
-                      <Route path="/" component={() => <Navigate href="session" />} />
-                      <Route path="/session/:id?" component={SessionRoute} />
-                    </Route>
-                  </Dynamic>
-                </ServerSyncProvider>
-              </ServerSDKProvider>
-            </QueryProvider>
-          </ServerKey>
+          <Dynamic
+            component={props.router ?? Router}
+            root={(routerProps) => (
+              <TabsProvider>
+                <ServerKey>
+                  <QueryProvider>
+                    <ServerSDKProvider>
+                      <ServerSyncProvider>
+                        <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>
+                      </ServerSyncProvider>
+                    </ServerSDKProvider>
+                  </QueryProvider>
+                </ServerKey>
+              </TabsProvider>
+            )}
+          >
+            <Route path="/" component={HomeRoute} />
+            <Route path="/:dir" component={DirectoryLayout}>
+              <Route path="/" component={() => <Navigate href="session" />} />
+              <Route path="/session/:id?" component={SessionRoute} />
+            </Route>
+          </Dynamic>
         </ConnectionGate>
       </GlobalProvider>
     </ServerProvider>

+ 10 - 8
packages/app/src/components/dialog-edit-project.tsx

@@ -6,21 +6,23 @@ import { useMutation } from "@tanstack/solid-query"
 import { Icon } from "@opencode-ai/ui/icon"
 import { createMemo, For, Show } from "solid-js"
 import { createStore } from "solid-js/store"
-import { useServerSDK } from "@/context/server-sdk"
-import { useServerSync } from "@/context/server-sync"
 import { type LocalProject, getAvatarColors } from "@/context/layout"
 import { getFilename } from "@opencode-ai/core/util/path"
 import { Avatar } from "@opencode-ai/ui/avatar"
 import { useLanguage } from "@/context/language"
 import { getProjectAvatarSource } from "@/pages/layout/helpers"
+import { ServerConnection } from "@/context/server"
+import { useGlobal } from "@/context/global"
 
 const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
 
-export function DialogEditProject(props: { project: LocalProject }) {
+export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
   const dialog = useDialog()
-  const serverSDK = useServerSDK()
-  const serverSync = useServerSync()
+  const global = useGlobal()
   const language = useLanguage()
+  const serverCtx = createMemo(() => global.createServerCtx(props.server))
+  const serverSDK = () => serverCtx().sdk
+  const serverSync = () => serverCtx().sync
 
   const folderName = createMemo(() => getFilename(props.project.worktree))
   const defaultName = createMemo(() => props.project.name || folderName())
@@ -78,19 +80,19 @@ export function DialogEditProject(props: { project: LocalProject }) {
       const start = store.startup.trim()
 
       if (props.project.id && props.project.id !== "global") {
-        await serverSDK.client.project.update({
+        await serverSDK().client.project.update({
           projectID: props.project.id,
           directory: props.project.worktree,
           name,
           icon: { color: store.color || "", override: store.iconOverride || "" },
           commands: { start },
         })
-        serverSync.project.icon(props.project.worktree, store.iconOverride || undefined)
+        serverSync().project.icon(props.project.worktree, store.iconOverride || undefined)
         dialog.close()
         return
       }
 
-      serverSync.project.meta(props.project.worktree, {
+      serverSync().project.meta(props.project.worktree, {
         name,
         icon: { color: store.color || undefined, override: store.iconOverride || undefined },
         commands: { start: start || undefined },

+ 7 - 2
packages/app/src/components/dialog-select-server.tsx

@@ -18,6 +18,7 @@ import { usePlatform } from "@/context/platform"
 import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
 import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
 import { useSettings } from "@/context/settings"
+import { useTabs } from "@/context/tabs"
 
 const DEFAULT_USERNAME = "opencode"
 
@@ -191,6 +192,7 @@ export function DialogSelectServer() {
 export function useServerManagementController(options: { onSelect?: () => void } = {}) {
   const navigate = useNavigate()
   const server = useServer()
+  const tabs = useTabs()
   const global = useGlobal()
   const platform = usePlatform()
   const language = useLanguage()
@@ -311,12 +313,14 @@ export function useServerManagementController(options: { onSelect?: () => void }
   }))
 
   const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
+    const originalKey = ServerConnection.key(original)
     const active = server.key
+    tabs.removeServer(originalKey)
     const newConn = server.add(next)
     if (!newConn) return
-    const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active
+    const nextActive = active === originalKey ? ServerConnection.key(newConn) : active
     if (nextActive) server.setActive(nextActive)
-    server.remove(ServerConnection.key(original))
+    server.remove(originalKey)
   }
 
   const items = createMemo(() => {
@@ -501,6 +505,7 @@ export function useServerManagementController(options: { onSelect?: () => void }
   })
 
   async function handleRemove(url: ServerConnection.Key) {
+    tabs.removeServer(url)
     server.remove(url)
     if ((await platform.getDefaultServer?.()) === url) {
       void platform.setDefaultServer?.(null)

+ 1 - 0
packages/app/src/components/prompt-input/submit.test.ts

@@ -127,6 +127,7 @@ beforeAll(async () => {
   mock.module("@/context/sdk", () => ({
     useSDK: () => {
       const sdk = {
+        scope: "local",
         directory: "/repo/main",
         client: rootClient,
         url: "http://localhost:4096",

+ 11 - 8
packages/app/src/components/prompt-input/submit.ts

@@ -18,6 +18,7 @@ import { Worktree as WorktreeState } from "@/utils/worktree"
 import { buildRequestParts } from "./build-request-parts"
 import { setCursorPosition } from "./editor-dom"
 import { formatServerError } from "@/utils/server-errors"
+import { ScopedKey } from "@/utils/server-scope"
 
 type PendingPrompt = {
   abort: AbortController
@@ -212,6 +213,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
   const layout = useLayout()
   const language = useLanguage()
   const params = useParams()
+  const pendingKey = (sessionID: string) => ScopedKey.from(sdk.scope, sessionID)
 
   const errorMessage = (err: unknown) => {
     if (err && typeof err === "object" && "data" in err) {
@@ -232,11 +234,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
 
     input.onAbort?.()
 
-    const queued = pending.get(sessionID)
+    const key = pendingKey(sessionID)
+    const queued = pending.get(key)
     if (queued) {
       queued.abort.abort()
       queued.cleanup()
-      pending.delete(sessionID)
+      pending.delete(key)
       return Promise.resolve()
     }
     return sdk.client.session
@@ -341,7 +344,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
           })
           return
         }
-        WorktreeState.pending(createdWorktree.directory)
+        WorktreeState.pending(sdk.scope, createdWorktree.directory)
         sessionDirectory = createdWorktree.directory
       }
 
@@ -500,7 +503,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
     clearInput()
 
     const waitForWorktree = async () => {
-      const worktree = WorktreeState.get(sessionDirectory)
+      const worktree = WorktreeState.get(sdk.scope, sessionDirectory)
       if (!worktree || worktree.status !== "pending") return true
 
       if (sessionDirectory === projectDirectory) {
@@ -517,7 +520,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
         restoreInput()
       }
 
-      pending.set(session.id, { abort: controller, cleanup })
+      pending.set(pendingKey(session.id), { abort: controller, cleanup })
 
       const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
         if (controller.signal.aborted) {
@@ -544,11 +547,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
         }, timeoutMs)
       })
 
-      const result = await Promise.race([WorktreeState.wait(sessionDirectory), abortWait, timeout]).finally(() => {
+      const result = await Promise.race([WorktreeState.wait(sdk.scope, sessionDirectory), abortWait, timeout]).finally(() => {
         if (timer.id === undefined) return
         clearTimeout(timer.id)
       })
-      pending.delete(session.id)
+      pending.delete(pendingKey(session.id))
       if (controller.signal.aborted) return false
       if (result.status === "failed") throw new Error(result.message)
       return true
@@ -563,7 +566,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       optimisticBusy: sessionDirectory === projectDirectory,
       before: waitForWorktree,
     }).catch((err) => {
-      pending.delete(session.id)
+      pending.delete(pendingKey(session.id))
       if (sessionDirectory === projectDirectory) {
         sync.set("session_status", session.id, { type: "idle" })
       }

+ 2 - 2
packages/app/src/components/settings-general.tsx

@@ -178,11 +178,11 @@ export const SettingsGeneral: Component = () => {
   const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
 
   const serverSync = useServerSync()
-  const globalSdk = useServerSDK()
+  const serverSdk = useServerSDK()
 
   const [shells] = createResource(
     () =>
-      globalSdk.client.pty
+      serverSdk.client.pty
         .shells()
         .then((res) => res.data ?? [])
         .catch(() => [] as ShellOption[]),

+ 6 - 6
packages/app/src/components/settings-v2/general.tsx

@@ -179,12 +179,12 @@ export const SettingsGeneralV2: Component = () => {
 
   const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
 
-  const globalSync = useServerSync()
-  const globalSdk = useServerSDK()
+  const serverSync = useServerSync()
+  const serverSdk = useServerSDK()
 
   const [shells] = createResource(
     () =>
-      globalSdk.client.pty
+      serverSdk.client.pty
         .shells()
         .then((res) => res.data ?? [])
         .catch(() => [] as ShellOption[]),
@@ -208,11 +208,11 @@ export const SettingsGeneralV2: Component = () => {
   })
 
   const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
-  const currentShell = createMemo(() => globalSync.data.config.shell ?? "")
+  const currentShell = createMemo(() => serverSync.data.config.shell ?? "")
 
   const shellOptions = createMemo<ShellSelectOption[]>(() => {
     const list = shells.latest
-    const current = globalSync.data.config.shell
+    const current = serverSync.data.config.shell
 
     const nameCounts = new Map<string, number>()
     for (const s of list) {
@@ -347,7 +347,7 @@ export const SettingsGeneralV2: Component = () => {
             onSelect={(option) => {
               if (!option) return
               if (option.value === currentShell()) return
-              globalSync.updateConfig({ shell: option.value })
+              serverSync.updateConfig({ shell: option.value })
             }}
           />
         </SettingsRowV2>

+ 10 - 10
packages/app/src/components/settings-v2/providers.tsx

@@ -33,8 +33,8 @@ const PROVIDER_ICON_SIZE = 16
 export const SettingsProvidersV2: Component = () => {
   const dialog = useDialog()
   const language = useLanguage()
-  const globalSDK = useServerSDK()
-  const globalSync = useServerSync()
+  const serverSdk = useServerSDK()
+  const serverSync = useServerSync()
   const providers = useProviders()
 
   const connected = createMemo(() => {
@@ -77,7 +77,7 @@ export const SettingsProvidersV2: Component = () => {
   const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
 
   const isConfigCustom = (providerID: string) => {
-    const provider = globalSync.data.config.provider?.[providerID]
+    const provider = serverSync.data.config.provider?.[providerID]
     if (!provider) return false
     if (provider.npm !== "@ai-sdk/openai-compatible") return false
     if (!provider.models || Object.keys(provider.models).length === 0) return false
@@ -85,11 +85,11 @@ export const SettingsProvidersV2: Component = () => {
   }
 
   const disableProvider = async (providerID: string, name: string) => {
-    const before = globalSync.data.config.disabled_providers ?? []
+    const before = serverSync.data.config.disabled_providers ?? []
     const next = before.includes(providerID) ? before : [...before, providerID]
-    globalSync.set("config", "disabled_providers", next)
+    serverSync.set("config", "disabled_providers", next)
 
-    await globalSync
+    await serverSync
       .updateConfig({ disabled_providers: next })
       .then(() => {
         showToast({
@@ -100,7 +100,7 @@ export const SettingsProvidersV2: Component = () => {
         })
       })
       .catch((err: unknown) => {
-        globalSync.set("config", "disabled_providers", before)
+        serverSync.set("config", "disabled_providers", before)
         const message = err instanceof Error ? err.message : String(err)
         showToast({ title: language.t("common.requestFailed"), description: message })
       })
@@ -108,14 +108,14 @@ export const SettingsProvidersV2: Component = () => {
 
   const disconnect = async (providerID: string, name: string) => {
     if (isConfigCustom(providerID)) {
-      await globalSDK.client.auth.remove({ providerID }).catch(() => undefined)
+      await serverSdk.client.auth.remove({ providerID }).catch(() => undefined)
       await disableProvider(providerID, name)
       return
     }
-    await globalSDK.client.auth
+    await serverSdk.client.auth
       .remove({ providerID })
       .then(async () => {
-        await globalSDK.client.global.dispose()
+        await serverSdk.client.global.dispose()
         showToast({
           variant: "success",
           icon: "circle-check",

+ 71 - 99
packages/app/src/components/titlebar.tsx

@@ -1,5 +1,5 @@
 import { createEffect, createMemo, createResource, For, Match, Show, startTransition, Switch, untrack } from "solid-js"
-import { createStore, produce } from "solid-js/store"
+import { createStore } from "solid-js/store"
 import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Icon } from "@opencode-ai/ui/icon"
@@ -17,20 +17,16 @@ import { useSettings } from "@/context/settings"
 import { WindowsAppMenu } from "./windows-app-menu"
 import { applyPath, backPath, forwardPath } from "./titlebar-history"
 import { useServerSync } from "@/context/server-sync"
-import { iife } from "@opencode-ai/core/util/iife"
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
 import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
 import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
 import { makeEventListener } from "@solid-primitives/event-listener"
-import {
-  readSessionTabsRemovedDetail,
-  SESSION_TABS_REMOVED_EVENT,
-  type SessionTabsRemovedDetail,
-} from "@/components/titlebar-session-events"
-import { Persist, persisted } from "@/utils/persist"
+import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
 import { useGlobal } from "@/context/global"
 import { decode64 } from "@/utils/base64"
+import { ServerConnection, useServer } from "@/context/server"
+import { tabHref, useTabs, type Tab } from "@/context/tabs"
 
 type TauriDesktopWindow = {
   startDragging?: () => Promise<void>
@@ -58,8 +54,6 @@ const v2TitlebarHeight = 36
 const minTitlebarZoom = 0.25
 const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
 
-const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
-
 export type TitlebarUpdate = {
   version: () => string | undefined
   installing: () => boolean
@@ -73,6 +67,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
   const language = useLanguage()
   const settings = useSettings()
   const theme = useTheme()
+  const server = useServer()
   const navigate = useNavigate()
   const location = useLocation()
   const params = useParams()
@@ -256,93 +251,38 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
               return `/${base64Encode(project.worktree)}/session`
             }
 
-            type SessionTab = { type: "session"; dirBase64: string; sessionId: string }
-            type Tab = SessionTab
-
-            const tabHref = (tab: Tab) => {
-              if (tab.type === "session") {
-                return makeSessionHref(tab.dirBase64, tab.sessionId)
+            const tabs = useTabs()
+            const tabsStore = tabs.store
+            const tabsStoreActions = tabs
+            const navigateTab = (tab: Tab) => {
+              const href = tabHref(tab)
+              if (tab.server === server.key) {
+                navigate(href)
+                return
               }
-              return "/"
+              void startTransition(() => {
+                server.setActive(tab.server)
+                navigate(href)
+              })
             }
 
-            const [tabsStore, tabsStoreActions] = iife(() => {
-              const [store, setStore] = persisted(Persist.global("tabs"), createStore<Tab[]>([]))
-
-              const actions = {
-                addSessionTab: (tab: Omit<SessionTab, "type">) => {
-                  setStore(
-                    produce((tabs) => {
-                      if (tabs.some((t) => t.type === "session" && tabHref(t) === tabHref({ type: "session", ...tab })))
-                        return
-
-                      tabs.push({ type: "session", ...tab })
-                    }),
-                  )
-                },
-                removeTab: (index: number) => {
-                  if (index < 0) return
-                  void startTransition(() => {
-                    setStore(
-                      produce((tabs) => {
-                        const nextTab = tabs[index + 1] ?? tabs[index - 1]
-                        tabs.splice(index, 1)
-                        if (nextTab) navigate(tabHref(nextTab))
-                        else navigate("/")
-                      }),
-                    )
-                  })
-                },
-                removeSessions: (input: SessionTabsRemovedDetail) => {
-                  void startTransition(() => {
-                    setStore(
-                      produce((tabs) => {
-                        const sessionIDs = new Set(input.sessionIDs)
-                        const currentHref = params.dir && params.id ? makeSessionHref(params.dir, params.id) : undefined
-                        const currentIndex = currentHref
-                          ? tabs.findIndex((tab) => tab.type === "session" && tabHref(tab) === currentHref)
-                          : -1
-                        const currentTab = tabs[currentIndex]
-                        const removedCurrent =
-                          currentTab?.type === "session" &&
-                          atob(currentTab.dirBase64) === input.directory &&
-                          sessionIDs.has(currentTab.sessionId)
-
-                        for (let i = tabs.length - 1; i >= 0; i--) {
-                          const tab = tabs[i]
-                          if (!tab || tab.type !== "session") continue
-                          if (atob(tab.dirBase64) !== input.directory) continue
-                          if (!sessionIDs.has(tab.sessionId)) continue
-                          tabs.splice(i, 1)
-                        }
-
-                        if (!removedCurrent) return
-                        const nextTab =
-                          tabs.slice(currentIndex).find((tab) => tab.type === "session") ??
-                          tabs.slice(0, currentIndex).findLast((tab) => tab.type === "session")
-                        if (nextTab) navigate(tabHref(nextTab))
-                        else navigate("/")
-                      }),
-                    )
-                  })
-                },
-              }
-
-              return [store, actions]
-            })
-
             const matchRoute = (route: LayoutRoute) => {
               if (route.type === "home") return
               if (route.type === "dir-new-sesssion") {
               }
               if (route.type === "session") {
-                const main = tabsStore.find((s) => s.type === "session" && s.sessionId === route.sessionId)
+                const main = tabsStore.find(
+                  (item) =>
+                    item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
+                )
                 if (main) return main
                 const sync = serverSync.createDirSyncContext(route.dir)
                 const session = sync.session.get(route.sessionId)
                 if (session?.parentID) {
                   const parentID = session.parentID
-                  const parent = tabsStore.find((s) => s.type === "session" && s.sessionId === parentID)
+                  const parent = tabsStore.find(
+                    (item) => item.type === "session" && item.server === route.server && item.sessionId === parentID,
+                  )
                   if (parent) return parent
                 }
               }
@@ -352,6 +292,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
 
             createEffect(() => {
               const route = layout.route()
+              if (!tabs.ready()) return
               const tab = currentTab()
               if (tab) return
 
@@ -360,10 +301,12 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                 const session = sync.session.get(route.sessionId)
                 if (!session) return
                 const sessionId = session.parentID ?? session.id
-                tabsStoreActions.addSessionTab({
+                const next = {
+                  server: route.server ?? server.key,
                   dirBase64: route.dirBase64,
                   sessionId,
-                })
+                }
+                tabsStoreActions.addSessionTab(next)
               }
             })
 
@@ -411,7 +354,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                     if (index === -1) index = tabsStore.length - 1
 
                     const next = tabsStore[index]
-                    if (next) navigate(tabHref(next))
+                    if (next) navigateTab(next)
                   },
                 },
                 {
@@ -428,7 +371,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                     if (index === tabsStore.length) index = 0
 
                     const next = tabsStore[index]
-                    if (next) navigate(tabHref(next))
+                    if (next) navigateTab(next)
                   },
                 },
                 ...Array.from({ length: 9 }, (_, i) => {
@@ -443,7 +386,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                     hidden: true,
                     onSelect: () => {
                       const tab = tabsStore[index]
-                      if (tab) navigate(tabHref(tab))
+                      if (tab) navigateTab(tab)
                     },
                   }
                 }),
@@ -482,10 +425,13 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                           )}
                           <TabNavItem
                             href={tabHref(tab)}
+                            server={tab.server}
                             directory={decode64(tab.dirBase64)!}
                             sessionId={tab.sessionId}
+                            onNavigate={() => navigateTab(tab)}
                             onClose={() => tabsStoreActions.removeTab(i())}
                             active={currentTab() === tab}
+                            activeServer={tab.server === server.key}
                           />
                         </>
                       )}
@@ -509,7 +455,11 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                     <NewSessionTabItem
                       href={`/${params.dir}/session`}
                       title={language.t("command.session.new")}
-                      onClose={() => navigate(tabsStore.at(-1) ? tabHref(tabsStore.at(-1)!) : "/")}
+                      onClose={() => {
+                        const tab = tabsStore.at(-1)
+                        if (tab) navigateTab(tab)
+                        else navigate("/")
+                      }}
                     />
                   </Show>
                   <div class="min-w-0 flex-1" />
@@ -736,11 +686,14 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
 
 function TabNavItem(props: {
   href: string
+  server: ServerConnection.Key
   directory: string
   sessionId?: string
   hideClose?: boolean
   onClose: () => void
+  onNavigate: () => void
   active?: boolean
+  activeServer: boolean
 }) {
   const closeTab = (event: MouseEvent) => {
     event.preventDefault()
@@ -748,17 +701,23 @@ function TabNavItem(props: {
     props.onClose()
   }
   const global = useGlobal()
-  const serverCtx = global.createServerCtx(global.servers.default())
+  const serverCtx = createMemo(() => {
+    const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
+    if (conn) return global.createServerCtx(conn)
+  })
+  const dirSyncCtx = createMemo(() => serverCtx()?.sync.createDirSyncContext(props.directory))
 
   const [session] = createResource(
     () => {
-      const dirSyncCtx = serverCtx.sync.createDirSyncContext(props.directory)
-      return props.sessionId ? ([props.sessionId, dirSyncCtx] as const) : undefined
+      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 (
@@ -770,18 +729,26 @@ function TabNavItem(props: {
         closeTab(event)
       }}
     >
-      <Show when={session()}>
+      <Show when={session.latest}>
         {(session) => {
-          const layout = useLayout()
-          const project = createMemo(() => projectForSession(session(), layout.projects.list()))
+          const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
 
           return (
             <a
               href={props.href}
+              onClick={(event) => {
+                event.preventDefault()
+                props.onNavigate()
+              }}
               class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
             >
               <span data-slot="project-avatar-slot">
-                <ProjectTabAvatar project={project()} directory={props.directory} sessionId={session().id} />
+                <ProjectTabAvatar
+                  project={project()}
+                  directory={props.directory}
+                  sessionId={session().id}
+                  activeServer={props.activeServer}
+                />
               </span>
               <span class="min-w-0 flex-1">{session().title}</span>
             </a>
@@ -809,10 +776,15 @@ function TabNavItem(props: {
   )
 }
 
-function ProjectTabAvatar(props: { project?: LocalProject; directory: string; sessionId: string }) {
+function ProjectTabAvatar(props: {
+  project?: LocalProject
+  directory: string
+  sessionId: string
+  activeServer: boolean
+}) {
   const directory = () => props.directory
   const sessionId = () => props.sessionId
-  const state = useSessionTabAvatarState(directory, sessionId)
+  const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
   return (
     <ProjectAvatar
       fallback={displayName(props.project ?? { worktree: props.directory })}

+ 6 - 3
packages/app/src/context/comments.tsx

@@ -3,6 +3,8 @@ import { createStore, reconcile, type SetStoreFunction, type Store } from "solid
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useParams } from "@solidjs/router"
 import { Persist, persisted } from "@/utils/persist"
+import { useServerSDK } from "./server-sdk"
+import type { ServerScope } from "@/utils/server-scope"
 import { createScopedCache } from "@/utils/scoped-cache"
 import { uuid } from "@/utils/uuid"
 import type { SelectedLineRange } from "@/context/file"
@@ -166,11 +168,11 @@ export function createCommentSessionForTest(comments: Record<string, LineComment
   return createCommentSessionState(store, setStore)
 }
 
-function createCommentSession(dir: string, id: string | undefined) {
+function createCommentSession(scope: ServerScope, dir: string, id: string | undefined) {
   const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
 
   const [store, setStore, _, ready] = persisted(
-    Persist.scoped(dir, id, "comments", [legacy]),
+    Persist.serverScoped(scope, dir, id, "comments", [legacy]),
     createStore<CommentStore>({
       comments: {},
     }),
@@ -200,11 +202,12 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
   gate: false,
   init: () => {
     const params = useParams()
+    const serverSDK = useServerSDK()
     const cache = createScopedCache(
       (key) => {
         const decoded = decodeSessionKey(key)
         return createRoot((dispose) => ({
-          value: createCommentSession(decoded.dir, decoded.id === WORKSPACE_KEY ? undefined : decoded.id),
+          value: createCommentSession(serverSDK.scope, decoded.dir, decoded.id === WORKSPACE_KEY ? undefined : decoded.id),
           dispose,
         }))
       },

+ 5 - 4
packages/app/src/context/directory-sync.ts

@@ -276,7 +276,7 @@ export const createDirSyncContext = (
 
   const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
     if (sessionIDs.length === 0) return
-    clearSessionPrefetch(directory, sessionIDs)
+    clearSessionPrefetch(serverSDK.scope, directory, sessionIDs)
     for (const sessionID of sessionIDs) {
       serverSync.todo.set(sessionID, undefined)
     }
@@ -348,6 +348,7 @@ export const createDirSyncContext = (
           setMeta("cursor", key, next.cursor)
           setMeta("complete", key, next.complete)
           setSessionPrefetch({
+            scope: serverSDK.scope,
             directory: input.directory,
             sessionID: input.sessionID,
             limit: message.length,
@@ -438,7 +439,7 @@ export const createDirSyncContext = (
 
         touch(directory, setStore, sessionID)
 
-        const seeded = getSessionPrefetch(directory, sessionID)
+        const seeded = getSessionPrefetch(serverSDK.scope, directory, sessionID)
         if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
           batch(() => {
             setMeta("limit", key, seeded.limit)
@@ -449,10 +450,10 @@ export const createDirSyncContext = (
         }
 
         return runInflight(inflight, key, async () => {
-          const pending = getSessionPrefetchPromise(directory, sessionID)
+          const pending = getSessionPrefetchPromise(serverSDK.scope, directory, sessionID)
           if (pending) {
             await pending
-            const seeded = getSessionPrefetch(directory, sessionID)
+            const seeded = getSessionPrefetch(serverSDK.scope, directory, sessionID)
             if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
               batch(() => {
                 setMeta("limit", key, seeded.limit)

+ 5 - 2
packages/app/src/context/file.tsx

@@ -21,6 +21,8 @@ import {
   touchFileContent,
 } from "./file/content-cache"
 import { createFileViewCache } from "./file/view-cache"
+import { useServerSDK } from "./server-sdk"
+import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
 import { createFileTreeStore } from "./file/tree-store"
 import { invalidateFromWatcher } from "./file/watcher"
 import {
@@ -56,12 +58,13 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
     const sdk = useSDK()
     useSync()
     const params = useParams()
+    const serverSDK = useServerSDK()
     const language = useLanguage()
     const layout = useLayout()
 
     const scope = createMemo(() => sdk.directory)
     const path = createPathHelpers(scope)
-    const tabs = layout.tabs(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
+    const tabs = layout.tabs(() => SessionStateKey.from(serverSDK.scope, SessionRouteKey.fromRoute(params.dir, params.id)))
 
     const inflight = new Map<string, Promise<void>>()
     const [store, setStore] = createStore<{
@@ -107,7 +110,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
       })
     })
 
-    const viewCache = createFileViewCache()
+    const viewCache = createFileViewCache(serverSDK.scope)
     const view = createMemo(() => viewCache.load(scope(), params.id))
 
     const ensure = (file: string) => {

+ 5 - 4
packages/app/src/context/file/view-cache.ts

@@ -3,6 +3,7 @@ import { createStore, produce } from "solid-js/store"
 import { Persist, persisted } from "@/utils/persist"
 import { createScopedCache } from "@/utils/scoped-cache"
 import type { FileViewState, SelectedLineRange } from "./types"
+import type { ServerScope } from "@/utils/server-scope"
 
 const WORKSPACE_KEY = "__workspace__"
 const MAX_FILE_VIEW_SESSIONS = 20
@@ -33,11 +34,11 @@ function equalSelectedLines(a: SelectedLineRange | null | undefined, b: Selected
   )
 }
 
-function createViewSession(dir: string, id: string | undefined) {
+function createViewSession(scope: ServerScope, dir: string, id: string | undefined) {
   const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
 
   const [view, setView, _, ready] = persisted(
-    Persist.scoped(dir, id, "file-view", [legacyViewKey]),
+    Persist.serverScoped(scope, dir, id, "file-view", [legacyViewKey]),
     createStore<{
       file: Record<string, FileViewState>
     }>({
@@ -119,14 +120,14 @@ function createViewSession(dir: string, id: string | undefined) {
   }
 }
 
-export function createFileViewCache() {
+export function createFileViewCache(scope: ServerScope) {
   const cache = createScopedCache(
     (key) => {
       const split = key.lastIndexOf("\n")
       const dir = split >= 0 ? key.slice(0, split) : key
       const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
       return createRoot((dispose) => ({
-        value: createViewSession(dir, id === WORKSPACE_KEY ? undefined : id),
+        value: createViewSession(scope, dir, id === WORKSPACE_KEY ? undefined : id),
         dispose,
       }))
     },

+ 14 - 1
packages/app/src/context/global-sync/bootstrap.test.ts

@@ -3,8 +3,9 @@ import { createStore } from "solid-js/store"
 import { QueryClient } from "@tanstack/solid-query"
 import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
 import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
-import { bootstrapDirectory } from "./bootstrap"
+import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
 import type { State, VcsCache } from "./types"
+import { ServerScope } from "@/utils/server-scope"
 
 const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
 
@@ -45,6 +46,7 @@ describe("bootstrapDirectory", () => {
 
     await bootstrapDirectory({
       directory: "/project",
+      scope: ServerScope.local,
       mcp: false,
       global: {
         config: {} satisfies Config,
@@ -89,3 +91,14 @@ describe("bootstrapDirectory", () => {
     expect(mcpReads).toEqual([])
   })
 })
+
+describe("query keys", () => {
+  test("partitions identical directories by server scope", () => {
+    const client = {} as OpencodeClient
+    const remote = "https://debian.example" as typeof ServerScope.local
+
+    expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
+    expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
+    expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual(["https://debian.example", null, "providers"])
+  })
+})

+ 26 - 22
packages/app/src/context/global-sync/bootstrap.ts

@@ -20,6 +20,7 @@ import { formatServerError } from "@/utils/server-errors"
 import { QueryClient, queryOptions } from "@tanstack/solid-query"
 import { loadMcpQuery } from "../server-sync"
 import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
+import { ScopedKey, type ServerScope } from "@/utils/server-scope"
 
 type GlobalStore = {
   ready: boolean
@@ -59,8 +60,8 @@ function errors(list: PromiseSettledResult<unknown>[]) {
 
 const providerRev = new Map<string, number>()
 
-export function clearProviderRev(directory: string) {
-  providerRev.delete(directory)
+export function clearProviderRev(scope: ServerScope, directory: string) {
+  providerRev.delete(ScopedKey.from(scope, directory))
 }
 
 function runAll(list: Array<() => Promise<unknown>>) {
@@ -83,15 +84,15 @@ function showErrors(input: {
   })
 }
 
-export const loadGlobalConfigQuery = (sdk: OpencodeClient) =>
+export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
   queryOptions({
-    queryKey: ["config"],
+    queryKey: [scope, "config"],
     queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
   })
 
-export const loadProjectsQuery = (sdk: OpencodeClient) =>
+export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
   queryOptions({
-    queryKey: ["project"],
+    queryKey: [scope, "project"],
     queryFn: () =>
       retry(() =>
         sdk.project.list().then((x) => {
@@ -106,6 +107,7 @@ export const loadProjectsQuery = (sdk: OpencodeClient) =>
 
 export async function bootstrapGlobal(input: {
   serverSDK: OpencodeClient
+  scope: ServerScope
   requestFailedTitle: string
   translate: (key: string, vars?: Record<string, string | number>) => string
   formatMoreCount: (count: number) => string
@@ -113,12 +115,12 @@ export async function bootstrapGlobal(input: {
   queryClient: QueryClient
 }) {
   const slow = [
-    () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.serverSDK)),
-    () => input.queryClient.fetchQuery(loadProvidersQuery(null, input.serverSDK)),
-    () => input.queryClient.fetchQuery(loadPathQuery(null, input.serverSDK)),
+    () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
+    () => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)),
+    () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)),
     () =>
       input.queryClient
-        .fetchQuery(loadProjectsQuery(input.serverSDK))
+        .fetchQuery(loadProjectsQuery(input.scope, input.serverSDK))
         .then((data) => input.setGlobalStore("project", data)),
   ]
   await runAll(slow)
@@ -178,26 +180,27 @@ function warmSessions(input: {
   ).then(() => undefined)
 }
 
-export const loadProvidersQuery = (directory: string | null, sdk: OpencodeClient) =>
+export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
   queryOptions({
-    queryKey: [directory, "providers"],
+    queryKey: [scope, directory, "providers"],
     queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))),
   })
 
-export const loadAgentsQuery = (directory: string | null, sdk: OpencodeClient) =>
+export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
   queryOptions({
-    queryKey: [directory, "agents"],
+    queryKey: [scope, directory, "agents"],
     queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))),
   })
 
-export const loadPathQuery = (directory: string | null, sdk: OpencodeClient) =>
+export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
   queryOptions<Path>({
-    queryKey: [directory, "path"],
+    queryKey: [scope, directory, "path"],
     queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
   })
 
 export async function bootstrapDirectory(input: {
   directory: string
+  scope: ServerScope
   mcp: boolean
   sdk: OpencodeClient
   store: Store<State>
@@ -223,14 +226,15 @@ export async function bootstrapDirectory(input: {
   }
   if (loading) input.setStore("status", "partial")
 
-  const rev = (providerRev.get(input.directory) ?? 0) + 1
-  providerRev.set(input.directory, rev)
+  const revKey = ScopedKey.from(input.scope, input.directory)
+  const rev = (providerRev.get(revKey) ?? 0) + 1
+  providerRev.set(revKey, rev)
   ;(async () => {
     const slow = [
       () => Promise.resolve(input.loadSessions(input.directory)),
       () =>
         input.queryClient
-          .ensureQueryData(loadAgentsQuery(input.directory, input.sdk))
+          .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk))
           .then((data) => input.setStore("agent", data)),
       () =>
         retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
@@ -239,7 +243,7 @@ export async function bootstrapDirectory(input: {
         (() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
       !seededPath &&
         (() =>
-          input.queryClient.ensureQueryData(loadPathQuery(input.directory, input.sdk)).then((data) => {
+          input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => {
             const next = projectID(data.directory ?? input.directory, input.global.project)
             if (next) input.setStore("project", next)
           })),
@@ -305,9 +309,9 @@ export async function bootstrapDirectory(input: {
           }),
         ),
       () => Promise.resolve(input.loadSessions(input.directory)),
-      input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk))),
+      input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
       () =>
-        input.queryClient.fetchQuery(loadProvidersQuery(input.directory, input.sdk)).catch((err) => {
+        input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
           const project = getFilename(input.directory)
           showToast({
             variant: "error",

+ 15 - 6
packages/app/src/context/global-sync/child-store.test.ts

@@ -4,9 +4,16 @@ import { createStore } from "solid-js/store"
 import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
 import type { State } from "./types"
 import type { QueryOptionsApi } from "../server-sync"
+import { ServerScope } from "@/utils/server-scope"
 
 let createChildStoreManager: typeof import("./child-store").createChildStoreManager
 const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
+const persist: typeof import("@/utils/persist").persisted = (_target, store) => [
+  store[0],
+  store[1],
+  null,
+  Object.assign(() => true, { promise: undefined }),
+]
 
 const child = () => createStore({} as State)
 const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
@@ -42,12 +49,6 @@ function createOwner(callback: (owner: Owner) => void) {
 }
 
 beforeAll(async () => {
-  mock.module("@/utils/persist", () => ({
-    Persist: {
-      workspace: (...parts: string[]) => parts.join(":"),
-    },
-    persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true],
-  }))
   mock.module("@tanstack/solid-query", () => ({
     useQuery: (options: () => { queryKey?: unknown[]; enabled?: boolean }) => {
       querySingles.push(options)
@@ -80,6 +81,8 @@ describe("createChildStoreManager", () => {
 
     const manager = createChildStoreManager({
       owner,
+      scope: ServerScope.local,
+      persist,
       isBooting: () => false,
       isLoadingSessions: () => false,
       onBootstrap() {},
@@ -109,6 +112,8 @@ describe("createChildStoreManager", () => {
     const dispose = createOwner((owner) => {
       manager = createChildStoreManager({
         owner,
+        scope: ServerScope.local,
+        persist,
         isBooting: () => false,
         isLoadingSessions: () => false,
         onBootstrap(directory) {
@@ -140,6 +145,8 @@ describe("createChildStoreManager", () => {
     const dispose = createOwner((owner) => {
       manager = createChildStoreManager({
         owner,
+        scope: ServerScope.local,
+        persist,
         isBooting: () => false,
         isLoadingSessions: () => false,
         onBootstrap() {},
@@ -171,6 +178,8 @@ describe("createChildStoreManager", () => {
     const dispose = createOwner((owner) => {
       manager = createChildStoreManager({
         owner,
+        scope: ServerScope.local,
+        persist,
         isBooting: () => false,
         isLoadingSessions: () => false,
         onBootstrap() {},

+ 9 - 6
packages/app/src/context/global-sync/child-store.ts

@@ -18,9 +18,12 @@ import { useQuery } from "@tanstack/solid-query"
 import { QueryOptionsApi } from "../server-sync"
 import { directoryKey, type DirectoryKey } from "./utils"
 import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
+import type { ServerScope } from "@/utils/server-scope"
 
 export function createChildStoreManager(input: {
   owner: Owner
+  scope: ServerScope
+  persist: typeof persisted
   isBooting: (directory: string) => boolean
   isLoadingSessions: (directory: string) => boolean
   onBootstrap: (directory: string) => void
@@ -147,8 +150,8 @@ export function createChildStoreManager(input: {
     if (!key) console.error("No directory provided")
     if (!children[key]) {
       const vcs = runWithOwner(input.owner, () =>
-        persisted(
-          Persist.workspace(directory, "vcs", ["vcs.v1"]),
+        input.persist(
+          Persist.serverWorkspace(input.scope, directory, "vcs", ["vcs.v1"]),
           createStore({ value: undefined as VcsInfo | undefined }),
         ),
       )
@@ -157,8 +160,8 @@ export function createChildStoreManager(input: {
       vcsCache.set(key, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
 
       const meta = runWithOwner(input.owner, () =>
-        persisted(
-          Persist.workspace(directory, "project", ["project.v1"]),
+        input.persist(
+          Persist.serverWorkspace(input.scope, directory, "project", ["project.v1"]),
           createStore({ value: undefined as ProjectMeta | undefined }),
         ),
       )
@@ -166,8 +169,8 @@ export function createChildStoreManager(input: {
       metaCache.set(key, { store: meta[0], setStore: meta[1], ready: meta[3] })
 
       const icon = runWithOwner(input.owner, () =>
-        persisted(
-          Persist.workspace(directory, "icon", ["icon.v1"]),
+        input.persist(
+          Persist.serverWorkspace(input.scope, directory, "icon", ["icon.v1"]),
           createStore({ value: undefined as string | undefined }),
         ),
       )

+ 28 - 14
packages/app/src/context/global-sync/session-prefetch.test.ts

@@ -7,14 +7,18 @@ import {
   setSessionPrefetch,
   shouldSkipSessionPrefetch,
 } from "./session-prefetch"
+import { ServerScope } from "@/utils/server-scope"
+
+const scope = ServerScope.local
 
 describe("session prefetch", () => {
   test("stores and clears message metadata by directory", () => {
-    clearSessionPrefetch("/tmp/a", ["ses_1"])
-    clearSessionPrefetch("/tmp/b", ["ses_1"])
+    clearSessionPrefetch(scope, "/tmp/a", ["ses_1"])
+    clearSessionPrefetch(scope, "/tmp/b", ["ses_1"])
 
     setSessionPrefetch({
       directory: "/tmp/a",
+      scope,
       sessionID: "ses_1",
       limit: 200,
       cursor: "abc",
@@ -22,21 +26,22 @@ describe("session prefetch", () => {
       at: 123,
     })
 
-    expect(getSessionPrefetch("/tmp/a", "ses_1")).toEqual({ limit: 200, cursor: "abc", complete: false, at: 123 })
-    expect(getSessionPrefetch("/tmp/b", "ses_1")).toBeUndefined()
+    expect(getSessionPrefetch(scope, "/tmp/a", "ses_1")).toEqual({ limit: 200, cursor: "abc", complete: false, at: 123 })
+    expect(getSessionPrefetch(scope, "/tmp/b", "ses_1")).toBeUndefined()
 
-    clearSessionPrefetch("/tmp/a", ["ses_1"])
+    clearSessionPrefetch(scope, "/tmp/a", ["ses_1"])
 
-    expect(getSessionPrefetch("/tmp/a", "ses_1")).toBeUndefined()
+    expect(getSessionPrefetch(scope, "/tmp/a", "ses_1")).toBeUndefined()
   })
 
   test("dedupes inflight work", async () => {
-    clearSessionPrefetch("/tmp/c", ["ses_2"])
+    clearSessionPrefetch(scope, "/tmp/c", ["ses_2"])
 
     let calls = 0
     const run = () =>
       runSessionPrefetch({
         directory: "/tmp/c",
+        scope,
         sessionID: "ses_2",
         task: async () => {
           calls += 1
@@ -52,15 +57,24 @@ describe("session prefetch", () => {
   })
 
   test("clears a whole directory", () => {
-    setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_1", limit: 10, cursor: "a", complete: true, at: 1 })
-    setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_2", limit: 20, cursor: "b", complete: false, at: 2 })
-    setSessionPrefetch({ directory: "/tmp/e", sessionID: "ses_1", limit: 30, cursor: "c", complete: true, at: 3 })
+    setSessionPrefetch({ scope, directory: "/tmp/d", sessionID: "ses_1", limit: 10, cursor: "a", complete: true, at: 1 })
+    setSessionPrefetch({ scope, directory: "/tmp/d", sessionID: "ses_2", limit: 20, cursor: "b", complete: false, at: 2 })
+    setSessionPrefetch({ scope, directory: "/tmp/e", sessionID: "ses_1", limit: 30, cursor: "c", complete: true, at: 3 })
+
+    clearSessionPrefetchDirectory(scope, "/tmp/d")
+
+    expect(getSessionPrefetch(scope, "/tmp/d", "ses_1")).toBeUndefined()
+    expect(getSessionPrefetch(scope, "/tmp/d", "ses_2")).toBeUndefined()
+    expect(getSessionPrefetch(scope, "/tmp/e", "ses_1")).toEqual({ limit: 30, cursor: "c", complete: true, at: 3 })
+  })
 
-    clearSessionPrefetchDirectory("/tmp/d")
+  test("isolates identical directories and sessions by server scope", () => {
+    const remote = "https://debian.example" as ServerScope
+    setSessionPrefetch({ scope, directory: "/repo", sessionID: "ses_1", limit: 10, complete: true, at: 1 })
+    setSessionPrefetch({ scope: remote, directory: "/repo", sessionID: "ses_1", limit: 20, complete: true, at: 2 })
 
-    expect(getSessionPrefetch("/tmp/d", "ses_1")).toBeUndefined()
-    expect(getSessionPrefetch("/tmp/d", "ses_2")).toBeUndefined()
-    expect(getSessionPrefetch("/tmp/e", "ses_1")).toEqual({ limit: 30, cursor: "c", complete: true, at: 3 })
+    expect(getSessionPrefetch(scope, "/repo", "ses_1")?.limit).toBe(10)
+    expect(getSessionPrefetch(remote, "/repo", "ses_1")?.limit).toBe(20)
   })
 
   test("refreshes stale first-page prefetched history", () => {

+ 22 - 15
packages/app/src/context/global-sync/session-prefetch.ts

@@ -1,4 +1,6 @@
-const key = (directory: string, sessionID: string) => `${directory}\n${sessionID}`
+import { ScopedKey, type ServerScope } from "@/utils/server-scope"
+
+const key = (scope: ServerScope, directory: string, sessionID: string) => ScopedKey.from(scope, directory, sessionID)
 
 export const SESSION_PREFETCH_TTL = 15_000
 
@@ -27,28 +29,32 @@ const rev = new Map<string, number>()
 
 const version = (id: string) => rev.get(id) ?? 0
 
-export function getSessionPrefetch(directory: string, sessionID: string) {
-  return cache.get(key(directory, sessionID))
+export function getSessionPrefetch(scope: ServerScope, directory: string, sessionID: string) {
+  return cache.get(key(scope, directory, sessionID))
 }
 
-export function getSessionPrefetchPromise(directory: string, sessionID: string) {
-  return inflight.get(key(directory, sessionID))
+export function getSessionPrefetchPromise(scope: ServerScope, directory: string, sessionID: string) {
+  return inflight.get(key(scope, directory, sessionID))
 }
 
-export function clearSessionPrefetchInflight() {
-  inflight.clear()
+export function clearSessionPrefetchInflight(scope: ServerScope) {
+  const prefix = ScopedKey.prefix(scope)
+  for (const id of inflight.keys()) {
+    if (id.startsWith(prefix)) inflight.delete(id)
+  }
 }
 
-export function isSessionPrefetchCurrent(directory: string, sessionID: string, value: number) {
-  return version(key(directory, sessionID)) === value
+export function isSessionPrefetchCurrent(scope: ServerScope, directory: string, sessionID: string, value: number) {
+  return version(key(scope, directory, sessionID)) === value
 }
 
 export function runSessionPrefetch(input: {
   directory: string
+  scope: ServerScope
   sessionID: string
   task: (value: number) => Promise<Meta | undefined>
 }) {
-  const id = key(input.directory, input.sessionID)
+  const id = key(input.scope, input.directory, input.sessionID)
   const pending = inflight.get(id)
   if (pending) return pending
 
@@ -64,13 +70,14 @@ export function runSessionPrefetch(input: {
 
 export function setSessionPrefetch(input: {
   directory: string
+  scope: ServerScope
   sessionID: string
   limit: number
   cursor?: string
   complete: boolean
   at?: number
 }) {
-  cache.set(key(input.directory, input.sessionID), {
+  cache.set(key(input.scope, input.directory, input.sessionID), {
     limit: input.limit,
     cursor: input.cursor,
     complete: input.complete,
@@ -78,18 +85,18 @@ export function setSessionPrefetch(input: {
   })
 }
 
-export function clearSessionPrefetch(directory: string, sessionIDs: Iterable<string>) {
+export function clearSessionPrefetch(scope: ServerScope, directory: string, sessionIDs: Iterable<string>) {
   for (const sessionID of sessionIDs) {
     if (!sessionID) continue
-    const id = key(directory, sessionID)
+    const id = key(scope, directory, sessionID)
     rev.set(id, version(id) + 1)
     cache.delete(id)
     inflight.delete(id)
   }
 }
 
-export function clearSessionPrefetchDirectory(directory: string) {
-  const prefix = `${directory}\n`
+export function clearSessionPrefetchDirectory(scope: ServerScope, directory: string) {
+  const prefix = ScopedKey.prefix(scope, directory)
   const keys = new Set([...cache.keys(), ...inflight.keys()])
   for (const id of keys) {
     if (!id.startsWith(prefix)) continue

+ 26 - 124
packages/app/src/context/global.tsx

@@ -1,17 +1,17 @@
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createEffect, createMemo, createRoot } from "solid-js"
 import { createStore } from "solid-js/store"
-import { ServerConnection, useServer } from "./server"
+import { createServerProjects, ServerConnection, useServer } from "./server"
 import { useServerHealth } from "@/utils/server-health"
-import { QueryClient } from "@tanstack/solid-query"
 import { createServerSdkContext } from "./server-sdk"
 import { createServerSyncContext } from "./server-sync"
 import { getOwner } from "solid-js/web"
-import { Persist, persisted } from "@/utils/persist"
+import { QueryClient } from "@tanstack/solid-query"
+import type { ServerScope } from "@/utils/server-scope"
 
 export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
   name: "Global",
-  init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
+  init: () => {
     const server = useServer()
     const serverHealth = useServerHealth(
       () => server.list,
@@ -23,8 +23,6 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
       },
     })
 
-    const serversAndProjects = createServersAndProjectStore()
-
     const settingsServer = createMemo(() => {
       const list = server.list
       return list.find((conn) => ServerConnection.key(conn) === store.settings.serverKey) ?? list[0]
@@ -43,18 +41,25 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
 
     const owner = getOwner()
 
+    const ensureServerCtx = (conn: ServerConnection.Any) => {
+      const key = ServerConnection.key(conn)
+      const existing = serverCtxs.get(key)
+      if (existing) return existing.serverCtx
+      const root = createRoot((dispose) => {
+        const serverCtx = createServerCtx(conn, server.scope(key), server.projects.forServer(key))
+        return { dispose, serverCtx }
+      }, owner as any)
+      serverCtxs.set(key, root)
+      return root.serverCtx
+    }
+
     createMemo(() => {
       for (const conn of server.list) {
-        const key = ServerConnection.key(conn)
-        if (!serverCtxs.has(key)) {
-          const root = createRoot((dispose) => {
-            const serverCtx = createServerCtx(conn, serversAndProjects)
-            return { dispose, serverCtx }
-          }, owner as any)
-          serverCtxs.set(key, root)
-        }
+        ensureServerCtx(conn)
       }
+    })
 
+    createEffect(() => {
       for (const [key] of serverCtxs) {
         if (!server.list.find((conn) => ServerConnection.key(conn) === key)) {
           const { dispose } = serverCtxs.get(key)!
@@ -64,16 +69,10 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
       }
     })
 
-    const allServers = createMemo(
-      (): Array<ServerConnection.Any> =>
-        resolveServerList({ stored: serversAndProjects.store.list, props: props.servers }),
-    )
-
     return {
       servers: {
-        list: allServers,
+        list: () => server.list,
         health: serverHealth,
-        default: () => allServers().find((s) => ServerConnection.key(s) === props.defaultServer) ?? allServers()[0]!,
       },
       settings: {
         server: {
@@ -87,33 +86,16 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
         },
       },
       createServerCtx(conn: ServerConnection.Any) {
-        const key = ServerConnection.key(conn)
-        const ctx = serverCtxs.get(key)
-        if (!ctx) return createServerCtx(conn, serversAndProjects)
-        return ctx.serverCtx
+        return ensureServerCtx(conn)
       },
     }
   },
 })
 
-type StoredProject = { worktree: string; expanded: boolean }
-type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
-
-const createServersAndProjectStore = () => {
-  const [store, setStore, _, ready] = persisted(
-    Persist.global("server", ["server.v3"]),
-    createStore({
-      list: [] as StoredServer[],
-      projects: {} as Record<string, StoredProject[]>,
-      lastProject: {} as Record<string, string>,
-    }),
-  )
-  return { store, setStore, ready }
-}
-
 function createServerCtx(
   conn: ServerConnection.Any,
-  { store, setStore }: ReturnType<typeof createServersAndProjectStore>,
+  scope: ServerScope,
+  projects: ReturnType<typeof createServerProjects>,
 ) {
   const queryClient = new QueryClient({
     defaultOptions: {
@@ -124,13 +106,9 @@ function createServerCtx(
       },
     },
   })
-
-  const sdk = createServerSdkContext(conn)
+  const sdk = createServerSdkContext(conn, scope)
   const sync = createServerSyncContext(sdk)
 
-  const key = ServerConnection.key(conn)
-  const storeKey = projectsKey(key)
-
   function enrich(project: { worktree: string; expanded: boolean }) {
     const [childStore] = sync.child(project.worktree, { bootstrap: false })
     const projectID = childStore.project
@@ -148,7 +126,7 @@ function createServerCtx(
     return base
   }
 
-  const projectsList = createMemo(() => (store.projects[storeKey] ?? []).map(enrich))
+  const projectsList = createMemo(() => projects.list().map(enrich))
 
   const isLocal =
     (conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
@@ -159,45 +137,8 @@ function createServerCtx(
     sync,
     isLocal,
     projects: {
+      ...projects,
       list: projectsList,
-      open(directory: string) {
-        const current = store.projects[storeKey] ?? []
-        if (current.find((x) => x.worktree === directory)) return
-        setStore("projects", storeKey, [{ worktree: directory, expanded: true }, ...current])
-      },
-      close(directory: string) {
-        const current = store.projects[storeKey] ?? []
-        setStore(
-          "projects",
-          storeKey,
-          current.filter((x) => x.worktree !== directory),
-        )
-      },
-      expand(directory: string) {
-        const current = store.projects[storeKey] ?? []
-        const index = current.findIndex((x) => x.worktree === directory)
-        if (index !== -1) setStore("projects", storeKey, index, "expanded", true)
-      },
-      collapse(directory: string) {
-        const current = store.projects[storeKey] ?? []
-        const index = current.findIndex((x) => x.worktree === directory)
-        if (index !== -1) setStore("projects", storeKey, index, "expanded", false)
-      },
-      move(directory: string, toIndex: number) {
-        const current = store.projects[storeKey] ?? []
-        const fromIndex = current.findIndex((x) => x.worktree === directory)
-        if (fromIndex === -1 || fromIndex === toIndex) return
-        const result = [...current]
-        const [item] = result.splice(fromIndex, 1)
-        result.splice(toIndex, 0, item)
-        setStore("projects", storeKey, result)
-      },
-      last() {
-        return store.lastProject[storeKey]
-      },
-      touch(directory: string) {
-        setStore("lastProject", storeKey, directory)
-      },
     },
   }
 }
@@ -208,42 +149,3 @@ function isLocalHost(url: string) {
   const host = url.replace(/^https?:\/\//, "").split(":")[0]
   if (host === "localhost" || host === "127.0.0.1") return "local"
 }
-
-function projectsKey(key: ServerConnection.Key) {
-  if (key === "sidecar") return "local"
-  if (isLocalHost(key)) return "local"
-  return key
-}
-
-export function resolveServerList(input: {
-  props?: Array<ServerConnection.Any>
-  stored: StoredServer[]
-}): Array<ServerConnection.Any> {
-  const deduped = new Map<ServerConnection.Key, ServerConnection.Any>(
-    input.props?.map((v) => [ServerConnection.key(v), v]) ?? [],
-  )
-
-  for (const value of input.stored) {
-    const conn: ServerConnection.Http =
-      typeof value === "string"
-        ? {
-            type: "http" as const,
-            http: { url: value },
-          }
-        : "http" in value
-          ? value
-          : { type: "http", http: value }
-    const key = ServerConnection.key(conn)
-
-    const existing = deduped.get(key)
-    if (existing)
-      deduped.set(key, {
-        ...existing,
-        ...conn,
-        http: { ...existing.http, ...conn.http },
-      })
-    else deduped.set(key, conn)
-  }
-
-  return [...deduped.values()]
-}

+ 1 - 0
packages/app/src/context/language.tsx

@@ -198,6 +198,7 @@ if (warm !== "en") void loadDict(warm)
 
 export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
   name: "Language",
+  gate: false,
   init: (props: { locale?: Locale }) => {
     const initial = props.locale ?? readStoredLocale() ?? detectLocale()
     const [store, setStore, _, ready] = persisted(

+ 24 - 10
packages/app/src/context/layout.tsx

@@ -14,6 +14,7 @@ import { same } from "@/utils/same"
 import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
 import { createPathHelpers } from "./file/path"
 import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
+import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
 import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
 
 export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
@@ -64,6 +65,7 @@ type SessionView = {
 }
 
 type TabHandoff = {
+  scope: ServerScope
   dir: string
   id: string
   at: number
@@ -87,7 +89,7 @@ function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string):
 }
 
 const sessionPath = (key: string) => {
-  const dir = key.split("/")[0]
+  const dir = SessionStateKey.route(key).split("/")[0]
   if (!dir) return
   const root = decode64(dir)
   if (!root) return
@@ -135,13 +137,18 @@ const currentRoute = (pathname: string): LayoutRoute => {
 
 export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
   name: "Layout",
+  gate: false,
   init: () => {
-    const globalSdk = useServerSDK()
+    const serverSdk = useServerSDK()
     const serverSync = useServerSync()
     const server = useServer()
     const platform = usePlatform()
     const location = useLocation()
-    const route = createMemo(() => currentRoute(location.pathname))
+    const route = createMemo(() => {
+      const value = currentRoute(location.pathname)
+      if (value.type === "home") return value
+      return { ...value, server: server.key }
+    })
 
     const isRecord = (value: unknown): value is Record<string, unknown> =>
       typeof value === "object" && value !== null && !Array.isArray(value)
@@ -186,7 +193,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         }
       })()
 
-      const sessionTabs = value.sessionTabs
+      const sessionTabs = migrateLegacySessionStateKeys(value.sessionTabs)
+      const sessionView = migrateLegacySessionStateKeys(value.sessionView)
       const migratedSessionTabs = (() => {
         if (!isRecord(sessionTabs)) return sessionTabs
 
@@ -215,7 +223,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         migratedSidebar === sidebar &&
         migratedReview === review &&
         migratedFileTree === fileTree &&
-        migratedSessionTabs === sessionTabs
+        migratedSessionTabs === value.sessionTabs &&
+        sessionView === value.sessionView
       ) {
         return value
       }
@@ -226,10 +235,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         review: migratedReview,
         fileTree: migratedFileTree,
         sessionTabs: migratedSessionTabs,
+        sessionView,
       }
     }
 
-    const target = Persist.global("layout", ["layout.v6"])
+    const target = Persist.serverGlobal(serverSdk.scope, "layout", ["layout.v6"])
     const [store, setStore, _, ready] = persisted(
       { ...target, migrate },
       createStore({
@@ -282,15 +292,19 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
 
     const dropSessionState = (keys: string[]) => {
       for (const key of keys) {
-        const parts = key.split("/")
+        const scope = SessionStateKey.scope(key)
+        const parts = SessionStateKey.route(key).split("/")
         const dir = parts[0]
         const session = parts[1]
         if (!dir) continue
 
         for (const entry of SESSION_STATE_KEYS) {
-          const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key)
+          const target = session
+            ? Persist.serverSession(scope, dir, session, entry.key)
+            : Persist.serverWorkspace(scope, dir, entry.key)
           void removePersisted(target, platform)
 
+          if (scope !== ServerScope.local) continue
           const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
           void removePersisted({ key: legacyKey }, platform)
         }
@@ -515,7 +529,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
           continue
         }
 
-        void globalSdk.client.project
+        void serverSdk.client.project
           .update({ projectID: project.id, directory: worktree, icon: { color } })
           .catch(() => {
             if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
@@ -551,7 +565,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
       handoff: {
         tabs: createMemo(() => store.handoff?.tabs),
         setTabs(dir: string, id: string) {
-          setStore("handoff", "tabs", { dir, id, at: Date.now() })
+          setStore("handoff", "tabs", { scope: server.scope(), dir, id, at: Date.now() })
         },
         clearTabs() {
           if (!store.handoff?.tabs) return

+ 9 - 6
packages/app/src/context/local.tsx

@@ -9,6 +9,8 @@ import { Persist, persisted } from "@/utils/persist"
 import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
 import { useSDK } from "./sdk"
 import { useSync } from "./sync"
+import { useServerSDK } from "./server-sdk"
+import { ScopedKey, type ServerScope } from "@/utils/server-scope"
 
 export type ModelKey = { providerID: string; modelID: string; variant?: string }
 
@@ -25,7 +27,7 @@ type Saved = {
 const WORKSPACE_KEY = "__workspace__"
 const handoff = new Map<string, State>()
 
-const handoffKey = (dir: string, id: string) => `${dir}\n${id}`
+const handoffKey = (scope: ServerScope, dir: string, id: string) => ScopedKey.from(scope, dir, id)
 
 const migrate = (value: unknown) => {
   if (!value || typeof value !== "object") return { session: {} }
@@ -57,6 +59,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
     const params = useParams()
     const sdk = useSDK()
     const sync = useSync()
+    const serverSDK = useServerSDK()
     const providers = useProviders()
     const models = useModels()
 
@@ -66,7 +69,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
 
     const [saved, setSaved] = persisted(
       {
-        ...Persist.workspace(sdk.directory, "model-selection", ["model-selection.v1"]),
+        ...Persist.serverWorkspace(serverSDK.scope, sdk.directory, "model-selection", ["model-selection.v1"]),
         migrate,
       },
       createStore<Saved>({
@@ -121,14 +124,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
     const scope = createMemo<State | undefined>(() => {
       const session = id()
       if (!session) return store.draft
-      return saved.session[session] ?? handoff.get(handoffKey(sdk.directory, session))
+      return saved.session[session] ?? handoff.get(handoffKey(serverSDK.scope, sdk.directory, session))
     })
 
     createEffect(() => {
       const session = id()
       if (!session) return
 
-      const key = handoffKey(sdk.directory, session)
+      const key = handoffKey(serverSDK.scope, sdk.directory, session)
       const next = handoff.get(key)
       if (!next) return
       if (saved.session[session] !== undefined) {
@@ -377,7 +380,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
             return
           }
 
-          handoff.set(handoffKey(dir, session), next)
+          handoff.set(handoffKey(serverSDK.scope, dir, session), next)
           setStore("draft", undefined)
         },
         restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
@@ -385,7 +388,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
           if (!session) return
           if (msg.sessionID !== session) return
           if (saved.session[session] !== undefined) return
-          if (handoff.has(handoffKey(sdk.directory, session))) return
+          if (handoff.has(handoffKey(serverSDK.scope, sdk.directory, session))) return
 
           setSaved("session", session, {
             agent: msg.agent,

+ 1 - 0
packages/app/src/context/models.tsx

@@ -24,6 +24,7 @@ function modelKey(model: ModelKey) {
 
 export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
   name: "Models",
+  gate: false,
   init: () => {
     const providers = useProviders()
 

+ 2 - 1
packages/app/src/context/notification.tsx

@@ -107,6 +107,7 @@ function buildNotificationIndex(list: Notification[]) {
 
 export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
   name: "Notification",
+  gate: false,
   init: () => {
     const params = useParams()
     const serverSDK = useServerSDK()
@@ -124,7 +125,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
     const currentSession = createMemo(() => params.id)
 
     const [store, setStore, _, ready] = persisted(
-      Persist.global("notification", ["notification.v1"]),
+      Persist.serverGlobal(serverSDK.scope, "notification", ["notification.v1"]),
       createStore({
         list: [] as Notification[],
       }),

+ 2 - 1
packages/app/src/context/permission.tsx

@@ -46,6 +46,7 @@ function hasPermissionPromptRules(permission: unknown) {
 
 export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
   name: "Permission",
+  gate: false,
   init: () => {
     const params = useParams()
     const serverSDK = useServerSDK()
@@ -60,7 +61,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
 
     const [store, setStore, _, ready] = persisted(
       {
-        ...Persist.global("permission", ["permission.v3"]),
+        ...Persist.serverGlobal(serverSDK.scope, "permission", ["permission.v3"]),
         migrate(value) {
           if (!value || typeof value !== "object" || Array.isArray(value)) return value
 

+ 6 - 3
packages/app/src/context/prompt.tsx

@@ -5,6 +5,8 @@ import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
 import { createStore, type SetStoreFunction } from "solid-js/store"
 import type { FileSelection } from "@/context/file"
 import { Persist, persisted } from "@/utils/persist"
+import { useServerSDK } from "./server-sdk"
+import type { ServerScope } from "@/utils/server-scope"
 
 interface PartBase {
   content: string
@@ -161,11 +163,11 @@ type PromptCacheEntry = {
   dispose: VoidFunction
 }
 
-function createPromptSession(dir: string, id: string | undefined) {
+function createPromptSession(scope: ServerScope, dir: string, id: string | undefined) {
   const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2`
 
   const [store, setStore, _, ready] = persisted(
-    Persist.scoped(dir, id, "prompt", [legacy]),
+    Persist.serverScoped(scope, dir, id, "prompt", [legacy]),
     createStore<{
       prompt: Prompt
       cursor?: number
@@ -229,6 +231,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
   gate: false,
   init: () => {
     const params = useParams()
+    const serverSDK = useServerSDK()
     const cache = new Map<string, PromptCacheEntry>()
 
     const disposeAll = () => {
@@ -262,7 +265,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
 
       const entry = createRoot(
         (dispose) => ({
-          value: createPromptSession(dir, id),
+          value: createPromptSession(serverSDK.scope, dir, id),
           dispose,
         }),
         owner,

+ 14 - 0
packages/app/src/context/server-sdk.test.ts

@@ -0,0 +1,14 @@
+import { describe, expect, test } from "bun:test"
+import { resumeStreamAfterPageShow } from "./server-sdk"
+
+describe("resumeStreamAfterPageShow", () => {
+  test("restarts a stream only after a back-forward cache restore", () => {
+    let starts = 0
+    const start = () => starts++
+
+    resumeStreamAfterPageShow({ persisted: false } as PageTransitionEvent, start)
+    resumeStreamAfterPageShow({ persisted: true } as PageTransitionEvent, start)
+
+    expect(starts).toBe(1)
+  })
+})

+ 25 - 8
packages/app/src/context/server-sdk.tsx

@@ -9,11 +9,19 @@ import { usePlatform } from "./platform"
 import { ServerConnection, useServer } from "./server"
 import { createRefCountMap } from "@/utils/refcount"
 import { useGlobal } from "./global"
+import { ServerScope } from "@/utils/server-scope"
 
 const isAbortError = (error: unknown) =>
   error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
 
-export function createServerSdkContext(server: ServerConnection.Any) {
+const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
+
+export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
+  if (!event.persisted) return
+  start()
+}
+
+export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope) {
   const platform = usePlatform()
   const abort = new AbortController()
 
@@ -96,11 +104,10 @@ export function createServerSdkContext(server: ServerConnection.Any) {
 
   let streamErrorLogged = false
   const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
-  const aborted = isAbortError
-
   let attempt: AbortController | undefined
   let run: Promise<void> | undefined
   let started = false
+  let generation = 0
   const HEARTBEAT_TIMEOUT_MS = 15_000
   let lastEventAt = Date.now()
   let heartbeat: ReturnType<typeof setTimeout> | undefined
@@ -120,9 +127,12 @@ export function createServerSdkContext(server: ServerConnection.Any) {
   const start = () => {
     if (started) return run
     started = true
-    run = (async () => {
+    const active = ++generation
+    const previous = run
+    const current = (async () => {
+      if (previous) await previous
       // oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
-      while (!abort.signal.aborted && started) {
+      while (!abort.signal.aborted && started && generation === active) {
         attempt = new AbortController()
         lastEventAt = Date.now()
         const onAbort = () => {
@@ -133,7 +143,7 @@ export function createServerSdkContext(server: ServerConnection.Any) {
           const events = await eventSdk.global.event({
             signal: attempt.signal,
             onSseError: (error) => {
-              if (aborted(error)) return
+              if (isStreamClosed(error, attempt?.signal)) return
               if (streamErrorLogged) return
               streamErrorLogged = true
               console.error("[global-sdk] event stream error", {
@@ -176,7 +186,7 @@ export function createServerSdkContext(server: ServerConnection.Any) {
             await wait(0)
           }
         } catch (error) {
-          if (!aborted(error) && !streamErrorLogged) {
+          if (!isStreamClosed(error, attempt?.signal) && !streamErrorLogged) {
             streamErrorLogged = true
             console.error("[global-sdk] event stream failed", {
               url: server.http.url,
@@ -190,23 +200,28 @@ export function createServerSdkContext(server: ServerConnection.Any) {
           clearHeartbeat()
         }
 
-        if (abort.signal.aborted || !started) return
+        if (abort.signal.aborted || !started || generation !== active) return
         await wait(RECONNECT_DELAY_MS)
       }
     })().finally(() => {
+      if (run !== current) return
       run = undefined
       flush()
     })
+    run = current
     return run
   }
 
   const stop = () => {
     started = false
+    generation++
     attempt?.abort()
     clearHeartbeat()
   }
 
   onMount(() => {
+    makeEventListener(window, "pagehide", stop)
+    makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
     makeEventListener(document, "visibilitychange", () => {
       if (document.visibilityState !== "visible") return
       if (!started) return
@@ -228,6 +243,7 @@ export function createServerSdkContext(server: ServerConnection.Any) {
   })
 
   return {
+    scope,
     url: server.http.url,
     client: sdk,
     event: {
@@ -282,6 +298,7 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDK) {
   onCleanup(unsub)
 
   return {
+    scope: serverSDK.scope,
     directory,
     client,
     event: emitter,

+ 28 - 20
packages/app/src/context/server-sync.tsx

@@ -34,6 +34,8 @@ import { createRefCountMap } from "@/utils/refcount"
 import { useGlobal } from "./global"
 import { ServerConnection, useServer } from "./server"
 import { retry } from "@opencode-ai/core/util/retry"
+import type { ServerScope } from "@/utils/server-scope"
+import { persisted } from "@/utils/persist"
 
 type GlobalStore = {
   ready: boolean
@@ -49,29 +51,30 @@ type GlobalStore = {
   reload: undefined | "pending" | "complete"
 }
 
-export const loadMcpQuery = (directory: string, sdk: OpencodeClient) =>
+export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
   queryOptions({
-    queryKey: [directory, "mcp"] as const,
+    queryKey: [scope, directory, "mcp"] as const,
     queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
   })
 
-export const loadLspQuery = (directory: string, sdk: OpencodeClient) =>
+export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
   queryOptions({
-    queryKey: [directory, "lsp"] as const,
+    queryKey: [scope, directory, "lsp"] as const,
     queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
   })
 
-function makeQueryOptionsApi(serverSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) {
+function makeQueryOptionsApi(scope: ServerScope, serverSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) {
   return {
-    globalConfig: () => loadGlobalConfigQuery(serverSDK()),
-    projects: () => loadProjectsQuery(serverSDK()),
+    globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
+    projects: () => loadProjectsQuery(scope, serverSDK()),
     providers: (directory: PathKey | null) =>
-      loadProvidersQuery(directory, directory === null ? serverSDK() : sdkFor(directory)),
-    path: (directory: PathKey | null) => loadPathQuery(directory, directory === null ? serverSDK() : sdkFor(directory)),
-    agents: (directory: PathKey) => loadAgentsQuery(directory, sdkFor(directory)),
-    mcp: (directory: PathKey) => loadMcpQuery(directory, sdkFor(directory)),
-    lsp: (directory: PathKey) => loadLspQuery(directory, sdkFor(directory)),
-    sessions: (directory: PathKey) => ({ queryKey: [directory, "loadSessions"] as const }),
+      loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
+    path: (directory: PathKey | null) =>
+      loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
+    agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
+    mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
+    lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
+    sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
   }
 }
 export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
@@ -99,7 +102,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
     return sdk
   }
 
-  const queryOptionsApi = makeQueryOptionsApi(() => serverSDK.client, sdkFor)
+  const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.client, sdkFor)
 
   const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
     queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
@@ -156,10 +159,11 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
   }) as typeof setGlobalStore
 
   const bootstrap = useQuery(() => ({
-    queryKey: ["bootstrap"],
+    queryKey: [serverSDK.scope, "bootstrap"],
     queryFn: async () => {
       await bootstrapGlobal({
         serverSDK: serverSDK.client,
+        scope: serverSDK.scope,
         requestFailedTitle: language.t("common.requestFailed"),
         translate: language.t,
         formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
@@ -198,12 +202,14 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
   const queue = createRefreshQueue({
     paused,
     key: directoryKey,
-    bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }),
+    bootstrap: () => queryClient.fetchQuery({ queryKey: [serverSDK.scope, "bootstrap"] }),
     bootstrapInstance,
   })
 
   const children = createChildStoreManager({
     owner,
+    scope: serverSDK.scope,
+    persist: persisted,
     isBooting: (directory) => booting.has(directory),
     isLoadingSessions: (directory) => sessionLoads.has(directory),
     onBootstrap: (directory) => {
@@ -227,8 +233,8 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
       queue.clear(key)
       sessionMeta.delete(key)
       sdkCache.delete(key)
-      clearProviderRev(key)
-      clearSessionPrefetchDirectory(key)
+      clearProviderRev(serverSDK.scope, key)
+      clearSessionPrefetchDirectory(serverSDK.scope, key)
     },
     translate: language.t,
     queryOptions: queryOptionsApi,
@@ -328,6 +334,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
       const sdk = sdkFor(directory)
       await bootstrapDirectory({
         directory,
+        scope: serverSDK.scope,
         mcp: children.mcp(key),
         global: {
           config: globalStore.config,
@@ -439,8 +446,8 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
       bootstrap.refetch()
       // Invalidate all provider queries so newly configured custom providers
       // appear immediately in the available provider list across all directories.
-      queryClient.invalidateQueries({ queryKey: [null, "providers"] })
-      queryClient.invalidateQueries({ predicate: (query) => query.queryKey[1] === "providers" })
+      queryClient.invalidateQueries({ queryKey: [serverSDK.scope, null, "providers"] })
+      queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers" })
     },
   }))
 
@@ -479,6 +486,7 @@ export function createServerSyncContext(_serverSDK?: ServerSDK) {
 
 export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
   name: "ServerSync",
+  gate: false,
   init: (props: { server?: ServerConnection.Any }) => {
     const global = useGlobal()
     const language = useLanguage()

+ 71 - 1
packages/app/src/context/server.test.ts

@@ -1,5 +1,8 @@
 import { describe, expect, test } from "bun:test"
-import { resolveServerList, ServerConnection } from "./server"
+import { createRoot, createSignal } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createServerProjects, migrateCanonicalLocalServerState, resolveServerList, ServerConnection } from "./server"
+import { ServerScope } from "@/utils/server-scope"
 
 describe("resolveServerList", () => {
   test("lets startup auth_token credentials override a persisted same-url server", () => {
@@ -51,3 +54,70 @@ describe("resolveServerList", () => {
     expect(list[0]?.type === "http" ? list[0].authToken : true).toBeUndefined()
   })
 })
+
+describe("createServerProjects", () => {
+  test("keeps active and explicit server buckets in one reactive store", () => {
+    createRoot((dispose) => {
+      const [scope] = createSignal(ServerScope.local)
+      const [store, setStore] = createStore({ projects: {}, lastProject: {} })
+      const active = createServerProjects({ scope, store, setStore })
+      const remote = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore })
+
+      remote.open("/repo")
+      expect(remote.list()).toEqual([{ worktree: "/repo", expanded: true }])
+      expect(active.list()).toEqual([])
+
+      const adopted = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore })
+      expect(adopted.list()).toEqual([{ worktree: "/repo", expanded: true }])
+
+      adopted.close("/repo")
+      expect(remote.list()).toEqual([])
+      dispose()
+    })
+  })
+})
+
+describe("migrateCanonicalLocalServerState", () => {
+  test("moves an existing canonical web bucket into local scope", () => {
+    expect(
+      migrateCanonicalLocalServerState(
+        {
+          list: [],
+          projects: { "https://opencode.example.com": [{ worktree: "/remote", expanded: true }] },
+          lastProject: { "https://opencode.example.com": "/remote" },
+        },
+        ServerConnection.Key.make("https://opencode.example.com"),
+      ),
+    ).toEqual({
+      list: [],
+      projects: { local: [{ worktree: "/remote", expanded: true }] },
+      lastProject: { local: "/remote" },
+    })
+  })
+
+  test("preserves existing local state while merging a canonical web bucket", () => {
+    expect(
+      migrateCanonicalLocalServerState(
+        {
+          projects: {
+            local: [{ worktree: "/local", expanded: false }],
+            "https://opencode.example.com": [
+              { worktree: "/local", expanded: true },
+              { worktree: "/remote", expanded: true },
+            ],
+          },
+          lastProject: { local: "/local", "https://opencode.example.com": "/remote" },
+        },
+        ServerConnection.Key.make("https://opencode.example.com"),
+      ),
+    ).toEqual({
+      projects: {
+        local: [
+          { worktree: "/local", expanded: false },
+          { worktree: "/remote", expanded: true },
+        ],
+      },
+      lastProject: { local: "/local" },
+    })
+  })
+})

+ 106 - 65
packages/app/src/context/server.tsx

@@ -1,10 +1,12 @@
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { type Accessor, batch, createMemo } from "solid-js"
-import { createStore } from "solid-js/store"
+import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
 import { Persist, persisted } from "@/utils/persist"
+import { ServerScope } from "@/utils/server-scope"
 
 type StoredProject = { worktree: string; expanded: boolean }
 type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
+type ServerProjectState = { projects: Record<string, StoredProject[]>; lastProject: Record<string, string> }
 const HEALTH_POLL_INTERVAL_MS = 10_000
 
 export function normalizeServerUrl(input: string) {
@@ -20,18 +22,91 @@ export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = fals
   return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
 }
 
-function projectsKey(key: ServerConnection.Key) {
-  if (!key) return ""
-  if (key === "sidecar") return "local"
-  if (isLocalHost(key)) return "local"
-  return key
-}
-
 function isLocalHost(url: string) {
   const host = url.replace(/^https?:\/\//, "").split(":")[0]
   if (host === "localhost" || host === "127.0.0.1") return "local"
 }
 
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === "object" && value !== null && !Array.isArray(value)
+}
+
+export function migrateCanonicalLocalServerState(value: unknown, canonicalLocalServer?: ServerConnection.Key) {
+  if (!canonicalLocalServer || canonicalLocalServer === "local") return value
+  if (!isRecord(value)) return value
+  const projects = isRecord(value.projects) ? value.projects : undefined
+  const lastProject = isRecord(value.lastProject) ? value.lastProject : undefined
+  const previousProjects = projects?.[canonicalLocalServer]
+  const previousLastProject = lastProject?.[canonicalLocalServer]
+  if (!Array.isArray(previousProjects) && typeof previousLastProject !== "string") return value
+
+  const next = { ...value }
+  if (projects && Array.isArray(previousProjects)) {
+    const local = Array.isArray(projects.local) ? projects.local : []
+    const worktrees = new Set(
+      local.flatMap((project) => (isRecord(project) && typeof project.worktree === "string" ? [project.worktree] : [])),
+    )
+    const migrated = previousProjects.filter((project) => {
+      if (!isRecord(project) || typeof project.worktree !== "string") return true
+      if (worktrees.has(project.worktree)) return false
+      worktrees.add(project.worktree)
+      return true
+    })
+    const nextProjects: Record<string, unknown> = { ...projects, local: [...local, ...migrated] }
+    delete nextProjects[canonicalLocalServer]
+    next.projects = nextProjects
+  }
+  if (lastProject && typeof previousLastProject === "string") {
+    const nextLastProject = { ...lastProject }
+    if (typeof nextLastProject.local !== "string") nextLastProject.local = previousLastProject
+    delete nextLastProject[canonicalLocalServer]
+    next.lastProject = nextLastProject
+  }
+  return next
+}
+
+export function createServerProjects<T extends ServerProjectState>(input: {
+  scope: Accessor<ServerScope>
+  store: Store<T>
+  setStore: SetStoreFunction<T>
+}) {
+  const setStore = input.setStore as unknown as SetStoreFunction<ServerProjectState>
+  const current = () => input.store.projects[input.scope()] ?? []
+  return {
+    list: current,
+    open(directory: string) {
+      const scope = input.scope()
+      if (current().some((project) => project.worktree === directory)) return
+      setStore("projects", scope, [{ worktree: directory, expanded: true }, ...current()])
+    },
+    close(directory: string) {
+      setStore("projects", input.scope(), current().filter((project) => project.worktree !== directory))
+    },
+    expand(directory: string) {
+      const index = current().findIndex((project) => project.worktree === directory)
+      if (index !== -1) setStore("projects", input.scope(), index, "expanded", true)
+    },
+    collapse(directory: string) {
+      const index = current().findIndex((project) => project.worktree === directory)
+      if (index !== -1) setStore("projects", input.scope(), index, "expanded", false)
+    },
+    move(directory: string, toIndex: number) {
+      const fromIndex = current().findIndex((project) => project.worktree === directory)
+      if (fromIndex === -1 || fromIndex === toIndex) return
+      const next = [...current()]
+      const [item] = next.splice(fromIndex, 1)
+      next.splice(toIndex, 0, item)
+      setStore("projects", input.scope(), next)
+    },
+    last() {
+      return input.store.lastProject[input.scope()]
+    },
+    touch(directory: string) {
+      setStore("lastProject", input.scope(), directory)
+    },
+  }
+}
+
 export function resolveServerList(input: {
   props?: Array<ServerConnection.Any>
   stored: StoredServer[]
@@ -127,9 +202,17 @@ export namespace ServerConnection {
 
 export const { use: useServer, provider: ServerProvider } = createSimpleContext({
   name: "Server",
-  init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
+  gate: true,
+  init: (props: {
+    defaultServer: ServerConnection.Key
+    canonicalLocalServer?: ServerConnection.Key
+    servers?: Array<ServerConnection.Any>
+  }) => {
     const [store, setStore, _, ready] = persisted(
-      Persist.global("server", ["server.v3"]),
+      {
+        ...Persist.global("server", ["server.v3"]),
+        migrate: (value) => migrateCanonicalLocalServerState(value, props.canonicalLocalServer),
+      },
       createStore({
         list: [] as StoredServer[],
         projects: {} as Record<string, StoredProject[]>,
@@ -180,8 +263,16 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
 
     const isReady = createMemo(() => ready() && !!state.active)
 
-    const origin = createMemo(() => projectsKey(state.active))
-    const projectsList = createMemo(() => store.projects[origin()] ?? [])
+    const scope = (key = state.active) => ServerScope.fromServerKey(key, props.canonicalLocalServer)
+    const projects = createServerProjects({ scope, store, setStore })
+    const projectStores = new Map<ServerConnection.Key, ReturnType<typeof createServerProjects>>()
+    const projectsForServer = (key: ServerConnection.Key) => {
+      const existing = projectStores.get(key)
+      if (existing) return existing
+      const next = createServerProjects({ scope: () => scope(key), store, setStore })
+      projectStores.set(key, next)
+      return next
+    }
     const current: Accessor<ServerConnection.Any | undefined> = createMemo(
       () => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
     )
@@ -208,60 +299,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
       setActive,
       add,
       remove,
+      scope,
       projects: {
-        list: projectsList,
-        open(directory: string) {
-          const key = origin()
-          if (!key) return
-          const current = store.projects[key] ?? []
-          if (current.find((x) => x.worktree === directory)) return
-          setStore("projects", key, [{ worktree: directory, expanded: true }, ...current])
-        },
-        close(directory: string) {
-          const key = origin()
-          if (!key) return
-          const current = store.projects[key] ?? []
-          setStore(
-            "projects",
-            key,
-            current.filter((x) => x.worktree !== directory),
-          )
-        },
-        expand(directory: string) {
-          const key = origin()
-          if (!key) return
-          const current = store.projects[key] ?? []
-          const index = current.findIndex((x) => x.worktree === directory)
-          if (index !== -1) setStore("projects", key, index, "expanded", true)
-        },
-        collapse(directory: string) {
-          const key = origin()
-          if (!key) return
-          const current = store.projects[key] ?? []
-          const index = current.findIndex((x) => x.worktree === directory)
-          if (index !== -1) setStore("projects", key, index, "expanded", false)
-        },
-        move(directory: string, toIndex: number) {
-          const key = origin()
-          if (!key) return
-          const current = store.projects[key] ?? []
-          const fromIndex = current.findIndex((x) => x.worktree === directory)
-          if (fromIndex === -1 || fromIndex === toIndex) return
-          const result = [...current]
-          const [item] = result.splice(fromIndex, 1)
-          result.splice(toIndex, 0, item)
-          setStore("projects", key, result)
-        },
-        last() {
-          const key = origin()
-          if (!key) return
-          return store.lastProject[key]
-        },
-        touch(directory: string) {
-          const key = origin()
-          if (!key) return
-          setStore("lastProject", key, directory)
-        },
+        ...projects,
+        forServer: projectsForServer,
       },
     }
   },

+ 1 - 0
packages/app/src/context/settings.tsx

@@ -156,6 +156,7 @@ function withFallback<T>(read: () => T | undefined, fallback: T) {
 
 export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
   name: "Settings",
+  gate: false,
   init: () => {
     const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
 

+ 140 - 0
packages/app/src/context/tabs.tsx

@@ -0,0 +1,140 @@
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { createStore, produce } from "solid-js/store"
+import { Persist, persisted } from "@/utils/persist"
+import { ServerConnection, useServer } from "./server"
+import { createEffect, startTransition } from "solid-js"
+import { useNavigate, useParams } from "@solidjs/router"
+import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
+
+export type SessionTab = {
+  type: "session"
+  server: ServerConnection.Key
+  dirBase64: string
+  sessionId: string
+}
+
+export type Tab = SessionTab
+
+export const tabHref = (tab: Tab) => `/${tab.dirBase64}/session/${tab.sessionId}`
+export const tabKey = (tab: Tab) => `${tab.server}\n${tabHref(tab)}`
+
+export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
+  name: "Tabs",
+  gate: false,
+  init: () => {
+    const server = useServer()
+    const fallback = server.key
+    const [store, setStore, _, ready] = persisted(
+      {
+        ...Persist.global("tabs"),
+        migrate: (value: unknown) => {
+          if (!Array.isArray(value)) return value
+          return value.map((tab) => {
+            if (!tab || typeof tab !== "object" || "server" in tab) return tab
+            return { ...tab, server: fallback }
+          })
+        },
+      },
+      createStore<Tab[]>([]),
+    )
+
+    const params = useParams()
+    const navigate = useNavigate()
+
+    const closing = new Set<string>()
+
+    createEffect(() => {
+      if (!ready()) return
+      const servers = new Set(server.list.map(ServerConnection.key))
+      if (store.every((tab) => servers.has(tab.server))) return
+      setStore((tabs) => tabs.filter((tab) => servers.has(tab.server)))
+    })
+
+    const navigateTab = (tab: Tab) => {
+      const href = tabHref(tab)
+      if (tab.server === server.key) {
+        navigate(href)
+        return
+      }
+      void startTransition(() => {
+        server.setActive(tab.server)
+        navigate(href)
+      })
+    }
+
+    const actions = {
+      addSessionTab: (tab: Omit<SessionTab, "type">) => {
+        const next = { type: "session" as const, ...tab }
+        if (closing.has(tabKey(next))) return
+        setStore(
+          produce((tabs) => {
+            if (tabs.some((item) => tabKey(item) === tabKey(next))) return
+            tabs.push(next)
+          }),
+        )
+      },
+      removeTab: (index: number) => {
+        const tab = store[index]
+        if (!tab) return
+        const key = tabKey(tab)
+        const nextTab = store[index + 1] ?? store[index - 1]
+        closing.add(key)
+        void startTransition(() => {
+          setStore(
+            produce((tabs) => {
+              tabs.splice(index, 1)
+            }),
+          )
+          if (nextTab) navigateTab(nextTab)
+          else navigate("/")
+        }).finally(() => closing.delete(key))
+      },
+      removeServer(key: ServerConnection.Key) {
+        setStore((tabs) => tabs.filter((tab) => tab.server !== key))
+        if (server.key === key) navigate("/")
+      },
+      removeSessions: (input: SessionTabsRemovedDetail) => {
+        void startTransition(() => {
+          setStore(
+            produce((tabs) => {
+              const sessionIDs = new Set(input.sessionIDs)
+              const currentHref =
+                params.dir && params.id
+                  ? tabHref({ type: "session", server: server.key, dirBase64: params.dir, sessionId: params.id })
+                  : undefined
+              const currentIndex = currentHref
+                ? tabs.findIndex(
+                    (tab) => tab.type === "session" && tab.server === server.key && tabHref(tab) === currentHref,
+                  )
+                : -1
+              const currentTab = tabs[currentIndex]
+              const removedCurrent =
+                currentTab?.type === "session" &&
+                currentTab.server === server.key &&
+                atob(currentTab.dirBase64) === input.directory &&
+                sessionIDs.has(currentTab.sessionId)
+
+              for (let i = tabs.length - 1; i >= 0; i--) {
+                const tab = tabs[i]
+                if (!tab || tab.type !== "session") continue
+                if (tab.server !== server.key) continue
+                if (atob(tab.dirBase64) !== input.directory) continue
+                if (!sessionIDs.has(tab.sessionId)) continue
+                tabs.splice(i, 1)
+              }
+
+              if (!removedCurrent) return
+              const nextTab =
+                tabs.slice(currentIndex).find((tab) => tab.type === "session") ??
+                tabs.slice(0, currentIndex).findLast((tab) => tab.type === "session")
+              if (nextTab) navigateTab(nextTab)
+              else navigate("/")
+            }),
+          )
+        })
+      },
+    }
+
+    return { ...actions, store, ready }
+  },
+})

+ 6 - 42
packages/app/src/context/terminal.test.ts

@@ -1,9 +1,7 @@
 import { beforeAll, describe, expect, mock, test } from "bun:test"
+import { ServerScope } from "@/utils/server-scope"
 
-type ServerKey = Parameters<typeof import("./terminal").getTerminalServerScope>[1]
-
-let getWorkspaceTerminalCacheKey: (dir: string, scope?: string) => string
-let getTerminalServerScope: typeof import("./terminal").getTerminalServerScope
+let getWorkspaceTerminalCacheKey: typeof import("./terminal").getWorkspaceTerminalCacheKey
 let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
 let migrateTerminalState: (value: unknown) => unknown
 
@@ -20,53 +18,19 @@ beforeAll(async () => {
   }))
   const mod = await import("./terminal")
   getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
-  getTerminalServerScope = mod.getTerminalServerScope
   getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
   migrateTerminalState = mod.migrateTerminalState
 })
 
 describe("getWorkspaceTerminalCacheKey", () => {
   test("uses workspace-only directory cache key", () => {
-    expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__")
+    expect(String(getWorkspaceTerminalCacheKey("/repo"))).toBe("local\u0000/repo\u0000__workspace__")
   })
 
   test("can include a server scope", () => {
-    expect(getWorkspaceTerminalCacheKey("/repo", "wsl:Debian")).toBe("wsl:Debian:/repo:__workspace__")
-  })
-})
-
-describe("getTerminalServerScope", () => {
-  test("preserves local server keys", () => {
-    expect(
-      getTerminalServerScope(
-        { type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } },
-        "sidecar" as ServerKey,
-      ),
-    ).toBeUndefined()
-    expect(
-      getTerminalServerScope(
-        { type: "http", http: { url: "http://localhost:4096" } },
-        "http://localhost:4096" as ServerKey,
-      ),
-    ).toBeUndefined()
-    expect(
-      getTerminalServerScope({ type: "http", http: { url: "http://[::1]:4096" } }, "http://[::1]:4096" as ServerKey),
-    ).toBeUndefined()
-  })
-
-  test("scopes non-local server keys", () => {
-    expect(
-      getTerminalServerScope(
-        { type: "sidecar", variant: "wsl", distro: "Debian", http: { url: "http://127.0.0.1:4096" } },
-        "wsl:Debian" as ServerKey,
-      ),
-    ).toBe("wsl:Debian" as ServerKey)
-    expect(
-      getTerminalServerScope(
-        { type: "http", http: { url: "https://example.com" } },
-        "https://example.com" as ServerKey,
-      ),
-    ).toBe("https://example.com" as ServerKey)
+    expect(String(getWorkspaceTerminalCacheKey("/repo", "ssh:debian" as ServerScope))).toBe(
+      "ssh:debian\u0000/repo\u0000__workspace__",
+    )
   })
 })
 

+ 24 - 37
packages/app/src/context/terminal.tsx

@@ -4,9 +4,10 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
 import { useParams } from "@solidjs/router"
 import { useSDK } from "./sdk"
 import type { Platform } from "./platform"
-import { ServerConnection, useServer } from "./server"
+import { useServer } from "./server"
 import { defaultTitle, titleNumber } from "./terminal-title"
 import { Persist, persisted, removePersisted } from "@/utils/persist"
+import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope"
 
 export type LocalPTY = {
   id: string
@@ -83,29 +84,8 @@ export function migrateTerminalState(value: unknown) {
   }
 }
 
-export function getWorkspaceTerminalCacheKey(dir: string, scope?: string) {
-  if (scope) return `${scope}:${dir}:${WORKSPACE_KEY}`
-  return `${dir}:${WORKSPACE_KEY}`
-}
-
-export function getTerminalServerScope(conn: ServerConnection.Any | undefined, key: ServerConnection.Key) {
-  if (!conn) return
-  if (conn.type === "sidecar" && conn.variant === "base") return
-  if (conn.type === "http") {
-    try {
-      const url = new URL(conn.http.url)
-      if (
-        url.hostname === "localhost" ||
-        url.hostname === "127.0.0.1" ||
-        url.hostname === "::1" ||
-        url.hostname === "[::1]"
-      )
-        return
-    } catch {
-      return key
-    }
-  }
-  return key
+export function getWorkspaceTerminalCacheKey(dir: string, scope: ServerScopeValue = ServerScope.local) {
+  return ScopedKey.from(scope, dir, WORKSPACE_KEY)
 }
 
 export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) {
@@ -132,16 +112,25 @@ const trimTerminal = (pty: LocalPTY) => {
   }
 }
 
-export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform, scope?: string) {
+function terminalPersistTarget(scope: ServerScopeValue, dir: string, legacy?: string[]) {
+  return Persist.serverWorkspace(scope, dir, "terminal", legacy)
+}
+
+export function clearWorkspaceTerminals(
+  dir: string,
+  sessionIDs?: string[],
+  platform?: Platform,
+  scope: ServerScopeValue = ServerScope.local,
+) {
   const key = getWorkspaceTerminalCacheKey(dir, scope)
   for (const cache of caches) {
     const entry = cache.get(key)
     entry?.value.clear()
   }
 
-  void removePersisted(Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal"), platform)
+  void removePersisted(terminalPersistTarget(scope, dir), platform)
 
-  if (scope) return
+  if (scope !== ServerScope.local) return
   const legacy = new Set(getLegacyTerminalStorageKeys(dir))
   for (const id of sessionIDs ?? []) {
     for (const key of getLegacyTerminalStorageKeys(dir, id)) {
@@ -156,14 +145,14 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
 function createWorkspaceTerminalSession(
   sdk: ReturnType<typeof useSDK>,
   dir: string,
+  scope: ServerScopeValue,
   legacySessionID?: string,
-  scope?: string,
 ) {
-  const legacy = scope ? [] : getLegacyTerminalStorageKeys(dir, legacySessionID)
+  const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : []
 
   const [store, setStore, _, ready] = persisted(
     {
-      ...Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal", legacy),
+      ...terminalPersistTarget(scope, dir, legacy),
       migrate: migrateTerminalState,
     },
     createStore<{
@@ -388,9 +377,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
     const server = useServer()
     const params = useParams()
     const cache = new Map<string, TerminalCacheEntry>()
-    const scope = createMemo(() => {
-      return getTerminalServerScope(server.current, server.key)
-    })
+    const scope = server.scope()
 
     caches.add(cache)
     onCleanup(() => caches.delete(cache))
@@ -414,7 +401,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
       }
     }
 
-    const loadWorkspace = (dir: string, legacySessionID: string | undefined, serverScope: string | undefined) => {
+    const loadWorkspace = (dir: string, legacySessionID: string | undefined, serverScope: ServerScopeValue) => {
       // Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
       const key = getWorkspaceTerminalCacheKey(dir, serverScope)
       const existing = cache.get(key)
@@ -425,7 +412,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
       }
 
       const entry = createRoot((dispose) => ({
-        value: createWorkspaceTerminalSession(sdk, dir, legacySessionID, serverScope),
+        value: createWorkspaceTerminalSession(sdk, dir, serverScope, legacySessionID),
         dispose,
       }))
 
@@ -434,11 +421,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
       return entry.value
     }
 
-    const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope()))
+    const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope))
 
     createEffect(
       on(
-        () => ({ dir: params.dir, id: params.id, scope: scope() }),
+        () => ({ dir: params.dir, id: params.id, scope }),
         (next, prev) => {
           if (!prev?.dir) return
           if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return

+ 1 - 0
packages/app/src/entry.tsx

@@ -170,6 +170,7 @@ if (root instanceof HTMLElement) {
         <AppBaseProviders>
           <AppInterface
             defaultServer={ServerConnection.Key.make(getDefaultUrl())}
+            canonicalLocalServer={ServerConnection.key(server)}
             servers={[server]}
             disableHealthCheck
           />

+ 267 - 176
packages/app/src/pages/home.tsx

@@ -1,5 +1,5 @@
 import type { Session } from "@opencode-ai/sdk/v2/client"
-import { createEffect, createMemo, For, Match, on, onCleanup, onMount, Show, Switch } from "solid-js"
+import { batch, createEffect, createMemo, For, Match, on, onCleanup, onMount, Show, Switch } from "solid-js"
 import { makeEventListener } from "@solid-primitives/event-listener"
 import { createStore } from "solid-js/store"
 import { useQuery } from "@tanstack/solid-query"
@@ -26,7 +26,18 @@ import { useServerSync } from "@/context/server-sync"
 import { useLanguage } from "@/context/language"
 import { useNotification } from "@/context/notification"
 import { usePermission } from "@/context/permission"
-import { displayName, getProjectAvatarSource, projectForSession, sortedRootSessions } from "@/pages/layout/helpers"
+import {
+  closeHomeProject,
+  displayName,
+  getProjectAvatarSource,
+  homeProjectDirectories,
+  homeProjectNavigation,
+  homeSessionServerStatus,
+  type HomeProjectSelection,
+  projectForSession,
+  sortedRootSessions,
+  toggleHomeProjectSelection,
+} from "@/pages/layout/helpers"
 import { sessionTitle } from "@/utils/session-title"
 import { pathKey } from "@/utils/path-key"
 import { messageAgentColor } from "@/utils/agent"
@@ -51,6 +62,8 @@ type HomeSessionRecord = {
   projectName: string
 }
 
+type HomeSessionSync = Pick<ReturnType<typeof useServerSync>, "child">
+
 type HomeSessionGroup = {
   id: "today" | "yesterday" | "older"
   title: string
@@ -65,8 +78,10 @@ const HOME_SEARCH_RESULT_TITLE =
 const HOME_SEARCH_RESULT_META =
   "min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]"
 
+let pendingHomeNavigation: { server: ServerConnection.Key; href: string } | undefined
+
 function buildHomeSessionRecords(input: {
-  sync: ReturnType<typeof useServerSync>
+  sync: Pick<ReturnType<typeof useServerSync>, "child">
   projectDirectories: () => string[]
   projects: () => LocalProject[]
   projectByID: () => Map<string, LocalProject>
@@ -95,6 +110,44 @@ function matchesHomeSessionSearch(record: HomeSessionRecord, query: string) {
   return `${record.session.title} ${record.projectName}`.toLowerCase().includes(query)
 }
 
+function createHomeSessionStatus(input: {
+  record: () => HomeSessionRecord
+  sync: () => HomeSessionSync
+  activeServer: () => boolean
+}) {
+  const notification = useNotification()
+  const permission = usePermission()
+  const sessionStore = createMemo(() => input.sync().child(input.record().session.directory, { bootstrap: false })[0])
+  const unseenCount = createMemo(() => (input.activeServer() ? notification.session.unseenCount(input.record().session.id) : 0))
+  const hasError = createMemo(() => input.activeServer() && notification.session.unseenHasError(input.record().session.id))
+  const hasPermissions = createMemo(
+    () =>
+      input.activeServer() &&
+      !!sessionPermissionRequest(sessionStore().session, sessionStore().permission, input.record().session.id, (item) => {
+        return !permission.autoResponds(item, input.record().session.directory)
+      }),
+  )
+  const serverStatus = createMemo(() =>
+    homeSessionServerStatus(input.activeServer(), () => ({
+      working: sessionStore().session_working(input.record().session.id),
+      tint: messageAgentColor(sessionStore().message[input.record().session.id], sessionStore().agent),
+    })),
+  )
+  const isWorking = createMemo(() => {
+    if (hasPermissions()) return false
+    return serverStatus().working
+  })
+  const tint = createMemo(() => serverStatus().tint)
+  return {
+    unseenCount,
+    hasError,
+    hasPermissions,
+    isWorking,
+    tint,
+    show: createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0),
+  }
+}
+
 function homeSessionSearchKey(record: HomeSessionRecord) {
   return `${pathKey(record.session.directory)}:${record.session.id}`
 }
@@ -122,12 +175,27 @@ function HomeDesign() {
   let focusSessionSearch: (() => void) | undefined
   const [state, setState] = createStore({
     search: "",
-    project: undefined as string | undefined,
+    selection: { server: server.key } as HomeProjectSelection,
     searchFocused: false,
   })
 
-  const projects = createMemo(() => layout.projects.list())
-  const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.project))
+  const focusedServer = createMemo(
+    () => global.servers.list().find((conn) => ServerConnection.key(conn) === state.selection.server) ?? server.current,
+  )
+  const focusedServerCtx = createMemo(() => {
+    const conn = focusedServer()
+    if (!conn) return
+    return global.createServerCtx(conn)
+  })
+  const focusedSync = () => focusedServerCtx()?.sync ?? sync
+  const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
+  const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.selection.directory))
+  const newSessionProject = createMemo(
+    () =>
+      selectedProject() ??
+      projects().find((project) => project.worktree === focusedServerCtx()?.projects.last()) ??
+      projects()[0],
+  )
   const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])]
   const projectDirectories = createMemo(() => {
     const project = selectedProject()
@@ -136,9 +204,9 @@ function HomeDesign() {
   })
   const search = createMemo(() => state.search.trim())
   const sessionLoad = useQuery(() => ({
-    queryKey: ["home", "sessions", ...projectDirectories()] as const,
+    queryKey: ["home", "sessions", state.selection.server, ...projectDirectories()] as const,
     queryFn: async () => {
-      await Promise.all(projectDirectories().map((directory) => sync.project.loadSessions(directory)))
+      await Promise.all(projectDirectories().map((directory) => focusedSync().project.loadSessions(directory)))
       return null
     },
   }))
@@ -148,7 +216,7 @@ function HomeDesign() {
   )
   const allRecords = createMemo(() =>
     buildHomeSessionRecords({
-      sync,
+      sync: focusedSync(),
       projectDirectories,
       projects,
       projectByID,
@@ -163,6 +231,13 @@ function HomeDesign() {
   const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
   const groups = createMemo(() => groupSessions(records(), language))
 
+  function setSelection(next: HomeProjectSelection) {
+    batch(() => {
+      if (state.selection.server !== next.server) setState("selection", "server", next.server)
+      if (state.selection.directory !== next.directory) setState("selection", "directory", next.directory)
+    })
+  }
+
   function closeSearch() {
     setState("search", "")
     setState("searchFocused", false)
@@ -183,47 +258,76 @@ function HomeDesign() {
     },
   ])
 
-  function selectProject(directory: string) {
-    if (!projects().some((project) => project.worktree === directory)) return
-    setState("project", state.project === directory ? undefined : directory)
+  createEffect(() => {
+    const list = global.servers.list()
+    if (list.some((conn) => ServerConnection.key(conn) === state.selection.server)) return
+    const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
+    if (conn) setSelection({ server: ServerConnection.key(conn) })
+  })
+
+  createEffect(() => {
+    const pending = pendingHomeNavigation
+    if (!pending || pending.server !== server.key) return
+    pendingHomeNavigation = undefined
+    navigate(pending.href)
+  })
+
+  function focusServer(conn: ServerConnection.Any) {
+    setSelection({ server: ServerConnection.key(conn) })
   }
 
-  function addProject(conn: ServerConnection.Any, directory: string) {
-    const server = global.createServerCtx(conn)
-    server.projects.open(directory)
-    server.projects.touch(directory)
-    setState("project", directory)
+  function selectProject(conn: ServerConnection.Any, directory: string) {
+    const key = ServerConnection.key(conn)
+    if (!global.createServerCtx(conn).projects.list().some((project) => project.worktree === directory)) return
+    setSelection(toggleHomeProjectSelection(state.selection, key, directory))
+  }
+
+  function addProjects(conn: ServerConnection.Any, directories: string[]) {
+    const directory = directories[0]
+    if (!directory) return
+    const ctx = global.createServerCtx(conn)
+    directories.forEach(ctx.projects.open)
+    ctx.projects.touch(directory)
+    setSelection({ server: ServerConnection.key(conn), directory })
   }
 
   function openNewSession() {
-    const project = selectedProject()
-    if (!project) {
-      const conn = server.current
-      if (conn) void chooseProject(conn)
+    const conn = focusedServer()
+    const project = newSessionProject()
+    if (!conn || !project) return
+    openProjectNewSession(conn, project.worktree)
+  }
+
+  function navigateOnServer(conn: ServerConnection.Any, href: string) {
+    const next = homeProjectNavigation(server.key, ServerConnection.key(conn), href)
+    if (!next.server) {
+      navigate(next.href)
       return
     }
-    layout.projects.open(project.worktree)
-    server.projects.touch(project.worktree)
-    navigate(`/${base64Encode(project.worktree)}/session`)
+    pendingHomeNavigation = next
+    server.setActive(next.server)
   }
 
-  function openProjectNewSession(directory: string) {
-    layout.projects.open(directory)
-    server.projects.touch(directory)
-    navigate(`/${base64Encode(directory)}/session`)
+  function openProjectNewSession(conn: ServerConnection.Any, directory: string) {
+    const ctx = global.createServerCtx(conn)
+    ctx.projects.open(directory)
+    ctx.projects.touch(directory)
+    navigateOnServer(conn, `/${base64Encode(directory)}/session`)
   }
 
-  function editProject(project: LocalProject) {
+  function editProject(conn: ServerConnection.Any, project: LocalProject) {
     void import("@/components/dialog-edit-project").then((x) => {
-      dialog.show(() => <x.DialogEditProject project={project} />)
+      dialog.show(() => <x.DialogEditProject server={conn} project={project} />)
     })
   }
 
-  function unseenCount(project: LocalProject) {
+  function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
+    if (ServerConnection.key(conn) !== server.key) return 0
     return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0)
   }
 
-  function clearNotifications(project: LocalProject) {
+  function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
+    if (ServerConnection.key(conn) !== server.key) return
     directories(project)
       .filter((directory) => notification.project.unseenCount(directory) > 0)
       .forEach((directory) => notification.project.markViewed(directory))
@@ -231,19 +335,18 @@ function HomeDesign() {
 
   function openSession(session: Session) {
     const project = projectForSession(session, projects(), projectByID())
-    layout.projects.open(project?.worktree ?? session.directory)
-    server.projects.touch(project?.worktree ?? session.directory)
-    navigate(`/${base64Encode(session.directory)}/session/${session.id}`)
+    const conn = focusedServer()
+    if (!conn) return
+    const directory = project?.worktree ?? session.directory
+    const ctx = global.createServerCtx(conn)
+    ctx.projects.open(directory)
+    ctx.projects.touch(directory)
+    navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`)
   }
 
   async function chooseProject(conn: ServerConnection.Any) {
     function resolve(result: string | string[] | null) {
-      if (Array.isArray(result)) {
-        result.forEach((r) => addProject(conn, r))
-        if (result[0]) setState("project", result[0])
-        return
-      }
-      if (result) addProject(conn, result)
+      addProjects(conn, homeProjectDirectories(result))
     }
 
     const server = global.createServerCtx(conn)
@@ -274,14 +377,20 @@ function HomeDesign() {
       <div class="mx-auto grid w-full h-full max-w-[1080px] gap-8 px-6 pb-16 lg:grid-cols-[280px_minmax(0,720px)]">
         <HomeProjectColumn
           projects={projects()}
-          selected={selectedProject()?.worktree}
+          selected={state.selection}
+          focusServer={focusServer}
           selectProject={selectProject}
           openNewSession={openProjectNewSession}
           chooseProject={(conn) => void chooseProject(conn)}
           editProject={editProject}
-          closeProject={(directory) => {
-            layout.projects.close(directory)
-            if (state.project === directory) setState("project", undefined)
+          closeProject={(conn, directory) => {
+            const next = closeHomeProject(
+              state.selection,
+              ServerConnection.key(conn),
+              global.createServerCtx(conn).projects,
+              directory,
+            )
+            if (next) setSelection(next)
           }}
           clearNotifications={clearNotifications}
           unseenCount={unseenCount}
@@ -297,6 +406,8 @@ function HomeDesign() {
             open={searchOpen()}
             loading={sessionLoad.isLoading}
             results={searchResults()}
+            sync={focusedSync()}
+            activeServer={state.selection.server === server.key}
             noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
             bindFocus={(focus) => {
               focusSessionSearch = focus
@@ -316,7 +427,10 @@ function HomeDesign() {
                   when={groups().length > 0}
                   fallback={
                     <div class="flex min-w-0 flex-col gap-4">
-                      <HomeSessionGroupHeader title={language.t("home.sessions.empty")} onNewSession={openNewSession} />
+                      <HomeSessionGroupHeader
+                        title={language.t("home.sessions.empty")}
+                        onNewSession={newSessionProject() ? openNewSession : undefined}
+                      />
                     </div>
                   }
                 >
@@ -325,11 +439,18 @@ function HomeDesign() {
                       <div class="flex min-w-0 flex-col gap-4">
                         <HomeSessionGroupHeader
                           title={group.title}
-                          onNewSession={index() === 0 ? openNewSession : undefined}
+                          onNewSession={index() === 0 && newSessionProject() ? openNewSession : undefined}
                         />
                         <div class="flex min-w-0 flex-col gap-px">
                           <For each={group.sessions}>
-                            {(record) => <HomeSessionRow record={record} openSession={openSession} />}
+                            {(record) => (
+                              <HomeSessionRow
+                                record={record}
+                                sync={focusedSync()}
+                                activeServer={state.selection.server === server.key}
+                                openSession={openSession}
+                              />
+                            )}
                           </For>
                         </div>
                       </div>
@@ -347,14 +468,15 @@ function HomeDesign() {
 
 function HomeProjectColumn(props: {
   projects: LocalProject[]
-  selected?: string
-  selectProject: (directory: string) => void
-  openNewSession: (directory: string) => void
+  selected: HomeProjectSelection
+  focusServer: (server: ServerConnection.Any) => void
+  selectProject: (server: ServerConnection.Any, directory: string) => void
+  openNewSession: (server: ServerConnection.Any, directory: string) => void
   chooseProject: (server: ServerConnection.Any) => void
-  editProject: (project: LocalProject) => void
-  closeProject: (directory: string) => void
-  clearNotifications: (project: LocalProject) => void
-  unseenCount: (project: LocalProject) => number
+  editProject: (server: ServerConnection.Any, project: LocalProject) => void
+  closeProject: (server: ServerConnection.Any, directory: string) => void
+  clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
+  unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
   openSettings: () => void
   openHelp: () => void
   language: ReturnType<typeof useLanguage>
@@ -378,40 +500,44 @@ function HomeProjectColumn(props: {
       </div>
       <Show
         when={global.servers.list().length > 1}
-        fallback={<HomeProjectList {...props} chooseProject={() => props.chooseProject(global.servers.list()[0]!)} />}
+        fallback={<HomeProjectList {...props} server={global.servers.list()[0]!} />}
       >
         <For each={global.servers.list()}>
           {(item) => {
             const key = ServerConnection.key(item)
             const healthy = () => !!global.servers.health[key]?.healthy
-            const [state, setState] = createStore({ open: true })
             const serverCtx = global.createServerCtx(item)
             return (
               <div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
-                <button
-                  type="button"
-                  class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60`}
-                  disabled={!healthy()}
-                  onClick={() => setState("open", !state.open)}
-                >
-                  <div class="flex size-4 shrink-0 items-center justify-center">
-                    <ServerHealthIndicator health={global.servers.health[key]} />
-                  </div>
-                  <span class={HOME_PROJECT_NAV_LABEL}>{item.displayName ?? new URL(item.http.url).host}</span>
-                  <Show when={healthy()}>
-                    <IconV2
-                      name="outline-chevron-down"
-                      class="shrink-0 text-v2-icon-icon-muted data-[open=false]:-rotate-90"
-                      data-open={state.open}
-                    />
-                  </Show>
-                </button>
-                <Show when={healthy() && state.open}>
+                <div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
+                  <button
+                    type="button"
+                    class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
+                    data-selected={props.selected.server === key && !props.selected.directory ? "" : undefined}
+                    disabled={!healthy()}
+                    onClick={() => props.focusServer(item)}
+                  >
+                    <div class="flex size-4 shrink-0 items-center justify-center">
+                      <ServerHealthIndicator health={global.servers.health[key]} />
+                    </div>
+                    <span class={HOME_PROJECT_NAV_LABEL}>{item.displayName ?? new URL(item.http.url).host}</span>
+                  </button>
+                  <IconButtonV2
+                    data-action="home-add-project"
+                    variant="ghost-muted"
+                    size="small"
+                    class="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 transition-opacity group-hover/server:opacity-100 focus:opacity-100"
+                    icon={<IconV2 name="folder-add-left" />}
+                    aria-label={props.language.t("home.project.add")}
+                    onClick={() => props.chooseProject(item)}
+                  />
+                </div>
+                <Show when={healthy()}>
                   <div class="mx-3 h-px bg-v2-border-border-base" />
                   <HomeProjectList
                     {...props}
+                    server={item}
                     projects={serverCtx.projects.list()}
-                    chooseProject={() => props.chooseProject(item)}
                   />
                 </Show>
               </div>
@@ -442,61 +568,49 @@ function HomeProjectColumn(props: {
 }
 
 function HomeProjectList(props: {
+  server: ServerConnection.Any
   projects: LocalProject[]
-  selected?: string
-  selectProject: (directory: string) => void
-  openNewSession: (directory: string) => void
-  chooseProject: () => void
-  editProject: (project: LocalProject) => void
-  closeProject: (directory: string) => void
-  clearNotifications: (project: LocalProject) => void
-  unseenCount: (project: LocalProject) => number
+  selected: HomeProjectSelection
+  selectProject: (server: ServerConnection.Any, directory: string) => void
+  openNewSession: (server: ServerConnection.Any, directory: string) => void
+  editProject: (server: ServerConnection.Any, project: LocalProject) => void
+  closeProject: (server: ServerConnection.Any, directory: string) => void
+  clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
+  unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
   language: ReturnType<typeof useLanguage>
 }) {
   return (
-    <Show
-      when={props.projects.length > 0}
-      fallback={
-        <button
-          type="button"
-          class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
-          onClick={props.chooseProject}
-        >
-          <IconV2 name="folder-add-left" size="small" />
-          <span>{props.language.t("home.project.add")}</span>
-        </button>
-      }
-    >
-      <div class="flex min-w-0 flex-col gap-1">
-        <For each={props.projects}>
-          {(project) => (
-            <HomeProjectRow
-              project={project}
-              selected={props.selected === project.worktree}
-              unseenCount={props.unseenCount(project)}
-              selectProject={props.selectProject}
-              openNewSession={props.openNewSession}
-              editProject={props.editProject}
-              closeProject={props.closeProject}
-              clearNotifications={props.clearNotifications}
-              language={props.language}
-            />
-          )}
-        </For>
-      </div>
-    </Show>
+    <div class="flex min-w-0 flex-col gap-1">
+      <For each={props.projects}>
+        {(project) => (
+          <HomeProjectRow
+            project={project}
+            server={props.server}
+            selected={props.selected.server === ServerConnection.key(props.server) && props.selected.directory === project.worktree}
+            unseenCount={props.unseenCount(props.server, project)}
+            selectProject={props.selectProject}
+            openNewSession={props.openNewSession}
+            editProject={props.editProject}
+            closeProject={props.closeProject}
+            clearNotifications={props.clearNotifications}
+            language={props.language}
+          />
+        )}
+      </For>
+    </div>
   )
 }
 
 function HomeProjectRow(props: {
   project: LocalProject
+  server: ServerConnection.Any
   selected: boolean
   unseenCount: number
-  selectProject: (directory: string) => void
-  openNewSession: (directory: string) => void
-  editProject: (project: LocalProject) => void
-  closeProject: (directory: string) => void
-  clearNotifications: (project: LocalProject) => void
+  selectProject: (server: ServerConnection.Any, directory: string) => void
+  openNewSession: (server: ServerConnection.Any, directory: string) => void
+  editProject: (server: ServerConnection.Any, project: LocalProject) => void
+  closeProject: (server: ServerConnection.Any, directory: string) => void
+  clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
   language: ReturnType<typeof useLanguage>
 }) {
   const [state, setState] = createStore({ menuOpen: false })
@@ -508,7 +622,7 @@ function HomeProjectRow(props: {
         class={`${HOME_PROJECT_NAV_ROW} pr-16`}
         data-selected={props.selected ? "" : undefined}
         aria-current={props.selected ? "page" : undefined}
-        onClick={() => props.selectProject(props.project.worktree)}
+        onClick={() => props.selectProject(props.server, props.project.worktree)}
       >
         <HomeProjectAvatar project={props.project} />
         <span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
@@ -523,7 +637,7 @@ function HomeProjectRow(props: {
           size="small"
           icon={<IconV2 name="edit" />}
           aria-label={props.language.t("command.session.new")}
-          onClick={() => props.openNewSession(props.project.worktree)}
+          onClick={() => props.openNewSession(props.server, props.project.worktree)}
         />
         <MenuV2
           gutter={4}
@@ -542,17 +656,17 @@ function HomeProjectRow(props: {
           />
           <MenuV2.Portal>
             <MenuV2.Content>
-              <MenuV2.Item onSelect={() => props.openNewSession(props.project.worktree)}>
+              <MenuV2.Item onSelect={() => props.openNewSession(props.server, props.project.worktree)}>
                 {props.language.t("command.session.new")}
               </MenuV2.Item>
-              <MenuV2.Item onSelect={() => props.editProject(props.project)}>
+              <MenuV2.Item onSelect={() => props.editProject(props.server, props.project)}>
                 {props.language.t("common.edit")}
               </MenuV2.Item>
-              <MenuV2.Item disabled={props.unseenCount === 0} onSelect={() => props.clearNotifications(props.project)}>
+              <MenuV2.Item disabled={props.unseenCount === 0} onSelect={() => props.clearNotifications(props.server, props.project)}>
                 {props.language.t("sidebar.project.clearNotifications")}
               </MenuV2.Item>
               <MenuV2.Separator />
-              <MenuV2.Item onSelect={() => props.closeProject(props.project.worktree)}>
+              <MenuV2.Item onSelect={() => props.closeProject(props.server, props.project.worktree)}>
                 {props.language.t("common.close")}
               </MenuV2.Item>
             </MenuV2.Content>
@@ -580,6 +694,8 @@ function HomeSessionSearch(props: {
   open: boolean
   loading: boolean
   results: HomeSessionRecord[]
+  sync: HomeSessionSync
+  activeServer: boolean
   noResultsLabel: string
   bindFocus: (focus: () => void) => void
   onInput: (value: string) => void
@@ -694,6 +810,8 @@ function HomeSessionSearch(props: {
                           {(record) => (
                             <HomeSessionSearchResultRow
                               record={record}
+                              sync={props.sync}
+                              activeServer={props.activeServer}
                               selected={store.active === homeSessionSearchKey(record)}
                               onHighlight={() => setStore("active", homeSessionSearchKey(record))}
                               onSelect={(session) => props.onSelect(session)}
@@ -778,29 +896,14 @@ function HomeSessionSearch(props: {
 
 function HomeSessionSearchResultRow(props: {
   record: HomeSessionRecord
+  sync: HomeSessionSync
+  activeServer: boolean
   selected: boolean
   onHighlight: () => void
   onSelect: (session: Session) => void
 }) {
-  const globalSync = useServerSync()
-  const notification = useNotification()
-  const permission = usePermission()
-  const [sessionStore] = globalSync.child(props.record.session.directory, { bootstrap: false })
+  const status = createHomeSessionStatus({ record: () => props.record, sync: () => props.sync, activeServer: () => props.activeServer })
   const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
-  const unseenCount = createMemo(() => notification.session.unseenCount(props.record.session.id))
-  const hasError = createMemo(() => notification.session.unseenHasError(props.record.session.id))
-  const hasPermissions = createMemo(
-    () =>
-      !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.record.session.id, (item) => {
-        return !permission.autoResponds(item, props.record.session.directory)
-      }),
-  )
-  const isWorking = createMemo(() => {
-    if (hasPermissions()) return false
-    return sessionStore.session_working(props.record.session.id)
-  })
-  const tint = createMemo(() => messageAgentColor(sessionStore.message[props.record.session.id], sessionStore.agent))
-  const showStatus = createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0)
 
   const key = () => homeSessionSearchKey(props.record)
 
@@ -820,7 +923,7 @@ function HomeSessionSearchResultRow(props: {
       onClick={() => props.onSelect(props.record.session)}
     >
       <Show
-        when={showStatus()}
+        when={status.show()}
         fallback={
           <div class="flex size-4 shrink-0 items-center justify-center">
             <TabStateIndicator />
@@ -829,19 +932,19 @@ function HomeSessionSearchResultRow(props: {
       >
         <div
           class="flex size-4 shrink-0 items-center justify-center"
-          style={{ color: tint() ?? "var(--icon-interactive-base)" }}
+          style={{ color: status.tint() ?? "var(--icon-interactive-base)" }}
         >
           <Switch>
-            <Match when={isWorking()}>
+            <Match when={status.isWorking()}>
               <Spinner class="size-[15px]" />
             </Match>
-            <Match when={hasPermissions()}>
+            <Match when={status.hasPermissions()}>
               <div class="size-1.5 rounded-full bg-surface-warning-strong" />
             </Match>
-            <Match when={hasError()}>
+            <Match when={status.hasError()}>
               <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
             </Match>
-            <Match when={unseenCount() > 0}>
+            <Match when={status.unseenCount() > 0}>
               <div class="size-1.5 rounded-full bg-text-interactive-base" />
             </Match>
           </Switch>
@@ -884,26 +987,14 @@ function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => voi
   )
 }
 
-function HomeSessionRow(props: { record: HomeSessionRecord; openSession: (session: Session) => void }) {
-  const globalSync = useServerSync()
-  const notification = useNotification()
-  const permission = usePermission()
-  const [sessionStore] = globalSync.child(props.record.session.directory, { bootstrap: false })
+function HomeSessionRow(props: {
+  record: HomeSessionRecord
+  sync: HomeSessionSync
+  activeServer: boolean
+  openSession: (session: Session) => void
+}) {
+  const status = createHomeSessionStatus({ record: () => props.record, sync: () => props.sync, activeServer: () => props.activeServer })
   const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
-  const unseenCount = createMemo(() => notification.session.unseenCount(props.record.session.id))
-  const hasError = createMemo(() => notification.session.unseenHasError(props.record.session.id))
-  const hasPermissions = createMemo(
-    () =>
-      !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.record.session.id, (item) => {
-        return !permission.autoResponds(item, props.record.session.directory)
-      }),
-  )
-  const isWorking = createMemo(() => {
-    if (hasPermissions()) return false
-    return sessionStore.session_working(props.record.session.id)
-  })
-  const tint = createMemo(() => messageAgentColor(sessionStore.message[props.record.session.id], sessionStore.agent))
-  const showStatus = createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0)
 
   return (
     <button
@@ -913,7 +1004,7 @@ function HomeSessionRow(props: { record: HomeSessionRecord; openSession: (sessio
       onClick={() => props.openSession(props.record.session)}
     >
       <Show
-        when={showStatus()}
+        when={status.show()}
         fallback={
           <div class="flex size-4 shrink-0 items-center justify-center">
             <TabStateIndicator />
@@ -922,19 +1013,19 @@ function HomeSessionRow(props: { record: HomeSessionRecord; openSession: (sessio
       >
         <div
           class="flex size-4 shrink-0 items-center justify-center"
-          style={{ color: tint() ?? "var(--icon-interactive-base)" }}
+          style={{ color: status.tint() ?? "var(--icon-interactive-base)" }}
         >
           <Switch>
-            <Match when={isWorking()}>
+            <Match when={status.isWorking()}>
               <Spinner class="size-[15px]" />
             </Match>
-            <Match when={hasPermissions()}>
+            <Match when={status.hasPermissions()}>
               <div class="size-1.5 rounded-full bg-surface-warning-strong" />
             </Match>
-            <Match when={hasError()}>
+            <Match when={status.hasError()}>
               <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
             </Match>
-            <Match when={unseenCount() > 0}>
+            <Match when={status.unseenCount() > 0}>
               <div class="size-1.5 rounded-full bg-text-interactive-base" />
             </Match>
           </Switch>

+ 28 - 20
packages/app/src/pages/layout.tsx

@@ -37,7 +37,7 @@ import { useProviders } from "@/hooks/use-providers"
 import { toaster } from "@opencode-ai/ui/toast"
 import { setV2Toast, showToast, ToastRegion } from "@/utils/toast"
 import { useServerSDK } from "@/context/server-sdk"
-import { clearWorkspaceTerminals, getTerminalServerScope } from "@/context/terminal"
+import { clearWorkspaceTerminals } from "@/context/terminal"
 import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
 import {
   clearSessionPrefetchInflight,
@@ -57,6 +57,7 @@ import { createAim } from "@/utils/aim"
 import { setNavigate } from "@/utils/notification-click"
 import { Worktree as WorktreeState } from "@/utils/worktree"
 import { setSessionHandoff } from "@/pages/session/handoff"
+import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
 
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
@@ -64,7 +65,7 @@ import { useCommand, type CommandOption } from "@/context/command"
 import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
 import { DebugBar } from "@/components/debug-bar"
 import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
-import { useServer } from "@/context/server"
+import { ServerConnection, useServer } from "@/context/server"
 import { useLanguage, type Locale } from "@/context/language"
 import { pathKey } from "@/utils/path-key"
 import {
@@ -92,8 +93,9 @@ import { SidebarContent } from "./layout/sidebar-shell"
 import { runUpdateAndRestart } from "./layout/update"
 
 export default function Layout(props: ParentProps) {
+  const serverSDK = useServerSDK()
   const [store, setStore, , ready] = persisted(
-    Persist.global("layout.page", ["layout.page.v1"]),
+    Persist.serverGlobal(serverSDK.scope, "layout.page", ["layout.page.v1"]),
     createStore({
       lastProjectSession: {} as { [directory: string]: { directory: string; id: string; at: number } },
       activeProject: undefined as string | undefined,
@@ -113,7 +115,6 @@ export default function Layout(props: ParentProps) {
   let dialogDead = false
 
   const params = useParams()
-  const serverSDK = useServerSDK()
   const serverSync = useServerSync()
   const layout = useLayout()
   const layoutReady = createMemo(() => layout.ready())
@@ -411,13 +412,17 @@ export default function Layout(props: ParentProps) {
       const unsub = serverSDK.event.listen((e) => {
         if (e.details?.type === "worktree.ready") {
           setBusy(e.name, false)
-          WorktreeState.ready(e.name)
+          WorktreeState.ready(serverSDK.scope, e.name)
           return
         }
 
         if (e.details?.type === "worktree.failed") {
           setBusy(e.name, false)
-          WorktreeState.failed(e.name, e.details.properties?.message ?? language.t("common.requestFailed"))
+          WorktreeState.failed(
+            serverSDK.scope,
+            e.name,
+            e.details.properties?.message ?? language.t("common.requestFailed"),
+          )
           return
         }
 
@@ -693,7 +698,7 @@ export default function Layout(props: ParentProps) {
     serverSDK.url
 
     prefetchToken.value += 1
-    clearSessionPrefetchInflight()
+    clearSessionPrefetchInflight(serverSDK.scope)
     prefetchQueues.clear()
   })
 
@@ -740,13 +745,14 @@ export default function Layout(props: ParentProps) {
     const [store, setStore] = serverSync.child(directory, { bootstrap: false })
 
     return runSessionPrefetch({
+      scope: serverSDK.scope,
       directory,
       sessionID,
       task: (rev) =>
         retry(() => serverSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
           .then((messages) => {
             if (prefetchToken.value !== token) return
-            if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return
+            if (!isSessionPrefetchCurrent(serverSDK.scope, directory, sessionID, rev)) return
 
             const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
             const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
@@ -761,7 +767,7 @@ export default function Layout(props: ParentProps) {
             }
 
             if (stale.length > 0) {
-              clearSessionPrefetch(directory, stale)
+              clearSessionPrefetch(serverSDK.scope, directory, stale)
               for (const id of stale) {
                 serverSync.todo.set(id, undefined)
               }
@@ -773,7 +779,7 @@ export default function Layout(props: ParentProps) {
               sorted,
             )
 
-            if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return
+            if (!isSessionPrefetchCurrent(serverSDK.scope, directory, sessionID, rev)) return
 
             batch(() => {
               if (stale.length > 0) {
@@ -785,7 +791,7 @@ export default function Layout(props: ParentProps) {
               }
 
               setStore("message", sessionID, reconcile(merged, { key: "id" }))
-              setSessionPrefetch({ directory, sessionID, ...meta })
+              setSessionPrefetch({ scope: serverSDK.scope, directory, sessionID, ...meta })
 
               for (const message of items) {
                 const currentParts = store.part[message.info.id] ?? []
@@ -830,7 +836,7 @@ export default function Layout(props: ParentProps) {
 
     const [store] = serverSync.child(directory, { bootstrap: false })
     const cached = untrack(() => {
-      const info = getSessionPrefetch(directory, session.id)
+      const info = getSessionPrefetch(serverSDK.scope, directory, session.id)
       return shouldSkipSessionPrefetch({
         message: store.message[session.id] !== undefined,
         info,
@@ -1381,7 +1387,9 @@ export default function Layout(props: ParentProps) {
       void openProject(link.directory, false)
       const slug = base64Encode(link.directory)
       if (link.prompt) {
-        setSessionHandoff(slug, { prompt: link.prompt })
+        setSessionHandoff(SessionStateKey.from(server.scope(), SessionRouteKey.fromLegacy(slug)), {
+          prompt: link.prompt,
+        })
       }
       const href = link.prompt ? `/${slug}/session?prompt=${encodeURIComponent(link.prompt)}` : `/${slug}/session`
       navigateWithSidebarReset(href)
@@ -1456,11 +1464,11 @@ export default function Layout(props: ParentProps) {
     layout.sidebar.toggleWorkspaces(project.worktree)
   }
 
-  const showEditProjectDialog = (project: LocalProject) => {
+  const showEditProjectDialog = (conn: ServerConnection.Any, project: LocalProject) => {
     const run = ++dialogRun
     void import("@/components/dialog-edit-project").then((x) => {
       if (dialogDead || dialogRun !== run) return
-      dialog.show(() => <x.DialogEditProject project={project} />)
+      dialog.show(() => <x.DialogEditProject server={conn} project={project} />)
     })
   }
 
@@ -1576,7 +1584,7 @@ export default function Layout(props: ParentProps) {
       directory,
       sessions.map((s) => s.id),
       platform,
-      getTerminalServerScope(server.current, server.key),
+      serverSDK.scope,
     )
     await serverSDK.client.instance.dispose({ directory }).catch(() => undefined)
 
@@ -1890,7 +1898,7 @@ export default function Layout(props: ParentProps) {
       directory && pathKey(directory) !== pathKey(local) && !dirs.some((item) => pathKey(item) === pathKey(directory))
         ? directory
         : undefined
-    const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
+    const pending = extra ? WorktreeState.get(serverSDK.scope, extra)?.status === "pending" : false
 
     const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
     if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)]
@@ -1962,7 +1970,7 @@ export default function Layout(props: ParentProps) {
     const root = pathKey(local)
 
     setBusy(created.directory, true)
-    WorktreeState.pending(created.directory)
+    WorktreeState.pending(serverSDK.scope, created.directory)
     setStore("workspaceExpanded", key, true)
     if (key !== created.directory) {
       setStore("workspaceExpanded", created.directory, true)
@@ -2023,7 +2031,7 @@ export default function Layout(props: ParentProps) {
     navigateToProject,
     openSidebar: () => layout.sidebar.open(),
     closeProject,
-    showEditProjectDialog,
+    showEditProjectDialog: (proj) => showEditProjectDialog(server.current!, proj),
     toggleProjectWorkspaces,
     workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(),
     workspaceIds,
@@ -2170,7 +2178,7 @@ export default function Layout(props: ParentProps) {
                       <DropdownMenu.Content class="mt-1">
                         <DropdownMenu.Item
                           onSelect={() => {
-                            showEditProjectDialog(project)
+                            showEditProjectDialog(server.current!, project)
                           }}
                         >
                           <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>

+ 86 - 0
packages/app/src/pages/layout/helpers.test.ts

@@ -9,13 +9,21 @@ import {
 import { type Session } from "@opencode-ai/sdk/v2/client"
 import {
   childSessionOnPath,
+  closeHomeProject,
   displayName,
   effectiveWorkspaceOrder,
   errorMessage,
   hasProjectPermissions,
+  homeProjectNavigation,
+  homeProjectDirectories,
+  homeSessionServerStatus,
   latestRootSession,
+  toggleHomeProjectSelection,
 } from "./helpers"
 import { pathKey } from "@/utils/path-key"
+import { ServerConnection } from "@/context/server"
+
+const serverKey = ServerConnection.Key.make
 
 const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
   ({
@@ -215,6 +223,84 @@ describe("layout workspace helpers", () => {
   test("formats fallback project display name", () => {
     expect(displayName({ worktree: "/tmp/app" })).toBe("app")
     expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
+    expect(displayName({ worktree: "/" })).toBe("/")
+  })
+
+  test("scopes home project selection by server", () => {
+    expect(toggleHomeProjectSelection(undefined, serverKey("https://debian.example"), "/home/luke/repos/amazon")).toEqual({
+      server: serverKey("https://debian.example"),
+      directory: "/home/luke/repos/amazon",
+    })
+    expect(
+      toggleHomeProjectSelection(
+        { server: serverKey("https://windows.example"), directory: "/home/luke/repos/amazon" },
+        serverKey("https://debian.example"),
+        "/home/luke/repos/amazon",
+      ),
+    ).toEqual({ server: serverKey("https://debian.example"), directory: "/home/luke/repos/amazon" })
+    expect(
+      toggleHomeProjectSelection(
+        { server: serverKey("https://debian.example"), directory: "/home/luke/repos/amazon" },
+        serverKey("https://debian.example"),
+        "/home/luke/repos/amazon",
+      ),
+    ).toEqual({ server: serverKey("https://debian.example") })
+  })
+
+  test("closes a home project through its server context", () => {
+    const closed: string[] = []
+
+    expect(
+      closeHomeProject(
+        { server: serverKey("https://windows.example"), directory: "/shared" },
+        serverKey("https://debian.example"),
+        { close: (directory) => closed.push(directory) },
+        "/shared",
+      ),
+    ).toEqual({ server: serverKey("https://windows.example"), directory: "/shared" })
+    expect(closed).toEqual(["/shared"])
+    expect(
+      closeHomeProject(
+        { server: serverKey("https://debian.example"), directory: "/shared" },
+        serverKey("https://debian.example"),
+        { close: (directory) => closed.push(directory) },
+        "/shared",
+      ),
+    ).toEqual({ server: serverKey("https://debian.example") })
+  })
+
+  test("defers home project navigation until its server is active", () => {
+    expect(homeProjectNavigation(serverKey("sidecar"), serverKey("https://debian.example"), "/YW1hem9u/session")).toEqual({
+      server: serverKey("https://debian.example"),
+      href: "/YW1hem9u/session",
+    })
+    expect(homeProjectNavigation(serverKey("https://debian.example"), serverKey("https://debian.example"), "/YW1hem9u/session")).toEqual({
+      href: "/YW1hem9u/session",
+    })
+  })
+
+  test("preserves picker order when adding multiple projects", () => {
+    expect(homeProjectDirectories(["/first", "/second"])).toEqual(["/first", "/second"])
+    expect(homeProjectDirectories("/only")).toEqual(["/only"])
+    expect(homeProjectDirectories(null)).toEqual([])
+  })
+
+  test("hides status derived from an inactive server", () => {
+    let reads = 0
+    const status = () => {
+      reads++
+      return { working: true, tint: "red" }
+    }
+    expect(homeSessionServerStatus(false, status)).toEqual({
+      working: false,
+      tint: undefined,
+    })
+    expect(reads).toBe(0)
+    expect(homeSessionServerStatus(true, status)).toEqual({
+      working: true,
+      tint: "red",
+    })
+    expect(reads).toBe(1)
   })
 
   test("extracts api error message and fallback", () => {

+ 39 - 1
packages/app/src/pages/layout/helpers.ts

@@ -1,6 +1,7 @@
 import { getFilename } from "@opencode-ai/core/util/path"
 import { type Session } from "@opencode-ai/sdk/v2/client"
 import { pathKey } from "@/utils/path-key"
+import type { ServerConnection } from "@/context/server"
 
 type SessionStore = {
   session?: Session[]
@@ -53,7 +54,44 @@ export const childSessionOnPath = (sessions: Session[] | undefined, rootID: stri
 }
 
 export const displayName = (project: { name?: string; worktree: string }) =>
-  project.name || getFilename(project.worktree)
+  project.name || getFilename(project.worktree) || project.worktree
+
+export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
+
+export function toggleHomeProjectSelection(
+  current: HomeProjectSelection | undefined,
+  server: ServerConnection.Key,
+  directory: string,
+): HomeProjectSelection {
+  if (current?.server === server && current.directory === directory) return { server }
+  return { server, directory }
+}
+
+export function closeHomeProject(
+  selected: HomeProjectSelection | undefined,
+  server: ServerConnection.Key,
+  projects: { close: (directory: string) => void },
+  directory: string,
+) {
+  projects.close(directory)
+  if (selected?.server === server && selected.directory === directory) return { server }
+  return selected
+}
+
+export function homeProjectNavigation(active: ServerConnection.Key, server: ServerConnection.Key, href: string) {
+  if (active === server) return { href }
+  return { server, href }
+}
+
+export function homeProjectDirectories(result: string | string[] | null) {
+  if (!result) return []
+  return Array.isArray(result) ? result : [result]
+}
+
+export function homeSessionServerStatus(active: boolean, status: () => { working: boolean; tint?: string }) {
+  if (!active) return { working: false, tint: undefined }
+  return status()
+}
 
 const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
 

+ 4 - 2
packages/app/src/pages/layout/project-avatar-state.ts

@@ -4,18 +4,20 @@ import { useNotification } from "@/context/notification"
 import { usePermission } from "@/context/permission"
 import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
 
-export function useSessionTabAvatarState(directory: Accessor<string>, sessionId: Accessor<string>) {
+export function useSessionTabAvatarState(directory: Accessor<string>, sessionId: Accessor<string>, active: Accessor<boolean> = () => true) {
   const globalSync = useServerSync()
   const notification = useNotification()
   const permission = usePermission()
   const hasPermissions = createMemo(() => {
+    if (!active()) return false
     const [store] = globalSync.child(directory(), { bootstrap: false })
     return !!sessionPermissionRequest(store.session, store.permission, sessionId(), (item) => {
       return !permission.autoResponds(item, directory())
     })
   })
-  const unread = createMemo(() => hasPermissions() || notification.session.unseenCount(sessionId()) > 0)
+  const unread = createMemo(() => active() && (hasPermissions() || notification.session.unseenCount(sessionId()) > 0))
   const loading = createMemo(() => {
+    if (!active()) return false
     if (hasPermissions()) return false
     const [store] = globalSync.child(directory(), { bootstrap: false })
     return store.session_working(sessionId())

+ 8 - 4
packages/app/src/pages/session.tsx

@@ -39,6 +39,7 @@ import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
 import { usePrompt } from "@/context/prompt"
 import { useSDK } from "@/context/sdk"
+import { useServerSDK } from "@/context/server-sdk"
 import { useSettings } from "@/context/settings"
 import { useSync } from "@/context/sync"
 import { useTerminal } from "@/context/terminal"
@@ -54,6 +55,7 @@ import {
 import { MessageTimeline } from "@/pages/session/message-timeline"
 import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
 import { useSessionLayout } from "@/pages/session/session-layout"
+import { useServer } from "@/context/server"
 import { syncSessionModel } from "@/pages/session/session-model-helpers"
 import { SessionSidePanel } from "@/pages/session/session-side-panel"
 import { TerminalPanel } from "@/pages/session/terminal-panel"
@@ -190,13 +192,15 @@ export default function Page() {
   const dialog = useDialog()
   const language = useLanguage()
   const sdk = useSDK()
+  const serverSDK = useServerSDK()
   const settings = useSettings()
   const prompt = usePrompt()
   const comments = useComments()
   const terminal = useTerminal()
+  const server = useServer()
   const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
   const location = useLocation()
-  const { params, sessionKey, tabs, view } = useSessionLayout()
+  const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
   const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
 
   createEffect(() => {
@@ -223,7 +227,6 @@ export default function Page() {
 
   const composer = createSessionComposerState()
 
-  const workspaceKey = createMemo(() => params.dir ?? "")
   const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
 
   createEffect(
@@ -239,6 +242,7 @@ export default function Page() {
           layout.handoff.clearTabs()
           return
         }
+        if (pending.scope !== server.scope()) return
 
         if (pending.id !== id) return
         layout.handoff.clearTabs()
@@ -386,7 +390,7 @@ export default function Page() {
   })
 
   const [followup, setFollowup] = persisted(
-    Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
+    Persist.serverWorkspace(serverSDK.scope, sdk.directory, "followup", ["followup.v1"]),
     createStore<{
       items: Record<string, FollowupItem[] | undefined>
       failed: Record<string, string | undefined>
@@ -633,7 +637,7 @@ export default function Page() {
       const stale = !cached
         ? false
         : (() => {
-            const info = getSessionPrefetch(directory, id)
+            const info = getSessionPrefetch(serverSDK.scope, directory, id)
             if (!info) return true
             return Date.now() - info.at > SESSION_PREFETCH_TTL
           })()

+ 8 - 4
packages/app/src/pages/session/composer/session-question-dock.tsx

@@ -10,6 +10,8 @@ import { useLanguage } from "@/context/language"
 import { useSDK } from "@/context/sdk"
 import { makeEventListener } from "@solid-primitives/event-listener"
 import { createResizeObserver } from "@solid-primitives/resize-observer"
+import { useServerSDK } from "@/context/server-sdk"
+import { ScopedKey } from "@/utils/server-scope"
 
 const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
 
@@ -60,12 +62,14 @@ function Option(props: {
 
 export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
   const sdk = useSDK()
+  const serverSDK = useServerSDK()
   const language = useLanguage()
+  const cacheKey = ScopedKey.from(serverSDK.scope, props.request.id)
 
   const questions = createMemo(() => props.request.questions)
   const total = createMemo(() => questions().length)
 
-  const cached = cache.get(props.request.id)
+  const cached = cache.get(cacheKey)
   const [store, setStore] = createStore({
     tab: cached?.tab ?? 0,
     answers: cached?.answers ?? ([] as QuestionAnswer[]),
@@ -191,7 +195,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
   onCleanup(() => {
     if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
     if (replied) return
-    cache.set(props.request.id, {
+    cache.set(cacheKey, {
       tab: store.tab,
       answers: store.answers.map((a) => (a ? [...a] : [])),
       custom: store.custom.map((s) => s ?? ""),
@@ -211,7 +215,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
     },
     onSuccess: () => {
       replied = true
-      cache.delete(props.request.id)
+      cache.delete(cacheKey)
     },
     onError: fail,
   }))
@@ -223,7 +227,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
     },
     onSuccess: () => {
       replied = true
-      cache.delete(props.request.id)
+      cache.delete(cacheKey)
     },
     onError: fail,
   }))

+ 9 - 3
packages/app/src/pages/session/session-layout.ts

@@ -1,19 +1,25 @@
 import { useParams } from "@solidjs/router"
 import { createMemo } from "solid-js"
 import { useLayout } from "@/context/layout"
+import { useServer } from "@/context/server"
+import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
 
 export const useSessionKey = () => {
   const params = useParams()
-  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
-  return { params, sessionKey }
+  const server = useServer()
+  const scope = createMemo(() => server.scope())
+  const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir)))
+  const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir, params.id)))
+  return { params, sessionKey, workspaceKey }
 }
 
 export const useSessionLayout = () => {
   const layout = useLayout()
-  const { params, sessionKey } = useSessionKey()
+  const { params, sessionKey, workspaceKey } = useSessionKey()
   return {
     params,
     sessionKey,
+    workspaceKey,
     tabs: createMemo(() => layout.tabs(sessionKey)),
     view: createMemo(() => layout.view(sessionKey)),
   }

+ 3 - 3
packages/app/src/pages/session/terminal-panel.tsx

@@ -26,7 +26,7 @@ export function TerminalPanel() {
   const terminal = useTerminal()
   const language = useLanguage()
   const command = useCommand()
-  const { params, view } = useSessionLayout()
+  const { params, workspaceKey, view } = useSessionLayout()
 
   const opened = createMemo(() => view().terminal.opened())
   const size = createSizing()
@@ -126,7 +126,7 @@ export function TerminalPanel() {
     language.locale()
 
     setTerminalHandoff(
-      dir,
+      workspaceKey(),
       terminal.all().map((pty) =>
         terminalTabLabel({
           title: pty.title,
@@ -140,7 +140,7 @@ export function TerminalPanel() {
   const handoff = createMemo(() => {
     const dir = params.dir
     if (!dir) return []
-    return getTerminalHandoff(dir) ?? []
+    return getTerminalHandoff(workspaceKey()) ?? []
   })
 
   const all = terminal.all

+ 26 - 0
packages/app/src/utils/persist.test.ts

@@ -1,4 +1,5 @@
 import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
+import { ServerScope } from "./server-scope"
 
 type PersistTestingType = typeof import("./persist").PersistTesting
 type PersistType = typeof import("./persist").Persist
@@ -164,4 +165,29 @@ describe("persist localStorage resilience", () => {
     expect(storage.getItem(`${target.storage}:${target.key}`)).toBeNull()
     expect(storage.getItem(`${target.legacyStorageNames![0]}:${target.key}`)).toBeNull()
   })
+
+  test("server workspace target preserves local storage and isolates remote storage", () => {
+    const local = Persist.serverWorkspace(ServerScope.local, "/home/luke/repo", "prompt")
+    const windows = Persist.serverWorkspace("https://windows.example" as ServerScope, "/home/luke/repo", "prompt")
+    const debian = Persist.serverWorkspace("https://debian.example" as ServerScope, "/home/luke/repo", "prompt")
+
+    expect(local).toEqual(Persist.workspace("/home/luke/repo", "prompt"))
+    expect(windows.storage).not.toBe(local.storage)
+    expect(debian.storage).not.toBe(local.storage)
+    expect(debian.storage).not.toBe(windows.storage)
+    expect(windows.legacyStorageNames).toBeUndefined()
+    expect(debian.legacyStorageNames).toBeUndefined()
+  })
+
+  test("server global target preserves local key and isolates remote keys", () => {
+    expect(Persist.serverGlobal(ServerScope.local, "notification")).toEqual(Persist.global("notification"))
+    expect(Persist.serverGlobal("https://debian.example" as ServerScope, "notification")).toEqual({
+      storage: "opencode.global.dat",
+      key: "https://debian.example\0notification",
+    })
+  })
+
+  test("server global target cannot collide when scope and key contain colons", () => {
+    expect(Persist.serverGlobal("a:b" as ServerScope, "c")).not.toEqual(Persist.serverGlobal("a" as ServerScope, "b:c"))
+  })
 })

+ 22 - 9
packages/app/src/utils/persist.ts

@@ -4,6 +4,7 @@ import { checksum } from "@opencode-ai/core/util/encode"
 import { createResource, type Accessor } from "solid-js"
 import type { SetStoreFunction, Store } from "solid-js/store"
 import { pathKey } from "@/utils/path-key"
+import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope"
 
 type InitType = Promise<string> | string | null
 type PersistedWithReady<T> = [
@@ -357,6 +358,11 @@ function legacyWorkspaceStorage(dir: string) {
   return [...result]
 }
 
+function serverWorkspaceTarget(scope: ServerScopeValue, dir: string, key: string, legacy?: string[]): PersistTarget {
+  if (scope !== ServerScope.local) return { storage: workspaceStorage(ScopedKey.from(scope, pathKey(dir))), key }
+  return { storage: workspaceStorage(pathKey(dir)), legacyStorageNames: legacyWorkspaceStorage(dir), key, legacy }
+}
+
 function localStorageWithPrefix(prefix: string): SyncStorage {
   const base = `${prefix}:`
   const scope = `prefix:${prefix}`
@@ -456,23 +462,30 @@ export const Persist = {
   global(key: string, legacy?: string[]): PersistTarget {
     return { storage: GLOBAL_STORAGE, key, legacy }
   },
+  serverGlobal(scope: ServerScopeValue, key: string, legacy?: string[]): PersistTarget {
+    if (scope === ServerScope.local) return Persist.global(key, legacy)
+    return { storage: GLOBAL_STORAGE, key: ScopedKey.from(scope, key) }
+  },
   workspace(dir: string, key: string, legacy?: string[]): PersistTarget {
-    const storage = workspaceStorage(pathKey(dir))
-    return { storage, legacyStorageNames: legacyWorkspaceStorage(dir), key: `workspace:${key}`, legacy }
+    return serverWorkspaceTarget(ServerScope.local, dir, `workspace:${key}`, legacy)
+  },
+  serverWorkspace(scope: ServerScopeValue, dir: string, key: string, legacy?: string[]): PersistTarget {
+    return serverWorkspaceTarget(scope, dir, `workspace:${key}`, legacy)
   },
   session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
-    const storage = workspaceStorage(pathKey(dir))
-    return {
-      storage,
-      legacyStorageNames: legacyWorkspaceStorage(dir),
-      key: `session:${session}:${key}`,
-      legacy,
-    }
+    return serverWorkspaceTarget(ServerScope.local, dir, `session:${session}:${key}`, legacy)
+  },
+  serverSession(scope: ServerScopeValue, dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
+    return serverWorkspaceTarget(scope, dir, `session:${session}:${key}`, legacy)
   },
   scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget {
     if (session) return Persist.session(dir, session, key, legacy)
     return Persist.workspace(dir, key, legacy)
   },
+  serverScoped(scope: ServerScopeValue, dir: string, session: string | undefined, key: string, legacy?: string[]) {
+    if (session) return Persist.serverSession(scope, dir, session, key, legacy)
+    return Persist.serverWorkspace(scope, dir, key, legacy)
+  },
 }
 
 export function removePersisted(

+ 25 - 0
packages/app/src/utils/server-health.test.ts

@@ -25,6 +25,31 @@ describe("checkServerHealth", () => {
     expect(result).toEqual({ healthy: true, version: "1.2.3" })
   })
 
+  test("allows slow servers thirty seconds by default", async () => {
+    const timeout = Object.getOwnPropertyDescriptor(AbortSignal, "timeout")
+    let timeoutMs = 0
+    Object.defineProperty(AbortSignal, "timeout", {
+      configurable: true,
+      value: (ms: number) => {
+        timeoutMs = ms
+        return new AbortController().signal
+      },
+    })
+
+    const fetch = (async () =>
+      new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
+        status: 200,
+        headers: { "content-type": "application/json" },
+      })) as unknown as typeof globalThis.fetch
+
+    await checkServerHealth(server, fetch).finally(() => {
+      if (timeout) Object.defineProperty(AbortSignal, "timeout", timeout)
+      if (!timeout) Reflect.deleteProperty(AbortSignal, "timeout")
+    })
+
+    expect(timeoutMs).toBe(30_000)
+  })
+
   test("returns unhealthy when request fails", async () => {
     const fetch = (async () => {
       throw new Error("network")

+ 5 - 2
packages/app/src/utils/server-health.ts

@@ -13,7 +13,7 @@ interface CheckServerHealthOptions {
   retryDelayMs?: number
 }
 
-const defaultTimeoutMs = 3000
+const defaultTimeoutMs = 30_000
 const defaultRetryCount = 2
 const defaultRetryDelayMs = 100
 const cacheMs = 750
@@ -132,7 +132,10 @@ export const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabl
       const results: Record<string, ServerHealth> = {}
       await Promise.all(
         list.map(async (conn) => {
-          results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
+          const key = ServerConnection.key(conn)
+          const result = await checkServerHealth(conn.http)
+          results[key] = result
+          if (!dead) setStatus(key, result)
         }),
       )
       if (dead) return

+ 57 - 0
packages/app/src/utils/server-scope.test.ts

@@ -0,0 +1,57 @@
+import { describe, expect, test } from "bun:test"
+import { ScopedKey, ServerScope, SessionRouteKey, SessionStateKey, migrateLegacySessionStateKeys } from "./server-scope"
+
+describe("ServerScope", () => {
+  test("uses a stable local scope for the canonical sidecar", () => {
+    expect(String(ServerScope.fromServerKey("sidecar" as Parameters<typeof ServerScope.fromServerKey>[0]))).toBe("local")
+  })
+
+  test("keeps configured loopback servers distinct from the canonical sidecar", () => {
+    expect(String(ServerScope.fromServerKey("http://localhost:4096" as Parameters<typeof ServerScope.fromServerKey>[0]))).toBe(
+      "http://localhost:4096",
+    )
+  })
+
+  test("uses a stable local scope for an explicit canonical web server", () => {
+    const key = "http://localhost:4096" as Parameters<typeof ServerScope.fromServerKey>[0]
+    expect(String(ServerScope.fromServerKey(key, key))).toBe("local")
+  })
+})
+
+describe("SessionStateKey", () => {
+  test("combines local and remote scope with route identity", () => {
+    const route = SessionRouteKey.fromRoute("cmVwbw", "session-1")
+    expect(String(SessionStateKey.from(ServerScope.local, route))).toBe("local\0cmVwbw/session-1")
+    expect(String(SessionStateKey.from("https://windows.example" as ServerScope, route))).toBe(
+      "https://windows.example\0cmVwbw/session-1",
+    )
+    expect(SessionStateKey.from("https://debian.example" as ServerScope, route)).not.toBe(
+      SessionStateKey.from("https://windows.example" as ServerScope, route),
+    )
+  })
+
+  test("extracts route keys from scoped and legacy state keys", () => {
+    expect(String(SessionStateKey.route("cmVwbw/session-1"))).toBe("cmVwbw/session-1")
+    expect(String(SessionStateKey.route("local\0cmVwbw/session-1"))).toBe("cmVwbw/session-1")
+    expect(String(SessionStateKey.route("https://debian.example\0cmVwbw/session-1"))).toBe("cmVwbw/session-1")
+  })
+})
+
+describe("migrateLegacySessionStateKeys", () => {
+  test("copies legacy route keys into local scope without overwriting scoped state", () => {
+    expect(
+      migrateLegacySessionStateKeys({
+        "cmVwbw/session-1": { active: "legacy" },
+        "local\0cmVwbw/session-1": { active: "scoped" },
+        "https://debian.example\0cmVwbw/session-1": { active: "remote" },
+      }),
+    ).toEqual({
+      "local\0cmVwbw/session-1": { active: "scoped" },
+      "https://debian.example\0cmVwbw/session-1": { active: "remote" },
+    })
+  })
+
+  test("rejects invalid identity fragments", () => {
+    expect(() => ScopedKey.from(ServerScope.local, "bad\0directory")).toThrow("Scoped key part cannot contain null bytes")
+  })
+})

+ 70 - 0
packages/app/src/utils/server-scope.ts

@@ -0,0 +1,70 @@
+import type { ServerConnection } from "@/context/server"
+
+export type ServerScope = string & { readonly __brand: "ServerScope" }
+export type SessionRouteKey = string & { readonly __brand: "SessionRouteKey" }
+export type SessionStateKey = string & { readonly __brand: "SessionStateKey" }
+export type ScopedKey = string & { readonly __brand: "ScopedKey" }
+
+const separator = "\u0000"
+
+function fragment(label: string, value: string) {
+  if (value.includes(separator)) throw new Error(`${label} cannot contain null bytes`)
+  return value
+}
+
+function compose(scope: ServerScope, parts: string[]) {
+  return [fragment("Server scope", scope), ...parts.map((part) => fragment("Scoped key part", part))].join(separator)
+}
+
+export const ServerScope = {
+  local: "local" as ServerScope,
+  fromServerKey(key: ServerConnection.Key, canonicalLocalServer?: ServerConnection.Key) {
+    return fragment("Server scope", key === "sidecar" || key === canonicalLocalServer ? ServerScope.local : key) as ServerScope
+  },
+}
+
+export const SessionRouteKey = {
+  fromRoute(dir: string | undefined, sessionID?: string) {
+    return fragment("Session route", `${dir ?? ""}${sessionID ? "/" + sessionID : ""}`) as SessionRouteKey
+  },
+  fromLegacy(key: string) {
+    return fragment("Legacy session route", key) as SessionRouteKey
+  },
+}
+
+export const SessionStateKey = {
+  from(scope: ServerScope, route: SessionRouteKey) {
+    return compose(scope, [route]) as SessionStateKey
+  },
+  route(key: string) {
+    const split = key.lastIndexOf(separator)
+    return SessionRouteKey.fromLegacy(split === -1 ? key : key.slice(split + 1))
+  },
+  scope(key: string) {
+    const split = key.indexOf(separator)
+    if (split === -1) return ServerScope.local
+    return fragment("Stored server scope", key.slice(0, split)) as ServerScope
+  },
+}
+
+export const ScopedKey = {
+  from(scope: ServerScope, ...parts: string[]) {
+    return compose(scope, parts) as ScopedKey
+  },
+  prefix(scope: ServerScope, ...parts: string[]) {
+    return `${ScopedKey.from(scope, ...parts)}${separator}`
+  },
+}
+
+export function migrateLegacySessionStateKeys(value: unknown) {
+  if (!value || typeof value !== "object" || Array.isArray(value)) return value
+  const entries = Object.entries(value)
+  if (entries.every(([key]) => key.includes(separator))) return value
+  const scoped = Object.fromEntries(entries.filter(([key]) => key.includes(separator)))
+  for (const [key, item] of entries) {
+    if (key.includes(separator)) continue
+    const next = SessionStateKey.from(ServerScope.local, SessionRouteKey.fromLegacy(key))
+    if (!(next in scoped)) scoped[next] = item
+  }
+  return scoped
+}

+ 24 - 12
packages/app/src/utils/worktree.test.ts

@@ -1,34 +1,36 @@
 import { describe, expect, test } from "bun:test"
 import { Worktree } from "./worktree"
+import { ServerScope } from "./server-scope"
 
 const dir = (name: string) => `/tmp/opencode-worktree-${name}-${crypto.randomUUID()}`
 
 describe("Worktree", () => {
+  const scope = ServerScope.local
   test("normalizes trailing slashes", () => {
     const key = dir("normalize")
-    Worktree.ready(`${key}/`)
+    Worktree.ready(scope, `${key}/`)
 
-    expect(Worktree.get(key)).toEqual({ status: "ready" })
+    expect(Worktree.get(scope, key)).toEqual({ status: "ready" })
   })
 
   test("pending does not overwrite a terminal state", () => {
     const key = dir("pending")
-    Worktree.failed(key, "boom")
-    Worktree.pending(key)
+    Worktree.failed(scope, key, "boom")
+    Worktree.pending(scope, key)
 
-    expect(Worktree.get(key)).toEqual({ status: "failed", message: "boom" })
+    expect(Worktree.get(scope, key)).toEqual({ status: "failed", message: "boom" })
   })
 
   test("wait resolves shared pending waiter when ready", async () => {
     const key = dir("wait-ready")
-    Worktree.pending(key)
+    Worktree.pending(scope, key)
 
-    const a = Worktree.wait(key)
-    const b = Worktree.wait(`${key}/`)
+    const a = Worktree.wait(scope, key)
+    const b = Worktree.wait(scope, `${key}/`)
 
     expect(a).toBe(b)
 
-    Worktree.ready(key)
+    Worktree.ready(scope, key)
 
     expect(await a).toEqual({ status: "ready" })
     expect(await b).toEqual({ status: "ready" })
@@ -36,11 +38,21 @@ describe("Worktree", () => {
 
   test("wait resolves with failure message", async () => {
     const key = dir("wait-failed")
-    const waiting = Worktree.wait(key)
+    const waiting = Worktree.wait(scope, key)
 
-    Worktree.failed(key, "permission denied")
+    Worktree.failed(scope, key, "permission denied")
 
     expect(await waiting).toEqual({ status: "failed", message: "permission denied" })
-    expect(await Worktree.wait(key)).toEqual({ status: "failed", message: "permission denied" })
+    expect(await Worktree.wait(scope, key)).toEqual({ status: "failed", message: "permission denied" })
+  })
+
+  test("isolates identical directories by server scope", () => {
+    const key = dir("scope")
+    const remote = "https://debian.example" as ServerScope
+    Worktree.ready(scope, key)
+    Worktree.failed(remote, key, "remote failed")
+
+    expect(Worktree.get(scope, key)).toEqual({ status: "ready" })
+    expect(Worktree.get(remote, key)).toEqual({ status: "failed", message: "remote failed" })
   })
 })

+ 24 - 21
packages/app/src/utils/worktree.ts

@@ -1,4 +1,7 @@
+import { ScopedKey, type ServerScope } from "@/utils/server-scope"
+
 const normalize = (directory: string) => directory.replace(/[\\/]+$/, "")
+const key = (scope: ServerScope, directory: string) => ScopedKey.from(scope, normalize(directory))
 
 type State =
   | {
@@ -30,44 +33,44 @@ function deferred() {
 }
 
 export const Worktree = {
-  get(directory: string) {
-    return state.get(normalize(directory))
+  get(scope: ServerScope, directory: string) {
+    return state.get(key(scope, directory))
   },
-  pending(directory: string) {
-    const key = normalize(directory)
-    const current = state.get(key)
+  pending(scope: ServerScope, directory: string) {
+    const id = key(scope, directory)
+    const current = state.get(id)
     if (current && current.status !== "pending") return
-    state.set(key, { status: "pending" })
+    state.set(id, { status: "pending" })
   },
-  ready(directory: string) {
-    const key = normalize(directory)
+  ready(scope: ServerScope, directory: string) {
+    const id = key(scope, directory)
     const next = { status: "ready" } as const
-    state.set(key, next)
-    const waiter = waiters.get(key)
+    state.set(id, next)
+    const waiter = waiters.get(id)
     if (!waiter) return
-    waiters.delete(key)
+    waiters.delete(id)
     waiter.resolve(next)
   },
-  failed(directory: string, message: string) {
-    const key = normalize(directory)
+  failed(scope: ServerScope, directory: string, message: string) {
+    const id = key(scope, directory)
     const next = { status: "failed", message } as const
-    state.set(key, next)
-    const waiter = waiters.get(key)
+    state.set(id, next)
+    const waiter = waiters.get(id)
     if (!waiter) return
-    waiters.delete(key)
+    waiters.delete(id)
     waiter.resolve(next)
   },
-  wait(directory: string) {
-    const key = normalize(directory)
-    const current = state.get(key)
+  wait(scope: ServerScope, directory: string) {
+    const id = key(scope, directory)
+    const current = state.get(id)
     if (current && current.status !== "pending") return Promise.resolve(current)
 
-    const existing = waiters.get(key)
+    const existing = waiters.get(id)
     if (existing) return existing.promise
 
     const waiter = deferred()
 
-    waiters.set(key, waiter)
+    waiters.set(id, waiter)
     return waiter.promise
   },
 }

+ 1 - 0
packages/desktop/src/main/server.ts

@@ -223,6 +223,7 @@ function createSidecarEnv(): Record<string, string> {
   )
   delete env.DEBUG
   if (process.platform === "linux") delete env.LD_PRELOAD
+  if (!app.isPackaged) env.OPENCODE_DISABLE_CHANNEL_DB = "1"
   return env
 }
 

+ 6 - 5
packages/ui/src/context/helper.tsx

@@ -1,10 +1,11 @@
 import { createContext, createMemo, Show, useContext, type ParentProps, type Accessor } from "solid-js"
 
-export function createSimpleContext<T, Props extends Record<string, any>>(input: {
-  name: string
-  init: ((input: Props) => T) | (() => T)
-  gate?: boolean
-}) {
+export function createSimpleContext<T, Props extends Record<string, any>>(
+  input: {
+    name: string
+    init: ((input: Props) => T) | (() => T)
+  } & (T extends { ready: unknown } ? { gate: boolean } : { gate?: boolean }),
+) {
   const ctx = createContext<T>()
 
   return {