mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
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:
committed by
GitHub
parent
3ddfc15df5
commit
ad53e39d2e
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user