diff --git a/src/app-server/connection/client-access.ts b/src/app-server/connection/client-access.ts new file mode 100644 index 00000000..960d3d52 --- /dev/null +++ b/src/app-server/connection/client-access.ts @@ -0,0 +1,5 @@ +import type { AppServerClient } from "./client"; + +export interface AppServerClientAccess { + withClient(operation: (client: AppServerClient) => Promise, options?: { unhandledServerRequestMessage?: string }): Promise; +} diff --git a/src/app-server/connection/client.ts b/src/app-server/connection/client.ts index 19173c01..49a38047 100644 --- a/src/app-server/connection/client.ts +++ b/src/app-server/connection/client.ts @@ -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(); 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(); + } } diff --git a/src/features/chat/application/connection/connection-controller.ts b/src/features/chat/application/connection/connection-controller.ts index 8525d1f5..7f6118e3 100644 --- a/src/features/chat/application/connection/connection-controller.ts +++ b/src/features/chat/application/connection/connection-controller.ts @@ -33,7 +33,7 @@ export interface ChatConnectionControllerHost { metadata: ChatConnectionMetadataActions; diagnostics: ChatConnectionDiagnosticsActions; invalidateResumeWork: () => void; - loadSharedThreadList: () => Promise; + refreshSharedThreadList: () => Promise; scheduleDeferredDiagnostics: () => void; clearDeferredDiagnostics: () => void; refreshTabHeader: () => void; @@ -107,7 +107,7 @@ async function ensureConnected(host: ChatConnectionControllerHost): Promise { 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(); diff --git a/src/features/chat/host/connection-bundle.ts b/src/features/chat/host/connection-bundle.ts index 20c12bee..970fdb0c 100644 --- a/src/features/chat/host/connection-bundle.ts +++ b/src/features/chat/host/connection-bundle.ts @@ -45,7 +45,6 @@ interface ChatPanelConnectionBundleHost { connectionWork: ConnectionWorkTracker; deferredTasks: ChatViewDeferredTasks; invalidateResumeWork: () => void; - loadSharedThreadList: () => Promise; deferLiveStateRefresh: () => void; refreshTabHeader: () => void; refreshLiveState: () => void; @@ -62,6 +61,7 @@ export interface ChatPanelConnectionBundle { metadata: ChatServerMetadataActions; diagnostics: ChatServerDiagnosticsActions; }; + refreshSharedThreadList: () => Promise; } function respondToCurrentServerRequest(currentClient: CurrentAppServerClient, requestId: RespondRequestId, result: unknown): boolean { @@ -122,9 +122,13 @@ export function createConnectionBundle( void goalSync.syncThreadGoal(threadId); }, }); + const refreshSharedThreadList = async (): Promise => { + 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, }; } diff --git a/src/features/chat/host/session-parts.ts b/src/features/chat/host/session-graph.ts similarity index 64% rename from src/features/chat/host/session-parts.ts rename to src/features/chat/host/session-graph.ts index 43a91bd0..2c141353 100644 --- a/src/features/chat/host/session-parts.ts +++ b/src/features/chat/host/session-graph.ts @@ -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; + startNewThread(): Promise; + 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; - notifyActiveThreadIdentityChanged: () => void; - refreshTabHeader: () => void; - refreshLiveState: () => void; - deferLiveStateRefresh: () => void; - startNewThread: () => Promise; - statusSummaryLines: () => string[]; - modelStatusLines: () => string[]; - effortStatusLines: () => string[]; - connectionDiagnosticDetails: () => MessageStreamNoticeSection[]; + viewWindow: () => Window; } type ChatPanelGoalSyncActions = ReturnType; @@ -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; +} + +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 { + 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 { + 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): void => { + if (result.data) receiveThreads(result.data); + }; + const receiveAppServerMetadata = (metadata: SharedServerMetadata): void => { + input.serverActions.metadata.applyAppServerMetadata(metadata); + }; + const receiveAppServerMetadataResult = (result: AppServerObservedQueryResult): 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): 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, -): 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, 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, 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; @@ -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; @@ -558,6 +757,14 @@ function createComposerAndTurnActions( serverDiagnostics: ChatServerDiagnosticsActions; goals: ChatPanelGoalActions; autoTitle: AutoTitleController; + invalidateResumeWork: () => void; + startNewThread: () => Promise; + 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; }, ): 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()); +} diff --git a/src/features/chat/host/session.ts b/src/features/chat/host/session.ts index 0175820b..1e48b5c9 100644 --- a/src/features/chat/host/session.ts +++ b/src/features/chat/host/session.ts @@ -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 { - return this.loadSharedThreadList(); + return this.graph.actions.refreshSharedThreadList(); } async runWithAppServerClient(operation: (client: AppServerClient) => Promise): Promise { - 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 { - await this.parts.thread.resume.resumeThread(threadId); + await this.graph.thread.resume.resumeThread(threadId); this.focusComposer(); } async focusThread(threadId: string | null = null): Promise { - 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 { - await this.parts.connection.controller.ensureConnected(); + await this.graph.connection.controller.ensureConnected(); } async startNewThread(): Promise { - 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): void { - if (result.data) this.receiveObservedThreads(result.data); - } - - private receiveObservedAppServerMetadata(metadata: SharedServerMetadata): void { - this.parts.serverActions.metadata.applyAppServerMetadata(metadata); - } - - private receiveObservedAppServerMetadataResult(result: AppServerObservedQueryResult): 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): 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 { - 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 { - 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[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); - } } diff --git a/src/features/chat/panel/surface/goal-projection.tsx b/src/features/chat/panel/surface/goal-projection.tsx index f1812772..903d3135 100644 --- a/src/features/chat/panel/surface/goal-projection.tsx +++ b/src/features/chat/panel/surface/goal-projection.tsx @@ -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: { diff --git a/src/features/selection-rewrite/command.ts b/src/features/selection-rewrite/command.ts index 6d9d7530..4ab548fa 100644 --- a/src/features/selection-rewrite/command.ts +++ b/src/features/selection-rewrite/command.ts @@ -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); diff --git a/src/features/selection-rewrite/popover.tsx b/src/features/selection-rewrite/popover.tsx index 6687ad09..fd833aec 100644 --- a/src/features/selection-rewrite/popover.tsx +++ b/src/features/selection-rewrite/popover.tsx @@ -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 { diff --git a/src/features/selection-rewrite/position.ts b/src/features/selection-rewrite/position.ts index 4916e3ca..a55b3005 100644 --- a/src/features/selection-rewrite/position.ts +++ b/src/features/selection-rewrite/position.ts @@ -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; diff --git a/src/features/thread-picker/modal.ts b/src/features/thread-picker/modal.ts index 5e3931dd..d866bd67 100644 --- a/src/features/thread-picker/modal.ts +++ b/src/features/thread-picker/modal.ts @@ -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; openThreadInAvailableView(threadId: string): Promise; diff --git a/src/features/threads-view/session.ts b/src/features/threads-view/session.ts index 8d62d16e..a2311fb2 100644 --- a/src/features/threads-view/session.ts +++ b/src/features/threads-view/session.ts @@ -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; openThreadInAvailableView(threadId: string): Promise; @@ -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 { - 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 { 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 { try { - await this.ensureConnected(); const result = await this.operations.archiveThread(threadId, { saveMarkdown, closeOpenPanels: true, diff --git a/src/features/threads-view/state.ts b/src/features/threads-view/state.ts index 0aa4413a..ba87d48b 100644 --- a/src/features/threads-view/state.ts +++ b/src/features/threads-view/state.ts @@ -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"; diff --git a/src/features/threads/thread-operations.ts b/src/features/threads/thread-operations.ts index 0eaad138..deca6bd4 100644 --- a/src/features/threads/thread-operations.ts +++ b/src/features/threads/thread-operations.ts @@ -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; - 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 { - 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}.`); } diff --git a/src/features/threads/thread-title-service.ts b/src/features/threads/thread-title-service.ts index 14922ae6..4899c76c 100644 --- a/src/features/threads/thread-title-service.ts +++ b/src/features/threads/thread-title-service.ts @@ -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; @@ -47,16 +47,19 @@ async function generateTitle(host: ThreadTitleServiceHost, threadId: string): Pr } async function resolveThreadTitleContext(host: ThreadTitleServiceHost, threadId: string): Promise { - 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 { + 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 { 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(), }); } diff --git a/src/plugin-runtime.ts b/src/plugin-runtime.ts index e70ee821..9ebefe10 100644 --- a/src/plugin-runtime.ts +++ b/src/plugin-runtime.ts @@ -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; } -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(operation: (client: AppServerClient) => Promise, options: { unhandledServerRequestMessage?: string } = {}): Promise { + return this.runWithAppServerClient(this.appServerQueryContext(), operation, options); + } + private async openTurnDiff(state: ChatTurnDiffViewState): Promise { const existing = this.options.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_TURN_DIFF).at(0); const leaf = existing ?? this.options.app.workspace.getLeaf("tab"); diff --git a/src/settings/dynamic-data-controller.ts b/src/settings/dynamic-data-controller.ts index f4aec692..e81f1f67 100644 --- a/src/settings/dynamic-data-controller.ts +++ b/src/settings/dynamic-data-controller.ts @@ -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(operation: (client: AppServerClient) => Promise): Promise { - 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.", }); } diff --git a/src/settings/host.ts b/src/settings/host.ts index 27e5d7f9..fbc89833 100644 --- a/src/settings/host.ts +++ b/src/settings/host.ts @@ -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" diff --git a/src/workspace/open-panel-snapshot.ts b/src/workspace/open-panel-snapshot.ts deleted file mode 100644 index 5622d58b..00000000 --- a/src/workspace/open-panel-snapshot.ts +++ /dev/null @@ -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; -} diff --git a/src/workspace/panel-coordinator.ts b/src/workspace/panel-coordinator.ts index ceea96d7..1f7acf9c 100644 --- a/src/workspace/panel-coordinator.ts +++ b/src/workspace/panel-coordinator.ts @@ -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 } | { kind: "cancelled" }; +export interface OpenCodexPanelSnapshot extends ChatPanelSnapshot { + lastFocused: boolean; +} + export interface WorkspacePanelCoordinatorOptions { app: App; refreshThreadsViewLiveState: () => void; diff --git a/tests/app-server/app-server-client.test.ts b/tests/app-server/app-server-client.test.ts index c5e1b8ba..48cce3e7 100644 --- a/tests/app-server/app-server-client.test.ts +++ b/tests/app-server/app-server-client.test.ts @@ -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 }); + 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 }); + 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; diff --git a/tests/features/chat/connection/connection-controller.test.ts b/tests/features/chat/connection/connection-controller.test.ts index 7d27009a..5ea3ab08 100644 --- a/tests/features/chat/connection/connection-controller.test.ts +++ b/tests/features/chat/connection/connection-controller.test.ts @@ -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(); }); diff --git a/tests/features/chat/threads/auto-title-controller.test.ts b/tests/features/chat/threads/auto-title-controller.test.ts index bbd71d15..08f8dd52 100644 --- a/tests/features/chat/threads/auto-title-controller.test.ts +++ b/tests/features/chat/threads/auto-title-controller.test.ts @@ -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"), diff --git a/tests/features/selection-rewrite/selection-rewrite-command.test.ts b/tests/features/selection-rewrite/selection-rewrite-command.test.ts index 0a86edbc..e3cf7e5c 100644 --- a/tests/features/selection-rewrite/selection-rewrite-command.test.ts +++ b/tests/features/selection-rewrite/selection-rewrite-command.test.ts @@ -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); diff --git a/tests/features/selection-rewrite/selection-rewrite.test.ts b/tests/features/selection-rewrite/selection-rewrite.test.ts index a29a6b50..747eef30 100644 --- a/tests/features/selection-rewrite/selection-rewrite.test.ts +++ b/tests/features/selection-rewrite/selection-rewrite.test.ts @@ -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, }; } diff --git a/tests/features/thread-picker/modal.test.ts b/tests/features/thread-picker/modal.test.ts index 06749461..b7ed7f2e 100644 --- a/tests/features/thread-picker/modal.test.ts +++ b/tests/features/thread-picker/modal.test.ts @@ -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: { diff --git a/tests/features/threads-view/renderer.test.ts b/tests/features/threads-view/renderer.test.ts index eb52fe49..11b42512 100644 --- a/tests/features/threads-view/renderer.test.ts +++ b/tests/features/threads-view/renderer.test.ts @@ -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(); diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index c13d59c8..d0e3e99d 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -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(); - 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 = {}) { codexPath: "codex", }, vaultPath: "/vault", + clientAccess: { + withClient: async (operation: (client: never) => Promise): Promise => { + 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(() => []), diff --git a/tests/features/threads/thread-operations.test.ts b/tests/features/threads/thread-operations.test.ts index 72ddb9ad..0c81e9f4 100644 --- a/tests/features/threads/thread-operations.test.ts +++ b/tests/features/threads/thread-operations.test.ts @@ -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(), diff --git a/tests/features/threads/thread-title-service.test.ts b/tests/features/threads/thread-title-service.test.ts index 185bffed..38ebc6aa 100644 --- a/tests/features/threads/thread-title-service.test.ts +++ b/tests/features/threads/thread-title-service.test.ts @@ -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 = {}): 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, }); } diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index 99fbc673..9d2f0afb 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -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: (operation: (client: never) => Promise, clientOptions?: { unhandledServerRequestMessage?: string }) => + withShortLivedAppServerClientMock(settings.codexPath, "/vault", operation, clientOptions) as Promise, + }, saveSettings: options.saveSettings ?? vi.fn().mockResolvedValue(undefined), refreshOpenViews: options.refreshOpenViews ?? vi.fn(), appServerData: {