Stabilize thread restore and resume lifecycle

This commit is contained in:
murashit 2026-05-25 11:48:48 +09:00
parent 7db0004d0d
commit 646dcb2ce0
10 changed files with 1006 additions and 159 deletions

View file

@ -16,7 +16,7 @@ export interface ChatMessageRendererOptions {
state: ChatState;
vaultPath: string;
blockSignatures: Map<string, string>;
consumeForceScrollToBottom: () => boolean;
consumeScrollIntent: () => ChatMessageScrollIntent;
loadOlderTurns: () => void;
rollbackThread: (threadId: string) => void;
implementPlan: (item: DisplayItem) => void;
@ -25,17 +25,24 @@ export interface ChatMessageRendererOptions {
renderPendingRequests: () => HTMLElement | null;
}
export type ChatMessageScrollIntent = "auto" | "force-bottom" | "preserve";
export class ChatMessageRenderer {
private renderGeneration = 0;
constructor(private readonly options: ChatMessageRendererOptions) {}
render(parent: HTMLElement): void {
const generation = ++this.renderGeneration;
const { state } = this.options;
const messagesEl = parent.querySelector<HTMLElement>(".codex-panel__messages") ?? parent.createDiv({ cls: "codex-panel__messages" });
messagesEl.onscroll = () => {
state.messagesPinnedToBottom = isNearScrollBottom(messagesEl);
};
const wasNearBottom = isNearScrollBottom(messagesEl);
const shouldScrollToBottom = this.options.consumeForceScrollToBottom() || wasNearBottom;
const scrollIntent = this.options.consumeScrollIntent();
const shouldPreserveScroll = scrollIntent === "preserve";
const wasNearBottom = shouldPreserveScroll ? false : isNearScrollBottom(messagesEl);
const shouldScrollToBottom = scrollIntent === "force-bottom" || wasNearBottom;
const scrollAnchor = shouldScrollToBottom ? null : captureScrollAnchor(messagesEl);
state.messagesPinnedToBottom = shouldScrollToBottom;
const rollbackCandidate = state.busy ? null : rollbackCandidateFromItems(state.displayItems);
@ -83,12 +90,14 @@ export class ChatMessageRenderer {
syncMessageRenderBlocks(messagesEl, blocks, this.options.blockSignatures);
messagesEl.win.requestAnimationFrame(() => {
if (generation !== this.renderGeneration) return;
if (shouldScrollToBottom) {
messagesEl.scrollTop = bottomScrollTop(messagesEl);
if (!state.messagesPinnedToBottom) return;
this.pinMessagesToBottom(messagesEl);
} else {
restoreScrollAnchor(messagesEl, scrollAnchor);
state.messagesPinnedToBottom = isNearScrollBottom(messagesEl);
}
state.messagesPinnedToBottom = isNearScrollBottom(messagesEl);
});
}
@ -113,11 +122,16 @@ export class ChatMessageRenderer {
if (!messagesEl) return;
messagesEl.win.requestAnimationFrame(() => {
if (!this.options.state.messagesPinnedToBottom) return;
messagesEl.scrollTop = bottomScrollTop(messagesEl);
this.options.state.messagesPinnedToBottom = isNearScrollBottom(messagesEl);
this.pinMessagesToBottom(messagesEl);
});
}
private pinMessagesToBottom(messagesEl: HTMLElement): void {
messagesEl.scrollTop = bottomScrollTop(messagesEl);
messagesEl.lastElementChild?.scrollIntoView({ block: "end" });
this.options.state.messagesPinnedToBottom = isNearScrollBottom(messagesEl);
}
private bindRenderedWikiLinks(parent: HTMLElement, sourcePath: string): void {
parent.querySelectorAll<HTMLAnchorElement>("a.internal-link").forEach((link) => {
link.addClass("codex-panel__wikilink");

View file

@ -17,6 +17,11 @@ export class ThreadHistoryLoader {
constructor(private readonly host: ThreadHistoryLoaderHost) {}
invalidate(): void {
this.generation += 1;
this.host.state.loadingHistory = false;
}
async loadLatest(threadId = this.host.state.activeThreadId): Promise<void> {
const client = this.host.currentClient();
if (!client || !threadId) return;

View file

@ -27,6 +27,7 @@ export interface ThreadRenameControllerHost {
refreshThreads: () => Promise<void>;
render: () => void;
addSystemMessage: (text: string) => void;
notifyThreadRenamed: (threadId: string, name: string) => void;
}
export class ThreadRenameController {
@ -91,7 +92,7 @@ export class ThreadRenameController {
thread.id === threadId ? { ...thread, name: title } : thread,
);
this.clear();
await this.host.refreshThreads();
this.host.notifyThreadRenamed(threadId, title);
} catch (error) {
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
} finally {
@ -155,7 +156,7 @@ export class ThreadRenameController {
this.host.state.listedThreads = this.host.state.listedThreads.map((thread) =>
thread.id === threadId ? { ...thread, name: title } : thread,
);
await this.host.refreshThreads();
this.host.notifyThreadRenamed(threadId, title);
} catch {
// Auto-naming is best-effort metadata. Leave the thread preview untouched on failure.
} finally {

View file

@ -13,6 +13,7 @@ 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 { 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";
import type { UserInput } from "../../generated/app-server/v2/UserInput";
import {
@ -69,14 +70,17 @@ import {
import { renderPendingRequestMessage } from "./ui/pending-request-message";
import { renderToolbar, toolbarSignature, type ToolbarChoice, type ToolbarViewModel } from "./ui/toolbar";
import type { ChatTurnDiffViewState } from "./ui/turn-diff";
import { ChatMessageRenderer } from "./chat-message-renderer";
import { ChatMessageRenderer, type ChatMessageScrollIntent } from "./chat-message-renderer";
import type { OpenCodexPanelSnapshot } from "./panel-snapshot";
export interface CodexChatHost {
readonly settings: CodexPanelSettings;
readonly vaultPath: string;
openThreadInNewView(threadId: string): Promise<unknown>;
openThreadInAvailableView(threadId: string): Promise<void>;
openTurnDiff(state: ChatTurnDiffViewState): Promise<void>;
notifyThreadArchived(threadId: string): void;
notifyThreadRenamed(threadId: string, name: string): void;
refreshOpenThreadLists(): void;
refreshThreadsViewLiveState(): void;
refreshThreadsViewThreadList(): void;
@ -103,18 +107,18 @@ export class CodexChatView extends ItemView {
private configSlotEl: HTMLElement | null = null;
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 resumeGeneration = 0;
private restoredThread: RestoredThreadState | null = null;
private restoredThreadLoading: Promise<void> | null = null;
private opened = false;
private closing = false;
private toolbarSignature: string | null = null;
private forceScrollMessagesToBottomOnNextRender = false;
private nextMessageScrollIntent: ChatMessageScrollIntent = "auto";
constructor(
leaf: WorkspaceLeaf,
@ -127,9 +131,9 @@ export class CodexChatView extends ItemView {
state: this.state,
vaultPath: this.plugin.vaultPath,
blockSignatures: this.blockSignatures,
consumeForceScrollToBottom: () => {
const value = this.forceScrollMessagesToBottomOnNextRender;
this.forceScrollMessagesToBottomOnNextRender = false;
consumeScrollIntent: () => {
const value = this.nextMessageScrollIntent;
this.nextMessageScrollIntent = "auto";
return value;
},
loadOlderTurns: () => void this.history.loadOlder(),
@ -171,6 +175,7 @@ export class CodexChatView extends ItemView {
this.render();
},
onExit: () => {
this.invalidateResumeWork();
this.setStatus("Codex app-server stopped.");
clearConnectionScopedState(this.state);
this.threadRename.resetThreadTurnPresence(false);
@ -201,7 +206,7 @@ export class CodexChatView extends ItemView {
currentClient: () => this.connection.currentClient(),
runtimeSnapshot: () => this.runtimeSnapshot(),
forceMessagesToBottom: () => {
this.forceMessagesToBottom();
this.queueMessagesBottomScroll();
},
});
this.history = new ThreadHistoryLoader({
@ -214,10 +219,10 @@ export class CodexChatView extends ItemView {
this.addSystemMessage(text);
},
forceMessagesToBottom: () => {
this.forceMessagesToBottom();
this.queueMessagesBottomScroll();
},
keepCurrentScrollPosition: () => {
this.forceScrollMessagesToBottomOnNextRender = false;
this.preserveMessagesScrollPosition();
},
setThreadTurnPresence: (hadTurns) => {
this.threadRename.resetThreadTurnPresence(hadTurns);
@ -236,6 +241,9 @@ export class CodexChatView extends ItemView {
addSystemMessage: (text) => {
this.addSystemMessage(text);
},
notifyThreadRenamed: (threadId, name) => {
this.plugin.notifyThreadRenamed(threadId, name);
},
});
}
@ -267,6 +275,7 @@ export class CodexChatView extends ItemView {
await super.setState(state, result);
const restoredThread = parseRestoredThreadState(state);
if (!restoredThread) {
this.invalidateResumeWork();
this.restoredThread = null;
this.clearDeferredRestoredThreadHydration();
return;
@ -300,6 +309,40 @@ export class CodexChatView extends ItemView {
await this.resumeThread(threadId);
}
async focusThread(threadId: string | null = null): Promise<void> {
if (threadId && this.isRestoredThreadPending(threadId)) {
await this.ensureRestoredThreadLoaded();
}
this.scrollMessagesToBottomOnFocus();
}
notifyThreadArchived(threadId: string): void {
if (this.clearArchivedActiveThread(threadId)) {
this.render();
}
}
notifyThreadRenamed(threadId: string, name: string): void {
let changed = false;
this.state.listedThreads = this.state.listedThreads.map((thread) => {
if (thread.id !== threadId) return thread;
changed = true;
return { ...thread, name };
});
if (this.restoredThread?.threadId === threadId && this.restoredThread.title !== name) {
this.restoredThread = { ...this.restoredThread, title: name };
changed = true;
}
const activeThreadChanged = this.state.activeThreadId === threadId || this.restoredThread?.threadId === threadId;
if (!changed && !activeThreadChanged) return;
if (activeThreadChanged) {
this.notifyActiveThreadIdentityChanged();
} else {
this.refreshTabHeader();
}
this.render();
}
override async onOpen(): Promise<void> {
this.opened = true;
this.closing = false;
@ -315,7 +358,6 @@ export class CodexChatView extends ItemView {
}),
);
this.render();
this.scheduleDeferredConnection();
this.scheduleDeferredRestoredThreadHydration();
}
@ -323,8 +365,8 @@ export class CodexChatView extends ItemView {
this.opened = false;
this.closing = true;
this.connectionGeneration += 1;
this.invalidateResumeWork();
this.connectingPromise = null;
this.clearDeferredConnection();
this.clearDeferredRestoredThreadHydration();
if (this.scheduledRenderTimer !== null) {
this.containerEl.win.clearTimeout(this.scheduledRenderTimer);
@ -344,7 +386,6 @@ export class CodexChatView extends ItemView {
}
async connect(): Promise<void> {
this.clearDeferredConnection();
await this.ensureConnected();
}
@ -400,26 +441,17 @@ export class CodexChatView extends ItemView {
async startNewThread(): Promise<void> {
if (this.state.busy) return;
this.invalidateResumeWork();
this.restoredThread = null;
this.clearDeferredRestoredThreadHydration();
await this.ensureConnected();
if (!this.client) return;
try {
const response = await this.session.startThread();
if (!response) return;
this.threadRename.resetThreadTurnPresence(false);
this.state.turnDiffs.clear();
this.state.displayItems = [this.systemItem(`Started thread ${response.thread.id}`)];
this.forceMessagesToBottom();
await this.refreshThreads();
this.plugin.refreshThreadsViewThreadList();
this.refreshTabHeader();
this.requestWorkspaceLayoutSave();
this.render();
} catch (error) {
this.addSystemMessage(error instanceof Error ? error.message : String(error));
}
clearActiveThreadState(this.state);
this.threadRename.resetThreadTurnPresence(false);
this.state.openDetails.delete("history");
this.setStatus("New chat.");
this.queueMessagesBottomScroll();
this.plugin.refreshThreadsViewLiveState();
this.notifyActiveThreadIdentityChanged();
this.render();
}
private async refreshThreads(): Promise<void> {
@ -456,38 +488,24 @@ export class CodexChatView extends ItemView {
this.addSystemMessage("Finish or interrupt the current turn before switching threads.");
return;
}
const resumeGeneration = this.beginResumeWork();
await this.ensureConnected();
if (!this.client) return;
if (!this.client || this.isStaleResumeWork(resumeGeneration)) return;
try {
const response = await this.client.resumeThread(threadId, this.plugin.vaultPath);
this.state.activeThreadId = response.thread.id;
this.state.activeThreadCwd = response.cwd;
this.state.activeTurnId = null;
this.state.activeModel = response.model;
this.state.activeReasoningEffort = response.reasoningEffort;
this.state.activeServiceTier = reportedServiceTier(response.serviceTier);
this.state.activeApprovalsReviewer = response.approvalsReviewer;
this.state.activeThreadCliVersion = response.thread.cliVersion;
this.state.tokenUsage = null;
this.state.displayItems = [];
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();
if (this.isStaleResumeWork(resumeGeneration)) return;
this.applyResumedThread(response);
await this.history.loadLatest(response.thread.id);
if (this.isStaleResumeWork(resumeGeneration)) return;
if (this.state.displayItems.length === 0) {
this.state.displayItems.push(this.systemItem(`Resumed thread ${response.thread.id}`));
this.forceMessagesToBottom();
this.queueMessagesBottomScroll();
this.render();
}
this.plugin.refreshThreadsViewLiveState();
} catch (error) {
if (this.isStaleResumeWork(resumeGeneration)) return;
this.addSystemMessage(error instanceof Error ? error.message : String(error));
}
}
@ -504,6 +522,33 @@ export class CodexChatView extends ItemView {
}
}
private applyResumedThread(response: ThreadResumeResponse): void {
this.state.activeThreadId = response.thread.id;
this.state.activeThreadCwd = response.cwd;
this.state.activeTurnId = null;
this.state.activeModel = response.model;
this.state.activeReasoningEffort = response.reasoningEffort;
this.state.activeServiceTier = reportedServiceTier(response.serviceTier);
this.state.activeApprovalsReviewer = response.approvalsReviewer;
this.state.activeThreadCliVersion = response.thread.cliVersion;
this.state.tokenUsage = null;
this.state.displayItems = [this.systemItem("Loading thread...")];
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.notifyActiveThreadIdentityChanged();
this.render();
this.plugin.refreshThreadsViewLiveState();
}
private notifyActiveThreadIdentityChanged(): void {
this.refreshTabHeader();
this.requestWorkspaceLayoutSave();
}
private requestWorkspaceLayoutSave(): void {
void this.app.workspace.requestSaveLayout();
}
@ -522,7 +567,6 @@ export class CodexChatView extends ItemView {
if (result?.sendText) {
await this.sendTurnText(result.sendText, result.sendInput, result.referencedThread);
}
this.render();
return;
}
@ -544,8 +588,7 @@ export class CodexChatView extends ItemView {
if (!this.state.activeThreadId) {
const threadResponse = await this.session.startThread();
if (!threadResponse) return;
this.refreshTabHeader();
this.requestWorkspaceLayoutSave();
this.notifyActiveThreadIdentityChanged();
this.threadRename.resetThreadTurnPresence(false);
}
const activeThreadId = this.state.activeThreadId;
@ -566,7 +609,7 @@ export class CodexChatView extends ItemView {
markdown: true,
});
this.state.pendingTurnStart = { anchorItemId: optimisticUserId, promptSubmitHookItemIds: [] };
this.forceMessagesToBottom();
this.queueMessagesBottomScroll();
this.composerController.setDraft("");
this.state.busy = true;
this.render();
@ -632,7 +675,7 @@ export class CodexChatView extends ItemView {
...(mentionedFiles.length > 0 ? { mentionedFiles } : {}),
markdown: true,
});
this.forceMessagesToBottom();
this.queueMessagesBottomScroll();
this.setStatus("Steered current turn.");
} catch (error) {
this.composerController.setDraft(text, { focus: true });
@ -677,7 +720,7 @@ export class CodexChatView extends ItemView {
activeThreadId: this.state.activeThreadId,
listedThreads: this.state.listedThreads,
startNewThread: () => this.startNewThread(),
resumeThread: (threadId) => this.resumeThread(threadId),
resumeThread: (threadId) => this.selectThread(threadId),
referThread: (thread, message) => this.referencedThreadInput(thread, message),
forkThread: (threadId) => this.forkThread(threadId),
rollbackThread: (threadId) => this.rollbackThread(threadId),
@ -927,6 +970,7 @@ export class CodexChatView extends ItemView {
}
private restoreThreadPlaceholder(restoredThread: RestoredThreadState): void {
this.invalidateResumeWork();
this.restoredThread = restoredThread;
this.state.activeThreadId = restoredThread.threadId;
this.state.activeThreadCwd = null;
@ -953,6 +997,21 @@ export class CodexChatView extends ItemView {
this.scheduleDeferredRestoredThreadHydration();
}
private beginResumeWork(): number {
this.resumeGeneration += 1;
this.history.invalidate();
return this.resumeGeneration;
}
private invalidateResumeWork(): void {
this.resumeGeneration += 1;
this.history.invalidate();
}
private isStaleResumeWork(generation: number): boolean {
return generation !== this.resumeGeneration || this.closing;
}
private async ensureRestoredThreadLoaded(): Promise<boolean> {
if (!this.restoredThread) return true;
this.clearDeferredRestoredThreadHydration();
@ -1052,7 +1111,7 @@ export class CodexChatView extends ItemView {
resumeThread: (threadId) => {
if (this.state.busy && threadId !== this.state.activeThreadId) return;
this.state.openDetails.delete("history");
void this.resumeThread(threadId);
void this.selectThread(threadId);
},
archiveThread: (threadId) => void this.archiveThread(threadId),
startRenameThread: (threadId) => {
@ -1122,8 +1181,8 @@ export class CodexChatView extends ItemView {
const threadId = this.state.activeThreadId;
this.state.openDetails.delete("status-panel");
this.connectionGeneration += 1;
this.invalidateResumeWork();
this.connectingPromise = null;
this.clearDeferredConnection();
this.clearDeferredDiagnostics();
this.connection.reconnect();
this.client = null;
@ -1188,6 +1247,14 @@ export class CodexChatView extends ItemView {
this.scheduleRender();
}
private async selectThread(threadId: string): Promise<void> {
if (this.state.busy && threadId !== this.state.activeThreadId) {
this.addSystemMessage("Finish or interrupt the current turn before switching threads.");
return;
}
await this.plugin.openThreadInAvailableView(threadId);
}
private closeToolbarPanelOnOutsidePointer(event: PointerEvent): void {
if (!this.hasOpenToolbarPanel()) return;
@ -1222,20 +1289,6 @@ export class CodexChatView extends ItemView {
}, 50);
}
private scheduleDeferredConnection(): void {
if (this.scheduledConnectionTimer !== null || this.connection.isConnected()) return;
this.scheduledConnectionTimer = this.containerEl.win.setTimeout(() => {
this.scheduledConnectionTimer = null;
void this.ensureConnected();
}, 1_500);
}
private clearDeferredConnection(): void {
if (this.scheduledConnectionTimer === null) return;
this.containerEl.win.clearTimeout(this.scheduledConnectionTimer);
this.scheduledConnectionTimer = null;
}
private scheduleDeferredDiagnostics(): void {
if (this.scheduledDiagnosticsTimer !== null) return;
this.scheduledDiagnosticsTimer = this.containerEl.win.setTimeout(() => {
@ -1407,14 +1460,7 @@ export class CodexChatView extends ItemView {
new Notice(`Saved archived thread to ${result.path}.`);
}
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();
this.plugin.notifyThreadArchived(threadId);
} catch (error) {
this.addSystemMessage(error instanceof Error ? error.message : String(error));
}
@ -1435,7 +1481,7 @@ export class CodexChatView extends ItemView {
if (sourceName) {
try {
await this.client.setThreadName(forkedThreadId, sourceName);
this.plugin.refreshOpenThreadLists();
this.plugin.notifyThreadRenamed(forkedThreadId, sourceName);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.addSystemMessage(`Forked thread ${forkedThreadId}, but could not copy the source thread name: ${message}`);
@ -1480,25 +1526,41 @@ export class CodexChatView extends ItemView {
this.setComposerText(candidate.text);
this.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted.");
this.setStatus("Rolled back latest turn.");
this.refreshTabHeader();
this.requestWorkspaceLayoutSave();
this.notifyActiveThreadIdentityChanged();
await this.refreshThreads();
this.plugin.refreshOpenThreadLists();
} catch (error) {
this.addSystemMessage(error instanceof Error ? error.message : String(error));
this.setStatus("Rollback failed.");
}
}
private forceMessagesToBottom(): void {
private queueMessagesBottomScroll(): void {
this.state.messagesPinnedToBottom = true;
this.forceScrollMessagesToBottomOnNextRender = true;
this.nextMessageScrollIntent = "force-bottom";
}
private preserveMessagesScrollPosition(): void {
this.nextMessageScrollIntent = "preserve";
}
private scrollMessagesToBottomOnFocus(): void {
this.forceMessagesToBottom();
this.queueMessagesBottomScroll();
this.render();
}
private clearArchivedActiveThread(threadId: string): boolean {
if (this.state.activeThreadId !== threadId) return false;
this.invalidateResumeWork();
this.restoredThread = null;
this.clearDeferredRestoredThreadHydration();
clearActiveThreadState(this.state);
this.threadRename.resetThreadTurnPresence(false);
this.notifyActiveThreadIdentityChanged();
this.plugin.refreshThreadsViewLiveState();
return true;
}
private renderMessages(parent: HTMLElement): void {
this.messageRenderer.render(parent);
}

View file

@ -13,11 +13,10 @@ import { threadRows } from "./state";
export interface CodexThreadsHost {
readonly settings: CodexPanelSettings;
readonly vaultPath: string;
openThreadInNewView(threadId: string): Promise<unknown>;
openThreadInIdleEmptyView(threadId: string): Promise<boolean>;
openThreadInAvailableView(threadId: string): Promise<void>;
getOpenPanelSnapshots(): OpenCodexPanelSnapshot[];
focusOpenPanel(viewId: string): Promise<boolean>;
refreshOpenThreadLists(): void;
notifyThreadArchived(threadId: string): void;
notifyThreadRenamed(threadId: string, name: string): void;
}
export class CodexThreadsView extends ItemView {
@ -25,6 +24,7 @@ export class CodexThreadsView extends ItemView {
private client: AppServerClient | null = null;
private connectingPromise: Promise<void> | null = null;
private connectionGeneration = 0;
private refreshGeneration = 0;
private renderTimer: number | null = null;
private refreshTimer: number | null = null;
private status: string | null = null;
@ -76,6 +76,7 @@ export class CodexThreadsView extends ItemView {
override async onClose(): Promise<void> {
this.connectionGeneration += 1;
this.refreshGeneration += 1;
this.connectingPromise = null;
if (this.renderTimer !== null) {
this.containerEl.win.clearTimeout(this.renderTimer);
@ -90,28 +91,33 @@ export class CodexThreadsView extends ItemView {
}
async refresh(): Promise<void> {
const generation = this.connectionGeneration;
const connectionGeneration = this.connectionGeneration;
const refreshGeneration = ++this.refreshGeneration;
this.loading = true;
this.status = this.threads.length === 0 ? "Loading threads..." : null;
this.render();
try {
await this.ensureConnected();
if (generation !== this.connectionGeneration || !this.client) return;
if (this.isStaleRefresh(connectionGeneration, refreshGeneration) || !this.client) return;
const response = await this.client.listThreads(this.plugin.vaultPath);
if (generation !== this.connectionGeneration) return;
if (this.isStaleRefresh(connectionGeneration, refreshGeneration)) return;
this.threads = response.data;
this.status = response.data.length === 0 ? "No threads" : null;
} catch (error) {
if (error instanceof StaleConnectionError) return;
this.status = error instanceof Error ? error.message : String(error);
} finally {
if (generation === this.connectionGeneration) {
if (!this.isStaleRefresh(connectionGeneration, refreshGeneration)) {
this.loading = false;
this.render();
}
}
}
private isStaleRefresh(connectionGeneration: number, refreshGeneration: number): boolean {
return connectionGeneration !== this.connectionGeneration || refreshGeneration !== this.refreshGeneration;
}
private async ensureConnected(): Promise<void> {
if (this.connection.isConnected()) {
this.client = this.connection.currentClient();
@ -180,12 +186,7 @@ export class CodexThreadsView extends ItemView {
}
private async openThread(threadId: string): Promise<void> {
const live = threadRows(this.threads, this.plugin.getOpenPanelSnapshots(), this.renameDrafts).find(
(row) => row.thread.id === threadId,
)?.live;
if (live && (await this.plugin.focusOpenPanel(live.viewId))) return;
if (await this.plugin.openThreadInIdleEmptyView(threadId)) return;
await this.plugin.openThreadInNewView(threadId);
await this.plugin.openThreadInAvailableView(threadId);
}
private startRename(threadId: string, value: string): void {
@ -213,8 +214,7 @@ export class CodexThreadsView extends ItemView {
if (!this.client) return;
await this.client.setThreadName(threadId, name);
this.renameDrafts.delete(threadId);
this.plugin.refreshOpenThreadLists();
await this.refresh();
this.plugin.notifyThreadRenamed(threadId, name);
} catch (error) {
this.status = error instanceof Error ? error.message : String(error);
this.render();
@ -232,8 +232,7 @@ export class CodexThreadsView extends ItemView {
}
await this.client.archiveThread(threadId);
this.renameDrafts.delete(threadId);
this.plugin.refreshOpenThreadLists();
await this.refresh();
this.plugin.notifyThreadArchived(threadId);
} catch (error) {
this.status = error instanceof Error ? error.message : String(error);
this.render();

View file

@ -13,6 +13,25 @@ import { persistedChatTurnDiffViewState, type ChatTurnDiffViewState } from "./fe
const BOOT_RESTORED_PANEL_LOAD_DELAY_MS = 1_000;
const BOOT_RESTORED_PANEL_LOAD_STAGGER_MS = 250;
type ThreadPanelTarget =
| {
kind: "open";
leaf: WorkspaceLeaf;
view: CodexChatView;
}
| {
kind: "restored";
leaf: WorkspaceLeaf;
}
| {
kind: "empty";
leaf: WorkspaceLeaf;
view: CodexChatView;
}
| {
kind: "new";
};
export default class CodexPanelPlugin extends Plugin {
settings: CodexPanelSettings = DEFAULT_SETTINGS;
vaultPath = "";
@ -84,32 +103,42 @@ export default class CodexPanelPlugin extends Plugin {
return view;
}
async activateNewView(): Promise<CodexChatView> {
async activateNewView(options: { connect?: boolean } = {}): Promise<CodexChatView> {
const leaf = this.createRightSidebarTab();
if (!leaf) throw new Error("Could not create a right sidebar leaf.");
await leaf.setViewState({ type: VIEW_TYPE_CODEX_PANEL, active: true });
await this.app.workspace.revealLeaf(leaf);
const view = leaf.view as CodexChatView;
await view.connect();
if (options.connect !== false) await view.connect();
return view;
}
async openThreadInNewView(threadId: string): Promise<CodexChatView> {
const view = await this.activateNewView();
const view = await this.activateThreadResumeView();
await view.openThread(threadId);
return view;
}
async openThreadInAvailableView(threadId: string): Promise<void> {
const target = this.findThreadPanelTarget(threadId);
await this.openThreadInTarget(target, threadId);
}
async focusThreadInOpenView(threadId: string): Promise<boolean> {
const target = this.findOpenThreadPanelTarget(threadId) ?? this.findRestoredThreadPanelTarget(threadId);
if (!target) return false;
await this.openThreadInTarget(target, threadId);
return true;
}
async openThreadInIdleEmptyView(threadId: string): Promise<boolean> {
for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL)) {
if (!(leaf.view instanceof CodexChatView)) continue;
if (!isIdleEmptyPanelSnapshot(leaf.view.openPanelSnapshot())) continue;
await this.app.workspace.revealLeaf(leaf);
await leaf.view.openThread(threadId);
return true;
}
return false;
const target = this.findIdleEmptyThreadPanelTarget();
if (!target) return false;
await this.openThreadInTarget(target, threadId);
return true;
}
async activateThreadsView(): Promise<CodexThreadsView> {
@ -170,16 +199,31 @@ export default class CodexPanelPlugin extends Plugin {
}
}
notifyThreadArchived(threadId: string): void {
this.notifyOpenPanels((view) => {
view.notifyThreadArchived(threadId);
});
this.refreshThreadSurfaces();
}
notifyThreadRenamed(threadId: string, name: string): void {
this.notifyOpenPanels((view) => {
view.notifyThreadRenamed(threadId, name);
});
this.refreshThreadSurfaces();
}
getOpenPanelSnapshots(): OpenCodexPanelSnapshot[] {
return this.app.workspace
.getLeavesOfType(VIEW_TYPE_CODEX_PANEL)
.flatMap((leaf) => (leaf.view instanceof CodexChatView ? [leaf.view.openPanelSnapshot()] : []));
}
async focusOpenPanel(viewId: string): Promise<boolean> {
async focusOpenPanel(viewId: string, threadId: string | null = null): Promise<boolean> {
for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL)) {
if (leaf.view instanceof CodexChatView && leaf.view.openPanelSnapshot().viewId === viewId) {
await this.app.workspace.revealLeaf(leaf);
await leaf.view.focusThread(threadId);
return true;
}
}
@ -206,6 +250,78 @@ export default class CodexPanelPlugin extends Plugin {
return workspace.createLeafInParent(existing.parent, Number.MAX_SAFE_INTEGER);
}
private activateThreadResumeView(): Promise<CodexChatView> {
return this.activateNewView({ connect: false });
}
private findThreadPanelTarget(threadId: string): ThreadPanelTarget {
return (
this.findOpenThreadPanelTarget(threadId) ??
this.findRestoredThreadPanelTarget(threadId) ??
this.findIdleEmptyThreadPanelTarget() ?? { kind: "new" }
);
}
private findOpenThreadPanelTarget(threadId: string): ThreadPanelTarget | null {
for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL)) {
if (!(leaf.view instanceof CodexChatView)) continue;
if (leaf.view.openPanelSnapshot().threadId !== threadId) continue;
return { kind: "open", leaf, view: leaf.view };
}
return null;
}
private findRestoredThreadPanelTarget(threadId: string): ThreadPanelTarget | null {
for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL)) {
if (leaf.view instanceof CodexChatView) continue;
if (restoredPanelThreadId(leaf) !== threadId) continue;
return { kind: "restored", leaf };
}
return null;
}
private findIdleEmptyThreadPanelTarget(): ThreadPanelTarget | null {
for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL)) {
if (!(leaf.view instanceof CodexChatView)) continue;
if (!isIdleEmptyPanelSnapshot(leaf.view.openPanelSnapshot())) continue;
return { kind: "empty", leaf, view: leaf.view };
}
return null;
}
private async openThreadInTarget(target: ThreadPanelTarget, threadId: string): Promise<void> {
switch (target.kind) {
case "open":
await this.app.workspace.revealLeaf(target.leaf);
await target.view.focusThread(threadId);
return;
case "restored":
await this.app.workspace.revealLeaf(target.leaf);
await target.leaf.loadIfDeferred();
if (target.leaf.view instanceof CodexChatView) {
await target.leaf.view.focusThread(threadId);
}
return;
case "empty":
await this.app.workspace.revealLeaf(target.leaf);
await target.view.openThread(threadId);
return;
case "new":
await this.openThreadInNewView(threadId);
return;
}
}
private notifyOpenPanels(callback: (view: CodexChatView) => void): void {
for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL)) {
if (leaf.view instanceof CodexChatView) callback(leaf.view);
}
}
private refreshThreadSurfaces(): void {
this.refreshOpenThreadLists();
}
private scheduleBootRestoredPanelLoads(): void {
this.scheduleBootRestoredPanelLoadTimer(() => {
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL);
@ -246,3 +362,10 @@ function isIdleEmptyPanelSnapshot(snapshot: OpenCodexPanelSnapshot): boolean {
!snapshot.hasComposerDraft
);
}
function restoredPanelThreadId(leaf: WorkspaceLeaf): string | null {
const state = leaf.getViewState().state;
if (!state || typeof state !== "object") return null;
const threadId = (state as { threadId?: unknown }).threadId;
return typeof threadId === "string" && threadId.length > 0 ? threadId : null;
}

View file

@ -0,0 +1,94 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import { ChatMessageRenderer } from "../../../src/features/chat/chat-message-renderer";
import { createChatState } from "../../../src/features/chat/chat-state";
import { installObsidianDomShims } from "./ui/dom-test-helpers";
installObsidianDomShims();
describe("ChatMessageRenderer scroll pinning", () => {
it("does not force the bottom into view when the user is reading older messages", async () => {
const state = createChatState();
state.activeThreadId = "thread";
state.displayItems = [{ id: "message", kind: "message", role: "assistant", text: "Initial message", turnId: "turn" }];
const parent = document.createElement("div");
const renderer = chatMessageRenderer(state);
renderer.render(parent);
const messages = parent.querySelector<HTMLElement>(".codex-panel__messages");
expect(messages).not.toBeNull();
if (!messages) return;
await settleMessageRender(messages);
Object.defineProperty(messages, "scrollHeight", { value: 1000, configurable: true });
Object.defineProperty(messages, "clientHeight", { value: 100, configurable: true });
messages.scrollTop = 100;
messages.dispatchEvent(new Event("scroll"));
expect(state.messagesPinnedToBottom).toBe(false);
const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView");
scrollIntoView.mockClear();
state.displayItems = [{ id: "message", kind: "message", role: "assistant", text: "Updated streaming message", turnId: "turn" }];
renderer.render(parent);
await settleMessageRender(messages);
expect(scrollIntoView).not.toHaveBeenCalled();
expect(state.messagesPinnedToBottom).toBe(false);
});
it("does not run a pending bottom pin after the user scrolls away", async () => {
const state = createChatState();
state.activeThreadId = "thread";
state.displayItems = [{ id: "message", kind: "message", role: "assistant", text: "Streaming message", turnId: "turn" }];
const parent = document.createElement("div");
const renderer = chatMessageRenderer(state);
const messages = parent.createDiv({ cls: "codex-panel__messages" });
Object.defineProperty(messages, "scrollHeight", { value: 1000, configurable: true });
Object.defineProperty(messages, "clientHeight", { value: 100, configurable: true });
messages.scrollTop = 920;
renderer.render(parent);
const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView");
scrollIntoView.mockClear();
messages.scrollTop = 100;
messages.dispatchEvent(new Event("scroll"));
await settleMessageRender(messages);
expect(scrollIntoView).not.toHaveBeenCalled();
expect(state.messagesPinnedToBottom).toBe(false);
});
});
function chatMessageRenderer(state = createChatState()): ChatMessageRenderer {
return new ChatMessageRenderer({
app: {
workspace: {
getActiveFile: vi.fn(() => null),
openLinkText: vi.fn(),
},
} as never,
owner: {} as never,
state,
vaultPath: "/vault",
blockSignatures: new Map(),
consumeScrollIntent: () => "auto",
loadOlderTurns: vi.fn(),
rollbackThread: vi.fn(),
implementPlan: vi.fn(),
openTurnDiff: vi.fn(),
pendingRequestsSignature: () => "",
renderPendingRequests: () => null,
});
}
async function settleMessageRender(element: HTMLElement): Promise<void> {
await Promise.resolve();
await new Promise<void>((resolve) => {
element.win.requestAnimationFrame(() => {
resolve();
});
});
}

View file

@ -3,6 +3,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 { notices } from "../../mocks/obsidian";
import { installObsidianDomShims } from "./ui/dom-test-helpers";
@ -135,6 +136,7 @@ describe("CodexChatView connection lifecycle", () => {
await view.onOpen();
await vi.advanceTimersByTimeAsync(1_500);
expect(connectionMock.state.connectCalls).toBe(0);
expect(client.resumeThread).not.toHaveBeenCalled();
await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never);
@ -144,6 +146,22 @@ describe("CodexChatView connection lifecycle", () => {
expect(client.threadTurnsList).toHaveBeenCalledWith("thread-1", null, 20);
});
it("hydrates a focused restored thread immediately", async () => {
vi.useFakeTimers();
const client = connectedClient();
connectionMock.state.client = client;
const view = await chatView();
await view.setState({ threadId: "thread-1", threadTitle: "Restored thread" }, {} as never);
await view.onOpen();
expect(client.resumeThread).not.toHaveBeenCalled();
await view.focusThread("thread-1");
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;
@ -170,6 +188,235 @@ describe("CodexChatView connection lifecycle", () => {
expect(requestSaveLayout).toHaveBeenCalledTimes(1);
});
it("resets to an unstarted empty chat without starting a thread", async () => {
const requestSaveLayout = vi.fn();
const client = connectedClient();
connectionMock.state.client = client;
const view = await chatView({ requestSaveLayout });
await view.openThread("thread-1");
await view.startNewThread();
expect(client.startThread).not.toHaveBeenCalled();
expect(view.getState()).toEqual({ version: 1 });
expect(view.openPanelSnapshot()).toMatchObject({ threadId: null, busy: false, activeTurnId: null, hasComposerDraft: false });
expect(requestSaveLayout).toHaveBeenCalledTimes(2);
});
it("starts a thread only when /new includes a message to send", async () => {
const client = connectedClient({
startThread: vi.fn().mockResolvedValue(startedThread("thread-new")),
});
connectionMock.state.client = client;
const view = await chatView();
await view.onOpen();
await view.onOpen();
await view.connect();
view.setComposerText("/new hello");
await (view as unknown as { submitComposerAction: () => Promise<void> }).submitComposerAction();
expect(client.startThread).toHaveBeenCalledOnce();
expect(client.startTurn).toHaveBeenCalledWith("thread-new", "/vault", [{ type: "text", text: "hello", text_elements: [] }]);
});
it("routes slash resume through the shared panel selection path", async () => {
const openThreadInAvailableView = vi.fn().mockResolvedValue(undefined);
const host = chatHost({
openThreadInAvailableView,
});
connectionMock.state.client = connectedClient({
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture("thread-1")] }),
});
const view = await chatView({ host });
await view.connect();
view.setComposerText("/resume thread-1");
await (view as unknown as { submitComposerAction: () => Promise<void> }).submitComposerAction();
expect(openThreadInAvailableView).toHaveBeenCalledWith("thread-1");
expect(connectionMock.state.client["resumeThread"]).not.toHaveBeenCalled();
});
it("keeps resumed messages pinned to bottom after slash resume in the same empty panel", async () => {
const client = connectedClient({
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture("thread-1")] }),
threadTurnsList: vi.fn().mockResolvedValue({ data: [turnWithUserMessage("restored prompt")], nextCursor: null }),
});
connectionMock.state.client = client;
const view = await chatView({
host: chatHost({
openThreadInAvailableView: async (threadId) => {
await view.openThread(threadId);
},
}),
});
await view.onOpen();
await view.connect();
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;
view.setComposerText("/resume thread-1");
await (view as unknown as { submitComposerAction: () => Promise<void> }).submitComposerAction();
await new Promise<void>((resolve) => {
messages.win.requestAnimationFrame(() => {
resolve();
});
});
const renderedMessages = view.containerEl.querySelector<HTMLElement>(".codex-panel__messages");
expect(renderedMessages?.scrollTop).toBe(1000);
});
it("routes slash archive through shared panel notifications", async () => {
const notifyThreadArchived = vi.fn();
const host = chatHost({
notifyThreadArchived,
});
const client = connectedClient({
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture("thread-1")] }),
});
connectionMock.state.client = client;
const view = await chatView({ host });
await view.connect();
view.setComposerText("/archive thread-1");
await (view as unknown as { submitComposerAction: () => Promise<void> }).submitComposerAction();
expect(client.archiveThread).toHaveBeenCalledWith("thread-1");
expect(notifyThreadArchived).toHaveBeenCalledWith("thread-1");
});
it("clears the active thread when another view archives it", async () => {
const requestSaveLayout = vi.fn();
const client = connectedClient();
connectionMock.state.client = client;
const view = await chatView({ requestSaveLayout });
await view.openThread("thread-1");
view.notifyThreadArchived("thread-1");
expect(view.getState()).toEqual({ version: 1 });
expect(requestSaveLayout).toHaveBeenCalledTimes(2);
});
it("updates restored panel title from shared rename notifications", async () => {
const view = await chatView();
await view.setState({ threadId: "thread-1", threadTitle: "Before rename" }, {} as never);
view.notifyThreadRenamed("thread-1", "After rename");
expect(view.getDisplayText()).toBe("Codex: After rename");
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "After rename" });
});
it("scrolls resumed messages to the bottom after history hydrates", async () => {
const client = connectedClient({
threadTurnsList: vi.fn().mockResolvedValue({ data: [turnWithUserMessage("restored prompt")], nextCursor: null }),
});
connectionMock.state.client = client;
const view = await chatView();
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;
await view.openThread("thread-1");
await new Promise<void>((resolve) => {
messages.win.requestAnimationFrame(() => {
resolve();
});
});
const renderedMessages = view.containerEl.querySelector<HTMLElement>(".codex-panel__messages");
expect(renderedMessages?.scrollTop).toBe(1000);
});
it("renders resumed thread metadata before history hydration completes", async () => {
const history = deferred<{ data: unknown[]; nextCursor: null }>();
const client = connectedClient({
threadTurnsList: vi.fn(() => history.promise),
});
connectionMock.state.client = client;
const view = await chatView();
const opening = view.openThread("thread-1");
await vi.waitFor(() => {
expect(client.threadTurnsList).toHaveBeenCalledWith("thread-1", null, 20);
});
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" });
expect(view.containerEl.textContent).toContain("Loading thread...");
history.resolve({ data: [], nextCursor: null });
await opening;
});
it("ignores stale resume results when another thread is opened first", async () => {
const firstResume = deferred<ReturnType<typeof resumedThread>>();
const secondResume = deferred<ReturnType<typeof resumedThread>>();
const client = connectedClient({
resumeThread: vi.fn((threadId: string) => (threadId === "thread-1" ? firstResume.promise : secondResume.promise)),
});
connectionMock.state.client = client;
const view = await chatView();
const firstOpen = view.openThread("thread-1");
await vi.waitFor(() => {
expect(client.resumeThread).toHaveBeenCalledWith("thread-1", "/vault");
});
const secondOpen = view.openThread("thread-2");
await vi.waitFor(() => {
expect(client.resumeThread).toHaveBeenCalledWith("thread-2", "/vault");
});
secondResume.resolve(resumedThread("thread-2"));
await secondOpen;
firstResume.resolve(resumedThread("thread-1"));
await firstOpen;
expect(view.getState()).toEqual({ version: 1, threadId: "thread-2", threadTitle: "Restored thread" });
expect(client.threadTurnsList).toHaveBeenCalledTimes(1);
expect(client.threadTurnsList).toHaveBeenCalledWith("thread-2", null, 20);
});
it("invalidates stale history hydration when a second resume starts", async () => {
const firstHistory = deferred<{ data: unknown[]; nextCursor: null }>();
const client = connectedClient({
resumeThread: vi.fn((threadId: string) => Promise.resolve(resumedThread(threadId))),
threadTurnsList: vi.fn((threadId: string) =>
threadId === "thread-1" ? firstHistory.promise : Promise.resolve({ data: [], nextCursor: null }),
),
});
connectionMock.state.client = client;
const view = await chatView();
const firstOpen = view.openThread("thread-1");
await vi.waitFor(() => {
expect(client.threadTurnsList).toHaveBeenCalledWith("thread-1", null, 20);
});
const secondOpen = view.openThread("thread-2");
await vi.waitFor(() => {
expect(client.threadTurnsList).toHaveBeenCalledWith("thread-2", null, 20);
});
firstHistory.resolve({ data: [turnWithUserMessage("first prompt")], nextCursor: null });
await firstOpen;
await secondOpen;
expect(view.getState()).toEqual({ version: 1, threadId: "thread-2", threadTitle: "Restored thread" });
expect(view.containerEl.textContent).not.toContain("first prompt");
});
it("scrolls restored messages to the bottom when the panel is focused", async () => {
const activeLeafChangeListeners: ((leaf: unknown) => void)[] = [];
const view = await chatView({ activeLeafChangeListeners });
@ -209,9 +456,28 @@ function baseClient() {
listSkills: vi.fn().mockResolvedValue({ data: [] }),
readAccountRateLimits: vi.fn().mockResolvedValue({ rateLimits: null }),
listThreads: vi.fn().mockResolvedValue({ data: [] }),
startThread: vi.fn().mockResolvedValue(startedThread("thread-new")),
resumeThread: vi.fn().mockResolvedValue(resumedThread("thread-1")),
threadTurnsList: vi.fn().mockResolvedValue({ data: [], nextCursor: null }),
startTurn: vi.fn().mockResolvedValue({ turn: { id: "turn-1" } }),
archiveThread: vi.fn().mockResolvedValue({}),
};
}
function startedThread(threadId: string) {
return {
thread: {
id: threadId,
name: null,
preview: "",
cwd: "/vault",
cliVersion: "0.0.0",
},
cwd: "/vault",
model: null,
reasoningEffort: null,
serviceTier: null,
approvalsReviewer: null,
};
}
@ -232,7 +498,71 @@ function resumedThread(threadId: string) {
};
}
async function chatView(options: { activeLeafChangeListeners?: ((leaf: unknown) => void)[]; requestSaveLayout?: () => void } = {}) {
function threadFixture(threadId: string) {
return {
id: threadId,
sessionId: "session",
forkedFromId: null,
preview: "Restored thread",
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: [],
};
}
function turnWithUserMessage(text: string) {
return {
id: "turn-1",
startedAt: 1,
completedAt: 2,
items: [{ type: "userMessage", id: "user-1", content: [{ type: "text", text, text_elements: [] }] }],
};
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void;
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve;
});
return { promise, resolve };
}
function chatHost(overrides: Partial<CodexChatHost> = {}): CodexChatHost {
return {
settings: {
...DEFAULT_SETTINGS,
codexPath: "codex",
sendShortcut: "enter",
},
vaultPath: "/vault",
openThreadInNewView: vi.fn(),
openThreadInAvailableView: vi.fn().mockResolvedValue(undefined),
openTurnDiff: vi.fn(),
notifyThreadArchived: vi.fn(),
notifyThreadRenamed: vi.fn(),
refreshOpenThreadLists: vi.fn(),
refreshThreadsViewLiveState: vi.fn(),
refreshThreadsViewThreadList: vi.fn(),
...overrides,
};
}
async function chatView(
options: { activeLeafChangeListeners?: ((leaf: unknown) => void)[]; host?: CodexChatHost; requestSaveLayout?: () => void } = {},
) {
const host = options.host ?? chatHost();
const { CodexChatView } = await import("../../../src/features/chat/view");
const containerEl = document.createElement("div");
containerEl.createDiv();
@ -256,16 +586,6 @@ async function chatView(options: { activeLeafChangeListeners?: ((leaf: unknown)
},
containerEl,
} as never,
{
settings: {
...DEFAULT_SETTINGS,
codexPath: "codex",
sendShortcut: "enter",
},
vaultPath: "/vault",
openThreadInNewView: vi.fn(),
openTurnDiff: vi.fn(),
refreshOpenThreadLists: vi.fn(),
},
host,
);
}

View file

@ -92,32 +92,53 @@ describe("CodexThreadsView", () => {
expect(view.containerEl.textContent).not.toContain("Late thread");
});
it("reuses an idle empty panel before opening a new panel", async () => {
it("ignores stale refresh results when a newer refresh completes first", async () => {
let resolveFirst!: (value: unknown) => void;
let resolveSecond!: (value: unknown) => void;
const listThreads = vi
.fn()
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirst = resolve;
}),
)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSecond = resolve;
}),
);
connectionMock.state.client = clientFixture({
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
listThreads,
});
const host = threadsHost({
openThreadInIdleEmptyView: vi.fn().mockResolvedValue(true),
openThreadInNewView: vi.fn(),
});
const view = await threadsView(host);
await view.refresh();
view.containerEl.querySelector<HTMLElement>(".codex-panel-threads__row")?.click();
const view = await threadsView();
const firstRefresh = view.refresh();
await vi.waitFor(() => {
expect(host.openThreadInIdleEmptyView).toHaveBeenCalledWith("thread");
expect(listThreads).toHaveBeenCalledTimes(1);
});
expect(host.openThreadInNewView).not.toHaveBeenCalled();
const secondRefresh = view.refresh();
await vi.waitFor(() => {
expect(listThreads).toHaveBeenCalledTimes(2);
});
resolveSecond({ data: [threadFixture({ id: "second", preview: "Second thread" })] });
await secondRefresh;
expect(view.containerEl.textContent).toContain("Second thread");
resolveFirst({ data: [threadFixture({ id: "first", preview: "First thread" })] });
await firstRefresh;
expect(view.containerEl.textContent).toContain("Second thread");
expect(view.containerEl.textContent).not.toContain("First thread");
});
it("opens a new panel when no idle empty panel can be reused", async () => {
it("opens selected threads through the shared panel selection path", async () => {
connectionMock.state.client = clientFixture({
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
});
const host = threadsHost({
openThreadInIdleEmptyView: vi.fn().mockResolvedValue(false),
openThreadInNewView: vi.fn(),
openThreadInAvailableView: vi.fn().mockResolvedValue(undefined),
});
const view = await threadsView(host);
@ -125,8 +146,52 @@ describe("CodexThreadsView", () => {
view.containerEl.querySelector<HTMLElement>(".codex-panel-threads__row")?.click();
await vi.waitFor(() => {
expect(host.openThreadInIdleEmptyView).toHaveBeenCalledWith("thread");
expect(host.openThreadInNewView).toHaveBeenCalledWith("thread");
expect(host.openThreadInAvailableView).toHaveBeenCalledWith("thread");
});
});
it("notifies open panels after archiving a thread", async () => {
const archiveThread = vi.fn().mockResolvedValue({});
connectionMock.state.client = clientFixture({
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
archiveThread,
});
const host = threadsHost({
notifyThreadArchived: vi.fn(),
});
const view = await threadsView(host);
await view.refresh();
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Archive thread"]')?.click();
await vi.waitFor(() => {
expect(archiveThread).toHaveBeenCalledWith("thread");
expect(host.notifyThreadArchived).toHaveBeenCalledWith("thread");
});
});
it("notifies open panels after renaming a thread", async () => {
const setThreadName = vi.fn().mockResolvedValue({});
connectionMock.state.client = clientFixture({
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
setThreadName,
});
const host = threadsHost({
notifyThreadRenamed: vi.fn(),
});
const view = await threadsView(host);
await view.refresh();
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
const input = view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input");
expect(input).not.toBeNull();
if (!input) return;
input.value = "Renamed thread";
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Save thread name"]')?.click();
await vi.waitFor(() => {
expect(setThreadName).toHaveBeenCalledWith("thread", "Renamed thread");
expect(host.notifyThreadRenamed).toHaveBeenCalledWith("thread", "Renamed thread");
});
});
});
@ -134,6 +199,8 @@ describe("CodexThreadsView", () => {
function clientFixture(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
listThreads: vi.fn().mockResolvedValue({ data: [] }),
archiveThread: vi.fn().mockResolvedValue({}),
setThreadName: vi.fn().mockResolvedValue({}),
rejectServerRequest: vi.fn(),
...overrides,
};
@ -146,10 +213,10 @@ function threadsHost(overrides: Record<string, unknown> = {}) {
codexPath: "codex",
},
vaultPath: "/vault",
openThreadInNewView: vi.fn(),
openThreadInIdleEmptyView: vi.fn().mockResolvedValue(false),
openThreadInAvailableView: vi.fn().mockResolvedValue(undefined),
getOpenPanelSnapshots: vi.fn(() => []),
focusOpenPanel: vi.fn(),
notifyThreadArchived: vi.fn(),
notifyThreadRenamed: vi.fn(),
refreshOpenThreadLists: vi.fn(),
...overrides,
};

View file

@ -4,6 +4,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
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 { installObsidianDomShims } from "./features/chat/ui/dom-test-helpers";
installObsidianDomShims();
describe("CodexPanelPlugin boot restored panel loading", () => {
beforeEach(() => {
@ -41,6 +46,120 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
expect(firstLeaf.loadIfDeferred).not.toHaveBeenCalled();
});
it("loads and focuses a deferred restored panel before opening another panel", async () => {
const restoredLeaf = leaf({ state: { threadId: "thread-1", threadTitle: "Restored thread" } });
const plugin = await pluginWithLeaves([restoredLeaf]);
const { CodexChatView } = await import("../src/features/chat/view");
restoredLeaf.loadIfDeferred.mockImplementation(async () => {
restoredLeaf.view = chatView(CodexChatView, restoredLeaf);
});
await plugin.openThreadInAvailableView("thread-1");
expect(restoredLeaf.loadIfDeferred).toHaveBeenCalledTimes(1);
expect(restoredLeaf.view).toBeInstanceOf(CodexChatView);
});
it("focuses an already open thread before reusing an empty panel", async () => {
const { CodexChatView } = await import("../src/features/chat/view");
const openLeaf = leaf();
openLeaf.view = chatView(CodexChatView, openLeaf);
const openView = openLeaf.view as CodexChatView;
vi.spyOn(openView, "openPanelSnapshot").mockReturnValue({
viewId: "open-view",
threadId: "thread-1",
busy: false,
activeTurnId: null,
pendingApprovals: 0,
pendingUserInputs: 0,
hasComposerDraft: false,
connected: true,
});
vi.spyOn(openView, "focusThread").mockResolvedValue(undefined);
const emptyLeaf = leaf();
emptyLeaf.view = chatView(CodexChatView, emptyLeaf);
const emptyView = emptyLeaf.view as CodexChatView;
const openEmptyThread = vi.spyOn(emptyView, "openThread").mockResolvedValue(undefined);
const plugin = await pluginWithLeaves([openLeaf, emptyLeaf]);
await plugin.openThreadInAvailableView("thread-1");
expect((plugin.app.workspace.revealLeaf as ReturnType<typeof vi.fn>).mock.calls).toContainEqual([openLeaf]);
expect(openEmptyThread).not.toHaveBeenCalled();
});
it("reuses an idle empty panel before opening a new panel", async () => {
const { CodexChatView } = await import("../src/features/chat/view");
const busyLeaf = leaf();
busyLeaf.view = chatView(CodexChatView, busyLeaf);
const busyView = busyLeaf.view as CodexChatView;
vi.spyOn(busyView, "openPanelSnapshot").mockReturnValue({
viewId: "busy-view",
threadId: "other-thread",
busy: false,
activeTurnId: null,
pendingApprovals: 0,
pendingUserInputs: 0,
hasComposerDraft: false,
connected: true,
});
const emptyLeaf = leaf();
emptyLeaf.view = chatView(CodexChatView, emptyLeaf);
const emptyView = emptyLeaf.view as CodexChatView;
vi.spyOn(emptyView, "openPanelSnapshot").mockReturnValue({
viewId: "empty-view",
threadId: null,
busy: false,
activeTurnId: null,
pendingApprovals: 0,
pendingUserInputs: 0,
hasComposerDraft: false,
connected: true,
});
const openEmptyThread = vi.spyOn(emptyView, "openThread").mockResolvedValue(undefined);
const plugin = await pluginWithLeaves([busyLeaf, emptyLeaf]);
await plugin.openThreadInAvailableView("thread-1");
expect(openEmptyThread).toHaveBeenCalledWith("thread-1");
});
it("opens a thread in a new panel without a separate pre-connect", async () => {
const newLeaf = leaf();
const plugin = await pluginWithLeaves([]);
(plugin.app.workspace.getRightLeaf as ReturnType<typeof vi.fn>).mockReturnValue(newLeaf);
const { CodexChatView } = await import("../src/features/chat/view");
const view = chatView(CodexChatView, newLeaf);
newLeaf.setViewState.mockImplementation(async () => {
newLeaf.view = view;
});
const connect = vi.spyOn(view, "connect").mockResolvedValue(undefined);
const openThread = vi.spyOn(view, "openThread").mockResolvedValue(undefined);
await plugin.openThreadInNewView("thread-1");
expect(connect).not.toHaveBeenCalled();
expect(openThread).toHaveBeenCalledWith("thread-1");
});
it("refreshes open thread lists after archive lifecycle notifications", async () => {
const plugin = await pluginWithLeaves([]);
const refreshOpenThreadLists = vi.spyOn(plugin, "refreshOpenThreadLists").mockImplementation(() => undefined);
plugin.notifyThreadArchived("thread-1");
expect(refreshOpenThreadLists).toHaveBeenCalledOnce();
});
it("refreshes open thread lists after rename lifecycle notifications", async () => {
const plugin = await pluginWithLeaves([]);
const refreshOpenThreadLists = vi.spyOn(plugin, "refreshOpenThreadLists").mockImplementation(() => undefined);
plugin.notifyThreadRenamed("thread-1", "Renamed thread");
expect(refreshOpenThreadLists).toHaveBeenCalledOnce();
});
});
async function pluginWithLeaves(leaves: ReturnType<typeof leaf>[]) {
@ -54,14 +173,57 @@ async function pluginWithLeaves(leaves: ReturnType<typeof leaf>[]) {
},
workspace: {
getLeavesOfType: vi.fn((type: string) => (type === VIEW_TYPE_CODEX_PANEL ? leaves : [])),
revealLeaf: vi.fn().mockResolvedValue(undefined),
getRightLeaf: vi.fn(() => null),
},
} as never,
{} as never,
);
}
function leaf() {
type TestLeaf = ReturnType<typeof leaf>;
function leaf(options: { state?: Record<string, unknown> } = {}) {
return {
view: null as unknown,
getViewState: vi.fn(() => ({ type: VIEW_TYPE_CODEX_PANEL, state: options.state ?? {} })),
setViewState: vi.fn().mockResolvedValue(undefined),
loadIfDeferred: vi.fn().mockResolvedValue(undefined),
};
}
function chatView(CodexChatViewCtor: typeof CodexChatView, leaf: TestLeaf) {
const containerEl = document.createElement("div");
containerEl.createDiv();
containerEl.createDiv();
return new CodexChatViewCtor(
{
...leaf,
app: {
workspace: {
getActiveFile: vi.fn(() => null),
on: vi.fn(() => ({})),
openLinkText: vi.fn(),
requestSaveLayout: vi.fn(),
},
vault: {
on: vi.fn(() => ({})),
getMarkdownFiles: vi.fn(() => []),
},
},
containerEl,
} as never,
{
settings: { ...DEFAULT_SETTINGS, codexPath: "codex", sendShortcut: "enter" },
vaultPath: "/vault",
openThreadInNewView: vi.fn(),
openThreadInAvailableView: vi.fn(),
openTurnDiff: vi.fn(),
notifyThreadArchived: vi.fn(),
notifyThreadRenamed: vi.fn(),
refreshOpenThreadLists: vi.fn(),
refreshThreadsViewLiveState: vi.fn(),
refreshThreadsViewThreadList: vi.fn(),
},
);
}