mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Centralize shared app-server state
This commit is contained in:
parent
94f5bd0730
commit
35f8469a53
12 changed files with 464 additions and 98 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
99
src/main.ts
99
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<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 {
|
||||
|
|
|
|||
54
src/runtime/shared-app-server-state.ts
Normal file
54
src/runtime/shared-app-server-state.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue