From fe1dafbada3634670422cebb47069595a7c2aff1 Mon Sep 17 00:00:00 2001 From: "Vyctor H. Brzezowski" Date: Wed, 12 Aug 2026 00:18:11 -0300 Subject: [PATCH] fix(ui): show nested session parent in chat header (#122335) * fix(ui): show nested session parent in chat header * fix(ui): cap nested chat breadcrumb width * test(ui): avoid breadcrumb geometry shadowing * chore(ui): keep breadcrumb comment issue-agnostic --- ui/src/e2e/chat-header-axis.e2e.test.ts | 38 ++++++++- ui/src/i18n/locales/en.ts | 1 + ui/src/pages/chat/chat-pane-header.ts | 5 ++ .../chat/chat-pane-session-access.test.ts | 41 ++++++++++ .../chat/chat-responsive.browser.test.ts | 79 +++++++++++++++++++ .../chat/components/chat-pane-header.test.ts | 53 +++++++++++++ .../pages/chat/components/chat-pane-header.ts | 49 +++++++++++- ui/src/styles/chat/split-view.css | 36 ++++++++- 8 files changed, 294 insertions(+), 8 deletions(-) diff --git a/ui/src/e2e/chat-header-axis.e2e.test.ts b/ui/src/e2e/chat-header-axis.e2e.test.ts index 7610a01c44b8..2d380ebca27d 100644 --- a/ui/src/e2e/chat-header-axis.e2e.test.ts +++ b/ui/src/e2e/chat-header-axis.e2e.test.ts @@ -11,7 +11,7 @@ const suite = createChatFlowE2eSuite(); suite.define(() => { for (const colorScheme of ["light", "dark"] as const) { - it(`centers the project trail with the header actions in ${colorScheme} mode`, async () => { + it(`centers and navigates the project-parent-child trail in ${colorScheme} mode`, async () => { const context = await suite.newBrowserContext({ colorScheme, locale: "en-US", @@ -26,10 +26,18 @@ suite.define(() => { await installMockGateway(page, { methodResponses: { "sessions.list": chatSessionListResponse([ + { + key: "agent:main:parent", + kind: "direct", + label: "Release readiness and production rollout coordination", + spawnedCwd: "/repo/openclaw", + updatedAt: 1, + }, { key: "agent:main:session-a", kind: "direct", - label: "Session A", + label: "Implement parent breadcrumb navigation and polish overflow behavior", + parentSessionKey: "agent:main:parent", spawnedCwd: "/repo/openclaw", updatedAt: 2, }, @@ -59,6 +67,7 @@ suite.define(() => { projectText: centerY(".chat-pane__workspace-chip span"), search: centerY(".chat-pane__palette-open svg"), separator: centerY(".chat-pane__crumb-sep"), + parentText: centerY(".chat-pane__parent-session-text"), sessionText: centerY(".chat-pane__session-title-text"), }; }); @@ -66,6 +75,31 @@ suite.define(() => { for (const center of Object.values(centers)) { expect(Math.abs(center - centers.nav), JSON.stringify(centers)).toBeLessThanOrEqual(0.1); } + expect(await header.locator(".chat-pane__crumb-sep").count()).toBe(2); + const parent = header.locator(".chat-pane__parent-session"); + const nestedTrail = await header.evaluate((root) => { + const parentCrumb = root.querySelector(".chat-pane__parent-session")!; + const child = root.querySelector(".chat-pane__session-title")!; + const parentText = root.querySelector(".chat-pane__parent-session-text")!; + const childText = root.querySelector(".chat-pane__session-title-text")!; + const headerRect = root.getBoundingClientRect(); + return { + childEllipses: childText.scrollWidth > childText.clientWidth, + headerWidth: headerRect.width, + parentEllipses: parentText.scrollWidth > parentText.clientWidth, + width: child.getBoundingClientRect().right - parentCrumb.getBoundingClientRect().left, + }; + }); + expect(nestedTrail.parentEllipses).toBe(true); + expect(nestedTrail.childEllipses).toBe(true); + expect(nestedTrail.width).toBeLessThanOrEqual(nestedTrail.headerWidth / 2 + 1); + expect((await parent.textContent())?.trim()).toBe( + "Release readiness and production rollout coordination", + ); + await parent.click(); + await expect + .poll(() => header.locator(".chat-pane__session-title-text").textContent()) + .toBe("Release readiness and production rollout coordination"); } finally { await suite.closeBrowserContext(context); } diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 7adf239635dc..fd6fc1e386ce 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -4581,6 +4581,7 @@ export const en: TranslationMap = { renameAria: "Rename session {title}", renameInputAria: "Session title", renameInputPlaceholder: "Session title", + openParent: "Open parent session {title}", panels: "Panels", layout: "Layout", workspaceAria: "Workspace actions for {workspace}", diff --git a/ui/src/pages/chat/chat-pane-header.ts b/ui/src/pages/chat/chat-pane-header.ts index ccbcf508630d..d80d4695bf19 100644 --- a/ui/src/pages/chat/chat-pane-header.ts +++ b/ui/src/pages/chat/chat-pane-header.ts @@ -38,6 +38,7 @@ import type { import { canRevealSessionWorkspace, renderChatPaneHeader, + resolveChatPaneParentSession, resolveChatPaneWorkspace, } from "./components/chat-pane-header.ts"; import { renderSessionRailToggle } from "./components/chat-session-rail-toggle.ts"; @@ -308,6 +309,7 @@ export abstract class ChatPaneHeader extends ChatPaneSessionMenu { workspaceRoot: workspace.root, workspaceLabel: workspace.label, workspaceIcon: this.resolveWorkspaceIcon(workspace.root ? row?.key : undefined), + parentSession: resolveChatPaneParentSession(row, this.state?.sessionsResult?.sessions ?? []), branch, branches: this.state && this.state.chatBranchesSessionKey === this.state.sessionKey @@ -449,6 +451,9 @@ export abstract class ChatPaneHeader extends ChatPaneSessionMenu { this.handleHeaderMenuAction(action, row, workspace.root, branch); } }, + onOpenParentSession: (sessionKey) => { + this.onPaneSessionChange?.(this.paneId, sessionKey); + }, onBranchSelect: (leafEntryId) => { const access = readChatSessionActionAccess( this.context.gateway.snapshot, diff --git a/ui/src/pages/chat/chat-pane-session-access.test.ts b/ui/src/pages/chat/chat-pane-session-access.test.ts index 8d2822a3f3de..1953528d08c5 100644 --- a/ui/src/pages/chat/chat-pane-session-access.test.ts +++ b/ui/src/pages/chat/chat-pane-session-access.test.ts @@ -11,6 +11,47 @@ import { createBackgroundTasksProps } from "./components/chat-background-tasks.t import { createSessionWorkspaceProps } from "./components/chat-session-workspace.ts"; describe("chat pane session access", () => { + it("opens the resolved parent from the header breadcrumb", () => { + const { pane, state } = createTestChatPane({ + client: {} as GatewayBrowserClient, + sessions: {} as SessionCapability, + }); + const parent = { + key: "agent:main:parent", + kind: "direct", + label: "Release prep", + updatedAt: 1, + } satisfies GatewaySessionRow; + const child = { + key: "agent:main:child", + kind: "direct", + label: "Implementation", + parentSessionKey: parent.key, + updatedAt: 2, + } satisfies GatewaySessionRow; + state.sessionsResult = { sessions: [parent, child] } as NonNullable< + typeof state.sessionsResult + >; + pane.paneId = "pane-child"; + pane.onPaneSessionChange = vi.fn(); + const container = document.createElement("div"); + + render( + pane.renderPaneHeader( + createSessionWorkspaceProps(state), + createBackgroundTasksProps(state), + child, + false, + undefined, + false, + ), + container, + ); + container.querySelector(".chat-pane__parent-session")?.click(); + + expect(pane.onPaneSessionChange).toHaveBeenCalledExactlyOnceWith("pane-child", parent.key); + }); + it("refuses ordinary session creation without operator.write", async () => { const sessions = { create: vi.fn(async () => "agent:main:new"), diff --git a/ui/src/pages/chat/chat-responsive.browser.test.ts b/ui/src/pages/chat/chat-responsive.browser.test.ts index 5fc5cda88421..7e00a4968cfd 100644 --- a/ui/src/pages/chat/chat-responsive.browser.test.ts +++ b/ui/src/pages/chat/chat-responsive.browser.test.ts @@ -1115,6 +1115,85 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => { } }); + it("caps a nested session trail at half the header while ellipsizing both titles", async () => { + const page = await openBrowserPage(720, 180); + try { + const splitViewCss = readStyleSheet("ui/src/styles/chat/split-view.css"); + await page.setContent( + ` +
+
+
+ + + + + + + +
+
+ +
+
+
+ `, + ); + + const readState = () => + page.locator(".chat-pane__header").evaluate((header) => { + const separators = [...header.querySelectorAll(".chat-pane__crumb-sep")]; + const parentText = header.querySelector(".chat-pane__parent-session-text")!; + const childText = header.querySelector(".chat-pane__session-title-text")!; + const parent = header.querySelector(".chat-pane__parent-session")!; + const child = header.querySelector(".chat-pane__session-title")!; + const headerRect = header.getBoundingClientRect(); + const parentRect = parent.getBoundingClientRect(); + const childRect = child.getBoundingClientRect(); + return { + firstSeparator: getComputedStyle(separators[0]!).display, + secondSeparator: getComputedStyle(separators[1]!).display, + parentEllipses: parentText.scrollWidth > parentText.clientWidth, + childEllipses: childText.scrollWidth > childText.clientWidth, + headerWidth: headerRect.width, + nestedTrailWidth: childRect.right - parentRect.left, + overflow: (header as HTMLElement).scrollWidth - (header as HTMLElement).clientWidth, + }; + }); + + const normal = await readState(); + expect(normal).toMatchObject({ + firstSeparator: "block", + secondSeparator: "block", + parentEllipses: true, + childEllipses: true, + overflow: 0, + }); + expect(normal.nestedTrailWidth).toBeLessThanOrEqual(normal.headerWidth / 2 + 1); + + await page.locator(".chat-split-view__cell").evaluate((cell) => { + (cell as HTMLElement).style.width = "320px"; + }); + const narrow = await readState(); + expect(narrow).toMatchObject({ + firstSeparator: "none", + secondSeparator: "block", + parentEllipses: true, + childEllipses: true, + overflow: 0, + }); + expect(narrow.nestedTrailWidth).toBeLessThanOrEqual(narrow.headerWidth / 2 + 1); + } finally { + await closeBrowserPage(page); + } + }); + it("keeps a Done status disjoint from a long compact session headline", async () => { const page = await openBrowserPage(320, 240); try { diff --git a/ui/src/pages/chat/components/chat-pane-header.test.ts b/ui/src/pages/chat/components/chat-pane-header.test.ts index 94701a9efe98..828e4b7c33fb 100644 --- a/ui/src/pages/chat/components/chat-pane-header.test.ts +++ b/ui/src/pages/chat/components/chat-pane-header.test.ts @@ -15,6 +15,7 @@ import { import { canRevealSessionWorkspace, renderChatPaneHeader, + resolveChatPaneParentSession, resolveChatPaneWorkspace, } from "./chat-pane-header.ts"; @@ -80,6 +81,7 @@ function mount(patch: Partial = {}) { workspaceRoot: "/repo/openclaw", workspaceLabel: "openclaw", workspaceIcon: null, + parentSession: null, branch: "feature/header", branches: [], branchSwitchDisabledReason: null, @@ -100,6 +102,7 @@ function mount(patch: Partial = {}) { onCancelRename: vi.fn(), onMenuOpenChange: vi.fn(), onMenuAction: vi.fn(), + onOpenParentSession: vi.fn(), onBranchSelect: vi.fn(), ...patch, }; @@ -324,6 +327,24 @@ describe("chat pane header", () => { ); }); + it("places a clickable parent between the project and child session", () => { + const parentSession = { key: "agent:main:parent", title: "Release prep" }; + const { container, props } = mount({ parentSession }); + const crumbs = container.querySelector(".chat-pane__crumbs"); + + expect([...(crumbs?.children ?? [])].map((child) => child.className)).toEqual([ + "chat-pane__workspace-menu", + "chat-pane__crumb-sep", + "chat-pane__parent-session", + "chat-pane__crumb-sep", + "chat-pane__session-title chat-pane__session-title-button", + ]); + const parent = crumbs?.querySelector(".chat-pane__parent-session"); + expect(parent?.textContent?.trim()).toBe("Release prep"); + parent?.click(); + expect(props.onOpenParentSession).toHaveBeenCalledExactlyOnceWith("agent:main:parent"); + }); + it("drops the separator when the session has no project segment", () => { const { container } = mount({ workspaceLabel: null, workspaceRoot: null }); expect(container.querySelector(".chat-pane__crumb-sep")).toBeNull(); @@ -508,6 +529,38 @@ describe("chat pane header", () => { }); }); +describe("chat pane parent resolution", () => { + it("uses the navigation parent and its canonical display name", () => { + const parent = row({ + key: "agent:main:parent", + label: "Release prep", + }); + const controlOwner = row({ + key: "agent:main:control-owner", + label: "Coordinator", + }); + + expect( + resolveChatPaneParentSession( + row({ + key: "agent:main:child", + parentSessionKey: parent.key, + spawnedBy: controlOwner.key, + }), + [controlOwner, parent], + ), + ).toEqual({ key: parent.key, title: "Release prep" }); + }); + + it("omits unresolved and self-referential parents", () => { + const child = row({ key: "agent:main:child", parentSessionKey: "agent:main:missing" }); + expect(resolveChatPaneParentSession(child, [child])).toBeNull(); + expect( + resolveChatPaneParentSession({ ...child, parentSessionKey: child.key }, [child]), + ).toBeNull(); + }); +}); + describe("chat pane workspace chip icon", () => { async function mountChip(workspaceIcon: ChatPaneHeaderProps["workspaceIcon"]) { const { container } = mount({ workspaceIcon }); diff --git a/ui/src/pages/chat/components/chat-pane-header.ts b/ui/src/pages/chat/components/chat-pane-header.ts index 64a695a06436..dd5cffadaa25 100644 --- a/ui/src/pages/chat/components/chat-pane-header.ts +++ b/ui/src/pages/chat/components/chat-pane-header.ts @@ -22,9 +22,19 @@ import "../../../components/workspace-icon.ts"; import "../../../components/web-awesome.ts"; import { t } from "../../../i18n/index.ts"; import { formatRelativeTimestamp } from "../../../lib/format.ts"; +import { resolveSessionDisplayName } from "../../../lib/session-display.ts"; +import { + areUiSessionKeysEquivalent, + resolveUiSessionNavigationParentKey, +} from "../../../lib/sessions/session-key.ts"; export type ChatPaneHeaderAction = "reveal" | "copy-path" | "copy-branch"; +type ChatPaneParentSession = { + key: string; + title: string; +}; + type ChatPaneHeaderProps = { paneId: string; narrow: boolean; @@ -40,6 +50,7 @@ type ChatPaneHeaderProps = { workspaceLabel: string | null; /** Gateway-resolved project icon for the chip; absent keeps the folder glyph. */ workspaceIcon: { routeUrl: string; authTokens: readonly string[]; authReady: boolean } | null; + parentSession: ChatPaneParentSession | null; branch: string | null; branches: SessionBranch[]; branchSwitchDisabledReason: string | null; @@ -66,6 +77,7 @@ type ChatPaneHeaderProps = { onCancelRename: () => void; onMenuOpenChange: (open: boolean) => void; onMenuAction: (action: ChatPaneHeaderAction) => void; + onOpenParentSession: (sessionKey: string) => void; onBranchSelect: (leafEntryId: string) => void; onOpenSplitView?: () => void; onSplitDown?: (paneId: string) => void; @@ -124,11 +136,23 @@ export function resolveChatPaneWorkspace(params: { return { root, label }; } +export function resolveChatPaneParentSession( + session: GatewaySessionRow | undefined, + sessions: readonly GatewaySessionRow[], +): ChatPaneParentSession | null { + const parentKey = resolveUiSessionNavigationParentKey(session); + if (!parentKey || (session && areUiSessionKeysEquivalent(parentKey, session.key))) { + return null; + } + const parent = sessions.find((row) => areUiSessionKeysEquivalent(row.key, parentKey)); + return parent ? { key: parent.key, title: resolveSessionDisplayName(parent.key, parent) } : null; +} + /** * Header identity trail: which project, then which session inside it. Segments * and separators are rendered from one list so a further segment — the parent - * session of a nested thread (#121700), yielding project / parent / child — - * slots in without moving the project chip or the title. + * session of a nested thread, yielding project / parent / child — slots in + * without moving the project chip or the title. */ function renderIdentityCrumbs( props: ChatPaneHeaderProps, @@ -138,6 +162,10 @@ function renderIdentityCrumbs( ) { const projectCrumb = renderProjectCrumb(props, copied, copyPathLabel, copyBranchLabel); const segments: TemplateResult[] = projectCrumb ? [projectCrumb] : []; + const parentCrumb = renderParentSessionCrumb(props); + if (parentCrumb) { + segments.push(parentCrumb); + } segments.push(renderSessionCrumb(props)); return html`
@@ -151,6 +179,23 @@ function renderIdentityCrumbs( `; } +function renderParentSessionCrumb(props: ChatPaneHeaderProps): TemplateResult | null { + const parent = props.parentSession; + if (!parent) { + return null; + } + const label = t("chat.sessionHeader.openParent", { title: parent.title }); + return html``; +} + function renderSessionCrumb(props: ChatPaneHeaderProps) { if (props.editing) { return html`