mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Strengthen active thread catalog projection
This commit is contained in:
parent
5dcbaa5478
commit
ba24038b92
23 changed files with 549 additions and 266 deletions
|
|
@ -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 } = {},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
65
src/workspace/active-thread-catalog.ts
Normal file
65
src/workspace/active-thread-catalog.ts
Normal 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];
|
||||
}
|
||||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in a new issue