From 871aa0864e36be6ac423c7c9ab30b2424568f775 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:32:21 +1000 Subject: [PATCH 1/2] fix(app): resolve target session lineage outside router transition --- .../subagent-child-navigation.spec.ts | 175 ++++++++++++++++++ packages/app/src/pages/session.tsx | 40 ++-- packages/app/src/utils/session-route.test.ts | 15 +- packages/app/src/utils/session-route.ts | 9 - 4 files changed, 204 insertions(+), 35 deletions(-) create mode 100644 packages/app/e2e/regression/subagent-child-navigation.spec.ts diff --git a/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app/e2e/regression/subagent-child-navigation.spec.ts new file mode 100644 index 000000000000..4cf209f7eb32 --- /dev/null +++ b/packages/app/e2e/regression/subagent-child-navigation.spec.ts @@ -0,0 +1,175 @@ +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/SubagentNavigation" +const projectID = "proj_subagent_navigation" +const parentID = "ses_subagent_parent" +const childID = "ses_subagent_child" +const parentTitle = "Parent session" +const childTitle = "Subagent child session" +// Child session pages derive their heading from the task part that spawned them. +const taskDescription = "Inspect child navigation" + +test.use({ viewport: { width: 1440, height: 900 } }) + +test("navigates to a subagent child session missing from the session list", async ({ page }) => { + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "subagent-navigation", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { + "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } }, + }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "claude-opus-4-6" }, + }, + sessions: [session(parentID, parentTitle, 1700000000000), childSession()], + pageMessages: (sessionID) => ({ items: sessionID === parentID ? parentMessages() : [] }), + }) + // The child session resolves via /session/:id but is absent from the /session list, + // matching a subagent session that has not been loaded into the list cache yet. + await page.route( + (url) => url.pathname === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"), + (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify([session(parentID, parentTitle, 1700000000000)]), + }), + ) + await configurePage(page) + + await page.goto(sessionHref(parentID)) + await expectSessionTitle(page, parentTitle) + + const card = page.locator(`a[href="${sessionHref(childID)}"]`) + await expect(card).toBeVisible() + await card.click() + + await expect(page).toHaveURL(new RegExp(`/server/.+/session/${childID}$`), { timeout: 15_000 }) + await expectSessionTitle(page, taskDescription) + await expect(page.getByRole("heading", { name: parentTitle })).toHaveCount(0) + + const titlebarRight = page.locator("#opencode-titlebar-right") + await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1) +}) + +function session(id: string, title: string, created: number, extra?: Record) { + return { + id, + slug: id, + projectID, + directory, + title, + version: "dev", + time: { created, updated: created }, + ...extra, + } +} + +function childSession() { + return session(childID, childTitle, 1700000001000, { parentID }) +} + +function parentMessages() { + const userID = "msg_user_0001" + const assistantID = "msg_assistant_0001" + return [ + { + info: { + id: userID, + sessionID: parentID, + role: "user", + time: { created: 1700000000000 }, + agent: "build", + model: { providerID: "opencode", modelID: "claude-opus-4-6" }, + }, + parts: [ + { + id: "prt_user_text_0001", + sessionID: parentID, + messageID: userID, + type: "text", + text: "Delegate work to a subagent", + }, + ], + }, + { + info: { + id: assistantID, + sessionID: parentID, + role: "assistant", + time: { created: 1700000001000, completed: 1700000002000 }, + parentID: userID, + modelID: "claude-opus-4-6", + providerID: "opencode", + mode: "build", + agent: "build", + path: { cwd: directory, root: directory }, + cost: 0.01, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: "stop", + }, + parts: [ + { + id: "prt_tool_task_0001", + sessionID: parentID, + messageID: assistantID, + type: "tool", + callID: "call_task_0001", + tool: "task", + state: { + status: "completed", + input: { description: taskDescription, subagent_type: "explore" }, + output: "Subagent finished", + title: taskDescription, + metadata: { sessionId: childID }, + time: { start: 1700000001000, end: 1700000002000 }, + }, + }, + ], + }, + ] +} + +async function configurePage(page: Page) { + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + await page.addInitScript( + ({ directory, server, sessionId }) => { + 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([{ type: "session", server, sessionId }]), + ) + }, + { directory, server, sessionId: parentID }, + ) +} + +function sessionHref(sessionID: string) { + const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + return `/server/${base64Encode(server)}/session/${sessionID}` +} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 36be14546bbf..19a5ab9dd33a 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -11,7 +11,7 @@ import { createMemo, createEffect, createComputed, - createResource, + createSignal, on, onMount, type ParentProps, @@ -85,7 +85,7 @@ 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 { legacySessionHref, requireServerKey, selectSessionLineage, sessionHref } from "@/utils/session-route" +import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" import { createSessionOwnership } from "./session/session-ownership" @@ -223,17 +223,8 @@ 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 cached = createMemo(() => sync().session.lineage.peek(params.id)) - const [resolved] = createResource( - () => { - if (cached()) return - return { id: params.id, sync: sync() } - }, - ({ id, sync }) => sync.session.lineage.resolve(id), - ) - const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved())) + const current = createSessionLineage(() => params.id) const directory = createMemo(() => current()?.session.directory) const targetDirectory = () => directory()! @@ -264,6 +255,31 @@ 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() + onMount(() => { + if (cached()) return + sync() + .session.lineage.resolve(sessionID()) + .catch((error) => setFailure(() => error)) + }) + return createMemo(() => { + const error = failure() + if (error) throw error + return cached() + }) +} + function TargetSessionPage() { const sdk = useSDK() const serverSDK = useServerSDK() diff --git a/packages/app/src/utils/session-route.test.ts b/packages/app/src/utils/session-route.test.ts index cfcc8e1e78e1..6a87e366cf8b 100644 --- a/packages/app/src/utils/session-route.test.ts +++ b/packages/app/src/utils/session-route.test.ts @@ -1,13 +1,6 @@ import { describe, expect, test } from "bun:test" import { ServerConnection } from "@/context/server" -import { - legacySessionHref, - legacySessionServer, - requireServerKey, - rootSession, - selectSessionLineage, - sessionHref, -} from "./session-route" +import { legacySessionHref, legacySessionServer, requireServerKey, rootSession, sessionHref } from "./session-route" describe("session routes", () => { test("uses the unique persisted server for a legacy session route", () => { @@ -75,10 +68,4 @@ describe("session routes", () => { expect(rootSession(sessions.child, async (id) => sessions[id]!)).rejects.toThrow("Session parent cycle: child") }) - - test("ignores a resolved lineage retained from the previous route", () => { - const previous = { session: { id: "A" }, root: { id: "A" } } - - expect(selectSessionLineage("B", undefined, previous)).toBeUndefined() - }) }) diff --git a/packages/app/src/utils/session-route.ts b/packages/app/src/utils/session-route.ts index 97ba356278b7..4721611d2eae 100644 --- a/packages/app/src/utils/session-route.ts +++ b/packages/app/src/utils/session-route.ts @@ -27,15 +27,6 @@ export function legacySessionServer( type SessionParent = { id: string; parentID?: string } -export function selectSessionLineage( - sessionID: string, - cached: T | undefined, - resolved: T | undefined, -) { - if (cached?.session.id === sessionID) return cached - if (resolved?.session.id === sessionID) return resolved -} - export async function rootSession(session: T, get: (sessionID: string) => Promise) { const seen = new Set([session.id]) let current = session From 574616f8aee46e1ffe054ca6f8fca8bce1d1148a Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:09:15 +1000 Subject: [PATCH 2/2] fix(app): surface session deletion while a target session is open --- .../subagent-child-navigation.spec.ts | 40 ++++++++++++++++--- packages/app/src/pages/session.tsx | 15 ++++++- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app/e2e/regression/subagent-child-navigation.spec.ts index 4cf209f7eb32..1229776b232c 100644 --- a/packages/app/e2e/regression/subagent-child-navigation.spec.ts +++ b/packages/app/e2e/regression/subagent-child-navigation.spec.ts @@ -12,9 +12,38 @@ const childTitle = "Subagent child session" // Child session pages derive their heading from the task part that spawned them. const taskDescription = "Inspect child navigation" +type EventPayload = { directory: string; payload: Record } + test.use({ viewport: { width: 1440, height: 900 } }) test("navigates to a subagent child session missing from the session list", async ({ page }) => { + await setup(page) + await openChildFromParent(page) + + await expectSessionTitle(page, taskDescription) + await expect(page.getByRole("heading", { name: parentTitle })).toHaveCount(0) + + const titlebarRight = page.locator("#opencode-titlebar-right") + await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1) +}) + +test("shows the not found fallback when the viewed session is deleted", async ({ page }) => { + const events: EventPayload[] = [] + await setup(page, () => events.splice(0, 1)) + await openChildFromParent(page) + await expectSessionTitle(page, taskDescription) + + events.push({ + directory, + payload: { type: "session.deleted", properties: { info: childSession() } }, + }) + + await expect(page.getByText("This session cannot be found")).toBeVisible() + await expect(page.getByRole("button", { name: "Close Tab" })).toBeVisible() + await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0) +}) + +async function setup(page: Page, events?: () => EventPayload[]) { await mockOpenCodeServer(page, { directory, project: { @@ -40,6 +69,8 @@ test("navigates to a subagent child session missing from the session list", asyn }, sessions: [session(parentID, parentTitle, 1700000000000), childSession()], pageMessages: (sessionID) => ({ items: sessionID === parentID ? parentMessages() : [] }), + events, + eventRetry: events ? 16 : undefined, }) // The child session resolves via /session/:id but is absent from the /session list, // matching a subagent session that has not been loaded into the list cache yet. @@ -54,7 +85,9 @@ test("navigates to a subagent child session missing from the session list", asyn }), ) await configurePage(page) +} +async function openChildFromParent(page: Page) { await page.goto(sessionHref(parentID)) await expectSessionTitle(page, parentTitle) @@ -63,12 +96,7 @@ test("navigates to a subagent child session missing from the session list", asyn await card.click() await expect(page).toHaveURL(new RegExp(`/server/.+/session/${childID}$`), { timeout: 15_000 }) - await expectSessionTitle(page, taskDescription) - await expect(page.getByRole("heading", { name: parentTitle })).toHaveCount(0) - - const titlebarRight = page.locator("#opencode-titlebar-right") - await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1) -}) +} function session(id: string, title: string, created: number, extra?: Record) { return { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 19a5ab9dd33a..1a3911aa6bd8 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -267,16 +267,27 @@ function createSessionLineage(sessionID: () => string) { const sync = useServerSync() const cached = createMemo(() => sync().session.lineage.peek(sessionID())) const [failure, setFailure] = createSignal() + const [settled, setSettled] = createSignal(false) onMount(() => { - if (cached()) return + 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 - return cached() + 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 }) }