diff --git a/src/database/adapters/HybridStorageAdapter.ts b/src/database/adapters/HybridStorageAdapter.ts index 6bf42296f..d6c7283a9 100644 --- a/src/database/adapters/HybridStorageAdapter.ts +++ b/src/database/adapters/HybridStorageAdapter.ts @@ -232,22 +232,37 @@ export class HybridStorageAdapter implements IStorageAdapter { */ private async performInitialization(): Promise { try { + console.log('[HybridStorageAdapter] Starting initialization...'); + const migrator = new LegacyMigrator(this.app); const migrationNeeded = await migrator.isMigrationNeeded(); let actuallyMigrated = false; if (migrationNeeded) { + console.log('[HybridStorageAdapter] Legacy migration needed, running...'); const migrationResult = await migrator.migrate(); // Only count as "actually migrated" if something was migrated actuallyMigrated = migrationResult.needed && (migrationResult.stats.workspacesMigrated > 0 || migrationResult.stats.conversationsMigrated > 0); + console.log('[HybridStorageAdapter] Legacy migration complete:', { + workspaces: migrationResult.stats.workspacesMigrated, + conversations: migrationResult.stats.conversationsMigrated + }); } + console.log('[HybridStorageAdapter] Preparing storage plan...'); const storagePlan = await this.storageCoordinator.prepareStoragePlan(); this.applyStoragePlan(storagePlan); + console.log('[HybridStorageAdapter] Storage plan applied:', { + writePath: storagePlan.writeBasePath, + readPaths: storagePlan.readBasePaths, + migrationState: storagePlan.state.migration.state + }); // 1. Initialize SQLite cache + console.log('[HybridStorageAdapter] Initializing SQLite cache...'); await this.sqliteCache.initialize(); + console.log('[HybridStorageAdapter] SQLite cache initialized'); // 2. Ensure JSONL directories exist await this.jsonlWriter.ensureDirectory('workspaces'); @@ -266,19 +281,24 @@ export class HybridStorageAdapter implements IStorageAdapter { // The UI will show incrementally as data syncs in. const syncState = await this.sqliteCache.getSyncState(this.jsonlWriter.getDeviceId()); if (!syncState || actuallyMigrated) { + console.log('[HybridStorageAdapter] Running full rebuild...'); try { await this.syncCoordinator.fullRebuild(); + console.log('[HybridStorageAdapter] Full rebuild complete'); } catch (rebuildError) { console.error('[HybridStorageAdapter] Full rebuild failed:', rebuildError); } } else { + console.log('[HybridStorageAdapter] Running incremental sync...'); try { await this.syncCoordinator.sync(); + console.log('[HybridStorageAdapter] Incremental sync complete'); } catch (syncError) { console.error('[HybridStorageAdapter] Incremental sync failed:', syncError); } // 5. Reconcile JSONL workspaces missing from SQLite + console.log('[HybridStorageAdapter] Reconciling missing data...'); try { await this.reconcileMissingWorkspaces(); } catch (reconcileError) { @@ -298,8 +318,28 @@ export class HybridStorageAdapter implements IStorageAdapter { } catch (reconcileError) { console.error('[HybridStorageAdapter] Task reconciliation failed:', reconcileError); } + console.log('[HybridStorageAdapter] Reconciliation complete'); + } + + // Copy fully-populated cache.db to plugin-scoped storage after sync completes. + // Must happen AFTER rebuild/sync so the copy includes sync state. + if (this.storageCoordinator.backgroundMigration) { + try { + await this.storageCoordinator.backgroundMigration; + const dataRoot = this.storageCoordinator.roots.dataRoot; + const legacyCacheDb = `${this.basePath}/cache.db`; + const newCacheDb = `${dataRoot}/cache.db`; + if (await this.app.vault.adapter.exists(legacyCacheDb)) { + const content = await this.app.vault.adapter.readBinary(legacyCacheDb); + await this.app.vault.adapter.writeBinary(newCacheDb, content); + console.log('[HybridStorageAdapter] Copied cache.db to plugin-scoped storage'); + } + } catch (cacheError) { + console.warn('[HybridStorageAdapter] cache.db copy failed (will rebuild on next boot):', cacheError); + } } + console.log('[HybridStorageAdapter] Initialization complete'); } catch (error) { console.error('[HybridStorageAdapter] Initialization failed:', error); this.initError = error as Error; @@ -314,6 +354,7 @@ export class HybridStorageAdapter implements IStorageAdapter { this.basePath = plan.writeBasePath; this.jsonlWriter.setBasePath(plan.writeBasePath); this.jsonlWriter.setReadBasePaths(plan.readBasePaths); + this.sqliteCache.setDbPath(`${plan.writeBasePath}/cache.db`); } /** diff --git a/src/database/migration/PluginScopedStorageCoordinator.ts b/src/database/migration/PluginScopedStorageCoordinator.ts index 3e7db9b45..f73ceb0f2 100644 --- a/src/database/migration/PluginScopedStorageCoordinator.ts +++ b/src/database/migration/PluginScopedStorageCoordinator.ts @@ -1,4 +1,4 @@ -import { App, Plugin, normalizePath } from 'obsidian'; +import { App, Notice, Plugin, normalizePath } from 'obsidian'; import type { MCPSettings } from '../../types/plugin/PluginTypes'; import { resolvePluginStorageRoot, @@ -64,9 +64,24 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } +/** + * Two-stage migration coordinator for plugin-scoped storage. + * + * Stage 1 (first boot with legacy data): Returns legacy paths immediately, + * kicks off file copy + verify as fire-and-forget background work. + * When complete, persists state as 'verified'. No path changes this session. + * + * Stage 2 (subsequent boot after verified): Returns plugin-data paths instantly. + * No file I/O needed — the copy was completed in a prior session. + * + * Failed state: Stays on legacy paths. Background copy may retry on next boot. + */ export class PluginScopedStorageCoordinator { readonly roots: ResolvedPluginStorageRoot; + /** Exposed for testing — resolves when background migration finishes. */ + backgroundMigration: Promise | null = null; + constructor( private readonly app: App, private readonly plugin: Plugin, @@ -75,81 +90,110 @@ export class PluginScopedStorageCoordinator { this.roots = resolvePluginStorageRoot(app, plugin); } + /** + * Return a storage plan quickly. Never blocks on file copy I/O. + * + * - verified: instant cutover to plugin-data paths + * - not_started/copying/copied/failed with legacy files: legacy paths, + * background copy kicked off + * - no legacy files: plugin-data paths (nothing to migrate) + */ async prepareStoragePlan(): Promise { - await this.ensureDirectory(this.roots.dataRoot); - await this.ensureDirectory(this.roots.migrationRoot); + const state = await this.loadState(); - // Short-circuit: if migration already verified, skip all filesystem I/O - const persistedState = await this.loadState(); - if (persistedState.migration.state === 'verified') { - return { - writeBasePath: this.roots.dataRoot, - readBasePaths: [this.roots.dataRoot, this.legacyBasePath], - state: { - ...persistedState, - sourceOfTruthLocation: 'plugin-data', - migration: { - ...persistedState.migration, - activeDestination: this.roots.dataRoot - } - }, - roots: this.roots - }; + // Stage 2: migration already verified in a prior session — instant cutover + if (state.migration.state === 'verified') { + return this.buildPluginDataPlan(state); } + // Check whether legacy files exist (lightweight directory listing) const legacyFiles = await this.collectLegacyFiles(); - let state = persistedState; - state = { - ...state, - migration: { - ...state.migration, - activeDestination: this.roots.dataRoot, - legacySourcesDetected: legacyFiles.length > 0 ? [this.legacyBasePath] : [] - } - }; + // No legacy data — go straight to plugin-data paths if (legacyFiles.length === 0) { - const nextState: PluginScopedStorageState = { + const freshState: PluginScopedStorageState = { ...state, sourceOfTruthLocation: 'plugin-data', migration: { ...state.migration, + activeDestination: this.roots.dataRoot, + legacySourcesDetected: [], + // Preserve failed state if a prior attempt failed state: state.migration.state === 'failed' ? 'failed' : state.migration.state, lastError: state.migration.state === 'failed' ? state.migration.lastError : undefined } }; - await this.saveState(nextState); + await this.saveState(freshState); return { writeBasePath: this.roots.dataRoot, readBasePaths: [this.roots.dataRoot], - state: nextState, + state: freshState, roots: this.roots }; } + // Stage 1: legacy files exist — return legacy plan immediately, + // kick off copy+verify in the background + const legacyState: PluginScopedStorageState = { + ...state, + sourceOfTruthLocation: 'legacy-dotnexus', + migration: { + ...state.migration, + activeDestination: this.roots.dataRoot, + legacySourcesDetected: [this.legacyBasePath] + } + }; + + this.backgroundMigration = this.runBackgroundMigration(legacyState, legacyFiles); + + return { + writeBasePath: this.legacyBasePath, + readBasePaths: [this.legacyBasePath], + state: legacyState, + roots: this.roots + }; + } + + /** + * Fire-and-forget background migration. Copies legacy files to plugin-scoped + * storage, verifies them, and saves state as 'verified'. Errors are caught + * and persisted as 'failed' state — they don't propagate to the caller. + */ + private async runBackgroundMigration( + state: PluginScopedStorageState, + legacyFiles: string[] + ): Promise { try { - state = await this.runCopyOnlyMigration(state, legacyFiles); - if (state.migration.state === 'verified') { - const verifiedState: PluginScopedStorageState = { - ...state, - sourceOfTruthLocation: 'plugin-data' - }; - await this.saveState(verifiedState); - return { - writeBasePath: this.roots.dataRoot, - readBasePaths: [this.roots.dataRoot, this.legacyBasePath], - state: verifiedState, - roots: this.roots - }; + new Notice('Preparing your data for cross-device sync…'); + await this.ensureDirectory(this.roots.dataRoot); + await this.ensureDirectory(this.roots.migrationRoot); + + const finalState = await this.runCopyOnlyMigration(state, legacyFiles); + if (finalState.migration.state === 'verified') { + console.warn('[PluginScopedStorageCoordinator] Background migration verified — cutover will happen on next boot'); + new Notice('Data migration complete — changes take effect on next restart.'); } } catch (error) { - state = await this.saveFailureState(state, error instanceof Error ? error.message : String(error)); + console.error('[PluginScopedStorageCoordinator] Background migration failed:', error); + new Notice('Data migration encountered an issue — see console for details.'); + await this.saveFailureState(state, error instanceof Error ? error.message : String(error)).catch(() => { + // Best-effort — don't let state save failure mask the original error + }); } + } + private buildPluginDataPlan(state: PluginScopedStorageState): PluginScopedStoragePlan { return { - writeBasePath: this.legacyBasePath, - readBasePaths: [this.legacyBasePath], - state, + writeBasePath: this.roots.dataRoot, + readBasePaths: [this.roots.dataRoot, this.legacyBasePath], + state: { + ...state, + sourceOfTruthLocation: 'plugin-data', + migration: { + ...state.migration, + activeDestination: this.roots.dataRoot + } + }, roots: this.roots }; } @@ -383,4 +427,4 @@ export class PluginScopedStorageCoordinator { await this.app.vault.adapter.mkdir(path); } -} \ No newline at end of file +} diff --git a/src/database/storage/SQLiteCacheManager.ts b/src/database/storage/SQLiteCacheManager.ts index dc8f44262..f56826d27 100644 --- a/src/database/storage/SQLiteCacheManager.ts +++ b/src/database/storage/SQLiteCacheManager.ts @@ -118,6 +118,18 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager this.searchService = new SQLiteSearchService(this); } + /** + * Update the database path before initialization. + * Must be called before initialize() — has no effect after the DB is open. + */ + setDbPath(path: string): void { + if (this.isInitialized) { + console.warn('[SQLiteCacheManager] setDbPath called after initialization — ignoring'); + return; + } + this.dbPath = path; + } + private getSqlite3OrThrow(): SQLite3Module { if (!this.sqlite3) { throw new Error('SQLite module not initialized'); diff --git a/src/ui/chat/ChatView.ts b/src/ui/chat/ChatView.ts index bb111a74f..74700adc5 100644 --- a/src/ui/chat/ChatView.ts +++ b/src/ui/chat/ChatView.ts @@ -8,60 +8,52 @@ * and tool event coordination to ToolEventCoordinator. */ -import { ItemView, WorkspaceLeaf, Notice } from 'obsidian'; +import { ItemView, WorkspaceLeaf } from 'obsidian'; import { ConversationList } from './components/ConversationList'; import { MessageDisplay } from './components/MessageDisplay'; import { ChatInput } from './components/ChatInput'; -import { ContextProgressBar } from './components/ContextProgressBar'; -import { ChatSettingsModal } from './components/ChatSettingsModal'; -import { ChatService } from '../../services/chat/ChatService'; -import { ConversationData, ConversationMessage } from '../../types/chat/ChatTypes'; -import { MessageEnhancement } from './components/suggesters/base/SuggesterInterfaces'; -import type NexusPlugin from '../../main'; -import type { WorkspaceService } from '../../services/WorkspaceService'; +import { ContextProgressBar } from './components/ContextProgressBar'; +import { ChatSettingsModal } from './components/ChatSettingsModal'; +import { ChatService } from '../../services/chat/ChatService'; +import { ConversationData, ConversationMessage } from '../../types/chat/ChatTypes'; +import type NexusPlugin from '../../main'; +import type { WorkspaceService } from '../../services/WorkspaceService'; // Services -import { ConversationManager, ConversationManagerEvents } from './services/ConversationManager'; -import { MessageManager, MessageManagerEvents } from './services/MessageManager'; -import { ModelAgentManager, ModelAgentManagerEvents } from './services/ModelAgentManager'; -import { BranchManager, BranchManagerEvents } from './services/BranchManager'; -import { ContextCompactionService } from '../../services/chat/ContextCompactionService'; -import { CompactionTranscriptRecoveryService } from '../../services/chat/CompactionTranscriptRecoveryService'; -import { ContextPreservationService } from '../../services/chat/ContextPreservationService'; -import type { PreservationDependencies } from '../../services/chat/ContextPreservationService'; -import { ContextTracker } from './services/ContextTracker'; +import { ConversationManager, ConversationManagerEvents } from './services/ConversationManager'; +import { MessageManager, MessageManagerEvents } from './services/MessageManager'; +import { ModelAgentManager, ModelAgentManagerEvents } from './services/ModelAgentManager'; +import { BranchManager, BranchManagerEvents } from './services/BranchManager'; +import { ChatSessionCoordinator, WorkflowMessageOptions } from './services/ChatSessionCoordinator'; +import { ChatSendCoordinator } from './services/ChatSendCoordinator'; +import { ChatBranchViewCoordinator } from './services/ChatBranchViewCoordinator'; +import { ChatSubagentIntegration } from './services/ChatSubagentIntegration'; +import { ContextPreservationService } from '../../services/chat/ContextPreservationService'; +import { ContextTracker } from './services/ContextTracker'; // Controllers -import { UIStateController, UIStateControllerEvents } from './controllers/UIStateController'; -import { StreamingController } from './controllers/StreamingController'; -import { NexusLoadingController } from './controllers/NexusLoadingController'; -import { SubagentController, SubagentContextProvider } from './controllers/SubagentController'; +import { UIStateController, UIStateControllerEvents } from './controllers/UIStateController'; +import { StreamingController } from './controllers/StreamingController'; +import { NexusLoadingController } from './controllers/NexusLoadingController'; +import { SubagentController } from './controllers/SubagentController'; // Coordinators import { ToolEventCoordinator } from './coordinators/ToolEventCoordinator'; -// Builders and Utilities -import { ChatLayoutBuilder, ChatLayoutElements } from './builders/ChatLayoutBuilder'; -import { ChatEventBinder } from './utils/ChatEventBinder'; - -// Utils -import { ReferenceMetadata } from './utils/ReferenceExtractor'; -import { CHAT_VIEW_TYPES } from '../../constants/branding'; -import { getNexusPlugin } from '../../utils/pluginLocator'; +// Builders and Utilities +import { ChatLayoutBuilder, ChatLayoutElements } from './builders/ChatLayoutBuilder'; +import { ChatEventBinder } from './utils/ChatEventBinder'; + +import { CHAT_VIEW_TYPES } from '../../constants/branding'; +import { getNexusPlugin } from '../../utils/pluginLocator'; // Nexus Lifecycle import { getWebLLMLifecycleManager } from '../../services/llm/adapters/webllm/WebLLMLifecycleManager'; -// Subagent infrastructure (delegated to SubagentController) -import type { AgentManager } from '../../services/AgentManager'; -import type { DirectToolExecutor } from '../../services/chat/DirectToolExecutor'; -import type { PromptManagerAgent } from '../../agents/promptManager/promptManager'; -import type { HybridStorageAdapter } from '../../database/adapters/HybridStorageAdapter'; - -// Branch UI components -import { BranchHeader, BranchViewContext } from './components/BranchHeader'; -import { isSubagentMetadata } from '../../types/branch/BranchTypes'; -import type { ModelOption, PromptOption } from './types/SelectionTypes'; -import type { ToolEventData as ChatServiceToolEventData } from '../../services/chat/ToolCallService'; +// Subagent infrastructure (delegated to SubagentController) +import type { HybridStorageAdapter } from '../../database/adapters/HybridStorageAdapter'; +import type { ModelOption, PromptOption } from './types/SelectionTypes'; +import type { ToolEventData as ChatServiceToolEventData } from '../../services/chat/ToolCallService'; +import type { BranchViewContext } from './components/BranchHeader'; export const CHAT_VIEW_TYPE = CHAT_VIEW_TYPES.current; type ChatToolEventData = Parameters[2]; @@ -80,13 +72,12 @@ export class ChatView extends ItemView { private contextProgressBar!: ContextProgressBar; // Services - private conversationManager!: ConversationManager; - private messageManager!: MessageManager; - private modelAgentManager!: ModelAgentManager; - private branchManager!: BranchManager; - private compactionService: ContextCompactionService; - private preservationService: ContextPreservationService | null = null; - private contextTracker!: ContextTracker; + private conversationManager!: ConversationManager; + private messageManager!: MessageManager; + private modelAgentManager!: ModelAgentManager; + private branchManager!: BranchManager; + private preservationService: ContextPreservationService | null = null; + private contextTracker!: ContextTracker; // Controllers and Coordinators private uiStateController!: UIStateController; @@ -103,24 +94,76 @@ export class ChatView extends ItemView { // Search debounce timer for conversation search input private searchDebounceTimer: ReturnType | null = null; - // Branch UI state - private branchHeader: BranchHeader | null = null; - private currentBranchContext: BranchViewContext | null = null; - - // Parent conversation reference when viewing a branch - // Used for back navigation - the branch becomes currentConversation when viewing - private parentConversationId: string | null = null; - // Scroll position to restore when returning from branch - private parentScrollPosition = 0; - private pendingConversationId: string | null = null; + private sessionCoordinator: ChatSessionCoordinator; + private sendCoordinator: ChatSendCoordinator; + private branchViewCoordinator: ChatBranchViewCoordinator; + private subagentIntegration: ChatSubagentIntegration; // Layout elements - private layoutElements!: ChatLayoutElements; - - constructor(leaf: WorkspaceLeaf, private chatService: ChatService) { - super(leaf); - this.compactionService = new ContextCompactionService(); - } + private layoutElements!: ChatLayoutElements; + + constructor(leaf: WorkspaceLeaf, private chatService: ChatService) { + super(leaf); + this.sessionCoordinator = new ChatSessionCoordinator({ + chatService: this.chatService, + component: this, + getContainerEl: () => this.containerEl, + getChatTitleEl: () => this.layoutElements?.chatTitle ?? null, + getConversationManager: () => this.conversationManager ?? null, + getMessageManager: () => this.messageManager ?? null, + getModelAgentManager: () => this.modelAgentManager ?? null, + getConversationList: () => this.conversationList ?? null, + getMessageDisplay: () => this.messageDisplay ?? null, + getChatInput: () => this.chatInput ?? null, + getUIStateController: () => this.uiStateController ?? null, + onClearStreamingState: () => this.streamingController?.cleanup(), + onClearAgentStatus: () => this.subagentController?.clearAgentStatus(), + onUpdateChatTitle: () => this.updateChatTitle(), + onUpdateContextProgress: () => { + void this.updateContextProgress(); + } + }); + this.sendCoordinator = new ChatSendCoordinator({ + app: this.app, + chatService: this.chatService, + getContainerEl: () => this.containerEl, + getConversationManager: () => this.conversationManager ?? null, + getMessageManager: () => this.messageManager ?? null, + getModelAgentManager: () => this.modelAgentManager ?? null, + getChatInput: () => this.chatInput ?? null, + getMessageDisplay: () => this.messageDisplay ?? null, + getStreamingController: () => this.streamingController ?? null, + getPreservationService: () => this.preservationService, + getStorageAdapter: () => + getNexusPlugin(this.app)?.getServiceIfReady('hybridStorageAdapter') ?? null, + onUpdateContextProgress: () => { + void this.updateContextProgress(); + } + }); + this.subagentIntegration = new ChatSubagentIntegration({ + app: this.app, + component: this, + chatService: this.chatService, + getConversationManager: () => this.conversationManager ?? null, + getModelAgentManager: () => this.modelAgentManager ?? null, + getStreamingController: () => this.streamingController ?? null, + getToolEventCoordinator: () => this.toolEventCoordinator ?? null, + getSettingsButtonContainer: () => this.layoutElements.settingsButton?.parentElement ?? undefined, + getSettingsButton: () => this.layoutElements.settingsButton, + getNavigationTarget: () => this.branchViewCoordinator ?? null, + }); + this.branchViewCoordinator = new ChatBranchViewCoordinator({ + component: this, + getConversation: (conversationId) => this.chatService.getConversation(conversationId), + getConversationManager: () => this.conversationManager ?? null, + getBranchManager: () => this.branchManager ?? null, + getMessageDisplay: () => this.messageDisplay ?? null, + getStreamingController: () => this.streamingController ?? null, + getSubagentController: () => this.subagentController, + getSubagentContextProvider: () => this.subagentIntegration.createContextProvider(), + getBranchHeaderContainer: () => this.layoutElements.branchHeaderContainer, + }); + } private getChatContainer(): HTMLElement | null { const container = this.containerEl.children[1]; @@ -365,27 +408,27 @@ export class ChatView extends ItemView { */ private initializeServices(): void { // Branch management - const branchEvents: BranchManagerEvents = { - onBranchCreated: (messageId: string, branchId: string) => { - this.handleBranchCreated(messageId, branchId); - }, - onBranchSwitched: (messageId: string, branchId: string) => { - void this.handleBranchSwitched(messageId, branchId); - }, - onError: (message) => this.uiStateController.showError(message) - }; + const branchEvents: BranchManagerEvents = { + onBranchCreated: (messageId: string, branchId: string) => { + this.branchViewCoordinator.handleBranchCreated(messageId, branchId); + }, + onBranchSwitched: (messageId: string, branchId: string) => { + this.branchViewCoordinator.handleBranchSwitched(messageId, branchId); + }, + onError: (message) => this.uiStateController.showError(message) + }; this.branchManager = new BranchManager(this.chatService.getConversationRepository(), branchEvents); // Conversation management - const conversationEvents: ConversationManagerEvents = { - onConversationSelected: (conversation) => { - void this.handleConversationSelected(conversation); - }, - onConversationsChanged: () => { - void this.handleConversationsChanged(); - }, - onError: (message) => this.uiStateController.showError(message) - }; + const conversationEvents: ConversationManagerEvents = { + onConversationSelected: (conversation) => { + void this.sessionCoordinator.handleConversationSelected(conversation); + }, + onConversationsChanged: () => { + void this.sessionCoordinator.handleConversationsChanged(); + }, + onError: (message) => this.uiStateController.showError(message) + }; this.conversationManager = new ConversationManager(this.app, this.chatService, this.branchManager, conversationEvents); // Message handling @@ -403,13 +446,13 @@ export class ChatView extends ItemView { toolCalls as unknown as DetectedToolCalls ), onToolExecutionStarted: (messageId, toolCall) => this.toolEventCoordinator.handleToolExecutionStarted(messageId, toolCall), - onToolExecutionCompleted: (messageId, toolId, result, success, error) => - this.toolEventCoordinator.handleToolExecutionCompleted(messageId, toolId, result, success, error), - onMessageIdUpdated: (oldId, newId, updatedMessage) => this.handleMessageIdUpdated(oldId, newId, updatedMessage), - onGenerationAborted: (messageId, partialContent) => this.handleGenerationAborted(messageId, partialContent), - // Token usage tracking for local models with limited context - onUsageAvailable: (usage) => this.modelAgentManager.recordTokenUsage(usage.promptTokens, usage.completionTokens) - }; + onToolExecutionCompleted: (messageId, toolId, result, success, error) => + this.toolEventCoordinator.handleToolExecutionCompleted(messageId, toolId, result, success, error), + onMessageIdUpdated: (oldId, newId, updatedMessage) => this.handleMessageIdUpdated(oldId, newId, updatedMessage), + onGenerationAborted: (messageId, _partialContent) => this.sendCoordinator.handleGenerationAborted(messageId), + // Token usage tracking for local models with limited context + onUsageAvailable: (usage) => this.modelAgentManager.recordTokenUsage(usage.promptTokens, usage.completionTokens) + }; this.messageManager = new MessageManager(this.chatService, this.branchManager, messageEvents); // Model and agent management @@ -469,40 +512,40 @@ export class ChatView extends ItemView { } ); - this.messageDisplay = new MessageDisplay( - this.layoutElements.messageContainer, - this.app, - this.branchManager, - (messageId) => { - void this.handleRetryMessage(messageId); - }, - (messageId, newContent) => { - void this.handleEditMessage(messageId, newContent); - }, - (messageId, event, data) => this.handleToolEvent(messageId, event, data as unknown as ChatToolEventData), - (messageId: string, alternativeIndex: number) => { - void this.handleBranchSwitchedByIndex(messageId, alternativeIndex); - }, - (branchId: string) => { - void this.navigateToBranch(branchId); - } - ); + this.messageDisplay = new MessageDisplay( + this.layoutElements.messageContainer, + this.app, + this.branchManager, + (messageId) => { + void this.sendCoordinator.handleRetryMessage(messageId); + }, + (messageId, newContent) => { + void this.sendCoordinator.handleEditMessage(messageId, newContent); + }, + (messageId, event, data) => this.handleToolEvent(messageId, event, data as unknown as ChatToolEventData), + (messageId: string, alternativeIndex: number) => { + void this.branchViewCoordinator.handleBranchSwitchedByIndex(messageId, alternativeIndex); + }, + (branchId: string) => { + void this.branchViewCoordinator.navigateToBranch(branchId); + } + ); // Initialize tool event coordinator after messageDisplay is created this.toolEventCoordinator = new ToolEventCoordinator(this.messageDisplay); - this.chatInput = new ChatInput( - this.layoutElements.inputContainer, - (message, enhancement, metadata) => { - void this.handleSendMessage(message, enhancement, metadata); - }, - () => this.messageManager.getIsLoading(), - this.app, - () => { - this.handleStopGeneration(); - }, - () => this.conversationManager.getCurrentConversation() !== null, - this // Pass Component for registerDomEvent + this.chatInput = new ChatInput( + this.layoutElements.inputContainer, + (message, enhancement, metadata) => { + void this.sendCoordinator.handleSendMessage(message, enhancement, metadata); + }, + () => this.messageManager.getIsLoading(), + this.app, + () => { + this.sendCoordinator.handleStopGeneration(); + }, + () => this.conversationManager.getCurrentConversation() !== null, + this // Pass Component for registerDomEvent ); this.contextProgressBar = new ContextProgressBar( @@ -573,95 +616,14 @@ export class ChatView extends ItemView { * Initialize subagent infrastructure via SubagentController * This is async and non-blocking - subagent features will be available once this completes */ - private async initializeSubagentInfrastructure(): Promise { - try { - const plugin = getNexusPlugin(this.app); - if (!plugin) return; - - // Get required services - const directToolExecutor = await plugin.getService('directToolExecutor'); - if (!directToolExecutor) return; - - const agentManager = await plugin.getService('agentManager'); - if (!agentManager) return; - - const promptManagerAgent = agentManager.getAgent('promptManager') as PromptManagerAgent | null; - if (!promptManagerAgent) return; - - // Use getServiceIfReady to avoid triggering SQLite WASM loading during startup - const storageAdapter = plugin.getServiceIfReady('hybridStorageAdapter'); - if (!storageAdapter) { - return; - } - - const llmService = this.chatService.getLLMService(); - if (!llmService) return; - - // Create SubagentController - this.subagentController = new SubagentController(this.app, this, { - onStreamingUpdate: () => { /* handled internally */ }, - onToolCallsDetected: () => { /* handled internally */ }, - onStatusChanged: () => { /* status menu auto-updates */ }, - onConversationNeedsRefresh: (conversationId: string) => { - // Reload conversation if viewing the one that was updated - const current = this.conversationManager?.getCurrentConversation(); - if (current?.id === conversationId) { - // Re-select current conversation to trigger full reload - void this.conversationManager?.selectConversation(current); - } - }, - }); - - // Build context provider from ModelAgentManager - const contextProvider: SubagentContextProvider = { - getCurrentConversation: () => this.conversationManager?.getCurrentConversation() ?? null, - getSelectedModel: () => this.modelAgentManager?.getSelectedModel() ?? null, - getSelectedPrompt: () => this.modelAgentManager?.getSelectedPrompt() ?? null, - getLoadedWorkspaceData: () => this.modelAgentManager?.getLoadedWorkspaceData(), - getContextNotes: () => this.modelAgentManager?.getContextNotes() || [], - getThinkingSettings: () => this.modelAgentManager?.getThinkingSettings() ?? null, - getSelectedWorkspaceId: () => this.modelAgentManager?.getSelectedWorkspaceId() ?? null, - }; - - // Initialize with dependencies - this.subagentController.initialize( - { - app: this.app, - chatService: this.chatService, - directToolExecutor, - promptManagerAgent, - storageAdapter, - llmService, - }, - contextProvider, - this.streamingController, - this.toolEventCoordinator, - this.layoutElements.settingsButton?.parentElement ?? undefined, - this.layoutElements.settingsButton - ); - - // Wire up navigation callbacks for agent status modal - this.subagentController.setNavigationCallbacks({ - onNavigateToBranch: (branchId) => { - void this.navigateToBranch(branchId); - }, - onContinueAgent: (branchId) => { - void this.continueSubagent(branchId); - }, - }); - - // Initialize ContextPreservationService for LLM-driven saveState at 90% context - this.preservationService = new ContextPreservationService({ - llmService: llmService as unknown as PreservationDependencies['llmService'], - getAgent: (name: string) => agentManager.getAgent(name), - executeToolCalls: (toolCalls, context) => - directToolExecutor.executeToolCalls(toolCalls, context), - }); - - - } catch (error) { - console.error('[ChatView] Failed to initialize subagent infrastructure:', error); - throw error; + private async initializeSubagentInfrastructure(): Promise { + try { + const result = await this.subagentIntegration.initialize(); + this.subagentController = result.subagentController; + this.preservationService = result.preservationService; + } catch (error) { + console.error('[ChatView] Failed to initialize subagent infrastructure:', error); + throw error; } } @@ -681,12 +643,11 @@ export class ChatView extends ItemView { return; } - const currentConversation = this.conversationManager.getCurrentConversation(); - - if (currentConversation) { - // Access private property via type assertion - currentConversationId exists but is private - (this.modelAgentManager as unknown as { currentConversationId: string | null }).currentConversationId = currentConversation.id; - } + const currentConversation = this.conversationManager.getCurrentConversation(); + + if (currentConversation) { + this.modelAgentManager.setCurrentConversationId(currentConversation.id); + } const modal = new ChatSettingsModal( this.app, @@ -700,164 +661,23 @@ export class ChatView extends ItemView { /** * Load initial data */ - private async loadInitialData(): Promise { - await this.conversationManager.loadConversations(); - - const conversations = this.conversationManager.getConversations(); - if (conversations.length === 0) { - // Initialize with defaults (model, workspace, agent) for new chats - await this.modelAgentManager.initializeDefaults(); - - const hasProviders = this.chatService.hasConfiguredProviders(); - this.uiStateController.showWelcomeState(hasProviders); - if (this.layoutElements.chatTitle) { - this.layoutElements.chatTitle.textContent = 'Chat'; - } - if (this.chatInput) { - this.chatInput.setConversationState(false); - } - if (hasProviders) { - this.wireWelcomeButton(); - } - } - - if (this.pendingConversationId) { - const pendingId = this.pendingConversationId; - this.pendingConversationId = null; - await this.openConversationById(pendingId); - } - } - - async openConversationById(conversationId: string): Promise { - if (!this.conversationManager) { - this.pendingConversationId = conversationId; - return; - } - - const conversation = await this.chatService.getConversation(conversationId); - if (!conversation) { - return; - } - - await this.conversationManager.loadConversations(); - const listedConversation = this.conversationManager - .getConversations() - .find(item => item.id === conversationId); - - await this.conversationManager.selectConversation(listedConversation || conversation); - } - - async sendMessageToConversation( - conversationId: string, - message: string, - options?: { - provider?: string; - model?: string; - systemPrompt?: string; - workspaceId?: string; - sessionId?: string; - enableThinking?: boolean; - thinkingEffort?: 'low' | 'medium' | 'high'; - } - ): Promise { - if (!this.conversationManager || !this.messageManager) { - this.pendingConversationId = conversationId; - throw new Error('Chat view is not ready'); - } - - await this.openConversationById(conversationId); - - const currentConversation = this.conversationManager.getCurrentConversation(); - if (!currentConversation || currentConversation.id !== conversationId) { - throw new Error('Failed to focus workflow conversation'); - } - - if (this.messageManager.getIsLoading()) { - await this.messageManager.interruptCurrentGeneration(); - } - - void this.messageManager.sendMessage(currentConversation, message, options).catch(error => { - console.error('[ChatView] Failed to send workflow message:', error); - new Notice('Failed to start workflow run'); - }); - } - - /** - * Wire up the welcome screen button - */ - private wireWelcomeButton(): void { - ChatEventBinder.bindWelcomeButton( - this.containerEl, - () => { - void this.conversationManager.createNewConversation(); - }, - this - ); - } - - // Event Handlers - - private async handleConversationSelected(conversation: ConversationData): Promise { - // Cancel any ongoing generation from the previous conversation - // This prevents the loading state from blocking the new conversation - if (this.messageManager.getIsLoading()) { - void this.messageManager.cancelCurrentGeneration(); - this.streamingController.cleanup(); - } - - // Clear agent status when switching conversations (session-scoped) - this.subagentController?.clearAgentStatus(); - - // Access private property via type assertion - currentConversationId exists but is private - (this.modelAgentManager as unknown as { currentConversationId: string | null }).currentConversationId = conversation.id; - await this.modelAgentManager.initializeFromConversation(conversation.id); - this.messageDisplay.setConversation(conversation); - this.updateChatTitle(); - this.uiStateController.setInputPlaceholder('Type your message...'); - void this.updateContextProgress(); - - if (this.chatInput) { - this.chatInput.setConversationState(true); - } - - if (this.uiStateController.getSidebarVisible()) { - this.uiStateController.toggleConversationList(); - } - } - - private async handleConversationsChanged(): Promise { - if (this.conversationList) { - this.conversationList.setIsSearchActive(this.conversationManager.isSearchActive); - this.conversationList.setConversations(this.conversationManager.getConversations()); - this.conversationList.setHasMore(this.conversationManager.hasMore); - this.conversationList.setIsLoading(this.conversationManager.isLoading); - } - - const conversations = this.conversationManager.getConversations(); - const currentConversation = this.conversationManager.getCurrentConversation(); - - if (conversations.length === 0 && !this.conversationManager.isSearchActive) { - // Re-initialize with defaults when returning to welcome state - // (only when truly empty — not when search returns zero results) - await this.modelAgentManager.initializeDefaults(); - - const hasProviders = this.chatService.hasConfiguredProviders(); - this.uiStateController.showWelcomeState(hasProviders); - if (this.layoutElements.chatTitle) { - this.layoutElements.chatTitle.textContent = 'Chat'; - } - if (this.chatInput) { - this.chatInput.setConversationState(false); - } - if (hasProviders) { - this.wireWelcomeButton(); - } - } else if (!currentConversation && conversations.length > 0) { - await this.conversationManager.selectConversation(conversations[0]); - } - } - - private handleAIMessageStarted(message: ConversationMessage): void { + private async loadInitialData(): Promise { + await this.sessionCoordinator.loadInitialData(); + } + + async openConversationById(conversationId: string): Promise { + await this.sessionCoordinator.openConversationById(conversationId); + } + + async sendMessageToConversation( + conversationId: string, + message: string, + options?: WorkflowMessageOptions + ): Promise { + await this.sessionCoordinator.sendMessageToConversation(conversationId, message, options); + } + + private handleAIMessageStarted(message: ConversationMessage): void { this.messageDisplay.addAIMessage(message); } @@ -873,9 +693,9 @@ export class ChatView extends ItemView { } } - private handleConversationUpdated(conversation: ConversationData | null): void { - if (!conversation) { - // Null signals a forced UI refresh (e.g., subagent completion) + private handleConversationUpdated(conversation: ConversationData | null): void { + if (!conversation) { + // Null signals a forced UI refresh (e.g., subagent completion) this.updateChatTitle(); void this.updateContextProgress(); return; @@ -883,261 +703,19 @@ export class ChatView extends ItemView { this.conversationManager.updateCurrentConversation(conversation); this.messageDisplay.setConversation(conversation); this.updateChatTitle(); - - void this.updateContextProgress(); - } - - private async handleSendMessage( - message: string, - enhancement?: MessageEnhancement, - metadata?: ReferenceMetadata - ): Promise { - try { - if (this.messageManager.getIsLoading()) { - await this.messageManager.interruptCurrentGeneration(); - } - - const currentConversation = this.conversationManager.getCurrentConversation(); - - if (!currentConversation) { - return; - } - - if (enhancement) { - this.modelAgentManager.setMessageEnhancement(enhancement); - } - - let messageOptions = await this.modelAgentManager.getMessageOptions(); - - // Check if context compaction is needed before sending. - // Uses shared provider policy with conservative soft caps. - if (this.modelAgentManager.shouldCompactBeforeSending( - currentConversation, - message, - messageOptions.systemPrompt || null, - messageOptions.provider - )) { - this.setPreSendCompactionState(true); - try { - await this.performContextCompaction(currentConversation); - messageOptions = await this.modelAgentManager.getMessageOptions(); - } finally { - this.setPreSendCompactionState(false); - } - } - - await this.messageManager.sendMessage( - currentConversation, - message, - messageOptions, - metadata - ); - } finally { - this.setPreSendCompactionState(false); - this.modelAgentManager.clearMessageEnhancement(); - this.chatInput?.clearMessageEnhancer(); - } - } - - /** - * Perform context compaction when approaching token limit (90%) - * Shows an auto-save style notice (like a video game) during the process. - * - * Flow: - * 1. Try LLM-driven saveState via preservationService (rich semantic context) - * 2. Fall back to programmatic compaction if LLM fails - * 3. Compact conversation messages - * 4. Update storage and progress bar - */ - private async performContextCompaction(conversation: ConversationData): Promise { - const originalMessages = [...conversation.messages]; - let stateContent: string | undefined; - let usedLLM = false; - - // Try LLM-driven saveState if preservationService is available - if (this.preservationService) { - // Show "saving" notice - like a video game auto-save - const savingNotice = new Notice('Saving context...', 0); // 0 = don't auto-dismiss - - try { - const messageOptions = await this.modelAgentManager.getMessageOptions(); - const result = await this.preservationService.forceStateSave( - conversation.messages, - { - provider: messageOptions.provider, - model: messageOptions.model, - }, - { - workspaceId: this.modelAgentManager.getSelectedWorkspaceId() || undefined, - sessionId: conversation.metadata?.chatSettings?.sessionId, - } - ); - - if (result.success && result.stateContent) { - stateContent = result.stateContent; - usedLLM = true; - } - } catch (error) { - // LLM-driven preservation failed, will fall back to programmatic - console.error('[ChatView] LLM-driven saveState failed, using programmatic fallback:', error); - } finally { - // Dismiss the "saving" notice - savingNotice.hide(); - } - } - - // Run programmatic compaction (truncates messages) - const compactedContext = this.compactionService.compact(conversation, { - exchangesToKeep: 2, // Keep last 2 user/assistant exchanges - maxSummaryLength: 500, - includeFileReferences: true - }); - - if (compactedContext.messagesRemoved > 0) { - // Use LLM-saved state if available, otherwise use programmatic summary - if (stateContent) { - compactedContext.summary = stateContent; - } - - compactedContext.transcriptCoverage = await this.buildCompactionTranscriptCoverage( - conversation.id, - originalMessages, - conversation.messages - ) ?? undefined; - - // Append the new compaction record so the active frontier is projected into the system prompt. - this.modelAgentManager.appendCompactionRecord(compactedContext); - conversation.metadata = this.modelAgentManager.buildMetadataWithCompactionRecord( - conversation.metadata, - compactedContext - ); - - // Reset token tracker for fresh accounting with compacted conversation - this.modelAgentManager.resetTokenTracker(); - - // Update conversation in storage with compacted messages and metadata. - const conversationService = this.chatService.getConversationService(); - if (conversationService?.updateConversation) { - await conversationService.updateConversation(conversation.id, { - title: conversation.title, - messages: conversation.messages, - metadata: conversation.metadata - }); - } else { - await this.chatService.updateConversation(conversation); - } - - // Update progress bar immediately to reflect new token count - void this.updateContextProgress(); - - // Show completion notice - brief auto-save style feedback - const savedMsg = usedLLM - ? `Context saved (${compactedContext.messagesRemoved} messages compacted)` - : `Context compacted (${compactedContext.messagesRemoved} messages)`; - new Notice(savedMsg, 2500); - } - } - - private async buildCompactionTranscriptCoverage( - conversationId: string, - originalMessages: ConversationMessage[], - keptMessages: ConversationMessage[] - ) { - const plugin = getNexusPlugin(this.app); - const storageAdapter = plugin?.getServiceIfReady('hybridStorageAdapter'); - if (!storageAdapter) { - return null; - } - - const keptIds = new Set(keptMessages.map(message => message.id)); - const compactedMessageIds = originalMessages - .filter(message => !keptIds.has(message.id)) - .map(message => message.id); - - if (compactedMessageIds.length === 0) { - return null; - } - - const transcriptRecoveryService = new CompactionTranscriptRecoveryService( - storageAdapter.messages, - this.app - ); - return transcriptRecoveryService.buildCoverageRef(conversationId, compactedMessageIds); - } - - private async handleRetryMessage(messageId: string): Promise { - const currentConversation = this.conversationManager.getCurrentConversation(); - if (currentConversation) { - const messageOptions = await this.modelAgentManager.getMessageOptions(); - await this.messageManager.handleRetryMessage( - currentConversation, - messageId, - messageOptions - ); - } - } - - private async handleEditMessage(messageId: string, newContent: string): Promise { - const currentConversation = this.conversationManager.getCurrentConversation(); - if (currentConversation) { - const messageOptions = await this.modelAgentManager.getMessageOptions(); - await this.messageManager.handleEditMessage( - currentConversation, - messageId, - newContent, - messageOptions - ); - } - } - - private handleStopGeneration(): void { - void this.messageManager.cancelCurrentGeneration(); - } - - private handleGenerationAborted(messageId: string, _partialContent: string): void { - const messageBubble = this.messageDisplay.findMessageBubble(messageId); - if (messageBubble) { - messageBubble.stopLoadingAnimation(); - } - - const messageElement = this.containerEl.querySelector(`[data-message-id="${messageId}"]`); - if (messageElement) { - const contentElement = messageElement.querySelector('.message-bubble .message-content'); - if (contentElement) { - this.streamingController.stopLoadingAnimation(contentElement); - } - } - - // Get actual content from conversation (progressively saved during streaming) - // The passed partialContent is always empty; real content is in conversation object - const currentConversation = this.conversationManager?.getCurrentConversation(); - const message = currentConversation?.messages.find(m => m.id === messageId); - const actualContent = message?.content || ''; - - // Only finalize if we have content - otherwise just stop the animation - if (actualContent) { - this.streamingController.finalizeStreaming(messageId, actualContent); - } - } - - private handleLoadingStateChanged(loading: boolean): void { - if (this.chatInput) { + + void this.updateContextProgress(); + } + + private handleLoadingStateChanged(loading: boolean): void { + if (this.chatInput) { if (loading) { this.chatInput.setPreSendCompacting(false); this.messageDisplay.clearTransientEventRow(); - } - this.chatInput.setLoading(loading); - } - } - - private setPreSendCompactionState(compacting: boolean): void { - this.chatInput?.setPreSendCompacting(compacting); - if (compacting) { - this.messageDisplay.showTransientEventRow('Compacting context before sending...'); - } else { - this.messageDisplay.clearTransientEventRow(); - } - } + } + this.chatInput.setLoading(loading); + } + } private handleModelChanged(_model: ModelOption | null): void { void this.updateContextProgress(); @@ -1184,268 +762,18 @@ export class ChatView extends ItemView { this.messageDisplay.updateMessageId(oldId, newId, updatedMessage); } - // Branch event handlers - - private handleBranchCreated(_messageId: string, _branchId: string): void { - const currentConversation = this.conversationManager.getCurrentConversation(); - if (currentConversation) { - this.messageDisplay.setConversation(currentConversation); - } - } - - private handleBranchSwitched(_messageId: string, _branchId: string): void { - // Intentional no-op — the caller (handleBranchSwitchedByIndex) already - // calls messageDisplay.updateMessage() on success. Doing anything here - // causes a double updateMessage race that corrupts output. - } - - /** - * Handle branch switch by index (for MessageDisplay callback compatibility) - */ - private async handleBranchSwitchedByIndex(messageId: string, alternativeIndex: number): Promise { - const currentConversation = this.conversationManager.getCurrentConversation(); - if (currentConversation) { - const success = await this.branchManager.switchToBranchByIndex( - currentConversation, - messageId, - alternativeIndex - ); - - if (success) { - const updatedMessage = currentConversation.messages.find(msg => msg.id === messageId); - if (updatedMessage) { - this.messageDisplay.updateMessage(messageId, updatedMessage); - } - } - } - } - - - // Branch navigation methods for subagent viewing - - /** - * Navigate to a specific branch (subagent or human) - * Shows the branch messages in the message display with a back header - * - * For actively streaming branches, uses in-memory messages for flicker-free updates. - * StreamingController handles live updates via onStreamingUpdate event. - * - * ARCHITECTURE NOTE (Dec 2025): - * A branch IS a conversation with parent metadata. When viewing a branch, - * we set the branch as the currentConversation in ConversationManager. - * This means all message operations (send, edit, retry) naturally save to - * the branch conversation via ChatService - no special routing needed. - */ - async navigateToBranch(branchId: string): Promise { - const currentConversation = this.conversationManager.getCurrentConversation(); - if (!currentConversation) { - return; - } - - try { - // In the new architecture, branchId IS the conversation ID. - // Prefer the in-memory version if this branch is the currently active - // conversation (avoids stale reads when streaming recently updated it - // but storage hasn't been flushed yet). - const inMemoryCurrent = this.conversationManager.getCurrentConversation(); - const branchConversation = (inMemoryCurrent && inMemoryCurrent.id === branchId) - ? inMemoryCurrent - : await this.chatService.getConversation(branchId); - if (!branchConversation) { - console.error('[ChatView] Branch conversation not found:', branchId); - return; - } - - // Store parent conversation ID and scroll position for back navigation - // Only set if not already viewing a branch (avoid nested overwrite) - if (!this.parentConversationId) { - this.parentConversationId = currentConversation.id; - this.parentScrollPosition = this.messageDisplay.getScrollPosition(); - } - - // Check if this branch is actively streaming - use in-memory messages - const inMemoryMessages = this.subagentController?.getStreamingBranchMessages(branchId); - const isStreaming = inMemoryMessages !== null; - - // Build branch context for header display (uses conversation metadata) - const branchType = branchConversation.metadata?.branchType || 'human'; - const parentMessageId = branchConversation.metadata?.parentMessageId || ''; - - this.currentBranchContext = { - conversationId: branchConversation.metadata?.parentConversationId || currentConversation.id, - branchId, - parentMessageId, - branchType: branchType as 'human' | 'subagent', - metadata: branchConversation.metadata?.subagent || { description: branchConversation.title }, - }; - - // Sync context to SubagentController for event filtering - this.subagentController?.setCurrentBranchContext(this.currentBranchContext); - - // Set the branch as the current conversation - // All message operations will now naturally save to the branch - this.conversationManager.setCurrentConversation(branchConversation); - - // Use in-memory messages if streaming, otherwise use stored messages - if (isStreaming && inMemoryMessages) { - const streamingView: ConversationData = { - ...branchConversation, - messages: inMemoryMessages, - }; - this.messageDisplay.setConversation(streamingView); - } else { - this.messageDisplay.setConversation(branchConversation); - } - - // If streaming, initialize StreamingController for the active message - if (isStreaming && inMemoryMessages && inMemoryMessages.length > 0) { - const lastMessage = inMemoryMessages[inMemoryMessages.length - 1]; - if (lastMessage.state === 'streaming') { - this.streamingController.startStreaming(lastMessage.id); - } - } - - // Show branch header - if (!this.branchHeader) { - this.branchHeader = new BranchHeader( - this.layoutElements.branchHeaderContainer, - { - onNavigateToParent: () => { - void this.navigateToParent(); - }, - onCancel: (subagentId) => { - this.cancelSubagent(subagentId); - }, - onContinue: (branchId) => { - void this.continueSubagent(branchId); - }, - }, - this - ); - } - this.branchHeader.show(this.currentBranchContext); - - } catch (error) { - console.error('[ChatView] Failed to navigate to branch:', error); - } - } - - /** - * Navigate back to the parent conversation from a branch view - * - * ARCHITECTURE NOTE (Dec 2025): - * When viewing a branch, the branch IS the currentConversation. - * To go back, we restore the parent conversation as current. - */ - async navigateToParent(): Promise { - // Hide branch header - this.branchHeader?.hide(); - this.currentBranchContext = null; - this.subagentController?.setCurrentBranchContext(null); - - // Get parent ID and scroll position before clearing - const parentId = this.parentConversationId; - const scrollPosition = this.parentScrollPosition; - this.parentConversationId = null; - this.parentScrollPosition = 0; - - if (parentId) { - // Load parent conversation fresh (may have new messages from subagent results) - const parentConversation = await this.chatService.getConversation(parentId); - if (parentConversation) { - // Set parent as current conversation - this.conversationManager.setCurrentConversation(parentConversation); - this.messageDisplay.setConversation(parentConversation); - // Restore scroll position after render - requestAnimationFrame(() => { - this.messageDisplay.setScrollPosition(scrollPosition); - }); - return; - } - } - - // Fallback: reload current conversation (shouldn't happen normally) - const currentConversation = this.conversationManager.getCurrentConversation(); - if (currentConversation) { - const updated = await this.chatService.getConversation(currentConversation.id); - if (updated) { - this.conversationManager.setCurrentConversation(updated); - this.messageDisplay.setConversation(updated); - } - } - } - - /** - * Cancel a running subagent - */ - private cancelSubagent(subagentId: string): void { - const cancelled = this.subagentController?.cancelSubagent(subagentId); - if (cancelled) { - // Update the branch header if we're viewing this branch - const contextMetadata = this.currentBranchContext?.metadata; - if (isSubagentMetadata(contextMetadata) && contextMetadata.subagentId === subagentId) { - this.branchHeader?.update({ - metadata: { ...contextMetadata, state: 'cancelled' }, - }); - } - } - } - - /** - * Continue a paused subagent (hit max_iterations) - */ - private async continueSubagent(_branchId: string): Promise { - // Navigate back to parent first - await this.navigateToParent(); - - // TODO: Implement subagent continuation - // This would call the subagent tool with continueBranchId parameter - } - - /** - * Open the agent status modal - */ - private openAgentStatusModal(): void { - if (!this.subagentController?.isInitialized()) { - console.warn('[ChatView] SubagentController not initialized - cannot open modal'); - return; - } - - const contextProvider: SubagentContextProvider = { - getCurrentConversation: () => this.conversationManager?.getCurrentConversation() ?? null, - getSelectedModel: () => this.modelAgentManager?.getSelectedModel() ?? null, - getSelectedPrompt: () => this.modelAgentManager?.getSelectedPrompt() ?? null, - getLoadedWorkspaceData: () => this.modelAgentManager?.getLoadedWorkspaceData(), - getContextNotes: () => this.modelAgentManager?.getContextNotes() || [], - getThinkingSettings: () => this.modelAgentManager?.getThinkingSettings() ?? null, - getSelectedWorkspaceId: () => this.modelAgentManager?.getSelectedWorkspaceId() ?? null, - }; - - this.subagentController.openStatusModal(contextProvider, { - onViewBranch: (branchId) => { - void this.navigateToBranch(branchId); - }, - onContinueAgent: (branchId) => { - void this.continueSubagent(branchId); - }, - }); - } - - /** - * Check if currently viewing a branch - */ - isViewingBranch(): boolean { - return this.currentBranchContext !== null; - } - - /** - * Get current branch context (for external use) - */ - getCurrentBranchContext(): BranchViewContext | null { - return this.currentBranchContext; - } - - private cleanup(): void { + isViewingBranch(): boolean { + return this.branchViewCoordinator.isViewingBranch(); + } + + /** + * Get current branch context (for external use) + */ + getCurrentBranchContext(): BranchViewContext | null { + return this.branchViewCoordinator.getCurrentBranchContext(); + } + + private cleanup(): void { if (this.searchDebounceTimer) { clearTimeout(this.searchDebounceTimer); this.searchDebounceTimer = null; @@ -1453,11 +781,11 @@ export class ChatView extends ItemView { this.conversationList?.cleanup(); this.messageDisplay?.cleanup(); this.chatInput?.cleanup(); - this.contextProgressBar?.cleanup(); - this.uiStateController?.cleanup(); - this.streamingController?.cleanup(); - this.nexusLoadingController?.unload(); - this.subagentController?.cleanup(); - this.branchHeader?.cleanup(); - } -} + this.contextProgressBar?.cleanup(); + this.uiStateController?.cleanup(); + this.streamingController?.cleanup(); + this.nexusLoadingController?.unload(); + this.subagentController?.cleanup(); + this.branchViewCoordinator.cleanup(); + } +} diff --git a/src/ui/chat/services/ChatBranchViewCoordinator.ts b/src/ui/chat/services/ChatBranchViewCoordinator.ts new file mode 100644 index 000000000..11c82ce88 --- /dev/null +++ b/src/ui/chat/services/ChatBranchViewCoordinator.ts @@ -0,0 +1,305 @@ +import type { Component } from 'obsidian'; +import { isSubagentMetadata } from '../../../types/branch/BranchTypes'; +import type { ConversationData, ConversationMessage } from '../../../types/chat/ChatTypes'; +import { BranchHeader, type BranchHeaderCallbacks, type BranchViewContext } from '../components/BranchHeader'; +import type { SubagentContextProvider } from '../controllers/SubagentController'; + +interface BranchManagerLike { + switchToBranchByIndex( + conversation: ConversationData, + messageId: string, + alternativeIndex: number + ): Promise; +} + +interface ConversationManagerLike { + getCurrentConversation(): ConversationData | null; + setCurrentConversation(conversation: ConversationData | null): void; +} + +interface MessageDisplayLike { + setConversation(conversation: ConversationData): void; + updateMessage(messageId: string, updatedMessage: ConversationMessage): void; + getScrollPosition(): number; + setScrollPosition(position: number): void; +} + +interface StreamingControllerLike { + startStreaming(messageId: string): void; +} + +interface BranchHeaderLike { + show(context: BranchViewContext): void; + hide(): void; + update(context: Partial): void; + cleanup(): void; +} + +interface SubagentControllerLike { + getStreamingBranchMessages(branchId: string): ConversationMessage[] | null; + setCurrentBranchContext(context: BranchViewContext | null): void; + cancelSubagent(subagentId: string): boolean; + openStatusModal( + contextProvider: SubagentContextProvider, + callbacks: { + onViewBranch: (branchId: string) => void; + onContinueAgent: (branchId: string) => void; + } + ): void; + isInitialized(): boolean; +} + +interface ChatBranchViewCoordinatorDependencies { + component: Component; + getConversation: (conversationId: string) => Promise; + getConversationManager: () => ConversationManagerLike | null; + getBranchManager: () => BranchManagerLike | null; + getMessageDisplay: () => MessageDisplayLike | null; + getStreamingController: () => StreamingControllerLike | null; + getSubagentController: () => SubagentControllerLike | null; + getBranchHeaderContainer: () => HTMLElement | null; + getSubagentContextProvider: () => SubagentContextProvider; + requestAnimationFrame?: (callback: FrameRequestCallback) => number; + branchHeaderFactory?: ( + container: HTMLElement, + callbacks: BranchHeaderCallbacks, + component: Component + ) => BranchHeaderLike; +} + +export class ChatBranchViewCoordinator { + private branchHeader: BranchHeaderLike | null = null; + private currentBranchContext: BranchViewContext | null = null; + private parentConversationId: string | null = null; + private parentScrollPosition = 0; + + constructor(private readonly deps: ChatBranchViewCoordinatorDependencies) {} + + handleBranchCreated(_messageId: string, _branchId: string): void { + const currentConversation = this.deps.getConversationManager()?.getCurrentConversation(); + if (currentConversation) { + this.deps.getMessageDisplay()?.setConversation(currentConversation); + } + } + + handleBranchSwitched(_messageId: string, _branchId: string): void { + // Intentional no-op. The caller that switches alternatives by index already + // performs a targeted updateMessage call on success. Re-rendering the full + // conversation here reintroduces the double-update race that corrupts + // branch output. + } + + async handleBranchSwitchedByIndex(messageId: string, alternativeIndex: number): Promise { + const conversationManager = this.deps.getConversationManager(); + const branchManager = this.deps.getBranchManager(); + const messageDisplay = this.deps.getMessageDisplay(); + const currentConversation = conversationManager?.getCurrentConversation(); + + if (!currentConversation || !branchManager || !messageDisplay) { + return; + } + + const success = await branchManager.switchToBranchByIndex( + currentConversation, + messageId, + alternativeIndex + ); + + if (!success) { + return; + } + + const updatedMessage = currentConversation.messages.find(msg => msg.id === messageId); + if (updatedMessage) { + messageDisplay.updateMessage(messageId, updatedMessage); + } + } + + async navigateToBranch(branchId: string): Promise { + const conversationManager = this.deps.getConversationManager(); + const messageDisplay = this.deps.getMessageDisplay(); + if (!conversationManager || !messageDisplay) { + return; + } + + const currentConversation = conversationManager.getCurrentConversation(); + if (!currentConversation) { + return; + } + + try { + const inMemoryCurrent = conversationManager.getCurrentConversation(); + const branchConversation = (inMemoryCurrent && inMemoryCurrent.id === branchId) + ? inMemoryCurrent + : await this.deps.getConversation(branchId); + + if (!branchConversation) { + console.error('[ChatBranchViewCoordinator] Branch conversation not found:', branchId); + return; + } + + if (!this.parentConversationId) { + this.parentConversationId = currentConversation.id; + this.parentScrollPosition = messageDisplay.getScrollPosition(); + } + + const subagentController = this.deps.getSubagentController(); + const inMemoryMessages = subagentController?.getStreamingBranchMessages(branchId); + const isStreaming = inMemoryMessages !== null; + + const branchType = branchConversation.metadata?.branchType || 'human'; + const parentMessageId = branchConversation.metadata?.parentMessageId || ''; + + this.currentBranchContext = { + conversationId: branchConversation.metadata?.parentConversationId || currentConversation.id, + branchId, + parentMessageId, + branchType: branchType as 'human' | 'subagent', + metadata: branchConversation.metadata?.subagent || { description: branchConversation.title }, + }; + + subagentController?.setCurrentBranchContext(this.currentBranchContext); + conversationManager.setCurrentConversation(branchConversation); + + if (isStreaming && inMemoryMessages) { + const streamingView: ConversationData = { + ...branchConversation, + messages: inMemoryMessages, + }; + messageDisplay.setConversation(streamingView); + } else { + messageDisplay.setConversation(branchConversation); + } + + if (isStreaming && inMemoryMessages && inMemoryMessages.length > 0) { + const lastMessage = inMemoryMessages[inMemoryMessages.length - 1]; + if (lastMessage.state === 'streaming') { + this.deps.getStreamingController()?.startStreaming(lastMessage.id); + } + } + + this.getOrCreateBranchHeader().show(this.currentBranchContext); + } catch (error) { + console.error('[ChatBranchViewCoordinator] Failed to navigate to branch:', error); + } + } + + async navigateToParent(): Promise { + this.branchHeader?.hide(); + this.currentBranchContext = null; + this.deps.getSubagentController()?.setCurrentBranchContext(null); + + const parentId = this.parentConversationId; + const scrollPosition = this.parentScrollPosition; + this.parentConversationId = null; + this.parentScrollPosition = 0; + + const conversationManager = this.deps.getConversationManager(); + const messageDisplay = this.deps.getMessageDisplay(); + if (!conversationManager || !messageDisplay) { + return; + } + + if (parentId) { + const parentConversation = await this.deps.getConversation(parentId); + if (parentConversation) { + conversationManager.setCurrentConversation(parentConversation); + messageDisplay.setConversation(parentConversation); + const raf = this.deps.requestAnimationFrame ?? requestAnimationFrame; + raf(() => { + messageDisplay.setScrollPosition(scrollPosition); + }); + return; + } + } + + const currentConversation = conversationManager.getCurrentConversation(); + if (currentConversation) { + const updated = await this.deps.getConversation(currentConversation.id); + if (updated) { + conversationManager.setCurrentConversation(updated); + messageDisplay.setConversation(updated); + } + } + } + + cancelSubagent(subagentId: string): void { + const cancelled = this.deps.getSubagentController()?.cancelSubagent(subagentId); + if (!cancelled) { + return; + } + + const contextMetadata = this.currentBranchContext?.metadata; + if (isSubagentMetadata(contextMetadata) && contextMetadata.subagentId === subagentId) { + this.branchHeader?.update({ + metadata: { ...contextMetadata, state: 'cancelled' }, + }); + } + } + + async continueSubagent(_branchId: string): Promise { + await this.navigateToParent(); + } + + openAgentStatusModal(): void { + const subagentController = this.deps.getSubagentController(); + if (!subagentController?.isInitialized()) { + console.warn('[ChatBranchViewCoordinator] SubagentController not initialized - cannot open modal'); + return; + } + + subagentController.openStatusModal(this.deps.getSubagentContextProvider(), { + onViewBranch: (branchId) => { + void this.navigateToBranch(branchId); + }, + onContinueAgent: (branchId) => { + void this.continueSubagent(branchId); + }, + }); + } + + isViewingBranch(): boolean { + return this.currentBranchContext !== null; + } + + getCurrentBranchContext(): BranchViewContext | null { + return this.currentBranchContext; + } + + cleanup(): void { + this.branchHeader?.cleanup(); + } + + private getOrCreateBranchHeader(): BranchHeaderLike { + if (this.branchHeader) { + return this.branchHeader; + } + + const container = this.deps.getBranchHeaderContainer(); + if (!container) { + throw new Error('Branch header container is not available'); + } + + const createBranchHeader = this.deps.branchHeaderFactory + ?? ((headerContainer: HTMLElement, callbacks: BranchHeaderCallbacks, component: Component) => + new BranchHeader(headerContainer, callbacks, component)); + + this.branchHeader = createBranchHeader( + container, + { + onNavigateToParent: () => { + void this.navigateToParent(); + }, + onCancel: (subagentId) => { + this.cancelSubagent(subagentId); + }, + onContinue: (branchId) => { + void this.continueSubagent(branchId); + }, + }, + this.deps.component + ); + + return this.branchHeader; + } +} diff --git a/src/ui/chat/services/ChatSendCoordinator.ts b/src/ui/chat/services/ChatSendCoordinator.ts new file mode 100644 index 000000000..6e5d94f96 --- /dev/null +++ b/src/ui/chat/services/ChatSendCoordinator.ts @@ -0,0 +1,361 @@ +import { Notice, type App } from 'obsidian'; +import type { IMessageRepository } from '../../../database/repositories/interfaces/IMessageRepository'; +import { ChatService } from '../../../services/chat/ChatService'; +import { + ContextCompactionService, + type CompactedContext, + type CompactionOptions +} from '../../../services/chat/ContextCompactionService'; +import { CompactionTranscriptRecoveryService } from '../../../services/chat/CompactionTranscriptRecoveryService'; +import type { ContextPreservationService } from '../../../services/chat/ContextPreservationService'; +import type { ConversationData, ConversationMessage } from '../../../types/chat/ChatTypes'; +import type { MessageEnhancement } from '../components/suggesters/base/SuggesterInterfaces'; +import type { ReferenceMetadata } from '../utils/ReferenceExtractor'; + +export interface MessageExecutionOptions { + provider?: string; + model?: string; + systemPrompt?: string; + workspaceId?: string; + sessionId?: string; + enableThinking?: boolean; + thinkingEffort?: 'low' | 'medium' | 'high'; +} + +interface ConversationManagerLike { + getCurrentConversation(): ConversationData | null; +} + +interface MessageManagerLike { + getIsLoading(): boolean; + interruptCurrentGeneration(): Promise; + sendMessage( + conversation: ConversationData, + message: string, + options?: MessageExecutionOptions, + metadata?: ReferenceMetadata + ): Promise; + handleRetryMessage( + conversation: ConversationData, + messageId: string, + options?: MessageExecutionOptions + ): Promise; + handleEditMessage( + conversation: ConversationData, + messageId: string, + newContent: string, + options?: MessageExecutionOptions + ): Promise; + cancelCurrentGeneration(): Promise; +} + +interface ModelAgentManagerLike { + setMessageEnhancement(enhancement: MessageEnhancement): void; + clearMessageEnhancement(): void; + getMessageOptions(): Promise; + shouldCompactBeforeSending( + conversation: ConversationData, + message: string, + systemPrompt: string | null, + provider: string | undefined + ): boolean; + getSelectedWorkspaceId(): string | null; + appendCompactionRecord(context: CompactedContext): void; + buildMetadataWithCompactionRecord( + metadata: ConversationData['metadata'], + context: CompactedContext + ): ConversationData['metadata']; + resetTokenTracker(): void; +} + +interface ChatInputLike { + clearMessageEnhancer(): void; + setPreSendCompacting(compacting: boolean): void; +} + +interface MessageBubbleLike { + stopLoadingAnimation(): void; +} + +interface MessageDisplayLike { + showTransientEventRow(message: string): void; + clearTransientEventRow(): void; + findMessageBubble(messageId: string): MessageBubbleLike | undefined; +} + +interface StreamingControllerLike { + stopLoadingAnimation(element: Element): void; + finalizeStreaming(messageId: string, content: string): void; +} + +interface StorageAdapterLike { + messages: Pick; +} + +interface ChatSendCoordinatorDependencies { + app: App; + chatService: ChatService; + getContainerEl: () => HTMLElement; + getConversationManager: () => ConversationManagerLike | null; + getMessageManager: () => MessageManagerLike | null; + getModelAgentManager: () => ModelAgentManagerLike | null; + getChatInput: () => ChatInputLike | null; + getMessageDisplay: () => MessageDisplayLike | null; + getStreamingController: () => StreamingControllerLike | null; + getPreservationService: () => ContextPreservationService | null; + getStorageAdapter: () => StorageAdapterLike | null; + onUpdateContextProgress: () => void; + compactionService?: { + compact(conversation: ConversationData, options?: CompactionOptions): CompactedContext; + }; +} + +export class ChatSendCoordinator { + private readonly compactionService: { + compact(conversation: ConversationData, options?: CompactionOptions): CompactedContext; + }; + + constructor(private readonly deps: ChatSendCoordinatorDependencies) { + this.compactionService = deps.compactionService ?? new ContextCompactionService(); + } + + async handleSendMessage( + message: string, + enhancement?: MessageEnhancement, + metadata?: ReferenceMetadata + ): Promise { + const messageManager = this.deps.getMessageManager(); + const conversationManager = this.deps.getConversationManager(); + const modelAgentManager = this.deps.getModelAgentManager(); + const chatInput = this.deps.getChatInput(); + if (!messageManager || !conversationManager || !modelAgentManager) { + return; + } + + try { + if (messageManager.getIsLoading()) { + await messageManager.interruptCurrentGeneration(); + } + + const currentConversation = conversationManager.getCurrentConversation(); + if (!currentConversation) { + return; + } + + if (enhancement) { + modelAgentManager.setMessageEnhancement(enhancement); + } + + let messageOptions = await modelAgentManager.getMessageOptions(); + + if (modelAgentManager.shouldCompactBeforeSending( + currentConversation, + message, + messageOptions.systemPrompt || null, + messageOptions.provider + )) { + this.setPreSendCompactionState(true); + try { + await this.performContextCompaction(currentConversation); + messageOptions = await modelAgentManager.getMessageOptions(); + } finally { + this.setPreSendCompactionState(false); + } + } + + await messageManager.sendMessage( + currentConversation, + message, + messageOptions, + metadata + ); + } finally { + this.setPreSendCompactionState(false); + modelAgentManager.clearMessageEnhancement(); + chatInput?.clearMessageEnhancer(); + } + } + + async handleRetryMessage(messageId: string): Promise { + const currentConversation = this.deps.getConversationManager()?.getCurrentConversation(); + const messageManager = this.deps.getMessageManager(); + const modelAgentManager = this.deps.getModelAgentManager(); + if (!currentConversation || !messageManager || !modelAgentManager) { + return; + } + + const messageOptions = await modelAgentManager.getMessageOptions(); + await messageManager.handleRetryMessage(currentConversation, messageId, messageOptions); + } + + async handleEditMessage(messageId: string, newContent: string): Promise { + const currentConversation = this.deps.getConversationManager()?.getCurrentConversation(); + const messageManager = this.deps.getMessageManager(); + const modelAgentManager = this.deps.getModelAgentManager(); + if (!currentConversation || !messageManager || !modelAgentManager) { + return; + } + + const messageOptions = await modelAgentManager.getMessageOptions(); + await messageManager.handleEditMessage( + currentConversation, + messageId, + newContent, + messageOptions + ); + } + + handleStopGeneration(): void { + void this.deps.getMessageManager()?.cancelCurrentGeneration(); + } + + handleGenerationAborted(messageId: string): void { + const messageBubble = this.deps.getMessageDisplay()?.findMessageBubble(messageId); + if (messageBubble) { + messageBubble.stopLoadingAnimation(); + } + + const containerEl = this.deps.getContainerEl(); + const streamingController = this.deps.getStreamingController(); + const messageElement = containerEl.querySelector(`[data-message-id="${messageId}"]`); + if (messageElement && streamingController) { + const contentElement = messageElement.querySelector('.message-bubble .message-content'); + if (contentElement) { + streamingController.stopLoadingAnimation(contentElement); + } + } + + const currentConversation = this.deps.getConversationManager()?.getCurrentConversation(); + const message = currentConversation?.messages.find(candidate => candidate.id === messageId); + const actualContent = message?.content || ''; + if (actualContent && streamingController) { + streamingController.finalizeStreaming(messageId, actualContent); + } + } + + private async performContextCompaction(conversation: ConversationData): Promise { + const originalMessages = [...conversation.messages]; + const preservationService = this.deps.getPreservationService(); + const modelAgentManager = this.deps.getModelAgentManager(); + if (!modelAgentManager) { + return; + } + + let stateContent: string | undefined; + let usedLLM = false; + + if (preservationService) { + const savingNotice = new Notice('Saving context...', 0); + + try { + const messageOptions = await modelAgentManager.getMessageOptions(); + const result = await preservationService.forceStateSave( + conversation.messages, + { + provider: messageOptions.provider, + model: messageOptions.model, + }, + { + workspaceId: modelAgentManager.getSelectedWorkspaceId() || undefined, + sessionId: conversation.metadata?.chatSettings?.sessionId, + } + ); + + if (result.success && result.stateContent) { + stateContent = result.stateContent; + usedLLM = true; + } + } catch (error) { + console.error('[ChatSendCoordinator] LLM-driven saveState failed, using programmatic fallback:', error); + } finally { + savingNotice.hide(); + } + } + + const compactedContext = this.compactionService.compact(conversation, { + exchangesToKeep: 2, + maxSummaryLength: 500, + includeFileReferences: true + }); + + if (compactedContext.messagesRemoved <= 0) { + return; + } + + if (stateContent) { + compactedContext.summary = stateContent; + } + + compactedContext.transcriptCoverage = await this.buildCompactionTranscriptCoverage( + conversation.id, + originalMessages, + conversation.messages + ) ?? undefined; + + modelAgentManager.appendCompactionRecord(compactedContext); + conversation.metadata = modelAgentManager.buildMetadataWithCompactionRecord( + conversation.metadata, + compactedContext + ); + modelAgentManager.resetTokenTracker(); + + const conversationService = this.deps.chatService.getConversationService(); + if (conversationService?.updateConversation) { + await conversationService.updateConversation(conversation.id, { + title: conversation.title, + messages: conversation.messages, + metadata: conversation.metadata + }); + } else { + await this.deps.chatService.updateConversation(conversation); + } + + this.deps.onUpdateContextProgress(); + + const savedMsg = usedLLM + ? `Context saved (${compactedContext.messagesRemoved} messages compacted)` + : `Context compacted (${compactedContext.messagesRemoved} messages)`; + new Notice(savedMsg, 2500); + } + + private async buildCompactionTranscriptCoverage( + conversationId: string, + originalMessages: ConversationMessage[], + keptMessages: ConversationMessage[] + ) { + const storageAdapter = this.deps.getStorageAdapter(); + if (!storageAdapter) { + return null; + } + + const keptIds = new Set(keptMessages.map(message => message.id)); + const compactedMessageIds = originalMessages + .filter(message => !keptIds.has(message.id)) + .map(message => message.id); + + if (compactedMessageIds.length === 0) { + return null; + } + + const transcriptRecoveryService = new CompactionTranscriptRecoveryService( + storageAdapter.messages, + this.deps.app + ); + return transcriptRecoveryService.buildCoverageRef(conversationId, compactedMessageIds); + } + + private setPreSendCompactionState(compacting: boolean): void { + this.deps.getChatInput()?.setPreSendCompacting(compacting); + + const messageDisplay = this.deps.getMessageDisplay(); + if (!messageDisplay) { + return; + } + + if (compacting) { + messageDisplay.showTransientEventRow('Compacting context before sending...'); + } else { + messageDisplay.clearTransientEventRow(); + } + } +} diff --git a/src/ui/chat/services/ChatSessionCoordinator.ts b/src/ui/chat/services/ChatSessionCoordinator.ts new file mode 100644 index 000000000..73127b276 --- /dev/null +++ b/src/ui/chat/services/ChatSessionCoordinator.ts @@ -0,0 +1,249 @@ +import { Component, Notice } from 'obsidian'; +import { ChatService } from '../../../services/chat/ChatService'; +import { ConversationData } from '../../../types/chat/ChatTypes'; +import { ChatEventBinder } from '../utils/ChatEventBinder'; + +export interface WorkflowMessageOptions { + provider?: string; + model?: string; + systemPrompt?: string; + workspaceId?: string; + sessionId?: string; + enableThinking?: boolean; + thinkingEffort?: 'low' | 'medium' | 'high'; +} + +interface ConversationManagerLike { + loadConversations(): Promise; + getConversations(): ConversationData[]; + getCurrentConversation(): ConversationData | null; + selectConversation(conversation: ConversationData): Promise; + createNewConversation(): Promise; + isSearchActive: boolean; + hasMore: boolean; + isLoading: boolean; +} + +interface MessageManagerLike { + getIsLoading(): boolean; + interruptCurrentGeneration(): Promise; + sendMessage( + conversation: ConversationData, + message: string, + options?: WorkflowMessageOptions + ): Promise; +} + +interface ModelAgentManagerLike { + initializeDefaults(): Promise; + initializeFromConversation(conversationId: string): Promise; + setCurrentConversationId(conversationId: string | null): void; +} + +interface ConversationListLike { + setIsSearchActive(isSearchActive: boolean): void; + setConversations(conversations: ConversationData[]): void; + setHasMore(hasMore: boolean): void; + setIsLoading(isLoading: boolean): void; +} + +interface MessageDisplayLike { + setConversation(conversation: ConversationData): void; +} + +interface ChatInputLike { + setConversationState(hasConversation: boolean): void; +} + +interface UIStateControllerLike { + showWelcomeState(hasConfiguredProviders?: boolean): void; + setInputPlaceholder(placeholder: string): void; + getSidebarVisible(): boolean; + toggleConversationList(): void; +} + +interface ChatSessionCoordinatorDependencies { + chatService: ChatService; + component: Component; + getContainerEl: () => HTMLElement; + getChatTitleEl: () => HTMLElement | null; + getConversationManager: () => ConversationManagerLike | null; + getMessageManager: () => MessageManagerLike | null; + getModelAgentManager: () => ModelAgentManagerLike | null; + getConversationList: () => ConversationListLike | null; + getMessageDisplay: () => MessageDisplayLike | null; + getChatInput: () => ChatInputLike | null; + getUIStateController: () => UIStateControllerLike | null; + onClearStreamingState: () => void; + onClearAgentStatus: () => void; + onUpdateChatTitle: () => void; + onUpdateContextProgress: () => void; +} + +export class ChatSessionCoordinator { + private pendingConversationId: string | null = null; + + constructor(private readonly deps: ChatSessionCoordinatorDependencies) {} + + async loadInitialData(): Promise { + const conversationManager = this.deps.getConversationManager(); + if (!conversationManager) { + return; + } + + await conversationManager.loadConversations(); + + if (conversationManager.getConversations().length === 0) { + await this.showWelcomeState(); + } + + if (this.pendingConversationId) { + const pendingId = this.pendingConversationId; + this.pendingConversationId = null; + await this.openConversationById(pendingId); + } + } + + async openConversationById(conversationId: string): Promise { + const conversationManager = this.deps.getConversationManager(); + if (!conversationManager) { + this.pendingConversationId = conversationId; + return; + } + + const conversation = await this.deps.chatService.getConversation(conversationId); + if (!conversation) { + return; + } + + await conversationManager.loadConversations(); + const listedConversation = conversationManager + .getConversations() + .find(item => item.id === conversationId); + + await conversationManager.selectConversation(listedConversation || conversation); + } + + async sendMessageToConversation( + conversationId: string, + message: string, + options?: WorkflowMessageOptions + ): Promise { + const conversationManager = this.deps.getConversationManager(); + const messageManager = this.deps.getMessageManager(); + if (!conversationManager || !messageManager) { + this.pendingConversationId = conversationId; + throw new Error('Chat view is not ready'); + } + + await this.openConversationById(conversationId); + + const currentConversation = conversationManager.getCurrentConversation(); + if (!currentConversation || currentConversation.id !== conversationId) { + throw new Error('Failed to focus workflow conversation'); + } + + if (messageManager.getIsLoading()) { + await messageManager.interruptCurrentGeneration(); + } + + void messageManager.sendMessage(currentConversation, message, options).catch(error => { + console.error('[ChatSessionCoordinator] Failed to send workflow message:', error); + new Notice('Failed to start workflow run'); + }); + } + + async handleConversationSelected(conversation: ConversationData): Promise { + const messageManager = this.deps.getMessageManager(); + const modelAgentManager = this.deps.getModelAgentManager(); + const messageDisplay = this.deps.getMessageDisplay(); + const chatInput = this.deps.getChatInput(); + const uiStateController = this.deps.getUIStateController(); + if (!messageManager || !modelAgentManager || !messageDisplay || !uiStateController) { + return; + } + + if (messageManager.getIsLoading()) { + void messageManager.interruptCurrentGeneration(); + this.deps.onClearStreamingState(); + } + + this.deps.onClearAgentStatus(); + modelAgentManager.setCurrentConversationId(conversation.id); + + await modelAgentManager.initializeFromConversation(conversation.id); + messageDisplay.setConversation(conversation); + this.deps.onUpdateChatTitle(); + uiStateController.setInputPlaceholder('Type your message...'); + this.deps.onUpdateContextProgress(); + chatInput?.setConversationState(true); + + if (uiStateController.getSidebarVisible()) { + uiStateController.toggleConversationList(); + } + } + + async handleConversationsChanged(): Promise { + const conversationManager = this.deps.getConversationManager(); + if (!conversationManager) { + return; + } + + const conversationList = this.deps.getConversationList(); + if (conversationList) { + conversationList.setIsSearchActive(conversationManager.isSearchActive); + conversationList.setConversations(conversationManager.getConversations()); + conversationList.setHasMore(conversationManager.hasMore); + conversationList.setIsLoading(conversationManager.isLoading); + } + + const conversations = conversationManager.getConversations(); + const currentConversation = conversationManager.getCurrentConversation(); + + if (conversations.length === 0 && !conversationManager.isSearchActive) { + await this.showWelcomeState(); + return; + } + + if (!currentConversation && conversations.length > 0) { + await conversationManager.selectConversation(conversations[0]); + } + } + + private async showWelcomeState(): Promise { + const modelAgentManager = this.deps.getModelAgentManager(); + const uiStateController = this.deps.getUIStateController(); + if (!modelAgentManager || !uiStateController) { + return; + } + + await modelAgentManager.initializeDefaults(); + + const hasProviders = this.deps.chatService.hasConfiguredProviders(); + uiStateController.showWelcomeState(hasProviders); + + const chatTitle = this.deps.getChatTitleEl(); + if (chatTitle) { + chatTitle.textContent = 'Chat'; + } + + this.deps.getChatInput()?.setConversationState(false); + + if (hasProviders) { + this.bindWelcomeButton(); + } + } + + private bindWelcomeButton(): void { + ChatEventBinder.bindWelcomeButton( + this.deps.getContainerEl(), + () => { + const conversationManager = this.deps.getConversationManager(); + if (conversationManager) { + void conversationManager.createNewConversation(); + } + }, + this.deps.component + ); + } +} diff --git a/src/ui/chat/services/ChatSubagentIntegration.ts b/src/ui/chat/services/ChatSubagentIntegration.ts new file mode 100644 index 000000000..c3b1732e9 --- /dev/null +++ b/src/ui/chat/services/ChatSubagentIntegration.ts @@ -0,0 +1,229 @@ +import type { App, Component } from 'obsidian'; +import type NexusPlugin from '../../../main'; +import type { AgentManager } from '../../../services/AgentManager'; +import { ContextPreservationService } from '../../../services/chat/ContextPreservationService'; +import type { PreservationDependencies } from '../../../services/chat/ContextPreservationService'; +import type { ChatService } from '../../../services/chat/ChatService'; +import type { DirectToolExecutor } from '../../../services/chat/DirectToolExecutor'; +import type { HybridStorageAdapter } from '../../../database/adapters/HybridStorageAdapter'; +import type { PromptManagerAgent } from '../../../agents/promptManager/promptManager'; +import { getNexusPlugin } from '../../../utils/pluginLocator'; +import type { ToolEventCoordinator } from '../coordinators/ToolEventCoordinator'; +import type { StreamingController } from '../controllers/StreamingController'; +import { + SubagentController, + type SubagentContextProvider, + type SubagentControllerEvents, +} from '../controllers/SubagentController'; +import type { ConversationData } from '../../../types/chat/ChatTypes'; + +interface ConversationManagerLike { + getCurrentConversation(): ConversationData | null; + selectConversation(conversation: ConversationData): Promise; +} + +interface ModelAgentManagerLike { + getSelectedModel(): { providerId?: string; modelId?: string } | null; + getSelectedPrompt(): { name?: string; systemPrompt?: string } | null; + getLoadedWorkspaceData(): Record | null; + getContextNotes(): string[]; + getThinkingSettings(): { enabled?: boolean; effort?: 'low' | 'medium' | 'high' } | null; + getSelectedWorkspaceId(): string | null; +} + +interface NavigationTarget { + navigateToBranch(branchId: string): Promise; + continueSubagent(branchId: string): Promise; +} + +interface PluginServiceLocator { + getService(name: string): Promise; + getServiceIfReady(name: string): T | null; +} + +interface SubagentControllerLike { + initialize( + deps: { + app: App; + chatService: ChatService; + directToolExecutor: DirectToolExecutor; + promptManagerAgent: PromptManagerAgent; + storageAdapter: HybridStorageAdapter; + llmService: NonNullable>; + }, + contextProvider: SubagentContextProvider, + streamingController: StreamingController, + toolEventCoordinator: ToolEventCoordinator, + settingsButtonContainer?: HTMLElement, + settingsButton?: HTMLElement + ): void; + setNavigationCallbacks(callbacks: { + onNavigateToBranch: (branchId: string) => void; + onContinueAgent: (branchId: string) => void; + }): void; +} + +interface ChatSubagentIntegrationResult { + preservationService: ContextPreservationService | null; + subagentController: SubagentController | null; +} + +interface ChatSubagentIntegrationDependencies { + app: App; + component: Component; + chatService: ChatService; + getConversationManager: () => ConversationManagerLike | null; + getModelAgentManager: () => ModelAgentManagerLike | null; + getStreamingController: () => StreamingController | null; + getToolEventCoordinator: () => ToolEventCoordinator | null; + getSettingsButtonContainer: () => HTMLElement | undefined; + getSettingsButton: () => HTMLElement | undefined; + getNavigationTarget: () => NavigationTarget | null; + getPlugin?: () => PluginServiceLocator | null; + createSubagentController?: ( + app: App, + component: Component, + events: SubagentControllerEvents + ) => SubagentControllerLike; + createPreservationService?: (deps: PreservationDependencies) => ContextPreservationService; +} + +export class ChatSubagentIntegration { + constructor(private readonly deps: ChatSubagentIntegrationDependencies) {} + + createContextProvider(): SubagentContextProvider { + return { + getCurrentConversation: () => this.deps.getConversationManager()?.getCurrentConversation() ?? null, + getSelectedModel: () => this.deps.getModelAgentManager()?.getSelectedModel() ?? null, + getSelectedPrompt: () => this.deps.getModelAgentManager()?.getSelectedPrompt() ?? null, + getLoadedWorkspaceData: () => this.deps.getModelAgentManager()?.getLoadedWorkspaceData() ?? null, + getContextNotes: () => this.deps.getModelAgentManager()?.getContextNotes() || [], + getThinkingSettings: () => this.deps.getModelAgentManager()?.getThinkingSettings() ?? null, + getSelectedWorkspaceId: () => this.deps.getModelAgentManager()?.getSelectedWorkspaceId() ?? null, + }; + } + + async initialize(): Promise { + try { + const plugin = this.getPlugin(); + if (!plugin) { + return { preservationService: null, subagentController: null }; + } + + const directToolExecutor = await plugin.getService('directToolExecutor'); + if (!directToolExecutor) { + return { preservationService: null, subagentController: null }; + } + + const agentManager = await plugin.getService('agentManager'); + if (!agentManager) { + return { preservationService: null, subagentController: null }; + } + + const promptManagerAgent = agentManager.getAgent('promptManager') as PromptManagerAgent | null; + if (!promptManagerAgent) { + return { preservationService: null, subagentController: null }; + } + + const storageAdapter = plugin.getServiceIfReady('hybridStorageAdapter'); + if (!storageAdapter) { + return { preservationService: null, subagentController: null }; + } + + const llmService = this.deps.chatService.getLLMService(); + if (!llmService) { + return { preservationService: null, subagentController: null }; + } + + const streamingController = this.deps.getStreamingController(); + const toolEventCoordinator = this.deps.getToolEventCoordinator(); + if (!streamingController || !toolEventCoordinator) { + return { preservationService: null, subagentController: null }; + } + + const subagentController = this.createSubagentController(); + const contextProvider = this.createContextProvider(); + + subagentController.initialize( + { + app: this.deps.app, + chatService: this.deps.chatService, + directToolExecutor, + promptManagerAgent, + storageAdapter, + llmService, + }, + contextProvider, + streamingController, + toolEventCoordinator, + this.deps.getSettingsButtonContainer(), + this.deps.getSettingsButton() + ); + + subagentController.setNavigationCallbacks({ + onNavigateToBranch: (branchId) => { + void this.deps.getNavigationTarget()?.navigateToBranch(branchId); + }, + onContinueAgent: (branchId) => { + void this.deps.getNavigationTarget()?.continueSubagent(branchId); + }, + }); + + const preservationService = this.createPreservationService( + llmService, + agentManager, + directToolExecutor + ); + + return { + preservationService, + subagentController: subagentController as SubagentController, + }; + } catch (error) { + console.error('[ChatSubagentIntegration] Failed to initialize subagent infrastructure:', error); + throw error; + } + } + + private getPlugin(): PluginServiceLocator | null { + if (this.deps.getPlugin) { + return this.deps.getPlugin(); + } + + return getNexusPlugin(this.deps.app); + } + + private createSubagentController(): SubagentControllerLike { + const createSubagentController = this.deps.createSubagentController + ?? ((app: App, component: Component, events: SubagentControllerEvents) => + new SubagentController(app, component, events)); + + return createSubagentController(this.deps.app, this.deps.component, { + onStreamingUpdate: () => { /* handled internally */ }, + onToolCallsDetected: () => { /* handled internally */ }, + onStatusChanged: () => { /* status menu auto-updates */ }, + onConversationNeedsRefresh: (conversationId: string) => { + const currentConversation = this.deps.getConversationManager()?.getCurrentConversation(); + if (currentConversation?.id === conversationId) { + void this.deps.getConversationManager()?.selectConversation(currentConversation); + } + }, + }); + } + + private createPreservationService( + llmService: NonNullable>, + agentManager: AgentManager, + directToolExecutor: DirectToolExecutor + ): ContextPreservationService { + const createPreservationService = this.deps.createPreservationService + ?? ((deps: PreservationDependencies) => new ContextPreservationService(deps)); + + return createPreservationService({ + llmService: llmService as unknown as PreservationDependencies['llmService'], + getAgent: (name: string) => agentManager.getAgent(name), + executeToolCalls: (toolCalls, context) => + directToolExecutor.executeToolCalls(toolCalls, context), + }); + } +} diff --git a/tests/unit/ChatBranchViewCoordinator.test.ts b/tests/unit/ChatBranchViewCoordinator.test.ts new file mode 100644 index 000000000..1fbb5f060 --- /dev/null +++ b/tests/unit/ChatBranchViewCoordinator.test.ts @@ -0,0 +1,203 @@ +import { Component } from 'obsidian'; +import { ChatBranchViewCoordinator } from '../../src/ui/chat/services/ChatBranchViewCoordinator'; +import type { ConversationData, ConversationMessage } from '../../src/types/chat/ChatTypes'; +import type { SubagentContextProvider } from '../../src/ui/chat/controllers/SubagentController'; + +function createConversationMessage(id: string, role: ConversationMessage['role'], content: string): ConversationMessage { + return { + id, + role, + content, + timestamp: 1000, + conversationId: 'parent-1', + }; +} + +function createConversation( + id: string, + title: string, + metadata?: ConversationData['metadata'] +): ConversationData { + return { + id, + title, + created: 1000, + updated: 2000, + messages: [createConversationMessage('msg-1', 'assistant', 'Message')], + metadata, + }; +} + +function createHarness() { + const parentConversation = createConversation('parent-1', 'Parent'); + const branchConversation = createConversation('branch-1', 'Branch', { + parentConversationId: 'parent-1', + parentMessageId: 'msg-1', + branchType: 'subagent', + subagentTask: 'Do work', + subagent: { + subagentId: 'sub-1', + task: 'Do work', + state: 'running', + iterations: 1, + maxIterations: 3, + startedAt: 1000, + }, + } as ConversationData['metadata'] & { subagent: Record }); + + let currentConversation: ConversationData | null = parentConversation; + + const conversationManager = { + getCurrentConversation: jest.fn(() => currentConversation), + setCurrentConversation: jest.fn((conversation: ConversationData | null) => { + currentConversation = conversation; + }), + }; + + const branchManager = { + switchToBranchByIndex: jest.fn().mockResolvedValue(true), + }; + + const messageDisplay = { + setConversation: jest.fn(), + updateMessage: jest.fn(), + getScrollPosition: jest.fn().mockReturnValue(42), + setScrollPosition: jest.fn(), + }; + + const streamingController = { + startStreaming: jest.fn(), + }; + + const subagentController = { + getStreamingBranchMessages: jest.fn().mockReturnValue(null), + setCurrentBranchContext: jest.fn(), + cancelSubagent: jest.fn().mockReturnValue(true), + isInitialized: jest.fn().mockReturnValue(true), + openStatusModal: jest.fn(), + }; + + const branchHeader = { + show: jest.fn(), + hide: jest.fn(), + update: jest.fn(), + cleanup: jest.fn(), + }; + const branchHeaderFactory = jest.fn(() => branchHeader); + + const contextProvider: SubagentContextProvider = { + getCurrentConversation: () => currentConversation, + getSelectedModel: () => null, + getSelectedPrompt: () => null, + getLoadedWorkspaceData: () => null, + getContextNotes: () => [], + getThinkingSettings: () => null, + getSelectedWorkspaceId: () => null, + }; + + const coordinator = new ChatBranchViewCoordinator({ + component: {} as Component, + getConversation: jest.fn(async (conversationId: string) => { + if (conversationId === 'branch-1') { + return branchConversation; + } + if (conversationId === 'parent-1') { + return parentConversation; + } + return null; + }), + getConversationManager: () => conversationManager, + getBranchManager: () => branchManager, + getMessageDisplay: () => messageDisplay, + getStreamingController: () => streamingController, + getSubagentController: () => subagentController, + getSubagentContextProvider: () => contextProvider, + getBranchHeaderContainer: () => ({}) as HTMLElement, + branchHeaderFactory, + }); + + return { + coordinator, + parentConversation, + branchConversation, + conversationManager, + branchManager, + messageDisplay, + subagentController, + branchHeader, + branchHeaderFactory, + }; +} + +describe('ChatBranchViewCoordinator', () => { + it('navigates to a branch and back to the parent conversation', async () => { + const harness = createHarness(); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const originalRequestAnimationFrame = global.requestAnimationFrame; + global.requestAnimationFrame = ((callback: FrameRequestCallback) => { + callback(0); + return 1; + }) as typeof requestAnimationFrame; + + try { + await harness.coordinator.navigateToBranch('branch-1'); + + expect(harness.conversationManager.setCurrentConversation).toHaveBeenCalledWith(harness.branchConversation); + expect(harness.messageDisplay.setConversation).toHaveBeenCalledWith(harness.branchConversation); + expect(harness.subagentController.setCurrentBranchContext).toHaveBeenCalledWith( + expect.objectContaining({ + branchId: 'branch-1', + conversationId: 'parent-1', + }) + ); + expect(harness.branchHeaderFactory).toHaveBeenCalledTimes(1); + expect(harness.branchHeader.show).toHaveBeenCalledWith( + expect.objectContaining({ branchId: 'branch-1' }) + ); + expect(harness.coordinator.isViewingBranch()).toBe(true); + + await harness.coordinator.navigateToParent(); + + expect(harness.branchHeader.hide).toHaveBeenCalledTimes(1); + expect(harness.conversationManager.setCurrentConversation).toHaveBeenLastCalledWith(harness.parentConversation); + expect(harness.messageDisplay.setConversation).toHaveBeenLastCalledWith(harness.parentConversation); + expect(harness.messageDisplay.setScrollPosition).toHaveBeenCalledWith(42); + expect(harness.subagentController.setCurrentBranchContext).toHaveBeenLastCalledWith(null); + expect(harness.coordinator.isViewingBranch()).toBe(false); + expect(harness.coordinator.getCurrentBranchContext()).toBeNull(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + } finally { + global.requestAnimationFrame = originalRequestAnimationFrame; + consoleErrorSpy.mockRestore(); + } + }); + + it('updates branch header metadata when cancelling the active subagent', async () => { + const harness = createHarness(); + + await harness.coordinator.navigateToBranch('branch-1'); + harness.coordinator.cancelSubagent('sub-1'); + + expect(harness.subagentController.cancelSubagent).toHaveBeenCalledWith('sub-1'); + expect(harness.branchHeader.update).toHaveBeenCalledWith({ + metadata: expect.objectContaining({ + subagentId: 'sub-1', + state: 'cancelled', + }), + }); + }); + + it('does not rerender the full conversation when branch manager emits a switch event', async () => { + const harness = createHarness(); + + await harness.coordinator.handleBranchSwitchedByIndex('msg-1', 1); + + expect(harness.branchManager.switchToBranchByIndex).toHaveBeenCalledWith( + harness.parentConversation, + 'msg-1', + 1 + ); + expect(harness.messageDisplay.updateMessage).toHaveBeenCalledTimes(1); + expect(harness.messageDisplay.setConversation).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/ChatSendCoordinator.test.ts b/tests/unit/ChatSendCoordinator.test.ts new file mode 100644 index 000000000..dd5be021d --- /dev/null +++ b/tests/unit/ChatSendCoordinator.test.ts @@ -0,0 +1,204 @@ +import { ChatSendCoordinator } from '../../src/ui/chat/services/ChatSendCoordinator'; +import type { ConversationData, ConversationMessage } from '../../src/types/chat/ChatTypes'; + +function createMessage( + id: string, + role: ConversationMessage['role'], + content: string +): ConversationMessage { + return { + id, + role, + content, + timestamp: 1000, + conversationId: 'conv-1' + }; +} + +function createConversation(messages: ConversationMessage[]): ConversationData { + return { + id: 'conv-1', + title: 'Conversation', + created: 1000, + updated: 2000, + messages, + metadata: { + chatSettings: { + sessionId: 'session-1' + } + } + }; +} + +function createHarness() { + const conversation = createConversation([ + createMessage('u1', 'user', 'first request'), + createMessage('a1', 'assistant', 'partial response'), + createMessage('u2', 'user', 'follow-up request'), + createMessage('a2', 'assistant', 'latest response') + ]); + + const bubble = { + stopLoadingAnimation: jest.fn() + }; + + const contentEl = {} as Element; + const messageEl = { + querySelector: jest.fn((selector: string) => + selector === '.message-bubble .message-content' ? contentEl : null + ) + } as unknown as Element; + const containerEl = { + querySelector: jest.fn((selector: string) => + selector === '[data-message-id="a1"]' ? messageEl : null + ) + } as unknown as HTMLElement; + + const conversationManager = { + getCurrentConversation: jest.fn().mockReturnValue(conversation) + }; + + const messageManager = { + getIsLoading: jest.fn().mockReturnValue(false), + interruptCurrentGeneration: jest.fn().mockResolvedValue(undefined), + sendMessage: jest.fn().mockResolvedValue(undefined), + handleRetryMessage: jest.fn().mockResolvedValue(undefined), + handleEditMessage: jest.fn().mockResolvedValue(undefined), + cancelCurrentGeneration: jest.fn().mockResolvedValue(undefined) + }; + + const modelAgentManager = { + setMessageEnhancement: jest.fn(), + clearMessageEnhancement: jest.fn(), + getMessageOptions: jest.fn().mockResolvedValue({ + provider: 'github-copilot', + model: 'copilot-model', + systemPrompt: 'System prompt' + }), + shouldCompactBeforeSending: jest.fn().mockReturnValue(false), + getSelectedWorkspaceId: jest.fn().mockReturnValue('workspace-1'), + appendCompactionRecord: jest.fn(), + buildMetadataWithCompactionRecord: jest.fn().mockImplementation((_metadata, compactedContext) => ({ + chatSettings: { sessionId: 'session-1' }, + compaction: { frontier: [compactedContext] } + })), + resetTokenTracker: jest.fn() + }; + + const chatInput = { + clearMessageEnhancer: jest.fn(), + setPreSendCompacting: jest.fn() + }; + + const messageDisplay = { + showTransientEventRow: jest.fn(), + clearTransientEventRow: jest.fn(), + findMessageBubble: jest.fn().mockReturnValue(bubble) + }; + + const streamingController = { + stopLoadingAnimation: jest.fn(), + finalizeStreaming: jest.fn() + }; + + const updateConversation = jest.fn().mockResolvedValue(undefined); + const chatService = { + getConversationService: jest.fn().mockReturnValue({ + updateConversation + }), + updateConversation: jest.fn().mockResolvedValue(undefined) + }; + + const compactionService = { + compact: jest.fn().mockImplementation((targetConversation: ConversationData) => { + targetConversation.messages = targetConversation.messages.slice(-2); + return { + summary: 'Compacted summary', + messagesRemoved: 2, + messagesKept: 2, + filesReferenced: [], + topics: ['topic'], + compactedAt: 3000 + }; + }) + }; + + const onUpdateContextProgress = jest.fn(); + + const coordinator = new ChatSendCoordinator({ + app: {} as never, + chatService: chatService as never, + getContainerEl: () => containerEl, + getConversationManager: () => conversationManager, + getMessageManager: () => messageManager, + getModelAgentManager: () => modelAgentManager, + getChatInput: () => chatInput, + getMessageDisplay: () => messageDisplay, + getStreamingController: () => streamingController, + getPreservationService: () => null, + getStorageAdapter: () => null, + onUpdateContextProgress, + compactionService + }); + + return { + coordinator, + conversation, + contentEl, + conversationManager, + messageManager, + modelAgentManager, + chatInput, + messageDisplay, + streamingController, + chatService, + updateConversation, + compactionService, + onUpdateContextProgress, + bubble + }; +} + +describe('ChatSendCoordinator', () => { + it('compacts context before sending when the selected model requires it', async () => { + const harness = createHarness(); + harness.modelAgentManager.shouldCompactBeforeSending.mockReturnValue(true); + + await harness.coordinator.handleSendMessage('next message'); + + expect(harness.compactionService.compact).toHaveBeenCalledTimes(1); + expect(harness.modelAgentManager.getMessageOptions).toHaveBeenCalledTimes(2); + expect(harness.chatInput.setPreSendCompacting).toHaveBeenCalledWith(true); + expect(harness.messageDisplay.showTransientEventRow).toHaveBeenCalledWith('Compacting context before sending...'); + expect(harness.modelAgentManager.appendCompactionRecord).toHaveBeenCalledTimes(1); + expect(harness.modelAgentManager.resetTokenTracker).toHaveBeenCalledTimes(1); + expect(harness.updateConversation).toHaveBeenCalledWith('conv-1', expect.objectContaining({ + title: 'Conversation', + messages: harness.conversation.messages + })); + expect(harness.onUpdateContextProgress).toHaveBeenCalledTimes(1); + expect(harness.messageManager.sendMessage).toHaveBeenCalledWith( + harness.conversation, + 'next message', + expect.objectContaining({ + provider: 'github-copilot', + model: 'copilot-model' + }), + undefined + ); + expect(harness.modelAgentManager.clearMessageEnhancement).toHaveBeenCalledTimes(1); + expect(harness.chatInput.clearMessageEnhancer).toHaveBeenCalledTimes(1); + expect(harness.messageDisplay.clearTransientEventRow).toHaveBeenCalled(); + }); + + it('stops animations and finalizes with the persisted partial content when generation aborts', () => { + const harness = createHarness(); + + harness.coordinator.handleGenerationAborted('a1'); + + expect(harness.messageDisplay.findMessageBubble).toHaveBeenCalledWith('a1'); + expect(harness.bubble.stopLoadingAnimation).toHaveBeenCalledTimes(1); + expect(harness.streamingController.stopLoadingAnimation).toHaveBeenCalledWith(harness.contentEl); + expect(harness.streamingController.finalizeStreaming).toHaveBeenCalledWith('a1', 'partial response'); + }); +}); diff --git a/tests/unit/ChatSessionCoordinator.test.ts b/tests/unit/ChatSessionCoordinator.test.ts new file mode 100644 index 000000000..b2206a99f --- /dev/null +++ b/tests/unit/ChatSessionCoordinator.test.ts @@ -0,0 +1,172 @@ +import { Component } from 'obsidian'; +import { ChatSessionCoordinator } from '../../src/ui/chat/services/ChatSessionCoordinator'; +import { ConversationData } from '../../src/types/chat/ChatTypes'; + +class FakeElement { + textContent = ''; + className = ''; + private children: FakeElement[] = []; + + appendChild(child: FakeElement): void { + this.children.push(child); + } + + querySelector(selector: string): FakeElement | null { + if (selector === '.chat-welcome-button') { + return this.children.find(child => child.className === 'chat-welcome-button') ?? null; + } + + return null; + } +} + +function createConversation(id = 'conv-1', title = 'Conversation'): ConversationData { + return { + id, + title, + messages: [], + created: 1000, + updated: 2000, + }; +} + +function createCoordinatorHarness() { + const containerEl = new FakeElement(); + const chatTitleEl = new FakeElement(); + + const component = { + registerDomEvent: jest.fn(), + } as unknown as Component; + + const conversationManager = { + loadConversations: jest.fn().mockResolvedValue(undefined), + getConversations: jest.fn().mockReturnValue([]), + getCurrentConversation: jest.fn().mockReturnValue(null), + selectConversation: jest.fn().mockResolvedValue(undefined), + createNewConversation: jest.fn().mockResolvedValue(undefined), + isSearchActive: false, + hasMore: false, + isLoading: false, + }; + + const messageManager = { + getIsLoading: jest.fn().mockReturnValue(false), + cancelCurrentGeneration: jest.fn().mockResolvedValue(undefined), + interruptCurrentGeneration: jest.fn().mockResolvedValue(undefined), + sendMessage: jest.fn().mockResolvedValue(undefined), + }; + + const modelAgentManager = { + initializeDefaults: jest.fn().mockResolvedValue(undefined), + initializeFromConversation: jest.fn().mockResolvedValue(undefined), + setCurrentConversationId: jest.fn(), + }; + + const conversationList = { + setIsSearchActive: jest.fn(), + setConversations: jest.fn(), + setHasMore: jest.fn(), + setIsLoading: jest.fn(), + }; + + const messageDisplay = { + setConversation: jest.fn(), + }; + + const chatInput = { + setConversationState: jest.fn(), + }; + + const uiStateController = { + showWelcomeState: jest.fn((hasProviders: boolean) => { + if (hasProviders) { + const button = new FakeElement(); + button.className = 'chat-welcome-button'; + containerEl.appendChild(button); + } + }), + setInputPlaceholder: jest.fn(), + getSidebarVisible: jest.fn().mockReturnValue(false), + toggleConversationList: jest.fn(), + }; + + const chatService = { + hasConfiguredProviders: jest.fn().mockReturnValue(true), + getConversation: jest.fn(), + }; + + const onUpdateChatTitle = jest.fn(); + const onUpdateContextProgress = jest.fn(); + const onClearStreamingState = jest.fn(); + const onClearAgentStatus = jest.fn(); + + const coordinator = new ChatSessionCoordinator({ + chatService: chatService as never, + component, + getContainerEl: () => containerEl as unknown as HTMLElement, + getChatTitleEl: () => chatTitleEl as unknown as HTMLElement, + getConversationManager: () => conversationManager as never, + getMessageManager: () => messageManager as never, + getModelAgentManager: () => modelAgentManager as never, + getConversationList: () => conversationList as never, + getMessageDisplay: () => messageDisplay as never, + getChatInput: () => chatInput as never, + getUIStateController: () => uiStateController as never, + onClearStreamingState, + onClearAgentStatus, + onUpdateChatTitle, + onUpdateContextProgress, + }); + + return { + coordinator, + containerEl, + chatTitleEl, + component, + conversationManager, + messageManager, + modelAgentManager, + conversationList, + messageDisplay, + chatInput, + uiStateController, + chatService, + onUpdateChatTitle, + onUpdateContextProgress, + onClearStreamingState, + onClearAgentStatus, + }; +} + +describe('ChatSessionCoordinator', () => { + it('shows welcome state and binds the welcome button when initial data is empty', async () => { + const harness = createCoordinatorHarness(); + + await harness.coordinator.loadInitialData(); + + expect(harness.conversationManager.loadConversations).toHaveBeenCalledTimes(1); + expect(harness.modelAgentManager.initializeDefaults).toHaveBeenCalledTimes(1); + expect(harness.chatService.hasConfiguredProviders).toHaveBeenCalledTimes(1); + expect(harness.uiStateController.showWelcomeState).toHaveBeenCalledWith(true); + expect(harness.chatTitleEl.textContent).toBe('Chat'); + expect(harness.chatInput.setConversationState).toHaveBeenCalledWith(false); + expect(harness.component.registerDomEvent).toHaveBeenCalledTimes(1); + expect(harness.containerEl.querySelector('.chat-welcome-button')).not.toBeNull(); + }); + + it('sets the current conversation id through ModelAgentManager public API when selecting a conversation', async () => { + const harness = createCoordinatorHarness(); + const conversation = createConversation('conv-selected', 'Selected'); + + await harness.coordinator.handleConversationSelected(conversation); + + expect(harness.modelAgentManager.setCurrentConversationId).toHaveBeenCalledWith('conv-selected'); + expect(harness.modelAgentManager.initializeFromConversation).toHaveBeenCalledWith('conv-selected'); + expect(harness.messageDisplay.setConversation).toHaveBeenCalledWith(conversation); + expect(harness.uiStateController.setInputPlaceholder).toHaveBeenCalledWith('Type your message...'); + expect(harness.chatInput.setConversationState).toHaveBeenCalledWith(true); + expect(harness.onClearAgentStatus).toHaveBeenCalledTimes(1); + expect(harness.onUpdateChatTitle).toHaveBeenCalledTimes(1); + expect(harness.onUpdateContextProgress).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/ChatSubagentIntegration.test.ts b/tests/unit/ChatSubagentIntegration.test.ts new file mode 100644 index 000000000..2a3df4d33 --- /dev/null +++ b/tests/unit/ChatSubagentIntegration.test.ts @@ -0,0 +1,186 @@ +import type { App, Component } from 'obsidian'; +jest.mock('../../src/ui/chat/controllers/SubagentController', () => ({ + SubagentController: jest.fn(), +})); + +import { ChatSubagentIntegration } from '../../src/ui/chat/services/ChatSubagentIntegration'; +import type { SubagentControllerEvents } from '../../src/ui/chat/controllers/SubagentController'; + +describe('ChatSubagentIntegration', () => { + it('creates a shared context provider and initializes subagent dependencies', async () => { + const currentConversation = { id: 'conv-1' }; + const conversationManager = { + getCurrentConversation: jest.fn(() => currentConversation), + selectConversation: jest.fn().mockResolvedValue(undefined), + }; + const modelAgentManager = { + getSelectedModel: jest.fn(() => ({ providerId: 'openai', modelId: 'gpt-5' })), + getSelectedPrompt: jest.fn(() => ({ name: 'Prompt', systemPrompt: 'System prompt' })), + getLoadedWorkspaceData: jest.fn(() => ({ workspace: 'data' })), + getContextNotes: jest.fn(() => ['Note A']), + getThinkingSettings: jest.fn(() => ({ enabled: true, effort: 'high' as const })), + getSelectedWorkspaceId: jest.fn(() => 'workspace-1'), + }; + const navigationTarget = { + navigateToBranch: jest.fn().mockResolvedValue(undefined), + continueSubagent: jest.fn().mockResolvedValue(undefined), + }; + const streamingController = {} as never; + const toolEventCoordinator = {} as never; + const settingsButtonContainer = {} as HTMLElement; + const settingsButton = {} as HTMLElement; + const llmService = { name: 'llm-service' }; + const directToolExecutor = { + executeToolCalls: jest.fn(), + }; + const promptManagerAgent = { name: 'prompt-manager' }; + const storageAdapter = { name: 'storage-adapter' }; + const preservationService = { name: 'preservation-service' }; + + const plugin = { + getService: jest.fn(async (name: string) => { + if (name === 'directToolExecutor') { + return directToolExecutor; + } + if (name === 'agentManager') { + return { + getAgent: jest.fn((agentName: string) => + agentName === 'promptManager' ? promptManagerAgent : null + ), + }; + } + return null; + }), + getServiceIfReady: jest.fn((name: string) => + name === 'hybridStorageAdapter' ? storageAdapter : null + ), + }; + + let capturedEvents: SubagentControllerEvents | null = null; + let capturedInitializeArgs: unknown[] | null = null; + let capturedNavigationCallbacks: + | { + onNavigateToBranch: (branchId: string) => void; + onContinueAgent: (branchId: string) => void; + } + | null = null; + + const subagentController = { + initialize: jest.fn((...args: unknown[]) => { + capturedInitializeArgs = args; + }), + setNavigationCallbacks: jest.fn((callbacks) => { + capturedNavigationCallbacks = callbacks; + }), + }; + + const createPreservationService = jest.fn(() => preservationService as never); + + const integration = new ChatSubagentIntegration({ + app: {} as App, + component: {} as Component, + chatService: { + getLLMService: jest.fn(() => llmService), + } as never, + getConversationManager: () => conversationManager, + getModelAgentManager: () => modelAgentManager, + getStreamingController: () => streamingController, + getToolEventCoordinator: () => toolEventCoordinator, + getSettingsButtonContainer: () => settingsButtonContainer, + getSettingsButton: () => settingsButton, + getNavigationTarget: () => navigationTarget, + getPlugin: () => plugin, + createSubagentController: (_app, _component, events) => { + capturedEvents = events; + return subagentController as never; + }, + createPreservationService, + }); + + const contextProvider = integration.createContextProvider(); + expect(contextProvider.getCurrentConversation()).toBe(currentConversation); + expect(contextProvider.getSelectedModel()).toEqual({ providerId: 'openai', modelId: 'gpt-5' }); + expect(contextProvider.getSelectedPrompt()).toEqual({ name: 'Prompt', systemPrompt: 'System prompt' }); + expect(contextProvider.getLoadedWorkspaceData()).toEqual({ workspace: 'data' }); + expect(contextProvider.getContextNotes()).toEqual(['Note A']); + expect(contextProvider.getThinkingSettings()).toEqual({ enabled: true, effort: 'high' }); + expect(contextProvider.getSelectedWorkspaceId()).toBe('workspace-1'); + + const result = await integration.initialize(); + + expect(subagentController.initialize).toHaveBeenCalledTimes(1); + expect(capturedInitializeArgs).not.toBeNull(); + const initializeArgs = capturedInitializeArgs as [ + { + app: App; + chatService: unknown; + directToolExecutor: unknown; + promptManagerAgent: unknown; + storageAdapter: unknown; + llmService: unknown; + }, + typeof contextProvider, + unknown, + unknown, + HTMLElement | undefined, + HTMLElement | undefined, + ]; + expect(initializeArgs[0]).toEqual( + expect.objectContaining({ + directToolExecutor, + promptManagerAgent, + storageAdapter, + llmService, + }) + ); + expect(initializeArgs[1].getSelectedWorkspaceId()).toBe('workspace-1'); + expect(initializeArgs[2]).toBe(streamingController); + expect(initializeArgs[3]).toBe(toolEventCoordinator); + expect(initializeArgs[4]).toBe(settingsButtonContainer); + expect(initializeArgs[5]).toBe(settingsButton); + + expect(capturedEvents).not.toBeNull(); + await capturedEvents?.onConversationNeedsRefresh?.('conv-1'); + expect(conversationManager.selectConversation).toHaveBeenCalledWith(currentConversation); + + expect(subagentController.setNavigationCallbacks).toHaveBeenCalledTimes(1); + capturedNavigationCallbacks?.onNavigateToBranch('branch-1'); + capturedNavigationCallbacks?.onContinueAgent('branch-2'); + expect(navigationTarget.navigateToBranch).toHaveBeenCalledWith('branch-1'); + expect(navigationTarget.continueSubagent).toHaveBeenCalledWith('branch-2'); + + expect(createPreservationService).toHaveBeenCalledTimes(1); + expect(createPreservationService).toHaveBeenCalledWith( + expect.objectContaining({ + llmService, + }) + ); + expect(result).toEqual({ + preservationService, + subagentController, + }); + }); + + it('returns null services when required plugin dependencies are unavailable', async () => { + const integration = new ChatSubagentIntegration({ + app: {} as App, + component: {} as Component, + chatService: { + getLLMService: jest.fn(() => ({ name: 'llm-service' })), + } as never, + getConversationManager: () => null, + getModelAgentManager: () => null, + getStreamingController: () => null, + getToolEventCoordinator: () => null, + getSettingsButtonContainer: () => undefined, + getSettingsButton: () => undefined, + getNavigationTarget: () => null, + getPlugin: () => null, + }); + + await expect(integration.initialize()).resolves.toEqual({ + preservationService: null, + subagentController: null, + }); + }); +}); diff --git a/tests/unit/PluginScopedStorageCoordinator.test.ts b/tests/unit/PluginScopedStorageCoordinator.test.ts index a9707fb6d..1587adfc0 100644 --- a/tests/unit/PluginScopedStorageCoordinator.test.ts +++ b/tests/unit/PluginScopedStorageCoordinator.test.ts @@ -90,7 +90,7 @@ describe('PluginScopedStorageCoordinator', () => { expect(roots.dataRoot).toBe('.obsidian/plugins/claudesidian-mcp/data'); }); - it('copies verified legacy JSONL data into plugin-scoped storage and cuts over reads and writes', async () => { + it('returns legacy paths on first boot with legacy data, kicks off background copy', async () => { const adapter = createMockAdapter({ '.nexus/workspaces/ws_alpha.jsonl': '{"id":"evt-ws"}\n', '.nexus/conversations/conv_alpha.jsonl': '{"id":"evt-conv"}\n' @@ -113,13 +113,18 @@ describe('PluginScopedStorageCoordinator', () => { const plan = await coordinator.prepareStoragePlan(); - expect(plan.writeBasePath).toBe('.obsidian/plugins/claudesidian-mcp/data'); - expect(plan.readBasePaths).toEqual([ - '.obsidian/plugins/claudesidian-mcp/data', - '.nexus' - ]); - expect(plan.state.sourceOfTruthLocation).toBe('plugin-data'); - expect(plan.state.migration.state).toBe('verified'); + // Stage 1: returns legacy paths immediately + expect(plan.writeBasePath).toBe('.nexus'); + expect(plan.readBasePaths).toEqual(['.nexus']); + expect(plan.state.sourceOfTruthLocation).toBe('legacy-dotnexus'); + + // Background migration was started + expect(coordinator.backgroundMigration).not.toBeNull(); + + // Wait for background migration to complete + await coordinator.backgroundMigration; + + // Verify files were copied in the background expect(adapter.write).toHaveBeenCalledWith( '.obsidian/plugins/claudesidian-mcp/data/workspaces/ws_alpha.jsonl', '{"id":"evt-ws"}\n' @@ -128,11 +133,59 @@ describe('PluginScopedStorageCoordinator', () => { '.obsidian/plugins/claudesidian-mcp/data/conversations/conv_alpha.jsonl', '{"id":"evt-conv"}\n' ); - expect(adapter.exists).toHaveBeenCalledWith('.nexus/workspaces'); - expect(saveData).toHaveBeenCalled(); + + // State was persisted as verified + const lastSaveCall = saveData.mock.calls[saveData.mock.calls.length - 1][0]; + expect(lastSaveCall.pluginStorage.migration.state).toBe('verified'); }); - it('does not overwrite newer plugin-scoped data and falls back to legacy writes when migration detects conflicts', async () => { + it('returns plugin-data paths on second boot after verified migration', async () => { + const adapter = createMockAdapter({ + '.nexus/workspaces/ws_alpha.jsonl': '{"id":"evt-ws"}\n', + '.obsidian/plugins/claudesidian-mcp/data/workspaces/ws_alpha.jsonl': '{"id":"evt-ws"}\n' + }); + const coordinator = new PluginScopedStorageCoordinator( + { + vault: { adapter, configDir: '.obsidian' } + } as never, + { + manifest: { + id: 'nexus', + dir: '/mock/.obsidian/plugins/claudesidian-mcp' + }, + loadData: jest.fn(async () => ({ + pluginStorage: { + storageVersion: 1, + sourceOfTruthLocation: 'plugin-data', + migration: { + state: 'verified', + verifiedAt: Date.now(), + legacySourcesDetected: ['.nexus'], + activeDestination: '.obsidian/plugins/claudesidian-mcp/data' + } + } + })), + saveData: jest.fn(async () => undefined) + } as never, + '.nexus' + ); + + const plan = await coordinator.prepareStoragePlan(); + + // Stage 2: instant cutover to plugin-data paths + expect(plan.writeBasePath).toBe('.obsidian/plugins/claudesidian-mcp/data'); + expect(plan.readBasePaths).toEqual([ + '.obsidian/plugins/claudesidian-mcp/data', + '.nexus' + ]); + expect(plan.state.sourceOfTruthLocation).toBe('plugin-data'); + expect(plan.state.migration.state).toBe('verified'); + + // No background migration started + expect(coordinator.backgroundMigration).toBeNull(); + }); + + it('does not overwrite newer plugin-scoped data and records failure in background', async () => { const adapter = createMockAdapter({ '.nexus/workspaces/ws_alpha.jsonl': '{"id":"legacy-evt"}\n', '.obsidian/plugins/claudesidian-mcp/data/workspaces/ws_alpha.jsonl': '{"id":"plugin-evt","newer":true}\n' @@ -155,18 +208,49 @@ describe('PluginScopedStorageCoordinator', () => { const plan = await coordinator.prepareStoragePlan(); + // Returns legacy paths immediately (Stage 1) expect(plan.writeBasePath).toBe('.nexus'); expect(plan.readBasePaths).toEqual(['.nexus']); expect(plan.state.sourceOfTruthLocation).toBe('legacy-dotnexus'); - expect(plan.state.migration.state).toBe('failed'); - expect(plan.state.migration.lastError).toContain('destination already exists with different content'); - expect(adapter.write).not.toHaveBeenCalledWith( - '.obsidian/plugins/claudesidian-mcp/data/workspaces/ws_alpha.jsonl', - '{"id":"legacy-evt"}\n' - ); + + // Wait for background migration to complete (it will fail due to conflict) + await coordinator.backgroundMigration; + + // Plugin-scoped data was NOT overwritten await expect( adapter.read('.obsidian/plugins/claudesidian-mcp/data/workspaces/ws_alpha.jsonl') ).resolves.toBe('{"id":"plugin-evt","newer":true}\n'); - expect(saveData).toHaveBeenCalled(); + + // State was persisted as failed + const lastSaveCall = saveData.mock.calls[saveData.mock.calls.length - 1][0]; + expect(lastSaveCall.pluginStorage.migration.state).toBe('failed'); + expect(lastSaveCall.pluginStorage.migration.lastError).toContain( + 'destination already exists with different content' + ); + }); + + it('goes straight to plugin-data paths when no legacy files exist', async () => { + const adapter = createMockAdapter({}); + const saveData = jest.fn(async () => undefined); + const coordinator = new PluginScopedStorageCoordinator( + { + vault: { adapter, configDir: '.obsidian' } + } as never, + { + manifest: { + id: 'nexus', + dir: '/mock/.obsidian/plugins/claudesidian-mcp' + }, + loadData: jest.fn(async () => ({})), + saveData + } as never, + '.nexus' + ); + + const plan = await coordinator.prepareStoragePlan(); + + expect(plan.writeBasePath).toBe('.obsidian/plugins/claudesidian-mcp/data'); + expect(plan.readBasePaths).toEqual(['.obsidian/plugins/claudesidian-mcp/data']); + expect(coordinator.backgroundMigration).toBeNull(); }); -}); \ No newline at end of file +});