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

Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
Luke Parker 2026-07-02 17:53:19 +10:00 committed by GitHub
commit 4a42caef2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 501 additions and 58 deletions

View file

@ -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}>

View file

@ -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)
})

View file

@ -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()}>

View file

@ -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
})
}

View file

@ -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