fix: make app-server context changes atomic

This commit is contained in:
murashit 2026-07-14 21:15:21 +09:00
parent 9624f19a95
commit ca1a71eaac
31 changed files with 975 additions and 202 deletions

View file

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

View file

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

View file

@ -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<readonly Thread[]> {
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<readonly Thread[]> {
return this.options.cache.refreshActiveThreads(context);
}
refreshArchivedThreads(): Promise<readonly Thread[]> {
return this.runForCurrentContext((context) => this.options.cache.refreshArchivedThreads(context));
}
refreshArchivedThreadsFor(context: AppServerQueryContext): Promise<readonly Thread[]> {
return this.options.cache.refreshArchivedThreads(context);
}
observeActiveThreadsResult(listener: ObservedResultListener<readonly Thread[]>, options?: { emitCurrent?: boolean }): () => void {
return this.observeCurrentContext(
(context, contextListener, observeOptions) => this.options.cache.observeActiveThreadsResult(context, contextListener, observeOptions),

View file

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

View file

@ -11,12 +11,17 @@ export interface ChatReconnectActionsHost {
resetConnection: () => void;
setStatus: (statusText: string, phase?: ChatConnectionPhase) => void;
ensureConnected: () => Promise<void>;
isConnected: () => boolean;
resumeThread: (threadId: string) => Promise<void>;
addSystemMessage: (text: string) => void;
}
export async function reconnectPanel(host: ChatReconnectActionsHost): Promise<void> {
const threadId = host.stateStore.getState().activeThread.id;
export async function reconnectPanel(
host: ChatReconnectActionsHost,
target: { resumeThreadId: string | null; isCurrent?: () => boolean } | null = null,
): Promise<boolean> {
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<vo
host.setStatus(STATUS_RECONNECTING, { kind: "connecting" });
await host.ensureConnected();
if (!threadId) return;
if (!isCurrent() || !host.isConnected()) return false;
if (!threadId) return true;
try {
await host.resumeThread(threadId);
if (!isCurrent()) return false;
return host.stateStore.getState().activeThread.id === threadId;
} catch (error) {
host.addSystemMessage(error instanceof Error ? error.message : String(error));
return false;
}
}

View file

@ -90,6 +90,10 @@ export interface ClearDisconnectedConnectionStateAction {
type: "connection/scoped-cleared";
}
export interface ReplaceConnectionContextAction {
type: "connection/context-replaced";
}
export interface ClearLocalTurnAction {
type: "turn/scoped-cleared";
}

View file

@ -52,6 +52,7 @@ import type {
ClearDisconnectedConnectionStateAction,
ClearLocalTurnAction,
ConnectionInitializedAction,
ReplaceConnectionContextAction,
ThreadListAppliedAction,
TurnOptimisticStartedAction,
TurnStartAcknowledgedAction,
@ -238,6 +239,7 @@ interface PendingStartHookUpsertedAction {
type ChatTransitionAction =
| ClearDisconnectedConnectionStateAction
| ReplaceConnectionContextAction
| ClearActiveThreadAction
| ActiveThreadResumedAction
| ActiveThreadSettingsAppliedAction
@ -279,6 +281,7 @@ export function createChatState(): ChatState {
export function chatReducer(state: ChatState, action: ChatAction): ChatState {
switch (action.type) {
case "connection/scoped-cleared":
case "connection/context-replaced":
case "active-thread/cleared":
case "active-thread/resumed":
case "active-thread/settings-applied":
@ -307,6 +310,8 @@ function reduceChatTransition(state: ChatState, action: ChatTransitionAction): C
switch (action.type) {
case "connection/scoped-cleared":
return clearConnectionScopedState(state);
case "connection/context-replaced":
return clearConnectionContextState(state);
case "active-thread/cleared":
return clearThreadScopedState(state);
case "active-thread/resumed":
@ -602,6 +607,17 @@ function clearConnectionScopedState(state: ChatState): ChatState {
});
}
function clearConnectionContextState(state: ChatState): ChatState {
const cleared = clearConnectionScopedState(state);
return patchChatState(cleared, {
connection: {
...cleared.connection,
runtimeConfig: null,
initializeResponse: null,
},
});
}
function reduceChatSlices(state: ChatState, action: ChatSliceAction): ChatState {
return patchChatState(state, {
connection: reduceConnectionSlice(state.connection, action),

View file

@ -166,8 +166,8 @@ export function createConnectionBundle(
maybeNameThread: (threadId, turnId, completedTurnTranscriptSummary) => {
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) => {

View file

@ -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<void>;
}
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<void>;
prepareAppServerContextChange(): void;
refreshSettings(): void;
}

View file

@ -44,7 +44,9 @@ interface ChatPanelSessionRuntimeParts {
shell: ChatPanelShellBundle;
actions: {
invalidateThreadWork(): void;
prepareAppServerContextChange(): void;
reconnect(): Promise<void>;
reconnectAfterAppServerContextChange(threadId: string | null, isCurrent: () => boolean): Promise<boolean>;
refreshSharedThreads(): Promise<void>;
startNewThread(): Promise<void>;
};
@ -69,6 +71,7 @@ interface ChatPanelSessionRuntimeHost {
resumeWork: ChatResumeWorkTracker;
threadStreamScrollBinding: ChatThreadStreamScrollBinding;
getClosing: () => boolean;
reconnect?: () => Promise<void>;
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(),
},

View file

@ -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<void> {
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<void> {
const replacement = this.pendingAppServerContextReplacement;
if (replacement) {
await this.reconnectAfterAppServerContextChange(replacement);
return;
}
await this.runtime.actions.reconnect();
}
refreshSharedThreads(): Promise<void> {
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(),
});
}

View file

@ -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<SelectionRewritePopover>();
export interface SelectionRewriteCommandController {
closeAll(): void;
}
plugin.register(() => {
export function registerSelectionRewriteCommand(
plugin: SelectionRewriteCommandHost,
transport: SelectionRewriteTransport,
): SelectionRewriteCommandController {
const activePopovers = new Set<SelectionRewritePopover>();
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<Editor["getCursor"]>): ReturnType<Editor["getCursor"]> {

View file

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

View file

@ -48,4 +48,12 @@ export class CodexThreadsView extends ItemView {
refreshLiveState(): void {
this.session.refreshLiveState();
}
prepareAppServerContextChange(): void {
this.session.prepareAppServerContextChange();
}
refreshSettings(): void {
this.session.refreshSettings();
}
}

View file

@ -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<readonly Thread[]>;
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<readonly Thread[]>;
hasMoreActiveThreads(): boolean;
loadMoreActiveThreads(): Promise<readonly Thread[]>;
refreshActiveThreads(): Promise<readonly Thread[]>;
refreshActiveThreadsFor(context: AppServerQueryContext): Promise<readonly Thread[]>;
refreshArchivedThreads(): Promise<readonly Thread[]>;
refreshArchivedThreadsFor(context: AppServerQueryContext): Promise<readonly Thread[]>;
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<string, ThreadCatalogFacts>, 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<void> {
async function refreshArchivedThreadsTwice(store: ThreadCatalogEventStore, archivedFacts: PendingThreadListFacts): Promise<void> {
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 {

View file

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

View file

@ -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<void> {
@ -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<void> {
await this.saveData(this.settings);
async saveSettings(settings: CodexPanelSettings = this.settings): Promise<void> {
await this.saveData(settings);
}
}

View file

@ -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<void>;
saveSettings(settings: CodexPanelSettings): Promise<void>;
}
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<T>(
context: AppServerQueryContext,
operation: (client: AppServerClient) => Promise<T>,
options: AppServerClientAccessOptions,
): Promise<T> {
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 {

View file

@ -7,6 +7,7 @@ export interface SettingsDynamicSectionsHost {
}
export interface CodexPanelSettingTabHost extends SettingsDynamicSectionsHost {
saveSettings(): Promise<void>;
saveSettings(settings: CodexPanelSettings): Promise<void>;
prepareAppServerContextChange(): void;
refreshOpenViews(): void;
}

View file

@ -189,121 +189,132 @@ export class CodexPanelSettingTab extends PluginSettingTab {
private setCodexPath(value: string): Promise<void> {
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<void> {
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<void> {
return this.queueSettingsMutation(() => {
this.plugin.settings.sendShortcut = value;
return this.queueSettingsMutation((settings) => {
settings.sendShortcut = value;
});
}
private setScrollThreadFromComposerEdges(value: boolean): Promise<void> {
return this.queueSettingsMutation(() => {
this.plugin.settings.scrollThreadFromComposerEdges = value;
return this.queueSettingsMutation((settings) => {
settings.scrollThreadFromComposerEdges = value;
});
}
private setReferenceActiveNoteOnSend(value: boolean): Promise<void> {
return this.queueSettingsMutation(() => {
this.plugin.settings.referenceActiveNoteOnSend = value;
return this.queueSettingsMutation((settings) => {
settings.referenceActiveNoteOnSend = value;
});
}
private setAttachmentFolder(value: string): Promise<void> {
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<void> {
return this.queueSettingsMutation(() => {
this.plugin.settings.archiveExportEnabled = enabled;
return this.queueSettingsMutation((settings) => {
settings.archiveExportEnabled = enabled;
});
}
private setArchiveExportFolderTemplate(value: string): Promise<void> {
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<void> {
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<void> {
return this.queueSettingsMutation(() => {
this.plugin.settings.archiveExportTags = value.trim();
return this.queueSettingsMutation((settings) => {
settings.archiveExportTags = value.trim();
});
}
private setThreadNamingModel(value: string | null): Promise<void> {
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<void> {
return this.queueSettingsMutation(() => {
this.plugin.settings.threadNamingEffort = value;
return this.queueSettingsMutation((settings) => {
settings.threadNamingEffort = value;
});
}
private setRewriteSelectionModel(value: string | null): Promise<void> {
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<void> {
return this.queueSettingsMutation(() => {
this.plugin.settings.rewriteSelectionEffort = value;
return this.queueSettingsMutation((settings) => {
settings.rewriteSelectionEffort = value;
});
}
private queueSettingsMutation(mutate: () => boolean | undefined, onSaved?: () => void): Promise<void> {
private queueSettingsMutation(
mutate: (settings: CodexPanelSettings) => boolean | undefined,
publication: { beforePublish?: () => void; onPublished?: () => void } = {},
): Promise<void> {
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;

View file

@ -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<ServerNotification, { method: "thread/name/updated" }>;
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();

View file

@ -18,27 +18,36 @@ import { chatStateFixture, chatStateWith } from "../../support/state";
import { chatStateThreadStreamItems, withChatStateThreadStreamItems } from "../../support/thread-stream";
type ThreadStartedNotification = Extract<ServerNotification, { method: "thread/started" }>;
const TEST_APP_SERVER_CONTEXT = { codexPath: "codex", vaultPath: "/vault" } as const;
function handlerForState(
state = chatStateFixture(),
actions: Partial<ChatInboundHandlerActions> = {},
): ChatInboundHandler & { currentState(): ChatState } {
type TestChatInboundHandler = Omit<ChatInboundHandler, "handleNotification"> & {
handleNotification(notification: ServerNotification): void;
currentState(): ChatState;
};
function handlerForState(state = chatStateFixture(), actions: Partial<ChatInboundHandlerActions> = {}): 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<ServerNotification, { method: "thread/archived" }>);
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<ServerNotification, { method: "thread/deleted" }>);
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<ServerNotification, { method: "thread/unarchived" }>);
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<ServerNotification, { method: "thread/started" }>);
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<ServerNotification, { method: "thread/started" }>);
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<ServerNotification, { method: "thread/name/updated" }>);
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", () => {

View file

@ -43,6 +43,7 @@ function createHost(overrides: Partial<ChatReconnectActionsHost> = {}) {
resetConnection: vi.fn(),
setStatus: vi.fn(),
ensureConnected: vi.fn().mockResolvedValue(undefined),
isConnected: vi.fn(() => true),
resumeThread: vi.fn().mockResolvedValue(undefined),
addSystemMessage: vi.fn(),
...overrides,

View file

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

View file

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

View file

@ -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<unknown> {
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<ConnectionManager["handlers"]>): 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<unknown>();
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<unknown>();
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 () => {

View file

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

View file

@ -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<object>();
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<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
const input = view.containerEl.querySelector<HTMLInputElement>(".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: [

View file

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

View file

@ -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<string>();
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<string>();
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<typeof CodexThreadsView>;
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),

View file

@ -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<void>();
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<void>;
saveSettings?: CodexPanelSettingTabHost["saveSettings"];
prepareAppServerContextChange?: () => void;
sendShortcut?: "enter" | "mod-enter";
modelsSnapshot?: ModelMetadata[];
fetchModels?: () => Promise<readonly ModelMetadata[]>;
@ -1133,7 +1177,8 @@ function newSettingsTab(
function settingsTabHost(
options: {
saveSettings?: () => Promise<void>;
saveSettings?: CodexPanelSettingTabHost["saveSettings"];
prepareAppServerContextChange?: () => void;
sendShortcut?: "enter" | "mod-enter";
modelsSnapshot?: ModelMetadata[];
fetchModels?: () => Promise<readonly ModelMetadata[]>;
@ -1191,6 +1236,7 @@ function settingsTabHost(
threadCatalog,
}),
saveSettings: options.saveSettings ?? vi.fn().mockResolvedValue(undefined),
prepareAppServerContextChange: options.prepareAppServerContextChange ?? vi.fn(),
refreshOpenViews: options.refreshOpenViews ?? vi.fn(),
};
}