Accumulate OpenCode settings restarts behind Apply & Restart

Defer OpenCode reloads after settings mutations, track pending changes,
and expose a top-right Apply & Restart OpenCode action with a counter so
sessions stay available until the user explicitly applies.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-08-03 06:56:38 +00:00
co-authored by Serhii Dziupin
parent 775da6e9f4
commit 57819dd164
31 changed files with 869 additions and 167 deletions
@@ -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'));
}
@@ -188,6 +188,8 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ 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 {
@@ -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')}
</Button>
</SettingsSection>
)}
@@ -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')
@@ -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')}
</Button>
</div>
</SettingsInset>
@@ -109,7 +109,11 @@ export const AddPluginDialog: React.FC<AddPluginDialogProps> = ({
});
}
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'));
}
@@ -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'));
}
@@ -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'));
@@ -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<PendingOpenCodeRestartActionProps> = ({
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 (
<Button
type="button"
size={compact ? 'xs' : 'sm'}
disabled={isApplying}
title={tooltip}
aria-label={tooltip}
onClick={() => void handleApply()}
className={cn('gap-1.5 shadow-sm', className)}
>
<Icon name="restart" className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">
{isApplying
? t('settings.view.pendingRestart.applying')
: t('settings.view.actions.applyAndRestartOpenCode')}
</span>
<span
className={cn(
'inline-flex min-w-5 items-center justify-center rounded-md px-1.5 typography-micro font-semibold',
'bg-primary-foreground/15 text-primary-foreground',
)}
aria-hidden="true"
>
{pendingCount}
</span>
</Button>
);
};
@@ -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<SettingsViewProps> = ({ 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<SettingsViewProps> = ({ onClose, forceMobile
{/* Footer */}
<div className="overflow-hidden transition-opacity duration-150 opacity-100">
<div className="border-t border-border bg-background px-4 py-1 space-y-0.5 sm:bg-sidebar">
{!runtimeCtx.isVSCode && (
{!runtimeCtx.isVSCode && pendingRestartCount <= 0 && (
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -984,7 +990,20 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
'text-sm font-semibold text-sidebar-foreground/90',
'hover:text-sidebar-foreground hover:bg-interactive-hover',
)}
onClick={() => void reloadOpenCodeConfiguration({ message: 'Restarting OpenCode…', mode: 'projects', scopes: ['all'] }).catch(() => undefined)}
onClick={() => {
void (async () => {
try {
await reloadOpenCodeConfiguration({
message: t('settings.view.pendingRestart.applying'),
mode: 'projects',
scopes: ['all'],
});
usePendingOpenCodeRestartStore.getState().clear();
} catch {
// ignore
}
})();
}}
>
<Icon name="restart" className="h-4 w-4 shrink-0" />
<span>{t('settings.view.actions.reloadOpenCode')}</span>
@@ -1113,6 +1132,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
</button>
)}
<PendingOpenCodeRestartAction compact className="max-w-[min(100%,14rem)]" />
{onClose && (
<button
type="button"
@@ -1140,8 +1161,9 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
</div>
)}
{onClose && (
<div className={cn('absolute right-0.5 z-50', isWindowed ? 'top-0.5' : 'top-1')}>
<div className={cn('absolute right-0.5 z-50 flex items-center gap-1.5', isWindowed ? 'top-0.5' : 'top-1')}>
<PendingOpenCodeRestartAction compact className="max-w-[min(100%,16rem)]" />
{onClose && (
<button
type="button"
onClick={onClose}
@@ -1151,8 +1173,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
>
<Icon name="close" className="h-5 w-5" />
</button>
</div>
)}
)}
</div>
</>
)}
+5
View File
@@ -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;
}
@@ -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',
@@ -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",
@@ -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',
@@ -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': 'セクション一覧を開く',
@@ -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': '섹션 목록 열기',
@@ -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ń',
@@ -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",
@@ -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": "Відкрити список розділів",
@@ -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': '打开分组列表',
@@ -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': '開啟群組清單',
@@ -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);
});
});
@@ -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;
}
}
+107 -30
View File
@@ -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 = <T>(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<AgentConfig>,
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<AgentConfig>,
) => {
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<AgentsStore>()(
},
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<AgentsStore>()(
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<AgentsStore>()(
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<AgentConfig>) => {
startConfigUpdate("Updating agent configuration…");
let requiresReload = false;
try {
const agentConfig: Record<string, unknown> = {};
@@ -426,7 +485,6 @@ export const useAgentsStore = create<AgentsStore>()(
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<AgentsStore>()(
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<AgentsStore>()(
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<AgentsStore>()(
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<AgentsStore>()(
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();
+79 -27
View File
@@ -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<CommandsStore>) => void,
get: () => CommandsStore,
name: string,
config: Partial<CommandConfig>,
) => {
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<CommandsStore>) => void,
get: () => CommandsStore,
name: string,
) => {
const nextState: Partial<CommandsStore> = {
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<CommandsStore>()(
},
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<CommandsStore>()(
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<CommandsStore>()(
} catch (error) {
console.error("[CommandsStore] Failed to create command:", error);
return false;
} finally {
if (!requiresReload) {
finishConfigUpdate();
}
}
},
updateCommand: async (name: string, config: Partial<CommandConfig>) => {
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<CommandsStore>()(
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<CommandsStore>()(
} 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<CommandsStore>()(
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<CommandsStore>()(
} catch (error) {
console.error("Failed to delete command:", error);
return false;
} finally {
if (!requiresReload) {
finishConfigUpdate();
}
}
},
+77 -22
View File
@@ -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<McpConfigStore>()(
},
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<McpConfigStore>()(
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<McpConfigStore>()(
} catch (error) {
console.error('[McpConfigStore] Failed to create MCP:', error);
return { ok: false };
} finally {
if (!requiresReload) finishConfigUpdate();
}
},
updateMcp: async (name: string, config: Partial<McpDraft>) => {
startConfigUpdate('Updating MCP server configuration…');
let requiresReload = false;
try {
const body = buildMcpBody(config);
const configDirectory = getConfigDirectory();
@@ -268,8 +284,30 @@ export const useMcpConfigStore = create<McpConfigStore>()(
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<McpConfigStore>()(
} 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<McpConfigStore>()(
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<McpConfigStore>()(
});
}
if (get().selectedMcpName === name) {
set({ selectedMcpName: null });
}
await get().loadMcpConfigs({ force: true });
return {
ok: true,
@@ -339,8 +396,6 @@ export const useMcpConfigStore = create<McpConfigStore>()(
} catch (error) {
console.error('[McpConfigStore] Failed to delete MCP:', error);
return { ok: false };
} finally {
if (!requiresReload) finishConfigUpdate();
}
},
@@ -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<PendingOpenCodeRestartState>((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;
+37 -15
View File
@@ -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<PluginsStore>()(
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<PluginsStore>()(
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<PluginsStore>()(
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<PluginsStore>()(
body: JSON.stringify(input),
});
return response;
}, get);
}, get, { restartId: input.fileName });
},
updateFile: async (id, input) => {
@@ -328,7 +331,7 @@ export const usePluginsStore = create<PluginsStore>()(
body: JSON.stringify(input),
});
return response;
}, get);
}, get, { restartId: id });
},
deleteFile: async (id) => {
@@ -338,7 +341,7 @@ export const usePluginsStore = create<PluginsStore>()(
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<Response>,
get: () => PluginsStore,
options?: { restartId?: string },
): Promise<PluginMutationResult> {
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();
}
}
+15 -12
View File
@@ -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<SkillsCatalogState>()(
},
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<SkillsCatalogState>()(
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<SkillsCatalogState>()(
} 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();
}
}
},
}),
+81 -27
View File
@@ -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<SkillsStore>) => void,
get: () => SkillsStore,
name: string,
config: Partial<SkillConfig>,
) => {
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<SkillsStore>) => void,
get: () => SkillsStore,
name: string,
) => {
const nextState: Partial<SkillsStore> = {
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<SkillsStore>()(
},
createSkill: async (config: SkillConfig) => {
startConfigUpdate("Creating skill...");
let requiresReload = false;
try {
const skillConfig: Record<string, unknown> = {
name: config.name,
@@ -306,10 +343,21 @@ export const useSkillsStore = create<SkillsStore>()(
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<SkillsStore>()(
return loaded;
} catch {
return false;
} finally {
if (!requiresReload) {
finishConfigUpdate();
}
}
},
updateSkill: async (name: string, config: Partial<SkillConfig>) => {
startConfigUpdate("Updating skill...");
let requiresReload = false;
try {
const skillConfig: Record<string, unknown> = {};
@@ -357,10 +399,21 @@ export const useSkillsStore = create<SkillsStore>()(
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<SkillsStore>()(
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<SkillsStore>()(
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<SkillsStore>()(
return loaded;
} catch {
return false;
} finally {
if (!requiresReload) {
finishConfigUpdate();
}
}
},