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
This commit is contained in:
Peter Steinberger
2026-07-20 08:22:04 -07:00
committed by GitHub
parent 5197428add
commit f6f484fb1c
2 changed files with 143 additions and 10 deletions
@@ -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<void>;
};
const SESSION_KEY = "agent:main:current";
function createDiscussionPane(params: {
info: SessionDiscussionInfo | Promise<SessionDiscussionInfo>;
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<SessionDiscussionInfo>((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();
});
});
+51 -10
View File
@@ -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<typeof this.state>,
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" &&