From 35f8469a53ec2dabcd7ba4d37975043cf9ac82a1 Mon Sep 17 00:00:00 2001 From: murashit Date: Tue, 26 May 2026 09:19:48 +0900 Subject: [PATCH] Centralize shared app-server state --- src/features/chat/chat-controller.ts | 4 + src/features/chat/chat-session-controller.ts | 126 +++++++++++++++---- src/features/chat/view.ts | 65 +++++++--- src/features/threads-view/view.ts | 13 +- src/main.ts | 99 +++++++++++---- src/runtime/shared-app-server-state.ts | 54 ++++++++ src/settings/tab.ts | 4 +- tests/features/chat/chat-controller.test.ts | 9 +- tests/features/chat/view-connection.test.ts | 69 ++++++++-- tests/features/threads-view/view.test.ts | 11 +- tests/main.test.ts | 77 ++++++++++-- tests/settings/settings-tab.test.ts | 31 ++++- 12 files changed, 464 insertions(+), 98 deletions(-) create mode 100644 src/runtime/shared-app-server-state.ts diff --git a/src/features/chat/chat-controller.ts b/src/features/chat/chat-controller.ts index f539d2e5..cea638bb 100644 --- a/src/features/chat/chat-controller.ts +++ b/src/features/chat/chat-controller.ts @@ -36,6 +36,7 @@ import { clearUserInputDrafts, createApprovalResultItem, createUserInputResultIt export interface ChatControllerActions { refreshThreads: () => void; refreshSkills: (forceReload?: boolean) => void; + publishSessionMetadata: () => void; maybeNameThread: (threadId: string, turn: Turn) => void; recordMcpStartupStatus: (name: string, status: "starting" | "ready" | "failed" | "cancelled", message: string | null) => void; respondToServerRequest: (requestId: RequestId, result: unknown) => boolean; @@ -244,6 +245,7 @@ export class ChatController { if (this.state.activeThreadId === params.threadId) { clearActiveThreadState(this.state); } + this.actions.refreshThreads(); } else if (method === "thread/unarchived") { this.actions.refreshThreads(); } else if (method === "thread/name/updated") { @@ -273,10 +275,12 @@ export class ChatController { this.state.tokenUsage = params.tokenUsage; } else if (method === "account/rateLimits/updated") { this.state.rateLimit = params.rateLimits; + this.actions.publishSessionMetadata(); } else if (method === "skills/changed") { this.actions.refreshSkills(true); } else if (method === "mcpServer/startupStatus/updated") { this.handleMcpStartupStatus(params); + this.actions.publishSessionMetadata(); } } diff --git a/src/features/chat/chat-session-controller.ts b/src/features/chat/chat-session-controller.ts index 9668170c..3fcdc45f 100644 --- a/src/features/chat/chat-session-controller.ts +++ b/src/features/chat/chat-session-controller.ts @@ -1,5 +1,6 @@ import type { AppServerClient } from "../../app-server/client"; import { + type AppServerDiagnostics, capabilityProbeError, capabilityProbeOk, upsertMcpServerDiagnostic, @@ -7,6 +8,10 @@ import { } from "../../app-server/compatibility"; import { reportedServiceTier } from "../../app-server/service-tier"; import type { McpServerStatus } from "../../generated/app-server/v2/McpServerStatus"; +import type { Model } from "../../generated/app-server/v2/Model"; +import type { RateLimitSnapshot } from "../../generated/app-server/v2/RateLimitSnapshot"; +import type { SkillMetadata } from "../../generated/app-server/v2/SkillMetadata"; +import type { SharedSessionMetadata } from "../../runtime/shared-app-server-state"; import { requestedOrConfiguredServiceTier, type RuntimeSnapshot } from "../../runtime/state"; import type { ChatState } from "./chat-state"; @@ -25,19 +30,58 @@ export interface RefreshCapabilityDiagnosticsOptions { export class ChatSessionController { constructor(private readonly host: ChatSessionControllerHost) {} - async refreshThreadList(): Promise { - const client = this.host.currentClient(); - if (!client) return; - const response = await client.listThreads(this.host.vaultPath); - this.host.state.listedThreads = response.data; + applyThreadList(threads: ChatState["listedThreads"]): void { + this.host.state.listedThreads = threads; this.host.state.threadsLoaded = true; } - async refreshSessionMetadata(): Promise { + async loadThreadList(): Promise { const client = this.host.currentClient(); - if (!client) return; - this.host.state.effectiveConfig = await client.readEffectiveConfig(this.host.vaultPath); - await Promise.all([this.refreshModels(), this.refreshSkills(), this.refreshRateLimits()]); + if (!client) return []; + const response = await client.listThreads(this.host.vaultPath); + return response.data; + } + + sessionMetadataSnapshot(): SharedSessionMetadata { + return { + effectiveConfig: this.host.state.effectiveConfig, + availableModels: this.host.state.availableModels, + availableSkills: this.host.state.availableSkills, + rateLimit: this.host.state.rateLimit, + appServerDiagnostics: this.host.state.appServerDiagnostics, + }; + } + + applySessionMetadata(metadata: SharedSessionMetadata): void { + this.host.state.effectiveConfig = metadata.effectiveConfig; + this.host.state.availableModels = metadata.availableModels; + this.host.state.availableSkills = metadata.availableSkills; + this.host.state.rateLimit = metadata.rateLimit; + this.host.state.appServerDiagnostics = metadata.appServerDiagnostics; + } + + async loadSessionMetadata(): Promise { + const client = this.host.currentClient(); + if (!client) return null; + const effectiveConfig = await client.readEffectiveConfig(this.host.vaultPath); + const [models, skills, rateLimit] = await Promise.all([this.loadModels(), this.loadSkills(), this.loadRateLimit()]); + const diagnostics = cloneAppServerDiagnostics(this.host.state.appServerDiagnostics); + diagnostics.probes["model/list"] = models.probe; + diagnostics.probes["skills/list"] = skills.probe; + diagnostics.probes["account/rateLimits/read"] = rateLimit.probe; + return { + effectiveConfig, + availableModels: models.data, + availableSkills: skills.data, + rateLimit: rateLimit.data, + appServerDiagnostics: diagnostics, + }; + } + + async refreshSessionMetadata(): Promise { + const metadata = await this.loadSessionMetadata(); + if (metadata) this.applySessionMetadata(metadata); + return metadata; } async startThread(): Promise> | null> { @@ -61,47 +105,68 @@ export class ChatSessionController { } async refreshModels(): Promise { + const models = await this.loadModels(); + this.host.state.availableModels = models.data; + this.host.state.appServerDiagnostics.probes["model/list"] = models.probe; + } + + async loadModels(): Promise<{ data: Model[]; probe: AppServerDiagnostics["probes"]["model/list"] }> { const client = this.host.currentClient(); - if (!client) return; + if (!client) return { data: [], probe: capabilityProbeError("model/list", new Error("Codex app-server is not connected.")) }; try { const response = await client.listModels(false); - this.host.state.availableModels = response.data; - this.host.state.appServerDiagnostics.probes["model/list"] = capabilityProbeOk("model/list", `${String(response.data.length)} models`); + return { data: response.data, probe: capabilityProbeOk("model/list", `${String(response.data.length)} models`) }; } catch (error) { - this.host.state.availableModels = []; - this.host.state.appServerDiagnostics.probes["model/list"] = capabilityProbeError("model/list", error); + return { data: [], probe: capabilityProbeError("model/list", error) }; } } async refreshSkills(forceReload = false): Promise { + const skills = await this.loadSkills(forceReload); + this.host.state.availableSkills = skills.data; + this.host.state.appServerDiagnostics.probes["skills/list"] = skills.probe; + } + + async loadSkills(forceReload = false): Promise<{ data: SkillMetadata[]; probe: AppServerDiagnostics["probes"]["skills/list"] }> { const client = this.host.currentClient(); - if (!client) return; + if (!client) return { data: [], probe: capabilityProbeError("skills/list", new Error("Codex app-server is not connected.")) }; try { const response = await client.listSkills(this.host.vaultPath, forceReload); - this.host.state.availableSkills = response.data.flatMap((entry) => entry.skills).filter((skill) => skill.enabled); + const data = response.data.flatMap((entry) => entry.skills).filter((skill) => skill.enabled); const count = response.data.reduce((total, entry) => total + entry.skills.length, 0); - this.host.state.appServerDiagnostics.probes["skills/list"] = capabilityProbeOk("skills/list", `${String(count)} skills`); + return { data, probe: capabilityProbeOk("skills/list", `${String(count)} skills`) }; } catch (error) { - this.host.state.availableSkills = []; - this.host.state.appServerDiagnostics.probes["skills/list"] = capabilityProbeError("skills/list", error); + return { data: [], probe: capabilityProbeError("skills/list", error) }; } } async refreshRateLimits(): Promise { + const rateLimit = await this.loadRateLimit(); + this.host.state.rateLimit = rateLimit.data; + this.host.state.appServerDiagnostics.probes["account/rateLimits/read"] = rateLimit.probe; + } + + async loadRateLimit(): Promise<{ data: RateLimitSnapshot | null; probe: AppServerDiagnostics["probes"]["account/rateLimits/read"] }> { const client = this.host.currentClient(); - if (!client) return; + if (!client) { + return { + data: null, + probe: capabilityProbeError("account/rateLimits/read", new Error("Codex app-server is not connected.")), + }; + } try { const response = await client.readAccountRateLimits(); const rateLimitsByLimitId = response.rateLimitsByLimitId; const codexRateLimit = rateLimitsByLimitId && Object.hasOwn(rateLimitsByLimitId, "codex") ? rateLimitsByLimitId["codex"] : undefined; - this.host.state.rateLimit = codexRateLimit ?? response.rateLimits; - this.host.state.appServerDiagnostics.probes["account/rateLimits/read"] = capabilityProbeOk( - "account/rateLimits/read", - response.rateLimitsByLimitId ? `${String(Object.keys(response.rateLimitsByLimitId).length)} limits` : "available", - ); + return { + data: codexRateLimit ?? response.rateLimits, + probe: capabilityProbeOk( + "account/rateLimits/read", + response.rateLimitsByLimitId ? `${String(Object.keys(response.rateLimitsByLimitId).length)} limits` : "available", + ), + }; } catch (error) { - this.host.state.rateLimit = null; - this.host.state.appServerDiagnostics.probes["account/rateLimits/read"] = capabilityProbeError("account/rateLimits/read", error); + return { data: null, probe: capabilityProbeError("account/rateLimits/read", error) }; } } @@ -210,3 +275,10 @@ export class ChatSessionController { } } } + +function cloneAppServerDiagnostics(diagnostics: AppServerDiagnostics): AppServerDiagnostics { + return { + probes: { ...diagnostics.probes }, + mcpServers: diagnostics.mcpServers.map((server) => ({ ...server })), + }; +} diff --git a/src/features/chat/view.ts b/src/features/chat/view.ts index 2da22a30..5c8dbcbe 100644 --- a/src/features/chat/view.ts +++ b/src/features/chat/view.ts @@ -12,6 +12,7 @@ import { fileMentionsFromInput } from "./display/thread-items"; import type { DisplayDetailSection, DisplayItem } from "./display/types"; import type { ReasoningEffort } from "../../generated/app-server/ReasoningEffort"; import type { ApprovalsReviewer } from "../../generated/app-server/v2/ApprovalsReviewer"; +import type { Model } from "../../generated/app-server/v2/Model"; import type { Thread } from "../../generated/app-server/v2/Thread"; import type { ThreadResumeResponse } from "../../generated/app-server/v2/ThreadResumeResponse"; import type { ThreadSettingsUpdateParams } from "../../generated/app-server/v2/ThreadSettingsUpdateParams"; @@ -72,6 +73,7 @@ import { renderToolbar, toolbarSignature, type ToolbarChoice, type ToolbarViewMo import type { ChatTurnDiffViewState } from "./ui/turn-diff"; import { ChatMessageRenderer, type ChatMessageScrollIntent } from "./chat-message-renderer"; import type { OpenCodexPanelSnapshot } from "./panel-snapshot"; +import type { SharedSessionMetadata } from "../../runtime/shared-app-server-state"; export interface CodexChatHost { readonly settings: CodexPanelSettings; @@ -82,10 +84,12 @@ export interface CodexChatHost { openTurnDiff(state: ChatTurnDiffViewState): Promise; notifyThreadArchived(threadId: string): void; notifyThreadRenamed(threadId: string, name: string): void; - refreshOpenThreadLists(): void; refreshThreadsViewLiveState(): void; - refreshThreadsViewThreadList(): void; - publishThreadList(threads: Thread[]): void; + refreshSharedThreadListFromOpenSurface(): void; + refreshThreadList(fetchThreads: () => Promise): Promise; + cachedThreadList(): Thread[] | null; + publishSessionMetadata(metadata: SharedSessionMetadata): void; + cachedSessionMetadata(): SharedSessionMetadata | null; } interface RestoredThreadState { @@ -190,9 +194,11 @@ export class CodexChatView extends ItemView { this.controller = new ChatController(this.state, { refreshThreads: () => { void this.refreshThreads(); - this.plugin.refreshThreadsViewThreadList(); }, refreshSkills: (forceReload) => void this.refreshSkills(forceReload), + publishSessionMetadata: () => { + this.publishSessionMetadataSnapshot(); + }, maybeNameThread: (threadId, turn) => { this.threadRename.maybeAutoNameThread(threadId, turn); }, @@ -292,17 +298,26 @@ export class CodexChatView extends ItemView { this.render(); } - refreshThreadList(): void { - void this.refreshThreads(); + refreshSharedThreadList(): Promise { + return this.loadSharedThreadList(); } applyThreadListSnapshot(threads: Thread[]): void { - this.state.listedThreads = threads; - this.state.threadsLoaded = true; + this.session.applyThreadList(threads); this.refreshTabHeader(); this.render(); } + applySessionMetadataSnapshot(metadata: SharedSessionMetadata): void { + this.session.applySessionMetadata(metadata); + this.render(); + } + + applyAvailableModelsSnapshot(models: Model[]): void { + this.state.availableModels = models; + this.render(); + } + openPanelSnapshot(): OpenCodexPanelSnapshot { return { viewId: this.viewId, @@ -368,6 +383,7 @@ export class CodexChatView extends ItemView { if (leaf === this.leaf) this.scrollMessagesToBottomOnFocus(); }), ); + this.applyCachedSharedAppServerState(); this.render(); this.scheduleDeferredSessionWarmup(); this.scheduleDeferredRestoredThreadHydration(); @@ -428,11 +444,11 @@ export class CodexChatView extends ItemView { if (this.isStaleConnectionGeneration(generation)) return; this.client = this.connection.currentClient(); if (!this.client) throw new Error("Codex app-server connection did not initialize."); - await this.session.refreshSessionMetadata(); + const metadata = await this.session.refreshSessionMetadata(); if (this.isStaleConnectionGeneration(generation)) return; - await this.session.refreshThreadList(); + if (metadata) this.plugin.publishSessionMetadata(metadata); + await this.loadSharedThreadList(); if (this.isStaleConnectionGeneration(generation)) return; - this.publishThreadListSnapshot(); this.scheduleDeferredDiagnostics(); this.refreshTabHeader(); this.setStatus("Connected."); @@ -472,9 +488,9 @@ export class CodexChatView extends ItemView { this.client = this.connection.currentClient(); if (!this.client) return; try { - await this.session.refreshThreadList(); - this.publishThreadListSnapshot(); - await this.session.refreshSessionMetadata(); + await this.loadSharedThreadList(); + const metadata = await this.session.refreshSessionMetadata(); + if (metadata) this.plugin.publishSessionMetadata(metadata); this.refreshTabHeader(); this.render(); } catch (error) { @@ -488,6 +504,7 @@ export class CodexChatView extends ItemView { if (!this.client) return; this.clearDeferredDiagnostics(); await this.session.refreshCapabilityDiagnostics(); + this.publishSessionMetadataSnapshot(); this.render(); } @@ -495,6 +512,7 @@ export class CodexChatView extends ItemView { this.client = this.connection.currentClient(); if (!this.client) return; await this.session.refreshSkills(forceReload); + this.publishSessionMetadataSnapshot(); this.render(); } @@ -564,8 +582,20 @@ export class CodexChatView extends ItemView { this.requestWorkspaceLayoutSave(); } - private publishThreadListSnapshot(): void { - this.plugin.publishThreadList(this.state.listedThreads); + private async loadSharedThreadList(): Promise { + const threads = await this.plugin.refreshThreadList(() => this.session.loadThreadList()); + this.session.applyThreadList(threads); + } + + private publishSessionMetadataSnapshot(): void { + this.plugin.publishSessionMetadata(this.session.sessionMetadataSnapshot()); + } + + private applyCachedSharedAppServerState(): void { + const threads = this.plugin.cachedThreadList(); + if (threads) this.session.applyThreadList(threads); + const metadata = this.plugin.cachedSessionMetadata(); + if (metadata) this.session.applySessionMetadata(metadata); } private requestWorkspaceLayoutSave(): void { @@ -1341,6 +1371,7 @@ export class CodexChatView extends ItemView { private async refreshDeferredDiagnostics(): Promise { if (!this.connection.isConnected()) return; await this.session.refreshCapabilityDiagnostics({ cachedSessionMetadata: true }); + this.publishSessionMetadataSnapshot(); this.render(); } @@ -1563,7 +1594,7 @@ export class CodexChatView extends ItemView { this.setStatus("Rolled back latest turn."); this.notifyActiveThreadIdentityChanged(); await this.refreshThreads(); - this.plugin.refreshOpenThreadLists(); + this.plugin.refreshSharedThreadListFromOpenSurface(); } catch (error) { this.addSystemMessage(error instanceof Error ? error.message : String(error)); this.setStatus("Rollback failed."); diff --git a/src/features/threads-view/view.ts b/src/features/threads-view/view.ts index 63c292c7..f5b3e4f7 100644 --- a/src/features/threads-view/view.ts +++ b/src/features/threads-view/view.ts @@ -19,7 +19,7 @@ export interface CodexThreadsHost { getOpenPanelSnapshots(): OpenCodexPanelSnapshot[]; notifyThreadArchived(threadId: string): void; notifyThreadRenamed(threadId: string, name: string): void; - publishThreadList(threads: Thread[]): void; + refreshThreadList(fetchThreads: () => Promise): Promise; cachedThreadList(): Thread[] | null; } @@ -109,11 +109,14 @@ export class CodexThreadsView extends ItemView { try { await this.ensureConnected(); if (this.isStaleRefresh(connectionGeneration, refreshGeneration) || !this.client) return; - const response = await this.client.listThreads(this.plugin.vaultPath); + const threads = await this.plugin.refreshThreadList(async () => { + if (!this.client) return []; + const response = await this.client.listThreads(this.plugin.vaultPath); + return response.data; + }); if (this.isStaleRefresh(connectionGeneration, refreshGeneration)) return; - this.threads = response.data; - this.plugin.publishThreadList(response.data); - this.status = response.data.length === 0 ? "No threads" : null; + this.threads = threads; + this.status = threads.length === 0 ? "No threads" : null; } catch (error) { if (error instanceof StaleConnectionError) return; this.status = error instanceof Error ? error.message : String(error); diff --git a/src/main.ts b/src/main.ts index a1b3b00f..eb01288e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,6 +7,15 @@ import { CodexChatTurnDiffView } from "./features/chat/chat-turn-diff-view"; import type { OpenCodexPanelSnapshot } from "./features/chat/panel-snapshot"; import { CodexThreadsView } from "./features/threads-view/view"; import type { Thread } from "./generated/app-server/v2/Thread"; +import type { Model } from "./generated/app-server/v2/Model"; +import { + applySharedModels, + applySharedSessionMetadata, + applySharedThreadList, + createSharedAppServerState, + type SharedAppServerState, + type SharedSessionMetadata, +} from "./runtime/shared-app-server-state"; import { DEFAULT_SETTINGS, getVaultPath, normalizeSettings, settingsMatchNormalizedData, type CodexPanelSettings } from "./settings/model"; import { CodexPanelSettingTab } from "./settings/tab"; import { persistedChatTurnDiffViewState, type ChatTurnDiffViewState } from "./features/chat/ui/turn-diff"; @@ -38,7 +47,8 @@ export default class CodexPanelPlugin extends Plugin { vaultPath = ""; private bootRestoredPanelLoadCancelled = false; private readonly bootRestoredPanelLoadTimers = new Set(); - private latestThreadList: Thread[] | null = null; + private sharedAppServerState: SharedAppServerState = createSharedAppServerState(); + private threadListRefreshPromise: Promise | null = null; override async onload(): Promise { this.bootRestoredPanelLoadCancelled = false; @@ -176,21 +186,39 @@ export default class CodexPanelPlugin extends Plugin { } } - refreshOpenThreadLists(): void { - for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL)) { - if (leaf.view instanceof CodexChatView) { - leaf.view.refreshThreadList(); - } - } - for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_THREADS)) { - if (leaf.view instanceof CodexThreadsView) { - void leaf.view.refresh(); - } + refreshSharedThreadListFromOpenSurface(): void { + const chatView = this.app.workspace + .getLeavesOfType(VIEW_TYPE_CODEX_PANEL) + .map((leaf) => leaf.view) + .find((view): view is CodexChatView => view instanceof CodexChatView); + if (chatView) { + void chatView.refreshSharedThreadList(); + return; } + + const threadsView = this.app.workspace + .getLeavesOfType(VIEW_TYPE_CODEX_THREADS) + .map((leaf) => leaf.view) + .find((view): view is CodexThreadsView => view instanceof CodexThreadsView); + if (threadsView) void threadsView.refresh(); } - publishThreadList(threads: Thread[]): void { - this.latestThreadList = threads; + refreshThreadList(fetchThreads: () => Promise): Promise { + if (this.threadListRefreshPromise) return this.threadListRefreshPromise; + const promise = fetchThreads() + .then((threads) => { + this.applyThreadListSnapshot(threads); + return threads; + }) + .finally(() => { + if (this.threadListRefreshPromise === promise) this.threadListRefreshPromise = null; + }); + this.threadListRefreshPromise = promise; + return promise; + } + + applyThreadListSnapshot(threads: Thread[]): void { + this.sharedAppServerState = applySharedThreadList(this.sharedAppServerState, threads); for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL)) { if (leaf.view instanceof CodexChatView) { leaf.view.applyThreadListSnapshot(threads); @@ -204,7 +232,40 @@ export default class CodexPanelPlugin extends Plugin { } cachedThreadList(): Thread[] | null { - return this.latestThreadList; + return this.sharedAppServerState.threads; + } + + publishSessionMetadata(metadata: SharedSessionMetadata): void { + this.sharedAppServerState = applySharedSessionMetadata(this.sharedAppServerState, metadata); + for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL)) { + if (leaf.view instanceof CodexChatView) { + leaf.view.applySessionMetadataSnapshot(metadata); + } + } + } + + publishModels(models: Model[]): void { + this.sharedAppServerState = applySharedModels(this.sharedAppServerState, models); + for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL)) { + if (leaf.view instanceof CodexChatView) { + leaf.view.applyAvailableModelsSnapshot(models); + } + } + } + + cachedSessionMetadata(): SharedSessionMetadata | null { + if (!this.sharedAppServerState.sessionMetadataLoaded) return null; + return { + effectiveConfig: this.sharedAppServerState.effectiveConfig, + availableModels: this.sharedAppServerState.availableModels, + availableSkills: this.sharedAppServerState.availableSkills, + rateLimit: this.sharedAppServerState.rateLimit, + appServerDiagnostics: this.sharedAppServerState.appServerDiagnostics, + }; + } + + cachedModels(): Model[] { + return this.sharedAppServerState.availableModels; } refreshThreadsViewLiveState(): void { @@ -215,14 +276,6 @@ export default class CodexPanelPlugin extends Plugin { } } - refreshThreadsViewThreadList(): void { - for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_THREADS)) { - if (leaf.view instanceof CodexThreadsView) { - void leaf.view.refresh(); - } - } - } - notifyThreadArchived(threadId: string): void { this.notifyOpenPanels((view) => { view.notifyThreadArchived(threadId); @@ -343,7 +396,7 @@ export default class CodexPanelPlugin extends Plugin { } private refreshThreadSurfaces(): void { - this.refreshOpenThreadLists(); + this.refreshSharedThreadListFromOpenSurface(); } private scheduleBootRestoredPanelLoads(): void { diff --git a/src/runtime/shared-app-server-state.ts b/src/runtime/shared-app-server-state.ts new file mode 100644 index 00000000..af64e5d9 --- /dev/null +++ b/src/runtime/shared-app-server-state.ts @@ -0,0 +1,54 @@ +import type { AppServerDiagnostics } from "../app-server/compatibility"; +import { createAppServerDiagnostics } from "../app-server/compatibility"; +import type { ConfigReadResponse } from "../generated/app-server/v2/ConfigReadResponse"; +import type { Model } from "../generated/app-server/v2/Model"; +import type { RateLimitSnapshot } from "../generated/app-server/v2/RateLimitSnapshot"; +import type { SkillMetadata } from "../generated/app-server/v2/SkillMetadata"; +import type { Thread } from "../generated/app-server/v2/Thread"; + +export interface SharedSessionMetadata { + effectiveConfig: ConfigReadResponse | null; + availableModels: Model[]; + availableSkills: SkillMetadata[]; + rateLimit: RateLimitSnapshot | null; + appServerDiagnostics: AppServerDiagnostics; +} + +export interface SharedAppServerState extends SharedSessionMetadata { + threads: Thread[] | null; + sessionMetadataLoaded: boolean; +} + +export function createSharedAppServerState(): SharedAppServerState { + return { + threads: null, + sessionMetadataLoaded: false, + effectiveConfig: null, + availableModels: [], + availableSkills: [], + rateLimit: null, + appServerDiagnostics: createAppServerDiagnostics(), + }; +} + +export function applySharedThreadList(state: SharedAppServerState, threads: Thread[]): SharedAppServerState { + return { + ...state, + threads, + }; +} + +export function applySharedSessionMetadata(state: SharedAppServerState, metadata: SharedSessionMetadata): SharedAppServerState { + return { + ...state, + ...metadata, + sessionMetadataLoaded: true, + }; +} + +export function applySharedModels(state: SharedAppServerState, models: Model[]): SharedAppServerState { + return { + ...state, + availableModels: models, + }; +} diff --git a/src/settings/tab.ts b/src/settings/tab.ts index 469747d0..b6b00243 100644 --- a/src/settings/tab.ts +++ b/src/settings/tab.ts @@ -46,6 +46,7 @@ export class CodexPanelSettingTab extends PluginSettingTab { private readonly plugin: CodexPanelPlugin, ) { super(app, plugin); + this.models = plugin.cachedModels(); } display(): void { @@ -234,6 +235,7 @@ export class CodexPanelSettingTab extends PluginSettingTab { if (result.models.ok) { this.models = result.models.data; + this.plugin.publishModels(result.models.data); this.modelsStatus = result.models.status; } else { failedCount += 1; @@ -359,7 +361,7 @@ export class CodexPanelSettingTab extends PluginSettingTab { this.archivedThreads = this.archivedThreads.filter((thread) => thread.id !== threadId); this.archivedThreadsLoaded = true; this.archivedThreadsStatus = `Restored "${archivedThreadDisplayTitle(response.thread)}".`; - this.plugin.refreshOpenThreadLists(); + this.plugin.refreshSharedThreadListFromOpenSurface(); } catch (error) { this.archivedThreadsStatus = `Could not restore archived thread: ${errorMessage(error)}`; new Notice("Could not restore archived Codex thread."); diff --git a/tests/features/chat/chat-controller.test.ts b/tests/features/chat/chat-controller.test.ts index 4f7b00d1..fec8ba55 100644 --- a/tests/features/chat/chat-controller.test.ts +++ b/tests/features/chat/chat-controller.test.ts @@ -15,6 +15,7 @@ function controllerForState( return new ChatController(state, { refreshThreads: vi.fn(), refreshSkills: vi.fn(), + publishSessionMetadata: vi.fn(), maybeNameThread: vi.fn(), recordMcpStartupStatus: vi.fn(), respondToServerRequest: vi.fn(() => true), @@ -531,7 +532,8 @@ describe("ChatController", () => { it("stores account rate limit updates outside thread scope", () => { const state = createChatState(); - const controller = controllerForState(state); + const publishSessionMetadata = vi.fn(); + const controller = controllerForState(state, { publishSessionMetadata }); controller.handleNotification({ method: "account/rateLimits/updated", @@ -552,12 +554,14 @@ describe("ChatController", () => { limitId: "codex", primary: { usedPercent: 64 }, }); + expect(publishSessionMetadata).toHaveBeenCalledOnce(); }); it("records MCP startup status for diagnostics without a chat system message", () => { const state = createChatState(); const recordMcpStartupStatus = vi.fn(); - const controller = controllerForState(state, { recordMcpStartupStatus }); + const publishSessionMetadata = vi.fn(); + const controller = controllerForState(state, { recordMcpStartupStatus, publishSessionMetadata }); controller.handleNotification({ method: "mcpServer/startupStatus/updated", @@ -569,6 +573,7 @@ describe("ChatController", () => { } satisfies Extract); expect(recordMcpStartupStatus).toHaveBeenCalledWith("github", "failed", "missing token"); + expect(publishSessionMetadata).toHaveBeenCalledOnce(); expect(state.displayItems).toEqual([]); }); }); diff --git a/tests/features/chat/view-connection.test.ts b/tests/features/chat/view-connection.test.ts index 879e9da9..72a0964b 100644 --- a/tests/features/chat/view-connection.test.ts +++ b/tests/features/chat/view-connection.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_SETTINGS } from "../../../src/settings/model"; import type { CodexChatHost } from "../../../src/features/chat/view"; +import { createAppServerDiagnostics } from "../../../src/app-server/compatibility"; import { notices } from "../../mocks/obsidian"; import { installObsidianDomShims } from "./ui/dom-test-helpers"; @@ -83,20 +84,39 @@ describe("CodexChatView connection lifecycle", () => { expect(client.listThreads).toHaveBeenCalledTimes(1); }); - it("publishes refreshed thread lists after connecting", async () => { - const publishThreadList = vi.fn(); + it("refreshes shared thread lists after connecting", async () => { + const refreshThreadList = vi.fn((fetchThreads: () => Promise) => fetchThreads() as Promise); const threads = [threadFixture("thread-1")]; const client = connectedClient({ listThreads: vi.fn().mockResolvedValue({ data: threads }), }); connectionMock.state.client = client; const view = await chatView({ - host: chatHost({ publishThreadList }), + host: chatHost({ refreshThreadList }), }); await view.connect(); - expect(publishThreadList).toHaveBeenCalledWith(threads); + expect(refreshThreadList).toHaveBeenCalledOnce(); + expect((view as unknown as { state: { listedThreads: unknown[] } }).state.listedThreads).toEqual(threads); + }); + + it("publishes session metadata after connecting", async () => { + const publishSessionMetadata = vi.fn(); + connectionMock.state.client = connectedClient(); + const view = await chatView({ + host: chatHost({ publishSessionMetadata }), + }); + + await view.connect(); + + expect(publishSessionMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + effectiveConfig: {}, + availableModels: [], + availableSkills: [], + }), + ); }); it("ignores stale connection work after the view closes", async () => { @@ -193,6 +213,37 @@ describe("CodexChatView connection lifecycle", () => { expect((view as unknown as { state: { listedThreads: unknown[] } }).state.listedThreads).toEqual([threadFixture("thread-1")]); }); + it("applies cached shared thread list and metadata when opened", async () => { + const cachedThread = threadFixture("thread-cached"); + const view = await chatView({ + host: chatHost({ + cachedThreadList: vi.fn(() => [cachedThread] as never[]), + cachedSessionMetadata: vi.fn( + () => + ({ + effectiveConfig: { config: { model: "gpt-cached" }, origins: {}, layers: [] }, + availableModels: [], + availableSkills: [{ name: "writer", enabled: true }], + rateLimit: null, + appServerDiagnostics: createAppServerDiagnostics(), + }) as never, + ), + }), + }); + + await view.onOpen(); + + const state = ( + view as unknown as { + state: { listedThreads: unknown[]; effectiveConfig: unknown; availableModels: unknown[]; availableSkills: unknown[] }; + } + ).state; + expect(state.listedThreads).toEqual([cachedThread]); + expect(state.effectiveConfig).toEqual({ config: { model: "gpt-cached" }, origins: {}, layers: [] }); + expect(state.availableModels).toEqual([]); + expect(state.availableSkills).toEqual([{ name: "writer", enabled: true }]); + }); + it("hydrates a focused restored thread immediately", async () => { vi.useFakeTimers(); const client = connectedClient(); @@ -616,10 +667,14 @@ function chatHost(overrides: Partial = {}): CodexChatHost { openTurnDiff: vi.fn(), notifyThreadArchived: vi.fn(), notifyThreadRenamed: vi.fn(), - refreshOpenThreadLists: vi.fn(), + refreshSharedThreadListFromOpenSurface: vi.fn(), refreshThreadsViewLiveState: vi.fn(), - refreshThreadsViewThreadList: vi.fn(), - publishThreadList: vi.fn(), + refreshThreadList: vi.fn( + (fetchThreads: () => Promise) => fetchThreads() as Promise, + ) as CodexChatHost["refreshThreadList"], + cachedThreadList: vi.fn(() => null), + publishSessionMetadata: vi.fn(), + cachedSessionMetadata: vi.fn(() => null), ...overrides, }; } diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index 9d8ffd37..2e0bec13 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -196,19 +196,21 @@ describe("CodexThreadsView", () => { }); }); - it("publishes refreshed thread lists to other thread surfaces", async () => { + it("refreshes thread lists through the plugin coordinator", async () => { const threads = [threadFixture({ id: "thread", preview: "Thread preview" })]; + const refreshThreadList = vi.fn((fetchThreads: () => Promise) => fetchThreads() as Promise); connectionMock.state.client = clientFixture({ listThreads: vi.fn().mockResolvedValue({ data: threads }), }); const host = threadsHost({ - publishThreadList: vi.fn(), + refreshThreadList, }); const view = await threadsView(host); await view.refresh(); - expect(host.publishThreadList).toHaveBeenCalledWith(threads); + expect(refreshThreadList).toHaveBeenCalledOnce(); + expect(view.containerEl.textContent).toContain("Thread preview"); }); it("renders cached thread lists before refreshing", async () => { @@ -328,8 +330,7 @@ function threadsHost(overrides: Record = {}) { getOpenPanelSnapshots: vi.fn(() => []), notifyThreadArchived: vi.fn(), notifyThreadRenamed: vi.fn(), - refreshOpenThreadLists: vi.fn(), - publishThreadList: vi.fn(), + refreshThreadList: vi.fn((fetchThreads: () => Promise) => fetchThreads() as Promise), cachedThreadList: vi.fn(() => null), ...overrides, }; diff --git a/tests/main.test.ts b/tests/main.test.ts index 7ad7a597..d5a19c38 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -6,6 +6,7 @@ import { FileSystemAdapter } from "obsidian"; import { VIEW_TYPE_CODEX_PANEL } from "../src/constants"; import { DEFAULT_SETTINGS } from "../src/settings/model"; import type { CodexChatView } from "../src/features/chat/view"; +import type { Thread } from "../src/generated/app-server/v2/Thread"; import { installObsidianDomShims } from "./features/chat/ui/dom-test-helpers"; installObsidianDomShims(); @@ -161,22 +162,54 @@ describe("CodexPanelPlugin boot restored panel loading", () => { expect(openThread).not.toHaveBeenCalled(); }); - it("refreshes open thread lists after archive lifecycle notifications", async () => { + it("refreshes shared thread lists after archive lifecycle notifications", async () => { const plugin = await pluginWithLeaves([]); - const refreshOpenThreadLists = vi.spyOn(plugin, "refreshOpenThreadLists").mockImplementation(() => undefined); + const refreshSharedThreadList = vi.spyOn(plugin, "refreshSharedThreadListFromOpenSurface").mockImplementation(() => undefined); plugin.notifyThreadArchived("thread-1"); - expect(refreshOpenThreadLists).toHaveBeenCalledOnce(); + expect(refreshSharedThreadList).toHaveBeenCalledOnce(); }); - it("refreshes open thread lists after rename lifecycle notifications", async () => { + it("refreshes shared thread lists after rename lifecycle notifications", async () => { const plugin = await pluginWithLeaves([]); - const refreshOpenThreadLists = vi.spyOn(plugin, "refreshOpenThreadLists").mockImplementation(() => undefined); + const refreshSharedThreadList = vi.spyOn(plugin, "refreshSharedThreadListFromOpenSurface").mockImplementation(() => undefined); plugin.notifyThreadRenamed("thread-1", "Renamed thread"); - expect(refreshOpenThreadLists).toHaveBeenCalledOnce(); + expect(refreshSharedThreadList).toHaveBeenCalledOnce(); + }); + + it("single-flights shared thread list refreshes and caches successful results", async () => { + const plugin = await pluginWithLeaves([]); + let resolveThreads!: (threads: Thread[]) => void; + const fetchThreads = vi.fn( + () => + new Promise((resolve) => { + resolveThreads = resolve; + }), + ); + const secondFetch = vi.fn().mockResolvedValue([thread("second")]); + + const first = plugin.refreshThreadList(fetchThreads); + const second = plugin.refreshThreadList(secondFetch); + + expect(fetchThreads).toHaveBeenCalledOnce(); + expect(secondFetch).not.toHaveBeenCalled(); + resolveThreads([thread("first")]); + + await expect(first).resolves.toEqual([thread("first")]); + await expect(second).resolves.toEqual([thread("first")]); + expect(plugin.cachedThreadList()).toEqual([thread("first")]); + }); + + it("keeps the previous shared thread list when refresh fails", async () => { + const plugin = await pluginWithLeaves([]); + await plugin.refreshThreadList(() => Promise.resolve([thread("cached")])); + + await expect(plugin.refreshThreadList(() => Promise.reject(new Error("boom")))).rejects.toThrow("boom"); + + expect(plugin.cachedThreadList()).toEqual([thread("cached")]); }); }); @@ -240,10 +273,36 @@ function chatView(CodexChatViewCtor: typeof CodexChatView, leaf: TestLeaf) { openTurnDiff: vi.fn(), notifyThreadArchived: vi.fn(), notifyThreadRenamed: vi.fn(), - refreshOpenThreadLists: vi.fn(), + refreshSharedThreadListFromOpenSurface: vi.fn(), refreshThreadsViewLiveState: vi.fn(), - refreshThreadsViewThreadList: vi.fn(), - publishThreadList: vi.fn(), + refreshThreadList: vi.fn((fetchThreads: () => Promise) => fetchThreads() as Promise), + cachedThreadList: vi.fn(() => null), + publishSessionMetadata: vi.fn(), + cachedSessionMetadata: vi.fn(() => null), }, ); } + +function thread(id: string): Thread { + return { + id, + sessionId: "session", + forkedFromId: null, + preview: id, + ephemeral: false, + modelProvider: "openai", + createdAt: 1, + updatedAt: 1, + status: { type: "idle" }, + path: null, + cwd: "/vault", + cliVersion: "0.0.0", + source: "appServer", + threadSource: null, + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns: [], + } as Thread; +} diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index ac6e20ff..d17456b4 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -173,6 +173,24 @@ describe("settings tab", () => { expect(tab.containerEl.textContent).not.toContain("Old"); }); + it("uses cached models initially and publishes refreshed models", async () => { + const publishModels = vi.fn(); + const client = settingsClient({ models: [model("gpt-5.5")] }); + withAppServerSessionMock.mockImplementation((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => + operation(client), + ); + const tab = newSettingsTab({ cachedModels: [model("gpt-cached")], publishModels }); + + tab.display(); + + expect(tab.containerEl.textContent).toContain("gpt-cached"); + + await flushPromises(); + + expect(publishModels).toHaveBeenCalledWith([model("gpt-5.5")]); + expect(tab.containerEl.textContent).toContain("gpt-5.5"); + }); + it("keeps successful sections when one settings data request fails", async () => { const client = settingsClient({ models: [model("gpt-5.4")], @@ -309,7 +327,14 @@ function settingsClient(options: { models?: Model[]; hooks?: HookMetadata[]; hoo }; } -function newSettingsTab(options: { saveSettings?: () => Promise; sendShortcut?: "enter" | "mod-enter" } = {}): CodexPanelSettingTab { +function newSettingsTab( + options: { + saveSettings?: () => Promise; + sendShortcut?: "enter" | "mod-enter"; + cachedModels?: Model[]; + publishModels?: (models: Model[]) => void; + } = {}, +): CodexPanelSettingTab { return new CodexPanelSettingTab( {} as never, { @@ -327,7 +352,9 @@ function newSettingsTab(options: { saveSettings?: () => Promise; sendShort }, vaultPath: "/vault", saveSettings: options.saveSettings ?? vi.fn().mockResolvedValue(undefined), - refreshOpenThreadLists: vi.fn(), + refreshSharedThreadListFromOpenSurface: vi.fn(), + cachedModels: vi.fn(() => options.cachedModels ?? []), + publishModels: options.publishModels ?? vi.fn(), } as never, ); }