diff --git a/src/app-server/connection/connection-manager.ts b/src/app-server/connection/connection-manager.ts index 52c3563c..fc134d5a 100644 --- a/src/app-server/connection/connection-manager.ts +++ b/src/app-server/connection/connection-manager.ts @@ -28,18 +28,14 @@ export class StaleConnectionError extends Error { export class ConnectionManager { private state: ConnectionLifecycleState = { kind: "idle", generation: 0 }; - private handlers: ConnectionManagerHandlers | null = null; constructor( private readonly codexPath: () => string, private readonly cwd: string, + private readonly handlers: ConnectionManagerHandlers, private readonly clientFactory: AppServerClientFactory = (codexPath, cwd, handlers) => new AppServerClient(codexPath, cwd, handlers), ) {} - setHandlers(handlers: ConnectionManagerHandlers): void { - this.handlers = handlers; - } - currentClient(): AppServerClient | null { return this.state.kind === "connected" && this.state.client.isConnected() ? this.state.client : null; } @@ -59,20 +55,20 @@ export class ConnectionManager { const client = this.clientFactory(this.codexPath(), this.cwd, { onNotification: (notification) => { if (this.isStale(generation)) return; - this.currentHandlers().onNotification(notification); + this.handlers.onNotification(notification); }, onServerRequest: (request) => { if (this.isStale(generation)) return; - this.currentHandlers().onServerRequest(request); + this.handlers.onServerRequest(request); }, onLog: (message) => { if (this.isStale(generation)) return; - this.currentHandlers().onLog(message); + this.handlers.onLog(message); }, onExit: () => { if (this.isStale(generation)) return; this.state = { kind: "disconnected", generation }; - this.currentHandlers().onExit(); + this.handlers.onExit(); }, }); const promise = client @@ -115,9 +111,4 @@ export class ConnectionManager { private isStale(generation: number): boolean { return generation !== this.state.generation; } - - private currentHandlers(): ConnectionManagerHandlers { - if (!this.handlers) throw new Error("ConnectionManager handlers have not been attached."); - return this.handlers; - } } diff --git a/src/features/chat/conversation/composer/composition.ts b/src/features/chat/conversation/composer/composition.ts new file mode 100644 index 00000000..fe48f57c --- /dev/null +++ b/src/features/chat/conversation/composer/composition.ts @@ -0,0 +1,80 @@ +import type { App } from "obsidian"; + +import type { CodexInput } from "../../../../domain/chat/input"; +import { MessageStreamScrollBridge } from "../../panel/surface/message-stream-scroll"; +import { currentModel, runtimeConfigOrDefault } from "../../runtime/effective"; +import { runtimeSnapshotForChatState } from "../../runtime/snapshot"; +import { activeTurnId, type ChatStateStore } from "../../state/reducer"; +import type { ComposerMetaViewModel } from "../../ui/composer"; +import type { ChatPanelComposerShellState } from "../../ui/shell-state"; +import type { CodexChatHost } from "../../chat-host"; +import type { ChatRuntimeSettingsActions } from "../../runtime/settings-actions"; +import { ChatComposerController } from "./controller"; + +export interface ConversationComposerContext { + app: App; + plugin: CodexChatHost; + stateStore: ChatStateStore; + viewId: string; + surface: { + composerPlaceholder: (state: ChatPanelComposerShellState) => string; + composerMetaViewModel: (state: ChatPanelComposerShellState) => ComposerMetaViewModel; + }; + liveState: { + refresh: () => void; + }; +} + +export interface ConversationComposerRefs { + runtimeSettings: ChatRuntimeSettingsActions; +} + +export interface ConversationComposerParts { + controller: ChatComposerController; + scrollBridge: MessageStreamScrollBridge; + codexInput: (text: string) => CodexInput; + setDraft: (text: string, options?: { focus?: boolean; clearSuggestions?: boolean }) => void; +} + +export function createConversationComposer( + context: ConversationComposerContext, + refs: ConversationComposerRefs, +): ConversationComposerParts { + const { app, plugin, stateStore, viewId, surface, liveState } = context; + const scrollBridge = new MessageStreamScrollBridge(); + const controller = new ChatComposerController({ + app, + stateStore, + viewId, + sendShortcut: () => plugin.settings.sendShortcut, + scrollThreadFromComposerEdges: () => plugin.settings.scrollThreadFromComposerEdges, + canInterrupt: (state) => { + return state.turn.lifecycle.kind !== "idle" && Boolean(state.activeThread.id && activeTurnId(state)); + }, + composerPlaceholder: surface.composerPlaceholder, + composerMeta: surface.composerMetaViewModel, + currentModelForSuggestions: () => { + const current = stateStore.getState(); + return currentModel(runtimeSnapshotForChatState(current), runtimeConfigOrDefault(current.connection.runtimeConfig)); + }, + threadScrollFromComposer: (action) => { + scrollBridge.scrollFromComposer(action); + }, + togglePlan: () => void refs.runtimeSettings.toggleCollaborationMode(), + toggleAutoReview: () => void refs.runtimeSettings.toggleAutoReview(), + toggleFast: () => void refs.runtimeSettings.toggleFastMode(), + onDraftChange: liveState.refresh, + onHeightChange: () => { + scrollBridge.repinMessageStreamToBottomIfPinned(); + }, + }); + + return { + controller, + scrollBridge, + codexInput: (text) => controller.codexInput(text), + setDraft: (text, options) => { + controller.setDraft(text, options); + }, + }; +} diff --git a/src/features/chat/conversation/composer/controller.ts b/src/features/chat/conversation/composer/controller.ts index 73231e16..5018abaa 100644 --- a/src/features/chat/conversation/composer/controller.ts +++ b/src/features/chat/conversation/composer/controller.ts @@ -30,6 +30,7 @@ export interface ChatComposerControllerOptions { composerPlaceholder: (state: ChatPanelComposerShellState) => string; composerMeta: (state: ChatPanelComposerShellState) => ComposerMetaViewModel; currentModelForSuggestions: () => string | null; + threadScrollFromComposer: (action: ComposerBoundaryScrollAction) => void; togglePlan: () => void; toggleAutoReview: () => void; toggleFast: () => void; @@ -37,23 +38,17 @@ export interface ChatComposerControllerOptions { onHeightChange: () => void; } -export interface ChatComposerActionHandlers { +export interface ChatComposerRenderActions { submit: () => void; - threadScrollFromComposer: (action: ComposerBoundaryScrollAction) => void; } export class ChatComposerController { private composer: HTMLTextAreaElement | null = null; private noteCandidatesCache: { sourcePath: string; notes: NoteCandidate[] } | null = null; private noteEventsRegistered = false; - private actionHandlers: ChatComposerActionHandlers | null = null; constructor(private readonly options: ChatComposerControllerOptions) {} - setActionHandlers(handlers: ChatComposerActionHandlers): void { - this.actionHandlers = handlers; - } - private get state(): ChatState { return this.options.stateStore.getState(); } @@ -78,7 +73,7 @@ export class ChatComposerController { registerEvent(this.options.app.vault.on("modify", invalidate)); } - renderState(state: ChatPanelComposerShellState): ComposerShellProps { + renderState(state: ChatPanelComposerShellState, actions: ChatComposerRenderActions): ComposerShellProps { return { viewId: this.options.viewId, draft: state.composer.draft, @@ -87,7 +82,7 @@ export class ChatComposerController { normalPlaceholder: this.options.composerPlaceholder(state), suggestions: state.composer.suggestions, selectedSuggestionIndex: state.composer.suggestSelected, - callbacks: this.composerCallbacks(), + callbacks: this.composerCallbacks(actions), meta: this.options.composerMeta(state), onComposer: this.setComposerElement, }; @@ -178,7 +173,7 @@ export class ChatComposerController { if (!action) return false; event.preventDefault(); - this.currentActionHandlers().threadScrollFromComposer(action); + this.options.threadScrollFromComposer(action); return true; } @@ -300,12 +295,7 @@ export class ChatComposerController { return this.noteCandidatesCache.notes; } - private currentActionHandlers(): ChatComposerActionHandlers { - if (!this.actionHandlers) throw new Error("ChatComposerController action handlers have not been attached."); - return this.actionHandlers; - } - - private composerCallbacks(): ComposerCallbacks { + private composerCallbacks(actions: ChatComposerRenderActions): ComposerCallbacks { return { onInput: (value) => { this.handleInput(value); @@ -322,11 +312,11 @@ export class ChatComposerController { } if (isComposerSendKey(event, this.options.sendShortcut())) { event.preventDefault(); - this.currentActionHandlers().submit(); + actions.submit(); } }, onSendOrInterrupt: () => { - this.currentActionHandlers().submit(); + actions.submit(); }, onHeightChange: () => { this.options.onHeightChange(); diff --git a/src/features/chat/conversation/composition.ts b/src/features/chat/conversation/composition.ts index 1adee05e..18d7477f 100644 --- a/src/features/chat/conversation/composition.ts +++ b/src/features/chat/conversation/composition.ts @@ -1,30 +1,24 @@ import type { App, Component } from "obsidian"; import type { AppServerClient } from "../../../app-server/connection/client"; import type { ChatServerThreadActions } from "../connection/server-actions/threads"; -import { ChatComposerController } from "./composer/controller"; -import { activeTurnId, type ChatStateStore } from "../state/reducer"; +import { type ChatStateStore } from "../state/reducer"; import type { ChatReconnectActions } from "../connection/reconnect-actions"; import { PendingRequestController } from "./pending-requests/controller"; import type { ChatRuntimeSettingsActions } from "../runtime/settings-actions"; -import { createComposerSubmitActions } from "./turns/composer-submit-actions"; -import { createPlanImplementation } from "./turns/plan-implementation"; -import { createSlashCommandHandler } from "./turns/slash-command-handler"; -import { TurnSubmissionController } from "./turns/turn-submission-controller"; import type { ChatThreadActions } from "../threads/action-context"; import type { GoalActions } from "../threads/goal-actions"; import type { HistoryController } from "../threads/history-controller"; import type { ChatInboundController } from "../protocol/inbound/controller"; -import { currentModel, runtimeConfigOrDefault } from "../runtime/effective"; -import { runtimeSnapshotForChatState } from "../runtime/snapshot"; -import { MessageStreamPresenter } from "../panel/surface/message-stream-presenter"; -import { MessageStreamScrollBridge } from "../panel/surface/message-stream-scroll"; import type { DisplayDetailSection } from "../display/types"; import type { ChatMessageScrollIntentState } from "../ui/message-stream/scroll-intent-state"; import type { ComposerMetaViewModel } from "../ui/composer"; import type { ChatPanelComposerShellState } from "../ui/shell-state"; import type { CodexChatHost } from "../chat-host"; +import { createConversationComposer } from "./composer/composition"; +import { createConversationMessageStreamPresenter } from "./message-stream/composition"; +import { createConversationTurnActions } from "./turns/composition"; -interface ConversationControllersContext { +interface ConversationPartsContext { obsidian: { app: App; owner: Component; @@ -74,8 +68,8 @@ interface ConversationControllersContext { }; } -export function createConversationControllers( - context: ConversationControllersContext, +export function createConversationParts( + context: ConversationPartsContext, refs: { controller: ChatInboundController; serverThreads: ChatServerThreadActions; @@ -90,41 +84,27 @@ export function createConversationControllers( const { app, owner, viewId } = context.obsidian; const stateStore = state.stateStore; const currentClient = client.getClient; - const { messageScrollIntent } = lifecycle; - const messageStreamScrollBridge = new MessageStreamScrollBridge(); - const composerController = new ChatComposerController({ - app, - stateStore, - viewId, - sendShortcut: () => plugin.settings.sendShortcut, - scrollThreadFromComposerEdges: () => plugin.settings.scrollThreadFromComposerEdges, - canInterrupt: (state) => { - return state.turn.lifecycle.kind !== "idle" && Boolean(state.activeThread.id && activeTurnId(state)); + const composer = createConversationComposer( + { + app, + plugin, + stateStore, + viewId, + surface: { + composerPlaceholder: surface.composerPlaceholder, + composerMetaViewModel: surface.composerMetaViewModel, + }, + liveState: { + refresh: liveState.refresh, + }, }, - composerPlaceholder: surface.composerPlaceholder, - composerMeta: surface.composerMetaViewModel, - currentModelForSuggestions: () => { - const current = stateStore.getState(); - return currentModel(runtimeSnapshotForChatState(current), runtimeConfigOrDefault(current.connection.runtimeConfig)); + { + runtimeSettings: refs.runtimeSettings, }, - togglePlan: () => void refs.runtimeSettings.toggleCollaborationMode(), - toggleAutoReview: () => void refs.runtimeSettings.toggleAutoReview(), - toggleFast: () => void refs.runtimeSettings.toggleFastMode(), - onDraftChange: liveState.refresh, - onHeightChange: () => { - messageStreamScrollBridge.repinMessageStreamToBottomIfPinned(); - }, - }); - const codexInput = (text: string) => composerController.codexInput(text); - const setComposerDraft = (text: string, options?: { focus?: boolean; clearSuggestions?: boolean }) => { - composerController.setDraft(text, options); - }; - const startThread = (preview?: string) => refs.serverThreads.startThread(preview); - const startThreadForGoal = async (objective: string) => { - const response = await refs.serverThreads.startThread(objective, { syncGoal: false }); - return response?.threadId ?? null; - }; + ); + const composerController = composer.controller; + const messageStreamScrollBridge = composer.scrollBridge; const pendingRequests = new PendingRequestController({ stateStore, @@ -133,125 +113,63 @@ export function createConversationControllers( refreshLiveState: liveState.refresh, }); - const turnSubmission = new TurnSubmissionController({ - stateStore, - vaultPath: plugin.vaultPath, - currentClient, - ensureRestoredThreadLoaded: thread.ensureRestoredThreadLoaded, - startThread, - notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged, - resetThreadTurnPresence: thread.resetTurnPresence, - applyPendingThreadSettings: () => refs.runtimeSettings.applyPendingThreadSettings(), - codexInput, - setDraft: setComposerDraft, - setStatus: status.set, - addSystemMessage: status.addSystemMessage, - }); - const slashCommands = createSlashCommandHandler({ - stateStore, - currentClient, - codexInput, - startNewThread: thread.startNewThread, - startThreadForGoal, - resumeThread: thread.selectThread, - forkThread: (threadId) => refs.threadActions.forkThread(threadId), - rollbackThread: (threadId) => refs.threadActions.rollbackThread(threadId), - compactThread: (threadId) => refs.threadActions.compactThread(threadId), - archiveThread: (threadId) => refs.threadActions.archiveThread(threadId), - renameThread: (threadId, name) => refs.threadActions.renameThread(threadId, name).then(() => undefined), - reconnect: () => refs.reconnectActions.reconnectPanel(), - toggleFastMode: () => refs.runtimeSettings.toggleFastMode(), - toggleCollaborationMode: () => refs.runtimeSettings.toggleCollaborationMode(), - toggleAutoReview: () => void refs.runtimeSettings.toggleAutoReview(), - requestModel: (model) => refs.runtimeSettings.requestModel(model), - resetModelToConfig: () => refs.runtimeSettings.resetModelToConfig(), - requestReasoningEffort: (effort) => refs.runtimeSettings.requestReasoningEffort(effort), - resetReasoningEffortToConfig: () => refs.runtimeSettings.resetReasoningEffortToConfig(), - activeGoal: () => refs.goals.activeGoal(), - setGoalObjective: (threadId, objective, tokenBudget) => refs.goals.setObjective(threadId, objective, tokenBudget), - setGoalStatus: (threadId, goalStatus) => refs.goals.setStatus(threadId, goalStatus), - clearGoal: (threadId) => refs.goals.clear(threadId), - addSystemMessage: status.addSystemMessage, - addStructuredSystemMessage: status.addStructuredSystemMessage, - setStatus: status.set, - statusSummaryLines: runtime.statusSummaryLines, - connectionDiagnosticDetails: runtime.connectionDiagnosticDetails, - mcpStatusLines: runtime.mcpStatusLines, - modelStatusLines: runtime.modelStatusLines, - effortStatusLines: runtime.effortStatusLines, - }); - const planImplementation = createPlanImplementation({ - stateStore, - currentClient, - ensureConnected: client.ensureConnected, - sendTurnText: (text) => turnSubmission.sendTurnText(text), - requestDefaultCollaborationModeForNextTurn: () => { - refs.runtimeSettings.requestDefaultCollaborationModeForNextTurn(); - }, - }); - - const messageStreamPresenter = new MessageStreamPresenter({ - obsidian: { - app, - owner, - }, - state: { - store: stateStore, - }, - workspace: { + const turnActions = createConversationTurnActions( + { vaultPath: plugin.vaultPath, - }, - scroll: { - consumeIntent: () => messageScrollIntent.consumeIntent(), - registerVirtualizer: messageStreamScrollBridge.registerVirtualizer, - dispose: () => { - messageStreamScrollBridge.dispose(); + stateStore, + client: { + currentClient, + ensureConnected: client.ensureConnected, + }, + status, + runtime, + thread, + composer: { + codexInput: composer.codexInput, + trimmedDraft: () => composerController.trimmedDraft, + setDraft: composer.setDraft, + }, + scroll: { + followBottom: scroll.followBottom, }, }, - history: { - loadOlderTurns: () => void refs.history.loadOlder(), + { + serverThreads: refs.serverThreads, + runtimeSettings: refs.runtimeSettings, + threadActions: refs.threadActions, + reconnectActions: refs.reconnectActions, + goals: refs.goals, }, - actions: { - rollbackThread: (threadId) => void refs.threadActions.rollbackThread(threadId), - forkThreadFromTurn: (threadId, turnId, archiveSource) => void refs.threadActions.forkThreadFromTurn(threadId, turnId, archiveSource), - implementPlan: (item) => void planImplementation.implement(item), - openTurnDiff: (state) => void plugin.openTurnDiff(state), + ); + + const messageStreamPresenter = createConversationMessageStreamPresenter( + { + obsidian: { + app, + owner, + }, + plugin, + stateStore, + lifecycle, + scroll: { + bridge: messageStreamScrollBridge, + }, + surface: { + pendingRequestsSignature: surface.pendingRequestsSignature, + }, }, - requests: { - pendingSignature: surface.pendingRequestsSignature, - pendingSnapshot: () => pendingRequests.snapshot(), - pendingActions: () => pendingRequests.actions(), - consumePendingAutoFocus: () => pendingRequests.consumeAutoFocus(), + { + history: refs.history, + threadActions: refs.threadActions, + pendingRequests, + planImplementation: turnActions.planImplementation, }, - }); - const composerSubmit = createComposerSubmitActions({ - stateStore, - composer: composerController, - slashCommands, - turnSubmission, - connection: { - currentClient, - ensureConnected: client.ensureConnected, - }, - status: { - setStatus: status.set, - addSystemMessage: status.addSystemMessage, - }, - scroll: { - followBottom: scroll.followBottom, - }, - }); - composerController.setActionHandlers({ - submit: () => void composerSubmit.submit(), - threadScrollFromComposer: (action) => { - messageStreamScrollBridge.scrollFromComposer(action); - }, - }); + ); return { pendingRequests, messageStreamPresenter, composerController, - composerSubmit, + composerSubmit: turnActions.composerSubmit, }; } diff --git a/src/features/chat/conversation/message-stream/composition.ts b/src/features/chat/conversation/message-stream/composition.ts new file mode 100644 index 00000000..6d6ad6ef --- /dev/null +++ b/src/features/chat/conversation/message-stream/composition.ts @@ -0,0 +1,77 @@ +import type { App, Component } from "obsidian"; + +import type { CodexChatHost } from "../../chat-host"; +import type { DisplayItem } from "../../display/types"; +import { MessageStreamPresenter } from "../../panel/surface/message-stream-presenter"; +import type { MessageStreamScrollBridge } from "../../panel/surface/message-stream-scroll"; +import type { ChatStateStore } from "../../state/reducer"; +import type { ChatThreadActions } from "../../threads/action-context"; +import type { HistoryController } from "../../threads/history-controller"; +import type { ChatMessageScrollIntentState } from "../../ui/message-stream/scroll-intent-state"; +import type { PendingRequestController } from "../pending-requests/controller"; +import type { PlanImplementation } from "../turns/plan-implementation"; + +export interface ConversationMessageStreamContext { + obsidian: { + app: App; + owner: Component; + }; + plugin: CodexChatHost; + stateStore: ChatStateStore; + lifecycle: { + messageScrollIntent: ChatMessageScrollIntentState; + }; + scroll: { + bridge: MessageStreamScrollBridge; + }; + surface: { + pendingRequestsSignature: () => string; + }; +} + +export interface ConversationMessageStreamRefs { + history: HistoryController; + threadActions: ChatThreadActions; + pendingRequests: PendingRequestController; + planImplementation: PlanImplementation; +} + +export function createConversationMessageStreamPresenter( + context: ConversationMessageStreamContext, + refs: ConversationMessageStreamRefs, +): MessageStreamPresenter { + const { obsidian, plugin, stateStore, lifecycle, scroll, surface } = context; + const { messageScrollIntent } = lifecycle; + const scrollBridge = scroll.bridge; + return new MessageStreamPresenter({ + obsidian, + state: { + store: stateStore, + }, + workspace: { + vaultPath: plugin.vaultPath, + }, + scroll: { + consumeIntent: () => messageScrollIntent.consumeIntent(), + registerVirtualizer: scrollBridge.registerVirtualizer, + dispose: () => { + scrollBridge.dispose(); + }, + }, + history: { + loadOlderTurns: () => void refs.history.loadOlder(), + }, + actions: { + rollbackThread: (threadId) => void refs.threadActions.rollbackThread(threadId), + forkThreadFromTurn: (threadId, turnId, archiveSource) => void refs.threadActions.forkThreadFromTurn(threadId, turnId, archiveSource), + implementPlan: (item: DisplayItem) => void refs.planImplementation.implement(item), + openTurnDiff: (state) => void plugin.openTurnDiff(state), + }, + requests: { + pendingSignature: surface.pendingRequestsSignature, + pendingSnapshot: () => refs.pendingRequests.snapshot(), + pendingActions: () => refs.pendingRequests.actions(), + consumePendingAutoFocus: () => refs.pendingRequests.consumeAutoFocus(), + }, + }); +} diff --git a/src/features/chat/conversation/turns/composition.ts b/src/features/chat/conversation/turns/composition.ts new file mode 100644 index 00000000..0501b2e2 --- /dev/null +++ b/src/features/chat/conversation/turns/composition.ts @@ -0,0 +1,155 @@ +import type { AppServerClient } from "../../../../app-server/connection/client"; +import type { CodexInput } from "../../../../domain/chat/input"; +import type { ChatServerThreadActions } from "../../connection/server-actions/threads"; +import type { ChatReconnectActions } from "../../connection/reconnect-actions"; +import type { DisplayDetailSection } from "../../display/types"; +import type { ChatRuntimeSettingsActions } from "../../runtime/settings-actions"; +import type { ChatStateStore } from "../../state/reducer"; +import type { ChatThreadActions } from "../../threads/action-context"; +import type { GoalActions } from "../../threads/goal-actions"; +import { createComposerSubmitActions, type ComposerSubmitActions } from "./composer-submit-actions"; +import { createPlanImplementation, type PlanImplementation } from "./plan-implementation"; +import { createSlashCommandHandler } from "./slash-command-handler"; +import { TurnSubmissionController } from "./turn-submission-controller"; + +export interface ConversationTurnActionsContext { + vaultPath: string; + stateStore: ChatStateStore; + client: { + currentClient: () => AppServerClient | null; + ensureConnected: () => Promise; + }; + status: { + set: (status: string) => void; + addSystemMessage: (text: string) => void; + addStructuredSystemMessage: (text: string, details: DisplayDetailSection[]) => void; + }; + runtime: { + connectionDiagnosticDetails: () => DisplayDetailSection[]; + modelStatusLines: () => string[]; + effortStatusLines: () => string[]; + statusSummaryLines: () => string[]; + mcpStatusLines: () => Promise; + }; + thread: { + ensureRestoredThreadLoaded: () => Promise; + startNewThread: () => Promise; + selectThread: (threadId: string) => Promise; + notifyIdentityChanged: () => void; + resetTurnPresence: (hadTurns: boolean) => void; + }; + composer: { + codexInput: (text: string) => CodexInput; + trimmedDraft: () => string; + setDraft: (text: string, options?: { focus?: boolean; clearSuggestions?: boolean }) => void; + }; + scroll: { + followBottom: () => void; + }; +} + +export interface ConversationTurnActionsRefs { + serverThreads: ChatServerThreadActions; + runtimeSettings: ChatRuntimeSettingsActions; + threadActions: ChatThreadActions; + reconnectActions: ChatReconnectActions; + goals: GoalActions; +} + +export interface ConversationTurnActions { + planImplementation: PlanImplementation; + composerSubmit: ComposerSubmitActions; +} + +export function createConversationTurnActions( + context: ConversationTurnActionsContext, + refs: ConversationTurnActionsRefs, +): ConversationTurnActions { + const { vaultPath, stateStore, client, status, runtime, thread, composer, scroll } = context; + const turnSubmission = new TurnSubmissionController({ + stateStore, + vaultPath, + currentClient: client.currentClient, + ensureRestoredThreadLoaded: thread.ensureRestoredThreadLoaded, + startThread: (preview) => refs.serverThreads.startThread(preview), + notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged, + resetThreadTurnPresence: thread.resetTurnPresence, + applyPendingThreadSettings: () => refs.runtimeSettings.applyPendingThreadSettings(), + codexInput: composer.codexInput, + setDraft: composer.setDraft, + setStatus: status.set, + addSystemMessage: status.addSystemMessage, + }); + const slashCommands = createSlashCommandHandler({ + stateStore, + currentClient: client.currentClient, + codexInput: composer.codexInput, + startNewThread: thread.startNewThread, + startThreadForGoal: (objective) => startThreadForGoal(refs.serverThreads, objective), + resumeThread: thread.selectThread, + forkThread: (threadId) => refs.threadActions.forkThread(threadId), + rollbackThread: (threadId) => refs.threadActions.rollbackThread(threadId), + compactThread: (threadId) => refs.threadActions.compactThread(threadId), + archiveThread: (threadId, saveMarkdown) => refs.threadActions.archiveThread(threadId, saveMarkdown), + renameThread: (threadId, name) => refs.threadActions.renameThread(threadId, name).then(() => undefined), + reconnect: () => refs.reconnectActions.reconnectPanel(), + toggleFastMode: () => refs.runtimeSettings.toggleFastMode(), + toggleCollaborationMode: () => refs.runtimeSettings.toggleCollaborationMode(), + toggleAutoReview: () => void refs.runtimeSettings.toggleAutoReview(), + requestModel: (model) => refs.runtimeSettings.requestModel(model), + resetModelToConfig: () => refs.runtimeSettings.resetModelToConfig(), + requestReasoningEffort: (effort) => refs.runtimeSettings.requestReasoningEffort(effort), + resetReasoningEffortToConfig: () => refs.runtimeSettings.resetReasoningEffortToConfig(), + activeGoal: () => refs.goals.activeGoal(), + setGoalObjective: (threadId, objective, tokenBudget) => refs.goals.setObjective(threadId, objective, tokenBudget), + setGoalStatus: (threadId, goalStatus) => refs.goals.setStatus(threadId, goalStatus), + clearGoal: (threadId) => refs.goals.clear(threadId), + addSystemMessage: status.addSystemMessage, + addStructuredSystemMessage: status.addStructuredSystemMessage, + setStatus: status.set, + statusSummaryLines: runtime.statusSummaryLines, + connectionDiagnosticDetails: runtime.connectionDiagnosticDetails, + mcpStatusLines: runtime.mcpStatusLines, + modelStatusLines: runtime.modelStatusLines, + effortStatusLines: runtime.effortStatusLines, + }); + const planImplementation = createPlanImplementation({ + stateStore, + currentClient: client.currentClient, + ensureConnected: client.ensureConnected, + sendTurnText: (text) => turnSubmission.sendTurnText(text), + requestDefaultCollaborationModeForNextTurn: () => { + refs.runtimeSettings.requestDefaultCollaborationModeForNextTurn(); + }, + }); + const composerSubmit = createComposerSubmitActions({ + stateStore, + composer: { + get trimmedDraft() { + return composer.trimmedDraft(); + }, + setDraft: composer.setDraft, + }, + slashCommands, + turnSubmission, + connection: { + currentClient: client.currentClient, + ensureConnected: client.ensureConnected, + }, + status: { + setStatus: status.set, + addSystemMessage: status.addSystemMessage, + }, + scroll, + }); + + return { + planImplementation, + composerSubmit, + }; +} + +async function startThreadForGoal(actions: ChatServerThreadActions, objective: string): Promise { + const response = await actions.startThread(objective, { syncGoal: false }); + return response?.threadId ?? null; +} diff --git a/src/features/chat/panel/surface/composer.ts b/src/features/chat/panel/surface/composer.ts index 922ee527..00b172eb 100644 --- a/src/features/chat/panel/surface/composer.ts +++ b/src/features/chat/panel/surface/composer.ts @@ -30,7 +30,11 @@ import { runtimeSnapshotForShellState } from "./runtime-snapshot"; type ComposerMetaState = Pick; export interface ChatPanelComposerController { - renderState(state: ChatPanelComposerShellState): ComposerShellProps; + renderState(state: ChatPanelComposerShellState, actions: ChatPanelComposerActions): ComposerShellProps; +} + +export interface ChatPanelComposerActions { + submit: () => void; } export interface RuntimeComposerChoicesInput { @@ -45,9 +49,15 @@ export function composerPlaceholder(threadName: string | null): string { return threadName ? `Ask Codex to work on “${threadName}”...` : "Ask Codex to work on this task..."; } -export function ChatPanelComposer({ controller }: { controller: ChatPanelComposerController }): UiNode { +export function ChatPanelComposer({ + controller, + actions, +}: { + controller: ChatPanelComposerController; + actions: ChatPanelComposerActions; +}): UiNode { const state = composerStateFromShellState(useChatPanelShellState()); - return h(ComposerShell, controller.renderState(state)); + return h(ComposerShell, controller.renderState(state, actions)); } export function chatPanelComposerPlaceholder(surface: ChatPanelComposerSurface, state: ChatPanelComposerShellState): string { diff --git a/src/features/chat/panel/surface/create-surface.ts b/src/features/chat/panel/surface/create-surface.ts index 3ccc2130..20a38645 100644 --- a/src/features/chat/panel/surface/create-surface.ts +++ b/src/features/chat/panel/surface/create-surface.ts @@ -1,6 +1,6 @@ import type { CodexPanelSettings } from "../../../../settings/model"; import type { ConnectionManager } from "../../../../app-server/connection/connection-manager"; -import { type ChatAction, type ChatState, type ChatStateStore } from "../../state/reducer"; +import type { ChatStateStore } from "../../state/reducer"; import type { ChatConnectionController } from "../../connection/connection-controller"; import type { ChatReconnectActions } from "../../connection/reconnect-actions"; import type { ChatInboundController } from "../../protocol/inbound/controller"; @@ -12,6 +12,8 @@ import type { SelectionActions } from "../../threads/selection-actions"; import type { ChatRuntimeSettingsActions } from "../../runtime/settings-actions"; import type { GoalActions } from "../../threads/goal-actions"; import type { RestoredThreadTitleSnapshot, ChatPanelSurface } from "./model"; +import { createChatPanelGoalSurface } from "./goal-surface"; +import { createChatPanelToolbarSurface } from "./toolbar-surface"; export interface ChatPanelSurfaceHost { settings: CodexPanelSettings; @@ -36,108 +38,37 @@ export interface ChatPanelSurfaceDependencies { } export function createChatPanelSurface(host: ChatPanelSurfaceHost, deps: ChatPanelSurfaceDependencies): ChatPanelSurface { - const dispatch = (action: ChatAction): void => { - host.stateStore.dispatch(action); - }; - const startGoalEditing = ({ closeToolbarPanel = false }: { closeToolbarPanel?: boolean } = {}): void => { - if (closeToolbarPanel) dispatch({ type: "ui/panel-set", panel: null }); - const goal = host.stateStore.getState().activeThread.goal; - dispatch({ - type: "ui/goal-editor-started", - threadId: goal?.threadId ?? null, - objective: goal?.objective ?? "", - tokenBudget: goal?.tokenBudget ?? null, - }); - }; - return { - toolbar: { - state: { - connected: () => deps.connection.isConnected(), - nowMs: () => Date.now(), + toolbar: createChatPanelToolbarSurface( + { + settings: host.settings, + vaultPath: host.vaultPath, + stateStore: host.stateStore, + startNewThread: host.startNewThread, }, - settings: { - vaultPath: () => host.vaultPath, - configuredCommand: () => host.settings.codexPath, - archiveExportEnabled: () => host.settings.archiveExportEnabled, + { + connection: deps.connection, + connectionController: deps.connectionController, + reconnectActions: deps.reconnectActions, + inboundController: deps.inboundController, + threadActions: deps.threadActions, + toolbarPanels: deps.toolbarPanels, + rename: deps.rename, + selection: deps.selection, }, - actions: { - toolbar: { - startNewThread: () => { - void host.startNewThread(); - }, - toggleChatActions: () => { - deps.toolbarPanels.toggleChatActions(); - }, - compactConversation: () => { - void compactConversation(host.stateStore.getState(), deps); - }, - setGoal: () => { - startGoalEditing({ closeToolbarPanel: true }); - }, - toggleHistory: () => { - deps.toolbarPanels.toggleHistory(); - }, - toggleStatusPanel: () => { - deps.toolbarPanels.toggleStatus(); - }, - connect: () => { - void deps.reconnectActions.reconnectPanel(); - }, - refreshStatus: () => { - void deps.connectionController.refreshStatusPanel(); - }, - resumeThread: (threadId) => { - void deps.selection.selectThreadFromToolbar(threadId); - }, - startArchiveThread: (threadId) => { - deps.toolbarPanels.startArchive(threadId); - }, - archiveThread: (threadId, saveMarkdown) => { - void deps.toolbarPanels.archiveThread(threadId, saveMarkdown); - }, - startRenameThread: (threadId) => { - deps.rename.start(threadId); - }, - updateRenameDraft: (threadId, value) => { - deps.rename.updateDraft(threadId, value); - }, - saveRenameThread: (threadId, value) => { - void deps.rename.save(threadId, value); - }, - cancelRenameThread: (threadId) => { - deps.rename.cancel(threadId); - }, - autoNameThread: (threadId) => { - void deps.rename.autoNameDraft(threadId); - }, - }, + ), + goal: createChatPanelGoalSurface( + { + settings: host.settings, + stateStore: host.stateStore, }, - }, - goal: { - settings: { - sendShortcut: () => host.settings.sendShortcut, + { + connectionController: deps.connectionController, + inboundController: deps.inboundController, + serverThreads: deps.serverThreads, + goals: deps.goals, }, - actions: { - goal: { - saveObjective: (objective, tokenBudget) => saveGoalObjective(host.stateStore.getState(), deps, objective, tokenBudget), - setStatus: (threadId, status) => deps.goals.setStatus(threadId, status), - clear: (threadId) => deps.goals.clear(threadId), - startEditing: (threadId, objective, tokenBudget) => { - dispatch({ type: "ui/goal-editor-started", threadId, objective, tokenBudget }); - }, - updateObjectiveDraft: (objective) => { - dispatch({ type: "ui/goal-editor-draft-updated", objective }); - }, - setObjectiveExpanded: (threadId, expanded) => { - dispatch({ type: "ui/disclosure-set", bucket: "goalObjectiveExpanded", id: threadId, open: expanded }); - }, - closeEditor: () => { - dispatch({ type: "ui/goal-editor-closed" }); - }, - }, - }, - }, + ), composer: { thread: { restoredPlaceholder: host.restoredThreadPlaceholder, @@ -150,33 +81,3 @@ export function createChatPanelSurface(host: ChatPanelSurfaceHost, deps: ChatPan }, }; } - -async function compactConversation(state: ChatState, deps: ChatPanelSurfaceDependencies): Promise { - const threadId = state.activeThread.id; - if (!threadId) { - deps.inboundController.addSystemMessage("No active thread to compact."); - return; - } - await deps.threadActions.compactThread(threadId); -} - -async function saveGoalObjective( - state: ChatState, - deps: ChatPanelSurfaceDependencies, - objective: string, - tokenBudget: number | null, -): Promise { - let threadId = state.activeThread.id; - if (!threadId) { - try { - await deps.connectionController.ensureConnected(); - const response = await deps.serverThreads.startThread(objective, { syncGoal: false }); - threadId = response?.threadId ?? null; - } catch (error) { - deps.inboundController.addSystemMessage(error instanceof Error ? error.message : String(error)); - return; - } - } - if (!threadId) return; - void deps.goals.setObjective(threadId, objective, tokenBudget); -} diff --git a/src/features/chat/panel/surface/goal-surface.ts b/src/features/chat/panel/surface/goal-surface.ts new file mode 100644 index 00000000..bb8207c6 --- /dev/null +++ b/src/features/chat/panel/surface/goal-surface.ts @@ -0,0 +1,71 @@ +import type { CodexPanelSettings } from "../../../../settings/model"; +import type { ChatConnectionController } from "../../connection/connection-controller"; +import type { ChatInboundController } from "../../protocol/inbound/controller"; +import type { ChatServerThreadActions } from "../../connection/server-actions/threads"; +import type { GoalActions } from "../../threads/goal-actions"; +import type { ChatAction, ChatState, ChatStateStore } from "../../state/reducer"; +import type { ChatPanelGoalSurface } from "./model"; + +export interface ChatPanelGoalSurfaceHost { + settings: CodexPanelSettings; + stateStore: ChatStateStore; +} + +export interface ChatPanelGoalSurfaceDependencies { + connectionController: ChatConnectionController; + inboundController: ChatInboundController; + serverThreads: ChatServerThreadActions; + goals: GoalActions; +} + +export function createChatPanelGoalSurface(host: ChatPanelGoalSurfaceHost, deps: ChatPanelGoalSurfaceDependencies): ChatPanelGoalSurface { + const dispatch = (action: ChatAction): void => { + host.stateStore.dispatch(action); + }; + + return { + settings: { + sendShortcut: () => host.settings.sendShortcut, + }, + actions: { + goal: { + saveObjective: (objective, tokenBudget) => saveGoalObjective(host.stateStore.getState(), deps, objective, tokenBudget), + setStatus: (threadId, status) => deps.goals.setStatus(threadId, status), + clear: (threadId) => deps.goals.clear(threadId), + startEditing: (threadId, objective, tokenBudget) => { + dispatch({ type: "ui/goal-editor-started", threadId, objective, tokenBudget }); + }, + updateObjectiveDraft: (objective) => { + dispatch({ type: "ui/goal-editor-draft-updated", objective }); + }, + setObjectiveExpanded: (threadId, expanded) => { + dispatch({ type: "ui/disclosure-set", bucket: "goalObjectiveExpanded", id: threadId, open: expanded }); + }, + closeEditor: () => { + dispatch({ type: "ui/goal-editor-closed" }); + }, + }, + }, + }; +} + +async function saveGoalObjective( + state: ChatState, + deps: ChatPanelGoalSurfaceDependencies, + objective: string, + tokenBudget: number | null, +): Promise { + let threadId = state.activeThread.id; + if (!threadId) { + try { + await deps.connectionController.ensureConnected(); + const response = await deps.serverThreads.startThread(objective, { syncGoal: false }); + threadId = response?.threadId ?? null; + } catch (error) { + deps.inboundController.addSystemMessage(error instanceof Error ? error.message : String(error)); + return; + } + } + if (!threadId) return; + void deps.goals.setObjective(threadId, objective, tokenBudget); +} diff --git a/src/features/chat/panel/surface/toolbar-surface.ts b/src/features/chat/panel/surface/toolbar-surface.ts new file mode 100644 index 00000000..a06d2893 --- /dev/null +++ b/src/features/chat/panel/surface/toolbar-surface.ts @@ -0,0 +1,117 @@ +import type { CodexPanelSettings } from "../../../../settings/model"; +import type { ConnectionManager } from "../../../../app-server/connection/connection-manager"; +import type { ChatConnectionController } from "../../connection/connection-controller"; +import type { ChatReconnectActions } from "../../connection/reconnect-actions"; +import type { ChatInboundController } from "../../protocol/inbound/controller"; +import type { ChatThreadActions } from "../../threads/action-context"; +import type { ToolbarPanelActions } from "../toolbar-actions"; +import type { RenameController } from "../../threads/rename-controller"; +import type { SelectionActions } from "../../threads/selection-actions"; +import type { ChatStateStore } from "../../state/reducer"; +import type { ChatPanelToolbarSurface } from "./model"; + +export interface ChatPanelToolbarSurfaceHost { + settings: CodexPanelSettings; + vaultPath: string; + stateStore: ChatStateStore; + startNewThread: () => Promise; +} + +export interface ChatPanelToolbarSurfaceDependencies { + connection: ConnectionManager; + connectionController: ChatConnectionController; + reconnectActions: ChatReconnectActions; + inboundController: ChatInboundController; + threadActions: ChatThreadActions; + toolbarPanels: ToolbarPanelActions; + rename: RenameController; + selection: SelectionActions; +} + +export function createChatPanelToolbarSurface( + host: ChatPanelToolbarSurfaceHost, + deps: ChatPanelToolbarSurfaceDependencies, +): ChatPanelToolbarSurface { + return { + state: { + connected: () => deps.connection.isConnected(), + nowMs: () => Date.now(), + }, + settings: { + vaultPath: () => host.vaultPath, + configuredCommand: () => host.settings.codexPath, + archiveExportEnabled: () => host.settings.archiveExportEnabled, + }, + actions: { + toolbar: { + startNewThread: () => { + void host.startNewThread(); + }, + toggleChatActions: () => { + deps.toolbarPanels.toggleChatActions(); + }, + compactConversation: () => { + void compactConversation(host.stateStore.getState(), deps); + }, + setGoal: () => { + host.stateStore.dispatch({ type: "ui/panel-set", panel: null }); + const goal = host.stateStore.getState().activeThread.goal; + host.stateStore.dispatch({ + type: "ui/goal-editor-started", + threadId: goal?.threadId ?? null, + objective: goal?.objective ?? "", + tokenBudget: goal?.tokenBudget ?? null, + }); + }, + toggleHistory: () => { + deps.toolbarPanels.toggleHistory(); + }, + toggleStatusPanel: () => { + deps.toolbarPanels.toggleStatus(); + }, + connect: () => { + void deps.reconnectActions.reconnectPanel(); + }, + refreshStatus: () => { + void deps.connectionController.refreshStatusPanel(); + }, + resumeThread: (threadId) => { + void deps.selection.selectThreadFromToolbar(threadId); + }, + startArchiveThread: (threadId) => { + deps.toolbarPanels.startArchive(threadId); + }, + archiveThread: (threadId, saveMarkdown) => { + void deps.toolbarPanels.archiveThread(threadId, saveMarkdown); + }, + startRenameThread: (threadId) => { + deps.rename.start(threadId); + }, + updateRenameDraft: (threadId, value) => { + deps.rename.updateDraft(threadId, value); + }, + saveRenameThread: (threadId, value) => { + void deps.rename.save(threadId, value); + }, + cancelRenameThread: (threadId) => { + deps.rename.cancel(threadId); + }, + autoNameThread: (threadId) => { + void deps.rename.autoNameDraft(threadId); + }, + }, + }, + }; +} + +async function compactConversation( + state: ReturnType, + deps: Pick, +): Promise { + const threadId = state.activeThread.id; + if (!threadId) { + deps.inboundController.addSystemMessage("No active thread to compact."); + return; + } + await deps.threadActions.compactThread(threadId); +} diff --git a/src/features/chat/session.ts b/src/features/chat/session.ts index 2f2a7472..b3aa20ed 100644 --- a/src/features/chat/session.ts +++ b/src/features/chat/session.ts @@ -16,7 +16,7 @@ import { createChatServerDiagnosticsActions, type ChatServerDiagnosticsActions } import { createChatServerMetadataActions, type ChatServerMetadataActions } from "./connection/server-actions/metadata"; import { createChatServerThreadActions, type ChatServerThreadActions } from "./connection/server-actions/threads"; import type { ChatComposerController } from "./conversation/composer/controller"; -import { createConversationControllers } from "./conversation/composition"; +import { createConversationParts } from "./conversation/composition"; import type { PendingRequestController } from "./conversation/pending-requests/controller"; import type { ComposerSubmitActions } from "./conversation/turns/composer-submit-actions"; import { createStructuredSystemItem, createSystemItem } from "./display/items/system"; @@ -63,7 +63,7 @@ import type { RenameController } from "./threads/rename-controller"; import type { RestorationController } from "./threads/restoration-controller"; import type { ResumeController } from "./threads/resume-controller"; import type { SelectionActions } from "./threads/selection-actions"; -import { createThreadSelectionActions, createThreadServices } from "./threads/services"; +import { createThreadParts, createThreadSelectionActions } from "./threads/composition"; import type { MessageStreamPresenter } from "./panel/surface/message-stream-presenter"; import { pendingRequestsSignature } from "./conversation/pending-requests/signatures"; import { createChatPanelSurface } from "./panel/surface/create-surface"; @@ -292,16 +292,29 @@ export class ChatPanelSession { } private createSessionParts(): ChatPanelSessionParts { - const connection = new ConnectionManager(() => this.environment.plugin.settings.codexPath, this.environment.plugin.vaultPath); + const connection = new ConnectionManager(() => this.environment.plugin.settings.codexPath, this.environment.plugin.vaultPath, { + onNotification: (notification) => { + this.parts.inbound.controller.handleNotification(notification); + this.refreshLiveState(); + }, + onServerRequest: (request) => { + this.parts.inbound.controller.handleServerRequest(request); + this.refreshLiveState(); + }, + onLog: (message) => { + this.parts.inbound.controller.handleAppServerLog(message); + }, + onExit: () => { + this.parts.connection.controller.handleExit(); + }, + }); const sideEffects = this.createSideEffects(); - let connectionController: ChatConnectionController | null = null; - let selection: SelectionActions | null = null; const currentClient = () => this.client; - const ensureConnected = () => requireSessionPart(connectionController, "connection controller").ensureConnected(); - const refreshThreads = () => requireSessionPart(connectionController, "connection controller").refreshThreads(); - const refreshSkills = (forceReload?: boolean) => - requireSessionPart(connectionController, "connection controller").refreshSkills(forceReload); - const selectThread = (threadId: string) => requireSessionPart(selection, "selection actions").selectThread(threadId); + const ensureConnected = () => this.parts.connection.controller.ensureConnected(); + const refreshThreads = () => this.parts.connection.controller.refreshThreads(); + const refreshSkills = (forceReload?: boolean) => this.parts.connection.controller.refreshSkills(forceReload); + const selectThread = (threadId: string) => this.parts.thread.selection.selectThread(threadId); + const resumeRestoredThread = (threadId: string) => this.parts.thread.resume.resumeThread(threadId); const runtimeSettings = createChatRuntimeSettingsActions({ stateStore: this.stateStore, @@ -310,7 +323,7 @@ export class ChatPanelSession { collaborationModeLabel: () => this.collaborationModeLabel(), addSystemMessage: sideEffects.status.addSystemMessage, }); - const threadControllers = createThreadServices( + const threadParts = createThreadParts( { obsidian: { archiveAdapter: this.environment.obsidian.archiveAdapter, @@ -332,6 +345,7 @@ export class ChatPanelSession { status: sideEffects.status, thread: { selectThread, + resumeRestoredThread, refreshThreads, notifyIdentityChanged: () => { this.notifyActiveThreadIdentityChanged(); @@ -359,12 +373,12 @@ export class ChatPanelSession { connection, }, ); - const { history, actions: threadActions, goals, identity, restoration, resume, rename, autoTitle } = threadControllers; + const { history, actions: threadActions, goals, identity, restoration, resume, rename, autoTitle } = threadParts; const toolbarPanels = createToolbarPanelActions({ stateStore: this.stateStore, threadActions, }); - selection = createThreadSelectionActions( + const selection = createThreadSelectionActions( { plugin: this.environment.plugin, state: { @@ -380,7 +394,7 @@ export class ChatPanelSession { toolbarPanels.closeForThreadSelection(); }, }, - ).selection; + ); const reconnectActions = createChatReconnectActions({ stateStore: this.stateStore, @@ -458,7 +472,7 @@ export class ChatPanelSession { respondToServerRequest: (requestId, result) => respondToServerRequest(serverRequestHost, requestId, result), rejectServerRequest: (requestId, code, message) => rejectServerRequest(serverRequestHost, requestId, code, message), }); - connectionController = new ChatConnectionController({ + const connectionController = new ChatConnectionController({ stateStore: this.stateStore, connection, connectionWork: this.connectionWork, @@ -504,23 +518,6 @@ export class ChatPanelSession { }, }); - connection.setHandlers({ - onNotification: (notification) => { - inboundController.handleNotification(notification); - this.refreshLiveState(); - }, - onServerRequest: (request) => { - inboundController.handleServerRequest(request); - this.refreshLiveState(); - }, - onLog: (message) => { - inboundController.handleAppServerLog(message); - }, - onExit: () => { - requireSessionPart(connectionController, "connection controller").handleExit(); - }, - }); - const surface = createChatPanelSurface( { settings: this.environment.plugin.settings, @@ -543,7 +540,7 @@ export class ChatPanelSession { goals, }, ); - const conversationControllers = createConversationControllers( + const conversationParts = createConversationParts( { obsidian: { app: this.environment.obsidian.app, @@ -614,8 +611,8 @@ export class ChatPanelSession { history, }, ); - const { pendingRequests, composerSubmit, messageStreamPresenter } = conversationControllers; - const composerController = conversationControllers.composerController; + const { pendingRequests, composerSubmit, messageStreamPresenter } = conversationParts; + const composerController = conversationParts.composerController; return { connection: { @@ -770,7 +767,12 @@ export class ChatPanelSession { toolbar: this.parts.surface.toolbar, goal: this.parts.surface.goal, messageStream: this.parts.render.messageStreamPresenter, - composer: this.parts.composer.controller, + composer: { + controller: this.parts.composer.controller, + actions: { + submit: () => void this.parts.composer.submission.submit(), + }, + }, }, }); } @@ -895,8 +897,3 @@ export class ChatPanelSession { return createStructuredSystemItem(`system-${String(Date.now())}-${Math.random().toString(36).slice(2)}`, text, details); } } - -function requireSessionPart(part: T | null, name: string): T { - if (!part) throw new Error(`Chat panel session did not initialize ${name}.`); - return part; -} diff --git a/src/features/chat/threads/action-parts.ts b/src/features/chat/threads/action-parts.ts new file mode 100644 index 00000000..a26b1900 --- /dev/null +++ b/src/features/chat/threads/action-parts.ts @@ -0,0 +1,69 @@ +import type { AppServerClient } from "../../../app-server/connection/client"; +import type { ArchiveExportAdapter } from "../../thread-export/archive-markdown"; +import type { ChatStateStore } from "../state/reducer"; +import type { CodexChatHost } from "../chat-host"; +import { archiveThread } from "./archive-actions"; +import { compactThread } from "./compact-actions"; +import { forkThread, forkThreadFromTurn } from "./fork-actions"; +import { renameThread } from "./rename-actions"; +import { rollbackThread } from "./rollback-actions"; +import type { ChatThreadActions, ChatThreadActionsHost } from "./action-context"; + +export interface ThreadActionPartsContext { + obsidian: { + archiveAdapter: () => ArchiveExportAdapter; + }; + plugin: CodexChatHost; + stateStore: ChatStateStore; + client: { + currentClient: () => AppServerClient | null; + ensureConnected: () => Promise; + }; + status: { + set: (status: string) => void; + addSystemMessage: (text: string) => void; + }; + thread: { + selectThread: (threadId: string) => Promise; + refreshThreads: () => Promise; + notifyIdentityChanged: () => void; + }; + composer: { + setText: (text: string) => void; + }; +} + +export function createThreadActions(context: ThreadActionPartsContext): ChatThreadActions { + const { obsidian, plugin, stateStore, client, status, thread, composer } = context; + const host: ChatThreadActionsHost = { + stateStore, + vaultPath: plugin.vaultPath, + settings: () => plugin.settings, + archiveAdapter: obsidian.archiveAdapter, + ensureConnected: client.ensureConnected, + currentClient: client.currentClient, + addSystemMessage: status.addSystemMessage, + setStatus: status.set, + setComposerText: composer.setText, + openThreadInNewView: (threadId) => plugin.openThreadInNewView(threadId), + openThreadInCurrentPanel: thread.selectThread, + notifyThreadArchived: plugin.notifyThreadArchived.bind(plugin), + notifyThreadRenamed: (threadId, name) => { + plugin.notifyThreadRenamed(threadId, name); + }, + notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged, + refreshThreads: thread.refreshThreads, + refreshSharedThreadListFromOpenSurface: () => { + plugin.refreshSharedThreadListFromOpenSurface(); + }, + }; + + return { + compactThread: (threadId) => compactThread(host, threadId), + archiveThread: (threadId, saveMarkdown) => archiveThread(host, threadId, saveMarkdown), + forkThread: (threadId) => forkThread(host, threadId), + forkThreadFromTurn: (threadId, turnId, archiveSource) => forkThreadFromTurn(host, threadId, turnId, archiveSource), + renameThread: (threadId, name) => renameThread(host, threadId, name), + rollbackThread: (threadId) => rollbackThread(host, threadId), + }; +} diff --git a/src/features/chat/threads/composition.ts b/src/features/chat/threads/composition.ts new file mode 100644 index 00000000..875b33ab --- /dev/null +++ b/src/features/chat/threads/composition.ts @@ -0,0 +1,172 @@ +import type { ConnectionManager } from "../../../app-server/connection/connection-manager"; +import type { AppServerClient } from "../../../app-server/connection/client"; +import type { ArchiveExportAdapter } from "../../thread-export/archive-markdown"; +import { createGoalActions } from "./goal-actions"; +import { createSelectionActions } from "./selection-actions"; +import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../lifecycle"; +import type { ChatStateStore } from "../state/reducer"; +import type { CodexChatHost } from "../chat-host"; +import { createThreadNamingParts } from "./naming-parts"; +import { createThreadActions } from "./action-parts"; +import { createThreadLifecycleParts } from "./lifecycle-parts"; + +interface ThreadPartsContext { + obsidian: { + archiveAdapter: () => ArchiveExportAdapter; + }; + plugin: CodexChatHost; + state: { + stateStore: ChatStateStore; + }; + client: { + getClient: () => AppServerClient | null; + ensureConnected: () => Promise; + }; + lifecycle: { + deferredTasks: ChatViewDeferredTasks; + resumeWork: ChatResumeWorkTracker; + getOpened: () => boolean; + getClosing: () => boolean; + }; + thread: { + selectThread: (threadId: string) => Promise; + resumeRestoredThread: (threadId: string) => Promise; + refreshThreads: () => Promise; + notifyIdentityChanged: () => void; + refreshTabHeader: () => void; + }; + status: { + set: (status: string) => void; + addSystemMessage: (text: string) => void; + }; + liveState: { + refresh: () => void; + }; + scroll: { + preservePosition: () => void; + forceBottom: () => void; + }; + composer: { + setText: (text: string) => void; + }; +} + +export function createThreadParts( + context: ThreadPartsContext, + refs: { + connection: ConnectionManager; + }, +) { + const { obsidian, plugin, state, thread, status, liveState, scroll, client, composer, lifecycle } = context; + const stateStore = state.stateStore; + const currentClient = client.getClient; + + const naming = createThreadNamingParts( + { + plugin, + stateStore, + client: { + currentClient, + ensureConnected: client.ensureConnected, + }, + status: { + addSystemMessage: status.addSystemMessage, + }, + }, + { + connection: refs.connection, + }, + ); + const { rename, autoTitle, resetThreadTurnPresence } = naming; + + const actions = createThreadActions({ + obsidian, + plugin, + stateStore, + client: { + currentClient, + ensureConnected: client.ensureConnected, + }, + status, + thread: { + selectThread: thread.selectThread, + refreshThreads: thread.refreshThreads, + notifyIdentityChanged: thread.notifyIdentityChanged, + }, + composer, + }); + const goals = createGoalActions({ + stateStore, + currentClient, + ensureConnected: client.ensureConnected, + addSystemMessage: status.addSystemMessage, + addGoalEvent: (item) => { + stateStore.dispatch({ type: "message-stream/item-upserted", item }); + }, + refreshLiveState: liveState.refresh, + }); + const threadLifecycle = createThreadLifecycleParts({ + plugin, + stateStore, + client: { + currentClient, + ensureConnected: client.ensureConnected, + }, + lifecycle, + thread: { + resumeRestoredThread: thread.resumeRestoredThread, + notifyIdentityChanged: thread.notifyIdentityChanged, + refreshTabHeader: thread.refreshTabHeader, + }, + status, + liveState, + scroll, + goals, + resetThreadTurnPresence, + }); + const { history, restoration, resume, identity } = threadLifecycle; + + return { + history, + actions, + goals, + restoration, + resume, + identity, + rename, + autoTitle, + }; +} + +interface ThreadSelectionActionsContext { + plugin: CodexChatHost; + state: { + stateStore: ChatStateStore; + }; + status: { + addSystemMessage: (text: string) => void; + }; + thread: { + resumeThread: (threadId: string) => Promise; + }; +} + +export function createThreadSelectionActions( + context: ThreadSelectionActionsContext, + refs: { + closeForThreadSelection: () => void; + }, +) { + const { plugin, thread, status } = context; + const stateStore = context.state.stateStore; + + return createSelectionActions({ + stateStore, + closeForThreadSelection: () => { + refs.closeForThreadSelection(); + }, + focusThreadInOpenView: (threadId) => plugin.focusThreadInOpenView(threadId), + resumeThread: thread.resumeThread, + addSystemMessage: status.addSystemMessage, + }); +} diff --git a/src/features/chat/threads/lifecycle-parts.ts b/src/features/chat/threads/lifecycle-parts.ts new file mode 100644 index 00000000..2c7fe987 --- /dev/null +++ b/src/features/chat/threads/lifecycle-parts.ts @@ -0,0 +1,118 @@ +import type { AppServerClient } from "../../../app-server/connection/client"; +import { recoverRolloutTokenUsage } from "../../../app-server/services/rollout-token-usage"; +import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../lifecycle"; +import type { ChatStateStore } from "../state/reducer"; +import type { CodexChatHost } from "../chat-host"; +import type { GoalActions } from "./goal-actions"; +import { HistoryController } from "./history-controller"; +import { createIdentitySync } from "./identity-sync"; +import { ResumeController } from "./resume-controller"; +import { RestorationController } from "./restoration-controller"; + +export interface ThreadLifecyclePartsContext { + plugin: CodexChatHost; + stateStore: ChatStateStore; + client: { + currentClient: () => AppServerClient | null; + ensureConnected: () => Promise; + }; + lifecycle: { + deferredTasks: ChatViewDeferredTasks; + resumeWork: ChatResumeWorkTracker; + getOpened: () => boolean; + getClosing: () => boolean; + }; + thread: { + resumeRestoredThread: (threadId: string) => Promise; + notifyIdentityChanged: () => void; + refreshTabHeader: () => void; + }; + status: { + set: (status: string) => void; + addSystemMessage: (text: string) => void; + }; + liveState: { + refresh: () => void; + }; + scroll: { + preservePosition: () => void; + forceBottom: () => void; + }; + goals: GoalActions; + resetThreadTurnPresence: (hadTurns: boolean) => void; +} + +export interface ThreadLifecycleParts { + history: HistoryController; + restoration: RestorationController; + resume: ResumeController; + identity: ReturnType; +} + +export function createThreadLifecycleParts(context: ThreadLifecyclePartsContext): ThreadLifecycleParts { + const { plugin, stateStore, client, lifecycle, thread, status, liveState, scroll, goals, resetThreadTurnPresence } = context; + const { deferredTasks, resumeWork } = lifecycle; + const history = new HistoryController({ + stateStore, + currentClient: client.currentClient, + addSystemMessage: status.addSystemMessage, + keepCurrentScrollPosition: scroll.preservePosition, + showLatestPageAtBottom: scroll.forceBottom, + setThreadTurnPresence: resetThreadTurnPresence, + }); + const invalidateResumeWork = () => { + resumeWork.invalidate(); + history.invalidate(); + }; + const restoration = new RestorationController({ + deferredTasks, + opened: lifecycle.getOpened, + resumeThread: thread.resumeRestoredThread, + invalidateResumeWork, + stateStore, + setStatus: status.set, + refreshTabHeader: thread.refreshTabHeader, + }); + const resume = new ResumeController({ + stateStore, + vaultPath: plugin.vaultPath, + resumeWork, + history, + restoration, + currentClient: client.currentClient, + ensureConnected: client.ensureConnected, + closing: lifecycle.getClosing, + resetThreadTurnPresence, + clearDeferredRestoredThreadHydration: () => { + restoration.clearHydration(); + }, + notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged, + addSystemMessage: status.addSystemMessage, + refreshLiveState: liveState.refresh, + syncThreadGoal: (threadId) => goals.syncThreadGoal(threadId), + recoverTokenUsageFromRollout: (path) => + recoverRolloutTokenUsage(path, async (filePath, options) => { + const response = await client.currentClient()?.readFile(filePath, options); + return response?.dataBase64 ?? ""; + }), + }); + const identity = createIdentitySync({ + stateStore, + restoration, + invalidateResumeWork, + clearDeferredRestoredThreadHydration: () => { + restoration.clearHydration(); + }, + resetThreadTurnPresence, + notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged, + refreshTabHeader: thread.refreshTabHeader, + refreshLiveState: liveState.refresh, + }); + + return { + history, + restoration, + resume, + identity, + }; +} diff --git a/src/features/chat/threads/naming-parts.ts b/src/features/chat/threads/naming-parts.ts new file mode 100644 index 00000000..02120c17 --- /dev/null +++ b/src/features/chat/threads/naming-parts.ts @@ -0,0 +1,56 @@ +import type { ConnectionManager } from "../../../app-server/connection/connection-manager"; +import type { AppServerClient } from "../../../app-server/connection/client"; +import type { ChatStateStore } from "../state/reducer"; +import type { CodexChatHost } from "../chat-host"; +import { AutoTitleController } from "./auto-title-controller"; +import { RenameController } from "./rename-controller"; + +export interface ThreadNamingPartsContext { + plugin: CodexChatHost; + stateStore: ChatStateStore; + client: { + currentClient: () => AppServerClient | null; + ensureConnected: () => Promise; + }; + status: { + addSystemMessage: (text: string) => void; + }; +} + +export interface ThreadNamingPartsRefs { + connection: ConnectionManager; +} + +export interface ThreadNamingParts { + rename: RenameController; + autoTitle: AutoTitleController; + resetThreadTurnPresence: (hadTurns: boolean) => void; +} + +export function createThreadNamingParts(context: ThreadNamingPartsContext, refs: ThreadNamingPartsRefs): ThreadNamingParts { + const { plugin, stateStore, client, status } = context; + const rename = new RenameController({ + stateStore, + vaultPath: plugin.vaultPath, + settings: () => plugin.settings, + ensureConnected: client.ensureConnected, + currentClient: () => refs.connection.currentClient(), + addSystemMessage: status.addSystemMessage, + notifyThreadRenamed: plugin.notifyThreadRenamed.bind(plugin), + }); + const autoTitle = new AutoTitleController({ + stateStore, + vaultPath: plugin.vaultPath, + settings: () => plugin.settings, + currentClient: client.currentClient, + notifyThreadRenamed: plugin.notifyThreadRenamed.bind(plugin), + }); + + return { + rename, + autoTitle, + resetThreadTurnPresence: (hadTurns) => { + autoTitle.resetThreadTurnPresence(hadTurns); + }, + }; +} diff --git a/src/features/chat/threads/services.ts b/src/features/chat/threads/services.ts deleted file mode 100644 index 366c9027..00000000 --- a/src/features/chat/threads/services.ts +++ /dev/null @@ -1,242 +0,0 @@ -import type { ConnectionManager } from "../../../app-server/connection/connection-manager"; -import type { AppServerClient } from "../../../app-server/connection/client"; -import { recoverRolloutTokenUsage } from "../../../app-server/services/rollout-token-usage"; -import type { ArchiveExportAdapter } from "../../thread-export/archive-markdown"; -import { archiveThread } from "./archive-actions"; -import { AutoTitleController } from "./auto-title-controller"; -import { compactThread } from "./compact-actions"; -import { forkThread, forkThreadFromTurn } from "./fork-actions"; -import { createGoalActions } from "./goal-actions"; -import { HistoryController } from "./history-controller"; -import { createIdentitySync } from "./identity-sync"; -import { RenameController } from "./rename-controller"; -import { renameThread } from "./rename-actions"; -import { ResumeController } from "./resume-controller"; -import { rollbackThread } from "./rollback-actions"; -import { createSelectionActions } from "./selection-actions"; -import { RestorationController } from "./restoration-controller"; -import type { ChatThreadActions, ChatThreadActionsHost } from "./action-context"; -import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../lifecycle"; -import type { ChatStateStore } from "../state/reducer"; -import type { CodexChatHost } from "../chat-host"; - -interface ThreadServicesContext { - obsidian: { - archiveAdapter: () => ArchiveExportAdapter; - }; - plugin: CodexChatHost; - state: { - stateStore: ChatStateStore; - }; - client: { - getClient: () => AppServerClient | null; - ensureConnected: () => Promise; - }; - lifecycle: { - deferredTasks: ChatViewDeferredTasks; - resumeWork: ChatResumeWorkTracker; - getOpened: () => boolean; - getClosing: () => boolean; - }; - thread: { - selectThread: (threadId: string) => Promise; - refreshThreads: () => Promise; - notifyIdentityChanged: () => void; - refreshTabHeader: () => void; - }; - status: { - set: (status: string) => void; - addSystemMessage: (text: string) => void; - }; - liveState: { - refresh: () => void; - }; - scroll: { - preservePosition: () => void; - forceBottom: () => void; - }; - composer: { - setText: (text: string) => void; - }; -} - -export function createThreadServices( - context: ThreadServicesContext, - refs: { - connection: ConnectionManager; - }, -) { - const { obsidian, plugin, state, thread, status, liveState, scroll, client, composer, lifecycle } = context; - const stateStore = state.stateStore; - const currentClient = client.getClient; - const { deferredTasks, resumeWork } = lifecycle; - - const rename = new RenameController({ - stateStore, - vaultPath: plugin.vaultPath, - settings: () => plugin.settings, - ensureConnected: client.ensureConnected, - currentClient: () => refs.connection.currentClient(), - addSystemMessage: status.addSystemMessage, - notifyThreadRenamed: plugin.notifyThreadRenamed.bind(plugin), - }); - const autoTitle = new AutoTitleController({ - stateStore, - vaultPath: plugin.vaultPath, - settings: () => plugin.settings, - currentClient, - notifyThreadRenamed: plugin.notifyThreadRenamed.bind(plugin), - }); - const resetThreadTurnPresence = (hadTurns: boolean) => { - autoTitle.resetThreadTurnPresence(hadTurns); - }; - const history = new HistoryController({ - stateStore, - currentClient, - addSystemMessage: status.addSystemMessage, - keepCurrentScrollPosition: scroll.preservePosition, - showLatestPageAtBottom: scroll.forceBottom, - setThreadTurnPresence: resetThreadTurnPresence, - }); - const invalidateResumeWork = () => { - resumeWork.invalidate(); - history.invalidate(); - }; - const threadActionHost: ChatThreadActionsHost = { - stateStore, - vaultPath: plugin.vaultPath, - settings: () => plugin.settings, - archiveAdapter: obsidian.archiveAdapter, - ensureConnected: client.ensureConnected, - currentClient, - addSystemMessage: status.addSystemMessage, - setStatus: status.set, - setComposerText: composer.setText, - openThreadInNewView: (threadId) => plugin.openThreadInNewView(threadId), - openThreadInCurrentPanel: thread.selectThread, - notifyThreadArchived: plugin.notifyThreadArchived.bind(plugin), - notifyThreadRenamed: (threadId, name) => { - plugin.notifyThreadRenamed(threadId, name); - }, - notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged, - refreshThreads: thread.refreshThreads, - refreshSharedThreadListFromOpenSurface: () => { - plugin.refreshSharedThreadListFromOpenSurface(); - }, - }; - const actions: ChatThreadActions = { - compactThread: (threadId) => compactThread(threadActionHost, threadId), - archiveThread: (threadId, saveMarkdown) => archiveThread(threadActionHost, threadId, saveMarkdown), - forkThread: (threadId) => forkThread(threadActionHost, threadId), - forkThreadFromTurn: (threadId, turnId, archiveSource) => forkThreadFromTurn(threadActionHost, threadId, turnId, archiveSource), - renameThread: (threadId, name) => renameThread(threadActionHost, threadId, name), - rollbackThread: (threadId) => rollbackThread(threadActionHost, threadId), - }; - const goals = createGoalActions({ - stateStore, - currentClient, - ensureConnected: client.ensureConnected, - addSystemMessage: status.addSystemMessage, - addGoalEvent: (item) => { - stateStore.dispatch({ type: "message-stream/item-upserted", item }); - }, - refreshLiveState: liveState.refresh, - }); - let resume: ResumeController | null = null; - const restoration = new RestorationController({ - deferredTasks, - opened: lifecycle.getOpened, - resumeThread: (threadId) => requireThreadController(resume, "resume controller").resumeThread(threadId), - invalidateResumeWork, - stateStore, - setStatus: status.set, - refreshTabHeader: thread.refreshTabHeader, - }); - resume = new ResumeController({ - stateStore, - vaultPath: plugin.vaultPath, - resumeWork, - history, - restoration, - currentClient, - ensureConnected: client.ensureConnected, - closing: lifecycle.getClosing, - resetThreadTurnPresence, - clearDeferredRestoredThreadHydration: () => { - restoration.clearHydration(); - }, - notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged, - addSystemMessage: status.addSystemMessage, - refreshLiveState: liveState.refresh, - syncThreadGoal: (threadId) => goals.syncThreadGoal(threadId), - recoverTokenUsageFromRollout: (path) => - recoverRolloutTokenUsage(path, async (filePath, options) => { - const response = await currentClient()?.readFile(filePath, options); - return response?.dataBase64 ?? ""; - }), - }); - const identity = createIdentitySync({ - stateStore, - restoration, - invalidateResumeWork, - clearDeferredRestoredThreadHydration: () => { - restoration.clearHydration(); - }, - resetThreadTurnPresence, - notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged, - refreshTabHeader: thread.refreshTabHeader, - refreshLiveState: liveState.refresh, - }); - - return { - history, - actions, - goals, - restoration, - resume: requireThreadController(resume, "resume controller"), - identity, - rename, - autoTitle, - invalidateResumeWork, - }; -} - -function requireThreadController(controller: T | null, name: string): T { - if (!controller) throw new Error(`Chat thread controller graph did not initialize ${name}.`); - return controller; -} - -interface ThreadSelectionActionsContext { - plugin: CodexChatHost; - state: { - stateStore: ChatStateStore; - }; - status: { - addSystemMessage: (text: string) => void; - }; - thread: { - resumeThread: (threadId: string) => Promise; - }; -} - -export function createThreadSelectionActions( - context: ThreadSelectionActionsContext, - refs: { - closeForThreadSelection: () => void; - }, -) { - const { plugin, thread, status } = context; - const stateStore = context.state.stateStore; - - const selection = createSelectionActions({ - stateStore, - closeForThreadSelection: () => { - refs.closeForThreadSelection(); - }, - focusThreadInOpenView: (threadId) => plugin.focusThreadInOpenView(threadId), - resumeThread: thread.resumeThread, - addSystemMessage: status.addSystemMessage, - }); - - return { selection }; -} diff --git a/src/features/chat/ui/shell.tsx b/src/features/chat/ui/shell.tsx index ec067d9c..95005be2 100644 --- a/src/features/chat/ui/shell.tsx +++ b/src/features/chat/ui/shell.tsx @@ -5,14 +5,17 @@ import type { ChatPanelGoalSurface, ChatPanelToolbarSurface } from "../panel/sur import { ChatPanelToolbar } from "../panel/surface/toolbar"; import { ChatPanelGoal } from "../panel/surface/goal"; import { ChatPanelMessageStream, type ChatPanelMessageStreamPresenter } from "../panel/surface/message-stream"; -import { ChatPanelComposer, type ChatPanelComposerController } from "../panel/surface/composer"; +import { ChatPanelComposer, type ChatPanelComposerActions, type ChatPanelComposerController } from "../panel/surface/composer"; import { ChatPanelShellStateContext, createChatPanelShellState, syncChatPanelShellState, type ChatPanelShellState } from "./shell-state"; export interface ChatPanelShellParts { toolbar: ChatPanelToolbarSurface; goal: ChatPanelGoalSurface; messageStream: ChatPanelMessageStreamPresenter; - composer: ChatPanelComposerController; + composer: { + controller: ChatPanelComposerController; + actions: ChatPanelComposerActions; + }; } export interface ChatPanelShellProps { @@ -111,7 +114,7 @@ function ChatPanelShell({ showToolbar, parts, shellState }: ChatPanelShellProps
- +
diff --git a/src/features/threads-view/view.ts b/src/features/threads-view/view.ts index 4d334c81..863e48b4 100644 --- a/src/features/threads-view/view.ts +++ b/src/features/threads-view/view.ts @@ -69,8 +69,7 @@ export class CodexThreadsView extends ItemView { ) { super(leaf); this.deferredTasks = createThreadsViewDeferredTasks(() => this.containerEl.win); - this.connection = new ConnectionManager(() => this.plugin.settings.codexPath, this.plugin.vaultPath); - this.connection.setHandlers({ + this.connection = new ConnectionManager(() => this.plugin.settings.codexPath, this.plugin.vaultPath, { onNotification: () => { this.scheduleRefresh(); }, diff --git a/tests/app-server/connection-manager.test.ts b/tests/app-server/connection-manager.test.ts index 2a657f1a..2b93baa3 100644 --- a/tests/app-server/connection-manager.test.ts +++ b/tests/app-server/connection-manager.test.ts @@ -1,7 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { AppServerClient } from "../../src/app-server/connection/client"; -import { ConnectionManager, StaleConnectionError } from "../../src/app-server/connection/connection-manager"; +import { + ConnectionManager, + type ConnectionManagerHandlers, + StaleConnectionError, +} from "../../src/app-server/connection/connection-manager"; import type { AppServerTransport, AppServerTransportHandlers } from "../../src/app-server/connection/transport"; import type { RpcOutboundMessage } from "../../src/app-server/connection/rpc-messages"; @@ -50,18 +54,13 @@ describe("ConnectionManager", () => { const manager = new ConnectionManager( () => "/bin/codex", "/vault", + silentConnectionHandlers(), (codexPath, cwd, handlers) => new AppServerClient(codexPath, cwd, handlers, 5, (transportHandlers) => { transport = new SilentTransport(transportHandlers); return transport; }), ); - manager.setHandlers({ - onNotification: () => undefined, - onServerRequest: () => undefined, - onLog: () => undefined, - onExit: () => undefined, - }); await expect(manager.connect()).rejects.toThrow("Codex app-server request timed out: initialize"); @@ -74,18 +73,13 @@ describe("ConnectionManager", () => { const manager = new ConnectionManager( () => "/bin/codex", "/vault", + silentConnectionHandlers(), (codexPath, cwd, handlers) => new AppServerClient(codexPath, cwd, handlers, 500, (transportHandlers) => { transport = new SilentTransport(transportHandlers); return transport; }), ); - manager.setHandlers({ - onNotification: () => undefined, - onServerRequest: () => undefined, - onLog: () => undefined, - onExit: () => undefined, - }); const first = manager.connect(); const second = manager.connect(); @@ -103,18 +97,13 @@ describe("ConnectionManager", () => { const manager = new ConnectionManager( () => "/bin/codex", "/vault", + { ...silentConnectionHandlers(), onExit }, (codexPath, cwd, handlers) => new AppServerClient(codexPath, cwd, handlers, 500, (transportHandlers) => { transport = new SilentTransport(transportHandlers); return transport; }), ); - manager.setHandlers({ - onNotification: () => undefined, - onServerRequest: () => undefined, - onLog: () => undefined, - onExit, - }); const connecting = manager.connect(); transport.emitExit(); @@ -124,78 +113,19 @@ describe("ConnectionManager", () => { expect(manager.currentClient()).toBeNull(); }); - it("uses the latest attached handlers for app-server events", async () => { - let transport!: SilentTransport; - const firstExit = vi.fn(); - const secondExit = vi.fn(); - const manager = new ConnectionManager( - () => "/bin/codex", - "/vault", - (codexPath, cwd, handlers) => - new AppServerClient(codexPath, cwd, handlers, 500, (transportHandlers) => { - transport = new SilentTransport(transportHandlers); - return transport; - }), - ); - manager.setHandlers({ - onNotification: () => undefined, - onServerRequest: () => undefined, - onLog: () => undefined, - onExit: firstExit, - }); - manager.setHandlers({ - onNotification: () => undefined, - onServerRequest: () => undefined, - onLog: () => undefined, - onExit: secondExit, - }); - - const connecting = manager.connect(); - transport.emitExit(); - - await expect(connecting).rejects.toThrow("Codex app-server exited: unknown"); - expect(firstExit).not.toHaveBeenCalled(); - expect(secondExit).toHaveBeenCalledOnce(); - }); - - it("fails clearly when app-server events arrive before handlers are attached", async () => { - let transport!: SilentTransport; - const manager = new ConnectionManager( - () => "/bin/codex", - "/vault", - (codexPath, cwd, handlers) => - new AppServerClient(codexPath, cwd, handlers, 500, (transportHandlers) => { - transport = new SilentTransport(transportHandlers); - return transport; - }), - ); - - const connecting = manager.connect(); - - expect(() => { - transport.emitExit(); - }).toThrow("ConnectionManager handlers have not been attached."); - await expect(connecting).rejects.toThrow("Codex app-server exited: unknown"); - }); - it("marks initialization completed after disconnect as stale", async () => { let transport!: SilentTransport; const onExit = vi.fn(); const manager = new ConnectionManager( () => "/bin/codex", "/vault", + { ...silentConnectionHandlers(), onExit }, (codexPath, cwd, handlers) => new AppServerClient(codexPath, cwd, handlers, 500, (transportHandlers) => { transport = new SilentTransport(transportHandlers); return transport; }), ); - manager.setHandlers({ - onNotification: () => undefined, - onServerRequest: () => undefined, - onLog: () => undefined, - onExit, - }); const connecting = manager.connect(); manager.disconnect(); @@ -206,3 +136,12 @@ describe("ConnectionManager", () => { expect(manager.currentClient()).toBeNull(); }); }); + +function silentConnectionHandlers(): ConnectionManagerHandlers { + return { + onNotification: () => undefined, + onServerRequest: () => undefined, + onLog: () => undefined, + onExit: () => undefined, + }; +} diff --git a/tests/features/chat/conversation/composer/controller.test.ts b/tests/features/chat/conversation/composer/controller.test.ts index 2bd18ea0..021db2f9 100644 --- a/tests/features/chat/conversation/composer/controller.test.ts +++ b/tests/features/chat/conversation/composer/controller.test.ts @@ -4,7 +4,7 @@ import type { App } from "obsidian"; import { describe, expect, it, vi } from "vitest"; import { h } from "preact"; -import { ChatComposerController } from "../../../../../src/features/chat/conversation/composer/controller"; +import { ChatComposerController, type ChatComposerRenderActions } from "../../../../../src/features/chat/conversation/composer/controller"; import { ComposerShell } from "../../../../../src/features/chat/ui/composer"; import { createChatStateStore, type ChatStateStore } from "../../../../../src/features/chat/state/reducer"; import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root"; @@ -13,8 +13,13 @@ import { installObsidianDomShims } from "../../../../support/dom"; installObsidianDomShims(); -function renderComposerController(parent: HTMLElement, controller: ChatComposerController, stateStore: ChatStateStore): void { - renderUiRoot(parent, h(ComposerShell, controller.renderState(stateStore.getState()))); +function renderComposerController( + parent: HTMLElement, + controller: ChatComposerController, + stateStore: ChatStateStore, + actions: ChatComposerRenderActions = { submit: vi.fn() }, +): void { + renderUiRoot(parent, h(ComposerShell, controller.renderState(stateStore.getState(), actions))); } describe("ChatComposerController", () => { @@ -32,6 +37,7 @@ describe("ChatComposerController", () => { viewId: "view", sendShortcut: () => "enter", scrollThreadFromComposerEdges: () => false, + threadScrollFromComposer: vi.fn(), canInterrupt: (_state) => false, composerPlaceholder: (_state) => "Ask Codex to work on this task...", composerMeta: (_state) => ({ @@ -87,6 +93,7 @@ describe("ChatComposerController", () => { viewId: "view", sendShortcut: () => "enter", scrollThreadFromComposerEdges: () => false, + threadScrollFromComposer: vi.fn(), canInterrupt: (_state) => false, composerPlaceholder: (_state) => "Ask Codex to work on this task...", composerMeta: (_state) => ({ @@ -149,6 +156,7 @@ describe("ChatComposerController", () => { viewId: "view", sendShortcut: () => "enter", scrollThreadFromComposerEdges: () => false, + threadScrollFromComposer: vi.fn(), canInterrupt: (_state) => false, composerPlaceholder: (_state) => "Ask Codex to work on this task...", composerMeta: (_state) => ({ @@ -204,6 +212,7 @@ describe("ChatComposerController", () => { viewId: "view", sendShortcut: () => "enter", scrollThreadFromComposerEdges: () => false, + threadScrollFromComposer: vi.fn(), canInterrupt: (_state) => false, composerPlaceholder: (_state) => "Ask Codex to work on this task...", composerMeta: (_state) => ({ @@ -241,7 +250,7 @@ describe("ChatComposerController", () => { expect(parent.querySelector(".codex-panel__composer-meta-icon")?.classList.contains("is-active")).toBe(false); }); - it("delegates submit events through attached action handlers", () => { + it("delegates submit events through render actions", () => { const stateStore = createChatStateStore(); stateStore.dispatch({ type: "composer/draft-set", draft: "hello" }); const parent = document.createElement("div"); @@ -252,6 +261,7 @@ describe("ChatComposerController", () => { viewId: "view", sendShortcut: () => "enter", scrollThreadFromComposerEdges: () => false, + threadScrollFromComposer: vi.fn(), canInterrupt: (_state) => false, composerPlaceholder: (_state) => "Ask Codex to work on this task...", composerMeta: (_state) => ({ @@ -279,12 +289,8 @@ describe("ChatComposerController", () => { onDraftChange: vi.fn(), onHeightChange: vi.fn(), }); - controller.setActionHandlers({ - submit, - threadScrollFromComposer: vi.fn(), - }); - renderComposerController(parent, controller, stateStore); + renderComposerController(parent, controller, stateStore, { submit }); composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" })); expect(submit).toHaveBeenCalledOnce(); @@ -300,6 +306,7 @@ describe("ChatComposerController", () => { viewId: "view", sendShortcut: () => "enter", scrollThreadFromComposerEdges: () => false, + threadScrollFromComposer: vi.fn(), canInterrupt: (_state) => false, composerPlaceholder: (_state) => "Ask Codex to work on this task...", composerMeta: (_state) => ({ diff --git a/tests/features/chat/ui/shell.test.tsx b/tests/features/chat/ui/shell.test.tsx index df0442c6..1d29f8ef 100644 --- a/tests/features/chat/ui/shell.test.tsx +++ b/tests/features/chat/ui/shell.test.tsx @@ -330,7 +330,7 @@ function shellProps(store: ReturnType) { interface TestShellParts { parts: ChatPanelShellParts; composerRenderState: ReturnType< - typeof vi.fn<(state: ChatPanelComposerShellState) => ReturnType> + typeof vi.fn<(state: ChatPanelComposerShellState) => ReturnType> >; messageStreamRenderState: ReturnType< typeof vi.fn<(state: ChatPanelMessageStreamShellState) => ReturnType> @@ -401,7 +401,12 @@ function trackedShellParts(): TestShellParts { renderState: messageStreamRenderState, }, composer: { - renderState: composerRenderState, + controller: { + renderState: composerRenderState, + }, + actions: { + submit: vi.fn(), + }, }, }, composerRenderState, diff --git a/tests/features/chat/ui/toolbar-archive-state.test.tsx b/tests/features/chat/ui/toolbar-archive-state.test.tsx index b7748193..e2258b52 100644 --- a/tests/features/chat/ui/toolbar-archive-state.test.tsx +++ b/tests/features/chat/ui/toolbar-archive-state.test.tsx @@ -98,45 +98,50 @@ function shellParts(store: ReturnType, toolbarActio }), }, composer: { - renderState: () => ({ - viewId: "view", - draft: "", - busy: false, - canInterrupt: false, - normalPlaceholder: "Ask Codex to work on this task...", - suggestions: [], - selectedSuggestionIndex: 0, - callbacks: { - onInput: vi.fn(), - onUpdateSuggestions: vi.fn(), - onKeydown: vi.fn(), - onSendOrInterrupt: vi.fn(), - onHeightChange: vi.fn(), - onSuggestionHover: vi.fn(), - onSuggestionInsert: vi.fn(), - }, - meta: { - fatal: null, - context: { - cells: [ - { text: "⣀", placeholder: true }, - { text: "⣀", placeholder: true }, - { text: "⣀", placeholder: true }, - { text: "⣀", placeholder: true }, - ], - percent: "--%", + controller: { + renderState: () => ({ + viewId: "view", + draft: "", + busy: false, + canInterrupt: false, + normalPlaceholder: "Ask Codex to work on this task...", + suggestions: [], + selectedSuggestionIndex: 0, + callbacks: { + onInput: vi.fn(), + onUpdateSuggestions: vi.fn(), + onKeydown: vi.fn(), + onSendOrInterrupt: vi.fn(), + onHeightChange: vi.fn(), + onSuggestionHover: vi.fn(), + onSuggestionInsert: vi.fn(), }, - statusSummary: "Context unavailable, plan off, auto-review off, fast off, model default, reasoning effort default", - model: "default", - effort: null, - planActive: false, - autoReviewActive: false, - fastActive: false, - modelChoices: [], - effortChoices: [], - }, - onComposer: () => undefined, - }), + meta: { + fatal: null, + context: { + cells: [ + { text: "⣀", placeholder: true }, + { text: "⣀", placeholder: true }, + { text: "⣀", placeholder: true }, + { text: "⣀", placeholder: true }, + ], + percent: "--%", + }, + statusSummary: "Context unavailable, plan off, auto-review off, fast off, model default, reasoning effort default", + model: "default", + effort: null, + planActive: false, + autoReviewActive: false, + fastActive: false, + modelChoices: [], + effortChoices: [], + }, + onComposer: () => undefined, + }), + }, + actions: { + submit: vi.fn(), + }, }, }; } diff --git a/tests/features/chat/view-connection.test.ts b/tests/features/chat/view-connection.test.ts index f931e7c4..b8a1d2f8 100644 --- a/tests/features/chat/view-connection.test.ts +++ b/tests/features/chat/view-connection.test.ts @@ -39,15 +39,16 @@ vi.mock("../../../src/app-server/connection/connection-manager", () => { class StaleConnectionError extends Error {} class ConnectionManager { - private handlers: { onNotification: (notification: ServerNotification) => void; onExit: () => void } | null = null; - - setHandlers(handlers: { - onNotification: (notification: ServerNotification) => void; - onServerRequest: (request: unknown) => void; - onLog: (message: string) => void; - onExit: () => void; - }): void { - this.handlers = handlers; + constructor( + _codexPath: () => string, + _cwd: string, + private readonly handlers: { + onNotification: (notification: ServerNotification) => void; + onServerRequest: (request: unknown) => void; + onLog: (message: string) => void; + onExit: () => void; + }, + ) { connectionMock.state.onNotification = handlers.onNotification; connectionMock.state.onExit = handlers.onExit; } @@ -81,7 +82,7 @@ vi.mock("../../../src/app-server/connection/connection-manager", () => { exit(): void { connectionMock.state.connected = false; - this.handlers?.onExit(); + this.handlers.onExit(); } } diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index 64b2a0fa..b196f4a6 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -35,7 +35,7 @@ vi.mock("../../../src/app-server/connection/connection-manager", () => { class StaleConnectionError extends Error {} class ConnectionManager { - setHandlers(handlers: { onExit: () => void }): void { + constructor(_codexPath: () => string, _cwd: string, handlers: { onExit: () => void }) { connectionMock.state.onExit = handlers.onExit; }