diff --git a/packages/ui/src/components/sections/behavior/BehaviorPage.tsx b/packages/ui/src/components/sections/behavior/BehaviorPage.tsx index db56e86a..758c954c 100644 --- a/packages/ui/src/components/sections/behavior/BehaviorPage.tsx +++ b/packages/ui/src/components/sections/behavior/BehaviorPage.tsx @@ -243,9 +243,7 @@ export const BehaviorPage: React.FC = () => { } const payload = await response.json().catch(() => null); - if (!noteDeferredRestartFromPayload(payload, 'behavior', { id: 'agents-md' })) { - recordDeferredOpenCodeRestart('behavior', { id: 'agents-md' }); - } + const deferred = noteDeferredRestartFromPayload(payload, 'behavior', { id: 'agents-md' }); await saveBehaviorSetting({ globalBehaviorPrompt: content, @@ -253,7 +251,11 @@ export const BehaviorPage: React.FC = () => { setPrompt(content); setInitialPrompt(content); - toast.success(t('settings.view.pendingRestart.saved')); + toast.success( + deferred + ? t('settings.view.pendingRestart.saved') + : t('settings.behavior.page.toast.saved'), + ); } catch (error) { console.error('Failed to save behavior:', error); const message = error instanceof Error ? error.message : t('settings.behavior.page.toast.saveFailed'); diff --git a/packages/ui/src/components/sections/commands/CommandsPage.tsx b/packages/ui/src/components/sections/commands/CommandsPage.tsx index f73dd681..b04649d1 100644 --- a/packages/ui/src/components/sections/commands/CommandsPage.tsx +++ b/packages/ui/src/components/sections/commands/CommandsPage.tsx @@ -4,6 +4,7 @@ import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { toast } from '@/components/ui'; import { useCommandsStore, type CommandConfig, type CommandScope } from '@/stores/useCommandsStore'; +import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore'; import { useShallow } from 'zustand/react/shallow'; import { ModelSelector } from '../agents/ModelSelector'; import { AgentSelector } from './AgentSelector'; @@ -170,7 +171,16 @@ export const CommandsPage: React.FC = () => { } if (success) { - toast.success(isNewCommand ? t('settings.commands.page.toast.created') : t('settings.commands.page.toast.updated')); + const deferred = usePendingOpenCodeRestartStore.getState().changes.some( + (change) => change.scope === 'commands' && change.id.startsWith(`commands:${commandName}:`), + ); + toast.success( + deferred + ? t('settings.view.pendingRestart.saved') + : isNewCommand + ? t('settings.commands.page.toast.created') + : t('settings.commands.page.toast.updated'), + ); } else { toast.error(isNewCommand ? t('settings.commands.page.toast.createFailed') : t('settings.commands.page.toast.updateFailed')); } diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index 88b4d1f8..150b4c03 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -490,9 +490,9 @@ export const ProvidersPage: React.FC = () => { } toast.success(t('settings.providers.page.toast.providerDisconnected')); - if (!noteDeferredRestartFromPayload(payload, 'providers', { id: providerId })) { - recordDeferredOpenCodeRestart('providers', { id: providerId }); - } + // Only accumulate when the server actually deferred a restart (e.g. auth removed). + // removed:false payloads must not create a phantom pending Apply & Restart. + noteDeferredRestartFromPayload(payload, '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/sections/skills/SkillsPage.tsx b/packages/ui/src/components/sections/skills/SkillsPage.tsx index 8e9a8d89..bf738b55 100644 --- a/packages/ui/src/components/sections/skills/SkillsPage.tsx +++ b/packages/ui/src/components/sections/skills/SkillsPage.tsx @@ -6,6 +6,7 @@ import { Textarea } from '@/components/ui/textarea'; import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor'; import { toast } from '@/components/ui'; import { useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore'; +import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore'; import { useShallow } from 'zustand/react/shallow'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout'; @@ -352,7 +353,16 @@ const SkillsInstalledPage: React.FC = () => { } if (success) { - toast.success(isNewSkill ? t('settings.skills.page.toast.skillCreated') : t('settings.skills.page.toast.skillUpdated')); + const deferred = usePendingOpenCodeRestartStore.getState().changes.some( + (change) => change.scope === 'skills' && change.id.startsWith(`skills:${skillName}:`), + ); + toast.success( + deferred + ? t('settings.view.pendingRestart.saved') + : isNewSkill + ? t('settings.skills.page.toast.skillCreated') + : t('settings.skills.page.toast.skillUpdated'), + ); } else { toast.error(isNewSkill ? t('settings.skills.page.toast.createSkillFailed') : t('settings.skills.page.toast.updateSkillFailed')); } diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index a2d03ead..cb12ec9b 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -44,14 +44,14 @@ export const settingsDict = { 'settings.view.actions.applyAndRestartOpenCode': 'Appliquer et redémarrer', '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.applying': 'Application des modifications et redémarrage d\'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.pendingRestart.confirm.title': 'Appliquer et redémarrer ?', - 'settings.view.pendingRestart.confirm.description': 'Le redémarrage de OpenCode arrêtera tous les chats en cours. Vos modifications de configuration enregistrées prendront effet après le redémarrage.', + 'settings.view.pendingRestart.confirm.description': 'Le redémarrage d\'OpenCode arrêtera tous les chats en cours. Vos modifications de configuration enregistrées prendront effet après le redémarrage.', 'settings.view.pendingRestart.confirm.dontShowAgain': 'Ne plus afficher', 'settings.view.pendingRestart.confirm.cancel': 'Annuler', 'settings.view.actions.backToSettings': 'Retour aux paramètres', 'settings.view.actions.closeSettings': 'Fermer les paramètres', diff --git a/packages/ui/src/lib/opencode/deferredRestart.test.ts b/packages/ui/src/lib/opencode/deferredRestart.test.ts index d468d1c8..1c2daaec 100644 --- a/packages/ui/src/lib/opencode/deferredRestart.test.ts +++ b/packages/ui/src/lib/opencode/deferredRestart.test.ts @@ -43,6 +43,20 @@ describe('deferred OpenCode restart helpers', () => { expect(usePendingOpenCodeRestartStore.getState().changes[0]?.scope).toBe('mcp'); }); + test('noteDeferredRestartFromPayload ignores non-deferred payloads', async () => { + const { usePendingOpenCodeRestartStore } = await import('@/stores/usePendingOpenCodeRestartStore'); + usePendingOpenCodeRestartStore.getState().clear(); + + const { noteDeferredRestartFromPayload } = await import('./deferredRestart'); + const noted = noteDeferredRestartFromPayload({ + requiresReload: false, + message: 'Provider was not connected', + }, 'providers', { id: 'openai' }); + + expect(noted).toBe(false); + expect(usePendingOpenCodeRestartStore.getState().changes).toHaveLength(0); + }); + test('applyPendingOpenCodeRestart clears pending changes after success', async () => { mock.module('@/stores/useAgentsStore', () => ({ reloadOpenCodeConfiguration: async () => undefined, @@ -59,4 +73,25 @@ describe('deferred OpenCode restart helpers', () => { expect(usePendingOpenCodeRestartStore.getState().changes).toHaveLength(0); expect(usePendingOpenCodeRestartStore.getState().isApplying).toBe(false); }); + + test('applyPendingOpenCodeRestart clears pending changes on manual restart', async () => { + mock.module('@/stores/useAgentsStore', () => ({ + reloadOpenCodeConfiguration: async () => { + const error = new Error('Restart your connected OpenCode server'); + (error as Error & { requiresManualRestart?: boolean }).requiresManualRestart = true; + throw error; + }, + })); + + const { usePendingOpenCodeRestartStore } = await import('@/stores/usePendingOpenCodeRestartStore'); + usePendingOpenCodeRestartStore.getState().clear(); + usePendingOpenCodeRestartStore.getState().recordChange({ scope: 'mcp', id: 'filesystem' }); + + const { applyPendingOpenCodeRestart } = await import('./deferredRestart'); + const result = await applyPendingOpenCodeRestart({ message: 'Applying…' }); + + expect(result).toEqual({ ok: false, requiresManualRestart: 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 index 1960a8f4..4308b71b 100644 --- a/packages/ui/src/lib/opencode/deferredRestart.ts +++ b/packages/ui/src/lib/opencode/deferredRestart.ts @@ -70,10 +70,12 @@ export async function applyPendingOpenCodeRestart(options?: { usePendingOpenCodeRestartStore.getState().clear(); return { ok: true }; } catch (error) { - usePendingOpenCodeRestartStore.getState().setApplying(false); if ((error as Error & { requiresManualRestart?: boolean })?.requiresManualRestart) { + // Changes are already on disk; clear the badge after delivering manual-restart guidance. + usePendingOpenCodeRestartStore.getState().clear(); return { ok: false, requiresManualRestart: true }; } + usePendingOpenCodeRestartStore.getState().setApplying(false); throw error; } }