feat: generic session discussion seam with Control UI panel (#111337)

* feat: add session discussion panel seam

* fix: keep discussion iframe cookie-capable and changelog release-owned

* test: cover cookie-capable discussion iframe sandbox

* fix: stretch discussion panel host so the embed fills the rail

* fix: keep provider failures retryable and probe discussion availability before showing the action

* fix: block same-origin discussion embeds, show action on catalog sessions, close stale panel on reconnect

* fix: scope discussion probes and panel callbacks to the issuing connection

* fix: dedupe in-flight discussion probes per session

* fix: retry superseded discussion probes and key-scope panel results

* fix: regenerate Swift protocol models and extend advertised-method expectations

* style: format chat-pane-header

* fix: regenerate Kotlin protocol models and date discussion methods in the 2026.7 train

* chore: restore release-owned changelog to main state

* chore: keep changelog untouched relative to merge-base
This commit is contained in:
Peter Steinberger
2026-07-19 09:47:47 -07:00
committed by GitHub
parent 7aedca21b2
commit 197c4f5a04
31 changed files with 1247 additions and 73 deletions
@@ -456,6 +456,8 @@ enum class GatewayMethod(
ApprovalHistory("approval.history"),
PluginSurfaceRefresh("plugin.surface.refresh"),
ConversationsList("conversations.list"),
SessionDiscussionInfo("session.discussion.info"),
SessionDiscussionOpen("session.discussion.open"),
}
enum class GatewayEvent(
@@ -93,6 +93,12 @@ public enum SessionPlacementState: String, Codable, Sendable {
case failed = "failed"
}
public enum SessionDiscussionState: String, Codable, Sendable {
case none = "none"
case available = "available"
case _open = "open"
}
public enum SessionFileKind: String, Codable, Sendable {
case modified = "modified"
case read = "read"
@@ -5024,6 +5030,100 @@ public struct SessionsReclaimResult: Codable, Sendable {
}
}
public struct SessionDiscussionInfo: Codable, Sendable {
public let state: SessionDiscussionState
public let embedurl: String?
public let openurl: String?
public init(
state: SessionDiscussionState,
embedurl: String? = nil,
openurl: String? = nil)
{
self.state = state
self.embedurl = embedurl
self.openurl = openurl
}
private enum CodingKeys: String, CodingKey {
case state
case embedurl = "embedUrl"
case openurl = "openUrl"
}
}
public struct SessionDiscussionInfoParams: Codable, Sendable {
public let sessionkey: String
public init(
sessionkey: String)
{
self.sessionkey = sessionkey
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
}
}
public struct SessionDiscussionInfoResult: Codable, Sendable {
public let state: SessionDiscussionState
public let embedurl: String?
public let openurl: String?
public init(
state: SessionDiscussionState,
embedurl: String? = nil,
openurl: String? = nil)
{
self.state = state
self.embedurl = embedurl
self.openurl = openurl
}
private enum CodingKeys: String, CodingKey {
case state
case embedurl = "embedUrl"
case openurl = "openUrl"
}
}
public struct SessionDiscussionOpenParams: Codable, Sendable {
public let sessionkey: String
public init(
sessionkey: String)
{
self.sessionkey = sessionkey
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
}
}
public struct SessionDiscussionOpenResult: Codable, Sendable {
public let state: SessionDiscussionState
public let embedurl: String?
public let openurl: String?
public init(
state: SessionDiscussionState,
embedurl: String? = nil,
openurl: String? = nil)
{
self.state = state
self.embedurl = embedurl
self.openurl = openurl
}
private enum CodingKeys: String, CodingKey {
case state
case embedurl = "embedUrl"
case openurl = "openUrl"
}
}
public struct SessionsCompactionListParams: Codable, Sendable {
public let key: String
public let agentid: String?
@@ -273,6 +273,7 @@ e9c4398b04d04fead0e46ec618589d8f597a0b6087805ab132138e941b76e190 module/sandbox
50beebb77e461deaccdbff038f6a461dff1d5773426322dc6d7991f6e05a7c37 module/self-hosted-provider-setup
250476d121ffa4ed67d497c59d9c7bb1886973e92ebed39325a02609217bade0 module/session-binding-runtime
ed35a448c8f7650acb34739a591334267371f1ffe352505fc2808330d78580bd module/session-catalog
31b785e74f1f8f56241b7756ef6a5d86199c5ce177cbb1c234a261866972f270 module/session-discussion
f07839f5b8929a179857a0b4b89f8448864078cd5972dd53f9a30e50bf55408d module/session-key-runtime
f59099aa2d536246d4b1297f01bcea66796351a9259debdd45909afeb7a42d86 module/session-store-runtime
b1d0a76337122cb9dbb0c89fbb8bfacc1a88ce689270d3d684019c9a03ce669a module/session-transcript-hit
+7
View File
@@ -86,6 +86,13 @@ deprecated re-export barrels are tracked in
The `register(api)` callback receives an `OpenClawPluginApi` object with these
methods:
Plugins that provide an external team-chat surface for a session can register
the single process-wide provider exported by
`openclaw/plugin-sdk/session-discussion`. Its `info({ sessionKey })` method
reports whether a discussion is unavailable, ready to open, or already open;
`open({ sessionKey })` creates or resolves the discussion and returns its embed
and external URLs. Registering another provider replaces the current provider.
### Capability registration
| Method | What it registers |
+4
View File
@@ -1028,6 +1028,10 @@
"types": "./dist/plugin-sdk/session-catalog.d.ts",
"default": "./dist/plugin-sdk/session-catalog.js"
},
"./plugin-sdk/session-discussion": {
"types": "./dist/plugin-sdk/session-discussion.d.ts",
"default": "./dist/plugin-sdk/session-discussion.js"
},
"./plugin-sdk/session-key-runtime": {
"types": "./dist/plugin-sdk/session-key-runtime.d.ts",
"default": "./dist/plugin-sdk/session-key-runtime.js"
+22
View File
@@ -465,6 +465,12 @@ import {
SessionsSearchResultSchema,
SessionsSendParamsSchema,
SessionsUsageParamsSchema,
SessionDiscussionInfoParamsSchema,
SessionDiscussionInfoResultSchema,
SessionDiscussionInfoSchema,
SessionDiscussionOpenParamsSchema,
SessionDiscussionOpenResultSchema,
SessionDiscussionStateSchema,
TaskSuggestionEventSchema,
TaskSuggestionResolutionSchema,
TaskSuggestionSchema,
@@ -769,6 +775,10 @@ export const validateSessionsBranchesSwitchParams = lazyCompile(SessionsBranches
export const validateSessionsRewindParams = lazyCompile(SessionsRewindParamsSchema);
export const validateSessionsForkParams = lazyCompile(SessionsForkParamsSchema);
export const validateSessionsUsageParams = lazyCompile(SessionsUsageParamsSchema);
export const validateSessionDiscussionInfoParams = lazyCompile(SessionDiscussionInfoParamsSchema);
export const validateSessionDiscussionInfoResult = lazyCompile(SessionDiscussionInfoResultSchema);
export const validateSessionDiscussionOpenParams = lazyCompile(SessionDiscussionOpenParamsSchema);
export const validateSessionDiscussionOpenResult = lazyCompile(SessionDiscussionOpenResultSchema);
export const validateTaskSuggestionsListParams = lazyCompile(TaskSuggestionsListParamsSchema);
export const validateTaskSuggestionsCreateParams = lazyCompile(TaskSuggestionsCreateParamsSchema);
export const validateTaskSuggestionsAcceptParams = lazyCompile(TaskSuggestionsAcceptParamsSchema);
@@ -1122,6 +1132,12 @@ export {
SessionsGroupsMutationResultSchema,
SessionsCompactParamsSchema,
SessionsUsageParamsSchema,
SessionDiscussionStateSchema,
SessionDiscussionInfoSchema,
SessionDiscussionInfoParamsSchema,
SessionDiscussionInfoResultSchema,
SessionDiscussionOpenParamsSchema,
SessionDiscussionOpenResultSchema,
ArtifactSummarySchema,
ArtifactsListParamsSchema,
ArtifactsGetParamsSchema,
@@ -1615,6 +1631,12 @@ export type {
SessionDiffFileStatus,
SessionsDiffParams,
SessionsDiffResult,
SessionDiscussionState,
SessionDiscussionInfo,
SessionDiscussionInfoParams,
SessionDiscussionInfoResult,
SessionDiscussionOpenParams,
SessionDiscussionOpenResult,
ArtifactSummary,
ArtifactsListParams,
ArtifactsListResult,
+1
View File
@@ -36,6 +36,7 @@ export * from "./schema/push.js";
export * from "./schema/questions.js";
export * from "./schema/secrets.js";
export * from "./schema/session-placement.js";
export * from "./schema/session-discussion.js";
export * from "./schema/sessions.js";
export * from "./schema/sessions-catalog.js";
export * from "./schema/skill-history.js";
@@ -433,6 +433,14 @@ import {
SecretsResolveParamsSchema,
SecretsResolveResultSchema,
} from "./secrets.js";
import {
SessionDiscussionInfoParamsSchema,
SessionDiscussionInfoResultSchema,
SessionDiscussionInfoSchema,
SessionDiscussionOpenParamsSchema,
SessionDiscussionOpenResultSchema,
SessionDiscussionStateSchema,
} from "./session-discussion.js";
import { SessionPlacementProtocolSchemas } from "./session-placement.js";
import {
SessionCatalogCapabilitiesSchema,
@@ -747,6 +755,12 @@ export const ProtocolSchemas = {
SessionCompactionCheckpoint: SessionCompactionCheckpointSchema,
SessionOperationEvent: SessionOperationEventSchema,
...SessionPlacementProtocolSchemas,
SessionDiscussionState: SessionDiscussionStateSchema,
SessionDiscussionInfo: SessionDiscussionInfoSchema,
SessionDiscussionInfoParams: SessionDiscussionInfoParamsSchema,
SessionDiscussionInfoResult: SessionDiscussionInfoResultSchema,
SessionDiscussionOpenParams: SessionDiscussionOpenParamsSchema,
SessionDiscussionOpenResult: SessionDiscussionOpenResultSchema,
SessionsCompactionListParams: SessionsCompactionListParamsSchema,
SessionsCompactionGetParams: SessionsCompactionGetParamsSchema,
SessionsCompactionBranchParams: SessionsCompactionBranchParamsSchema,
@@ -0,0 +1,35 @@
// Gateway Protocol schema module defines session discussion validation shapes.
import type { Static } from "typebox";
import { Type } from "typebox";
import { closedObject } from "./closed-object.js";
import { NonEmptyString } from "./primitives.js";
export const SessionDiscussionStateSchema = Type.Union([
Type.Literal("none"),
Type.Literal("available"),
Type.Literal("open"),
]);
export const SessionDiscussionInfoSchema = closedObject({
state: SessionDiscussionStateSchema,
embedUrl: Type.Optional(Type.String()),
openUrl: Type.Optional(Type.String()),
});
export const SessionDiscussionInfoParamsSchema = closedObject({
sessionKey: NonEmptyString,
});
export const SessionDiscussionOpenParamsSchema = closedObject({
sessionKey: NonEmptyString,
});
export const SessionDiscussionInfoResultSchema = SessionDiscussionInfoSchema;
export const SessionDiscussionOpenResultSchema = SessionDiscussionInfoSchema;
export type SessionDiscussionState = Static<typeof SessionDiscussionStateSchema>;
export type SessionDiscussionInfo = Static<typeof SessionDiscussionInfoSchema>;
export type SessionDiscussionInfoParams = Static<typeof SessionDiscussionInfoParamsSchema>;
export type SessionDiscussionOpenParams = Static<typeof SessionDiscussionOpenParamsSchema>;
export type SessionDiscussionInfoResult = Static<typeof SessionDiscussionInfoResultSchema>;
export type SessionDiscussionOpenResult = Static<typeof SessionDiscussionOpenResultSchema>;
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import {
validateSessionDiscussionInfoParams,
validateSessionDiscussionInfoResult,
validateSessionDiscussionOpenParams,
validateSessionDiscussionOpenResult,
} from "./index.js";
describe("session discussion protocol validators", () => {
it.each([
["info", validateSessionDiscussionInfoParams],
["open", validateSessionDiscussionOpenParams],
])("requires a non-empty session key for %s", (_name, validate) => {
expect(validate({ sessionKey: "agent:main:thread" })).toBe(true);
expect(validate({ sessionKey: "" })).toBe(false);
expect(validate({})).toBe(false);
expect(validate({ sessionKey: "thread", extra: true })).toBe(false);
});
it.each([
["info", validateSessionDiscussionInfoResult],
["open", validateSessionDiscussionOpenResult],
])("validates discussion states and optional URLs for %s", (_name, validate) => {
expect(validate({ state: "none" })).toBe(true);
expect(
validate({
state: "open",
embedUrl: "https://chat.example/embed/thread",
openUrl: "https://chat.example/thread",
}),
).toBe(true);
expect(validate({ state: "unknown" })).toBe(false);
expect(validate({ state: "open", extra: true })).toBe(false);
});
});
+1
View File
@@ -225,6 +225,7 @@
"response-limit-runtime",
"session-binding-runtime",
"session-catalog",
"session-discussion",
"session-key-runtime",
"session-store-runtime",
"session-transcript-runtime",
+6 -3
View File
@@ -218,7 +218,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +1: meeting-runtime barrel: browser meeting-bot core behind MeetingPlatformAdapter.
// +1: question-gateway-runtime resolves ask_user choices for channel plugins.
// +1: ingress-effect-once gives drained channels a narrow durable side-effect guard.
332,
// +1: session-discussion binds one external discussion provider to sessions.
333,
env,
),
// ScopeTree adds six channel-policy exports, mirrored by compat, including three functions.
@@ -292,7 +293,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +2: lifecycle-owned prepared model catalog sync and async readers.
// Harvest: retired tuning-knob config types -10.
// Harvest: removed process-global API-provider publication functions -2.
8180,
// +4: session discussion state, info, provider, and registration contracts.
8184,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
@@ -342,7 +344,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +6: outbound echo record/query helpers across channel-outbound and mirrors.
// +2: lifecycle-owned prepared model catalog sync and async readers.
// Harvest: removed process-global API-provider publication functions -2.
4555,
// +1: session discussion provider registration.
4556,
env,
),
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
+2
View File
@@ -78,6 +78,8 @@ describe("method scope resolution", () => {
["sessions.catalog.read", ["operator.read"]],
["sessions.catalog.continue", ["operator.write"]],
["sessions.catalog.archive", ["operator.write"]],
["session.discussion.info", ["operator.read"]],
["session.discussion.open", ["operator.write"]],
["environments.status", ["operator.read"]],
["diagnostics.stability", ["operator.read"]],
["skills.curator.status", ["operator.read"]],
@@ -7,6 +7,8 @@ const CURRENT_TRAIN_METHODS = [
"question.resolve",
"question.get",
"question.list",
"session.discussion.info",
"session.discussion.open",
"terminal.open",
"terminal.input",
"terminal.resize",
+2
View File
@@ -440,6 +440,8 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
{ name: "approval.history", scope: "operator.approvals", since: "2026.7" },
{ name: "plugin.surface.refresh", scope: "operator.read", since: "<=2026.7" },
{ name: "conversations.list", scope: "operator.admin", since: "<=2026.7" },
{ name: "session.discussion.info", scope: "operator.read", since: "2026.7" },
{ name: "session.discussion.open", scope: "operator.write", since: "2026.7" },
] as const;
const CORE_GATEWAY_METHOD_SPEC_BY_NAME: ReadonlyMap<string, CoreGatewayMethodSpec> = new Map(
+6 -2
View File
@@ -58,7 +58,7 @@ describe("listGatewayMethods", () => {
});
it("appends new methods after model probing without shifting older method indices", () => {
expect(listGatewayMethods().slice(-7)).toEqual([
expect(listGatewayMethods().slice(-9)).toEqual([
"models.probe",
"migrations.memory.plan",
"migrations.memory.apply",
@@ -66,6 +66,8 @@ describe("listGatewayMethods", () => {
"approval.history",
"plugin.surface.refresh",
"conversations.list",
"session.discussion.info",
"session.discussion.open",
]);
const methods = listGatewayMethods();
expect(methods.indexOf("node.pluginSurface.refresh")).toBe(
@@ -126,7 +128,7 @@ describe("listGatewayMethods", () => {
"exec.approval.get",
]);
expect(methods).toContain("tts.speak");
expect(coreMethods.slice(-14)).toEqual([
expect(coreMethods.slice(-16)).toEqual([
"sessions.catalog.continue",
"sessions.catalog.archive",
"approval.get",
@@ -141,6 +143,8 @@ describe("listGatewayMethods", () => {
"approval.history",
"plugin.surface.refresh",
"conversations.list",
"session.discussion.info",
"session.discussion.open",
]);
expect(methods.indexOf("approval.get")).toBeGreaterThan(methods.indexOf("tts.speak"));
expect(methods.indexOf("approval.resolve")).toBe(methods.indexOf("approval.get") + 1);
+8
View File
@@ -225,6 +225,10 @@ const loadSessionCatalogHandlers = lazyHandlerModule(
() => import("./server-methods/session-catalog.js"),
(module) => module.sessionCatalogHandlers,
);
const loadSessionDiscussionHandlers = lazyHandlerModule(
() => import("./server-methods/session-discussion.js"),
(module) => module.sessionDiscussionHandlers,
);
const loadSkillsHandlers = lazyHandlerModule(
() => import("./server-methods/skills.js"),
(module) => module.skillsHandlers,
@@ -676,6 +680,10 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
],
loadHandlers: loadSessionCatalogHandlers,
}),
...createLazyCoreHandlers({
methods: ["session.discussion.info", "session.discussion.open"],
loadHandlers: loadSessionDiscussionHandlers,
}),
...createLazyCoreHandlers({
methods: [
"sessions.list",
@@ -0,0 +1,126 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SessionDiscussionProvider } from "../../plugins/session-discussion-registry.js";
import { sessionDiscussionHandlers } from "./session-discussion.js";
const mocks = vi.hoisted(() => ({ getProvider: vi.fn() }));
vi.mock("../../plugins/session-discussion-registry.js", () => ({
getSessionDiscussionProvider: mocks.getProvider,
}));
type Method = "session.discussion.info" | "session.discussion.open";
async function invoke(method: Method, params: Record<string, unknown>) {
const calls: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = [];
await sessionDiscussionHandlers[method]?.({
req: { type: "req", id: method, method, params: {} },
params,
client: null,
isWebchatConnect: () => false,
respond: (ok, payload, error) => calls.push({ ok, payload, error }),
context: {} as never,
});
return calls[0];
}
function provider() {
const info = vi.fn<SessionDiscussionProvider["info"]>().mockResolvedValue({
state: "open",
embedUrl: "https://chat.example/embed/thread",
openUrl: "https://chat.example/thread",
});
const open = vi.fn<SessionDiscussionProvider["open"]>().mockResolvedValue({ state: "available" });
return {
value: { id: "test", info, open } satisfies SessionDiscussionProvider,
info,
open,
};
}
describe("session discussion gateway methods", () => {
beforeEach(() => {
mocks.getProvider.mockReset();
});
it.each(["session.discussion.info", "session.discussion.open"] as const)(
"returns none when %s has no provider",
async (method) => {
mocks.getProvider.mockReturnValue(undefined);
expect(await invoke(method, { sessionKey: "agent:main:thread" })).toMatchObject({
ok: true,
payload: { state: "none" },
});
},
);
it("passes the session key to info and returns its result", async () => {
const registered = provider();
mocks.getProvider.mockReturnValue(registered.value);
const response = await invoke("session.discussion.info", {
sessionKey: "agent:main:thread",
});
expect(registered.info).toHaveBeenCalledWith({ sessionKey: "agent:main:thread" });
expect(response).toMatchObject({
ok: true,
payload: {
state: "open",
embedUrl: "https://chat.example/embed/thread",
openUrl: "https://chat.example/thread",
},
});
});
it("passes the session key to open and returns its result", async () => {
const registered = provider();
mocks.getProvider.mockReturnValue(registered.value);
const response = await invoke("session.discussion.open", {
sessionKey: "agent:main:thread",
});
expect(registered.open).toHaveBeenCalledWith({ sessionKey: "agent:main:thread" });
expect(response).toMatchObject({ ok: true, payload: { state: "available" } });
});
it.each(["session.discussion.info", "session.discussion.open"] as const)(
"returns a retryable error when the provider throws from %s",
async (method) => {
const registered = provider();
const operation = method === "session.discussion.info" ? registered.info : registered.open;
operation.mockRejectedValueOnce(new Error("provider failed"));
mocks.getProvider.mockReturnValue(registered.value);
expect(await invoke(method, { sessionKey: "agent:main:thread" })).toMatchObject({
ok: false,
error: { code: "UNAVAILABLE" },
});
},
);
it.each(["session.discussion.info", "session.discussion.open"] as const)(
"rejects a malformed provider result from %s",
async (method) => {
const registered = provider();
const operation = method === "session.discussion.info" ? registered.info : registered.open;
operation.mockResolvedValueOnce({ state: "invalid" } as never);
mocks.getProvider.mockReturnValue(registered.value);
expect(await invoke(method, { sessionKey: "agent:main:thread" })).toMatchObject({
ok: false,
error: { code: "UNAVAILABLE" },
});
},
);
it.each(["session.discussion.info", "session.discussion.open"] as const)(
"rejects an empty session key for %s",
async (method) => {
expect(await invoke(method, { sessionKey: "" })).toMatchObject({
ok: false,
error: { code: "INVALID_REQUEST" },
});
},
);
});
@@ -0,0 +1,103 @@
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateSessionDiscussionInfoParams,
validateSessionDiscussionInfoResult,
validateSessionDiscussionOpenParams,
validateSessionDiscussionOpenResult,
} from "../../../packages/gateway-protocol/src/index.js";
import { getSessionDiscussionProvider } from "../../plugins/session-discussion-registry.js";
import type { GatewayRequestHandlers } from "./types.js";
import { assertValidParams } from "./validation.js";
export const sessionDiscussionHandlers: GatewayRequestHandlers = {
"session.discussion.info": async ({ params, respond }) => {
if (
!assertValidParams(
params,
validateSessionDiscussionInfoParams,
"session.discussion.info",
respond,
)
) {
return;
}
const provider = getSessionDiscussionProvider();
if (!provider) {
respond(true, { state: "none" }, undefined);
return;
}
try {
const result = await provider.info({ sessionKey: params.sessionKey });
if (!validateSessionDiscussionInfoResult(result)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
`invalid session.discussion.info result: ${formatValidationErrors(validateSessionDiscussionInfoResult.errors)}`,
),
);
return;
}
respond(true, result, undefined);
} catch (error) {
// A throwing provider is a transient failure, not "no discussion":
// returning none here would make the UI cache-hide the feature until
// reconnect. Only an absent provider means none.
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
error instanceof Error ? error.message : "session discussion provider failed",
),
);
}
},
"session.discussion.open": async ({ params, respond }) => {
if (
!assertValidParams(
params,
validateSessionDiscussionOpenParams,
"session.discussion.open",
respond,
)
) {
return;
}
const provider = getSessionDiscussionProvider();
if (!provider) {
respond(true, { state: "none" }, undefined);
return;
}
try {
const result = await provider.open({ sessionKey: params.sessionKey });
if (!validateSessionDiscussionOpenResult(result)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
`invalid session.discussion.open result: ${formatValidationErrors(validateSessionDiscussionOpenResult.errors)}`,
),
);
return;
}
respond(true, result, undefined);
} catch (error) {
// A throwing provider is a transient failure, not "no discussion":
// returning none here would make the UI cache-hide the feature until
// reconnect. Only an absent provider means none.
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
error instanceof Error ? error.message : "session discussion provider failed",
),
);
}
},
};
+6
View File
@@ -0,0 +1,6 @@
export type {
SessionDiscussionInfo,
SessionDiscussionProvider,
SessionDiscussionState,
} from "../plugins/session-discussion-registry.js";
export { registerSessionDiscussionProvider } from "../plugins/session-discussion-registry.js";
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ warn: vi.fn() }));
vi.mock("../logging/subsystem.js", () => ({
createSubsystemLogger: () => ({ warn: mocks.warn }),
}));
import {
getSessionDiscussionProvider,
registerSessionDiscussionProvider,
type SessionDiscussionProvider,
} from "./session-discussion-registry.js";
function provider(id: string): SessionDiscussionProvider {
return {
id,
info: vi.fn().mockResolvedValue({ state: "available" }),
open: vi.fn().mockResolvedValue({ state: "open" }),
};
}
describe("session discussion provider registry", () => {
beforeEach(() => {
mocks.warn.mockClear();
});
it("returns the registered provider and warns when replacing it", () => {
const first = provider("first");
const second = provider("second");
registerSessionDiscussionProvider(first);
expect(getSessionDiscussionProvider()).toBe(first);
expect(mocks.warn).not.toHaveBeenCalled();
registerSessionDiscussionProvider(second);
expect(getSessionDiscussionProvider()).toBe(second);
expect(mocks.warn).toHaveBeenCalledWith(
"replacing session discussion provider first with second",
);
});
});
@@ -0,0 +1,41 @@
import { createSubsystemLogger } from "../logging/subsystem.js";
export type SessionDiscussionState = "none" | "available" | "open";
export type SessionDiscussionInfo = {
state: SessionDiscussionState;
embedUrl?: string;
openUrl?: string;
};
export type SessionDiscussionProvider = {
id: string;
info(params: { sessionKey: string }): Promise<SessionDiscussionInfo>;
open(params: { sessionKey: string }): Promise<SessionDiscussionInfo>;
};
const log = createSubsystemLogger("plugins/session-discussion");
const SESSION_DISCUSSION_REGISTRY = Symbol.for("openclaw.sessionDiscussionRegistry");
type SessionDiscussionRegistry = {
provider?: SessionDiscussionProvider;
};
function getRegistry(): SessionDiscussionRegistry {
// The public SDK entrypoint and lazy gateway chunks may load separately;
// a global symbol keeps them on the same process-wide provider slot.
const globalStore = globalThis as typeof globalThis & {
[SESSION_DISCUSSION_REGISTRY]?: SessionDiscussionRegistry;
};
return (globalStore[SESSION_DISCUSSION_REGISTRY] ??= {});
}
export function registerSessionDiscussionProvider(provider: SessionDiscussionProvider): void {
const registry = getRegistry();
if (registry.provider) {
log.warn(`replacing session discussion provider ${registry.provider.id} with ${provider.id}`);
}
registry.provider = provider;
}
export function getSessionDiscussionProvider(): SessionDiscussionProvider | undefined {
return getRegistry().provider;
}
+12
View File
@@ -4129,6 +4129,18 @@ export const en: TranslationMap = {
statusRenamed: "Renamed",
statusModified: "Modified",
},
sessionDiscussion: {
title: "Discussion",
show: "Show discussion",
disconnected: "Gateway is disconnected.",
loading: "Loading discussion…",
open: "Open discussion",
opening: "Opening discussion…",
opened: "Session discussion",
openExternal: "Open discussion in a new tab",
frameTitle: "Session discussion",
unavailable: "This discussion cannot be embedded.",
},
workspaceFiles: {
label: "Thread workspace",
expand: "Expand thread workspace",
+147
View File
@@ -6,6 +6,8 @@ import type {
SessionCatalogHost,
SessionCatalogSession,
SessionCatalogTranscriptItem,
SessionDiscussionInfo,
SessionDiscussionState,
SessionsCatalogContinueResult,
SessionsCatalogReadResult,
SessionsFilesRevealResult,
@@ -53,6 +55,7 @@ import {
} from "../../components/command-palette-contract.ts";
import "../../components/modal-dialog.ts";
import { createDockPanelLayout } from "../../components/dock-panel-layout.ts";
import { icons } from "../../components/icons.ts";
import { isCloudWorkerPlacementState } from "../../components/session-row-badges.ts";
import { t } from "../../i18n/index.ts";
import {
@@ -192,6 +195,7 @@ import {
import {
CHAT_DETAIL_FULL_MESSAGE_MAX_CHARS,
type DetailFullMessageResult,
type SidebarContent,
type SidebarFullMessageRequest,
} from "./components/chat-sidebar.ts";
import { ChatTranscriptController } from "./components/chat-thread.ts";
@@ -406,6 +410,8 @@ class ChatPane extends OpenClawLightDomElement {
private swarmBoardSnapshot: BoardSnapshot | null = null;
private swarmBoardSnapshotBase: BoardSnapshot | null = null;
private swarmBoardSnapshotRequest = 0;
private readonly sessionDiscussionStates = new Map<string, SessionDiscussionState>();
private readonly sessionDiscussionProbes = new Set<string>();
private headerRenameInitialLabel: string | null = null;
private headerRenameInitialValue = "";
private headerRenameSessionKey = "";
@@ -2166,6 +2172,13 @@ class ChatPane extends OpenClawLightDomElement {
const nextSessionKey = catalogKey
? this.sessionKey
: resolveSessionKey(this.sessionKey, this.context.gateway.snapshot.hello);
if (nextSessionKey) {
this.sessionDiscussionStates.delete(nextSessionKey);
// Resolve availability before the action renders: the methods are
// advertised even without a provider, so an unprobed session would
// otherwise show a dead Discussion button on provider-less installs.
void this.probeSessionDiscussion(nextSessionKey);
}
if (nextSessionKey && nextSessionKey !== this.state.sessionKey) {
this.switchPaneSession(nextSessionKey);
} else if (catalogKey && this.catalogRequestedSessionKey !== this.sessionKey) {
@@ -2332,6 +2345,7 @@ class ChatPane extends OpenClawLightDomElement {
this.taskSuggestions = [];
this.taskSuggestionBusyIds.clear();
this.taskSuggestionOperations.clear();
this.sessionDiscussionStates.clear();
this.resetOlderMessagesViewport();
state.chatLoading = false;
}
@@ -2339,6 +2353,17 @@ class ChatPane extends OpenClawLightDomElement {
state.connected = snapshot.connected;
state.connectionEpoch = this.connectionGeneration;
state.hello = snapshot.hello;
if (sourceChanged && state.sidebarContent?.kind === "session-discussion") {
// A reconnect may point at a different gateway/provider; an open panel
// would keep rendering the previous provider's URL. Close it — the
// re-probe below restores the action for the new source.
state.handleCloseSidebar();
}
if (sourceChanged && snapshot.connected && state.sessionKey) {
// Reconnects clear the probed states above; re-probe the active session
// so the Discussion action reappears without a manual session switch.
void this.probeSessionDiscussion(state.sessionKey);
}
state.terminalAvailable =
this.context.config.current.terminalEnabled &&
snapshot.connected &&
@@ -2660,6 +2685,127 @@ class ChatPane extends OpenClawLightDomElement {
}
}
// Probe once per session activation; transient failures stay uncached so the
// next activation retries instead of permanently hiding the feature.
private async probeSessionDiscussion(sessionKey: string) {
const state = this.state;
if (
!state?.connected ||
!state.client ||
this.sessionDiscussionStates.has(sessionKey) ||
// One in-flight probe per key: a rapid A→B→A switch must not start a
// second probe whose slower twin could later overwrite the fresh result.
this.sessionDiscussionProbes.has(sessionKey) ||
isGatewayMethodAdvertised(this.context.gateway.snapshot, "session.discussion.info") !== true
) {
return;
}
const generation = this.connectionGeneration;
this.sessionDiscussionProbes.add(sessionKey);
try {
const info = await state.client.request<SessionDiscussionInfo>("session.discussion.info", {
sessionKey,
});
// A reconnect supersedes in-flight probes; a stale result must not
// overwrite the new source's cache (e.g. an old "none" hiding the action).
if (generation !== this.connectionGeneration) {
return;
}
this.sessionDiscussionStates.set(sessionKey, info.state);
this.requestUpdate();
} catch {
// Leave unprobed: the action stays hidden and a later switch retries.
} finally {
this.sessionDiscussionProbes.delete(sessionKey);
// A reconnect during this probe skipped its own probe (the key was
// still held here); retry now so the new source gets a fresh answer.
if (
generation !== this.connectionGeneration &&
this.state?.sessionKey === sessionKey &&
!this.sessionDiscussionStates.has(sessionKey)
) {
void this.probeSessionDiscussion(sessionKey);
}
}
}
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 canOpen =
hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null) &&
isGatewayMethodAdvertised(this.context.gateway.snapshot, "session.discussion.open") === true;
const contentGeneration = this.connectionGeneration;
const content: SidebarContent = {
kind: "session-discussion",
sessionKey,
canOpen,
loadInfo: async (key) => {
if (!state.connected || !state.client) {
throw new Error(t("chat.sessionDiscussion.disconnected"));
}
return await state.client.request<SessionDiscussionInfo>("session.discussion.info", {
sessionKey: key,
});
},
openDiscussion: async (key) => {
if (!state.connected || !state.client) {
throw new Error(t("chat.sessionDiscussion.disconnected"));
}
return await state.client.request<SessionDiscussionInfo>("session.discussion.open", {
sessionKey: key,
});
},
onStateChange: (key, discussionState) => {
// 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 current = state.sidebarContent;
if (
discussionState === "none" &&
current?.kind === "session-discussion" &&
current.sessionKey === key
) {
state.handleCloseSidebar();
return;
}
state.requestUpdate();
},
};
const active =
state.sidebarOpen &&
state.sidebarContent?.kind === "session-discussion" &&
state.sidebarContent.sessionKey === sessionKey;
const label = t("chat.sessionDiscussion.show");
return html`
<openclaw-tooltip .content=${label}>
<button
class="btn btn--ghost btn--icon chat-icon-btn chat-session-discussion-toggle"
type="button"
aria-label=${label}
aria-pressed=${String(active)}
@click=${() => state.handleOpenSidebar(content)}
>
${icons.messageSquare}
</button>
</openclaw-tooltip>
`;
}
private renderPaneHeader(
sessionWorkspace: SessionWorkspaceProps,
backgroundTasks: BackgroundTasksProps,
@@ -2733,6 +2879,7 @@ class ChatPane extends OpenClawLightDomElement {
this.state?.connected === true &&
hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null),
terminalAction: renderCatalogTerminalButton(this.state, this.catalogSession),
discussionAction: this.renderSessionDiscussionAction(),
diffAction: renderSessionDiffToggle(sessionWorkspace),
backgroundTasksAction: renderBackgroundTasksToggle(backgroundTasks),
workspaceAction: renderSessionWorkspaceToggle(sessionWorkspace),
+3
View File
@@ -507,6 +507,9 @@ export function resetChatStateForRouteSession(
saveChatMessagesForSession(state, previousSessionKey);
const snapshot = restoreChatMessagesForSession(state, sessionKey);
state.sessionKey = sessionKey;
if (state.sidebarContent?.kind === "session-discussion") {
state.sidebarContent = { ...state.sidebarContent, sessionKey };
}
state.selectedChatSessionArchived =
state.sessionsResult?.sessions.some(
(row) => row.archived === true && areUiSessionKeysEquivalent(row.key, sessionKey),
@@ -49,6 +49,7 @@ function mount(patch: Partial<ChatPaneHeaderProps> = {}) {
copiedAction: null,
canRename: true,
terminalAction: nothing,
discussionAction: nothing,
diffAction: nothing,
backgroundTasksAction: nothing,
workspaceAction: nothing,
@@ -34,6 +34,7 @@ type ChatPaneHeaderProps = {
copiedAction: ChatPaneHeaderAction | null;
canRename: boolean;
terminalAction: TemplateResult | typeof nothing;
discussionAction: TemplateResult | typeof nothing;
diffAction: TemplateResult | typeof nothing;
backgroundTasksAction: TemplateResult | typeof nothing;
workspaceAction: TemplateResult | typeof nothing;
@@ -297,7 +298,7 @@ export function renderChatPaneHeader(props: ChatPaneHeaderProps) {
`
: nothing}
<div class="chat-pane__actions">
${props.boardDockAction ?? nothing} ${props.terminalAction}
${props.boardDockAction ?? nothing} ${props.terminalAction} ${props.discussionAction}
${props.catalog
? nothing
: html`${props.diffAction} ${props.backgroundTasksAction} ${props.workspaceAction}`}
+109 -67
View File
@@ -20,10 +20,16 @@ import {
import { copyToClipboard } from "../../../lib/clipboard.ts";
import { type EditorId, openEditor } from "../../../lib/editor-links.ts";
import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts";
import "./session-discussion-panel.ts";
import "./session-diff-panel.ts";
import { renderChatSidebarEditorMenu } from "./chat-sidebar-editor-menu.ts";
import type { FileEditorViewHandle } from "./file-editor-view.ts";
import type { SessionDiffLoader } from "./session-diff-panel.ts";
import type {
SessionDiscussionInfoLoader,
SessionDiscussionOpener,
SessionDiscussionStateListener,
} from "./session-discussion-panel.ts";
export const CHAT_DETAIL_FULL_MESSAGE_MAX_CHARS = 500_000;
@@ -81,6 +87,18 @@ type SessionDiffSidebarContent = {
unavailableReason?: DetailUnavailableReason | null;
};
type SessionDiscussionSidebarContent = {
kind: "session-discussion";
sessionKey: string;
canOpen: boolean;
loadInfo: SessionDiscussionInfoLoader;
openDiscussion: SessionDiscussionOpener;
onStateChange: SessionDiscussionStateListener;
rawText?: string | null;
fullMessageRequest?: SidebarFullMessageRequest;
unavailableReason?: DetailUnavailableReason | null;
};
type FileSaveOutcome =
| { ok: true; hash: string; updatedAtMs?: number }
| { ok: false; code: "conflict"; currentHash?: string }
@@ -134,6 +152,7 @@ export type SidebarContent =
| CanvasSidebarContent
| ImageSidebarContent
| FileSidebarContent
| SessionDiscussionSidebarContent
| SessionDiffSidebarContent;
function hasFullMessageRequest(content: SidebarContent): content is SidebarContent & {
@@ -513,9 +532,11 @@ function renderMarkdownSidebar(props: MarkdownSidebarProps) {
? content.name.trim() || "File"
: content?.kind === "session-diff"
? t("chat.sessionDiff.title")
: content?.kind === "markdown"
? "Markdown Preview"
: "Tool Details";
: content?.kind === "session-discussion"
? t("chat.sessionDiscussion.title")
: content?.kind === "markdown"
? "Markdown Preview"
: "Tool Details";
return html`
<div class="sidebar-panel">
<div class="sidebar-header">
@@ -531,7 +552,11 @@ function renderMarkdownSidebar(props: MarkdownSidebarProps) {
</button>
</openclaw-tooltip>
</div>
<div class="sidebar-content">
<div
class="sidebar-content ${content?.kind === "session-discussion"
? "sidebar-content--discussion"
: ""}"
>
${props.error
? html`
<div class="callout danger">${props.error}</div>
@@ -553,46 +578,34 @@ function renderMarkdownSidebar(props: MarkdownSidebarProps) {
? renderFileSidebarContent(content, props.onViewRawText, props.fileView)
: content.kind === "session-diff"
? html`<openclaw-session-diff .loader=${content.load}></openclaw-session-diff>`
: content.kind === "canvas"
: content.kind === "session-discussion"
? html`
<div class="chat-tool-card__preview" data-kind="canvas">
<div class="chat-tool-card__preview-panel" data-side="front">
${keyed(
`${canvasSandbox}\u0000${canvasSrc ?? ""}\u0000${content.preferredHeight ?? ""}`,
html`
<iframe
class="chat-tool-card__preview-frame"
title=${content.title?.trim() || "Render preview"}
sandbox=${canvasSandbox}
src=${canvasSrc ?? nothing}
style=${content.preferredHeight
? `height:${content.preferredHeight}px`
: ""}
></iframe>
`,
)}
</div>
${content.rawText?.trim()
? html`
<div style="margin-top: 12px;">
<button @click=${props.onViewRawText} class="btn" type="button">
${t("chat.detailPanel.viewRawText")}
</button>
</div>
`
: nothing}
</div>
<openclaw-session-discussion
.sessionKey=${content.sessionKey}
.canOpen=${content.canOpen}
.loadInfo=${content.loadInfo}
.openDiscussion=${content.openDiscussion}
.onStateChange=${content.onStateChange}
></openclaw-session-discussion>
`
: content.kind === "image"
: content.kind === "canvas"
? html`
<div class="chat-tool-card__preview" data-kind="image">
<div class="chat-tool-card__preview" data-kind="canvas">
<div class="chat-tool-card__preview-panel" data-side="front">
<img
class="chat-tool-card__preview-image"
src=${content.src}
alt=${title}
style="display:block;max-width:100%;height:auto;border-radius:8px;"
/>
${keyed(
`${canvasSandbox}\u0000${canvasSrc ?? ""}\u0000${content.preferredHeight ?? ""}`,
html`
<iframe
class="chat-tool-card__preview-frame"
title=${content.title?.trim() || "Render preview"}
sandbox=${canvasSandbox}
src=${canvasSrc ?? nothing}
style=${content.preferredHeight
? `height:${content.preferredHeight}px`
: ""}
></iframe>
`,
)}
</div>
${content.rawText?.trim()
? html`
@@ -605,35 +618,61 @@ function renderMarkdownSidebar(props: MarkdownSidebarProps) {
: nothing}
</div>
`
: html`
<section class="sidebar-markdown-shell">
<div class="sidebar-markdown-shell__toolbar">
<div class="sidebar-markdown-shell__intro">
<div class="sidebar-markdown-shell__eyebrow">
${icons.scrollText}
<span>${t("chat.detailPanel.renderedMarkdown")}</span>
</div>
<div class="sidebar-markdown-shell__hint">
${t("chat.detailPanel.renderedMarkdownHint")}
</div>
: content.kind === "image"
? html`
<div class="chat-tool-card__preview" data-kind="image">
<div class="chat-tool-card__preview-panel" data-side="front">
<img
class="chat-tool-card__preview-image"
src=${content.src}
alt=${title}
style="display:block;max-width:100%;height:auto;border-radius:8px;"
/>
</div>
<button @click=${props.onViewRawText} class="btn btn--sm" type="button">
${t("chat.detailPanel.viewRawText")}
</button>
${content.rawText?.trim()
? html`
<div style="margin-top: 12px;">
<button @click=${props.onViewRawText} class="btn" type="button">
${t("chat.detailPanel.viewRawText")}
</button>
</div>
`
: nothing}
</div>
${markdownHtml
? html`
<article class="sidebar-markdown-reader sidebar-markdown">
${unsafeHTML(markdownHtml)}
</article>
`
: html`
<div class="sidebar-markdown-empty">
${t("chat.detailPanel.noPreviewableMarkdown")}
`
: html`
<section class="sidebar-markdown-shell">
<div class="sidebar-markdown-shell__toolbar">
<div class="sidebar-markdown-shell__intro">
<div class="sidebar-markdown-shell__eyebrow">
${icons.scrollText}
<span>${t("chat.detailPanel.renderedMarkdown")}</span>
</div>
`}
</section>
`
<div class="sidebar-markdown-shell__hint">
${t("chat.detailPanel.renderedMarkdownHint")}
</div>
</div>
<button
@click=${props.onViewRawText}
class="btn btn--sm"
type="button"
>
${t("chat.detailPanel.viewRawText")}
</button>
</div>
${markdownHtml
? html`
<article class="sidebar-markdown-reader sidebar-markdown">
${unsafeHTML(markdownHtml)}
</article>
`
: html`
<div class="sidebar-markdown-empty">
${t("chat.detailPanel.noPreviewableMarkdown")}
</div>
`}
</section>
`
: html` <div class="muted">${t("chat.detailPanel.noContent")}</div> `}
</div>
</div>
@@ -1234,8 +1273,11 @@ class ChatDetailPanel extends OpenClawLightDomElement {
const currentMatchIndex = matches.length
? Math.min(this.fileSearchMatchIndex, matches.length - 1)
: 0;
// The discussion iframe has no intrinsic height, so its host wrapper must
// stretch; content-sized kinds (files, tool details) keep auto height.
const fillHost = this.visibleContent?.kind === "session-discussion";
return html`
<div @click=${this.handlePanelClick}>
<div class=${fillHost ? "sidebar-panel-host--fill" : ""} @click=${this.handlePanelClick}>
${renderMarkdownSidebar({
content: this.visibleContent,
error: this.error,
@@ -0,0 +1,131 @@
/* @vitest-environment jsdom */
import { afterEach, describe, expect, it, vi } from "vitest";
import type {
SessionDiscussionInfoLoader,
SessionDiscussionOpener,
SessionDiscussionStateListener,
} from "./session-discussion-panel.ts";
import "./session-discussion-panel.ts";
type DiscussionPanelElement = HTMLElement & {
sessionKey: string;
canOpen: boolean;
loadInfo: SessionDiscussionInfoLoader;
openDiscussion: SessionDiscussionOpener;
onStateChange: SessionDiscussionStateListener;
updateComplete: Promise<unknown>;
};
const panels: DiscussionPanelElement[] = [];
afterEach(() => {
panels.splice(0).forEach((panel) => panel.remove());
});
function mount(params: {
loadInfo: SessionDiscussionInfoLoader;
openDiscussion: SessionDiscussionOpener;
onStateChange?: SessionDiscussionStateListener;
}): DiscussionPanelElement {
const panel = document.createElement("openclaw-session-discussion") as DiscussionPanelElement;
panel.sessionKey = "agent:main:first";
panel.loadInfo = params.loadInfo;
panel.openDiscussion = params.openDiscussion;
panel.onStateChange = params.onStateChange ?? vi.fn();
document.body.append(panel);
panels.push(panel);
return panel;
}
describe("session discussion panel", () => {
it("loads once, opens an available discussion, and renders both URLs", async () => {
const loadInfo = vi.fn<SessionDiscussionInfoLoader>().mockResolvedValue({
state: "available",
});
const openDiscussion = vi.fn<SessionDiscussionOpener>().mockResolvedValue({
state: "open",
embedUrl: "https://discussion.example/embed/thread",
openUrl: "https://discussion.example/thread",
});
const panel = mount({ loadInfo, openDiscussion });
await vi.waitFor(() => {
expect(panel.querySelector("button")?.textContent).toContain("Open discussion");
});
expect(loadInfo).toHaveBeenCalledTimes(1);
panel.querySelector<HTMLButtonElement>("button")?.click();
await vi.waitFor(() => {
expect(panel.querySelector("iframe")?.getAttribute("src")).toBe(
"https://discussion.example/embed/thread",
);
expect(panel.querySelector("iframe")?.getAttribute("sandbox")).toBe(
"allow-forms allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts",
);
});
const external = panel.querySelector<HTMLAnchorElement>("a");
expect(openDiscussion).toHaveBeenCalledWith("agent:main:first");
expect(external?.href).toBe("https://discussion.example/thread");
expect(external?.target).toBe("_blank");
expect(external?.rel).toBe("noopener");
});
it("refetches on session switch and reports a hidden discussion", async () => {
const loadInfo = vi
.fn<SessionDiscussionInfoLoader>()
.mockResolvedValueOnce({ state: "available" })
.mockResolvedValueOnce({ state: "none" });
const onStateChange = vi.fn<SessionDiscussionStateListener>();
const panel = mount({ loadInfo, openDiscussion: vi.fn(), onStateChange });
await vi.waitFor(() => expect(loadInfo).toHaveBeenCalledTimes(1));
panel.sessionKey = "agent:main:second";
await vi.waitFor(() => {
expect(loadInfo).toHaveBeenNthCalledWith(2, "agent:main:second");
expect(onStateChange).toHaveBeenLastCalledWith("agent:main:second", "none");
});
expect(panel.querySelector("button")).toBeNull();
expect(panel.querySelector("iframe")).toBeNull();
});
it("clears an in-flight open state when the session changes", async () => {
const loadInfo = vi.fn<SessionDiscussionInfoLoader>().mockResolvedValue({
state: "available",
});
const openDiscussion = vi
.fn<SessionDiscussionOpener>()
.mockImplementation(() => new Promise(() => {}));
const panel = mount({ loadInfo, openDiscussion });
await vi.waitFor(() => expect(panel.querySelector("button")?.disabled).toBe(false));
panel.querySelector<HTMLButtonElement>("button")?.click();
await vi.waitFor(() => expect(panel.querySelector("button")?.disabled).toBe(true));
panel.sessionKey = "agent:main:second";
await vi.waitFor(() => {
expect(loadInfo).toHaveBeenCalledTimes(2);
expect(panel.querySelector("button")?.disabled).toBe(false);
expect(panel.querySelector("button")?.textContent).toContain("Open discussion");
});
});
it("does not render non-HTTP discussion URLs", async () => {
const panel = mount({
loadInfo: vi.fn().mockResolvedValue({
state: "open",
embedUrl: "javascript:alert(1)",
openUrl: "data:text/html,unsafe",
}),
openDiscussion: vi.fn(),
});
await vi.waitFor(() => {
expect(panel.textContent).toContain("cannot be embedded");
});
expect(panel.querySelector("iframe")).toBeNull();
expect(panel.querySelector("a")).toBeNull();
});
});
@@ -0,0 +1,209 @@
import { html, nothing, type TemplateResult } from "lit";
import { property, state } from "lit/decorators.js";
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";
export type SessionDiscussionInfoLoader = (sessionKey: string) => Promise<SessionDiscussionInfo>;
export type SessionDiscussionOpener = (sessionKey: string) => Promise<SessionDiscussionInfo>;
export type SessionDiscussionStateListener = (
sessionKey: string,
discussionState: SessionDiscussionState,
) => void;
function resolveDiscussionUrl(value: string | undefined): string | null {
if (!value?.trim()) {
return null;
}
try {
const url = new URL(value);
return url.protocol === "https:" || url.protocol === "http:" ? url.href : null;
} catch {
return null;
}
}
// The frame runs with allow-scripts + allow-same-origin (cookies must flow for
// the discussion app's session). A same-origin src would therefore inherit THIS
// app's origin and could reach the parent DOM and gateway credentials — reject
// it; cross-origin same-site hosts (the supported topology) pass.
function resolveDiscussionEmbedUrl(value: string | undefined): string | null {
const resolved = resolveDiscussionUrl(value);
if (!resolved) {
return null;
}
return new URL(resolved).origin === window.location.origin ? null : resolved;
}
class SessionDiscussionPanel extends OpenClawLightDomElement {
@property() sessionKey = "";
@property({ attribute: false }) loadInfo: SessionDiscussionInfoLoader | null = null;
@property({ attribute: false }) openDiscussion: SessionDiscussionOpener | null = null;
@property({ attribute: false }) onStateChange: SessionDiscussionStateListener | null = null;
@property({ type: Boolean }) canOpen = true;
@state() private info: SessionDiscussionInfo | null = null;
@state() private loading = false;
@state() private opening = false;
@state() private error: string | null = null;
private requestVersion = 0;
protected override updated(changed: Map<string, unknown>) {
if (changed.has("sessionKey") || changed.has("loadInfo")) {
void this.refresh();
}
}
// requestKey is the key the request was issued for; the sessionKey property
// may already name the next session while an old result resolves, and a
// stale result must not be attributed to (or close the panel of) the new one.
private publish(requestKey: string, info: SessionDiscussionInfo): void {
if (requestKey !== this.sessionKey.trim()) {
return;
}
this.info = info;
this.onStateChange?.(requestKey, info.state);
}
private async refresh(): Promise<void> {
const loader = this.loadInfo;
const sessionKey = this.sessionKey.trim();
const version = ++this.requestVersion;
this.info = null;
this.error = null;
this.opening = false;
if (!loader || !sessionKey) {
this.loading = false;
return;
}
this.loading = true;
try {
const info = await loader(sessionKey);
if (version === this.requestVersion) {
this.publish(sessionKey, info);
}
} catch (error) {
if (version === this.requestVersion) {
this.error = error instanceof Error ? error.message : String(error);
}
} finally {
if (version === this.requestVersion) {
this.loading = false;
}
}
}
private async open(): Promise<void> {
const opener = this.openDiscussion;
const sessionKey = this.sessionKey.trim();
if (!opener || !sessionKey || this.opening) {
return;
}
const version = ++this.requestVersion;
this.opening = true;
this.error = null;
try {
const info = await opener(sessionKey);
if (version === this.requestVersion) {
this.publish(sessionKey, info);
}
} catch (error) {
if (version === this.requestVersion) {
this.error = error instanceof Error ? error.message : String(error);
}
} finally {
if (version === this.requestVersion) {
this.opening = false;
}
}
}
// The iframe sandbox must include allow-same-origin: without it the frame
// gets an opaque origin, the discussion app's session cookie is never sent,
// and the embed is stuck on its sign-in card.
private renderOpen(info: SessionDiscussionInfo): TemplateResult {
const embedUrl = resolveDiscussionEmbedUrl(info.embedUrl);
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
class="session-discussion__frame"
src=${embedUrl}
title=${t("chat.sessionDiscussion.frameTitle")}
sandbox="allow-forms allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts"
></iframe>
`
: html`<div class="session-discussion__empty">
${t("chat.sessionDiscussion.unavailable")}
</div>`}
</div>
`;
}
override render() {
if (this.error) {
return html`<div class="session-discussion__empty">
<div class="callout danger">${this.error}</div>
</div>`;
}
if (this.loading || !this.info) {
return html`<div class="session-discussion__empty">
${t("chat.sessionDiscussion.loading")}
</div>`;
}
if (this.info.state === "none") {
return nothing;
}
if (this.info.state === "available") {
return html`
<div class="session-discussion__empty">
<button
class="btn primary"
type="button"
?disabled=${!this.canOpen || this.opening}
@click=${() => void this.open()}
>
${this.opening ? t("chat.sessionDiscussion.opening") : t("chat.sessionDiscussion.open")}
</button>
</div>
`;
}
return this.renderOpen(this.info);
}
}
if (!customElements.get("openclaw-session-discussion")) {
customElements.define("openclaw-session-discussion", SessionDiscussionPanel);
}
declare global {
interface HTMLElementTagNameMap {
"openclaw-session-discussion": SessionDiscussionPanel;
}
}
+67
View File
@@ -1025,6 +1025,73 @@
padding: 16px;
}
.sidebar-content--discussion {
min-height: 0;
overflow: hidden;
padding: 0;
}
/* Host wrapper for full-height panel kinds: the discussion iframe has no
intrinsic height, so the wrapper must stretch for height:100% to resolve. */
.sidebar-panel-host--fill {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
openclaw-session-discussion {
display: flex;
height: 100%;
min-height: 0;
}
.session-discussion__empty {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: 24px;
color: var(--muted);
}
.session-discussion__open {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
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%;
min-height: 0;
border: 0;
background: var(--panel);
}
.sidebar-file-view {
display: flex;
flex-direction: column;