fix(app): group nested tabs and show full session path (#51064)

Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-09-24 08:10:15 +00:00
committed by GitHub
parent 3ddfc15df5
commit ad53e39d2e
4 changed files with 201 additions and 59 deletions
@@ -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,
@@ -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 (
<div class="flex min-w-0 max-w-full items-center">
<div
data-slot="session-title-ancestors"
class="flex min-w-0 items-center overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
<For each={ancestors()}>
{(ancestor, index) => (
<>
<button
type="button"
data-slot={ancestor.direct ? "session-title-parent" : "session-title-ancestor"}
data-session-id={ancestor.id}
title={ancestor.title}
dir="auto"
class="max-w-[min(200px,40vw)] shrink-0 truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
onClick={() => open(ancestor.id)}
>
{ancestor.title}
</button>
<Show when={index() < ancestors().length - 1}>
<span
data-slot="session-title-separator"
class="-translate-y-[0.5px] shrink-0 pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
/
</span>
</Show>
</>
)}
</For>
</div>
<Show when={props.trailing}>
<span
data-slot="session-title-separator"
class="-translate-y-[0.5px] shrink-0 pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
/
</span>
</Show>
</div>
)
}
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 (
<Show when={title() || parentTitle()}>
<SessionTitleHeader>
<div class="flex h-12 w-full items-center justify-between gap-2">
<div class="flex min-w-0 flex-1 items-center gap-1">
<div class="flex min-w-0 w-full flex-1 items-center gap-0.5">
<SessionProjectMenu
project={project()}
directory={directory()}
workspace={workspaceSession()}
/>
<Show when={parentTitle()}>
{(value) => (
<button
type="button"
data-slot="session-title-parent"
dir="auto"
class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
onClick={navigateParent}
>
{value()}
</button>
<SessionProjectMenu project={project()} directory={directory()} workspace={workspaceSession()} />
<Show when={parentID()}>
{(id) => (
<SessionAncestorTrail
sessionID={props.sessionID}
parentID={id()}
parentTitle={parentTitle()}
trailing={!!title()}
/>
)}
</Show>
<Show when={parentTitle() && title()}>
<span
data-slot="session-title-separator"
class="-translate-y-[0.5px] pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
/
</span>
</Show>
<Show when={title()}>
{(value) => (
<h1
data-slot={parentID() ? "session-title-child" : undefined}
dir="auto"
class="w-fit truncate rounded-[6px] px-1 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
classList={{ "max-w-[45%] shrink-0": !!parentID() }}
>
{value()}
</h1>
@@ -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()}
/>
<Show when={parentID()}>
<button
type="button"
data-slot="session-title-parent"
class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
onClick={props.action.navigateParent}
>
{parentTitle()}
</button>
<span
data-slot="session-title-separator"
class="-translate-y-[0.5px] pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
/
</span>
{(id) => (
<SessionAncestorTrail
sessionID={sessionID() ?? ""}
parentID={id()}
parentTitle={parentTitle()}
trailing={!!(childTitle() || title.editing)}
/>
)}
</Show>
<Show when={childTitle() || title.editing}>
<Show
@@ -436,6 +429,7 @@ function MessageTimelineView(
<h1
data-slot="session-title-child"
class="truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base w-fit rounded-[6px] px-1 py-1 hover:bg-v2-overlay-simple-overlay-hover"
classList={{ "max-w-[45%] shrink-0": !!parentID() }}
onClick={openTitleEditor}
>
{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",
+26 -6
View File
@@ -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)
}