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
+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();