diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 807d997ebe29..928cc86a011e 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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.", diff --git a/ui/src/pages/chat/chat-pane.session-discussion.test.ts b/ui/src/pages/chat/chat-pane.session-discussion.test.ts index 9c7c3431df1a..72a0f18dadec 100644 --- a/ui/src/pages/chat/chat-pane.session-discussion.test.ts +++ b/ui/src/pages/chat/chat-pane.session-discussion.test.ts @@ -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; @@ -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; + }; + panel.content = state.sidebarContent as SidebarContent; + panel.onClose = vi.fn(); + document.body.append(panel); + await panel.updateComplete; + + const external = panel.querySelector(".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" }, diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index 2811321b52e5..a825be3e4faa 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -470,6 +470,7 @@ class ChatPane extends OpenClawLightDomElement { private swarmBoardSnapshotBase: BoardSnapshot | null = null; private swarmBoardSnapshotRequest = 0; private readonly sessionDiscussionStates = new Map(); + private readonly sessionDiscussionOpenUrls = new Map(); private readonly sessionDiscussionProbes = new Set(); 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, diff --git a/ui/src/pages/chat/components/chat-sidebar.ts b/ui/src/pages/chat/components/chat-sidebar.ts index 7fa65d90bf44..0e455947d94c 100644 --- a/ui/src/pages/chat/components/chat-sidebar.ts +++ b/ui/src/pages/chat/components/chat-sidebar.ts @@ -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) {