mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
fix: make app-server context changes atomic
This commit is contained in:
parent
9624f19a95
commit
ca1a71eaac
31 changed files with 975 additions and 202 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]> {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,4 +48,12 @@ export class CodexThreadsView extends ItemView {
|
|||
refreshLiveState(): void {
|
||||
this.session.refreshLiveState();
|
||||
}
|
||||
|
||||
prepareAppServerContextChange(): void {
|
||||
this.session.prepareAppServerContextChange();
|
||||
}
|
||||
|
||||
refreshSettings(): void {
|
||||
this.session.refreshSettings();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}.`);
|
||||
}
|
||||
|
|
|
|||
20
src/main.ts
20
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<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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export interface SettingsDynamicSectionsHost {
|
|||
}
|
||||
|
||||
export interface CodexPanelSettingTabHost extends SettingsDynamicSectionsHost {
|
||||
saveSettings(): Promise<void>;
|
||||
saveSettings(settings: CodexPanelSettings): Promise<void>;
|
||||
prepareAppServerContextChange(): void;
|
||||
refreshOpenViews(): void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue