Strengthen active thread catalog projection

This commit is contained in:
murashit 2026-06-16 21:14:42 +09:00
parent 5dcbaa5478
commit ba24038b92
23 changed files with 549 additions and 266 deletions

View file

@ -43,10 +43,23 @@ interface AppServerQueryOptions<T> {
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<string, number>();
private readonly activeThreadsMutationOverlays = new Map<string, ActiveThreadsMutationOverlay[]>();
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<readonly Thread[]> => {
const threads = cloneThreads(await this.runWithClient(refreshContext, (client) => listThreads(client, refreshContext.vaultPath)));
if (this.activeThreadsWriteVersion(refreshContext) !== writeVersion) {
return cloneThreads(this.client.getQueryData<readonly Thread[]>(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<readonly Thread[]>(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<readonly Thread[]>((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 } = {},

View file

@ -96,6 +96,10 @@ export async function rollbackThread(client: AppServerClient, threadId: string):
return threadRollbackSnapshotFromAppServerResponse(await client.rollbackThread(threadId));
}
export function threadFromThreadForkResponse(response: Awaited<ReturnType<AppServerClient["forkThread"]>>): Thread {
return threadFromThreadRecord(response.thread);
}
export async function restoreArchivedThread(client: AppServerClient, threadId: string): Promise<Thread> {
const response = await client.unarchiveThread(threadId);
return threadFromThreadRecord(response.thread);

View file

@ -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 };
}

View file

@ -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<void>;
notifyActiveThreadIdentityChanged: () => void;
refreshAfterThreadMutation: () => Promise<void>;
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,

View file

@ -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<unknown>;
focusThreadInOpenView(threadId: string): Promise<boolean>;
openTurnDiff(state: ChatTurnDiffViewState): Promise<void>;
refreshThreadsViewLiveState(): void;
}
type ChatThreadCatalog = Pick<
SharedThreadCatalog,
| "archiveThreadInCatalog"
| "renameThreadInCatalog"
| "refreshThreadsViewLiveState"
| "setActiveThreads"
| "refreshActiveThreads"
| "activeThreadsSnapshot"
| "observeActiveThreadsResult"
>;
type ChatThreadCatalog = ActiveThreadCatalog;
type ChatAppServerData = Pick<
AppServerSharedQueries,

View file

@ -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<void> {
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);

View file

@ -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<void>;
openThreadInAvailableView(threadId: string): Promise<void>;
}
type ThreadPickerCatalog = Pick<SharedThreadCatalog, "activeThreadsSnapshot" | "fetchActiveThreads">;
interface ThreadSuggestion {
thread: Thread;
title: string;
@ -76,10 +74,7 @@ function threadOpenModeFromEvent(evt: MouseEvent | KeyboardEvent): ThreadOpenMod
}
async function loadThreadPickerThreads(host: ThreadPickerHost): Promise<readonly Thread[]> {
const cached = host.threadCatalog.activeThreadsSnapshot();
if (cached) return cached;
return host.threadCatalog.fetchActiveThreads();
return host.threadCatalog.load();
}
class ThreadPickerModal extends SuggestModal<ThreadSuggestion> {

View file

@ -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" };

View file

@ -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;
}

View file

@ -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,

View file

@ -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<SharedThreadCatalog, "refreshActiveThreads">;
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, {

View file

@ -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<readonly Thread[]>) => 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<readonly Thread[]>;
refresh(): Promise<readonly Thread[]>;
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];
}

View file

@ -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<readonly Thread[]>;
refreshActiveThreads(): Promise<readonly Thread[]>;
setActiveThreads(threads: readonly Thread[]): void;
observeActiveThreadsResult(
listener: (result: AppServerObservedQueryResult<readonly Thread[]>) => 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();
},
};
}

View file

@ -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<readonly ReturnType<typeof thread>[]>();
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<readonly ReturnType<typeof thread>[]>();
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<readonly ReturnType<typeof thread>[]>();
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> = {}): AppServerQueryContext {

View file

@ -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();
});

View file

@ -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<undefined>();
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<typeof clientMock>;
items: MessageStreamItem[];
archiveAdapter?: ArchiveExportAdapter;
settings?: Partial<typeof DEFAULT_SETTINGS>;
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(),
};
}

View file

@ -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<readonly Thread[]>) => 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(),
},
};
}

View file

@ -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);

View file

@ -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<string, unknown> = {}) {
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<string, unknown> = {}) {
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,

View file

@ -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<typeof clientMock>;

View file

@ -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),
},
};
}

View file

@ -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<unknown>) =>
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<unknown>) =>
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<readonly Thread[]>();
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<readonly Thread[]>;
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<readonly Thread[]>;
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(),
},
};
}

View file

@ -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(),
};
}