diff --git a/src/app-server/query/cache.ts b/src/app-server/query/cache.ts index cbf38086..addbe2ed 100644 --- a/src/app-server/query/cache.ts +++ b/src/app-server/query/cache.ts @@ -43,10 +43,23 @@ interface AppServerQueryOptions { readonly staleTime: number; } +type ActiveThreadsUpdater = (threads: readonly Thread[] | null) => readonly Thread[] | null; + +interface ActiveThreadsMutationOverlay { + readonly version: number; + readonly update: ActiveThreadsUpdater; +} + +interface AppliedActiveThreadsMutationOverlays { + readonly applied: boolean; + readonly threads: readonly Thread[]; +} + export class AppServerQueryCache { readonly client: QueryClient; private readonly clientRunner: AppServerQueryClientRunner | null; private readonly activeThreadsWriteVersions = new Map(); + private readonly activeThreadsMutationOverlays = new Map(); constructor(options: { client?: QueryClient; clientRunner?: AppServerQueryClientRunner } = {}) { this.client = options.client ?? createAppServerQueryClient(); @@ -56,6 +69,7 @@ export class AppServerQueryCache { clear(): void { this.client.clear(); this.activeThreadsWriteVersions.clear(); + this.activeThreadsMutationOverlays.clear(); } clearContext(context: AppServerQueryContext): void { @@ -63,7 +77,9 @@ export class AppServerQueryCache { const filter = appServerQueriesFilter(context); void this.client.cancelQueries(filter); this.client.removeQueries(filter); - this.activeThreadsWriteVersions.delete(this.activeThreadsCacheKey(context)); + const key = this.activeThreadsCacheKey(context); + this.activeThreadsWriteVersions.delete(key); + this.activeThreadsMutationOverlays.delete(key); } activeThreadsSnapshot(context: AppServerQueryContext): readonly Thread[] | null { @@ -98,17 +114,18 @@ export class AppServerQueryCache { setActiveThreads(context: AppServerQueryContext, threads: readonly Thread[]): void { if (!appServerQueryContextIsComplete(context)) return; this.bumpActiveThreadsWriteVersion(context); + this.activeThreadsMutationOverlays.delete(this.activeThreadsCacheKey(context)); this.client.setQueryData(activeThreadsQueryKey(context), cloneThreads(threads)); } - updateActiveThreads( - context: AppServerQueryContext, - updater: (threads: readonly Thread[] | null) => readonly Thread[] | null, - ): readonly Thread[] | null { + updateActiveThreads(context: AppServerQueryContext, updater: ActiveThreadsUpdater): readonly Thread[] | null { + if (!appServerQueryContextIsComplete(context)) return null; + const version = this.bumpActiveThreadsWriteVersion(context); + this.recordActiveThreadsMutationOverlay(context, { version, update: updater }); const current = this.activeThreadsSnapshot(context); const next = updater(current); if (!next) return null; - this.setActiveThreads(context, next); + this.client.setQueryData(activeThreadsQueryKey(context), cloneThreads(next), current ? undefined : { updatedAt: 0 }); return cloneThreads(next); } @@ -203,9 +220,15 @@ export class AppServerQueryCache { queryKey: key, queryFn: async (): Promise => { const threads = cloneThreads(await this.runWithClient(refreshContext, (client) => listThreads(client, refreshContext.vaultPath))); - if (this.activeThreadsWriteVersion(refreshContext) !== writeVersion) { - return cloneThreads(this.client.getQueryData(key) ?? threads); + const currentWriteVersion = this.activeThreadsWriteVersion(refreshContext); + if (currentWriteVersion !== writeVersion) { + const overlaid = this.applyActiveThreadsMutationOverlays(refreshContext, threads, writeVersion); + if (overlaid.applied) return cloneThreads(overlaid.threads); + const cached = this.client.getQueryData(key); + if (cached) return cloneThreads(cached); + return threads; } + this.pruneActiveThreadsMutationOverlays(refreshContext, writeVersion); return threads; }, staleTime: ACTIVE_THREADS_STALE_TIME_MS, @@ -216,15 +239,47 @@ export class AppServerQueryCache { return this.activeThreadsWriteVersions.get(this.activeThreadsCacheKey(context)) ?? 0; } - private bumpActiveThreadsWriteVersion(context: AppServerQueryContext): void { + private bumpActiveThreadsWriteVersion(context: AppServerQueryContext): number { const key = this.activeThreadsCacheKey(context); - this.activeThreadsWriteVersions.set(key, (this.activeThreadsWriteVersions.get(key) ?? 0) + 1); + const version = (this.activeThreadsWriteVersions.get(key) ?? 0) + 1; + this.activeThreadsWriteVersions.set(key, version); + return version; } private activeThreadsCacheKey(context: AppServerQueryContext): string { return JSON.stringify(activeThreadsQueryKey(context)); } + private recordActiveThreadsMutationOverlay(context: AppServerQueryContext, overlay: ActiveThreadsMutationOverlay): void { + const key = this.activeThreadsCacheKey(context); + this.activeThreadsMutationOverlays.set(key, [...(this.activeThreadsMutationOverlays.get(key) ?? []), overlay]); + } + + private applyActiveThreadsMutationOverlays( + context: AppServerQueryContext, + threads: readonly Thread[], + afterVersion: number, + ): AppliedActiveThreadsMutationOverlays { + const overlays = this.activeThreadsMutationOverlays + .get(this.activeThreadsCacheKey(context)) + ?.filter((overlay) => overlay.version > afterVersion); + if (!overlays || overlays.length === 0) return { applied: false, threads }; + return { + applied: true, + threads: overlays.reduce((current, overlay) => overlay.update(current) ?? current, threads), + }; + } + + private pruneActiveThreadsMutationOverlays(context: AppServerQueryContext, throughVersion: number): void { + const key = this.activeThreadsCacheKey(context); + const overlays = this.activeThreadsMutationOverlays.get(key)?.filter((overlay) => overlay.version > throughVersion); + if (!overlays || overlays.length === 0) { + this.activeThreadsMutationOverlays.delete(key); + return; + } + this.activeThreadsMutationOverlays.set(key, overlays); + } + private appServerMetadataQueryOptions( context: AppServerQueryContext, options: { forceSkills?: boolean } = {}, diff --git a/src/app-server/threads/data.ts b/src/app-server/threads/data.ts index 69958691..66872d7d 100644 --- a/src/app-server/threads/data.ts +++ b/src/app-server/threads/data.ts @@ -96,6 +96,10 @@ export async function rollbackThread(client: AppServerClient, threadId: string): return threadRollbackSnapshotFromAppServerResponse(await client.rollbackThread(threadId)); } +export function threadFromThreadForkResponse(response: Awaited>): Thread { + return threadFromThreadRecord(response.thread); +} + export async function restoreArchivedThread(client: AppServerClient, threadId: string): Promise { const response = await client.unarchiveThread(threadId); return threadFromThreadRecord(response.thread); diff --git a/src/features/chat/app-server/actions/threads.ts b/src/features/chat/app-server/actions/threads.ts index 03fe20ac..687d6bed 100644 --- a/src/features/chat/app-server/actions/threads.ts +++ b/src/features/chat/app-server/actions/threads.ts @@ -12,7 +12,7 @@ interface StartedThreadSummary { export interface ChatServerThreadActionsHost extends ChatServerActionHost { runtimeSnapshotForState: (state: ChatState) => RuntimeSnapshot; - publishThreadList: (threads: readonly Thread[]) => void; + recordStartedThread: (thread: Thread) => void; syncThreadGoal: (threadId: string) => void; } @@ -60,7 +60,7 @@ async function startThread( preserveRequestedRuntimeSettings: requestState.activeThread.id === null, }); host.stateStore.dispatch(action); - if (action.listedThreads) host.publishThreadList(action.listedThreads); + host.recordStartedThread(action.thread); if (options.syncGoal ?? true) host.syncThreadGoal(response.thread.id); return { threadId: response.thread.id }; } diff --git a/src/features/chat/application/threads/thread-management-actions.ts b/src/features/chat/application/threads/thread-management-actions.ts index 3aa892c9..936ed1cb 100644 --- a/src/features/chat/application/threads/thread-management-actions.ts +++ b/src/features/chat/application/threads/thread-management-actions.ts @@ -1,6 +1,6 @@ import type { AppServerClient } from "../../../../app-server/connection/client"; -import { rollbackThread as rollbackThreadOnAppServer } from "../../../../app-server/threads/data"; -import { inheritedForkThreadName } from "../../../../domain/threads/model"; +import { rollbackThread as rollbackThreadOnAppServer, threadFromThreadForkResponse } from "../../../../app-server/threads/data"; +import { inheritedForkThreadName, type Thread } from "../../../../domain/threads/model"; import type { ThreadOperations } from "../../../threads/thread-operations"; import { archivedSourceOpenForkFailedMessage, @@ -36,6 +36,7 @@ export interface ThreadManagementActionsHost { openThreadInCurrentPanel: (threadId: string) => Promise; notifyActiveThreadIdentityChanged: () => void; refreshAfterThreadMutation: () => Promise; + recordForkedThread: (thread: Thread) => void; } export interface ThreadManagementActions { @@ -65,6 +66,7 @@ async function compactThread(host: ThreadManagementActionsHost, threadId: string const initialActiveThreadId = threadManagementState(host).activeThread.id; try { await client.compactThread(threadId); + if (host.currentClient() !== client) return; if (!threadManagementStillTargetsOriginalPanel(threadManagementState(host), initialActiveThreadId, threadId)) return; host.addSystemMessage(STATUS_COMPACTION_REQUESTED); host.setStatus(STATUS_COMPACTION_REQUESTED); @@ -119,10 +121,13 @@ async function forkThreadFromTurn( try { const sourceName = inheritedForkThreadName(threadId, threadManagementState(host).threadList.listedThreads); const response = await client.forkThread(threadId, host.vaultPath); + if (host.currentClient() !== client) return; const forkedThreadId = response.thread.id; if (turnsToDrop > 0) { await client.rollbackThread(forkedThreadId, turnsToDrop); + if (host.currentClient() !== client) return; } + host.recordForkedThread(threadFromThreadForkResponse(response)); if (!threadManagementStillTargetsOriginalPanel(threadManagementState(host), initialActiveThreadId, threadId)) return; if (sourceName) { try { @@ -182,6 +187,7 @@ async function rollbackThread(host: ThreadManagementActionsHost, threadId: strin try { host.setStatus(STATUS_ROLLBACK_STARTING); const snapshot = await rollbackThreadOnAppServer(client, threadId); + if (host.currentClient() !== client) return; if (!threadManagementStillTargetsPanel(threadManagementState(host), threadId)) return; threadManagementDispatch( host, diff --git a/src/features/chat/host/runtime.ts b/src/features/chat/host/runtime.ts index 7be493b7..64e9a5fd 100644 --- a/src/features/chat/host/runtime.ts +++ b/src/features/chat/host/runtime.ts @@ -3,7 +3,7 @@ import type { App, Component, EventRef } from "obsidian"; import type { AppServerSharedQueries } from "../../../app-server/query/shared-queries"; import type { ArchiveExportAdapter } from "../../../app-server/services/thread-archive-markdown"; import type { CodexPanelSettings } from "../../../settings/model"; -import type { SharedThreadCatalog } from "../../../workspace/shared-thread-catalog"; +import type { ActiveThreadCatalog } from "../../../workspace/active-thread-catalog"; import type { ChatTurnDiffViewState } from "../domain/turn-diff"; export interface CodexChatHost { @@ -22,18 +22,10 @@ interface WorkspacePanels { openThreadInNewView(threadId: string): Promise; focusThreadInOpenView(threadId: string): Promise; openTurnDiff(state: ChatTurnDiffViewState): Promise; + refreshThreadsViewLiveState(): void; } -type ChatThreadCatalog = Pick< - SharedThreadCatalog, - | "archiveThreadInCatalog" - | "renameThreadInCatalog" - | "refreshThreadsViewLiveState" - | "setActiveThreads" - | "refreshActiveThreads" - | "activeThreadsSnapshot" - | "observeActiveThreadsResult" ->; +type ChatThreadCatalog = ActiveThreadCatalog; type ChatAppServerData = Pick< AppServerSharedQueries, diff --git a/src/features/chat/host/session.ts b/src/features/chat/host/session.ts index 8840ae14..4536604a 100644 --- a/src/features/chat/host/session.ts +++ b/src/features/chat/host/session.ts @@ -362,7 +362,7 @@ export class ChatPanelSession implements ChatSurfaceHandle { } private applyCachedAppServerState(): void { - const threads = this.environment.plugin.threadCatalog.activeThreadsSnapshot(); + const threads = this.environment.plugin.threadCatalog.snapshot(); if (threads) this.parts.serverActions.threads.applyThreadList(threads); const metadata = this.environment.plugin.appServerData.appServerMetadataSnapshot(); if (metadata) this.parts.serverActions.metadata.applyAppServerMetadata(metadata); @@ -374,7 +374,7 @@ export class ChatPanelSession implements ChatSurfaceHandle { this.unsubscribeAppServerState(); this.applyCachedAppServerState(); this.appServerStateUnsubscribers.push( - this.environment.plugin.threadCatalog.observeActiveThreadsResult( + this.environment.plugin.threadCatalog.observe( (result) => { this.receiveObservedThreadResult(result); }, @@ -441,7 +441,7 @@ export class ChatPanelSession implements ChatSurfaceHandle { private async loadSharedThreadList(): Promise { try { - const threads = await this.environment.plugin.threadCatalog.refreshActiveThreads(); + const threads = await this.environment.plugin.threadCatalog.refresh(); this.parts.serverActions.threads.applyThreadList(threads); } catch (error) { if (isStaleAppServerSharedQueryContextError(error)) return; @@ -459,7 +459,7 @@ export class ChatPanelSession implements ChatSurfaceHandle { } private refreshLiveState(): void { - this.environment.plugin.threadCatalog.refreshThreadsViewLiveState(); + this.environment.plugin.workspace.refreshThreadsViewLiveState(); } private deferLiveStateRefresh(): void { @@ -647,7 +647,7 @@ export class ChatPanelSession implements ChatSurfaceHandle { await client.setThreadName(threadId, name); if (currentClient() !== client) return false; if (options.shouldPublish()) { - this.environment.plugin.threadCatalog.renameThreadInCatalog(threadId, name); + this.environment.plugin.threadCatalog.recordThreadRenamed(threadId, name); } return true; }, @@ -870,6 +870,9 @@ export class ChatPanelSession implements ChatSurfaceHandle { refreshAfterThreadMutation: async () => { await refreshActiveThreads(); }, + recordForkedThread: (thread) => { + environment.plugin.threadCatalog.upsertFromAppServer(thread); + }, }; const actions = createThreadManagementActions(threadManagementHost); const toolbarPanels = createToolbarPanelActions({ @@ -1152,8 +1155,8 @@ export class ChatPanelSession implements ChatSurfaceHandle { vaultPath: environment.plugin.settingsRef.vaultPath, currentClient, runtimeSnapshotForState: runtimeSnapshotForChatState, - publishThreadList: (threads) => { - environment.plugin.threadCatalog.setActiveThreads(threads); + recordStartedThread: (thread) => { + environment.plugin.threadCatalog.upsertFromAppServer(thread); }, syncThreadGoal: (threadId) => { void goalSync.syncThreadGoal(threadId); @@ -1176,10 +1179,10 @@ export class ChatPanelSession implements ChatSurfaceHandle { autoTitle.maybeAutoTitleThread(threadId, turnId, completedSummary); }, applyThreadArchived: (threadId) => { - environment.plugin.threadCatalog.archiveThreadInCatalog(threadId); + environment.plugin.threadCatalog.recordThreadArchived(threadId); }, applyThreadRenamed: (threadId, name) => { - environment.plugin.threadCatalog.renameThreadInCatalog(threadId, name); + environment.plugin.threadCatalog.recordThreadRenamed(threadId, name); }, recordMcpStartupStatus: (name, mcpStatus, message) => { serverDiagnostics.recordMcpStartupStatus(name, mcpStatus, message); diff --git a/src/features/thread-picker/modal.ts b/src/features/thread-picker/modal.ts index 045b2af3..5e3931dd 100644 --- a/src/features/thread-picker/modal.ts +++ b/src/features/thread-picker/modal.ts @@ -4,19 +4,17 @@ import { getThreadTitle } from "../../domain/threads/model"; import type { Thread } from "../../domain/threads/model"; import type { CodexPanelSettings } from "../../settings/model"; import { shortThreadId } from "../../utils"; -import type { SharedThreadCatalog } from "../../workspace/shared-thread-catalog"; +import type { ActiveThreadCatalogReader } from "../../workspace/active-thread-catalog"; export interface ThreadPickerHost { readonly app: App; readonly settings: CodexPanelSettings; readonly vaultPath: string; - readonly threadCatalog: ThreadPickerCatalog; + readonly threadCatalog: ActiveThreadCatalogReader; openThreadInCurrentView(threadId: string): Promise; openThreadInAvailableView(threadId: string): Promise; } -type ThreadPickerCatalog = Pick; - interface ThreadSuggestion { thread: Thread; title: string; @@ -76,10 +74,7 @@ function threadOpenModeFromEvent(evt: MouseEvent | KeyboardEvent): ThreadOpenMod } async function loadThreadPickerThreads(host: ThreadPickerHost): Promise { - const cached = host.threadCatalog.activeThreadsSnapshot(); - if (cached) return cached; - - return host.threadCatalog.fetchActiveThreads(); + return host.threadCatalog.load(); } class ThreadPickerModal extends SuggestModal { diff --git a/src/features/threads-view/session.ts b/src/features/threads-view/session.ts index 151763fd..689b15a1 100644 --- a/src/features/threads-view/session.ts +++ b/src/features/threads-view/session.ts @@ -7,7 +7,7 @@ import { ConnectionManager, type ConnectionManagerHandlers, StaleConnectionError import type { Thread } from "../../domain/threads/model"; import type { CodexPanelSettings } from "../../settings/model"; import type { OpenCodexPanelSnapshot } from "../../workspace/open-panel-snapshot"; -import type { SharedThreadCatalog } from "../../workspace/shared-thread-catalog"; +import type { ActiveThreadCatalog } from "../../workspace/active-thread-catalog"; import { ConnectionWorkTracker } from "../../shared/lifecycle/connection-work"; import type { ArchiveExportAdapter } from "../../app-server/services/thread-archive-markdown"; import { createThreadOperations, type ThreadOperations } from "../threads/thread-operations"; @@ -40,10 +40,7 @@ export interface CodexThreadsHost { getOpenPanelSnapshots(): OpenCodexPanelSnapshot[]; } -type ThreadsThreadCatalog = Pick< - SharedThreadCatalog, - "archiveThreadInCatalog" | "renameThreadInCatalog" | "refreshActiveThreads" | "activeThreadsSnapshot" | "observeActiveThreadsResult" ->; +type ThreadsThreadCatalog = ActiveThreadCatalog; export interface CodexThreadsSessionEnvironment { root: HTMLElement; @@ -127,11 +124,11 @@ export class CodexThreadsSession { this.environment.registerPointerDown((event) => { this.cancelArchiveConfirmOnOutsidePointer(event); }); - const activeThreadsSnapshot = this.host.threadCatalog.activeThreadsSnapshot(); + const activeThreadsSnapshot = this.host.threadCatalog.snapshot(); if (activeThreadsSnapshot) { this.threads = activeThreadsSnapshot; } - this.unsubscribeThreads = this.host.threadCatalog.observeActiveThreadsResult((result) => { + this.unsubscribeThreads = this.host.threadCatalog.observe((result) => { this.receiveObservedThreadsResult(result); }); this.render(); @@ -156,7 +153,7 @@ export class CodexThreadsSession { try { await this.ensureConnected(); if (this.isStaleRefresh(refresh) || !this.client) return; - const threads = await this.host.threadCatalog.refreshActiveThreads(); + const threads = await this.host.threadCatalog.refresh(); if (this.isStaleRefresh(refresh)) return; this.threads = threads; this.status = threads.length === 0 ? { kind: "empty", message: "No threads" } : { kind: "idle" }; diff --git a/src/features/threads/thread-operations.ts b/src/features/threads/thread-operations.ts index 90276b9f..9c9a82cc 100644 --- a/src/features/threads/thread-operations.ts +++ b/src/features/threads/thread-operations.ts @@ -15,8 +15,8 @@ export interface ThreadOperationsHost { }; archiveAdapter(): ArchiveExportAdapter; catalog: { - archiveThreadInCatalog(threadId: string, options?: { closeOpenPanels?: boolean }): void; - renameThreadInCatalog(threadId: string, name: string | null): void; + recordThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void; + recordThreadRenamed(threadId: string, name: string | null): void; }; notice(message: string): void; } @@ -56,8 +56,9 @@ async function renameThread( if (!client) return false; await client.setThreadName(threadId, name); + if (host.connection.currentClient() !== client) return false; if (options.shouldPublish?.() ?? true) { - host.catalog.renameThreadInCatalog(threadId, name); + host.catalog.recordThreadRenamed(threadId, name); } return true; } @@ -78,13 +79,14 @@ async function archiveThread( archiveAdapter: () => host.archiveAdapter(), saveMarkdown: options.saveMarkdown ?? settings.archiveExportEnabled, }); + if (host.connection.currentClient() !== client) return null; if (result.exportedPath) { host.notice(`Saved archived thread to ${result.exportedPath}.`); } if (options.closeOpenPanels === undefined) { - host.catalog.archiveThreadInCatalog(threadId); + host.catalog.recordThreadArchived(threadId); } else { - host.catalog.archiveThreadInCatalog(threadId, { closeOpenPanels: options.closeOpenPanels }); + host.catalog.recordThreadArchived(threadId, { closeOpenPanels: options.closeOpenPanels }); } return result; } diff --git a/src/plugin-runtime.ts b/src/plugin-runtime.ts index 38bf79e5..91b6490c 100644 --- a/src/plugin-runtime.ts +++ b/src/plugin-runtime.ts @@ -20,7 +20,7 @@ import { openThreadPicker, type ThreadPickerHost } from "./features/thread-picke import { CodexThreadsView, type CodexThreadsHost } from "./features/threads-view/view"; import type { CodexPanelSettingTabHost } from "./settings/tab"; import { WorkspacePanelCoordinator } from "./workspace/panel-coordinator"; -import { createSharedThreadCatalog, type SharedThreadCatalog } from "./workspace/shared-thread-catalog"; +import { createActiveThreadCatalog, type ActiveThreadCatalog } from "./workspace/active-thread-catalog"; export interface CodexPanelRuntimeOptions { app: App; @@ -39,7 +39,7 @@ export class CodexPanelRuntime { context: () => this.appServerQueryContext(), }); private readonly panels: WorkspacePanelCoordinator; - private readonly threadCatalog: SharedThreadCatalog; + private readonly threadCatalog: ActiveThreadCatalog; constructor(private readonly options: CodexPanelRuntimeOptions) { this.panels = new WorkspacePanelCoordinator({ @@ -48,7 +48,7 @@ export class CodexPanelRuntime { this.refreshThreadsViewLiveState(); }, }); - this.threadCatalog = createSharedThreadCatalog({ + this.threadCatalog = createActiveThreadCatalog({ queries: this.appServerSharedQueries, surfaces: { applyThreadArchived: (threadId, archiveOptions) => { @@ -57,9 +57,6 @@ export class CodexPanelRuntime { applyThreadRenamed: (threadId, name) => { this.applyThreadRenamed(threadId, name); }, - refreshThreadsViewLiveState: () => { - this.refreshThreadsViewLiveState(); - }, }, }); } @@ -116,6 +113,9 @@ export class CodexPanelRuntime { openThreadInNewView: (threadId) => this.panels.openThreadInNewView(threadId), focusThreadInOpenView: (threadId) => this.panels.focusThreadInOpenView(threadId), openTurnDiff: (state) => this.openTurnDiff(state), + refreshThreadsViewLiveState: () => { + this.refreshThreadsViewLiveState(); + }, }, appServerData: this.appServerSharedQueries, threadCatalog: this.threadCatalog, diff --git a/src/settings/dynamic-data-controller.ts b/src/settings/dynamic-data-controller.ts index 60950fce..f8610db5 100644 --- a/src/settings/dynamic-data-controller.ts +++ b/src/settings/dynamic-data-controller.ts @@ -9,7 +9,7 @@ import type { HookItem, ModelMetadata, ReasoningEffort } from "../domain/catalog import { findModelMetadataByIdOrName, sortedModelMetadata, supportedEffortsForModelMetadata } from "../domain/catalog/metadata"; import type { Thread } from "../domain/threads/model"; import { errorMessage } from "../utils"; -import type { SharedThreadCatalog } from "../workspace/shared-thread-catalog"; +import type { ActiveThreadCatalogMutations } from "../workspace/active-thread-catalog"; import { archivedThreadDisplayTitle } from "./archived-thread-title"; import { loadHookData, loadSettingsCompanionData } from "./app-server-data"; import { @@ -33,7 +33,7 @@ type SettingsAppServerData = Pick< "modelsSnapshot" | "observeModelsResult" | "fetchModels" | "refreshModels" | "notifyContextChanged" >; -type SettingsThreadCatalog = Pick; +type SettingsThreadCatalog = ActiveThreadCatalogMutations; function archivedThreadTitleForStatus(thread: Thread | undefined, threadId: string): string { return thread ? archivedThreadDisplayTitle(thread) : threadId; @@ -358,14 +358,7 @@ export class SettingsDynamicDataController { status: `Restored "${archivedThreadDisplayTitle(restoredThread)}".`, operationId, }); - this.callbacks.display(); - try { - await this.host.threadCatalog.refreshActiveThreads(); - } catch (error) { - if (!this.isStaleArchivedThreadsOperation(operationId) && !isStaleAppServerSharedQueryContextError(error)) { - this.callbacks.notify("Could not refresh active Codex threads."); - } - } + this.host.threadCatalog.recordThreadRestored(restoredThread); } catch (error) { if (this.isStaleArchivedThreadsOperation(operationId)) return; this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, { diff --git a/src/workspace/active-thread-catalog.ts b/src/workspace/active-thread-catalog.ts new file mode 100644 index 00000000..ea208299 --- /dev/null +++ b/src/workspace/active-thread-catalog.ts @@ -0,0 +1,65 @@ +import type { AppServerObservedQueryResult } from "../app-server/query/cache"; +import type { AppServerSharedQueries } from "../app-server/query/shared-queries"; +import type { Thread } from "../domain/threads/model"; + +type ActiveThreadObserver = (result: AppServerObservedQueryResult) => void; + +interface ThreadSurfaceActions { + applyThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void; + applyThreadRenamed(threadId: string, name: string | null): void; +} + +export interface ActiveThreadCatalogOptions { + queries: AppServerSharedQueries; + surfaces: ThreadSurfaceActions; +} + +export interface ActiveThreadCatalogReader { + snapshot(): readonly Thread[] | null; + load(): Promise; + refresh(): Promise; + observe(observer: ActiveThreadObserver, options?: { emitCurrent?: boolean }): () => void; +} + +export interface ActiveThreadCatalogMutations { + replaceFromAppServer(threads: readonly Thread[]): void; + upsertFromAppServer(thread: Thread): void; + recordThreadRenamed(threadId: string, name: string | null): void; + recordThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void; + recordThreadRestored(thread: Thread): void; +} + +export interface ActiveThreadCatalog extends ActiveThreadCatalogReader, ActiveThreadCatalogMutations {} + +export function createActiveThreadCatalog(options: ActiveThreadCatalogOptions): ActiveThreadCatalog { + return { + snapshot: () => options.queries.activeThreadsSnapshot(), + load: () => options.queries.fetchActiveThreads(), + refresh: () => options.queries.refreshActiveThreads(), + observe: (observer, observeOptions) => options.queries.observeActiveThreadsResult(observer, observeOptions), + replaceFromAppServer: (threads) => { + options.queries.setActiveThreads(threads); + }, + upsertFromAppServer: (thread) => { + options.queries.updateActiveThreads((current) => upsertThread(current ?? [], thread)); + }, + recordThreadRenamed: (threadId, name) => { + options.queries.updateActiveThreads((current) => + current ? current.map((thread) => (thread.id === threadId ? { ...thread, name } : thread)) : null, + ); + options.surfaces.applyThreadRenamed(threadId, name); + }, + recordThreadArchived: (threadId, archiveOptions) => { + options.queries.updateActiveThreads((current) => (current ? current.filter((thread) => thread.id !== threadId) : null)); + options.surfaces.applyThreadArchived(threadId, archiveOptions); + }, + recordThreadRestored: (thread) => { + options.queries.updateActiveThreads((current) => upsertThread(current ?? [], thread)); + }, + }; +} + +function upsertThread(threads: readonly Thread[], thread: Thread): readonly Thread[] { + const withoutThread = threads.filter((item) => item.id !== thread.id); + return [thread, ...withoutThread]; +} diff --git a/src/workspace/shared-thread-catalog.ts b/src/workspace/shared-thread-catalog.ts deleted file mode 100644 index cf6ec8be..00000000 --- a/src/workspace/shared-thread-catalog.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { Thread } from "../domain/threads/model"; -import type { AppServerObservedQueryResult } from "../app-server/query/cache"; -import type { AppServerSharedQueries } from "../app-server/query/shared-queries"; - -interface ThreadSurfaceActions { - applyThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void; - applyThreadRenamed(threadId: string, name: string | null): void; - refreshThreadsViewLiveState(): void; -} - -export interface SharedThreadCatalogOptions { - queries: AppServerSharedQueries; - surfaces: ThreadSurfaceActions; -} - -export interface SharedThreadCatalog { - activeThreadsSnapshot(): readonly Thread[] | null; - fetchActiveThreads(): Promise; - refreshActiveThreads(): Promise; - setActiveThreads(threads: readonly Thread[]): void; - observeActiveThreadsResult( - listener: (result: AppServerObservedQueryResult) => void, - options?: { emitCurrent?: boolean }, - ): () => void; - renameThreadInCatalog(threadId: string, name: string | null): void; - archiveThreadInCatalog(threadId: string, options?: { closeOpenPanels?: boolean }): void; - refreshThreadsViewLiveState(): void; -} - -export function createSharedThreadCatalog(options: SharedThreadCatalogOptions): SharedThreadCatalog { - return { - activeThreadsSnapshot: () => options.queries.activeThreadsSnapshot(), - fetchActiveThreads: () => options.queries.fetchActiveThreads(), - refreshActiveThreads: () => options.queries.refreshActiveThreads(), - setActiveThreads: (threads) => { - options.queries.setActiveThreads(threads); - }, - observeActiveThreadsResult: (listener, observeOptions) => options.queries.observeActiveThreadsResult(listener, observeOptions), - renameThreadInCatalog: (threadId, name) => { - options.queries.updateActiveThreads((current) => { - return current ? current.map((thread) => (thread.id === threadId ? { ...thread, name } : thread)) : null; - }); - options.surfaces.applyThreadRenamed(threadId, name); - }, - archiveThreadInCatalog: (threadId, archiveOptions) => { - options.queries.updateActiveThreads((current) => { - return current ? current.filter((thread) => thread.id !== threadId) : null; - }); - options.surfaces.applyThreadArchived(threadId, archiveOptions); - }, - refreshThreadsViewLiveState: () => { - options.surfaces.refreshThreadsViewLiveState(); - }, - }; -} diff --git a/tests/app-server/query-cache.test.ts b/tests/app-server/query-cache.test.ts index 1156e8f2..1c630d29 100644 --- a/tests/app-server/query-cache.test.ts +++ b/tests/app-server/query-cache.test.ts @@ -273,6 +273,68 @@ describe("AppServerQueryCache", () => { await expect(promise).resolves.toEqual([thread("other")]); expect(cache.activeThreadsSnapshot(context)).toEqual([thread("other")]); }); + + it("keeps active thread archives recorded while the cache is empty when an older refresh resolves later", async () => { + const context = cacheContext(); + const refresh = deferred[]>(); + const cache = cacheWithThreads(() => refresh.promise); + + const promise = cache.refreshActiveThreads(context); + await flushMicrotasks(); + + expect(cache.activeThreadsSnapshot(context)).toBeNull(); + cache.updateActiveThreads(context, (threads) => (threads ? threads.filter((item) => item.id !== "thread") : null)); + refresh.resolve([thread("thread"), thread("other")]); + + await expect(promise).resolves.toEqual([thread("other")]); + expect(cache.activeThreadsSnapshot(context)).toEqual([thread("other")]); + }); + + it("keeps active thread renames recorded while the cache is empty when an older refresh resolves later", async () => { + const context = cacheContext(); + const refresh = deferred[]>(); + const cache = cacheWithThreads(() => refresh.promise); + + const promise = cache.refreshActiveThreads(context); + await flushMicrotasks(); + + expect(cache.activeThreadsSnapshot(context)).toBeNull(); + cache.updateActiveThreads( + context, + (threads) => threads?.map((item) => (item.id === "thread" ? { ...item, name: "Renamed" } : item)) ?? null, + ); + refresh.resolve([thread("thread"), thread("other")]); + + await expect(promise).resolves.toEqual([{ ...thread("thread"), name: "Renamed" }, thread("other")]); + expect(cache.activeThreadsSnapshot(context)).toEqual([{ ...thread("thread"), name: "Renamed" }, thread("other")]); + }); + + it("merges active thread upserts recorded while the cache is empty into older refresh results", async () => { + const context = cacheContext(); + const refresh = deferred[]>(); + const cache = cacheWithThreads(() => refresh.promise); + + const promise = cache.refreshActiveThreads(context); + await flushMicrotasks(); + + cache.updateActiveThreads(context, (threads) => [thread("started"), ...(threads ?? [])]); + expect(cache.activeThreadsSnapshot(context)).toEqual([thread("started")]); + refresh.resolve([thread("other")]); + + await expect(promise).resolves.toEqual([thread("started"), thread("other")]); + expect(cache.activeThreadsSnapshot(context)).toEqual([thread("started"), thread("other")]); + }); + + it("does not treat active thread upserts into an empty cache as fresh full-list snapshots", async () => { + const context = cacheContext(); + const fetchThreads = vi.fn().mockResolvedValue([thread("started"), thread("other")]); + const cache = cacheWithThreads(fetchThreads); + + cache.updateActiveThreads(context, (threads) => [thread("started"), ...(threads ?? [])]); + + await expect(cache.fetchActiveThreads(context)).resolves.toEqual([thread("started"), thread("other")]); + expect(fetchThreads).toHaveBeenCalledOnce(); + }); }); function cacheContext(overrides: Partial = {}): AppServerQueryContext { diff --git a/tests/features/chat/connection/server-actions/server-actions.test.ts b/tests/features/chat/connection/server-actions/server-actions.test.ts index ea5b0d60..178aec5d 100644 --- a/tests/features/chat/connection/server-actions/server-actions.test.ts +++ b/tests/features/chat/connection/server-actions/server-actions.test.ts @@ -36,7 +36,7 @@ describe("chat server actions", () => { const started = threadFixture("started"); const optimistic = threadFromThreadRecord({ ...started, preview: "first prompt" }); const existingThread = threadFromThreadRecord(existing); - const publishThreadList = vi.fn(); + const recordStartedThread = vi.fn(); const syncThreadGoal = vi.fn(); const client = { startThread: vi.fn().mockResolvedValue({ @@ -56,14 +56,14 @@ describe("chat server actions", () => { vaultPath: "/vault", currentClient: () => client, runtimeSnapshotForState: () => ({ requestedServiceTier: { kind: "unchanged" }, runtimeConfig: null }) as never, - publishThreadList, + recordStartedThread, syncThreadGoal, }); await controller.startThread("first prompt"); expect(stateStore.getState().threadList.listedThreads).toEqual([optimistic, existingThread]); - expect(publishThreadList).toHaveBeenCalledWith([optimistic, existingThread]); + expect(recordStartedThread).toHaveBeenCalledWith(optimistic); expect(syncThreadGoal).toHaveBeenCalledWith("started"); }); @@ -94,7 +94,7 @@ describe("chat server actions", () => { vaultPath: "/vault", currentClient: () => client, runtimeSnapshotForState: runtimeSnapshotForChatState, - publishThreadList: vi.fn(), + recordStartedThread: vi.fn(), syncThreadGoal: vi.fn(), }); @@ -131,7 +131,7 @@ describe("chat server actions", () => { vaultPath: "/vault", currentClient: () => client, runtimeSnapshotForState: () => ({ requestedServiceTier: { kind: "unchanged" }, runtimeConfig: null }) as never, - publishThreadList: vi.fn(), + recordStartedThread: vi.fn(), syncThreadGoal, }); @@ -164,7 +164,7 @@ describe("chat server actions", () => { vaultPath: "/vault", currentClient: () => client, runtimeSnapshotForState: () => ({ requestedServiceTier: { kind: "unchanged" }, runtimeConfig: null }) as never, - publishThreadList: vi.fn(), + recordStartedThread: vi.fn(), syncThreadGoal: vi.fn(), }); @@ -176,7 +176,7 @@ describe("chat server actions", () => { it("keeps app-server preview when newly started threads already have one", async () => { const stateStore = createChatStateStore(chatStateFixture()); const started = threadFixture("started", { preview: "server preview" }); - const publishThreadList = vi.fn(); + const recordStartedThread = vi.fn(); const client = { startThread: vi.fn().mockResolvedValue({ thread: started, @@ -195,13 +195,13 @@ describe("chat server actions", () => { vaultPath: "/vault", currentClient: () => client, runtimeSnapshotForState: () => ({ requestedServiceTier: { kind: "unchanged" }, runtimeConfig: null }) as never, - publishThreadList, + recordStartedThread, syncThreadGoal: () => undefined, }); await controller.startThread("local preview"); - expect(publishThreadList).toHaveBeenCalledWith([threadFromThreadRecord(started)]); + expect(recordStartedThread).toHaveBeenCalledWith(threadFromThreadRecord(started)); }); it("does not apply newly started threads after the client changes", async () => { @@ -212,14 +212,14 @@ describe("chat server actions", () => { } as unknown as AppServerClient; const secondClient = {} as unknown as AppServerClient; let currentClient = firstClient; - const publishThreadList = vi.fn(); + const recordStartedThread = vi.fn(); const syncThreadGoal = vi.fn(); const controller = createChatServerThreadActions({ stateStore, vaultPath: "/vault", currentClient: () => currentClient, runtimeSnapshotForState: () => ({ requestedServiceTier: { kind: "unchanged" }, runtimeConfig: null }) as never, - publishThreadList, + recordStartedThread, syncThreadGoal, }); @@ -243,7 +243,7 @@ describe("chat server actions", () => { await expect(starting).resolves.toBeNull(); expect(stateStore.getState().activeThread.id).toBeNull(); expect(stateStore.getState().threadList.listedThreads).toEqual([]); - expect(publishThreadList).not.toHaveBeenCalled(); + expect(recordStartedThread).not.toHaveBeenCalled(); expect(syncThreadGoal).not.toHaveBeenCalled(); }); diff --git a/tests/features/chat/threads/thread-management-actions.test.ts b/tests/features/chat/threads/thread-management-actions.test.ts index 8ce85e66..def0d6f4 100644 --- a/tests/features/chat/threads/thread-management-actions.test.ts +++ b/tests/features/chat/threads/thread-management-actions.test.ts @@ -77,6 +77,27 @@ describe("thread management actions", () => { expect(host.setStatus).not.toHaveBeenCalledWith("Compaction requested."); }); + it("does not report compaction completion after the current client changes", async () => { + const compact = deferred(); + const firstClient = clientMock(); + const secondClient = clientMock(); + let currentClient = firstClient; + firstClient.compactThread.mockReturnValue(compact.promise); + const host = hostMock({ client: firstClient, currentClient: () => currentClient as unknown as AppServerClient, items: [] }); + const controller = threadManagementActions(host); + + const pendingCompact = controller.compactThread("source"); + await waitForAsyncWork(() => { + expect(firstClient.compactThread).toHaveBeenCalledWith("source"); + }); + currentClient = secondClient; + compact.resolve(undefined); + await pendingCompact; + + expect(host.addSystemMessage).not.toHaveBeenCalledWith("Compaction requested."); + expect(host.setStatus).not.toHaveBeenCalledWith("Compaction requested."); + }); + it("saves archive markdown before archiving and notifying shared surfaces", async () => { const client = clientMock(); const adapter = archiveAdapterMock(); @@ -142,6 +163,7 @@ describe("thread management actions", () => { expect(client.forkThread).toHaveBeenCalledWith("source", "/vault"); expect(client.rollbackThread).toHaveBeenCalledWith("forked", 2); + expect(host.recordForkedThread).toHaveBeenCalledWith(expect.objectContaining({ id: "forked" })); expect(host.openThreadInNewView).toHaveBeenCalledWith("forked"); expect(client.archiveThread).not.toHaveBeenCalled(); expect(host.openThreadInCurrentPanel).not.toHaveBeenCalled(); @@ -252,6 +274,32 @@ describe("thread management actions", () => { expect(host.notifyThreadArchived).not.toHaveBeenCalled(); }); + it("does not open or record fork responses after the current client changes", async () => { + const fork = deferred<{ thread: ThreadRecord }>(); + const firstClient = clientMock(); + const secondClient = clientMock(); + let currentClient = firstClient; + firstClient.forkThread.mockReturnValue(fork.promise); + const host = hostMock({ + client: firstClient, + currentClient: () => currentClient as unknown as AppServerClient, + items: turnItems(), + }); + const controller = threadManagementActions(host); + + const pendingFork = controller.forkThreadFromTurn("source", null, false); + await waitForAsyncWork(() => { + expect(firstClient.forkThread).toHaveBeenCalledWith("source", "/vault"); + }); + currentClient = secondClient; + fork.resolve({ thread: archivedThread() }); + await pendingFork; + + expect(host.recordForkedThread).not.toHaveBeenCalled(); + expect(host.openThreadInNewView).not.toHaveBeenCalled(); + expect(firstClient.archiveThread).not.toHaveBeenCalled(); + }); + it("renames a thread and notifies shared surfaces", async () => { const client = clientMock(); const host = hostMock({ client, items: [] }); @@ -358,6 +406,49 @@ describe("thread management actions", () => { expect(host.notifyActiveThreadIdentityChanged).not.toHaveBeenCalled(); expect(host.refreshAfterThreadMutation).not.toHaveBeenCalled(); }); + + it("ignores rollback responses after the current client changes", async () => { + const rollback = deferred<{ thread: ThreadRecord }>(); + const firstClient = clientMock(); + const secondClient = clientMock(); + let currentClient = firstClient; + firstClient.rollbackThread.mockReturnValue(rollback.promise); + const host = hostMock({ + client: firstClient, + currentClient: () => currentClient as unknown as AppServerClient, + items: turnItems(), + }); + host.stateStore.dispatch({ + type: "active-thread/resumed", + thread: panelThread("source"), + cwd: "/vault", + model: null, + reasoningEffort: null, + serviceTier: null, + approvalPolicy: null, + approvalsReviewer: null, + activePermissionProfile: null, + }); + host.stateStore.dispatch({ + type: "message-stream/items-replaced", + items: turnItems(), + historyCursor: null, + loadingHistory: false, + }); + const controller = threadManagementActions(host); + + const pendingRollback = controller.rollbackThread("source"); + await waitForAsyncWork(() => { + expect(firstClient.rollbackThread).toHaveBeenCalledWith("source"); + }); + currentClient = secondClient; + rollback.resolve({ thread: rollbackThread() }); + await pendingRollback; + + expect(host.setComposerText).not.toHaveBeenCalled(); + expect(host.notifyActiveThreadIdentityChanged).not.toHaveBeenCalled(); + expect(host.refreshAfterThreadMutation).not.toHaveBeenCalled(); + }); }); function turnItems(): MessageStreamItem[] { @@ -415,11 +506,13 @@ function hostMock({ items, archiveAdapter = archiveAdapterMock(), settings = {}, + currentClient, }: { client: ReturnType; items: MessageStreamItem[]; archiveAdapter?: ArchiveExportAdapter; settings?: Partial; + currentClient?: () => AppServerClient | null; }) { const state = withChatStateMessageStreamItems(chatStateFixture(), items); const stateStore = createChatStateStore(state); @@ -431,7 +524,7 @@ function hostMock({ stateStore, vaultPath: "/vault", ensureConnected, - currentClient: () => client as unknown as AppServerClient, + currentClient: currentClient ?? (() => client as unknown as AppServerClient), addSystemMessage: vi.fn(), setStatus: vi.fn(), setComposerText: vi.fn(), @@ -464,6 +557,7 @@ function hostMock({ notifyThreadRenamed, notifyActiveThreadIdentityChanged: vi.fn(), refreshAfterThreadMutation: vi.fn().mockResolvedValue(undefined), + recordForkedThread: vi.fn(), }; } diff --git a/tests/features/chat/view-connection.test.ts b/tests/features/chat/view-connection.test.ts index 0e393a20..9f6caf01 100644 --- a/tests/features/chat/view-connection.test.ts +++ b/tests/features/chat/view-connection.test.ts @@ -168,7 +168,7 @@ describe("CodexChatView connection lifecycle", () => { }); it("refreshes shared thread lists after connecting", async () => { - const refreshActiveThreads = vi + const refresh = vi .fn() .mockResolvedValue([{ id: "thread-1", preview: "Restored thread", name: null, archived: false, createdAt: 1, updatedAt: 1 }]); const client = connectedClient({ @@ -176,13 +176,13 @@ describe("CodexChatView connection lifecycle", () => { }); connectionMock.state.client = client; const view = await chatView({ - host: chatHost({ refreshActiveThreads }), + host: chatHost({ refresh }), }); await view.onOpen(); await view.surface.connect(); - expect(refreshActiveThreads).toHaveBeenCalledOnce(); + expect(refresh).toHaveBeenCalledOnce(); expect(client.listThreads).not.toHaveBeenCalled(); requiredButton(view.containerEl, '[aria-label="Show thread list"]').click(); await waitForAsyncWork(() => { @@ -191,18 +191,18 @@ describe("CodexChatView connection lifecycle", () => { }); it("ignores stale shared thread refreshes after connecting", async () => { - const refreshActiveThreads = vi.fn().mockRejectedValue(new StaleAppServerSharedQueryContextError()); + const refresh = vi.fn().mockRejectedValue(new StaleAppServerSharedQueryContextError()); connectionMock.state.client = connectedClient({ listThreads: vi.fn(), }); const view = await chatView({ - host: chatHost({ refreshActiveThreads }), + host: chatHost({ refresh }), }); await view.onOpen(); await view.surface.connect(); - expect(refreshActiveThreads).toHaveBeenCalledOnce(); + expect(refresh).toHaveBeenCalledOnce(); expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: true }); expect(notices).toEqual([]); requiredButton(view.containerEl, '[aria-label="Show thread list"]').click(); @@ -379,14 +379,14 @@ describe("CodexChatView connection lifecycle", () => { await view.setState({ threadId: "thread-named" }, {} as never); await view.onOpen(); - host.threadCatalog.setActiveThreads([panelThread({ id: "thread-named", name: "作業メモ" })]); + host.threadCatalog.replaceFromAppServer([panelThread({ id: "thread-named", name: "作業メモ" })]); expect(view.getDisplayText()).toBe("Codex: 作業メモ"); - host.threadCatalog.setActiveThreads([panelThread({ id: "thread-named", name: null, preview: "初回依頼" })]); + host.threadCatalog.replaceFromAppServer([panelThread({ id: "thread-named", name: null, preview: "初回依頼" })]); expect(view.getDisplayText()).toBe("Codex: 初回依頼"); await view.setState({ threadId: "019e061e-0000-7000-8000-000000000001" }, {} as never); - host.threadCatalog.setActiveThreads([]); + host.threadCatalog.replaceFromAppServer([]); expect(view.getDisplayText()).toBe("Codex: 019e061e"); }); @@ -440,7 +440,7 @@ describe("CodexChatView connection lifecycle", () => { const cachedThread = threadFixture("thread-cached"); const view = await chatView({ host: chatHost({ - activeThreadsSnapshot: vi.fn(() => [cachedThread] as never[]), + snapshot: vi.fn(() => [cachedThread] as never[]), appServerMetadataSnapshot: vi.fn( () => ({ @@ -658,9 +658,9 @@ describe("CodexChatView connection lifecycle", () => { }); it("routes slash archive through shared panel notifications", async () => { - const archiveThreadInCatalog = vi.fn(); + const recordThreadArchived = vi.fn(); const host = chatHost({ - archiveThreadInCatalog, + recordThreadArchived, }); const client = connectedClient({ listThreads: vi.fn().mockResolvedValue({ data: [threadFixture("thread-1")] }), @@ -674,12 +674,12 @@ describe("CodexChatView connection lifecycle", () => { await submitComposerByEnter(view); expect(client.archiveThread).toHaveBeenCalledWith("thread-1"); - expect(archiveThreadInCatalog).toHaveBeenCalledWith("thread-1"); + expect(recordThreadArchived).toHaveBeenCalledWith("thread-1"); }); it("replaces the current panel with the forked thread after fork and archive", async () => { - const archiveThreadInCatalog = vi.fn(); - const host = chatHost({ archiveThreadInCatalog }); + const recordThreadArchived = vi.fn(); + const host = chatHost({ recordThreadArchived }); const client = connectedClient({ listThreads: vi.fn().mockResolvedValue({ data: [threadFixture("source")] }), resumeThread: vi.fn((threadId: string) => Promise.resolve(resumedThread(threadId))), @@ -707,7 +707,7 @@ describe("CodexChatView connection lifecycle", () => { expect(client.resumeThread).toHaveBeenLastCalledWith("forked", "/vault"); expect(view.getState()).toEqual({ version: 1, threadId: "forked", threadTitle: "Restored thread" }); expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "forked" }); - expect(archiveThreadInCatalog).toHaveBeenCalledWith("source"); + expect(recordThreadArchived).toHaveBeenCalledWith("source"); }); }); @@ -743,7 +743,7 @@ describe("CodexChatView connection lifecycle", () => { expect(composerPlaceholder(view)).toBe("Ask Codex to work on this task..."); - host.threadCatalog.setActiveThreads([panelThread({ id: "thread-1", name: "Explicit name" })]); + host.threadCatalog.replaceFromAppServer([panelThread({ id: "thread-1", name: "Explicit name" })]); view.surface.applyThreadRenamed("thread-1", "Explicit name"); await waitForAsyncWork(() => { @@ -786,14 +786,14 @@ describe("CodexChatView connection lifecycle", () => { await view.onOpen(); await view.surface.openThread("thread-1"); - host.threadCatalog.setActiveThreads([panelThread({ id: "thread-1", name: "Renamed thread" })]); + host.threadCatalog.replaceFromAppServer([panelThread({ id: "thread-1", name: "Renamed thread" })]); view.surface.applyThreadRenamed("thread-1", "Renamed thread"); await waitForAsyncWork(() => { expect(composerPlaceholder(view)).toBe("Ask Codex to work on “Renamed thread”..."); }); - host.threadCatalog.setActiveThreads([panelThread({ id: "thread-1", name: null })]); + host.threadCatalog.replaceFromAppServer([panelThread({ id: "thread-1", name: null })]); view.surface.applyThreadRenamed("thread-1", null); await waitForAsyncWork(() => { @@ -816,7 +816,7 @@ describe("CodexChatView connection lifecycle", () => { }); composer.setSelectionRange(5, 9); - host.threadCatalog.setActiveThreads([panelThread({ id: "thread-1", name: "Renamed thread" })]); + host.threadCatalog.replaceFromAppServer([panelThread({ id: "thread-1", name: "Renamed thread" })]); view.surface.applyThreadRenamed("thread-1", "Renamed thread"); await waitForAsyncWork(() => { @@ -1324,13 +1324,14 @@ interface ChatHostFixtureOverrides { openThreadInNewView?: CodexChatHost["workspace"]["openThreadInNewView"]; focusThreadInOpenView?: CodexChatHost["workspace"]["focusThreadInOpenView"]; openTurnDiff?: CodexChatHost["workspace"]["openTurnDiff"]; - archiveThreadInCatalog?: CodexChatHost["threadCatalog"]["archiveThreadInCatalog"]; - renameThreadInCatalog?: CodexChatHost["threadCatalog"]["renameThreadInCatalog"]; - refreshThreadsViewLiveState?: CodexChatHost["threadCatalog"]["refreshThreadsViewLiveState"]; - setActiveThreads?: CodexChatHost["threadCatalog"]["setActiveThreads"]; + refreshThreadsViewLiveState?: CodexChatHost["workspace"]["refreshThreadsViewLiveState"]; + recordThreadArchived?: CodexChatHost["threadCatalog"]["recordThreadArchived"]; + recordThreadRenamed?: CodexChatHost["threadCatalog"]["recordThreadRenamed"]; + replaceFromAppServer?: CodexChatHost["threadCatalog"]["replaceFromAppServer"]; + upsertFromAppServer?: CodexChatHost["threadCatalog"]["upsertFromAppServer"]; updateAppServerMetadata?: CodexChatHost["appServerData"]["updateAppServerMetadata"]; - refreshActiveThreads?: CodexChatHost["threadCatalog"]["refreshActiveThreads"]; - activeThreadsSnapshot?: CodexChatHost["threadCatalog"]["activeThreadsSnapshot"]; + refresh?: CodexChatHost["threadCatalog"]["refresh"]; + snapshot?: CodexChatHost["threadCatalog"]["snapshot"]; appServerMetadataSnapshot?: CodexChatHost["appServerData"]["appServerMetadataSnapshot"]; modelsSnapshot?: CodexChatHost["appServerData"]["modelsSnapshot"]; fetchModels?: CodexChatHost["appServerData"]["fetchModels"]; @@ -1339,7 +1340,7 @@ interface ChatHostFixtureOverrides { } function chatHost(overrides: ChatHostFixtureOverrides = {}): CodexChatHost { - let activeThreads = overrides.activeThreadsSnapshot?.() ?? null; + let activeThreads = overrides.snapshot?.() ?? null; let metadata = overrides.appServerMetadataSnapshot?.() ?? null; const models = overrides.modelsSnapshot?.() ?? null; const activeThreadResultListeners = new Set<(result: AppServerObservedQueryResult) => void>(); @@ -1397,6 +1398,7 @@ function chatHost(overrides: ChatHostFixtureOverrides = {}): CodexChatHost { openThreadInNewView: overrides.openThreadInNewView ?? vi.fn(), focusThreadInOpenView: overrides.focusThreadInOpenView ?? vi.fn().mockResolvedValue(false), openTurnDiff: overrides.openTurnDiff ?? vi.fn(), + refreshThreadsViewLiveState: overrides.refreshThreadsViewLiveState ?? vi.fn(), }, appServerData: { updateAppServerMetadata: @@ -1432,17 +1434,36 @@ function chatHost(overrides: ChatHostFixtureOverrides = {}): CodexChatHost { }, }, threadCatalog: { - archiveThreadInCatalog: overrides.archiveThreadInCatalog ?? vi.fn(), - renameThreadInCatalog: overrides.renameThreadInCatalog ?? vi.fn(), - refreshThreadsViewLiveState: overrides.refreshThreadsViewLiveState ?? vi.fn(), - setActiveThreads: - overrides.setActiveThreads ?? + recordThreadArchived: + overrides.recordThreadArchived ?? + ((threadId) => { + activeThreads = activeThreads?.filter((thread) => thread.id !== threadId) ?? null; + if (activeThreads) { + for (const listener of activeThreadResultListeners) listener(queryResult(activeThreads)); + } + }), + recordThreadRenamed: + overrides.recordThreadRenamed ?? + ((threadId, name) => { + activeThreads = activeThreads?.map((thread) => (thread.id === threadId ? { ...thread, name } : thread)) ?? null; + if (activeThreads) { + for (const listener of activeThreadResultListeners) listener(queryResult(activeThreads)); + } + }), + upsertFromAppServer: + overrides.upsertFromAppServer ?? + ((thread) => { + activeThreads = [thread, ...(activeThreads?.filter((item) => item.id !== thread.id) ?? [])]; + for (const listener of activeThreadResultListeners) listener(queryResult(activeThreads)); + }), + replaceFromAppServer: + overrides.replaceFromAppServer ?? ((threads) => { activeThreads = threads; for (const listener of activeThreadResultListeners) listener(queryResult(threads)); }), - refreshActiveThreads: - overrides.refreshActiveThreads ?? + refresh: + overrides.refresh ?? (vi.fn(async () => { const client = connectionMock.state.client; if (!client) return activeThreads ?? []; @@ -1451,15 +1472,17 @@ function chatHost(overrides: ChatHostFixtureOverrides = {}): CodexChatHost { activeThreads = response.data.map(threadFromRecord); for (const listener of activeThreadResultListeners) listener(queryResult(activeThreads)); return activeThreads; - }) as CodexChatHost["threadCatalog"]["refreshActiveThreads"]), - activeThreadsSnapshot: overrides.activeThreadsSnapshot ?? vi.fn(() => activeThreads), - observeActiveThreadsResult: (listener, options = {}) => { + }) as CodexChatHost["threadCatalog"]["refresh"]), + load: vi.fn(async () => activeThreads ?? []), + snapshot: overrides.snapshot ?? vi.fn(() => activeThreads), + observe: (listener, options = {}) => { activeThreadResultListeners.add(listener); if ((options.emitCurrent ?? true) && activeThreads) listener(queryResult(activeThreads)); return () => { activeThreadResultListeners.delete(listener); }; }, + recordThreadRestored: vi.fn(), }, }; } diff --git a/tests/features/thread-picker/modal.test.ts b/tests/features/thread-picker/modal.test.ts index 1ab877e2..06749461 100644 --- a/tests/features/thread-picker/modal.test.ts +++ b/tests/features/thread-picker/modal.test.ts @@ -110,8 +110,10 @@ function threadPickerHost(threads: readonly Thread[]): TestThreadPickerHost { openedCurrent, openedAvailable, threadCatalog: { - activeThreadsSnapshot: () => threads, - fetchActiveThreads: async () => threads, + snapshot: () => null, + load: async () => threads, + refresh: async () => threads, + observe: () => () => undefined, }, openThreadInCurrentView: async (threadId) => { openedCurrent.push(threadId); diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index b893f0c6..d7b81a9f 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -238,20 +238,20 @@ describe("CodexThreadsView", () => { it("refreshes thread lists through the plugin coordinator", async () => { const threads = [{ id: "thread", preview: "Thread preview", name: null, archived: false, createdAt: 1, updatedAt: 1 }]; - const refreshActiveThreads = vi.fn().mockResolvedValue(threads); + const refresh = vi.fn().mockResolvedValue(threads); connectionMock.state.client = clientFixture({ listThreads: vi.fn(), }); const host = threadsHost({ threadCatalog: { - refreshActiveThreads, + refresh, }, }); const view = await threadsView(host); await view.refresh(); - expect(refreshActiveThreads).toHaveBeenCalledOnce(); + expect(refresh).toHaveBeenCalledOnce(); expect(view.containerEl.textContent).toContain("Thread preview"); }); @@ -267,7 +267,7 @@ describe("CodexThreadsView", () => { const view = await threadsView( threadsHost({ threadCatalog: { - activeThreadsSnapshot: vi.fn(() => [threadFixture({ id: "cached", preview: "Cached thread" })]), + snapshot: vi.fn(() => [threadFixture({ id: "cached", preview: "Cached thread" })]), }, }), ); @@ -283,9 +283,9 @@ describe("CodexThreadsView", () => { listThreads: vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }), archiveThread, }); - const archiveThreadInCatalog = vi.fn(); + const recordThreadArchived = vi.fn(); const host = threadsHost({ - threadCatalog: { archiveThreadInCatalog }, + threadCatalog: { recordThreadArchived }, }); const view = await threadsView(host); @@ -295,7 +295,7 @@ describe("CodexThreadsView", () => { await waitForAsyncWork(() => { expect(archiveThread).toHaveBeenCalledWith("thread"); - expect(archiveThreadInCatalog).toHaveBeenCalledWith("thread", { closeOpenPanels: true }); + expect(recordThreadArchived).toHaveBeenCalledWith("thread", { closeOpenPanels: true }); }); }); @@ -305,9 +305,9 @@ describe("CodexThreadsView", () => { listThreads: vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }), setThreadName, }); - const renameThreadInCatalog = vi.fn(); + const recordThreadRenamed = vi.fn(); const host = threadsHost({ - threadCatalog: { renameThreadInCatalog }, + threadCatalog: { recordThreadRenamed }, }); const view = await threadsView(host); @@ -321,7 +321,7 @@ describe("CodexThreadsView", () => { await waitForAsyncWork(() => { expect(setThreadName).toHaveBeenCalledWith("thread", "Renamed thread"); - expect(renameThreadInCatalog).toHaveBeenCalledWith("thread", "Renamed thread"); + expect(recordThreadRenamed).toHaveBeenCalledWith("thread", "Renamed thread"); }); }); @@ -425,9 +425,13 @@ function threadsHost(overrides: Record = {}) { openThreadInAvailableView: vi.fn().mockResolvedValue(undefined), getOpenPanelSnapshots: vi.fn(() => []), threadCatalog: { - archiveThreadInCatalog: vi.fn(), - renameThreadInCatalog: vi.fn(), - refreshActiveThreads: vi.fn(async () => { + recordThreadArchived: vi.fn(), + recordThreadRenamed: vi.fn(), + replaceFromAppServer: vi.fn(), + upsertFromAppServer: vi.fn(), + recordThreadRestored: vi.fn(), + load: vi.fn(async () => []), + refresh: vi.fn(async () => { const client = connectionMock.state.client; if (!client) return []; const listThreads = client["listThreads"] as ( @@ -439,8 +443,8 @@ function threadsHost(overrides: Record = {}) { const response = await listThreads("/vault", { archived: false, cursor: null, limit: 100 }); return response.data.map(threadFromRecord); }), - activeThreadsSnapshot: vi.fn(() => null), - observeActiveThreadsResult: vi.fn(() => () => undefined), + snapshot: vi.fn(() => null), + observe: vi.fn(() => () => undefined), ...threadCatalogOverrides, }, ...hostOverrides, diff --git a/tests/features/threads/thread-operations.test.ts b/tests/features/threads/thread-operations.test.ts index a58b2a80..a2c740d8 100644 --- a/tests/features/threads/thread-operations.test.ts +++ b/tests/features/threads/thread-operations.test.ts @@ -21,7 +21,7 @@ describe("ThreadOperations", () => { await expect(operations.renameThread("thread", " Saved title ")).resolves.toBe(true); expect(client?.setThreadName).toHaveBeenCalledWith("thread", "Saved title"); - expect(catalog.renameThreadInCatalog).toHaveBeenCalledWith("thread", "Saved title"); + expect(catalog.recordThreadRenamed).toHaveBeenCalledWith("thread", "Saved title"); }); it("can skip rename publication when the caller invalidates the save", async () => { @@ -29,7 +29,7 @@ describe("ThreadOperations", () => { await operations.renameThread("thread", "Generated title", { shouldPublish: () => false }); - expect(catalog.renameThreadInCatalog).not.toHaveBeenCalled(); + expect(catalog.recordThreadRenamed).not.toHaveBeenCalled(); }); it("archives a thread, reports exported markdown, and notifies shared surfaces", async () => { @@ -41,7 +41,7 @@ describe("ThreadOperations", () => { }); expect(notice).toHaveBeenCalledWith("Saved archived thread to Archive/thread.md."); - expect(catalog.archiveThreadInCatalog).toHaveBeenCalledWith("thread", { closeOpenPanels: true }); + expect(catalog.recordThreadArchived).toHaveBeenCalledWith("thread", { closeOpenPanels: true }); }); it("does not notify surfaces when an operation has no current client", async () => { @@ -50,24 +50,54 @@ describe("ThreadOperations", () => { await expect(operations.renameThread("thread", "Title")).resolves.toBe(false); await expect(operations.archiveThread("thread")).resolves.toBeNull(); - expect(catalog.renameThreadInCatalog).not.toHaveBeenCalled(); - expect(catalog.archiveThreadInCatalog).not.toHaveBeenCalled(); + expect(catalog.recordThreadRenamed).not.toHaveBeenCalled(); + expect(catalog.recordThreadArchived).not.toHaveBeenCalled(); + }); + + it("does not publish stale rename results after the current client changes", async () => { + const firstClient = clientMock(); + const secondClient = clientMock(); + let currentClient: MockClient | null = firstClient; + const { operations, catalog } = operationsFixture({ client: () => currentClient }); + firstClient.setThreadName.mockImplementationOnce(async () => { + currentClient = secondClient; + }); + + await expect(operations.renameThread("thread", "Title")).resolves.toBe(false); + + expect(catalog.recordThreadRenamed).not.toHaveBeenCalled(); + }); + + it("does not publish stale archive results after the current client changes", async () => { + const firstClient = clientMock(); + const secondClient = clientMock(); + let currentClient: MockClient | null = firstClient; + const { operations, catalog } = operationsFixture({ client: () => currentClient }); + archiveMock.archiveThreadOnAppServer.mockImplementationOnce(async () => { + currentClient = secondClient; + return { exportedPath: null } satisfies ArchiveThreadResult; + }); + + await expect(operations.archiveThread("thread")).resolves.toBeNull(); + + expect(catalog.recordThreadArchived).not.toHaveBeenCalled(); }); }); -function operationsFixture(options: { client?: MockClient | null } = {}) { +function operationsFixture(options: { client?: MockClient | null | (() => MockClient | null) } = {}) { archiveMock.archiveThreadOnAppServer.mockReset(); archiveMock.archiveThreadOnAppServer.mockResolvedValue({ exportedPath: null } satisfies ArchiveThreadResult); - const client = options.client === undefined ? clientMock() : options.client; + const configuredClient = options.client === undefined ? clientMock() : options.client; + const currentClient = typeof configuredClient === "function" ? configuredClient : () => configuredClient; const catalog = { - archiveThreadInCatalog: vi.fn(), - renameThreadInCatalog: vi.fn(), + recordThreadArchived: vi.fn(), + recordThreadRenamed: vi.fn(), }; const notice = vi.fn(); const host: ThreadOperationsHost = { connection: { ensureConnected: vi.fn().mockResolvedValue(undefined), - currentClient: () => client as AppServerClient | null, + currentClient: () => currentClient() as AppServerClient | null, }, settings: { current: () => ({ ...DEFAULT_SETTINGS, archiveExportEnabled: false }), @@ -77,7 +107,7 @@ function operationsFixture(options: { client?: MockClient | null } = {}) { catalog, notice, }; - return { operations: createThreadOperations(host), client, catalog, notice }; + return { operations: createThreadOperations(host), client: currentClient(), catalog, notice }; } type MockClient = ReturnType; diff --git a/tests/main.test.ts b/tests/main.test.ts index ac793cba..786b508f 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -370,7 +370,7 @@ describe("CodexPanelPlugin boot restored panel loading", () => { const refreshSharedThreadList = vi.spyOn(connectedView.surface, "refreshSharedThreadList").mockResolvedValue(undefined); const plugin = await pluginWithLeaves([connectedLeaf]); - threadCatalog(plugin).archiveThreadInCatalog("thread-1"); + threadCatalog(plugin).recordThreadArchived("thread-1"); expect(refreshSharedThreadList).not.toHaveBeenCalled(); }); @@ -388,13 +388,13 @@ describe("CodexPanelPlugin boot restored panel loading", () => { vi.spyOn((otherLeaf.view as CodexChatView).surface, "refreshSharedThreadList").mockResolvedValue(undefined); const plugin = await pluginWithLeaves([restoredMatchingLeaf, matchingLeaf, otherLeaf]); - threadCatalog(plugin).archiveThreadInCatalog("thread-1"); + threadCatalog(plugin).recordThreadArchived("thread-1"); expect(restoredMatchingLeaf.detach).not.toHaveBeenCalled(); expect(matchingLeaf.detach).not.toHaveBeenCalled(); expect(otherLeaf.detach).not.toHaveBeenCalled(); - threadCatalog(plugin).archiveThreadInCatalog("thread-1", { closeOpenPanels: true }); + threadCatalog(plugin).recordThreadArchived("thread-1", { closeOpenPanels: true }); expect(restoredMatchingLeaf.detach).toHaveBeenCalledOnce(); expect(matchingLeaf.detach).toHaveBeenCalledOnce(); @@ -410,7 +410,7 @@ describe("CodexPanelPlugin boot restored panel loading", () => { const refreshSharedThreadList = vi.spyOn(connectedView.surface, "refreshSharedThreadList").mockResolvedValue(undefined); const plugin = await pluginWithLeaves([connectedLeaf]); - threadCatalog(plugin).renameThreadInCatalog("thread-1", "Renamed thread"); + threadCatalog(plugin).recordThreadRenamed("thread-1", "Renamed thread"); expect(refreshSharedThreadList).not.toHaveBeenCalled(); }); @@ -435,8 +435,8 @@ describe("CodexPanelPlugin boot restored panel loading", () => { const plugin = await pluginWithLeaves([connectedLeaf]); plugin.settings.codexPath = "codex"; - const first = threadCatalog(plugin).refreshActiveThreads(); - const second = threadCatalog(plugin).refreshActiveThreads(); + const first = threadCatalog(plugin).refresh(); + const second = threadCatalog(plugin).refresh(); await flushMicrotasks(); expect(runWithAppServerClient).toHaveBeenCalledOnce(); @@ -444,7 +444,7 @@ describe("CodexPanelPlugin boot restored panel loading", () => { await expect(first).resolves.toEqual([thread("first")]); await expect(second).resolves.toEqual([thread("first")]); - expect(threadCatalog(plugin).activeThreadsSnapshot()).toEqual([thread("first")]); + expect(threadCatalog(plugin).snapshot()).toEqual([thread("first")]); }); it("keeps shared thread list refreshes separate across app-server cache contexts", async () => { @@ -469,21 +469,21 @@ describe("CodexPanelPlugin boot restored panel loading", () => { ), ); - const first = threadCatalog(plugin).refreshActiveThreads(); + const first = threadCatalog(plugin).refresh(); await flushMicrotasks(); plugin.settings.codexPath = "codex-b"; - const second = threadCatalog(plugin).refreshActiveThreads(); + const second = threadCatalog(plugin).refresh(); await flushMicrotasks(); expect(runWithAppServerClient).toHaveBeenCalledTimes(2); await expect(second).resolves.toEqual([thread("second")]); - expect(threadCatalog(plugin).activeThreadsSnapshot()).toEqual([thread("second")]); + expect(threadCatalog(plugin).snapshot()).toEqual([thread("second")]); resolveFirst([thread("first")]); await expect(first).rejects.toThrow("Codex app-server query context changed while loading shared data."); - expect(threadCatalog(plugin).activeThreadsSnapshot()).toEqual([thread("second")]); + expect(threadCatalog(plugin).snapshot()).toEqual([thread("second")]); plugin.settings.codexPath = "codex-a"; - expect(threadCatalog(plugin).activeThreadsSnapshot()).toEqual([thread("first")]); + expect(threadCatalog(plugin).snapshot()).toEqual([thread("first")]); }); it("keeps the previous shared thread list when refresh fails", async () => { @@ -497,12 +497,12 @@ describe("CodexPanelPlugin boot restored panel loading", () => { .mockImplementationOnce((operation) => operation(threadListClient(() => Promise.resolve([thread("cached")])))) .mockImplementationOnce(() => Promise.reject(new Error("boom"))); const plugin = await pluginWithLeaves([connectedLeaf]); - await threadCatalog(plugin).refreshActiveThreads(); + await threadCatalog(plugin).refresh(); - await expect(threadCatalog(plugin).refreshActiveThreads()).rejects.toThrow("boom"); + await expect(threadCatalog(plugin).refresh()).rejects.toThrow("boom"); expect(runWithAppServerClient).toHaveBeenCalledTimes(2); - expect(threadCatalog(plugin).activeThreadsSnapshot()).toEqual([thread("cached")]); + expect(threadCatalog(plugin).snapshot()).toEqual([thread("cached")]); }); it("refreshes shared thread lists from a remaining connected panel after the archived panel is detached", async () => { @@ -530,7 +530,7 @@ describe("CodexPanelPlugin boot restored panel loading", () => { const plugin = await pluginWithLeaves(leaves); sourceLeaf.detach(); - threadCatalog(plugin).archiveThreadInCatalog("thread-1"); + threadCatalog(plugin).recordThreadArchived("thread-1"); expect(sourceRefresh).not.toHaveBeenCalled(); expect(remainingRefresh).not.toHaveBeenCalled(); @@ -619,6 +619,7 @@ function chatHostFixture(): CodexChatHost { openThreadInNewView: vi.fn(), focusThreadInOpenView: vi.fn(), openTurnDiff: vi.fn(), + refreshThreadsViewLiveState: vi.fn(), }, appServerData: { updateAppServerMetadata: vi.fn(() => null), @@ -631,13 +632,15 @@ function chatHostFixture(): CodexChatHost { observeModelsResult: vi.fn(() => () => undefined), }, threadCatalog: { - archiveThreadInCatalog: vi.fn(), - renameThreadInCatalog: vi.fn(), - refreshThreadsViewLiveState: vi.fn(), - setActiveThreads: vi.fn(), - refreshActiveThreads: vi.fn(() => Promise.resolve([])), - activeThreadsSnapshot: vi.fn(() => null), - observeActiveThreadsResult: vi.fn(() => () => undefined), + recordThreadArchived: vi.fn(), + recordThreadRenamed: vi.fn(), + replaceFromAppServer: vi.fn(), + upsertFromAppServer: vi.fn(), + recordThreadRestored: vi.fn(), + load: vi.fn(() => Promise.resolve([])), + refresh: vi.fn(() => Promise.resolve([])), + snapshot: vi.fn(() => null), + observe: vi.fn(() => () => undefined), }, }; } diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index 08a660f5..84877f9d 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -399,7 +399,7 @@ describe("settings tab", () => { it("ignores stale archived restore results after a newer dynamic operation completes", async () => { const staleRestore = deferred<{ thread: ThreadRecord }>(); - const refreshActiveThreads = vi.fn().mockResolvedValue([]); + const recordThreadRestored = vi.fn(); const initialClient = settingsClient({ threads: [appServerThread({ id: "thread-old", preview: "Old archived" })], }); @@ -419,7 +419,7 @@ describe("settings tab", () => { .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => operation(newerClient), ); - const controller = new SettingsDynamicDataController(settingsTabHost({ refreshActiveThreads }), { + const controller = new SettingsDynamicDataController(settingsTabHost({ recordThreadRestored }), { display: vi.fn(), notify: vi.fn(), }); @@ -434,13 +434,13 @@ describe("settings tab", () => { staleRestore.resolve({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }); await restore; - expect(refreshActiveThreads).not.toHaveBeenCalled(); + expect(recordThreadRestored).not.toHaveBeenCalled(); expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New archived"]); }); - it("keeps restored archived threads removed when active thread refresh fails", async () => { + it("records restored archived threads in the active catalog", async () => { const notify = vi.fn(); - const refreshActiveThreads = vi.fn().mockRejectedValue(new Error("offline")); + const recordThreadRestored = vi.fn(); const initialClient = settingsClient({ threads: [appServerThread({ id: "thread-old", preview: "Old archived" })], }); @@ -454,7 +454,7 @@ describe("settings tab", () => { .mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise) => operation(restoreClient), ); - const controller = new SettingsDynamicDataController(settingsTabHost({ refreshActiveThreads }), { display: vi.fn(), notify }); + const controller = new SettingsDynamicDataController(settingsTabHost({ recordThreadRestored }), { display: vi.fn(), notify }); await controller.refreshSettingsData(); await controller.restoreArchivedThread("thread-old"); @@ -462,13 +462,13 @@ describe("settings tab", () => { const snapshot = controller.snapshot(); expect(snapshot.archivedThreads).toEqual([]); expect(snapshot.archivedThreadsLifecycle.kind).toBe("loaded"); - expect(refreshActiveThreads).toHaveBeenCalledOnce(); - expect(notify).toHaveBeenCalledWith("Could not refresh active Codex threads."); + expect(recordThreadRestored).toHaveBeenCalledWith( + expect.objectContaining({ id: "thread-old", preview: "Restored old", archived: false }), + ); expect(notify).not.toHaveBeenCalledWith("Could not restore archived Codex thread."); }); - it("displays restored archived thread state before active thread refresh completes", async () => { - const activeRefresh = deferred(); + it("displays restored archived thread state after recording the active catalog event", async () => { const snapshots: SettingsDynamicDataSnapshot[] = []; const initialClient = settingsClient({ threads: [appServerThread({ id: "thread-old", preview: "Old archived" })], @@ -484,7 +484,7 @@ describe("settings tab", () => { operation(restoreClient), ); const controllerRef: { current: SettingsDynamicDataController | null } = { current: null }; - const controller = new SettingsDynamicDataController(settingsTabHost({ refreshActiveThreads: () => activeRefresh.promise }), { + const controller = new SettingsDynamicDataController(settingsTabHost(), { display: () => { const snapshot = controllerRef.current?.snapshot(); if (snapshot) snapshots.push(snapshot); @@ -495,14 +495,10 @@ describe("settings tab", () => { await controller.refreshSettingsData(); snapshots.length = 0; - const restore = controller.restoreArchivedThread("thread-old"); - await flushPromises(); + await controller.restoreArchivedThread("thread-old"); expect(snapshots.at(-1)?.archivedThreads).toEqual([]); expect(snapshots.at(-1)?.archivedThreadsLifecycle.kind).toBe("loaded"); - - activeRefresh.resolve([]); - await restore; }); it("uses cached models initially and publishes refreshed models", async () => { @@ -814,7 +810,7 @@ function newSettingsTab( observeModels?: CodexPanelSettingTabHost["appServerData"]["observeModelsResult"]; notifyContextChanged?: () => void; refreshOpenViews?: () => void; - refreshActiveThreads?: () => Promise; + recordThreadRestored?: (thread: Thread) => void; settings?: Partial<{ threadNamingModel: string | null; threadNamingEffort: string | null; @@ -836,7 +832,7 @@ function settingsTabHost( observeModels?: CodexPanelSettingTabHost["appServerData"]["observeModelsResult"]; notifyContextChanged?: () => void; refreshOpenViews?: () => void; - refreshActiveThreads?: () => Promise; + recordThreadRestored?: (thread: Thread) => void; settings?: Partial<{ threadNamingModel: string | null; threadNamingEffort: string | null; @@ -871,7 +867,11 @@ function settingsTabHost( notifyContextChanged: options.notifyContextChanged ?? vi.fn(), }, threadCatalog: { - refreshActiveThreads: options.refreshActiveThreads ?? vi.fn().mockResolvedValue([]), + replaceFromAppServer: vi.fn(), + upsertFromAppServer: vi.fn(), + recordThreadRenamed: vi.fn(), + recordThreadArchived: vi.fn(), + recordThreadRestored: options.recordThreadRestored ?? vi.fn(), }, }; } diff --git a/tests/workspace/shared-thread-catalog.test.ts b/tests/workspace/active-thread-catalog.test.ts similarity index 71% rename from tests/workspace/shared-thread-catalog.test.ts rename to tests/workspace/active-thread-catalog.test.ts index 77da999a..126b544f 100644 --- a/tests/workspace/shared-thread-catalog.test.ts +++ b/tests/workspace/active-thread-catalog.test.ts @@ -3,25 +3,24 @@ import { describe, expect, it, vi, type Mock } from "vitest"; import { AppServerQueryCache } from "../../src/app-server/query/cache"; import { AppServerSharedQueries } from "../../src/app-server/query/shared-queries"; import type { Thread } from "../../src/domain/threads/model"; -import { createSharedThreadCatalog } from "../../src/workspace/shared-thread-catalog"; +import { createActiveThreadCatalog } from "../../src/workspace/active-thread-catalog"; interface MockSurfaceActions { refreshOpenViews: Mock<() => void>; applyThreadArchived: Mock<(threadId: string, options?: { closeOpenPanels?: boolean }) => void>; applyThreadRenamed: Mock<(threadId: string, name: string | null) => void>; - refreshThreadsViewLiveState: Mock<() => void>; } -describe("SharedThreadCatalog", () => { +describe("ActiveThreadCatalog", () => { it("applies thread snapshots to the shared cache and active observers", () => { const { catalog } = catalogFixture(); const threads = [thread("thread")]; const listener = vi.fn(); - catalog.observeActiveThreadsResult(listener); + catalog.observe(listener); - catalog.setActiveThreads(threads); + catalog.replaceFromAppServer(threads); - expect(catalog.activeThreadsSnapshot()).toEqual(threads); + expect(catalog.snapshot()).toEqual(threads); expect(listener).toHaveBeenCalledWith(expect.objectContaining({ data: threads })); }); @@ -29,15 +28,15 @@ describe("SharedThreadCatalog", () => { const fetchThreads = vi.fn().mockResolvedValue([thread("thread")]); const { catalog } = catalogFixture({ fetchThreads }); const listener = vi.fn(); - catalog.observeActiveThreadsResult(listener); + catalog.observe(listener); - const first = catalog.refreshActiveThreads(); - const second = catalog.refreshActiveThreads(); + const first = catalog.refresh(); + const second = catalog.refresh(); await expect(first).resolves.toEqual([thread("thread")]); await expect(second).resolves.toEqual([thread("thread")]); expect(fetchThreads).toHaveBeenCalledOnce(); - expect(catalog.activeThreadsSnapshot()).toEqual([thread("thread")]); + expect(catalog.snapshot()).toEqual([thread("thread")]); expect(listener.mock.calls.filter(([result]) => result.data !== null)).toHaveLength(1); expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({ data: [thread("thread")] })); }); @@ -45,12 +44,12 @@ describe("SharedThreadCatalog", () => { it("applies known rename mutations to cache and surfaces", () => { const { catalog, surfaces } = catalogFixture(); const listener = vi.fn(); - catalog.observeActiveThreadsResult(listener); - catalog.setActiveThreads([thread("thread"), thread("other")]); + catalog.observe(listener); + catalog.replaceFromAppServer([thread("thread"), thread("other")]); - catalog.renameThreadInCatalog("thread", "Renamed"); + catalog.recordThreadRenamed("thread", "Renamed"); - expect(catalog.activeThreadsSnapshot()).toEqual([{ ...thread("thread"), name: "Renamed" }, thread("other")]); + expect(catalog.snapshot()).toEqual([{ ...thread("thread"), name: "Renamed" }, thread("other")]); expect(listener).toHaveBeenLastCalledWith( expect.objectContaining({ data: [{ ...thread("thread"), name: "Renamed" }, thread("other")] }), ); @@ -60,15 +59,25 @@ describe("SharedThreadCatalog", () => { it("applies known archive mutations to cache and surfaces", () => { const { catalog, surfaces } = catalogFixture(); const listener = vi.fn(); - catalog.observeActiveThreadsResult(listener); - catalog.setActiveThreads([thread("thread"), thread("other")]); + catalog.observe(listener); + catalog.replaceFromAppServer([thread("thread"), thread("other")]); - catalog.archiveThreadInCatalog("thread", { closeOpenPanels: true }); + catalog.recordThreadArchived("thread", { closeOpenPanels: true }); - expect(catalog.activeThreadsSnapshot()).toEqual([thread("other")]); + expect(catalog.snapshot()).toEqual([thread("other")]); expect(listener).toHaveBeenLastCalledWith(expect.objectContaining({ data: [thread("other")] })); expect(surfaces.applyThreadArchived).toHaveBeenCalledWith("thread", { closeOpenPanels: true }); }); + + it("upserts started, forked, and restored threads without replacing the whole catalog", () => { + const { catalog } = catalogFixture(); + catalog.replaceFromAppServer([thread("existing")]); + + catalog.upsertFromAppServer(thread("started")); + catalog.recordThreadRestored(thread("restored")); + + expect(catalog.snapshot()?.map((item) => item.id)).toEqual(["restored", "started", "existing"]); + }); }); function catalogFixture( @@ -79,7 +88,7 @@ function catalogFixture( cache: cacheWithThreads(options.fetchThreads ?? (() => Promise.resolve([]))), context: () => ({ codexPath: "codex", vaultPath: "/vault" }), }); - const catalog = createSharedThreadCatalog({ + const catalog = createActiveThreadCatalog({ queries, surfaces, }); @@ -108,7 +117,6 @@ function surfaceActions(): MockSurfaceActions { refreshOpenViews: vi.fn(), applyThreadArchived: vi.fn(), applyThreadRenamed: vi.fn(), - refreshThreadsViewLiveState: vi.fn(), }; }