Parcourir la source

fix(app): improve tab handling (#30669)

Brendan Allan il y a 2 mois
Parent
commit
5426478e46

+ 128 - 118
packages/app/src/components/titlebar.tsx

@@ -1,4 +1,4 @@
-import { createEffect, createMemo, For, mapArray, Match, Show, startTransition, Switch, untrack } from "solid-js"
+import { createEffect, createMemo, createResource, For, Match, Show, startTransition, Switch, untrack } from "solid-js"
 import { createStore, produce } from "solid-js/store"
 import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router"
 import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -9,7 +9,7 @@ import { useTheme } from "@opencode-ai/ui/theme/context"
 import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
 import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
 
-import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout"
+import { getProjectAvatarVariant, LayoutRoute, useLayout, type LocalProject } from "@/context/layout"
 import { usePlatform } from "@/context/platform"
 import { useCommand } from "@/context/command"
 import { useLanguage } from "@/context/language"
@@ -17,7 +17,6 @@ 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 { decodeDirectory } from "@/pages/directory-layout"
 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"
@@ -29,6 +28,9 @@ import {
   SESSION_TABS_REMOVED_EVENT,
   type SessionTabsRemovedDetail,
 } from "@/components/titlebar-session-events"
+import { Persist, persisted } from "@/utils/persist"
+import { useGlobal } from "@/context/global"
+import { decode64 } from "@/utils/base64"
 
 type TauriDesktopWindow = {
   startDragging?: () => Promise<void>
@@ -243,6 +245,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
             const serverSync = useServerSync()
             const navigate = useNavigate()
             const homeMatch = useMatch(() => "/")
+            const layout = useLayout()
 
             const newSessionHref = () => {
               if (params.dir) return `/${params.dir}/session`
@@ -253,41 +256,38 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
               return `/${base64Encode(project.worktree)}/session`
             }
 
-            type Tab = { dir: string; sessionId: string; href: string }
+            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)
+              }
+              return "/"
+            }
 
             const [tabsStore, tabsStoreActions] = iife(() => {
-              const [store, setStore] = createStore<Tab[]>(
-                iife(() => {
-                  if (!params.dir || !params.id) return []
-                  return [
-                    {
-                      dir: decodeDirectory(params.dir) ?? "",
-                      sessionId: params.id,
-                      href: makeSessionHref(params.dir, params.id),
-                    },
-                  ]
-                }),
-              )
+              const [store, setStore] = persisted(Persist.global("tabs"), createStore<Tab[]>([]))
 
               const actions = {
-                addTab: (tab: Tab) => {
+                addSessionTab: (tab: Omit<SessionTab, "type">) => {
                   setStore(
                     produce((tabs) => {
-                      if (tabs.some((t) => t.href === tab.href)) return
+                      if (tabs.some((t) => t.type === "session" && tabHref(t) === tabHref({ type: "session", ...tab })))
+                        return
 
-                      tabs.push(tab)
+                      tabs.push({ type: "session", ...tab })
                     }),
                   )
                 },
-                removeTab: (href: string) => {
+                removeTab: (index: number) => {
+                  if (index < 0) return
                   void startTransition(() => {
                     setStore(
                       produce((tabs) => {
-                        const index = tabs.findIndex((t) => t.href === href)
-                        if (index === -1) return
+                        const nextTab = tabs[index + 1] ?? tabs[index - 1]
                         tabs.splice(index, 1)
-                        const nextTab = tabs[index] ?? tabs[tabs.length - 1]
-                        if (nextTab) navigate(nextTab.href)
+                        if (nextTab) navigate(tabHref(nextTab))
                         else navigate("/")
                       }),
                     )
@@ -299,23 +299,28 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                       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.href === currentHref) : -1
+                        const currentIndex = currentHref
+                          ? tabs.findIndex((tab) => tab.type === "session" && tabHref(tab) === currentHref)
+                          : -1
+                        const currentTab = tabs[currentIndex]
                         const removedCurrent =
-                          currentIndex !== -1 &&
-                          tabs[currentIndex]?.dir === input.directory &&
-                          sessionIDs.has(tabs[currentIndex]?.sessionId ?? "")
+                          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) continue
-                          if (tab.dir !== input.directory) continue
+                          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[currentIndex] ?? tabs[tabs.length - 1]
-                        if (nextTab) navigate(nextTab.href)
+                        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("/")
                       }),
                     )
@@ -326,55 +331,54 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
               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)
+                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)
+                  if (parent) return parent
+                }
+              }
+            }
+
+            const currentTab = () => matchRoute(layout.route())
+
+            createEffect(() => {
+              const route = layout.route()
+              const tab = currentTab()
+              if (tab) return
+
+              if (route.type === "session") {
+                const sync = serverSync.createDirSyncContext(route.dir)
+                const session = sync.session.get(route.sessionId)
+                if (!session) return
+                const sessionId = session.parentID ?? session.id
+                tabsStoreActions.addSessionTab({
+                  dirBase64: route.dirBase64,
+                  sessionId,
+                })
+              }
+            })
+
             makeEventListener(window, SESSION_TABS_REMOVED_EVENT, (event) => {
               const detail = readSessionTabsRemovedDetail(event)
               if (!detail) return
               tabsStoreActions.removeSessions(detail)
             })
 
