mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Stabilize thread restore and resume lifecycle
This commit is contained in:
parent
7db0004d0d
commit
646dcb2ce0
10 changed files with 1006 additions and 159 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
147
src/main.ts
147
src/main.ts
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
94
tests/features/chat/chat-message-renderer.test.ts
Normal file
94
tests/features/chat/chat-message-renderer.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue