fix(app): resolve session lineage across in-place session changes

This commit is contained in:
LukeParkerDev 2026-07-02 15:48:10 +10:00
commit 0eb31eb8e3
3 changed files with 236 additions and 38 deletions

View file

@ -11,7 +11,6 @@ import {
createMemo,
createEffect,
createComputed,
createSignal,
on,
onMount,
type ParentProps,
@ -90,6 +89,7 @@ import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/sessio
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
import { createSessionOwnership } from "./session/session-ownership"
import { SessionRouteErrorBoundary } from "./session/route-boundary"
import { createSessionLineage } from "./session/session-lineage"
type FollowupItem = FollowupDraft & { id: string }
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
@ -228,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()!
@ -260,42 +264,6 @@ 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
})
}
function TargetSessionPage() {
const sdk = useSDK()
const serverSDK = useServerSDK()

View file

@ -0,0 +1,62 @@
import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
// 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: on navigation 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: () => { peek: (id: string) => T | undefined; resolve: (id: string) => Promise<unknown> },
) {
const cached = createMemo(() => lineage().peek(sessionID()))
const [status, setStatus] = createSignal<{ id: string; settled: boolean; failure?: unknown }>()
createEffect(
on(sessionID, (id) => {
let stale = false
onCleanup(() => {
stale = true
})
if (cached()) {
setStatus({ id, settled: true })
return
}
setStatus({ id, settled: false })
lineage()
.resolve(id)
.then(() => {
if (!stale) setStatus({ id, settled: true })
})
.catch((error) => {
if (!stale) setStatus({ id, settled: true, failure: error })
})
}),
)
return createMemo(() => {
const id = sessionID()
const value = cached()
if (value) return value
const state = status()
if (state?.id !== id) return undefined
if (state.failure !== undefined) throw state.failure
// 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 (state.settled) throw new Error(`Session not found: ${id}`)
return undefined
})
}