-            createEffect(() => {
-              const params = useParams()
-              if (!(params.dir && params.id)) return
-
-              tabsStoreActions.addTab({
-                dir: decodeDirectory(params.dir) ?? "",
-                sessionId: params.id,
-                href: makeSessionHref(params.dir, params.id),
-              })
-            })
-
-            const projects = createMemo(() => layout.projects.list())
-            const projectByID = createMemo(
-              () => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
-            )
-
-            const currentSessionTab = () => {
-              if (!params.dir || !params.id) return
-              const href = makeSessionHref(params.dir, params.id)
-              return tabsStore.find((tab) => tab.href === href)
-            }
-
-            const closeCurrentSessionTab = () => {
-              const tab = currentSessionTab()
-              if (!tab) return false
-              tabsStoreActions.removeTab(tab.href)
-              return true
-            }
-
-            const closeNewSessionTab = () => {
-              if (!(params.dir && !params.id)) return false
-              const last = tabsStore[tabsStore.length - 1]
-              if (last) navigate(last.href)
-              else navigate("/")
-              return true
-            }
-
             const openNewTab = () => navigate(newSessionHref())
 
-            const closeActiveTab = () => closeCurrentSessionTab() || closeNewSessionTab()
+            command.register("tabs", () => {
+              const current = currentTab()
 
-            command.register(() => {
-              const commands = [
+              return [
                 {
                   id: "tab.new",
                   category: "tab",
@@ -383,13 +387,15 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                   hidden: true,
                   onSelect: openNewTab,
                 },
-                {
+                current && {
                   id: "tab.close",
                   category: "tab",
                   title: language.t("command.tab.close"),
                   keybind: "mod+w",
                   hidden: true,
-                  onSelect: closeActiveTab,
+                  onSelect: () => {
+                    tabsStoreActions.removeTab(tabsStore.findIndex((tab) => current === tab))
+                  },
                 },
                 {
                   id: `tab.prev`,
@@ -398,14 +404,14 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                   keybind: `mod+option+ArrowLeft`,
                   hidden: true,
                   onSelect: () => {
-                    let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href)
+                    let index = tabsStore.findIndex((tab) => tab === currentTab())
                     if (index === -1) return
 
                     index -= 1
                     if (index === -1) index = tabsStore.length - 1
 
                     const next = tabsStore[index]
-                    if (next) navigate(next.href)
+                    if (next) navigate(tabHref(next))
                   },
                 },
                 {
@@ -415,14 +421,14 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                   keybind: `mod+option+ArrowRight`,
                   hidden: true,
                   onSelect: () => {
-                    let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href)
+                    let index = tabsStore.findIndex((tab) => tab === currentTab())
                     if (index === -1) return
 
                     index += 1
                     if (index === tabsStore.length) index = 0
 
                     const next = tabsStore[index]
-                    if (next) navigate(next.href)
+                    if (next) navigate(tabHref(next))
                   },
                 },
                 ...Array.from({ length: 9 }, (_, i) => {
@@ -437,26 +443,11 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                     hidden: true,
                     onSelect: () => {
                       const tab = tabsStore[index]
-                      if (tab) navigate(tab.href)
+                      if (tab) navigate(tabHref(tab))
                     },
                   }
                 }),
-              ]
-
-              return commands
-            })
-
-            const tabsEnriched = iife(() => {
-              const base = mapArray(
-                () => tabsStore,
-                (tab) => {
-                  const sync = serverSync.createDirSyncContext(tab.dir)
-                  const session = sync.session.get(tab.sessionId)
-                  return session ? { ...tab, info: session } : null
-                },
-              )
-
-              return () => base().flatMap((s) => (s ? [s] : []))
+              ].filter((v) => v !== undefined)
             })
 
             return (
@@ -483,19 +474,18 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
 
                 <div class="flex min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden">
                   <div class="flex min-w-0 flex-row items-center gap-1.5 overflow-hidden">
-                    <For each={tabsEnriched()}>
+                    <For each={tabsStore}>
                       {(tab, i) => (
                         <>
                           {i() !== 0 && (
                             <div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
                           )}
                           <TabNavItem
-                            href={tab.href}
-                            title={tab.info.title}
-                            project={projectForSession(tab.info, projects(), projectByID())}
-                            directory={tab.dir}
-                            sessionId={tab.info.id}
-                            onClose={() => tabsStoreActions.removeTab(tab.href)}
+                            href={tabHref(tab)}
+                            directory={decode64(tab.dirBase64)!}
+                            sessionId={tab.sessionId}
+                            onClose={() => tabsStoreActions.removeTab(i())}
+                            active={currentTab() === tab}
                           />
                         </>
                       )}
@@ -519,7 +509,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                     <NewSessionTabItem
                       href={`/${params.dir}/session`}
                       title={language.t("command.session.new")}
-                      onClose={() => navigate(tabsEnriched().at(-1)?.href ?? "/")}
+                      onClose={() => navigate(tabsStore.at(-1) ? tabHref(tabsStore.at(-1)!) : "/")}
                     />
                   </Show>
                   <div class="min-w-0 flex-1" />
@@ -746,38 +736,58 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
 
 function TabNavItem(props: {
   href: string
-  title: string
-  project?: LocalProject
   directory: string
-  sessionId: string
+  sessionId?: string
   hideClose?: boolean
   onClose: () => void
+  active?: boolean
 }) {
-  const match = useMatch(() => props.href)
-  const isActive = () => !!match()
   const closeTab = (event: MouseEvent) => {
     event.preventDefault()
     event.stopPropagation()
     props.onClose()
   }
+  const global = useGlobal()
+  const serverCtx = global.createServerCtx(global.servers.default())
+
+  const [session] = createResource(
+    () => {
+      const dirSyncCtx = serverCtx.sync.createDirSyncContext(props.directory)
+      return props.sessionId ? ([props.sessionId, dirSyncCtx] as const) : undefined
+    },
+    async ([sessionId, dirSyncCtx]) => {
+      await dirSyncCtx.session.sync(sessionId).catch(() => {})
+      return dirSyncCtx.session.get(sessionId)
+    },
+  )
+
   return (
     <div
       class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
-      data-active={isActive()}
+      data-active={props.active}
       onMouseDown={(event) => {
         if (event.button !== 1) return
         closeTab(event)
       }}
     >
-      <a
-        href={props.href}
-        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={props.project} directory={props.directory} sessionId={props.sessionId} />
-        </span>
-        <span class="min-w-0 flex-1">{props.title}</span>
-      </a>
+      <Show when={session()}>
+        {(session) => {
+          const layout = useLayout()
+          const project = createMemo(() => projectForSession(session(), layout.projects.list()))
+
+          return (
+            <a
+              href={props.href}
+              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} />
+              </span>
+              <span class="min-w-0 flex-1">{session().title}</span>
+            </a>
+          )
+        }}
+      </Show>
 
       <div class="absolute not-group-hover:not-group-data-[active=true]:left-52 group-hover:right-0 group-data-[active=true]:right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
         <div

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

@@ -8,11 +8,11 @@ import {
   getSessionPrefetchPromise,
   setSessionPrefetch,
 } from "./global-sync/session-prefetch"
-import { createServerSyncContext } from "./server-sync"
 import type { Message, Part } from "@opencode-ai/sdk/v2/client"
 import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
 import { diffs as list, message as clean } from "@/utils/diffs"
-import { useServerSDK } from "./server-sdk"
+import { createServerSdkContext, useServerSDK } from "./server-sdk"
+import { type createServerSyncContextInner } from "./server-sync"
 
 const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
 
@@ -171,8 +171,11 @@ function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: Opti
   })
 }
 
