diff --git a/packages/ui/src/components/sections/agents/AgentsPage.tsx b/packages/ui/src/components/sections/agents/AgentsPage.tsx index f8cb2b09..6fb23f2b 100644 --- a/packages/ui/src/components/sections/agents/AgentsPage.tsx +++ b/packages/ui/src/components/sections/agents/AgentsPage.tsx @@ -243,6 +243,8 @@ export const AgentsPage: React.FC = () => { if (result.ok) { if (result.requiresManualRestart) { toast.warning(t('settings.agents.page.toast.savedManualRestart')); + } else if (result.restartDeferred) { + toast.success(t('settings.view.pendingRestart.saved')); } else { toast.success(isNewAgent ? t('settings.agents.page.toast.created') : t('settings.agents.page.toast.updated')); } diff --git a/packages/ui/src/components/sections/agents/AgentsSidebar.tsx b/packages/ui/src/components/sections/agents/AgentsSidebar.tsx index 0f3df73d..ebca0cf2 100644 --- a/packages/ui/src/components/sections/agents/AgentsSidebar.tsx +++ b/packages/ui/src/components/sections/agents/AgentsSidebar.tsx @@ -188,6 +188,8 @@ export const AgentsSidebar: React.FC = ({ onItemSelect }) => if (result.ok) { if (result.requiresManualRestart) { toast.warning(t('settings.agents.page.toast.savedManualRestart')); + } else if (result.restartDeferred) { + toast.success(t('settings.view.pendingRestart.saved')); } else if (confirmActionType === 'delete') { toast.success(t('settings.agents.sidebar.toast.agentDeleted', { name: confirmActionAgent.name })); } else { diff --git a/packages/ui/src/components/sections/behavior/BehaviorPage.tsx b/packages/ui/src/components/sections/behavior/BehaviorPage.tsx index d33fefc6..db56e86a 100644 --- a/packages/ui/src/components/sections/behavior/BehaviorPage.tsx +++ b/packages/ui/src/components/sections/behavior/BehaviorPage.tsx @@ -20,7 +20,7 @@ import { } from '@/lib/responseStyle'; import type { DesktopSettings } from '@/lib/desktop'; import { runtimeFetch } from '@/lib/runtime-fetch'; -import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; +import { noteDeferredRestartFromPayload, recordDeferredOpenCodeRestart } from '@/lib/opencode/deferredRestart'; import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout'; import { SettingsSection, @@ -242,13 +242,18 @@ export const BehaviorPage: React.FC = () => { throw new Error(await readApiError(response, t('settings.behavior.page.toast.saveFailed'))); } + const payload = await response.json().catch(() => null); + if (!noteDeferredRestartFromPayload(payload, 'behavior', { id: 'agents-md' })) { + recordDeferredOpenCodeRestart('behavior', { id: 'agents-md' }); + } + await saveBehaviorSetting({ globalBehaviorPrompt: content, }, t('settings.behavior.page.toast.saveFailed')); setPrompt(content); setInitialPrompt(content); - toast.success(t('settings.behavior.page.toast.saved')); + toast.success(t('settings.view.pendingRestart.saved')); } catch (error) { console.error('Failed to save behavior:', error); const message = error instanceof Error ? error.message : t('settings.behavior.page.toast.saveFailed'); @@ -266,19 +271,8 @@ export const BehaviorPage: React.FC = () => { t('settings.behavior.page.toast.saveFailed'), ); setInitialOptimizeSystemPrompt(optimizeSystemPrompt); - } catch (error) { - const message = error instanceof Error ? error.message : t('settings.behavior.page.toast.saveFailed'); - toast.error(message); - setIsApplyingPromptOptimization(false); - return; - } - - try { - await reloadOpenCodeConfiguration({ - message: t('settings.behavior.page.systemPromptOptimization.restarting'), - mode: 'projects', - scopes: ['all'], - }); + recordDeferredOpenCodeRestart('behavior', { id: 'optimize-system-prompt' }); + toast.success(t('settings.view.pendingRestart.saved')); } catch (error) { const message = error instanceof Error ? error.message : t('settings.behavior.page.toast.saveFailed'); toast.error(message); @@ -317,7 +311,7 @@ export const BehaviorPage: React.FC = () => { > {isApplyingPromptOptimization ? t('settings.common.actions.saving') - : t('settings.openchamber.opencodeCli.actions.saveAndReload')} + : t('settings.common.actions.saveChanges')} )} diff --git a/packages/ui/src/components/sections/mcp/McpPage.tsx b/packages/ui/src/components/sections/mcp/McpPage.tsx index 69cbfe73..d1ea3680 100644 --- a/packages/ui/src/components/sections/mcp/McpPage.tsx +++ b/packages/ui/src/components/sections/mcp/McpPage.tsx @@ -930,6 +930,8 @@ export const McpPage: React.FC = () => { : t('settings.mcp.page.toast.savedReloadFailed')), { description: result.warning || t('settings.mcp.page.toast.retryRefreshHint'), }); + } else if (result.restartDeferred) { + toast.success(t('settings.view.pendingRestart.saved')); } else { toast.success(result.message || (isNewServer ? t('settings.mcp.page.toast.serverCreatedReloading') diff --git a/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx index bafba6bb..af2785cd 100644 --- a/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx @@ -12,11 +12,12 @@ import { } from '@/components/sections/shared/SettingsSection'; import { isDesktopShell, requestFileAccess } from '@/lib/desktop'; import { updateDesktopSettings } from '@/lib/persistence'; -import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; +import { recordDeferredOpenCodeRestart } from '@/lib/opencode/deferredRestart'; import { useUIStore } from '@/stores/useUIStore'; import { useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { isWindowsArm64 } from '@/lib/platform'; +import { toast } from '@/components/ui'; export const OpenCodeCliSettings: React.FC = () => { const { t } = useI18n(); @@ -89,11 +90,8 @@ export const OpenCodeCliSettings: React.FC = () => { ? trimmed.slice(1, -1).trim() : trimmed; await updateDesktopSettings({ opencodeBinary: unquoted }); - await reloadOpenCodeConfiguration({ - message: t('settings.openchamber.opencodeCli.actions.restartingOpenCode'), - mode: 'projects', - scopes: ['all'], - }); + recordDeferredOpenCodeRestart('cli', { id: 'opencode-binary' }); + toast.success(t('settings.view.pendingRestart.saved')); } finally { setIsSaving(false); } @@ -179,7 +177,7 @@ export const OpenCodeCliSettings: React.FC = () => { disabled={isLoading || isSaving} className="shrink-0 !font-normal" > - {isSaving ? t('settings.common.actions.saving') : t('settings.openchamber.opencodeCli.actions.saveAndReload')} + {isSaving ? t('settings.common.actions.saving') : t('settings.common.actions.saveChanges')} diff --git a/packages/ui/src/components/sections/plugins/AddPluginDialog.tsx b/packages/ui/src/components/sections/plugins/AddPluginDialog.tsx index 54d472b7..30f93753 100644 --- a/packages/ui/src/components/sections/plugins/AddPluginDialog.tsx +++ b/packages/ui/src/components/sections/plugins/AddPluginDialog.tsx @@ -109,7 +109,11 @@ export const AddPluginDialog: React.FC = ({ }); } if (result.ok) { - toast.success(result.message || t('settings.plugins.toast.created')); + if (result.restartDeferred) { + toast.success(t('settings.view.pendingRestart.saved')); + } else { + toast.success(result.message || t('settings.plugins.toast.created')); + } if (result.reloadFailed) { toast.warning(t('settings.plugins.toast.reloadFailed')); } diff --git a/packages/ui/src/components/sections/plugins/PluginsPage.tsx b/packages/ui/src/components/sections/plugins/PluginsPage.tsx index 7e4e7446..7dd0f18f 100644 --- a/packages/ui/src/components/sections/plugins/PluginsPage.tsx +++ b/packages/ui/src/components/sections/plugins/PluginsPage.tsx @@ -183,6 +183,8 @@ export const PluginsPage: React.FC = () => { result.message || t('settings.plugins.toast.reloadFailed'), { description: result.warning }, ); + } else if (result.restartDeferred) { + toast.success(t('settings.view.pendingRestart.saved')); } else { toast.success(result.message || t('settings.plugins.toast.updated')); } @@ -299,6 +301,8 @@ export const PluginsPage: React.FC = () => { result.message || t('settings.plugins.toast.reloadFailed'), { description: result.warning }, ); + } else if (result.restartDeferred) { + toast.success(t('settings.view.pendingRestart.saved')); } else { toast.success(result.message || t('settings.plugins.toast.updated')); } diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index 3dd15fd8..88b4d1f8 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -17,7 +17,7 @@ import { import { toast } from '@/components/ui'; import { Icon } from "@/components/icon/Icon"; import type { IconName } from "@/components/icon/icons"; -import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; +import { noteDeferredRestartFromPayload, recordDeferredOpenCodeRestart } from '@/lib/opencode/deferredRestart'; import { cn } from '@/lib/utils'; import { copyTextToClipboard } from '@/lib/clipboard'; import { openExternalUrl } from '@/lib/url'; @@ -351,7 +351,7 @@ export const ProvidersPage: React.FC = () => { toast.success(t('settings.providers.page.toast.apiKeySaved')); setApiKeyInputs((prev) => ({ ...prev, [providerId]: '' })); - await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" }); + recordDeferredOpenCodeRestart('providers', { id: providerId }); setSelectedProvider(providerId); } catch (error) { console.error('Failed to save API key:', error); @@ -444,7 +444,7 @@ export const ProvidersPage: React.FC = () => { toast.success(t('settings.providers.page.toast.oauthCompleted')); setOauthCodes((prev) => ({ ...prev, [codeKey]: '' })); setPendingOAuth(null); - await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" }); + recordDeferredOpenCodeRestart('providers', { id: providerId }); setSelectedProvider(providerId); } catch (error) { console.error('Failed to complete OAuth flow:', error); @@ -490,7 +490,9 @@ export const ProvidersPage: React.FC = () => { } toast.success(t('settings.providers.page.toast.providerDisconnected')); - await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" }); + if (!noteDeferredRestartFromPayload(payload, 'providers', { id: providerId })) { + recordDeferredOpenCodeRestart('providers', { id: providerId }); + } } catch (error) { console.error('Failed to disconnect provider:', error); toast.error(t('settings.providers.page.toast.providerDisconnectFailed')); diff --git a/packages/ui/src/components/views/PendingOpenCodeRestartAction.tsx b/packages/ui/src/components/views/PendingOpenCodeRestartAction.tsx new file mode 100644 index 00000000..22cf0540 --- /dev/null +++ b/packages/ui/src/components/views/PendingOpenCodeRestartAction.tsx @@ -0,0 +1,81 @@ +import * as React from 'react'; +import { Button } from '@/components/ui/button'; +import { Icon } from '@/components/icon/Icon'; +import { useI18n } from '@/lib/i18n'; +import { applyPendingOpenCodeRestart } from '@/lib/opencode/deferredRestart'; +import { + selectPendingOpenCodeRestartCount, + usePendingOpenCodeRestartStore, +} from '@/stores/usePendingOpenCodeRestartStore'; +import { toast } from '@/components/ui'; +import { cn } from '@/lib/utils'; + +type PendingOpenCodeRestartActionProps = { + className?: string; + compact?: boolean; +}; + +export const PendingOpenCodeRestartAction: React.FC = ({ + className, + compact = false, +}) => { + const { t } = useI18n(); + const pendingCount = usePendingOpenCodeRestartStore(selectPendingOpenCodeRestartCount); + const isApplying = usePendingOpenCodeRestartStore((state) => state.isApplying); + + const handleApply = React.useCallback(async () => { + try { + const result = await applyPendingOpenCodeRestart({ + message: t('settings.view.pendingRestart.applying'), + }); + if (result.requiresManualRestart) { + toast.warning(t('settings.view.pendingRestart.manualRestartRequired')); + return; + } + if (result.ok) { + toast.success(t('settings.view.pendingRestart.applied')); + } + } catch (error) { + const message = error instanceof Error && error.message + ? error.message + : t('settings.view.pendingRestart.applyFailed'); + toast.error(message); + } + }, [t]); + + if (pendingCount <= 0) { + return null; + } + + const tooltip = pendingCount === 1 + ? t('settings.view.actions.applyAndRestartOpenCodeTooltipSingle') + : t('settings.view.actions.applyAndRestartOpenCodeTooltipPlural', { count: pendingCount }); + + return ( + + ); +}; diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index 74505195..ca9e7fec 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -49,6 +49,11 @@ import { Icon } from "@/components/icon/Icon"; import type { IconName } from "@/components/icon/icons"; import { McpIcon } from '@/components/icons/McpIcon'; import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; +import { PendingOpenCodeRestartAction } from '@/components/views/PendingOpenCodeRestartAction'; +import { + selectPendingOpenCodeRestartCount, + usePendingOpenCodeRestartStore, +} from '@/stores/usePendingOpenCodeRestartStore'; import { SETTINGS_PAGE_METADATA, getSettingsPageMeta, @@ -235,6 +240,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile const { t } = useI18n(); const deviceInfo = useDeviceInfo(); const isMobile = forceMobile ?? deviceInfo.isMobile; + const pendingRestartCount = usePendingOpenCodeRestartStore(selectPendingOpenCodeRestartCount); const settingsPageRaw = useUIStore((state) => state.settingsPage); const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen); @@ -974,7 +980,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile {/* Footer */}
- {!runtimeCtx.isVSCode && ( + {!runtimeCtx.isVSCode && pendingRestartCount <= 0 && ( )} + + {onClose && (
)} - {onClose && ( -
+
+ + {onClose && ( -
- )} + )} +
)} diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 9b1ae0b7..2983650d 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -1356,6 +1356,11 @@ export interface SkillsInstallResponse { skipped?: Array<{ skillName: string; reason: string }>; error?: SkillsInstallError; requiresReload?: boolean; + requiresRestart?: boolean; + restartDeferred?: boolean; + requiresManualRestart?: boolean; + reloadFailed?: boolean; + warning?: string; message?: string; reloadDelayMs?: number; } diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index ece90e3f..2cd8dca9 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -40,6 +40,14 @@ export const settingsDict = { 'settings.view.badge.beta': 'beta', 'settings.view.actions.reloadOpenCode': 'Reload OpenCode', 'settings.view.actions.reloadOpenCodeTooltip': 'Restart OpenCode and reload its configuration.', + 'settings.view.actions.applyAndRestartOpenCode': 'Apply & Restart OpenCode', + 'settings.view.actions.applyAndRestartOpenCodeTooltipSingle': 'Apply 1 pending configuration change and restart OpenCode.', + 'settings.view.actions.applyAndRestartOpenCodeTooltipPlural': 'Apply {count} pending configuration changes and restart OpenCode.', + 'settings.view.pendingRestart.applying': 'Applying changes and restarting OpenCode…', + 'settings.view.pendingRestart.applied': 'OpenCode restarted with pending configuration changes.', + 'settings.view.pendingRestart.applyFailed': 'Failed to apply configuration changes.', + 'settings.view.pendingRestart.manualRestartRequired': 'Saved on disk. Restart your connected OpenCode server to apply the changes.', + 'settings.view.pendingRestart.saved': 'Saved. Restart OpenCode to apply.', 'settings.view.actions.backToSettings': 'Back to Settings', 'settings.view.actions.closeSettings': 'Close settings', 'settings.view.actions.openSectionList': 'Open section list', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 657a936c..4e32537b 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -40,6 +40,15 @@ export const settingsDict = { "settings.view.badge.beta": "beta", "settings.view.actions.reloadOpenCode": "Recargar OpenCode", "settings.view.actions.reloadOpenCodeTooltip": "Restablecer OpenCode y recargar su configuración.", + + "settings.view.actions.applyAndRestartOpenCode": "Aplicar y reiniciar OpenCode", + "settings.view.actions.applyAndRestartOpenCodeTooltipSingle": "Aplicar 1 cambio de configuración pendiente y reiniciar OpenCode.", + "settings.view.actions.applyAndRestartOpenCodeTooltipPlural": "Aplicar {count} cambios de configuración pendientes y reiniciar OpenCode.", + "settings.view.pendingRestart.applying": "Aplicando cambios y reiniciando OpenCode…", + "settings.view.pendingRestart.applied": "OpenCode se reinició con los cambios de configuración pendientes.", + "settings.view.pendingRestart.applyFailed": "No se pudieron aplicar los cambios de configuración.", + "settings.view.pendingRestart.manualRestartRequired": "Guardado en disco. Reinicia el servidor OpenCode conectado para aplicar los cambios.", + "settings.view.pendingRestart.saved": "Guardado. Reinicia OpenCode para aplicar.", "settings.view.actions.backToSettings": "Volver a Configuración", "settings.view.actions.closeSettings": "Cerrar configuración", "settings.view.actions.openSectionList": "Abrir lista de secciones", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 050f7904..f1e876e1 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -40,6 +40,15 @@ export const settingsDict = { 'settings.view.badge.beta': 'bêta', 'settings.view.actions.reloadOpenCode': 'Recharger OpenCode', 'settings.view.actions.reloadOpenCodeTooltip': 'Redémarrez OpenCode et rechargez sa configuration.', + + 'settings.view.actions.applyAndRestartOpenCode': 'Appliquer et redémarrer OpenCode', + 'settings.view.actions.applyAndRestartOpenCodeTooltipSingle': 'Appliquer 1 modification de configuration en attente et redémarrer OpenCode.', + 'settings.view.actions.applyAndRestartOpenCodeTooltipPlural': 'Appliquer {count} modifications de configuration en attente et redémarrer OpenCode.', + 'settings.view.pendingRestart.applying': 'Application des modifications et redémarrage de OpenCode…', + 'settings.view.pendingRestart.applied': 'OpenCode a redémarré avec les modifications de configuration en attente.', + 'settings.view.pendingRestart.applyFailed': 'Échec de l\'application des modifications de configuration.', + 'settings.view.pendingRestart.manualRestartRequired': 'Enregistré sur le disque. Redémarrez le serveur OpenCode connecté pour appliquer les modifications.', + 'settings.view.pendingRestart.saved': 'Enregistré. Redémarrez OpenCode pour appliquer.', 'settings.view.actions.backToSettings': 'Retour aux paramètres', 'settings.view.actions.closeSettings': 'Fermer les paramètres', 'settings.view.actions.openSectionList': 'Ouvrir la liste des sections', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index be7fe2b3..75842698 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -40,6 +40,15 @@ export const settingsDict = { 'settings.view.badge.beta': 'ベータ', 'settings.view.actions.reloadOpenCode': 'OpenCode を再読み込み', 'settings.view.actions.reloadOpenCodeTooltip': 'OpenCode を再起動して設定を再読み込みします。', + + 'settings.view.actions.applyAndRestartOpenCode': '適用して OpenCode を再起動', + 'settings.view.actions.applyAndRestartOpenCodeTooltipSingle': '保留中の設定変更 1 件を適用して OpenCode を再起動します。', + 'settings.view.actions.applyAndRestartOpenCodeTooltipPlural': '保留中の設定変更 {count} 件を適用して OpenCode を再起動します。', + 'settings.view.pendingRestart.applying': '変更を適用して OpenCode を再起動しています…', + 'settings.view.pendingRestart.applied': '保留中の設定変更を適用して OpenCode を再起動しました。', + 'settings.view.pendingRestart.applyFailed': '設定変更の適用に失敗しました。', + 'settings.view.pendingRestart.manualRestartRequired': 'ディスクに保存しました。接続中の OpenCode サーバーを再起動して変更を適用してください。', + 'settings.view.pendingRestart.saved': '保存しました。適用するには OpenCode を再起動してください。', 'settings.view.actions.backToSettings': '設定に戻る', 'settings.view.actions.closeSettings': '設定を閉じる', 'settings.view.actions.openSectionList': 'セクション一覧を開く', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index accc0d40..bba717c1 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -40,6 +40,15 @@ export const settingsDict = { 'settings.view.badge.beta': 'beta', 'settings.view.actions.reloadOpenCode': 'OpenCode 다시 로드', 'settings.view.actions.reloadOpenCodeTooltip': 'OpenCode를 재시작하고 설정을 다시 불러옵니다.', + + 'settings.view.actions.applyAndRestartOpenCode': '적용하고 OpenCode 다시 시작', + 'settings.view.actions.applyAndRestartOpenCodeTooltipSingle': '보류 중인 구성 변경 1개를 적용하고 OpenCode를 다시 시작합니다.', + 'settings.view.actions.applyAndRestartOpenCodeTooltipPlural': '보류 중인 구성 변경 {count}개를 적용하고 OpenCode를 다시 시작합니다.', + 'settings.view.pendingRestart.applying': '변경 사항을 적용하고 OpenCode를 다시 시작하는 중…', + 'settings.view.pendingRestart.applied': '보류 중인 구성 변경으로 OpenCode를 다시 시작했습니다.', + 'settings.view.pendingRestart.applyFailed': '구성 변경을 적용하지 못했습니다.', + 'settings.view.pendingRestart.manualRestartRequired': '디스크에 저장되었습니다. 연결된 OpenCode 서버를 다시 시작하여 변경 사항을 적용하세요.', + 'settings.view.pendingRestart.saved': '저장되었습니다. 적용하려면 OpenCode를 다시 시작하세요.', 'settings.view.actions.backToSettings': '설정으로 돌아가기', 'settings.view.actions.closeSettings': '설정 닫기', 'settings.view.actions.openSectionList': '섹션 목록 열기', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 318072d8..aed25553 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1913,6 +1913,15 @@ export const settingsDict = { 'settings.view.actions.openSectionList': 'Otwórz listę sekcji', 'settings.view.actions.reloadOpenCode': 'Przeładuj OpenCode', 'settings.view.actions.reloadOpenCodeTooltip': 'Uruchom ponownie OpenCode i przeładuj jego konfigurację.', + + 'settings.view.actions.applyAndRestartOpenCode': 'Zastosuj i uruchom ponownie OpenCode', + 'settings.view.actions.applyAndRestartOpenCodeTooltipSingle': 'Zastosuj 1 oczekującą zmianę konfiguracji i uruchom ponownie OpenCode.', + 'settings.view.actions.applyAndRestartOpenCodeTooltipPlural': 'Zastosuj {count} oczekujących zmian konfiguracji i uruchom ponownie OpenCode.', + 'settings.view.pendingRestart.applying': 'Stosowanie zmian i ponowne uruchamianie OpenCode…', + 'settings.view.pendingRestart.applied': 'OpenCode został uruchomiony ponownie z oczekującymi zmianami konfiguracji.', + 'settings.view.pendingRestart.applyFailed': 'Nie udało się zastosować zmian konfiguracji.', + 'settings.view.pendingRestart.manualRestartRequired': 'Zapisano na dysku. Uruchom ponownie podłączony serwer OpenCode, aby zastosować zmiany.', + 'settings.view.pendingRestart.saved': 'Zapisano. Uruchom ponownie OpenCode, aby zastosować.', 'settings.view.actions.resizeNavigation': 'Zmień rozmiar nawigacji', 'settings.view.search.placeholder': 'Szukaj ustawień', 'settings.view.search.aria': 'Szukaj ustawień', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index a931c456..f45b36e7 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -40,6 +40,15 @@ export const settingsDict = { "settings.view.badge.beta": "beta", "settings.view.actions.reloadOpenCode": "Recarregar OpenCode", "settings.view.actions.reloadOpenCodeTooltip": "Reiniciar OpenCode e recarregar suas configurações.", + + "settings.view.actions.applyAndRestartOpenCode": "Aplicar e reiniciar o OpenCode", + "settings.view.actions.applyAndRestartOpenCodeTooltipSingle": "Aplicar 1 alteração de configuração pendente e reiniciar o OpenCode.", + "settings.view.actions.applyAndRestartOpenCodeTooltipPlural": "Aplicar {count} alterações de configuração pendentes e reiniciar o OpenCode.", + "settings.view.pendingRestart.applying": "Aplicando alterações e reiniciando o OpenCode…", + "settings.view.pendingRestart.applied": "OpenCode reiniciado com as alterações de configuração pendentes.", + "settings.view.pendingRestart.applyFailed": "Falha ao aplicar as alterações de configuração.", + "settings.view.pendingRestart.manualRestartRequired": "Salvo no disco. Reinicie o servidor OpenCode conectado para aplicar as alterações.", + "settings.view.pendingRestart.saved": "Salvo. Reinicie o OpenCode para aplicar.", "settings.view.actions.backToSettings": "Voltar às configurações", "settings.view.actions.closeSettings": "Fechar configurações", "settings.view.actions.openSectionList": "Abrir lista de seções", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 65655407..401455e7 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -40,6 +40,15 @@ export const settingsDict = { "settings.view.badge.beta": "бета-версія", "settings.view.actions.reloadOpenCode": "Перезавантажити OpenCode", "settings.view.actions.reloadOpenCodeTooltip": "Перезапустити OpenCode і перезавантажити його конфігурацію.", + + "settings.view.actions.applyAndRestartOpenCode": "Застосувати та перезапустити OpenCode", + "settings.view.actions.applyAndRestartOpenCodeTooltipSingle": "Застосувати 1 очікувану зміну конфігурації та перезапустити OpenCode.", + "settings.view.actions.applyAndRestartOpenCodeTooltipPlural": "Застосувати {count} очікуваних змін конфігурації та перезапустити OpenCode.", + "settings.view.pendingRestart.applying": "Застосування змін і перезапуск OpenCode…", + "settings.view.pendingRestart.applied": "OpenCode перезапущено з очікуваними змінами конфігурації.", + "settings.view.pendingRestart.applyFailed": "Не вдалося застосувати зміни конфігурації.", + "settings.view.pendingRestart.manualRestartRequired": "Збережено на диску. Перезапустіть підключений сервер OpenCode, щоб застосувати зміни.", + "settings.view.pendingRestart.saved": "Збережено. Перезапустіть OpenCode, щоб застосувати.", "settings.view.actions.backToSettings": "Назад до налаштувань", "settings.view.actions.closeSettings": "Закрити налаштування", "settings.view.actions.openSectionList": "Відкрити список розділів", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 3650a25a..087c01e2 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -40,6 +40,15 @@ export const settingsDict = { 'settings.view.badge.beta': '测试版', 'settings.view.actions.reloadOpenCode': '重新加载 OpenCode', 'settings.view.actions.reloadOpenCodeTooltip': '重启 OpenCode 并重新加载其配置。', + + 'settings.view.actions.applyAndRestartOpenCode': '应用并重启 OpenCode', + 'settings.view.actions.applyAndRestartOpenCodeTooltipSingle': '应用 1 项待处理的配置更改并重启 OpenCode。', + 'settings.view.actions.applyAndRestartOpenCodeTooltipPlural': '应用 {count} 项待处理的配置更改并重启 OpenCode。', + 'settings.view.pendingRestart.applying': '正在应用更改并重启 OpenCode…', + 'settings.view.pendingRestart.applied': '已使用待处理的配置更改重启 OpenCode。', + 'settings.view.pendingRestart.applyFailed': '应用配置更改失败。', + 'settings.view.pendingRestart.manualRestartRequired': '已保存到磁盘。请重启已连接的 OpenCode 服务器以应用更改。', + 'settings.view.pendingRestart.saved': '已保存。请重启 OpenCode 以应用。', 'settings.view.actions.backToSettings': '返回设置', 'settings.view.actions.closeSettings': '关闭设置', 'settings.view.actions.openSectionList': '打开分组列表', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index c027b3ad..8d8925e2 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -38,6 +38,15 @@ 'settings.view.badge.beta': 'beta', 'settings.view.actions.reloadOpenCode': '重新載入 OpenCode', 'settings.view.actions.reloadOpenCodeTooltip': '重新啟動 OpenCode 並重新載入其設定。', + + 'settings.view.actions.applyAndRestartOpenCode': '套用並重新啟動 OpenCode', + 'settings.view.actions.applyAndRestartOpenCodeTooltipSingle': '套用 1 項待處理的設定變更並重新啟動 OpenCode。', + 'settings.view.actions.applyAndRestartOpenCodeTooltipPlural': '套用 {count} 項待處理的設定變更並重新啟動 OpenCode。', + 'settings.view.pendingRestart.applying': '正在套用變更並重新啟動 OpenCode…', + 'settings.view.pendingRestart.applied': '已使用待處理的設定變更重新啟動 OpenCode。', + 'settings.view.pendingRestart.applyFailed': '套用設定變更失敗。', + 'settings.view.pendingRestart.manualRestartRequired': '已儲存到磁碟。請重新啟動已連線的 OpenCode 伺服器以套用變更。', + 'settings.view.pendingRestart.saved': '已儲存。請重新啟動 OpenCode 以套用。', 'settings.view.actions.backToSettings': '返回設定頁', 'settings.view.actions.closeSettings': '關閉設定', 'settings.view.actions.openSectionList': '開啟群組清單', diff --git a/packages/ui/src/lib/opencode/deferredRestart.test.ts b/packages/ui/src/lib/opencode/deferredRestart.test.ts new file mode 100644 index 00000000..d468d1c8 --- /dev/null +++ b/packages/ui/src/lib/opencode/deferredRestart.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, mock, test } from 'bun:test'; + +describe('deferred OpenCode restart helpers', () => { + test('isDeferredRestartPayload detects deferred responses', async () => { + const { isDeferredRestartPayload } = await import('./deferredRestart'); + + expect(isDeferredRestartPayload({ + requiresReload: false, + requiresRestart: true, + restartDeferred: true, + })).toBe(true); + + expect(isDeferredRestartPayload({ + requiresReload: false, + requiresRestart: true, + })).toBe(true); + + expect(isDeferredRestartPayload({ + requiresReload: true, + message: 'reloading', + })).toBe(false); + + expect(isDeferredRestartPayload({ + requiresManualRestart: true, + requiresRestart: true, + restartDeferred: true, + })).toBe(false); + }); + + test('noteDeferredRestartFromPayload records pending changes', async () => { + const { usePendingOpenCodeRestartStore } = await import('@/stores/usePendingOpenCodeRestartStore'); + usePendingOpenCodeRestartStore.getState().clear(); + + const { noteDeferredRestartFromPayload } = await import('./deferredRestart'); + const noted = noteDeferredRestartFromPayload({ + requiresReload: false, + requiresRestart: true, + restartDeferred: true, + }, 'mcp', { id: 'filesystem' }); + + expect(noted).toBe(true); + expect(usePendingOpenCodeRestartStore.getState().changes).toHaveLength(1); + expect(usePendingOpenCodeRestartStore.getState().changes[0]?.scope).toBe('mcp'); + }); + + test('applyPendingOpenCodeRestart clears pending changes after success', async () => { + mock.module('@/stores/useAgentsStore', () => ({ + reloadOpenCodeConfiguration: async () => undefined, + })); + + const { usePendingOpenCodeRestartStore } = await import('@/stores/usePendingOpenCodeRestartStore'); + usePendingOpenCodeRestartStore.getState().clear(); + usePendingOpenCodeRestartStore.getState().recordChange({ scope: 'agents', id: 'demo' }); + + const { applyPendingOpenCodeRestart } = await import('./deferredRestart'); + const result = await applyPendingOpenCodeRestart({ message: 'Applying…' }); + + expect(result).toEqual({ ok: true }); + expect(usePendingOpenCodeRestartStore.getState().changes).toHaveLength(0); + expect(usePendingOpenCodeRestartStore.getState().isApplying).toBe(false); + }); +}); diff --git a/packages/ui/src/lib/opencode/deferredRestart.ts b/packages/ui/src/lib/opencode/deferredRestart.ts new file mode 100644 index 00000000..1960a8f4 --- /dev/null +++ b/packages/ui/src/lib/opencode/deferredRestart.ts @@ -0,0 +1,79 @@ +import { + usePendingOpenCodeRestartStore, + type PendingOpenCodeRestartScope, +} from '@/stores/usePendingOpenCodeRestartStore'; + +export type ConfigMutationPayload = { + requiresReload?: boolean; + requiresRestart?: boolean; + restartDeferred?: boolean; + requiresManualRestart?: boolean; + reloadFailed?: boolean; + message?: string; + warning?: string; + reloadDelayMs?: number; +} | null | undefined; + +export function isDeferredRestartPayload(payload: ConfigMutationPayload): boolean { + if (!payload || typeof payload !== 'object') { + return false; + } + if (payload.requiresManualRestart === true) { + return false; + } + return payload.restartDeferred === true || (payload.requiresRestart === true && payload.requiresReload !== true); +} + +export function recordDeferredOpenCodeRestart( + scope: PendingOpenCodeRestartScope, + options?: { id?: string; label?: string }, +): void { + usePendingOpenCodeRestartStore.getState().recordChange({ + scope, + id: options?.id, + label: options?.label, + }); +} + +/** + * If the mutation response deferred the OpenCode restart, record it and return true. + * Callers should skip immediate refresh overlays when this returns true. + */ +export function noteDeferredRestartFromPayload( + payload: ConfigMutationPayload, + scope: PendingOpenCodeRestartScope, + options?: { id?: string; label?: string }, +): boolean { + if (!isDeferredRestartPayload(payload)) { + return false; + } + recordDeferredOpenCodeRestart(scope, options); + return true; +} + +export async function applyPendingOpenCodeRestart(options?: { + message?: string; +}): Promise<{ ok: boolean; requiresManualRestart?: boolean }> { + const store = usePendingOpenCodeRestartStore.getState(); + if (store.isApplying) { + return { ok: false }; + } + + store.setApplying(true); + try { + const { reloadOpenCodeConfiguration } = await import('@/stores/useAgentsStore'); + await reloadOpenCodeConfiguration({ + message: options?.message, + mode: 'projects', + scopes: ['all'], + }); + usePendingOpenCodeRestartStore.getState().clear(); + return { ok: true }; + } catch (error) { + usePendingOpenCodeRestartStore.getState().setApplying(false); + if ((error as Error & { requiresManualRestart?: boolean })?.requiresManualRestart) { + return { ok: false, requiresManualRestart: true }; + } + throw error; + } +} diff --git a/packages/ui/src/stores/useAgentsStore.ts b/packages/ui/src/stores/useAgentsStore.ts index 1412bc0a..ba9c9cae 100644 --- a/packages/ui/src/stores/useAgentsStore.ts +++ b/packages/ui/src/stores/useAgentsStore.ts @@ -9,6 +9,7 @@ import { finishConfigUpdate, updateConfigUpdateMessage, } from "@/lib/configUpdate"; +import { noteDeferredRestartFromPayload } from "@/lib/opencode/deferredRestart"; import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { useConfigStore } from "@/stores/useConfigStore"; import { invalidateCommandsLoadCache, useCommandsStore } from "@/stores/useCommandsStore"; @@ -120,10 +121,13 @@ export interface AgentConfig { * `requiresManualRestart` is true when the change was persisted to disk but the * connected (external) OpenCode server could not be reloaded by OpenChamber, so * the user must restart that server before the change takes effect. + * `restartDeferred` is true when the change is saved and waiting for an explicit + * Apply & Restart OpenCode action. */ export interface AgentMutationResult { ok: boolean; requiresManualRestart?: boolean; + restartDeferred?: boolean; } // Extended Agent type for API properties not in SDK types @@ -179,6 +183,63 @@ const SLOW_HEALTH_POLL_MAX_MS = 2000; const hasValue = (value: T | null | undefined): value is T => value !== null && value !== undefined; +const parseModelRef = (model: string | null | undefined): Agent['model'] | undefined => { + if (!model || typeof model !== 'string') return undefined; + const trimmed = model.trim(); + if (!trimmed) return undefined; + const slash = trimmed.indexOf('/'); + if (slash <= 0 || slash >= trimmed.length - 1) { + return { providerID: trimmed, modelID: trimmed }; + } + return { + providerID: trimmed.slice(0, slash), + modelID: trimmed.slice(slash + 1), + }; +}; + +const buildOptimisticAgent = ( + name: string, + config: Partial, + previous?: Agent, +): AgentWithExtras => { + const previousExtras = previous as AgentWithExtras | undefined; + const model = 'model' in config + ? parseModelRef(config.model) + : previous?.model; + return { + ...(previous || { name }), + name, + description: config.description !== undefined ? (config.description || undefined) : previous?.description, + mode: config.mode ?? previous?.mode ?? 'subagent', + model, + variant: 'variant' in config ? (config.variant ?? undefined) : previous?.variant, + temperature: 'temperature' in config ? (config.temperature ?? undefined) : previous?.temperature, + topP: 'top_p' in config ? (config.top_p ?? undefined) : previous?.topP, + prompt: config.prompt !== undefined ? (config.prompt || undefined) : previous?.prompt, + permission: config.permission !== undefined ? (config.permission || undefined) : previous?.permission, + scope: config.scope ?? previousExtras?.scope, + group: previousExtras?.group, + } as unknown as AgentWithExtras; +}; + +const upsertOptimisticAgentLocal = ( + set: (partial: { agents: Agent[] }) => void, + get: () => { agents: Agent[] }, + name: string, + config: Partial, +) => { + const agents = get().agents; + const existing = agents.find((agent) => agent.name === name); + const nextAgent = buildOptimisticAgent(name, config, existing); + if (existing) { + set({ + agents: agents.map((agent) => (agent.name === name ? nextAgent : agent)), + }); + } else { + set({ agents: [...agents, nextAgent] }); + } +}; + export interface AgentDraft { name: string; scope: AgentScope; @@ -335,8 +396,6 @@ export const useAgentsStore = create()( }, createAgent: async (config: AgentConfig) => { - startConfigUpdate("Creating agent configuration…"); - let requiresReload = false; try { console.log('[AgentsStore] Creating agent:', config.name); @@ -376,16 +435,20 @@ export const useAgentsStore = create()( invalidateAgentsLoadCache(configDirectory); - // External OpenCode server: persisted to disk but not reloaded. - // Skip the reload so the form keeps the just-saved values instead of - // reverting to the server's stale, startup-cached config. if (payload?.requiresManualRestart) { + upsertOptimisticAgentLocal(set, get, config.name, config); return { ok: true, requiresManualRestart: true }; } + if (noteDeferredRestartFromPayload(payload, 'agents', { id: config.name })) { + upsertOptimisticAgentLocal(set, get, config.name, config); + emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE }); + return { ok: true, restartDeferred: true }; + } + + startConfigUpdate("Creating agent configuration…"); const needsReload = payload?.requiresReload ?? true; if (needsReload) { - requiresReload = true; await refreshAfterOpenCodeRestart({ message: payload?.message, delayMs: payload?.reloadDelayMs, @@ -399,20 +462,16 @@ export const useAgentsStore = create()( if (loaded) { emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE }); } + finishConfigUpdate(); return { ok: loaded }; } catch (error) { console.error('Failed to create agent:', error); + finishConfigUpdate(); return { ok: false }; - } finally { - if (!requiresReload) { - finishConfigUpdate(); - } } }, updateAgent: async (name: string, config: Partial) => { - startConfigUpdate("Updating agent configuration…"); - let requiresReload = false; try { const agentConfig: Record = {}; @@ -426,7 +485,6 @@ export const useAgentsStore = create()( if (config.permission !== undefined) agentConfig.permission = config.permission; if (config.disable !== undefined) agentConfig.disable = config.disable; - // Use active project root for project-level agent support. const configDirectory = getConfigDirectory(); const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : ''; @@ -447,16 +505,20 @@ export const useAgentsStore = create()( invalidateAgentsLoadCache(configDirectory); - // External OpenCode server: persisted to disk but not reloaded. - // Skip the reload so the form keeps the just-saved values instead of - // reverting to the server's stale, startup-cached config. if (payload?.requiresManualRestart) { + upsertOptimisticAgentLocal(set, get, name, config); return { ok: true, requiresManualRestart: true }; } + if (noteDeferredRestartFromPayload(payload, 'agents', { id: name })) { + upsertOptimisticAgentLocal(set, get, name, config); + emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE }); + return { ok: true, restartDeferred: true }; + } + + startConfigUpdate("Updating agent configuration…"); const needsReload = payload?.requiresReload ?? true; if (needsReload) { - requiresReload = true; await refreshAfterOpenCodeRestart({ message: payload?.message, delayMs: payload?.reloadDelayMs, @@ -470,22 +532,17 @@ export const useAgentsStore = create()( if (loaded) { emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE }); } + finishConfigUpdate(); return { ok: loaded }; } catch (error) { console.error('Failed to update agent:', error); + finishConfigUpdate(); throw error; - } finally { - if (!requiresReload) { - finishConfigUpdate(); - } } }, deleteAgent: async (name: string, scope?: AgentScope) => { - startConfigUpdate("Deleting agent configuration…"); - let requiresReload = false; try { - // Use active project root for project-level agent support. const configDirectory = getConfigDirectory(); const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : ''; @@ -510,14 +567,24 @@ export const useAgentsStore = create()( set({ selectedAgentName: null }); } - // External OpenCode server: persisted to disk but not reloaded. + const removeLocal = () => { + set({ agents: get().agents.filter((agent) => agent.name !== name) }); + }; + if (payload?.requiresManualRestart) { + removeLocal(); return { ok: true, requiresManualRestart: true }; } + if (noteDeferredRestartFromPayload(payload, 'agents', { id: name })) { + removeLocal(); + emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE }); + return { ok: true, restartDeferred: true }; + } + + startConfigUpdate("Deleting agent configuration…"); const needsReload = payload?.requiresReload ?? true; if (needsReload) { - requiresReload = true; await refreshAfterOpenCodeRestart({ message: payload?.message, delayMs: payload?.reloadDelayMs, @@ -532,14 +599,12 @@ export const useAgentsStore = create()( emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE }); } + finishConfigUpdate(); return { ok: loaded }; } catch (error) { console.error('Failed to delete agent:', error); + finishConfigUpdate(); throw error; - } finally { - if (!requiresReload) { - finishConfigUpdate(); - } } }, @@ -747,6 +812,15 @@ export async function reloadOpenCodeConfiguration(options?: { throw new Error(message); } + if (payload?.requiresManualRestart) { + finishConfigUpdate(); + const error = new Error( + payload?.message || 'Restart your connected OpenCode server to apply the changes.', + ); + (error as Error & { requiresManualRestart?: boolean }).requiresManualRestart = true; + throw error; + } + const refreshOptions = { ...options, scopes: options?.scopes ?? ["all"], @@ -764,6 +838,9 @@ export async function reloadOpenCodeConfiguration(options?: { } } catch (error) { console.error('[reloadOpenCodeConfiguration] Failed:', error); + if ((error as Error & { requiresManualRestart?: boolean })?.requiresManualRestart) { + throw error; + } updateConfigUpdateMessage('Failed to reload configuration. Please try again.'); await sleep(2000); finishConfigUpdate(); diff --git a/packages/ui/src/stores/useCommandsStore.ts b/packages/ui/src/stores/useCommandsStore.ts index 63640002..e9aab754 100644 --- a/packages/ui/src/stores/useCommandsStore.ts +++ b/packages/ui/src/stores/useCommandsStore.ts @@ -12,6 +12,7 @@ import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { useProjectsStore } from "@/stores/useProjectsStore"; import { runtimeFetch } from "@/lib/runtime-fetch"; import { runBackgroundNetworkTask } from '@/lib/background-network'; +import { noteDeferredRestartFromPayload } from "@/lib/opencode/deferredRestart"; export type CommandScope = 'user' | 'project'; @@ -65,6 +66,42 @@ const buildCommandsSignature = (commands: Command[]): string => { .join('||'); }; +const upsertCommandLocal = ( + set: (state: Partial) => void, + get: () => CommandsStore, + name: string, + config: Partial, +) => { + const existing = get().commands.find((command) => command.name === name); + const nextCommand: Command = { + ...existing, + name, + ...config, + source: config.source ?? existing?.source, + scope: config.scope ?? existing?.scope, + isBuiltIn: existing?.isBuiltIn, + }; + const commands = get().commands; + const nextCommands = commands.some((command) => command.name === name) + ? commands.map((command) => (command.name === name ? nextCommand : command)) + : [...commands, nextCommand]; + set({ commands: nextCommands }); +}; + +const removeCommandLocal = ( + set: (state: Partial) => void, + get: () => CommandsStore, + name: string, +) => { + const nextState: Partial = { + commands: get().commands.filter((command) => command.name !== name), + }; + if (get().selectedCommandName === name) { + nextState.selectedCommandName = null; + } + set(nextState); +}; + const getRequestDirectory = (): string | null => { try { const projectsStore = useProjectsStore.getState(); @@ -245,8 +282,6 @@ export const useCommandsStore = create()( }, createCommand: async (config: CommandConfig) => { - startConfigUpdate("Creating command configuration…"); - let requiresReload = false; try { console.log('[CommandsStore] Creating command:', config.name); @@ -281,10 +316,21 @@ export const useCommandsStore = create()( console.log('[CommandsStore] Command created successfully'); - const needsReload = payload?.requiresReload ?? true; invalidateCommandsLoadCache(directory); - if (needsReload) { - requiresReload = true; + + if (payload?.requiresManualRestart) { + upsertCommandLocal(set, get, config.name, config); + return true; + } + + if (noteDeferredRestartFromPayload(payload, 'commands', { id: config.name })) { + upsertCommandLocal(set, get, config.name, config); + emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE }); + return true; + } + + if (payload?.requiresReload) { + startConfigUpdate("Creating command configuration…"); await performFullConfigRefresh({ message: payload?.message, delayMs: payload?.reloadDelayMs, @@ -300,16 +346,10 @@ export const useCommandsStore = create()( } catch (error) { console.error("[CommandsStore] Failed to create command:", error); return false; - } finally { - if (!requiresReload) { - finishConfigUpdate(); - } } }, updateCommand: async (name: string, config: Partial) => { - startConfigUpdate("Updating command configuration…"); - let requiresReload = false; try { console.log('[CommandsStore] Updating command:', name); console.log('[CommandsStore] Config received:', config); @@ -343,10 +383,21 @@ export const useCommandsStore = create()( console.log('[CommandsStore] Command updated successfully'); - const needsReload = payload?.requiresReload ?? true; invalidateCommandsLoadCache(directory); - if (needsReload) { - requiresReload = true; + + if (payload?.requiresManualRestart) { + upsertCommandLocal(set, get, name, config); + return true; + } + + if (noteDeferredRestartFromPayload(payload, 'commands', { id: name })) { + upsertCommandLocal(set, get, name, config); + emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE }); + return true; + } + + if (payload?.requiresReload) { + startConfigUpdate("Updating command configuration…"); await performFullConfigRefresh({ message: payload?.message, delayMs: payload?.reloadDelayMs, @@ -362,16 +413,10 @@ export const useCommandsStore = create()( } catch (error) { console.error("[CommandsStore] Failed to update command:", error); return false; - } finally { - if (!requiresReload) { - finishConfigUpdate(); - } } }, deleteCommand: async (name: string) => { - startConfigUpdate("Deleting command configuration…"); - let requiresReload = false; try { // Use active project root for project-level command support const directory = getRequestDirectory(); @@ -390,10 +435,21 @@ export const useCommandsStore = create()( console.log('[CommandsStore] Command deleted successfully'); - const needsReload = payload?.requiresReload ?? true; invalidateCommandsLoadCache(directory); - if (needsReload) { - requiresReload = true; + + if (payload?.requiresManualRestart) { + removeCommandLocal(set, get, name); + return true; + } + + if (noteDeferredRestartFromPayload(payload, 'commands', { id: name })) { + removeCommandLocal(set, get, name); + emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE }); + return true; + } + + if (payload?.requiresReload) { + startConfigUpdate("Deleting command configuration…"); await performFullConfigRefresh({ message: payload?.message, delayMs: payload?.reloadDelayMs, @@ -414,10 +470,6 @@ export const useCommandsStore = create()( } catch (error) { console.error("Failed to delete command:", error); return false; - } finally { - if (!requiresReload) { - finishConfigUpdate(); - } } }, diff --git a/packages/ui/src/stores/useMcpConfigStore.ts b/packages/ui/src/stores/useMcpConfigStore.ts index 48a25a25..18ee8463 100644 --- a/packages/ui/src/stores/useMcpConfigStore.ts +++ b/packages/ui/src/stores/useMcpConfigStore.ts @@ -1,14 +1,12 @@ import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { createDeferredSafeJSONStorage } from './utils/safeStorage'; -import { - startConfigUpdate, - finishConfigUpdate, -} from '@/lib/configUpdate'; +import { startConfigUpdate } from '@/lib/configUpdate'; import { refreshAfterOpenCodeRestart } from '@/stores/useAgentsStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { opencodeClient } from '@/lib/opencode/client'; import { runtimeFetch } from '@/lib/runtime-fetch'; +import { noteDeferredRestartFromPayload } from '@/lib/opencode/deferredRestart'; export type McpScope = 'user' | 'project'; @@ -17,6 +15,8 @@ type McpMutationResult = { reloadFailed?: boolean; message?: string; warning?: string; + requiresManualRestart?: boolean; + restartDeferred?: boolean; }; const getConfigDirectory = (): string | null => { @@ -192,8 +192,6 @@ export const useMcpConfigStore = create()( }, createMcp: async (config: McpDraft) => { - startConfigUpdate('Creating MCP server configuration…'); - let requiresReload = false; try { const body = buildMcpBody(config); const configDirectory = getConfigDirectory(); @@ -214,8 +212,30 @@ export const useMcpConfigStore = create()( invalidateMcpCache(configDirectory); + if (payload?.requiresManualRestart) { + await get().loadMcpConfigs({ force: true }); + return { + ok: true, + requiresManualRestart: true, + reloadFailed: payload?.reloadFailed === true, + message: payload?.message, + warning: payload?.warning, + }; + } + + if (noteDeferredRestartFromPayload(payload, 'mcp', { id: config.name })) { + await get().loadMcpConfigs({ force: true }); + return { + ok: true, + restartDeferred: true, + reloadFailed: payload?.reloadFailed === true, + message: payload?.message, + warning: payload?.warning, + }; + } + if (payload?.requiresReload) { - requiresReload = true; + startConfigUpdate('Creating MCP server configuration…'); await refreshAfterOpenCodeRestart({ message: payload.message, delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS, @@ -240,14 +260,10 @@ export const useMcpConfigStore = create()( } catch (error) { console.error('[McpConfigStore] Failed to create MCP:', error); return { ok: false }; - } finally { - if (!requiresReload) finishConfigUpdate(); } }, updateMcp: async (name: string, config: Partial) => { - startConfigUpdate('Updating MCP server configuration…'); - let requiresReload = false; try { const body = buildMcpBody(config); const configDirectory = getConfigDirectory(); @@ -268,8 +284,30 @@ export const useMcpConfigStore = create()( invalidateMcpCache(configDirectory); + if (payload?.requiresManualRestart) { + await get().loadMcpConfigs({ force: true }); + return { + ok: true, + requiresManualRestart: true, + reloadFailed: payload?.reloadFailed === true, + message: payload?.message, + warning: payload?.warning, + }; + } + + if (noteDeferredRestartFromPayload(payload, 'mcp', { id: name })) { + await get().loadMcpConfigs({ force: true }); + return { + ok: true, + restartDeferred: true, + reloadFailed: payload?.reloadFailed === true, + message: payload?.message, + warning: payload?.warning, + }; + } + if (payload?.requiresReload) { - requiresReload = true; + startConfigUpdate('Updating MCP server configuration…'); await refreshAfterOpenCodeRestart({ message: payload.message, delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS, @@ -294,14 +332,10 @@ export const useMcpConfigStore = create()( } catch (error) { console.error('[McpConfigStore] Failed to update MCP:', error); throw error; - } finally { - if (!requiresReload) finishConfigUpdate(); } }, deleteMcp: async (name: string) => { - startConfigUpdate('Deleting MCP server configuration…'); - let requiresReload = false; try { const configDirectory = getConfigDirectory(); const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : ''; @@ -317,8 +351,34 @@ export const useMcpConfigStore = create()( invalidateMcpCache(configDirectory); + if (get().selectedMcpName === name) { + set({ selectedMcpName: null }); + } + + if (payload?.requiresManualRestart) { + await get().loadMcpConfigs({ force: true }); + return { + ok: true, + requiresManualRestart: true, + reloadFailed: payload?.reloadFailed === true, + message: payload?.message, + warning: payload?.warning, + }; + } + + if (noteDeferredRestartFromPayload(payload, 'mcp', { id: name })) { + await get().loadMcpConfigs({ force: true }); + return { + ok: true, + restartDeferred: true, + reloadFailed: payload?.reloadFailed === true, + message: payload?.message, + warning: payload?.warning, + }; + } + if (payload?.requiresReload) { - requiresReload = true; + startConfigUpdate('Deleting MCP server configuration…'); await refreshAfterOpenCodeRestart({ message: payload.message, delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS, @@ -326,9 +386,6 @@ export const useMcpConfigStore = create()( }); } - if (get().selectedMcpName === name) { - set({ selectedMcpName: null }); - } await get().loadMcpConfigs({ force: true }); return { ok: true, @@ -339,8 +396,6 @@ export const useMcpConfigStore = create()( } catch (error) { console.error('[McpConfigStore] Failed to delete MCP:', error); return { ok: false }; - } finally { - if (!requiresReload) finishConfigUpdate(); } }, diff --git a/packages/ui/src/stores/usePendingOpenCodeRestartStore.ts b/packages/ui/src/stores/usePendingOpenCodeRestartStore.ts new file mode 100644 index 00000000..c9ffb327 --- /dev/null +++ b/packages/ui/src/stores/usePendingOpenCodeRestartStore.ts @@ -0,0 +1,66 @@ +import { create } from 'zustand'; + +export type PendingOpenCodeRestartScope = + | 'agents' + | 'providers' + | 'commands' + | 'mcp' + | 'plugins' + | 'skills' + | 'behavior' + | 'cli' + | 'all'; + +export type PendingOpenCodeRestartChange = { + id: string; + scope: PendingOpenCodeRestartScope; + label?: string; + recordedAt: number; +}; + +type PendingOpenCodeRestartState = { + changes: PendingOpenCodeRestartChange[]; + isApplying: boolean; + recordChange: (input: { + scope: PendingOpenCodeRestartScope; + id?: string; + label?: string; + }) => void; + setApplying: (isApplying: boolean) => void; + clear: () => void; +}; + +let changeSeq = 0; + +const nextChangeId = (scope: PendingOpenCodeRestartScope, id?: string): string => { + changeSeq += 1; + return id?.trim() ? `${scope}:${id.trim()}:${changeSeq}` : `${scope}:${changeSeq}`; +}; + +export const usePendingOpenCodeRestartStore = create((set) => ({ + changes: [], + isApplying: false, + + recordChange: ({ scope, id, label }) => { + const entry: PendingOpenCodeRestartChange = { + id: nextChangeId(scope, id), + scope, + label, + recordedAt: Date.now(), + }; + set((state) => ({ + changes: [...state.changes, entry], + })); + }, + + setApplying: (isApplying) => { + set({ isApplying }); + }, + + clear: () => { + set({ changes: [], isApplying: false }); + }, +})); + +export const selectPendingOpenCodeRestartCount = (state: PendingOpenCodeRestartState): number => + state.changes.length; diff --git a/packages/ui/src/stores/usePluginsStore.ts b/packages/ui/src/stores/usePluginsStore.ts index 364da000..7f4b9d10 100644 --- a/packages/ui/src/stores/usePluginsStore.ts +++ b/packages/ui/src/stores/usePluginsStore.ts @@ -1,14 +1,12 @@ import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { createDeferredSafeJSONStorage } from './utils/safeStorage'; -import { - startConfigUpdate, - finishConfigUpdate, -} from '@/lib/configUpdate'; +import { startConfigUpdate } from '@/lib/configUpdate'; import { refreshAfterOpenCodeRestart } from '@/stores/useAgentsStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { opencodeClient } from '@/lib/opencode/client'; import { runtimeFetch } from '@/lib/runtime-fetch'; +import { noteDeferredRestartFromPayload } from '@/lib/opencode/deferredRestart'; export type PluginScope = 'user' | 'project'; type PluginParsedKind = 'npm' | 'path'; @@ -43,6 +41,8 @@ export type PluginMutationResult = { reloadFailed?: boolean; message?: string; warning?: string; + requiresManualRestart?: boolean; + restartDeferred?: boolean; }; export type RegistryResult = @@ -91,6 +91,9 @@ type RegistryInfoResponse = { type PluginMutationPayload = { success?: boolean; requiresReload?: boolean; + requiresRestart?: boolean; + restartDeferred?: boolean; + requiresManualRestart?: boolean; message?: string; reloadDelayMs?: number; reloadFailed?: boolean; @@ -248,7 +251,7 @@ export const usePluginsStore = create()( body: JSON.stringify(buildEntryBody(input)), }); return response; - }, get); + }, get, { restartId: input.spec }); if (result.ok) { void get().loadRegistryInfo({ specs: [input.spec], force: true }); } @@ -265,7 +268,7 @@ export const usePluginsStore = create()( body: JSON.stringify(buildEntryBody(input)), }); return response; - }, get); + }, get, { restartId: id }); if (result.ok && nextSpec) { void get().loadRegistryInfo({ specs: [nextSpec], force: true }); } @@ -280,7 +283,7 @@ export const usePluginsStore = create()( headers: buildDirectoryHeaders(configDirectory), }); return response; - }, get); + }, get, { restartId: id }); if (result.ok && get().selectedId === id) { set({ selectedId: null }); @@ -317,7 +320,7 @@ export const usePluginsStore = create()( body: JSON.stringify(input), }); return response; - }, get); + }, get, { restartId: input.fileName }); }, updateFile: async (id, input) => { @@ -328,7 +331,7 @@ export const usePluginsStore = create()( body: JSON.stringify(input), }); return response; - }, get); + }, get, { restartId: id }); }, deleteFile: async (id) => { @@ -338,7 +341,7 @@ export const usePluginsStore = create()( headers: buildDirectoryHeaders(configDirectory), }); return response; - }, get); + }, get, { restartId: id }); if (result.ok && get().selectedId === id) { set({ selectedId: null }); @@ -425,9 +428,8 @@ async function runPluginMutation( progressMessage: string, request: (configDirectory: string | null) => Promise, get: () => PluginsStore, + options?: { restartId?: string }, ): Promise { - startConfigUpdate(progressMessage); - let requiresReload = false; try { const configDirectory = getConfigDirectory(); const response = await request(configDirectory); @@ -439,8 +441,30 @@ async function runPluginMutation( invalidatePluginCache(configDirectory); + if (payload?.requiresManualRestart) { + await get().loadPlugins({ force: true }); + return { + ok: true, + requiresManualRestart: true, + reloadFailed: payload?.reloadFailed === true, + message: payload?.message, + warning: payload?.warning, + }; + } + + if (noteDeferredRestartFromPayload(payload, 'plugins', { id: options?.restartId })) { + await get().loadPlugins({ force: true }); + return { + ok: true, + restartDeferred: true, + reloadFailed: payload?.reloadFailed === true, + message: payload?.message, + warning: payload?.warning, + }; + } + if (payload?.requiresReload) { - requiresReload = true; + startConfigUpdate(progressMessage); await refreshAfterOpenCodeRestart({ message: payload.message, delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS, @@ -458,8 +482,6 @@ async function runPluginMutation( } catch (error) { console.error('[PluginsStore] Failed to update plugin configuration:', error); return { ok: false }; - } finally { - if (!requiresReload) finishConfigUpdate(); } } diff --git a/packages/ui/src/stores/useSkillsCatalogStore.ts b/packages/ui/src/stores/useSkillsCatalogStore.ts index 139348fb..150286c3 100644 --- a/packages/ui/src/stores/useSkillsCatalogStore.ts +++ b/packages/ui/src/stores/useSkillsCatalogStore.ts @@ -15,8 +15,9 @@ import type { import { invalidateSkillsLoadCache, refreshSkillsAfterOpenCodeRestart, useSkillsStore } from '@/stores/useSkillsStore'; import { opencodeClient } from '@/lib/opencode/client'; -import { startConfigUpdate, finishConfigUpdate, updateConfigUpdateMessage } from '@/lib/configUpdate'; +import { startConfigUpdate } from '@/lib/configUpdate'; import { runtimeFetch } from '@/lib/runtime-fetch'; +import { noteDeferredRestartFromPayload } from '@/lib/opencode/deferredRestart'; const FALLBACK_SOURCES: SkillsCatalogSource[] = [ { @@ -384,9 +385,7 @@ export const useSkillsCatalogStore = create()( }, installSkills: async (request, options) => { - startConfigUpdate('Installing skills…'); set({ isInstalling: true, lastInstallError: null }); - let requiresReload = false; try { const directoryOverride = typeof options?.directory === 'string' && options.directory.trim().length > 0 ? options.directory.trim() @@ -404,26 +403,34 @@ export const useSkillsCatalogStore = create()( if (!payload) { const error = { kind: 'unknown', message: 'Failed to install skills' } as SkillsInstallError; set({ lastInstallError: error }); - updateConfigUpdateMessage('Failed to install skills. Please retry.'); return { ok: false, error }; } if (!response.ok || !payload.ok) { const error = payload.error || ({ kind: 'unknown', message: 'Failed to install skills' } as SkillsInstallError); set({ lastInstallError: error }); - updateConfigUpdateMessage(error.message || 'Failed to install skills. Please retry.'); return { ok: false, error }; } + invalidateSkillsLoadCache(currentDirectory); + + if (payload.requiresManualRestart) { + void get().loadCatalog({ refresh: true }); + return payload; + } + + if (noteDeferredRestartFromPayload(payload, 'skills')) { + void get().loadCatalog({ refresh: true }); + return { ...payload, restartDeferred: true }; + } + if (payload.requiresReload) { - requiresReload = true; + startConfigUpdate('Installing skills…'); await refreshSkillsAfterOpenCodeRestart({ message: payload.message, delayMs: payload.reloadDelayMs, }); } else { - updateConfigUpdateMessage(payload.message || 'Refreshing skills…'); - invalidateSkillsLoadCache(currentDirectory); void useSkillsStore.getState().loadSkills(); } @@ -431,13 +438,9 @@ export const useSkillsCatalogStore = create()( } catch (error) { const err = { kind: 'unknown', message: error instanceof Error ? error.message : String(error) } as SkillsInstallError; set({ lastInstallError: err }); - updateConfigUpdateMessage('Failed to install skills. Please retry.'); return { ok: false, error: err }; } finally { set({ isInstalling: false }); - if (!requiresReload) { - finishConfigUpdate(); - } } }, }), diff --git a/packages/ui/src/stores/useSkillsStore.ts b/packages/ui/src/stores/useSkillsStore.ts index 2ffe69dd..9707d777 100644 --- a/packages/ui/src/stores/useSkillsStore.ts +++ b/packages/ui/src/stores/useSkillsStore.ts @@ -10,6 +10,7 @@ import { import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { runtimeFetch } from "@/lib/runtime-fetch"; import { runBackgroundNetworkTask } from "@/lib/background-network"; +import { noteDeferredRestartFromPayload } from "@/lib/opencode/deferredRestart"; import { opencodeClient } from '@/lib/opencode/client'; @@ -173,6 +174,44 @@ export const invalidateSkillsLoadCache = (directory: string | null = getCurrentD skillsLastLoadedAt.delete(getSkillsCacheKey(directory)); }; +const upsertSkillLocal = ( + set: (state: Partial) => void, + get: () => SkillsStore, + name: string, + config: Partial, +) => { + const existing = get().skills.find((skill) => skill.name === name); + const path = config.targetPath ?? existing?.path ?? ''; + const nextSkill: DiscoveredSkill = { + ...existing, + name, + path, + scope: config.scope ?? existing?.scope ?? 'user', + source: config.source ?? existing?.source ?? 'opencode', + description: config.description ?? existing?.description ?? '', + group: parseSkillGroup(path), + }; + const skills = get().skills; + const nextSkills = skills.some((skill) => skill.name === name) + ? skills.map((skill) => (skill.name === name ? nextSkill : skill)) + : [...skills, nextSkill]; + set({ skills: nextSkills }); +}; + +const removeSkillLocal = ( + set: (state: Partial) => void, + get: () => SkillsStore, + name: string, +) => { + const nextState: Partial = { + skills: get().skills.filter((skill) => skill.name !== name), + }; + if (get().selectedSkillName === name) { + nextState.selectedSkillName = null; + } + set(nextState); +}; + const MAX_HEALTH_WAIT_MS = 20000; const FAST_HEALTH_POLL_INTERVAL_MS = 300; const FAST_HEALTH_POLL_ATTEMPTS = 4; @@ -278,8 +317,6 @@ export const useSkillsStore = create()( }, createSkill: async (config: SkillConfig) => { - startConfigUpdate("Creating skill..."); - let requiresReload = false; try { const skillConfig: Record = { name: config.name, @@ -306,10 +343,21 @@ export const useSkillsStore = create()( throw new Error(message); } - const needsReload = payload?.requiresReload ?? false; invalidateSkillsLoadCache(currentDirectory); - if (needsReload) { - requiresReload = true; + + if (payload?.requiresManualRestart) { + upsertSkillLocal(set, get, config.name, config); + return true; + } + + if (noteDeferredRestartFromPayload(payload, 'skills', { id: config.name })) { + upsertSkillLocal(set, get, config.name, config); + emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE }); + return true; + } + + if (payload?.requiresReload) { + startConfigUpdate("Creating skill..."); await refreshSkillsAfterOpenCodeRestart({ message: payload?.message, delayMs: payload?.reloadDelayMs, @@ -324,16 +372,10 @@ export const useSkillsStore = create()( return loaded; } catch { return false; - } finally { - if (!requiresReload) { - finishConfigUpdate(); - } } }, updateSkill: async (name: string, config: Partial) => { - startConfigUpdate("Updating skill..."); - let requiresReload = false; try { const skillConfig: Record = {}; @@ -357,10 +399,21 @@ export const useSkillsStore = create()( throw new Error(message); } - const needsReload = payload?.requiresReload ?? false; invalidateSkillsLoadCache(currentDirectory); - if (needsReload) { - requiresReload = true; + + if (payload?.requiresManualRestart) { + upsertSkillLocal(set, get, name, config); + return true; + } + + if (noteDeferredRestartFromPayload(payload, 'skills', { id: name })) { + upsertSkillLocal(set, get, name, config); + emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE }); + return true; + } + + if (payload?.requiresReload) { + startConfigUpdate("Updating skill..."); await refreshSkillsAfterOpenCodeRestart({ message: payload?.message, delayMs: payload?.reloadDelayMs, @@ -375,16 +428,10 @@ export const useSkillsStore = create()( return loaded; } catch { return false; - } finally { - if (!requiresReload) { - finishConfigUpdate(); - } } }, deleteSkill: async (name: string) => { - startConfigUpdate("Deleting skill..."); - let requiresReload = false; try { const currentDirectory = getCurrentDirectory(); const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; @@ -399,10 +446,21 @@ export const useSkillsStore = create()( throw new Error(message); } - const needsReload = payload?.requiresReload ?? false; invalidateSkillsLoadCache(currentDirectory); - if (needsReload) { - requiresReload = true; + + if (payload?.requiresManualRestart) { + removeSkillLocal(set, get, name); + return true; + } + + if (noteDeferredRestartFromPayload(payload, 'skills', { id: name })) { + removeSkillLocal(set, get, name); + emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE }); + return true; + } + + if (payload?.requiresReload) { + startConfigUpdate("Deleting skill..."); await refreshSkillsAfterOpenCodeRestart({ message: payload?.message, delayMs: payload?.reloadDelayMs, @@ -422,10 +480,6 @@ export const useSkillsStore = create()( return loaded; } catch { return false; - } finally { - if (!requiresReload) { - finishConfigUpdate(); - } } }, diff --git a/packages/web/server/lib/opencode/config-mutation-response.test.js b/packages/web/server/lib/opencode/config-mutation-response.test.js new file mode 100644 index 00000000..e7e4fe5d --- /dev/null +++ b/packages/web/server/lib/opencode/config-mutation-response.test.js @@ -0,0 +1,27 @@ +import { describe, expect, test } from 'bun:test'; + +import { + buildDeferredRestartResponse, + buildExternalManualRestartResponse, +} from './config-mutation-response.js'; + +describe('config mutation response helpers', () => { + test('buildDeferredRestartResponse marks restart as deferred', () => { + expect(buildDeferredRestartResponse('Saved. Restart OpenCode to apply.')).toEqual({ + success: true, + requiresReload: false, + requiresRestart: true, + restartDeferred: true, + message: 'Saved. Restart OpenCode to apply.', + }); + }); + + test('buildExternalManualRestartResponse asks for external restart', () => { + expect(buildExternalManualRestartResponse('Restart your server.')).toEqual({ + success: true, + requiresReload: false, + requiresManualRestart: true, + message: 'Restart your server.', + }); + }); +});