fix(ui): collapse discussion panel chrome to a single header (#112574)

This commit is contained in:
Peter Steinberger
2026-07-22 01:07:16 -07:00
committed by GitHub
parent 5610f24fc8
commit db2ea84e9a
7 changed files with 140 additions and 61 deletions
-1
View File
@@ -4289,7 +4289,6 @@ export const en: TranslationMap = {
loading: "Loading discussion…",
opening: "Opening discussion…",
requiresWriteAccess: "Operator write access is required to open this discussion.",
opened: "Session discussion",
openExternal: "Open discussion in a new tab",
frameTitle: "Session discussion",
unavailable: "This discussion cannot be embedded.",
@@ -6,6 +6,7 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { SessionCapability } from "../../lib/sessions/index.ts";
import { createTestChatPane, type TestChatPane } from "./chat-pane.test-support.ts";
import type { SidebarContent } from "./components/chat-sidebar.ts";
import "./components/chat-sidebar.ts";
type DiscussionTestPane = TestChatPane & {
probeSessionDiscussion: (sessionKey: string) => Promise<void>;
@@ -53,6 +54,42 @@ describe("chat pane session discussion auto-show", () => {
expect(content && "sessionKey" in content ? content.sessionKey : null).toBe(SESSION_KEY);
});
it("shows the reported external URL in the outer sidebar header", async () => {
const openUrl = "https://clack.example/channels/c1";
const { pane, state, handleOpenSidebar } = createDiscussionPane({
info: {
state: "open",
embedUrl: "https://clack.example/embed/c1",
openUrl,
},
});
await pane.probeSessionDiscussion(SESSION_KEY);
const content = handleOpenSidebar.mock.calls[0]?.[0];
if (!content || content.kind !== "session-discussion") {
throw new Error("expected a session discussion sidebar");
}
content.onStateChange(SESSION_KEY, "open", openUrl);
const panel = document.createElement("openclaw-chat-detail-panel") as HTMLElement & {
content: SidebarContent;
onClose: () => void;
updateComplete: Promise<unknown>;
};
panel.content = state.sidebarContent as SidebarContent;
panel.onClose = vi.fn();
document.body.append(panel);
await panel.updateComplete;
const external = panel.querySelector<HTMLAnchorElement>(".sidebar-header a");
expect(external?.href).toBe(openUrl);
expect(external?.target).toBe("_blank");
expect(external?.rel).toBe("noopener");
expect(panel.querySelector(".session-discussion__header")).toBeNull();
panel.remove();
});
it("does not auto-show for a merely available discussion", async () => {
const { pane, handleOpenSidebar } = createDiscussionPane({
info: { state: "available" },
+26 -2
View File
@@ -470,6 +470,7 @@ class ChatPane extends OpenClawLightDomElement {
private swarmBoardSnapshotBase: BoardSnapshot | null = null;
private swarmBoardSnapshotRequest = 0;
private readonly sessionDiscussionStates = new Map<string, SessionDiscussionState>();
private readonly sessionDiscussionOpenUrls = new Map<string, string | null>();
private readonly sessionDiscussionProbes = new Set<string>();
private headerRenameInitialLabel: string | null = null;
private headerRenameInitialValue = "";
@@ -923,6 +924,7 @@ class ChatPane extends OpenClawLightDomElement {
// Close old-session portals and listener-owning popovers before the next
// render detaches their DOM and makes owner-scoped cleanup impossible.
resetChatThreadPresentationState(this.paneId, this);
this.sessionDiscussionOpenUrls.clear();
const previousSessionKey = state.sessionKey;
// An in-progress title edit belongs to the previous session; committing
// it against the newly routed row would rename the wrong session.
@@ -2562,6 +2564,7 @@ class ChatPane extends OpenClawLightDomElement {
this.taskSuggestionBusyIds.clear();
this.taskSuggestionOperations.clear();
this.sessionDiscussionStates.clear();
this.sessionDiscussionOpenUrls.clear();
this.resetSessionPullRequests();
this.resetOlderMessagesViewport();
state.chatLoading = false;
@@ -2992,6 +2995,7 @@ class ChatPane extends OpenClawLightDomElement {
kind: "session-discussion",
sessionKey,
canOpen,
openUrl: this.sessionDiscussionOpenUrls.get(sessionKey) ?? null,
loadInfo: async (key) => {
if (!state.connected || !state.client) {
throw new Error(t("chat.sessionDiscussion.disconnected"));
@@ -3008,13 +3012,20 @@ class ChatPane extends OpenClawLightDomElement {
sessionKey: key,
});
},
onStateChange: (key, discussionState) => {
onStateChange: (key, discussionState, openUrl) => {
// Panels created under a previous connection may report late; their
// state belongs to the old provider and must not touch the new cache.
if (contentGeneration !== this.connectionGeneration) {
return;
}
this.sessionDiscussionStates.set(key, discussionState);
const isCurrentSession = state.sessionKey.trim() === key;
if (isCurrentSession) {
this.sessionDiscussionOpenUrls.set(key, openUrl);
}
if (discussionState === "none") {
this.sessionDiscussionOpenUrls.delete(key);
}
const current = state.sidebarContent;
if (
discussionState === "none" &&
@@ -3024,6 +3035,13 @@ class ChatPane extends OpenClawLightDomElement {
state.handleCloseSidebar();
return;
}
if (
isCurrentSession &&
current?.kind === "session-discussion" &&
current.sessionKey === key
) {
state.sidebarContent = { ...current, openUrl };
}
state.requestUpdate();
},
};
@@ -3607,7 +3625,13 @@ class ChatPane extends OpenClawLightDomElement {
canvasPluginSurfaceUrl: state.hello?.pluginSurfaceUrls?.canvas ?? null,
boardProvider: board.provider,
onOpenSidebar: state.handleOpenSidebar,
onCloseSidebar: state.handleCloseSidebar,
onCloseSidebar: () => {
const content = state.sidebarContent;
if (content?.kind === "session-discussion") {
this.sessionDiscussionOpenUrls.delete(content.sessionKey);
}
state.handleCloseSidebar();
},
onSplitRatioChange: state.handleSplitRatioChange,
assistantName: state.assistantName,
assistantAvatar: state.assistantAvatar,
+30 -10
View File
@@ -91,6 +91,7 @@ type SessionDiscussionSidebarContent = {
kind: "session-discussion";
sessionKey: string;
canOpen: boolean;
openUrl?: string | null;
loadInfo: SessionDiscussionInfoLoader;
openDiscussion: SessionDiscussionOpener;
onStateChange: SessionDiscussionStateListener;
@@ -523,6 +524,8 @@ function renderMarkdownSidebar(props: MarkdownSidebarProps) {
props.allowExternalEmbedUrls ?? false,
)
: null;
const discussionOpenUrl =
content?.kind === "session-discussion" ? (content.openUrl ?? null) : null;
const title =
content?.kind === "canvas"
? content.title?.trim() || "Render Preview"
@@ -541,16 +544,33 @@ function renderMarkdownSidebar(props: MarkdownSidebarProps) {
<div class="sidebar-panel">
<div class="sidebar-header">
<div class="sidebar-title">${title}</div>
<openclaw-tooltip .content=${t("chat.detailPanel.close")}>
<button
@click=${props.onClose}
class="btn"
type="button"
aria-label=${t("chat.detailPanel.close")}
>
${icons.x}
</button>
</openclaw-tooltip>
<div class="sidebar-header__actions">
${discussionOpenUrl
? html`
<openclaw-tooltip .content=${t("chat.sessionDiscussion.openExternal")}>
<a
class="btn btn--ghost btn--icon"
href=${discussionOpenUrl}
target="_blank"
rel="noopener"
aria-label=${t("chat.sessionDiscussion.openExternal")}
>
${icons.externalLink}
</a>
</openclaw-tooltip>
`
: nothing}
<openclaw-tooltip .content=${t("chat.detailPanel.close")}>
<button
@click=${props.onClose}
class="btn"
type="button"
aria-label=${t("chat.detailPanel.close")}
>
${icons.x}
</button>
</openclaw-tooltip>
</div>
</div>
<div
class="sidebar-content ${content?.kind === "session-discussion"
@@ -41,7 +41,7 @@ function mount(params: {
}
describe("session discussion panel", () => {
it("automatically opens an available discussion and renders both URLs", async () => {
it("automatically opens an available discussion without a redundant header", async () => {
const loadInfo = vi.fn<SessionDiscussionInfoLoader>().mockResolvedValue({
state: "available",
});
@@ -50,7 +50,8 @@ describe("session discussion panel", () => {
embedUrl: "https://discussion.example/embed/thread",
openUrl: "https://discussion.example/thread",
});
const panel = mount({ loadInfo, openDiscussion });
const onStateChange = vi.fn<SessionDiscussionStateListener>();
const panel = mount({ loadInfo, openDiscussion, onStateChange });
await vi.waitFor(() => {
expect(panel.querySelector("iframe")?.getAttribute("src")).toBe(
@@ -60,11 +61,36 @@ describe("session discussion panel", () => {
"allow-forms allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts",
);
});
const external = panel.querySelector<HTMLAnchorElement>("a");
expect(loadInfo).toHaveBeenCalledTimes(1);
expect(openDiscussion).toHaveBeenCalledTimes(1);
expect(openDiscussion).toHaveBeenCalledWith("agent:main:first");
expect(external?.href).toBe("https://discussion.example/thread");
expect(onStateChange).toHaveBeenLastCalledWith(
"agent:main:first",
"open",
"https://discussion.example/thread",
);
expect(panel.querySelector(".session-discussion__header")).toBeNull();
expect(panel.querySelector("a")).toBeNull();
});
it("offers the valid open URL when a same-origin embed is rejected", async () => {
const openUrl = "https://discussion.example/thread";
const panel = mount({
loadInfo: vi.fn().mockResolvedValue({
state: "open",
embedUrl: new URL("/embed/thread", window.location.origin).href,
openUrl,
}),
openDiscussion: vi.fn(),
});
await vi.waitFor(() => {
expect(panel.textContent).toContain("This discussion cannot be embedded");
});
const external = panel.querySelector<HTMLAnchorElement>("a");
expect(panel.querySelector("iframe")).toBeNull();
expect(external?.textContent).toContain("Open discussion in a new tab");
expect(external?.href).toBe(openUrl);
expect(external?.target).toBe("_blank");
expect(external?.rel).toBe("noopener");
});
@@ -133,7 +159,7 @@ describe("session discussion panel", () => {
await vi.waitFor(() => {
expect(loadInfo).toHaveBeenNthCalledWith(2, "agent:main:second");
expect(onStateChange).toHaveBeenLastCalledWith("agent:main:second", "none");
expect(onStateChange).toHaveBeenLastCalledWith("agent:main:second", "none", null);
});
expect(panel.querySelector("button")).toBeNull();
expect(panel.querySelector("iframe")).toBeNull();
@@ -4,8 +4,6 @@ import type {
SessionDiscussionInfo,
SessionDiscussionState,
} from "../../../../../packages/gateway-protocol/src/index.js";
import { icons } from "../../../components/icons.ts";
import "../../../components/tooltip.ts";
import { t } from "../../../i18n/index.ts";
import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts";
@@ -14,6 +12,7 @@ export type SessionDiscussionOpener = (sessionKey: string) => Promise<SessionDis
export type SessionDiscussionStateListener = (
sessionKey: string,
discussionState: SessionDiscussionState,
openUrl: string | null,
) => void;
function resolveDiscussionUrl(value: string | undefined): string | null {
@@ -79,7 +78,7 @@ class SessionDiscussionPanel extends OpenClawLightDomElement {
return;
}
this.info = info;
this.onStateChange?.(requestKey, info.state);
this.onStateChange?.(requestKey, info.state, resolveDiscussionUrl(info.openUrl));
}
private async refresh(): Promise<void> {
@@ -145,24 +144,6 @@ class SessionDiscussionPanel extends OpenClawLightDomElement {
const openUrl = resolveDiscussionUrl(info.openUrl);
return html`
<div class="session-discussion__open">
<div class="session-discussion__header">
<span>${t("chat.sessionDiscussion.opened")}</span>
${openUrl
? html`
<openclaw-tooltip .content=${t("chat.sessionDiscussion.openExternal")}>
<a
class="btn btn--ghost btn--icon session-discussion__external"
href=${openUrl}
target="_blank"
rel="noopener"
aria-label=${t("chat.sessionDiscussion.openExternal")}
>
${icons.externalLink}
</a>
</openclaw-tooltip>
`
: nothing}
</div>
${embedUrl
? html`
<iframe
@@ -173,7 +154,12 @@ class SessionDiscussionPanel extends OpenClawLightDomElement {
></iframe>
`
: html`<div class="session-discussion__empty">
${t("chat.sessionDiscussion.unavailable")}
<span>${t("chat.sessionDiscussion.unavailable")}</span>
${openUrl
? html`<a class="session-link" href=${openUrl} target="_blank" rel="noopener">
${t("chat.sessionDiscussion.openExternal")}
</a>`
: nothing}
</div>`}
</div>
`;
+8 -21
View File
@@ -1014,6 +1014,12 @@
line-height: 1;
}
.sidebar-header__actions {
display: flex;
align-items: center;
gap: 4px;
}
.sidebar-title {
font-weight: 600;
font-size: var(--control-ui-text-md);
@@ -1049,8 +1055,10 @@ openclaw-session-discussion {
.session-discussion__empty {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 24px;
color: var(--muted);
}
@@ -1063,27 +1071,6 @@ openclaw-session-discussion {
min-height: 0;
}
.session-discussion__header {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 36px;
padding: 4px 8px 4px 12px;
border-bottom: 1px solid var(--border);
color: var(--muted);
font-size: var(--control-ui-text-sm);
}
.session-discussion__external {
width: 28px;
min-width: 28px;
height: 28px;
min-height: 28px;
padding: 5px;
}
.session-discussion__frame {
flex: 1;
width: 100%;