Refactor panel graph and app-server boundaries

This commit is contained in:
murashit 2026-06-17 12:55:54 +09:00
parent fa5f9214f1
commit 20e82a09ff
31 changed files with 681 additions and 552 deletions

View file

@ -0,0 +1,5 @@
import type { AppServerClient } from "./client";
export interface AppServerClientAccess {
withClient<T>(operation: (client: AppServerClient) => Promise<T>, options?: { unhandledServerRequestMessage?: string }): Promise<T>;
}

View file

@ -173,6 +173,7 @@ function appServerTurnRuntimeParams(runtime: AppServerTurnRuntimeOverrides | und
export class AppServerClient {
private lifecycle: AppServerClientLifecycleState = { kind: "disconnected" };
private readonly rpc: JsonRpcClient;
private readonly intentionallyStoppedTransports = new WeakSet<AppServerTransport>();
constructor(
private readonly codexPath: string,
@ -197,21 +198,30 @@ export class AppServerClient {
throw new Error("Codex app-server is already running.");
}
let transport: AppServerTransport;
const transportHandlers: AppServerTransportHandlers = {
onLine: (line) => {
if (!this.isActiveTransport(transport)) return;
this.rpc.handleLine(line);
},
onLog: this.handlers.onLog,
onLog: (message) => {
if (!this.isActiveTransport(transport)) return;
this.handlers.onLog(message);
},
onError: (error) => {
this.rpc.rejectAll(error);
if (!this.isActiveTransport(transport)) return;
this.failActiveTransport(transport, error);
},
onExit: (code, signal) => {
if (!this.isActiveTransport(transport)) return;
const intentional = this.intentionallyStoppedTransports.has(transport);
this.lifecycle = { kind: "disconnected" };
this.rpc.rejectAll(new Error(`Codex app-server exited: ${String(code ?? signal ?? "unknown")}`));
if (intentional) return;
this.handlers.onExit(code, signal);
},
};
const transport = this.transportFactory
transport = this.transportFactory
? this.transportFactory(transportHandlers)
: new StdioAppServerTransport(this.codexPath, this.cwd, transportHandlers);
this.lifecycle = { kind: "starting", transport };
@ -234,9 +244,12 @@ export class AppServerClient {
}
disconnect(): void {
this.activeTransport()?.stop();
const transport = this.activeTransport();
this.lifecycle = { kind: "disconnected" };
this.rpc.rejectAll(new Error("Codex app-server disconnected."));
if (!transport) return;
this.intentionallyStoppedTransports.add(transport);
transport.stop();
}
isConnected(): boolean {
@ -516,4 +529,16 @@ export class AppServerClient {
private activeTransport(): AppServerTransport | null {
return this.lifecycle.kind === "disconnected" ? null : this.lifecycle.transport;
}
private isActiveTransport(transport: AppServerTransport): boolean {
return this.activeTransport() === transport;
}
private failActiveTransport(transport: AppServerTransport, error: Error): void {
if (!this.isActiveTransport(transport)) return;
this.lifecycle = { kind: "disconnected" };
this.rpc.rejectAll(error);
this.intentionallyStoppedTransports.add(transport);
transport.stop();
}
}

View file

@ -33,7 +33,7 @@ export interface ChatConnectionControllerHost {
metadata: ChatConnectionMetadataActions;
diagnostics: ChatConnectionDiagnosticsActions;
invalidateResumeWork: () => void;
loadSharedThreadList: () => Promise<void>;
refreshSharedThreadList: () => Promise<void>;
scheduleDeferredDiagnostics: () => void;
clearDeferredDiagnostics: () => void;
refreshTabHeader: () => void;
@ -107,7 +107,7 @@ async function ensureConnected(host: ChatConnectionControllerHost): Promise<void
async function refreshActiveThreads(host: ChatConnectionControllerHost): Promise<void> {
if (!host.connection.currentClient()) return;
try {
await host.loadSharedThreadList();
await host.refreshSharedThreadList();
host.refreshTabHeader();
} catch (error) {
if (isStaleAppServerSharedQueryContextError(error)) return;
@ -154,7 +154,7 @@ async function initializeConnection(host: ChatConnectionControllerHost, connecti
if (!client) throw new Error("Codex app-server connection did not initialize.");
await host.metadata.refreshAppServerMetadata();
if (host.connectionWork.isStale(connection)) return;
await host.loadSharedThreadList();
await host.refreshSharedThreadList();
if (host.connectionWork.isStale(connection)) return;
host.scheduleDeferredDiagnostics();
host.refreshTabHeader();

View file

@ -45,7 +45,6 @@ interface ChatPanelConnectionBundleHost {
connectionWork: ConnectionWorkTracker;
deferredTasks: ChatViewDeferredTasks;
invalidateResumeWork: () => void;
loadSharedThreadList: () => Promise<void>;
deferLiveStateRefresh: () => void;
refreshTabHeader: () => void;
refreshLiveState: () => void;
@ -62,6 +61,7 @@ export interface ChatPanelConnectionBundle {
metadata: ChatServerMetadataActions;
diagnostics: ChatServerDiagnosticsActions;
};
refreshSharedThreadList: () => Promise<void>;
}
function respondToCurrentServerRequest(currentClient: CurrentAppServerClient, requestId: RespondRequestId, result: unknown): boolean {
@ -122,9 +122,13 @@ export function createConnectionBundle(
void goalSync.syncThreadGoal(threadId);
},
});
const refreshSharedThreadList = async (): Promise<void> => {
const threads = await environment.plugin.threadCatalog.refresh();
serverThreads.applyThreadList(threads);
};
const inboundController = new ChatInboundController(stateStore, {
refreshActiveThreads: () => {
void host.loadSharedThreadList();
void refreshSharedThreadList();
},
refreshRateLimits: () => {
void serverMetadata.refreshRateLimits({ preserveExistingOnFailure: true });
@ -198,7 +202,7 @@ export function createConnectionBundle(
diagnostics: {
refreshDiagnosticProbes: (options) => serverDiagnostics.refreshDiagnosticProbes(options),
},
loadSharedThreadList: () => host.loadSharedThreadList(),
refreshSharedThreadList,
scheduleDeferredDiagnostics: () => {
host.deferredTasks.scheduleDiagnostics(() => {
if (connection.isConnected()) {
@ -234,5 +238,6 @@ export function createConnectionBundle(
metadata: serverMetadata,
diagnostics: serverDiagnostics,
},
refreshSharedThreadList,
};
}

View file

@ -1,6 +1,10 @@
import { Notice } from "obsidian";
import { ConnectionManager } from "../../../app-server/connection/connection-manager";
import type { AppServerClientAccess } from "../../../app-server/connection/client-access";
import type { AppServerObservedQueryResult } from "../../../app-server/query/cache";
import { isStaleAppServerSharedQueryContextError } from "../../../app-server/query/shared-queries";
import type { ModelMetadata } from "../../../domain/catalog/metadata";
import type { MessageStreamItem, MessageStreamNoticeSection } from "../domain/message-stream/items";
import { createThreadOperations, type ThreadOperations } from "../../threads/thread-operations";
import { createThreadTitleService, type ThreadTitleService } from "../../threads/thread-title-service";
@ -13,7 +17,7 @@ import type { ComposerSubmitActions } from "../application/conversation/composer
import { reconnectPanel, type ChatReconnectActionsHost } from "../application/connection/reconnect-actions";
import { runtimeSnapshotForChatState } from "../application/runtime/snapshot";
import { createChatRuntimeSettingsActions } from "../application/runtime/settings-actions";
import { activeTurnId, type ChatConnectionPhase } from "../application/state/root-reducer";
import { activeTurnId, chatTurnBusy, type ChatAction, type ChatConnectionPhase } from "../application/state/root-reducer";
import { messageStreamItems } from "../application/state/message-stream";
import type { ChatStateStore } from "../application/state/store";
import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../application/lifecycle";
@ -46,13 +50,23 @@ import type { ToolbarActions } from "../ui/toolbar";
import { pendingRequestsSignature } from "../domain/pending-requests/signatures";
import { currentModel, runtimeConfigOrDefault } from "../domain/runtime/effective";
import { threadTitleContextFromMessageStreamItems } from "../application/threads/title-context";
import { normalizeExplicitThreadName } from "../../../domain/threads/model";
import { normalizeExplicitThreadName, type Thread } from "../../../domain/threads/model";
import type { SharedServerMetadata } from "../../../domain/server/metadata";
import type { ConnectionWorkTracker } from "../../../shared/lifecycle/connection-work";
import { collaborationModeLabel as formatCollaborationModeLabel } from "../presentation/runtime/messages";
import {
effortStatusLines as buildEffortStatusLines,
modelStatusLines as buildModelStatusLines,
statusSummaryLines as buildStatusSummaryLines,
} from "../presentation/runtime/status";
import { connectionDiagnosticsModel } from "../application/connection/diagnostics-display";
import { createStructuredSystemItem, createSystemItem } from "../domain/message-stream/factories/system-items";
import { createLocalChatItemIdFactory, type LocalChatItemIdFactory } from "../domain/local-id";
import type { RuntimeSnapshot } from "../application/runtime/snapshot";
import type { ChatPanelEnvironment } from "./runtime";
import { createConnectionBundle, type ChatPanelConnectionBundle, type CurrentAppServerClient } from "./connection-bundle";
export interface ChatPanelSessionParts {
export interface ChatPanelSessionGraph {
connection: {
manager: ConnectionManager;
controller: ChatPanelConnectionBundle["connection"]["controller"];
@ -85,15 +99,31 @@ export interface ChatPanelSessionParts {
goal: ChatPanelGoalSurface;
composer: ChatPanelComposerSurface;
};
actions: {
invalidateResumeWork(): void;
refreshSharedThreadList(): Promise<void>;
startNewThread(): Promise<void>;
statusSummaryLines(): string[];
modelStatusLines(): string[];
effortStatusLines(): string[];
connectionDiagnosticDetails(): MessageStreamNoticeSection[];
};
runtime: {
applyCachedAppServerState(): void;
subscribeAppServerState(): void;
unsubscribeAppServerState(): void;
refreshLiveState(): void;
deferLiveStateRefresh(): void;
};
}
export interface ChatPanelSessionStatus {
interface ChatPanelSessionStatus {
set: (statusText: string, phase?: ChatConnectionPhase) => void;
addSystemMessage: (text: string) => void;
addStructuredSystemMessage: (text: string, details: MessageStreamNoticeSection[]) => void;
}
interface ChatPanelSessionPartsHost {
interface ChatPanelSessionGraphHost {
environment: ChatPanelEnvironment;
stateStore: ChatStateStore;
deferredTasks: ChatViewDeferredTasks;
@ -102,17 +132,7 @@ interface ChatPanelSessionPartsHost {
messageScrollIntent: ChatMessageScrollIntentState;
getOpened: () => boolean;
getClosing: () => boolean;
invalidateResumeWork: () => void;
loadSharedThreadList: () => Promise<void>;
notifyActiveThreadIdentityChanged: () => void;
refreshTabHeader: () => void;
refreshLiveState: () => void;
deferLiveStateRefresh: () => void;
startNewThread: () => Promise<void>;
statusSummaryLines: () => string[];
modelStatusLines: () => string[];
effortStatusLines: () => string[];
connectionDiagnosticDetails: () => MessageStreamNoticeSection[];
viewWindow: () => Window;
}
type ChatPanelGoalSyncActions = ReturnType<typeof createThreadGoalSyncActions>;
@ -141,20 +161,70 @@ interface ChatPanelSurfacePresenterParts {
messageStreamPresenter: MessageStreamPresenter;
}
export function createChatPanelSessionParts(host: ChatPanelSessionPartsHost, status: ChatPanelSessionStatus): ChatPanelSessionParts {
interface ChatPanelSessionGraphActionParts {
connection: ConnectionManager;
serverParts: ChatPanelConnectionBundle;
invalidateResumeWork: () => void;
startNewThread: () => Promise<void>;
}
interface ChatPanelSessionGraphRuntimeParts {
serverActions: ChatPanelConnectionBundle["serverActions"];
}
export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): ChatPanelSessionGraph {
const { actionParts, runtimeParts, ...graphObjects } = buildChatPanelSessionGraphObjects(host);
return {
...graphObjects,
actions: createChatPanelSessionGraphActions(host, actionParts),
runtime: createChatPanelSessionGraphRuntime(host, runtimeParts),
};
}
interface ChatPanelSessionGraphObjects extends Omit<ChatPanelSessionGraph, "actions" | "runtime"> {
actionParts: ChatPanelSessionGraphActionParts;
runtimeParts: ChatPanelSessionGraphRuntimeParts;
}
function buildChatPanelSessionGraphObjects(host: ChatPanelSessionGraphHost): ChatPanelSessionGraphObjects {
const { environment, stateStore } = host;
const localItemIds = createLocalChatItemIdFactory();
const connection = createConnectionManager(environment);
const currentClient = () => connection.currentClient();
const status = createSessionStatus(stateStore, localItemIds);
let threadHistory: HistoryController | null = null;
const invalidateGraphResumeWork = () => {
host.resumeWork.invalidate();
threadHistory?.invalidate();
};
const titleService = createSessionThreadTitleService(host, currentClient);
const autoTitle = createSessionAutoTitleController(host, currentClient, titleService);
const goalSync = createSessionGoalSyncActions(host, currentClient, status);
const serverParts = createConnectionBundle(host, {
connection,
currentClient,
goalSync,
autoTitle,
status,
});
const serverParts = createConnectionBundle(
{
environment,
stateStore,
connectionWork: host.connectionWork,
deferredTasks: host.deferredTasks,
invalidateResumeWork: invalidateGraphResumeWork,
deferLiveStateRefresh: () => {
deferLiveStateRefresh(host);
},
refreshTabHeader: () => {
refreshTabHeader(host);
},
refreshLiveState: () => {
refreshLiveState(host);
},
},
{
connection,
currentClient,
goalSync,
autoTitle,
status,
},
);
const {
connection: { controller },
inboundController,
@ -163,16 +233,18 @@ export function createChatPanelSessionParts(host: ChatPanelSessionPartsHost, sta
const { threads: serverThreads, diagnostics: serverDiagnostics } = serverParts.serverActions;
const ensureConnected = () => connectionController.ensureConnected();
const refreshActiveThreads = () => connectionController.refreshActiveThreads();
const threadOperations = createSessionThreadOperations(environment, currentClient, ensureConnected);
const threadOperations = createSessionThreadOperations(environment, currentClient);
const runtimeSettings = createSessionRuntimeSettingsActions(host, currentClient, status);
const goals = createSessionGoalActions(host, currentClient, ensureConnected, status);
const rename = createSessionThreadRenameEditor(stateStore, threadOperations, titleService, ensureConnected, status);
const threadLifecycle = createSessionThreadLifecycle(host, currentClient, ensureConnected, status, goals, autoTitle);
const { history, identity, restoration, resume } = threadLifecycle;
threadHistory = history;
const composerSurface = createSessionComposerSurface(threadLifecycle, runtimeSettings);
const messageStreamScrollBridge = new MessageStreamScrollBridge();
const composerController = createSessionComposerController(host, composerSurface, runtimeSettings, messageStreamScrollBridge);
const startNewThread = createStartNewThreadAction(host, { identity, composerController });
const threadActionParts = createThreadActionParts(host, {
operations: threadOperations,
ensureConnected,
@ -197,6 +269,14 @@ export function createChatPanelSessionParts(host: ChatPanelSessionPartsHost, sta
serverDiagnostics,
goals,
autoTitle,
invalidateResumeWork: invalidateGraphResumeWork,
startNewThread,
runtimeProjection: {
connectionDiagnosticDetails: () => connectionDiagnosticDetails(host, connection),
modelStatusLines: () => modelStatusLines(host),
effortStatusLines: () => effortStatusLines(host),
statusSummaryLines: () => statusSummaryLines(host),
},
});
const surfaceAndPresenter = createSurfacesAndPresenter(host, {
connection,
@ -213,6 +293,7 @@ export function createChatPanelSessionParts(host: ChatPanelSessionPartsHost, sta
pendingRequests: composerAndTurn.pendingRequests,
turnActions: composerAndTurn.turnActions,
messageStreamScrollBridge,
startNewThread,
});
return {
@ -244,6 +325,15 @@ export function createChatPanelSessionParts(host: ChatPanelSessionPartsHost, sta
goal: surfaceAndPresenter.goalSurface,
composer: composerSurface,
},
actionParts: {
connection,
serverParts,
invalidateResumeWork: invalidateGraphResumeWork,
startNewThread,
},
runtimeParts: {
serverActions: serverParts.serverActions,
},
};
}
@ -251,14 +341,115 @@ function createConnectionManager(environment: ChatPanelEnvironment): ConnectionM
return new ConnectionManager(() => environment.plugin.settingsRef.settings.codexPath, environment.plugin.settingsRef.vaultPath);
}
function createSessionThreadTitleService(host: ChatPanelSessionPartsHost, currentClient: CurrentAppServerClient): ThreadTitleService {
function createStartNewThreadAction(
host: ChatPanelSessionGraphHost,
input: {
identity: IdentitySync;
composerController: ChatComposerController;
},
): () => Promise<void> {
return async () => {
if (chatTurnBusy(host.stateStore.getState())) return;
input.identity.clearActiveThreadContext();
host.stateStore.dispatch({ type: "ui/panel-set", panel: null });
host.stateStore.dispatch({ type: "connection/status-set", statusText: "New chat." });
input.composerController.focus();
};
}
function createChatPanelSessionGraphActions(
host: ChatPanelSessionGraphHost,
input: ChatPanelSessionGraphActionParts,
): ChatPanelSessionGraph["actions"] {
return {
invalidateResumeWork: () => {
input.invalidateResumeWork();
},
refreshSharedThreadList: async () => {
try {
await input.serverParts.refreshSharedThreadList();
} catch (error) {
if (isStaleAppServerSharedQueryContextError(error)) return;
throw error;
}
},
startNewThread: input.startNewThread,
statusSummaryLines: () => statusSummaryLines(host),
modelStatusLines: () => modelStatusLines(host),
effortStatusLines: () => effortStatusLines(host),
connectionDiagnosticDetails: () => connectionDiagnosticDetails(host, input.connection),
};
}
function createChatPanelSessionGraphRuntime(
host: ChatPanelSessionGraphHost,
input: ChatPanelSessionGraphRuntimeParts,
): ChatPanelSessionGraph["runtime"] {
const unsubscribers: (() => void)[] = [];
const receiveThreads = (threads: readonly Thread[]): void => {
input.serverActions.threads.applyThreadList(threads);
refreshTabHeader(host);
};
const receiveThreadResult = (result: AppServerObservedQueryResult<readonly Thread[]>): void => {
if (result.data) receiveThreads(result.data);
};
const receiveAppServerMetadata = (metadata: SharedServerMetadata): void => {
input.serverActions.metadata.applyAppServerMetadata(metadata);
};
const receiveAppServerMetadataResult = (result: AppServerObservedQueryResult<SharedServerMetadata>): void => {
if (result.data) receiveAppServerMetadata(result.data);
};
const receiveModels = (models: readonly ModelMetadata[]): void => {
dispatch(host.stateStore, { type: "connection/metadata-applied", availableModels: models });
};
const receiveModelsResult = (result: AppServerObservedQueryResult<readonly ModelMetadata[]>): void => {
if (result.data) receiveModels(result.data);
};
const unsubscribeAppServerState = (): void => {
while (unsubscribers.length > 0) {
unsubscribers.pop()?.();
}
};
const applyCachedAppServerState = (): void => {
const threads = host.environment.plugin.threadCatalog.snapshot();
if (threads) input.serverActions.threads.applyThreadList(threads);
const metadata = host.environment.plugin.appServerData.appServerMetadataSnapshot();
if (metadata) input.serverActions.metadata.applyAppServerMetadata(metadata);
const models = host.environment.plugin.appServerData.modelsSnapshot();
if (models) receiveModels(models);
};
return {
applyCachedAppServerState,
subscribeAppServerState: () => {
unsubscribeAppServerState();
applyCachedAppServerState();
unsubscribers.push(
host.environment.plugin.threadCatalog.observe(receiveThreadResult, { emitCurrent: false }),
host.environment.plugin.appServerData.observeAppServerMetadataResult(receiveAppServerMetadataResult, { emitCurrent: false }),
host.environment.plugin.appServerData.observeModelsResult(receiveModelsResult, { emitCurrent: false }),
);
},
unsubscribeAppServerState,
refreshLiveState: () => {
refreshLiveState(host);
},
deferLiveStateRefresh: () => {
deferLiveStateRefresh(host);
},
};
}
function createSessionThreadTitleService(host: ChatPanelSessionGraphHost, currentClient: CurrentAppServerClient): ThreadTitleService {
const { environment, stateStore } = host;
return createThreadTitleService({
settings: {
current: () => environment.plugin.settingsRef.settings,
vaultPath: environment.plugin.settingsRef.vaultPath,
},
currentClient,
codexPath: () => environment.plugin.settingsRef.settings.codexPath,
vaultPath: environment.plugin.settingsRef.vaultPath,
threadNamingModel: () => environment.plugin.settingsRef.settings.threadNamingModel,
threadNamingEffort: () => environment.plugin.settingsRef.settings.threadNamingEffort,
clientAccess: createCurrentClientAccess(currentClient),
visibleContext: (threadId) => activeThreadRenameTitleContext(stateStore.getState(), threadId),
visibleCompletedTurnContext: (turnId) =>
threadTitleContextFromMessageStreamItems(turnId, messageStreamItems(stateStore.getState().messageStream)),
@ -266,7 +457,7 @@ function createSessionThreadTitleService(host: ChatPanelSessionPartsHost, curren
}
function createSessionAutoTitleController(
host: ChatPanelSessionPartsHost,
host: ChatPanelSessionGraphHost,
currentClient: CurrentAppServerClient,
titleService: ThreadTitleService,
): AutoTitleController {
@ -291,7 +482,7 @@ function createSessionAutoTitleController(
}
function createSessionGoalSyncActions(
host: ChatPanelSessionPartsHost,
host: ChatPanelSessionGraphHost,
currentClient: CurrentAppServerClient,
status: ChatPanelSessionStatus,
): ChatPanelGoalSyncActions {
@ -305,23 +496,17 @@ function createSessionGoalSyncActions(
host.stateStore.dispatch({ type: "message-stream/item-upserted", item });
},
refreshLiveState: () => {
host.refreshLiveState();
refreshLiveState(host);
},
});
}
function createSessionThreadOperations(
environment: ChatPanelEnvironment,
currentClient: CurrentAppServerClient,
ensureConnected: () => Promise<void>,
): ThreadOperations {
function createSessionThreadOperations(environment: ChatPanelEnvironment, currentClient: CurrentAppServerClient): ThreadOperations {
return createThreadOperations({
connection: {
ensureConnected,
currentClient,
},
settings: {
current: () => environment.plugin.settingsRef.settings,
clientAccess: createCurrentClientAccess(currentClient),
archiveExport: {
settings: () => environment.plugin.settingsRef.settings,
enabled: () => environment.plugin.settingsRef.settings.archiveExportEnabled,
vaultPath: environment.plugin.settingsRef.vaultPath,
},
archiveAdapter: environment.obsidian.archiveAdapter,
@ -332,8 +517,22 @@ function createSessionThreadOperations(
});
}
function createCurrentClientAccess(currentClient: CurrentAppServerClient): AppServerClientAccess {
return {
withClient: async (operation) => {
const client = currentClient();
if (!client) throw new Error("Codex app-server is not connected.");
const result = await operation(client);
if (currentClient() !== client) {
throw new Error("Codex app-server connection changed while running the operation.");
}
return result;
},
};
}
function createSessionRuntimeSettingsActions(
host: ChatPanelSessionPartsHost,
host: ChatPanelSessionGraphHost,
currentClient: CurrentAppServerClient,
status: ChatPanelSessionStatus,
): ChatPanelRuntimeSettingsActions {
@ -349,7 +548,7 @@ function createSessionRuntimeSettingsActions(
}
function createSessionGoalActions(
host: ChatPanelSessionPartsHost,
host: ChatPanelSessionGraphHost,
currentClient: CurrentAppServerClient,
ensureConnected: () => Promise<void>,
status: ChatPanelSessionStatus,
@ -365,7 +564,7 @@ function createSessionGoalActions(
host.stateStore.dispatch({ type: "message-stream/item-upserted", item });
},
refreshLiveState: () => {
host.refreshLiveState();
refreshLiveState(host);
},
});
}
@ -387,7 +586,7 @@ function createSessionThreadRenameEditor(
}
function createSessionThreadLifecycle(
host: ChatPanelSessionPartsHost,
host: ChatPanelSessionGraphHost,
currentClient: CurrentAppServerClient,
ensureConnected: () => Promise<void>,
status: ChatPanelSessionStatus,
@ -409,16 +608,16 @@ function createSessionThreadLifecycle(
},
thread: {
notifyIdentityChanged: () => {
host.notifyActiveThreadIdentityChanged();
notifyActiveThreadIdentityChanged(host);
},
refreshTabHeader: () => {
host.refreshTabHeader();
refreshTabHeader(host);
},
},
status,
liveState: {
refresh: () => {
host.refreshLiveState();
refreshLiveState(host);
},
},
scroll: {
@ -452,7 +651,7 @@ function createSessionComposerSurface(
}
function createSessionComposerController(
host: ChatPanelSessionPartsHost,
host: ChatPanelSessionGraphHost,
composerSurface: ChatPanelComposerSurface,
runtimeSettings: ChatPanelRuntimeSettingsActions,
messageStreamScrollBridge: MessageStreamScrollBridge,
@ -479,7 +678,7 @@ function createSessionComposerController(
toggleAutoReview: () => void runtimeSettings.toggleAutoReview(),
toggleFast: () => void runtimeSettings.toggleFastMode(),
onDraftChange: () => {
host.refreshLiveState();
refreshLiveState(host);
},
onHeightChange: () => {
messageStreamScrollBridge.repinMessageStreamToBottomIfPinned();
@ -488,7 +687,7 @@ function createSessionComposerController(
}
function createThreadActionParts(
host: ChatPanelSessionPartsHost,
host: ChatPanelSessionGraphHost,
input: {
operations: ThreadOperations;
ensureConnected: () => Promise<void>;
@ -515,7 +714,7 @@ function createThreadActionParts(
openThreadInNewView: (threadId) => environment.plugin.workspace.openThreadInNewView(threadId),
openThreadInCurrentPanel: (threadId) => resume.resumeThread(threadId),
notifyActiveThreadIdentityChanged: () => {
host.notifyActiveThreadIdentityChanged();
notifyActiveThreadIdentityChanged(host);
},
refreshAfterThreadMutation: async () => {
await refreshActiveThreads();
@ -542,7 +741,7 @@ function createThreadActionParts(
}
function createComposerAndTurnActions(
host: ChatPanelSessionPartsHost,
host: ChatPanelSessionGraphHost,
input: {
connection: ConnectionManager;
ensureConnected: () => Promise<void>;
@ -558,6 +757,14 @@ function createComposerAndTurnActions(
serverDiagnostics: ChatServerDiagnosticsActions;
goals: ChatPanelGoalActions;
autoTitle: AutoTitleController;
invalidateResumeWork: () => void;
startNewThread: () => Promise<void>;
runtimeProjection: {
connectionDiagnosticDetails: () => MessageStreamNoticeSection[];
modelStatusLines: () => string[];
effortStatusLines: () => string[];
statusSummaryLines: () => string[];
};
},
): ChatPanelComposerAndTurnParts {
const {
@ -575,13 +782,16 @@ function createComposerAndTurnActions(
serverDiagnostics,
goals,
autoTitle,
invalidateResumeWork,
startNewThread,
runtimeProjection,
} = input;
const pendingRequests = new PendingRequestController({
stateStore: host.stateStore,
responder: inboundController,
composerHasFocus: () => composerController.hasFocus(),
refreshLiveState: () => {
host.refreshLiveState();
refreshLiveState(host);
},
});
const reconnectHost: ChatReconnectActionsHost = {
@ -590,7 +800,7 @@ function createComposerAndTurnActions(
host.connectionWork.invalidate();
},
invalidateResumeWork: () => {
host.invalidateResumeWork();
invalidateResumeWork();
},
clearDeferredDiagnostics: () => {
host.deferredTasks.clearDiagnostics();
@ -618,19 +828,19 @@ function createComposerAndTurnActions(
},
status,
runtime: {
connectionDiagnosticDetails: () => host.connectionDiagnosticDetails(),
modelStatusLines: () => host.modelStatusLines(),
effortStatusLines: () => host.effortStatusLines(),
statusSummaryLines: () => host.statusSummaryLines(),
connectionDiagnosticDetails: runtimeProjection.connectionDiagnosticDetails,
modelStatusLines: runtimeProjection.modelStatusLines,
effortStatusLines: runtimeProjection.effortStatusLines,
statusSummaryLines: runtimeProjection.statusSummaryLines,
mcpStatusLines: () => serverDiagnostics.mcpStatusLines(),
},
thread: {
ensureRestoredThreadLoaded: () =>
threadLifecycle.restoration.ensureLoaded((threadId) => threadLifecycle.resume.resumeThread(threadId)),
startNewThread: () => host.startNewThread(),
startNewThread,
selectThread: (threadId) => selection.selectThread(threadId),
notifyIdentityChanged: () => {
host.notifyActiveThreadIdentityChanged();
notifyActiveThreadIdentityChanged(host);
},
resetTurnPresence: (hadTurns) => {
autoTitle.resetThreadTurnPresence(hadTurns);
@ -666,7 +876,7 @@ function createComposerAndTurnActions(
}
function createSurfacesAndPresenter(
host: ChatPanelSessionPartsHost,
host: ChatPanelSessionGraphHost,
input: {
connection: ConnectionManager;
connectionController: ChatPanelConnectionBundle["connection"]["controller"];
@ -682,6 +892,7 @@ function createSurfacesAndPresenter(
pendingRequests: PendingRequestController;
turnActions: ChatPanelConversationTurnActions;
messageStreamScrollBridge: MessageStreamScrollBridge;
startNewThread: () => Promise<void>;
},
): ChatPanelSurfacePresenterParts {
const {
@ -699,12 +910,13 @@ function createSurfacesAndPresenter(
pendingRequests,
turnActions,
messageStreamScrollBridge,
startNewThread,
} = input;
const { environment, stateStore } = host;
const toolbarActions = createChatPanelToolbarActions(
{
stateStore,
startNewThread: () => host.startNewThread(),
startNewThread,
},
{
connectionController,
@ -729,7 +941,7 @@ function createSurfacesAndPresenter(
};
const goalSurface = createChatPanelGoalSurface(
{
settings: environment.plugin.settingsRef.settings,
sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut,
stateStore,
},
{
@ -788,3 +1000,86 @@ function createSurfacesAndPresenter(
function collaborationModeLabel(stateStore: ChatStateStore): string {
return formatCollaborationModeLabel(stateStore.getState().runtime.selectedCollaborationMode);
}
function createSessionStatus(stateStore: ChatStateStore, localItemIds: LocalChatItemIdFactory): ChatPanelSessionStatus {
return {
set: (statusText, phase) => {
dispatch(stateStore, { type: "connection/status-set", statusText, ...(phase ? { phase } : {}) });
},
addSystemMessage: (text) => {
dispatch(stateStore, { type: "message-stream/system-item-added", item: createSystemItem(localItemIds.next("system"), text) });
},
addStructuredSystemMessage: (text, details) => {
dispatch(stateStore, {
type: "message-stream/system-item-added",
item: createStructuredSystemItem(localItemIds.next("system"), text, details),
});
},
};
}
function dispatch(stateStore: ChatStateStore, action: ChatAction): void {
stateStore.dispatch(action);
}
function notifyActiveThreadIdentityChanged(host: ChatPanelSessionGraphHost): void {
refreshTabHeader(host);
host.environment.obsidian.requestWorkspaceLayoutSave();
}
function refreshTabHeader(host: ChatPanelSessionGraphHost): void {
host.environment.view.refreshTabHeader();
}
function refreshLiveState(host: ChatPanelSessionGraphHost): void {
host.environment.plugin.workspace.refreshThreadsViewLiveState();
}
function deferLiveStateRefresh(host: ChatPanelSessionGraphHost): void {
host.viewWindow().setTimeout(() => {
refreshLiveState(host);
}, 0);
}
function statusSummaryLines(host: ChatPanelSessionGraphHost): string[] {
const state = host.stateStore.getState();
return buildStatusSummaryLines({
activeThreadId: state.activeThread.id,
snapshot: runtimeSnapshot(host),
nowMs: Date.now(),
});
}
function modelStatusLines(host: ChatPanelSessionGraphHost): string[] {
const state = host.stateStore.getState();
return buildModelStatusLines({
runtimeConfig: state.connection.runtimeConfig,
requestedModel: state.runtime.requestedModel,
snapshot: runtimeSnapshot(host),
collaborationModeLabel: collaborationModeLabel(host.stateStore),
});
}
function effortStatusLines(host: ChatPanelSessionGraphHost): string[] {
const state = host.stateStore.getState();
return buildEffortStatusLines({
runtimeConfig: state.connection.runtimeConfig,
requestedReasoningEffort: state.runtime.requestedReasoningEffort,
snapshot: runtimeSnapshot(host),
});
}
function connectionDiagnosticDetails(host: ChatPanelSessionGraphHost, connection: ConnectionManager): MessageStreamNoticeSection[] {
return connectionDiagnosticsModel({
state: host.stateStore.getState(),
connected: connection.isConnected(),
configuredCommand: host.environment.plugin.settingsRef.settings.codexPath,
}).map((section) => ({
title: section.title,
auditFacts: section.rows.map((row) => ({ key: row.label, value: row.value })),
}));
}
function runtimeSnapshot(host: ChatPanelSessionGraphHost): RuntimeSnapshot {
return runtimeSnapshotForChatState(host.stateStore.getState());
}

View file

@ -1,35 +1,20 @@
import type { AppServerClient } from "../../../app-server/connection/client";
import type { AppServerObservedQueryResult } from "../../../app-server/query/cache";
import { isStaleAppServerSharedQueryContextError } from "../../../app-server/query/shared-queries";
import { appServerQueryContextRawEquals, type AppServerQueryContext } from "../../../app-server/query/keys";
import type { ModelMetadata } from "../../../domain/catalog/metadata";
import type { Thread } from "../../../domain/threads/model";
import { getThreadTitle } from "../../../domain/threads/model";
import type { SharedServerMetadata } from "../../../domain/server/metadata";
import { ConnectionWorkTracker } from "../../../shared/lifecycle/connection-work";
import { shortThreadId } from "../../../utils";
import type { MessageStreamItem, MessageStreamNoticeSection } from "../domain/message-stream/items";
import { createStructuredSystemItem, createSystemItem } from "../domain/message-stream/factories/system-items";
import { createLocalChatItemIdFactory, type LocalChatItemIdFactory } from "../domain/local-id";
import {
effortStatusLines as buildEffortStatusLines,
modelStatusLines as buildModelStatusLines,
statusSummaryLines as buildStatusSummaryLines,
} from "../presentation/runtime/status";
import { createChatViewDeferredTasks } from "./lifecycle";
import { ChatResumeWorkTracker, type ChatViewDeferredTasks } from "../application/lifecycle";
import { connectionDiagnosticsModel } from "../application/connection/diagnostics-display";
import { openPanelTurnLifecycle, parseRestoredThreadState, type ChatPanelSnapshot } from "../panel/snapshot";
import { collaborationModeLabel as formatCollaborationModeLabel } from "../presentation/runtime/messages";
import { runtimeSnapshotForChatState, type RuntimeSnapshot } from "../application/runtime/snapshot";
import { chatTurnBusy, type ChatAction, type ChatState } from "../application/state/root-reducer";
import type { ChatState } from "../application/state/root-reducer";
import { renderChatPanelShell, unmountChatPanelShell } from "../panel/shell";
import { createChatStateStore, type ChatStateStore } from "../application/state/store";
import { createChatMessageScrollIntentState, type ChatMessageScrollIntentState } from "../panel/surface/message-stream-scroll";
import type { ChatSurfaceHandle } from "./surface-handle";
import type { ChatPanelEnvironment } from "./runtime";
import { createChatPanelSessionParts, type ChatPanelSessionParts, type ChatPanelSessionStatus } from "./session-parts";
import { createChatPanelSessionGraph, type ChatPanelSessionGraph } from "./session-graph";
function codexPanelDisplayTitle(activeThreadId: string | null, threads: readonly Thread[], fallbackTitle?: string | null): string {
if (!activeThreadId) return "Codex";
@ -41,14 +26,12 @@ function codexPanelDisplayTitle(activeThreadId: string | null, threads: readonly
export class ChatPanelSession implements ChatSurfaceHandle {
private readonly stateStore: ChatStateStore = createChatStateStore();
private readonly parts: ChatPanelSessionParts;
private readonly graph: ChatPanelSessionGraph;
private readonly deferredTasks: ChatViewDeferredTasks;
private readonly connectionWork = new ConnectionWorkTracker();
private readonly resumeWork = new ChatResumeWorkTracker();
private readonly messageScrollIntent: ChatMessageScrollIntentState = createChatMessageScrollIntentState();
private readonly localItemIds: LocalChatItemIdFactory = createLocalChatItemIdFactory();
private readonly appServerStateUnsubscribers: (() => void)[] = [];
private observedAppServerContext: AppServerQueryContext;
private opened = false;
private closing = false;
@ -56,7 +39,7 @@ export class ChatPanelSession implements ChatSurfaceHandle {
constructor(private readonly environment: ChatPanelEnvironment) {
this.observedAppServerContext = this.currentAppServerContext();
this.deferredTasks = createChatViewDeferredTasks(() => this.viewWindow());
this.parts = this.createSessionParts();
this.graph = this.createSessionGraph();
}
displayTitle(): string {
@ -78,14 +61,14 @@ export class ChatPanelSession implements ChatSurfaceHandle {
applyViewState(state: unknown): void {
const restoredThread = parseRestoredThreadState(state);
if (restoredThread) {
this.parts.thread.restoration.restore(restoredThread);
this.graph.thread.restoration.restore(restoredThread);
this.scheduleRestoredThreadHydration();
return;
}
this.invalidateResumeWork();
this.parts.thread.restoration.clear();
this.parts.thread.restoration.clearHydration();
this.graph.actions.invalidateResumeWork();
this.graph.thread.restoration.clear();
this.graph.thread.restoration.clearHydration();
this.scheduleWarmup();
}
@ -94,22 +77,22 @@ export class ChatPanelSession implements ChatSurfaceHandle {
if (!appServerQueryContextRawEquals(this.observedAppServerContext, nextContext)) {
this.observedAppServerContext = nextContext;
this.connectionWork.invalidate();
this.invalidateResumeWork();
this.parts.connection.manager.resetConnection();
this.applyCachedAppServerState();
this.graph.actions.invalidateResumeWork();
this.graph.connection.manager.resetConnection();
this.graph.runtime.applyCachedAppServerState();
}
this.mountOrRepairShell();
}
refreshSharedThreadList(): Promise<void> {
return this.loadSharedThreadList();
return this.graph.actions.refreshSharedThreadList();
}
async runWithAppServerClient<T>(operation: (client: AppServerClient) => Promise<T>): Promise<T> {
const client = this.parts.connection.manager.currentClient();
const client = this.graph.connection.manager.currentClient();
if (!client) throw new Error("Codex app-server is not connected.");
const result = await operation(client);
if (this.parts.connection.manager.currentClient() !== client) {
if (this.graph.connection.manager.currentClient() !== client) {
throw new Error("Codex app-server connection changed while loading shared data.");
}
return result;
@ -123,44 +106,44 @@ export class ChatPanelSession implements ChatSurfaceHandle {
pendingApprovals: this.state.requests.approvals.length,
pendingUserInputs: this.state.requests.pendingUserInputs.length,
hasComposerDraft: this.state.composer.draft.trim().length > 0,
connected: this.parts.connection.manager.isConnected(),
connected: this.graph.connection.manager.isConnected(),
};
}
async openThread(threadId: string): Promise<void> {
await this.parts.thread.resume.resumeThread(threadId);
await this.graph.thread.resume.resumeThread(threadId);
this.focusComposer();
}
async focusThread(threadId: string | null = null): Promise<void> {
if (threadId && this.parts.thread.restoration.isPending(threadId)) {
if (threadId && this.graph.thread.restoration.isPending(threadId)) {
await this.ensureRestoredThreadLoaded();
}
this.focusComposer();
}
focusComposer(): void {
this.parts.composer.controller.focus();
this.graph.composer.controller.focus();
}
applyThreadArchived(threadId: string): void {
this.parts.thread.identity.applyThreadArchived(threadId);
this.graph.thread.identity.applyThreadArchived(threadId);
}
applyThreadRenamed(threadId: string, name: string | null): void {
this.parts.thread.identity.applyThreadRenamed(threadId, name);
this.graph.thread.identity.applyThreadRenamed(threadId, name);
}
open(): void {
this.opened = true;
this.closing = false;
this.parts.composer.controller.registerNoteIndexInvalidation((eventRef) => {
this.graph.composer.controller.registerNoteIndexInvalidation((eventRef) => {
this.environment.obsidian.registerEvent(eventRef);
});
this.environment.obsidian.registerPointerDown((event) => {
this.closeToolbarPanelOnOutsidePointer(event);
});
this.subscribeAppServerState();
this.graph.runtime.subscribeAppServerState();
this.mountOrRepairShell();
this.scheduleWarmup();
this.scheduleRestoredThreadHydration();
@ -170,108 +153,34 @@ export class ChatPanelSession implements ChatSurfaceHandle {
this.opened = false;
this.closing = true;
this.connectionWork.invalidate();
this.invalidateResumeWork();
this.graph.actions.invalidateResumeWork();
this.deferredTasks.clearAll();
this.unsubscribeAppServerState();
this.graph.runtime.unsubscribeAppServerState();
const panelRoot = this.environment.view.panelRoot();
this.parts.render.messageStreamPresenter.dispose();
this.parts.composer.controller.dispose();
this.graph.render.messageStreamPresenter.dispose();
this.graph.composer.controller.dispose();
unmountChatPanelShell(panelRoot);
this.parts.connection.manager.disconnect();
this.refreshLiveState();
this.deferLiveStateRefresh();
this.graph.connection.manager.disconnect();
this.graph.runtime.refreshLiveState();
this.graph.runtime.deferLiveStateRefresh();
}
setComposerText(text: string): void {
this.parts.composer.controller.setDraft(text, { focus: true });
this.graph.composer.controller.setDraft(text, { focus: true });
}
async connect(): Promise<void> {
await this.parts.connection.controller.ensureConnected();
await this.graph.connection.controller.ensureConnected();
}
async startNewThread(): Promise<void> {
if (chatTurnBusy(this.state)) return;
this.parts.thread.identity.clearActiveThreadContext();
this.dispatch({ type: "ui/panel-set", panel: null });
this.dispatch({ type: "connection/status-set", statusText: "New chat." });
this.focusComposer();
await this.graph.actions.startNewThread();
}
private get state(): ChatState {
return this.stateStore.getState();
}
private dispatch(action: ChatAction): void {
this.stateStore.dispatch(action);
}
private receiveObservedThreads(threads: readonly Thread[]): void {
this.parts.serverActions.threads.applyThreadList(threads);
this.refreshTabHeader();
}
private receiveObservedThreadResult(result: AppServerObservedQueryResult<readonly Thread[]>): void {
if (result.data) this.receiveObservedThreads(result.data);
}
private receiveObservedAppServerMetadata(metadata: SharedServerMetadata): void {
this.parts.serverActions.metadata.applyAppServerMetadata(metadata);
}
private receiveObservedAppServerMetadataResult(result: AppServerObservedQueryResult<SharedServerMetadata>): void {
if (result.data) this.receiveObservedAppServerMetadata(result.data);
}
private receiveObservedModels(models: readonly ModelMetadata[]): void {
this.dispatch({ type: "connection/metadata-applied", availableModels: models });
}
private receiveObservedModelsResult(result: AppServerObservedQueryResult<readonly ModelMetadata[]>): void {
if (result.data) this.receiveObservedModels(result.data);
}
private applyCachedAppServerState(): void {
const threads = this.environment.plugin.threadCatalog.snapshot();
if (threads) this.parts.serverActions.threads.applyThreadList(threads);
const metadata = this.environment.plugin.appServerData.appServerMetadataSnapshot();
if (metadata) this.parts.serverActions.metadata.applyAppServerMetadata(metadata);
const models = this.environment.plugin.appServerData.modelsSnapshot();
if (models) this.receiveObservedModels(models);
}
private subscribeAppServerState(): void {
this.unsubscribeAppServerState();
this.applyCachedAppServerState();
this.appServerStateUnsubscribers.push(
this.environment.plugin.threadCatalog.observe(
(result) => {
this.receiveObservedThreadResult(result);
},
{ emitCurrent: false },
),
this.environment.plugin.appServerData.observeAppServerMetadataResult(
(result) => {
this.receiveObservedAppServerMetadataResult(result);
},
{ emitCurrent: false },
),
this.environment.plugin.appServerData.observeModelsResult(
(result) => {
this.receiveObservedModelsResult(result);
},
{ emitCurrent: false },
),
);
}
private unsubscribeAppServerState(): void {
while (this.appServerStateUnsubscribers.length > 0) {
this.appServerStateUnsubscribers.pop()?.();
}
}
private mountOrRepairShell(): void {
const root = this.environment.view.panelRoot();
if (!root) return;
@ -280,15 +189,15 @@ export class ChatPanelSession implements ChatSurfaceHandle {
showToolbar: this.environment.plugin.settingsRef.settings.showToolbar,
parts: {
toolbar: {
surface: this.parts.surface.toolbar,
actions: this.parts.toolbar.actions,
surface: this.graph.surface.toolbar,
actions: this.graph.toolbar.actions,
},
goal: this.parts.surface.goal,
messageStream: this.parts.render.messageStreamPresenter,
goal: this.graph.surface.goal,
messageStream: this.graph.render.messageStreamPresenter,
composer: {
controller: this.parts.composer.controller,
controller: this.graph.composer.controller,
actions: {
submit: () => void this.parts.composer.submission.submit(),
submit: () => void this.graph.composer.submission.submit(),
},
},
},
@ -296,49 +205,15 @@ export class ChatPanelSession implements ChatSurfaceHandle {
}
private scheduleWarmup(): void {
const shouldWarmup = (): boolean => this.opened && !this.parts.connection.manager.isConnected();
const shouldWarmup = (): boolean => this.opened && !this.graph.connection.manager.isConnected();
if (!shouldWarmup()) return;
this.deferredTasks.scheduleAppServerWarmup(() => {
if (!shouldWarmup() || this.closing) return;
void this.parts.connection.controller.ensureConnected();
void this.graph.connection.controller.ensureConnected();
});
}
private invalidateResumeWork(): void {
this.resumeWork.invalidate();
this.parts.thread.history.invalidate();
}
private async loadSharedThreadList(): Promise<void> {
try {
const threads = await this.environment.plugin.threadCatalog.refresh();
this.parts.serverActions.threads.applyThreadList(threads);
} catch (error) {
if (isStaleAppServerSharedQueryContextError(error)) return;
throw error;
}
}
private notifyActiveThreadIdentityChanged(): void {
this.refreshTabHeader();
this.environment.obsidian.requestWorkspaceLayoutSave();
}
private refreshTabHeader(): void {
this.environment.view.refreshTabHeader();
}
private refreshLiveState(): void {
this.environment.plugin.workspace.refreshThreadsViewLiveState();
}
private deferLiveStateRefresh(): void {
this.viewWindow().setTimeout(() => {
this.refreshLiveState();
}, 0);
}
private viewWindow(): Window {
return this.environment.view.viewWindow() ?? window;
}
@ -351,11 +226,11 @@ export class ChatPanelSession implements ChatSurfaceHandle {
}
private closeToolbarPanelOnOutsidePointer(event: PointerEvent): void {
this.parts.toolbar.panels.closeOnOutsidePointer({
this.graph.toolbar.panels.closeOnOutsidePointer({
target: event.target,
viewWindow: this.environment.view.viewWindow() as (Window & { Element: typeof Element }) | null,
contains: (element) => this.environment.view.containsElement(element),
renameEditing: this.parts.thread.rename.isEditing(),
renameEditing: this.graph.thread.rename.isEditing(),
});
}
@ -367,23 +242,19 @@ export class ChatPanelSession implements ChatSurfaceHandle {
}
private restoredThreadTitle(): string | null {
return this.parts.thread.restoration.title();
return this.graph.thread.restoration.title();
}
private ensureRestoredThreadLoaded(): Promise<boolean> {
return this.parts.thread.restoration.ensureLoaded((threadId) => this.parts.thread.resume.resumeThread(threadId));
return this.graph.thread.restoration.ensureLoaded((threadId) => this.graph.thread.resume.resumeThread(threadId));
}
private scheduleRestoredThreadHydration(): void {
this.parts.thread.restoration.scheduleHydration((threadId) => this.parts.thread.resume.resumeThread(threadId));
this.graph.thread.restoration.scheduleHydration((threadId) => this.graph.thread.resume.resumeThread(threadId));
}
private createSessionParts(): ChatPanelSessionParts {
return createChatPanelSessionParts(this.sessionPartsHost(), this.createSessionStatus());
}
private sessionPartsHost(): Parameters<typeof createChatPanelSessionParts>[0] {
return {
private createSessionGraph(): ChatPanelSessionGraph {
return createChatPanelSessionGraph({
environment: this.environment,
stateStore: this.stateStore,
deferredTasks: this.deferredTasks,
@ -392,93 +263,7 @@ export class ChatPanelSession implements ChatSurfaceHandle {
messageScrollIntent: this.messageScrollIntent,
getOpened: () => this.opened,
getClosing: () => this.closing,
invalidateResumeWork: () => {
this.invalidateResumeWork();
},
loadSharedThreadList: () => this.loadSharedThreadList(),
notifyActiveThreadIdentityChanged: () => {
this.notifyActiveThreadIdentityChanged();
},
refreshTabHeader: () => {
this.refreshTabHeader();
},
refreshLiveState: () => {
this.refreshLiveState();
},
deferLiveStateRefresh: () => {
this.deferLiveStateRefresh();
},
startNewThread: () => this.startNewThread(),
statusSummaryLines: () => this.statusSummaryLines(),
modelStatusLines: () => this.modelStatusLines(),
effortStatusLines: () => this.effortStatusLines(),
connectionDiagnosticDetails: () => this.connectionDiagnosticDetails(),
};
}
private createSessionStatus(): ChatPanelSessionStatus {
return {
set: (statusText, phase) => {
this.dispatch({ type: "connection/status-set", statusText, ...(phase ? { phase } : {}) });
},
addSystemMessage: (text) => {
this.dispatch({ type: "message-stream/system-item-added", item: this.systemItem(text) });
},
addStructuredSystemMessage: (text, details) => {
this.dispatch({ type: "message-stream/system-item-added", item: this.structuredSystemItem(text, details) });
},
};
}
private statusSummaryLines(): string[] {
return buildStatusSummaryLines({
activeThreadId: this.state.activeThread.id,
snapshot: this.runtimeSnapshot(),
nowMs: Date.now(),
viewWindow: () => this.viewWindow(),
});
}
private modelStatusLines(): string[] {
return buildModelStatusLines({
runtimeConfig: this.state.connection.runtimeConfig,
requestedModel: this.state.runtime.requestedModel,
snapshot: this.runtimeSnapshot(),
collaborationModeLabel: this.collaborationModeLabel(),
});
}
private effortStatusLines(): string[] {
return buildEffortStatusLines({
runtimeConfig: this.state.connection.runtimeConfig,
requestedReasoningEffort: this.state.runtime.requestedReasoningEffort,
snapshot: this.runtimeSnapshot(),
});
}
private connectionDiagnosticDetails(): MessageStreamNoticeSection[] {
return connectionDiagnosticsModel({
state: this.state,
connected: this.parts.connection.manager.isConnected(),
configuredCommand: this.environment.plugin.settingsRef.settings.codexPath,
}).map((section) => ({
title: section.title,
auditFacts: section.rows.map((row) => ({ key: row.label, value: row.value })),
}));
}
private collaborationModeLabel(): string {
return formatCollaborationModeLabel(this.state.runtime.selectedCollaborationMode);
}
private runtimeSnapshot(): RuntimeSnapshot {
return runtimeSnapshotForChatState(this.state);
}
private systemItem(text: string): MessageStreamItem {
return createSystemItem(this.localItemIds.next("system"), text);
}
private structuredSystemItem(text: string, details: MessageStreamNoticeSection[]): MessageStreamItem {
return createStructuredSystemItem(this.localItemIds.next("system"), text, details);
}
}

View file

@ -1,4 +1,3 @@
import type { CodexPanelSettings } from "../../../../settings/model";
import type { ChatConnectionController } from "../../application/connection/connection-controller";
import type { ChatInboundController } from "../../app-server/inbound/controller";
import type { GoalActions } from "../../application/threads/goal-actions";
@ -31,7 +30,7 @@ export interface ChatPanelGoalSurface {
}
export interface ChatPanelGoalSurfaceHost {
settings: CodexPanelSettings;
sendShortcut: () => SendShortcut;
stateStore: ChatStateStore;
}
@ -60,7 +59,7 @@ export function createChatPanelGoalSurface(host: ChatPanelGoalSurfaceHost, deps:
return {
settings: {
sendShortcut: () => host.settings.sendShortcut,
sendShortcut: host.sendShortcut,
},
actions: {
goal: {

View file

@ -34,6 +34,12 @@ export function registerSelectionRewriteCommand(plugin: SelectionRewriteCommandH
new Notice("Select text to rewrite first.");
return;
}
const viewDocument = view.containerEl.doc;
const viewWindow = viewDocument.defaultView;
if (!viewWindow) {
new Notice("Could not open rewrite popover for this note.");
return;
}
const rewriteState: SelectionRewriteState = {
filePath: view.file.path,
@ -61,6 +67,8 @@ export function registerSelectionRewriteCommand(plugin: SelectionRewriteCommandH
runtimeSettings: plugin.settings,
sendShortcut: plugin.settings.sendShortcut,
state: rewriteState,
viewDocument,
viewWindow,
});
popover.open();
activePopovers.add(popover);

View file

@ -37,6 +37,8 @@ export interface SelectionRewritePopoverOptions {
runtimeSettings: SelectionRewriteRuntimeSettings;
sendShortcut: SendShortcut;
state: SelectionRewriteState;
viewDocument: Document;
viewWindow: Window;
}
type Cleanup = () => void;
@ -62,24 +64,24 @@ export class SelectionRewritePopover {
const elements = this.createElements();
this.elements = elements;
this.addDomListener(activeWindow, "resize", () => {
this.addDomListener(this.options.viewWindow, "resize", () => {
this.position();
});
this.addDomListener(
activeWindow,
this.options.viewWindow,
"scroll",
() => {
this.position();
},
true,
);
this.addDomListener(activeDocument, "keydown", (event) => {
this.addDomListener(this.options.viewDocument, "keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault();
this.cancel();
}
});
this.addDomListener(activeDocument, "pointerdown", (event) => {
this.addDomListener(this.options.viewDocument, "pointerdown", (event) => {
if (!this.elements?.root.contains(event.target as Node | null)) this.cancel();
});
@ -127,7 +129,7 @@ export class SelectionRewritePopover {
}
private createElements(): SelectionRewriteElements {
const root = activeDocument.body.createDiv({ cls: "codex-panel-selection-rewrite" });
const root = this.options.viewDocument.body.createDiv({ cls: "codex-panel-selection-rewrite" });
root.setAttr("role", "dialog");
const elements: SelectionRewriteElements = { root, instruction: null, applyButton: null };
this.renderView(elements);
@ -172,7 +174,7 @@ export class SelectionRewritePopover {
private position(): void {
if (!this.elements) return;
if (!positionSelectionRewritePopover(this.elements.root, this.options.editor, POPOVER_MARGIN)) this.close();
if (!positionSelectionRewritePopover(this.elements.root, this.options.editor, this.options.viewWindow, POPOVER_MARGIN)) this.close();
}
private renderView(elements: SelectionRewriteElements | null = this.elements): void {

View file

@ -1,15 +1,15 @@
import type { Editor } from "obsidian";
export function positionSelectionRewritePopover(root: HTMLElement, editor: Editor, margin: number): boolean {
export function positionSelectionRewritePopover(root: HTMLElement, editor: Editor, viewWindow: Window, margin: number): boolean {
if (!root.isConnected) return false;
const view = editorViewFromEditor(editor);
if (view?.dom instanceof HTMLElement && !view.dom.isConnected) return false;
const anchor = selectionRect() ?? editorCursorRect(editor) ?? root.ownerDocument.body.getBoundingClientRect();
const anchor = selectionRect(viewWindow) ?? editorCursorRect(editor) ?? root.ownerDocument.body.getBoundingClientRect();
const size = root.getBoundingClientRect();
const viewportWidth = activeWindow.innerWidth;
const viewportHeight = activeWindow.innerHeight;
const viewportWidth = viewWindow.innerWidth;
const viewportHeight = viewWindow.innerHeight;
const left = clamp(anchor.left, margin, viewportWidth - size.width - margin);
const belowTop = anchor.bottom + margin;
const aboveTop = anchor.top - size.height - margin;
@ -20,8 +20,8 @@ export function positionSelectionRewritePopover(root: HTMLElement, editor: Edito
return true;
}
function selectionRect(): DOMRect | null {
const selection = activeWindow.getSelection();
function selectionRect(viewWindow: Window): DOMRect | null {
const selection = viewWindow.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
const rect = selection.getRangeAt(0).getBoundingClientRect();
return rect.width > 0 || rect.height > 0 ? rect : null;

View file

@ -2,14 +2,11 @@ import { Notice, Platform, SuggestModal, type App } from "obsidian";
import { getThreadTitle } from "../../domain/threads/model";
import type { Thread } from "../../domain/threads/model";
import type { CodexPanelSettings } from "../../settings/model";
import { shortThreadId } from "../../utils";
import type { ActiveThreadCatalogReader } from "../../workspace/active-thread-catalog";
export interface ThreadPickerHost {
readonly app: App;
readonly settings: CodexPanelSettings;
readonly vaultPath: string;
readonly threadCatalog: ActiveThreadCatalogReader;
openThreadInCurrentView(threadId: string): Promise<void>;
openThreadInAvailableView(threadId: string): Promise<void>;

View file

@ -1,15 +1,13 @@
import { Notice } from "obsidian";
import type { AppServerClient } from "../../app-server/connection/client";
import type { AppServerClientAccess } from "../../app-server/connection/client-access";
import type { AppServerObservedQueryResult } from "../../app-server/query/cache";
import { isStaleAppServerSharedQueryContextError } from "../../app-server/query/shared-queries";
import { ConnectionManager, type ConnectionManagerHandlers, StaleConnectionError } from "../../app-server/connection/connection-manager";
import type { ReasoningEffort } from "../../domain/catalog/metadata";
import type { Thread } from "../../domain/threads/model";
import type { CodexPanelSettings } from "../../settings/model";
import type { OpenCodexPanelSnapshot } from "../../workspace/open-panel-snapshot";
import type { OpenCodexPanelSnapshot } from "../../workspace/panel-coordinator";
import type { ActiveThreadCatalogReader, ActiveThreadCatalogThreadEvents } from "../../workspace/active-thread-catalog";
import { ConnectionWorkTracker } from "../../shared/lifecycle/connection-work";
import type { ArchiveExportAdapter } from "../../domain/threads/archive-markdown";
import type { ArchiveExportAdapter, ArchiveExportSettings } from "../../domain/threads/archive-markdown";
import { createThreadOperations, type ThreadOperations } from "../threads/thread-operations";
import { createThreadTitleService, type ThreadTitleService } from "../threads/thread-title-service";
import { renderThreadsView, unmountThreadsView } from "./renderer";
@ -32,8 +30,9 @@ import {
} from "./view-lifecycle";
export interface CodexThreadsHost {
readonly settings: CodexPanelSettings;
readonly settings: CodexThreadsSettings;
readonly vaultPath: string;
readonly clientAccess: AppServerClientAccess;
readonly threadCatalog: ThreadsThreadCatalog;
openNewPanel(): Promise<unknown>;
openThreadInAvailableView(threadId: string): Promise<void>;
@ -42,6 +41,13 @@ export interface CodexThreadsHost {
type ThreadsThreadCatalog = ActiveThreadCatalogReader & ActiveThreadCatalogThreadEvents;
interface CodexThreadsSettings extends ArchiveExportSettings {
codexPath: string;
threadNamingModel: string | null;
threadNamingEffort: ReasoningEffort | null;
archiveExportEnabled: boolean;
}
export interface CodexThreadsSessionEnvironment {
root: HTMLElement;
host: CodexThreadsHost;
@ -58,12 +64,9 @@ type ThreadsViewStatus =
| { kind: "error"; message: string };
export class CodexThreadsSession {
private readonly connection: ConnectionManager;
private readonly operations: ThreadOperations;
private readonly titleService: ThreadTitleService;
private readonly deferredTasks: ThreadsViewDeferredTasks;
private readonly connectionWork = new ConnectionWorkTracker();
private client: AppServerClient | null = null;
private refreshLifecycle: ThreadsViewRefreshLifecycleState = { kind: "idle" };
private status: ThreadsViewStatus = { kind: "idle" };
private threads: readonly Thread[] = [];
@ -73,14 +76,11 @@ export class CodexThreadsSession {
constructor(private readonly environment: CodexThreadsSessionEnvironment) {
this.deferredTasks = createThreadsViewDeferredTasks(() => this.viewWindow());
this.connection = new ConnectionManager(() => this.host.settings.codexPath, this.host.vaultPath);
this.operations = createThreadOperations({
connection: {
ensureConnected: () => this.ensureConnected(),
currentClient: () => this.client,
},
settings: {
current: () => this.host.settings,
clientAccess: this.host.clientAccess,
archiveExport: {
settings: () => this.host.settings,
enabled: () => this.host.settings.archiveExportEnabled,
vaultPath: this.host.vaultPath,
},
archiveAdapter: () => this.environment.archiveAdapter(),
@ -90,36 +90,14 @@ export class CodexThreadsSession {
},
});
this.titleService = createThreadTitleService({
settings: {
current: () => this.host.settings,
vaultPath: this.host.vaultPath,
},
currentClient: () => this.client,
codexPath: () => this.host.settings.codexPath,
vaultPath: this.host.vaultPath,
threadNamingModel: () => this.host.settings.threadNamingModel,
threadNamingEffort: () => this.host.settings.threadNamingEffort,
clientAccess: this.host.clientAccess,
});
}
private connectionHandlers(): ConnectionManagerHandlers {
return {
onNotification: () => {
this.scheduleRefresh();
},
onServerRequest: (request) => {
this.connection.currentClient()?.rejectServerRequest(request.id, -32601, "Codex Threads view does not handle server requests.");
},
onLog: (message) => {
this.status = { kind: "log", message };
this.render();
},
onExit: () => {
this.client = null;
this.connectionWork.invalidate();
this.refreshLifecycle = transitionThreadsViewRefreshLifecycle(this.refreshLifecycle, { type: "invalidated" });
this.status = { kind: "error", message: "Codex app-server stopped." };
this.render();
},
};
}
open(): void {
this.environment.registerPointerDown((event) => {
this.cancelArchiveConfirmOnOutsidePointer(event);
@ -136,13 +114,10 @@ export class CodexThreadsSession {
}
close(): void {
this.connectionWork.invalidate();
this.refreshLifecycle = transitionThreadsViewRefreshLifecycle(this.refreshLifecycle, { type: "invalidated" });
this.deferredTasks.clearAll();
this.unsubscribeThreads?.();
this.unsubscribeThreads = null;
this.connection.disconnect();
this.client = null;
unmountThreadsView(this.environment.root);
}
@ -151,14 +126,11 @@ export class CodexThreadsSession {
this.status = this.threads.length === 0 ? { kind: "loading", message: "Loading threads..." } : { kind: "idle" };
this.render();
try {
await this.ensureConnected();
if (this.isStaleRefresh(refresh) || !this.client) return;
const threads = await this.host.threadCatalog.refresh();
if (this.isStaleRefresh(refresh)) return;
this.threads = threads;
this.status = threads.length === 0 ? { kind: "empty", message: "No threads" } : { kind: "idle" };
} catch (error) {
if (error instanceof StaleConnectionError) return;
if (isStaleAppServerSharedQueryContextError(error)) return;
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
} finally {
@ -212,29 +184,6 @@ export class CodexThreadsSession {
return this.refreshLifecycle !== refresh;
}
private async ensureConnected(): Promise<void> {
const connecting = this.connectionWork.active();
if (connecting?.promise) return connecting.promise;
if (this.connection.isConnected()) {
this.client = this.connection.currentClient();
return;
}
const connection = this.connectionWork.begin();
const promise = this.connection
.connect(this.connectionHandlers())
.then(() => {
if (this.connectionWork.isStale(connection)) throw new StaleConnectionError();
this.client = this.connection.currentClient();
})
.finally(() => {
this.connectionWork.finish(connection, promise);
});
connection.promise = promise;
return promise;
}
private render(): void {
renderThreadsView(
this.environment.root,
@ -278,12 +227,6 @@ export class CodexThreadsSession {
});
}
private scheduleRefresh(): void {
this.deferredTasks.scheduleRefresh(() => {
void this.refresh();
});
}
private async openThread(threadId: string): Promise<void> {
this.archiveConfirmThreadId = null;
await this.host.openThreadInAvailableView(threadId);
@ -313,7 +256,6 @@ export class CodexThreadsSession {
const editingState = this.renameStates.get(threadId);
if (!editingState || editingState.kind === "generating") return;
try {
await this.ensureConnected();
if (this.renameStates.get(threadId) !== editingState) return;
const result = await this.operations.renameThread(threadId, value);
if (!result) {
@ -334,7 +276,6 @@ export class CodexThreadsSession {
this.render();
try {
await this.ensureConnected();
if (this.renameStates.get(threadId) !== generatingState) return;
const title = await this.titleService.generateTitle(threadId);
const renamedState = generatedThreadAutoNameState(this.renameStates.get(threadId), generatingState, title);
@ -367,7 +308,6 @@ export class CodexThreadsSession {
private async archiveThread(threadId: string, saveMarkdown: boolean): Promise<void> {
try {
await this.ensureConnected();
const result = await this.operations.archiveThread(threadId, {
saveMarkdown,
closeOpenPanels: true,

View file

@ -1,4 +1,4 @@
import type { OpenCodexPanelSnapshot } from "../../workspace/open-panel-snapshot";
import type { OpenCodexPanelSnapshot } from "../../workspace/panel-coordinator";
import type { Thread } from "../../domain/threads/model";
import { explicitThreadName, getThreadTitle } from "../../domain/threads/model";

View file

@ -1,16 +1,13 @@
import type { AppServerClient } from "../../app-server/connection/client";
import type { AppServerClientAccess } from "../../app-server/connection/client-access";
import { archiveThreadOnAppServer, type ArchiveThreadResult } from "../../app-server/services/thread-archive";
import type { ArchiveExportAdapter } from "../../domain/threads/archive-markdown";
import type { ArchiveExportAdapter, ArchiveExportSettings } from "../../domain/threads/archive-markdown";
import { normalizeExplicitThreadName } from "../../domain/threads/model";
import type { CodexPanelSettings } from "../../settings/model";
export interface ThreadOperationsHost {
connection: {
ensureConnected(): Promise<void>;
currentClient(): AppServerClient | null;
};
settings: {
current(): CodexPanelSettings;
clientAccess: AppServerClientAccess;
archiveExport: {
settings(): ArchiveExportSettings;
enabled(): boolean;
vaultPath: string;
};
archiveAdapter(): ArchiveExportAdapter;
@ -51,12 +48,7 @@ async function renameThread(
const name = normalizeExplicitThreadName(value);
if (!name) return false;
await host.connection.ensureConnected();
const client = host.connection.currentClient();
if (!client) return false;
await client.setThreadName(threadId, name);
if (host.connection.currentClient() !== client) return false;
await host.clientAccess.withClient((client) => client.setThreadName(threadId, name));
if (options.shouldPublish?.() ?? true) {
host.catalog.recordThreadRenamed(threadId, name);
}
@ -68,18 +60,15 @@ async function archiveThread(
threadId: string,
options: ArchiveThreadOptions = {},
): Promise<ArchiveThreadResult | null> {
await host.connection.ensureConnected();
const client = host.connection.currentClient();
if (!client) return null;
const settings = host.settings.current();
const result = await archiveThreadOnAppServer(client, threadId, {
settings,
vaultPath: host.settings.vaultPath,
archiveAdapter: () => host.archiveAdapter(),
saveMarkdown: options.saveMarkdown ?? settings.archiveExportEnabled,
});
if (host.connection.currentClient() !== client) return null;
const archiveSettings = host.archiveExport.settings();
const result = await host.clientAccess.withClient((client) =>
archiveThreadOnAppServer(client, threadId, {
settings: archiveSettings,
vaultPath: host.archiveExport.vaultPath,
archiveAdapter: () => host.archiveAdapter(),
saveMarkdown: options.saveMarkdown ?? host.archiveExport.enabled(),
}),
);
if (result.exportedPath) {
host.notice(`Saved archived thread to ${result.exportedPath}.`);
}

View file

@ -1,4 +1,4 @@
import type { AppServerClient } from "../../app-server/connection/client";
import type { AppServerClientAccess } from "../../app-server/connection/client-access";
import { generateThreadTitleWithCodex } from "../../app-server/services/thread-title-generation";
import { readCompletedConversationSummariesPage } from "../../app-server/threads";
import type { ThreadConversationSummary } from "../../domain/threads/transcript";
@ -8,14 +8,14 @@ import {
threadTitleContextFromConversationSummary,
type ThreadTitleContext,
} from "../../domain/threads/title-generation-model";
import type { CodexPanelSettings } from "../../settings/model";
import type { ReasoningEffort } from "../../domain/catalog/metadata";
export interface ThreadTitleServiceHost {
settings: {
current(): CodexPanelSettings;
vaultPath: string;
};
currentClient(): AppServerClient | null;
codexPath: () => string;
vaultPath: string;
threadNamingModel: () => string | null;
threadNamingEffort: () => ReasoningEffort | null;
clientAccess: AppServerClientAccess;
visibleContext?(threadId: string): ThreadTitleContext | null;
visibleCompletedTurnContext?(turnId: string): ThreadTitleContext | null;
generateThreadTitle?(context: ThreadTitleContext): Promise<string | null>;
@ -47,16 +47,19 @@ async function generateTitle(host: ThreadTitleServiceHost, threadId: string): Pr
}
async function resolveThreadTitleContext(host: ThreadTitleServiceHost, threadId: string): Promise<ThreadTitleContext | null> {
const client = host.currentClient();
const persistedContext = client
? await findThreadTitleContext({
threadId,
readTurns: (id, cursor, limit, sortDirection) => readCompletedConversationSummariesPage(client, id, cursor, limit, sortDirection),
})
: null;
const persistedContext = await persistedThreadTitleContext(host, threadId);
return persistedContext ?? host.visibleContext?.(threadId) ?? null;
}
async function persistedThreadTitleContext(host: ThreadTitleServiceHost, threadId: string): Promise<ThreadTitleContext | null> {
return host.clientAccess.withClient((client) =>
findThreadTitleContext({
threadId,
readTurns: (id, cursor, limit, sortDirection) => readCompletedConversationSummariesPage(client, id, cursor, limit, sortDirection),
}),
);
}
function completedTurnContext(
host: ThreadTitleServiceHost,
turnId: string,
@ -69,9 +72,8 @@ function completedTurnContext(
async function generateTitleFromContext(host: ThreadTitleServiceHost, context: ThreadTitleContext): Promise<string | null> {
if (host.generateThreadTitle) return host.generateThreadTitle(context);
const settings = host.settings.current();
return generateThreadTitleWithCodex(settings.codexPath, host.settings.vaultPath, context, {
threadNamingModel: settings.threadNamingModel,
threadNamingEffort: settings.threadNamingEffort,
return generateThreadTitleWithCodex(host.codexPath(), host.vaultPath, context, {
threadNamingModel: host.threadNamingModel(),
threadNamingEffort: host.threadNamingEffort(),
});
}

View file

@ -4,6 +4,7 @@ import { VIEW_TYPE_CODEX_THREADS, VIEW_TYPE_CODEX_TURN_DIFF } from "./constants"
import { AppServerQueryCache } from "./app-server/query/cache";
import { AppServerSharedQueries } from "./app-server/query/shared-queries";
import type { AppServerClient } from "./app-server/connection/client";
import type { AppServerClientAccess } from "./app-server/connection/client-access";
import { withShortLivedAppServerClient } from "./app-server/connection/short-lived-client";
import { appServerQueryContextIsComplete, type AppServerQueryContext } from "./app-server/query/keys";
import type { ChatTurnDiffViewState } from "./features/chat/domain/turn-diff";
@ -28,7 +29,7 @@ export interface CodexPanelRuntimeOptions {
saveSettings(): Promise<void>;
}
export class CodexPanelRuntime {
export class CodexPanelRuntime implements AppServerClientAccess {
private readonly appServerQueries = new AppServerQueryCache({
clientRunner: {
runWithClient: (context, operation, options) => this.runWithAppServerClient(context, operation, options),
@ -126,6 +127,7 @@ export class CodexPanelRuntime {
return {
settings: this.options.settingsRef.settings,
vaultPath: this.options.settingsRef.vaultPath,
clientAccess: this,
threadCatalog: this.threadCatalog,
openNewPanel: () => this.panels.openNewPanel(),
openThreadInAvailableView: (threadId) => this.panels.openThreadInAvailableView(threadId),
@ -137,6 +139,7 @@ export class CodexPanelRuntime {
return {
settings: this.options.settingsRef.settings,
vaultPath: this.options.settingsRef.vaultPath,
clientAccess: this,
saveSettings: () => this.options.saveSettings(),
refreshOpenViews: () => {
this.refreshOpenViews();
@ -149,14 +152,16 @@ export class CodexPanelRuntime {
private threadPickerHost(): ThreadPickerHost {
return {
app: this.options.app,
settings: this.options.settingsRef.settings,
vaultPath: this.options.settingsRef.vaultPath,
threadCatalog: this.threadCatalog,
openThreadInCurrentView: (threadId) => this.panels.openThreadInCurrentView(threadId),
openThreadInAvailableView: (threadId) => this.panels.openThreadInAvailableView(threadId),
};
}
withClient<T>(operation: (client: AppServerClient) => Promise<T>, options: { unhandledServerRequestMessage?: string } = {}): Promise<T> {
return this.runWithAppServerClient(this.appServerQueryContext(), operation, options);
}
private async openTurnDiff(state: ChatTurnDiffViewState): Promise<void> {
const existing = this.options.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_TURN_DIFF).at(0);
const leaf = existing ?? this.options.app.workspace.getLeaf("tab");

View file

@ -1,7 +1,6 @@
import type { AppServerClient } from "../app-server/connection/client";
import type { AppServerObservedQueryResult } from "../app-server/query/cache";
import { isStaleAppServerSharedQueryContextError } from "../app-server/query/shared-queries";
import { withShortLivedAppServerClient } from "../app-server/connection/short-lived-client";
import { setHookItemEnabled, trustHookItem } from "../app-server/catalog";
import { restoreArchivedThread as restoreArchivedThreadOnAppServer } from "../app-server/threads";
import type { HookItem, ModelMetadata, ReasoningEffort } from "../domain/catalog/metadata";
@ -440,7 +439,7 @@ export class SettingsDynamicDataController {
}
private async withSettingsConnection<T>(operation: (client: AppServerClient) => Promise<T>): Promise<T> {
return withShortLivedAppServerClient(this.host.settings.codexPath, this.host.vaultPath, operation, {
return this.host.clientAccess.withClient(operation, {
unhandledServerRequestMessage: "Codex Panel settings does not handle server requests.",
});
}

View file

@ -1,10 +1,12 @@
import type { AppServerSharedQueries } from "../app-server/query/shared-queries";
import type { AppServerClientAccess } from "../app-server/connection/client-access";
import type { CodexPanelSettings } from "./model";
import type { ActiveThreadCatalogThreadRestores } from "../workspace/active-thread-catalog";
export interface SettingsDynamicDataHost {
settings: CodexPanelSettings;
vaultPath: string;
clientAccess: AppServerClientAccess;
appServerData: Pick<
AppServerSharedQueries,
"modelsSnapshot" | "observeModelsResult" | "fetchModels" | "refreshModels" | "notifyContextChanged"

View file

@ -1,7 +0,0 @@
import type { ChatPanelSnapshot } from "../features/chat/panel/snapshot";
export type { ChatPanelSnapshot } from "../features/chat/panel/snapshot";
export interface OpenCodexPanelSnapshot extends ChatPanelSnapshot {
lastFocused: boolean;
}

View file

@ -3,7 +3,7 @@ import type { App, WorkspaceLeaf } from "obsidian";
import { VIEW_TYPE_CODEX_PANEL } from "../constants";
import { CodexChatView } from "../features/chat/host/view";
import type { ChatWorkspacePanelSurface } from "../features/chat/host/surface-handle";
import type { ChatPanelSnapshot, OpenCodexPanelSnapshot } from "./open-panel-snapshot";
import type { ChatPanelSnapshot } from "../features/chat/panel/snapshot";
const BOOT_RESTORED_PANEL_LOAD_DELAY_MS = 1_000;
const BOOT_RESTORED_PANEL_LOAD_STAGGER_MS = 250;
@ -38,6 +38,10 @@ type ThreadPanelTarget =
type BootRestoredPanelLoadLifecycleState = { kind: "idle" } | { kind: "scheduled"; timers: Set<number> } | { kind: "cancelled" };
export interface OpenCodexPanelSnapshot extends ChatPanelSnapshot {
lastFocused: boolean;
}
export interface WorkspacePanelCoordinatorOptions {
app: App;
refreshThreadsViewLiveState: () => void;

View file

@ -38,6 +38,10 @@ class FakeTransport implements AppServerTransport {
this.running = false;
this.handlers.onExit(code, signal);
}
emitError(error: Error): void {
this.handlers.onError(error);
}
}
async function connectedClient(): Promise<{ client: AppServerClient; transport: FakeTransport }> {
@ -212,6 +216,65 @@ describe("AppServerClient", () => {
await expect(listing).rejects.toThrow("Codex app-server disconnected.");
});
it("does not notify external exit handlers for intentional disconnect exits", async () => {
let transport!: FakeTransport;
const onExit = vi.fn();
const client = new AppServerClient(
"/bin/codex",
"/vault",
{
onNotification: () => undefined,
onServerRequest: () => undefined,
onLog: () => undefined,
onExit,
},
500,
(handlers) => {
transport = new FakeTransport(handlers);
return transport;
},
);
const connecting = client.connect();
transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
await connecting;
client.disconnect();
transport.emitExit(0);
expect(onExit).not.toHaveBeenCalled();
});
it("fails active transports on transport error without waiting for exit", async () => {
let transport!: FakeTransport;
const onExit = vi.fn();
const client = new AppServerClient(
"/bin/codex",
"/vault",
{
onNotification: () => undefined,
onServerRequest: () => undefined,
onLog: () => undefined,
onExit,
},
500,
(handlers) => {
transport = new FakeTransport(handlers);
return transport;
},
);
const connecting = client.connect();
transport.emitLine({ id: 1, result: { codexHome: "/tmp/codex" } satisfies Partial<InitializeResponse> });
await connecting;
const listing = client.listModels();
transport.emitError(new Error("transport failed"));
expect(client.isConnected()).toBe(false);
expect(transport.isRunning()).toBe(false);
await expect(listing).rejects.toThrow("transport failed");
expect(onExit).not.toHaveBeenCalled();
});
it("sends typed turn steering requests", async () => {
let transport: FakeTransport;
const getTransport = () => transport;

View file

@ -42,7 +42,7 @@ function createController({ connected = false, client = {} as AppServerClient }
metadata,
diagnostics,
invalidateResumeWork: vi.fn(),
loadSharedThreadList: vi.fn().mockResolvedValue(undefined),
refreshSharedThreadList: vi.fn().mockResolvedValue(undefined),
scheduleDeferredDiagnostics: vi.fn(),
clearDeferredDiagnostics: vi.fn(),
refreshTabHeader: vi.fn(),
@ -77,7 +77,7 @@ describe("ChatConnectionController", () => {
userAgent: "test",
});
expect(refreshAppServerMetadata).toHaveBeenCalledOnce();
expect(host.loadSharedThreadList).toHaveBeenCalledOnce();
expect(host.refreshSharedThreadList).toHaveBeenCalledOnce();
expect(host.scheduleDeferredDiagnostics).toHaveBeenCalledOnce();
expect(host.setStatus).toHaveBeenCalledWith("Connected.", { kind: "connected" });
});
@ -97,7 +97,7 @@ describe("ChatConnectionController", () => {
await controller.refreshActiveThreads();
expect(host.loadSharedThreadList).toHaveBeenCalledOnce();
expect(host.refreshSharedThreadList).toHaveBeenCalledOnce();
expect(refreshAppServerMetadata).not.toHaveBeenCalled();
});

View file

@ -135,11 +135,13 @@ function controllerFixture(
const currentClient = overrides.currentClient ?? (() => fakeClient());
const notifyThreadRenamed = vi.fn();
const titleService = createThreadTitleService({
settings: {
current: () => ({ ...DEFAULT_SETTINGS, codexPath: "codex" }),
vaultPath: "/vault",
codexPath: () => "codex",
vaultPath: "/vault",
threadNamingModel: () => DEFAULT_SETTINGS.threadNamingModel,
threadNamingEffort: () => DEFAULT_SETTINGS.threadNamingEffort,
clientAccess: {
withClient: async (operation) => operation(currentClient()),
},
currentClient,
visibleCompletedTurnContext: (turnId) =>
threadTitleContextFromMessageStreamItems(turnId, messageStreamItems(stateStore.getState().messageStream)),
generateThreadTitle: overrides.generateThreadTitle ?? vi.fn().mockResolvedValue("Generated title"),

View file

@ -57,6 +57,7 @@ describe("selection rewrite command", () => {
getCursor: vi.fn((which: "from" | "to") => (which === "from" ? from : to)),
};
const view = new MarkdownView({} as never);
Object.assign(view, { containerEl: { doc: document } });
view.file = Object.assign(new TFile(), { path: "Draft.md", basename: "Draft" });
registerSelectionRewriteCommand(plugin as never);

View file

@ -38,8 +38,6 @@ installObsidianDomShims();
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
beforeEach(() => {
Object.defineProperty(globalThis, "activeDocument", { configurable: true, value: document });
Object.defineProperty(globalThis, "activeWindow", { configurable: true, value: window });
document.body.replaceChildren();
});
@ -624,6 +622,8 @@ function popoverOptions(
runtimeSettings: { rewriteSelectionModel: null, rewriteSelectionEffort: null },
sendShortcut: "enter",
state: rewriteState(),
viewDocument: document,
viewWindow: window,
...overrides,
};
}

View file

@ -105,8 +105,6 @@ function threadPickerHost(threads: readonly Thread[]): TestThreadPickerHost {
const openedAvailable: string[] = [];
return {
app: {} as never,
settings: { codexPath: "codex" } as never,
vaultPath: "/vault",
openedCurrent,
openedAvailable,
threadCatalog: {

View file

@ -5,7 +5,7 @@ import { describe, expect, it, vi } from "vitest";
import { renderThreadsView } from "../../../src/features/threads-view/renderer";
import { threadRows, type ThreadsRowModel } from "../../../src/features/threads-view/state";
import type { Thread } from "../../../src/domain/threads/model";
import type { OpenCodexPanelSnapshot } from "../../../src/workspace/open-panel-snapshot";
import type { OpenCodexPanelSnapshot } from "../../../src/workspace/panel-coordinator";
import { changeInputValue, installObsidianDomShims } from "../../support/dom";
installObsidianDomShims();

View file

@ -96,7 +96,6 @@ describe("CodexThreadsView", () => {
await view.refresh();
expect(connectionMock.state.connectCalls).toBe(1);
expect(view.containerEl.textContent).toContain("Thread preview");
});
@ -124,24 +123,16 @@ describe("CodexThreadsView", () => {
expect(view.containerEl.textContent).not.toContain("Late thread");
});
it("ignores stale refresh results after the app-server exits", async () => {
const threads = deferred<unknown>();
const listThreads = vi.fn(() => threads.promise);
it("renders shared thread refresh failures", async () => {
const listThreads = vi.fn().mockRejectedValue(new Error("Codex app-server stopped."));
connectionMock.state.client = clientFixture({
listThreads,
});
const view = await threadsView();
const refresh = view.refresh();
await waitForAsyncWork(() => {
expect(listThreads).toHaveBeenCalled();
});
connectionMock.state.onExit?.();
threads.resolve({ data: [threadFixture({ id: "thread", preview: "Late thread" })] });
await refresh;
await view.refresh();
expect(view.containerEl.textContent).toContain("Codex app-server stopped.");
expect(view.containerEl.textContent).not.toContain("Late thread");
});
it("ignores stale refresh results when a newer refresh completes first", async () => {
@ -421,6 +412,13 @@ function threadsHost(overrides: Record<string, unknown> = {}) {
codexPath: "codex",
},
vaultPath: "/vault",
clientAccess: {
withClient: async <T>(operation: (client: never) => Promise<T>): Promise<T> => {
const client = connectionMock.state.client;
if (!client) throw new Error("No current client.");
return operation(client as never);
},
},
openNewPanel: vi.fn().mockResolvedValue(undefined),
openThreadInAvailableView: vi.fn().mockResolvedValue(undefined),
getOpenPanelSnapshots: vi.fn(() => []),

View file

@ -47,8 +47,8 @@ describe("ThreadOperations", () => {
it("does not notify surfaces when an operation has no current client", async () => {
const { operations, catalog } = operationsFixture({ client: null });
await expect(operations.renameThread("thread", "Title")).resolves.toBe(false);
await expect(operations.archiveThread("thread")).resolves.toBeNull();
await expect(operations.renameThread("thread", "Title")).rejects.toThrow("No current client.");
await expect(operations.archiveThread("thread")).rejects.toThrow("No current client.");
expect(catalog.recordThreadRenamed).not.toHaveBeenCalled();
expect(catalog.recordThreadArchived).not.toHaveBeenCalled();
@ -63,7 +63,7 @@ describe("ThreadOperations", () => {
currentClient = secondClient;
});
await expect(operations.renameThread("thread", "Title")).resolves.toBe(false);
await expect(operations.renameThread("thread", "Title")).rejects.toThrow("Client changed.");
expect(catalog.recordThreadRenamed).not.toHaveBeenCalled();
});
@ -78,7 +78,7 @@ describe("ThreadOperations", () => {
return { exportedPath: null } satisfies ArchiveThreadResult;
});
await expect(operations.archiveThread("thread")).resolves.toBeNull();
await expect(operations.archiveThread("thread")).rejects.toThrow("Client changed.");
expect(catalog.recordThreadArchived).not.toHaveBeenCalled();
});
@ -95,12 +95,18 @@ function operationsFixture(options: { client?: MockClient | null | (() => MockCl
};
const notice = vi.fn();
const host: ThreadOperationsHost = {
connection: {
ensureConnected: vi.fn().mockResolvedValue(undefined),
currentClient: () => currentClient() as AppServerClient | null,
clientAccess: {
withClient: async (operation) => {
const client = currentClient() as AppServerClient | null;
if (!client) throw new Error("No current client.");
const result = await operation(client);
if ((currentClient() as AppServerClient | null) !== client) throw new Error("Client changed.");
return result;
},
},
settings: {
current: () => ({ ...DEFAULT_SETTINGS, archiveExportEnabled: false }),
archiveExport: {
settings: () => ({ ...DEFAULT_SETTINGS, archiveExportEnabled: false }),
enabled: () => false,
vaultPath: "/vault",
},
archiveAdapter: () => archiveAdapterMock(),

View file

@ -1,6 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../src/app-server/connection/client";
import { THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE, type ThreadTitleContext } from "../../../src/domain/threads/title-generation-model";
import { DEFAULT_SETTINGS } from "../../../src/settings/model";
import {
@ -51,11 +50,13 @@ describe("ThreadTitleService", () => {
function titleService(options: Partial<ThreadTitleServiceHost> = {}): ThreadTitleService {
return createThreadTitleService({
settings: {
current: () => ({ ...DEFAULT_SETTINGS, codexPath: "codex" }),
vaultPath: "/vault",
codexPath: () => "codex",
vaultPath: "/vault",
threadNamingModel: () => DEFAULT_SETTINGS.threadNamingModel,
threadNamingEffort: () => DEFAULT_SETTINGS.threadNamingEffort,
clientAccess: {
withClient: vi.fn().mockResolvedValue(null),
},
currentClient: () => null as AppServerClient | null,
...options,
});
}

View file

@ -841,22 +841,27 @@ function settingsTabHost(
}>;
} = {},
): CodexPanelSettingTabHost {
const settings = {
codexPath: "codex",
threadNamingModel: options.settings?.threadNamingModel ?? null,
threadNamingEffort: options.settings?.threadNamingEffort ?? null,
rewriteSelectionModel: options.settings?.rewriteSelectionModel ?? null,
rewriteSelectionEffort: options.settings?.rewriteSelectionEffort ?? null,
showToolbar: true,
sendShortcut: options.sendShortcut ?? "enter",
scrollThreadFromComposerEdges: false,
archiveExportEnabled: false,
archiveExportFolderTemplate: "Codex Archives",
archiveExportFilenameTemplate: "{{date}} {{time}} {{title}} {{shortId}}.md",
archiveExportTags: "",
};
return {
settings: {
codexPath: "codex",
threadNamingModel: options.settings?.threadNamingModel ?? null,
threadNamingEffort: options.settings?.threadNamingEffort ?? null,
rewriteSelectionModel: options.settings?.rewriteSelectionModel ?? null,
rewriteSelectionEffort: options.settings?.rewriteSelectionEffort ?? null,
showToolbar: true,
sendShortcut: options.sendShortcut ?? "enter",
scrollThreadFromComposerEdges: false,
archiveExportEnabled: false,
archiveExportFolderTemplate: "Codex Archives",
archiveExportFilenameTemplate: "{{date}} {{time}} {{title}} {{shortId}}.md",
archiveExportTags: "",
},
settings,
vaultPath: "/vault",
clientAccess: {
withClient: <T>(operation: (client: never) => Promise<T>, clientOptions?: { unhandledServerRequestMessage?: string }) =>
withShortLivedAppServerClientMock(settings.codexPath, "/vault", operation, clientOptions) as Promise<T>,
},
saveSettings: options.saveSettings ?? vi.fn().mockResolvedValue(undefined),
refreshOpenViews: options.refreshOpenViews ?? vi.fn(),
appServerData: {