mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-20 09:47:53 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -225,6 +225,7 @@
|
||||
"response-limit-runtime",
|
||||
"session-binding-runtime",
|
||||
"session-catalog",
|
||||
"session-discussion",
|
||||
"session-key-runtime",
|
||||
"session-store-runtime",
|
||||
"session-transcript-runtime",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user