mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Persist restored Codex panel thread state
This commit is contained in:
parent
59f35ebc85
commit
1e94b7eaa6
6 changed files with 434 additions and 6 deletions
|
|
@ -10,11 +10,11 @@ export function getThreadTitle(thread: Thread): string {
|
|||
);
|
||||
}
|
||||
|
||||
export function codexPanelDisplayTitle(activeThreadId: string | null, threads: Thread[]): string {
|
||||
export function codexPanelDisplayTitle(activeThreadId: string | null, threads: Thread[], fallbackTitle?: string | null): string {
|
||||
if (!activeThreadId) return "Codex";
|
||||
|
||||
const thread = threads.find((item) => item.id === activeThreadId);
|
||||
const title = thread ? fullThreadTitle(thread) : shortThreadId(activeThreadId);
|
||||
const title = thread ? fullThreadTitle(thread) : (fallbackTitle ?? shortThreadId(activeThreadId));
|
||||
return title ? `Codex: ${title}` : "Codex";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ItemView, Notice, type WorkspaceLeaf } from "obsidian";
|
||||
import { ItemView, Notice, type ViewStateResult, type WorkspaceLeaf } from "obsidian";
|
||||
|
||||
import type { AppServerClient } from "../../app-server/client";
|
||||
import { ConnectionManager, StaleConnectionError } from "../../app-server/connection-manager";
|
||||
|
|
@ -79,6 +79,11 @@ export interface CodexChatHost {
|
|||
refreshOpenThreadLists(): void;
|
||||
}
|
||||
|
||||
interface RestoredThreadState {
|
||||
threadId: string;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
export class CodexChatView extends ItemView {
|
||||
private client: AppServerClient | null = null;
|
||||
private readonly connection: ConnectionManager;
|
||||
|
|
@ -96,10 +101,14 @@ export class CodexChatView extends ItemView {
|
|||
private messagesSlotEl: HTMLElement | null = null;
|
||||
private composerSlotEl: HTMLElement | null = null;
|
||||
private scheduledConnectionTimer: number | null = null;
|
||||
private scheduledRestoredThreadHydrationTimer: number | null = null;
|
||||
private scheduledRenderTimer: number | null = null;
|
||||
private scheduledDiagnosticsTimer: number | null = null;
|
||||
private connectingPromise: Promise<void> | null = null;
|
||||
private connectionGeneration = 0;
|
||||
private restoredThread: RestoredThreadState | null = null;
|
||||
private restoredThreadLoading: Promise<void> | null = null;
|
||||
private opened = false;
|
||||
private toolbarSignature: string | null = null;
|
||||
private forceScrollMessagesToBottomOnNextRender = false;
|
||||
|
||||
|
|
@ -222,13 +231,37 @@ export class CodexChatView extends ItemView {
|
|||
}
|
||||
|
||||
override getDisplayText(): string {
|
||||
return codexPanelDisplayTitle(this.state.activeThreadId, this.state.listedThreads);
|
||||
return codexPanelDisplayTitle(this.state.activeThreadId, this.state.listedThreads, this.restoredThread?.title ?? null);
|
||||
}
|
||||
|
||||
override getIcon(): string {
|
||||
return "bot-message-square";
|
||||
}
|
||||
|
||||
override getState(): Record<string, unknown> {
|
||||
const threadId = this.state.activeThreadId;
|
||||
if (!threadId) return { version: 1 };
|
||||
|
||||
const threadTitle = this.restoredThread?.title ?? this.activeThreadTitle();
|
||||
return {
|
||||
version: 1,
|
||||
threadId,
|
||||
...(threadTitle ? { threadTitle } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
override async setState(state: unknown, result: ViewStateResult): Promise<void> {
|
||||
await super.setState(state, result);
|
||||
const restoredThread = parseRestoredThreadState(state);
|
||||
if (!restoredThread) {
|
||||
this.restoredThread = null;
|
||||
this.clearDeferredRestoredThreadHydration();
|
||||
return;
|
||||
}
|
||||
|
||||
this.restoreThreadPlaceholder(restoredThread);
|
||||
}
|
||||
|
||||
refreshSettings(): void {
|
||||
this.render();
|
||||
}
|
||||
|
|
@ -242,20 +275,29 @@ export class CodexChatView extends ItemView {
|
|||
}
|
||||
|
||||
override async onOpen(): Promise<void> {
|
||||
this.opened = true;
|
||||
this.composerController.registerNoteIndexInvalidation((eventRef) => {
|
||||
this.registerEvent(eventRef);
|
||||
});
|
||||
this.registerDomEvent(this.containerEl.doc, "pointerdown", (event) => {
|
||||
this.closeToolbarPanelOnOutsidePointer(event);
|
||||
});
|
||||
this.registerEvent(
|
||||
this.app.workspace.on("active-leaf-change", (leaf) => {
|
||||
if (leaf === this.leaf) this.scrollMessagesToBottomOnFocus();
|
||||
}),
|
||||
);
|
||||
this.render();
|
||||
this.scheduleDeferredConnection();
|
||||
this.scheduleDeferredRestoredThreadHydration();
|
||||
}
|
||||
|
||||
override async onClose(): Promise<void> {
|
||||
this.opened = false;
|
||||
this.connectionGeneration += 1;
|
||||
this.connectingPromise = null;
|
||||
this.clearDeferredConnection();
|
||||
this.clearDeferredRestoredThreadHydration();
|
||||
if (this.scheduledRenderTimer !== null) {
|
||||
this.containerEl.win.clearTimeout(this.scheduledRenderTimer);
|
||||
this.scheduledRenderTimer = null;
|
||||
|
|
@ -326,6 +368,8 @@ export class CodexChatView extends ItemView {
|
|||
async startNewThread(): Promise<void> {
|
||||
if (this.state.busy) return;
|
||||
|
||||
this.restoredThread = null;
|
||||
this.clearDeferredRestoredThreadHydration();
|
||||
await this.ensureConnected();
|
||||
if (!this.client) return;
|
||||
|
||||
|
|
@ -338,6 +382,7 @@ export class CodexChatView extends ItemView {
|
|||
this.forceMessagesToBottom();
|
||||
await this.refreshThreads();
|
||||
this.refreshTabHeader();
|
||||
this.requestWorkspaceLayoutSave();
|
||||
this.render();
|
||||
} catch (error) {
|
||||
this.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
|
|
@ -396,8 +441,11 @@ export class CodexChatView extends ItemView {
|
|||
this.state.turnDiffs.clear();
|
||||
this.state.historyCursor = null;
|
||||
this.state.listedThreads = upsertThread(this.state.listedThreads, response.thread);
|
||||
this.restoredThread = null;
|
||||
this.clearDeferredRestoredThreadHydration();
|
||||
this.threadRename.resetThreadTurnPresence(false);
|
||||
this.refreshTabHeader();
|
||||
this.requestWorkspaceLayoutSave();
|
||||
this.forceMessagesToBottom();
|
||||
await this.history.loadLatest(response.thread.id);
|
||||
if (this.state.displayItems.length === 0) {
|
||||
|
|
@ -422,6 +470,10 @@ export class CodexChatView extends ItemView {
|
|||
}
|
||||
}
|
||||
|
||||
private requestWorkspaceLayoutSave(): void {
|
||||
void this.app.workspace.requestSaveLayout();
|
||||
}
|
||||
|
||||
private async sendMessage(): Promise<void> {
|
||||
const text = this.composerController.trimmedDraft;
|
||||
if (!text) return;
|
||||
|
|
@ -444,6 +496,7 @@ export class CodexChatView extends ItemView {
|
|||
}
|
||||
|
||||
private async sendTurnText(text: string, codexInputOverride?: UserInput[], referencedThread?: ReferencedThreadDisplay): Promise<void> {
|
||||
if (!(await this.ensureRestoredThreadLoaded())) return;
|
||||
const client = this.client;
|
||||
if (!client) return;
|
||||
|
||||
|
|
@ -458,6 +511,7 @@ export class CodexChatView extends ItemView {
|
|||
const threadResponse = await this.session.startThread();
|
||||
if (!threadResponse) return;
|
||||
this.refreshTabHeader();
|
||||
this.requestWorkspaceLayoutSave();
|
||||
this.threadRename.resetThreadTurnPresence(false);
|
||||
}
|
||||
const activeThreadId = this.state.activeThreadId;
|
||||
|
|
@ -835,6 +889,82 @@ export class CodexChatView extends ItemView {
|
|||
this.state.status = status;
|
||||
}
|
||||
|
||||
private restoreThreadPlaceholder(restoredThread: RestoredThreadState): void {
|
||||
this.restoredThread = restoredThread;
|
||||
this.state.activeThreadId = restoredThread.threadId;
|
||||
this.state.activeThreadCwd = null;
|
||||
this.state.activeTurnId = null;
|
||||
this.state.activeModel = null;
|
||||
this.state.activeReasoningEffort = null;
|
||||
this.state.activeCollaborationMode = "default";
|
||||
this.state.activeServiceTier = null;
|
||||
this.state.activeApprovalsReviewer = null;
|
||||
this.state.activeThreadCliVersion = null;
|
||||
this.state.tokenUsage = null;
|
||||
this.state.busy = false;
|
||||
this.state.displayItems = [this.systemItem("Thread restored. Send a message to resume it.")];
|
||||
this.state.pendingTurnStart = null;
|
||||
this.state.turnDiffs.clear();
|
||||
this.state.approvals = [];
|
||||
this.state.pendingUserInputs = [];
|
||||
this.state.userInputDrafts.clear();
|
||||
this.state.historyCursor = null;
|
||||
this.state.loadingHistory = false;
|
||||
this.state.messagesPinnedToBottom = true;
|
||||
this.setStatus("Thread ready to resume.");
|
||||
this.refreshTabHeader();
|
||||
this.scheduleDeferredRestoredThreadHydration();
|
||||
}
|
||||
|
||||
private async ensureRestoredThreadLoaded(): Promise<boolean> {
|
||||
if (!this.restoredThread) return true;
|
||||
this.clearDeferredRestoredThreadHydration();
|
||||
if (this.restoredThreadLoading) {
|
||||
const threadId = this.restoredThread.threadId;
|
||||
await this.restoredThreadLoading;
|
||||
return !this.isRestoredThreadPending(threadId);
|
||||
}
|
||||
|
||||
const threadId = this.restoredThread.threadId;
|
||||
const loading = this.resumeThread(threadId);
|
||||
this.restoredThreadLoading = loading;
|
||||
try {
|
||||
await loading;
|
||||
} finally {
|
||||
if (this.restoredThreadLoading === loading) {
|
||||
this.restoredThreadLoading = null;
|
||||
}
|
||||
}
|
||||
return !this.isRestoredThreadPending(threadId);
|
||||
}
|
||||
|
||||
private isRestoredThreadPending(threadId: string): boolean {
|
||||
return this.restoredThread?.threadId === threadId;
|
||||
}
|
||||
|
||||
private scheduleDeferredRestoredThreadHydration(): void {
|
||||
if (!this.opened || !this.restoredThread || this.scheduledRestoredThreadHydrationTimer !== null) return;
|
||||
const threadId = this.restoredThread.threadId;
|
||||
this.scheduledRestoredThreadHydrationTimer = this.containerEl.win.setTimeout(() => {
|
||||
this.scheduledRestoredThreadHydrationTimer = null;
|
||||
if (!this.isRestoredThreadPending(threadId)) return;
|
||||
void this.ensureRestoredThreadLoaded();
|
||||
}, 1_500);
|
||||
}
|
||||
|
||||
private clearDeferredRestoredThreadHydration(): void {
|
||||
if (this.scheduledRestoredThreadHydrationTimer === null) return;
|
||||
this.containerEl.win.clearTimeout(this.scheduledRestoredThreadHydrationTimer);
|
||||
this.scheduledRestoredThreadHydrationTimer = null;
|
||||
}
|
||||
|
||||
private activeThreadTitle(): string | null {
|
||||
const threadId = this.state.activeThreadId;
|
||||
if (!threadId) return null;
|
||||
const thread = this.state.listedThreads.find((item) => item.id === threadId);
|
||||
return thread ? getThreadTitle(thread) : null;
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
if (this.scheduledRenderTimer !== null) {
|
||||
window.clearTimeout(this.scheduledRenderTimer);
|
||||
|
|
@ -1241,8 +1371,10 @@ export class CodexChatView extends ItemView {
|
|||
}
|
||||
await this.client.archiveThread(threadId);
|
||||
if (this.state.activeThreadId === threadId) {
|
||||
this.restoredThread = null;
|
||||
clearActiveThreadState(this.state);
|
||||
this.threadRename.resetThreadTurnPresence(false);
|
||||
this.requestWorkspaceLayoutSave();
|
||||
}
|
||||
await this.refreshThreads();
|
||||
this.render();
|
||||
|
|
@ -1312,6 +1444,7 @@ export class CodexChatView extends ItemView {
|
|||
this.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted.");
|
||||
this.setStatus("Rolled back latest turn.");
|
||||
this.refreshTabHeader();
|
||||
this.requestWorkspaceLayoutSave();
|
||||
await this.refreshThreads();
|
||||
} catch (error) {
|
||||
this.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
|
|
@ -1324,6 +1457,11 @@ export class CodexChatView extends ItemView {
|
|||
this.forceScrollMessagesToBottomOnNextRender = true;
|
||||
}
|
||||
|
||||
private scrollMessagesToBottomOnFocus(): void {
|
||||
this.forceMessagesToBottom();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private renderMessages(parent: HTMLElement): void {
|
||||
this.messageRenderer.render(parent);
|
||||
}
|
||||
|
|
@ -1351,3 +1489,15 @@ export class CodexChatView extends ItemView {
|
|||
function latestProposedPlanItem(items: DisplayItem[]): DisplayItem | null {
|
||||
return [...items].reverse().find((item) => item.kind === "message" && item.role === "assistant" && item.proposedPlan === true) ?? null;
|
||||
}
|
||||
|
||||
function parseRestoredThreadState(state: unknown): RestoredThreadState | null {
|
||||
if (!state || typeof state !== "object") return null;
|
||||
const record = state as Record<string, unknown>;
|
||||
const threadId = record["threadId"];
|
||||
if (typeof threadId !== "string" || threadId.trim().length === 0) return null;
|
||||
const title = record["threadTitle"];
|
||||
return {
|
||||
threadId,
|
||||
title: typeof title === "string" && title.trim().length > 0 ? title : null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
45
src/main.ts
45
src/main.ts
|
|
@ -8,11 +8,17 @@ import { DEFAULT_SETTINGS, getVaultPath, normalizeSettings, settingsMatchNormali
|
|||
import { CodexPanelSettingTab } from "./settings/tab";
|
||||
import { persistedChatTurnDiffViewState, type ChatTurnDiffViewState } from "./features/chat/ui/turn-diff";
|
||||
|
||||
const BOOT_RESTORED_PANEL_LOAD_DELAY_MS = 1_000;
|
||||
const BOOT_RESTORED_PANEL_LOAD_STAGGER_MS = 250;
|
||||
|
||||
export default class CodexPanelPlugin extends Plugin {
|
||||
settings: CodexPanelSettings = DEFAULT_SETTINGS;
|
||||
vaultPath = "";
|
||||
private bootRestoredPanelLoadCancelled = false;
|
||||
private readonly bootRestoredPanelLoadTimers = new Set<number>();
|
||||
|
||||
override async onload(): Promise<void> {
|
||||
this.bootRestoredPanelLoadCancelled = false;
|
||||
this.vaultPath = getVaultPath(this.app);
|
||||
await this.loadSettings();
|
||||
|
||||
|
|
@ -47,6 +53,16 @@ export default class CodexPanelPlugin extends Plugin {
|
|||
registerSelectionRewriteCommand(this);
|
||||
|
||||
this.addSettingTab(new CodexPanelSettingTab(this.app, this));
|
||||
|
||||
this.scheduleBootRestoredPanelLoads();
|
||||
}
|
||||
|
||||
override onunload(): void {
|
||||
this.bootRestoredPanelLoadCancelled = true;
|
||||
for (const timer of this.bootRestoredPanelLoadTimers) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
this.bootRestoredPanelLoadTimers.clear();
|
||||
}
|
||||
|
||||
async activateView(): Promise<CodexChatView> {
|
||||
|
|
@ -122,4 +138,33 @@ export default class CodexPanelPlugin extends Plugin {
|
|||
|
||||
return workspace.createLeafInParent(existing.parent, Number.MAX_SAFE_INTEGER);
|
||||
}
|
||||
|
||||
private scheduleBootRestoredPanelLoads(): void {
|
||||
this.scheduleBootRestoredPanelLoadTimer(() => {
|
||||
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL);
|
||||
leaves.forEach((leaf, index) => {
|
||||
this.scheduleBootRestoredPanelLoadTimer(() => {
|
||||
void this.loadRestoredPanelLeaf(leaf);
|
||||
}, index * BOOT_RESTORED_PANEL_LOAD_STAGGER_MS);
|
||||
});
|
||||
}, BOOT_RESTORED_PANEL_LOAD_DELAY_MS);
|
||||
}
|
||||
|
||||
private scheduleBootRestoredPanelLoadTimer(callback: () => void, delay: number): void {
|
||||
const timer = window.setTimeout(() => {
|
||||
this.bootRestoredPanelLoadTimers.delete(timer);
|
||||
if (this.bootRestoredPanelLoadCancelled) return;
|
||||
callback();
|
||||
}, delay);
|
||||
this.bootRestoredPanelLoadTimers.add(timer);
|
||||
}
|
||||
|
||||
private async loadRestoredPanelLeaf(leaf: WorkspaceLeaf): Promise<void> {
|
||||
if (this.bootRestoredPanelLoadCancelled) return;
|
||||
try {
|
||||
await leaf.loadIfDeferred();
|
||||
} catch (error) {
|
||||
console.warn("Codex Panel could not hydrate a restored panel leaf.", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,95 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
expect(notices).toEqual([]);
|
||||
expect(client.listThreads).not.toHaveBeenCalled();
|
||||
});
|
||||
it("restores the active thread from workspace state and hydrates it after a delay", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = connectedClient({
|
||||
resumeThread: vi.fn().mockResolvedValue(resumedThread("thread-1")),
|
||||
});
|
||||
connectionMock.state.client = client;
|
||||
const view = await chatView();
|
||||
|
||||
await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never);
|
||||
await view.onOpen();
|
||||
|
||||
expect(view.getDisplayText()).toBe("Codex: Restored thread");
|
||||
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" });
|
||||
expect(connectionMock.state.connectCalls).toBe(0);
|
||||
expect(client.resumeThread).not.toHaveBeenCalled();
|
||||
expect(view.containerEl.textContent).toContain("Thread restored. Send a message to resume it.");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_500);
|
||||
|
||||
expect(client.resumeThread).toHaveBeenCalledWith("thread-1", "/vault");
|
||||
expect(client.threadTurnsList).toHaveBeenCalledWith("thread-1", null, 20);
|
||||
});
|
||||
|
||||
it("hydrates a restored thread when workspace state arrives after open", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = connectedClient();
|
||||
connectionMock.state.client = client;
|
||||
const view = await chatView();
|
||||
|
||||
await view.onOpen();
|
||||
await vi.advanceTimersByTimeAsync(1_500);
|
||||
expect(client.resumeThread).not.toHaveBeenCalled();
|
||||
|
||||
await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never);
|
||||
await vi.advanceTimersByTimeAsync(1_500);
|
||||
|
||||
expect(client.resumeThread).toHaveBeenCalledWith("thread-1", "/vault");
|
||||
expect(client.threadTurnsList).toHaveBeenCalledWith("thread-1", null, 20);
|
||||
});
|
||||
|
||||
it("resumes a restored thread before sending the first message", async () => {
|
||||
const client = connectedClient();
|
||||
connectionMock.state.client = client;
|
||||
const view = await chatView();
|
||||
|
||||
await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never);
|
||||
view.setComposerText("hello");
|
||||
await (view as unknown as { submitComposerAction: () => Promise<void> }).submitComposerAction();
|
||||
|
||||
expect(client.resumeThread).toHaveBeenCalledWith("thread-1", "/vault");
|
||||
expect(client.startTurn).toHaveBeenCalledWith("thread-1", "/vault", [{ type: "text", text: "hello", text_elements: [] }]);
|
||||
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" });
|
||||
});
|
||||
|
||||
it("requests a workspace layout save after resuming a thread", async () => {
|
||||
const requestSaveLayout = vi.fn();
|
||||
const client = connectedClient();
|
||||
connectionMock.state.client = client;
|
||||
const view = await chatView({ requestSaveLayout });
|
||||
|
||||
await view.openThread("thread-1");
|
||||
|
||||
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" });
|
||||
expect(requestSaveLayout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("scrolls restored messages to the bottom when the panel is focused", async () => {
|
||||
const activeLeafChangeListeners: ((leaf: unknown) => void)[] = [];
|
||||
const view = await chatView({ activeLeafChangeListeners });
|
||||
|
||||
await view.onOpen();
|
||||
const messages = view.containerEl.querySelector<HTMLElement>(".codex-panel__messages");
|
||||
expect(messages).not.toBeNull();
|
||||
if (!messages) return;
|
||||
Object.defineProperty(messages, "scrollHeight", { value: 1000, configurable: true });
|
||||
Object.defineProperty(messages, "clientHeight", { value: 100, configurable: true });
|
||||
messages.scrollTop = 0;
|
||||
|
||||
activeLeafChangeListeners.forEach((listener) => {
|
||||
listener(view.leaf);
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
messages.win.requestAnimationFrame(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
expect(messages.scrollTop).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
function connectedClient(overrides: Partial<ReturnType<typeof baseClient>> = {}): ReturnType<typeof baseClient> {
|
||||
|
|
@ -120,10 +209,30 @@ function baseClient() {
|
|||
listSkills: vi.fn().mockResolvedValue({ data: [] }),
|
||||
readAccountRateLimits: vi.fn().mockResolvedValue({ rateLimits: null }),
|
||||
listThreads: vi.fn().mockResolvedValue({ data: [] }),
|
||||
resumeThread: vi.fn().mockResolvedValue(resumedThread("thread-1")),
|
||||
threadTurnsList: vi.fn().mockResolvedValue({ data: [], nextCursor: null }),
|
||||
startTurn: vi.fn().mockResolvedValue({ turn: { id: "turn-1" } }),
|
||||
};
|
||||
}
|
||||
|
||||
async function chatView() {
|
||||
function resumedThread(threadId: string) {
|
||||
return {
|
||||
thread: {
|
||||
id: threadId,
|
||||
name: "Restored thread",
|
||||
preview: "Restored thread",
|
||||
cwd: "/vault",
|
||||
cliVersion: "0.0.0",
|
||||
},
|
||||
cwd: "/vault",
|
||||
model: null,
|
||||
reasoningEffort: null,
|
||||
serviceTier: null,
|
||||
approvalsReviewer: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function chatView(options: { activeLeafChangeListeners?: ((leaf: unknown) => void)[]; requestSaveLayout?: () => void } = {}) {
|
||||
const { CodexChatView } = await import("../../../src/features/chat/view");
|
||||
const containerEl = document.createElement("div");
|
||||
containerEl.createDiv();
|
||||
|
|
@ -131,8 +240,18 @@ async function chatView() {
|
|||
return new CodexChatView(
|
||||
{
|
||||
app: {
|
||||
workspace: {
|
||||
getActiveFile: vi.fn(() => null),
|
||||
on: vi.fn((eventName: string, callback: (leaf: unknown) => void) => {
|
||||
if (eventName === "active-leaf-change") options.activeLeafChangeListeners?.push(callback);
|
||||
return {};
|
||||
}),
|
||||
openLinkText: vi.fn(),
|
||||
requestSaveLayout: options.requestSaveLayout ?? vi.fn(),
|
||||
},
|
||||
vault: {
|
||||
on: vi.fn(() => ({})),
|
||||
getMarkdownFiles: vi.fn(() => []),
|
||||
},
|
||||
},
|
||||
containerEl,
|
||||
|
|
|
|||
67
tests/main.test.ts
Normal file
67
tests/main.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { FileSystemAdapter } from "obsidian";
|
||||
|
||||
import { VIEW_TYPE_CODEX_PANEL } from "../src/constants";
|
||||
|
||||
describe("CodexPanelPlugin boot restored panel loading", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("loads restored Codex panel leaves after startup without blocking onload", async () => {
|
||||
vi.useFakeTimers();
|
||||
const firstLeaf = leaf();
|
||||
const secondLeaf = leaf();
|
||||
const plugin = await pluginWithLeaves([firstLeaf, secondLeaf]);
|
||||
|
||||
await plugin.onload();
|
||||
|
||||
expect(firstLeaf.loadIfDeferred).not.toHaveBeenCalled();
|
||||
expect(secondLeaf.loadIfDeferred).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(firstLeaf.loadIfDeferred).toHaveBeenCalledTimes(1);
|
||||
expect(secondLeaf.loadIfDeferred).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(249);
|
||||
expect(secondLeaf.loadIfDeferred).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cancels pending boot panel loads on unload", async () => {
|
||||
vi.useFakeTimers();
|
||||
const firstLeaf = leaf();
|
||||
const plugin = await pluginWithLeaves([firstLeaf]);
|
||||
|
||||
await plugin.onload();
|
||||
plugin.onunload();
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(firstLeaf.loadIfDeferred).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
async function pluginWithLeaves(leaves: ReturnType<typeof leaf>[]) {
|
||||
const { default: CodexPanelPlugin } = await import("../src/main");
|
||||
const adapter = new FileSystemAdapter();
|
||||
vi.spyOn(adapter, "getBasePath").mockReturnValue("/vault");
|
||||
return new CodexPanelPlugin(
|
||||
{
|
||||
vault: {
|
||||
adapter,
|
||||
},
|
||||
workspace: {
|
||||
getLeavesOfType: vi.fn((type: string) => (type === VIEW_TYPE_CODEX_PANEL ? leaves : [])),
|
||||
},
|
||||
} as never,
|
||||
{} as never,
|
||||
);
|
||||
}
|
||||
|
||||
function leaf() {
|
||||
return {
|
||||
loadIfDeferred: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ export type App = Record<string, never>;
|
|||
export const notices: string[] = [];
|
||||
|
||||
export class FileSystemAdapter {
|
||||
constructor(readonly basePath: string) {}
|
||||
constructor(readonly basePath = "") {}
|
||||
|
||||
getBasePath(): string {
|
||||
return this.basePath;
|
||||
|
|
@ -61,12 +61,59 @@ export class ItemView {
|
|||
registerEvent(_eventRef: unknown): void {
|
||||
// Test mock placeholder.
|
||||
}
|
||||
|
||||
getState(): Record<string, unknown> {
|
||||
return {};
|
||||
}
|
||||
|
||||
setState(_state: unknown, _result: unknown): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
export class MarkdownView {
|
||||
file: TFile | null = null;
|
||||
}
|
||||
|
||||
export class Plugin {
|
||||
constructor(readonly app: App) {}
|
||||
|
||||
register(_callback: () => void): void {
|
||||
// Test mock placeholder.
|
||||
}
|
||||
|
||||
addCommand(_command: unknown): void {
|
||||
// Test mock placeholder.
|
||||
}
|
||||
|
||||
addRibbonIcon(_icon: string, _title: string, _callback: () => void): void {
|
||||
// Test mock placeholder.
|
||||
}
|
||||
|
||||
addSettingTab(_tab: unknown): void {
|
||||
// Test mock placeholder.
|
||||
}
|
||||
|
||||
registerView(_type: string, _factory: unknown): void {
|
||||
// Test mock placeholder.
|
||||
}
|
||||
|
||||
loadData(): Promise<unknown> {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
saveData(_data: unknown): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
export const MarkdownRenderer = {
|
||||
render(_app: unknown, text: string, parent: HTMLElement): Promise<void> {
|
||||
parent.textContent = text;
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
|
||||
export class PluginSettingTab {
|
||||
containerEl: HTMLElement;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue