diff --git a/src/app-server/connection/connection-manager.ts b/src/app-server/connection/connection-manager.ts index 671b04e5..9fd66549 100644 --- a/src/app-server/connection/connection-manager.ts +++ b/src/app-server/connection/connection-manager.ts @@ -5,17 +5,17 @@ import type { ServerRequest } from "../../generated/app-server/ServerRequest"; import { AppServerClient, type AppServerClientHandlers, type AppServerServerRequestResponder } from "./client"; export interface ConnectionManagerHandlers { - onNotification: (notification: ServerNotification) => void; - onServerRequest: (request: ServerRequest, responder: AppServerServerRequestResponder) => void; - onLog: (message: string) => void; - onExit: () => void; + onNotification: (notification: ServerNotification, context: AppServerConnectionContext) => void; + onServerRequest: (request: ServerRequest, responder: AppServerServerRequestResponder, context: AppServerConnectionContext) => void; + onLog: (message: string, context: AppServerConnectionContext) => void; + onExit: (context: AppServerConnectionContext) => void; } export type AppServerClientFactory = (codexPath: string, cwd: string, handlers: AppServerClientHandlers) => AppServerClient; export interface AppServerConnectionContext { - codexPath: string; - cwd: string; + readonly codexPath: string; + readonly cwd: string; } type ConnectionLifecycleState = @@ -77,23 +77,24 @@ export class ConnectionManager { const generation = this.state.generation + 1; const codexPath = this.codexPath(); const cwd = this.cwd; + const context = { codexPath, cwd }; const client = this.clientFactory(codexPath, cwd, { onNotification: (notification) => { if (this.isStale(generation)) return; - handlers.onNotification(notification); + handlers.onNotification(notification, context); }, onServerRequest: (request, responder) => { if (this.isStale(generation)) return; - handlers.onServerRequest(request, responder); + handlers.onServerRequest(request, responder, context); }, onLog: (message) => { if (this.isStale(generation)) return; - handlers.onLog(message); + handlers.onLog(message, context); }, onExit: () => { if (this.isStale(generation)) return; this.state = { kind: "disconnected", generation }; - handlers.onExit(); + handlers.onExit(context); }, }); const promise = client diff --git a/src/app-server/query/keys.ts b/src/app-server/query/keys.ts index 885ff072..c490edf0 100644 --- a/src/app-server/query/keys.ts +++ b/src/app-server/query/keys.ts @@ -25,6 +25,10 @@ export function appServerQueryContextMatches(left: AppServerQueryContext, right: return appServerQueryContextIsComplete(left) && appServerQueryContextIsComplete(right) && appServerQueryContextRawEquals(left, right); } +export function appServerQueryContextKey(context: AppServerQueryContext): string { + return `${context.codexPath}\u0000${context.vaultPath}`; +} + function appServerQueryScope(context: AppServerQueryContext): AppServerQueryScope { return ["app-server", context.codexPath, context.vaultPath]; } diff --git a/src/app-server/query/shared-queries.ts b/src/app-server/query/shared-queries.ts index c298774b..07221f56 100644 --- a/src/app-server/query/shared-queries.ts +++ b/src/app-server/query/shared-queries.ts @@ -4,6 +4,7 @@ import type { Thread } from "../../domain/threads/model"; import type { AppServerQueryCache, MetadataResourceKind } from "./cache"; import { type AppServerQueryContext, + appServerQueryContextKey, appServerQueryContextMatches, appServerQueryContextRawEquals, cloneAppServerQueryContext, @@ -32,18 +33,29 @@ export class AppServerSharedQueries { constructor(private readonly options: AppServerSharedQueriesOptions) {} contextKey(): string { - const context = this.context(); - return `${context.codexPath}\u0000${context.vaultPath}`; + return appServerQueryContextKey(this.context()); + } + + contextKeyFor(context: AppServerQueryContext): string { + return appServerQueryContextKey(context); } activeThreadsSnapshot(): readonly Thread[] | null { return this.options.cache.activeThreadsSnapshot(this.context()); } + activeThreadsSnapshotFor(context: AppServerQueryContext): readonly Thread[] | null { + return this.options.cache.activeThreadsSnapshot(context); + } + archivedThreadsSnapshot(): readonly Thread[] | null { return this.options.cache.archivedThreadsSnapshot(this.context()); } + archivedThreadsSnapshotFor(context: AppServerQueryContext): readonly Thread[] | null { + return this.options.cache.archivedThreadsSnapshot(context); + } + fetchAllActiveThreads(): Promise { return this.runForCurrentContext((context) => this.options.cache.fetchAllActiveThreads(context)); } @@ -60,10 +72,18 @@ export class AppServerSharedQueries { return this.runForCurrentContext((context) => this.options.cache.refreshActiveThreads(context)); } + refreshActiveThreadsFor(context: AppServerQueryContext): Promise { + return this.options.cache.refreshActiveThreads(context); + } + refreshArchivedThreads(): Promise { return this.runForCurrentContext((context) => this.options.cache.refreshArchivedThreads(context)); } + refreshArchivedThreadsFor(context: AppServerQueryContext): Promise { + return this.options.cache.refreshArchivedThreads(context); + } + observeActiveThreadsResult(listener: ObservedResultListener, options?: { emitCurrent?: boolean }): () => void { return this.observeCurrentContext( (context, contextListener, observeOptions) => this.options.cache.observeActiveThreadsResult(context, contextListener, observeOptions), diff --git a/src/features/chat/app-server/inbound/handler.ts b/src/features/chat/app-server/inbound/handler.ts index 931c6529..6bef1ebf 100644 --- a/src/features/chat/app-server/inbound/handler.ts +++ b/src/features/chat/app-server/inbound/handler.ts @@ -1,4 +1,5 @@ import type { RequestId, ServerNotification, ServerRequest } from "../../../../app-server/connection/rpc-messages"; +import type { AppServerQueryContext } from "../../../../app-server/query/keys"; import { routeServerRequest, serverRequestApprovalResponse, @@ -35,13 +36,13 @@ export interface ChatInboundHandlerActions { refreshServerDiagnostics: (options?: { forceResourceProbes?: boolean }) => void; applyAppServerResourceEvent: (event: AppServerResourceEvent) => void; maybeNameThread: (threadId: string, turnId: string, completedTurnTranscriptSummary: TurnTranscriptSummary | null) => void; - applyThreadCatalogEvent: (event: ThreadCatalogEvent) => void; + applyThreadCatalogEvent: (event: ThreadCatalogEvent, sourceContext: AppServerQueryContext) => void; respondToServerRequest: (requestId: RequestId, result: unknown) => boolean; rejectServerRequest: (requestId: RequestId, code: number, message: string) => boolean; } export interface ChatInboundHandler { - handleNotification(notification: ServerNotification): void; + handleNotification(notification: ServerNotification, sourceContext: AppServerQueryContext): void; handleServerRequest(request: ServerRequest): void; handleAppServerLog(message: string): void; resolveApproval(requestId: PendingRequestId, action: ApprovalAction): void; @@ -66,8 +67,8 @@ export function createChatInboundHandler( ): ChatInboundHandler { const context: ChatInboundHandlerContext = { store, actions, localItemIds }; return { - handleNotification: (notification) => { - handleNotification(context, notification); + handleNotification: (notification, sourceContext) => { + handleNotification(context, notification, sourceContext); }, handleServerRequest: (request) => { handleServerRequest(context, request); @@ -107,10 +108,14 @@ function dispatch(context: ChatInboundHandlerContext, action: ChatAction): void context.store.dispatch(action); } -function handleNotification(context: ChatInboundHandlerContext, notification: ServerNotification): void { +function handleNotification( + context: ChatInboundHandlerContext, + notification: ServerNotification, + sourceContext: AppServerQueryContext, +): void { const plan = planChatNotification(state(context), notification, (prefix) => localItemId(context, prefix)); for (const action of plan.actions) dispatch(context, action); - for (const effect of plan.effects) runNotificationEffect(context, effect); + for (const effect of plan.effects) runNotificationEffect(context, effect, sourceContext); } function handleServerRequest(context: ChatInboundHandlerContext, request: ServerRequest): void { @@ -238,7 +243,11 @@ function localItemId(context: ChatInboundHandlerContext, prefix: string): string return context.localItemIds.next(prefix); } -function runNotificationEffect(context: ChatInboundHandlerContext, effect: ChatNotificationEffect): void { +function runNotificationEffect( + context: ChatInboundHandlerContext, + effect: ChatNotificationEffect, + sourceContext: AppServerQueryContext, +): void { switch (effect.type) { case "refresh-threads": context.actions.refreshActiveThreads(); @@ -253,7 +262,7 @@ function runNotificationEffect(context: ChatInboundHandlerContext, effect: ChatN context.actions.maybeNameThread(effect.threadId, effect.turnId, effect.completedTurnTranscriptSummary); return; case "apply-thread-catalog-event": - context.actions.applyThreadCatalogEvent(effect.event); + context.actions.applyThreadCatalogEvent(effect.event, sourceContext); return; } } diff --git a/src/features/chat/application/connection/reconnect-actions.ts b/src/features/chat/application/connection/reconnect-actions.ts index 28f4df5e..25437ef5 100644 --- a/src/features/chat/application/connection/reconnect-actions.ts +++ b/src/features/chat/application/connection/reconnect-actions.ts @@ -11,12 +11,17 @@ export interface ChatReconnectActionsHost { resetConnection: () => void; setStatus: (statusText: string, phase?: ChatConnectionPhase) => void; ensureConnected: () => Promise; + isConnected: () => boolean; resumeThread: (threadId: string) => Promise; addSystemMessage: (text: string) => void; } -export async function reconnectPanel(host: ChatReconnectActionsHost): Promise { - const threadId = host.stateStore.getState().activeThread.id; +export async function reconnectPanel( + host: ChatReconnectActionsHost, + target: { resumeThreadId: string | null; isCurrent?: () => boolean } | null = null, +): Promise { + const threadId = target ? target.resumeThreadId : host.stateStore.getState().activeThread.id; + const isCurrent = target?.isCurrent ?? (() => true); host.stateStore.dispatch({ type: "ui/panel-set", panel: null }); host.invalidateConnectionWork(); host.invalidateThreadWork(); @@ -26,10 +31,14 @@ export async function reconnectPanel(host: ChatReconnectActionsHost): Promise { autoTitleCoordinator.maybeAutoTitleThread(threadId, turnId, completedTurnTranscriptSummary); }, - applyThreadCatalogEvent: (event) => { - environment.plugin.threadCatalog.apply(event); + applyThreadCatalogEvent: (event, sourceContext) => { + environment.plugin.threadCatalog.applyConnectionEvent(sourceContext, event); }, respondToServerRequest: (requestId, result) => serverRequestResponders.respond(requestId, result), rejectServerRequest: (requestId, code, message) => serverRequestResponders.reject(requestId, code, message), @@ -192,8 +192,11 @@ export function createConnectionBundle( connection: { connect: () => connection.connect({ - onNotification: (notification) => { - inboundHandler.handleNotification(notification); + onNotification: (notification, sourceContext) => { + inboundHandler.handleNotification(notification, { + codexPath: sourceContext.codexPath, + vaultPath: sourceContext.cwd, + }); host.deferLiveStateRefresh(); }, onServerRequest: (request, responder) => { diff --git a/src/features/chat/host/contracts.ts b/src/features/chat/host/contracts.ts index ea80849d..70fb9644 100644 --- a/src/features/chat/host/contracts.ts +++ b/src/features/chat/host/contracts.ts @@ -8,7 +8,11 @@ import type { SendShortcut } from "../../../domain/input/send-shortcut"; import type { PendingRequestCounts } from "../../../domain/pending-requests/aggregate"; import type { SharedServerMetadata } from "../../../domain/server/metadata"; import type { ArchiveExportSettings } from "../../../domain/threads/archive-markdown"; -import type { ThreadCatalogActiveReader, ThreadCatalogEventSink } from "../../threads/catalog/thread-catalog"; +import type { + ThreadCatalogActiveReader, + ThreadCatalogConnectionEventSink, + ThreadCatalogEventSink, +} from "../../threads/catalog/thread-catalog"; import type { ArchiveExportDestination } from "../../threads/workflows/archive-export"; import type { ThreadNameMutationCoordinator } from "../../threads/workflows/thread-name-mutation-coordinator"; import type { TurnDiffViewState } from "../../turn-diff/model"; @@ -47,7 +51,7 @@ interface WorkspacePanels { openSideChat(sourceThreadId: string, sourceThreadTitle: string | null): Promise; } -type ChatThreadCatalog = ThreadCatalogActiveReader & ThreadCatalogEventSink; +type ChatThreadCatalog = ThreadCatalogActiveReader & ThreadCatalogEventSink & ThreadCatalogConnectionEventSink; interface ChatAppServerQueries { beginAppServerMetadataResourceRefresh(resource: "skills" | "rateLimits"): () => boolean; @@ -88,6 +92,7 @@ export interface ChatViewLifecycleSurface { applyViewState(state: unknown): void; open(): void; close(): Promise; + prepareAppServerContextChange(): void; refreshSettings(): void; } diff --git a/src/features/chat/host/session-runtime.ts b/src/features/chat/host/session-runtime.ts index 5d38e6a8..fc3aad1a 100644 --- a/src/features/chat/host/session-runtime.ts +++ b/src/features/chat/host/session-runtime.ts @@ -44,7 +44,9 @@ interface ChatPanelSessionRuntimeParts { shell: ChatPanelShellBundle; actions: { invalidateThreadWork(): void; + prepareAppServerContextChange(): void; reconnect(): Promise; + reconnectAfterAppServerContextChange(threadId: string | null, isCurrent: () => boolean): Promise; refreshSharedThreads(): Promise; startNewThread(): Promise; }; @@ -69,6 +71,7 @@ interface ChatPanelSessionRuntimeHost { resumeWork: ChatResumeWorkTracker; threadStreamScrollBinding: ChatThreadStreamScrollBinding; getClosing: () => boolean; + reconnect?: () => Promise; viewWindow: () => Window; } @@ -219,31 +222,46 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat notifyActiveThreadIdentityChanged, prepareForPersistentNavigation: () => ephemeral.prepareForPersistentNavigation(), }); - const reconnect = () => - reconnectPanel({ - stateStore, - invalidateConnectionWork: () => { - connectionActions.invalidate(); - }, - invalidateThreadWork: () => { - threadFoundation.invalidateThreadWork(); - }, - clearDeferredDiagnostics: () => { - host.deferredTasks.clearDiagnostics(); - }, - resetConnection: () => { - connectionBundle.invalidateConnectionScope(); - connection.resetConnection(); - }, - setStatus: (statusText, phase) => { - status.set(statusText, phase); - }, - ensureConnected, - resumeThread: (threadId) => threadLifecycle.resume.resumeThread(threadId), - addSystemMessage: (text) => { - status.addSystemMessage(text); - }, - }); + const reconnectHost = { + stateStore, + invalidateConnectionWork: () => { + connectionActions.invalidate(); + }, + invalidateThreadWork: () => { + threadFoundation.invalidateThreadWork(); + }, + clearDeferredDiagnostics: () => { + host.deferredTasks.clearDiagnostics(); + }, + resetConnection: () => { + connectionBundle.invalidateConnectionScope(); + connection.resetConnection(); + }, + setStatus: (statusText: string, phase?: ChatConnectionPhase) => { + status.set(statusText, phase); + }, + ensureConnected, + isConnected: () => connection.isConnected(), + resumeThread: (threadId: string) => threadLifecycle.resume.resumeThread(threadId), + addSystemMessage: (text: string) => { + status.addSystemMessage(text); + }, + }; + const reconnect = async () => { + await reconnectPanel(reconnectHost); + }; + const reconnectAfterAppServerContextChange = (threadId: string | null, isCurrent: () => boolean) => + reconnectPanel(reconnectHost, { resumeThreadId: threadId, isCurrent }); + const reconnectForUser = host.reconnect ?? reconnect; + const prepareAppServerContextChange = (): void => { + connectionActions.invalidate(); + threadFoundation.invalidateThreadWork(); + threadLifecycle.restoration.invalidate(); + host.deferredTasks.clearDiagnostics(); + connectionBundle.invalidateConnectionScope(); + connection.resetConnection(); + stateStore.dispatch({ type: "connection/context-replaced" }); + }; const turn = createTurnBundle(host, { localItemIds, appServer, @@ -257,7 +275,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat threadStart, goals: threadLifecycle.goals, autoTitleCoordinator: threadFoundation.autoTitleCoordinator, - reconnect, + reconnect: reconnectForUser, runtimeProjection: runtime.projection, refreshDiagnostics: () => connectionActions.refreshDiagnostics(), refreshLiveState, @@ -271,7 +289,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat threadActions: threadActions.actions, toolbarPanelActions: threadActions.toolbarPanelActions, navigation: threadActions.navigation, - reconnect, + reconnect: reconnectForUser, history: threadFoundation.history, pendingRequests: turn.pendingRequests, turn, @@ -313,7 +331,9 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat threadFoundation.invalidateThreadWork(); threadLifecycle.restoration.invalidate(); }, + prepareAppServerContextChange, reconnect, + reconnectAfterAppServerContextChange, refreshSharedThreads, startNewThread: () => threadActions.navigation.startNewThread(), }, diff --git a/src/features/chat/host/session.ts b/src/features/chat/host/session.ts index d55d25aa..1f6a9e91 100644 --- a/src/features/chat/host/session.ts +++ b/src/features/chat/host/session.ts @@ -20,6 +20,9 @@ export class ChatPanelSession implements ChatPanelHandle { private readonly resumeWork = new ChatResumeWorkTracker(); private readonly threadStreamScrollBinding: ChatThreadStreamScrollBinding = createChatThreadStreamScrollBinding(); private observedAppServerContext: AppServerQueryContext; + private appServerContextReconnectAttemptGeneration = 0; + private appServerContextReplacementGeneration = 0; + private pendingAppServerContextReplacement: { activeThreadId: string | null; generation: number } | null = null; private opened = false; private closing = false; @@ -77,12 +80,58 @@ export class ChatPanelSession implements ChatPanelHandle { const nextContext = this.currentAppServerContext(); if (!appServerQueryContextRawEquals(this.observedAppServerContext, nextContext)) { this.observedAppServerContext = nextContext; - void this.runtime.actions.reconnect(); + const replacement = this.pendingAppServerContextReplacement ?? this.captureAppServerContextReplacement(this.state.activeThread.id); + void this.reconnectAfterAppServerContextChange(replacement); this.runtime.runtime.sharedState.applyCached(); } this.mountOrRepairShell(); } + prepareAppServerContextChange(): void { + const activeThreadId = this.pendingAppServerContextReplacement?.activeThreadId ?? this.state.activeThread.id; + this.captureAppServerContextReplacement(activeThreadId); + this.runtime.actions.prepareAppServerContextChange(); + } + + private captureAppServerContextReplacement(activeThreadId: string | null): { + activeThreadId: string | null; + generation: number; + } { + const replacement = { activeThreadId, generation: ++this.appServerContextReplacementGeneration }; + this.pendingAppServerContextReplacement = replacement; + return replacement; + } + + private async reconnectAfterAppServerContextChange(replacement: { activeThreadId: string | null; generation: number }): Promise { + const attemptGeneration = ++this.appServerContextReconnectAttemptGeneration; + try { + const resumed = await this.runtime.actions.reconnectAfterAppServerContextChange( + replacement.activeThreadId, + () => + this.pendingAppServerContextReplacement?.generation === replacement.generation && + this.appServerContextReconnectAttemptGeneration === attemptGeneration, + ); + if ( + resumed && + this.pendingAppServerContextReplacement?.generation === replacement.generation && + this.appServerContextReconnectAttemptGeneration === attemptGeneration + ) { + this.pendingAppServerContextReplacement = null; + } + } catch { + // Keep the captured thread for a later context correction or retry. + } + } + + private async reconnect(): Promise { + const replacement = this.pendingAppServerContextReplacement; + if (replacement) { + await this.reconnectAfterAppServerContextChange(replacement); + return; + } + await this.runtime.actions.reconnect(); + } + refreshSharedThreads(): Promise { return this.runtime.actions.refreshSharedThreads(); } @@ -267,6 +316,7 @@ export class ChatPanelSession implements ChatPanelHandle { resumeWork: this.resumeWork, threadStreamScrollBinding: this.threadStreamScrollBinding, getClosing: () => this.closing, + reconnect: () => this.reconnect(), viewWindow: () => this.viewWindow(), }); } diff --git a/src/features/selection-rewrite/command.obsidian.ts b/src/features/selection-rewrite/command.obsidian.ts index 014aa0bf..bad3c4b8 100644 --- a/src/features/selection-rewrite/command.obsidian.ts +++ b/src/features/selection-rewrite/command.obsidian.ts @@ -8,13 +8,21 @@ export interface SelectionRewriteCommandHost extends Plugin { settings: { sendShortcut: SendShortcut } & SelectionRewriteRuntimeSettings; } -export function registerSelectionRewriteCommand(plugin: SelectionRewriteCommandHost, transport: SelectionRewriteTransport): void { - const activePopovers = new Set(); +export interface SelectionRewriteCommandController { + closeAll(): void; +} - plugin.register(() => { +export function registerSelectionRewriteCommand( + plugin: SelectionRewriteCommandHost, + transport: SelectionRewriteTransport, +): SelectionRewriteCommandController { + const activePopovers = new Set(); + const closeAll = (): void => { for (const popover of activePopovers) popover.close(); activePopovers.clear(); - }); + }; + + plugin.register(closeAll); plugin.addCommand({ id: "rewrite-selection", @@ -69,6 +77,8 @@ export function registerSelectionRewriteCommand(plugin: SelectionRewriteCommandH activePopovers.add(popover); }, }); + + return { closeAll }; } function clonePosition(position: ReturnType): ReturnType { diff --git a/src/features/threads-view/session.ts b/src/features/threads-view/session.ts index c23b5244..5866371c 100644 --- a/src/features/threads-view/session.ts +++ b/src/features/threads-view/session.ts @@ -1,5 +1,6 @@ import { Notice } from "obsidian"; +import { type AppServerQueryContext, appServerQueryContextRawEquals } from "../../app-server/query/keys"; import type { ObservedResult } from "../../app-server/query/observed-result"; import { observedInitialError, observedInitialLoading } from "../../app-server/query/observed-result"; import { isStaleAppServerSharedQueryContextError } from "../../app-server/query/shared-queries"; @@ -82,8 +83,11 @@ export class ThreadsViewSession { private nextRenameGenerationToken = 1; private unsubscribeThreads: (() => void) | null = null; private archiveConfirmThreadId: string | null = null; + private observedAppServerContext: AppServerQueryContext; + private operationGeneration = 0; constructor(private readonly environment: ThreadsViewSessionEnvironment) { + this.observedAppServerContext = this.currentAppServerContext(); this.deferredTasks = createThreadsViewDeferredTasks(() => this.viewWindow()); this.operations = createThreadOperations({ transport: this.host.threadOperationsTransport, @@ -179,6 +183,33 @@ export class ThreadsViewSession { this.scheduleRender(); } + prepareAppServerContextChange(): void { + this.operationGeneration += 1; + this.refreshLifecycle = transitionThreadsViewRefreshLifecycle(this.refreshLifecycle, { type: "invalidated" }); + this.deferredTasks.clearAll(); + this.threads = []; + this.threadsLoaded = false; + this.renameStates.clear(); + this.archiveConfirmThreadId = null; + this.status = { kind: "idle" }; + this.render(); + } + + refreshSettings(): void { + const nextContext = this.currentAppServerContext(); + if (appServerQueryContextRawEquals(this.observedAppServerContext, nextContext)) { + this.render(); + return; + } + this.observedAppServerContext = nextContext; + const snapshot = this.host.threadCatalog.activeSnapshot(); + this.threads = snapshot ?? []; + this.threadsLoaded = snapshot !== null; + this.status = snapshot?.length === 0 ? { kind: "empty", message: "No threads" } : { kind: "idle" }; + this.render(); + void this.refresh(); + } + private receiveObservedThreads(threads: readonly Thread[]): void { this.threads = threads; this.threadsLoaded = true; @@ -302,11 +333,17 @@ export class ThreadsViewSession { private async saveRename(threadId: string, value: string): Promise { const lifetime = this.lifetime.signal(); + const operationGeneration = this.operationGeneration; const editingState = this.renameStates.get(threadId); if (!editingState || editingState.kind === "generating") return; try { if (this.renameStates.get(threadId) !== editingState) return; - const result = await this.operations.renameThread(threadId, value); + const operationIsCurrent = () => operationGeneration === this.operationGeneration; + const result = await this.operations.renameThread(threadId, value, { + shouldStart: operationIsCurrent, + shouldPublish: operationIsCurrent, + }); + if (!operationIsCurrent()) return; if (!this.lifetime.isCurrent(lifetime) || this.renameStates.get(threadId) !== editingState) return; if (!result) { this.cancelRename(threadId); @@ -323,6 +360,7 @@ export class ThreadsViewSession { private async autoNameThread(threadId: string): Promise { const lifetime = this.lifetime.signal(); + const operationGeneration = this.operationGeneration; const previousState = this.renameStates.get(threadId); const generatingState = this.transitionRenameState(threadId, { type: "auto-name-started", @@ -335,15 +373,17 @@ export class ThreadsViewSession { try { if (this.renameStates.get(threadId) !== generatingState) return; const title = await this.titleService.generateTitle(threadId); - if (!this.lifetime.isCurrent(lifetime)) return; + if (!this.lifetime.isCurrent(lifetime) || operationGeneration !== this.operationGeneration) return; this.transitionRenameState(threadId, { type: "auto-name-generated", generatingState, title }); } catch (error) { - if (!this.lifetime.isCurrent(lifetime)) return; + if (!this.lifetime.isCurrent(lifetime) || operationGeneration !== this.operationGeneration) return; if (this.renameStates.get(threadId) === generatingState) { this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) }; } } finally { - if (this.lifetime.isCurrent(lifetime)) this.finishAutoNameThread(threadId, generatingState); + if (this.lifetime.isCurrent(lifetime) && operationGeneration === this.operationGeneration) { + this.finishAutoNameThread(threadId, generatingState); + } } } @@ -361,11 +401,13 @@ export class ThreadsViewSession { private async archiveThread(threadId: string, saveMarkdown: boolean): Promise { const lifetime = this.lifetime.signal(); + const operationGeneration = this.operationGeneration; try { await this.operations.archiveThread(threadId, { saveMarkdown, + shouldPublish: () => operationGeneration === this.operationGeneration, }); - if (!this.lifetime.isCurrent(lifetime)) return; + if (!this.lifetime.isCurrent(lifetime) || operationGeneration !== this.operationGeneration) return; this.host.closeOpenPanelsForThread(threadId); if (this.archiveConfirmThreadId === threadId) this.archiveConfirmThreadId = null; this.renameStates.delete(threadId); @@ -395,4 +437,8 @@ export class ThreadsViewSession { private viewWindow(): Window { return this.environment.viewWindow() ?? window; } + + private currentAppServerContext(): AppServerQueryContext { + return { codexPath: this.host.settings.codexPath(), vaultPath: this.host.vaultPath }; + } } diff --git a/src/features/threads-view/view.obsidian.ts b/src/features/threads-view/view.obsidian.ts index ad7e6937..2063c573 100644 --- a/src/features/threads-view/view.obsidian.ts +++ b/src/features/threads-view/view.obsidian.ts @@ -48,4 +48,12 @@ export class CodexThreadsView extends ItemView { refreshLiveState(): void { this.session.refreshLiveState(); } + + prepareAppServerContextChange(): void { + this.session.prepareAppServerContextChange(); + } + + refreshSettings(): void { + this.session.refreshSettings(); + } } diff --git a/src/features/threads/catalog/thread-catalog.ts b/src/features/threads/catalog/thread-catalog.ts index b581d5ce..f966dd0f 100644 --- a/src/features/threads/catalog/thread-catalog.ts +++ b/src/features/threads/catalog/thread-catalog.ts @@ -1,3 +1,4 @@ +import type { AppServerQueryContext } from "../../../app-server/query/keys"; import type { ObservedResult, ObservedResultListener } from "../../../app-server/query/observed-result"; import type { Thread } from "../../../domain/threads/model"; @@ -5,17 +6,27 @@ type ThreadListObserver = ObservedResultListener; interface ThreadCatalogStore { contextKey(): string; + contextKeyFor(context: AppServerQueryContext): string; activeThreadsSnapshot(): readonly Thread[] | null; + activeThreadsSnapshotFor(context: AppServerQueryContext): readonly Thread[] | null; archivedThreadsSnapshot(): readonly Thread[] | null; + archivedThreadsSnapshotFor(context: AppServerQueryContext): readonly Thread[] | null; fetchAllActiveThreads(): Promise; hasMoreActiveThreads(): boolean; loadMoreActiveThreads(): Promise; refreshActiveThreads(): Promise; + refreshActiveThreadsFor(context: AppServerQueryContext): Promise; refreshArchivedThreads(): Promise; + refreshArchivedThreadsFor(context: AppServerQueryContext): Promise; observeActiveThreadsResult(observer: ThreadListObserver, options?: { emitCurrent?: boolean }): () => void; observeArchivedThreadsResult(observer: ThreadListObserver, options?: { emitCurrent?: boolean }): () => void; } +type ThreadCatalogEventStore = Pick< + ThreadCatalogStore, + "activeThreadsSnapshot" | "archivedThreadsSnapshot" | "refreshActiveThreads" | "refreshArchivedThreads" +>; + interface PendingThreadUpsert { readonly thread: Thread; readonly acknowledgedBy: (thread: Thread) => boolean; @@ -80,7 +91,15 @@ export interface ThreadCatalogEventSink { apply(event: ThreadCatalogEvent): void; } -export interface ThreadCatalog extends ThreadCatalogPaginatedActiveReader, ThreadCatalogArchivedReader, ThreadCatalogEventSink { +export interface ThreadCatalogConnectionEventSink { + applyConnectionEvent(context: AppServerQueryContext, event: ThreadCatalogEvent): void; +} + +export interface ThreadCatalog + extends ThreadCatalogPaginatedActiveReader, + ThreadCatalogArchivedReader, + ThreadCatalogEventSink, + ThreadCatalogConnectionEventSink { clear(): void; } @@ -103,16 +122,34 @@ export function createThreadCatalog(options: ThreadCatalogOptions): ThreadCatalo }; for (const observer of kind === "active" ? activeObservers : archivedObservers) observer(result); }; + const applyToFacts = ( + eventStore: ThreadCatalogEventStore, + facts: ThreadCatalogFacts, + event: ThreadCatalogEvent, + publishChanges: boolean, + ): void => { + const changed = applyThreadCatalogEvent(eventStore, facts, event); + if (publishChanges) { + if (changed.active) publish("active"); + if (changed.archived) publish("archived"); + options.onEventApplied?.(event); + } + }; const apply = (event: ThreadCatalogEvent): void => { - const facts = currentFacts(); - const changed = applyThreadCatalogEvent(store, facts, event); - if (changed.active) publish("active"); - if (changed.archived) publish("archived"); - options.onEventApplied?.(event); + applyToFacts(store, currentFacts(), event, true); }; return { apply, + applyConnectionEvent: (context, event) => { + const capturedContextKey = store.contextKeyFor(context); + applyToFacts( + threadCatalogEventStoreForContext(store, context), + threadCatalogFactsForContext(factsByContext, capturedContextKey), + event, + capturedContextKey === store.contextKey(), + ); + }, clear: () => { factsByContext.clear(); }, @@ -168,6 +205,15 @@ export function createThreadCatalog(options: ThreadCatalogOptions): ThreadCatalo }; } +function threadCatalogEventStoreForContext(store: ThreadCatalogStore, context: AppServerQueryContext): ThreadCatalogEventStore { + return { + activeThreadsSnapshot: () => store.activeThreadsSnapshotFor(context), + archivedThreadsSnapshot: () => store.archivedThreadsSnapshotFor(context), + refreshActiveThreads: () => store.refreshActiveThreadsFor(context), + refreshArchivedThreads: () => store.refreshArchivedThreadsFor(context), + }; +} + type ThreadListKind = "active" | "archived"; function threadCatalogFactsForContext(factsByContext: Map, contextKey: string): ThreadCatalogFacts { @@ -203,7 +249,7 @@ function threadListFactsPending(facts: PendingThreadListFacts): boolean { } function applyThreadCatalogEvent( - store: ThreadCatalogStore, + store: ThreadCatalogEventStore, facts: ThreadCatalogFacts, event: ThreadCatalogEvent, ): { active: boolean; archived: boolean } { @@ -260,7 +306,7 @@ function upsertActiveThread(activeFacts: PendingThreadListFacts, thread: Thread, } function applyThreadTouchedEvent( - store: ThreadCatalogStore, + store: ThreadCatalogEventStore, facts: ThreadCatalogFacts, threadId: string, recencyAt: number | null | undefined, @@ -282,7 +328,7 @@ function applyThreadTouchedEvent( ); } -function applyThreadRenamedEvent(store: ThreadCatalogStore, facts: ThreadCatalogFacts, threadId: string, name: string | null): void { +function applyThreadRenamedEvent(store: ThreadCatalogEventStore, facts: ThreadCatalogFacts, threadId: string, name: string | null): void { const { active: activeFacts, archived: archivedFacts } = facts; const activeThread = threadListProjection(facts.activeTestSnapshot ?? store.activeThreadsSnapshot(), activeFacts)?.find( @@ -296,7 +342,7 @@ function applyThreadRenamedEvent(store: ThreadCatalogStore, facts: ThreadCatalog if (archivedThread) rememberPendingThreadUpsert(archivedFacts, { ...archivedThread, name }, acknowledgedByName(name)); } -function applyThreadArchivedEvent(store: ThreadCatalogStore, facts: ThreadCatalogFacts, threadId: string): void { +function applyThreadArchivedEvent(store: ThreadCatalogEventStore, facts: ThreadCatalogFacts, threadId: string): void { const { active: activeFacts, archived: archivedFacts } = facts; const archivedThread = threadListProjection(facts.activeTestSnapshot ?? store.activeThreadsSnapshot(), activeFacts)?.find( (thread) => thread.id === threadId, @@ -320,7 +366,7 @@ function applyThreadRestoredEvent(activeFacts: PendingThreadListFacts, archivedF rememberPendingThreadRemoval(archivedFacts, thread.id); } -function applyThreadUnarchivedEvent(store: ThreadCatalogStore, facts: ThreadCatalogFacts, threadId: string): void { +function applyThreadUnarchivedEvent(store: ThreadCatalogEventStore, facts: ThreadCatalogFacts, threadId: string): void { const { active: activeFacts, archived: archivedFacts } = facts; const restoredThread = threadListProjection(facts.archivedTestSnapshot ?? store.archivedThreadsSnapshot(), archivedFacts)?.find( @@ -400,13 +446,13 @@ function acknowledgedByRecency(recencyAt: number | null): (thread: Thread) => bo return (thread) => thread.recencyAt === recencyAt; } -function refreshArchivedThreadsAfterUnknownArchive(store: ThreadCatalogStore, archivedFacts: PendingThreadListFacts): void { +function refreshArchivedThreadsAfterUnknownArchive(store: ThreadCatalogEventStore, archivedFacts: PendingThreadListFacts): void { // A force refresh can join an older in-flight archived request. Run one more // refresh afterward so an archive recorded during that request is not lost. void refreshArchivedThreadsTwice(store, archivedFacts); } -async function refreshArchivedThreadsTwice(store: ThreadCatalogStore, archivedFacts: PendingThreadListFacts): Promise { +async function refreshArchivedThreadsTwice(store: ThreadCatalogEventStore, archivedFacts: PendingThreadListFacts): Promise { for (let attempt = 0; attempt < 2; attempt += 1) { try { acknowledgeThreadListSnapshot(archivedFacts, await store.refreshArchivedThreads()); @@ -417,7 +463,7 @@ async function refreshArchivedThreadsTwice(store: ThreadCatalogStore, archivedFa } function refreshThreadListsAfterUnknownUnarchive( - store: ThreadCatalogStore, + store: ThreadCatalogEventStore, activeFacts: PendingThreadListFacts, archivedFacts: PendingThreadListFacts, ): void { diff --git a/src/features/threads/workflows/thread-operations.ts b/src/features/threads/workflows/thread-operations.ts index 363c76b0..3d690c4a 100644 --- a/src/features/threads/workflows/thread-operations.ts +++ b/src/features/threads/workflows/thread-operations.ts @@ -21,6 +21,7 @@ export interface ThreadOperationsHost { interface ArchiveThreadOptions { saveMarkdown?: boolean; + shouldPublish?: () => boolean; } export interface ArchiveThreadResult { @@ -87,6 +88,7 @@ async function archiveThread( } : undefined, ); + if (!(options.shouldPublish?.() ?? true)) return { exportedPath }; if (exportedPath) { host.notice(`Saved archived thread to ${exportedPath}.`); } diff --git a/src/main.ts b/src/main.ts index cbef91f5..5c949385 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,7 +17,7 @@ export default class CodexPanelPlugin extends Plugin { readonly runtime = new CodexPanelRuntime({ app: this.app, settingsRef: this, - saveSettings: () => this.saveSettings(), + saveSettings: (settings) => this.saveSettings(settings), }); override async onload(): Promise { @@ -71,12 +71,14 @@ export default class CodexPanelPlugin extends Plugin { callback: () => void this.runtime.startNewChat().catch(reportCommandError), }); - registerSelectionRewriteCommand( - this, - createAppServerSelectionRewriteTransport({ - codexPath: () => this.settings.codexPath, - cwd: this.vaultPath, - }), + this.runtime.setSelectionRewriteController( + registerSelectionRewriteCommand( + this, + createAppServerSelectionRewriteTransport({ + codexPath: () => this.settings.codexPath, + cwd: this.vaultPath, + }), + ), ); this.addSettingTab(new CodexPanelSettingTab(this.app, this, this.runtime.settingTabHost())); @@ -97,8 +99,8 @@ export default class CodexPanelPlugin extends Plugin { } } - async saveSettings(): Promise { - await this.saveData(this.settings); + async saveSettings(settings: CodexPanelSettings = this.settings): Promise { + await this.saveData(settings); } } diff --git a/src/plugin-runtime.ts b/src/plugin-runtime.ts index 1eb8df00..ab5587da 100644 --- a/src/plugin-runtime.ts +++ b/src/plugin-runtime.ts @@ -3,8 +3,8 @@ import type { AppServerClient } from "./app-server/connection/client"; import type { AppServerClientAccess, AppServerClientAccessOptions } from "./app-server/connection/client-access"; import { withShortLivedAppServerClient } from "./app-server/connection/short-lived-client"; import { AppServerQueryCache } from "./app-server/query/cache"; -import { type AppServerQueryContext, appServerQueryContextIsComplete } from "./app-server/query/keys"; -import { AppServerSharedQueries } from "./app-server/query/shared-queries"; +import { type AppServerQueryContext, appServerQueryContextIsComplete, appServerQueryContextRawEquals } from "./app-server/query/keys"; +import { AppServerSharedQueries, StaleAppServerSharedQueryContextError } from "./app-server/query/shared-queries"; import { VIEW_TYPE_CODEX_THREADS, VIEW_TYPE_CODEX_TURN_DIFF } from "./constants"; import { hasPendingRequests } from "./domain/pending-requests/aggregate"; import type { @@ -15,6 +15,7 @@ import type { ChatWorkspacePanelSurface, CodexChatHost, } from "./features/chat/host/contracts"; +import type { SelectionRewriteCommandController } from "./features/selection-rewrite/command.obsidian"; import { openThreadPicker, type ThreadPickerHost } from "./features/thread-picker/modal.obsidian"; import { createThreadOperationsTransport, createThreadTitleTransport } from "./features/threads/app-server/workflow-transports"; import { createThreadCatalog, type ThreadCatalog, type ThreadCatalogEvent } from "./features/threads/catalog/thread-catalog"; @@ -37,7 +38,7 @@ interface CodexPanelRuntimeSettingsRef { export interface CodexPanelRuntimeOptions { app: App; settingsRef: CodexPanelRuntimeSettingsRef; - saveSettings(): Promise; + saveSettings(settings: CodexPanelSettings): Promise; } export class CodexPanelRuntime implements AppServerClientAccess { @@ -53,6 +54,7 @@ export class CodexPanelRuntime implements AppServerClientAccess { private readonly panels: WorkspacePanelCoordinator; private readonly threadCatalog: ThreadCatalog; private readonly threadNameMutations = createThreadNameMutationCoordinator(); + private selectionRewriteController: SelectionRewriteCommandController | null = null; constructor(private readonly options: CodexPanelRuntimeOptions) { this.panels = new WorkspacePanelCoordinator({ @@ -70,6 +72,8 @@ export class CodexPanelRuntime implements AppServerClientAccess { } reset(): void { + this.selectionRewriteController?.closeAll(); + this.selectionRewriteController = null; this.panels.reset(); this.appServerQueries.clear(); this.threadCatalog.clear(); @@ -115,6 +119,10 @@ export class CodexPanelRuntime implements AppServerClientAccess { this.panels.cancelWorkspacePanelReconcile(); } + setSelectionRewriteController(controller: SelectionRewriteCommandController): void { + this.selectionRewriteController = controller; + } + chatHost(): CodexChatHost { return { settingsRef: { @@ -201,7 +209,12 @@ export class CodexPanelRuntime implements AppServerClientAccess { appServerQueries: this.appServerSharedQueries, threadCatalog: this.threadCatalog, }), - saveSettings: () => this.options.saveSettings(), + saveSettings: (settings) => this.options.saveSettings(settings), + prepareAppServerContextChange: () => { + this.selectionRewriteController?.closeAll(); + for (const view of this.panels.panelViews()) view.surface.prepareAppServerContextChange(); + for (const view of this.threadsViews()) view.prepareAppServerContextChange(); + }, refreshOpenViews: () => { this.refreshOpenViews(); }, @@ -237,6 +250,7 @@ export class CodexPanelRuntime implements AppServerClientAccess { const surface: ChatViewLifecycleSurface = view.surface; surface.refreshSettings(); } + for (const view of this.threadsViews()) view.refreshSettings(); } private applyThreadArchived(threadId: string): void { @@ -302,12 +316,25 @@ export class CodexPanelRuntime implements AppServerClientAccess { if (!appServerQueryContextIsComplete(context)) { throw new Error("Codex app-server query context is incomplete."); } + const result = await this.runWithContextClient(context, operation, options); + if (!appServerQueryContextRawEquals(this.appServerQueryContext(), context)) { + throw new StaleAppServerSharedQueryContextError(); + } + return result; + } + + private runWithContextClient( + context: AppServerQueryContext, + operation: (client: AppServerClient) => Promise, + options: AppServerClientAccessOptions, + ): Promise { if (options.serverRequests?.kind === "reject") { return withShortLivedAppServerClient(context.codexPath, context.vaultPath, operation, options); } const chatSurface = this.connectedClientSurface(context); - if (chatSurface) return chatSurface.runWithAppServerClient(operation); - return withShortLivedAppServerClient(context.codexPath, context.vaultPath, operation, options); + return chatSurface + ? chatSurface.runWithAppServerClient(operation) + : withShortLivedAppServerClient(context.codexPath, context.vaultPath, operation, options); } private connectedClientSurface(context: AppServerQueryContext): ChatPanelClientSurface | null { diff --git a/src/settings/host.ts b/src/settings/host.ts index 1986b6e2..62ada3dd 100644 --- a/src/settings/host.ts +++ b/src/settings/host.ts @@ -7,6 +7,7 @@ export interface SettingsDynamicSectionsHost { } export interface CodexPanelSettingTabHost extends SettingsDynamicSectionsHost { - saveSettings(): Promise; + saveSettings(settings: CodexPanelSettings): Promise; + prepareAppServerContextChange(): void; refreshOpenViews(): void; } diff --git a/src/settings/tab.obsidian.tsx b/src/settings/tab.obsidian.tsx index 0e5d9a03..5e795ee3 100644 --- a/src/settings/tab.obsidian.tsx +++ b/src/settings/tab.obsidian.tsx @@ -189,121 +189,132 @@ export class CodexPanelSettingTab extends PluginSettingTab { private setCodexPath(value: string): Promise { const codexPath = value.trim() || DEFAULT_CODEX_PATH; return this.queueSettingsMutation( - () => { - if (codexPath === this.plugin.settings.codexPath) return false; - this.plugin.settings.codexPath = codexPath; + (settings) => { + if (codexPath === settings.codexPath) return false; + settings.codexPath = codexPath; return true; }, - () => { - this.dynamicSections.resetDynamicSectionContext(); - this.plugin.dynamicData.notifyContextChanged(); - this.plugin.refreshOpenViews(); + { + beforePublish: () => { + this.plugin.prepareAppServerContextChange(); + }, + onPublished: () => { + this.dynamicSections.resetDynamicSectionContext(); + this.plugin.dynamicData.notifyContextChanged(); + this.plugin.refreshOpenViews(); + }, }, ); } private setShowToolbar(value: boolean): Promise { return this.queueSettingsMutation( - () => { - this.plugin.settings.showToolbar = value; + (settings) => { + settings.showToolbar = value; }, - () => { - this.plugin.refreshOpenViews(); + { + onPublished: () => { + this.plugin.refreshOpenViews(); + }, }, ); } private setSendShortcut(value: "enter" | "mod-enter"): Promise { - return this.queueSettingsMutation(() => { - this.plugin.settings.sendShortcut = value; + return this.queueSettingsMutation((settings) => { + settings.sendShortcut = value; }); } private setScrollThreadFromComposerEdges(value: boolean): Promise { - return this.queueSettingsMutation(() => { - this.plugin.settings.scrollThreadFromComposerEdges = value; + return this.queueSettingsMutation((settings) => { + settings.scrollThreadFromComposerEdges = value; }); } private setReferenceActiveNoteOnSend(value: boolean): Promise { - return this.queueSettingsMutation(() => { - this.plugin.settings.referenceActiveNoteOnSend = value; + return this.queueSettingsMutation((settings) => { + settings.referenceActiveNoteOnSend = value; }); } private setAttachmentFolder(value: string): Promise { - return this.queueSettingsMutation(() => { - this.plugin.settings.attachmentFolder = value.trim() || DEFAULT_ATTACHMENT_FOLDER; + return this.queueSettingsMutation((settings) => { + settings.attachmentFolder = value.trim() || DEFAULT_ATTACHMENT_FOLDER; }); } private setArchiveExportEnabled(enabled: boolean): Promise { - return this.queueSettingsMutation(() => { - this.plugin.settings.archiveExportEnabled = enabled; + return this.queueSettingsMutation((settings) => { + settings.archiveExportEnabled = enabled; }); } private setArchiveExportFolderTemplate(value: string): Promise { - return this.queueSettingsMutation(() => { - this.plugin.settings.archiveExportFolderTemplate = value.trim() || DEFAULT_ARCHIVE_EXPORT_FOLDER_TEMPLATE; + return this.queueSettingsMutation((settings) => { + settings.archiveExportFolderTemplate = value.trim() || DEFAULT_ARCHIVE_EXPORT_FOLDER_TEMPLATE; }); } private setArchiveExportFilenameTemplate(value: string): Promise { - return this.queueSettingsMutation(() => { - this.plugin.settings.archiveExportFilenameTemplate = value.trim() || DEFAULT_ARCHIVE_EXPORT_FILENAME_TEMPLATE; + return this.queueSettingsMutation((settings) => { + settings.archiveExportFilenameTemplate = value.trim() || DEFAULT_ARCHIVE_EXPORT_FILENAME_TEMPLATE; }); } private setArchiveExportTags(value: string): Promise { - return this.queueSettingsMutation(() => { - this.plugin.settings.archiveExportTags = value.trim(); + return this.queueSettingsMutation((settings) => { + settings.archiveExportTags = value.trim(); }); } private setThreadNamingModel(value: string | null): Promise { - return this.queueSettingsMutation(() => { - this.plugin.settings.threadNamingModel = value; - if (!this.dynamicSections.namingEffortSupported(this.plugin.settings.threadNamingEffort)) { - this.plugin.settings.threadNamingEffort = null; + return this.queueSettingsMutation((settings) => { + settings.threadNamingModel = value; + if (!this.dynamicSections.namingEffortSupported(settings.threadNamingEffort)) { + settings.threadNamingEffort = null; } }); } private setThreadNamingEffort(value: ReasoningEffort | null): Promise { - return this.queueSettingsMutation(() => { - this.plugin.settings.threadNamingEffort = value; + return this.queueSettingsMutation((settings) => { + settings.threadNamingEffort = value; }); } private setRewriteSelectionModel(value: string | null): Promise { - return this.queueSettingsMutation(() => { - this.plugin.settings.rewriteSelectionModel = value; - if (!this.dynamicSections.rewriteSelectionEffortSupported(this.plugin.settings.rewriteSelectionEffort)) { - this.plugin.settings.rewriteSelectionEffort = null; + return this.queueSettingsMutation((settings) => { + settings.rewriteSelectionModel = value; + if (!this.dynamicSections.rewriteSelectionEffortSupported(settings.rewriteSelectionEffort)) { + settings.rewriteSelectionEffort = null; } }); } private setRewriteSelectionEffort(value: ReasoningEffort | null): Promise { - return this.queueSettingsMutation(() => { - this.plugin.settings.rewriteSelectionEffort = value; + return this.queueSettingsMutation((settings) => { + settings.rewriteSelectionEffort = value; }); } - private queueSettingsMutation(mutate: () => boolean | undefined, onSaved?: () => void): Promise { + private queueSettingsMutation( + mutate: (settings: CodexPanelSettings) => boolean | undefined, + publication: { beforePublish?: () => void; onPublished?: () => void } = {}, + ): Promise { const operation = this.settingsMutationQueue.then(async () => { - const previousSettings: CodexPanelSettings = { ...this.plugin.settings }; - if (mutate() === false) return; + const candidateSettings: CodexPanelSettings = { ...this.plugin.settings }; + if (mutate(candidateSettings) === false) return; try { - await this.plugin.saveSettings(); + await this.plugin.saveSettings(candidateSettings); } catch (error) { - Object.assign(this.plugin.settings, previousSettings); this.settingsShellRevision += 1; new Notice(`Failed to save Codex Panel settings: ${error instanceof Error ? error.message : String(error)}`); return; } - onSaved?.(); + publication.beforePublish?.(); + Object.assign(this.plugin.settings, candidateSettings); + publication.onPublished?.(); }); const settledOperation = operation.catch(() => undefined); this.settingsMutationQueue = settledOperation; diff --git a/tests/app-server/connection-manager.test.ts b/tests/app-server/connection-manager.test.ts index 784d051b..77fea8e8 100644 --- a/tests/app-server/connection-manager.test.ts +++ b/tests/app-server/connection-manager.test.ts @@ -7,7 +7,7 @@ import { type ConnectionManagerHandlers, StaleConnectionError, } from "../../src/app-server/connection/connection-manager"; -import type { RpcOutboundMessage } from "../../src/app-server/connection/rpc-messages"; +import type { RpcOutboundMessage, ServerNotification } from "../../src/app-server/connection/rpc-messages"; import type { AppServerTransport, AppServerTransportHandlers } from "../../src/app-server/connection/transport"; import type { InitializeParams } from "../../src/generated/app-server/InitializeParams"; @@ -127,6 +127,31 @@ describe("ConnectionManager", () => { expect(manager.currentConnectionContext()).toEqual({ codexPath: "/bin/codex-b", cwd: "/vault" }); }); + it("labels inbound events with the context captured when the connection was created", async () => { + let codexPath = "/bin/codex-a"; + let transport!: SilentTransport; + const onNotification = vi.fn(); + const manager = new ConnectionManager( + () => codexPath, + "/vault", + TEST_INITIALIZE_PARAMS, + testClientFactory({ onTransport: (next) => (transport = next) }), + ); + + const connecting = manager.connect({ ...silentConnectionHandlers(), onNotification }); + transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex-a" } }); + await connecting; + codexPath = "/bin/codex-b"; + const notification = { + method: "thread/name/updated", + params: { threadId: "thread-a", threadName: "A" }, + } satisfies Extract; + + transport.emitLine(notification); + + expect(onNotification).toHaveBeenCalledWith(notification, { codexPath: "/bin/codex-a", cwd: "/vault" }); + }); + it("rejects app-server exit during initialization without reporting a connected-client exit", async () => { let transport!: SilentTransport; const onExit = vi.fn(); diff --git a/tests/features/chat/app-server/inbound/handler.test.ts b/tests/features/chat/app-server/inbound/handler.test.ts index f7f8a355..1c5f33e1 100644 --- a/tests/features/chat/app-server/inbound/handler.test.ts +++ b/tests/features/chat/app-server/inbound/handler.test.ts @@ -18,27 +18,36 @@ import { chatStateFixture, chatStateWith } from "../../support/state"; import { chatStateThreadStreamItems, withChatStateThreadStreamItems } from "../../support/thread-stream"; type ThreadStartedNotification = Extract; +const TEST_APP_SERVER_CONTEXT = { codexPath: "codex", vaultPath: "/vault" } as const; -function handlerForState( - state = chatStateFixture(), - actions: Partial = {}, -): ChatInboundHandler & { currentState(): ChatState } { +type TestChatInboundHandler = Omit & { + handleNotification(notification: ServerNotification): void; + currentState(): ChatState; +}; + +function handlerForState(state = chatStateFixture(), actions: Partial = {}): TestChatInboundHandler { const store = testStoreForState(state); + const handler = createChatInboundHandler( + store, + { + refreshActiveThreads: vi.fn(), + refreshServerDiagnostics: vi.fn(), + applyAppServerResourceEvent: vi.fn(), + maybeNameThread: vi.fn(), + applyThreadCatalogEvent: vi.fn(), + respondToServerRequest: vi.fn(() => true), + rejectServerRequest: vi.fn(() => true), + ...actions, + }, + createLocalIdSource({ nowMs: () => 1, seed: "test" }), + ); return Object.assign( - createChatInboundHandler( - store, - { - refreshActiveThreads: vi.fn(), - refreshServerDiagnostics: vi.fn(), - applyAppServerResourceEvent: vi.fn(), - maybeNameThread: vi.fn(), - applyThreadCatalogEvent: vi.fn(), - respondToServerRequest: vi.fn(() => true), - rejectServerRequest: vi.fn(() => true), - ...actions, + { + ...handler, + handleNotification: (notification: ServerNotification) => { + handler.handleNotification(notification, TEST_APP_SERVER_CONTEXT); }, - createLocalIdSource({ nowMs: () => 1, seed: "test" }), - ), + }, { currentState: () => store.getState(), }, @@ -464,11 +473,14 @@ describe("ChatInboundHandler", () => { expect(chatStateThreadStreamItems(handler.currentState()).map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]); expect(chatStateThreadStreamItems(handler.currentState())[1]).toMatchObject({ id: "hook-hook-1-1", turnId: "turn-active" }); expect(pendingTurnStart(handler.currentState())).toBeNull(); - expect(applyThreadCatalogEvent).toHaveBeenCalledWith({ - type: "thread-touched", - threadId: "thread-active", - recencyAt: 1, - } satisfies ThreadCatalogEvent); + expect(applyThreadCatalogEvent).toHaveBeenCalledWith( + { + type: "thread-touched", + threadId: "thread-active", + recencyAt: 1, + } satisfies ThreadCatalogEvent, + TEST_APP_SERVER_CONTEXT, + ); expect(refreshActiveThreads).not.toHaveBeenCalled(); }); @@ -1343,10 +1355,13 @@ describe("ChatInboundHandler", () => { } satisfies Extract); expect(handler.currentState().activeThread.id).toBe("thread-active"); - expect(applyThreadCatalogEvent).toHaveBeenCalledWith({ - type: "thread-archived", - threadId: "thread-active", - } satisfies ThreadCatalogEvent); + expect(applyThreadCatalogEvent).toHaveBeenCalledWith( + { + type: "thread-archived", + threadId: "thread-active", + } satisfies ThreadCatalogEvent, + TEST_APP_SERVER_CONTEXT, + ); }); it("records deleted thread notifications in the active catalog", () => { @@ -1359,10 +1374,13 @@ describe("ChatInboundHandler", () => { params: { threadId: "thread-active" }, } satisfies Extract); - expect(applyThreadCatalogEvent).toHaveBeenCalledWith({ - type: "thread-deleted", - threadId: "thread-active", - } satisfies ThreadCatalogEvent); + expect(applyThreadCatalogEvent).toHaveBeenCalledWith( + { + type: "thread-deleted", + threadId: "thread-active", + } satisfies ThreadCatalogEvent, + TEST_APP_SERVER_CONTEXT, + ); expect(refreshActiveThreads).not.toHaveBeenCalled(); }); @@ -1376,10 +1394,13 @@ describe("ChatInboundHandler", () => { params: { threadId: "thread-active" }, } satisfies Extract); - expect(applyThreadCatalogEvent).toHaveBeenCalledWith({ - type: "thread-unarchived", - threadId: "thread-active", - } satisfies ThreadCatalogEvent); + expect(applyThreadCatalogEvent).toHaveBeenCalledWith( + { + type: "thread-unarchived", + threadId: "thread-active", + } satisfies ThreadCatalogEvent, + TEST_APP_SERVER_CONTEXT, + ); expect(refreshActiveThreads).not.toHaveBeenCalled(); }); @@ -1396,10 +1417,13 @@ describe("ChatInboundHandler", () => { } satisfies Extract); expect(handler.currentState().activeThread.cwd).toBe("/workspace/active"); - expect(applyThreadCatalogEvent).toHaveBeenCalledWith({ - type: "thread-started", - thread: expect.objectContaining({ id: "thread-other" }), - }); + expect(applyThreadCatalogEvent).toHaveBeenCalledWith( + { + type: "thread-started", + thread: expect.objectContaining({ id: "thread-other" }), + }, + TEST_APP_SERVER_CONTEXT, + ); }); it("records cwd from active thread-started notifications", () => { @@ -1414,10 +1438,13 @@ describe("ChatInboundHandler", () => { } satisfies Extract); expect(handler.currentState().activeThread.cwd).toBe("/workspace/active"); - expect(applyThreadCatalogEvent).toHaveBeenCalledWith({ - type: "thread-started", - thread: expect.objectContaining({ id: "thread-active" }), - }); + expect(applyThreadCatalogEvent).toHaveBeenCalledWith( + { + type: "thread-started", + thread: expect.objectContaining({ id: "thread-active" }), + }, + TEST_APP_SERVER_CONTEXT, + ); }); it("keeps ephemeral thread-started notifications out of the shared catalog", () => { @@ -1711,11 +1738,14 @@ describe("ChatInboundHandler", () => { } satisfies Extract); expect(state.threadList.listedThreads[0]?.name).toBeNull(); - expect(applyThreadCatalogEvent).toHaveBeenCalledWith({ - type: "thread-renamed", - threadId: "thread-active", - name: "Codex Panel自動命名", - } satisfies ThreadCatalogEvent); + expect(applyThreadCatalogEvent).toHaveBeenCalledWith( + { + type: "thread-renamed", + threadId: "thread-active", + name: "Codex Panel自動命名", + } satisfies ThreadCatalogEvent, + TEST_APP_SERVER_CONTEXT, + ); }); it("syncs active runtime state from thread settings notifications", () => { diff --git a/tests/features/chat/application/connection/reconnect-actions.test.ts b/tests/features/chat/application/connection/reconnect-actions.test.ts index 12e3ec48..6d2a904a 100644 --- a/tests/features/chat/application/connection/reconnect-actions.test.ts +++ b/tests/features/chat/application/connection/reconnect-actions.test.ts @@ -43,6 +43,7 @@ function createHost(overrides: Partial = {}) { resetConnection: vi.fn(), setStatus: vi.fn(), ensureConnected: vi.fn().mockResolvedValue(undefined), + isConnected: vi.fn(() => true), resumeThread: vi.fn().mockResolvedValue(undefined), addSystemMessage: vi.fn(), ...overrides, diff --git a/tests/features/chat/application/state/root-reducer.test.ts b/tests/features/chat/application/state/root-reducer.test.ts index a11397a7..4f2794b2 100644 --- a/tests/features/chat/application/state/root-reducer.test.ts +++ b/tests/features/chat/application/state/root-reducer.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { emptyRuntimeConfigSnapshot } from "../../../../../src/domain/runtime/config"; import type { ThreadGoal } from "../../../../../src/domain/threads/goal"; import type { Thread } from "../../../../../src/domain/threads/model"; import { type ChatState, chatReducer } from "../../../../../src/features/chat/application/state/root-reducer"; @@ -11,6 +12,28 @@ import { chatStateFixture, chatStateWith } from "../../support/state"; import { chatStateThreadStreamItems, withChatStateThreadStreamItems } from "../../support/thread-stream"; describe("chatReducer", () => { + it("clears connection metadata only when the app-server context is replaced", () => { + const state = chatStateWith(chatStateFixture(), { + connection: { + runtimeConfig: { ...emptyRuntimeConfigSnapshot(), model: "gpt-old" }, + initializeResponse: { + codexHome: "/old/codex-home", + platformFamily: "unix", + platformOs: "macos", + userAgent: "codex-old", + }, + }, + }); + + const disconnected = chatReducer(state, { type: "connection/scoped-cleared" }); + expect(disconnected.connection.runtimeConfig).toEqual(state.connection.runtimeConfig); + expect(disconnected.connection.initializeResponse).toEqual(state.connection.initializeResponse); + + const replaced = chatReducer(state, { type: "connection/context-replaced" }); + expect(replaced.connection.runtimeConfig).toBeNull(); + expect(replaced.connection.initializeResponse).toBeNull(); + }); + it("applies restored thread identity as an atomic thread-scoped transition", () => { const state = threadScopedResidue({ threadId: "old-thread", draft: "stale draft", itemId: "stale-item" }); diff --git a/tests/features/chat/host/session-runtime.test.ts b/tests/features/chat/host/session-runtime.test.ts index ef38e237..558c40bf 100644 --- a/tests/features/chat/host/session-runtime.test.ts +++ b/tests/features/chat/host/session-runtime.test.ts @@ -221,6 +221,7 @@ describe("ChatPanelSessionRuntime actions", () => { const clearDiagnostics = vi.spyOn(deferredTasks, "clearDiagnostics"); const resetConnection = vi.spyOn(runtime.connection.manager, "resetConnection"); const ensureConnected = vi.spyOn(runtime.connection.actions, "ensureConnected").mockResolvedValue(undefined); + vi.spyOn(runtime.connection.manager, "isConnected").mockReturnValue(true); const resumeThread = vi.spyOn(runtime.thread.resume, "resumeThread").mockResolvedValue(undefined); runtime.shell.parts.toolbar.actions.status.connect(); @@ -356,6 +357,7 @@ describe("ChatPanelSessionRuntime actions", () => { activeSnapshot: vi.fn(() => null), observeActive: vi.fn(() => () => undefined), apply: vi.fn(), + applyConnectionEvent: vi.fn(), ...overrides, }; } diff --git a/tests/features/chat/host/view-connection.test.ts b/tests/features/chat/host/view-connection.test.ts index c388496b..46630846 100644 --- a/tests/features/chat/host/view-connection.test.ts +++ b/tests/features/chat/host/view-connection.test.ts @@ -54,28 +54,32 @@ vi.mock("../../../../src/app-server/connection/connection-manager", () => { class StaleConnectionError extends Error {} class ConnectionManager { + private readonly codexPath: () => string; + private readonly cwd: string; private handlers: { - onNotification: (notification: ServerNotification) => void; + onNotification: (notification: ServerNotification, context: { codexPath: string; cwd: string }) => void; onServerRequest: ( request: unknown, responder: { respond(result: unknown): void; reject(code: number, message: string): void }, ) => void; onLog: (message: string) => void; - onExit: () => void; + onExit: (context: { codexPath: string; cwd: string }) => void; } | null; - constructor(_codexPath: () => string, _cwd: string) { + constructor(codexPath: () => string, cwd: string) { + this.codexPath = codexPath; + this.cwd = cwd; this.handlers = null; } connect(handlers: { - onNotification: (notification: ServerNotification) => void; + onNotification: (notification: ServerNotification, context: { codexPath: string; cwd: string }) => void; onServerRequest: ( request: unknown, responder: { respond(result: unknown): void; reject(code: number, message: string): void }, ) => void; onLog: (message: string) => void; - onExit: () => void; + onExit: (context: { codexPath: string; cwd: string }) => void; }): Promise { this.handlers = handlers; this.publishHandlers(handlers); @@ -107,12 +111,13 @@ vi.mock("../../../../src/app-server/connection/connection-manager", () => { exit(): void { connectionMock.state.connected = false; - this.handlers?.onExit(); + this.handlers?.onExit({ codexPath: this.codexPath(), cwd: this.cwd }); } private publishHandlers(handlers: NonNullable): void { - connectionMock.state.onNotification = handlers.onNotification; - connectionMock.state.onExit = handlers.onExit; + const context = { codexPath: this.codexPath(), cwd: this.cwd }; + connectionMock.state.onNotification = (notification) => handlers.onNotification(notification, context); + connectionMock.state.onExit = () => handlers.onExit(context); } } @@ -227,6 +232,207 @@ describe("CodexChatView connection lifecycle", () => { }); }); + it("invalidates the old connection before publishing a new app-server context", async () => { + connectionMock.state.client = connectedClient(); + const host = chatHost(); + const view = await chatView({ host }); + + await view.surface.connect(); + expect(view.surface.openPanelSnapshot().connected).toBe(true); + + view.surface.prepareAppServerContextChange(); + + expect(view.surface.openPanelSnapshot().connected).toBe(false); + expect(host.settingsSource.codexPath).toBe("codex"); + expect(connectionMock.state.connectCalls).toBe(1); + + connectionMock.state.client = connectedClient(); + host.settingsSource.codexPath = "codex-next"; + view.surface.refreshSettings(); + await waitForAsyncWork(() => { + expect(connectionMock.state.connectCalls).toBe(2); + expect(view.surface.openPanelSnapshot().connected).toBe(true); + }); + }); + + it("resumes each panel's captured thread after a shared app-server context replacement", async () => { + const host = chatHost(); + const resumeRequestedThread = vi.fn((params: unknown) => { + const threadId = (params as { threadId: string }).threadId; + return resumedThread(threadId); + }); + const oldClient = connectedClient({ "thread/resume": resumeRequestedThread }); + connectionMock.state.client = oldClient; + const first = await chatView({ host }); + const second = await chatView({ host }); + + await first.onOpen(); + await second.onOpen(); + await first.surface.connect(); + await second.surface.connect(); + await first.surface.openThread("thread-1"); + await second.surface.openThread("thread-2"); + + first.surface.prepareAppServerContextChange(); + second.surface.prepareAppServerContextChange(); + const nextClient = connectedClient({ "thread/resume": resumeRequestedThread }); + connectionMock.state.client = nextClient; + host.settingsSource.codexPath = "codex-next"; + first.surface.refreshSettings(); + await waitForAsyncWork(() => { + expect(nextClient.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-1", cwd: "/vault" })); + }); + + connectionMock.state.connected = false; + second.surface.refreshSettings(); + await waitForAsyncWork(() => { + expect(nextClient.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-2", cwd: "/vault" })); + expect(first.surface.openPanelSnapshot().threadId).toBe("thread-1"); + expect(second.surface.openPanelSnapshot().threadId).toBe("thread-2"); + }); + }); + + it("keeps the captured thread across consecutive app-server context replacements", async () => { + const host = chatHost(); + const firstClient = connectedClient(); + connectionMock.state.client = firstClient; + const view = await chatView({ host }); + + await view.onOpen(); + await view.surface.connect(); + await view.surface.openThread("thread-1"); + + const stalledConfig = deferred(); + const stalledClient = connectedClient({ "config/read": vi.fn(() => stalledConfig.promise) }); + view.surface.prepareAppServerContextChange(); + connectionMock.state.client = stalledClient; + host.settingsSource.codexPath = "codex-broken"; + view.surface.refreshSettings(); + await waitForAsyncWork(() => { + expectRequestTimes(stalledClient, "config/read", 1); + }); + + const recoveredClient = connectedClient(); + view.surface.prepareAppServerContextChange(); + connectionMock.state.client = recoveredClient; + host.settingsSource.codexPath = "codex-recovered"; + view.surface.refreshSettings(); + + await waitForAsyncWork(() => { + expect(recoveredClient.request).toHaveBeenCalledWith( + "thread/resume", + expect.objectContaining({ threadId: "thread-1", cwd: "/vault" }), + ); + expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: true, threadId: "thread-1" }); + }); + + stalledConfig.resolve({}); + await new Promise((resolve) => setTimeout(resolve, 0)); + expectRequestTimes(recoveredClient, "thread/resume", 1); + }); + + it("keeps the captured thread after a context reconnect cannot resume it", async () => { + const host = chatHost(); + connectionMock.state.client = connectedClient(); + const view = await chatView({ host }); + + await view.onOpen(); + await view.surface.connect(); + await view.surface.openThread("thread-1"); + + const failedClient = connectedClient({ "thread/resume": vi.fn().mockResolvedValue(null) }); + view.surface.prepareAppServerContextChange(); + connectionMock.state.client = failedClient; + host.settingsSource.codexPath = "codex-broken"; + view.surface.refreshSettings(); + await waitForAsyncWork(() => { + expectRequestTimes(failedClient, "thread/resume", 1); + expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: true, threadId: null }); + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const recoveredClient = connectedClient(); + view.surface.prepareAppServerContextChange(); + connectionMock.state.client = recoveredClient; + host.settingsSource.codexPath = "codex-recovered"; + view.surface.refreshSettings(); + + await waitForAsyncWork(() => { + expect(recoveredClient.request).toHaveBeenCalledWith( + "thread/resume", + expect.objectContaining({ threadId: "thread-1", cwd: "/vault" }), + ); + expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: true, threadId: "thread-1" }); + }); + }); + + it("retries the captured thread when reconnecting after a context resume failure", async () => { + const host = chatHost(); + connectionMock.state.client = connectedClient(); + const view = await chatView({ host }); + + await view.onOpen(); + await view.surface.connect(); + await view.surface.openThread("thread-1"); + + const failedClient = connectedClient({ "thread/resume": vi.fn().mockResolvedValue(null) }); + view.surface.prepareAppServerContextChange(); + connectionMock.state.client = failedClient; + host.settingsSource.codexPath = "codex-next"; + view.surface.refreshSettings(); + await waitForAsyncWork(() => { + expectRequestTimes(failedClient, "thread/resume", 1); + expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: true, threadId: null }); + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const recoveredClient = connectedClient(); + connectionMock.state.client = recoveredClient; + view.surface.setComposerText("/reconnect"); + await submitComposerByEnter(view); + + await waitForAsyncWork(() => { + expect(recoveredClient.request).toHaveBeenCalledWith( + "thread/resume", + expect.objectContaining({ threadId: "thread-1", cwd: "/vault" }), + ); + expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: true, threadId: "thread-1" }); + }); + }); + + it("does not let a stale automatic reconnect resume again after a manual retry", async () => { + const host = chatHost(); + connectionMock.state.client = connectedClient(); + const view = await chatView({ host }); + + await view.onOpen(); + await view.surface.connect(); + await view.surface.openThread("thread-1"); + + const stalledConfig = deferred(); + const stalledClient = connectedClient({ "config/read": vi.fn(() => stalledConfig.promise) }); + view.surface.prepareAppServerContextChange(); + connectionMock.state.client = stalledClient; + host.settingsSource.codexPath = "codex-next"; + view.surface.refreshSettings(); + await waitForAsyncWork(() => { + expectRequestTimes(stalledClient, "config/read", 1); + }); + + const recoveredClient = connectedClient(); + connectionMock.state.client = recoveredClient; + view.surface.setComposerText("/reconnect"); + await submitComposerByEnter(view); + await waitForAsyncWork(() => { + expectRequestTimes(recoveredClient, "thread/resume", 1); + }); + + stalledConfig.resolve({}); + await new Promise((resolve) => setTimeout(resolve, 0)); + expectRequestTimes(recoveredClient, "thread/resume", 1); + expect(view.surface.openPanelSnapshot()).toMatchObject({ connected: true, threadId: "thread-1" }); + }); + it("starts an empty thread when saving a toolbar goal from a blank panel", async () => { vi.useFakeTimers(); const client = connectedClient({ @@ -1237,6 +1443,7 @@ function chatHost(overrides: ChatHostFixtureOverrides = {}): TestCodexChatHost { }, threadCatalog: { apply: overrides.applyThreadCatalogEvent ?? applyThreadCatalogEvent, + applyConnectionEvent: (_context, event) => (overrides.applyThreadCatalogEvent ?? applyThreadCatalogEvent)(event), refreshActive: overrides.refreshActive ?? (vi.fn(async () => { diff --git a/tests/features/selection-rewrite/selection-rewrite-command.test.ts b/tests/features/selection-rewrite/selection-rewrite-command.test.ts index 722f8562..bbf34a3e 100644 --- a/tests/features/selection-rewrite/selection-rewrite-command.test.ts +++ b/tests/features/selection-rewrite/selection-rewrite-command.test.ts @@ -62,7 +62,7 @@ describe("selection rewrite command", () => { view.file = Object.assign(new TFile(), { path: "Draft.md", basename: "Draft" }); const transport: SelectionRewriteTransport = { generate: vi.fn() }; - registerSelectionRewriteCommand(plugin as never, transport); + const controller = registerSelectionRewriteCommand(plugin as never, transport); expect(addedCommand.current).not.toBeNull(); addedCommand.current?.editorCallback(editor, view); from.line = 99; @@ -81,6 +81,9 @@ describe("selection rewrite command", () => { expect(popoverMock.instances[0]?.open).toHaveBeenCalledOnce(); expect(popoverMock.instances[0]?.options.transport).toBe(transport); + controller.closeAll(); + expect(popoverMock.instances[0]?.close).toHaveBeenCalledOnce(); + cleanup.current?.(); expect(popoverMock.instances[0]?.close).toHaveBeenCalledOnce(); }); diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index 87611b6c..f421b726 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -422,6 +422,52 @@ describe("CodexThreadsView", () => { }); }); + it("clears old rows and suppresses a delayed rename across an app-server context replacement", async () => { + let codexPath = "codex-a"; + const saved = deferred(); + const renameThreadRequest = vi.fn(() => saved.promise); + const oldClient = clientFixture({ + "thread/list": vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread-a", preview: "Old thread" })] }), + "thread/name/set": renameThreadRequest, + }); + connectionMock.state.client = oldClient; + const applyThreadCatalogEvent = vi.fn(); + const host = threadsHost({ + settings: { + archiveExportEnabled: () => DEFAULT_SETTINGS.archiveExportEnabled, + codexPath: () => codexPath, + threadNamingModel: () => DEFAULT_SETTINGS.threadNamingModel, + threadNamingEffort: () => DEFAULT_SETTINGS.threadNamingEffort, + archiveExportSettings: () => ({ + archiveExportFolderTemplate: DEFAULT_SETTINGS.archiveExportFolderTemplate, + archiveExportFilenameTemplate: DEFAULT_SETTINGS.archiveExportFilenameTemplate, + archiveExportTags: DEFAULT_SETTINGS.archiveExportTags, + }), + }, + threadCatalog: { apply: applyThreadCatalogEvent }, + }); + const view = await threadsView(host); + await view.onOpen(); + await waitForAsyncWork(() => expect(view.containerEl.textContent).toContain("Old thread")); + view.containerEl.querySelector('[aria-label="Rename thread"]')?.click(); + const input = view.containerEl.querySelector(".codex-panel-threads__rename-input"); + if (!input) throw new Error("Missing rename input"); + changeInputValue(input, "Renamed old thread"); + input.dispatchEvent(new FocusEvent("blur")); + await waitForAsyncWork(() => expect(renameThreadRequest).toHaveBeenCalledOnce()); + + view.prepareAppServerContextChange(); + expect(view.containerEl.textContent).not.toContain("Old thread"); + codexPath = "codex-b"; + connectionMock.state.client = clientFixture({ "thread/list": vi.fn().mockResolvedValue({ data: [] }) }); + view.refreshSettings(); + saved.resolve({}); + + await waitForAsyncWork(() => expect(view.containerEl.textContent).toContain("No threads")); + expect(applyThreadCatalogEvent).not.toHaveBeenCalled(); + expect(view.containerEl.textContent).not.toContain("Old thread"); + }); + it("auto-names a thread rename draft from completed history", async () => { const threadTurnsList = vi.fn().mockResolvedValue({ data: [ diff --git a/tests/features/threads/catalog/thread-catalog.test.ts b/tests/features/threads/catalog/thread-catalog.test.ts index 19ce50be..d2f907c7 100644 --- a/tests/features/threads/catalog/thread-catalog.test.ts +++ b/tests/features/threads/catalog/thread-catalog.test.ts @@ -194,6 +194,36 @@ describe("ThreadCatalog", () => { expect(catalog.activeSnapshot()).toEqual([thread("started-a")]); }); + it("applies a connection event to its captured context after the current context changes", () => { + const context = { codexPath: "codex-a", vaultPath: "/vault" }; + const { catalog } = catalogFixture({ context: () => context }); + const connectionContext = { ...context }; + + context.codexPath = "codex-b"; + catalog.applyConnectionEvent(connectionContext, { type: "thread-started", thread: thread("started-a") }); + + expect(catalog.activeSnapshot()).toBeNull(); + context.codexPath = "codex-a"; + expect(catalog.activeSnapshot()).toEqual([thread("started-a")]); + }); + + it("reads captured-context snapshots when applying connection events", async () => { + const context = { codexPath: "codex-a", vaultPath: "/vault" }; + const connectionContext = { ...context }; + const { catalog } = catalogFixture({ + context: () => context, + fetchThreads: (source) => Promise.resolve(source.codexPath === "codex-a" ? [thread("thread-a")] : []), + }); + await catalog.refreshActive(); + + context.codexPath = "codex-b"; + catalog.applyConnectionEvent(connectionContext, { type: "thread-renamed", threadId: "thread-a", name: "Renamed A" }); + + expect(catalog.activeSnapshot()).toBeNull(); + context.codexPath = "codex-a"; + expect(catalog.activeSnapshot()).toEqual([thread("thread-a", false, { name: "Renamed A" })]); + }); + it("clears pending lifecycle facts across every app-server query context", () => { const context = { codexPath: "codex-a", vaultPath: "/vault" }; const { catalog, cache } = catalogFixture({ context: () => context }); diff --git a/tests/main.test.ts b/tests/main.test.ts index f9fb6ea4..4fc3e12a 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -12,7 +12,7 @@ import type CodexPanelPlugin from "../src/main"; import { type CodexPanelSettings, DEFAULT_SETTINGS } from "../src/settings/model"; import { WorkspacePanelCoordinator } from "../src/workspace/panel-coordinator"; import { chatPanelSettingsAccess } from "./features/chat/support/settings"; -import { waitForAsyncWork } from "./support/async"; +import { deferred, waitForAsyncWork } from "./support/async"; import { installObsidianDomShims } from "./support/dom"; installObsidianDomShims(); @@ -664,7 +664,7 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { await expect(first).rejects.toThrow("Codex app-server query context changed while loading shared queries."); expect(threadCatalog(plugin).activeSnapshot()).toEqual([thread("second")]); plugin.settings.codexPath = "codex-a"; - expect(threadCatalog(plugin).activeSnapshot()).toEqual([thread("first")]); + expect(threadCatalog(plugin).activeSnapshot()).toBeNull(); }); it("does not reuse a connected panel whose app-server context does not match the shared query", async () => { @@ -736,6 +736,71 @@ describe("CodexPanelPlugin workspace panel reconciliation", () => { expect(shortLivedClient.request).toHaveBeenCalledWith("config/read", { cwd: "/vault", includeLayers: true }); }); + it("rejects a connected-panel result when the app-server context changes during the operation", async () => { + const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); + const connectedLeaf = leaf(); + connectedLeaf.view = chatView(CodexChatView, connectedLeaf); + const connectedView = connectedLeaf.view as CodexChatView; + vi.spyOn(connectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ connected: true })); + vi.spyOn(connectedView.surface, "canServeAppServerContext").mockReturnValue(true); + const result = deferred(); + vi.spyOn(connectedView.surface, "runWithAppServerClient").mockReturnValue(result.promise); + const plugin = await pluginWithLeaves([connectedLeaf]); + plugin.settings.codexPath = "codex-a"; + + const operation = plugin.runtime.withClient(() => Promise.resolve("unused")); + plugin.settings.codexPath = "codex-b"; + result.resolve("stale-result"); + + await expect(operation).rejects.toThrow("Codex app-server query context changed while loading shared queries."); + }); + + it("rejects a short-lived result when the app-server context changes during the operation", async () => { + const result = deferred(); + withShortLivedAppServerClientMock.mockReturnValue(result.promise); + const plugin = await pluginWithLeaves([]); + plugin.settings.codexPath = "codex-a"; + + const operation = plugin.runtime.withClient(() => Promise.resolve("unused"), { + serverRequests: { kind: "reject", message: "test" }, + }); + plugin.settings.codexPath = "codex-b"; + result.resolve("stale-result"); + + await expect(operation).rejects.toThrow("Codex app-server query context changed while loading shared queries."); + }); + + it("coordinates context replacement with every open Threads view", async () => { + const { CodexThreadsView } = await import("../src/features/threads-view/view.obsidian"); + const prepareAppServerContextChange = vi.fn(); + const refreshSettings = vi.fn(); + const threadsView = Object.assign(Object.create(CodexThreadsView.prototype), { + prepareAppServerContextChange, + refreshSettings, + }) as InstanceType; + const threadsLeaf = leaf(); + threadsLeaf.view = threadsView; + const plugin = await pluginWithLeaves([], { threadsLeaves: [threadsLeaf] }); + const settingsHost = plugin.runtime.settingTabHost(); + + settingsHost.prepareAppServerContextChange(); + expect(prepareAppServerContextChange).toHaveBeenCalledOnce(); + + plugin.settings.codexPath = "codex-next"; + settingsHost.refreshOpenViews(); + expect(refreshSettings).toHaveBeenCalledOnce(); + }); + + it("cancels selection rewrites before publishing a new app-server context", async () => { + const plugin = await pluginWithLeaves([]); + const closeAll = vi.fn(); + plugin.runtime.setSelectionRewriteController({ closeAll }); + + plugin.runtime.settingTabHost().prepareAppServerContextChange(); + + expect(closeAll).toHaveBeenCalledOnce(); + }); + it("refreshes shared thread lists from a remaining connected panel after the archived panel is detached", async () => { const { CodexChatView } = await import("../src/features/chat/host/view.obsidian"); const sourceLeaf = leaf(); @@ -884,6 +949,7 @@ function chatHostFixture(): CodexChatHost { }, threadCatalog: { apply: vi.fn(), + applyConnectionEvent: vi.fn(), loadActive: vi.fn(() => Promise.resolve([])), refreshActive: vi.fn(() => Promise.resolve([])), activeSnapshot: vi.fn(() => null), diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index 62e2ffd1..1a064326 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -413,6 +413,49 @@ describe("settings tab", () => { expect(tab.containerEl.textContent).toContain("New archived"); }); + it("publishes a Codex executable change only after persistence and old-context invalidation", async () => { + const save = deferred(); + const saveSettings = vi.fn(() => save.promise); + let host!: CodexPanelSettingTabHost; + const prepareAppServerContextChange = vi.fn(() => { + expect(host.settings.codexPath).toBe("codex"); + }); + host = settingsTabHost({ saveSettings, prepareAppServerContextChange }); + const tab = new CodexPanelSettingTab({} as never, {} as never, host); + + tab.display(); + const codexInput = inputForSetting(tab, "Codex executable"); + if (!codexInput) throw new Error("Missing Codex executable input"); + codexInput.value = "/opt/codex-next"; + codexInput.dispatchEvent(new FocusEvent("blur")); + await Promise.resolve(); + + expect(saveSettings).toHaveBeenCalledWith(expect.objectContaining({ codexPath: "/opt/codex-next" })); + expect(host.settings.codexPath).toBe("codex"); + expect(prepareAppServerContextChange).not.toHaveBeenCalled(); + + save.resolve(undefined); + await flushPromises(); + + expect(prepareAppServerContextChange).toHaveBeenCalledOnce(); + expect(host.settings.codexPath).toBe("/opt/codex-next"); + }); + + it("does not publish a Codex executable candidate when persistence fails", async () => { + const host = settingsTabHost({ saveSettings: vi.fn().mockRejectedValue(new Error("disk full")) }); + const tab = new CodexPanelSettingTab({} as never, {} as never, host); + + tab.display(); + const codexInput = inputForSetting(tab, "Codex executable"); + if (!codexInput) throw new Error("Missing Codex executable input"); + codexInput.value = "/opt/codex-next"; + codexInput.dispatchEvent(new FocusEvent("blur")); + await flushPromises(); + + expect(host.settings.codexPath).toBe("codex"); + expect(inputForSetting(tab, "Codex executable")?.value).toBe("codex"); + }); + it("subscribes model updates only while the settings tab is displayed", () => { useShortLivedClients(settingsClient()); const unsubscribe = vi.fn(); @@ -1106,7 +1149,8 @@ function expectRequestTimes(client: SettingsRequestClient, method: string, times function newSettingsTab( options: { - saveSettings?: () => Promise; + saveSettings?: CodexPanelSettingTabHost["saveSettings"]; + prepareAppServerContextChange?: () => void; sendShortcut?: "enter" | "mod-enter"; modelsSnapshot?: ModelMetadata[]; fetchModels?: () => Promise; @@ -1133,7 +1177,8 @@ function newSettingsTab( function settingsTabHost( options: { - saveSettings?: () => Promise; + saveSettings?: CodexPanelSettingTabHost["saveSettings"]; + prepareAppServerContextChange?: () => void; sendShortcut?: "enter" | "mod-enter"; modelsSnapshot?: ModelMetadata[]; fetchModels?: () => Promise; @@ -1191,6 +1236,7 @@ function settingsTabHost( threadCatalog, }), saveSettings: options.saveSettings ?? vi.fn().mockResolvedValue(undefined), + prepareAppServerContextChange: options.prepareAppServerContextChange ?? vi.fn(), refreshOpenViews: options.refreshOpenViews ?? vi.fn(), }; }