Merge upstream main into feat/subagent-cost-rollup
This commit is contained in:
@@ -262,7 +262,7 @@ describe('addSelectionToChat', () => {
|
||||
installSelectionEnvironment({ activeElement: textarea });
|
||||
|
||||
expect(addSelectionToChat()).toBe(true);
|
||||
expect(activeSurfaceCalls).toEqual(['chat']);
|
||||
expect(activeSurfaceCalls).toEqual([]);
|
||||
expect(sessionSwitcherCalls).toEqual([false]);
|
||||
expect(pendingInputCalls).toEqual([{ text: '```md\nselected\n```', mode: 'append' }]);
|
||||
|
||||
@@ -290,7 +290,7 @@ describe('addSelectionToChat', () => {
|
||||
|
||||
expect(addSelectionToChat()).toBe(false);
|
||||
expect(pendingInputCalls).toEqual([]);
|
||||
expect(activeSurfaceCalls).toEqual(['chat']);
|
||||
expect(activeSurfaceCalls).toEqual([]);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(focusChatInputCalls.length).toBe(1);
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
// Regression tests for issue #2470: sessions stuck on "loading sessions"
|
||||
// forever after managed OpenCode connection goes half-open.
|
||||
//
|
||||
// The SDK client fetch wrapper must bound read requests so a socket that
|
||||
// neither resolves nor rejects fails after `requestTimeoutMs`, releasing the
|
||||
// directory bootstrap concurrency slot. Long-lived streams must be excluded:
|
||||
// POST (prompt/shell/summarize/command) and the `/event` SSE stream.
|
||||
|
||||
type CapturedFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
type RuntimeFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
// `mock(...)` returns a Mock that exposes mockImplementation; keep a typed
|
||||
// reference so per-test overrides stay type-safe without re-importing.
|
||||
const runtimeFetchMock = mock<RuntimeFetch>(async () => new Response('', { status: 200 }));
|
||||
|
||||
let capturedFetch: CapturedFetch | null = null;
|
||||
|
||||
(mock as unknown as { restore?: () => void }).restore?.();
|
||||
|
||||
mock.module('@opencode-ai/sdk/v2', () => ({
|
||||
createOpencodeClient: mock((opts: { fetch: CapturedFetch }) => {
|
||||
capturedFetch = opts.fetch;
|
||||
return {};
|
||||
}),
|
||||
}));
|
||||
|
||||
mock.module('@/contexts/runtimeAPIRegistry', () => ({
|
||||
getRegisteredRuntimeAPIs: mock(() => null),
|
||||
}));
|
||||
|
||||
mock.module('@/lib/runtime-url', () => ({
|
||||
getRuntimeUrlResolver: mock(() => ({ api: (path: string) => path })),
|
||||
}));
|
||||
|
||||
mock.module('@/lib/runtime-switch', () => ({
|
||||
getRuntimeApiBaseUrl: mock(() => ''),
|
||||
getRuntimeKey: mock(() => 'test-runtime'),
|
||||
}));
|
||||
|
||||
mock.module('@/lib/runtime-fetch', () => ({
|
||||
runtimeFetch: runtimeFetchMock,
|
||||
}));
|
||||
|
||||
mock.module('@/lib/startupTrace', () => ({
|
||||
markStartupTrace: mock(() => undefined),
|
||||
}));
|
||||
|
||||
const { createRuntimeOpencodeClient } = await import(
|
||||
`./client?timeout-final=${Date.now()}`
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
capturedFetch = null;
|
||||
runtimeFetchMock.mockImplementation(async () => new Response('', { status: 200 }));
|
||||
});
|
||||
|
||||
describe('createRuntimeOpencodeClient fetch wrapper (#2470)', () => {
|
||||
test('AbortSignal.timeout fires inside a test environment (sanity)', async () => {
|
||||
const sig = AbortSignal.timeout(20);
|
||||
const fired = await new Promise<boolean>((resolve) => {
|
||||
sig.addEventListener('abort', () => resolve(true));
|
||||
setTimeout(() => resolve(false), 200);
|
||||
});
|
||||
expect(fired).toBe(true);
|
||||
});
|
||||
|
||||
test('createRuntimeOpencodeClient is exported', () => {
|
||||
expect(typeof createRuntimeOpencodeClient).toBe('function');
|
||||
});
|
||||
|
||||
test('a GET whose socket never settles rejects with normalized "request timed out" error', async () => {
|
||||
let firedAt = 0;
|
||||
let calledAt = Date.now();
|
||||
runtimeFetchMock.mockImplementation(async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
calledAt = Date.now();
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
firedAt = Date.now();
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
});
|
||||
setTimeout(
|
||||
() => reject(new Error('TIMEOUT_TEST_FAIL: signal never fired')),
|
||||
1000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
createRuntimeOpencodeClient({ baseUrl: '', requestTimeoutMs: 25 });
|
||||
expect(capturedFetch).not.toBeNull();
|
||||
|
||||
const start = Date.now();
|
||||
await expect(
|
||||
capturedFetch!('http://opencode.test/api/session'),
|
||||
).rejects.toThrow(/request timed out/);
|
||||
const elapsed = Date.now() - start;
|
||||
expect(firedAt).toBeGreaterThanOrEqual(calledAt);
|
||||
expect(elapsed).toBeLessThan(500);
|
||||
});
|
||||
|
||||
test('POST requests (long-running prompt/shell/summarize) are NOT timed out', async () => {
|
||||
runtimeFetchMock.mockImplementation(async () => {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
return new Response('ok', { status: 200 });
|
||||
});
|
||||
|
||||
createRuntimeOpencodeClient({ baseUrl: '', requestTimeoutMs: 25 });
|
||||
expect(capturedFetch).not.toBeNull();
|
||||
|
||||
const response = await capturedFetch!(
|
||||
'http://opencode.test/session/prompt',
|
||||
{ method: 'POST' },
|
||||
);
|
||||
expect(await response.text()).toBe('ok');
|
||||
});
|
||||
|
||||
test('the /event SSE stream is NOT timed out', async () => {
|
||||
runtimeFetchMock.mockImplementation(async () => {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
return new Response('event: ping\n\n', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
});
|
||||
});
|
||||
|
||||
createRuntimeOpencodeClient({ baseUrl: '', requestTimeoutMs: 25 });
|
||||
expect(capturedFetch).not.toBeNull();
|
||||
|
||||
const response = await capturedFetch!(
|
||||
'http://opencode.test/api/global/event',
|
||||
);
|
||||
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
|
||||
});
|
||||
|
||||
test('caller-provided abort signal still wins (no normalized timeout error)', async () => {
|
||||
runtimeFetchMock.mockImplementation(async (_input: string | URL | Request, init?: RequestInit) =>
|
||||
new Promise<Response>((_, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
createRuntimeOpencodeClient({ baseUrl: '', requestTimeoutMs: 25 });
|
||||
expect(capturedFetch).not.toBeNull();
|
||||
|
||||
const callerController = new AbortController();
|
||||
setTimeout(() => callerController.abort(), 5);
|
||||
|
||||
await expect(
|
||||
capturedFetch!('http://opencode.test/api/session', {
|
||||
signal: callerController.signal,
|
||||
}),
|
||||
).rejects.toThrow('Aborted');
|
||||
});
|
||||
});
|
||||
@@ -200,10 +200,86 @@ const createTimeoutSignal = (timeoutMs: number): { signal: AbortSignal; cleanup:
|
||||
};
|
||||
};
|
||||
|
||||
const createRuntimeOpencodeClient = (config: { baseUrl: string; directory?: string }): OpencodeClient => {
|
||||
/**
|
||||
* Upper bound for non-streaming OpenCode read requests. Without it, a socket
|
||||
* that neither resolves nor rejects (the half-open state described in #2470)
|
||||
* keeps the bootstrap concurrency slot busy forever and the UI stays on
|
||||
* "loading sessions". Long-lived streams (POST prompts, the /event SSE) are
|
||||
* explicitly excluded in {@link createRuntimeOpencodeClient}.
|
||||
*/
|
||||
const OPENCODE_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
const isEventStreamUrl = (input: string | URL | Request): boolean => {
|
||||
const url = typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url;
|
||||
return url.includes('/event');
|
||||
};
|
||||
|
||||
type RuntimeOpencodeClientConfig = {
|
||||
baseUrl: string;
|
||||
directory?: string;
|
||||
/** Read-request timeout in ms. Overridable so tests can use short value. */
|
||||
requestTimeoutMs?: number;
|
||||
};
|
||||
|
||||
export const createRuntimeOpencodeClient = (config: RuntimeOpencodeClientConfig): OpencodeClient => {
|
||||
const requestTimeoutMs = config.requestTimeoutMs ?? OPENCODE_REQUEST_TIMEOUT_MS;
|
||||
return createOpencodeClient({
|
||||
...config,
|
||||
fetch: runtimeFetch,
|
||||
fetch: async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const method = String(
|
||||
init?.method ?? (input instanceof Request ? input.method : 'GET'),
|
||||
).toUpperCase();
|
||||
if (isEventStreamUrl(input) || method === 'POST') {
|
||||
return runtimeFetch(input, init);
|
||||
}
|
||||
const timeout = createTimeoutSignal(requestTimeoutMs);
|
||||
const callerSignal = init?.signal;
|
||||
const supportsAny = typeof AbortSignal !== 'undefined'
|
||||
&& typeof (AbortSignal as { any?: unknown }).any === 'function';
|
||||
let signal: AbortSignal;
|
||||
let detachFallback: (() => void) | null = null;
|
||||
if (callerSignal && supportsAny) {
|
||||
signal = (AbortSignal as typeof AbortSignal & { any: (signals: AbortSignal[]) => AbortSignal })
|
||||
.any([callerSignal, timeout.signal]);
|
||||
} else if (callerSignal) {
|
||||
// No AbortSignal.any: compose manually. Silently dropping the timeout
|
||||
// here would disable the fix on exactly the bootstrap reads it
|
||||
// targets, since those carry a cancellation signal.
|
||||
const controller = new AbortController();
|
||||
const abortFromCaller = () => controller.abort(callerSignal.reason);
|
||||
const abortFromTimeout = () => controller.abort(timeout.signal.reason);
|
||||
if (callerSignal.aborted) {
|
||||
abortFromCaller();
|
||||
} else if (timeout.signal.aborted) {
|
||||
abortFromTimeout();
|
||||
} else {
|
||||
callerSignal.addEventListener('abort', abortFromCaller, { once: true });
|
||||
timeout.signal.addEventListener('abort', abortFromTimeout, { once: true });
|
||||
detachFallback = () => {
|
||||
callerSignal.removeEventListener('abort', abortFromCaller);
|
||||
timeout.signal.removeEventListener('abort', abortFromTimeout);
|
||||
};
|
||||
}
|
||||
signal = controller.signal;
|
||||
} else {
|
||||
signal = timeout.signal;
|
||||
}
|
||||
try {
|
||||
return await runtimeFetch(input, { ...init, signal });
|
||||
} catch (error) {
|
||||
if (timeout.signal.aborted && !callerSignal?.aborted) {
|
||||
throw new Error(`OpenCode request timed out after ${requestTimeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
detachFallback?.();
|
||||
timeout.cleanup();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -548,6 +548,136 @@ describe('updateDesktopSettings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('does not broadcast a stale project selection over a newer pending update', async () => {
|
||||
const firstSave = deferred<SettingsPayload>();
|
||||
const savedChanges: Array<Partial<SettingsPayload>> = [];
|
||||
registerSettingsSave(async (changes) => {
|
||||
savedChanges.push(changes);
|
||||
if (savedChanges.length === 1) return firstSave.promise;
|
||||
return changes as SettingsPayload;
|
||||
});
|
||||
const syncedSettings: SettingsPayload[] = [];
|
||||
const handleSettingsSynced = (event: Event) => {
|
||||
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
|
||||
};
|
||||
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||
|
||||
try {
|
||||
const firstUpdate = updateDesktopSettings({ activeProjectId: 'project-a' });
|
||||
await delay(250);
|
||||
const secondUpdate = updateDesktopSettings({ activeProjectId: 'project-b' });
|
||||
|
||||
firstSave.resolve({ activeProjectId: 'project-a' });
|
||||
await firstUpdate;
|
||||
|
||||
expect(syncedSettings.at(-1)?.activeProjectId).toBe('project-b');
|
||||
|
||||
await secondUpdate;
|
||||
} finally {
|
||||
getWindow().removeEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not broadcast a stale loaded project selection over a newer pending update', async () => {
|
||||
const loadedSettings = deferred<{ settings: SettingsPayload; source: 'web' | 'vscode' }>();
|
||||
registerSettingsApi(async (changes) => changes as SettingsPayload, () => loadedSettings.promise);
|
||||
invalidateSettingsCache();
|
||||
const syncedSettings: SettingsPayload[] = [];
|
||||
const handleSettingsSynced = (event: Event) => {
|
||||
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
|
||||
};
|
||||
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||
|
||||
try {
|
||||
const sync = syncDesktopSettings();
|
||||
const update = updateDesktopSettings({ activeProjectId: 'project-b' });
|
||||
|
||||
loadedSettings.resolve({
|
||||
settings: {
|
||||
activeProjectId: 'project-a',
|
||||
draftStartersCraftGoalAdded: true,
|
||||
draftStartersScheduleTaskAdded: true,
|
||||
},
|
||||
source: 'web',
|
||||
});
|
||||
await sync;
|
||||
|
||||
expect(syncedSettings.at(-1)?.activeProjectId).toBe('project-b');
|
||||
|
||||
await update;
|
||||
} finally {
|
||||
getWindow().removeEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not broadcast a stale load after a newer project update has saved', async () => {
|
||||
const loadedSettings = deferred<{ settings: SettingsPayload; source: 'web' | 'vscode' }>();
|
||||
registerSettingsApi(async (changes) => changes as SettingsPayload, () => loadedSettings.promise);
|
||||
invalidateSettingsCache();
|
||||
const syncedSettings: SettingsPayload[] = [];
|
||||
const handleSettingsSynced = (event: Event) => {
|
||||
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
|
||||
};
|
||||
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||
|
||||
try {
|
||||
const sync = syncDesktopSettings();
|
||||
const update = updateDesktopSettings({ activeProjectId: 'project-b' });
|
||||
await update;
|
||||
|
||||
loadedSettings.resolve({
|
||||
settings: {
|
||||
activeProjectId: 'project-a',
|
||||
draftStartersCraftGoalAdded: true,
|
||||
draftStartersScheduleTaskAdded: true,
|
||||
},
|
||||
source: 'web',
|
||||
});
|
||||
await sync;
|
||||
|
||||
expect(syncedSettings.at(-1)?.activeProjectId).toBe('project-b');
|
||||
} finally {
|
||||
getWindow().removeEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||
}
|
||||
});
|
||||
|
||||
test('preserves only the latest settings values across repeated pending updates', async () => {
|
||||
const loadedSettings = deferred<{ settings: SettingsPayload; source: 'web' | 'vscode' }>();
|
||||
registerSettingsApi(async (changes) => changes as SettingsPayload, () => loadedSettings.promise);
|
||||
invalidateSettingsCache();
|
||||
const syncedSettings: SettingsPayload[] = [];
|
||||
const handleSettingsSynced = (event: Event) => {
|
||||
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
|
||||
};
|
||||
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||
|
||||
try {
|
||||
const sync = syncDesktopSettings();
|
||||
const updates = Array.from({ length: 100 }, (_, index) => updateDesktopSettings({
|
||||
activeProjectId: `project-${index}`,
|
||||
showReasoningTraces: index % 2 === 0,
|
||||
}));
|
||||
|
||||
loadedSettings.resolve({
|
||||
settings: {
|
||||
activeProjectId: 'stale-project',
|
||||
showReasoningTraces: true,
|
||||
draftStartersCraftGoalAdded: true,
|
||||
draftStartersScheduleTaskAdded: true,
|
||||
},
|
||||
source: 'web',
|
||||
});
|
||||
await sync;
|
||||
|
||||
expect(syncedSettings.at(-1)?.activeProjectId).toBe('project-99');
|
||||
expect(syncedSettings.at(-1)?.showReasoningTraces).toBe(false);
|
||||
|
||||
await Promise.all(updates);
|
||||
} finally {
|
||||
getWindow().removeEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||
}
|
||||
});
|
||||
|
||||
test('applies model selector settings from server settings', async () => {
|
||||
getWindow();
|
||||
const settings = {
|
||||
|
||||
@@ -1676,6 +1676,62 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
};
|
||||
|
||||
type SettingsRuntimeContext = { runtimeKey: string; generation: number };
|
||||
type SettingsMutation = { revision: number; changes: Partial<DesktopSettings> };
|
||||
type SettingsOperation = { revision: number };
|
||||
|
||||
class SettingsMutationTracker {
|
||||
private revision = 0;
|
||||
private mutations: SettingsMutation[] = [];
|
||||
private operations = new Set<SettingsOperation>();
|
||||
|
||||
record(changes: Partial<DesktopSettings>): number {
|
||||
this.revision += 1;
|
||||
if (this.operations.size > 0) {
|
||||
const latest = this.mutations.at(-1);
|
||||
// A new segment is only needed when an operation started after the last one.
|
||||
const crossedOperationBoundary = latest
|
||||
? [...this.operations].some((operation) => operation.revision >= latest.revision)
|
||||
: true;
|
||||
if (latest && !crossedOperationBoundary) {
|
||||
latest.revision = this.revision;
|
||||
latest.changes = { ...latest.changes, ...changes };
|
||||
} else {
|
||||
this.mutations.push({ revision: this.revision, changes });
|
||||
}
|
||||
}
|
||||
return this.revision;
|
||||
}
|
||||
|
||||
begin(revision = this.revision): SettingsOperation {
|
||||
const operation = { revision };
|
||||
this.operations.add(operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
reconcile(settings: DesktopSettings, operation: SettingsOperation): DesktopSettings {
|
||||
let reconciled = settings;
|
||||
for (const mutation of this.mutations) {
|
||||
if (mutation.revision <= operation.revision) continue;
|
||||
reconciled = { ...reconciled, ...mutation.changes };
|
||||
}
|
||||
return reconciled;
|
||||
}
|
||||
|
||||
finish(operation: SettingsOperation): void {
|
||||
if (!this.operations.delete(operation)) return;
|
||||
if (this.operations.size === 0) {
|
||||
this.mutations = [];
|
||||
return;
|
||||
}
|
||||
const oldestRevision = Math.min(...[...this.operations].map(({ revision }) => revision));
|
||||
this.mutations = this.mutations.filter((mutation) => mutation.revision > oldestRevision);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.mutations = [];
|
||||
this.operations.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Short-lived cache + in-flight dedup for settings fetches to avoid repeated GET calls during startup
|
||||
let _settingsRuntimeGeneration = 0;
|
||||
@@ -1686,6 +1742,8 @@ let _pendingSettingsContext: SettingsRuntimeContext | null = null;
|
||||
let _settingsFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let _settingsFlushWaiters: Array<() => void> = [];
|
||||
let _settingsLifecycleInitialized = false;
|
||||
let _pendingSettingsRevision = 0;
|
||||
const _settingsMutationTracker = new SettingsMutationTracker();
|
||||
const SETTINGS_CACHE_TTL = 2_000; // 2 seconds — covers the startup burst
|
||||
const SETTINGS_DEBOUNCE_MS = 200;
|
||||
|
||||
@@ -1714,6 +1772,8 @@ const ensureSettingsRuntimeLifecycle = (): void => {
|
||||
subscribeRuntimeEndpointChanged((detail) => {
|
||||
if (detail.runtimeKey === detail.previousRuntimeKey) return;
|
||||
_settingsRuntimeGeneration += 1;
|
||||
_settingsMutationTracker.reset();
|
||||
_pendingSettingsRevision = 0;
|
||||
_settingsCache = null;
|
||||
_settingsInflight = null;
|
||||
});
|
||||
@@ -1787,6 +1847,7 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
||||
}
|
||||
ensureSettingsRuntimeLifecycle();
|
||||
const context = captureSettingsRuntimeContext();
|
||||
const operation = _settingsMutationTracker.begin();
|
||||
|
||||
const persistApis = [getPersistApi(), useSessionDisplayStore.persist];
|
||||
|
||||
@@ -1823,8 +1884,22 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
||||
// Each step is wrapped in try/catch so a failure in one side-effect (e.g.
|
||||
// a TypeError from writing to a contextBridge-protected global) doesn't
|
||||
// prevent server settings from reaching the Zustand store.
|
||||
const applySettings = async (settings: DesktopSettings) => {
|
||||
// Local changes sitting in the debounce buffer are not yet tracked as
|
||||
// mutations (record() only stores while a request is in flight), so a GET
|
||||
// racing the debounce window would briefly revert them. Reapply the
|
||||
// pending buffer over every reconciled result.
|
||||
const overlayPendingChanges = (settings: DesktopSettings): DesktopSettings => {
|
||||
if (!_pendingSettingsChanges || !_pendingSettingsContext) return settings;
|
||||
if (!isSettingsRuntimeContextCurrent(_pendingSettingsContext)) return settings;
|
||||
return { ...settings, ..._pendingSettingsChanges };
|
||||
};
|
||||
|
||||
const applySettings = async (loadedSettings: DesktopSettings) => {
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
let settings = overlayPendingChanges(_settingsMutationTracker.reconcile(loadedSettings, operation));
|
||||
await waitForHydration();
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
settings = overlayPendingChanges(_settingsMutationTracker.reconcile(loadedSettings, operation));
|
||||
const shouldPersistCraftGoalMigration = settings.draftStartersCraftGoalAdded !== true
|
||||
|| settings.draftStartersScheduleTaskAdded !== true;
|
||||
// `autoSaveEnabled` is new to the settings backend. Until the server has a
|
||||
@@ -1843,8 +1918,6 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
||||
} catch (error) {
|
||||
console.warn('persistToLocalStorage failed:', error);
|
||||
}
|
||||
await waitForHydration();
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
if (shouldSeedAutoSaveEnabled) {
|
||||
authoritativeSettings.autoSaveEnabled = useUIStore.getState().autoSaveEnabled;
|
||||
}
|
||||
@@ -1907,6 +1980,8 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to synchronise settings:', error);
|
||||
} finally {
|
||||
_settingsMutationTracker.finish(operation);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1914,9 +1989,11 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
||||
async function _flushSettingsUpdate(): Promise<void> {
|
||||
const changes = _pendingSettingsChanges;
|
||||
const context = _pendingSettingsContext;
|
||||
const revision = _pendingSettingsRevision;
|
||||
const waiters = _settingsFlushWaiters;
|
||||
_pendingSettingsChanges = null;
|
||||
_pendingSettingsContext = null;
|
||||
_pendingSettingsRevision = 0;
|
||||
_settingsFlushTimer = null;
|
||||
_settingsFlushWaiters = [];
|
||||
try {
|
||||
@@ -1925,59 +2002,66 @@ async function _flushSettingsUpdate(): Promise<void> {
|
||||
dispatchSettingsSaveState('saved');
|
||||
return;
|
||||
}
|
||||
const operation = _settingsMutationTracker.begin(revision);
|
||||
|
||||
const runtimeSettings = getRuntimeSettingsAPI();
|
||||
if (runtimeSettings) {
|
||||
try {
|
||||
const runtimeSettings = getRuntimeSettingsAPI();
|
||||
if (runtimeSettings) {
|
||||
try {
|
||||
const updated = await runtimeSettings.save(changes);
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
if (updated) {
|
||||
const reconciled = _settingsMutationTracker.reconcile(updated, operation);
|
||||
applyDesktopUiPreferences(reconciled);
|
||||
dispatchSettingsSynced(reconciled);
|
||||
_settingsCache = null;
|
||||
}
|
||||
dispatchSettingsSaveState(updated ? 'saved' : 'error');
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
console.warn('Failed to update settings via runtime settings API:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
try {
|
||||
const updated = await runtimeSettings.save(changes);
|
||||
const response = await runtimeFetch('/api/config/settings', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(changes),
|
||||
});
|
||||
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
if (!response.ok) {
|
||||
console.warn('Failed to update shared settings via API:', response.status, response.statusText);
|
||||
dispatchSettingsSaveState('error');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = sanitizeWebSettings(await response.json().catch(() => null));
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
if (updated) {
|
||||
applyDesktopUiPreferences(updated);
|
||||
dispatchSettingsSynced(updated);
|
||||
const reconciled = _settingsMutationTracker.reconcile(updated, operation);
|
||||
applyDesktopUiPreferences(reconciled);
|
||||
dispatchSettingsSynced(reconciled);
|
||||
dispatchSettingsSaveState('saved');
|
||||
// Invalidate GET cache so next read sees the fresh data
|
||||
_settingsCache = null;
|
||||
} else {
|
||||
dispatchSettingsSaveState('error');
|
||||
}
|
||||
dispatchSettingsSaveState(updated ? 'saved' : 'error');
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
console.warn('Failed to update settings via runtime settings API:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
try {
|
||||
const response = await runtimeFetch('/api/config/settings', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(changes),
|
||||
});
|
||||
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
if (!response.ok) {
|
||||
console.warn('Failed to update shared settings via API:', response.status, response.statusText);
|
||||
dispatchSettingsSaveState('error');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = sanitizeWebSettings(await response.json().catch(() => null));
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
if (updated) {
|
||||
applyDesktopUiPreferences(updated);
|
||||
dispatchSettingsSynced(updated);
|
||||
dispatchSettingsSaveState('saved');
|
||||
// Invalidate GET cache so next read sees the fresh data
|
||||
_settingsCache = null;
|
||||
} else {
|
||||
dispatchSettingsSaveState('error');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isSettingsRuntimeContextCurrent(context)) {
|
||||
console.warn('Failed to update shared settings via API:', error);
|
||||
dispatchSettingsSaveState('error');
|
||||
if (isSettingsRuntimeContextCurrent(context)) {
|
||||
console.warn('Failed to update shared settings via API:', error);
|
||||
dispatchSettingsSaveState('error');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_settingsMutationTracker.finish(operation);
|
||||
}
|
||||
} finally {
|
||||
waiters.forEach((resolve) => resolve());
|
||||
@@ -1998,6 +2082,7 @@ export const updateDesktopSettings = async (changes: Partial<DesktopSettings>):
|
||||
|
||||
_pendingSettingsChanges = { ...(_pendingSettingsChanges ?? {}), ...changes };
|
||||
_pendingSettingsContext = context;
|
||||
_pendingSettingsRevision = _settingsMutationTracker.record(changes);
|
||||
dispatchSettingsSaveState('saving');
|
||||
|
||||
if (_settingsFlushTimer) {
|
||||
|
||||
Reference in New Issue
Block a user