Browse Source

ignore: refactoring and tests (#12460)

Adam 2 weeks ago
parent
commit
4afec6731d

+ 63 - 0
packages/app/src/components/titlebar-history.test.ts

@@ -0,0 +1,63 @@
+import { describe, expect, test } from "bun:test"
+import { applyPath, backPath, forwardPath, type TitlebarHistory } from "./titlebar-history"
+
+function history(): TitlebarHistory {
+  return { stack: [], index: 0, action: undefined }
+}
+
+describe("titlebar history", () => {
+  test("append and trim keeps max bounded", () => {
+    let state = history()
+    state = applyPath(state, "/", 3)
+    state = applyPath(state, "/a", 3)
+    state = applyPath(state, "/b", 3)
+    state = applyPath(state, "/c", 3)
+
+    expect(state.stack).toEqual(["/a", "/b", "/c"])
+    expect(state.stack.length).toBe(3)
+    expect(state.index).toBe(2)
+  })
+
+  test("back and forward indexes stay correct after trimming", () => {
+    let state = history()
+    state = applyPath(state, "/", 3)
+    state = applyPath(state, "/a", 3)
+    state = applyPath(state, "/b", 3)
+    state = applyPath(state, "/c", 3)
+
+    expect(state.stack).toEqual(["/a", "/b", "/c"])
+    expect(state.index).toBe(2)
+
+    const back = backPath(state)
+    expect(back?.to).toBe("/b")
+    expect(back?.state.index).toBe(1)
+
+    const afterBack = applyPath(back!.state, back!.to, 3)
+    expect(afterBack.stack).toEqual(["/a", "/b", "/c"])
+    expect(afterBack.index).toBe(1)
+
+    const forward = forwardPath(afterBack)
+    expect(forward?.to).toBe("/c")
+    expect(forward?.state.index).toBe(2)
+
+    const afterForward = applyPath(forward!.state, forward!.to, 3)
+    expect(afterForward.stack).toEqual(["/a", "/b", "/c"])
+    expect(afterForward.index).toBe(2)
+  })
+
+  test("action-driven navigation does not push duplicate history entries", () => {
+    const state: TitlebarHistory = {
+      stack: ["/", "/a", "/b"],
+      index: 2,
+      action: undefined,
+    }
+
+    const back = backPath(state)
+    expect(back?.to).toBe("/a")
+
+    const next = applyPath(back!.state, back!.to, 10)
+    expect(next.stack).toEqual(["/", "/a", "/b"])
+    expect(next.index).toBe(1)
+    expect(next.action).toBeUndefined()
+  })
+})

+ 57 - 0
packages/app/src/components/titlebar-history.ts

@@ -0,0 +1,57 @@
+export const MAX_TITLEBAR_HISTORY = 100
+
+export type TitlebarAction = "back" | "forward" | undefined
+
+export type TitlebarHistory = {
+  stack: string[]
+  index: number
+  action: TitlebarAction
+}
+
+export function applyPath(state: TitlebarHistory, current: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
+  if (!state.stack.length) {
+    const stack = current === "/" ? ["/"] : ["/", current]
+    return { stack, index: stack.length - 1, action: undefined }
+  }
+
+  const active = state.stack[state.index]
+  if (current === active) {
+    if (!state.action) return state
+    return { ...state, action: undefined }
+  }
+
+  if (state.action) return { ...state, action: undefined }
+
+  return pushPath(state, current, max)
+}
+
+export function pushPath(state: TitlebarHistory, path: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
+  const stack = state.stack.slice(0, state.index + 1).concat(path)
+  const next = trimHistory(stack, stack.length - 1, max)
+  return { ...state, ...next, action: undefined }
+}
+
+export function trimHistory(stack: string[], index: number, max = MAX_TITLEBAR_HISTORY) {
+  if (stack.length <= max) return { stack, index }
+  const cut = stack.length - max
+  return {
+    stack: stack.slice(cut),
+    index: Math.max(0, index - cut),
+  }
+}
+
+export function backPath(state: TitlebarHistory) {
+  if (state.index <= 0) return
+  const index = state.index - 1
+  const to = state.stack[index]
+  if (!to) return
+  return { state: { ...state, index, action: "back" as const }, to }
+}
+
+export function forwardPath(state: TitlebarHistory) {
+  if (state.index >= state.stack.length - 1) return
+  const index = state.index + 1
+  const to = state.stack[index]
+  if (!to) return
+  return { state: { ...state, index, action: "forward" as const }, to }
+}

+ 12 - 31
packages/app/src/components/titlebar.tsx

@@ -11,6 +11,7 @@ import { useLayout } from "@/context/layout"
 import { usePlatform } from "@/context/platform"
 import { useCommand } from "@/context/command"
 import { useLanguage } from "@/context/language"
+import { applyPath, backPath, forwardPath } from "./titlebar-history"
 
 export function Titlebar() {
   const layout = useLayout()
@@ -39,25 +40,9 @@ export function Titlebar() {
     const current = path()
 
     untrack(() => {
-      if (!history.stack.length) {
-        const stack = current === "/" ? ["/"] : ["/", current]
-        setHistory({ stack, index: stack.length - 1 })
-        return
-      }
-
-      const active = history.stack[history.index]
-      if (current === active) {
-        if (history.action) setHistory("action", undefined)
-        return
-      }
-
-      if (history.action) {
-        setHistory("action", undefined)
-        return
-      }
-
-      const next = history.stack.slice(0, history.index + 1).concat(current)
-      setHistory({ stack: next, index: next.length - 1 })
+      const next = applyPath(history, current)
+      if (next === history) return
+      setHistory(next)
     })
   })
 
@@ -65,21 +50,17 @@ export function Titlebar() {
   const canForward = createMemo(() => history.index < history.stack.length - 1)
 
   const back = () => {
-    if (!canBack()) return
-    const index = history.index - 1
-    const to = history.stack[index]
-    if (!to) return
-    setHistory({ index, action: "back" })
-    navigate(to)
+    const next = backPath(history)
+    if (!next) return
+    setHistory(next.state)
+    navigate(next.to)
   }
 
   const forward = () => {
-    if (!canForward()) return
-    const index = history.index + 1
-    const to = history.stack[index]
-    if (!to) return
-    setHistory({ index, action: "forward" })
-    navigate(to)
+    const next = forwardPath(history)
+    if (!next) return
+    setHistory(next.state)
+    navigate(next.to)
   }
 
   command.register(() => [

+ 25 - 0
packages/app/src/context/command.test.ts

@@ -0,0 +1,25 @@
+import { describe, expect, test } from "bun:test"
+import { upsertCommandRegistration } from "./command"
+
+describe("upsertCommandRegistration", () => {
+  test("replaces keyed registrations", () => {
+    const one = () => [{ id: "one", title: "One" }]
+    const two = () => [{ id: "two", title: "Two" }]
+
+    const next = upsertCommandRegistration([{ key: "layout", options: one }], { key: "layout", options: two })
+
+    expect(next).toHaveLength(1)
+    expect(next[0]?.options).toBe(two)
+  })
+
+  test("keeps unkeyed registrations additive", () => {
+    const one = () => [{ id: "one", title: "One" }]
+    const two = () => [{ id: "two", title: "Two" }]
+
+    const next = upsertCommandRegistration([{ options: one }], { options: two })
+
+    expect(next).toHaveLength(2)
+    expect(next[0]?.options).toBe(two)
+    expect(next[1]?.options).toBe(one)
+  })
+})

+ 38 - 10
packages/app/src/context/command.tsx

@@ -64,6 +64,16 @@ export type CommandCatalogItem = {
   slash?: string
 }
 
+export type CommandRegistration = {
+  key?: string
+  options: Accessor<CommandOption[]>
+}
+
+export function upsertCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) {
+  if (entry.key === undefined) return [entry, ...registrations]
+  return [entry, ...registrations.filter((x) => x.key !== entry.key)]
+}
+
 export function parseKeybind(config: string): Keybind[] {
   if (!config || config === "none") return []
 
@@ -166,9 +176,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
     const settings = useSettings()
     const language = useLanguage()
     const [store, setStore] = createStore({
-      registrations: [] as Accessor<CommandOption[]>[],
+      registrations: [] as CommandRegistration[],
       suspendCount: 0,
     })
+    const warnedDuplicates = new Set<string>()
 
     const [catalog, setCatalog, _, catalogReady] = persisted(
       Persist.global("command.catalog.v1"),
@@ -187,8 +198,14 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
       const all: CommandOption[] = []
 
       for (const reg of store.registrations) {
-        for (const opt of reg()) {
-          if (seen.has(opt.id)) continue
+        for (const opt of reg.options()) {
+          if (seen.has(opt.id)) {
+            if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
+              warnedDuplicates.add(opt.id)
+              console.warn(`[command] duplicate command id \"${opt.id}\" registered; keeping first entry`)
+            }
+            continue
+          }
           seen.add(opt.id)
           all.push(opt)
         }
@@ -296,14 +313,25 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
       document.removeEventListener("keydown", handleKeyDown)
     })
 
+    function register(cb: () => CommandOption[]): void
+    function register(key: string, cb: () => CommandOption[]): void
+    function register(key: string | (() => CommandOption[]), cb?: () => CommandOption[]) {
+      const id = typeof key === "string" ? key : undefined
+      const next = typeof key === "function" ? key : cb
+      if (!next) return
+      const options = createMemo(next)
+      const entry: CommandRegistration = {
+        key: id,
+        options,
+      }
+      setStore("registrations", (arr) => upsertCommandRegistration(arr, entry))
+      onCleanup(() => {
+        setStore("registrations", (arr) => arr.filter((x) => x !== entry))
+      })
+    }
+
     return {
-      register(cb: () => CommandOption[]) {
-        const results = createMemo(cb)
-        setStore("registrations", (arr) => [results, ...arr])
-        onCleanup(() => {
-          setStore("registrations", (arr) => arr.filter((x) => x !== results))
-        })
-      },
+      register,
       trigger(id: string, source?: "palette" | "keybind" | "slash") {
         run(id, source)
       },

+ 136 - 0
packages/app/src/context/global-sync.test.ts

@@ -0,0 +1,136 @@
+import { describe, expect, test } from "bun:test"
+import {
+  canDisposeDirectory,
+  estimateRootSessionTotal,
+  loadRootSessionsWithFallback,
+  pickDirectoriesToEvict,
+} from "./global-sync"
+
+describe("pickDirectoriesToEvict", () => {
+  test("keeps pinned stores and evicts idle stores", () => {
+    const now = 5_000
+    const picks = pickDirectoriesToEvict({
+      stores: ["a", "b", "c", "d"],
+      state: new Map([
+        ["a", { lastAccessAt: 1_000 }],
+        ["b", { lastAccessAt: 4_900 }],
+        ["c", { lastAccessAt: 4_800 }],
+        ["d", { lastAccessAt: 3_000 }],
+      ]),
+      pins: new Set(["a"]),
+      max: 2,
+      ttl: 1_500,
+      now,
+    })
+
+    expect(picks).toEqual(["d", "c"])
+  })
+})
+
+describe("loadRootSessionsWithFallback", () => {
+  test("uses limited roots query when supported", async () => {
+    const calls: Array<{ directory: string; roots: true; limit?: number }> = []
+    let fallback = 0
+
+    const result = await loadRootSessionsWithFallback({
+      directory: "dir",
+      limit: 10,
+      list: async (query) => {
+        calls.push(query)
+        return { data: [] }
+      },
+      onFallback: () => {
+        fallback += 1
+      },
+    })
+
+    expect(result.data).toEqual([])
+    expect(result.limited).toBe(true)
+    expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }])
+    expect(fallback).toBe(0)
+  })
+
+  test("falls back to full roots query on limited-query failure", async () => {
+    const calls: Array<{ directory: string; roots: true; limit?: number }> = []
+    let fallback = 0
+
+    const result = await loadRootSessionsWithFallback({
+      directory: "dir",
+      limit: 25,
+      list: async (query) => {
+        calls.push(query)
+        if (query.limit) throw new Error("unsupported")
+        return { data: [] }
+      },
+      onFallback: () => {
+        fallback += 1
+      },
+    })
+
+    expect(result.data).toEqual([])
+    expect(result.limited).toBe(false)
+    expect(calls).toEqual([
+      { directory: "dir", roots: true, limit: 25 },
+      { directory: "dir", roots: true },
+    ])
+    expect(fallback).toBe(1)
+  })
+})
+
+describe("estimateRootSessionTotal", () => {
+  test("keeps exact total for full fetches", () => {
+    expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
+  })
+
+  test("marks has-more for full-limit limited fetches", () => {
+    expect(estimateRootSessionTotal({ count: 10, limit: 10, limited: true })).toBe(11)
+  })
+
+  test("keeps exact total when limited fetch is under limit", () => {
+    expect(estimateRootSessionTotal({ count: 9, limit: 10, limited: true })).toBe(9)
+  })
+})
+
+describe("canDisposeDirectory", () => {
+  test("rejects pinned or inflight directories", () => {
+    expect(
+      canDisposeDirectory({
+        directory: "dir",
+        hasStore: true,
+        pinned: true,
+        booting: false,
+        loadingSessions: false,
+      }),
+    ).toBe(false)
+    expect(
+      canDisposeDirectory({
+        directory: "dir",
+        hasStore: true,
+        pinned: false,
+        booting: true,
+        loadingSessions: false,
+      }),
+    ).toBe(false)
+    expect(
+      canDisposeDirectory({
+        directory: "dir",
+        hasStore: true,
+        pinned: false,
+        booting: false,
+        loadingSessions: true,
+      }),
+    ).toBe(false)
+  })
+
+  test("accepts idle unpinned directory store", () => {
+    expect(
+      canDisposeDirectory({
+        directory: "dir",
+        hasStore: true,
+        pinned: false,
+        booting: false,
+        loadingSessions: false,
+      }),
+    ).toBe(true)
+  })
+})

+ 287 - 42
packages/app/src/context/global-sync.tsx

@@ -27,6 +27,7 @@ import type { InitError } from "../pages/error"
 import {
   batch,
   createContext,
+  createRoot,
   createEffect,
   untrack,
   getOwner,
@@ -131,6 +132,96 @@ function normalizeProviderList(input: ProviderListResponse): ProviderListRespons
   }
 }
 
+const MAX_DIR_STORES = 30
+const DIR_IDLE_TTL_MS = 20 * 60 * 1000
+
+type DirState = {
+  lastAccessAt: number
+}
+
+type EvictPlan = {
+  stores: string[]
+  state: Map<string, DirState>
+  pins: Set<string>
+  max: number
+  ttl: number
+  now: number
+}
+
+export function pickDirectoriesToEvict(input: EvictPlan) {
+  const overflow = Math.max(0, input.stores.length - input.max)
+  let pendingOverflow = overflow
+  const sorted = input.stores
+    .filter((dir) => !input.pins.has(dir))
+    .slice()
+    .sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0))
+
+  const output: string[] = []
+  for (const dir of sorted) {
+    const last = input.state.get(dir)?.lastAccessAt ?? 0
+    const idle = input.now - last >= input.ttl
+    if (!idle && pendingOverflow <= 0) continue
+    output.push(dir)
+    if (pendingOverflow > 0) pendingOverflow -= 1
+  }
+  return output
+}
+
+type RootLoadArgs = {
+  directory: string
+  limit: number
+  list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
+  onFallback: () => void
+}
+
+type RootLoadResult = {
+  data?: Session[]
+  limit: number
+  limited: boolean
+}
+
+export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
+  try {
+    const result = await input.list({ directory: input.directory, roots: true, limit: input.limit })
+    return {
+      data: result.data,
+      limit: input.limit,
+      limited: true,
+    } satisfies RootLoadResult
+  } catch {
+    input.onFallback()
+    const result = await input.list({ directory: input.directory, roots: true })
+    return {
+      data: result.data,
+      limit: input.limit,
+      limited: false,
+    } satisfies RootLoadResult
+  }
+}
+
+export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
+  if (!input.limited) return input.count
+  if (input.count < input.limit) return input.count
+  return input.count + 1
+}
+
+type DisposeCheck = {
+  directory: string
+  hasStore: boolean
+  pinned: boolean
+  booting: boolean
+  loadingSessions: boolean
+}
+
+export function canDisposeDirectory(input: DisposeCheck) {
+  if (!input.directory) return false
+  if (!input.hasStore) return false
+  if (input.pinned) return false
+  if (input.booting) return false
+  if (input.loadingSessions) return false
+  return true
+}
+
 function createGlobalSync() {
   const globalSDK = useGlobalSDK()
   const platform = usePlatform()
@@ -140,8 +231,133 @@ function createGlobalSync() {
   const vcsCache = new Map<string, VcsCache>()
   const metaCache = new Map<string, MetaCache>()
   const iconCache = new Map<string, IconCache>()
+  const lifecycle = new Map<string, DirState>()
+  const pins = new Map<string, number>()
+  const ownerPins = new WeakMap<object, Set<string>>()
+  const disposers = new Map<string, () => void>()
+  const stats = {
+    evictions: 0,
+    loadSessionsFallback: 0,
+  }
 
   const sdkCache = new Map<string, ReturnType<typeof createOpencodeClient>>()
+
+  const updateStats = () => {
+    if (!import.meta.env.DEV) return
+    ;(
+      globalThis as {
+        __OPENCODE_GLOBAL_SYNC_STATS?: {
+          activeDirectoryStores: number
+          evictions: number
+          loadSessionsFullFetchFallback: number
+        }
+      }
+    ).__OPENCODE_GLOBAL_SYNC_STATS = {
+      activeDirectoryStores: Object.keys(children).length,
+      evictions: stats.evictions,
+      loadSessionsFullFetchFallback: stats.loadSessionsFallback,
+    }
+  }
+
+  const mark = (directory: string) => {
+    if (!directory) return
+    lifecycle.set(directory, { lastAccessAt: Date.now() })
+    runEviction()
+  }
+
+  const pin = (directory: string) => {
+    if (!directory) return
+    pins.set(directory, (pins.get(directory) ?? 0) + 1)
+    mark(directory)
+  }
+
+  const unpin = (directory: string) => {
+    if (!directory) return
+    const next = (pins.get(directory) ?? 0) - 1
+    if (next > 0) {
+      pins.set(directory, next)
+      return
+    }
+    pins.delete(directory)
+    runEviction()
+  }
+
+  const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0
+
+  const pinForOwner = (directory: string) => {
+    const current = getOwner()
+    if (!current) return
+    if (current === owner) return
+    const key = current as object
+    const set = ownerPins.get(key)
+    if (set?.has(directory)) return
+    if (set) set.add(directory)
+    else ownerPins.set(key, new Set([directory]))
+    pin(directory)
+    onCleanup(() => {
+      const set = ownerPins.get(key)
+      if (set) {
+        set.delete(directory)
+        if (set.size === 0) ownerPins.delete(key)
+      }
+      unpin(directory)
+    })
+  }
+
+  function disposeDirectory(directory: string) {
+    if (
+      !canDisposeDirectory({
+        directory,
+        hasStore: !!children[directory],
+        pinned: pinned(directory),
+        booting: booting.has(directory),
+        loadingSessions: sessionLoads.has(directory),
+      })
+    ) {
+      return false
+    }
+
+    queued.delete(directory)
+    sessionMeta.delete(directory)
+    sdkCache.delete(directory)
+    vcsCache.delete(directory)
+    metaCache.delete(directory)
+    iconCache.delete(directory)
+    lifecycle.delete(directory)
+
+    const dispose = disposers.get(directory)
+    if (dispose) {
+      dispose()
+      disposers.delete(directory)
+    }
+
+    delete children[directory]
+    updateStats()
+    return true
+  }
+
+  function runEviction() {
+    const stores = Object.keys(children)
+    if (stores.length === 0) return
+    const list = pickDirectoriesToEvict({
+      stores,
+      state: lifecycle,
+      pins: new Set(stores.filter(pinned)),
+      max: MAX_DIR_STORES,
+      ttl: DIR_IDLE_TTL_MS,
+      now: Date.now(),
+    })
+
+    if (list.length === 0) return
+    let changed = false
+    for (const directory of list) {
+      if (!disposeDirectory(directory)) continue
+      stats.evictions += 1
+      changed = true
+    }
+    if (changed) updateStats()
+  }
+
   const sdkFor = (directory: string) => {
     const cached = sdkCache.get(directory)
     if (cached) return cached
@@ -379,52 +595,56 @@ function createGlobalSync() {
       if (!icon) throw new Error("Failed to create persisted project icon")
       iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
 
-      const init = () => {
-        const child = createStore<State>({
-          project: "",
-          projectMeta: meta[0].value,
-          icon: icon[0].value,
-          provider: { all: [], connected: [], default: {} },
-          config: {},
-          path: { state: "", config: "", worktree: "", directory: "", home: "" },
-          status: "loading" as const,
-          agent: [],
-          command: [],
-          session: [],
-          sessionTotal: 0,
-          session_status: {},
-          session_diff: {},
-          todo: {},
-          permission: {},
-          question: {},
-          mcp: {},
-          lsp: [],
-          vcs: vcsStore.value,
-          limit: 5,
-          message: {},
-          part: {},
-        })
+      const init = () =>
+        createRoot((dispose) => {
+          const child = createStore<State>({
+            project: "",
+            projectMeta: meta[0].value,
+            icon: icon[0].value,
+            provider: { all: [], connected: [], default: {} },
+            config: {},
+            path: { state: "", config: "", worktree: "", directory: "", home: "" },
+            status: "loading" as const,
+            agent: [],
+            command: [],
+            session: [],
+            sessionTotal: 0,
+            session_status: {},
+            session_diff: {},
+            todo: {},
+            permission: {},
+            question: {},
+            mcp: {},
+            lsp: [],
+            vcs: vcsStore.value,
+            limit: 5,
+            message: {},
+            part: {},
+          })
 
-        children[directory] = child
+          children[directory] = child
+          disposers.set(directory, dispose)
 
-        createEffect(() => {
-          if (!vcsReady()) return
-          const cached = vcsStore.value
-          if (!cached?.branch) return
-          child[1]("vcs", (value) => value ?? cached)
-        })
+          createEffect(() => {
+            if (!vcsReady()) return
+            const cached = vcsStore.value
+            if (!cached?.branch) return
+            child[1]("vcs", (value) => value ?? cached)
+          })
 
-        createEffect(() => {
-          child[1]("projectMeta", meta[0].value)
-        })
+          createEffect(() => {
+            child[1]("projectMeta", meta[0].value)
+          })
 
-        createEffect(() => {
-          child[1]("icon", icon[0].value)
+          createEffect(() => {
+            child[1]("icon", icon[0].value)
+          })
         })
-      }
 
       runWithOwner(owner, init)
+      updateStats()
     }
+    mark(directory)
     const childStore = children[directory]
     if (!childStore) throw new Error("Failed to create store")
     return childStore
@@ -432,6 +652,7 @@ function createGlobalSync() {
 
   function child(directory: string, options: ChildOptions = {}) {
     const childStore = ensureChild(directory)
+    pinForOwner(directory)
     const shouldBootstrap = options.bootstrap ?? true
     if (shouldBootstrap && childStore[0].status === "loading") {
       void bootstrapInstance(directory)
@@ -443,6 +664,7 @@ function createGlobalSync() {
     const pending = sessionLoads.get(directory)
     if (pending) return pending
 
+    pin(directory)
     const [store, setStore] = child(directory, { bootstrap: false })
     const meta = sessionMeta.get(directory)
     if (meta && meta.limit >= store.limit) {
@@ -450,11 +672,20 @@ function createGlobalSync() {
       if (next.length !== store.session.length) {
         setStore("session", reconcile(next, { key: "id" }))
       }
+      unpin(directory)
       return
     }
 
-    const promise = globalSDK.client.session
-      .list({ directory, roots: true })
+    const limit = Math.max(store.limit + sessionRecentLimit, sessionRecentLimit)
+    const promise = loadRootSessionsWithFallback({
+      directory,
+      limit,
+      list: (query) => globalSDK.client.session.list(query),
+      onFallback: () => {
+        stats.loadSessionsFallback += 1
+        updateStats()
+      },
+    })
       .then((x) => {
         const nonArchived = (x.data ?? [])
           .filter((s) => !!s?.id)
@@ -468,8 +699,13 @@ function createGlobalSync() {
         const children = store.session.filter((s) => !!s.parentID)
         const sessions = trimSessions([...nonArchived, ...children], { limit, permission: store.permission })
 
-        // Store total session count (used for "load more" pagination)
-        setStore("sessionTotal", nonArchived.length)
+        // Store root session total for "load more" pagination.
+        // For limited root queries, preserve has-more behavior by treating
+        // full-limit responses as "potentially more".
+        setStore(
+          "sessionTotal",
+          estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited }),
+        )
         setStore("session", reconcile(sessions, { key: "id" }))
         sessionMeta.set(directory, { limit })
       })
@@ -482,6 +718,7 @@ function createGlobalSync() {
     sessionLoads.set(directory, promise)
     promise.finally(() => {
       sessionLoads.delete(directory)
+      unpin(directory)
     })
     return promise
   }
@@ -491,6 +728,7 @@ function createGlobalSync() {
     const pending = booting.get(directory)
     if (pending) return pending
 
+    pin(directory)
     const promise = (async () => {
       const [store, setStore] = ensureChild(directory)
       const cache = vcsCache.get(directory)
@@ -605,6 +843,7 @@ function createGlobalSync() {
     booting.set(directory, promise)
     promise.finally(() => {
       booting.delete(directory)
+      unpin(directory)
     })
     return promise
   }
@@ -670,6 +909,7 @@ function createGlobalSync() {
 
     const existing = children[directory]
     if (!existing) return
+    mark(directory)
 
     const [store, setStore] = existing
 
@@ -955,6 +1195,11 @@ function createGlobalSync() {
     if (!timer) return
     clearTimeout(timer)
   })
+  onCleanup(() => {
+    for (const directory of Object.keys(children)) {
+      disposeDirectory(directory)
+    }
+  })
 
   async function bootstrap() {
     const health = await globalSDK.client.global

+ 66 - 0
packages/app/src/context/notification-index.ts

@@ -0,0 +1,66 @@
+type NotificationIndexItem = {
+  directory?: string
+  session?: string
+  viewed: boolean
+  type: string
+}
+
+export function buildNotificationIndex<T extends NotificationIndexItem>(list: T[]) {
+  const sessionAll = new Map<string, T[]>()
+  const sessionUnseen = new Map<string, T[]>()
+  const sessionUnseenCount = new Map<string, number>()
+  const sessionUnseenHasError = new Map<string, boolean>()
+  const projectAll = new Map<string, T[]>()
+  const projectUnseen = new Map<string, T[]>()
+  const projectUnseenCount = new Map<string, number>()
+  const projectUnseenHasError = new Map<string, boolean>()
+
+  for (const notification of list) {
+    const session = notification.session
+    if (session) {
+      const all = sessionAll.get(session)
+      if (all) all.push(notification)
+      else sessionAll.set(session, [notification])
+
+      if (!notification.viewed) {
+        const unseen = sessionUnseen.get(session)
+        if (unseen) unseen.push(notification)
+        else sessionUnseen.set(session, [notification])
+
+        sessionUnseenCount.set(session, (sessionUnseenCount.get(session) ?? 0) + 1)
+        if (notification.type === "error") sessionUnseenHasError.set(session, true)
+      }
+    }
+
+    const directory = notification.directory
+    if (directory) {
+      const all = projectAll.get(directory)
+      if (all) all.push(notification)
+      else projectAll.set(directory, [notification])
+
+      if (!notification.viewed) {
+        const unseen = projectUnseen.get(directory)
+        if (unseen) unseen.push(notification)
+        else projectUnseen.set(directory, [notification])
+
+        projectUnseenCount.set(directory, (projectUnseenCount.get(directory) ?? 0) + 1)
+        if (notification.type === "error") projectUnseenHasError.set(directory, true)
+      }
+    }
+  }
+
+  return {
+    session: {
+      all: sessionAll,
+      unseen: sessionUnseen,
+      unseenCount: sessionUnseenCount,
+      unseenHasError: sessionUnseenHasError,
+    },
+    project: {
+      all: projectAll,
+      unseen: projectUnseen,
+      unseenCount: projectUnseenCount,
+      unseenHasError: projectUnseenHasError,
+    },
+  }
+}

+ 73 - 0
packages/app/src/context/notification.test.ts

@@ -0,0 +1,73 @@
+import { describe, expect, test } from "bun:test"
+import { buildNotificationIndex } from "./notification-index"
+
+type Notification = {
+  type: "turn-complete" | "error"
+  session: string
+  directory: string
+  viewed: boolean
+  time: number
+}
+
+const turn = (session: string, directory: string, viewed = false): Notification => ({
+  type: "turn-complete",
+  session,
+  directory,
+  viewed,
+  time: 1,
+})
+
+const error = (session: string, directory: string, viewed = false): Notification => ({
+  type: "error",
+  session,
+  directory,
+  viewed,
+  time: 1,
+})
+
+describe("buildNotificationIndex", () => {
+  test("builds unseen counts and unseen error flags", () => {
+    const list = [
+      turn("s1", "d1", false),
+      error("s1", "d1", false),
+      turn("s1", "d1", true),
+      turn("s2", "d1", false),
+      error("s3", "d2", true),
+    ]
+
+    const index = buildNotificationIndex(list)
+
+    expect(index.session.all.get("s1")?.length).toBe(3)
+    expect(index.session.unseen.get("s1")?.length).toBe(2)
+    expect(index.session.unseenCount.get("s1")).toBe(2)
+    expect(index.session.unseenHasError.get("s1")).toBe(true)
+
+    expect(index.session.unseenCount.get("s2")).toBe(1)
+    expect(index.session.unseenHasError.get("s2") ?? false).toBe(false)
+    expect(index.session.unseenCount.get("s3") ?? 0).toBe(0)
+    expect(index.session.unseenHasError.get("s3") ?? false).toBe(false)
+
+    expect(index.project.unseenCount.get("d1")).toBe(3)
+    expect(index.project.unseenHasError.get("d1")).toBe(true)
+    expect(index.project.unseenCount.get("d2") ?? 0).toBe(0)
+    expect(index.project.unseenHasError.get("d2") ?? false).toBe(false)
+  })
+
+  test("updates selectors after viewed transitions", () => {
+    const list = [turn("s1", "d1", false), error("s1", "d1", false), turn("s2", "d1", false)]
+    const next = list.map((item) => (item.session === "s1" ? { ...item, viewed: true } : item))
+
+    const before = buildNotificationIndex(list)
+    const after = buildNotificationIndex(next)
+
+    expect(before.session.unseenCount.get("s1")).toBe(2)
+    expect(before.session.unseenHasError.get("s1")).toBe(true)
+    expect(before.project.unseenCount.get("d1")).toBe(3)
+    expect(before.project.unseenHasError.get("d1")).toBe(true)
+
+    expect(after.session.unseenCount.get("s1") ?? 0).toBe(0)
+    expect(after.session.unseenHasError.get("s1") ?? false).toBe(false)
+    expect(after.project.unseenCount.get("d1")).toBe(1)
+    expect(after.project.unseenHasError.get("d1") ?? false).toBe(false)
+  })
+})

+ 14 - 43
packages/app/src/context/notification.tsx

@@ -13,6 +13,7 @@ import { decode64 } from "@/utils/base64"
 import { EventSessionError } from "@opencode-ai/sdk/v2"
 import { Persist, persisted } from "@/utils/persist"
 import { playSound, soundSrc } from "@/utils/sound"
+import { buildNotificationIndex } from "./notification-index"
 
 type NotificationBase = {
   directory?: string
@@ -81,49 +82,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
       setStore("list", (list) => pruneNotifications([...list, notification]))
     }
 
-    const index = createMemo(() => {
-      const sessionAll = new Map<string, Notification[]>()
-      const sessionUnseen = new Map<string, Notification[]>()
-      const projectAll = new Map<string, Notification[]>()
-      const projectUnseen = new Map<string, Notification[]>()
-
-      for (const notification of store.list) {
-        const session = notification.session
-        if (session) {
-          const list = sessionAll.get(session)
-          if (list) list.push(notification)
-          else sessionAll.set(session, [notification])
-          if (!notification.viewed) {
-            const unseen = sessionUnseen.get(session)
-            if (unseen) unseen.push(notification)
-            else sessionUnseen.set(session, [notification])
-          }
-        }
-
-        const directory = notification.directory
-        if (directory) {
-          const list = projectAll.get(directory)
-          if (list) list.push(notification)
-          else projectAll.set(directory, [notification])
-          if (!notification.viewed) {
-            const unseen = projectUnseen.get(directory)
-            if (unseen) unseen.push(notification)
-            else projectUnseen.set(directory, [notification])
-          }
-        }
-      }
-
-      return {
-        session: {
-          all: sessionAll,
-          unseen: sessionUnseen,
-        },
-        project: {
-          all: projectAll,
-          unseen: projectUnseen,
-        },
-      }
-    })
+    const index = createMemo(() => buildNotificationIndex(store.list))
 
     const unsub = globalSDK.event.listen((e) => {
       const event = e.details
@@ -208,6 +167,12 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
         unseen(session: string) {
           return index().session.unseen.get(session) ?? empty
         },
+        unseenCount(session: string) {
+          return index().session.unseenCount.get(session) ?? 0
+        },
+        unseenHasError(session: string) {
+          return index().session.unseenHasError.get(session) ?? false
+        },
         markViewed(session: string) {
           setStore("list", (n) => n.session === session, "viewed", true)
         },
@@ -219,6 +184,12 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
         unseen(directory: string) {
           return index().project.unseen.get(directory) ?? empty
         },
+        unseenCount(directory: string) {
+          return index().project.unseenCount.get(directory) ?? 0
+        },
+        unseenHasError(directory: string) {
+          return index().project.unseenHasError.get(directory) ?? false
+        },
         markViewed(directory: string) {
           setStore("list", (n) => n.directory === directory, "viewed", true)
         },

+ 128 - 166
packages/app/src/pages/layout.tsx

@@ -76,6 +76,44 @@ import { Titlebar } from "@/components/titlebar"
 import { useServer } from "@/context/server"
 import { useLanguage, type Locale } from "@/context/language"
 
+const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
+
+const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "")
+
+function sortSessions(now: number) {
+  const oneMinuteAgo = now - 60 * 1000
+  return (a: Session, b: Session) => {
+    const aUpdated = a.time.updated ?? a.time.created
+    const bUpdated = b.time.updated ?? b.time.created
+    const aRecent = aUpdated > oneMinuteAgo
+    const bRecent = bUpdated > oneMinuteAgo
+    if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
+    if (aRecent && !bRecent) return -1
+    if (!aRecent && bRecent) return 1
+    return bUpdated - aUpdated
+  }
+}
+
+const isRootVisibleSession = (session: Session, directory: string) =>
+  workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
+
+const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) =>
+  store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).toSorted(sortSessions(now))
+
+const childMapByParent = (sessions: Session[]) => {
+  const map = new Map<string, string[]>()
+  for (const session of sessions) {
+    if (!session.parentID) continue
+    const existing = map.get(session.parentID)
+    if (existing) {
+      existing.push(session.id)
+      continue
+    }
+    map.set(session.parentID, [session.id])
+  }
+  return map
+}
+
 export default function Layout(props: ParentProps) {
   const [store, setStore, , ready] = persisted(
     Persist.global("layout.page", ["layout.page.v1"]),
@@ -119,6 +157,7 @@ export default function Layout(props: ParentProps) {
     dark: "theme.scheme.dark",
   }
   const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
+  const currentDir = createMemo(() => decode64(params.dir) ?? "")
 
   const [state, setState] = createStore({
     autoselect: !initialDirectory,
@@ -143,8 +182,6 @@ export default function Layout(props: ParentProps) {
     })
   }
   const isBusy = (directory: string) => state.busyWorkspaces.has(workspaceKey(directory))
-  const editorRef = { current: undefined as HTMLInputElement | undefined }
-
   const navLeave = { current: undefined as number | undefined }
 
   const aim = createAim({
@@ -289,7 +326,6 @@ export default function Layout(props: ParentProps) {
       >
         <InlineInput
           ref={(el) => {
-            editorRef.current = el
             requestAnimationFrame(() => el.focus())
           }}
           value={editorValue()}
@@ -466,10 +502,9 @@ export default function Layout(props: ParentProps) {
         }
       }
 
-      const currentDir = decode64(params.dir)
       const currentSession = params.id
-      if (directory === currentDir && props.sessionID === currentSession) return
-      if (directory === currentDir && session?.parentID === currentSession) return
+      if (directory === currentDir() && props.sessionID === currentSession) return
+      if (directory === currentDir() && session?.parentID === currentSession) return
 
       const existingToastId = toastBySession.get(sessionKey)
       if (existingToastId !== undefined) toaster.dismiss(existingToastId)
@@ -495,20 +530,19 @@ export default function Layout(props: ParentProps) {
     onCleanup(unsub)
 
     createEffect(() => {
-      const currentDir = decode64(params.dir)
       const currentSession = params.id
-      if (!currentDir || !currentSession) return
-      const sessionKey = `${currentDir}:${currentSession}`
+      if (!currentDir() || !currentSession) return
+      const sessionKey = `${currentDir()}:${currentSession}`
       const toastId = toastBySession.get(sessionKey)
       if (toastId !== undefined) {
         toaster.dismiss(toastId)
         toastBySession.delete(sessionKey)
         alertedAtBySession.delete(sessionKey)
       }
-      const [store] = globalSync.child(currentDir, { bootstrap: false })
+      const [store] = globalSync.child(currentDir(), { bootstrap: false })
       const childSessions = store.session.filter((s) => s.parentID === currentSession)
       for (const child of childSessions) {
-        const childKey = `${currentDir}:${child.id}`
+        const childKey = `${currentDir()}:${child.id}`
         const childToastId = toastBySession.get(childKey)
         if (childToastId !== undefined) {
           toaster.dismiss(childToastId)
@@ -519,20 +553,6 @@ export default function Layout(props: ParentProps) {
     })
   })
 
-  function sortSessions(now: number) {
-    const oneMinuteAgo = now - 60 * 1000
-    return (a: Session, b: Session) => {
-      const aUpdated = a.time.updated ?? a.time.created
-      const bUpdated = b.time.updated ?? b.time.created
-      const aRecent = aUpdated > oneMinuteAgo
-      const bRecent = bUpdated > oneMinuteAgo
-      if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
-      if (aRecent && !bRecent) return -1
-      if (!aRecent && bRecent) return 1
-      return bUpdated - aUpdated
-    }
-  }
-
   function scrollToSession(sessionId: string, sessionKey: string) {
     if (!scrollContainerRef) return
     if (state.scrollSessionKey === sessionKey) return
@@ -549,7 +569,7 @@ export default function Layout(props: ParentProps) {
   }
 
   const currentProject = createMemo(() => {
-    const directory = decode64(params.dir)
+    const directory = currentDir()
     if (!directory) return
 
     const projects = layout.projects.list()
@@ -614,8 +634,6 @@ export default function Layout(props: ParentProps) {
     ),
   )
 
-  const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "")
-
   const workspaceName = (directory: string, projectId?: string, branch?: string) => {
     const key = workspaceKey(directory)
     const direct = store.workspaceName[key] ?? store.workspaceName[directory]
@@ -687,29 +705,23 @@ export default function Layout(props: ParentProps) {
   const currentSessions = createMemo(() => {
     const project = currentProject()
     if (!project) return [] as Session[]
-    const compare = sortSessions(Date.now())
+    const now = Date.now()
     if (workspaceSetting()) {
       const dirs = workspaceIds(project)
-      const activeDir = decode64(params.dir) ?? ""
+      const activeDir = currentDir()
       const result: Session[] = []
       for (const dir of dirs) {
         const expanded = store.workspaceExpanded[dir] ?? dir === project.worktree
         const active = dir === activeDir
         if (!expanded && !active) continue
         const [dirStore] = globalSync.child(dir, { bootstrap: true })
-        const dirSessions = dirStore.session
-          .filter((session) => session.directory === dirStore.path.directory)
-          .filter((session) => !session.parentID && !session.time?.archived)
-          .toSorted(compare)
+        const dirSessions = sortedRootSessions(dirStore, now)
         result.push(...dirSessions)
       }
       return result
     }
     const [projectStore] = globalSync.child(project.worktree)
-    return projectStore.session
-      .filter((session) => session.directory === projectStore.path.directory)
-      .filter((session) => !session.parentID && !session.time?.archived)
-      .toSorted(compare)
+    return sortedRootSessions(projectStore, now)
   })
 
   type PrefetchQueue = {
@@ -951,7 +963,7 @@ export default function Layout(props: ParentProps) {
     const sessions = currentSessions()
     if (sessions.length === 0) return
 
-    const hasUnseen = sessions.some((session) => notification.session.unseen(session.id).length > 0)
+    const hasUnseen = sessions.some((session) => notification.session.unseenCount(session.id) > 0)
     if (!hasUnseen) return
 
     const activeIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1
@@ -961,7 +973,7 @@ export default function Layout(props: ParentProps) {
       const index = offset > 0 ? (start + i) % sessions.length : (start - i + sessions.length) % sessions.length
       const session = sessions[index]
       if (!session) continue
-      if (notification.session.unseen(session.id).length === 0) continue
+      if (notification.session.unseenCount(session.id) === 0) continue
 
       prefetchSession(session, "high")
 
@@ -1019,7 +1031,7 @@ export default function Layout(props: ParentProps) {
     }
   }
 
-  command.register(() => {
+  command.register("layout", () => {
     const commands: CommandOption[] = [
       {
         id: "sidebar.toggle",
@@ -1093,6 +1105,18 @@ export default function Layout(props: ParentProps) {
           if (session) archiveSession(session)
         },
       },
+      {
+        id: "workspace.new",
+        title: language.t("workspace.new"),
+        category: language.t("command.category.workspace"),
+        keybind: "mod+shift+w",
+        disabled: !workspaceSetting(),
+        onSelect: () => {
+          const project = currentProject()
+          if (!project) return
+          return createWorkspace(project)
+        },
+      },
       {
         id: "workspace.toggle",
         title: language.t("command.workspace.toggle"),
@@ -1344,7 +1368,7 @@ export default function Layout(props: ParentProps) {
     layout.projects.close(directory)
     layout.projects.open(root)
 
-    if (params.dir && decode64(params.dir) === directory) {
+    if (params.dir && currentDir() === directory) {
       navigateToProject(root)
     }
   }
@@ -1584,7 +1608,7 @@ export default function Layout(props: ParentProps) {
     if (!project) return
 
     if (workspaceSetting()) {
-      const activeDir = decode64(params.dir) ?? ""
+      const activeDir = currentDir()
       const dirs = [project.worktree, ...(project.sandboxes ?? [])]
       for (const directory of dirs) {
         const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
@@ -1634,7 +1658,7 @@ export default function Layout(props: ParentProps) {
     const local = project.worktree
     const dirs = [local, ...(project.sandboxes ?? [])]
     const active = currentProject()
-    const directory = active?.worktree === project.worktree ? decode64(params.dir) : undefined
+    const directory = active?.worktree === project.worktree ? currentDir() : undefined
     const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined
     const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
 
@@ -1688,23 +1712,25 @@ export default function Layout(props: ParentProps) {
 
   const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => {
     const notification = useNotification()
-    const notifications = createMemo(() => notification.project.unseen(props.project.worktree))
-    const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
+    const unseenCount = createMemo(() => notification.project.unseenCount(props.project.worktree))
+    const hasError = createMemo(() => notification.project.unseenHasError(props.project.worktree))
     const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
-    const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
-
     return (
       <div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
         <div class="size-full rounded overflow-clip">
           <Avatar
             fallback={name()}
-            src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.override}
+            src={
+              props.project.id === OPENCODE_PROJECT_ID
+                ? "https://opencode.ai/favicon.svg"
+                : props.project.icon?.override
+            }
             {...getAvatarColors(props.project.icon?.color)}
             class="size-full rounded"
-            classList={{ "badge-mask": notifications().length > 0 && props.notify }}
+            classList={{ "badge-mask": unseenCount() > 0 && props.notify }}
           />
         </div>
-        <Show when={notifications().length > 0 && props.notify}>
+        <Show when={unseenCount() > 0 && props.notify}>
           <div
             classList={{
               "absolute top-px right-px size-1.5 rounded-full z-10": true,
@@ -1723,28 +1749,18 @@ export default function Layout(props: ParentProps) {
     mobile?: boolean
     dense?: boolean
     popover?: boolean
-    children?: Map<string, string[]>
+    children: Map<string, string[]>
   }): JSX.Element => {
     const notification = useNotification()
-    const notifications = createMemo(() => notification.session.unseen(props.session.id))
-    const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
+    const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id))
+    const hasError = createMemo(() => notification.session.unseenHasError(props.session.id))
     const [sessionStore] = globalSync.child(props.session.directory)
     const hasPermissions = createMemo(() => {
       const permissions = sessionStore.permission?.[props.session.id] ?? []
       if (permissions.length > 0) return true
 
-      const childIDs = props.children?.get(props.session.id)
-      if (childIDs) {
-        for (const id of childIDs) {
-          const childPermissions = sessionStore.permission?.[id] ?? []
-          if (childPermissions.length > 0) return true
-        }
-        return false
-      }
-
-      const childSessions = sessionStore.session.filter((s) => s.parentID === props.session.id)
-      for (const child of childSessions) {
-        const childPermissions = sessionStore.permission?.[child.id] ?? []
+      for (const id of props.children.get(props.session.id) ?? []) {
+        const childPermissions = sessionStore.permission?.[id] ?? []
         if (childPermissions.length > 0) return true
       }
       return false
@@ -1758,10 +1774,13 @@ export default function Layout(props: ParentProps) {
     const tint = createMemo(() => {
       const messages = sessionStore.message[props.session.id]
       if (!messages) return undefined
-      const user = messages
-        .slice()
-        .reverse()
-        .find((m) => m.role === "user")
+      let user: Message | undefined
+      for (let i = messages.length - 1; i >= 0; i--) {
+        const message = messages[i]
+        if (message.role !== "user") continue
+        user = message
+        break
+      }
       if (!user?.agent) return undefined
 
       const agent = sessionStore.agent.find((a) => a.name === user.agent)
@@ -1828,7 +1847,7 @@ export default function Layout(props: ParentProps) {
               <Match when={hasError()}>
                 <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
               </Match>
-              <Match when={notifications().length > 0}>
+              <Match when={unseenCount() > 0}>
                 <div class="size-1.5 rounded-full bg-text-interactive-base" />
               </Match>
             </Switch>
@@ -2023,30 +2042,10 @@ export default function Layout(props: ParentProps) {
       pendingRename: false,
     })
     const slug = createMemo(() => base64Encode(props.directory))
-    const sessions = createMemo(() =>
-      workspaceStore.session
-        .filter((session) => session.directory === workspaceStore.path.directory)
-        .filter((session) => !session.parentID && !session.time?.archived)
-        .toSorted(sortSessions(Date.now())),
-    )
-    const children = createMemo(() => {
-      const map = new Map<string, string[]>()
-      for (const session of workspaceStore.session) {
-        if (!session.parentID) continue
-        const existing = map.get(session.parentID)
-        if (existing) {
-          existing.push(session.id)
-          continue
-        }
-        map.set(session.parentID, [session.id])
-      }
-      return map
-    })
+    const sessions = createMemo(() => sortedRootSessions(workspaceStore, Date.now()))
+    const children = createMemo(() => childMapByParent(workspaceStore.session))
     const local = createMemo(() => props.directory === props.project.worktree)
-    const active = createMemo(() => {
-      const current = decode64(params.dir) ?? ""
-      return current === props.directory
-    })
+    const active = createMemo(() => currentDir() === props.directory)
     const workspaceValue = createMemo(() => {
       const branch = workspaceStore.vcs?.branch
       const name = branch ?? getFilename(props.directory)
@@ -2257,7 +2256,7 @@ export default function Layout(props: ParentProps) {
   const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
     const sortable = createSortable(props.project.worktree)
     const selected = createMemo(() => {
-      const current = decode64(params.dir) ?? ""
+      const current = currentDir()
       return props.project.worktree === current || props.project.sandboxes?.includes(current)
     })
 
@@ -2288,25 +2287,16 @@ export default function Layout(props: ParentProps) {
       return `${kind} : ${name}`
     }
 
-    const sessions = (directory: string) => {
+    const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
+    const projectSessions = createMemo(() => sortedRootSessions(projectStore(), Date.now()).slice(0, 2))
+    const projectChildren = createMemo(() => childMapByParent(projectStore().session))
+    const workspaceSessions = (directory: string) => {
       const [data] = globalSync.child(directory, { bootstrap: false })
-      const root = workspaceKey(directory)
-      return data.session
-        .filter((session) => workspaceKey(session.directory) === root)
-        .filter((session) => !session.parentID && !session.time?.archived)
-        .toSorted(sortSessions(Date.now()))
-        .slice(0, 2)
+      return sortedRootSessions(data, Date.now()).slice(0, 2)
     }
-
-    const projectSessions = () => {
-      const directory = props.project.worktree
+    const workspaceChildren = (directory: string) => {
       const [data] = globalSync.child(directory, { bootstrap: false })
-      const root = workspaceKey(directory)
-      return data.session
-        .filter((session) => workspaceKey(session.directory) === root)
-        .filter((session) => !session.parentID && !session.time?.archived)
-        .toSorted(sortSessions(Date.now()))
-        .slice(0, 2)
+      return childMapByParent(data.session)
     }
 
     const projectName = () => props.project.name || getFilename(props.project.worktree)
@@ -2435,33 +2425,39 @@ export default function Layout(props: ParentProps) {
                           dense
                           mobile={props.mobile}
                           popover={false}
+                          children={projectChildren()}
                         />
                       )}
                     </For>
                   }
                 >
                   <For each={workspaces()}>
-                    {(directory) => (
-                      <div class="flex flex-col gap-1">
-                        <div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
-                          <div class="shrink-0 size-6 flex items-center justify-center">
-                            <Icon name="branch" size="small" class="text-icon-base" />
+                    {(directory) => {
+                      const sessions = createMemo(() => workspaceSessions(directory))
+                      const children = createMemo(() => workspaceChildren(directory))
+                      return (
+                        <div class="flex flex-col gap-1">
+                          <div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
+                            <div class="shrink-0 size-6 flex items-center justify-center">
+                              <Icon name="branch" size="small" class="text-icon-base" />
+                            </div>
+                            <span class="truncate text-14-medium text-text-base">{label(directory)}</span>
                           </div>
-                          <span class="truncate text-14-medium text-text-base">{label(directory)}</span>
+                          <For each={sessions()}>
+                            {(session) => (
+                              <SessionItem
+                                session={session}
+                                slug={base64Encode(directory)}
+                                dense
+                                mobile={props.mobile}
+                                popover={false}
+                                children={children()}
+                              />
+                            )}
+                          </For>
                         </div>
-                        <For each={sessions(directory)}>
-                          {(session) => (
-                            <SessionItem
-                              session={session}
-                              slug={base64Encode(directory)}
-                              dense
-                              mobile={props.mobile}
-                              popover={false}
-                            />
-                          )}
-                        </For>
-                      </div>
-                    )}
+                      )
+                    }}
                   </For>
                 </Show>
               </div>
@@ -2494,27 +2490,8 @@ export default function Layout(props: ParentProps) {
       return { store, setStore }
     })
     const slug = createMemo(() => base64Encode(props.project.worktree))
-    const sessions = createMemo(() => {
-      const store = workspace().store
-      return store.session
-        .filter((session) => session.directory === store.path.directory)
-        .filter((session) => !session.parentID && !session.time?.archived)
-        .toSorted(sortSessions(Date.now()))
-    })
-    const children = createMemo(() => {
-      const store = workspace().store
-      const map = new Map<string, string[]>()
-      for (const session of store.session) {
-        if (!session.parentID) continue
-        const existing = map.get(session.parentID)
-        if (existing) {
-          existing.push(session.id)
-          continue
-        }
-        map.set(session.parentID, [session.id])
-      }
-      return map
-    })
+    const sessions = createMemo(() => sortedRootSessions(workspace().store, Date.now()))
+    const children = createMemo(() => childMapByParent(workspace().store.session))
     const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
     const loading = createMemo(() => !booted() && sessions().length === 0)
     const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length)
@@ -2819,21 +2796,6 @@ export default function Layout(props: ParentProps) {
   const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
     const expanded = () => sidebarProps.mobile || layout.sidebar.opened()
 
-    command.register(() => [
-      {
-        id: "workspace.new",
-        title: language.t("workspace.new"),
-        category: language.t("command.category.workspace"),
-        keybind: "mod+shift+w",
-        disabled: !workspaceSetting(),
-        onSelect: () => {
-          const project = currentProject()
-          if (!project) return
-          return createWorkspace(project)
-        },
-      },
-    ])
-
     return (
       <div class="flex h-full w-full overflow-hidden">
         <div class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden" onMouseMove={aim.move}>