mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +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"),
|
ApprovalHistory("approval.history"),
|
||||||
PluginSurfaceRefresh("plugin.surface.refresh"),
|
PluginSurfaceRefresh("plugin.surface.refresh"),
|
||||||
ConversationsList("conversations.list"),
|
ConversationsList("conversations.list"),
|
||||||
|
SessionDiscussionInfo("session.discussion.info"),
|
||||||
|
SessionDiscussionOpen("session.discussion.open"),
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class GatewayEvent(
|
enum class GatewayEvent(
|
||||||
|
|||||||
@@ -93,6 +93,12 @@ public enum SessionPlacementState: String, Codable, Sendable {
|
|||||||
case failed = "failed"
|
case failed = "failed"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum SessionDiscussionState: String, Codable, Sendable {
|
||||||
|
case none = "none"
|
||||||
|
case available = "available"
|
||||||
|
case _open = "open"
|
||||||
|
}
|
||||||
|
|
||||||
public enum SessionFileKind: String, Codable, Sendable {
|
public enum SessionFileKind: String, Codable, Sendable {
|
||||||
case modified = "modified"
|
case modified = "modified"
|
||||||
case read = "read"
|
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 struct SessionsCompactionListParams: Codable, Sendable {
|
||||||
public let key: String
|
public let key: String
|
||||||
public let agentid: String?
|
public let agentid: String?
|
||||||
|
|||||||
@@ -273,6 +273,7 @@ e9c4398b04d04fead0e46ec618589d8f597a0b6087805ab132138e941b76e190 module/sandbox
|
|||||||
50beebb77e461deaccdbff038f6a461dff1d5773426322dc6d7991f6e05a7c37 module/self-hosted-provider-setup
|
50beebb77e461deaccdbff038f6a461dff1d5773426322dc6d7991f6e05a7c37 module/self-hosted-provider-setup
|
||||||
250476d121ffa4ed67d497c59d9c7bb1886973e92ebed39325a02609217bade0 module/session-binding-runtime
|
250476d121ffa4ed67d497c59d9c7bb1886973e92ebed39325a02609217bade0 module/session-binding-runtime
|
||||||
ed35a448c8f7650acb34739a591334267371f1ffe352505fc2808330d78580bd module/session-catalog
|
ed35a448c8f7650acb34739a591334267371f1ffe352505fc2808330d78580bd module/session-catalog
|
||||||
|
31b785e74f1f8f56241b7756ef6a5d86199c5ce177cbb1c234a261866972f270 module/session-discussion
|
||||||
f07839f5b8929a179857a0b4b89f8448864078cd5972dd53f9a30e50bf55408d module/session-key-runtime
|
f07839f5b8929a179857a0b4b89f8448864078cd5972dd53f9a30e50bf55408d module/session-key-runtime
|
||||||
f59099aa2d536246d4b1297f01bcea66796351a9259debdd45909afeb7a42d86 module/session-store-runtime
|
f59099aa2d536246d4b1297f01bcea66796351a9259debdd45909afeb7a42d86 module/session-store-runtime
|
||||||
b1d0a76337122cb9dbb0c89fbb8bfacc1a88ce689270d3d684019c9a03ce669a module/session-transcript-hit
|
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
|
The `register(api)` callback receives an `OpenClawPluginApi` object with these
|
||||||
methods:
|
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
|
### Capability registration
|
||||||
|
|
||||||
| Method | What it registers |
|
| Method | What it registers |
|
||||||
|
|||||||
@@ -1028,6 +1028,10 @@
|
|||||||
"types": "./dist/plugin-sdk/session-catalog.d.ts",
|
"types": "./dist/plugin-sdk/session-catalog.d.ts",
|
||||||
"default": "./dist/plugin-sdk/session-catalog.js"
|
"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": {
|
"./plugin-sdk/session-key-runtime": {
|
||||||
"types": "./dist/plugin-sdk/session-key-runtime.d.ts",
|
"types": "./dist/plugin-sdk/session-key-runtime.d.ts",
|
||||||
"default": "./dist/plugin-sdk/session-key-runtime.js"
|
"default": "./dist/plugin-sdk/session-key-runtime.js"
|
||||||
|
|||||||
@@ -465,6 +465,12 @@ import {
|
|||||||
SessionsSearchResultSchema,
|
SessionsSearchResultSchema,
|
||||||
SessionsSendParamsSchema,
|
SessionsSendParamsSchema,
|
||||||
SessionsUsageParamsSchema,
|
SessionsUsageParamsSchema,
|
||||||
|
SessionDiscussionInfoParamsSchema,
|
||||||
|
SessionDiscussionInfoResultSchema,
|
||||||
|
SessionDiscussionInfoSchema,
|
||||||
|
SessionDiscussionOpenParamsSchema,
|
||||||
|
SessionDiscussionOpenResultSchema,
|
||||||
|
SessionDiscussionStateSchema,
|
||||||
TaskSuggestionEventSchema,
|
TaskSuggestionEventSchema,
|
||||||
TaskSuggestionResolutionSchema,
|
TaskSuggestionResolutionSchema,
|
||||||
TaskSuggestionSchema,
|
TaskSuggestionSchema,
|
||||||
@@ -769,6 +775,10 @@ export const validateSessionsBranchesSwitchParams = lazyCompile(SessionsBranches
|
|||||||
export const validateSessionsRewindParams = lazyCompile(SessionsRewindParamsSchema);
|
export const validateSessionsRewindParams = lazyCompile(SessionsRewindParamsSchema);
|
||||||
export const validateSessionsForkParams = lazyCompile(SessionsForkParamsSchema);
|
export const validateSessionsForkParams = lazyCompile(SessionsForkParamsSchema);
|
||||||
export const validateSessionsUsageParams = lazyCompile(SessionsUsageParamsSchema);
|
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 validateTaskSuggestionsListParams = lazyCompile(TaskSuggestionsListParamsSchema);
|
||||||
export const validateTaskSuggestionsCreateParams = lazyCompile(TaskSuggestionsCreateParamsSchema);
|
export const validateTaskSuggestionsCreateParams = lazyCompile(TaskSuggestionsCreateParamsSchema);
|
||||||
export const validateTaskSuggestionsAcceptParams = lazyCompile(TaskSuggestionsAcceptParamsSchema);
|
export const validateTaskSuggestionsAcceptParams = lazyCompile(TaskSuggestionsAcceptParamsSchema);
|
||||||
@@ -1122,6 +1132,12 @@ export {
|
|||||||
SessionsGroupsMutationResultSchema,
|
SessionsGroupsMutationResultSchema,
|
||||||
SessionsCompactParamsSchema,
|
SessionsCompactParamsSchema,
|
||||||
SessionsUsageParamsSchema,
|
SessionsUsageParamsSchema,
|
||||||
|
SessionDiscussionStateSchema,
|
||||||
|
SessionDiscussionInfoSchema,
|
||||||
|
SessionDiscussionInfoParamsSchema,
|
||||||
|
SessionDiscussionInfoResultSchema,
|
||||||
|
SessionDiscussionOpenParamsSchema,
|
||||||
|
SessionDiscussionOpenResultSchema,
|
||||||
ArtifactSummarySchema,
|
ArtifactSummarySchema,
|
||||||
ArtifactsListParamsSchema,
|
ArtifactsListParamsSchema,
|
||||||
ArtifactsGetParamsSchema,
|
ArtifactsGetParamsSchema,
|
||||||
@@ -1615,6 +1631,12 @@ export type {
|
|||||||
SessionDiffFileStatus,
|
SessionDiffFileStatus,
|
||||||
SessionsDiffParams,
|
SessionsDiffParams,
|
||||||
SessionsDiffResult,
|
SessionsDiffResult,
|
||||||
|
SessionDiscussionState,
|
||||||
|
SessionDiscussionInfo,
|
||||||
|
SessionDiscussionInfoParams,
|
||||||
|
SessionDiscussionInfoResult,
|
||||||
|
SessionDiscussionOpenParams,
|
||||||
|
SessionDiscussionOpenResult,
|
||||||
ArtifactSummary,
|
ArtifactSummary,
|
||||||
ArtifactsListParams,
|
ArtifactsListParams,
|
||||||
ArtifactsListResult,
|
ArtifactsListResult,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export * from "./schema/push.js";
|
|||||||
export * from "./schema/questions.js";
|
export * from "./schema/questions.js";
|
||||||
export * from "./schema/secrets.js";
|
export * from "./schema/secrets.js";
|
||||||
export * from "./schema/session-placement.js";
|
export * from "./schema/session-placement.js";
|
||||||
|
export * from "./schema/session-discussion.js";
|
||||||
export * from "./schema/sessions.js";
|
export * from "./schema/sessions.js";
|
||||||
export * from "./schema/sessions-catalog.js";
|
export * from "./schema/sessions-catalog.js";
|
||||||
export * from "./schema/skill-history.js";
|
export * from "./schema/skill-history.js";
|
||||||
|
|||||||
@@ -433,6 +433,14 @@ import {
|
|||||||
SecretsResolveParamsSchema,
|
SecretsResolveParamsSchema,
|
||||||
SecretsResolveResultSchema,
|
SecretsResolveResultSchema,
|
||||||
} from "./secrets.js";
|
} from "./secrets.js";
|
||||||
|
import {
|
||||||
|
SessionDiscussionInfoParamsSchema,
|
||||||
|
SessionDiscussionInfoResultSchema,
|
||||||
|
SessionDiscussionInfoSchema,
|
||||||
|
SessionDiscussionOpenParamsSchema,
|
||||||
|
SessionDiscussionOpenResultSchema,
|
||||||
|
SessionDiscussionStateSchema,
|
||||||
|
} from "./session-discussion.js";
|
||||||
import { SessionPlacementProtocolSchemas } from "./session-placement.js";
|
import { SessionPlacementProtocolSchemas } from "./session-placement.js";
|
||||||
import {
|
import {
|
||||||
SessionCatalogCapabilitiesSchema,
|
SessionCatalogCapabilitiesSchema,
|
||||||
@@ -747,6 +755,12 @@ export const ProtocolSchemas = {
|
|||||||
SessionCompactionCheckpoint: SessionCompactionCheckpointSchema,
|
SessionCompactionCheckpoint: SessionCompactionCheckpointSchema,
|
||||||
SessionOperationEvent: SessionOperationEventSchema,
|
SessionOperationEvent: SessionOperationEventSchema,
|
||||||
...SessionPlacementProtocolSchemas,
|
...SessionPlacementProtocolSchemas,
|
||||||
|
SessionDiscussionState: SessionDiscussionStateSchema,
|
||||||
|
SessionDiscussionInfo: SessionDiscussionInfoSchema,
|
||||||
|
SessionDiscussionInfoParams: SessionDiscussionInfoParamsSchema,
|
||||||
|
SessionDiscussionInfoResult: SessionDiscussionInfoResultSchema,
|
||||||
|
SessionDiscussionOpenParams: SessionDiscussionOpenParamsSchema,
|
||||||
|
SessionDiscussionOpenResult: SessionDiscussionOpenResultSchema,
|
||||||
SessionsCompactionListParams: SessionsCompactionListParamsSchema,
|
SessionsCompactionListParams: SessionsCompactionListParamsSchema,
|
||||||
SessionsCompactionGetParams: SessionsCompactionGetParamsSchema,
|
SessionsCompactionGetParams: SessionsCompactionGetParamsSchema,
|
||||||
SessionsCompactionBranchParams: SessionsCompactionBranchParamsSchema,
|
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",
|
"response-limit-runtime",
|
||||||
"session-binding-runtime",
|
"session-binding-runtime",
|
||||||
"session-catalog",
|
"session-catalog",
|
||||||
|
"session-discussion",
|
||||||
"session-key-runtime",
|
"session-key-runtime",
|
||||||
"session-store-runtime",
|
"session-store-runtime",
|
||||||
"session-transcript-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: meeting-runtime barrel: browser meeting-bot core behind MeetingPlatformAdapter.
|
||||||
// +1: question-gateway-runtime resolves ask_user choices for channel plugins.
|
// +1: question-gateway-runtime resolves ask_user choices for channel plugins.
|
||||||
// +1: ingress-effect-once gives drained channels a narrow durable side-effect guard.
|
// +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,
|
env,
|
||||||
),
|
),
|
||||||
// ScopeTree adds six channel-policy exports, mirrored by compat, including three functions.
|
// 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.
|
// +2: lifecycle-owned prepared model catalog sync and async readers.
|
||||||
// Harvest: retired tuning-knob config types -10.
|
// Harvest: retired tuning-knob config types -10.
|
||||||
// Harvest: removed process-global API-provider publication functions -2.
|
// Harvest: removed process-global API-provider publication functions -2.
|
||||||
8180,
|
// +4: session discussion state, info, provider, and registration contracts.
|
||||||
|
8184,
|
||||||
env,
|
env,
|
||||||
),
|
),
|
||||||
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
|
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
|
||||||
@@ -342,7 +344,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
|
|||||||
// +6: outbound echo record/query helpers across channel-outbound and mirrors.
|
// +6: outbound echo record/query helpers across channel-outbound and mirrors.
|
||||||
// +2: lifecycle-owned prepared model catalog sync and async readers.
|
// +2: lifecycle-owned prepared model catalog sync and async readers.
|
||||||
// Harvest: removed process-global API-provider publication functions -2.
|
// Harvest: removed process-global API-provider publication functions -2.
|
||||||
4555,
|
// +1: session discussion provider registration.
|
||||||
|
4556,
|
||||||
env,
|
env,
|
||||||
),
|
),
|
||||||
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
|
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ describe("method scope resolution", () => {
|
|||||||
["sessions.catalog.read", ["operator.read"]],
|
["sessions.catalog.read", ["operator.read"]],
|
||||||
["sessions.catalog.continue", ["operator.write"]],
|
["sessions.catalog.continue", ["operator.write"]],
|
||||||
["sessions.catalog.archive", ["operator.write"]],
|
["sessions.catalog.archive", ["operator.write"]],
|
||||||
|
["session.discussion.info", ["operator.read"]],
|
||||||
|
["session.discussion.open", ["operator.write"]],
|
||||||
["environments.status", ["operator.read"]],
|
["environments.status", ["operator.read"]],
|
||||||
["diagnostics.stability", ["operator.read"]],
|
["diagnostics.stability", ["operator.read"]],
|
||||||
["skills.curator.status", ["operator.read"]],
|
["skills.curator.status", ["operator.read"]],
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ const CURRENT_TRAIN_METHODS = [
|
|||||||
"question.resolve",
|
"question.resolve",
|
||||||
"question.get",
|
"question.get",
|
||||||
"question.list",
|
"question.list",
|
||||||
|
"session.discussion.info",
|
||||||
|
"session.discussion.open",
|
||||||
"terminal.open",
|
"terminal.open",
|
||||||
"terminal.input",
|
"terminal.input",
|
||||||
"terminal.resize",
|
"terminal.resize",
|
||||||
|
|||||||
@@ -440,6 +440,8 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
|
|||||||
{ name: "approval.history", scope: "operator.approvals", since: "2026.7" },
|
{ name: "approval.history", scope: "operator.approvals", since: "2026.7" },
|
||||||
{ name: "plugin.surface.refresh", scope: "operator.read", since: "<=2026.7" },
|
{ name: "plugin.surface.refresh", scope: "operator.read", since: "<=2026.7" },
|
||||||
{ name: "conversations.list", scope: "operator.admin", 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;
|
] as const;
|
||||||
|
|
||||||
const CORE_GATEWAY_METHOD_SPEC_BY_NAME: ReadonlyMap<string, CoreGatewayMethodSpec> = new Map(
|
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", () => {
|
it("appends new methods after model probing without shifting older method indices", () => {
|
||||||
expect(listGatewayMethods().slice(-7)).toEqual([
|
expect(listGatewayMethods().slice(-9)).toEqual([
|
||||||
"models.probe",
|
"models.probe",
|
||||||
"migrations.memory.plan",
|
"migrations.memory.plan",
|
||||||
"migrations.memory.apply",
|
"migrations.memory.apply",
|
||||||
@@ -66,6 +66,8 @@ describe("listGatewayMethods", () => {
|
|||||||
"approval.history",
|
"approval.history",
|
||||||
"plugin.surface.refresh",
|
"plugin.surface.refresh",
|
||||||
"conversations.list",
|
"conversations.list",
|
||||||
|
"session.discussion.info",
|
||||||
|
"session.discussion.open",
|
||||||
]);
|
]);
|
||||||
const methods = listGatewayMethods();
|
const methods = listGatewayMethods();
|
||||||
expect(methods.indexOf("node.pluginSurface.refresh")).toBe(
|
expect(methods.indexOf("node.pluginSurface.refresh")).toBe(
|
||||||
@@ -126,7 +128,7 @@ describe("listGatewayMethods", () => {
|
|||||||
"exec.approval.get",
|
"exec.approval.get",
|
||||||
]);
|
]);
|
||||||
expect(methods).toContain("tts.speak");
|
expect(methods).toContain("tts.speak");
|
||||||
expect(coreMethods.slice(-14)).toEqual([
|
expect(coreMethods.slice(-16)).toEqual([
|
||||||
"sessions.catalog.continue",
|
"sessions.catalog.continue",
|
||||||
"sessions.catalog.archive",
|
"sessions.catalog.archive",
|
||||||
"approval.get",
|
"approval.get",
|
||||||
@@ -141,6 +143,8 @@ describe("listGatewayMethods", () => {
|
|||||||
"approval.history",
|
"approval.history",
|
||||||
"plugin.surface.refresh",
|
"plugin.surface.refresh",
|
||||||
"conversations.list",
|
"conversations.list",
|
||||||
|
"session.discussion.info",
|
||||||
|
"session.discussion.open",
|
||||||
]);
|
]);
|
||||||
expect(methods.indexOf("approval.get")).toBeGreaterThan(methods.indexOf("tts.speak"));
|
expect(methods.indexOf("approval.get")).toBeGreaterThan(methods.indexOf("tts.speak"));
|
||||||
expect(methods.indexOf("approval.resolve")).toBe(methods.indexOf("approval.get") + 1);
|
expect(methods.indexOf("approval.resolve")).toBe(methods.indexOf("approval.get") + 1);
|
||||||
|
|||||||
@@ -225,6 +225,10 @@ const loadSessionCatalogHandlers = lazyHandlerModule(
|
|||||||
() => import("./server-methods/session-catalog.js"),
|
() => import("./server-methods/session-catalog.js"),
|
||||||
(module) => module.sessionCatalogHandlers,
|
(module) => module.sessionCatalogHandlers,
|
||||||
);
|
);
|
||||||
|
const loadSessionDiscussionHandlers = lazyHandlerModule(
|
||||||
|
() => import("./server-methods/session-discussion.js"),
|
||||||
|
(module) => module.sessionDiscussionHandlers,
|
||||||
|
);
|
||||||
const loadSkillsHandlers = lazyHandlerModule(
|
const loadSkillsHandlers = lazyHandlerModule(
|
||||||
() => import("./server-methods/skills.js"),
|
() => import("./server-methods/skills.js"),
|
||||||
(module) => module.skillsHandlers,
|
(module) => module.skillsHandlers,
|
||||||
@@ -676,6 +680,10 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
|||||||
],
|
],
|
||||||
loadHandlers: loadSessionCatalogHandlers,
|
loadHandlers: loadSessionCatalogHandlers,
|
||||||
}),
|
}),
|
||||||
|
...createLazyCoreHandlers({
|
||||||
|
methods: ["session.discussion.info", "session.discussion.open"],
|
||||||
|
loadHandlers: loadSessionDiscussionHandlers,
|
||||||
|
}),
|
||||||
...createLazyCoreHandlers({
|
...createLazyCoreHandlers({
|
||||||
methods: [
|
methods: [
|
||||||
"sessions.list",
|
"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",
|
statusRenamed: "Renamed",
|
||||||
statusModified: "Modified",
|
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: {
|
workspaceFiles: {
|
||||||
label: "Thread workspace",
|
label: "Thread workspace",
|
||||||
expand: "Expand thread workspace",
|
expand: "Expand thread workspace",
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type {
|
|||||||
SessionCatalogHost,
|
SessionCatalogHost,
|
||||||
SessionCatalogSession,
|
SessionCatalogSession,
|
||||||
SessionCatalogTranscriptItem,
|
SessionCatalogTranscriptItem,
|
||||||
|
SessionDiscussionInfo,
|
||||||
|
SessionDiscussionState,
|
||||||
SessionsCatalogContinueResult,
|
SessionsCatalogContinueResult,
|
||||||
SessionsCatalogReadResult,
|
SessionsCatalogReadResult,
|
||||||
SessionsFilesRevealResult,
|
SessionsFilesRevealResult,
|
||||||
@@ -53,6 +55,7 @@ import {
|
|||||||
} from "../../components/command-palette-contract.ts";
|
} from "../../components/command-palette-contract.ts";
|
||||||
import "../../components/modal-dialog.ts";
|
import "../../components/modal-dialog.ts";
|
||||||
import { createDockPanelLayout } from "../../components/dock-panel-layout.ts";
|
import { createDockPanelLayout } from "../../components/dock-panel-layout.ts";
|
||||||
|
import { icons } from "../../components/icons.ts";
|
||||||
import { isCloudWorkerPlacementState } from "../../components/session-row-badges.ts";
|
import { isCloudWorkerPlacementState } from "../../components/session-row-badges.ts";
|
||||||
import { t } from "../../i18n/index.ts";
|
import { t } from "../../i18n/index.ts";
|
||||||
import {
|
import {
|
||||||
@@ -192,6 +195,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
CHAT_DETAIL_FULL_MESSAGE_MAX_CHARS,
|
CHAT_DETAIL_FULL_MESSAGE_MAX_CHARS,
|
||||||
type DetailFullMessageResult,
|
type DetailFullMessageResult,
|
||||||
|
type SidebarContent,
|
||||||
type SidebarFullMessageRequest,
|
type SidebarFullMessageRequest,
|
||||||
} from "./components/chat-sidebar.ts";
|
} from "./components/chat-sidebar.ts";
|
||||||
import { ChatTranscriptController } from "./components/chat-thread.ts";
|
import { ChatTranscriptController } from "./components/chat-thread.ts";
|
||||||
@@ -406,6 +410,8 @@ class ChatPane extends OpenClawLightDomElement {
|
|||||||
private swarmBoardSnapshot: BoardSnapshot | null = null;
|
private swarmBoardSnapshot: BoardSnapshot | null = null;
|
||||||
private swarmBoardSnapshotBase: BoardSnapshot | null = null;
|
private swarmBoardSnapshotBase: BoardSnapshot | null = null;
|
||||||
private swarmBoardSnapshotRequest = 0;
|
private swarmBoardSnapshotRequest = 0;
|
||||||
|
private readonly sessionDiscussionStates = new Map<string, SessionDiscussionState>();
|
||||||
|
private readonly sessionDiscussionProbes = new Set<string>();
|
||||||
private headerRenameInitialLabel: string | null = null;
|
private headerRenameInitialLabel: string | null = null;
|
||||||
private headerRenameInitialValue = "";
|
private headerRenameInitialValue = "";
|
||||||
private headerRenameSessionKey = "";
|
private headerRenameSessionKey = "";
|
||||||
@@ -2166,6 +2172,13 @@ class ChatPane extends OpenClawLightDomElement {
|
|||||||
const nextSessionKey = catalogKey
|
const nextSessionKey = catalogKey
|
||||||
? this.sessionKey
|
? this.sessionKey
|
||||||
: resolveSessionKey(this.sessionKey, this.context.gateway.snapshot.hello);
|
: 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) {
|
if (nextSessionKey && nextSessionKey !== this.state.sessionKey) {
|
||||||
this.switchPaneSession(nextSessionKey);
|
this.switchPaneSession(nextSessionKey);
|
||||||
} else if (catalogKey && this.catalogRequestedSessionKey !== this.sessionKey) {
|
} else if (catalogKey && this.catalogRequestedSessionKey !== this.sessionKey) {
|
||||||
@@ -2332,6 +2345,7 @@ class ChatPane extends OpenClawLightDomElement {
|
|||||||
this.taskSuggestions = [];
|
this.taskSuggestions = [];
|
||||||
this.taskSuggestionBusyIds.clear();
|
this.taskSuggestionBusyIds.clear();
|
||||||
this.taskSuggestionOperations.clear();
|
this.taskSuggestionOperations.clear();
|
||||||
|
this.sessionDiscussionStates.clear();
|
||||||
this.resetOlderMessagesViewport();
|
this.resetOlderMessagesViewport();
|
||||||
state.chatLoading = false;
|
state.chatLoading = false;
|
||||||
}
|
}
|
||||||
@@ -2339,6 +2353,17 @@ class ChatPane extends OpenClawLightDomElement {
|
|||||||
state.connected = snapshot.connected;
|
state.connected = snapshot.connected;
|
||||||
state.connectionEpoch = this.connectionGeneration;
|
state.connectionEpoch = this.connectionGeneration;
|
||||||
state.hello = snapshot.hello;
|
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 =
|
state.terminalAvailable =
|
||||||
this.context.config.current.terminalEnabled &&
|
this.context.config.current.terminalEnabled &&
|
||||||
snapshot.connected &&
|
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(
|
private renderPaneHeader(
|
||||||
sessionWorkspace: SessionWorkspaceProps,
|
sessionWorkspace: SessionWorkspaceProps,
|
||||||
backgroundTasks: BackgroundTasksProps,
|
backgroundTasks: BackgroundTasksProps,
|
||||||
@@ -2733,6 +2879,7 @@ class ChatPane extends OpenClawLightDomElement {
|
|||||||
this.state?.connected === true &&
|
this.state?.connected === true &&
|
||||||
hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null),
|
hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null),
|
||||||
terminalAction: renderCatalogTerminalButton(this.state, this.catalogSession),
|
terminalAction: renderCatalogTerminalButton(this.state, this.catalogSession),
|
||||||
|
discussionAction: this.renderSessionDiscussionAction(),
|
||||||
diffAction: renderSessionDiffToggle(sessionWorkspace),
|
diffAction: renderSessionDiffToggle(sessionWorkspace),
|
||||||
backgroundTasksAction: renderBackgroundTasksToggle(backgroundTasks),
|
backgroundTasksAction: renderBackgroundTasksToggle(backgroundTasks),
|
||||||
workspaceAction: renderSessionWorkspaceToggle(sessionWorkspace),
|
workspaceAction: renderSessionWorkspaceToggle(sessionWorkspace),
|
||||||
|
|||||||
@@ -507,6 +507,9 @@ export function resetChatStateForRouteSession(
|
|||||||
saveChatMessagesForSession(state, previousSessionKey);
|
saveChatMessagesForSession(state, previousSessionKey);
|
||||||
const snapshot = restoreChatMessagesForSession(state, sessionKey);
|
const snapshot = restoreChatMessagesForSession(state, sessionKey);
|
||||||
state.sessionKey = sessionKey;
|
state.sessionKey = sessionKey;
|
||||||
|
if (state.sidebarContent?.kind === "session-discussion") {
|
||||||
|
state.sidebarContent = { ...state.sidebarContent, sessionKey };
|
||||||
|
}
|
||||||
state.selectedChatSessionArchived =
|
state.selectedChatSessionArchived =
|
||||||
state.sessionsResult?.sessions.some(
|
state.sessionsResult?.sessions.some(
|
||||||
(row) => row.archived === true && areUiSessionKeysEquivalent(row.key, sessionKey),
|
(row) => row.archived === true && areUiSessionKeysEquivalent(row.key, sessionKey),
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ function mount(patch: Partial<ChatPaneHeaderProps> = {}) {
|
|||||||
copiedAction: null,
|
copiedAction: null,
|
||||||
canRename: true,
|
canRename: true,
|
||||||
terminalAction: nothing,
|
terminalAction: nothing,
|
||||||
|
discussionAction: nothing,
|
||||||
diffAction: nothing,
|
diffAction: nothing,
|
||||||
backgroundTasksAction: nothing,
|
backgroundTasksAction: nothing,
|
||||||
workspaceAction: nothing,
|
workspaceAction: nothing,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type ChatPaneHeaderProps = {
|
|||||||
copiedAction: ChatPaneHeaderAction | null;
|
copiedAction: ChatPaneHeaderAction | null;
|
||||||
canRename: boolean;
|
canRename: boolean;
|
||||||
terminalAction: TemplateResult | typeof nothing;
|
terminalAction: TemplateResult | typeof nothing;
|
||||||
|
discussionAction: TemplateResult | typeof nothing;
|
||||||
diffAction: TemplateResult | typeof nothing;
|
diffAction: TemplateResult | typeof nothing;
|
||||||
backgroundTasksAction: TemplateResult | typeof nothing;
|
backgroundTasksAction: TemplateResult | typeof nothing;
|
||||||
workspaceAction: TemplateResult | typeof nothing;
|
workspaceAction: TemplateResult | typeof nothing;
|
||||||
@@ -297,7 +298,7 @@ export function renderChatPaneHeader(props: ChatPaneHeaderProps) {
|
|||||||
`
|
`
|
||||||
: nothing}
|
: nothing}
|
||||||
<div class="chat-pane__actions">
|
<div class="chat-pane__actions">
|
||||||
${props.boardDockAction ?? nothing} ${props.terminalAction}
|
${props.boardDockAction ?? nothing} ${props.terminalAction} ${props.discussionAction}
|
||||||
${props.catalog
|
${props.catalog
|
||||||
? nothing
|
? nothing
|
||||||
: html`${props.diffAction} ${props.backgroundTasksAction} ${props.workspaceAction}`}
|
: html`${props.diffAction} ${props.backgroundTasksAction} ${props.workspaceAction}`}
|
||||||
|
|||||||
@@ -20,10 +20,16 @@ import {
|
|||||||
import { copyToClipboard } from "../../../lib/clipboard.ts";
|
import { copyToClipboard } from "../../../lib/clipboard.ts";
|
||||||
import { type EditorId, openEditor } from "../../../lib/editor-links.ts";
|
import { type EditorId, openEditor } from "../../../lib/editor-links.ts";
|
||||||
import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts";
|
import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts";
|
||||||
|
import "./session-discussion-panel.ts";
|
||||||
import "./session-diff-panel.ts";
|
import "./session-diff-panel.ts";
|
||||||
import { renderChatSidebarEditorMenu } from "./chat-sidebar-editor-menu.ts";
|
import { renderChatSidebarEditorMenu } from "./chat-sidebar-editor-menu.ts";
|
||||||
import type { FileEditorViewHandle } from "./file-editor-view.ts";
|
import type { FileEditorViewHandle } from "./file-editor-view.ts";
|
||||||
import type { SessionDiffLoader } from "./session-diff-panel.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;
|
export const CHAT_DETAIL_FULL_MESSAGE_MAX_CHARS = 500_000;
|
||||||
|
|
||||||
@@ -81,6 +87,18 @@ type SessionDiffSidebarContent = {
|
|||||||
unavailableReason?: DetailUnavailableReason | null;
|
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 =
|
type FileSaveOutcome =
|
||||||
| { ok: true; hash: string; updatedAtMs?: number }
|
| { ok: true; hash: string; updatedAtMs?: number }
|
||||||
| { ok: false; code: "conflict"; currentHash?: string }
|
| { ok: false; code: "conflict"; currentHash?: string }
|
||||||
@@ -134,6 +152,7 @@ export type SidebarContent =
|
|||||||
| CanvasSidebarContent
|
| CanvasSidebarContent
|
||||||
| ImageSidebarContent
|
| ImageSidebarContent
|
||||||
| FileSidebarContent
|
| FileSidebarContent
|
||||||
|
| SessionDiscussionSidebarContent
|
||||||
| SessionDiffSidebarContent;
|
| SessionDiffSidebarContent;
|
||||||
|
|
||||||
function hasFullMessageRequest(content: SidebarContent): content is SidebarContent & {
|
function hasFullMessageRequest(content: SidebarContent): content is SidebarContent & {
|
||||||
@@ -513,9 +532,11 @@ function renderMarkdownSidebar(props: MarkdownSidebarProps) {
|
|||||||
? content.name.trim() || "File"
|
? content.name.trim() || "File"
|
||||||
: content?.kind === "session-diff"
|
: content?.kind === "session-diff"
|
||||||
? t("chat.sessionDiff.title")
|
? t("chat.sessionDiff.title")
|
||||||
: content?.kind === "markdown"
|
: content?.kind === "session-discussion"
|
||||||
? "Markdown Preview"
|
? t("chat.sessionDiscussion.title")
|
||||||
: "Tool Details";
|
: content?.kind === "markdown"
|
||||||
|
? "Markdown Preview"
|
||||||
|
: "Tool Details";
|
||||||
return html`
|
return html`
|
||||||
<div class="sidebar-panel">
|
<div class="sidebar-panel">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
@@ -531,7 +552,11 @@ function renderMarkdownSidebar(props: MarkdownSidebarProps) {
|
|||||||
</button>
|
</button>
|
||||||
</openclaw-tooltip>
|
</openclaw-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-content">
|
<div
|
||||||
|
class="sidebar-content ${content?.kind === "session-discussion"
|
||||||
|
? "sidebar-content--discussion"
|
||||||
|
: ""}"
|
||||||
|
>
|
||||||
${props.error
|
${props.error
|
||||||
? html`
|
? html`
|
||||||
<div class="callout danger">${props.error}</div>
|
<div class="callout danger">${props.error}</div>
|
||||||
@@ -553,46 +578,34 @@ function renderMarkdownSidebar(props: MarkdownSidebarProps) {
|
|||||||
? renderFileSidebarContent(content, props.onViewRawText, props.fileView)
|
? renderFileSidebarContent(content, props.onViewRawText, props.fileView)
|
||||||
: content.kind === "session-diff"
|
: content.kind === "session-diff"
|
||||||
? html`<openclaw-session-diff .loader=${content.load}></openclaw-session-diff>`
|
? html`<openclaw-session-diff .loader=${content.load}></openclaw-session-diff>`
|
||||||
: content.kind === "canvas"
|
: content.kind === "session-discussion"
|
||||||
? html`
|
? html`
|
||||||
<div class="chat-tool-card__preview" data-kind="canvas">
|
<openclaw-session-discussion
|
||||||
<div class="chat-tool-card__preview-panel" data-side="front">
|
.sessionKey=${content.sessionKey}
|
||||||
${keyed(
|
.canOpen=${content.canOpen}
|
||||||
`${canvasSandbox}\u0000${canvasSrc ?? ""}\u0000${content.preferredHeight ?? ""}`,
|
.loadInfo=${content.loadInfo}
|
||||||
html`
|
.openDiscussion=${content.openDiscussion}
|
||||||
<iframe
|
.onStateChange=${content.onStateChange}
|
||||||
class="chat-tool-card__preview-frame"
|
></openclaw-session-discussion>
|
||||||
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>
|
|
||||||
`
|
`
|
||||||
: content.kind === "image"
|
: content.kind === "canvas"
|
||||||
? html`
|
? 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">
|
<div class="chat-tool-card__preview-panel" data-side="front">
|
||||||
<img
|
${keyed(
|
||||||
class="chat-tool-card__preview-image"
|
`${canvasSandbox}\u0000${canvasSrc ?? ""}\u0000${content.preferredHeight ?? ""}`,
|
||||||
src=${content.src}
|
html`
|
||||||
alt=${title}
|
<iframe
|
||||||
style="display:block;max-width:100%;height:auto;border-radius:8px;"
|
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>
|
</div>
|
||||||
${content.rawText?.trim()
|
${content.rawText?.trim()
|
||||||
? html`
|
? html`
|
||||||
@@ -605,35 +618,61 @@ function renderMarkdownSidebar(props: MarkdownSidebarProps) {
|
|||||||
: nothing}
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: html`
|
: content.kind === "image"
|
||||||
<section class="sidebar-markdown-shell">
|
? html`
|
||||||
<div class="sidebar-markdown-shell__toolbar">
|
<div class="chat-tool-card__preview" data-kind="image">
|
||||||
<div class="sidebar-markdown-shell__intro">
|
<div class="chat-tool-card__preview-panel" data-side="front">
|
||||||
<div class="sidebar-markdown-shell__eyebrow">
|
<img
|
||||||
${icons.scrollText}
|
class="chat-tool-card__preview-image"
|
||||||
<span>${t("chat.detailPanel.renderedMarkdown")}</span>
|
src=${content.src}
|
||||||
</div>
|
alt=${title}
|
||||||
<div class="sidebar-markdown-shell__hint">
|
style="display:block;max-width:100%;height:auto;border-radius:8px;"
|
||||||
${t("chat.detailPanel.renderedMarkdownHint")}
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button @click=${props.onViewRawText} class="btn btn--sm" type="button">
|
${content.rawText?.trim()
|
||||||
${t("chat.detailPanel.viewRawText")}
|
? html`
|
||||||
</button>
|
<div style="margin-top: 12px;">
|
||||||
|
<button @click=${props.onViewRawText} class="btn" type="button">
|
||||||
|
${t("chat.detailPanel.viewRawText")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
${markdownHtml
|
`
|
||||||
? html`
|
: html`
|
||||||
<article class="sidebar-markdown-reader sidebar-markdown">
|
<section class="sidebar-markdown-shell">
|
||||||
${unsafeHTML(markdownHtml)}
|
<div class="sidebar-markdown-shell__toolbar">
|
||||||
</article>
|
<div class="sidebar-markdown-shell__intro">
|
||||||
`
|
<div class="sidebar-markdown-shell__eyebrow">
|
||||||
: html`
|
${icons.scrollText}
|
||||||
<div class="sidebar-markdown-empty">
|
<span>${t("chat.detailPanel.renderedMarkdown")}</span>
|
||||||
${t("chat.detailPanel.noPreviewableMarkdown")}
|
|
||||||
</div>
|
</div>
|
||||||
`}
|
<div class="sidebar-markdown-shell__hint">
|
||||||
</section>
|
${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> `}
|
: html` <div class="muted">${t("chat.detailPanel.noContent")}</div> `}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1234,8 +1273,11 @@ class ChatDetailPanel extends OpenClawLightDomElement {
|
|||||||
const currentMatchIndex = matches.length
|
const currentMatchIndex = matches.length
|
||||||
? Math.min(this.fileSearchMatchIndex, matches.length - 1)
|
? Math.min(this.fileSearchMatchIndex, matches.length - 1)
|
||||||
: 0;
|
: 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`
|
return html`
|
||||||
<div @click=${this.handlePanelClick}>
|
<div class=${fillHost ? "sidebar-panel-host--fill" : ""} @click=${this.handlePanelClick}>
|
||||||
${renderMarkdownSidebar({
|
${renderMarkdownSidebar({
|
||||||
content: this.visibleContent,
|
content: this.visibleContent,
|
||||||
error: this.error,
|
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;
|
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 {
|
.sidebar-file-view {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user