-export const createDirSyncContext = (directory: string, serverSync: ReturnType<typeof createServerSyncContext>) => {
-  const serverSDK = useServerSDK()
+export const createDirSyncContext = (
+  directory: string,
+  serverSync: ReturnType<typeof createServerSyncContextInner>,
+  serverSDK: ReturnType<typeof createServerSdkContext> = useServerSDK(),
+) => {
   const client = serverSDK.createClient({ directory, throwOnError: true })
 
   type Child = ReturnType<(typeof serverSync)["child"]>

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

@@ -73,6 +73,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
       servers: {
         list: allServers,
         health: serverHealth,
+        default: () => allServers().find((s) => ServerConnection.key(s) === props.defaultServer) ?? allServers()[0]!,
       },
       settings: {
         server: {

+ 38 - 0
packages/app/src/context/layout-helpers.ts

@@ -0,0 +1,38 @@
+import type { Accessor } from "solid-js"
+
+export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) {
+  touch(key)
+  seed(key)
+  return key
+}
+
+export function createSessionKeyReader(sessionKey: string | Accessor<string>, ensure: (key: string) => void) {
+  const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
+  return () => {
+    const value = key()
+    ensure(value)
+    return value
+  }
+}
+
+export function pruneSessionKeys(input: {
+  keep?: string
+  max: number
+  used: Map<string, number>
+  view: string[]
+  tabs: string[]
+}) {
+  if (!input.keep) return []
+
+  const keys = new Set<string>([...input.view, ...input.tabs])
+  if (keys.size <= input.max) return []
+
+  const score = (key: string) => {
+    if (key === input.keep) return Number.MAX_SAFE_INTEGER
+    return input.used.get(key) ?? 0
+  }
+
+  return Array.from(keys)
+    .sort((a, b) => score(b) - score(a))
+    .slice(input.max)
+}

+ 1 - 1
packages/app/src/context/layout.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import { createRoot, createSignal } from "solid-js"
-import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout"
+import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
 
 describe("layout session-key helpers", () => {
   test("couples touch and scroll seed in order", () => {

+ 27 - 37
packages/app/src/context/layout.tsx

@@ -1,10 +1,11 @@
 import { createStore, produce } from "solid-js/store"
 import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
+import { useLocation } from "@solidjs/router"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { makeEventListener } from "@solid-primitives/event-listener"
 import { useServerSync } from "./server-sync"
 import { useServerSDK } from "./server-sdk"
-import { useServer } from "./server"
+import { ServerConnection, useServer } from "./server"
 import { usePlatform } from "./platform"
 import { Project } from "@opencode-ai/sdk/v2"
 import { Persist, persisted, removePersisted } from "@/utils/persist"
@@ -13,6 +14,9 @@ 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 { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
+
+export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
 
 export type { ProjectAvatarVariant }
 
@@ -69,42 +73,10 @@ export type LocalProject = Partial<Project> & { worktree: string; expanded: bool
 
 export type ReviewDiffStyle = "unified" | "split"
 
-export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) {
-  touch(key)
-  seed(key)
-  return key
-}
-
-export function createSessionKeyReader(sessionKey: string | Accessor<string>, ensure: (key: string) => void) {
-  const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
-  return () => {
-    const value = key()
-    ensure(value)
-    return value
-  }
-}
-
-export function pruneSessionKeys(input: {
-  keep?: string
-  max: number
-  used: Map<string, number>
-  view: string[]
-  tabs: string[]
-}) {
-  if (!input.keep) return []
-
-  const keys = new Set<string>([...input.view, ...input.tabs])
-  if (keys.size <= input.max) return []
-
-  const score = (key: string) => {
-    if (key === input.keep) return Number.MAX_SAFE_INTEGER
-    return input.used.get(key) ?? 0
-  }
-
-  return Array.from(keys)
-    .sort((a, b) => score(b) - score(a))
-    .slice(input.max)
-}
+export type LayoutRoute =
+  | { type: "home" }
+  | { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
+  | { type: "session"; dir: string; dirBase64: string; sessionId: string; server?: ServerConnection.Key }
 
 function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
   const all = current?.all ?? []
@@ -146,6 +118,21 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
   }
 }
 
+const currentRoute = (pathname: string): LayoutRoute => {
+  const parts = pathname.split("/").filter(Boolean)
+  if (parts.length === 0) return { type: "home" }
+
+  const dirBase64 = parts[0]
+  const dir = decode64(dirBase64)
+  if (!dir) return { type: "home" }
+
+  if (parts[1] !== "session") return { type: "home" }
+
+  const id = parts[2]
+  if (id) return { type: "session", dir, dirBase64, sessionId: id }
+  return { type: "dir-new-sesssion", dir, dirBase64 }
+}
+
 export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
   name: "Layout",
   init: () => {
@@ -153,6 +140,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
     const serverSync = useServerSync()
     const server = useServer()
     const platform = usePlatform()
+    const location = useLocation()
+    const route = createMemo(() => currentRoute(location.pathname))
 
     const isRecord = (value: unknown): value is Record<string, unknown> =>
       typeof value === "object" && value !== null && !Array.isArray(value)
@@ -557,6 +546,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
     })
 
     return {
+      route,
       ready,
       handoff: {
         tabs: createMemo(() => store.handoff?.tabs),

+ 14 - 19
packages/app/src/context/server-sync.tsx

@@ -1,17 +1,7 @@
 import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
 import { showToast } from "@/utils/toast"
 import { getFilename } from "@opencode-ai/core/util/path"
-import {
-  batch,
-  createContext,
-  createEffect,
-  getOwner,
-  onCleanup,
-  onMount,
-  type ParentProps,
-  untrack,
-  useContext,
-} from "solid-js"
+import { batch, getOwner, onCleanup, onMount, untrack } from "solid-js"
 import { createStore, produce, reconcile } from "solid-js/store"
 import { useLanguage } from "@/context/language"
 import type { InitError } from "../pages/error"
@@ -86,7 +76,7 @@ function makeQueryOptionsApi(serverSDK: () => OpencodeClient, sdkFor: (dir: Path
 }
 export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
 
-export function createServerSyncContext(_serverSDK?: ServerSDK) {
+export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
   const serverSDK: ServerSDK = _serverSDK ?? useServerSDK()
   const language = useLanguage()
   const owner = getOwner()
@@ -476,6 +466,17 @@ export function createServerSyncContext(_serverSDK?: ServerSDK) {
   }
 }
 
+export function createServerSyncContext(_serverSDK?: ServerSDK) {
+  const inner = createServerSyncContextInner(_serverSDK)
+  return Object.assign(inner, {
+    createDirSyncContext: createRefCountMap(
+      (dir) => createDirSyncContext(dir, inner, _serverSDK),
+      (dir) => inner.disableMcp(dir),
+      directoryKey,
+    ),
+  })
+}
+
 export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
   name: "ServerSync",
   init: (props: { server?: ServerConnection.Any }) => {
@@ -487,13 +488,7 @@ export const { use: useServerSync, provider: ServerSyncProvider } = createSimple
     if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
     const ctx = global.createServerCtx(conn)
 
-    return Object.assign(ctx.sync, {
-      createDirSyncContext: createRefCountMap(
-        (dir) => createDirSyncContext(dir, ctx.sync),
-        (dir) => ctx.sync.disableMcp(dir),
-        directoryKey,
-      ),
-    })
+    return ctx.sync
   },
 })
 

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

@@ -159,10 +159,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
   init: () => {
     const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
 
-    createEffect(() => {
-      console.log("settings", { ready: ready() })
-    })
-
     createEffect(() => {
       if (typeof document === "undefined") return
       const root = document.documentElement

+ 1 - 1
packages/app/src/pages/directory-layout.tsx

@@ -26,7 +26,7 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
 
   createResource(
     () => params.id,
-    (id) => sync.session.sync(id),
+    (id) => sync.session.sync(id).catch(() => {}),
   )
 
   return (

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

@@ -67,7 +67,7 @@ export function getProjectAvatarSource(id?: string, icon?: { color?: string; url
 export function projectForSession<T extends { id?: string; worktree: string; sandboxes?: string[] }>(
   session: Session,
   projects: T[],
-  byID: Map<string, T>,
+  byID: Map<string, T> = new Map(projects.flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
 ) {
   const direct = byID.get(session.projectID)
   if (direct) return direct

+ 20 - 17
packages/app/src/pages/session/use-session-commands.tsx

@@ -418,23 +418,26 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
     }),
   ]
 
-  const fileCmds = () => [
-    fileCommand({
-      id: "file.open",
-      title: language.t("command.file.open"),
-      description: language.t("palette.search.placeholder"),
-      keybind: "mod+k,mod+p",
-      slash: "open",
-      onSelect: openFile,
-    }),
-    fileCommand({
-      id: "tab.close",
-      title: language.t("command.tab.close"),
-      keybind: "mod+w",
-      disabled: !closableTab(),
-      onSelect: closeTab,
-    }),
-  ]
+  const fileCmds = () => {
+    const tab = closableTab()
+    return [
+      fileCommand({
+        id: "file.open",
+        title: language.t("command.file.open"),
+        description: language.t("palette.search.placeholder"),
+        keybind: "mod+k,mod+p",
+        slash: "open",
+        onSelect: openFile,
+      }),
+      tab &&
+        fileCommand({
+          id: "tab.close",
+          title: language.t("command.tab.close"),
+          keybind: "mod+w",
+          onSelect: closeTab,
+        }),
+    ].filter((v) => !!v)
+  }
 
   const contextCmds = () => [
     contextCommand({

+ 0 - 1
packages/ui/src/components/session-review.tsx

@@ -390,7 +390,6 @@ export const SessionReview = (props: SessionReviewProps) => {
               <Accordion multiple value={open()} onChange={handleChange}>
                 <For each={files()}>
                   {(file) => {
-                    console.log({ file })
                     const diff = () => itemsMap()[file]
 
                     // binary files have empty diffs that we can't render