From 4c786f0ff8c58f5ad4a7c8b74ac9adb6af117443 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:18:39 +0800 Subject: [PATCH] fix(app): preserve subagent tab lineage (#44404) --- .../subagent-child-navigation.spec.ts | 64 ++++++++++++++++++ packages/app/src/session/composer/region.tsx | 2 +- .../session-composer-region-controller.ts | 16 ++--- packages/app/src/session/model.ts | 15 ++++- .../src/session/session-identity-header.tsx | 65 +++++++++++++++++-- .../src/shell/routes/session-ui-provider.tsx | 9 +++ packages/app/src/shell/tabs/migration.ts | 21 +++++- packages/app/src/shell/tabs/tabs.test.ts | 20 +++++- packages/app/src/shell/tabs/tabs.tsx | 31 +++++++-- packages/app/src/shell/titlebar/titlebar.tsx | 27 ++++++-- 10 files changed, 242 insertions(+), 28 deletions(-) diff --git a/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app/e2e/regression/subagent-child-navigation.spec.ts index 9a2ba091dc5..c3fa0148de4 100644 --- a/packages/app/e2e/regression/subagent-child-navigation.spec.ts +++ b/packages/app/e2e/regression/subagent-child-navigation.spec.ts @@ -26,6 +26,32 @@ test("navigates to a subagent child session missing from the session list", asyn await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1) }) +test("shows parent lineage while the child timeline loads", async ({ page }) => { + await setup(page) + const requested = Promise.withResolvers() + const release = Promise.withResolvers() + await page.route( + (url) => + url.pathname === `/api/session/${childID}/message` && + url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"), + async (route) => { + requested.resolve() + await release.promise + await route.fallback() + }, + ) + + await page.goto(sessionHref(parentID)) + await expectSessionTitle(page, parentTitle) + await page.locator(`a[href="${sessionHref(childID)}"]`).click() + await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))]) + await Promise.all([ + expect(page.locator('[data-slot="session-title-parent"]')).toHaveText(parentTitle), + expect(page.locator('[data-slot="session-title-child"]')).toHaveText(childTitle), + ]).finally(() => release.resolve()) + await expectSessionTitle(page, taskDescription) +}) + test("keeps the parent visible while the child session resolves", async ({ page }) => { await setup(page) const requested = Promise.withResolvers() @@ -50,6 +76,44 @@ test("keeps the parent visible while the child session resolves", async ({ page await expectSessionTitle(page, taskDescription) }) +test("keeps the parent tab selected while a loaded child session resolves", async ({ page }) => { + await setup(page) + await openChildFromParent(page) + await expectSessionTitle(page, taskDescription) + await page.goBack() + await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]) + + const requested = Promise.withResolvers() + const release = Promise.withResolvers() + await page.route( + (url) => url.pathname === `/api/session/${childID}` && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"), + async (route) => { + requested.resolve() + await release.promise + await route.fallback() + }, + ) + + const parentTab = page.locator("[data-titlebar-tab-slot]", { + has: page.locator('[data-slot="tab-title"]', { hasText: parentTitle }), + }) + await page.locator(`a[href="${sessionHref(childID)}"]`).click() + await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))]) + await Promise.all([ + expect(parentTab).toHaveAttribute("data-active", "true"), + expect(page.locator('[data-slot="session-title-parent"]')).toHaveText(parentTitle), + ]).finally(() => release.resolve()) + await expectSessionTitle(page, taskDescription) + + const home = page.getByRole("button", { name: "Home" }) + await home.click() + await expect(page).toHaveURL("/") + const childTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${sessionHref(childID)}"]`) + await expect(childTab).toHaveCount(1) + await childTab.click() + await Promise.all([expect(page).toHaveURL(sessionHref(childID)), expectSessionTitle(page, taskDescription)]) +}) + test("shows the not found fallback when the viewed session is deleted", async ({ page }) => { const events: OpenCodeEvent[] = [] await setup(page, () => events.splice(0, 1)) diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index 3f85866074a..3bfe4095003 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -204,7 +204,7 @@ export function ActiveSessionComposerRegion(props: { }) { const region = createSessionComposerRegionController({ state: props.model.region.state, - sessionID: () => props.session.identity.params.id, + parentID: props.session.data.parentID, centered: props.model.region.centered, onResponseSubmit: props.onResponseSubmit, openParent: props.model.region.openParent, diff --git a/packages/app/src/session/composer/session-composer-region-controller.ts b/packages/app/src/session/composer/session-composer-region-controller.ts index 55e8c1047f4..f07f5ddc132 100644 --- a/packages/app/src/session/composer/session-composer-region-controller.ts +++ b/packages/app/src/session/composer/session-composer-region-controller.ts @@ -1,21 +1,15 @@ -import { type Accessor, createMemo } from "solid-js" -import { useData } from "@/runtime/server/current" +import type { Accessor } from "solid-js" import type { SessionRequestModel } from "../requests/model" export function createSessionComposerRegionController(input: { state: SessionRequestModel - sessionID: Accessor + parentID: Accessor centered: Accessor onResponseSubmit: () => void openParent: () => void setPromptRef: (el: HTMLDivElement) => void setDockRef: (el: HTMLDivElement) => void }) { - const data = useData() - const parentID = createMemo(() => { - const id = input.sessionID() - return id ? data.session.get(id)?.parentID : undefined - }) return { state: input.state, centered: input.centered, @@ -23,9 +17,9 @@ export function createSessionComposerRegionController(input: { openParent: input.openParent, setPromptRef: input.setPromptRef, setDockRef: input.setDockRef, - parentID, - child: () => !!parentID(), - showComposer: () => !input.state.blocked() || !!parentID(), + parentID: input.parentID, + child: () => !!input.parentID(), + showComposer: () => !input.state.blocked() || !!input.parentID(), } } diff --git a/packages/app/src/session/model.ts b/packages/app/src/session/model.ts index afcc43dec26..edc392bc972 100644 --- a/packages/app/src/session/model.ts +++ b/packages/app/src/session/model.ts @@ -15,6 +15,8 @@ import { } from "./session-domain" import { useSessionLayout } from "./session-layout" import { createSessionOwnership } from "./session-ownership" +import { useTabs } from "@/shell/tabs/tabs" +import { useServer } from "@/runtime/server/current" const emptyMessages: SessionMessageInfo[] = [] const emptyUserMessages: SessionMessageUser[] = [] @@ -23,6 +25,8 @@ const idle = { type: "idle" as const } export function useSessionModel() { const file = useFile() const data = useData() + const server = useServer() + const shellTabs = useTabs() const layout = useSessionLayout() const location = useWorkspaceLocation() const isDesktop = createMediaQuery("(min-width: 768px)") @@ -31,7 +35,16 @@ export function useSessionModel() { const id = sessionID() return id ? data.session.get(id) : undefined }) - const parentID = createMemo(() => info()?.parentID) + const parentID = createMemo(() => { + const current = info()?.parentID + if (current) return current + const id = sessionID() + if (!id) return + const tab = shellTabs.store.find( + (item) => item.type === "session" && item.server === server.key && item.routeSessionId === id, + ) + return tab?.type === "session" ? (tab.routeParentId ?? tab.sessionId) : undefined + }) const parent = createMemo(() => { const id = parentID() return id ? data.session.get(id) : undefined diff --git a/packages/app/src/session/session-identity-header.tsx b/packages/app/src/session/session-identity-header.tsx index 75f967ba5cb..23a9b308b27 100644 --- a/packages/app/src/session/session-identity-header.tsx +++ b/packages/app/src/session/session-identity-header.tsx @@ -1,6 +1,7 @@ import type { SessionInfo } from "@opencode-ai/client/promise" import { Icon } from "@opencode-ai/ui/icon" import { ProjectAvatar } from "@opencode-ai/ui/project-avatar" +import { useNavigate } from "@solidjs/router" import { createMemo, Show, type ParentProps } from "solid-js" import { useServer } from "@/runtime/server/current" import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers" @@ -9,6 +10,7 @@ import { tabKey, useTabs } from "@/shell/tabs/tabs" import { useSettings } from "@/settings/model" import { pathKey } from "@/workspaces/path-key" import { isWorkspaceDirectory } from "@/workspaces/paths" +import { sessionHref } from "@/shell/routes/session" import { sessionTitle } from "./title" export function SessionTitleHeader(props: ParentProps) { @@ -26,11 +28,36 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess const server = useServer() const tabs = useTabs() const settings = useSettings() - const info = createMemo( - () => tabs.info[tabKey({ type: "session", server: server.key, sessionId: props.sessionID })], + const navigate = useNavigate() + const tab = createMemo(() => + tabs.store.find( + (item) => + item.type === "session" && + item.server === server.key && + (item.sessionId === props.sessionID || item.routeSessionId === props.sessionID), + ), ) + const info = createMemo(() => { + const current = tab() + return current ? tabs.info[tabKey(current)] : undefined + }) + const parentID = createMemo(() => { + if (props.session?.parentID) return props.session.parentID + const current = tab() + if (current?.type !== "session" || current.routeSessionId !== props.sessionID) return + return current.routeParentId ?? current.sessionId + }) + const parent = createMemo(() => { + const id = parentID() + return id ? server.ctx.data.session.get(id) : undefined + }) + const parentTitle = createMemo(() => { + const id = parentID() + const current = tab() + return sessionTitle(parent()?.title ?? (current?.type === "session" && current.sessionId === id ? info()?.title : undefined)) + }) const directory = createMemo(() => props.session?.location.directory ?? info()?.directory) - const title = createMemo(() => sessionTitle(props.session?.title ?? info()?.title)) + const title = createMemo(() => sessionTitle(props.session?.title ?? (parentID() ? undefined : info()?.title))) const project = createMemo(() => { const projects = server.ctx.projects.list() if (props.session) return projectForSession(props.session, projects) @@ -44,9 +71,16 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess const showProjectIcon = () => import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon() && !!directory() const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), directory() ?? "")) + const navigateParent = () => { + const id = parentID() + const current = tab() + if (!id || current?.type !== "session") return + tabs.rememberSessionRoute(current, id, parent()?.parentID) + navigate(sessionHref(server.key, id)) + } return ( - +
@@ -69,9 +103,32 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess /> + + {(value) => ( + + )} + + + + {(value) => (

diff --git a/packages/app/src/shell/routes/session-ui-provider.tsx b/packages/app/src/shell/routes/session-ui-provider.tsx index 64379288ce6..ac127162555 100644 --- a/packages/app/src/shell/routes/session-ui-provider.tsx +++ b/packages/app/src/shell/routes/session-ui-provider.tsx @@ -5,6 +5,7 @@ import { LocalProvider } from "@/providers/models/selection" import type { ServerConnection } from "@/runtime/server/registry" import { sessionHref } from "@/shell/routes/session" import { useData } from "@/runtime/server/current" +import { useTabs } from "@/shell/tabs/tabs" export function SessionUIProvider( props: ParentProps<{ @@ -15,9 +16,17 @@ export function SessionUIProvider( const navigate = useNavigate() const params = useParams() const data = useData() + const tabs = useTabs() const directory = () => props.directory const href = (sessionID: string) => sessionHref(props.server, sessionID) const navigateToSession = async (sessionID: string) => { + const tab = tabs.store.find( + (item) => + item.type === "session" && + item.server === props.server && + (item.sessionId === params.id || item.routeSessionId === params.id), + ) + if (tab?.type === "session") tabs.rememberSessionRoute(tab, sessionID, params.id) await data.session.sync(sessionID).catch(() => undefined) navigate(href(sessionID)) } diff --git a/packages/app/src/shell/tabs/migration.ts b/packages/app/src/shell/tabs/migration.ts index 27ee8b24ca1..69617c36833 100644 --- a/packages/app/src/shell/tabs/migration.ts +++ b/packages/app/src/shell/tabs/migration.ts @@ -7,8 +7,25 @@ export function migrateTabs(value: unknown): Tab[] { if (!tab || typeof tab !== "object") return [] if (!("server" in tab) || typeof tab.server !== "string") return [] const server = tab.server as ServerConnection.Key - if (tab.type === "session" && typeof tab.sessionId === "string") { - return [{ type: tab.type, server, sessionId: tab.sessionId }] + if ( + tab.type === "session" && + typeof tab.sessionId === "string" && + (tab.routeSessionId === undefined || typeof tab.routeSessionId === "string") && + (tab.routeParentId === undefined || typeof tab.routeParentId === "string") + ) { + return [ + { + type: tab.type, + server, + sessionId: tab.sessionId, + ...(tab.routeSessionId && tab.routeSessionId !== tab.sessionId + ? { + routeSessionId: tab.routeSessionId, + ...(tab.routeParentId ? { routeParentId: tab.routeParentId } : {}), + } + : {}), + }, + ] } if ( tab.type === "draft" && diff --git a/packages/app/src/shell/tabs/tabs.test.ts b/packages/app/src/shell/tabs/tabs.test.ts index 9a99cfbaf7c..530ed7807f3 100644 --- a/packages/app/src/shell/tabs/tabs.test.ts +++ b/packages/app/src/shell/tabs/tabs.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { createRoot, getOwner, onCleanup } from "solid-js" import { createTabMemory } from "./memory" import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed" -import type { SessionTab, Tab } from "./tabs" +import { tabHref, tabKey, type SessionTab, type Tab } from "./tabs" import { migrateTabs } from "./migration" import type { ServerConnection } from "@/runtime/server/registry" @@ -27,6 +27,24 @@ describe("tab migration", () => { expect(migrateTabs(null)).toEqual([]) expect(migrateTabs({})).toEqual([]) }) + + test("preserves the active child route", () => { + expect(migrateTabs([{ ...sessionTab("root"), routeSessionId: "child", routeParentId: "parent" }])).toEqual([ + { ...sessionTab("root"), routeSessionId: "child", routeParentId: "parent" }, + ]) + }) + + test("drops an invalid child route", () => { + expect(migrateTabs([{ ...sessionTab("parent"), routeSessionId: 1 }])).toEqual([]) + }) +}) + +test("session tab identity stays rooted while its href follows the child route", () => { + const parent = sessionTab("parent") + const child = { ...parent, routeSessionId: "child" } + + expect(tabKey(child)).toBe(tabKey(parent)) + expect(tabHref(child)).toContain("/session/child") }) describe("tab memory", () => { diff --git a/packages/app/src/shell/tabs/tabs.tsx b/packages/app/src/shell/tabs/tabs.tsx index 39bf5372c54..7a3e8cc3b6d 100644 --- a/packages/app/src/shell/tabs/tabs.tsx +++ b/packages/app/src/shell/tabs/tabs.tsx @@ -19,6 +19,8 @@ export type SessionTab = { type: "session" server: ServerConnection.Key sessionId: string + routeSessionId?: string + routeParentId?: string } export type DraftTab = { @@ -43,12 +45,18 @@ type RecentTab = { export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}` export const tabHref = (tab: Tab) => - tab.type === "draft" ? draftHref(tab.draftID) : sessionHref(tab.server, tab.sessionId) + tab.type === "draft" ? draftHref(tab.draftID) : sessionHref(tab.server, tab.routeSessionId ?? tab.sessionId) -export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`) +export const tabKey = (tab: Tab) => + tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${sessionHref(tab.server, tab.sessionId)}` export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: SessionInfo) { - return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id) + return tabs.some( + (tab) => + tab.type === "session" && + tab.server === server && + (tab.sessionId === session.id || tab.routeSessionId === session.id), + ) } export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ @@ -284,7 +292,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ removeSessionTab(input: Omit) { updateClosed((stack) => removeClosedTabs(stack, input.server, [input.sessionId])) const index = store.findIndex( - (tab) => tab.type === "session" && tab.server === input.server && tab.sessionId === input.sessionId, + (tab) => + tab.type === "session" && + tab.server === input.server && + (tab.sessionId === input.sessionId || tab.routeSessionId === input.sessionId), ) if (index !== -1) removeTab(index) }, @@ -360,6 +371,18 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const key = tabKey(tab) if (recentKey() !== key) setRecentKey(key) }, + rememberSessionRoute(tab: SessionTab, sessionId: string, parentId?: string) { + const index = store.findIndex((item) => tabKey(item) === tabKey(tab)) + if (index === -1) return + setStore( + index, + produce((item) => { + if (item.type !== "session") return + item.routeSessionId = sessionId === item.sessionId ? undefined : sessionId + item.routeParentId = sessionId === item.sessionId ? undefined : parentId + }), + ) + }, toggleHome(input: { home: boolean; current?: Tab }) { if (input.home) { const tab = store.find((tab) => tabKey(tab) === recentKey()) diff --git a/packages/app/src/shell/titlebar/titlebar.tsx b/packages/app/src/shell/titlebar/titlebar.tsx index c80a37bf05c..036c49b7786 100644 --- a/packages/app/src/shell/titlebar/titlebar.tsx +++ b/packages/app/src/shell/titlebar/titlebar.tsx @@ -151,15 +151,24 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl const tabs = useTabs() const tabsStore = tabs.store const tabsStoreActions = tabs - const [session] = createResource( + const [loadedSession] = createResource( () => { const route = layout.route() if (route.type !== "session") return undefined const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server) - return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined + return conn ? { route, ctx: global.ensureServerCtx(conn) } : undefined }, - ({ route, sdk }) => sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}), + ({ route, ctx }) => ctx.sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}), ) + const session = createMemo(() => { + const route = layout.route() + if (route.type !== "session") return + const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server) + const cached = conn ? global.ensureServerCtx(conn).data.session.get(route.sessionId) : undefined + if (cached) return cached + const loaded = loadedSession() + return loaded?.id === route.sessionId ? loaded : undefined + }) const matchRoute = (route: LayoutRoute) => { if (route.type === "home") return @@ -169,7 +178,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl if (route.type === "session") { const main = tabsStore.find( (item) => - item.type === "session" && item.server === route.server && item.sessionId === route.sessionId, + item.type === "session" && + item.server === route.server && + (item.sessionId === route.sessionId || item.routeSessionId === route.sessionId), ) if (main) return main const s = session() @@ -190,6 +201,14 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl if (!tabs.ready()) return const tab = currentTab() if (tab) { + const current = session() + if ( + route.type === "session" && + tab.type === "session" && + (route.sessionId === tab.sessionId || current?.id === route.sessionId) + ) { + tabs.rememberSessionRoute(tab, route.sessionId, current?.parentID) + } tabs.remember(tab) return }