mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
38a5390e2e
commit
fe1dafbada
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user