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:
co-authored by
Serhii Dziupin
parent
775da6e9f4
commit
57819dd164
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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,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();
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user