Centralize shared app-server state

This commit is contained in:
murashit 2026-05-26 09:19:48 +09:00
parent 94f5bd0730
commit 35f8469a53
12 changed files with 464 additions and 98 deletions

View file

@ -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();
}
}

View file

@ -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<void> {
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<void> {
async loadThreadList(): Promise<ChatState["listedThreads"]> {
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<SharedSessionMetadata | null> {
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<SharedSessionMetadata | null> {
const metadata = await this.loadSessionMetadata();
if (metadata) this.applySessionMetadata(metadata);
return metadata;
}
async startThread(): Promise<Awaited<ReturnType<AppServerClient["startThread"]>> | null> {
@ -61,47 +105,68 @@ export class ChatSessionController {
}
async refreshModels(): Promise<void> {
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<void> {
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<void> {
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 })),
};
}

View file

@ -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<void>;
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<Thread[]>): Promise<Thread[]>;
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<void> {
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<void> {
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<void> {
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.");

View file

@ -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<Thread[]>): Promise<Thread[]>;
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);

View file

@ -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<number>();
private latestThreadList: Thread[] | null = null;
private sharedAppServerState: SharedAppServerState = createSharedAppServerState();
private threadListRefreshPromise: Promise<Thread[]> | null = null;
override async onload(): Promise<void> {
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<Thread[]>): Promise<Thread[]> {
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 {

View file

@ -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,
};
}

View file

@ -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.");

View file

@ -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<ServerNotification, { method: "mcpServer/startupStatus/updated" }>);
expect(recordMcpStartupStatus).toHaveBeenCalledWith("github", "failed", "missing token");
expect(publishSessionMetadata).toHaveBeenCalledOnce();
expect(state.displayItems).toEqual([]);
});
});

View file

@ -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<unknown>) => fetchThreads() as Promise<never[]>);
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> = {}): 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<unknown>) => fetchThreads() as Promise<never[]>,
) as CodexChatHost["refreshThreadList"],
cachedThreadList: vi.fn(() => null),
publishSessionMetadata: vi.fn(),
cachedSessionMetadata: vi.fn(() => null),
...overrides,
};
}

View file

@ -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<unknown>) => fetchThreads() as Promise<never[]>);
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<string, unknown> = {}) {
getOpenPanelSnapshots: vi.fn(() => []),
notifyThreadArchived: vi.fn(),
notifyThreadRenamed: vi.fn(),
refreshOpenThreadLists: vi.fn(),
publishThreadList: vi.fn(),
refreshThreadList: vi.fn((fetchThreads: () => Promise<unknown>) => fetchThreads() as Promise<never[]>),
cachedThreadList: vi.fn(() => null),
...overrides,
};

View file

@ -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<Thread[]>((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<unknown>) => fetchThreads() as Promise<never[]>),
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;
}

View file

@ -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<unknown>) =>
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<void>; sendShortcut?: "enter" | "mod-enter" } = {}): CodexPanelSettingTab {
function newSettingsTab(
options: {
saveSettings?: () => Promise<void>;
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<void>; 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,
);
}