diff --git a/CHANGELOG.md b/CHANGELOG.md index bd6a4c7a34e..636956934fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,9 +40,12 @@ Docs: https://docs.openclaw.ai - Plugins/binding: add `onConversationBindingResolved(...)` so plugins can react immediately after bind approvals or denies without blocking channel interaction acknowledgements. (#48678) Thanks @huntharo. - CLI/config: expand `config set` with SecretRef and provider builder modes, JSON/batch assignment support, and `--dry-run` validation with structured JSON output. (#49296) Thanks @joshavant. - Control UI/appearance: unify theme border radii across Claw, Knot, and Dash, and add a Roundness slider to the Appearance settings so users can adjust corner radius from sharp to fully rounded. Thanks @BunsDev. +- Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev. ### Fixes +- Plugins/bundler TDZ: fix `RESERVED_COMMANDS` temporal dead zone error that prevented device-pair, phone-control, and talk-voice plugins from registering when the bundler placed the commands module after call sites in the same output chunk. Thanks @BunsDev. +- Plugins/imports: fix stale googlechat runtime-api import paths and signal SDK circular re-exports broken by recent plugin-sdk refactors. Thanks @BunsDev. - Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli. - Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. (#47560) Thanks @ngutman. - Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc. diff --git a/src/plugin-sdk/signal.ts b/src/plugin-sdk/signal.ts index f44dfa2f9ff..2935f634b19 100644 --- a/src/plugin-sdk/signal.ts +++ b/src/plugin-sdk/signal.ts @@ -52,6 +52,12 @@ export { listSignalAccountIds, resolveDefaultSignalAccountId, } from "../../extensions/signal/api.js"; -export { resolveSignalReactionLevel } from "../../extensions/signal/runtime-api.js"; -export { removeReactionSignal, sendReactionSignal } from "../../extensions/signal/runtime-api.js"; -export { sendMessageSignal } from "../../extensions/signal/runtime-api.js"; +export { resolveSignalReactionLevel } from "../../extensions/signal/src/reaction-level.js"; +export { signalMessageActions } from "../../extensions/signal/src/message-actions.js"; +export { monitorSignalProvider } from "../../extensions/signal/src/monitor.js"; +export { probeSignal } from "../../extensions/signal/src/probe.js"; +export { + removeReactionSignal, + sendReactionSignal, +} from "../../extensions/signal/src/send-reactions.js"; +export { sendMessageSignal } from "../../extensions/signal/src/send.js"; diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index b16b3aef4ed..a44cbc26e7e 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -35,50 +35,15 @@ let registryLocked = false; const MAX_ARGS_LENGTH = 4096; /** - * Reserved command names that plugins cannot override. - * These are built-in commands from commands-registry.data.ts. + * Reserved command names that plugins cannot override (built-in commands). + * + * Constructed lazily inside validateCommandName to avoid TDZ errors: the + * bundler can place this module's body after call sites within the same + * output chunk, so any module-level const/let would be uninitialized when + * first accessed during plugin registration. */ -const RESERVED_COMMANDS = new Set([ - // Core commands - "help", - "commands", - "status", - "whoami", - "context", - "btw", - // Session management - "stop", - "restart", - "reset", - "new", - "compact", - // Configuration - "config", - "debug", - "allowlist", - "activation", - // Agent control - "skill", - "subagents", - "kill", - "steer", - "tell", - "model", - "models", - "queue", - // Messaging - "send", - // Execution - "bash", - "exec", - // Mode toggles - "think", - "verbose", - "reasoning", - "elevated", - // Billing - "usage", -]); +// eslint-disable-next-line no-var -- var avoids TDZ when bundler reorders module bodies in a chunk +var reservedCommands: Set | undefined; /** * Validate a command name. @@ -97,8 +62,41 @@ export function validateCommandName(name: string): string | null { return "Command name must start with a letter and contain only letters, numbers, hyphens, and underscores"; } - // Check reserved commands - if (RESERVED_COMMANDS.has(trimmed)) { + reservedCommands ??= new Set([ + "help", + "commands", + "status", + "whoami", + "context", + "btw", + "stop", + "restart", + "reset", + "new", + "compact", + "config", + "debug", + "allowlist", + "activation", + "skill", + "subagents", + "kill", + "steer", + "tell", + "model", + "models", + "queue", + "send", + "bash", + "exec", + "think", + "verbose", + "reasoning", + "elevated", + "usage", + ]); + + if (reservedCommands.has(trimmed)) { return `Command name "${trimmed}" is reserved by a built-in command`; } diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index 16cf15d51ee..bce1c2422cf 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -194,10 +194,31 @@ img.chat-avatar { padding-right: 36px; } -.chat-copy-btn { +.chat-bubble-actions { position: absolute; top: 6px; right: 8px; + display: flex; + gap: 4px; + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease-out; +} + +.chat-bubble:hover .chat-bubble-actions { + opacity: 1; + pointer-events: auto; +} + +@media (hover: none) { + .chat-bubble-actions { + opacity: 1; + pointer-events: auto; + } +} + +.chat-copy-btn, +.chat-expand-btn { border: 1px solid var(--border); background: var(--bg); color: var(--muted); @@ -206,11 +227,7 @@ img.chat-avatar { font-size: 14px; line-height: 1; cursor: pointer; - opacity: 0; - pointer-events: none; - transition: - opacity 120ms ease-out, - background 120ms ease-out; + transition: background 120ms ease-out; } .chat-copy-btn__icon { @@ -250,12 +267,8 @@ img.chat-avatar { opacity: 1; } -.chat-bubble:hover .chat-copy-btn { - opacity: 1; - pointer-events: auto; -} - -.chat-copy-btn:hover { +.chat-copy-btn:hover, +.chat-expand-btn:hover { background: var(--bg-hover); } @@ -265,33 +278,37 @@ img.chat-avatar { } .chat-copy-btn[data-error="1"] { - opacity: 1; - pointer-events: auto; border-color: var(--danger-subtle); background: var(--danger-subtle); color: var(--danger); } .chat-copy-btn[data-copied="1"] { - opacity: 1; - pointer-events: auto; border-color: var(--ok-subtle); background: var(--ok-subtle); color: var(--ok); } -.chat-copy-btn:focus-visible { - opacity: 1; - pointer-events: auto; +.chat-copy-btn:focus-visible, +.chat-expand-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } -@media (hover: none) { - .chat-copy-btn { - opacity: 1; - pointer-events: auto; - } +.chat-expand-btn__icon { + display: inline-flex; + width: 14px; + height: 14px; +} + +.chat-expand-btn__icon svg { + width: 14px; + height: 14px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; + stroke-linecap: round; + stroke-linejoin: round; } /* Light mode: restore borders */ diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index d4835d42aad..4edba864cd3 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1312,6 +1312,96 @@ font: inherit; } +/* Code block wrapper chrome (generated by markdown renderer) */ + +.code-block-wrapper { + position: relative; + border-radius: 6px; + overflow: hidden; + margin-top: 0.75em; +} + +.code-block-wrapper pre { + margin: 0; + border-radius: 0 0 6px 6px; +} + +.code-block-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 4px 8px 4px 12px; + background: rgba(0, 0, 0, 0.25); + font-size: 12px; + line-height: 1; +} + +:root[data-theme-mode="light"] .code-block-header { + background: rgba(0, 0, 0, 0.08); +} + +.code-block-lang { + color: var(--muted); + font-family: var(--mono); + font-size: 11px; + text-transform: lowercase; + user-select: none; +} + +.code-block-copy { + appearance: none; + border: none; + background: transparent; + color: var(--muted); + font-size: 11px; + font-family: var(--font-body); + cursor: pointer; + padding: 2px 6px; + border-radius: var(--radius-sm); + transition: + color 150ms ease, + background 150ms ease; +} + +.code-block-copy:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.1); +} + +:root[data-theme-mode="light"] .code-block-copy:hover { + background: rgba(0, 0, 0, 0.08); +} + +.code-block-copy__done { + display: none; +} + +.code-block-copy.copied .code-block-copy__idle { + display: none; +} + +.code-block-copy.copied .code-block-copy__done { + display: inline; + color: var(--success, #22c55e); +} + +.json-collapse { + margin-top: 0.75em; +} + +.json-collapse summary { + cursor: pointer; + font-size: 12px; + color: var(--muted); + padding: 4px 8px; + user-select: none; +} + +.json-collapse summary:hover { + color: var(--text); +} + /* =========================================== Lists =========================================== */ diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index e83825ab899..d27fb221582 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -487,7 +487,7 @@ export function renderChatMobileToggle(state: AppViewState) { `; } -function switchChatSession(state: AppViewState, nextSessionKey: string) { +export function switchChatSession(state: AppViewState, nextSessionKey: string) { state.sessionKey = nextSessionKey; state.chatMessage = ""; state.chatStream = null; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index dd9ac932a2e..c0535cd6c30 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -14,6 +14,7 @@ import { renderTab, renderSidebarConnectionStatus, renderTopbarThemeModeToggle, + switchChatSession, } from "./app-render.helpers.ts"; import type { AppViewState } from "./app-view-state.ts"; import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts"; @@ -765,6 +766,10 @@ export function renderApp(state: AppViewState) { onRefresh: () => loadSessions(state), onPatch: (key, patch) => patchSession(state, key, patch), onDelete: (key) => deleteSessionAndRefresh(state, key), + onNavigateToChat: (sessionKey) => { + switchChatSession(state, sessionKey); + state.setTab("chat" as import("./navigation.ts").Tab); + }, }), ) : nothing @@ -865,6 +870,10 @@ export function renderApp(state: AppViewState) { } await loadCronRuns(state, state.cronRunsJobId); }, + onNavigateToChat: (sessionKey) => { + switchChatSession(state, sessionKey); + state.setTab("chat" as import("./navigation.ts").Tab); + }, }), ) : nothing @@ -1432,10 +1441,7 @@ export function renderApp(state: AppViewState) { state.setTab("agents" as import("./navigation.ts").Tab); }, onSessionSelect: (key: string) => { - state.setSessionKey(key); - state.chatMessages = []; - void loadChatHistory(state); - void state.loadAssistantIdentity(); + switchChatSession(state, key); }, showNewMessages: state.chatNewMessagesBelow && !state.chatManualRefreshInFlight, onScrollToBottom: () => state.scrollToBottom(), diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 4e9742fbdbc..df806794645 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -357,7 +357,6 @@ export type AppViewState = { handleDebugCall: () => Promise; handleRunUpdate: () => Promise; setPassword: (next: string) => void; - setSessionKey: (next: string) => void; setChatMessage: (next: string) => void; handleSendChat: (messageOverride?: string, opts?: { restoreDraft?: boolean }) => Promise; handleAbortChat: () => Promise; diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 7dcc0b62e19..d5cc37f4fe9 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -620,6 +620,20 @@ function jsonSummaryLabel(parsed: unknown): string { return "JSON"; } +function renderExpandButton(markdown: string, onOpenSidebar: (content: string) => void) { + return html` + + `; +} + function renderGroupedMessage( message: unknown, opts: { isStreaming: boolean; showReasoning: boolean; showToolCalls?: boolean }, @@ -647,6 +661,7 @@ function renderGroupedMessage( const reasoningMarkdown = extractedThinking ? formatReasoningMarkdown(extractedThinking) : null; const markdown = markdownBase; const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim()); + const canExpand = role === "assistant" && Boolean(onOpenSidebar && markdown?.trim()); // Detect pure-JSON messages and render as collapsible block const jsonResult = markdown && !opts.isStreaming ? detectJson(markdown) : null; @@ -674,9 +689,18 @@ function renderGroupedMessage( const toolPreview = markdown && !toolSummaryLabel ? markdown.trim().replace(/\s+/g, " ").slice(0, 120) : ""; + const hasActions = canCopyMarkdown || canExpand; + return html`
- ${canCopyMarkdown ? html`
${renderCopyAsMarkdownButton(markdown!)}
` : nothing} + ${ + hasActions + ? html`
+ ${canExpand ? renderExpandButton(markdown!, onOpenSidebar!) : nothing} + ${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing} +
` + : nothing + } ${ isToolMessage ? html` diff --git a/ui/src/ui/icons.ts b/ui/src/ui/icons.ts index de594541110..39815b8aa0b 100644 --- a/ui/src/ui/icons.ts +++ b/ui/src/ui/icons.ts @@ -441,6 +441,13 @@ export const icons = { `, + panelRightOpen: html` + + + + + + `, } as const; export type IconName = keyof typeof icons; diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index 1509637b46f..e87879d0321 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -91,6 +91,7 @@ export type CronProps = { cronRunsQuery?: string; cronRunsSortDir?: CronSortDir; }) => void | Promise; + onNavigateToChat?: (sessionKey: string) => void; }; function getRunStatusOptions(): Array<{ value: CronRunsStatusValue; label: string }> { @@ -674,7 +675,7 @@ export function renderCron(props: CronProps) { ` : html`
- ${runs.map((entry) => renderRun(entry, props.basePath))} + ${runs.map((entry) => renderRun(entry, props.basePath, props.onNavigateToChat))}
` } @@ -1709,7 +1710,11 @@ function runDeliveryLabel(value: string): string { } } -function renderRun(entry: CronRunLogEntry, basePath: string) { +function renderRun( + entry: CronRunLogEntry, + basePath: string, + onNavigateToChat?: (sessionKey: string) => void, +) { const chatUrl = typeof entry.sessionKey === "string" && entry.sessionKey.trim().length > 0 ? `${pathForTab("chat", basePath)}?session=${encodeURIComponent(entry.sessionKey)}` @@ -1749,7 +1754,22 @@ function renderRun(entry: CronRunLogEntry, basePath: string) { } ${ chatUrl - ? html`` + ? html`` : nothing } ${entry.error ? html`
${entry.error}
` : nothing} diff --git a/ui/src/ui/views/sessions.ts b/ui/src/ui/views/sessions.ts index 2620ec35acf..e028b7b4c85 100644 --- a/ui/src/ui/views/sessions.ts +++ b/ui/src/ui/views/sessions.ts @@ -43,6 +43,7 @@ export type SessionsProps = { }, ) => void; onDelete: (key: string) => void; + onNavigateToChat?: (sessionKey: string) => void; }; const THINK_LEVELS = ["", "off", "minimal", "low", "medium", "high", "xhigh"] as const; @@ -337,6 +338,7 @@ export function renderSessions(props: SessionsProps) { props.onActionsOpenChange, props.actionsOpenKey, props.loading, + props.onNavigateToChat, ), ) } @@ -391,6 +393,7 @@ function renderRow( onActionsOpenChange: (key: string | null) => void, actionsOpenKey: string | null, disabled: boolean, + onNavigateToChat?: (sessionKey: string) => void, ) { const updated = row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : "n/a"; const rawThinking = row.thinkingLevel ?? ""; @@ -430,7 +433,30 @@ function renderRow(