فهرست منبع

fix(app): keep terminal mounted when switching session tabs in a workspace (#34852)

Co-authored-by: Brendan Allan <git@brendonovich.dev>
Luke Parker 2 ماه پیش
والد
کامیت
4a42caef2c

+ 145 - 0
packages/app/e2e/regression/terminal-tab-switch.spec.ts

@@ -0,0 +1,145 @@
+import { base64Encode } from "@opencode-ai/core/util/encode"
+import { expect, test, type Page } from "@playwright/test"
+import { mockOpenCodeServer } from "../utils/mock-server"
+import { expectSessionTitle } from "../utils/waits"
+
+const directory = "C:/OpenCode/TerminalTabSwitch"
+const projectID = "proj_terminal_tab_switch"
+const sessionA = "ses_terminal_tab_a"
+const sessionB = "ses_terminal_tab_b"
+const titleA = "Alpha session"
+const titleB = "Beta session"
+const ptyID = "pty_tab_switch"
+const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
+// Marks the terminal DOM node so a remount (fresh node) is detectable.
+const PROBE = "original"
+
+test.use({ viewport: { width: 1440, height: 900 } })
+
+// Terminals are workspace-scoped: switching between session tabs in the same
+// workspace must keep the terminal mounted and its PTY connection open instead
+// of tearing it down and reconnecting.
+test("keeps the terminal session alive when switching session tabs in a workspace", async ({ page }) => {
+  const connections = await setup(page)
+
+  await page.goto(sessionHref(sessionA))
+  await expectSessionTitle(page, titleA)
+
+  await page.keyboard.press("Control+Backquote")
+  const terminal = page.locator('[data-component="terminal"]')
+  await expect(terminal).toBeVisible()
+  await expect.poll(() => connections.length).toBe(1)
+  await writeProbe(page)
+
+  await switchTab(page, titleB)
+  await expectSessionTitle(page, titleB)
+  await expect(terminal).toBeVisible()
+  expect(await readProbe(page)).toBe(PROBE)
+  expect(connections.length).toBe(1)
+
+  await switchTab(page, titleA)
+  await expectSessionTitle(page, titleA)
+  await expect(terminal).toBeVisible()
+  expect(await readProbe(page)).toBe(PROBE)
+  expect(connections.length).toBe(1)
+})
+
+type Probed = HTMLElement & { __e2eProbe?: string }
+
+async function switchTab(page: Page, title: string) {
+  await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
+}
+
+async function writeProbe(page: Page) {
+  await page.locator('[data-component="terminal"]').evaluate((el, probe) => {
+    ;(el as Probed).__e2eProbe = probe
+  }, PROBE)
+}
+
+async function readProbe(page: Page) {
+  return page.locator('[data-component="terminal"]').evaluate((el) => (el as Probed).__e2eProbe)
+}
+
+async function setup(page: Page) {
+  await mockOpenCodeServer(page, {
+    directory,
+    project: {
+      id: projectID,
+      worktree: directory,
+      vcs: "git",
+      name: "terminal-tab-switch",
+      time: { created: 1700000000000, updated: 1700000000000 },
+      sandboxes: [],
+    },
+    provider: {
+      all: [
+        {
+          id: "opencode",
+          name: "OpenCode",
+          models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
+        },
+      ],
+      connected: ["opencode"],
+      default: { providerID: "opencode", modelID: "test" },
+    },
+    sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
+    pageMessages: () => ({ items: [] }),
+  })
+  await page.route("**/pty", (route) =>
+    route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
+    }),
+  )
+  await page.route(`**/pty/${ptyID}`, (route) =>
+    route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
+  )
+  await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
+    route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      headers: { "access-control-allow-origin": "*" },
+      body: JSON.stringify({ ticket: "e2e-ticket" }),
+    }),
+  )
+  const connections: string[] = []
+  await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), (ws) => {
+    connections.push(ws.url())
+  })
+
+  await page.addInitScript(
+    ({ directory, server, sessions }) => {
+      localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
+      localStorage.setItem(
+        "opencode.global.dat:server",
+        JSON.stringify({
+          projects: { local: [{ worktree: directory, expanded: true }] },
+          lastProject: { local: directory },
+        }),
+      )
+      localStorage.setItem(
+        "opencode.window.browser.dat:tabs",
+        JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
+      )
+    },
+    { directory, server, sessions: [sessionA, sessionB] },
+  )
+  return connections
+}
+
+function session(id: string, title: string, created: number) {
+  return {
+    id,
+    slug: id,
+    projectID,
+    directory,
+    title,
+    version: "dev",
+    time: { created, updated: created },
+  }
+}
+
+function sessionHref(sessionID: string) {
+  return `/server/${base64Encode(server)}/session/${sessionID}`
+}

