diff --git a/src/features/chat/host/composer-bundle.ts b/src/features/chat/host/composer-bundle.ts new file mode 100644 index 00000000..6caac3a0 --- /dev/null +++ b/src/features/chat/host/composer-bundle.ts @@ -0,0 +1,90 @@ +import { runtimeConfigOrDefault } from "../../../domain/runtime/config"; +import { runtimeSnapshotForChatState } from "../application/runtime/snapshot"; +import type { ChatStateStore } from "../application/state/store"; +import { resolveRuntimeControls } from "../domain/runtime/resolution"; +import { ChatComposerController } from "../panel/composer-controller"; +import { type ChatPanelComposerSurface, chatPanelComposerProjection } from "../panel/surface/composer-projection"; +import type { ChatMessageScrollController } from "../panel/surface/message-stream-scroll"; +import { VaultNoteCandidateProvider } from "../panel/vault-note-candidate-provider"; +import type { ChatPanelEnvironment } from "./contracts"; +import type { ChatPanelRuntimeSettingsActions } from "./runtime-bundle"; +import type { ChatPanelThreadLifecycle } from "./thread-bundle"; + +interface ChatPanelComposerHost { + environment: ChatPanelEnvironment; + stateStore: ChatStateStore; + messageScrollController: ChatMessageScrollController; +} + +export interface ChatPanelComposerBundle { + controller: ChatComposerController; + dispose(): void; +} + +export function createComposerBundle( + host: ChatPanelComposerHost, + input: { + threadLifecycle: ChatPanelThreadLifecycle; + runtimeSettings: ChatPanelRuntimeSettingsActions; + }, +): ChatPanelComposerBundle { + const surface = createSessionComposerSurface(input.threadLifecycle, input.runtimeSettings); + const controller = createSessionComposerController(host, surface, input.runtimeSettings); + + return { + controller, + dispose: () => { + controller.dispose(); + }, + }; +} + +function createSessionComposerSurface( + threadLifecycle: ChatPanelThreadLifecycle, + runtimeSettings: ChatPanelRuntimeSettingsActions, +): ChatPanelComposerSurface { + return { + thread: { + restoredPlaceholder: () => threadLifecycle.restoration.placeholder(), + }, + runtime: { + requestModel: (model) => runtimeSettings.requestModelFromUi(model), + requestReasoningEffort: (effort) => runtimeSettings.requestReasoningEffortFromUi(effort), + }, + }; +} + +function createSessionComposerController( + host: ChatPanelComposerHost, + composerSurface: ChatPanelComposerSurface, + runtimeSettings: ChatPanelRuntimeSettingsActions, +): ChatComposerController { + const { environment, stateStore } = host; + return new ChatComposerController({ + noteCandidateProvider: new VaultNoteCandidateProvider(environment.obsidian.app), + sourcePath: () => environment.obsidian.app.workspace.getActiveFile()?.path ?? "", + stateStore, + viewId: environment.obsidian.viewId, + sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut, + scrollThreadFromComposerEdges: () => environment.plugin.settingsRef.settings.scrollThreadFromComposerEdges, + canInterrupt: (state) => { + return state.turnBusy && Boolean(state.activeThreadId && state.activeTurnId); + }, + composerProjection: (state) => chatPanelComposerProjection(composerSurface, state), + currentModelForSuggestions: () => { + const current = stateStore.getState(); + const config = runtimeConfigOrDefault(current.connection.runtimeConfig); + return resolveRuntimeControls(runtimeSnapshotForChatState(current), config).model.effective; + }, + threadScrollFromComposer: (action) => { + host.messageScrollController.scrollFromComposer(action); + }, + togglePlan: () => void runtimeSettings.toggleCollaborationMode(), + toggleAutoReview: () => void runtimeSettings.toggleAutoReview(), + toggleFast: () => void runtimeSettings.toggleFastMode(), + onDraftChange: () => { + environment.plugin.workspace.refreshThreadsViewLiveState(); + }, + onHeightChange: () => undefined, + }); +} diff --git a/src/features/chat/host/connection-bundle.ts b/src/features/chat/host/connection-bundle.ts index 97f7e890..5a0fdf9d 100644 --- a/src/features/chat/host/connection-bundle.ts +++ b/src/features/chat/host/connection-bundle.ts @@ -20,7 +20,7 @@ import type { ChatConnectionPhase } from "../application/state/root-reducer"; import type { ChatStateStore } from "../application/state/store"; import type { AutoTitleCoordinator } from "../application/threads/auto-title-coordinator"; import type { createThreadGoalSyncActions } from "../application/threads/goal-actions"; -import type { ChatPanelEnvironment } from "./environment"; +import type { ChatPanelEnvironment } from "./contracts"; export type CurrentAppServerClient = () => AppServerClient | null; diff --git a/src/features/chat/host/environment.ts b/src/features/chat/host/contracts.ts similarity index 65% rename from src/features/chat/host/environment.ts rename to src/features/chat/host/contracts.ts index 4115938c..b1e4e863 100644 --- a/src/features/chat/host/environment.ts +++ b/src/features/chat/host/contracts.ts @@ -1,5 +1,7 @@ import type { App, Component, EventRef } from "obsidian"; +import type { AppServerClient } from "../../../app-server/connection/client"; +import type { AppServerQueryContext } from "../../../app-server/query/keys"; import type { ArchiveExportDestination } from "../../../app-server/services/thread-archive-markdown"; import type { ModelMetadata } from "../../../domain/catalog/metadata"; import type { ObservedResultListener } from "../../../domain/observed-result"; @@ -7,6 +9,7 @@ import type { SharedServerMetadata } from "../../../domain/server/metadata"; import type { CodexPanelSettings } from "../../../settings/model"; import type { ThreadCatalogActiveReader, ThreadCatalogEventSink } from "../../../workspace/thread-catalog"; import type { ChatTurnDiffViewState } from "../domain/turn-diff"; +import type { ChatPanelSnapshot } from "../panel/snapshot"; export interface CodexChatHost { readonly settingsRef: PluginSettingsRef; @@ -58,3 +61,40 @@ export interface ChatPanelEnvironment { refreshTabHeader: () => void; }; } + +export interface ChatViewLifecycleSurface { + displayTitle(): string; + persistedState(): Record; + applyViewState(state: unknown): void; + open(): void; + close(): void; + refreshSettings(): void; +} + +export interface ChatWorkspacePanelSurface { + openPanelSnapshot(): ChatPanelSnapshot; + openThread(threadId: string): Promise; + focusThread(threadId?: string | null): Promise; + hydrateRestoredThread(): Promise; + focusComposer(): void; + connect(): Promise; + startNewThread(): Promise; +} + +export interface ChatSharedThreadSurface { + refreshSharedThreads(): Promise; + applyThreadArchived(threadId: string): void; + applyThreadRenamed(threadId: string, name: string | null): void; +} + +export interface ChatPanelClientSurface { + canServeAppServerContext(context: AppServerQueryContext): boolean; + runWithAppServerClient(operation: (client: AppServerClient) => Promise): Promise; +} + +export type ChatPanelHandle = ChatViewLifecycleSurface & + ChatWorkspacePanelSurface & + ChatSharedThreadSurface & + ChatPanelClientSurface & { + setComposerText(text: string): void; + }; diff --git a/src/features/chat/host/deferred-tasks.ts b/src/features/chat/host/deferred-work.ts similarity index 100% rename from src/features/chat/host/deferred-tasks.ts rename to src/features/chat/host/deferred-work.ts diff --git a/src/features/chat/host/runtime-bundle.ts b/src/features/chat/host/runtime-bundle.ts new file mode 100644 index 00000000..d441fd1d --- /dev/null +++ b/src/features/chat/host/runtime-bundle.ts @@ -0,0 +1,136 @@ +import type { ConnectionManager } from "../../../app-server/connection/connection-manager"; +import { connectionDiagnosticSectionsFromState } from "../application/connection/diagnostic-sections"; +import { toolInventoryDiagnosticSections } from "../application/connection/tool-inventory-diagnostic-sections"; +import { type ChatRuntimeSettingsActions, createChatRuntimeSettingsActions } from "../application/runtime/settings-actions"; +import { runtimeSnapshotForChatState } from "../application/runtime/snapshot"; +import type { ChatStateStore } from "../application/state/store"; +import type { MessageStreamNoticeSection } from "../domain/message-stream/items"; +import { collaborationModeLabel as formatCollaborationModeLabel } from "../domain/runtime/labels"; +import type { RuntimeSnapshot } from "../domain/runtime/snapshot"; +import { + effortStatusLines as buildEffortStatusLines, + modelStatusLines as buildModelStatusLines, + statusSummaryLines as buildStatusSummaryLines, +} from "../presentation/runtime/status"; +import type { CurrentAppServerClient } from "./connection-bundle"; +import type { ChatPanelEnvironment } from "./contracts"; + +export type ChatPanelRuntimeSettingsActions = ChatRuntimeSettingsActions; + +interface ChatPanelRuntimeStatus { + addSystemMessage: (text: string) => void; +} + +interface ChatPanelRuntimeHost { + environment: ChatPanelEnvironment; + stateStore: ChatStateStore; +} + +export interface ChatPanelRuntimeProjection { + connectionDiagnosticDetails: () => MessageStreamNoticeSection[]; + modelStatusLines: () => string[]; + effortStatusLines: () => string[]; + statusSummaryLines: () => string[]; + toolInventoryDetails: () => MessageStreamNoticeSection[]; +} + +interface ChatPanelRuntimeBundle { + settings: ChatPanelRuntimeSettingsActions; + projection: ChatPanelRuntimeProjection; +} + +export function createRuntimeBundle( + host: ChatPanelRuntimeHost, + input: { + connection: ConnectionManager; + currentClient: CurrentAppServerClient; + status: ChatPanelRuntimeStatus; + }, +): ChatPanelRuntimeBundle { + return { + settings: createSessionRuntimeSettingsActions(host, input.currentClient, input.status), + projection: createSessionRuntimeProjection(host, input.connection), + }; +} + +function createSessionRuntimeSettingsActions( + host: ChatPanelRuntimeHost, + currentClient: CurrentAppServerClient, + status: ChatPanelRuntimeStatus, +): ChatPanelRuntimeSettingsActions { + return createChatRuntimeSettingsActions({ + stateStore: host.stateStore, + currentClient, + runtimeSnapshotForState: runtimeSnapshotForChatState, + collaborationModeLabel: () => collaborationModeLabel(host.stateStore), + addSystemMessage: (text) => { + status.addSystemMessage(text); + }, + }); +} + +function collaborationModeLabel(stateStore: ChatStateStore): string { + return formatCollaborationModeLabel(stateStore.getState().runtime.pending.collaborationMode); +} + +function createSessionRuntimeProjection(host: ChatPanelRuntimeHost, connection: ConnectionManager): ChatPanelRuntimeProjection { + return { + connectionDiagnosticDetails: () => connectionDiagnosticDetails(host, connection), + modelStatusLines: () => modelStatusLines(host), + effortStatusLines: () => effortStatusLines(host), + statusSummaryLines: () => statusSummaryLines(host), + toolInventoryDetails: () => toolInventoryDetails(host), + }; +} + +function statusSummaryLines(host: ChatPanelRuntimeHost): string[] { + const state = host.stateStore.getState(); + return buildStatusSummaryLines({ + activeThreadId: state.activeThread.id, + snapshot: runtimeSnapshot(host), + nowMs: Date.now(), + }); +} + +function modelStatusLines(host: ChatPanelRuntimeHost): string[] { + const state = host.stateStore.getState(); + return buildModelStatusLines({ + runtimeConfig: state.connection.runtimeConfig, + pendingModel: state.runtime.pending.model, + snapshot: runtimeSnapshot(host), + collaborationModeLabel: collaborationModeLabel(host.stateStore), + }); +} + +function effortStatusLines(host: ChatPanelRuntimeHost): string[] { + const state = host.stateStore.getState(); + return buildEffortStatusLines({ + runtimeConfig: state.connection.runtimeConfig, + pendingReasoningEffort: state.runtime.pending.reasoningEffort, + snapshot: runtimeSnapshot(host), + }); +} + +function connectionDiagnosticDetails(host: ChatPanelRuntimeHost, connection: ConnectionManager): MessageStreamNoticeSection[] { + const sections = connectionDiagnosticSectionsFromState({ + state: host.stateStore.getState(), + connected: connection.isConnected(), + configuredCommand: host.environment.plugin.settingsRef.settings.codexPath, + }); + return sections.map((section) => ({ + title: section.title, + auditFacts: section.rows.map((row) => ({ key: row.label, value: row.value })), + })); +} + +function toolInventoryDetails(host: ChatPanelRuntimeHost): MessageStreamNoticeSection[] { + const sections = toolInventoryDiagnosticSections(host.stateStore.getState().connection.serverDiagnostics); + return sections.map((section) => ({ + title: section.title, + auditFacts: section.rows.map((row) => ({ key: row.label, value: row.value })), + })); +} + +function runtimeSnapshot(host: ChatPanelRuntimeHost): RuntimeSnapshot { + return runtimeSnapshotForChatState(host.stateStore.getState()); +} diff --git a/src/features/chat/host/session-graph.ts b/src/features/chat/host/session-graph.ts index 02057a2e..f8965c37 100644 --- a/src/features/chat/host/session-graph.ts +++ b/src/features/chat/host/session-graph.ts @@ -1,104 +1,40 @@ -import { Notice } from "obsidian"; -import type { AppServerClientAccess } from "../../../app-server/connection/client-access"; import { ConnectionManager } from "../../../app-server/connection/connection-manager"; import { isStaleAppServerSharedQueryContextError } from "../../../app-server/query/shared-queries"; -import { runtimeConfigOrDefault } from "../../../domain/runtime/config"; -import { normalizeExplicitThreadName } from "../../../domain/threads/model"; import { createLocalIdSource, type LocalIdSource } from "../../../shared/id/local-id"; import type { ConnectionWorkTracker } from "../../../shared/lifecycle/connection-work"; -import { createThreadOperations, type ThreadOperations } from "../../threads/thread-operations"; -import { createThreadTitleService, type ThreadTitleService } from "../../threads/thread-title-service"; -import type { ChatServerDiagnosticsActions } from "../app-server/actions/diagnostics"; -import type { ChatServerMetadataActions } from "../app-server/actions/metadata"; -import type { ChatServerThreadActions } from "../app-server/actions/threads"; -import type { ChatInboundHandler } from "../app-server/inbound/handler"; -import { connectionDiagnosticSectionsFromState } from "../application/connection/diagnostic-sections"; -import { type ChatReconnectActionsHost, reconnectPanel } from "../application/connection/reconnect-actions"; -import { toolInventoryDiagnosticSections } from "../application/connection/tool-inventory-diagnostic-sections"; -import type { ComposerSubmitActions } from "../application/conversation/composer-submit-actions"; -import { - type ConversationTurnActions as ChatPanelConversationTurnActions, - createConversationTurnActions, -} from "../application/conversation/composition"; import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../application/lifecycle"; -import { createPendingRequestActions, type PendingRequestActions } from "../application/pending-requests/pending-request-actions"; -import { createChatRuntimeSettingsActions } from "../application/runtime/settings-actions"; -import { runtimeSnapshotForChatState } from "../application/runtime/snapshot"; -import { messageStreamItems } from "../application/state/message-stream"; import type { ChatAction, ChatConnectionPhase } from "../application/state/root-reducer"; import type { ChatStateStore } from "../application/state/store"; import type { ActiveThreadIdentitySync } from "../application/threads/active-thread-identity-sync"; -import { type AutoTitleCoordinator, createAutoTitleCoordinator } from "../application/threads/auto-title-coordinator"; -import { createGoalActions, createThreadGoalSyncActions } from "../application/threads/goal-actions"; -import { HistoryController } from "../application/threads/history-controller"; -import { createThreadLifecycleParts } from "../application/threads/lifecycle-parts"; -import { - activeThreadRenameTitleContext, - createThreadRenameEditorActions, - type ThreadRenameEditorActions, -} from "../application/threads/rename-editor-actions"; import type { RestorationController } from "../application/threads/restoration-controller"; import type { ResumeActions } from "../application/threads/resume-actions"; -import { createThreadManagementActions, type ThreadManagementActionsHost } from "../application/threads/thread-management-actions"; -import { createThreadNavigationActions } from "../application/threads/thread-navigation-actions"; -import { threadTitleContextFromMessageStreamItems } from "../application/threads/title-context"; import { createStructuredSystemItem, createSystemItem } from "../domain/message-stream/factories/system-items"; import type { MessageStreamNoticeSection } from "../domain/message-stream/items"; -import { collaborationModeLabel as formatCollaborationModeLabel } from "../domain/runtime/labels"; -import { resolveRuntimeControls } from "../domain/runtime/resolution"; -import type { RuntimeSnapshot } from "../domain/runtime/snapshot"; -import { ChatComposerController } from "../panel/composer-controller"; -import { type ChatPanelComposerSurface, chatPanelComposerProjection } from "../panel/surface/composer-projection"; -import type { ChatPanelGoalSurface } from "../panel/surface/goal-projection"; -import type { MessageStreamPresenter } from "../panel/surface/message-stream-presenter"; +import type { ChatComposerController } from "../panel/composer-controller"; import type { ChatMessageScrollController } from "../panel/surface/message-stream-scroll"; -import type { ChatPanelToolbarSurface } from "../panel/surface/toolbar-projection"; -import { createToolbarPanelActions, type ToolbarPanelActions } from "../panel/toolbar-actions"; -import { VaultNoteCandidateProvider } from "../panel/vault-note-candidate-provider"; -import { - effortStatusLines as buildEffortStatusLines, - modelStatusLines as buildModelStatusLines, - statusSummaryLines as buildStatusSummaryLines, -} from "../presentation/runtime/status"; -import type { ToolbarActions } from "../ui/toolbar"; -import { type ChatPanelConnectionBundle, type CurrentAppServerClient, createConnectionBundle } from "./connection-bundle"; -import type { ChatPanelEnvironment } from "./environment"; -import { createChatPanelSurfaces } from "./panel-surfaces"; +import { createComposerBundle } from "./composer-bundle"; +import { type ChatPanelConnectionBundle, createConnectionBundle } from "./connection-bundle"; +import type { ChatPanelEnvironment } from "./contracts"; +import { createRuntimeBundle } from "./runtime-bundle"; import { type ChatPanelSharedStateBinding, createChatPanelSharedStateBinding } from "./shared-state-binding"; +import { type ChatPanelShellBundle, createShellBundle } from "./shell-bundle"; +import { createThreadActionBundle, createThreadFoundation, createThreadLifecycleBundle } from "./thread-bundle"; +import { createTurnBundle } from "./turn-bundle"; export interface ChatPanelSessionGraph { connection: { manager: ConnectionManager; controller: ChatPanelConnectionBundle["connection"]["controller"]; }; - serverActions: { - threads: ChatServerThreadActions; - metadata: ChatServerMetadataActions; - diagnostics: ChatServerDiagnosticsActions; - }; thread: { - history: HistoryController; resume: ResumeActions; restoration: RestorationController; identity: ActiveThreadIdentitySync; - rename: ThreadRenameEditorActions; - }; - toolbar: { - panels: ToolbarPanelActions; - actions: ToolbarActions; }; composer: { controller: ChatComposerController; - submission: ComposerSubmitActions; - }; - render: { - messageStreamPresenter: MessageStreamPresenter; - }; - surface: { - toolbar: ChatPanelToolbarSurface; - goal: ChatPanelGoalSurface; - composer: ChatPanelComposerSurface; }; + shell: ChatPanelShellBundle; actions: { invalidateThreadWork(): void; refreshSharedThreads(): Promise; @@ -129,98 +65,59 @@ interface ChatPanelSessionGraphHost { viewWindow: () => Window; } -type ChatPanelGoalSyncActions = ReturnType; -type ChatPanelRuntimeSettingsActions = ReturnType; -type ChatPanelGoalActions = ReturnType; -type ChatPanelThreadLifecycle = ReturnType; -type ChatPanelThreadActions = ReturnType; -type ChatPanelThreadNavigationActions = ReturnType; - -interface ChatPanelThreadActionParts { - actions: ChatPanelThreadActions; - toolbarPanelActions: ToolbarPanelActions; - navigation: ChatPanelThreadNavigationActions; -} - -interface ChatPanelComposerAndTurnParts { - pendingRequests: PendingRequestActions; - reconnect: () => Promise; - turnActions: ChatPanelConversationTurnActions; -} - -interface ChatPanelRuntimeProjection { - connectionDiagnosticDetails: () => MessageStreamNoticeSection[]; - modelStatusLines: () => string[]; - effortStatusLines: () => string[]; - statusSummaryLines: () => string[]; - toolInventoryDetails: () => MessageStreamNoticeSection[]; -} - -interface ChatPanelComposerAndTurnInput { - connection: ConnectionManager; - localItemIds: LocalIdSource; - ensureConnected: () => Promise; - connectedClient: () => Promise>; - currentClient: CurrentAppServerClient; - status: ChatPanelSessionStatus; - inboundHandler: ChatInboundHandler; - threadLifecycle: ChatPanelThreadLifecycle; - threadActions: ChatPanelThreadActions; - navigation: ChatPanelThreadNavigationActions; - composerController: ChatComposerController; - runtimeSettings: ChatPanelRuntimeSettingsActions; - serverThreads: ChatServerThreadActions; - goals: ChatPanelGoalActions; - autoTitleCoordinator: AutoTitleCoordinator; - invalidateThreadWork: () => void; - runtimeProjection: ChatPanelRuntimeProjection; -} - export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): ChatPanelSessionGraph { const { environment, stateStore } = host; const localItemIds = createLocalIdSource(); const connection = createConnectionManager(environment); const currentClient = () => connection.currentClient(); const status = createSessionStatus(stateStore, localItemIds); - const titleService = createSessionThreadTitleService(host, currentClient); - const autoTitleCoordinator = createSessionAutoTitleCoordinator(host, currentClient, titleService); - const history = createSessionHistoryController(host, currentClient, status, autoTitleCoordinator); - const invalidateThreadWork = () => { - host.resumeWork.invalidate(); - history.invalidate(); + const refreshTabHeader = () => { + host.environment.view.refreshTabHeader(); }; - const goalSync = createSessionGoalSyncActions(host, currentClient, localItemIds, status); + const refreshLiveState = () => { + host.environment.plugin.workspace.refreshThreadsViewLiveState(); + }; + const deferLiveStateRefresh = () => { + host.viewWindow().setTimeout(refreshLiveState, 0); + }; + const notifyActiveThreadIdentityChanged = () => { + refreshTabHeader(); + host.environment.obsidian.requestWorkspaceLayoutSave(); + refreshLiveState(); + }; + + const threadFoundation = createThreadFoundation(host, { + currentClient, + localItemIds, + status, + refreshLiveState, + }); const connectionBundle = createConnectionBundle( { environment, stateStore, connectionWork: host.connectionWork, deferredTasks: host.deferredTasks, - invalidateThreadWork, - deferLiveStateRefresh: () => { - deferLiveStateRefresh(host); - }, - refreshTabHeader: () => { - refreshTabHeader(host); - }, - refreshLiveState: () => { - refreshLiveState(host); + invalidateThreadWork: () => { + threadFoundation.invalidateThreadWork(); }, + deferLiveStateRefresh, + refreshTabHeader, + refreshLiveState, }, { connection, currentClient, localItemIds, - goalSync, - autoTitleCoordinator, + goalSync: threadFoundation.goalSync, + autoTitleCoordinator: threadFoundation.autoTitleCoordinator, status, }, ); const { - connection: { controller }, + connection: { controller: connectionController }, inboundHandler, } = connectionBundle; - const connectionController = controller; const { threads: serverThreads } = connectionBundle.serverActions; const ensureConnected = () => connectionController.ensureConnected(); const connectedClient = async () => { @@ -228,35 +125,38 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch return currentClient(); }; const refreshActiveThreads = () => connectionController.refreshActiveThreads(); - const threadOperations = createSessionThreadOperations(environment, currentClient); - const runtimeSettings = createSessionRuntimeSettingsActions(host, currentClient, status); - const goals = createSessionGoalActions(host, currentClient, localItemIds, connectedClient, status, serverThreads); - const rename = createSessionThreadRenameEditorActions(stateStore, threadOperations, titleService, ensureConnected, status); - const threadLifecycle = createSessionThreadLifecycle( - host, + const runtime = createRuntimeBundle(host, { + connection, currentClient, + status, + }); + const threadLifecycle = createThreadLifecycleBundle(host, { + currentClient, + localItemIds, + connectedClient, ensureConnected, status, - goals, - autoTitleCoordinator, - history, - invalidateThreadWork, - ); - const { identity, restoration, resume } = threadLifecycle; - - const composerSurface = createSessionComposerSurface(threadLifecycle, runtimeSettings); - const composerController = createSessionComposerController(host, composerSurface, runtimeSettings); - const threadActionParts = createThreadActionParts(host, { - operations: threadOperations, - connectedClient, - currentClient, - status, - composerController, - identity, - resume, - refreshActiveThreads, + serverThreads, + foundation: threadFoundation, + refreshTabHeader, + refreshLiveState, + notifyActiveThreadIdentityChanged, }); - const composerAndTurn = createComposerAndTurnActions(host, { + const composer = createComposerBundle(host, { + threadLifecycle: threadLifecycle.lifecycle, + runtimeSettings: runtime.settings, + }); + const threadActions = createThreadActionBundle(host, { + currentClient, + connectedClient, + status, + composerController: composer.controller, + foundation: threadFoundation, + lifecycle: threadLifecycle, + refreshActiveThreads, + notifyActiveThreadIdentityChanged, + }); + const turn = createTurnBundle(host, { connection, localItemIds, ensureConnected, @@ -264,29 +164,34 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch currentClient, status, inboundHandler, - threadLifecycle, - threadActions: threadActionParts.actions, - navigation: threadActionParts.navigation, - composerController, - runtimeSettings, + threadLifecycle: threadLifecycle.lifecycle, + threadActions: threadActions.actions, + navigation: threadActions.navigation, + composerController: composer.controller, + runtimeSettings: runtime.settings, serverThreads, - goals, - autoTitleCoordinator, - invalidateThreadWork, - runtimeProjection: createSessionRuntimeProjection(host, connection), + goals: threadLifecycle.goals, + autoTitleCoordinator: threadFoundation.autoTitleCoordinator, + invalidateThreadWork: () => { + threadFoundation.invalidateThreadWork(); + }, + runtimeProjection: runtime.projection, + refreshLiveState, + notifyActiveThreadIdentityChanged, }); - const surfaces = createChatPanelSurfaces(host, { + const shell = createShellBundle(host, { connection, connectionController, - goals, - rename, - threadActions: threadActionParts.actions, - toolbarPanelActions: threadActionParts.toolbarPanelActions, - navigation: threadActionParts.navigation, - reconnect: composerAndTurn.reconnect, - history, - pendingRequests: composerAndTurn.pendingRequests, - turnActions: composerAndTurn.turnActions, + goals: threadLifecycle.goals, + rename: threadLifecycle.rename, + threadActions: threadActions.actions, + toolbarPanelActions: threadActions.toolbarPanelActions, + navigation: threadActions.navigation, + reconnect: turn.reconnect, + history: threadFoundation.history, + pendingRequests: turn.pendingRequests, + turn, + composer, }); const refreshSharedThreads = async (): Promise => { try { @@ -296,18 +201,12 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch throw error; } }; - const dispose = (): void => { - surfaces.messageStreamPresenter.dispose(); - composerController.dispose(); - }; const sharedState = createChatPanelSharedStateBinding({ - stateStore: host.stateStore, - threadCatalog: host.environment.plugin.threadCatalog, - appServerQueries: host.environment.plugin.appServerQueries, + stateStore, + threadCatalog: environment.plugin.threadCatalog, + appServerQueries: environment.plugin.appServerQueries, serverActions: connectionBundle.serverActions, - refreshTabHeader: () => { - refreshTabHeader(host); - }, + refreshTabHeader, }); return { @@ -315,44 +214,30 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch manager: connection, controller: connectionController, }, - serverActions: connectionBundle.serverActions, thread: { - history, - resume, - restoration, - identity, - rename, - }, - toolbar: { - panels: threadActionParts.toolbarPanelActions, - actions: surfaces.toolbarActions, + resume: threadLifecycle.resume, + restoration: threadLifecycle.restoration, + identity: threadLifecycle.identity, }, composer: { - controller: composerController, - submission: composerAndTurn.turnActions.composerSubmit, - }, - render: { - messageStreamPresenter: surfaces.messageStreamPresenter, - }, - surface: { - toolbar: surfaces.toolbarSurface, - goal: surfaces.goalSurface, - composer: composerSurface, + controller: composer.controller, }, + shell, actions: { - invalidateThreadWork, + invalidateThreadWork: () => { + threadFoundation.invalidateThreadWork(); + }, refreshSharedThreads, - startNewThread: () => threadActionParts.navigation.startNewThread(), - dispose, + startNewThread: () => threadActions.navigation.startNewThread(), + dispose: () => { + shell.dispose(); + composer.dispose(); + }, }, runtime: { sharedState, - refreshLiveState: () => { - refreshLiveState(host); - }, - deferLiveStateRefresh: () => { - deferLiveStateRefresh(host); - }, + refreshLiveState, + deferLiveStateRefresh, }, }; } @@ -361,462 +246,6 @@ function createConnectionManager(environment: ChatPanelEnvironment): ConnectionM return new ConnectionManager(() => environment.plugin.settingsRef.settings.codexPath, environment.plugin.settingsRef.vaultPath); } -function createSessionThreadTitleService(host: ChatPanelSessionGraphHost, currentClient: CurrentAppServerClient): ThreadTitleService { - const { environment, stateStore } = host; - return createThreadTitleService({ - 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)), - }); -} - -function createSessionAutoTitleCoordinator( - host: ChatPanelSessionGraphHost, - currentClient: CurrentAppServerClient, - titleService: ThreadTitleService, -): AutoTitleCoordinator { - return createAutoTitleCoordinator({ - stateStore: host.stateStore, - completedTurnTitleContext: (turnId, completedSummary) => titleService.completedTurnContext(turnId, completedSummary), - generateTitleFromContext: (context) => titleService.generate(context), - renameGeneratedTitle: async (threadId, title, options) => { - const name = normalizeExplicitThreadName(title); - if (!name) return false; - const client = currentClient(); - if (!client) return false; - - await client.setThreadName(threadId, name); - if (currentClient() !== client) return false; - if (options.shouldPublish()) { - host.environment.plugin.threadCatalog.apply({ type: "thread-renamed", threadId, name }); - } - return true; - }, - }); -} - -function createSessionHistoryController( - host: ChatPanelSessionGraphHost, - currentClient: CurrentAppServerClient, - status: ChatPanelSessionStatus, - autoTitleCoordinator: AutoTitleCoordinator, -): HistoryController { - return new HistoryController({ - stateStore: host.stateStore, - currentClient, - addSystemMessage: status.addSystemMessage, - showLatestPageAtBottom: () => { - host.messageScrollController.showLatest(); - }, - setThreadTurnPresence: (hadTurns) => { - autoTitleCoordinator.resetThreadTurnPresence(hadTurns); - }, - }); -} - -function createSessionGoalSyncActions( - host: ChatPanelSessionGraphHost, - currentClient: CurrentAppServerClient, - localItemIds: LocalIdSource, - status: ChatPanelSessionStatus, -): ChatPanelGoalSyncActions { - return createThreadGoalSyncActions({ - stateStore: host.stateStore, - currentClient, - localItemIds, - addSystemMessage: (text) => { - status.addSystemMessage(text); - }, - addGoalEvent: (item) => { - host.stateStore.dispatch({ type: "message-stream/item-upserted", item }); - }, - refreshLiveState: () => { - refreshLiveState(host); - }, - }); -} - -function createSessionThreadOperations(environment: ChatPanelEnvironment, currentClient: CurrentAppServerClient): ThreadOperations { - return createThreadOperations({ - clientAccess: createCurrentClientAccess(currentClient), - archiveExport: { - settings: () => ({ - archiveExportFolderTemplate: environment.plugin.settingsRef.settings.archiveExportFolderTemplate, - archiveExportFilenameTemplate: environment.plugin.settingsRef.settings.archiveExportFilenameTemplate, - archiveExportTags: environment.plugin.settingsRef.settings.archiveExportTags, - }), - enabled: () => environment.plugin.settingsRef.settings.archiveExportEnabled, - vaultPath: environment.plugin.settingsRef.vaultPath, - vaultConfigDir: environment.obsidian.app.vault.configDir, - }, - archiveDestination: environment.obsidian.archiveDestination, - catalog: environment.plugin.threadCatalog, - notice: (text) => { - new Notice(text); - }, - }); -} - -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: ChatPanelSessionGraphHost, - currentClient: CurrentAppServerClient, - status: ChatPanelSessionStatus, -): ChatPanelRuntimeSettingsActions { - return createChatRuntimeSettingsActions({ - stateStore: host.stateStore, - currentClient, - runtimeSnapshotForState: runtimeSnapshotForChatState, - collaborationModeLabel: () => collaborationModeLabel(host.stateStore), - addSystemMessage: (text) => { - status.addSystemMessage(text); - }, - }); -} - -function createSessionGoalActions( - host: ChatPanelSessionGraphHost, - currentClient: CurrentAppServerClient, - localItemIds: LocalIdSource, - connectedClient: () => Promise>, - status: ChatPanelSessionStatus, - serverThreads: ChatServerThreadActions, -): ChatPanelGoalActions { - return createGoalActions({ - stateStore: host.stateStore, - currentClient, - localItemIds, - connectedClient, - startThread: (preview, options) => serverThreads.startThread(preview, options), - addSystemMessage: (text) => { - status.addSystemMessage(text); - }, - addGoalEvent: (item) => { - host.stateStore.dispatch({ type: "message-stream/item-upserted", item }); - }, - refreshLiveState: () => { - refreshLiveState(host); - }, - }); -} - -function createSessionThreadRenameEditorActions( - stateStore: ChatStateStore, - operations: ThreadOperations, - titleService: ThreadTitleService, - ensureConnected: () => Promise, - status: ChatPanelSessionStatus, -): ThreadRenameEditorActions { - return createThreadRenameEditorActions({ - stateStore, - ensureConnected, - addSystemMessage: status.addSystemMessage, - renameThread: (threadId, value, options) => operations.renameThread(threadId, value, options), - generateThreadTitle: (threadId) => titleService.generateTitle(threadId), - }); -} - -function createSessionThreadLifecycle( - host: ChatPanelSessionGraphHost, - currentClient: CurrentAppServerClient, - ensureConnected: () => Promise, - status: ChatPanelSessionStatus, - goals: ChatPanelGoalActions, - autoTitleCoordinator: AutoTitleCoordinator, - history: HistoryController, - invalidateThreadWork: () => void, -): ChatPanelThreadLifecycle { - return createThreadLifecycleParts({ - settingsRef: host.environment.plugin.settingsRef, - stateStore: host.stateStore, - client: { - currentClient, - ensureConnected, - }, - lifecycle: { - resumeWork: host.resumeWork, - history, - invalidateThreadWork, - getClosing: host.getClosing, - }, - thread: { - notifyIdentityChanged: () => { - notifyActiveThreadIdentityChanged(host); - }, - refreshTabHeader: () => { - refreshTabHeader(host); - }, - }, - status, - liveState: { - refresh: () => { - refreshLiveState(host); - }, - }, - goals, - resetThreadTurnPresence: (hadTurns) => { - autoTitleCoordinator.resetThreadTurnPresence(hadTurns); - }, - }); -} - -function createSessionComposerSurface( - threadLifecycle: ChatPanelThreadLifecycle, - runtimeSettings: ChatPanelRuntimeSettingsActions, -): ChatPanelComposerSurface { - return { - thread: { - restoredPlaceholder: () => threadLifecycle.restoration.placeholder(), - }, - runtime: { - requestModel: (model) => runtimeSettings.requestModelFromUi(model), - requestReasoningEffort: (effort) => runtimeSettings.requestReasoningEffortFromUi(effort), - }, - }; -} - -function createSessionComposerController( - host: ChatPanelSessionGraphHost, - composerSurface: ChatPanelComposerSurface, - runtimeSettings: ChatPanelRuntimeSettingsActions, -): ChatComposerController { - const { environment, stateStore } = host; - return new ChatComposerController({ - noteCandidateProvider: new VaultNoteCandidateProvider(environment.obsidian.app), - sourcePath: () => environment.obsidian.app.workspace.getActiveFile()?.path ?? "", - stateStore, - viewId: environment.obsidian.viewId, - sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut, - scrollThreadFromComposerEdges: () => environment.plugin.settingsRef.settings.scrollThreadFromComposerEdges, - canInterrupt: (state) => { - return state.turnBusy && Boolean(state.activeThreadId && state.activeTurnId); - }, - composerProjection: (state) => chatPanelComposerProjection(composerSurface, state), - currentModelForSuggestions: () => { - const current = stateStore.getState(); - const config = runtimeConfigOrDefault(current.connection.runtimeConfig); - return resolveRuntimeControls(runtimeSnapshotForChatState(current), config).model.effective; - }, - threadScrollFromComposer: (action) => { - host.messageScrollController.scrollFromComposer(action); - }, - togglePlan: () => void runtimeSettings.toggleCollaborationMode(), - toggleAutoReview: () => void runtimeSettings.toggleAutoReview(), - toggleFast: () => void runtimeSettings.toggleFastMode(), - onDraftChange: () => { - refreshLiveState(host); - }, - onHeightChange: () => undefined, - }); -} - -function createThreadActionParts( - host: ChatPanelSessionGraphHost, - input: { - operations: ThreadOperations; - connectedClient: () => Promise>; - currentClient: CurrentAppServerClient; - status: ChatPanelSessionStatus; - composerController: ChatComposerController; - identity: ActiveThreadIdentitySync; - resume: ResumeActions; - refreshActiveThreads: () => Promise; - }, -): ChatPanelThreadActionParts { - const { operations, connectedClient, currentClient, status, composerController, identity, resume, refreshActiveThreads } = input; - const { environment, stateStore } = host; - const threadManagementHost: ThreadManagementActionsHost = { - stateStore, - vaultPath: environment.plugin.settingsRef.vaultPath, - operations, - connectedClient, - currentClient, - addSystemMessage: status.addSystemMessage, - setStatus: status.set, - setComposerText: (text) => { - composerController.setDraft(text, { focus: true }); - }, - openThreadInNewView: (threadId) => environment.plugin.workspace.openThreadInNewView(threadId), - openThreadInCurrentPanel: (threadId) => resume.resumeThread(threadId), - notifyActiveThreadIdentityChanged: () => { - notifyActiveThreadIdentityChanged(host); - }, - refreshAfterThreadMutation: async () => { - await refreshActiveThreads(); - }, - applyThreadCatalogEvent: (event) => { - environment.plugin.threadCatalog.apply(event); - }, - }; - const actions = createThreadManagementActions(threadManagementHost); - const toolbarPanelActions = createToolbarPanelActions({ - stateStore, - threadActions: actions, - }); - const navigation = createThreadNavigationActions({ - stateStore, - identity, - closeForThreadSelection: () => { - toolbarPanelActions.closeForThreadSelection(); - }, - focusThreadInOpenView: (threadId) => environment.plugin.workspace.focusThreadInOpenView(threadId), - resumeThread: (threadId) => resume.resumeThread(threadId), - addSystemMessage: status.addSystemMessage, - focusComposer: () => { - composerController.focus(); - }, - }); - return { actions, toolbarPanelActions, navigation }; -} - -function createComposerAndTurnActions( - host: ChatPanelSessionGraphHost, - input: ChatPanelComposerAndTurnInput, -): ChatPanelComposerAndTurnParts { - const { - connection, - localItemIds, - ensureConnected, - connectedClient, - currentClient, - status, - inboundHandler, - threadLifecycle, - threadActions, - navigation, - composerController, - runtimeSettings, - serverThreads, - goals, - autoTitleCoordinator, - invalidateThreadWork, - runtimeProjection, - } = input; - const pendingRequests = createPendingRequestActions({ - stateStore: host.stateStore, - responder: inboundHandler, - composerHasFocus: () => composerController.hasFocus(), - focusComposer: () => { - composerController.focus(); - }, - refreshLiveState: () => { - refreshLiveState(host); - }, - }); - const reconnectHost: ChatReconnectActionsHost = { - stateStore: host.stateStore, - invalidateConnectionWork: () => { - host.connectionWork.invalidate(); - }, - invalidateThreadWork: () => { - invalidateThreadWork(); - }, - clearDeferredDiagnostics: () => { - host.deferredTasks.clearDiagnostics(); - }, - resetConnection: () => { - connection.resetConnection(); - }, - setStatus: (statusText, phase) => { - status.set(statusText, phase); - }, - ensureConnected, - resumeThread: (threadId) => threadLifecycle.resume.resumeThread(threadId), - addSystemMessage: (text) => { - status.addSystemMessage(text); - }, - }; - const reconnect = () => reconnectPanel(reconnectHost); - const turnActions = createConversationTurnActions( - { - vaultPath: host.environment.plugin.settingsRef.vaultPath, - stateStore: host.stateStore, - localItemIds, - client: { - currentClient, - connectedClient, - }, - status, - runtime: { - connectionDiagnosticDetails: runtimeProjection.connectionDiagnosticDetails, - modelStatusLines: runtimeProjection.modelStatusLines, - effortStatusLines: runtimeProjection.effortStatusLines, - statusSummaryLines: runtimeProjection.statusSummaryLines, - toolInventoryDetails: runtimeProjection.toolInventoryDetails, - }, - thread: { - ensureRestoredThreadLoaded: () => - threadLifecycle.restoration.ensureLoaded((threadId) => threadLifecycle.resume.resumeThread(threadId)), - startNewThread: () => navigation.startNewThread(), - selectThread: (threadId) => navigation.selectThread(threadId), - notifyIdentityChanged: () => { - notifyActiveThreadIdentityChanged(host); - }, - resetTurnPresence: (hadTurns) => { - autoTitleCoordinator.resetThreadTurnPresence(hadTurns); - }, - }, - composer: { - codexInput: (text) => composerController.codexInput(text), - trimmedDraft: () => composerController.trimmedDraft, - setDraft: (text, options) => { - composerController.setDraft(text, options); - }, - }, - scroll: { - showLatest: () => { - host.messageScrollController.showLatest(); - }, - }, - }, - { - threadStarter: serverThreads, - runtimeSettings, - threadActions, - reconnectPanel: reconnect, - goals, - }, - ); - - return { - pendingRequests, - reconnect, - turnActions, - }; -} - -function collaborationModeLabel(stateStore: ChatStateStore): string { - return formatCollaborationModeLabel(stateStore.getState().runtime.pending.collaborationMode); -} - -function createSessionRuntimeProjection(host: ChatPanelSessionGraphHost, connection: ConnectionManager): ChatPanelRuntimeProjection { - return { - connectionDiagnosticDetails: () => connectionDiagnosticDetails(host, connection), - modelStatusLines: () => modelStatusLines(host), - effortStatusLines: () => effortStatusLines(host), - statusSummaryLines: () => statusSummaryLines(host), - toolInventoryDetails: () => toolInventoryDetails(host), - }; -} - function createSessionStatus(stateStore: ChatStateStore, localItemIds: LocalIdSource): ChatPanelSessionStatus { return { set: (statusText, phase) => { @@ -837,75 +266,3 @@ function createSessionStatus(stateStore: ChatStateStore, localItemIds: LocalIdSo function dispatch(stateStore: ChatStateStore, action: ChatAction): void { stateStore.dispatch(action); } - -function notifyActiveThreadIdentityChanged(host: ChatPanelSessionGraphHost): void { - refreshTabHeader(host); - host.environment.obsidian.requestWorkspaceLayoutSave(); - refreshLiveState(host); -} - -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, - pendingModel: state.runtime.pending.model, - snapshot: runtimeSnapshot(host), - collaborationModeLabel: collaborationModeLabel(host.stateStore), - }); -} - -function effortStatusLines(host: ChatPanelSessionGraphHost): string[] { - const state = host.stateStore.getState(); - return buildEffortStatusLines({ - runtimeConfig: state.connection.runtimeConfig, - pendingReasoningEffort: state.runtime.pending.reasoningEffort, - snapshot: runtimeSnapshot(host), - }); -} - -function connectionDiagnosticDetails(host: ChatPanelSessionGraphHost, connection: ConnectionManager): MessageStreamNoticeSection[] { - const sections = connectionDiagnosticSectionsFromState({ - state: host.stateStore.getState(), - connected: connection.isConnected(), - configuredCommand: host.environment.plugin.settingsRef.settings.codexPath, - }); - return sections.map((section) => ({ - title: section.title, - auditFacts: section.rows.map((row) => ({ key: row.label, value: row.value })), - })); -} - -function toolInventoryDetails(host: ChatPanelSessionGraphHost): MessageStreamNoticeSection[] { - const sections = toolInventoryDiagnosticSections(host.stateStore.getState().connection.serverDiagnostics); - return sections.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 ead6c0d6..3b48c80f 100644 --- a/src/features/chat/host/session.ts +++ b/src/features/chat/host/session.ts @@ -9,12 +9,11 @@ import { type ChatStateStore, createChatStateStore } from "../application/state/ import { renderChatPanelShell, unmountChatPanelShell } from "../panel/shell.dom"; import { type ChatPanelSnapshot, openPanelTurnLifecycle, parseRestoredThreadState } from "../panel/snapshot"; import { type ChatMessageScrollController, createChatMessageScrollController } from "../panel/surface/message-stream-scroll"; -import { createChatViewDeferredTasks } from "./deferred-tasks"; -import type { ChatPanelEnvironment } from "./environment"; +import type { ChatPanelEnvironment, ChatPanelHandle } from "./contracts"; +import { createChatViewDeferredTasks } from "./deferred-work"; import { type ChatPanelSessionGraph, createChatPanelSessionGraph } from "./session-graph"; -import type { ChatSurfaceHandle } from "./surface-handle"; -export class ChatPanelSession implements ChatSurfaceHandle { +export class ChatPanelSession implements ChatPanelHandle { private readonly stateStore: ChatStateStore = createChatStateStore(); private readonly graph: ChatPanelSessionGraph; @@ -191,20 +190,7 @@ export class ChatPanelSession implements ChatSurfaceHandle { renderChatPanelShell(root, { stateStore: this.stateStore, showToolbar: this.environment.plugin.settingsRef.settings.showToolbar, - parts: { - toolbar: { - surface: this.graph.surface.toolbar, - actions: this.graph.toolbar.actions, - }, - goal: this.graph.surface.goal, - messageStream: this.graph.render.messageStreamPresenter, - composer: { - presenter: this.graph.composer.controller, - actions: { - submit: () => void this.graph.composer.submission.submit(), - }, - }, - }, + parts: this.graph.shell.parts, }); } @@ -230,12 +216,7 @@ export class ChatPanelSession implements ChatSurfaceHandle { } private closeToolbarPanelOnOutsidePointer(event: PointerEvent): void { - 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.graph.thread.rename.isEditing(), - }); + this.graph.shell.closeToolbarPanelOnOutsidePointer(event); } private activeThreadTitle(): string | null { diff --git a/src/features/chat/host/panel-surfaces.ts b/src/features/chat/host/shell-bundle.ts similarity index 67% rename from src/features/chat/host/panel-surfaces.ts rename to src/features/chat/host/shell-bundle.ts index b03191f2..675bf34d 100644 --- a/src/features/chat/host/panel-surfaces.ts +++ b/src/features/chat/host/shell-bundle.ts @@ -1,33 +1,27 @@ import type { ConnectionManager } from "../../../app-server/connection/connection-manager"; -import type { ConversationTurnActions } from "../application/conversation/composition"; import type { PendingRequestActions } from "../application/pending-requests/pending-request-actions"; import type { ChatStateStore } from "../application/state/store"; -import type { createGoalActions } from "../application/threads/goal-actions"; import type { HistoryController } from "../application/threads/history-controller"; import type { ThreadRenameEditorActions } from "../application/threads/rename-editor-actions"; -import type { createThreadManagementActions } from "../application/threads/thread-management-actions"; -import type { createThreadNavigationActions } from "../application/threads/thread-navigation-actions"; +import type { ChatPanelShellParts } from "../panel/shell.dom"; import type { ChatPanelGoalSurface } from "../panel/surface/goal-projection"; import { MessageStreamPresenter } from "../panel/surface/message-stream-presenter"; import type { ChatMessageScrollController } from "../panel/surface/message-stream-scroll"; import type { ChatPanelToolbarSurface } from "../panel/surface/toolbar-projection"; import { createToolbarUiActions, type ToolbarPanelActions } from "../panel/toolbar-actions"; -import type { ToolbarActions } from "../ui/toolbar"; +import type { ChatPanelComposerBundle } from "./composer-bundle"; import type { ChatPanelConnectionBundle } from "./connection-bundle"; -import type { ChatPanelEnvironment } from "./environment"; +import type { ChatPanelEnvironment } from "./contracts"; +import type { ChatPanelGoalActions, ChatPanelThreadActions, ChatPanelThreadNavigationActions } from "./thread-bundle"; +import type { ChatPanelTurnBundle } from "./turn-bundle"; -type ChatPanelGoalActions = ReturnType; -type ChatPanelThreadActions = ReturnType; -type ChatPanelThreadNavigationActions = ReturnType; -type ChatPanelConversationTurnActions = ConversationTurnActions; - -export interface ChatPanelSurfacesHost { +interface ChatPanelShellBundleHost { environment: ChatPanelEnvironment; stateStore: ChatStateStore; messageScrollController: ChatMessageScrollController; } -export interface ChatPanelSurfacesInput { +interface ChatPanelShellBundleInput { connection: ConnectionManager; connectionController: ChatPanelConnectionBundle["connection"]["controller"]; goals: ChatPanelGoalActions; @@ -38,17 +32,17 @@ export interface ChatPanelSurfacesInput { reconnect: () => Promise; history: HistoryController; pendingRequests: PendingRequestActions; - turnActions: ChatPanelConversationTurnActions; + turn: ChatPanelTurnBundle; + composer: ChatPanelComposerBundle; } -export interface ChatPanelSurfaces { - toolbarActions: ToolbarActions; - toolbarSurface: ChatPanelToolbarSurface; - goalSurface: ChatPanelGoalSurface; - messageStreamPresenter: MessageStreamPresenter; +export interface ChatPanelShellBundle { + parts: ChatPanelShellParts; + closeToolbarPanelOnOutsidePointer(event: PointerEvent): void; + dispose(): void; } -export function createChatPanelSurfaces(host: ChatPanelSurfacesHost, input: ChatPanelSurfacesInput): ChatPanelSurfaces { +export function createShellBundle(host: ChatPanelShellBundleHost, input: ChatPanelShellBundleInput): ChatPanelShellBundle { const { connection, connectionController, @@ -60,7 +54,8 @@ export function createChatPanelSurfaces(host: ChatPanelSurfacesHost, input: Chat reconnect, history, pendingRequests, - turnActions, + turn, + composer, } = input; const { environment, stateStore } = host; const toolbarActions = createToolbarUiActions({ @@ -110,7 +105,7 @@ export function createChatPanelSurfaces(host: ChatPanelSurfacesHost, input: Chat actions: { rollbackThread: (threadId) => void threadActions.rollbackThread(threadId), forkThreadFromTurn: (threadId, turnId, archiveSource) => void threadActions.forkThreadFromTurn(threadId, turnId, archiveSource), - implementPlan: (itemId) => void turnActions.planImplementation.implement(itemId), + implementPlan: (itemId) => void turn.turnActions.planImplementation.implement(itemId), openTurnDiff: (state) => void environment.plugin.workspace.openTurnDiff(state), }, requests: { @@ -118,11 +113,33 @@ export function createChatPanelSurfaces(host: ChatPanelSurfacesHost, input: Chat consumePendingAutoFocus: () => pendingRequests.consumeAutoFocus(), }, }); + const parts: ChatPanelShellParts = { + toolbar: { + surface: toolbarSurface, + actions: toolbarActions, + }, + goal: goalSurface, + messageStream: messageStreamPresenter, + composer: { + presenter: composer.controller, + actions: { + submit: () => void turn.turnActions.composerSubmit.submit(), + }, + }, + }; return { - toolbarActions, - toolbarSurface, - goalSurface, - messageStreamPresenter, + parts, + closeToolbarPanelOnOutsidePointer: (event) => { + toolbarPanelActions.closeOnOutsidePointer({ + target: event.target, + viewWindow: environment.view.viewWindow() as (Window & { Element: typeof Element }) | null, + contains: (element) => environment.view.containsElement(element), + renameEditing: rename.isEditing(), + }); + }, + dispose: () => { + messageStreamPresenter.dispose(); + }, }; } diff --git a/src/features/chat/host/surface-handle.ts b/src/features/chat/host/surface-handle.ts deleted file mode 100644 index 814a3672..00000000 --- a/src/features/chat/host/surface-handle.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { AppServerClient } from "../../../app-server/connection/client"; -import type { AppServerQueryContext } from "../../../app-server/query/keys"; -import type { ChatPanelSnapshot } from "../panel/snapshot"; - -export interface ChatViewLifecycleSurface { - displayTitle(): string; - persistedState(): Record; - applyViewState(state: unknown): void; - open(): void; - close(): void; - refreshSettings(): void; -} - -export interface ChatWorkspacePanelSurface { - openPanelSnapshot(): ChatPanelSnapshot; - openThread(threadId: string): Promise; - focusThread(threadId?: string | null): Promise; - hydrateRestoredThread(): Promise; - focusComposer(): void; - connect(): Promise; - startNewThread(): Promise; -} - -export interface ChatSharedThreadSurface { - refreshSharedThreads(): Promise; - applyThreadArchived(threadId: string): void; - applyThreadRenamed(threadId: string, name: string | null): void; -} - -export interface ChatPanelClientSurface { - canServeAppServerContext(context: AppServerQueryContext): boolean; - runWithAppServerClient(operation: (client: AppServerClient) => Promise): Promise; -} - -export type ChatSurfaceHandle = ChatViewLifecycleSurface & - ChatWorkspacePanelSurface & - ChatSharedThreadSurface & - ChatPanelClientSurface & { - setComposerText(text: string): void; - }; diff --git a/src/features/chat/host/thread-bundle.ts b/src/features/chat/host/thread-bundle.ts new file mode 100644 index 00000000..463f0254 --- /dev/null +++ b/src/features/chat/host/thread-bundle.ts @@ -0,0 +1,442 @@ +import { Notice } from "obsidian"; + +import type { AppServerClientAccess } from "../../../app-server/connection/client-access"; +import { normalizeExplicitThreadName } from "../../../domain/threads/model"; +import type { LocalIdSource } from "../../../shared/id/local-id"; +import { createThreadOperations, type ThreadOperations } from "../../threads/thread-operations"; +import { createThreadTitleService, type ThreadTitleService } from "../../threads/thread-title-service"; +import type { ChatServerThreadActions } from "../app-server/actions/threads"; +import type { ChatResumeWorkTracker } from "../application/lifecycle"; +import { messageStreamItems } from "../application/state/message-stream"; +import type { ChatStateStore } from "../application/state/store"; +import type { ActiveThreadIdentitySync } from "../application/threads/active-thread-identity-sync"; +import { type AutoTitleCoordinator, createAutoTitleCoordinator } from "../application/threads/auto-title-coordinator"; +import { createGoalActions, createThreadGoalSyncActions } from "../application/threads/goal-actions"; +import { HistoryController } from "../application/threads/history-controller"; +import { createThreadLifecycleParts } from "../application/threads/lifecycle-parts"; +import { + activeThreadRenameTitleContext, + createThreadRenameEditorActions, + type ThreadRenameEditorActions, +} from "../application/threads/rename-editor-actions"; +import type { RestorationController } from "../application/threads/restoration-controller"; +import type { ResumeActions } from "../application/threads/resume-actions"; +import { createThreadManagementActions, type ThreadManagementActionsHost } from "../application/threads/thread-management-actions"; +import { createThreadNavigationActions } from "../application/threads/thread-navigation-actions"; +import { threadTitleContextFromMessageStreamItems } from "../application/threads/title-context"; +import type { ChatComposerController } from "../panel/composer-controller"; +import { createToolbarPanelActions, type ToolbarPanelActions } from "../panel/toolbar-actions"; +import type { CurrentAppServerClient } from "./connection-bundle"; +import type { ChatPanelEnvironment } from "./contracts"; + +type ChatPanelGoalSyncActions = ReturnType; +export type ChatPanelGoalActions = ReturnType; +export type ChatPanelThreadLifecycle = ReturnType; +export type ChatPanelThreadActions = ReturnType; +export type ChatPanelThreadNavigationActions = ReturnType; + +interface ChatPanelThreadStatus { + set: (statusText: string) => void; + addSystemMessage: (text: string) => void; +} + +interface ChatPanelThreadHost { + environment: ChatPanelEnvironment; + stateStore: ChatStateStore; + resumeWork: ChatResumeWorkTracker; + messageScrollController: { + showLatest(): void; + }; + getClosing: () => boolean; +} + +interface ChatPanelThreadFoundationInput { + currentClient: CurrentAppServerClient; + localItemIds: LocalIdSource; + status: ChatPanelThreadStatus; + refreshLiveState: () => void; +} + +interface ChatPanelThreadFoundation { + titleService: ThreadTitleService; + autoTitleCoordinator: AutoTitleCoordinator; + history: HistoryController; + goalSync: ChatPanelGoalSyncActions; + threadOperations: ThreadOperations; + invalidateThreadWork(): void; +} + +interface ChatPanelThreadLifecycleInput { + currentClient: CurrentAppServerClient; + localItemIds: LocalIdSource; + connectedClient: () => Promise>; + ensureConnected: () => Promise; + status: ChatPanelThreadStatus; + serverThreads: ChatServerThreadActions; + foundation: ChatPanelThreadFoundation; + refreshTabHeader: () => void; + refreshLiveState: () => void; + notifyActiveThreadIdentityChanged: () => void; +} + +interface ChatPanelThreadLifecycleBundle { + goals: ChatPanelGoalActions; + rename: ThreadRenameEditorActions; + lifecycle: ChatPanelThreadLifecycle; + identity: ActiveThreadIdentitySync; + restoration: RestorationController; + resume: ResumeActions; +} + +interface ChatPanelThreadActionInput { + currentClient: CurrentAppServerClient; + connectedClient: () => Promise>; + status: ChatPanelThreadStatus; + composerController: ChatComposerController; + foundation: ChatPanelThreadFoundation; + lifecycle: ChatPanelThreadLifecycleBundle; + refreshActiveThreads: () => Promise; + notifyActiveThreadIdentityChanged: () => void; +} + +interface ChatPanelThreadActionBundle { + actions: ChatPanelThreadActions; + toolbarPanelActions: ToolbarPanelActions; + navigation: ChatPanelThreadNavigationActions; +} + +export function createThreadFoundation(host: ChatPanelThreadHost, input: ChatPanelThreadFoundationInput): ChatPanelThreadFoundation { + const { currentClient, localItemIds, status, refreshLiveState } = input; + const titleService = createSessionThreadTitleService(host, currentClient); + const autoTitleCoordinator = createSessionAutoTitleCoordinator(host, currentClient, titleService); + const history = createSessionHistoryController(host, currentClient, status, autoTitleCoordinator); + const invalidateThreadWork = () => { + host.resumeWork.invalidate(); + history.invalidate(); + }; + const goalSync = createSessionGoalSyncActions(host, currentClient, localItemIds, status, refreshLiveState); + const threadOperations = createSessionThreadOperations(host.environment, currentClient); + + return { + titleService, + autoTitleCoordinator, + history, + goalSync, + threadOperations, + invalidateThreadWork, + }; +} + +export function createThreadLifecycleBundle( + host: ChatPanelThreadHost, + input: ChatPanelThreadLifecycleInput, +): ChatPanelThreadLifecycleBundle { + const { + currentClient, + localItemIds, + connectedClient, + ensureConnected, + status, + serverThreads, + foundation, + refreshTabHeader, + refreshLiveState, + notifyActiveThreadIdentityChanged, + } = input; + const goals = createSessionGoalActions(host, currentClient, localItemIds, connectedClient, status, serverThreads, refreshLiveState); + const rename = createSessionThreadRenameEditorActions( + host.stateStore, + foundation.threadOperations, + foundation.titleService, + ensureConnected, + status, + ); + const lifecycle = createSessionThreadLifecycle(host, { + currentClient, + ensureConnected, + status, + goals, + autoTitleCoordinator: foundation.autoTitleCoordinator, + history: foundation.history, + invalidateThreadWork: () => { + foundation.invalidateThreadWork(); + }, + refreshTabHeader, + refreshLiveState, + notifyActiveThreadIdentityChanged, + }); + const { identity, restoration, resume } = lifecycle; + + return { + goals, + rename, + lifecycle, + identity, + restoration, + resume, + }; +} + +export function createThreadActionBundle(host: ChatPanelThreadHost, input: ChatPanelThreadActionInput): ChatPanelThreadActionBundle { + const { + currentClient, + connectedClient, + status, + composerController, + foundation, + lifecycle, + refreshActiveThreads, + notifyActiveThreadIdentityChanged, + } = input; + const { environment, stateStore } = host; + const threadManagementHost: ThreadManagementActionsHost = { + stateStore, + vaultPath: environment.plugin.settingsRef.vaultPath, + operations: foundation.threadOperations, + connectedClient, + currentClient, + addSystemMessage: status.addSystemMessage, + setStatus: status.set, + setComposerText: (text) => { + composerController.setDraft(text, { focus: true }); + }, + openThreadInNewView: (threadId) => environment.plugin.workspace.openThreadInNewView(threadId), + openThreadInCurrentPanel: (threadId) => lifecycle.resume.resumeThread(threadId), + notifyActiveThreadIdentityChanged, + refreshAfterThreadMutation: async () => { + await refreshActiveThreads(); + }, + applyThreadCatalogEvent: (event) => { + environment.plugin.threadCatalog.apply(event); + }, + }; + const actions = createThreadManagementActions(threadManagementHost); + const toolbarPanelActions = createToolbarPanelActions({ + stateStore, + threadActions: actions, + }); + const navigation = createThreadNavigationActions({ + stateStore, + identity: lifecycle.identity, + closeForThreadSelection: () => { + toolbarPanelActions.closeForThreadSelection(); + }, + focusThreadInOpenView: (threadId) => environment.plugin.workspace.focusThreadInOpenView(threadId), + resumeThread: (threadId) => lifecycle.resume.resumeThread(threadId), + addSystemMessage: status.addSystemMessage, + focusComposer: () => { + composerController.focus(); + }, + }); + return { actions, toolbarPanelActions, navigation }; +} + +function createSessionThreadTitleService(host: ChatPanelThreadHost, currentClient: CurrentAppServerClient): ThreadTitleService { + const { environment, stateStore } = host; + return createThreadTitleService({ + 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)), + }); +} + +function createSessionAutoTitleCoordinator( + host: ChatPanelThreadHost, + currentClient: CurrentAppServerClient, + titleService: ThreadTitleService, +): AutoTitleCoordinator { + return createAutoTitleCoordinator({ + stateStore: host.stateStore, + completedTurnTitleContext: (turnId, completedSummary) => titleService.completedTurnContext(turnId, completedSummary), + generateTitleFromContext: (context) => titleService.generate(context), + renameGeneratedTitle: async (threadId, title, options) => { + const name = normalizeExplicitThreadName(title); + if (!name) return false; + const client = currentClient(); + if (!client) return false; + + await client.setThreadName(threadId, name); + if (currentClient() !== client) return false; + if (options.shouldPublish()) { + host.environment.plugin.threadCatalog.apply({ type: "thread-renamed", threadId, name }); + } + return true; + }, + }); +} + +function createSessionHistoryController( + host: ChatPanelThreadHost, + currentClient: CurrentAppServerClient, + status: ChatPanelThreadStatus, + autoTitleCoordinator: AutoTitleCoordinator, +): HistoryController { + return new HistoryController({ + stateStore: host.stateStore, + currentClient, + addSystemMessage: status.addSystemMessage, + showLatestPageAtBottom: () => { + host.messageScrollController.showLatest(); + }, + setThreadTurnPresence: (hadTurns) => { + autoTitleCoordinator.resetThreadTurnPresence(hadTurns); + }, + }); +} + +function createSessionGoalSyncActions( + host: ChatPanelThreadHost, + currentClient: CurrentAppServerClient, + localItemIds: LocalIdSource, + status: ChatPanelThreadStatus, + refreshLiveState: () => void, +): ChatPanelGoalSyncActions { + return createThreadGoalSyncActions({ + stateStore: host.stateStore, + currentClient, + localItemIds, + addSystemMessage: (text) => { + status.addSystemMessage(text); + }, + addGoalEvent: (item) => { + host.stateStore.dispatch({ type: "message-stream/item-upserted", item }); + }, + refreshLiveState, + }); +} + +function createSessionThreadOperations(environment: ChatPanelEnvironment, currentClient: CurrentAppServerClient): ThreadOperations { + return createThreadOperations({ + clientAccess: createCurrentClientAccess(currentClient), + archiveExport: { + settings: () => ({ + archiveExportFolderTemplate: environment.plugin.settingsRef.settings.archiveExportFolderTemplate, + archiveExportFilenameTemplate: environment.plugin.settingsRef.settings.archiveExportFilenameTemplate, + archiveExportTags: environment.plugin.settingsRef.settings.archiveExportTags, + }), + enabled: () => environment.plugin.settingsRef.settings.archiveExportEnabled, + vaultPath: environment.plugin.settingsRef.vaultPath, + vaultConfigDir: environment.obsidian.app.vault.configDir, + }, + archiveDestination: environment.obsidian.archiveDestination, + catalog: environment.plugin.threadCatalog, + notice: (text) => { + new Notice(text); + }, + }); +} + +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 createSessionGoalActions( + host: ChatPanelThreadHost, + currentClient: CurrentAppServerClient, + localItemIds: LocalIdSource, + connectedClient: () => Promise>, + status: ChatPanelThreadStatus, + serverThreads: ChatServerThreadActions, + refreshLiveState: () => void, +): ChatPanelGoalActions { + return createGoalActions({ + stateStore: host.stateStore, + currentClient, + localItemIds, + connectedClient, + startThread: (preview, options) => serverThreads.startThread(preview, options), + addSystemMessage: (text) => { + status.addSystemMessage(text); + }, + addGoalEvent: (item) => { + host.stateStore.dispatch({ type: "message-stream/item-upserted", item }); + }, + refreshLiveState, + }); +} + +function createSessionThreadRenameEditorActions( + stateStore: ChatStateStore, + operations: ThreadOperations, + titleService: ThreadTitleService, + ensureConnected: () => Promise, + status: ChatPanelThreadStatus, +): ThreadRenameEditorActions { + return createThreadRenameEditorActions({ + stateStore, + ensureConnected, + addSystemMessage: status.addSystemMessage, + renameThread: (threadId, value, options) => operations.renameThread(threadId, value, options), + generateThreadTitle: (threadId) => titleService.generateTitle(threadId), + }); +} + +function createSessionThreadLifecycle( + host: ChatPanelThreadHost, + input: { + currentClient: CurrentAppServerClient; + ensureConnected: () => Promise; + status: ChatPanelThreadStatus; + goals: ChatPanelGoalActions; + autoTitleCoordinator: AutoTitleCoordinator; + history: HistoryController; + invalidateThreadWork: () => void; + refreshTabHeader: () => void; + refreshLiveState: () => void; + notifyActiveThreadIdentityChanged: () => void; + }, +): ChatPanelThreadLifecycle { + const { + currentClient, + ensureConnected, + status, + goals, + autoTitleCoordinator, + history, + invalidateThreadWork, + refreshTabHeader, + refreshLiveState, + notifyActiveThreadIdentityChanged, + } = input; + return createThreadLifecycleParts({ + settingsRef: host.environment.plugin.settingsRef, + stateStore: host.stateStore, + client: { + currentClient, + ensureConnected, + }, + lifecycle: { + resumeWork: host.resumeWork, + history, + invalidateThreadWork, + getClosing: host.getClosing, + }, + thread: { + notifyIdentityChanged: notifyActiveThreadIdentityChanged, + refreshTabHeader, + }, + status, + liveState: { + refresh: refreshLiveState, + }, + goals, + resetThreadTurnPresence: (hadTurns) => { + autoTitleCoordinator.resetThreadTurnPresence(hadTurns); + }, + }); +} diff --git a/src/features/chat/host/turn-bundle.ts b/src/features/chat/host/turn-bundle.ts new file mode 100644 index 00000000..20d257b4 --- /dev/null +++ b/src/features/chat/host/turn-bundle.ts @@ -0,0 +1,181 @@ +import type { ConnectionManager } from "../../../app-server/connection/connection-manager"; +import type { LocalIdSource } from "../../../shared/id/local-id"; +import type { ConnectionWorkTracker } from "../../../shared/lifecycle/connection-work"; +import type { ChatServerThreadActions } from "../app-server/actions/threads"; +import type { ChatInboundHandler } from "../app-server/inbound/handler"; +import { type ChatReconnectActionsHost, reconnectPanel } from "../application/connection/reconnect-actions"; +import { + type ConversationTurnActions as ChatPanelConversationTurnActions, + createConversationTurnActions, +} from "../application/conversation/composition"; +import type { ChatViewDeferredTasks } from "../application/lifecycle"; +import { createPendingRequestActions, type PendingRequestActions } from "../application/pending-requests/pending-request-actions"; +import type { ChatConnectionPhase } from "../application/state/root-reducer"; +import type { ChatStateStore } from "../application/state/store"; +import type { AutoTitleCoordinator } from "../application/threads/auto-title-coordinator"; +import type { MessageStreamNoticeSection } from "../domain/message-stream/items"; +import type { ChatComposerController } from "../panel/composer-controller"; +import type { CurrentAppServerClient } from "./connection-bundle"; +import type { ChatPanelEnvironment } from "./contracts"; +import type { ChatPanelRuntimeProjection, ChatPanelRuntimeSettingsActions } from "./runtime-bundle"; +import type { + ChatPanelGoalActions, + ChatPanelThreadActions, + ChatPanelThreadLifecycle, + ChatPanelThreadNavigationActions, +} from "./thread-bundle"; + +interface ChatPanelTurnStatus { + set: (statusText: string, phase?: ChatConnectionPhase) => void; + addSystemMessage: (text: string) => void; + addStructuredSystemMessage: (text: string, details: MessageStreamNoticeSection[]) => void; +} + +interface ChatPanelTurnHost { + environment: ChatPanelEnvironment; + stateStore: ChatStateStore; + deferredTasks: ChatViewDeferredTasks; + connectionWork: ConnectionWorkTracker; + messageScrollController: { + showLatest(): void; + }; +} + +export interface ChatPanelTurnBundle { + pendingRequests: PendingRequestActions; + reconnect: () => Promise; + turnActions: ChatPanelConversationTurnActions; +} + +interface ChatPanelTurnInput { + connection: ConnectionManager; + localItemIds: LocalIdSource; + ensureConnected: () => Promise; + connectedClient: () => Promise>; + currentClient: CurrentAppServerClient; + status: ChatPanelTurnStatus; + inboundHandler: ChatInboundHandler; + threadLifecycle: ChatPanelThreadLifecycle; + threadActions: ChatPanelThreadActions; + navigation: ChatPanelThreadNavigationActions; + composerController: ChatComposerController; + runtimeSettings: ChatPanelRuntimeSettingsActions; + serverThreads: ChatServerThreadActions; + goals: ChatPanelGoalActions; + autoTitleCoordinator: AutoTitleCoordinator; + invalidateThreadWork: () => void; + runtimeProjection: ChatPanelRuntimeProjection; + refreshLiveState: () => void; + notifyActiveThreadIdentityChanged: () => void; +} + +export function createTurnBundle(host: ChatPanelTurnHost, input: ChatPanelTurnInput): ChatPanelTurnBundle { + const { + connection, + localItemIds, + ensureConnected, + connectedClient, + currentClient, + status, + inboundHandler, + threadLifecycle, + threadActions, + navigation, + composerController, + runtimeSettings, + serverThreads, + goals, + autoTitleCoordinator, + invalidateThreadWork, + runtimeProjection, + refreshLiveState, + notifyActiveThreadIdentityChanged, + } = input; + const pendingRequests = createPendingRequestActions({ + stateStore: host.stateStore, + responder: inboundHandler, + composerHasFocus: () => composerController.hasFocus(), + focusComposer: () => { + composerController.focus(); + }, + refreshLiveState, + }); + const reconnectHost: ChatReconnectActionsHost = { + stateStore: host.stateStore, + invalidateConnectionWork: () => { + host.connectionWork.invalidate(); + }, + invalidateThreadWork: () => { + invalidateThreadWork(); + }, + clearDeferredDiagnostics: () => { + host.deferredTasks.clearDiagnostics(); + }, + resetConnection: () => { + connection.resetConnection(); + }, + setStatus: (statusText, phase) => { + status.set(statusText, phase); + }, + ensureConnected, + resumeThread: (threadId) => threadLifecycle.resume.resumeThread(threadId), + addSystemMessage: (text) => { + status.addSystemMessage(text); + }, + }; + const reconnect = () => reconnectPanel(reconnectHost); + const turnActions = createConversationTurnActions( + { + vaultPath: host.environment.plugin.settingsRef.vaultPath, + stateStore: host.stateStore, + localItemIds, + client: { + currentClient, + connectedClient, + }, + status, + runtime: { + connectionDiagnosticDetails: runtimeProjection.connectionDiagnosticDetails, + modelStatusLines: runtimeProjection.modelStatusLines, + effortStatusLines: runtimeProjection.effortStatusLines, + statusSummaryLines: runtimeProjection.statusSummaryLines, + toolInventoryDetails: runtimeProjection.toolInventoryDetails, + }, + thread: { + ensureRestoredThreadLoaded: () => + threadLifecycle.restoration.ensureLoaded((threadId) => threadLifecycle.resume.resumeThread(threadId)), + startNewThread: () => navigation.startNewThread(), + selectThread: (threadId) => navigation.selectThread(threadId), + notifyIdentityChanged: notifyActiveThreadIdentityChanged, + resetTurnPresence: (hadTurns) => { + autoTitleCoordinator.resetThreadTurnPresence(hadTurns); + }, + }, + composer: { + codexInput: (text) => composerController.codexInput(text), + trimmedDraft: () => composerController.trimmedDraft, + setDraft: (text, options) => { + composerController.setDraft(text, options); + }, + }, + scroll: { + showLatest: () => { + host.messageScrollController.showLatest(); + }, + }, + }, + { + threadStarter: serverThreads, + runtimeSettings, + threadActions, + reconnectPanel: reconnect, + goals, + }, + ); + + return { + pendingRequests, + reconnect, + turnActions, + }; +} diff --git a/src/features/chat/host/view.ts b/src/features/chat/host/view.ts index e6535e49..f68ae51f 100644 --- a/src/features/chat/host/view.ts +++ b/src/features/chat/host/view.ts @@ -3,12 +3,11 @@ import { ItemView, type ViewStateResult, type WorkspaceLeaf } from "obsidian"; import { VIEW_TYPE_CODEX_PANEL } from "../../../constants"; import { createLocalIdSource } from "../../../shared/id/local-id"; import { createObsidianArchiveExportDestination } from "../../../shared/obsidian/archive-export-destination"; -import type { CodexChatHost } from "./environment"; +import type { ChatPanelHandle, CodexChatHost } from "./contracts"; import { ChatPanelSession } from "./session"; -import type { ChatSurfaceHandle } from "./surface-handle"; export class CodexChatView extends ItemView { - readonly surface: ChatSurfaceHandle; + readonly surface: ChatPanelHandle; private readonly viewId = createLocalIdSource().next("codex-panel"); constructor(leaf: WorkspaceLeaf, plugin: CodexChatHost) { diff --git a/src/plugin-runtime.ts b/src/plugin-runtime.ts index 81b3a852..7bee24f7 100644 --- a/src/plugin-runtime.ts +++ b/src/plugin-runtime.ts @@ -8,13 +8,14 @@ import { AppServerSharedQueries } from "./app-server/query/shared-queries"; import { VIEW_TYPE_CODEX_THREADS, VIEW_TYPE_CODEX_TURN_DIFF } from "./constants"; import type { ChatTurnDiffViewState } from "./features/chat/domain/turn-diff"; import { persistedChatTurnDiffViewState } from "./features/chat/domain/turn-diff"; -import type { CodexChatHost, PluginSettingsRef } from "./features/chat/host/environment"; import type { ChatPanelClientSurface, ChatSharedThreadSurface, ChatViewLifecycleSurface, ChatWorkspacePanelSurface, -} from "./features/chat/host/surface-handle"; + CodexChatHost, + PluginSettingsRef, +} from "./features/chat/host/contracts"; import { CodexChatTurnDiffView } from "./features/chat/ui/turn-diff/view.obsidian"; import { openThreadPicker, type ThreadPickerHost } from "./features/thread-picker/modal.obsidian"; import type { ThreadsViewHost, ThreadsViewSettingsAccess } from "./features/threads-view/session"; diff --git a/src/workspace/panel-coordinator.ts b/src/workspace/panel-coordinator.ts index 53ad7c2e..6ab6bd0e 100644 --- a/src/workspace/panel-coordinator.ts +++ b/src/workspace/panel-coordinator.ts @@ -2,7 +2,7 @@ import type { App, WorkspaceLeaf } from "obsidian"; import { VIEW_TYPE_CODEX_PANEL } from "../constants"; import { hasPendingRequests, pendingRequestCounts } from "../domain/pending-requests/aggregate"; -import type { ChatWorkspacePanelSurface } from "../features/chat/host/surface-handle"; +import type { ChatWorkspacePanelSurface } from "../features/chat/host/contracts"; import { CodexChatView } from "../features/chat/host/view"; import type { ChatPanelSnapshot } from "../features/chat/panel/snapshot"; diff --git a/tests/features/chat/host/lifecycle.test.ts b/tests/features/chat/host/lifecycle.test.ts index b6acbadd..9690b9ea 100644 --- a/tests/features/chat/host/lifecycle.test.ts +++ b/tests/features/chat/host/lifecycle.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ChatResumeWorkTracker, transitionRestoredThreadLifecycle } from "../../../../src/features/chat/application/lifecycle"; -import { createChatViewDeferredTasks } from "../../../../src/features/chat/host/deferred-tasks"; +import { createChatViewDeferredTasks } from "../../../../src/features/chat/host/deferred-work"; import { ConnectionWorkTracker } from "../../../../src/shared/lifecycle/connection-work"; describe("createChatViewDeferredTasks", () => { diff --git a/tests/features/chat/host/session-graph.test.ts b/tests/features/chat/host/session-graph.test.ts index 99b37487..b13f5c12 100644 --- a/tests/features/chat/host/session-graph.test.ts +++ b/tests/features/chat/host/session-graph.test.ts @@ -7,8 +7,9 @@ import type { ModelMetadata } from "../../../../src/domain/catalog/metadata"; import type { Thread } from "../../../../src/domain/threads/model"; import { ChatResumeWorkTracker } from "../../../../src/features/chat/application/lifecycle"; import { type ChatStateStore, createChatStateStore } from "../../../../src/features/chat/application/state/store"; -import { createChatViewDeferredTasks } from "../../../../src/features/chat/host/deferred-tasks"; -import type { ChatPanelEnvironment } from "../../../../src/features/chat/host/environment"; +import { HistoryController } from "../../../../src/features/chat/application/threads/history-controller"; +import type { ChatPanelEnvironment } from "../../../../src/features/chat/host/contracts"; +import { createChatViewDeferredTasks } from "../../../../src/features/chat/host/deferred-work"; import { createChatPanelSessionGraph } from "../../../../src/features/chat/host/session-graph"; import { ChatComposerController } from "../../../../src/features/chat/panel/composer-controller"; import { MessageStreamPresenter } from "../../../../src/features/chat/panel/surface/message-stream-presenter"; @@ -34,7 +35,7 @@ describe("createChatPanelSessionGraph actions", () => { it("invalidates thread work through the graph action", () => { const { graph, resumeWork } = sessionGraphFixture(); const resume = resumeWork.begin("thread-1"); - const invalidateHistory = vi.spyOn(graph.thread.history, "invalidate"); + const invalidateHistory = vi.spyOn(HistoryController.prototype, "invalidate"); graph.actions.invalidateThreadWork(); diff --git a/tests/features/chat/host/view-connection.test.ts b/tests/features/chat/host/view-connection.test.ts index 27703653..51ee6b62 100644 --- a/tests/features/chat/host/view-connection.test.ts +++ b/tests/features/chat/host/view-connection.test.ts @@ -10,7 +10,7 @@ import { emptyRuntimeConfigSnapshot } from "../../../../src/domain/runtime/confi import { createServerDiagnostics } from "../../../../src/domain/server/diagnostics"; import type { SharedServerMetadata } from "../../../../src/domain/server/metadata"; import type { Thread } from "../../../../src/domain/threads/model"; -import type { CodexChatHost } from "../../../../src/features/chat/host/environment"; +import type { CodexChatHost } from "../../../../src/features/chat/host/contracts"; import { DEFAULT_SETTINGS } from "../../../../src/settings/model"; import type { ThreadCatalogEvent } from "../../../../src/workspace/thread-catalog"; import { notices } from "../../../mocks/obsidian"; diff --git a/tests/main.test.ts b/tests/main.test.ts index 2ef30504..0a8008cd 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { VIEW_TYPE_CODEX_PANEL, VIEW_TYPE_CODEX_THREADS } from "../src/constants"; import type { Thread } from "../src/domain/threads/model"; -import type { CodexChatHost } from "../src/features/chat/host/environment"; +import type { CodexChatHost } from "../src/features/chat/host/contracts"; import type { CodexChatView } from "../src/features/chat/host/view"; import type CodexPanelPlugin from "../src/main"; import { DEFAULT_SETTINGS } from "../src/settings/model";