Separate settings app-server dynamic data

This commit is contained in:
murashit 2026-06-27 22:12:00 +09:00
parent ca53d77fed
commit f068607509
10 changed files with 255 additions and 92 deletions

View file

@ -43,6 +43,10 @@
"path": "./scripts/lint/no-generated-app-server-boundary-imports.grit",
"includes": ["**/src/**/*.ts", "**/src/**/*.tsx", "!**/src/app-server/connection/**"]
},
{
"path": "./scripts/lint/no-settings-app-server-boundary-imports.grit",
"includes": ["**/src/settings/**/*.ts", "**/src/settings/**/*.tsx", "!**/src/settings/app-server/**"]
},
// Shared source layering boundaries.
{

View file

@ -0,0 +1,18 @@
language js
private pattern js_module_reference() {
or {
JsImport(),
JsExportNamedFromClause(),
JsExportFromClause(),
TsImportType(),
JsImportCallExpression()
}
}
js_module_reference() as $stmt where {
$stmt <: contains `$source` where {
$source <: r"^[\"'](?:(?:\.\./)+app-server|src/app-server|\./app-server|(?:\.\./)+settings/app-server|src/settings/app-server)(?:/.*)?[\"']$"
},
register_diagnostic(span=$stmt, message="Settings modules must depend on settings-owned dynamic data ports instead of importing app-server modules or settings app-server adapters.", severity="error")
}

View file

@ -22,6 +22,7 @@ import type { ThreadsViewPanelActivity } from "./features/threads-view/state";
import { CodexThreadsView } from "./features/threads-view/view.obsidian";
import { persistedTurnDiffViewState, type TurnDiffViewState } from "./features/turn-diff/model";
import { CodexTurnDiffView } from "./features/turn-diff/view.obsidian";
import { createSettingsAppServerDynamicData } from "./settings/app-server/dynamic-data";
import type { CodexPanelSettingTabHost } from "./settings/host";
import type { CodexPanelSettings } from "./settings/model";
import { WorkspacePanelCoordinator } from "./workspace/panel-coordinator";
@ -175,14 +176,16 @@ export class CodexPanelRuntime implements AppServerClientAccess {
settingTabHost(): CodexPanelSettingTabHost {
return {
settings: this.options.settingsRef.settings,
vaultPath: this.options.settingsRef.vaultPath,
clientAccess: this,
dynamicData: createSettingsAppServerDynamicData({
vaultPath: this.options.settingsRef.vaultPath,
clientAccess: this,
appServerQueries: this.appServerSharedQueries,
threadCatalog: this.threadCatalog,
}),
saveSettings: () => this.options.saveSettings(),
refreshOpenViews: () => {
this.refreshOpenViews();
},
appServerQueries: this.appServerSharedQueries,
threadCatalog: this.threadCatalog,
};
}

View file

@ -0,0 +1,80 @@
import type { AppServerClient } from "../../app-server/connection/client";
import type { AppServerClientAccess } from "../../app-server/connection/client-access";
import { isStaleAppServerSharedQueryContextError } from "../../app-server/query/shared-queries";
import type { ThreadCatalogArchivedReader, ThreadCatalogEventSink } from "../../app-server/query/thread-catalog";
import { listHookCatalog, setHookItemEnabled, trustHookItem } from "../../app-server/services/catalog";
import { restoreArchivedThread as restoreArchivedThreadOnAppServer } from "../../app-server/services/threads";
import type { ModelMetadata } from "../../domain/catalog/metadata";
import type { ObservedResultListener } from "../../shared/query/observed-result";
import { type SettingsDynamicDataAccess, type SettingsHookCatalog, StaleSettingsDynamicDataContextError } from "../dynamic-data";
interface SettingsAppServerQueries {
modelsSnapshot(): readonly ModelMetadata[] | null;
observeModelsResult(listener: ObservedResultListener<readonly ModelMetadata[]>, options?: { emitCurrent?: boolean }): () => void;
fetchModels(): Promise<readonly ModelMetadata[]>;
refreshModels(): Promise<readonly ModelMetadata[]>;
notifyContextChanged(): void;
}
type SettingsArchivedThreadCatalog = ThreadCatalogArchivedReader & ThreadCatalogEventSink;
export interface SettingsAppServerDynamicDataOptions {
vaultPath: string;
clientAccess: AppServerClientAccess;
appServerQueries: SettingsAppServerQueries;
threadCatalog: SettingsArchivedThreadCatalog;
}
export function createSettingsAppServerDynamicData(options: SettingsAppServerDynamicDataOptions): SettingsDynamicDataAccess {
const withSettingsConnection = <T>(operation: (client: AppServerClient) => Promise<T>): Promise<T> =>
options.clientAccess.withClient(operation, {
serverRequests: { kind: "reject", message: "Codex Panel settings does not handle server requests." },
});
return {
modelsSnapshot: () => options.appServerQueries.modelsSnapshot(),
observeModelsResult: (listener, observeOptions) => options.appServerQueries.observeModelsResult(listener, observeOptions),
fetchModels: () => mapStaleContextError(() => options.appServerQueries.fetchModels()),
refreshModels: () => mapStaleContextError(() => options.appServerQueries.refreshModels()),
archivedThreadsSnapshot: () => options.threadCatalog.archivedSnapshot(),
observeArchivedThreadsResult: (listener, observeOptions) => options.threadCatalog.observeArchived(listener, observeOptions),
refreshArchivedThreads: () => mapStaleContextError(() => options.threadCatalog.refreshArchived()),
loadHooks: () => withSettingsConnection((client) => loadSettingsHookCatalog(client, options.vaultPath)),
trustHook: (hook) => withSettingsConnection((client) => trustHookItem(client, hook)),
setHookEnabled: (hook, enabled) => withSettingsConnection((client) => setHookItemEnabled(client, hook, enabled)),
restoreArchivedThread: async (threadId, mutationOptions) => {
const thread = await withSettingsConnection((client) => restoreArchivedThreadOnAppServer(client, threadId));
if (mutationOptions?.shouldPublish?.() ?? true) {
options.threadCatalog.apply({ type: "thread-restored", thread });
}
return thread;
},
deleteArchivedThread: async (threadId, mutationOptions) => {
await withSettingsConnection((client) => client.deleteThread(threadId));
if (mutationOptions?.shouldPublish?.() ?? true) {
options.threadCatalog.apply({ type: "thread-deleted", threadId });
}
},
notifyContextChanged: () => {
options.appServerQueries.notifyContextChanged();
},
};
}
async function loadSettingsHookCatalog(client: AppServerClient, cwd: string): Promise<SettingsHookCatalog> {
const hooks = await listHookCatalog(client, cwd);
const hookCount = hooks.hooks.length;
return {
...hooks,
status: `Loaded ${String(hookCount)} hook${hookCount === 1 ? "" : "s"}.`,
};
}
async function mapStaleContextError<T>(operation: () => Promise<T>): Promise<T> {
try {
return await operation();
} catch (error) {
if (isStaleAppServerSharedQueryContextError(error)) throw new StaleSettingsDynamicDataContextError();
throw error;
}
}

View file

@ -0,0 +1,41 @@
import type { HookItem, ModelMetadata } from "../domain/catalog/metadata";
import type { Thread } from "../domain/threads/model";
import type { ObservedResultListener } from "../shared/query/observed-result";
export interface SettingsHookCatalog {
hooks: readonly HookItem[];
warnings: readonly string[];
errors: readonly string[];
status: string;
}
interface SettingsDynamicDataMutationOptions {
shouldPublish?: () => boolean;
}
export interface SettingsDynamicDataAccess {
modelsSnapshot(): readonly ModelMetadata[] | null;
observeModelsResult(listener: ObservedResultListener<readonly ModelMetadata[]>, options?: { emitCurrent?: boolean }): () => void;
fetchModels(): Promise<readonly ModelMetadata[]>;
refreshModels(): Promise<readonly ModelMetadata[]>;
archivedThreadsSnapshot(): readonly Thread[] | null;
observeArchivedThreadsResult(listener: ObservedResultListener<readonly Thread[]>, options?: { emitCurrent?: boolean }): () => void;
refreshArchivedThreads(): Promise<readonly Thread[]>;
loadHooks(): Promise<SettingsHookCatalog>;
trustHook(hook: HookItem): Promise<void>;
setHookEnabled(hook: HookItem, enabled: boolean): Promise<void>;
restoreArchivedThread(threadId: string, options?: SettingsDynamicDataMutationOptions): Promise<Thread>;
deleteArchivedThread(threadId: string, options?: SettingsDynamicDataMutationOptions): Promise<void>;
notifyContextChanged(): 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

@ -1,13 +1,10 @@
import type { AppServerClient } from "../app-server/connection/client";
import { isStaleAppServerSharedQueryContextError } from "../app-server/query/shared-queries";
import { type HookCatalog, listHookCatalog, setHookItemEnabled, trustHookItem } from "../app-server/services/catalog";
import { restoreArchivedThread as restoreArchivedThreadOnAppServer } from "../app-server/services/threads";
import type { HookItem, ModelMetadata, ReasoningEffort } from "../domain/catalog/metadata";
import { findModelMetadataByIdOrName, sortedModelMetadata, supportedEffortsForModelMetadata } from "../domain/catalog/metadata";
import type { Thread } from "../domain/threads/model";
import { threadArchiveDisplayTitle } from "../domain/threads/title";
import type { ObservedResult } from "../shared/query/observed-result";
import { observedValue } from "../shared/query/observed-result";
import { isStaleSettingsDynamicDataContextError } from "./dynamic-data";
import type { SettingsDynamicSectionsHost } from "./host";
import {
createSettingsDynamicSectionLifecycle,
@ -15,19 +12,6 @@ import {
transitionSettingsDynamicSectionLifecycle,
} from "./lifecycle";
interface LoadedHookCatalog extends HookCatalog {
status: string;
}
async function loadHookCatalog(client: AppServerClient, cwd: string): Promise<LoadedHookCatalog> {
const hooks = await listHookCatalog(client, cwd);
const hookCount = hooks.hooks.length;
return {
...hooks,
status: `Loaded ${String(hookCount)} hook${hookCount === 1 ? "" : "s"}.`,
};
}
interface SettingsDynamicSectionsControllerCallbacks {
display(target: SettingsDynamicSectionsDisplayTarget): void;
notify(message: string): void;
@ -75,8 +59,8 @@ export class SettingsDynamicSectionsController {
activate(): void {
if (this.unsubscribeModels) return;
this.models = [...(this.host.appServerQueries.modelsSnapshot() ?? [])];
const archivedThreads = this.host.threadCatalog.archivedSnapshot();
this.models = [...(this.host.dynamicData.modelsSnapshot() ?? [])];
const archivedThreads = this.host.dynamicData.archivedThreadsSnapshot();
if (archivedThreads) {
this.archivedThreads = [...archivedThreads];
this.archivedThreadsLoaded = true;
@ -86,13 +70,13 @@ export class SettingsDynamicSectionsController {
operationToken: this.archivedThreadsOperationToken,
});
}
this.unsubscribeModels = this.host.appServerQueries.observeModelsResult(
this.unsubscribeModels = this.host.dynamicData.observeModelsResult(
(result) => {
this.receiveObservedModelsResult(result);
},
{ emitCurrent: false },
);
this.unsubscribeArchivedThreads = this.host.threadCatalog.observeArchived(
this.unsubscribeArchivedThreads = this.host.dynamicData.observeArchivedThreadsResult(
(result) => {
this.receiveObservedArchivedThreadsResult(result);
},
@ -112,7 +96,7 @@ export class SettingsDynamicSectionsController {
this.modelsOperationToken += 1;
this.hooksOperationToken += 1;
this.archivedThreadsOperationToken += 1;
this.models = [...(this.host.appServerQueries.modelsSnapshot() ?? [])];
this.models = [...(this.host.dynamicData.modelsSnapshot() ?? [])];
this.modelsLifecycle = createSettingsDynamicSectionLifecycle();
this.hooks = [];
this.hookWarnings = [];
@ -179,9 +163,9 @@ export class SettingsDynamicSectionsController {
let failedCount = 0;
try {
const [modelsResult, hooksResult, archivedThreadsResult] = await Promise.allSettled([
options.forceModels === false ? this.host.appServerQueries.fetchModels() : this.host.appServerQueries.refreshModels(),
this.withSettingsConnection((client) => loadHookCatalog(client, this.host.vaultPath)),
this.host.threadCatalog.refreshArchived(),
options.forceModels === false ? this.host.dynamicData.fetchModels() : this.host.dynamicData.refreshModels(),
this.host.dynamicData.loadHooks(),
this.host.dynamicData.refreshArchivedThreads(),
] as const);
if (this.isStaleDynamicSectionsRefreshOperation(operationToken)) return;
@ -194,7 +178,7 @@ export class SettingsDynamicSectionsController {
status: `Loaded ${String(modelsResult.value.length)} model${modelsResult.value.length === 1 ? "" : "s"}.`,
operationToken: modelsOperationToken,
});
} else if (isStaleAppServerSharedQueryContextError(modelsResult.reason)) {
} else if (isStaleSettingsDynamicDataContextError(modelsResult.reason)) {
return;
} else {
failedCount += 1;
@ -208,9 +192,9 @@ export class SettingsDynamicSectionsController {
if (this.isStaleHooksOperation(hooksOperationToken)) {
// A newer hooks operation owns this section.
} else if (hooksResult.status === "fulfilled") {
this.hooks = hooksResult.value.hooks;
this.hookWarnings = hooksResult.value.warnings;
this.hookErrors = hooksResult.value.errors;
this.hooks = [...hooksResult.value.hooks];
this.hookWarnings = [...hooksResult.value.warnings];
this.hookErrors = [...hooksResult.value.errors];
this.hooksLoaded = true;
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "loaded",
@ -236,7 +220,7 @@ export class SettingsDynamicSectionsController {
status: archivedThreadsStatus(archivedThreadsResult.value.length),
operationToken: archivedThreadsOperationToken,
});
} else if (isStaleAppServerSharedQueryContextError(archivedThreadsResult.reason)) {
} else if (isStaleSettingsDynamicDataContextError(archivedThreadsResult.reason)) {
return;
} else {
failedCount += 1;
@ -310,7 +294,7 @@ export class SettingsDynamicSectionsController {
failureStatus: (error) => `Could not trust hook: ${errorMessage(error)}`,
failureNotice: "Could not trust Codex hook.",
operation: async (operationToken) => {
await this.withSettingsConnection((client) => trustHookItem(client, hook));
await this.host.dynamicData.trustHook(hook);
if (this.isStaleHooksOperation(operationToken)) return;
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "loaded",
@ -329,7 +313,7 @@ export class SettingsDynamicSectionsController {
failureStatus: (error) => `Could not update hook: ${errorMessage(error)}`,
failureNotice: "Could not update Codex hook.",
operation: async (operationToken) => {
await this.withSettingsConnection((client) => setHookItemEnabled(client, hook, enabled));
await this.host.dynamicData.setHookEnabled(hook, enabled);
if (this.isStaleHooksOperation(operationToken)) return;
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "loaded",
@ -348,10 +332,11 @@ export class SettingsDynamicSectionsController {
failureStatus: (error) => `Could not restore archived thread: ${errorMessage(error)}`,
failureNotice: "Could not restore archived Codex thread.",
operation: async (operationToken) => {
const restoredThread = await this.withSettingsConnection((client) => restoreArchivedThreadOnAppServer(client, threadId));
const restoredThread = await this.host.dynamicData.restoreArchivedThread(threadId, {
shouldPublish: () => !this.isStaleArchivedThreadsOperation(operationToken),
});
if (this.isStaleArchivedThreadsOperation(operationToken)) return;
this.archivedThreads = this.archivedThreads.filter((thread) => thread.id !== threadId);
this.host.threadCatalog.apply({ type: "thread-restored", thread: restoredThread });
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "loaded",
status: `Restored "${threadArchiveDisplayTitle(restoredThread)}".`,
@ -370,10 +355,11 @@ export class SettingsDynamicSectionsController {
failureStatus: (error) => `Could not delete archived thread: ${errorMessage(error)}`,
failureNotice: "Could not delete archived Codex thread.",
operation: async (operationToken) => {
await this.withSettingsConnection((client) => client.deleteThread(threadId));
await this.host.dynamicData.deleteArchivedThread(threadId, {
shouldPublish: () => !this.isStaleArchivedThreadsOperation(operationToken),
});
if (this.isStaleArchivedThreadsOperation(operationToken)) return;
this.archivedThreads = this.archivedThreads.filter((thread) => thread.id !== threadId);
this.host.threadCatalog.apply({ type: "thread-deleted", threadId });
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "loaded",
status: `Deleted "${title}".`,
@ -407,11 +393,11 @@ export class SettingsDynamicSectionsController {
failureStatus: (error) => `Could not load hooks: ${errorMessage(error)}`,
failureNotice: "Could not load Codex hooks.",
operation: async (operationToken) => {
const hooks = await this.withSettingsConnection((client) => loadHookCatalog(client, this.host.vaultPath));
const hooks = await this.host.dynamicData.loadHooks();
if (this.isStaleHooksOperation(operationToken)) return;
this.hooks = hooks.hooks;
this.hookWarnings = hooks.warnings;
this.hookErrors = hooks.errors;
this.hooks = [...hooks.hooks];
this.hookWarnings = [...hooks.warnings];
this.hookErrors = [...hooks.errors];
this.hooksLoaded = true;
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "loaded",
@ -467,12 +453,6 @@ export class SettingsDynamicSectionsController {
}
}
private async withSettingsConnection<T>(operation: (client: AppServerClient) => Promise<T>): Promise<T> {
return this.host.clientAccess.withClient(operation, {
serverRequests: { kind: "reject", message: "Codex Panel settings does not handle server requests." },
});
}
private nextDynamicSectionsRefreshOperationToken(): number {
this.dynamicSectionsRefreshOperationToken += 1;
return this.dynamicSectionsRefreshOperationToken;

View file

@ -1,23 +1,9 @@
import type { AppServerClientAccess } from "../app-server/connection/client-access";
import type { ThreadCatalogArchivedReader, ThreadCatalogEventSink } from "../app-server/query/thread-catalog";
import type { ModelMetadata } from "../domain/catalog/metadata";
import type { ObservedResultListener } from "../shared/query/observed-result";
import type { SettingsDynamicDataAccess } from "./dynamic-data";
import type { CodexPanelSettings } from "./model";
interface SettingsAppServerQueries {
modelsSnapshot(): readonly ModelMetadata[] | null;
observeModelsResult(listener: ObservedResultListener<readonly ModelMetadata[]>, options?: { emitCurrent?: boolean }): () => void;
fetchModels(): Promise<readonly ModelMetadata[]>;
refreshModels(): Promise<readonly ModelMetadata[]>;
notifyContextChanged(): void;
}
export interface SettingsDynamicSectionsHost {
settings: CodexPanelSettings;
vaultPath: string;
clientAccess: AppServerClientAccess;
appServerQueries: SettingsAppServerQueries;
threadCatalog: ThreadCatalogArchivedReader & ThreadCatalogEventSink;
dynamicData: SettingsDynamicDataAccess;
}
export interface CodexPanelSettingTabHost extends SettingsDynamicSectionsHost {

View file

@ -176,7 +176,7 @@ export class CodexPanelSettingTab extends PluginSettingTab {
this.plugin.settings.codexPath = codexPath;
await this.plugin.saveSettings();
this.dynamicSections.resetDynamicSectionContext();
this.plugin.appServerQueries.notifyContextChanged();
this.plugin.dynamicData.notifyContextChanged();
this.plugin.refreshOpenViews();
this.renderSettingsShell();
}

View file

@ -21,6 +21,8 @@ const APP_SERVER_ROOT_MODULE_IMPORT_MESSAGE =
"Import app-server boundary modules from responsibility subfolders, not src/app-server root modules.";
const APP_SERVER_SUBFOLDER_ROOT_IMPORT_MESSAGE =
"App-server subfolders must not import sibling root modules; move the dependency into a responsibility subfolder.";
const SETTINGS_APP_SERVER_BOUNDARY_MESSAGE =
"Settings modules must depend on settings-owned dynamic data ports instead of importing app-server modules or settings app-server adapters.";
const CHAT_APPLICATION_OUTER_LAYER_MESSAGE =
"Chat application modules must not import app-server, host, panel, presentation, or UI layers; expose state and workflow contracts instead.";
const CHAT_APP_SERVER_OUTER_LAYER_MESSAGE = "Chat app-server adapters must not import chat host, panel, presentation, or UI layers.";
@ -826,6 +828,14 @@ export const value = statusText;
]);
});
it("keeps settings app-server access behind settings app-server adapters", async () => {
const report = await appServerBoundaryPolicyReport();
expect(pluginMessages(report, "src/settings/dynamic-sections-controller.ts")).toEqual([SETTINGS_APP_SERVER_BOUNDARY_MESSAGE]);
expect(pluginMessages(report, "src/settings/adapter-leak.ts")).toEqual([SETTINGS_APP_SERVER_BOUNDARY_MESSAGE]);
expect(pluginDiagnostics(report, "src/settings/app-server/dynamic-data.ts")).toEqual([]);
});
it("keeps app-server root from becoming a boundary escape hatch", async () => {
const cwd = await tempBiomeWorkspace([
"no-responsibility-root-module-files.grit",
@ -1056,6 +1066,7 @@ async function createAppServerBoundaryPolicyReport() {
"no-lower-level-feature-imports.grit",
"no-app-server-connection-boundary-imports.grit",
"no-app-server-projection-rpcs.grit",
"no-settings-app-server-boundary-imports.grit",
]);
await writeFile(
path.join(cwd, "src/features/chat/domain/generated-thread.ts"),
@ -1317,6 +1328,30 @@ export async function read(client: AppServerClient): Promise<void> {
import type { AppServerClient } from "../../../app-server/connection/client";
export type AppServerThreadResumeClient = Pick<AppServerClient, "resumeThread">;
`.trimStart(),
);
await writeFile(
path.join(cwd, "src/settings/dynamic-sections-controller.ts"),
`
import { listHookCatalog } from "../app-server/services/catalog";
export const load = listHookCatalog;
`.trimStart(),
);
await writeFile(
path.join(cwd, "src/settings/adapter-leak.ts"),
`
import { createSettingsAppServerDynamicData } from "./app-server/dynamic-data";
export const create = createSettingsAppServerDynamicData;
`.trimStart(),
);
await writeFile(
path.join(cwd, "src/settings/app-server/dynamic-data.ts"),
`
import { listHookCatalog } from "../../app-server/services/catalog";
export const load = listHookCatalog;
`.trimStart(),
);
@ -1352,6 +1387,9 @@ export type AppServerThreadResumeClient = Pick<AppServerClient, "resumeThread">;
"src/features/chat/application/threads/history.ts",
"src/app-server/services/threads.ts",
"src/features/chat/host/connection-bundle.ts",
"src/settings/dynamic-sections-controller.ts",
"src/settings/adapter-leak.ts",
"src/settings/app-server/dynamic-data.ts",
],
cwd,
);
@ -1377,6 +1415,7 @@ async function tempBiomeWorkspace(plugins) {
await mkdir(path.join(cwd, "src/features/threads-view"), { recursive: true });
await mkdir(path.join(cwd, "src/features/turn-diff"), { recursive: true });
await mkdir(path.join(cwd, "src/settings"), { recursive: true });
await mkdir(path.join(cwd, "src/settings/app-server"), { recursive: true });
await mkdir(path.join(cwd, "src/domain/threads"), { recursive: true });
await mkdir(path.join(cwd, "src/app-server/connection"), { recursive: true });
await mkdir(path.join(cwd, "src/app-server/protocol"), { recursive: true });

View file

@ -1,12 +1,16 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../src/app-server/connection/client";
import type { AppServerClientAccessOptions } from "../../src/app-server/connection/client-access";
import type { CatalogHookMetadata, CatalogModel } from "../../src/app-server/protocol/catalog";
import { modelMetadataFromCatalogModels } from "../../src/app-server/protocol/catalog";
import type { ThreadRecord } from "../../src/app-server/protocol/thread";
import type { ThreadCatalogEvent } from "../../src/app-server/query/thread-catalog";
import type { ModelMetadata, ReasoningEffort } from "../../src/domain/catalog/metadata";
import type { Thread } from "../../src/domain/threads/model";
import { createSettingsAppServerDynamicData } from "../../src/settings/app-server/dynamic-data";
import type { SettingsDynamicDataAccess } from "../../src/settings/dynamic-data";
import { SettingsDynamicSectionsController, type SettingsDynamicSectionsSnapshot } from "../../src/settings/dynamic-sections-controller";
import type { CodexPanelSettingTabHost } from "../../src/settings/host";
import { CodexPanelSettingTab } from "../../src/settings/tab.obsidian";
@ -952,14 +956,15 @@ function newSettingsTab(
modelsSnapshot?: ModelMetadata[];
fetchModels?: () => Promise<readonly ModelMetadata[]>;
refreshModels?: () => Promise<readonly ModelMetadata[]>;
observeModels?: CodexPanelSettingTabHost["appServerQueries"]["observeModelsResult"];
observeModels?: SettingsDynamicDataAccess["observeModelsResult"];
notifyContextChanged?: () => void;
refreshOpenViews?: () => void;
archivedThreads?: Thread[];
archivedSnapshot?: Thread[] | null;
refreshArchived?: () => Promise<readonly Thread[]>;
observeArchived?: CodexPanelSettingTabHost["threadCatalog"]["observeArchived"];
applyThreadCatalogEvent?: CodexPanelSettingTabHost["threadCatalog"]["apply"];
observeArchived?: SettingsDynamicDataAccess["observeArchivedThreadsResult"];
applyThreadCatalogEvent?: (event: ThreadCatalogEvent) => void;
dynamicData?: SettingsDynamicDataAccess;
settings?: Partial<{
threadNamingModel: string | null;
threadNamingEffort: string | null;
@ -978,14 +983,15 @@ function settingsTabHost(
modelsSnapshot?: ModelMetadata[];
fetchModels?: () => Promise<readonly ModelMetadata[]>;
refreshModels?: () => Promise<readonly ModelMetadata[]>;
observeModels?: CodexPanelSettingTabHost["appServerQueries"]["observeModelsResult"];
observeModels?: SettingsDynamicDataAccess["observeModelsResult"];
notifyContextChanged?: () => void;
refreshOpenViews?: () => void;
archivedThreads?: Thread[];
archivedSnapshot?: Thread[] | null;
refreshArchived?: () => Promise<readonly Thread[]>;
observeArchived?: CodexPanelSettingTabHost["threadCatalog"]["observeArchived"];
applyThreadCatalogEvent?: CodexPanelSettingTabHost["threadCatalog"]["apply"];
observeArchived?: SettingsDynamicDataAccess["observeArchivedThreadsResult"];
applyThreadCatalogEvent?: (event: ThreadCatalogEvent) => void;
dynamicData?: SettingsDynamicDataAccess;
settings?: Partial<{
threadNamingModel: string | null;
threadNamingEffort: string | null;
@ -1009,29 +1015,35 @@ function settingsTabHost(
archiveExportFilenameTemplate: "{{date}} {{time}} {{title}} {{shortId}}.md",
archiveExportTags: "",
};
const appServerQueries = {
modelsSnapshot: vi.fn(() => options.modelsSnapshot ?? []),
fetchModels: options.fetchModels ?? vi.fn().mockResolvedValue(options.modelsSnapshot ?? []),
refreshModels: options.refreshModels ?? vi.fn().mockResolvedValue(options.modelsSnapshot ?? []),
observeModelsResult: options.observeModels ?? vi.fn(() => () => undefined),
notifyContextChanged: options.notifyContextChanged ?? vi.fn(),
};
const threadCatalog = {
archivedSnapshot: vi.fn(() => options.archivedSnapshot ?? null),
loadArchived: vi.fn().mockResolvedValue(options.archivedThreads ?? defaultArchivedThreads),
refreshArchived: options.refreshArchived ?? vi.fn().mockResolvedValue(options.archivedThreads ?? defaultArchivedThreads),
observeArchived: options.observeArchived ?? vi.fn(() => () => undefined),
apply: options.applyThreadCatalogEvent ?? vi.fn(),
};
return {
settings,
vaultPath: "/vault",
clientAccess: {
withClient: <T>(operation: (client: never) => Promise<T>, clientOptions?: AppServerClientAccessOptions) =>
withShortLivedAppServerClientMock(settings.codexPath, "/vault", operation, clientOptions) as Promise<T>,
},
dynamicData:
options.dynamicData ??
createSettingsAppServerDynamicData({
vaultPath: "/vault",
clientAccess: {
withClient: <T>(operation: (client: AppServerClient) => Promise<T>, clientOptions?: AppServerClientAccessOptions) =>
withShortLivedAppServerClientMock(settings.codexPath, "/vault", operation, clientOptions) as Promise<T>,
},
appServerQueries,
threadCatalog,
}),
saveSettings: options.saveSettings ?? vi.fn().mockResolvedValue(undefined),
refreshOpenViews: options.refreshOpenViews ?? vi.fn(),
appServerQueries: {
modelsSnapshot: vi.fn(() => options.modelsSnapshot ?? []),
fetchModels: options.fetchModels ?? vi.fn().mockResolvedValue(options.modelsSnapshot ?? []),
refreshModels: options.refreshModels ?? vi.fn().mockResolvedValue(options.modelsSnapshot ?? []),
observeModelsResult: options.observeModels ?? vi.fn(() => () => undefined),
notifyContextChanged: options.notifyContextChanged ?? vi.fn(),
},
threadCatalog: {
archivedSnapshot: vi.fn(() => options.archivedSnapshot ?? null),
loadArchived: vi.fn().mockResolvedValue(options.archivedThreads ?? defaultArchivedThreads),
refreshArchived: options.refreshArchived ?? vi.fn().mockResolvedValue(options.archivedThreads ?? defaultArchivedThreads),
observeArchived: options.observeArchived ?? vi.fn(() => () => undefined),
apply: options.applyThreadCatalogEvent ?? vi.fn(),
},
};
}