mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
fix(ui): collapse discussion panel chrome to a single header (#112574)
This commit is contained in:
committed by
GitHub
parent
5610f24fc8
commit
db2ea84e9a
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user