mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" &&
|
||||
|
||||
Reference in New Issue
Block a user