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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user