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
This commit is contained in:
Vyctor H. Brzezowski
2026-08-12 00:18:11 -03:00
committed by GitHub
parent 38a5390e2e
commit fe1dafbada
8 changed files with 294 additions and 8 deletions
+36 -2
View File
@@ -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<HTMLElement>(".chat-pane__parent-session")!;
const child = root.querySelector<HTMLElement>(".chat-pane__session-title")!;
const parentText = root.querySelector<HTMLElement>(".chat-pane__parent-session-text")!;
const childText = root.querySelector<HTMLElement>(".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);
}
+1
View File
@@ -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}",
+5
View File
@@ -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,
@@ -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<HTMLButtonElement>(".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"),
@@ -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(
`<!doctype html><html><head><style>${readUiCss()}\n${splitViewCss}</style></head><body>
<div class="chat-split-view__cell" style="width: 640px;">
<div class="chat-pane__header">
<div class="chat-pane__crumbs">
<wa-dropdown class="chat-pane__workspace-menu">
<button class="chat-pane__workspace-chip" type="button">
${iconSvg()}<span>openclaw</span>
</button>
</wa-dropdown>
<span class="chat-pane__crumb-sep" aria-hidden="true">/</span>
<button class="chat-pane__parent-session" type="button">
<span class="chat-pane__parent-session-text">Release preparation with a long parent name</span>
</button>
<span class="chat-pane__crumb-sep" aria-hidden="true">/</span>
<button class="chat-pane__session-title chat-pane__session-title-button" type="button">
<span class="chat-pane__session-title-text">Implementation details with a long child name</span>
</button>
</div>
<div class="chat-pane__actions">
<button class="btn btn--ghost btn--icon chat-icon-btn chat-pane__close-pane" type="button">X</button>
</div>
</div>
</div>
</body></html>`,
);
const readState = () =>
page.locator(".chat-pane__header").evaluate((header) => {
const separators = [...header.querySelectorAll<HTMLElement>(".chat-pane__crumb-sep")];
const parentText = header.querySelector<HTMLElement>(".chat-pane__parent-session-text")!;
const childText = header.querySelector<HTMLElement>(".chat-pane__session-title-text")!;
const parent = header.querySelector<HTMLElement>(".chat-pane__parent-session")!;
const child = header.querySelector<HTMLElement>(".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 {
@@ -15,6 +15,7 @@ import {
import {
canRevealSessionWorkspace,
renderChatPaneHeader,
resolveChatPaneParentSession,
resolveChatPaneWorkspace,
} from "./chat-pane-header.ts";
@@ -80,6 +81,7 @@ function mount(patch: Partial<ChatPaneHeaderProps> = {}) {
workspaceRoot: "/repo/openclaw",
workspaceLabel: "openclaw",
workspaceIcon: null,
parentSession: null,
branch: "feature/header",
branches: [],
branchSwitchDisabledReason: null,
@@ -100,6 +102,7 @@ function mount(patch: Partial<ChatPaneHeaderProps> = {}) {
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<HTMLButtonElement>(".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 });
@@ -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`
<div class="chat-pane__crumbs">
@@ -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`<button
class="chat-pane__parent-session"
type="button"
title=${label}
aria-label=${label}
@click=${() => props.onOpenParentSession(parent.key)}
>
<span class="chat-pane__parent-session-text">${parent.title}</span>
</button>`;
}
function renderSessionCrumb(props: ChatPaneHeaderProps) {
if (props.editing) {
return html`<input
+32 -4
View File
@@ -201,7 +201,8 @@ openclaw-chat-pane {
font-size: 12px;
}
.chat-pane__session-title-button {
.chat-pane__session-title-button,
.chat-pane__parent-session {
display: block;
/* Hover padding is drawn outside the text box: the plain-span title (catalog
and rename-disabled panes) has none, and without the pull-back the same
@@ -212,16 +213,43 @@ openclaw-chat-pane {
border-radius: var(--radius-sm);
background: transparent;
text-align: left;
}
.chat-pane__session-title-button {
cursor: text;
}
.chat-pane__session-title-button:hover,
.chat-pane__session-title-button:focus-visible {
.chat-pane__session-title-button:focus-visible,
.chat-pane__parent-session:hover,
.chat-pane__parent-session:focus-visible {
background: color-mix(in srgb, var(--text) 7%, transparent);
outline: none;
}
.chat-pane__session-title-text {
.chat-pane__parent-session {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
color: var(--muted);
font-size: 12px;
cursor: var(--cursor-action);
}
.chat-pane__parent-session:hover,
.chat-pane__parent-session:focus-visible {
color: var(--text);
}
/* Keep nested identity contextual rather than letting it become the header.
The two titles share at most half the container after separator spacing. */
.chat-pane__crumbs:has(.chat-pane__parent-session) .chat-pane__parent-session,
.chat-pane__crumbs:has(.chat-pane__parent-session) .chat-pane__session-title {
max-width: min(18ch, calc((50cqw - 20px) / 2));
}
.chat-pane__session-title-text,
.chat-pane__parent-session-text {
display: block;
overflow: hidden;
white-space: nowrap;
@@ -622,7 +650,7 @@ openclaw-chat-pane {
/* The project name is gone at this width, so the separator would trail an
icon-only pill and read as punctuation with nothing on its left. */
.chat-pane__crumb-sep {
.chat-pane__workspace-menu + .chat-pane__crumb-sep {
display: none;
}