From f6f484fb1c28bb425c5b64b2ea199fa98ff9367b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 20 Jul 2026 08:22:04 -0700 Subject: [PATCH] fix(ui): auto-show the discussion sidebar when a session has an open discussion (#111871) * fix(ui): auto-show the discussion sidebar when a session has an open discussion * docs(ui): document why probe resolution is the only auto-show hook --- .../chat/chat-pane.session-discussion.test.ts | 92 +++++++++++++++++++ ui/src/pages/chat/chat-pane.ts | 61 ++++++++++-- 2 files changed, 143 insertions(+), 10 deletions(-) create mode 100644 ui/src/pages/chat/chat-pane.session-discussion.test.ts diff --git a/ui/src/pages/chat/chat-pane.session-discussion.test.ts b/ui/src/pages/chat/chat-pane.session-discussion.test.ts new file mode 100644 index 000000000000..9c7c3431df1a --- /dev/null +++ b/ui/src/pages/chat/chat-pane.session-discussion.test.ts @@ -0,0 +1,92 @@ +/* @vitest-environment jsdom */ + +import { describe, expect, it, vi } from "vitest"; +import type { SessionDiscussionInfo } from "../../../../packages/gateway-protocol/src/index.js"; +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"; + +type DiscussionTestPane = TestChatPane & { + probeSessionDiscussion: (sessionKey: string) => Promise; +}; + +const SESSION_KEY = "agent:main:current"; + +function createDiscussionPane(params: { + info: SessionDiscussionInfo | Promise; + sidebarOpen?: boolean; +}) { + const request = vi.fn().mockImplementation(async (method: string) => { + if (method === "session.discussion.info") { + return await params.info; + } + throw new Error(`unexpected method ${method}`); + }); + const client = { request } as unknown as GatewayBrowserClient; + const created = createTestChatPane({ client, sessions: {} as SessionCapability }); + const pane = created.pane as DiscussionTestPane; + const state = created.state; + (pane.context.gateway.snapshot as { hello: unknown }).hello = { + features: { methods: ["session.discussion.info", "session.discussion.open"] }, + }; + const handleOpenSidebar = vi.fn((content: SidebarContent) => { + state.sidebarContent = content; + state.sidebarOpen = true; + }); + state.handleOpenSidebar = handleOpenSidebar; + state.sidebarOpen = params.sidebarOpen ?? false; + return { pane, state, handleOpenSidebar, request }; +} + +describe("chat pane session discussion auto-show", () => { + it("auto-shows the sidebar when the probe reports an open discussion", async () => { + const { pane, handleOpenSidebar } = createDiscussionPane({ + info: { state: "open", embedUrl: "https://clack.example/embed/c1" }, + }); + + await pane.probeSessionDiscussion(SESSION_KEY); + + expect(handleOpenSidebar).toHaveBeenCalledTimes(1); + const content = handleOpenSidebar.mock.calls[0]?.[0]; + expect(content?.kind).toBe("session-discussion"); + expect(content && "sessionKey" in content ? content.sessionKey : null).toBe(SESSION_KEY); + }); + + it("does not auto-show for a merely available discussion", async () => { + const { pane, handleOpenSidebar } = createDiscussionPane({ + info: { state: "available" }, + }); + + await pane.probeSessionDiscussion(SESSION_KEY); + + expect(handleOpenSidebar).not.toHaveBeenCalled(); + }); + + it("does not steal a sidebar that is already open", async () => { + const { pane, handleOpenSidebar } = createDiscussionPane({ + info: { state: "open", embedUrl: "https://clack.example/embed/c1" }, + sidebarOpen: true, + }); + + await pane.probeSessionDiscussion(SESSION_KEY); + + expect(handleOpenSidebar).not.toHaveBeenCalled(); + }); + + it("does not auto-show when the pane switched sessions before the probe resolved", async () => { + let resolveInfo!: (value: SessionDiscussionInfo) => void; + const { pane, state, handleOpenSidebar } = createDiscussionPane({ + info: new Promise((resolve) => { + resolveInfo = resolve; + }), + }); + + const probe = pane.probeSessionDiscussion(SESSION_KEY); + state.sessionKey = "agent:main:other"; + resolveInfo({ state: "open", embedUrl: "https://clack.example/embed/c1" }); + await probe; + + expect(handleOpenSidebar).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index 19965b403f3f..fd8fe85afa2e 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -2757,6 +2757,7 @@ class ChatPane extends OpenClawLightDomElement { return; } this.sessionDiscussionStates.set(sessionKey, info.state); + this.maybeAutoShowSessionDiscussion(sessionKey, info.state); this.requestUpdate(); } catch { // Leave unprobed: the action stays hidden and a later switch retries. @@ -2774,19 +2775,38 @@ class ChatPane extends OpenClawLightDomElement { } } - private renderSessionDiscussionAction() { + // An "open" probe result means this session already has a bound discussion; + // surface it immediately instead of hiding live chat behind the toggle. + // Probe resolution is the only hook needed: willUpdate deletes the target + // key's cached state on every session switch (and reconnect clears all), so + // each activation resolves a fresh probe and reaches this. Within one + // activation the cache dedupes — closing the sidebar sticks, and an + // already-open sidebar is never stolen. + private maybeAutoShowSessionDiscussion( + sessionKey: string, + discussionState: SessionDiscussionState, + ) { const state = this.state; - const sessionKey = state?.sessionKey.trim() ?? ""; - const known = sessionKey ? this.sessionDiscussionStates.get(sessionKey) : undefined; if ( - !state?.connected || - !state.client || - !sessionKey || - known === undefined || - known === "none" || - isGatewayMethodAdvertised(this.context.gateway.snapshot, "session.discussion.info") !== true + discussionState !== "open" || + !state || + state.sessionKey.trim() !== sessionKey || + state.sidebarOpen ) { - return nothing; + return; + } + const content = this.buildSessionDiscussionContent(state, sessionKey); + if (content) { + state.handleOpenSidebar(content); + } + } + + private buildSessionDiscussionContent( + state: NonNullable, + sessionKey: string, + ): SidebarContent | null { + if (!state.connected || !state.client) { + return null; } const canOpen = hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null) && @@ -2831,6 +2851,27 @@ class ChatPane extends OpenClawLightDomElement { state.requestUpdate(); }, }; + return content; + } + + private renderSessionDiscussionAction() { + const state = this.state; + const sessionKey = state?.sessionKey.trim() ?? ""; + const known = sessionKey ? this.sessionDiscussionStates.get(sessionKey) : undefined; + if ( + !state?.connected || + !state.client || + !sessionKey || + known === undefined || + known === "none" || + isGatewayMethodAdvertised(this.context.gateway.snapshot, "session.discussion.info") !== true + ) { + return nothing; + } + const content = this.buildSessionDiscussionContent(state, sessionKey); + if (!content) { + return nothing; + } const active = state.sidebarOpen && state.sidebarContent?.kind === "session-discussion" &&