refactor(settings): bind dynamic data to one runtime lease

This commit is contained in:
murashit 2026-07-20 17:42:15 +09:00
parent aaa04507e0
commit 381646ebfb
10 changed files with 142 additions and 251 deletions

View file

@ -1,10 +1,7 @@
import type { App } from "obsidian";
import type { AppServerClient } from "./app-server/connection/client";
import type { AppServerClientAccessOptions } from "./app-server/connection/client-access";
import type { ObservedResultListener } from "./app-server/query/observed-result";
import { VIEW_TYPE_CODEX_THREADS, VIEW_TYPE_CODEX_TURN_DIFF } from "./constants";
import type { ModelMetadata } from "./domain/catalog/metadata";
import type { Thread } from "./domain/threads/model";
import { CodexExecutionRuntime } from "./execution-runtime";
import type {
ChatRuntimeView,
@ -40,7 +37,6 @@ export interface CodexPanelRuntimeOptions {
export class CodexPanelRuntime implements ChatViewRuntimeOwner, ThreadsViewRuntimeOwner {
private readonly panels: WorkspacePanelCoordinator;
private executionRuntime: CodexExecutionRuntime | null = null;
private readonly settingsDynamicData = new SwappableSettingsDynamicData();
private selectionRewriteController: SelectionRewriteCommandController | null = null;
constructor(private readonly options: CodexPanelRuntimeOptions) {
@ -55,7 +51,6 @@ export class CodexPanelRuntime implements ChatViewRuntimeOwner, ThreadsViewRunti
initialize(): void {
if (this.executionRuntime) throw new Error("Codex execution runtime is already initialized.");
this.executionRuntime = this.createExecutionRuntime(this.options.settingsRef.settings.codexPath);
this.settingsDynamicData.replace(this.executionRuntime.settingsDynamicData);
}
reset(): void {
@ -64,7 +59,6 @@ export class CodexPanelRuntime implements ChatViewRuntimeOwner, ThreadsViewRunti
const executionRuntime = this.executionRuntime;
this.executionRuntime = null;
executionRuntime?.dispose();
this.settingsDynamicData.replace(null);
this.panels.reset();
}
@ -145,15 +139,16 @@ export class CodexPanelRuntime implements ChatViewRuntimeOwner, ThreadsViewRunti
settingTabHost(): CodexPanelSettingTabHost {
return {
settings: this.options.settingsRef.settings,
dynamicData: this.settingsDynamicData,
dynamicData: this.currentExecutionRuntime().settingsDynamicData,
publishSettings: (settings) => this.publishSettings(settings),
};
}
private async publishSettings(settings: CodexPanelSettings): Promise<{ appServerContextReplaced: boolean }> {
private async publishSettings(settings: CodexPanelSettings): Promise<{ replacementDynamicData: SettingsDynamicDataAccess | null }> {
const previousSettings = { ...this.options.settingsRef.settings };
await this.options.saveSettings(settings);
const appServerContextReplaced = previousSettings.codexPath !== settings.codexPath;
let replacementDynamicData: SettingsDynamicDataAccess | null = null;
if (appServerContextReplaced) {
const nextRuntime = this.createExecutionRuntime(settings.codexPath);
this.selectionRewriteController?.closeAll();
@ -162,14 +157,14 @@ export class CodexPanelRuntime implements ChatViewRuntimeOwner, ThreadsViewRunti
this.executionRuntime = null;
const views = previousRuntime?.dispose() ?? { chat: [], threads: [] };
Object.assign(this.options.settingsRef.settings, settings);
this.settingsDynamicData.replace(nextRuntime.settingsDynamicData);
nextRuntime.adoptViews(views);
this.executionRuntime = nextRuntime;
replacementDynamicData = nextRuntime.settingsDynamicData;
} else {
Object.assign(this.options.settingsRef.settings, settings);
}
if (appServerContextReplaced || previousSettings.showToolbar !== settings.showToolbar) this.refreshOpenViews();
return { appServerContextReplaced };
return { replacementDynamicData };
}
private async openTurnDiff(state: TurnDiffViewState): Promise<void> {
@ -275,98 +270,3 @@ export class CodexPanelRuntime implements ChatViewRuntimeOwner, ThreadsViewRunti
});
}
}
interface StableObserver<T> {
readonly listener: ObservedResultListener<readonly T[]>;
readonly options: { emitCurrent?: boolean } | undefined;
unsubscribe: (() => void) | null;
}
export class SwappableSettingsDynamicData implements SettingsDynamicDataAccess {
private current: SettingsDynamicDataAccess | null = null;
private readonly modelObservers = new Set<StableObserver<ModelMetadata>>();
private readonly archivedThreadObservers = new Set<StableObserver<Thread>>();
replace(next: SettingsDynamicDataAccess | null): void {
for (const observer of [...this.modelObservers, ...this.archivedThreadObservers]) {
observer.unsubscribe?.();
observer.unsubscribe = null;
}
this.current = next;
if (!next) return;
for (const observer of this.modelObservers) {
observer.unsubscribe = next.observeModelsResult(observer.listener, observer.options);
}
for (const observer of this.archivedThreadObservers) {
observer.unsubscribe = next.observeArchivedThreadsResult(observer.listener, observer.options);
}
}
modelsSnapshot(): readonly ModelMetadata[] | null {
return this.delegate().modelsSnapshot();
}
observeModelsResult(listener: ObservedResultListener<readonly ModelMetadata[]>, options?: { emitCurrent?: boolean }): () => void {
const observer: StableObserver<ModelMetadata> = { listener, options, unsubscribe: null };
this.modelObservers.add(observer);
observer.unsubscribe = this.delegate().observeModelsResult(listener, options);
return () => {
observer.unsubscribe?.();
this.modelObservers.delete(observer);
};
}
fetchModels(): Promise<readonly ModelMetadata[]> {
return this.delegate().fetchModels();
}
refreshModels(): Promise<readonly ModelMetadata[]> {
return this.delegate().refreshModels();
}
archivedThreadsSnapshot(): readonly Thread[] | null {
return this.delegate().archivedThreadsSnapshot();
}
observeArchivedThreadsResult(listener: ObservedResultListener<readonly Thread[]>, options?: { emitCurrent?: boolean }): () => void {
const observer: StableObserver<Thread> = { listener, options, unsubscribe: null };
this.archivedThreadObservers.add(observer);
observer.unsubscribe = this.delegate().observeArchivedThreadsResult(listener, options);
return () => {
observer.unsubscribe?.();
this.archivedThreadObservers.delete(observer);
};
}
refreshArchivedThreads(): Promise<readonly Thread[]> {
return this.delegate().refreshArchivedThreads();
}
refreshHooks(): ReturnType<SettingsDynamicDataAccess["refreshHooks"]> {
return this.delegate().refreshHooks();
}
trustHook(hook: Parameters<SettingsDynamicDataAccess["trustHook"]>[0]): ReturnType<SettingsDynamicDataAccess["trustHook"]> {
return this.delegate().trustHook(hook);
}
setHookEnabled(
hook: Parameters<SettingsDynamicDataAccess["setHookEnabled"]>[0],
enabled: boolean,
): ReturnType<SettingsDynamicDataAccess["setHookEnabled"]> {
return this.delegate().setHookEnabled(hook, enabled);
}
restoreArchivedThread(threadId: string): Promise<Thread> {
return this.delegate().restoreArchivedThread(threadId);
}
deleteArchivedThread(threadId: string): Promise<void> {
return this.delegate().deleteArchivedThread(threadId);
}
private delegate(): SettingsDynamicDataAccess {
if (!this.current) throw new Error("Codex execution runtime is not initialized.");
return this.current;
}
}

View file

@ -1,13 +1,12 @@
import type { AppServerClient } from "../app-server/connection/client";
import type { AppServerClientAccess } from "../app-server/connection/client-access";
import type { ObservedResultListener } from "../app-server/query/observed-result";
import { isStaleAppServerResourceContextError } from "../app-server/query/resource-store";
import { listHookCatalog, setHookItemEnabled, trustHookItem } from "../app-server/services/catalog";
import { deleteThread, restoreArchivedThread as restoreArchivedThreadOnAppServer } from "../app-server/services/threads";
import type { HookItem, ModelMetadata } from "../domain/catalog/metadata";
import type { ThreadCatalogArchivedReader, ThreadCatalogEventSink } from "../features/threads/catalog/thread-catalog";
import { createKeyedOperationQueue } from "../shared/runtime/keyed-operation-queue";
import { type SettingsDynamicDataAccess, type SettingsHookCatalog, StaleSettingsDynamicDataContextError } from "./dynamic-data";
import type { SettingsDynamicDataAccess, SettingsHookCatalog } from "./dynamic-data";
interface SettingsAppServerQueries {
modelsSnapshot(): readonly ModelMetadata[] | null;
@ -32,26 +31,24 @@ export function createSettingsAppServerDynamicData(options: SettingsAppServerDyn
serverRequests: { kind: "reject", message: "Codex Panel settings does not handle server requests." },
});
const runArchivedThreadMutation = <T>(threadId: string, operation: () => Promise<T>): Promise<T> => {
return archivedThreadMutations.run(threadId, () => mapStaleContextError(operation));
return archivedThreadMutations.run(threadId, operation);
};
const loadHooks = (client: AppServerClient): Promise<SettingsHookCatalog> => loadSettingsHookCatalog(client, options.vaultPath);
const mutateHook = (hook: HookItem, mutation: (client: AppServerClient, hook: HookItem) => Promise<void>): Promise<SettingsHookCatalog> =>
mapStaleContextError(() =>
withSettingsConnection(async (client) => {
await mutation(client, hook);
return loadHooks(client);
}),
);
withSettingsConnection(async (client) => {
await mutation(client, hook);
return loadHooks(client);
});
return {
modelsSnapshot: () => options.appServerQueries.modelsSnapshot(),
observeModelsResult: (listener, observeOptions) => options.appServerQueries.observeModelsResult(listener, observeOptions),
fetchModels: () => mapStaleContextError(() => options.appServerQueries.fetchModels()),
refreshModels: () => mapStaleContextError(() => options.appServerQueries.refreshModels()),
fetchModels: () => options.appServerQueries.fetchModels(),
refreshModels: () => options.appServerQueries.refreshModels(),
archivedThreadsSnapshot: () => options.threadCatalog.archivedSnapshot(),
observeArchivedThreadsResult: (listener, observeOptions) => options.threadCatalog.observeArchived(listener, observeOptions),
refreshArchivedThreads: () => mapStaleContextError(() => options.threadCatalog.refreshArchived()),
refreshHooks: () => mapStaleContextError(() => withSettingsConnection(loadHooks)),
refreshArchivedThreads: () => options.threadCatalog.refreshArchived(),
refreshHooks: () => withSettingsConnection(loadHooks),
trustHook: (hook) => mutateHook(hook, trustHookItem),
setHookEnabled: (hook, enabled) => mutateHook(hook, (client, item) => setHookItemEnabled(client, item, enabled)),
restoreArchivedThread: (threadId) =>
@ -76,12 +73,3 @@ async function loadSettingsHookCatalog(client: AppServerClient, vaultPath: strin
status: `Loaded ${String(hookCount)} hook${hookCount === 1 ? "" : "s"}.`,
};
}
async function mapStaleContextError<T>(operation: () => Promise<T>): Promise<T> {
try {
return await operation();
} catch (error) {
if (isStaleAppServerResourceContextError(error)) throw new StaleSettingsDynamicDataContextError();
throw error;
}
}

View file

@ -23,14 +23,3 @@ export interface SettingsDynamicDataAccess {
restoreArchivedThread(threadId: string): Promise<Thread>;
deleteArchivedThread(threadId: string): Promise<void>;
}
export class StaleSettingsDynamicDataContextError extends Error {
constructor() {
super("Settings dynamic data context changed while loading Codex details.");
this.name = "StaleSettingsDynamicDataContextError";
}
}
export function isStaleSettingsDynamicDataContextError(error: unknown): error is StaleSettingsDynamicDataContextError {
return error instanceof StaleSettingsDynamicDataContextError;
}

View file

@ -4,7 +4,7 @@ import { findModelMetadataByIdOrName, sortedModelMetadata, supportedEffortsForMo
import type { Thread } from "../domain/threads/model";
import { threadArchiveDisplayTitle } from "../domain/threads/title";
import { OwnerLifetime } from "../shared/runtime/owner-lifetime";
import { isStaleSettingsDynamicDataContextError, type SettingsHookCatalog } from "./dynamic-data";
import type { SettingsDynamicDataAccess, SettingsHookCatalog } from "./dynamic-data";
import type { SettingsDynamicSectionsHost } from "./host";
interface SettingsDynamicSectionsControllerCallbacks {
@ -50,30 +50,65 @@ export class SettingsDynamicSectionsController {
private modelsLifecycle: SettingsDynamicSectionLifecycleState = createSettingsDynamicSectionLifecycle();
private unsubscribeModels: (() => void) | null = null;
private unsubscribeArchivedThreads: (() => void) | null = null;
private dynamicData: SettingsDynamicDataAccess;
constructor(
private readonly host: SettingsDynamicSectionsHost,
private readonly callbacks: SettingsDynamicSectionsControllerCallbacks,
) {}
) {
this.dynamicData = host.dynamicData;
}
activate(): void {
if (this.unsubscribeModels) return;
this.lifetime.activate();
this.models = [...(this.host.dynamicData.modelsSnapshot() ?? [])];
const archivedThreads = this.host.dynamicData.archivedThreadsSnapshot();
this.loadSnapshots();
this.subscribe();
}
replaceDynamicData(next: SettingsDynamicDataAccess): void {
if (next === this.dynamicData) return;
this.unsubscribe();
this.modelsOperationToken += 1;
this.archivedThreadsOperationToken += 1;
this.hookMutationOperation = null;
this.dynamicData = next;
this.dynamicSectionsAutoLoadStarted = false;
this.modelsLifecycle = createSettingsDynamicSectionLifecycle();
this.hooks = [];
this.hookWarnings = [];
this.hookErrors = [];
this.hooksLoaded = false;
this.hooksLifecycle = createSettingsDynamicSectionLifecycle();
this.archivedThreads = [];
this.archivedThreadsLoaded = false;
this.archivedThreadsLifecycle = createSettingsDynamicSectionLifecycle();
this.loadSnapshots();
if (this.lifetime.isActive()) this.subscribe();
}
private loadSnapshots(): void {
this.models = [...(this.dynamicData.modelsSnapshot() ?? [])];
const archivedThreads = this.dynamicData.archivedThreadsSnapshot();
if (archivedThreads) {
this.archivedThreads = [...archivedThreads];
this.archivedThreadsLoaded = true;
this.archivedThreadsLifecycle = settingsDynamicSectionLoaded(archivedThreadsStatus(archivedThreads.length));
}
this.unsubscribeModels = this.host.dynamicData.observeModelsResult(
}
private subscribe(): void {
const dynamicData = this.dynamicData;
this.unsubscribeModels = dynamicData.observeModelsResult(
(result) => {
if (!this.dynamicDataIsCurrent(dynamicData)) return;
this.receiveObservedModelsResult(result);
},
{ emitCurrent: false },
);
this.unsubscribeArchivedThreads = this.host.dynamicData.observeArchivedThreadsResult(
this.unsubscribeArchivedThreads = dynamicData.observeArchivedThreadsResult(
(result) => {
if (!this.dynamicDataIsCurrent(dynamicData)) return;
this.receiveObservedArchivedThreadsResult(result);
},
{ emitCurrent: false },
@ -86,23 +121,6 @@ export class SettingsDynamicSectionsController {
void this.refreshDynamicSections({ forceModels: false });
}
resetDynamicSectionContext(): void {
this.dynamicSectionsAutoLoadStarted = false;
this.modelsOperationToken += 1;
this.archivedThreadsOperationToken += 1;
this.hookMutationOperation = null;
this.models = [...(this.host.dynamicData.modelsSnapshot() ?? [])];
this.modelsLifecycle = createSettingsDynamicSectionLifecycle();
this.hooks = [];
this.hookWarnings = [];
this.hookErrors = [];
this.hooksLoaded = false;
this.hooksLifecycle = createSettingsDynamicSectionLifecycle();
this.archivedThreads = [];
this.archivedThreadsLoaded = false;
this.archivedThreadsLifecycle = createSettingsDynamicSectionLifecycle();
}
dispose(): void {
this.lifetime.dispose();
this.dynamicSectionsAutoLoadStarted = false;
@ -115,10 +133,7 @@ export class SettingsDynamicSectionsController {
}
this.modelsOperationToken += 1;
this.archivedThreadsOperationToken += 1;
this.unsubscribeModels?.();
this.unsubscribeModels = null;
this.unsubscribeArchivedThreads?.();
this.unsubscribeArchivedThreads = null;
this.unsubscribe();
}
private receiveObservedModelsResult(result: ObservedResult<readonly ModelMetadata[]>): void {
@ -152,6 +167,7 @@ export class SettingsDynamicSectionsController {
async refreshDynamicSections(options: { forceModels?: boolean } = {}): Promise<void> {
const lifetime = this.lifetime.signal();
if (!this.lifetime.isCurrent(lifetime)) return;
const dynamicData = this.dynamicData;
this.dynamicSectionsAutoLoadStarted = true;
const modelsOperationToken = this.nextModelsOperationToken();
const archivedThreadsOperationToken = this.nextArchivedThreadsOperationToken();
@ -163,11 +179,11 @@ export class SettingsDynamicSectionsController {
let failedCount = 0;
try {
const [modelsResult, hooksResult, archivedThreadsResult] = await Promise.allSettled([
options.forceModels === false ? this.host.dynamicData.fetchModels() : this.host.dynamicData.refreshModels(),
this.host.dynamicData.refreshHooks(),
this.host.dynamicData.refreshArchivedThreads(),
options.forceModels === false ? dynamicData.fetchModels() : dynamicData.refreshModels(),
dynamicData.refreshHooks(),
dynamicData.refreshArchivedThreads(),
] as const);
if (!this.lifetime.isCurrent(lifetime)) return;
if (!this.lifetime.isCurrent(lifetime) || !this.dynamicDataIsCurrent(dynamicData)) return;
if (this.isStaleModelsOperation(modelsOperationToken)) {
// A newer models operation owns this section.
@ -176,8 +192,6 @@ export class SettingsDynamicSectionsController {
this.modelsLifecycle = settingsDynamicSectionLoaded(
`Loaded ${String(modelsResult.value.length)} model${modelsResult.value.length === 1 ? "" : "s"}.`,
);
} else if (isStaleSettingsDynamicDataContextError(modelsResult.reason)) {
return;
} else {
failedCount += 1;
this.modelsLifecycle = settingsDynamicSectionFailed(`Could not load models: ${errorMessage(modelsResult.reason)}`);
@ -186,8 +200,6 @@ export class SettingsDynamicSectionsController {
if (hooksResult.status === "fulfilled") {
this.receiveHookCatalog(hooksResult.value);
this.hooksLifecycle = settingsDynamicSectionLoaded(hooksResult.value.status);
} else if (isStaleSettingsDynamicDataContextError(hooksResult.reason)) {
return;
} else {
failedCount += 1;
this.hooksLifecycle = settingsDynamicSectionFailed(`Could not load hooks: ${errorMessage(hooksResult.reason)}`);
@ -199,8 +211,6 @@ export class SettingsDynamicSectionsController {
this.archivedThreads = [...archivedThreadsResult.value];
this.archivedThreadsLoaded = true;
this.archivedThreadsLifecycle = settingsDynamicSectionLoaded(archivedThreadsStatus(archivedThreadsResult.value.length));
} else if (isStaleSettingsDynamicDataContextError(archivedThreadsResult.reason)) {
return;
} else {
failedCount += 1;
this.archivedThreadsLifecycle = settingsDynamicSectionFailed(
@ -208,7 +218,7 @@ export class SettingsDynamicSectionsController {
);
}
} catch (error) {
if (!this.lifetime.isCurrent(lifetime)) return;
if (!this.lifetime.isCurrent(lifetime) || !this.dynamicDataIsCurrent(dynamicData)) return;
failedCount = 3;
const message = errorMessage(error);
if (!this.isStaleModelsOperation(modelsOperationToken)) {
@ -219,7 +229,7 @@ export class SettingsDynamicSectionsController {
this.archivedThreadsLifecycle = settingsDynamicSectionFailed(`Could not load archived threads: ${message}`);
}
} finally {
if (this.lifetime.isCurrent(lifetime)) {
if (this.lifetime.isCurrent(lifetime) && this.dynamicDataIsCurrent(dynamicData)) {
if (failedCount > 0) {
this.callbacks.notify("Could not refresh all Codex details.");
}
@ -255,7 +265,7 @@ export class SettingsDynamicSectionsController {
failureStatus: (error) => `Could not trust hook: ${errorMessage(error)}`,
failureNotice: "Could not trust Codex hook.",
successStatus: "Trusted hook definition.",
operation: () => this.host.dynamicData.trustHook(hook),
operation: (dynamicData) => dynamicData.trustHook(hook),
});
}
@ -265,7 +275,7 @@ export class SettingsDynamicSectionsController {
failureStatus: (error) => `Could not update hook: ${errorMessage(error)}`,
failureNotice: "Could not update Codex hook.",
successStatus: enabled ? "Enabled hook." : "Disabled hook.",
operation: () => this.host.dynamicData.setHookEnabled(hook, enabled),
operation: (dynamicData) => dynamicData.setHookEnabled(hook, enabled),
});
}
@ -274,8 +284,8 @@ export class SettingsDynamicSectionsController {
loadingStatus: "Loading archived threads...",
failureStatus: (error) => `Could not restore archived thread: ${errorMessage(error)}`,
failureNotice: "Could not restore archived Codex thread.",
operation: async (operationToken) => {
const restoredThread = await this.host.dynamicData.restoreArchivedThread(threadId);
operation: async (dynamicData, operationToken) => {
const restoredThread = await dynamicData.restoreArchivedThread(threadId);
if (this.isStaleArchivedThreadsOperation(operationToken)) return;
this.archivedThreads = this.archivedThreads.filter((thread) => thread.id !== threadId);
this.archivedThreadsLifecycle = settingsDynamicSectionLoaded(`Restored "${threadArchiveDisplayTitle(restoredThread)}".`);
@ -290,8 +300,8 @@ export class SettingsDynamicSectionsController {
loadingStatus: "Loading archived threads...",
failureStatus: (error) => `Could not delete archived thread: ${errorMessage(error)}`,
failureNotice: "Could not delete archived Codex thread.",
operation: async (operationToken) => {
await this.host.dynamicData.deleteArchivedThread(threadId);
operation: async (dynamicData, operationToken) => {
await dynamicData.deleteArchivedThread(threadId);
if (this.isStaleArchivedThreadsOperation(operationToken)) return;
this.archivedThreads = this.archivedThreads.filter((thread) => thread.id !== threadId);
this.archivedThreadsLifecycle = settingsDynamicSectionLoaded(`Deleted "${title}".`);
@ -321,20 +331,21 @@ export class SettingsDynamicSectionsController {
failureStatus: (error: unknown) => string;
failureNotice: string;
successStatus: string;
operation: () => Promise<SettingsHookCatalog>;
operation: (dynamicData: SettingsDynamicDataAccess) => Promise<SettingsHookCatalog>;
}): Promise<void> {
const dynamicData = this.dynamicData;
const operation = {};
this.hookMutationOperation = operation;
const isCurrent = (): boolean => this.hookMutationOperation === operation;
const isCurrent = (): boolean => this.hookMutationOperation === operation && this.dynamicDataIsCurrent(dynamicData);
this.hooksLifecycle = settingsDynamicSectionLoading(options.loadingStatus);
this.callbacks.display();
try {
const catalog = await options.operation();
const catalog = await options.operation(dynamicData);
if (!isCurrent()) return;
this.receiveHookCatalog(catalog);
this.hooksLifecycle = settingsDynamicSectionLoaded(options.successStatus);
} catch (error) {
if (!isCurrent() || isStaleSettingsDynamicDataContextError(error)) return;
if (!isCurrent()) return;
this.hooksLifecycle = settingsDynamicSectionFailed(options.failureStatus(error));
if (this.lifetime.isActive()) this.callbacks.notify(options.failureNotice);
} finally {
@ -349,19 +360,21 @@ export class SettingsDynamicSectionsController {
loadingStatus: string;
failureStatus: (error: unknown) => string;
failureNotice: string;
operation: (operationToken: number) => Promise<void>;
operation: (dynamicData: SettingsDynamicDataAccess, operationToken: number) => Promise<void>;
}): Promise<void> {
const lifetime = this.lifetime.signal();
if (!this.lifetime.isCurrent(lifetime)) return;
const dynamicData = this.dynamicData;
const operationToken = this.nextArchivedThreadsOperationToken();
const stale = (): boolean => !this.lifetime.isCurrent(lifetime) || this.isStaleArchivedThreadsOperation(operationToken);
const stale = (): boolean =>
!this.lifetime.isCurrent(lifetime) || !this.dynamicDataIsCurrent(dynamicData) || this.isStaleArchivedThreadsOperation(operationToken);
this.archivedThreadsLifecycle = settingsDynamicSectionLoading(options.loadingStatus);
this.callbacks.display();
try {
await options.operation(operationToken);
await options.operation(dynamicData, operationToken);
} catch (error) {
if (stale() || isStaleSettingsDynamicDataContextError(error)) return;
if (stale()) return;
this.archivedThreadsLifecycle = settingsDynamicSectionFailed(options.failureStatus(error));
this.callbacks.notify(options.failureNotice);
} finally {
@ -386,6 +399,17 @@ export class SettingsDynamicSectionsController {
private isStaleArchivedThreadsOperation(operationToken: number): boolean {
return operationToken !== this.archivedThreadsOperationToken;
}
private dynamicDataIsCurrent(dynamicData: SettingsDynamicDataAccess): boolean {
return this.dynamicData === dynamicData;
}
private unsubscribe(): void {
this.unsubscribeModels?.();
this.unsubscribeModels = null;
this.unsubscribeArchivedThreads?.();
this.unsubscribeArchivedThreads = null;
}
}
function archivedThreadsStatus(count: number): string {

View file

@ -3,9 +3,9 @@ import type { CodexPanelSettings } from "./model";
export interface SettingsDynamicSectionsHost {
settings: CodexPanelSettings;
dynamicData: SettingsDynamicDataAccess;
readonly dynamicData: SettingsDynamicDataAccess;
}
export interface CodexPanelSettingTabHost extends SettingsDynamicSectionsHost {
publishSettings(settings: CodexPanelSettings): Promise<{ appServerContextReplaced: boolean }>;
publishSettings(settings: CodexPanelSettings): Promise<{ replacementDynamicData: SettingsDynamicDataAccess | null }>;
}

View file

@ -478,18 +478,11 @@ export class CodexPanelSettingTab extends PluginSettingTab {
private setCodexPath(value: string): Promise<void> {
const codexPath = value.trim() || DEFAULT_CODEX_PATH;
return this.queueSettingsMutation(
(settings) => {
if (codexPath === settings.codexPath) return false;
settings.codexPath = codexPath;
return true;
},
{
onPublished: ({ appServerContextReplaced }) => {
if (appServerContextReplaced) this.dynamicSections.resetDynamicSectionContext();
},
},
);
return this.queueSettingsMutation((settings) => {
if (codexPath === settings.codexPath) return false;
settings.codexPath = codexPath;
return true;
});
}
private setShowToolbar(value: boolean): Promise<void> {
@ -576,16 +569,13 @@ export class CodexPanelSettingTab extends PluginSettingTab {
});
}
private queueSettingsMutation(
mutate: (settings: CodexPanelSettings) => boolean | undefined,
publication: { onPublished?: (result: { appServerContextReplaced: boolean }) => void } = {},
): Promise<void> {
private queueSettingsMutation(mutate: (settings: CodexPanelSettings) => boolean | undefined): Promise<void> {
const operation = this.settingsMutationQueue.then(async () => {
const candidateSettings: CodexPanelSettings = { ...this.plugin.settings };
if (mutate(candidateSettings) === false) return;
try {
const result = await this.plugin.publishSettings(candidateSettings);
publication.onPublished?.(result);
const { replacementDynamicData } = await this.plugin.publishSettings(candidateSettings);
if (replacementDynamicData) this.dynamicSections.replaceDynamicData(replacementDynamicData);
} catch (error) {
this.settingsShellRevision += 1;
new Notice(`Failed to save Codex Panel settings: ${error instanceof Error ? error.message : String(error)}`);

View file

@ -6,7 +6,6 @@ import { VIEW_TYPE_CODEX_PANEL } from "../src/constants";
import type { Thread } from "../src/domain/threads/model";
import type { CodexChatView } from "../src/features/chat/host/view.obsidian";
import type CodexPanelPlugin from "../src/main";
import { SwappableSettingsDynamicData } from "../src/plugin-runtime";
import { deferred } from "./support/async";
import { installObsidianDomShims } from "./support/dom";
import {
@ -408,29 +407,3 @@ describe("CodexPanelPlugin runtime integration", () => {
expect(closeAll).toHaveBeenCalledOnce();
});
});
describe("SwappableSettingsDynamicData", () => {
it("moves existing observers to the replacement runtime", () => {
const firstUnsubscribe = vi.fn();
const secondUnsubscribe = vi.fn();
const first = {
observeModelsResult: vi.fn(() => firstUnsubscribe),
observeArchivedThreadsResult: vi.fn(() => vi.fn()),
};
const second = {
observeModelsResult: vi.fn(() => secondUnsubscribe),
observeArchivedThreadsResult: vi.fn(() => vi.fn()),
};
const dynamicData = new SwappableSettingsDynamicData();
const listener = vi.fn();
dynamicData.replace(first as never);
const unsubscribe = dynamicData.observeModelsResult(listener, { emitCurrent: false });
dynamicData.replace(second as never);
expect(firstUnsubscribe).toHaveBeenCalledOnce();
expect(second.observeModelsResult).toHaveBeenCalledWith(listener, { emitCurrent: false });
unsubscribe();
expect(secondUnsubscribe).toHaveBeenCalledOnce();
});
});

View file

@ -3,9 +3,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { modelMetadataFromCatalogModels } from "../../src/app-server/protocol/catalog";
import type { ThreadRecord } from "../../src/app-server/protocol/thread";
import type { ObservedResult } from "../../src/app-server/query/observed-result";
import { StaleAppServerResourceContextError } from "../../src/app-server/query/resource-store";
import type { ModelMetadata } from "../../src/domain/catalog/metadata";
import type { Thread } from "../../src/domain/threads/model";
import { StaleSettingsDynamicDataContextError } from "../../src/settings/dynamic-data";
import { SettingsDynamicSectionsController, type SettingsDynamicSectionsSnapshot } from "../../src/settings/dynamic-sections-controller";
import { deferred } from "../support/async";
import {
@ -189,8 +189,8 @@ describe("SettingsDynamicSectionsController", () => {
const oldMutation = controller.trustHook(hook({ key: "hook-old-context", trustStatus: "untrusted" }));
await flushPromises();
await host.publishSettings({ ...host.settings, codexPath: "/opt/codex-next" });
controller.resetDynamicSectionContext();
const publication = await host.publishSettings({ ...host.settings, codexPath: "/opt/codex-next" });
controller.replaceDynamicData(publication.replacementDynamicData as NonNullable<typeof publication.replacementDynamicData>);
await controller.refreshDynamicSections();
expect(controller.snapshot().hooks).toEqual([expect.objectContaining({ key: "hook-new-context" })]);
@ -338,8 +338,9 @@ describe("SettingsDynamicSectionsController", () => {
const staleMutation = host.dynamicData.restoreArchivedThread("thread-shared");
await flushPromises();
await host.publishSettings({ ...host.settings, codexPath: "/opt/codex-next" });
const currentMutation = host.dynamicData.restoreArchivedThread("thread-shared");
const publication = await host.publishSettings({ ...host.settings, codexPath: "/opt/codex-next" });
if (!publication.replacementDynamicData) throw new Error("Expected replacement settings data.");
const currentMutation = publication.replacementDynamicData.restoreArchivedThread("thread-shared");
await expect(currentMutation).resolves.toMatchObject({ id: "thread-shared", preview: "New context" });
expect(newClient.requestHandlers["thread/unarchive"]).toHaveBeenCalledOnce();
@ -347,7 +348,7 @@ describe("SettingsDynamicSectionsController", () => {
oldRestore.resolve({
thread: appServerThread({ id: "thread-shared", preview: "Old context" }),
});
await expect(staleMutation).rejects.toBeInstanceOf(StaleSettingsDynamicDataContextError);
await expect(staleMutation).rejects.toBeInstanceOf(StaleAppServerResourceContextError);
});
it("records restored archived threads in the active catalog", async () => {

View file

@ -329,6 +329,32 @@ describe("settings tab", () => {
expect(tab.containerEl.children).toHaveLength(0);
});
it("binds the replacement executable data source when a hidden tab is shown again", async () => {
const save = deferred<void>();
const observeModels = vi.fn(() => vi.fn());
useShortLivedClients(settingsClient(), settingsClient());
const host = settingsTabHost({
saveSettings: vi.fn(() => save.promise),
observeModels,
});
const tab = new CodexPanelSettingTab({} as never, {} as never, host);
tab.display();
const codexInput = inputForSetting(tab, "Codex executable");
if (!codexInput) throw new Error("Missing Codex executable input");
codexInput.value = "/opt/codex-next";
codexInput.dispatchEvent(new FocusEvent("blur"));
await Promise.resolve();
tab.hide();
save.resolve(undefined);
await flushPromises();
tab.display();
expect(host.settings.codexPath).toBe("/opt/codex-next");
expect(observeModels).toHaveBeenCalledTimes(2);
});
it("serializes overlapping settings saves", async () => {
const firstSave = deferred<void>();
const saveSettings = vi.fn().mockReturnValueOnce(firstSave.promise).mockResolvedValueOnce(undefined);

View file

@ -8,7 +8,6 @@ import { StaleAppServerResourceContextError } from "../../src/app-server/query/r
import type { ModelMetadata, ReasoningEffort } from "../../src/domain/catalog/metadata";
import type { Thread } from "../../src/domain/threads/model";
import type { ThreadCatalogEvent } from "../../src/features/threads/catalog/thread-catalog";
import { SwappableSettingsDynamicData } from "../../src/plugin-runtime";
import { createSettingsAppServerDynamicData } from "../../src/settings/app-server-dynamic-data";
import type { SettingsDynamicDataAccess } from "../../src/settings/dynamic-data";
import type { CodexPanelSettingTabHost } from "../../src/settings/host";
@ -243,8 +242,6 @@ export function settingsTabHost(options: SettingsTabHostOptions = {}): CodexPane
return result;
},
};
const swappableDynamicData = options.dynamicData ? null : new SwappableSettingsDynamicData();
const dynamicData: SettingsDynamicDataAccess = options.dynamicData ?? (swappableDynamicData as SwappableSettingsDynamicData);
const createDynamicData = () =>
createSettingsAppServerDynamicData({
vaultPath: "/vault",
@ -252,8 +249,8 @@ export function settingsTabHost(options: SettingsTabHostOptions = {}): CodexPane
appServerQueries,
threadCatalog,
});
swappableDynamicData?.replace(createDynamicData());
return {
let dynamicData = options.dynamicData ?? createDynamicData();
const host: CodexPanelSettingTabHost = {
settings,
dynamicData,
publishSettings: async (nextSettings) => {
@ -261,11 +258,14 @@ export function settingsTabHost(options: SettingsTabHostOptions = {}): CodexPane
await (options.saveSettings ?? vi.fn().mockResolvedValue(undefined))(nextSettings);
const appServerContextReplaced = previousSettings.codexPath !== nextSettings.codexPath;
Object.assign(settings, nextSettings);
if (appServerContextReplaced) swappableDynamicData?.replace(createDynamicData());
if (appServerContextReplaced && !options.dynamicData) {
dynamicData = createDynamicData();
}
if (appServerContextReplaced || previousSettings.showToolbar !== nextSettings.showToolbar) options.refreshOpenViews?.();
return { appServerContextReplaced };
return { replacementDynamicData: appServerContextReplaced ? dynamicData : null };
},
};
return host;
}
export async function flushPromises(): Promise<void> {