import { type AppSettings as BackendAppSettings, appSettingsSchema as backendAppSettingsSchema, appSettingsUpdateInputSchema,} from "@t3tools/contracts";import { useCallback, useEffect, useSyncExternalStore } from "react";import { z } from "zod"; import { readNativeApi } from "./session-logic"; const LOCAL_APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:local:v1";const LEGACY_APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const localAppSettingsSchema = z.object({ confirmThreadDelete: z.boolean().default(true),}); const legacyAppSettingsSchema = backendAppSettingsSchema.extend({ confirmThreadDelete: z.boolean().default(true),}); const appSettingsSchema = backendAppSettingsSchema.extend({ confirmThreadDelete: z.boolean().default(true),}); export type AppSettings = z.infer<typeof appSettingsSchema>; const DEFAULT_BACKEND_APP_SETTINGS: BackendAppSettings = backendAppSettingsSchema.parse({});const DEFAULT_LOCAL_APP_SETTINGS = localAppSettingsSchema.parse({});const DEFAULT_APP_SETTINGS: AppSettings = { ...DEFAULT_BACKEND_APP_SETTINGS, ...DEFAULT_LOCAL_APP_SETTINGS,}; let listeners: Array<() => void> = [];let cachedBackendSettings: BackendAppSettings = DEFAULT_BACKEND_APP_SETTINGS;let cachedBackendCacheKey = JSON.stringify(DEFAULT_BACKEND_APP_SETTINGS);let cachedLocalSettings = DEFAULT_LOCAL_APP_SETTINGS;let cachedLocalCacheKey: string | undefined;let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS;let cachedSnapshotKey = `${cachedBackendCacheKey}|${cachedLocalCacheKey ?? ""}`;let backendHydrationPromise: Promise<void> | null = null;let backendHydrated = false;let backendUpdateSequence = 0;let optimisticWriteSequence = 0; function emitChange(): void { for (const listener of listeners) { listener(); }} function setCachedBackendSettings(next: BackendAppSettings): void { cachedBackendSettings = next; cachedBackendCacheKey = JSON.stringify(next);} function parseLegacySettings(value: string | null): z.infer<typeof legacyAppSettingsSchema> | null { if (!value) { return null; } try { const parsed = JSON.parse(value) as unknown; return legacyAppSettingsSchema.parse(parsed); } catch { return null; }} function readLegacySettingsSnapshot(): z.infer<typeof legacyAppSettingsSchema> | null { if (typeof window === "undefined") { return null; } return parseLegacySettings(window.localStorage.getItem(LEGACY_APP_SETTINGS_STORAGE_KEY));} function readLocalSettingsSnapshot() { if (typeof window === "undefined") { return DEFAULT_LOCAL_APP_SETTINGS; } const rawLocal = window.localStorage.getItem(LOCAL_APP_SETTINGS_STORAGE_KEY); const rawLegacy = rawLocal === null ? window.localStorage.getItem(LEGACY_APP_SETTINGS_STORAGE_KEY) : null; const cacheKey = rawLocal !== null ? `local:${rawLocal}` : `legacy:${rawLegacy ?? ""}`; if (cacheKey === cachedLocalCacheKey) { return cachedLocalSettings; } if (rawLocal !== null) { try { const parsed = JSON.parse(rawLocal) as unknown; cachedLocalSettings = localAppSettingsSchema.parse(parsed); } catch { cachedLocalSettings = DEFAULT_LOCAL_APP_SETTINGS; } } else { const legacySettings = parseLegacySettings(rawLegacy); cachedLocalSettings = localAppSettingsSchema.parse({ confirmThreadDelete: legacySettings?.confirmThreadDelete ?? DEFAULT_LOCAL_APP_SETTINGS.confirmThreadDelete, }); } cachedLocalCacheKey = cacheKey; return cachedLocalSettings;} function persistLocalSettings(next: z.infer<typeof localAppSettingsSchema>): void { if (typeof window === "undefined") return; const normalized = localAppSettingsSchema.parse(next); const raw = JSON.stringify(normalized); try { window.localStorage.setItem(LOCAL_APP_SETTINGS_STORAGE_KEY, raw); } catch { // Best-effort persistence only. } cachedLocalSettings = normalized; cachedLocalCacheKey = `local:${raw}`;} function normalizeBackendSettings(value: unknown): BackendAppSettings { const parsed = backendAppSettingsSchema.safeParse(value); if (parsed.success) { return parsed.data; } return DEFAULT_BACKEND_APP_SETTINGS;} async function hydrateBackendSettings(): Promise<void> { if (typeof window === "undefined" || backendHydrated) { return; } if (backendHydrationPromise) { await backendHydrationPromise; return; } const hydrationSequence = optimisticWriteSequence; const hasOptimisticWriteSinceHydration = () => hydrationSequence !== optimisticWriteSequence; backendHydrationPromise = (async () => { const api = readNativeApi(); if (!api?.appSettings || typeof api.appSettings.get !== "function") { backendHydrated = true; return; } const legacySettings = readLegacySettingsSnapshot(); let next = normalizeBackendSettings(await api.appSettings.get()); if (!hasOptimisticWriteSinceHydration()) { const migrationPatch = appSettingsUpdateInputSchema.parse({ ...(next.codexBinaryPath.length === 0 && legacySettings?.codexBinaryPath ? { codexBinaryPath: legacySettings.codexBinaryPath } : {}), ...(next.codexHomePath.length === 0 && legacySettings?.codexHomePath ? { codexHomePath: legacySettings.codexHomePath } : {}), }); const hasMigrationPatch = migrationPatch.codexBinaryPath !== undefined || migrationPatch.codexHomePath !== undefined; if (hasMigrationPatch) { next = normalizeBackendSettings(await api.appSettings.update(migrationPatch)); } } if (!hasOptimisticWriteSinceHydration()) { setCachedBackendSettings(next); if (legacySettings) { persistLocalSettings({ confirmThreadDelete: legacySettings.confirmThreadDelete, }); } emitChange(); } backendHydrated = true; if (legacySettings) { try { window.localStorage.removeItem(LEGACY_APP_SETTINGS_STORAGE_KEY); } catch { // Best-effort legacy cleanup only. } } })() .catch(() => undefined) .finally(() => { backendHydrationPromise = null; }); await backendHydrationPromise;} export function getAppSettingsSnapshot(): AppSettings { const localSettings = readLocalSettingsSnapshot(); const snapshotKey = `${cachedBackendCacheKey}|${cachedLocalCacheKey ?? ""}`; if (snapshotKey === cachedSnapshotKey) { return cachedSnapshot; } cachedSnapshot = appSettingsSchema.parse({ ...cachedBackendSettings, ...localSettings, }); cachedSnapshotKey = snapshotKey; return cachedSnapshot;} export function ensureAppSettingsHydrated(): Promise<void> { return hydrateBackendSettings();} export function updateAppSettings(patch: Partial<AppSettings>): void { const parsedPatch = appSettingsSchema.partial().parse(patch); let didChange = false; let didOptimisticWrite = false; const markOptimisticWrite = () => { if (didOptimisticWrite) return; didOptimisticWrite = true; optimisticWriteSequence += 1; }; if (parsedPatch.confirmThreadDelete !== undefined) { markOptimisticWrite(); persistLocalSettings({ ...readLocalSettingsSnapshot(), confirmThreadDelete: parsedPatch.confirmThreadDelete, }); didChange = true; } const backendPatch = appSettingsUpdateInputSchema.parse({ ...(parsedPatch.codexBinaryPath !== undefined ? { codexBinaryPath: parsedPatch.codexBinaryPath } : {}), ...(parsedPatch.codexHomePath !== undefined ? { codexHomePath: parsedPatch.codexHomePath } : {}), }); const hasBackendPatch = backendPatch.codexBinaryPath !== undefined || backendPatch.codexHomePath !== undefined; if (hasBackendPatch) { markOptimisticWrite(); const previous = cachedBackendSettings; const optimisticNext = backendAppSettingsSchema.parse({ ...cachedBackendSettings, ...backendPatch, }); setCachedBackendSettings(optimisticNext); backendHydrated = true; didChange = true; const updateSequence = ++backendUpdateSequence; const api = readNativeApi(); if (api?.appSettings && typeof api.appSettings.update === "function") { void api.appSettings .update(backendPatch) .then((response) => { if (updateSequence !== backendUpdateSequence) return; setCachedBackendSettings(normalizeBackendSettings(response)); emitChange(); }) .catch(() => { if (updateSequence !== backendUpdateSequence) return; setCachedBackendSettings(previous); emitChange(); }); } } if (didChange) { emitChange(); }} function subscribe(listener: () => void): () => void { listeners.push(listener); const onStorage = (event: StorageEvent) => { if ( event.key === null || event.key === LOCAL_APP_SETTINGS_STORAGE_KEY || event.key === LEGACY_APP_SETTINGS_STORAGE_KEY ) { emitChange(); } }; window.addEventListener("storage", onStorage); return () => { listeners = listeners.filter((entry) => entry !== listener); window.removeEventListener("storage", onStorage); };} export function useAppSettings() { const settings = useSyncExternalStore(subscribe, getAppSettingsSnapshot, () => DEFAULT_APP_SETTINGS); useEffect(() => { void ensureAppSettingsHydrated(); }, []); const updateSettings = useCallback((patch: Partial<AppSettings>) => { updateAppSettings(patch); }, []); const resetSettings = useCallback(() => { updateSettings(DEFAULT_APP_SETTINGS); }, [updateSettings]); return { settings, updateSettings, resetSettings, defaults: DEFAULT_APP_SETTINGS, } as const;}
import {
type AppSettings as BackendAppSettings,
appSettingsSchema as backendAppSettingsSchema,
appSettingsUpdateInputSchema,
} from "@t3tools/contracts";
import { useCallback, useEffect, useSyncExternalStore } from "react";
import { z } from "zod";
import { readNativeApi } from "./session-logic";
const LOCAL_APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:local:v1";
const LEGACY_APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
const localAppSettingsSchema = z.object({
confirmThreadDelete: z.boolean().default(true),
});
const legacyAppSettingsSchema = backendAppSettingsSchema.extend({
confirmThreadDelete: z.boolean().default(true),
});
const appSettingsSchema = backendAppSettingsSchema.extend({
confirmThreadDelete: z.boolean().default(true),
});
export type AppSettings = z.infer<typeof appSettingsSchema>;
const DEFAULT_BACKEND_APP_SETTINGS: BackendAppSettings = backendAppSettingsSchema.parse({});
const DEFAULT_LOCAL_APP_SETTINGS = localAppSettingsSchema.parse({});
const DEFAULT_APP_SETTINGS: AppSettings = {
...DEFAULT_BACKEND_APP_SETTINGS,
...DEFAULT_LOCAL_APP_SETTINGS,
};
let listeners: Array<() => void> = [];
let cachedBackendSettings: BackendAppSettings = DEFAULT_BACKEND_APP_SETTINGS;
let cachedBackendCacheKey = JSON.stringify(DEFAULT_BACKEND_APP_SETTINGS);
let cachedLocalSettings = DEFAULT_LOCAL_APP_SETTINGS;
let cachedLocalCacheKey: string | undefined;
let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS;
let cachedSnapshotKey = `${cachedBackendCacheKey}|${cachedLocalCacheKey ?? ""}`;
let backendHydrationPromise: Promise<void> | null = null;
let backendHydrated = false;
let backendUpdateSequence = 0;
let optimisticWriteSequence = 0;
function emitChange(): void {
for (const listener of listeners) {
listener();
}
}
function setCachedBackendSettings(next: BackendAppSettings): void {
cachedBackendSettings = next;
cachedBackendCacheKey = JSON.stringify(next);
}
function parseLegacySettings(value: string | null): z.infer<typeof legacyAppSettingsSchema> | null {
if (!value) {
return null;
}
try {
const parsed = JSON.parse(value) as unknown;
return legacyAppSettingsSchema.parse(parsed);
} catch {
return null;
}
}
function readLegacySettingsSnapshot(): z.infer<typeof legacyAppSettingsSchema> | null {
if (typeof window === "undefined") {
return null;
}
return parseLegacySettings(window.localStorage.getItem(LEGACY_APP_SETTINGS_STORAGE_KEY));
}
function readLocalSettingsSnapshot() {
if (typeof window === "undefined") {
return DEFAULT_LOCAL_APP_SETTINGS;
}
const rawLocal = window.localStorage.getItem(LOCAL_APP_SETTINGS_STORAGE_KEY);
const rawLegacy = rawLocal === null ? window.localStorage.getItem(LEGACY_APP_SETTINGS_STORAGE_KEY) : null;
const cacheKey = rawLocal !== null ? `local:${rawLocal}` : `legacy:${rawLegacy ?? ""}`;
if (cacheKey === cachedLocalCacheKey) {
return cachedLocalSettings;
}
if (rawLocal !== null) {
try {
const parsed = JSON.parse(rawLocal) as unknown;
cachedLocalSettings = localAppSettingsSchema.parse(parsed);
} catch {
cachedLocalSettings = DEFAULT_LOCAL_APP_SETTINGS;
}
} else {
const legacySettings = parseLegacySettings(rawLegacy);
cachedLocalSettings = localAppSettingsSchema.parse({
confirmThreadDelete: legacySettings?.confirmThreadDelete ?? DEFAULT_LOCAL_APP_SETTINGS.confirmThreadDelete,
});
}
cachedLocalCacheKey = cacheKey;
return cachedLocalSettings;
}
function persistLocalSettings(next: z.infer<typeof localAppSettingsSchema>): void {
if (typeof window === "undefined") return;
const normalized = localAppSettingsSchema.parse(next);
const raw = JSON.stringify(normalized);
try {
window.localStorage.setItem(LOCAL_APP_SETTINGS_STORAGE_KEY, raw);
} catch {
// Best-effort persistence only.
}
cachedLocalSettings = normalized;
cachedLocalCacheKey = `local:${raw}`;
}
function normalizeBackendSettings(value: unknown): BackendAppSettings {
const parsed = backendAppSettingsSchema.safeParse(value);
if (parsed.success) {
return parsed.data;
}
return DEFAULT_BACKEND_APP_SETTINGS;
}
async function hydrateBackendSettings(): Promise<void> {
if (typeof window === "undefined" || backendHydrated) {
return;
}
if (backendHydrationPromise) {
await backendHydrationPromise;
return;
}
const hydrationSequence = optimisticWriteSequence;
const hasOptimisticWriteSinceHydration = () => hydrationSequence !== optimisticWriteSequence;
backendHydrationPromise = (async () => {
const api = readNativeApi();
if (!api?.appSettings || typeof api.appSettings.get !== "function") {
backendHydrated = true;
return;
}
const legacySettings = readLegacySettingsSnapshot();
let next = normalizeBackendSettings(await api.appSettings.get());
if (!hasOptimisticWriteSinceHydration()) {
const migrationPatch = appSettingsUpdateInputSchema.parse({
...(next.codexBinaryPath.length === 0 && legacySettings?.codexBinaryPath
? { codexBinaryPath: legacySettings.codexBinaryPath }
: {}),
...(next.codexHomePath.length === 0 && legacySettings?.codexHomePath
? { codexHomePath: legacySettings.codexHomePath }
: {}),
});
const hasMigrationPatch =
migrationPatch.codexBinaryPath !== undefined || migrationPatch.codexHomePath !== undefined;
if (hasMigrationPatch) {
next = normalizeBackendSettings(await api.appSettings.update(migrationPatch));
}
}
if (!hasOptimisticWriteSinceHydration()) {
setCachedBackendSettings(next);
if (legacySettings) {
persistLocalSettings({
confirmThreadDelete: legacySettings.confirmThreadDelete,
});
}
emitChange();
}
backendHydrated = true;
if (legacySettings) {
try {
window.localStorage.removeItem(LEGACY_APP_SETTINGS_STORAGE_KEY);
} catch {
// Best-effort legacy cleanup only.
}
}
})()
.catch(() => undefined)
.finally(() => {
backendHydrationPromise = null;
});
await backendHydrationPromise;
}
export function getAppSettingsSnapshot(): AppSettings {
const localSettings = readLocalSettingsSnapshot();
const snapshotKey = `${cachedBackendCacheKey}|${cachedLocalCacheKey ?? ""}`;
if (snapshotKey === cachedSnapshotKey) {
return cachedSnapshot;
}
cachedSnapshot = appSettingsSchema.parse({
...cachedBackendSettings,
...localSettings,
});
cachedSnapshotKey = snapshotKey;
return cachedSnapshot;
}
export function ensureAppSettingsHydrated(): Promise<void> {
return hydrateBackendSettings();
}
export function updateAppSettings(patch: Partial<AppSettings>): void {
const parsedPatch = appSettingsSchema.partial().parse(patch);
let didChange = false;
let didOptimisticWrite = false;
const markOptimisticWrite = () => {
if (didOptimisticWrite) return;
didOptimisticWrite = true;
optimisticWriteSequence += 1;
};
if (parsedPatch.confirmThreadDelete !== undefined) {
markOptimisticWrite();
persistLocalSettings({
...readLocalSettingsSnapshot(),
confirmThreadDelete: parsedPatch.confirmThreadDelete,
});
didChange = true;
}
const backendPatch = appSettingsUpdateInputSchema.parse({
...(parsedPatch.codexBinaryPath !== undefined
? { codexBinaryPath: parsedPatch.codexBinaryPath }
: {}),
...(parsedPatch.codexHomePath !== undefined ? { codexHomePath: parsedPatch.codexHomePath } : {}),
});
const hasBackendPatch =
backendPatch.codexBinaryPath !== undefined || backendPatch.codexHomePath !== undefined;
if (hasBackendPatch) {
markOptimisticWrite();
const previous = cachedBackendSettings;
const optimisticNext = backendAppSettingsSchema.parse({
...cachedBackendSettings,
...backendPatch,
});
setCachedBackendSettings(optimisticNext);
backendHydrated = true;
didChange = true;
const updateSequence = ++backendUpdateSequence;
const api = readNativeApi();
if (api?.appSettings && typeof api.appSettings.update === "function") {
void api.appSettings
.update(backendPatch)
.then((response) => {
if (updateSequence !== backendUpdateSequence) return;
setCachedBackendSettings(normalizeBackendSettings(response));
emitChange();
})
.catch(() => {
if (updateSequence !== backendUpdateSequence) return;
setCachedBackendSettings(previous);
emitChange();
});
}
}
if (didChange) {
emitChange();
}
}
function subscribe(listener: () => void): () => void {
listeners.push(listener);
const onStorage = (event: StorageEvent) => {
if (
event.key === null ||
event.key === LOCAL_APP_SETTINGS_STORAGE_KEY ||
event.key === LEGACY_APP_SETTINGS_STORAGE_KEY
) {
emitChange();
}
};
window.addEventListener("storage", onStorage);
return () => {
listeners = listeners.filter((entry) => entry !== listener);
window.removeEventListener("storage", onStorage);
};
}
export function useAppSettings() {
const settings = useSyncExternalStore(subscribe, getAppSettingsSnapshot, () => DEFAULT_APP_SETTINGS);
useEffect(() => {
void ensureAppSettingsHydrated();
}, []);
const updateSettings = useCallback((patch: Partial<AppSettings>) => {
updateAppSettings(patch);
}, []);
const resetSettings = useCallback(() => {
updateSettings(DEFAULT_APP_SETTINGS);
}, [updateSettings]);
return {
settings,
updateSettings,
resetSettings,
defaults: DEFAULT_APP_SETTINGS,
} as const;
}