diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index 610d63f..438c3d1 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -220,6 +220,7 @@ export class GatewayManager extends EventEmitter { }> = new Map(); private deviceIdentity: DeviceIdentity | null = null; private restartDebounceTimer: NodeJS.Timeout | null = null; + private reloadDebounceTimer: NodeJS.Timeout | null = null; private lifecycleEpoch = 0; private deferredRestartPending = false; private restartInFlight: Promise | null = null; @@ -640,6 +641,71 @@ export class GatewayManager extends EventEmitter { }, delayMs); } + /** + * Ask the Gateway process to reload config in-place when possible. + * Falls back to restart on unsupported platforms or signaling failures. + */ + async reload(): Promise { + if (this.isRestartDeferred()) { + this.markDeferredRestart('reload'); + return; + } + + if (!this.process?.pid || this.status.state !== 'running') { + logger.warn('Gateway reload requested while not running; falling back to restart'); + await this.restart(); + return; + } + + if (process.platform === 'win32') { + logger.debug('Windows detected, falling back to Gateway restart for reload'); + await this.restart(); + return; + } + + const connectedForMs = this.status.connectedAt + ? Date.now() - this.status.connectedAt + : Number.POSITIVE_INFINITY; + + // Avoid signaling a process that just came up; it will already read latest config. + if (connectedForMs < 8000) { + logger.info(`Gateway connected ${connectedForMs}ms ago, skipping reload signal`); + return; + } + + try { + process.kill(this.process.pid, 'SIGUSR1'); + logger.info(`Sent SIGUSR1 to Gateway for config reload (pid=${this.process.pid})`); + // Some gateway builds do not handle SIGUSR1 as an in-process reload. + // If process state doesn't recover quickly, fall back to restart. + await new Promise((resolve) => setTimeout(resolve, 1500)); + if (this.status.state !== 'running' || !this.process?.pid) { + logger.warn('Gateway did not stay running after reload signal, falling back to restart'); + await this.restart(); + } + } catch (error) { + logger.warn('Gateway reload signal failed, falling back to restart:', error); + await this.restart(); + } + } + + /** + * Debounced reload — coalesces multiple rapid config-change events into one + * in-process reload when possible. + */ + debouncedReload(delayMs = 1200): void { + if (this.reloadDebounceTimer) { + clearTimeout(this.reloadDebounceTimer); + } + logger.debug(`Gateway reload debounced (will fire in ${delayMs}ms)`); + this.reloadDebounceTimer = setTimeout(() => { + this.reloadDebounceTimer = null; + void this.reload().catch((err) => { + logger.warn('Debounced Gateway reload failed:', err); + }); + }, delayMs); + } + /** * Clear all active timers */ @@ -660,6 +726,10 @@ export class GatewayManager extends EventEmitter { clearTimeout(this.restartDebounceTimer); this.restartDebounceTimer = null; } + if (this.reloadDebounceTimer) { + clearTimeout(this.reloadDebounceTimer); + this.reloadDebounceTimer = null; + } } /** diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 24ab292..61e6327 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -160,7 +160,7 @@ export function registerIpcHandlers( registerClawHubHandlers(clawHubService); // OpenClaw handlers - registerOpenClawHandlers(); + registerOpenClawHandlers(gatewayManager); // Provider handlers registerProviderHandlers(gatewayManager); @@ -1486,7 +1486,7 @@ function registerGatewayHandlers( * OpenClaw-related IPC handlers * For checking package status and channel configuration */ -function registerOpenClawHandlers(): void { +function registerOpenClawHandlers(gatewayManager: GatewayManager): void { async function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> { const targetDir = join(homedir(), '.openclaw', 'extensions', 'dingtalk'); const targetManifest = join(targetDir, 'openclaw.plugin.json'); @@ -1599,9 +1599,12 @@ function registerOpenClawHandlers(): void { }; } await saveChannelConfig(channelType, config); - logger.info( - `Skipping app-forced Gateway restart after channel:saveConfig (${channelType}); Gateway handles channel config reload/restart internally` - ); + if (gatewayManager.getStatus().state !== 'stopped') { + logger.info(`Scheduling Gateway reload after channel:saveConfig (${channelType})`); + gatewayManager.debouncedReload(); + } else { + logger.info(`Gateway is stopped; skip immediate reload after channel:saveConfig (${channelType})`); + } return { success: true, pluginInstalled: installResult.installed, @@ -1609,12 +1612,12 @@ function registerOpenClawHandlers(): void { }; } await saveChannelConfig(channelType, config); - // Do not force stop/start here. Recent Gateway builds detect channel config - // changes and perform an internal service restart; forcing another restart - // from Electron can race with reconnect and kill the newly spawned process. - logger.info( - `Skipping app-forced Gateway restart after channel:saveConfig (${channelType}); waiting for Gateway internal channel reload` - ); + if (gatewayManager.getStatus().state !== 'stopped') { + logger.info(`Scheduling Gateway reload after channel:saveConfig (${channelType})`); + gatewayManager.debouncedReload(); + } else { + logger.info(`Gateway is stopped; skip immediate reload after channel:saveConfig (${channelType})`); + } return { success: true }; } catch (error) { console.error('Failed to save channel config:', error); @@ -1648,6 +1651,12 @@ function registerOpenClawHandlers(): void { ipcMain.handle('channel:deleteConfig', async (_, channelType: string) => { try { await deleteChannelConfig(channelType); + if (gatewayManager.getStatus().state !== 'stopped') { + logger.info(`Scheduling Gateway reload after channel:deleteConfig (${channelType})`); + gatewayManager.debouncedReload(); + } else { + logger.info(`Gateway is stopped; skip immediate reload after channel:deleteConfig (${channelType})`); + } return { success: true }; } catch (error) { console.error('Failed to delete channel config:', error); @@ -1670,6 +1679,12 @@ function registerOpenClawHandlers(): void { ipcMain.handle('channel:setEnabled', async (_, channelType: string, enabled: boolean) => { try { await setChannelEnabled(channelType, enabled); + if (gatewayManager.getStatus().state !== 'stopped') { + logger.info(`Scheduling Gateway reload after channel:setEnabled (${channelType}, enabled=${enabled})`); + gatewayManager.debouncedReload(); + } else { + logger.info(`Gateway is stopped; skip immediate reload after channel:setEnabled (${channelType})`); + } return { success: true }; } catch (error) { console.error('Failed to set channel enabled:', error); diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index 1732e41..f9a335f 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -39,6 +39,7 @@ export interface PluginsConfig { export interface OpenClawConfig { channels?: Record; plugins?: PluginsConfig; + commands?: Record; [key: string]: unknown; } @@ -71,6 +72,14 @@ export async function writeOpenClawConfig(config: OpenClawConfig): Promise await ensureConfigDir(); try { + // Enable graceful in-process reload authorization for SIGUSR1 flows. + const commands = + config.commands && typeof config.commands === 'object' + ? { ...(config.commands as Record) } + : {}; + commands.restart = true; + config.commands = commands; + await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8'); } catch (error) { logger.error('Failed to write OpenClaw config', error); diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index 3beabb5..d52f8ad 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -131,6 +131,15 @@ async function readOpenClawJson(): Promise> { } async function writeOpenClawJson(config: Record): Promise { + // Ensure SIGUSR1 graceful reload is authorized by OpenClaw config. + const commands = ( + config.commands && typeof config.commands === 'object' + ? { ...(config.commands as Record) } + : {} + ) as Record; + commands.restart = true; + config.commands = commands; + await writeJsonFile(OPENCLAW_CONFIG_PATH, config); } @@ -781,6 +790,20 @@ export async function sanitizeOpenClawConfig(): Promise { } } + // ── commands section ─────────────────────────────────────────── + // Required for SIGUSR1 in-process reload authorization. + const commands = ( + config.commands && typeof config.commands === 'object' + ? { ...(config.commands as Record) } + : {} + ) as Record; + if (commands.restart !== true) { + commands.restart = true; + config.commands = commands; + modified = true; + console.log('[sanitize] Enabling commands.restart for graceful reload support'); + } + if (modified) { await writeOpenClawJson(config); console.log('[sanitize] openclaw.json sanitized successfully'); diff --git a/refactor.md b/refactor.md index 1bff1aa..32543f0 100644 --- a/refactor.md +++ b/refactor.md @@ -66,3 +66,48 @@ This branch captures local refactors focused on frontend UX polish, IPC call con ## Notes - Navigation order in sidebar is kept aligned with `main` ordering. - This commit snapshots current local refactor state for follow-up cleanup/cherry-pick work. + +## Incremental Updates (2026-03-08) + +### 9. Channel i18n fixes +- Added missing `channels` locale keys in EN/ZH/JA to prevent raw key fallback: + - `configured`, `configuredDesc`, `configuredBadge`, `deleteConfirm` +- Fixed confirm dialog namespace usage on Channels page: + - `common:actions.confirm`, `common:actions.delete`, `common:actions.cancel` + +### 10. Channel save/delete behavior aligned to reload-first strategy +- Added Gateway reload capability in `GatewayManager`: + - `reload()` (SIGUSR1 on macOS/Linux, restart fallback on failure/unsupported platforms) + - `debouncedReload()` for coalesced config-change reloads +- Wired channel config operations to reload pipeline: + - `channel:saveConfig` + - `channel:deleteConfig` + - `channel:setEnabled` +- Removed redundant renderer-side forced restart call after WhatsApp configuration. + +### 11. OpenClaw config compatibility for graceful reload +- Ensured `commands.restart = true` is persisted in OpenClaw config write paths: + - `electron/utils/channel-config.ts` + - `electron/utils/openclaw-auth.ts` +- Added sanitize fallback that auto-enables `commands.restart` before Gateway start. + +### 12. Channels page data consistency fixes +- Unified configured state derivation so the following sections share one source: + - stats cards + - configured channels list + - available channel configured badge +- Fixed post-delete refresh by explicitly refetching both: + - configured channel types + - channel status list + +### 13. Channels UX resilience during Gateway restart/reconnect +- Added delayed gateway warning display to reduce transient false alarms. +- Added "running snapshot" rendering strategy: + - keep previous channels/configured view during `starting/reconnecting` when live response is temporarily empty + - avoids UI flashing to zero counts / empty configured state +- Added automatic refresh once Gateway transitions back to `running`. + +### 14. Configure-but-disable support +- Added enable toggle in channel setup dialog (`Enable Channel`). +- Save flow now persists `enabled` with configuration payload. +- Existing config load now reads `enabled` state and pre-fills toggle accordingly. diff --git a/src/i18n/locales/en/channels.json b/src/i18n/locales/en/channels.json index c6630ac..932660d 100644 --- a/src/i18n/locales/en/channels.json +++ b/src/i18n/locales/en/channels.json @@ -11,6 +11,10 @@ "gatewayWarning": "Gateway service is not running. Channels cannot connect.", "available": "Available Channels", "availableDesc": "Connect a new channel", + "configured": "Configured Channels", + "configuredDesc": "Manage channels that are already configured", + "configuredBadge": "Configured", + "deleteConfirm": "Are you sure you want to delete this channel?", "showAll": "Show All", "pluginBadge": "Plugin", "toast": { @@ -37,6 +41,8 @@ "viewDocs": "View Documentation", "channelName": "Channel Name", "channelNamePlaceholder": "My {{name}}", + "enableChannel": "Enable Channel", + "enableChannelDesc": "When off, config is saved but the channel stays disabled", "credentialsVerified": "Credentials Verified", "validationFailed": "Validation Failed", "warnings": "Warnings", @@ -293,4 +299,4 @@ } }, "viewDocs": "View Documentation" -} \ No newline at end of file +} diff --git a/src/i18n/locales/ja/channels.json b/src/i18n/locales/ja/channels.json index 35938b8..3fa3ef7 100644 --- a/src/i18n/locales/ja/channels.json +++ b/src/i18n/locales/ja/channels.json @@ -11,6 +11,10 @@ "gatewayWarning": "ゲートウェイサービスが実行されていないため、チャンネルに接続できません。", "available": "利用可能なチャンネル", "availableDesc": "新しいチャンネルを接続", + "configured": "設定済みチャンネル", + "configuredDesc": "すでに設定済みのチャンネルを管理", + "configuredBadge": "設定済み", + "deleteConfirm": "このチャンネルを削除してもよろしいですか?", "showAll": "すべて表示", "pluginBadge": "プラグイン", "toast": { @@ -37,6 +41,8 @@ "viewDocs": "ドキュメントを表示", "channelName": "チャンネル名", "channelNamePlaceholder": "マイ {{name}}", + "enableChannel": "チャンネルを有効化", + "enableChannelDesc": "オフの場合、設定のみ保存しチャンネルは起動しません", "credentialsVerified": "認証情報が確認されました", "validationFailed": "検証に失敗しました", "warnings": "警告", @@ -293,4 +299,4 @@ } }, "viewDocs": "ドキュメントを表示" -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh/channels.json b/src/i18n/locales/zh/channels.json index 95f1848..e0a68e9 100644 --- a/src/i18n/locales/zh/channels.json +++ b/src/i18n/locales/zh/channels.json @@ -11,6 +11,10 @@ "gatewayWarning": "网关服务未运行,频道无法连接。", "available": "可用频道", "availableDesc": "连接一个新的频道", + "configured": "已配置频道", + "configuredDesc": "管理已完成配置的频道", + "configuredBadge": "已配置", + "deleteConfirm": "确定要删除此频道吗?", "showAll": "显示全部", "pluginBadge": "插件", "toast": { @@ -37,6 +41,8 @@ "viewDocs": "查看文档", "channelName": "频道名称", "channelNamePlaceholder": "我的 {{name}}", + "enableChannel": "启用频道", + "enableChannelDesc": "关闭后会保存配置,但不会启动该频道", "credentialsVerified": "凭证已验证", "validationFailed": "验证失败", "warnings": "警告", @@ -293,4 +299,4 @@ } }, "viewDocs": "查看文档" -} \ No newline at end of file +} diff --git a/src/pages/Channels/index.tsx b/src/pages/Channels/index.tsx index 5c9e13f..de67193 100644 --- a/src/pages/Channels/index.tsx +++ b/src/pages/Channels/index.tsx @@ -2,7 +2,7 @@ * Channels Page * Manage messaging channel connections with configuration UI */ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Plus, Radio, @@ -25,6 +25,7 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { Badge } from '@/components/ui/badge'; @@ -55,9 +56,13 @@ export function Channels() { const [showAddDialog, setShowAddDialog] = useState(false); const [selectedChannelType, setSelectedChannelType] = useState(null); const [configuredTypes, setConfiguredTypes] = useState([]); + const [channelSnapshot, setChannelSnapshot] = useState([]); + const [configuredTypesSnapshot, setConfiguredTypesSnapshot] = useState([]); const [channelToDelete, setChannelToDelete] = useState<{ id: string } | null>(null); const [refreshing, setRefreshing] = useState(false); + const [showGatewayWarning, setShowGatewayWarning] = useState(false); const refreshDebounceRef = useRef | null>(null); + const lastGatewayStateRef = useRef(gatewayStatus.state); // Fetch channels on mount useEffect(() => { @@ -104,11 +109,60 @@ export function Channels() { }; }, [fetchChannels, fetchConfiguredTypes]); + useEffect(() => { + if (gatewayStatus.state === 'running') { + setChannelSnapshot(channels); + setConfiguredTypesSnapshot(configuredTypes); + } + }, [gatewayStatus.state, channels, configuredTypes]); + + useEffect(() => { + const previousState = lastGatewayStateRef.current; + const currentState = gatewayStatus.state; + const justReconnected = + currentState === 'running' && + previousState !== 'running'; + lastGatewayStateRef.current = currentState; + + if (!justReconnected) return; + void fetchChannels({ probe: false, silent: true }); + void fetchConfiguredTypes(); + }, [gatewayStatus.state, fetchChannels, fetchConfiguredTypes]); + + // Delay warning to avoid flicker during expected short reload/restart windows. + useEffect(() => { + const shouldWarn = gatewayStatus.state === 'stopped' || gatewayStatus.state === 'error'; + const timer = setTimeout(() => { + setShowGatewayWarning(shouldWarn); + }, shouldWarn ? 1800 : 0); + return () => clearTimeout(timer); + }, [gatewayStatus.state]); + // Get channel types to display const displayedChannelTypes = getPrimaryChannels(); + const isGatewayTransitioning = + gatewayStatus.state === 'starting' || gatewayStatus.state === 'reconnecting'; + const channelsForView = + isGatewayTransitioning && channels.length === 0 ? channelSnapshot : channels; + const configuredTypesForView = + isGatewayTransitioning && configuredTypes.length === 0 ? configuredTypesSnapshot : configuredTypes; + + // Single source of truth for configured status across cards, stats and badges. + const configuredTypeSet = useMemo(() => { + const set = new Set(configuredTypesForView); + if (set.size === 0 && channelsForView.length > 0) { + channelsForView.forEach((channel) => set.add(channel.type)); + } + return set; + }, [configuredTypesForView, channelsForView]); + + const configuredChannels = useMemo( + () => channelsForView.filter((channel) => configuredTypeSet.has(channel.type)), + [channelsForView, configuredTypeSet] + ); // Connected/disconnected channel counts - const connectedCount = channels.filter((c) => c.status === 'connected').length; + const connectedCount = configuredChannels.filter((c) => c.status === 'connected').length; if (loading && channels.length === 0) { return ( @@ -161,7 +215,7 @@ export function Channels() {
-

{channels.length}

+

{configuredChannels.length}

{t('stats.total')}

@@ -187,7 +241,7 @@ export function Channels() {
-

{channels.length - connectedCount}

+

{configuredChannels.length - connectedCount}

{t('stats.disconnected')}

@@ -196,7 +250,7 @@ export function Channels() { {/* Gateway Warning */} - {gatewayStatus.state !== 'running' && ( + {showGatewayWarning && ( @@ -217,7 +271,7 @@ export function Channels() { )} {/* Configured Channels */} - {channels.length > 0 && ( + {configuredChannels.length > 0 && ( {t('configured')} @@ -225,7 +279,7 @@ export function Channels() {
- {channels.map((channel) => ( + {configuredChannels.map((channel) => ( {displayedChannelTypes.map((type) => { const meta = CHANNEL_META[type]; - const isConfigured = configuredTypes.includes(type); + const isConfigured = configuredTypeSet.has(type); return (
+
+
+

{t('dialog.enableChannel')}

+

{t('dialog.enableChannelDesc')}

+
+ +
+ {/* Configuration fields */} {meta?.configFields.map((field) => (