From ad53e39d2e6c222ea35aa4b8331616a9f85b4ef5 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:10:15 +0000 Subject: [PATCH] fix(app): group nested tabs and show full session path (#51064) Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com> --- .../subagent-child-navigation.spec.ts | 70 +++++++++- .../src/session/session-identity-header.tsx | 131 +++++++++++++----- .../src/session/timeline/message-timeline.tsx | 27 ++-- packages/app/src/shell/titlebar/titlebar.tsx | 32 ++++- 4 files changed, 201 insertions(+), 59 deletions(-) diff --git a/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app/e2e/regression/subagent-child-navigation.spec.ts index 2a3fe123d6f..caf6fe2c081 100644 --- a/packages/app/e2e/regression/subagent-child-navigation.spec.ts +++ b/packages/app/e2e/regression/subagent-child-navigation.spec.ts @@ -8,8 +8,13 @@ const directory = "C:/OpenCode/SubagentNavigation" const projectID = "proj_subagent_navigation" const parentID = "ses_subagent_parent" const childID = "ses_subagent_child" +const grandchildID = "ses_subagent_grandchild" +const greatGrandchildID = "ses_subagent_great_grandchild" const parentTitle = "Parent session" const childTitle = "Subagent child session" +const grandchildTitle = "Nested subagent session" +const greatGrandchildTitle = + "Deep research subagent session investigating a very long chain of agent registry failures and navigation breadcrumbs" // Child session pages derive their heading from the task part that spawned them. const taskDescription = "Inspect child navigation" @@ -25,6 +30,60 @@ test("navigates to a subagent child session missing from the session list", asyn await expect(page.getByRole("button", { name: "Toggle review", exact: true })).toBeVisible() }) +test("opens a directly linked nested subagent in its root session tab", async ({ page }) => { + await setup(page, undefined, 1) + await page.goto(sessionHref(grandchildID), { waitUntil: "domcontentloaded" }) + + await expect(page.locator('[data-slot="session-title-parent"]')).toHaveText(childTitle) + await expect(page.locator('[data-slot="session-title-child"]')).toHaveText(grandchildTitle) + const tabs = page.locator('[data-slot="titlebar-tabs"] [data-titlebar-tab-slot]') + await expect(tabs.locator(`a[href="${sessionHref(grandchildID)}"]`)).toHaveCount(1) + await expect(tabs).toHaveCount(1) + await expect(tabs.locator('[data-slot="tab-title"]')).toHaveText(parentTitle) +}) + +test("shows the full ancestor path and navigates to an earlier subagent", async ({ page }) => { + await setup(page, undefined, 2) + await page.goto(sessionHref(greatGrandchildID), { waitUntil: "domcontentloaded" }) + + await expect(page.locator("[data-timeline-virtual-content]")).toBeAttached() + const header = page.locator("[data-session-title]") + await expect(header.locator('[data-slot="session-title-ancestor"]')).toHaveText([parentTitle, childTitle]) + await expect(header.locator('[data-slot="session-title-parent"]')).toHaveText(grandchildTitle) + await expect(header.getByRole("heading", { name: greatGrandchildTitle })).toBeVisible() + + await header.getByRole("button", { name: childTitle, exact: true }).click() + await expect(page).toHaveURL(sessionHref(childID)) + await expect(page.locator("[data-session-title]").getByRole("heading", { name: childTitle })).toBeVisible() + await expect(page.locator('[data-slot="titlebar-tabs"] [data-titlebar-tab-slot]')).toHaveCount(1) +}) + +test("keeps the active nested session visible in a narrow desktop header", async ({ page }) => { + await page.setViewportSize({ width: 820, height: 720 }) + await setup(page, undefined, 2) + await page.goto(sessionHref(greatGrandchildID), { waitUntil: "domcontentloaded" }) + + const header = page.locator("[data-session-title]") + const heading = header.getByRole("heading", { name: greatGrandchildTitle }) + const separator = header.locator('[data-slot="session-title-ancestors"] + [data-slot="session-title-separator"]') + await expect(header.locator('[data-slot="session-title-ancestor"]')).toHaveText([parentTitle, childTitle]) + await expect(heading).toBeInViewport() + await expect(separator).toBeInViewport() + const bounds = await heading.boundingBox() + expect(bounds).not.toBeNull() + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(820) + + await page.evaluate(() => document.documentElement.setAttribute("dir", "rtl")) + await expect(heading).toBeInViewport() + await expect(separator).toBeInViewport() + + const root = header.locator(`[data-slot="session-title-ancestor"][data-session-id="${parentID}"]`) + await root.focus() + await expect(root).toBeFocused() + await root.press("Enter") + await expect(page).toHaveURL(sessionHref(parentID)) +}) + test("returns to the parent session with Escape", async ({ page }) => { await setup(page) await openChildFromParent(page) @@ -144,7 +203,7 @@ test("shows the not found fallback when the viewed session is deleted", async ({ await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0) }) -async function setup(page: Page, events?: () => OpenCodeEvent[]) { +async function setup(page: Page, events?: () => OpenCodeEvent[], nestedDepth: 0 | 1 | 2 = 0) { await mockOpenCodeServer(page, { directory, project: { @@ -168,7 +227,14 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) { connected: ["opencode"], default: { providerID: "opencode", modelID: "claude-opus-4-6" }, }, - sessions: [session(parentID, parentTitle, 1700000000000), childSession()], + sessions: [ + session(parentID, parentTitle, 1700000000000), + childSession(), + ...(nestedDepth >= 1 ? [session(grandchildID, grandchildTitle, 1700000002000, { parentID: childID })] : []), + ...(nestedDepth >= 2 + ? [session(greatGrandchildID, greatGrandchildTitle, 1700000003000, { parentID: grandchildID })] + : []), + ], pageMessages: (sessionID) => ({ items: sessionID === parentID ? parentMessages() : [] }), events, eventRetry: events ? 16 : undefined, diff --git a/packages/app/src/session/session-identity-header.tsx b/packages/app/src/session/session-identity-header.tsx index 9545e414cf7..1100bfc9811 100644 --- a/packages/app/src/session/session-identity-header.tsx +++ b/packages/app/src/session/session-identity-header.tsx @@ -6,7 +6,7 @@ import { ProjectAvatar } from "@opencode/ui/project-avatar" import { Tooltip } from "@opencode/ui/tooltip" import { createResizeObserver } from "@solid-primitives/resize-observer" import { useNavigate } from "@solidjs/router" -import { createMemo, Show, type ParentProps } from "solid-js" +import { createMemo, For, Show, type ParentProps } from "solid-js" import { createStore } from "solid-js/store" import { useServer } from "@/runtime/server/current" import { ServerConnection } from "@/runtime/server/registry" @@ -191,12 +191,96 @@ export function SessionProjectMenu(props: { ) } +export function SessionAncestorTrail(props: { + sessionID: string + parentID: string + parentTitle?: string + trailing: boolean +}) { + const server = useServer() + const tabs = useTabs() + const navigate = useNavigate() + const language = useLanguage() + const ancestors = createMemo(() => { + const path: { id: string; title: string; direct: boolean }[] = [] + const seen = new Set([props.sessionID]) + let id: string | undefined = props.parentID + while (id && !seen.has(id)) { + seen.add(id) + const info = server.ctx.data.session.get(id) + path.unshift({ + id, + title: + sessionTitle(info?.title ?? (id === props.parentID ? props.parentTitle : undefined)) ?? + language.t("session.tab.session"), + direct: id === props.parentID, + }) + id = info?.parentID + } + return path + }) + const open = (id: string) => { + const tab = tabs.store.find( + (item) => + item.type === "session" && + item.server === server.key && + (item.sessionId === props.sessionID || item.routeSessionId === props.sessionID), + ) + if (tab?.type === "session") tabs.rememberSessionRoute(tab, id, server.ctx.data.session.get(id)?.parentID) + navigate(sessionHref(server.key, id)) + } + + return ( +
+
+ + {(ancestor, index) => ( + <> + + + + + + )} + +
+ + + +
+ ) +} + export function SessionIdentityHeader(props: { sessionID: string; session?: SessionInfo }) { const server = useServer() const tabs = useTabs() const language = useLanguage() const pending = createMemo(() => tabs.pendingSession(server.key, props.sessionID)) - const navigate = useNavigate() const tab = createMemo(() => tabs.store.find( (item) => @@ -250,53 +334,30 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess ) }) const workspaceSession = createMemo(() => !!pending() || 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 (
- - - {(value) => ( - + + + {(id) => ( + )} - - - {(value) => (

{value()}

diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index 58fbbaaebfd..52ffb131e90 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -34,7 +34,7 @@ import { containsDirectory, isWorkspaceDirectory } from "@/workspaces/paths" import { projectForSession } from "@/shell/layout/helpers" import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note" import { useCommand } from "@/shell/commands/command" -import { SessionProjectMenu, SessionTitleHeader } from "../session-identity-header" +import { SessionAncestorTrail, SessionProjectMenu, SessionTitleHeader } from "../session-identity-header" import { SessionHeaderSpacer } from "@/session/header/session-header" import type { BackgroundTask } from "../summary/background" @@ -413,21 +413,14 @@ function MessageTimelineView( workspace={workspaceSession()} /> - - + {(id) => ( + + )} {childTitle()} @@ -451,6 +445,7 @@ function MessageTimelineView( value={title.draft} disabled={props.pending.rename()} class="block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base field-sizing-content rounded-[6px] px-1 py-1" + classList={{ "max-w-[45%] shrink-0": !!parentID() }} style={{ "--inline-input-shadow": "none", "text-align": "start", diff --git a/packages/app/src/shell/titlebar/titlebar.tsx b/packages/app/src/shell/titlebar/titlebar.tsx index 6c352443350..af2d1eec822 100644 --- a/packages/app/src/shell/titlebar/titlebar.tsx +++ b/packages/app/src/shell/titlebar/titlebar.tsx @@ -33,6 +33,7 @@ import { projectForSession } from "@/shell/layout/helpers" import { useSettingsDialog } from "@/settings/command" import { updaterAction } from "@/shell/updates/action" import type { UpdaterState } from "@/shell/updates/types" +import { rootSession } from "@/shell/routes/session" import devIcon from "../../../../desktop/icons/dev/64x64.png" import betaIcon from "../../../../desktop/icons/beta/64x64.png" @@ -185,7 +186,7 @@ export function Titlebar(props: { const route = layout.route() return route.type === "session" && !!tabs.pendingSession(route.server, route.sessionId) }) - const [loadedSession] = createResource( + const [resolvedSession] = createResource( () => { const route = layout.route() if (route.type !== "session") return undefined @@ -193,7 +194,23 @@ export function Titlebar(props: { const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server) return conn ? { route, ctx: global.ensureServerCtx(conn) } : undefined }, - ({ route, ctx }) => ctx.sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}), + async ({ route, ctx }) => { + const info = await ctx.sdk.api.session + .get({ sessionID: route.sessionId }) + .catch(() => ctx.data.session.get(route.sessionId)) + if (!info) return + ctx.data.session.remember(info) + const rootID = await rootSession(info, async (id) => { + const cached = ctx.data.session.get(id) + if (cached) return cached + const ancestor = await ctx.sdk.api.session.get({ sessionID: id }) + ctx.data.session.remember(ancestor) + return ancestor + }) + .then((root) => root.id) + .catch(() => ctx.data.session.root(info.id)) + return { info, rootID } + }, ) const session = createMemo(() => { const route = layout.route() @@ -202,8 +219,8 @@ export function Titlebar(props: { 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 resolved = resolvedSession() + return resolved?.info.id === route.sessionId ? resolved.info : undefined }) const matchRoute = (route: LayoutRoute) => { @@ -221,7 +238,8 @@ export function Titlebar(props: { if (main) return main const s = session() if (s?.parentID) { - const parentID = s.parentID + const resolved = resolvedSession() + const parentID = resolved?.info.id === s.id ? resolved.rootID : s.parentID const parent = tabsStore.find( (item) => item.type === "session" && item.server === route.server && item.sessionId === parentID, ) @@ -256,7 +274,9 @@ export function Titlebar(props: { } const s = session() if (!s) return - const sessionId = s.parentID ?? s.id + const resolved = resolvedSession() + if (s.parentID && resolved?.info.id !== s.id) return + const sessionId = resolved?.info.id === s.id ? resolved.rootID : s.id const next = { server: route.server, sessionId } tabsStoreActions.addSessionTab(next) }