refactor(runtime): simplify disposable runtime boundaries

This commit is contained in:
murashit 2026-07-20 23:26:14 +09:00
parent 1f5aa50862
commit ffcc814ba5
19 changed files with 153 additions and 118 deletions

View file

@ -81,15 +81,12 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
});
}
chatHost(): CodexChatHost {
private chatHost(): CodexChatHost {
this.assertActive();
return {
appServerClientAccess: this,
appServerContext: this.context,
settingsRef: {
settings: this.chatSettings(),
vaultPath: this.context.vaultPath,
},
settings: this.chatSettings(),
workspace: this.options.workspace,
appServerQueries: this.resourceStore,
threadCatalog: this.threadCatalog,
@ -100,7 +97,7 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
};
}
threadsHost(): ThreadsViewHost {
private threadsHost(): ThreadsViewHost {
this.assertActive();
return {
settings: this.threadsSettings(),
@ -362,21 +359,15 @@ export class CodexExecutionRuntime implements AppServerClientAccess {
archiveExportFilenameTemplate: this.options.settings().archiveExportFilenameTemplate,
archiveExportTags: this.options.settings().archiveExportTags,
}),
codexPath: () => this.context.codexPath,
scrollThreadFromComposerEdges: () => this.options.settings().scrollThreadFromComposerEdges,
sendShortcut: () => this.options.settings().sendShortcut,
showToolbar: () => this.options.settings().showToolbar,
threadNamingEffort: () => this.options.settings().threadNamingEffort,
threadNamingModel: () => this.options.settings().threadNamingModel,
};
}
private threadsSettings(): ThreadsViewSettingsAccess {
return {
archiveExportEnabled: () => this.options.settings().archiveExportEnabled,
codexPath: () => this.context.codexPath,
threadNamingModel: () => this.options.settings().threadNamingModel,
threadNamingEffort: () => this.options.settings().threadNamingEffort,
archiveExportSettings: () => ({
archiveExportFolderTemplate: this.options.settings().archiveExportFolderTemplate,
archiveExportFilenameTemplate: this.options.settings().archiveExportFilenameTemplate,

View file

@ -31,14 +31,14 @@ export function createChatComposerController(
contextReferenceProvider: new VaultComposerContextReferenceProvider(environment.obsidian.app),
attachmentHandler: createVaultComposerAttachmentHandler({
app: environment.obsidian.app,
attachmentFolder: () => environment.plugin.settingsRef.settings.attachmentFolder(),
attachmentFolder: () => environment.plugin.settings.attachmentFolder(),
}),
sourcePath: () => environment.obsidian.app.workspace.getActiveFile()?.path ?? "",
stateStore,
viewId: environment.obsidian.viewId,
referenceActiveNoteOnSend: () => environment.plugin.settingsRef.settings.referenceActiveNoteOnSend(),
sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut(),
scrollThreadFromComposerEdges: () => environment.plugin.settingsRef.settings.scrollThreadFromComposerEdges(),
referenceActiveNoteOnSend: () => environment.plugin.settings.referenceActiveNoteOnSend(),
sendShortcut: () => environment.plugin.settings.sendShortcut(),
scrollThreadFromComposerEdges: () => environment.plugin.settings.scrollThreadFromComposerEdges(),
canInterrupt: (model) => {
return model.turnBusy && Boolean(model.activeThreadId && model.activeTurnId);
},

View file

@ -222,7 +222,7 @@ export function createConnectionBundle(
},
setStatus: status.set,
addSystemMessage: status.addSystemMessage,
configuredCommand: () => environment.plugin.settingsRef.settings.codexPath(),
configuredCommand: () => environment.plugin.appServerContext.codexPath,
isStaleConnectionError: (error) => error instanceof StaleConnectionError,
isStaleResourceContextError: isStaleAppServerResourceContextError,
notifyConnectionFailed: () => {

View file

@ -48,8 +48,8 @@ export function createRuntimeBundle(
projection: createChatPanelRuntimeProjection({
state: () => host.stateStore.getState(),
connected: () => input.connection.isConnected(),
configuredCommand: () => host.environment.plugin.settingsRef.settings.codexPath(),
vaultPath: () => host.environment.plugin.settingsRef.vaultPath,
configuredCommand: () => host.environment.plugin.appServerContext.codexPath,
vaultPath: () => host.environment.plugin.appServerContext.vaultPath,
nowMs: () => Date.now(),
}),
};

View file

@ -89,13 +89,13 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan
nowMs: () => Date.now(),
},
settings: {
vaultPath: () => environment.plugin.settingsRef.vaultPath,
configuredCommand: () => environment.plugin.settingsRef.settings.codexPath(),
archiveExportEnabled: () => environment.plugin.settingsRef.settings.archiveExportEnabled(),
vaultPath: () => environment.plugin.appServerContext.vaultPath,
configuredCommand: () => environment.plugin.appServerContext.codexPath,
archiveExportEnabled: () => environment.plugin.settings.archiveExportEnabled(),
},
};
const goalSurface: ChatPanelGoalSurface = {
sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut(),
sendShortcut: () => environment.plugin.settings.sendShortcut(),
actions: goals,
};
const threadStreamContext = createChatThreadStreamSurfaceContext({
@ -103,7 +103,7 @@ export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPan
app: environment.obsidian.app,
owner: environment.obsidian.owner,
stateStore,
vaultPath: environment.plugin.settingsRef.vaultPath,
vaultPath: environment.plugin.appServerContext.vaultPath,
loadOlderTurns: () => void history.loadOlder(),
actions: {
rollbackThread: (threadId) => void threadActions.rollbackThread(threadId),

View file

@ -125,9 +125,9 @@ export function createThreadFoundation(host: ChatPanelThreadHost, input: ChatPan
transport: threadOperationsTransport,
nameMutations: environment.plugin.threadNameMutations,
archiveExport: {
settings: () => environment.plugin.settingsRef.settings.archiveExportSettings(),
enabled: () => environment.plugin.settingsRef.settings.archiveExportEnabled(),
vaultPath: environment.plugin.settingsRef.vaultPath,
settings: () => environment.plugin.settings.archiveExportSettings(),
enabled: () => environment.plugin.settings.archiveExportEnabled(),
vaultPath: environment.plugin.appServerContext.vaultPath,
vaultConfigDir: environment.obsidian.app.vault.configDir,
},
archiveDestination: environment.obsidian.archiveDestination,

View file

@ -3,7 +3,7 @@ import type { App, Component, EventRef } from "obsidian";
import type { AppServerClientAccess } from "../../../app-server/connection/client-access";
import type { AppServerQueryContext } from "../../../app-server/query/keys";
import type { ObservedResultListener } from "../../../app-server/query/observed-result";
import type { ModelMetadata, ReasoningEffort } from "../../../domain/catalog/metadata";
import type { ModelMetadata } from "../../../domain/catalog/metadata";
import type { SendShortcut } from "../../../domain/input/send-shortcut";
import type { SharedServerMetadata, SharedServerMetadataResource } from "../../../domain/server/metadata";
import type { ArchiveExportSettings } from "../../../domain/threads/archive-markdown";
@ -18,7 +18,7 @@ import type { ThreadGoalOperationCoordinator } from "../application/threads/goal
export interface CodexChatHost {
readonly appServerClientAccess: AppServerClientAccess;
readonly appServerContext: Readonly<AppServerQueryContext>;
readonly settingsRef: ChatPanelSettingsRef;
readonly settings: ChatPanelSettingsAccess;
readonly workspace: WorkspacePanels;
readonly appServerQueries: ChatAppServerQueries;
readonly threadCatalog: ChatThreadCatalog;
@ -28,22 +28,14 @@ export interface CodexChatHost {
readonly runtimeSettingsCommitQueue: KeyedOperationQueue<string>;
}
interface ChatPanelSettingsRef {
readonly settings: ChatPanelSettingsAccess;
readonly vaultPath: string;
}
export interface ChatPanelSettingsAccess {
referenceActiveNoteOnSend(): boolean;
attachmentFolder(): string;
archiveExportEnabled(): boolean;
archiveExportSettings(): ArchiveExportSettings;
codexPath(): string;
scrollThreadFromComposerEdges(): boolean;
sendShortcut(): SendShortcut;
showToolbar(): boolean;
threadNamingEffort(): ReasoningEffort | null;
threadNamingModel(): string | null;
}
export interface WorkspacePanels {

View file

@ -214,7 +214,7 @@ export class ChatPanelSession implements ChatPanelHandle {
if (!root) return;
renderChatPanelShell(root, {
stateStore: this.stateStore,
showToolbar: this.environment.plugin.settingsRef.settings.showToolbar(),
showToolbar: this.environment.plugin.settings.showToolbar(),
parts: this.runtime.shell.parts,
});
}

View file

@ -3,7 +3,6 @@ import { Notice } from "obsidian";
import type { ObservedPaginatedResult } from "../../app-server/query/observed-result";
import { observedInitialError, observedInitialLoading } from "../../app-server/query/observed-result";
import { isStaleAppServerResourceContextError } from "../../app-server/query/resource-store";
import type { ReasoningEffort } from "../../domain/catalog/metadata";
import type { ArchiveExportSettings } from "../../domain/threads/archive-markdown";
import type { Thread } from "../../domain/threads/model";
import type { ThreadRenameLifecycleEvent } from "../../domain/threads/rename-lifecycle";
@ -33,9 +32,6 @@ type ThreadsViewThreadCatalog = ThreadCatalogPaginatedActiveReader & ThreadCatal
export interface ThreadsViewSettingsAccess {
archiveExportEnabled(): boolean;
codexPath(): string;
threadNamingModel(): string | null;
threadNamingEffort(): ReasoningEffort | null;
archiveExportSettings(): ArchiveExportSettings;
}

View file

@ -1,6 +1,4 @@
import type { App } from "obsidian";
import type { AppServerClient } from "./app-server/connection/client";
import type { AppServerClientAccessOptions } from "./app-server/connection/client-access";
import { VIEW_TYPE_CODEX_THREADS, VIEW_TYPE_CODEX_TURN_DIFF } from "./constants";
import { CodexExecutionRuntime } from "./execution-runtime";
import type {
@ -8,12 +6,10 @@ import type {
ChatSharedThreadSurface,
ChatViewLifecycleSurface,
ChatViewRuntimeOwner,
CodexChatHost,
} from "./features/chat/host/contracts";
import type { SelectionRewriteCommandController } from "./features/selection-rewrite/command.obsidian";
import type { SelectionRewriteTransport } from "./features/selection-rewrite/transport";
import type { ThreadCatalogEvent } from "./features/threads/catalog/thread-catalog";
import type { ThreadsViewHost } from "./features/threads-view/session";
import type { ThreadsViewPanelActivity } from "./features/threads-view/state";
import { CodexThreadsView, type ThreadsRuntimeView, type ThreadsViewRuntimeOwner } from "./features/threads-view/view.obsidian";
import { persistedTurnDiffViewState, type TurnDiffViewState } from "./features/turn-diff/model";
@ -124,18 +120,6 @@ export class CodexPanelRuntime implements ChatViewRuntimeOwner, ThreadsViewRunti
};
}
withClient<T>(operation: (client: AppServerClient) => Promise<T>, options?: AppServerClientAccessOptions): Promise<T> {
return this.currentExecutionRuntime().withClient(operation, options);
}
chatHost(): CodexChatHost {
return this.currentExecutionRuntime().chatHost();
}
threadsHost(): ThreadsViewHost {
return this.currentExecutionRuntime().threadsHost();
}
settingTabHost(): CodexPanelSettingTabHost {
return {
settings: this.options.settingsRef.settings,
@ -163,7 +147,10 @@ export class CodexPanelRuntime implements ChatViewRuntimeOwner, ThreadsViewRunti
} else {
Object.assign(this.options.settingsRef.settings, settings);
}
if (appServerContextReplaced || previousSettings.showToolbar !== settings.showToolbar) this.refreshOpenViews();
if (previousSettings.showToolbar !== settings.showToolbar || previousSettings.archiveExportEnabled !== settings.archiveExportEnabled) {
this.refreshChatViewSettings();
}
if (previousSettings.archiveExportEnabled !== settings.archiveExportEnabled) this.refreshThreadsViewSettings();
return { replacementDynamicData };
}
@ -178,11 +165,14 @@ export class CodexPanelRuntime implements ChatViewRuntimeOwner, ThreadsViewRunti
await this.options.app.workspace.revealLeaf(leaf);
}
private refreshOpenViews(): void {
private refreshChatViewSettings(): void {
for (const view of this.panels.panelViews()) {
const surface: ChatViewLifecycleSurface = view.surface;
surface.refreshSettings();
}
}
private refreshThreadsViewSettings(): void {
for (const view of this.threadsViews()) view.refreshSettings();
}

View file

@ -82,11 +82,7 @@ function connectionBundleFixture(overrides: { readServerDiagnostics?: ReturnType
refreshActive: vi.fn().mockResolvedValue(undefined),
apply: vi.fn(),
},
settingsRef: {
settings: {
codexPath: () => "codex",
},
},
appServerContext: { codexPath: "codex", vaultPath: "/vault" },
},
},
stateStore,

View file

@ -217,7 +217,8 @@ describe("ChatPanelSessionRuntime actions", () => {
workspace?: Partial<ChatPanelEnvironment["plugin"]["workspace"]>;
threadCatalog?: Partial<ChatPanelEnvironment["plugin"]["threadCatalog"]>;
appServerQueries?: Partial<ChatPanelEnvironment["plugin"]["appServerQueries"]>;
settingsRef?: Partial<ChatPanelEnvironment["plugin"]["settingsRef"]>;
settings?: ChatPanelEnvironment["plugin"]["settings"];
appServerContext?: ChatPanelEnvironment["plugin"]["appServerContext"];
};
view?: Partial<ChatPanelEnvironment["view"]>;
}
@ -225,7 +226,6 @@ describe("ChatPanelSessionRuntime actions", () => {
function chatPanelEnvironmentFixture(overrides: PartialChatPanelEnvironment = {}): ChatPanelEnvironment {
const threadCatalog = threadCatalogFixture(overrides.plugin?.threadCatalog);
const appServerQueries = appServerQueriesFixture(overrides.plugin?.appServerQueries);
const settingsRef = overrides.plugin?.settingsRef;
const settingsSource: CodexPanelSettings = {
...DEFAULT_SETTINGS,
codexPath: "codex",
@ -269,15 +269,12 @@ describe("ChatPanelSessionRuntime actions", () => {
appServerClientAccess: {
withClient: vi.fn(() => Promise.reject(new Error("Unexpected fallback app-server client request."))),
},
appServerContext: { codexPath: "codex", vaultPath: "/vault" },
appServerContext: overrides.plugin?.appServerContext ?? { codexPath: "codex", vaultPath: "/vault" },
threadTitleTransport: {
persistedContext: vi.fn().mockResolvedValue(null),
generateTitle: vi.fn().mockResolvedValue(null),
},
settingsRef: {
settings: settingsRef?.settings ?? chatPanelSettingsAccess(settingsSource),
vaultPath: settingsRef?.vaultPath ?? "/vault",
},
settings: overrides.plugin?.settings ?? chatPanelSettingsAccess(settingsSource),
workspace: {
openThreadInNewView: vi.fn().mockResolvedValue(undefined),
threadHasPendingOrRunningPanel: vi.fn(() => false),

View file

@ -564,10 +564,7 @@ export function chatHost(overrides: ChatHostFixtureOverrides = {}): TestCodexCha
},
threadGoalOperations: createThreadGoalOperationCoordinator(),
runtimeSettingsCommitQueue: createKeyedOperationQueue(),
settingsRef: {
settings: chatPanelSettingsAccess(settings),
vaultPath,
},
settings: chatPanelSettingsAccess(settings),
workspace: {
openThreadInNewView: overrides.openThreadInNewView ?? vi.fn(),
openThreadFromPanel: overrides.openThreadFromPanel ?? vi.fn(),

View file

@ -11,11 +11,8 @@ export function chatPanelSettingsAccess(settings: CodexPanelSettings): ChatPanel
archiveExportFilenameTemplate: settings.archiveExportFilenameTemplate,
archiveExportTags: settings.archiveExportTags,
}),
codexPath: () => settings.codexPath,
scrollThreadFromComposerEdges: () => settings.scrollThreadFromComposerEdges,
sendShortcut: () => settings.sendShortcut,
showToolbar: () => settings.showToolbar,
threadNamingEffort: () => settings.threadNamingEffort,
threadNamingModel: () => settings.threadNamingModel,
};
}

View file

@ -649,9 +649,6 @@ function threadsHost(overrides: Record<string, unknown> = {}) {
return {
settings: {
archiveExportEnabled: () => DEFAULT_SETTINGS.archiveExportEnabled,
codexPath: () => "codex",
threadNamingModel: () => DEFAULT_SETTINGS.threadNamingModel,
threadNamingEffort: () => DEFAULT_SETTINGS.threadNamingEffort,
archiveExportSettings: () => ({
archiveExportFolderTemplate: DEFAULT_SETTINGS.archiveExportFolderTemplate,
archiveExportFilenameTemplate: DEFAULT_SETTINGS.archiveExportFilenameTemplate,

View file

@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { VIEW_TYPE_CODEX_PANEL } from "../src/constants";
import type { Thread } from "../src/domain/threads/model";
import type { ChatRuntimeView, CodexChatHost } from "../src/features/chat/host/contracts";
import type { CodexChatView } from "../src/features/chat/host/view.obsidian";
import type CodexPanelPlugin from "../src/main";
import { deferred } from "./support/async";
@ -30,7 +31,30 @@ vi.mock("../src/app-server/connection/short-lived-client", () => ({
}));
function threadCatalog(plugin: CodexPanelPlugin) {
return plugin.runtime.chatHost().threadCatalog;
return currentChatHost(plugin).threadCatalog;
}
const capturedChatHosts = new WeakMap<CodexPanelPlugin, { current: CodexChatHost | null }>();
function currentChatHost(plugin: CodexPanelPlugin): CodexChatHost {
let capture = capturedChatHosts.get(plugin);
if (!capture) {
const createdCapture = { current: null as CodexChatHost | null };
const view: ChatRuntimeView = {
attachRuntime: (host) => {
createdCapture.current = host;
},
activateRuntime: () => undefined,
detachRuntime: () => {
createdCapture.current = null;
},
};
capturedChatHosts.set(plugin, createdCapture);
capture = createdCapture;
plugin.runtime.attachChatView(view);
}
if (!capture.current) throw new Error("Expected a captured chat runtime host.");
return capture.current;
}
describe("CodexPanelPlugin runtime integration", () => {
@ -174,9 +198,12 @@ describe("CodexPanelPlugin runtime integration", () => {
const plugin = await pluginWithLeaves([]);
plugin.settings.codexPath = "codex";
const result = await plugin.runtime.withClient((client) => client.request("config/read", { cwd: "/vault", includeLayers: true }), {
serverRequests: { kind: "reject", message: "Settings refresh does not handle server requests." },
});
const result = await currentChatHost(plugin).appServerClientAccess.withClient(
(client) => client.request("config/read", { cwd: "/vault", includeLayers: true }),
{
serverRequests: { kind: "reject", message: "Settings refresh does not handle server requests." },
},
);
expect(result).toEqual({});
expect(withShortLivedAppServerClientMock).toHaveBeenCalledWith(
@ -197,7 +224,7 @@ describe("CodexPanelPlugin runtime integration", () => {
const plugin = await pluginWithLeaves([]);
await publishCodexPath(plugin, "codex-a");
const operation = plugin.runtime.withClient(() => Promise.resolve("unused"), {
const operation = currentChatHost(plugin).appServerClientAccess.withClient(() => Promise.resolve("unused"), {
serverRequests: { kind: "reject", message: "test" },
});
await publishCodexPath(plugin, "codex-b");
@ -212,7 +239,7 @@ describe("CodexPanelPlugin runtime integration", () => {
const plugin = await pluginWithLeaves([]);
await publishCodexPath(plugin, "codex-a");
const operation = plugin.runtime.withClient(() => Promise.resolve("unused"), {
const operation = currentChatHost(plugin).appServerClientAccess.withClient(() => Promise.resolve("unused"), {
serverRequests: { kind: "reject", message: "test" },
});
await publishCodexPath(plugin, "codex-b");
@ -220,7 +247,7 @@ describe("CodexPanelPlugin runtime integration", () => {
result.resolve("stale-a");
await expect(operation).rejects.toThrow("Codex app-server resource context changed while loading.");
expect(plugin.runtime.chatHost().appServerContext).toEqual({ codexPath: "codex-a", vaultPath: "/vault" });
expect(currentChatHost(plugin).appServerContext).toEqual({ codexPath: "codex-a", vaultPath: "/vault" });
});
it("does not start a short-lived operation when its app-server context changes while connecting", async () => {
@ -234,7 +261,7 @@ describe("CodexPanelPlugin runtime integration", () => {
await publishCodexPath(plugin, "codex-a");
const callback = vi.fn(() => Promise.resolve("unused"));
const operation = plugin.runtime.withClient(callback, {
const operation = currentChatHost(plugin).appServerClientAccess.withClient(callback, {
serverRequests: { kind: "reject", message: "test" },
});
await publishCodexPath(plugin, "codex-b");
@ -247,7 +274,7 @@ describe("CodexPanelPlugin runtime integration", () => {
it("publishes a persisted context and its replacement runtime atomically", async () => {
const plugin = await pluginWithLeaves([]);
const firstContext = plugin.runtime.chatHost().appServerContext;
const firstContext = currentChatHost(plugin).appServerContext;
const save = deferred<void>();
const saveSettings = vi.spyOn(plugin, "saveSettings").mockReturnValue(save.promise);
@ -256,14 +283,14 @@ describe("CodexPanelPlugin runtime integration", () => {
expect(saveSettings).toHaveBeenCalledWith(expect.objectContaining({ codexPath: "codex-next" }));
expect(plugin.settings.codexPath).toBe(firstContext.codexPath);
expect(plugin.runtime.chatHost().appServerContext).toBe(firstContext);
expect(currentChatHost(plugin).appServerContext).toBe(firstContext);
save.resolve(undefined);
await publication;
expect(plugin.settings.codexPath).toBe("codex-next");
expect(plugin.runtime.chatHost().appServerContext).toEqual({ codexPath: "codex-next", vaultPath: "/vault" });
expect(plugin.runtime.chatHost().appServerContext).not.toBe(firstContext);
expect(currentChatHost(plugin).appServerContext).toEqual({ codexPath: "codex-next", vaultPath: "/vault" });
expect(currentChatHost(plugin).appServerContext).not.toBe(firstContext);
});
it("detaches and reattaches every open Chat view to the new execution runtime", async () => {
@ -280,8 +307,8 @@ describe("CodexPanelPlugin runtime integration", () => {
expect(detachRuntime).toHaveBeenCalledOnce();
expect(attachRuntime).toHaveBeenCalledOnce();
expect(activateRuntime).toHaveBeenCalledOnce();
expect(attachRuntime.mock.calls[0]?.[0].settingsRef.settings.codexPath()).toBe("codex-next");
expect(attachRuntime.mock.calls[0]?.[0].settingsRef.vaultPath).toBe("/vault");
expect(attachRuntime.mock.calls[0]?.[0].appServerContext.codexPath).toBe("codex-next");
expect(attachRuntime.mock.calls[0]?.[0].appServerContext.vaultPath).toBe("/vault");
});
it("detaches and reattaches every open Threads view to the new execution runtime", async () => {
@ -299,7 +326,6 @@ describe("CodexPanelPlugin runtime integration", () => {
expect(attachRuntime).toHaveBeenCalledOnce();
expect(activateRuntime).toHaveBeenCalledOnce();
expect(attachRuntime.mock.calls[0]?.[0].vaultPath).toBe("/vault");
expect(attachRuntime.mock.calls[0]?.[0].settings.codexPath()).toBe("codex-next");
});
it("constructs every replacement session before activating any of them", async () => {
@ -366,7 +392,7 @@ describe("CodexPanelPlugin runtime integration", () => {
expect(broken.detachRuntime).toHaveBeenCalledOnce();
expect(healthy.detachRuntime).toHaveBeenCalledOnce();
expect(healthy.attachRuntime).toHaveBeenCalledOnce();
expect(plugin.runtime.chatHost().appServerContext.codexPath).toBe("codex-next");
expect(currentChatHost(plugin).appServerContext.codexPath).toBe("codex-next");
});
it("keeps activating healthy views when one replacement attachment fails", async () => {
@ -394,7 +420,7 @@ describe("CodexPanelPlugin runtime integration", () => {
expect(broken.detachRuntime).toHaveBeenCalledTimes(2);
expect(broken.activateRuntime).toHaveBeenCalledOnce();
expect(healthy.activateRuntime).toHaveBeenCalledOnce();
expect(plugin.runtime.chatHost().appServerContext.codexPath).toBe("codex-next");
expect(currentChatHost(plugin).appServerContext.codexPath).toBe("codex-next");
});
it("cancels selection rewrites before publishing a new app-server context", async () => {
@ -406,4 +432,54 @@ describe("CodexPanelPlugin runtime integration", () => {
expect(closeAll).toHaveBeenCalledOnce();
});
it("refreshes only chat settings when toolbar visibility changes", async () => {
const { CodexChatView } = await import("../src/features/chat/host/view.obsidian");
const chatLeaf = leaf();
chatLeaf.view = chatView(CodexChatView, chatLeaf);
const refreshChat = vi.spyOn((chatLeaf.view as CodexChatView).surface, "refreshSettings");
const plugin = await pluginWithLeaves([chatLeaf]);
await plugin.runtime.settingTabHost().publishSettings({ ...plugin.settings, showToolbar: !plugin.settings.showToolbar });
expect(refreshChat).toHaveBeenCalledOnce();
});
it("refreshes chat and Threads settings when the archive default changes", async () => {
const { CodexChatView } = await import("../src/features/chat/host/view.obsidian");
const { CodexThreadsView } = await import("../src/features/threads-view/view.obsidian");
const chatLeaf = leaf();
chatLeaf.view = chatView(CodexChatView, chatLeaf);
const refreshChat = vi.spyOn((chatLeaf.view as CodexChatView).surface, "refreshSettings");
const threadsView = Object.create(CodexThreadsView.prototype) as InstanceType<typeof CodexThreadsView>;
const refreshThreads = vi.spyOn(threadsView, "refreshSettings");
const threadsLeaf = leaf();
threadsLeaf.view = threadsView;
const plugin = await pluginWithLeaves([chatLeaf], { threadsLeaves: [threadsLeaf] });
await plugin.runtime
.settingTabHost()
.publishSettings({ ...plugin.settings, archiveExportEnabled: !plugin.settings.archiveExportEnabled });
expect(refreshChat).toHaveBeenCalledOnce();
expect(refreshThreads).toHaveBeenCalledOnce();
});
it("does not redundantly refresh workspace views after runtime replacement", async () => {
const { CodexChatView } = await import("../src/features/chat/host/view.obsidian");
const { CodexThreadsView } = await import("../src/features/threads-view/view.obsidian");
const chatLeaf = leaf();
chatLeaf.view = chatView(CodexChatView, chatLeaf);
const refreshChat = vi.spyOn((chatLeaf.view as CodexChatView).surface, "refreshSettings");
const threadsView = Object.create(CodexThreadsView.prototype) as InstanceType<typeof CodexThreadsView>;
const refreshThreads = vi.spyOn(threadsView, "refreshSettings");
const threadsLeaf = leaf();
threadsLeaf.view = threadsView;
const plugin = await pluginWithLeaves([chatLeaf], { threadsLeaves: [threadsLeaf] });
await publishCodexPath(plugin, "codex-next");
expect(refreshChat).not.toHaveBeenCalled();
expect(refreshThreads).not.toHaveBeenCalled();
});
});

View file

@ -74,8 +74,9 @@ describe("settings tab", () => {
it("routes every declarative control through the existing settings publication boundary", async () => {
const saveSettings = vi.fn().mockResolvedValue(undefined);
const refreshOpenViews = vi.fn();
const tab = newSettingsTab({ saveSettings, refreshOpenViews });
const refreshChatViews = vi.fn();
const refreshThreadsViews = vi.fn();
const tab = newSettingsTab({ saveSettings, refreshChatViews, refreshThreadsViews });
const changes = [
["showToolbar", false],
@ -91,7 +92,8 @@ describe("settings tab", () => {
}
expect(saveSettings).toHaveBeenCalledTimes(changes.length);
expect(refreshOpenViews).toHaveBeenCalledOnce();
expect(refreshChatViews).toHaveBeenCalledTimes(2);
expect(refreshThreadsViews).toHaveBeenCalledOnce();
});
it("ignores invalid declarative control values and rejects unknown keys", async () => {
@ -290,8 +292,8 @@ describe("settings tab", () => {
it("saves the toolbar visibility setting and refreshes open panels", async () => {
const saveSettings = vi.fn().mockResolvedValue(undefined);
const refreshOpenViews = vi.fn();
const tab = newSettingsTab({ saveSettings, refreshOpenViews });
const refreshChatViews = vi.fn();
const tab = newSettingsTab({ saveSettings, refreshChatViews });
tab.display();
const toggle = inputForSetting(tab, "Show chat toolbar");
@ -301,15 +303,15 @@ describe("settings tab", () => {
await flushPromises();
expect(saveSettings).toHaveBeenCalledOnce();
expect(refreshOpenViews).toHaveBeenCalledOnce();
expect(refreshChatViews).toHaveBeenCalledOnce();
expect(settingDesc(tab, "Show chat toolbar")).toContain("toolbar above chat panels");
});
it("finishes a pending settings save without remounting a hidden tab", async () => {
const save = deferred<void>();
const saveSettings = vi.fn(() => save.promise);
const refreshOpenViews = vi.fn();
const tab = newSettingsTab({ saveSettings, refreshOpenViews });
const refreshChatViews = vi.fn();
const tab = newSettingsTab({ saveSettings, refreshChatViews });
tab.display();
const toggle = inputForSetting(tab, "Show chat toolbar");
@ -325,7 +327,7 @@ describe("settings tab", () => {
await flushPromises();
expect(saveSettings).toHaveBeenCalledOnce();
expect(refreshOpenViews).toHaveBeenCalledOnce();
expect(refreshChatViews).toHaveBeenCalledOnce();
expect(tab.containerEl.children).toHaveLength(0);
});
@ -556,7 +558,7 @@ describe("settings tab", () => {
it("clears dynamic sections when the Codex executable changes", async () => {
const saveSettings = vi.fn().mockResolvedValue(undefined);
const refreshOpenViews = vi.fn();
const refreshChatViews = vi.fn();
const oldClient = settingsClient({
models: [model("gpt-old")],
hooks: [hook({ key: "hook-old", command: "old hook", currentHash: "oldhash" })],
@ -572,7 +574,7 @@ describe("settings tab", () => {
.fn()
.mockResolvedValueOnce([panelThread({ id: "thread-old", preview: "Old archived", archived: true })])
.mockResolvedValueOnce([panelThread({ id: "thread-new", preview: "New archived", archived: true })]);
const tab = newSettingsTab({ saveSettings, refreshOpenViews, fetchModels, refreshModels, refreshArchived });
const tab = newSettingsTab({ saveSettings, refreshChatViews, fetchModels, refreshModels, refreshArchived });
tab.display();
await flushPromises();
@ -587,13 +589,13 @@ describe("settings tab", () => {
await flushPromises();
expect(saveSettings).not.toHaveBeenCalled();
expect(refreshOpenViews).not.toHaveBeenCalled();
expect(refreshChatViews).not.toHaveBeenCalled();
codexInput.dispatchEvent(new FocusEvent("blur"));
await flushPromises();
expect(saveSettings).toHaveBeenCalledOnce();
expect(refreshOpenViews).toHaveBeenCalledOnce();
expect(refreshChatViews).not.toHaveBeenCalled();
expect(withShortLivedAppServerClientMock).toHaveBeenCalledTimes(1);
expect(tab.containerEl.textContent).not.toContain("gpt-old");
expect(tab.containerEl.textContent).not.toContain("Old archived");

View file

@ -188,7 +188,8 @@ export interface SettingsTabHostOptions {
fetchModels?: () => Promise<readonly ModelMetadata[]>;
refreshModels?: () => Promise<readonly ModelMetadata[]>;
observeModels?: SettingsDynamicDataAccess["observeModelsResult"];
refreshOpenViews?: () => void;
refreshChatViews?: () => void;
refreshThreadsViews?: () => void;
archivedThreads?: Thread[];
archivedSnapshot?: Thread[] | null;
refreshArchived?: () => Promise<readonly Thread[]>;
@ -261,7 +262,13 @@ export function settingsTabHost(options: SettingsTabHostOptions = {}): CodexPane
if (appServerContextReplaced && !options.dynamicData) {
dynamicData = createDynamicData();
}
if (appServerContextReplaced || previousSettings.showToolbar !== nextSettings.showToolbar) options.refreshOpenViews?.();
if (
previousSettings.showToolbar !== nextSettings.showToolbar ||
previousSettings.archiveExportEnabled !== nextSettings.archiveExportEnabled
) {
options.refreshChatViews?.();
}
if (previousSettings.archiveExportEnabled !== nextSettings.archiveExportEnabled) options.refreshThreadsViews?.();
return { replacementDynamicData: appServerContextReplaced ? dynamicData : null };
},
};

View file

@ -136,10 +136,7 @@ function chatHostFixture(): CodexChatHost {
},
threadGoalOperations: createThreadGoalOperationCoordinator(),
runtimeSettingsCommitQueue: createKeyedOperationQueue(),
settingsRef: {
settings: chatPanelSettingsAccess(settings),
vaultPath: "/vault",
},
settings: chatPanelSettingsAccess(settings),
workspace: {
openThreadInNewView: vi.fn(),
openThreadFromPanel: vi.fn(),