+ 4 - 1
packages/app/src/app.tsx

@@ -54,7 +54,7 @@ import { ErrorPage } from "./pages/error"
 import { useCheckServerHealth } from "./utils/server-health"
 import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
 
-import { SessionPage, TargetSessionRoute as TargetSessionRouteContent } from "@/pages/session"
+import { SessionPage, TargetSessionRouteContent } from "@/pages/session"
 import { NewHome, LegacyHome } from "@/pages/home"
 
 const NewSession = lazy(() => import("@/pages/new-session"))
@@ -100,6 +100,9 @@ const TargetSessionRoute = () => {
   })
 
   return (
+    // Owns the server-identity remount. Session changes must NOT remount this
+    // subtree (SessionRouteErrorBoundary resets and createSessionLineage
+    // re-resolves reactively instead); both rely on this key for server changes.
     <Show when={requireServerKey(params.serverKey)} keyed>
       <ServerSDKProvider server={conn}>
         <ServerSyncProvider server={conn}>

+ 2 - 1
packages/app/src/context/server-session.ts

@@ -14,6 +14,7 @@ import type {
 import { batch } from "solid-js"
 import { createStore, produce, reconcile } from "solid-js/store"
 import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
+import { sessionNotFoundError } from "@/utils/server-errors"
 import { rootSession } from "@/utils/session-route"
 import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
 
@@ -235,7 +236,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
     if (pending) return pending
     const active = generation(sessionID)
     const request = client.session.get({ sessionID }).then((result) => {
-      if (!result.data) throw new Error(`Session not found: ${sessionID}`)
+      if (!result.data) throw sessionNotFoundError(sessionID)
       if (generations.get(sessionID) !== active) return result.data
       return remember(result.data)
     })

+ 36 - 56
packages/app/src/pages/session.tsx

@@ -11,7 +11,6 @@ import {
   createMemo,
   createEffect,
   createComputed,
-  createSignal,
   on,
   onMount,
   type ParentProps,
@@ -91,10 +90,11 @@ import { Identifier } from "@/utils/id"
 import { diffs as list } from "@/utils/diffs"
 import { Persist, persisted } from "@/utils/persist"
 import { extractPromptFromParts } from "@/utils/prompt"
-import { formatServerError, isSessionNotFoundError } from "@/utils/server-errors"
+import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
 import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
 import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
 import { createSessionOwnership } from "./session/session-ownership"
+import { createSessionLineage } from "./session/session-lineage"
 
 type FollowupItem = FollowupDraft & { id: string }
 type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
@@ -109,10 +109,6 @@ const sessionViewState = () => ({
   changes: "git" as ChangeMode,
 })
 
-function isLocalSessionNotFoundError(error: unknown, sessionID: string) {
-  return error instanceof Error && error.message === `Session not found: ${sessionID}`
-}
-
 function isCurrentSessionNotFoundError(error: unknown, sessionID: string | undefined) {
   if (!sessionID) return false
   return isSessionNotFoundError(error, sessionID) || isLocalSessionNotFoundError(error, sessionID)
@@ -149,14 +145,16 @@ export function SessionPage() {
   )
 }
 
-export function TargetSessionRoute() {
+// Rendered under app.tsx's TargetSessionRoute, which owns the per-server keyed
+// remount around the server-scoped providers. Nothing here may key on the
+// session ID: session tabs on the same server share this route instance, and
+// workspace-scoped state (terminal, directory providers) lives below.
+export function TargetSessionRouteContent() {
   const params = useParams<{ serverKey: string; id: string }>()
   return (
-    <Show when={`${params.serverKey}\0${params.id}`} keyed>
-      <SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
-        <ResolvedTargetSessionRoute />
-      </SessionRouteErrorBoundary>
-    </Show>
+    <SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
+      <ResolvedTargetSessionRoute />
+    </SessionRouteErrorBoundary>
   )
 }
 
@@ -230,8 +228,12 @@ function ResolvedTargetSessionRoute() {
   const params = useParams<{ serverKey: string; id: string }>()
   const settings = useSettings()
   const tabs = useTabs()
+  const sync = useServerSync()
   const serverKey = createMemo(() => requireServerKey(params.serverKey))
-  const current = createSessionLineage(() => params.id)
+  const current = createSessionLineage(
+    () => params.id,
+    () => sync().session.lineage,
+  )
   const directory = createMemo(() => current()?.session.directory)
   const targetDirectory = () => directory()!
 
@@ -246,6 +248,10 @@ function ResolvedTargetSessionRoute() {
 
   return (
     <TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
+      {/* Non-keyed: closes only while the target's directory is unknown (uncached
+          lineage mid-resolution), which tears down the workspace subtree including
+          the terminal. Same-workspace tab switches keep it open because warm
+          targets resolve synchronously from the sync cache. */}
       <Show when={directory()}>
         <Show
           when={settings.general.newLayoutDesigns()}
@@ -262,42 +268,9 @@ function ResolvedTargetSessionRoute() {
   )
 }
 
-// Reactive session lineage for the target session route, read from the sync store.
-// The route keys its consumer to the session ID, so resolution runs once per target.
-// Resolution is imperative rather than a resource on purpose: a resource created here
-// would be created inside the router's navigation transition, and suspending that
-// transition deadlocks the URL commit and double-mounts the session header portals
-// from the transition's shadow render. `lineage.resolve` fills the sync store, which
-// the returned accessor observes; resolve failures rethrow on read so the enclosing
-// SessionRouteErrorBoundary renders the scoped session error.
-function createSessionLineage(sessionID: () => string) {
-  const sync = useServerSync()
-  const cached = createMemo(() => sync().session.lineage.peek(sessionID()))
-  const [failure, setFailure] = createSignal<unknown>()
-  const [settled, setSettled] = createSignal(false)
-  onMount(() => {
-    if (cached()) {
-      setSettled(true)
-      return
-    }
-    sync()
-      .session.lineage.resolve(sessionID())
-      .then(() => setSettled(true))
-      .catch((error) => setFailure(() => error))
-  })
-  return createMemo(() => {
-    const error = failure()
-    if (error) throw error
-    const lineage = cached()
-    // The viewed session is pinned and pinned lineages are exempt from cache pruning,
-    // so a lineage missing after settlement means the session (or an ancestor) was
-    // deleted, possibly by another client. Match the resolve error so the boundary
-    // shows the session not found fallback.
-    if (!lineage && settled()) throw new Error(`Session not found: ${sessionID()}`)
-    return lineage
-  })
-}
-
+// Owns the workspace-identity remount. Must not include the session ID in the
+// key: SessionPage handles session changes reactively, and remounting here
+// destroys workspace-scoped state (terminal PTYs, file/prompt providers).
 function TargetSessionPage() {
   const sdk = useSDK()
   const serverSDK = useServerSDK()
@@ -419,6 +392,7 @@ export default function Page() {
   })
 
   const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
+  const sessionPanelKey = createMemo(() => (params.id ? `${serverSDK().scope}\0${params.id}` : undefined))
 
   createEffect(
     on(
@@ -2135,13 +2109,19 @@ export default function Page() {
             width: sessionPanelWidth(),
           }}
         >
-          <SessionPanelFrame newLayout={settings.general.newLayoutDesigns()} raised={!!params.id}>
-            {settings.general.newLayoutDesigns() ? (
-              <ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
-            ) : (
-              sessionPanelContent()
-            )}
-          </SessionPanelFrame>
+          {settings.general.newLayoutDesigns() ? (
+            <Show when={sessionPanelKey()} keyed>
+              {(_) => (
+                <SessionPanelFrame newLayout raised={!!params.id}>
+                  <ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
+                </SessionPanelFrame>
+              )}
+            </Show>
+          ) : (
+            <SessionPanelFrame newLayout={false} raised={!!params.id}>
+              {sessionPanelContent()}
+            </SessionPanelFrame>
+          )}
 
           <Show when={desktopReviewOpen()}>
             <div onPointerDown={() => size.start()}>

+ 69 - 0
packages/app/src/pages/session/session-lineage.ts

@@ -0,0 +1,69 @@
+import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
+import { sessionNotFoundError } from "@/utils/server-errors"
+
+type LineageStore<T> = { peek: (id: string) => T | undefined; resolve: (id: string) => Promise<unknown> }
+
+type Resolution<T> = { id: string; store: LineageStore<T> } & (
+  | { state: "pending" }
+  | { state: "settled" }
+  | { state: "failed"; failure: unknown }
+)
+
+// Reactive session lineage for the target session route, read from the sync store.
+// All session tabs on a server share one route instance, so the target session ID
+// changes in place; the effect is only a trigger that starts resolution for the
+// current target, and each run cancels the previous one through onCleanup so a
+// late result from an abandoned target is dropped. Resolution is imperative rather
+// than a resource on purpose: a resource created here would be created inside the
+// router's navigation transition, and suspending that transition deadlocks the URL
+// commit and double-mounts the session header portals from the transition's shadow
+// render.
+//
+// The returned accessor is a pure derivation. The sync cache is authoritative, and
+// status only applies while it matches the current target (store + session ID): on
+// navigation or store replacement the memo re-evaluates before the trigger runs,
+// so trusting a previous target's settlement would fabricate a not-found for a
+// session that simply has not resolved yet. Resolve failures rethrow on read so
+// the enclosing SessionRouteErrorBoundary renders the scoped session error.
+export function createSessionLineage<T>(sessionID: () => string, lineage: () => LineageStore<T>) {
+  const cached = createMemo(() => lineage().peek(sessionID()))
+  const [status, setStatus] = createSignal<Resolution<T>>()
+
+  createEffect(
+    on([sessionID, lineage] as const, ([id, store]) => {
+      let stale = false
+      onCleanup(() => {
+        stale = true
+      })
+      if (cached()) {
+        setStatus({ id, store, state: "settled" })
+        return
+      }
+      setStatus({ id, store, state: "pending" })
+      store
+        .resolve(id)
+        .then(() => {
+          if (!stale) setStatus({ id, store, state: "settled" })
+        })
+        .catch((failure) => {
+          if (!stale) setStatus({ id, store, state: "failed", failure })
+        })
+    }),
+  )
+
+  return createMemo(() => {
+    const id = sessionID()
+    const value = cached()
+    if (value) return value
+    const state = status()
+    if (state?.id !== id || state.store !== lineage()) return undefined
+    if (state.state === "failed") throw state.failure
+    // The viewed session is pinned (DirectoryDataProvider, directory-layout.tsx)
+    // and pinned lineages are exempt from cache pruning, so a lineage missing
+    // after settlement means the session (or an ancestor) was deleted, possibly
+    // by another client. Match the resolve error so the boundary shows the
+    // session not found fallback.
+    if (state.state === "settled") throw sessionNotFoundError(id)
+    return undefined
+  })
+}

+ 14 - 0
packages/app/src/utils/server-errors.ts

@@ -42,6 +42,20 @@ function unwrapNamedError(error: unknown): unknown {
   return error
 }
 
+// Client-synthesized session not-found errors share one constructor and
+// predicate so the message contract cannot drift between the sync store
+// (server-session.ts), the route lineage (session-lineage.ts), and the
+// not-found fallback matching (session.tsx).
+const sessionNotFoundMessage = (sessionID: string) => `Session not found: ${sessionID}`
+
+export function sessionNotFoundError(sessionID: string) {
+  return new Error(sessionNotFoundMessage(sessionID))
+}
+
+export function isLocalSessionNotFoundError(error: unknown, sessionID: string) {
+  return error instanceof Error && error.message === sessionNotFoundMessage(sessionID)
+}
+
 export function isSessionNotFoundError(error: unknown, sessionID: string) {
   const unwrapped = unwrapNamedError(error)
   if (typeof unwrapped !== "object" || unwrapped === null) return false

+ 231 - 0
packages/app/test-browser/session-lineage.test.ts

@@ -0,0 +1,231 @@
+import { expect, test } from "bun:test"
+import { createRoot, createSignal } from "solid-js"
+import { createSessionLineage } from "@/pages/session/session-lineage"
+
+type Lineage = { session: { id: string; directory: string } }
+
+const lineageOf = (id: string): Lineage => ({ session: { id, directory: `/dir/${id}` } })
+
+// Fake sync lineage store: peek reads a reactive cache, resolve returns a
+// deferred promise the test settles or fails explicitly. The lineage memo is
+// live (read below), so it recomputes eagerly on cache/status writes — throws
+// surface at the write site, which is also where the enclosing ErrorBoundary
+// would see them in the app. Assertions wrap write + read to cover both.
+function createFixture(initial: Record<string, Lineage> = {}) {
+  const [cache, setCache] = createSignal(initial)
+  const deferred = new Map<string, PromiseWithResolvers<unknown>>()
+  const resolves: string[] = []
+  return {
+    resolves,
+    lineage: {
+      peek: (id: string) => cache()[id],
+      resolve: (id: string) => {
+        resolves.push(id)
+        const entry = deferred.get(id) ?? Promise.withResolvers<unknown>()
+        deferred.set(id, entry)
+        return entry.promise
+      },
+    },
+    settle(id: string) {
+      setCache({ ...cache(), [id]: lineageOf(id) })
+      deferred.get(id)?.resolve(undefined)
+    },
+    fail(id: string, error: unknown) {
+      deferred.get(id)?.reject(error)
+      // The real store does not cache failures: the inflight request entry is
+      // dropped on rejection so the next resolve retries (server-session.ts).
+      deferred.delete(id)
+    },
+    remove(id: string) {
+      const next = { ...cache() }
+      delete next[id]
+      setCache(next)
+    },
+  }
+}
+
+// Two microtask ticks: one for the resolve promise handed back by the fixture,
+// one for the .then/.catch chain inside createSessionLineage.
+const flush = async () => {
+  await Promise.resolve()
+  await Promise.resolve()
+}
+
+test("resolves an uncached session and exposes its lineage", async () => {
+  await createRoot(async (dispose) => {
+    const fixture = createFixture()
+    const current = createSessionLineage(
+      () => "ses_a",
+      () => fixture.lineage,
+    )
+
+    expect(current()).toBeUndefined()
+    await flush()
+    expect(fixture.resolves).toEqual(["ses_a"])
+
+    fixture.settle("ses_a")
+    await flush()
+    expect(current()?.session.id).toBe("ses_a")
+
+    dispose()
+  })
+})
+
+// Session tabs on the same server share one route instance, so navigating to
+// another session changes the id in place; resolution must follow it instead
+// of reporting the new session as missing.
+test("re-resolves when navigating to an uncached session without a remount", async () => {
+  await createRoot(async (dispose) => {
+    const fixture = createFixture({ ses_a: lineageOf("ses_a") })
+    const [id, setId] = createSignal("ses_a")
+    const current = createSessionLineage(id, () => fixture.lineage)
+
+    await flush()
+    expect(current()?.session.id).toBe("ses_a")
+
+    expect(() => {
+      setId("ses_b")
+      current()
+    }).not.toThrow()
+    expect(fixture.resolves).toEqual(["ses_b"])
+
+    fixture.settle("ses_b")
+    await flush()
+    expect(current()?.session.id).toBe("ses_b")
+
+    dispose()
+  })
+})
+
+// A late failure from a session the user already navigated away from must not
+// poison the currently viewed session.
+test("ignores a stale resolution failure after the target changes", async () => {
+  await createRoot(async (dispose) => {
+    const fixture = createFixture()
+    const [id, setId] = createSignal("ses_a")
+    const current = createSessionLineage(id, () => fixture.lineage)
+
+    await flush()
+    setId("ses_b")
+    fixture.fail("ses_a", new Error("Session not found: ses_a"))
+    await flush()
+
+    expect(() => current()).not.toThrow()
+    fixture.settle("ses_b")
+    await flush()
+    expect(current()?.session.id).toBe("ses_b")
+
+    dispose()
+  })
+})
+
+test("returning to a pruned session re-resolves instead of throwing not found", async () => {
+  await createRoot(async (dispose) => {
+    const fixture = createFixture()
+    const [id, setId] = createSignal("ses_a")
+    const current = createSessionLineage(id, () => fixture.lineage)
+
+    await flush()
+    fixture.settle("ses_a")
+    await flush()
+
+    setId("ses_b")
+    fixture.settle("ses_b")
+    await flush()
+
+    fixture.remove("ses_a")
+    expect(() => {
+      setId("ses_a")
+      current()
+    }).not.toThrow()
+    expect(fixture.resolves).toEqual(["ses_a", "ses_b", "ses_a"])
+
+    fixture.settle("ses_a")
+    await flush()
+    expect(current()?.session.id).toBe("ses_a")
+
+    dispose()
+  })
+})
+
+// A resolution that fails while its session is unfocused must not leave a
+// poisoned status behind: revisiting that session retries cleanly instead of
+// rethrowing the stale failure before the retry can start.
+test("revisiting a session whose resolution failed while unfocused retries cleanly", async () => {
+  await createRoot(async (dispose) => {
+    const fixture = createFixture()
+    const [id, setId] = createSignal("ses_a")
+    const current = createSessionLineage(id, () => fixture.lineage)
+
+    await flush()
+    setId("ses_b")
+    fixture.fail("ses_a", new Error("resolve failed"))
+    await flush()
+
+    expect(() => {
+      setId("ses_a")
+      current()
+    }).not.toThrow()
+    expect(fixture.resolves).toEqual(["ses_a", "ses_b", "ses_a"])
+
+    fixture.settle("ses_a")
+    await flush()
+    expect(current()?.session.id).toBe("ses_a")
+
+    dispose()
+  })
+})
+
+// The lineage accessor is reactive: replacing the sync store (for example after
+// the server context is rebuilt) must gate out the old store's status and
+// re-resolve against the new one instead of fabricating a not-found.
+test("re-resolves against a replaced lineage store", async () => {
+  await createRoot(async (dispose) => {
+    const first = createFixture()
+    const second = createFixture()
+    const [store, setStore] = createSignal(first.lineage)
+    const current = createSessionLineage(() => "ses_a", store)
+
+    await flush()
+    first.settle("ses_a")
+    await flush()
+    expect(current()?.session.id).toBe("ses_a")
+
+    expect(() => {
+      setStore(second.lineage)
+      current()
+    }).not.toThrow()
+    await flush()
+    expect(second.resolves).toEqual(["ses_a"])
+
+    second.settle("ses_a")
+    await flush()
+    expect(current()?.session.id).toBe("ses_a")
+
+    dispose()
+  })
+})
+
+// The viewed session is pinned in the cache, so disappearing after settlement
+// means it was deleted; the boundary must show the not found fallback.
+test("throws not found when the settled session is deleted", async () => {
+  await createRoot(async (dispose) => {
+    const fixture = createFixture()
+    const current = createSessionLineage(
+      () => "ses_a",
+      () => fixture.lineage,
+    )
+
+    await flush()
+    fixture.settle("ses_a")
+    await flush()
+    expect(current()?.session.id).toBe("ses_a")
+
+    expect(() => {
+      fixture.remove("ses_a")
+      current()
+    }).toThrow("Session not found: ses_a")
+
+    dispose()
+  })
+})