diff --git a/eslint.config.mjs b/eslint.config.mjs index e9ce444b0..b6c4ac0e9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -91,6 +91,7 @@ export default defineConfig([ "src/services/embeddings/IndexingQueue.ts", "src/settings/getStartedStatus.ts", "src/utils/cli*.ts", + "src/database/storage/JSONLWriter.ts", ], rules: { "import/no-nodejs-modules": "off", diff --git a/src/agents/searchManager/services/MemorySearchProcessor.ts b/src/agents/searchManager/services/MemorySearchProcessor.ts index 572c42a3f..8c317b2e8 100644 --- a/src/agents/searchManager/services/MemorySearchProcessor.ts +++ b/src/agents/searchManager/services/MemorySearchProcessor.ts @@ -450,7 +450,7 @@ export class MemorySearchProcessor implements MemorySearchProcessorInterface { for (const state of statesResult.items) { let score = 0; - if (state.name.toLowerCase().includes(queryLower)) score += 0.9; + if (state.name?.toLowerCase().includes(queryLower)) score += 0.9; if (score > 0) { results.push({ trace: state as unknown as RawMemoryResult['trace'], similarity: score } as RawMemoryResult); } @@ -473,7 +473,7 @@ export class MemorySearchProcessor implements MemorySearchProcessorInterface { for (const workspace of workspaces) { let score = 0; - if (workspace.name.toLowerCase().includes(queryLower)) score += 0.9; + if (workspace.name?.toLowerCase().includes(queryLower)) score += 0.9; if (workspace.description?.toLowerCase().includes(queryLower)) score += 0.8; if (score > 0) { results.push({ trace: workspace as unknown as RawMemoryResult['trace'], similarity: score } as RawMemoryResult); diff --git a/src/agents/toolManager/services/ToolBatchExecutionService.ts b/src/agents/toolManager/services/ToolBatchExecutionService.ts index 26c36e3f1..4d46a4179 100644 --- a/src/agents/toolManager/services/ToolBatchExecutionService.ts +++ b/src/agents/toolManager/services/ToolBatchExecutionService.ts @@ -237,7 +237,7 @@ export class ToolBatchExecutionService { } const byName = this.knownWorkspaces.find(workspace => - workspace.name.toLowerCase() === workspaceId.toLowerCase() + workspace.name?.toLowerCase() === workspaceId.toLowerCase() ); if (byName) { return null; diff --git a/src/components/LLMProviderModal.ts b/src/components/LLMProviderModal.ts index b439f9234..73c9c64da 100644 --- a/src/components/LLMProviderModal.ts +++ b/src/components/LLMProviderModal.ts @@ -12,7 +12,7 @@ * - GenericProviderModal (API-key providers) */ -import { Modal, App } from 'obsidian'; +import { Modal, App } from 'obsidian'; import { LLMProviderConfig } from '../types'; import { LLMProviderManager } from '../services/llm/providers/ProviderManager'; import { StaticModelsService } from '../services/StaticModelsService'; @@ -40,7 +40,7 @@ export interface LLMProviderModalConfig { config: LLMProviderConfig; oauthConfig?: OAuthModalConfig; secondaryOAuthProvider?: SecondaryOAuthProviderConfig; - onSave: (config: LLMProviderConfig) => void; + onSave: (config: LLMProviderConfig) => void | Promise; /** If true, hide the API key input — provider uses OAuth exclusively */ oauthOnly?: boolean; } @@ -168,7 +168,7 @@ export class LLMProviderModal extends Modal { clearTimeout(this.autoSaveTimeout); this.autoSaveTimeout = null; } - this.config.onSave(config); + void this.config.onSave(config); this.showSaveStatus('Saved'); setTimeout(() => this.showSaveStatus('Ready'), 2000); } else { @@ -187,19 +187,24 @@ export class LLMProviderModal extends Modal { this.showSaveStatus('Saving...'); this.autoSaveTimeout = setTimeout(() => { - // Get final config from provider modal - if (this.providerModal) { - this.config.config = this.providerModal.getConfig(); - } - - // Call the save callback - this.config.onSave(this.config.config); - this.showSaveStatus('Saved'); - - // Reset status after 2 seconds - setTimeout(() => { - this.showSaveStatus('Ready'); - }, 2000); + void (async () => { + // Get final config from provider modal + if (this.providerModal) { + this.config.config = this.providerModal.getConfig(); + } + + try { + await this.config.onSave(this.config.config); + this.showSaveStatus('Saved'); + } catch { + this.showSaveStatus('Save failed'); + } + + // Reset status after 2 seconds + setTimeout(() => { + this.showSaveStatus('Ready'); + }, 2000); + })(); }, 500); } diff --git a/src/components/shared/ChatSettingsRenderer.ts b/src/components/shared/ChatSettingsRenderer.ts index 31b1bfa72..2ef8a0f25 100644 --- a/src/components/shared/ChatSettingsRenderer.ts +++ b/src/components/shared/ChatSettingsRenderer.ts @@ -514,7 +514,7 @@ export class ChatSettingsRenderer { dropdown.onChange((value) => { this.settings.workspaceId = value || null; this.notifyChange(); - void this.syncWorkspacePrompt(value); + this.syncWorkspacePrompt(value); }); }); diff --git a/src/core/ingest/VaultIngestionManager.ts b/src/core/ingest/VaultIngestionManager.ts index 335b5838e..cfaa7a234 100644 --- a/src/core/ingest/VaultIngestionManager.ts +++ b/src/core/ingest/VaultIngestionManager.ts @@ -253,6 +253,7 @@ export class VaultIngestionManager { }; } + const transcriptionProvider = llmSettings.defaultTranscriptionModel?.provider; const transcriptionModel = llmSettings.defaultTranscriptionModel?.model; if (!transcriptionProvider || !transcriptionModel) { diff --git a/src/database/adapters/HybridStorageAdapter.ts b/src/database/adapters/HybridStorageAdapter.ts index 23b1b445e..ff71f96f7 100644 --- a/src/database/adapters/HybridStorageAdapter.ts +++ b/src/database/adapters/HybridStorageAdapter.ts @@ -56,12 +56,12 @@ import { ConversationRepository } from '../repositories/ConversationRepository'; import { MessageRepository } from '../repositories/MessageRepository'; import { ProjectRepository } from '../repositories/ProjectRepository'; import { TaskRepository } from '../repositories/TaskRepository'; -// Import services -import { ExportService } from '../services/ExportService'; - -type ExportServiceStateRepo = { - getStates(workspaceId: string, sessionId: string | undefined, options?: { pageSize?: number }): Promise<{ items: StateData[] }>; -}; +// Import services +import { ExportService } from '../services/ExportService'; + +type ExportServiceStateRepo = { + getStates(workspaceId: string, sessionId: string | undefined, options?: { pageSize?: number }): Promise<{ items: StateData[] }>; +}; /** * Configuration options for HybridStorageAdapter @@ -158,16 +158,16 @@ export class HybridStorageAdapter implements IStorageAdapter { this.taskRepo = new TaskRepository(deps); // Initialize services - this.exportService = new ExportService({ - app: this.app, - conversationRepo: this.conversationRepo, - messageRepo: this.messageRepo, - workspaceRepo: this.workspaceRepo, - sessionRepo: this.sessionRepo, - stateRepo: this.stateRepo as unknown as ExportServiceStateRepo, - traceRepo: this.traceRepo - }); - } + this.exportService = new ExportService({ + app: this.app, + conversationRepo: this.conversationRepo, + messageRepo: this.messageRepo, + workspaceRepo: this.workspaceRepo, + sessionRepo: this.sessionRepo, + stateRepo: this.stateRepo as unknown as ExportServiceStateRepo, + traceRepo: this.traceRepo + }); + } // ============================================================================ // Lifecycle Management @@ -199,10 +199,10 @@ export class HybridStorageAdapter implements IStorageAdapter { }); // Start initialization in background - this.performInitialization().catch((error: unknown) => { - this.initError = error instanceof Error ? error : new Error(String(error)); - console.error('[HybridStorageAdapter] Background initialization failed:', error); - }); + this.performInitialization().catch((error: unknown) => { + this.initError = error instanceof Error ? error : new Error(String(error)); + console.error('[HybridStorageAdapter] Background initialization failed:', error); + }); // If blocking mode, wait for completion if (blocking) { @@ -248,6 +248,23 @@ export class HybridStorageAdapter implements IStorageAdapter { // This can take a long time for large vaults (168MB+ JSONL files). // The UI will show incrementally as data syncs in. const syncState = await this.sqliteCache.getSyncState(this.jsonlWriter.getDeviceId()); + + // 5. Prune orphaned conversation JSONL files BEFORE sync/rebuild. + // Only safe when a prior session exists (syncState present), meaning SQLite + // accurately reflects what was alive at end of last session. Orphaned files + // (from the pre-fix delete bug) have no SQLite record at this point and can + // be safely deleted. Running BEFORE rebuild prevents fullRebuild from + // re-reading the orphaned files and resurrecting deleted conversations. + // Skip on first-ever startup (!syncState) — SQLite is empty and every file + // would look orphaned. + if (syncState) { + try { + await this.pruneOrphanedConversationFiles(); + } catch (pruneError) { + console.error('[HybridStorageAdapter] Orphaned conversation file pruning failed:', pruneError); + } + } + if (!syncState || actuallyMigrated) { try { await this.syncCoordinator.fullRebuild(); @@ -261,14 +278,14 @@ export class HybridStorageAdapter implements IStorageAdapter { console.error('[HybridStorageAdapter] Incremental sync failed:', syncError); } - // 5. Reconcile JSONL workspaces missing from SQLite + // 6. Reconcile JSONL workspaces missing from SQLite try { await this.reconcileMissingWorkspaces(); } catch (reconcileError) { console.error('[HybridStorageAdapter] Workspace reconciliation failed:', reconcileError); } - // 6. Reconcile JSONL tasks missing from SQLite + // 7. Reconcile JSONL tasks missing from SQLite try { await this.reconcileMissingTasks(); } catch (reconcileError) { @@ -390,6 +407,40 @@ export class HybridStorageAdapter implements IStorageAdapter { } } + /** + * Remove JSONL files for conversations that no longer exist in SQLite. + * + * Prior to the deleteConversation fix, ConversationRepository.delete() only + * removed the SQLite row and never deleted the JSONL file. This left orphaned + * files behind for every conversation that was ever deleted. This method runs + * once per startup (incremental sync path) to clean them up. + */ + private async pruneOrphanedConversationFiles(): Promise { + const files = await this.jsonlWriter.listFiles('conversations'); + if (files.length === 0) return; + + let pruned = 0; + for (const file of files) { + const match = file.match(/conversations\/conv_(.+)\.jsonl$/); + if (!match) continue; + + const conversationId = match[1]; + const existing = await this.conversationRepo.getById(conversationId); + if (existing) continue; + + try { + await this.jsonlWriter.deleteFile(file); + pruned++; + } catch (e) { + console.error(`[HybridStorageAdapter] Failed to prune orphaned conversation file ${file}:`, e); + } + } + + if (pruned > 0) { + console.warn(`[HybridStorageAdapter] Pruned ${pruned} orphaned conversation JSONL file(s)`); + } + } + /** * Check if the adapter is ready for use */ diff --git a/src/database/repositories/ConversationRepository.ts b/src/database/repositories/ConversationRepository.ts index d3d00d58d..d9d58e190 100644 --- a/src/database/repositories/ConversationRepository.ts +++ b/src/database/repositories/ConversationRepository.ts @@ -17,20 +17,20 @@ * - src/types/storage/HybridStorageTypes.ts - Data types */ -import { BaseRepository, DatabaseRow, RepositoryDependencies } from './base/BaseRepository'; +import { BaseRepository, DatabaseRow, RepositoryDependencies } from './base/BaseRepository'; import { IConversationRepository, CreateConversationData, UpdateConversationData } from './interfaces/IConversationRepository'; import { ConversationMetadata } from '../../types/storage/HybridStorageTypes'; -import { ConversationCreatedEvent, ConversationUpdatedEvent } from '../interfaces/StorageEvents'; -import { PaginatedResult, PaginationParams } from '../../types/pagination/PaginationTypes'; -import { QueryOptions } from '../interfaces/IStorageAdapter'; - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -function isRunTrigger(value: unknown): value is 'manual' | 'scheduled' | 'catch_up' { - return value === 'manual' || value === 'scheduled' || value === 'catch_up'; -} +import { ConversationCreatedEvent, ConversationUpdatedEvent } from '../interfaces/StorageEvents'; +import { PaginatedResult, PaginationParams } from '../../types/pagination/PaginationTypes'; +import { QueryOptions } from '../interfaces/IStorageAdapter'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isRunTrigger(value: unknown): value is 'manual' | 'scheduled' | 'catch_up' { + return value === 'manual' || value === 'scheduled' || value === 'catch_up'; +} /** * Conversation repository implementation @@ -38,9 +38,9 @@ function isRunTrigger(value: unknown): value is 'manual' | 'scheduled' | 'catch_ * Stores conversation metadata in SQLite for fast queries. * Each conversation has its own JSONL file for messages and events. */ -export class ConversationRepository - extends BaseRepository - implements IConversationRepository { +export class ConversationRepository + extends BaseRepository + implements IConversationRepository { protected readonly tableName = 'conversations'; protected readonly entityType = 'conversation'; @@ -57,9 +57,9 @@ export class ConversationRepository // Abstract method implementations // ============================================================================ - protected rowToEntity(row: DatabaseRow): ConversationMetadata { - return this.rowToConversation(row as ConversationRow); - } + protected rowToEntity(row: DatabaseRow): ConversationMetadata { + return this.rowToConversation(row as ConversationRow); + } async getAll(options?: PaginationParams): Promise> { return this.getConversations(options); @@ -76,10 +76,10 @@ export class ConversationRepository return this.getCachedOrFetch( `${this.entityType}:${id}`, async () => { - const row = await this.sqliteCache.queryOne( - `SELECT * FROM ${this.tableName} WHERE id = ?`, - [id] - ); + const row = await this.sqliteCache.queryOne( + `SELECT * FROM ${this.tableName} WHERE id = ?`, + [id] + ); return row ? this.rowToConversation(row) : null; } ); @@ -101,46 +101,46 @@ export class ConversationRepository if (!ALLOWED_SORT_COLUMNS.includes(requestedSort as typeof ALLOWED_SORT_COLUMNS[number])) { throw new Error(`Invalid sort column: ${requestedSort}`); } - if (!ALLOWED_SORT_ORDERS.includes(requestedOrder)) { - throw new Error(`Invalid sort order: ${requestedOrder}`); - } + if (!ALLOWED_SORT_ORDERS.includes(requestedOrder)) { + throw new Error(`Invalid sort order: ${requestedOrder}`); + } const sortBy = requestedSort; const sortOrder = requestedOrder; // Build WHERE clause const filters: string[] = []; - const params: Array = []; + const params: Array = []; // Exclude branches by default (branches have parentConversationId in metadata) if (!includeBranches) { filters.push(`(metadataJson IS NULL OR metadataJson NOT LIKE '%"parentConversationId"%')`); } - if (options?.filter) { - if (typeof options.filter.vaultName === 'string') { - filters.push('vaultName = ?'); - params.push(options.filter.vaultName); - } - if (typeof options.filter.workspaceId === 'string') { - filters.push('workspaceId = ?'); - params.push(options.filter.workspaceId); - } - if (typeof options.filter.sessionId === 'string') { - filters.push('sessionId = ?'); - params.push(options.filter.sessionId); - } - if (typeof options.filter.workflowId === 'string') { - filters.push('workflowId = ?'); - params.push(options.filter.workflowId); - } - if (typeof options.filter.runKey === 'string') { - filters.push('runKey = ?'); - params.push(options.filter.runKey); - } - if (typeof options.filter.runTrigger === 'string') { - filters.push('runTrigger = ?'); - params.push(options.filter.runTrigger); - } + if (options?.filter) { + if (typeof options.filter.vaultName === 'string') { + filters.push('vaultName = ?'); + params.push(options.filter.vaultName); + } + if (typeof options.filter.workspaceId === 'string') { + filters.push('workspaceId = ?'); + params.push(options.filter.workspaceId); + } + if (typeof options.filter.sessionId === 'string') { + filters.push('sessionId = ?'); + params.push(options.filter.sessionId); + } + if (typeof options.filter.workflowId === 'string') { + filters.push('workflowId = ?'); + params.push(options.filter.workflowId); + } + if (typeof options.filter.runKey === 'string') { + filters.push('runKey = ?'); + params.push(options.filter.runKey); + } + if (typeof options.filter.runTrigger === 'string') { + filters.push('runTrigger = ?'); + params.push(options.filter.runTrigger); + } } const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''; @@ -153,7 +153,7 @@ export class ConversationRepository const totalItems = countResult?.count ?? 0; // Get data - const rows = await this.sqliteCache.query( + const rows = await this.sqliteCache.query( `SELECT * FROM ${this.tableName} ${whereClause} ORDER BY ${sortBy} ${sortOrder} LIMIT ? OFFSET ?`, @@ -174,24 +174,24 @@ export class ConversationRepository /** * Search conversations by title using FTS */ - async search(query: string): Promise { - const rows = await this.sqliteCache.searchConversations(query); - return rows.map((r) => this.rowToConversation(r as ConversationRow)); - } + async search(query: string): Promise { + const rows = await this.sqliteCache.searchConversations(query); + return rows.map((r) => this.rowToConversation(r as ConversationRow)); + } /** * Count conversations matching filter */ async count(filter?: Record): Promise { let whereClause = ''; - const params: Array = []; - - if (filter) { - const filters: string[] = []; - if (typeof filter.vaultName === 'string') { - filters.push('vaultName = ?'); - params.push(filter.vaultName); - } + const params: Array = []; + + if (filter) { + const filters: string[] = []; + if (typeof filter.vaultName === 'string') { + filters.push('vaultName = ?'); + params.push(filter.vaultName); + } if (filters.length > 0) { whereClause = `WHERE ${filters.join(' AND ')}`; } @@ -292,7 +292,7 @@ export class ConversationRepository // 2. Update SQLite cache const setClauses: string[] = []; - const params: Array = []; + const params: Array = []; if (data.title !== undefined) { setClauses.push('title = ?'); @@ -352,10 +352,13 @@ export class ConversationRepository */ async delete(id: string): Promise { try { - // No specific delete event - just remove from SQLite - // Messages are cascaded via foreign key constraint + // Remove from SQLite — messages cascade via foreign key constraint await this.sqliteCache.run(`DELETE FROM ${this.tableName} WHERE id = ?`, [id]); + // Delete the JSONL file (contains both conversation metadata events and all + // message events — same file path used by MessageRepository for this conversation) + await this.jsonlWriter.deleteFile(this.jsonlPath(id)); + // Invalidate cache this.invalidateCache(); @@ -408,22 +411,22 @@ export class ConversationRepository /** * Convert SQLite row to ConversationMetadata */ - private rowToConversation(row: ConversationRow): ConversationMetadata { - const metadataRaw: unknown = row.metadataJson ? JSON.parse(row.metadataJson) : undefined; - const metadata = isRecord(metadataRaw) ? metadataRaw : undefined; - const chatSettings = metadata && isRecord(metadata.chatSettings) ? metadata.chatSettings : undefined; - const workspaceId = row.workspaceId ?? (typeof metadata?.workspaceId === 'string' ? metadata.workspaceId : undefined) ?? (typeof chatSettings?.workspaceId === 'string' ? chatSettings.workspaceId : undefined); - const sessionId = row.sessionId ?? (typeof metadata?.sessionId === 'string' ? metadata.sessionId : undefined) ?? (typeof chatSettings?.sessionId === 'string' ? chatSettings.sessionId : undefined); - const workflowId = row.workflowId ?? (typeof metadata?.workflowId === 'string' ? metadata.workflowId : undefined); - const rawRunTrigger = metadata?.runTrigger; - const runTrigger: ConversationMetadata['runTrigger'] = isRunTrigger(row.runTrigger) - ? row.runTrigger - : isRunTrigger(rawRunTrigger) - ? rawRunTrigger - : undefined; - const scheduledFor = row.scheduledFor ?? (typeof metadata?.scheduledFor === 'number' ? metadata.scheduledFor : undefined); - const runKey = row.runKey ?? (typeof metadata?.runKey === 'string' ? metadata.runKey : undefined); - return { + private rowToConversation(row: ConversationRow): ConversationMetadata { + const metadataRaw: unknown = row.metadataJson ? JSON.parse(row.metadataJson) : undefined; + const metadata = isRecord(metadataRaw) ? metadataRaw : undefined; + const chatSettings = metadata && isRecord(metadata.chatSettings) ? metadata.chatSettings : undefined; + const workspaceId = row.workspaceId ?? (typeof metadata?.workspaceId === 'string' ? metadata.workspaceId : undefined) ?? (typeof chatSettings?.workspaceId === 'string' ? chatSettings.workspaceId : undefined); + const sessionId = row.sessionId ?? (typeof metadata?.sessionId === 'string' ? metadata.sessionId : undefined) ?? (typeof chatSettings?.sessionId === 'string' ? chatSettings.sessionId : undefined); + const workflowId = row.workflowId ?? (typeof metadata?.workflowId === 'string' ? metadata.workflowId : undefined); + const rawRunTrigger = metadata?.runTrigger; + const runTrigger: ConversationMetadata['runTrigger'] = isRunTrigger(row.runTrigger) + ? row.runTrigger + : isRunTrigger(rawRunTrigger) + ? rawRunTrigger + : undefined; + const scheduledFor = row.scheduledFor ?? (typeof metadata?.scheduledFor === 'number' ? metadata.scheduledFor : undefined); + const runKey = row.runKey ?? (typeof metadata?.runKey === 'string' ? metadata.runKey : undefined); + return { id: row.id, title: row.title, created: row.created, @@ -433,11 +436,11 @@ export class ConversationRepository workspaceId, sessionId, workflowId, - runTrigger, - scheduledFor, - runKey, - metadata - }; + runTrigger, + scheduledFor, + runKey, + metadata + }; } private getWorkspaceId(data: Partial): string | undefined { @@ -460,23 +463,23 @@ export class ConversationRepository return data.scheduledFor ?? (data.metadata?.scheduledFor as number | undefined); } - private getRunKey(data: Partial): string | undefined { - return data.runKey ?? (data.metadata?.runKey as string | undefined); - } -} - -interface ConversationRow extends DatabaseRow { - id: string; - title: string; - created: number; - updated: number; - vaultName: string; - messageCount: number; - metadataJson?: string | null; - workspaceId?: string | null; - sessionId?: string | null; - workflowId?: string | null; - runTrigger?: string | null; - scheduledFor?: number | null; - runKey?: string | null; -} + private getRunKey(data: Partial): string | undefined { + return data.runKey ?? (data.metadata?.runKey as string | undefined); + } +} + +interface ConversationRow extends DatabaseRow { + id: string; + title: string; + created: number; + updated: number; + vaultName: string; + messageCount: number; + metadataJson?: string | null; + workspaceId?: string | null; + sessionId?: string | null; + workflowId?: string | null; + runTrigger?: string | null; + scheduledFor?: number | null; + runKey?: string | null; +} diff --git a/src/database/repositories/MessageRepository.ts b/src/database/repositories/MessageRepository.ts index 39486b4fa..998da1cae 100644 --- a/src/database/repositories/MessageRepository.ts +++ b/src/database/repositories/MessageRepository.ts @@ -368,35 +368,42 @@ export class MessageRepository throw new Error(`Message ${messageId} not found`); } - // 1. Write update event to JSONL - await this.writeEvent( - this.jsonlPath(message.conversationId), - { - type: 'message_updated', - conversationId: message.conversationId, - messageId, - data: { - content: data.content ?? undefined, - state: data.state, - reasoning: data.reasoning, - // Persist full tool call data including results so tool bubbles can be reconstructed - tool_calls: data.toolCalls?.map(tc => ({ - id: tc.id, - type: tc.type || 'function', - function: tc.function, - name: tc.name, - parameters: tc.parameters, - result: tc.result, - success: tc.success, - error: tc.error - })), - tool_call_id: data.toolCallId ?? undefined, - // Branching support - alternatives: this.convertAlternativesToEvent(data.alternatives), - activeAlternativeIndex: data.activeAlternativeIndex + // 1. Write update event to JSONL — skip in-progress streaming states. + // During streaming every chunk calls update() with state='draft'/'streaming' and + // the full accumulated content so far. Writing each chunk to JSONL is O(n²) storage + // per message and the intermediate content is not useful for replay or sync. + // SQLite is the live store during streaming; JSONL gets the single final event. + const isStreaming = data.state === 'draft' || data.state === 'streaming'; + if (!isStreaming) { + await this.writeEvent( + this.jsonlPath(message.conversationId), + { + type: 'message_updated', + conversationId: message.conversationId, + messageId, + data: { + content: data.content ?? undefined, + state: data.state, + reasoning: data.reasoning, + // Persist full tool call data including results so tool bubbles can be reconstructed + tool_calls: data.toolCalls?.map(tc => ({ + id: tc.id, + type: tc.type || 'function', + function: tc.function, + name: tc.name, + parameters: tc.parameters, + result: tc.result, + success: tc.success, + error: tc.error + })), + tool_call_id: data.toolCallId ?? undefined, + // Branching support + alternatives: this.convertAlternativesToEvent(data.alternatives), + activeAlternativeIndex: data.activeAlternativeIndex + } } - } - ); + ); + } // 2. Update SQLite cache const setClauses: string[] = []; diff --git a/src/database/schema/SchemaMigrator.ts b/src/database/schema/SchemaMigrator.ts index 3a47730af..a30a12f81 100644 --- a/src/database/schema/SchemaMigrator.ts +++ b/src/database/schema/SchemaMigrator.ts @@ -73,7 +73,7 @@ export interface MigratableDatabase { // Alias for backward compatibility type Database = MigratableDatabase; -export const CURRENT_SCHEMA_VERSION = 11; +export const CURRENT_SCHEMA_VERSION = 19; export interface Migration { version: number; @@ -404,6 +404,109 @@ export const MIGRATIONS: Migration[] = [ 'CREATE INDEX IF NOT EXISTS idx_workspaces_archived ON workspaces(isArchived)' ] }, + + // Version 11 -> 12: Fix note/block embedding vec0 table dimensions (768 → 384) + // vec0 virtual tables cannot be DROPped and recreated via prepare().step() DDL — + // they require the native WASM exec() path. This migration is a version marker only; + // the actual DROP/CREATE is handled in SQLiteCacheManager.fixVec0TableDimensions() + // which is called after migrations run and uses the correct raw db.exec() path. + { + version: 12, + description: 'Version marker: note/block vec0 table dimension fix (768→384) handled by SQLiteCacheManager.fixVec0TableDimensions()', + sql: [] + }, + + // ======================================================================== + // Versions 13–16: Stub acknowledgement markers for the prior fork era + // + // The live cache.db was at schema version 16 when this fork was initialized. + // That v16 state came from a previous local-fixes branch of nexus that had: + // - A Nomic embedding pipeline (nomic-embed-text-v1.5, 768-dim) + // - A semantic panel UI feature with block_embeddings and semantic_feedback tables + // - An embedding_config key/value table + // That branch ran migrations up to v16, then was abandoned in favour of this fork. + // + // These stubs record that history so CURRENT_SCHEMA_VERSION matches the live DB + // and the migrate() version comparison stays accurate for future migrations. + // They do nothing — all actual cleanup is in migration v17 below. + // ======================================================================== + { + version: 13, + description: 'Stub: acknowledge legacy Nomic embedding pipeline era (prior local-fixes fork, v13)', + sql: [] + }, + { + version: 14, + description: 'Stub: acknowledge legacy batch GPU inference / semantic panel features (prior local-fixes fork, v14)', + sql: [] + }, + { + version: 15, + description: 'Stub: acknowledge legacy mtime-based embedding optimization (prior local-fixes fork, v15)', + sql: [] + }, + { + version: 16, + description: 'Stub: acknowledge legacy upstream-merge state (prior local-fixes fork, v16)', + sql: [] + }, + + // Version 16 -> 17: Drop orphaned embedding_config table from prior Nomic era. + // The embedding_config table (key TEXT PRIMARY KEY, value TEXT NOT NULL) was created + // by the old local-fixes fork's Nomic embedding pipeline. It holds stale records like + // activeModel=Xenova/nomic-embed-text-v1.5 and activeDimension=768. Our fork uses + // Xenova/all-MiniLM-L6-v2 via iframe/CDN and never reads or writes embedding_config. + // Dropping it removes the confusion and shrinks the DB. + { + version: 17, + description: 'Drop orphaned embedding_config table left by prior Nomic embedding era', + sql: [ + 'DROP TABLE IF EXISTS embedding_config', + ] + }, + + // Version 17 -> 18: Recreate embedding_metadata without the dimension column. + // An intermediate migration in the old local-fixes fork (between its v13-v16) added a + // `dimension INTEGER NOT NULL` column to embedding_metadata. That fork's strip commit + // (which dropped the column) ran at migration v12/v13 in its final HEAD numbering, but + // the live DB was already at v16 so that strip never executed. The column is not in our + // fresh-install schema or in NoteEmbeddingService's INSERT statement, causing a + // NOT NULL constraint violation on every note indexing attempt. Fix: drop and recreate + // the table with the correct schema. Data loss is safe — this table is a re-indexing + // cache; the system will re-embed notes on the next indexing pass. + { + version: 18, + description: 'Recreate embedding_metadata without legacy dimension column from old local-fixes fork', + sql: [ + 'DROP TABLE IF EXISTS embedding_metadata', + `CREATE TABLE IF NOT EXISTS embedding_metadata ( + rowid INTEGER PRIMARY KEY, + notePath TEXT NOT NULL UNIQUE, + model TEXT NOT NULL, + contentHash TEXT NOT NULL, + created INTEGER NOT NULL, + updated INTEGER NOT NULL + )`, + 'CREATE INDEX IF NOT EXISTS idx_embedding_meta_path ON embedding_metadata(notePath)', + 'CREATE INDEX IF NOT EXISTS idx_embedding_meta_hash ON embedding_metadata(contentHash)', + ] + }, + + // Version 18 -> 19: Drop remaining orphaned tables from the prior local-fixes fork. + // The abandoned C:\Users\middl\Documents\GitHub\nexus branch (local-fixes) ran migrations + // through v16, leaving behind two tables our fork never uses: + // - semantic_feedback: from the semantic panel / in-chat feedback UI (Plan 04/05) + // - block_embedding_metadata: metadata index for block-level embeddings (Nomic era) + // Neither table has any code references in this fork. Both are safe to drop. + // IF EXISTS ensures this is a no-op on fresh installs that never had these tables. + { + version: 19, + description: 'Drop orphaned semantic_feedback and block_embedding_metadata tables from prior local-fixes fork', + sql: [ + 'DROP TABLE IF EXISTS semantic_feedback', + 'DROP TABLE IF EXISTS block_embedding_metadata', + ] + }, ]; /** diff --git a/src/database/storage/JSONLWriter.ts b/src/database/storage/JSONLWriter.ts index 90531522b..82892c874 100644 --- a/src/database/storage/JSONLWriter.ts +++ b/src/database/storage/JSONLWriter.ts @@ -23,11 +23,14 @@ * - src/database/services/cache/EntityCache.ts - In-memory cache layer */ -import { App } from 'obsidian'; +import { App, FileSystemAdapter } from 'obsidian'; import { StorageEvent, BaseStorageEvent } from '../interfaces/StorageEvents'; import { v4 as uuidv4 } from '../../utils/uuid'; import { NamedLocks } from '../../utils/AsyncLock'; +/** Max file size (bytes) before readEvents falls back to streaming readline (default: 50 MB) */ +const READ_STREAM_THRESHOLD = 50 * 1024 * 1024; + /** * Configuration options for JSONLWriter */ @@ -298,12 +301,22 @@ export class JSONLWriter { try { const fullPath = `${this.basePath}/${relativePath}`; - // Use adapter.exists and adapter.read for hidden folder support (.nexus/) + // Use adapter.exists for hidden folder support (.nexus/) const exists = await this.app.vault.adapter.exists(fullPath); if (!exists) { return []; } + const stat = await this.app.vault.adapter.stat(fullPath); + const fileSize = stat?.size ?? 0; + + // For large files use Node.js readline streaming to avoid RangeError: Invalid string length + if (fileSize > READ_STREAM_THRESHOLD && this.app.vault.adapter instanceof FileSystemAdapter) { + return await this.readEventsStreaming( + this.app.vault.adapter.getBasePath() + '/' + fullPath + ); + } + const content = await this.app.vault.adapter.read(fullPath); const lines = content.split('\n').filter(line => line.trim()); @@ -324,6 +337,40 @@ export class JSONLWriter { } } + /** + * Read events from a large JSONL file using Node.js readline streaming. + * Avoids loading the entire file into a single string (V8 string length limit). + * + * @param absolutePath - Absolute filesystem path to the .jsonl file + */ + private readEventsStreaming(absolutePath: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const fs = require('fs') as typeof import('fs'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const readline = require('readline') as typeof import('readline'); + + return new Promise((resolve, reject) => { + const events: T[] = []; + const rl = readline.createInterface({ + input: fs.createReadStream(absolutePath, { encoding: 'utf8' }), + crlfDelay: Infinity, + }); + + rl.on('line', (line: string) => { + const trimmed = line.trim(); + if (!trimmed) return; + try { + events.push(JSON.parse(trimmed) as T); + } catch { + // skip malformed lines + } + }); + + rl.on('close', () => resolve(events)); + rl.on('error', reject); + }); + } + /** * Get events newer than a specific timestamp * diff --git a/src/database/storage/SQLiteCacheManager.ts b/src/database/storage/SQLiteCacheManager.ts index ec3802673..1c38c9c48 100644 --- a/src/database/storage/SQLiteCacheManager.ts +++ b/src/database/storage/SQLiteCacheManager.ts @@ -246,6 +246,9 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager await this.saveToFile(); // Save after migrations } + // Fix vec0 virtual table dimensions if needed (768→384 migration) + this.fixVec0TableDimensions(dbAdapter); + // Start auto-save timer if (this.autoSaveInterval > 0) { this.autoSaveTimer = setInterval(() => { @@ -748,6 +751,49 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager } } + // ==================== Schema fixes ==================== + + /** + * Fix vec0 virtual table dimensions if they were created with 768-dim (legacy Nomic era). + * vec0 tables cannot be ALTERed — must be dropped and recreated. + * Called after migrations run during initialize(). + */ + private fixVec0TableDimensions(dbAdapter: DatabaseAdapter): boolean { + const db = this.getDbOrThrow(); + let fixed = false; + + try { + const noteResult = dbAdapter.exec("SELECT sql FROM sqlite_master WHERE name='note_embeddings'"); + const noteSql = noteResult[0]?.values[0]?.[0] as string | undefined; + if (noteSql?.includes('float[768]')) { + db.exec('DROP TABLE IF EXISTS note_embeddings'); + db.exec('CREATE VIRTUAL TABLE IF NOT EXISTS note_embeddings USING vec0(embedding float[384])'); + db.exec('DELETE FROM embedding_metadata'); + fixed = true; + } + } catch (error) { + console.error('[SQLiteCacheManager] Failed to fix note_embeddings dimensions:', error); + } + + try { + const blockResult = dbAdapter.exec("SELECT sql FROM sqlite_master WHERE name='block_embeddings'"); + const blockSql = blockResult[0]?.values[0]?.[0] as string | undefined; + if (blockSql?.includes('float[768]')) { + db.exec('DROP TABLE IF EXISTS block_embeddings'); + db.exec('CREATE VIRTUAL TABLE IF NOT EXISTS block_embeddings USING vec0(embedding float[384])'); + fixed = true; + } + } catch (error) { + console.error('[SQLiteCacheManager] Failed to fix block_embeddings dimensions:', error); + } + + if (fixed) { + console.warn('[SQLiteCacheManager] Fixed vec0 embedding table dimensions (768→384)'); + } + + return fixed; + } + // ==================== Full-text search ==================== // Delegated to SQLiteSearchService for single responsibility diff --git a/src/services/WorkspaceService.ts b/src/services/WorkspaceService.ts index 15ef2e205..6f14ac866 100644 --- a/src/services/WorkspaceService.ts +++ b/src/services/WorkspaceService.ts @@ -17,9 +17,9 @@ import { normalizeWorkspaceData, normalizeWorkspaceContext } from './helpers/Wor import { WorkspaceSessionService } from './workspace/WorkspaceSessionService'; import { WorkspaceStateService } from './workspace/WorkspaceStateService'; -// Export constant for backward compatibility -export const GLOBAL_WORKSPACE_ID = 'default'; -const DEFAULT_WORKSPACE_NAME = 'Default Workspace'; +// Export constant for backward compatibility +export const GLOBAL_WORKSPACE_ID = 'default'; +const DEFAULT_WORKSPACE_NAME = 'Default Workspace'; export class WorkspaceService { private storageAdapterOrGetter: StorageAdapterOrGetter; @@ -34,16 +34,16 @@ export class WorkspaceService { ) { this.storageAdapterOrGetter = storageAdapter; - this.sessionService = new WorkspaceSessionService( - fileSystem, - indexManager, - storageAdapter, - { - getWorkspace: (id) => this.getWorkspace(id), - getWorkspaceByNameOrId: (identifier) => this.getWorkspaceByNameOrId(identifier), - createWorkspace: (data) => this.createWorkspace(data) - } - ); + this.sessionService = new WorkspaceSessionService( + fileSystem, + indexManager, + storageAdapter, + { + getWorkspace: (id) => this.getWorkspace(id), + getWorkspaceByNameOrId: (identifier) => this.getWorkspaceByNameOrId(identifier), + createWorkspace: (data) => this.createWorkspace(data) + } + ); this.stateService = new WorkspaceStateService( fileSystem, @@ -60,29 +60,29 @@ export class WorkspaceService { * Resolve the storage adapter if available and ready. * Delegates to shared DualBackendExecutor helper. */ - private getReadyAdapter(): IStorageAdapter | undefined { - return resolveAdapter(this.storageAdapterOrGetter); - } - - private isWorkspaceNameUniqueConstraint(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - return message.includes('UNIQUE constraint failed: workspaces.name'); - } - - private async reuseExistingWorkspaceAfterUniqueError( - data: Partial - ): Promise { - const existingById = data.id ? await this.getWorkspace(data.id) : null; - if (existingById) { - return existingById; - } - - if (!data.name) { - return null; - } - - return this.getWorkspaceByNameOrId(data.name); - } + private getReadyAdapter(): IStorageAdapter | undefined { + return resolveAdapter(this.storageAdapterOrGetter); + } + + private isWorkspaceNameUniqueConstraint(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.includes('UNIQUE constraint failed: workspaces.name'); + } + + private async reuseExistingWorkspaceAfterUniqueError( + data: Partial + ): Promise { + const existingById = data.id ? await this.getWorkspace(data.id) : null; + if (existingById) { + return existingById; + } + + if (!data.name) { + return null; + } + + return this.getWorkspaceByNameOrId(data.name); + } // ============================================================================ // Workspace CRUD (kept in this file — core responsibility) @@ -142,7 +142,7 @@ export class WorkspaceService { let comparison = 0; switch (sortBy) { case 'name': - comparison = a.name.localeCompare(b.name); + comparison = (a.name ?? '').localeCompare(b.name ?? ''); break; case 'created': comparison = a.created - b.created; @@ -263,51 +263,51 @@ export class WorkspaceService { dedicatedAgent: data.context.dedicatedAgent } : undefined; - const hybridData: Omit & { id?: string } = { - id: data.id, // Pass optional ID (e.g., 'default') - name: data.name || 'Untitled Workspace', + const hybridData: Omit & { id?: string } = { + id: data.id, // Pass optional ID (e.g., 'default') + name: data.name || 'Untitled Workspace', description: data.description, rootFolder: data.rootFolder || '/', created: data.created || Date.now(), lastAccessed: data.lastAccessed || Date.now(), isActive: data.isActive ?? true, isArchived: data.isArchived, - dedicatedAgentId: data.dedicatedAgentId, // Pass through dedicatedAgentId - context: hybridContext - }; - - try { - const id = await adapterForCreate.createWorkspace(hybridData); - - return { - id, - name: hybridData.name, - description: hybridData.description, - rootFolder: hybridData.rootFolder, - created: hybridData.created, - lastAccessed: hybridData.lastAccessed, - isActive: hybridData.isActive, - isArchived: hybridData.isArchived, - context: data.context, - sessions: {} - }; - } catch (error) { - const isDefaultWorkspace = - hybridData.id === GLOBAL_WORKSPACE_ID || hybridData.name === DEFAULT_WORKSPACE_NAME; - - if (isDefaultWorkspace && this.isWorkspaceNameUniqueConstraint(error)) { - const existingWorkspace = await this.reuseExistingWorkspaceAfterUniqueError(hybridData); - if (existingWorkspace) { - return existingWorkspace; - } - } - - throw error; - } - } + dedicatedAgentId: data.dedicatedAgentId, // Pass through dedicatedAgentId + context: hybridContext + }; + + try { + const id = await adapterForCreate.createWorkspace(hybridData); + + return { + id, + name: hybridData.name, + description: hybridData.description, + rootFolder: hybridData.rootFolder, + created: hybridData.created, + lastAccessed: hybridData.lastAccessed, + isActive: hybridData.isActive, + isArchived: hybridData.isArchived, + context: data.context, + sessions: {} + }; + } catch (error) { + const isDefaultWorkspace = + hybridData.id === GLOBAL_WORKSPACE_ID || hybridData.name === DEFAULT_WORKSPACE_NAME; + + if (isDefaultWorkspace && this.isWorkspaceNameUniqueConstraint(error)) { + const existingWorkspace = await this.reuseExistingWorkspaceAfterUniqueError(hybridData); + if (existingWorkspace) { + return existingWorkspace; + } + } + + throw error; + } + } // Fall back to legacy implementation - const id = data.id || `ws_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; + const id = data.id || `ws_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; const workspace: IndividualWorkspace = { id, @@ -582,7 +582,7 @@ export class WorkspaceService { pageSize: 100 }); const match = result.items.find( - ws => ws.name.toLowerCase() === identifier.toLowerCase() + ws => ws.name?.toLowerCase() === identifier.toLowerCase() ); return match?.id ?? null; }, @@ -590,7 +590,7 @@ export class WorkspaceService { const index = await this.indexManager.loadWorkspaceIndex(); const workspaces = Object.values(index.workspaces); const match = workspaces.find( - ws => ws.name.toLowerCase() === identifier.toLowerCase() + ws => ws.name?.toLowerCase() === identifier.toLowerCase() ); return match?.id ?? null; } diff --git a/src/services/chat/ChatService.ts b/src/services/chat/ChatService.ts index be72891a5..a451e387c 100644 --- a/src/services/chat/ChatService.ts +++ b/src/services/chat/ChatService.ts @@ -14,11 +14,11 @@ import { CostTrackingService } from './CostTrackingService'; import { ConversationQueryService } from './ConversationQueryService'; import { ConversationManager } from './ConversationManager'; import { StreamingResponseService } from './StreamingResponseService'; -import { ChatTraceService } from './ChatTraceService'; -import type { DirectToolExecutor } from './DirectToolExecutor'; -import type { PaginatedResult } from '../../types/pagination/PaginationTypes'; -import type { LLMService } from '../llm/core/LLMService'; -import type { JSONSchema } from '../../types/schema/JSONSchemaTypes'; +import { ChatTraceService } from './ChatTraceService'; +import type { DirectToolExecutor } from './DirectToolExecutor'; +import type { PaginatedResult } from '../../types/pagination/PaginationTypes'; +import type { LLMService } from '../llm/core/LLMService'; +import type { JSONSchema } from '../../types/schema/JSONSchemaTypes'; interface ConversationListItem { id: string; @@ -53,24 +53,24 @@ interface ChatMessageCreateParams { id?: string; } -interface ConversationRepositoryLike { - updateConversation(id: string, updates: Partial): Promise; -} - -interface MCPConnectorLike { - getAvailableTools?: () => Array<{ - type: 'function'; - function?: { - name: string; - description?: string; - parameters?: JSONSchema; - }; - name: string; - description?: string; - inputSchema?: JSONSchema; - }>; - executeTool: (name: string, args: Record) => Promise; -} +interface ConversationRepositoryLike { + updateConversation(id: string, updates: Partial): Promise; +} + +interface MCPConnectorLike { + getAvailableTools?: () => Array<{ + type: 'function'; + function?: { + name: string; + description?: string; + parameters?: JSONSchema; + }; + name: string; + description?: string; + inputSchema?: JSONSchema; + }>; + executeTool: (name: string, args: Record) => Promise; +} interface ConversationServiceDependency { getConversation: (id: string, pagination?: { page?: number; pageSize?: number }) => Promise; @@ -90,18 +90,18 @@ interface ConversationServiceDependency { vault_name?: string; message_count?: number; }>>; - addMessage: (params: { - conversationId: string; - role: string; + addMessage: (params: { + conversationId: string; + role: string; content: string; id?: string; toolCalls?: ToolCall[]; metadata?: Record; - }) => Promise; - updateConversation: (id: string, updates: Partial) => Promise; - updateConversationMetadata?: (conversationId: string, metadata: Record) => Promise; - createConversation: (data: unknown) => Promise; - deleteConversation: (id: string) => Promise; + }) => Promise; + updateConversation: (id: string, updates: Partial) => Promise; + updateConversationMetadata?: (conversationId: string, metadata: Record) => Promise; + createConversation: (data: unknown) => Promise; + deleteConversation: (id: string) => Promise; getMessages?: (conversationId: string, options?: { page?: number; pageSize?: number }) => Promise>; getRepository?: () => ConversationRepositoryLike; count?: () => Promise; @@ -457,15 +457,15 @@ export class ChatService { })); } - /** Get conversation repository for branch management */ - getConversationRepository(): ConversationRepositoryLike { - return this.conversationQueryService.getConversationRepository() as ConversationRepositoryLike; - } - - /** Get conversation service (alias for getConversationRepository) */ - getConversationService(): ConversationServiceDependency { - return this.dependencies.conversationService; - } + /** Get conversation repository for branch management */ + getConversationRepository(): ConversationRepositoryLike { + return this.conversationQueryService.getConversationRepository() as ConversationRepositoryLike; + } + + /** Get conversation service (alias for getConversationRepository) */ + getConversationService(): ConversationServiceDependency { + return this.dependencies.conversationService; + } /** * Check if any LLM providers are configured and available diff --git a/src/services/chat/StreamingResponseService.ts b/src/services/chat/StreamingResponseService.ts index 50109cbe3..b92d178a7 100644 --- a/src/services/chat/StreamingResponseService.ts +++ b/src/services/chat/StreamingResponseService.ts @@ -18,12 +18,12 @@ * Follows Single Responsibility Principle - only handles streaming coordination. */ -import { ConversationData, ConversationMessage, MessageCost, MessageUsage, ToolCall } from '../../types/chat/ChatTypes'; -import { ConversationContextBuilder } from './ConversationContextBuilder'; -import { ToolCallService } from './ToolCallService'; -import { CostTrackingService } from './CostTrackingService'; -import type { MessageQueueService } from './MessageQueueService'; -import { ContextBudgetService, type NormalizedTokenUsage } from './ContextBudgetService'; +import { ConversationData, ConversationMessage, MessageCost, MessageUsage, ToolCall } from '../../types/chat/ChatTypes'; +import { ConversationContextBuilder } from './ConversationContextBuilder'; +import { ToolCallService } from './ToolCallService'; +import { CostTrackingService } from './CostTrackingService'; +import type { MessageQueueService } from './MessageQueueService'; +import { ContextBudgetService, type NormalizedTokenUsage } from './ContextBudgetService'; export interface StreamingOptions { provider?: string; @@ -39,58 +39,58 @@ export interface StreamingOptions { temperature?: number; // 0.0-1.0, controls randomness } -export interface StreamingChunk { - chunk: string; - complete: boolean; - messageId: string; - toolCalls?: StreamingToolCall[]; - // Reasoning/thinking support (Claude, GPT-5, Gemini, etc.) - reasoning?: string; // Incremental reasoning text - reasoningComplete?: boolean; // True when reasoning finished - // Token usage (available on complete chunk) - usage?: MessageUsage; -} - -interface StreamingToolCall extends ToolCall { - arguments?: string; - function: { - name: string; - arguments: string; - }; -} - -interface LLMDefaultModel { - provider: string; - model: string; -} - -interface LLMChunkLike { - chunk: string; - complete: boolean; - toolCalls?: StreamingToolCall[]; - toolCallsReady?: boolean; - reasoning?: string; - reasoningComplete?: boolean; - usage?: unknown; -} - -interface LLMServiceLike { - getDefaultModel(): LLMDefaultModel; - generateResponseStream(messages: Array<{ role: string; content: string }>, options: Record): AsyncGenerator; -} - -interface ConversationServiceLike { - getConversation(conversationId: string): Promise; - addMessage(params: { conversationId: string; role: string; content: string; id: string }): Promise; - updateConversation(conversationId: string, update: { messages?: ConversationMessage[]; metadata?: ConversationData['metadata'] }): Promise; -} - -export interface StreamingDependencies { - llmService: LLMServiceLike; - conversationService: ConversationServiceLike; - toolCallService: ToolCallService; - costTrackingService: CostTrackingService; - messageQueueService?: MessageQueueService; // Optional: for subagent result queueing +export interface StreamingChunk { + chunk: string; + complete: boolean; + messageId: string; + toolCalls?: StreamingToolCall[]; + // Reasoning/thinking support (Claude, GPT-5, Gemini, etc.) + reasoning?: string; // Incremental reasoning text + reasoningComplete?: boolean; // True when reasoning finished + // Token usage (available on complete chunk) + usage?: MessageUsage; +} + +interface StreamingToolCall extends ToolCall { + arguments?: string; + function: { + name: string; + arguments: string; + }; +} + +interface LLMDefaultModel { + provider: string; + model: string; +} + +interface LLMChunkLike { + chunk: string; + complete: boolean; + toolCalls?: StreamingToolCall[]; + toolCallsReady?: boolean; + reasoning?: string; + reasoningComplete?: boolean; + usage?: unknown; +} + +interface LLMServiceLike { + getDefaultModel(): LLMDefaultModel; + generateResponseStream(messages: Array<{ role: string; content: string }>, options: Record): AsyncGenerator; +} + +interface ConversationServiceLike { + getConversation(conversationId: string): Promise; + addMessage(params: { conversationId: string; role: string; content: string; id: string }): Promise; + updateConversation(conversationId: string, update: { messages?: ConversationMessage[]; metadata?: ConversationData['metadata'] }): Promise; +} + +export interface StreamingDependencies { + llmService: LLMServiceLike; + conversationService: ConversationServiceLike; + toolCallService: ToolCallService; + costTrackingService: CostTrackingService; + messageQueueService?: MessageQueueService; // Optional: for subagent result queueing } export class StreamingResponseService { @@ -111,7 +111,7 @@ export class StreamingResponseService { options?: StreamingOptions ): AsyncGenerator { // Notify queue service that generation is starting (pauses processing) - void this.dependencies.messageQueueService?.onGenerationStart?.(); + void this.dependencies.messageQueueService?.onGenerationStart?.(); try { const messageId = options?.messageId || `msg_${Date.now()}_ai`; @@ -122,7 +122,7 @@ export class StreamingResponseService { // Check if message already exists (retry case) const existingConv = await this.dependencies.conversationService.getConversation(conversationId); - const messageExists = existingConv?.messages.some((m) => m.id === messageId); + const messageExists = existingConv?.messages.some((m) => m.id === messageId); // Only create placeholder if message doesn't exist (prevents duplicate during retry) if (!messageExists) { @@ -144,7 +144,7 @@ export class StreamingResponseService { // Filter conversation for retry: exclude message being retried and everything after let filteredConversation = conversation; if (conversation && options?.excludeFromMessageId) { - const excludeIndex = conversation.messages.findIndex((m) => m.id === options.excludeFromMessageId); + const excludeIndex = conversation.messages.findIndex((m) => m.id === options.excludeFromMessageId); if (excludeIndex >= 0) { filteredConversation = { ...conversation, @@ -166,9 +166,9 @@ export class StreamingResponseService { // Only add user message if it's NOT already in the filtered conversation // (happens on first message when conversation is empty, or during retry) - if (!filteredConversation || !filteredConversation.messages.some((m) => m.content === userMessage && m.role === 'user')) { - messages.push({ role: 'user', content: userMessage }); - } + if (!filteredConversation || !filteredConversation.messages.some((m) => m.content === userMessage && m.role === 'user')) { + messages.push({ role: 'user', content: userMessage }); + } // Get tools from ToolCallService in OpenAI format // NOTE: WebLLM/Nexus models are fine-tuned with tool knowledge baked in @@ -179,7 +179,7 @@ export class StreamingResponseService { // Prepare LLM options with converted tools // NOTE: systemPrompt is already in the messages array from buildLLMMessages() // Do NOT pass it again here - this caused duplicate system prompts - const llmOptions: Record = { + const llmOptions: Record = { provider: options?.provider || defaultModel.provider, model: options?.model || defaultModel.model, // systemPrompt intentionally omitted - already in messages array @@ -196,10 +196,10 @@ export class StreamingResponseService { responsesApiId: filteredConversation?.metadata?.responsesApiId }; - // Add tool event callback for live UI updates (delegates to ToolCallService) - llmOptions.onToolEvent = (event: 'started' | 'completed', data: unknown) => { - this.dependencies.toolCallService.fireToolEvent(messageId, event, data as Parameters[2]); - }; + // Add tool event callback for live UI updates (delegates to ToolCallService) + llmOptions.onToolEvent = (event: 'started' | 'completed', data: unknown) => { + this.dependencies.toolCallService.fireToolEvent(messageId, event, data as Parameters[2]); + }; // Add usage callback for async cost calculation (e.g., OpenRouter streaming) llmOptions.onUsageAvailable = this.dependencies.costTrackingService.createUsageCallback(conversationId, messageId); @@ -219,13 +219,13 @@ export class StreamingResponseService { }; // Stream the response from LLM service with MCP tools - let toolCalls: StreamingToolCall[] | undefined = undefined; - this.dependencies.toolCallService.resetDetectedTools(); // Reset tool detection state for new message - - // Track usage and cost for conversation tracking - let finalUsage: NormalizedTokenUsage | undefined = undefined; - let finalCost: MessageCost | undefined = undefined; - const selectedModel = typeof llmOptions.model === 'string' ? llmOptions.model : defaultModel.model; + let toolCalls: StreamingToolCall[] | undefined = undefined; + this.dependencies.toolCallService.resetDetectedTools(); // Reset tool detection state for new message + + // Track usage and cost for conversation tracking + let finalUsage: NormalizedTokenUsage | undefined = undefined; + let finalCost: MessageCost | undefined = undefined; + const selectedModel = typeof llmOptions.model === 'string' ? llmOptions.model : defaultModel.model; for await (const chunk of this.dependencies.llmService.generateResponseStream(messages, llmOptions)) { // Check if aborted FIRST before processing chunk @@ -250,10 +250,10 @@ export class StreamingResponseService { // Handle progressive tool call detection (fires 'detected' and 'updated' events) if (toolCalls) { // Only emit once we have non-empty argument content to reduce duplicate spam - const hasMeaningfulArgs = toolCalls.some((tc) => { - const args = tc.function?.arguments || tc.arguments || ''; - return typeof args === 'string' ? args.trim().length > 0 : true; - }); + const hasMeaningfulArgs = toolCalls.some((tc) => { + const args = tc.function?.arguments || tc.arguments || ''; + return typeof args === 'string' ? args.trim().length > 0 : true; + }); if (hasMeaningfulArgs) { this.dependencies.toolCallService.handleToolCallDetection( messageId, @@ -269,22 +269,22 @@ export class StreamingResponseService { if (chunk.complete) { // Calculate cost from final usage using CostTrackingService if (finalUsage) { - const usageData = this.dependencies.costTrackingService.extractUsage(finalUsage); - if (usageData) { - finalCost = await this.dependencies.costTrackingService.trackMessageUsage( - conversationId, - messageId, - provider, - selectedModel, - usageData - ) ?? undefined; - } - } + const usageData = this.dependencies.costTrackingService.extractUsage(finalUsage); + if (usageData) { + finalCost = await this.dependencies.costTrackingService.trackMessageUsage( + conversationId, + messageId, + provider, + selectedModel, + usageData + ) ?? undefined; + } + } // Update the placeholder message with final content const conv = await this.dependencies.conversationService.getConversation(conversationId); if (conv) { - const msg = conv.messages.find((m) => m.id === messageId); + const msg = conv.messages.find((m) => m.id === messageId); if (msg) { // Update existing placeholder message msg.content = accumulatedContent; @@ -298,12 +298,12 @@ export class StreamingResponseService { if (finalCost) { msg.cost = finalCost; } - if (finalUsage) { - msg.usage = finalUsage; - } - - msg.provider = provider; - msg.model = selectedModel; + if (finalUsage) { + msg.usage = finalUsage; + } + + msg.provider = provider; + msg.model = selectedModel; // Save updated conversation await this.dependencies.conversationService.updateConversation(conversationId, { @@ -329,8 +329,8 @@ export class StreamingResponseService { reasoning: chunk.reasoning, reasoningComplete: chunk.reasoningComplete, // Pass through usage for context tracking - usage: chunk.complete ? finalUsage : undefined - }; + usage: chunk.complete ? finalUsage : undefined + }; if (chunk.complete) { break; @@ -338,15 +338,15 @@ export class StreamingResponseService { } } catch (error) { - const response = error instanceof Error && 'response' in error - ? (error as Error & { response?: { data?: unknown; json?: unknown; text?: unknown } }).response - : undefined; - const extra = response?.data ?? response?.json ?? response?.text; - console.error('Error in generateResponse:', error, extra ? JSON.stringify(extra) : ''); - throw error; + const response = error instanceof Error && 'response' in error + ? (error as Error & { response?: { data?: unknown; json?: unknown; text?: unknown } }).response + : undefined; + const extra = response?.data ?? response?.json ?? response?.text; + console.error('Error in generateResponse:', error, extra ? JSON.stringify(extra) : ''); + throw error; } finally { // Notify queue service that generation is complete (resumes processing) - void this.dependencies.messageQueueService?.onGenerationComplete?.(); + void this.dependencies.messageQueueService?.onGenerationComplete?.(); } } @@ -359,12 +359,12 @@ export class StreamingResponseService { * NOTE: For Google, we return simple {role, content} format because * StreamingOrchestrator will convert to Google format ({role, parts}) */ - private buildLLMMessages(conversation: ConversationData, provider?: string, systemPrompt?: string): Array<{ role: string; content: string }> { - const currentProvider = provider || this.getCurrentProvider(); + private buildLLMMessages(conversation: ConversationData, provider?: string, systemPrompt?: string): Array<{ role: string; content: string }> { + const currentProvider = provider || this.getCurrentProvider(); // For Google, return simple format - StreamingOrchestrator handles Google conversion if (currentProvider === 'google') { - const messages: Array<{ role: string; content: string }> = []; + const messages: Array<{ role: string; content: string }> = []; // Add system prompt if provided if (systemPrompt) { @@ -381,18 +381,18 @@ export class StreamingResponseService { } return messages; - } - - // For other providers, use ConversationContextBuilder - return ConversationContextBuilder.buildContextForProvider( - conversation, - currentProvider, - systemPrompt - ).map((message) => ({ - role: message.role, - content: 'content' in message && typeof message.content === 'string' ? message.content : '' - })); - } + } + + // For other providers, use ConversationContextBuilder + return ConversationContextBuilder.buildContextForProvider( + conversation, + currentProvider, + systemPrompt + ).map((message) => ({ + role: message.role, + content: 'content' in message && typeof message.content === 'string' ? message.content : '' + })); + } /** * Get current provider for context building diff --git a/src/services/llm/adapters/shared/ProviderHttpClient.ts b/src/services/llm/adapters/shared/ProviderHttpClient.ts index 84989a089..69faf3f2b 100644 --- a/src/services/llm/adapters/shared/ProviderHttpClient.ts +++ b/src/services/llm/adapters/shared/ProviderHttpClient.ts @@ -187,10 +187,13 @@ export class ProviderHttpClient { const parsed = new URL(config.url); const isHttps = parsed.protocol === 'https:'; - // Dynamically import Node.js modules (available in Electron) - const nodeModule = isHttps - ? await import('node:https') - : await import('node:http'); + // Use require() for Node.js built-ins — dynamic import() fails in Electron renderer + // due to CORS policy blocking the node: protocol scheme. + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment + const httpsReq: typeof import('node:https') = require('https'); + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment + const httpReq: typeof import('node:http') = require('http'); + const nodeModule = isHttps ? httpsReq : httpReq; const timeoutMs = config.timeoutMs ?? 120_000; @@ -203,7 +206,14 @@ export class ProviderHttpClient { headers: config.headers ?? {}, }; + // Keep a reference to res so the timeout handler can destroy it too. + // Without this, req.destroy() silently closes the socket without emitting + // an error on res, so the for-await loop in processNodeStream sees a clean + // end rather than a thrown error — causing silent truncation. + let resRef: import('http').IncomingMessage | null = null; + const req = nodeModule.request(requestOptions, (res) => { + resRef = res; const status = res.statusCode ?? 0; if (status < 200 || status >= 300) { @@ -232,9 +242,14 @@ export class ProviderHttpClient { resolve(res); }); - // Timeout handling + // Timeout handling — fires when socket is idle (no bytes received) for timeoutMs ms. + // We must destroy BOTH req and res: req.destroy() kills the socket but does NOT + // emit an error on res (IncomingMessage), so the streaming for-await loop would + // see a clean end and silently save partial content without any console output. req.setTimeout(timeoutMs, () => { - req.destroy(new Error(`Stream request timeout after ${timeoutMs}ms`)); + const err = new Error(`Stream request timeout after ${timeoutMs}ms (${config.provider || 'unknown'})`); + req.destroy(err); + resRef?.destroy(err); }); req.on('error', (err) => { diff --git a/src/services/llm/core/StreamingOrchestrator.ts b/src/services/llm/core/StreamingOrchestrator.ts index 6ded0edfa..0819df4ef 100644 --- a/src/services/llm/core/StreamingOrchestrator.ts +++ b/src/services/llm/core/StreamingOrchestrator.ts @@ -1,46 +1,46 @@ -/** - * StreamingOrchestrator - Manages streaming LLM responses with tool execution - * - * Orchestrates the complete streaming lifecycle by coordinating: - * - ProviderMessageBuilder: Provider-specific message formatting - * - ToolContinuationService: Tool execution and pingpong loop - * - TerminalToolHandler: Detection of tools that stop the loop - * - * Follows Single Responsibility Principle - only handles stream coordination. - */ - -import { IToolExecutor } from '../adapters/shared/ToolExecutionUtils'; -import { LLMProviderSettings } from '../../../types'; -import { IAdapterRegistry } from './AdapterRegistry'; +/** + * StreamingOrchestrator - Manages streaming LLM responses with tool execution + * + * Orchestrates the complete streaming lifecycle by coordinating: + * - ProviderMessageBuilder: Provider-specific message formatting + * - ToolContinuationService: Tool execution and pingpong loop + * - TerminalToolHandler: Detection of tools that stop the loop + * + * Follows Single Responsibility Principle - only handles stream coordination. + */ + +import { IToolExecutor } from '../adapters/shared/ToolExecutionUtils'; +import { LLMProviderSettings } from '../../../types'; +import { IAdapterRegistry } from './AdapterRegistry'; import { TokenUsage, LLMProviderError, GenerateOptions } from '../adapters/types'; -import { Notice } from 'obsidian'; -import { ToolCall as ChatToolCall } from '../../../types/chat/ChatTypes'; -import { +import { Notice } from 'obsidian'; +import { ToolCall as ChatToolCall } from '../../../types/chat/ChatTypes'; +import { ProviderMessageBuilder, ConversationMessage, GenerateOptionsInternal, StreamingOptions, GoogleMessage } from './ProviderMessageBuilder'; -import { ToolContinuationService, StreamYield } from './ToolContinuationService'; - -// Re-export types for backward compatibility -export type { ConversationMessage, GoogleMessage, StreamingOptions, StreamYield }; - +import { ToolContinuationService, StreamYield } from './ToolContinuationService'; + +// Re-export types for backward compatibility +export type { ConversationMessage, GoogleMessage, StreamingOptions, StreamYield }; + export class StreamingOrchestrator { - // Track OpenAI response IDs for stateful continuations - private conversationResponseIds: Map = new Map(); - - // Delegate services - private messageBuilder: ProviderMessageBuilder; - private toolContinuation: ToolContinuationService; - + // Track OpenAI response IDs for stateful continuations + private conversationResponseIds: Map = new Map(); + + // Delegate services + private messageBuilder: ProviderMessageBuilder; + private toolContinuation: ToolContinuationService; + constructor( - private adapterRegistry: IAdapterRegistry, - private settings: LLMProviderSettings, - toolExecutor?: IToolExecutor - ) { - this.messageBuilder = new ProviderMessageBuilder(this.conversationResponseIds); + private adapterRegistry: IAdapterRegistry, + private settings: LLMProviderSettings, + toolExecutor?: IToolExecutor + ) { + this.messageBuilder = new ProviderMessageBuilder(this.conversationResponseIds); this.toolContinuation = new ToolContinuationService(toolExecutor, this.messageBuilder); } @@ -79,13 +79,13 @@ export class StreamingOrchestrator { conversationHistory: options.conversationHistory as Array> | undefined }; } - - /** - * Primary method: orchestrate streaming response with tool execution - * @param messages - Conversation message history - * @param options - Streaming configuration - * @returns AsyncGenerator yielding chunks and tool calls - */ + + /** + * Primary method: orchestrate streaming response with tool execution + * @param messages - Conversation message history + * @param options - Streaming configuration + * @returns AsyncGenerator yielding chunks and tool calls + */ async* generateResponseStream( messages: ConversationMessage[], options?: StreamingOptions @@ -131,62 +131,62 @@ export class StreamingOrchestrator { // These may be swapped to a fallback on Codex rate limit (429). let activeAdapter = adapter; let activeProvider = provider; - - try { + + try { const adapterGenerateOptions = this.createAdapterGenerateOptions(generateOptions); for await (const chunk of activeAdapter.generateStreamAsync(promptToPass, adapterGenerateOptions)) { - // Track usage from chunks - if (chunk.usage) { - finalUsage = chunk.usage; - } - - // Handle text content streaming - if (chunk.content) { - fullContent += chunk.content; - - yield { - chunk: chunk.content, - complete: false, - content: fullContent, - toolCalls: undefined - }; - } - - // Handle reasoning/thinking content (Claude, GPT-5, Gemini) - if (chunk.reasoning) { - yield { - chunk: '', - complete: false, - content: fullContent, - toolCalls: undefined, - reasoning: chunk.reasoning, - reasoningComplete: chunk.reasoningComplete - }; - } - - // Handle dynamic tool call detection - if (chunk.toolCalls) { - const chatToolCalls: ChatToolCall[] = chunk.toolCalls.map(tc => ({ - ...tc, - type: tc.type || 'function', - function: tc.function || { name: '', arguments: '{}' } - })); - - // ALWAYS yield tool calls for progressive UI display - yield { - chunk: '', - complete: false, - content: fullContent, - toolCalls: chatToolCalls, - toolCallsReady: chunk.complete || false - }; - - // Only STORE tool calls for execution when streaming is COMPLETE - if (chunk.complete) { - detectedToolCalls = chatToolCalls; - } - } - + // Track usage from chunks + if (chunk.usage) { + finalUsage = chunk.usage; + } + + // Handle text content streaming + if (chunk.content) { + fullContent += chunk.content; + + yield { + chunk: chunk.content, + complete: false, + content: fullContent, + toolCalls: undefined + }; + } + + // Handle reasoning/thinking content (Claude, GPT-5, Gemini) + if (chunk.reasoning) { + yield { + chunk: '', + complete: false, + content: fullContent, + toolCalls: undefined, + reasoning: chunk.reasoning, + reasoningComplete: chunk.reasoningComplete + }; + } + + // Handle dynamic tool call detection + if (chunk.toolCalls) { + const chatToolCalls: ChatToolCall[] = chunk.toolCalls.map(tc => ({ + ...tc, + type: tc.type || 'function', + function: tc.function || { name: '', arguments: '{}' } + })); + + // ALWAYS yield tool calls for progressive UI display + yield { + chunk: '', + complete: false, + content: fullContent, + toolCalls: chatToolCalls, + toolCallsReady: chunk.complete || false + }; + + // Only STORE tool calls for execution when streaming is COMPLETE + if (chunk.complete) { + detectedToolCalls = chatToolCalls; + } + } + if (chunk.complete) { this.persistLatestResponseId(activeProvider, chunk, options); break; @@ -270,15 +270,15 @@ export class StreamingOrchestrator { throw error; } } - - // If no tool calls detected, we're done + + // If no tool calls detected, we're done if (detectedToolCalls.length === 0 || !generateOptions.tools || generateOptions.tools.length === 0) { yield { chunk: '', complete: true, content: fullContent, - toolCalls: undefined, - usage: finalUsage + toolCalls: undefined, + usage: finalUsage }; return; } diff --git a/src/settings/tabs/ProvidersTab.ts b/src/settings/tabs/ProvidersTab.ts index 1b6edb0e9..b93084295 100644 --- a/src/settings/tabs/ProvidersTab.ts +++ b/src/settings/tabs/ProvidersTab.ts @@ -113,35 +113,35 @@ export class ProvidersTab { signupUrl: 'https://github.com/google-gemini/gemini-cli', category: 'cloud' }, - mistral: { - name: 'Mistral AI', - keyFormat: 'msak_...', - signupUrl: 'https://console.mistral.ai/api-keys', - category: 'cloud' - }, - groq: { - name: 'Groq', - keyFormat: 'gsk_...', - signupUrl: 'https://console.groq.com/keys', - category: 'cloud' - }, - deepgram: { - name: 'Deepgram', - keyFormat: 'dg_...', - signupUrl: 'https://console.deepgram.com/project/api-keys', - category: 'cloud' - }, - assemblyai: { - name: 'AssemblyAI', - keyFormat: '...API key...', - signupUrl: 'https://www.assemblyai.com/dashboard/api-keys', - category: 'cloud' - }, - openrouter: { - name: 'OpenRouter', - keyFormat: 'sk-or-...', - signupUrl: 'https://openrouter.ai/keys', - category: 'cloud' + mistral: { + name: 'Mistral AI', + keyFormat: 'msak_...', + signupUrl: 'https://console.mistral.ai/api-keys', + category: 'cloud' + }, + groq: { + name: 'Groq', + keyFormat: 'gsk_...', + signupUrl: 'https://console.groq.com/keys', + category: 'cloud' + }, + deepgram: { + name: 'Deepgram', + keyFormat: 'dg_...', + signupUrl: 'https://console.deepgram.com/project/api-keys', + category: 'cloud' + }, + assemblyai: { + name: 'AssemblyAI', + keyFormat: '...API key...', + signupUrl: 'https://www.assemblyai.com/dashboard/api-keys', + category: 'cloud' + }, + openrouter: { + name: 'OpenRouter', + keyFormat: 'sk-or-...', + signupUrl: 'https://openrouter.ai/keys', + category: 'cloud' }, requesty: { name: 'Requesty', @@ -392,28 +392,28 @@ export class ProvidersTab { if (!isDesktop()) { this.container.createEl('p', { cls: 'setting-item-description', - text: 'On mobile, only fetch-based providers are supported. Configure local providers and SDK-based providers on desktop.' - }); + text: 'On mobile, only fetch-based providers are supported. Configure local providers and SDK-based providers on desktop.' + }); const items = [...MOBILE_COMPATIBLE_PROVIDERS] .map(id => this.buildProviderCardItem(id, settings)) .filter((item): item is ProviderCardItem => item !== null); new SearchableCardManager({ - containerEl: this.container, - cardManagerConfig: { - title: 'Mobile Providers', - emptyStateText: 'No providers available.', - showToggle: true, - onToggle: async (item, enabled) => { - if (item.comingSoon) return; - settings.providers[item.providerId] = { - ...(settings.providers[item.providerId] || { apiKey: '' }), - enabled - }; - await this.saveSettings(); - this.render(); - }, + containerEl: this.container, + cardManagerConfig: { + title: 'Mobile Providers', + emptyStateText: 'No providers available.', + showToggle: true, + onToggle: async (item, enabled) => { + if (item.comingSoon) return; + settings.providers[item.providerId] = { + ...(settings.providers[item.providerId] || { apiKey: '' }), + enabled + }; + await this.saveSettings(); + this.render(); + }, onEdit: (item) => { if (item.comingSoon) return; const displayConfig = this.providerConfigs[item.providerId]; @@ -442,7 +442,7 @@ export class ProvidersTab { groups.push({ title: 'LOCAL PROVIDERS', items: localItems }); } - const cloudIds = ['openai', 'anthropic', 'google', 'mistral', 'groq', 'deepgram', 'assemblyai', 'openrouter', 'requesty', 'perplexity', 'github-copilot']; + const cloudIds = ['openai', 'anthropic', 'google', 'mistral', 'groq', 'deepgram', 'assemblyai', 'openrouter', 'requesty', 'perplexity', 'github-copilot']; const cloudItems = cloudIds .map(id => this.buildProviderCardItem(id, settings)) .filter((item): item is ProviderCardItem => item !== null); @@ -451,19 +451,19 @@ export class ProvidersTab { new SearchableCardManager({ containerEl: this.container, - cardManagerConfig: { - title: 'Providers', - emptyStateText: 'No providers available.', - showToggle: true, - onToggle: async (item, enabled) => { - if (item.comingSoon) return; - settings.providers[item.providerId] = { - ...(settings.providers[item.providerId] || { apiKey: '' }), - enabled - }; - await this.saveSettings(); - this.render(); - }, + cardManagerConfig: { + title: 'Providers', + emptyStateText: 'No providers available.', + showToggle: true, + onToggle: async (item, enabled) => { + if (item.comingSoon) return; + settings.providers[item.providerId] = { + ...(settings.providers[item.providerId] || { apiKey: '' }), + enabled + }; + await this.saveSettings(); + this.render(); + }, onEdit: (item) => { if (item.comingSoon) return; const displayConfig = this.providerConfigs[item.providerId]; @@ -519,19 +519,19 @@ export class ProvidersTab { description: 'Connect your ChatGPT Plus/Pro account to use GPT-5 models via OAuth.', config: { ...codexConfig }, oauthConfig: codexDisplay.oauthConfig, - onConfigChange: (updatedCodexConfig: LLMProviderConfig) => { - void (async () => { - settings.providers['openai-codex'] = updatedCodexConfig; - await this.saveSettings(); - })(); - }, + onConfigChange: (updatedCodexConfig: LLMProviderConfig) => { + void (async () => { + settings.providers['openai-codex'] = updatedCodexConfig; + await this.saveSettings(); + })(); + }, }; } - } else if (providerId === 'anthropic') { - const claudeCodeConfig = settings.providers['anthropic-claude-code'] || { - apiKey: '', - enabled: false, - }; + } else if (providerId === 'anthropic') { + const claudeCodeConfig = settings.providers['anthropic-claude-code'] || { + apiKey: '', + enabled: false, + }; secondaryOAuthProvider = { providerId: 'anthropic-claude-code', @@ -542,12 +542,12 @@ export class ProvidersTab { providerLabel: 'Claude Code', startFlow: () => this.startClaudeCodeConnectFlow(), }, - onConfigChange: (updatedClaudeCodeConfig: LLMProviderConfig) => { - void (async () => { - settings.providers['anthropic-claude-code'] = updatedClaudeCodeConfig; - await this.saveSettings(); - })(); - }, + onConfigChange: (updatedClaudeCodeConfig: LLMProviderConfig) => { + void (async () => { + settings.providers['anthropic-claude-code'] = updatedClaudeCodeConfig; + await this.saveSettings(); + })(); + }, statusOnly: true, statusHint: 'run `claude auth login` in your terminal', }; @@ -566,12 +566,12 @@ export class ProvidersTab { providerLabel: 'Gemini CLI', startFlow: () => this.startGeminiCliConnectFlow(), }, - onConfigChange: (updatedGeminiCliConfig: LLMProviderConfig) => { - void (async () => { - settings.providers['google-gemini-cli'] = updatedGeminiCliConfig; - await this.saveSettings(); - })(); - }, + onConfigChange: (updatedGeminiCliConfig: LLMProviderConfig) => { + void (async () => { + settings.providers['google-gemini-cli'] = updatedGeminiCliConfig; + await this.saveSettings(); + })(); + }, statusOnly: true, statusHint: 'run `gemini auth` in your terminal', }; @@ -586,9 +586,8 @@ export class ProvidersTab { oauthConfig: displayConfig.oauthConfig, secondaryOAuthProvider, oauthOnly: providerId === 'github-copilot', - onSave: (updatedConfig: LLMProviderConfig) => { - void (async () => { - settings.providers[providerId] = updatedConfig; + onSave: async (updatedConfig: LLMProviderConfig) => { + settings.providers[providerId] = updatedConfig; // Handle Ollama model update if (providerId === 'ollama' && '__ollamaModel' in updatedConfig) { @@ -601,12 +600,11 @@ export class ProvidersTab { } } - await this.saveSettings(); - this.render(); // Refresh the view - new Notice(`${displayConfig.name} settings saved`); - })(); - } - }; + await this.saveSettings(); + this.render(); // Refresh the view + new Notice(`${displayConfig.name} settings saved`); + } + }; new LLMProviderModal(this.services.app, modalConfig, this.providerManager).open(); } diff --git a/src/ui/chat/ChatView.ts b/src/ui/chat/ChatView.ts index 450fd6907..3192e85c4 100644 --- a/src/ui/chat/ChatView.ts +++ b/src/ui/chat/ChatView.ts @@ -295,7 +295,7 @@ export class ChatView extends ItemView { // Notify Nexus lifecycle manager that ChatView is open // Pass current provider so it can pre-load if Nexus is selected - const currentProvider = (await this.modelAgentManager.getMessageOptions()).provider; + const currentProvider = this.modelAgentManager.getSelectedModel()?.providerId; lifecycleManager.handleChatViewOpened(currentProvider).catch((error) => { console.error('[ChatView] handleChatViewOpened failed:', error); }); @@ -344,6 +344,15 @@ export class ChatView extends ItemView { this.initializeSubagentInfrastructure().catch((error) => { console.error('[ChatView] Failed to initialize subagent infrastructure:', error); }); + + // Refresh context progress bar when this view regains focus + this.registerEvent( + this.app.workspace.on('active-leaf-change', (leaf) => { + if (leaf === this.leaf) { + void this.updateContextProgress(); + } + }) + ); } /** @@ -676,6 +685,9 @@ export class ChatView extends ItemView { 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); } @@ -802,6 +814,9 @@ export class ChatView extends ItemView { 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); } @@ -1149,22 +1164,17 @@ export class ChatView extends ItemView { } } - private async handleBranchSwitched(messageId: string, branchId: string): Promise { - const currentConversation = this.conversationManager.getCurrentConversation(); - if (currentConversation) { - const success = await this.branchManager.switchToBranch( - currentConversation, - messageId, - branchId - ); - - if (success) { - const updatedMessage = currentConversation.messages.find(msg => msg.id === messageId); - if (updatedMessage) { - this.messageDisplay.updateMessage(messageId, updatedMessage); - } - } - } + private handleBranchSwitched(_messageId: string, _branchId: string): void { + // Intentional no-op. + // + // This event fires AFTER BranchManager completes a switch. The caller + // (handleBranchSwitchedByIndex) already calls messageDisplay.updateMessage() + // on success, so there is nothing left to do here. + // + // Doing anything here causes a double updateMessage call that races with + // the first async renderContent, producing truncated / corrupted output. + // Calling switchToBranch here would re-enter the switch, and fails hard + // when branchId is the sentinel 'original' (back-to-original navigation). } /** diff --git a/src/ui/chat/builders/ChatLayoutBuilder.ts b/src/ui/chat/builders/ChatLayoutBuilder.ts index 5e1d51389..7d87ea8ec 100644 --- a/src/ui/chat/builders/ChatLayoutBuilder.ts +++ b/src/ui/chat/builders/ChatLayoutBuilder.ts @@ -7,7 +7,6 @@ * - Building header with hamburger, title, and settings buttons * - Creating message display, input, and context containers * - Building sidebar with conversation list - * - Auto-hiding experimental warning banner * * Used by ChatView to build the initial DOM structure, * following the Builder pattern for complex UI construction. @@ -42,9 +41,6 @@ export class ChatLayoutBuilder { const chatLayout = container.createDiv('chat-layout'); const mainContainer = chatLayout.createDiv('chat-main'); - // Experimental warning banner - this.createWarningBanner(mainContainer); - // Header const { chatTitle, hamburgerButton, settingsButton } = this.createHeader(mainContainer); @@ -122,29 +118,6 @@ export class ChatLayoutBuilder { return overlay; } - /** - * Create experimental warning banner with auto-hide - */ - private static createWarningBanner(container: HTMLElement): void { - const warningBanner = container.createDiv('chat-experimental-warning'); - - warningBanner.createEl('span', { cls: 'warning-icon', text: '⚠️' }); - warningBanner.createEl('span', { cls: 'warning-text', text: 'This chat is in beta.' }); - const link = warningBanner.createEl('a', { cls: 'warning-link', text: 'Report issues' }); - link.href = 'https://github.com/ProfSynapse/nexus/issues'; - link.target = '_blank'; - link.rel = 'noopener noreferrer'; - warningBanner.createEl('span', { cls: 'warning-text', text: 'Use at your own risk.' }); - - // Auto-hide warning after 5 seconds - setTimeout(() => { - warningBanner.addClass('chat-warning-banner-fadeout'); - setTimeout(() => { - warningBanner.addClass('chat-loading-overlay-hidden'); - }, 500); - }, 5000); - } - /** * Create chat header with hamburger, title, and settings */ diff --git a/src/ui/chat/components/BranchHeader.ts b/src/ui/chat/components/BranchHeader.ts index 93fc6bc7c..062f393eb 100644 --- a/src/ui/chat/components/BranchHeader.ts +++ b/src/ui/chat/components/BranchHeader.ts @@ -60,11 +60,15 @@ export class BranchHeader { } /** - * Update the context (e.g., when iteration count changes) + * Update the context (e.g., when iteration count changes). + * Skips re-render when merged context is identical to current — prevents + * unbounded registerDomEvent accumulation in component._events on hot paths. */ update(context: Partial): void { if (!this.context) return; - this.context = { ...this.context, ...context }; + const merged = { ...this.context, ...context }; + if (JSON.stringify(merged) === JSON.stringify(this.context)) return; + this.context = merged; this.render(); } diff --git a/src/ui/chat/components/ChatInput.ts b/src/ui/chat/components/ChatInput.ts index 67bf4c786..2ef8ffa2e 100644 --- a/src/ui/chat/components/ChatInput.ts +++ b/src/ui/chat/components/ChatInput.ts @@ -4,22 +4,22 @@ * Provides text input, send button, and model selection */ -import { setIcon, App, Component } from 'obsidian'; -import { initializeSuggesters, SuggesterInstances } from './suggesters/initializeSuggesters'; -import { ContentEditableHelper } from '../utils/ContentEditableHelper'; -import { ReferenceExtractor, ReferenceMetadata } from '../utils/ReferenceExtractor'; -import { MessageEnhancement } from './suggesters/base/SuggesterInterfaces'; -import { MessageEnhancer } from '../services/MessageEnhancer'; +import { setIcon, App, Component } from 'obsidian'; +import { initializeSuggesters, SuggesterInstances } from './suggesters/initializeSuggesters'; +import { ContentEditableHelper } from '../utils/ContentEditableHelper'; +import { ReferenceExtractor, ReferenceMetadata } from '../utils/ReferenceExtractor'; +import { MessageEnhancement } from './suggesters/base/SuggesterInterfaces'; +import { MessageEnhancer } from '../services/MessageEnhancer'; import { isMobile, isIOS } from '../../../utils/platform'; -export class ChatInput { - private element: HTMLElement | null = null; - private inputElement: HTMLElement | null = null; - private sendButton: HTMLButtonElement | null = null; - private isLoading = false; - private isPreSendCompacting = false; - private hasConversation = false; - private suggesters: SuggesterInstances | null = null; +export class ChatInput { + private element: HTMLElement | null = null; + private inputElement: HTMLElement | null = null; + private sendButton: HTMLButtonElement | null = null; + private isLoading = false; + private isPreSendCompacting = false; + private hasConversation = false; + private suggesters: SuggesterInstances | null = null; constructor( private container: HTMLElement, @@ -40,18 +40,18 @@ export class ChatInput { /** * Set loading state */ - setLoading(loading: boolean): void { - this.isLoading = loading; - this.updateUI(); - } - - /** - * Set pre-send compaction state. - */ - setPreSendCompacting(compacting: boolean): void { - this.isPreSendCompacting = compacting; - this.updateUI(); - } + setLoading(loading: boolean): void { + this.isLoading = loading; + this.updateUI(); + } + + /** + * Set pre-send compaction state. + */ + setPreSendCompacting(compacting: boolean): void { + this.isPreSendCompacting = compacting; + this.updateUI(); + } /** * Set conversation state (whether a conversation is active) @@ -73,10 +73,10 @@ export class ChatInput { /** * Render the chat input interface */ - private render(): void { - this.container.empty(); - this.container.addClass('chat-input'); - const component = this.component; + private render(): void { + this.container.empty(); + this.container.addClass('chat-input'); + const component = this.component; // Input wrapper - contains both textarea and embedded send button const inputWrapper = this.container.createDiv('chat-input-wrapper'); @@ -104,11 +104,11 @@ export class ChatInput { } }; - // Auto-resize on input - const inputHandler = () => { - this.autoResizeInput(); - this.updateUI(); - }; + // Auto-resize on input + const inputHandler = () => { + this.autoResizeInput(); + this.updateUI(); + }; // iOS: Scroll input into view when keyboard opens const focusHandler = () => { @@ -119,37 +119,37 @@ export class ChatInput { }; // Register events with component for auto-cleanup - if (component) { - component.registerDomEvent(this.inputElement, 'keydown', keydownHandler); - component.registerDomEvent(this.inputElement, 'input', inputHandler); - if (isIOS()) { - component.registerDomEvent(this.inputElement, 'focus', focusHandler); - } - } + if (component) { + component.registerDomEvent(this.inputElement, 'keydown', keydownHandler); + component.registerDomEvent(this.inputElement, 'input', inputHandler); + if (isIOS()) { + component.registerDomEvent(this.inputElement, 'focus', focusHandler); + } + } // Mobile: Add mobile-specific class for styling if (isMobile()) { inputWrapper.addClass('chat-input-mobile'); } - // Send button - embedded inside the input wrapper (bottom-right) - // Uses Obsidian's clickable-icon class for proper icon sizing - this.sendButton = inputWrapper.createEl('button', { - cls: 'chat-send-button clickable-icon' - }); + // Send button - embedded inside the input wrapper (bottom-right) + // Uses Obsidian's clickable-icon class for proper icon sizing + this.sendButton = inputWrapper.createEl('button', { + cls: 'chat-send-button clickable-icon' + }); // Add send icon using Obsidian's setIcon setIcon(this.sendButton, 'arrow-up'); this.sendButton.setAttribute('aria-label', 'Send message'); - const sendClickHandler = () => { - this.handleSendOrStop(); - }; - component?.registerDomEvent(this.sendButton, 'click', sendClickHandler); - - // Initialize suggesters if app is available - if (this.app && this.inputElement) { - this.suggesters = initializeSuggesters(this.app, this.inputElement, this.component); + const sendClickHandler = () => { + this.handleSendOrStop(); + }; + component?.registerDomEvent(this.sendButton, 'click', sendClickHandler); + + // Initialize suggesters if app is available + if (this.app && this.inputElement) { + this.suggesters = initializeSuggesters(this.app, this.inputElement, this.component); } this.element = this.container; @@ -159,16 +159,16 @@ export class ChatInput { /** * Handle send or stop based on current state */ - private handleSendOrStop(): void { - const actuallyLoading = this.isLoading || this.getLoadingState(); - const hasPendingInput = this.hasPendingInput(); - - if (actuallyLoading && !hasPendingInput) { - // Stop generation - if (this.onStopGeneration) { - this.onStopGeneration(); - } - } else { + private handleSendOrStop(): void { + const actuallyLoading = this.isLoading || this.getLoadingState(); + const hasPendingInput = this.hasPendingInput(); + + if (actuallyLoading && !hasPendingInput) { + // Stop generation + if (this.onStopGeneration) { + this.onStopGeneration(); + } + } else { // Send message this.handleSendMessage(); } @@ -217,16 +217,16 @@ export class ChatInput { private autoResizeInput(): void { if (!this.inputElement) return; - // Reset height to auto to get the correct scrollHeight - this.inputElement.addClass('chat-input-auto-height'); + // Match CSS values exactly: desktop min 72px / max 200px, mobile min 64px / max 160px + const minHeight = isMobile() ? 64 : 72; + const maxHeight = isMobile() ? 160 : 200; - // Set height limits - matches CSS min/max heights - const minHeight = 48; - const maxHeight = 120; - const newHeight = Math.min(Math.max(this.inputElement.scrollHeight, minHeight), maxHeight); + // Remove explicit height so scrollHeight reflects the natural content height. + // This is more reliable than the class-toggle approach for contenteditable divs, + // which can read a stale scrollHeight before the browser has reflowed. + this.inputElement.style.removeProperty('height'); - // Remove auto-height class and set specific height - this.inputElement.removeClass('chat-input-auto-height'); + const newHeight = Math.min(Math.max(this.inputElement.scrollHeight, minHeight), maxHeight); this.inputElement.style.setProperty('height', newHeight + 'px'); // Enable scrolling if content exceeds max height @@ -242,78 +242,78 @@ export class ChatInput { /** * Update UI based on current state */ - private updateUI(): void { - if (!this.sendButton || !this.inputElement) return; - - const actuallyLoading = this.isLoading || this.getLoadingState(); - const hasConversation = this.getHasConversation ? this.getHasConversation() : this.hasConversation; - const hasPendingInput = this.hasPendingInput(); - this.inputElement.setAttribute('aria-busy', this.isPreSendCompacting ? 'true' : 'false'); - - if (this.isPreSendCompacting) { - this.container.addClass('chat-input-compacting'); - } else { - this.container.removeClass('chat-input-compacting'); - } - - if (!hasConversation) { - // No conversation selected - disable everything - this.sendButton.disabled = true; - this.sendButton.classList.remove('stop-mode'); - this.sendButton.classList.add('disabled-mode'); - this.sendButton.empty(); - setIcon(this.sendButton, 'arrow-up'); - this.sendButton.setAttribute('aria-label', 'No conversation selected'); - this.inputElement.contentEditable = 'false'; - this.inputElement.setAttribute('data-placeholder', 'Select or create a conversation to begin'); - } else if (this.isPreSendCompacting) { - this.sendButton.disabled = true; - this.sendButton.classList.remove('stop-mode'); - this.sendButton.classList.add('disabled-mode'); - this.sendButton.empty(); - setIcon(this.sendButton, 'arrow-up'); - this.sendButton.setAttribute('aria-label', 'Compacting context before sending'); - this.inputElement.contentEditable = 'false'; - this.inputElement.setAttribute('data-placeholder', 'Compacting context before sending...'); - } else if (actuallyLoading) { - // Keep the input active so a new message can interrupt the current turn. - this.sendButton.disabled = false; - this.sendButton.empty(); - this.inputElement.contentEditable = 'true'; - - if (hasPendingInput) { - this.sendButton.classList.remove('stop-mode'); - this.sendButton.classList.remove('disabled-mode'); - setIcon(this.sendButton, 'arrow-up'); - this.sendButton.setAttribute('aria-label', 'Interrupt and send message'); - this.inputElement.setAttribute('data-placeholder', 'Send a steering message...'); - } else { - this.sendButton.classList.add('stop-mode'); - this.sendButton.classList.remove('disabled-mode'); - setIcon(this.sendButton, 'square'); - this.sendButton.setAttribute('aria-label', 'Stop generation'); - this.inputElement.setAttribute('data-placeholder', 'Type to interrupt, or stop generation'); - } - } else { - // Show normal send button - this.sendButton.disabled = false; - this.sendButton.classList.remove('stop-mode'); - this.sendButton.classList.remove('disabled-mode'); + private updateUI(): void { + if (!this.sendButton || !this.inputElement) return; + + const actuallyLoading = this.isLoading || this.getLoadingState(); + const hasConversation = this.getHasConversation ? this.getHasConversation() : this.hasConversation; + const hasPendingInput = this.hasPendingInput(); + this.inputElement.setAttribute('aria-busy', this.isPreSendCompacting ? 'true' : 'false'); + + if (this.isPreSendCompacting) { + this.container.addClass('chat-input-compacting'); + } else { + this.container.removeClass('chat-input-compacting'); + } + + if (!hasConversation) { + // No conversation selected - disable everything + this.sendButton.disabled = true; + this.sendButton.classList.remove('stop-mode'); + this.sendButton.classList.add('disabled-mode'); + this.sendButton.empty(); + setIcon(this.sendButton, 'arrow-up'); + this.sendButton.setAttribute('aria-label', 'No conversation selected'); + this.inputElement.contentEditable = 'false'; + this.inputElement.setAttribute('data-placeholder', 'Select or create a conversation to begin'); + } else if (this.isPreSendCompacting) { + this.sendButton.disabled = true; + this.sendButton.classList.remove('stop-mode'); + this.sendButton.classList.add('disabled-mode'); + this.sendButton.empty(); + setIcon(this.sendButton, 'arrow-up'); + this.sendButton.setAttribute('aria-label', 'Compacting context before sending'); + this.inputElement.contentEditable = 'false'; + this.inputElement.setAttribute('data-placeholder', 'Compacting context before sending...'); + } else if (actuallyLoading) { + // Keep the input active so a new message can interrupt the current turn. + this.sendButton.disabled = false; + this.sendButton.empty(); + this.inputElement.contentEditable = 'true'; + + if (hasPendingInput) { + this.sendButton.classList.remove('stop-mode'); + this.sendButton.classList.remove('disabled-mode'); + setIcon(this.sendButton, 'arrow-up'); + this.sendButton.setAttribute('aria-label', 'Interrupt and send message'); + this.inputElement.setAttribute('data-placeholder', 'Send a steering message...'); + } else { + this.sendButton.classList.add('stop-mode'); + this.sendButton.classList.remove('disabled-mode'); + setIcon(this.sendButton, 'square'); + this.sendButton.setAttribute('aria-label', 'Stop generation'); + this.inputElement.setAttribute('data-placeholder', 'Type to interrupt, or stop generation'); + } + } else { + // Show normal send button + this.sendButton.disabled = false; + this.sendButton.classList.remove('stop-mode'); + this.sendButton.classList.remove('disabled-mode'); this.sendButton.empty(); setIcon(this.sendButton, 'arrow-up'); this.sendButton.setAttribute('aria-label', 'Send message'); this.inputElement.contentEditable = 'true'; - this.inputElement.setAttribute('data-placeholder', 'Type your message...'); - } - } - - private hasPendingInput(): boolean { - if (!this.inputElement) { - return false; - } - - return ContentEditableHelper.getPlainText(this.inputElement).trim().length > 0; - } + this.inputElement.setAttribute('data-placeholder', 'Type your message...'); + } + } + + private hasPendingInput(): boolean { + if (!this.inputElement) { + return false; + } + + return ContentEditableHelper.getPlainText(this.inputElement).trim().length > 0; + } /** * Focus the input @@ -327,13 +327,13 @@ export class ChatInput { /** * Clear the input */ - clear(): void { - if (this.inputElement) { - ContentEditableHelper.clear(this.inputElement); - this.autoResizeInput(); - this.updateUI(); - } - } + clear(): void { + if (this.inputElement) { + ContentEditableHelper.clear(this.inputElement); + this.autoResizeInput(); + this.updateUI(); + } + } /** * Get current input value @@ -345,20 +345,20 @@ export class ChatInput { /** * Set input value */ - setValue(value: string): void { - if (this.inputElement) { - ContentEditableHelper.setPlainText(this.inputElement, value); - this.autoResizeInput(); - this.updateUI(); - } - } + setValue(value: string): void { + if (this.inputElement) { + ContentEditableHelper.setPlainText(this.inputElement, value); + this.autoResizeInput(); + this.updateUI(); + } + } /** * Get message enhancer (for accessing enhancements before sending) */ - getMessageEnhancer(): MessageEnhancer | null { - return this.suggesters?.messageEnhancer || null; - } + getMessageEnhancer(): MessageEnhancer | null { + return this.suggesters?.messageEnhancer || null; + } /** * Clear message enhancer (call after message is sent) @@ -377,9 +377,9 @@ export class ChatInput { this.suggesters.cleanup(); this.suggesters = null; } - - this.element = null; - this.inputElement = null; - this.sendButton = null; - } -} + + this.element = null; + this.inputElement = null; + this.sendButton = null; + } +} diff --git a/src/ui/chat/components/ChatSettingsModal.ts b/src/ui/chat/components/ChatSettingsModal.ts index 6ba61f9bf..151cfdd2b 100644 --- a/src/ui/chat/components/ChatSettingsModal.ts +++ b/src/ui/chat/components/ChatSettingsModal.ts @@ -39,30 +39,30 @@ export class ChatSettingsModal extends Modal { this.modelAgentManager = modelAgentManager; } - onOpen(): void { + onOpen(): void { const { contentEl } = this; contentEl.empty(); contentEl.addClass('chat-settings-modal'); // Header with buttons const header = contentEl.createDiv('chat-settings-header'); - header.createEl('h2', { text: 'Chat settings' }); + header.createEl('h2', { text: 'Chat settings' }); const buttonContainer = header.createDiv('chat-settings-buttons'); new ButtonComponent(buttonContainer) .setButtonText('Cancel') .onClick(() => this.close()); - new ButtonComponent(buttonContainer) - .setButtonText('Save') - .setCta() - .onClick(() => { - void this.handleSave(); - }); - - // Load data and render - void this.loadAndRender(contentEl); - } + new ButtonComponent(buttonContainer) + .setButtonText('Save') + .setCta() + .onClick(() => { + void this.handleSave(); + }); + + // Load data and render + void this.loadAndRender(contentEl); + } private async loadAndRender(contentEl: HTMLElement): Promise { const plugin = getNexusPlugin(this.app); @@ -179,10 +179,7 @@ export class ChatSettingsModal extends Modal { // Update workspace if (settings.workspaceId) { - const workspace = await this.workspaceService.getWorkspace(settings.workspaceId); - if (workspace?.context) { - await this.modelAgentManager.setWorkspaceContext(settings.workspaceId, workspace.context); - } + await this.modelAgentManager.setWorkspaceContext(settings.workspaceId); } else { await this.modelAgentManager.clearWorkspaceContext(); } @@ -207,6 +204,17 @@ export class ChatSettingsModal extends Modal { // Update context notes await this.modelAgentManager.setContextNotes(settings.contextNotes); + // Update image model in plugin settings + const plugin = getNexusPlugin(this.app); + const llmSettings = plugin?.settings?.settings?.llmProviders; + if (llmSettings && plugin?.settings) { + llmSettings.defaultImageModel = { + provider: settings.imageProvider, + model: settings.imageModel + }; + await plugin.settings.saveSettings(); + } + // Save to conversation metadata if (this.conversationId) { await this.modelAgentManager.saveToConversation(this.conversationId); diff --git a/src/ui/chat/components/ContextProgressBar.ts b/src/ui/chat/components/ContextProgressBar.ts index 3b5c42c68..83ee24a0e 100644 --- a/src/ui/chat/components/ContextProgressBar.ts +++ b/src/ui/chat/components/ContextProgressBar.ts @@ -35,10 +35,10 @@ export class ContextProgressBar { this.container.empty(); this.container.addClass('context-progress-container'); - // Header - const header = this.container.createDiv('context-progress-header'); - const label = header.createSpan('context-progress-label'); - label.textContent = 'Context usage'; + // Header + const header = this.container.createDiv('context-progress-header'); + const label = header.createSpan('context-progress-label'); + label.textContent = 'Context usage'; this.usageText = header.createSpan('context-progress-usage'); this.usageText.textContent = '0 / 0 tokens (0%)'; @@ -118,7 +118,8 @@ export class ContextProgressBar { this.progressBar.style.width = `${Math.min(visualPercentage, 100)}%`; // The gradient automatically shows the appropriate color based on fill width - this.progressBar.className = 'context-progress-bar-fill'; + this.progressBar.removeAttribute('class'); + this.progressBar.addClass('context-progress-bar-fill'); // Update usage text const usedFormatted = this.formatTokenCount(used); @@ -233,4 +234,4 @@ export class ContextProgressBar { this.progressBar = null; this.usageText = null; } -} +} diff --git a/src/ui/chat/components/ConversationList.ts b/src/ui/chat/components/ConversationList.ts index a07172ca0..ebc730cb9 100644 --- a/src/ui/chat/components/ConversationList.ts +++ b/src/ui/chat/components/ConversationList.ts @@ -7,11 +7,11 @@ import { setIcon, Component } from 'obsidian'; import { ConversationData } from '../../../types/chat/ChatTypes'; -export class ConversationList { - private conversations: ConversationData[] = []; - private activeConversationId: string | null = null; - private pendingDeleteConversationId: string | null = null; - private pendingDeleteTimer: number | null = null; +export class ConversationList { + private conversations: ConversationData[] = []; + private activeConversationId: string | null = null; + private pendingDeleteConversationId: string | null = null; + private pendingDeleteTimer: number | null = null; constructor( private container: HTMLElement, @@ -61,10 +61,10 @@ export class ConversationList { // Main conversation content const content = item.createDiv('conversation-content'); - const selectHandler = () => { - this.onConversationSelect(conversation); - }; - this.component?.registerDomEvent(content, 'click', selectHandler); + const selectHandler = () => { + this.onConversationSelect(conversation); + }; + this.component?.registerDomEvent(content, 'click', selectHandler); // Title const title = content.createDiv('conversation-title'); @@ -98,20 +98,20 @@ export class ConversationList { e.stopPropagation(); this.showRenameInput(item, content, conversation); }; - this.component?.registerDomEvent(editBtn, 'click', editHandler); + this.component?.registerDomEvent(editBtn, 'click', editHandler); } // Delete button - uses clickable-icon for proper icon sizing const deleteBtn = actions.createEl('button', { cls: 'conversation-action-btn conversation-delete-btn clickable-icon' }); - setIcon(deleteBtn, 'trash-2'); - deleteBtn.setAttribute('aria-label', 'Delete conversation'); - const deleteHandler = (e: MouseEvent) => { - e.stopPropagation(); - this.requestDeleteConversation(conversation); - }; - this.component?.registerDomEvent(deleteBtn, 'click', deleteHandler); + setIcon(deleteBtn, 'trash-2'); + deleteBtn.setAttribute('aria-label', 'Delete conversation'); + const deleteHandler = (e: MouseEvent) => { + e.stopPropagation(); + this.requestDeleteConversation(conversation); + }; + this.component?.registerDomEvent(deleteBtn, 'click', deleteHandler); }); } @@ -129,10 +129,9 @@ export class ConversationList { const currentTitle = conversation.title; // Create input element - const input = document.createElement('input'); + const input = createEl('input', { cls: 'conversation-rename-input' }); input.type = 'text'; input.value = currentTitle; - input.className = 'conversation-rename-input'; // Replace title with input titleEl.replaceWith(input); @@ -149,9 +148,10 @@ export class ConversationList { const newTitle = input.value.trim(); // Restore title element - const newTitleEl = document.createElement('div'); - newTitleEl.className = 'conversation-title'; - newTitleEl.textContent = save && newTitle ? newTitle : currentTitle; + const newTitleEl = createEl('div', { + cls: 'conversation-title', + text: save && newTitle ? newTitle : currentTitle + }); input.replaceWith(newTitleEl); // Restore action buttons @@ -181,9 +181,9 @@ export class ConversationList { } }; - this.component?.registerDomEvent(input, 'blur', blurHandler); - this.component?.registerDomEvent(input, 'keydown', keydownHandler); - } + this.component?.registerDomEvent(input, 'blur', blurHandler); + this.component?.registerDomEvent(input, 'keydown', keydownHandler); + } /** * Update active state styling @@ -203,7 +203,7 @@ export class ConversationList { /** * Format timestamp for display */ - private formatTimestamp(timestamp: number): string { + private formatTimestamp(timestamp: number): string { const date = new Date(timestamp); const now = new Date(); const diffMs = now.getTime() - date.getTime(); @@ -215,38 +215,38 @@ export class ConversationList { if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; - - return date.toLocaleDateString(); - } - - private requestDeleteConversation(conversation: ConversationData): void { - if (this.pendingDeleteConversationId === conversation.id) { - this.clearPendingDeleteConversation(); - this.onConversationDelete(conversation.id); - return; - } - - this.pendingDeleteConversationId = conversation.id; - if (this.pendingDeleteTimer !== null) { - window.clearTimeout(this.pendingDeleteTimer); - } - this.pendingDeleteTimer = window.setTimeout(() => { - this.clearPendingDeleteConversation(); - }, 5000); - } - - private clearPendingDeleteConversation(): void { - this.pendingDeleteConversationId = null; - if (this.pendingDeleteTimer !== null) { - window.clearTimeout(this.pendingDeleteTimer); - this.pendingDeleteTimer = null; - } - } - - /** - * Cleanup resources + + return date.toLocaleDateString(); + } + + private requestDeleteConversation(conversation: ConversationData): void { + if (this.pendingDeleteConversationId === conversation.id) { + this.clearPendingDeleteConversation(); + this.onConversationDelete(conversation.id); + return; + } + + this.pendingDeleteConversationId = conversation.id; + if (this.pendingDeleteTimer !== null) { + window.clearTimeout(this.pendingDeleteTimer); + } + this.pendingDeleteTimer = window.setTimeout(() => { + this.clearPendingDeleteConversation(); + }, 5000); + } + + private clearPendingDeleteConversation(): void { + this.pendingDeleteConversationId = null; + if (this.pendingDeleteTimer !== null) { + window.clearTimeout(this.pendingDeleteTimer); + this.pendingDeleteTimer = null; + } + } + + /** + * Cleanup resources */ cleanup(): void { - // Clean up any event listeners if needed + this.clearPendingDeleteConversation(); } } diff --git a/src/ui/chat/components/CreateFileModal.ts b/src/ui/chat/components/CreateFileModal.ts new file mode 100644 index 000000000..3b45f8f62 --- /dev/null +++ b/src/ui/chat/components/CreateFileModal.ts @@ -0,0 +1,123 @@ +/** + * CreateFileModal - Modal for creating a new vault file from chat content + * Location: /src/ui/chat/components/CreateFileModal.ts + * + * Presents a filename field, folder path (defaulting to 00-inbox), and an + * open-after-save toggle. Creates the folder if it does not exist, guards + * against duplicate filenames, and opens the created file on request. + * + * Used by MessageActionBar when the user clicks "Create new file". + */ + +import { App, Modal, Notice, Setting, ToggleComponent, normalizePath } from 'obsidian'; + +export class CreateFileModal extends Modal { + private filename = ''; + private folderPath = '00-Inbox'; + private openAfterSave = true; + private openAfterSaveToggle: ToggleComponent | null = null; + private readonly content: string; + + constructor(app: App, content: string) { + super(app); + this.content = content; + } + + onOpen(): void { + const { contentEl } = this; + + contentEl.createEl('h2', { text: 'Create new file' }); + + // File name input + let filenameInput: HTMLInputElement; + new Setting(contentEl) + .setName('File name') + .addText(text => { + text.setPlaceholder('Note name') + .onChange(value => { this.filename = value; }); + filenameInput = text.inputEl; + }); + + // Folder path input + new Setting(contentEl) + .setName('Folder') + .setDesc('Folder path within your vault') + .addText(text => { + text.setValue(this.folderPath) + .onChange(value => { this.folderPath = value; }); + }); + + // Open after save toggle — store ref so handleCreate reads current value directly + new Setting(contentEl) + .setName('Open after saving') + .addToggle(toggle => { + toggle.setValue(this.openAfterSave); + this.openAfterSaveToggle = toggle; + }); + + // Action buttons + new Setting(contentEl) + .addButton(button => { + button.setButtonText('Create') + .setCta() + .onClick(() => this.handleCreate()); + }) + .addButton(button => { + button.setButtonText('Cancel') + .onClick(() => this.close()); + }); + + // Focus filename input on open + setTimeout(() => { filenameInput?.focus(); }, 50); + } + + private async handleCreate(): Promise { + // Strip .md suffix and trim whitespace + let name = this.filename.trim(); + if (name.toLowerCase().endsWith('.md')) { + name = name.slice(0, -3).trim(); + } + + if (!name) { + new Notice('Please enter a file name.'); + return; + } + + const folder = this.folderPath.trim() || '00-Inbox'; + const filePath = normalizePath(`${folder}/${name}.md`); + + // Guard against duplicate + if (this.app.vault.getFileByPath(filePath)) { + new Notice(`File already exists: ${filePath}`); + return; + } + + try { + await this.ensureFolder(folder); + const file = await this.app.vault.create(filePath, this.content); + new Notice(`Created: ${name}.md`); + this.close(); + + const shouldOpen = this.openAfterSaveToggle + ? this.openAfterSaveToggle.getValue() + : this.openAfterSave; + if (shouldOpen) { + await this.app.workspace.getLeaf().openFile(file); + } + } catch (err) { + console.error('[CreateFileModal] Error creating file:', err); + new Notice(`Failed to create file: ${String(err)}`); + } + } + + private async ensureFolder(folderPath: string): Promise { + const normalized = normalizePath(folderPath); + if (!this.app.vault.getAbstractFileByPath(normalized)) { + await this.app.vault.createFolder(normalized); + } + } + + onClose(): void { + this.contentEl.empty(); + } +} diff --git a/src/ui/chat/components/MessageActionBar.ts b/src/ui/chat/components/MessageActionBar.ts new file mode 100644 index 000000000..65f700142 --- /dev/null +++ b/src/ui/chat/components/MessageActionBar.ts @@ -0,0 +1,139 @@ +/** + * MessageActionBar - Populates the existing message-actions-external pill + * Location: /src/ui/chat/components/MessageActionBar.ts + * + * Renders four action buttons into the caller-supplied container element + * (the existing .message-actions-external pill that sits in the upper-right + * corner of each message bubble). Buttons appear alongside any other pill + * contents (e.g. branch navigator) and use the same message-action-btn + * styling as the original copy button did. + * + * Only rendered for completed assistant messages with non-empty text content. + * Called by MessageBubble.appendActionBar() after message state transitions + * to complete. + */ + +import { App, Component, MarkdownView, Notice, setIcon } from 'obsidian'; +import { CreateFileModal } from './CreateFileModal'; + +export class MessageActionBar extends Component { + private buttons: HTMLElement[] = []; + private copyButton: HTMLElement | null = null; + + constructor( + private readonly content: string, + private readonly app: App + ) { + super(); + } + + /** + * Create the four action buttons inside the provided container element. + * The container is the existing .message-actions-external pill — no new + * wrapper is created. Call removeFromContainer() before unload to clean up. + */ + renderInto(container: HTMLElement): void { + this.copyButton = this.addButton(container, 'copy', 'Copy message', () => this.handleCopy()); + + // Insert and Append need mousedown:preventDefault so clicking the button + // does not shift focus away from the active note (and lose the cursor). + const insertBtn = this.addButton(container, 'file-input', 'Insert at cursor', () => this.handleInsert()); + this.registerDomEvent(insertBtn, 'mousedown', (e: MouseEvent) => e.preventDefault()); + + const appendBtn = this.addButton(container, 'file-plus-2', 'Append to active note', () => { void this.handleAppend(); }); + this.registerDomEvent(appendBtn, 'mousedown', (e: MouseEvent) => e.preventDefault()); + + this.addButton(container, 'file-plus', 'Create new file', () => this.handleCreate()); + } + + /** + * Remove all buttons this component added from their parent container. + * Call before unload() to keep the DOM clean. + */ + removeFromContainer(): void { + this.buttons.forEach(btn => btn.remove()); + this.buttons = []; + this.copyButton = null; + } + + // ─── Private helpers ──────────────────────────────────────────────────────── + + private addButton( + parent: HTMLElement, + icon: string, + title: string, + handler: () => void + ): HTMLElement { + const btn = parent.createEl('button', { + cls: 'message-action-btn clickable-icon', + attr: { title, 'aria-label': title } + }); + setIcon(btn, icon); + this.registerDomEvent(btn, 'click', handler); + this.buttons.push(btn); + return btn; + } + + private handleCopy(): void { + navigator.clipboard.writeText(this.content).then(() => { + if (this.copyButton) this.showCopyFeedback(this.copyButton); + }).catch(err => { + console.error('[MessageActionBar] Copy failed:', err); + new Notice('Copy failed.'); + }); + } + + private showCopyFeedback(button: HTMLElement): void { + setIcon(button, 'check'); + button.classList.add('copy-success'); + setTimeout(() => { + setIcon(button, 'copy'); + button.classList.remove('copy-success'); + }, 1500); + } + + /** + * Returns the active MarkdownView, or falls back to the most recently + * opened markdown leaf if the chat panel currently has workspace focus. + */ + private getMarkdownView(): MarkdownView | null { + const active = this.app.workspace.getActiveViewOfType(MarkdownView); + if (active) return active; + + // Chat panel has focus — find any open note tab + const leaves = this.app.workspace.getLeavesOfType('markdown'); + if (leaves.length === 0) return null; + return leaves[leaves.length - 1].view as MarkdownView; + } + + private handleInsert(): void { + const view = this.getMarkdownView(); + if (!view) { + new Notice('No active note — open a note and place your cursor first.'); + return; + } + view.editor.focus(); + view.editor.replaceSelection(this.content); + } + + private async handleAppend(): Promise { + const view = this.getMarkdownView(); + if (!view?.file) { + new Notice('No active note — open a note first.'); + return; + } + + const timestamp = new Date().toLocaleString(); + const separator = `\n\n---\n*Appended from Nexus Chat — ${timestamp}*\n\n`; + + await this.app.vault.process(view.file, (fileContent) => { + return fileContent + separator + this.content; + }); + + new Notice('Appended to note.'); + } + + private handleCreate(): void { + new CreateFileModal(this.app, this.content).open(); + } +} diff --git a/src/ui/chat/components/MessageBubble.ts b/src/ui/chat/components/MessageBubble.ts index d5f258761..08b0fc360 100644 --- a/src/ui/chat/components/MessageBubble.ts +++ b/src/ui/chat/components/MessageBubble.ts @@ -2,7 +2,7 @@ * MessageBubble - Individual message bubble component * Location: /src/ui/chat/components/MessageBubble.ts * - * Renders user/AI messages with copy, retry, and edit actions. + * Renders user/AI messages with retry, edit, and action bar actions. * Delegates rendering responsibilities to specialized classes following SOLID principles. * * Used by MessageDisplay to render individual messages in the chat interface. @@ -13,49 +13,51 @@ import { ConversationMessage } from '../../../types/chat/ChatTypes'; import { ProgressiveToolAccordion } from './ProgressiveToolAccordion'; import { MessageBranchNavigator, MessageBranchNavigatorEvents } from './MessageBranchNavigator'; +import { MessageActionBar } from './MessageActionBar'; import { setIcon, Component, App } from 'obsidian'; // Extracted classes import { ReferenceBadgeRenderer } from './renderers/ReferenceBadgeRenderer'; -import { ToolBubbleFactory } from './factories/ToolBubbleFactory'; -import { ToolEventParser } from '../utils/ToolEventParser'; -import { normalizeToolCallForDisplay } from '../utils/toolDisplayNormalizer'; -import { MessageContentRenderer } from './renderers/MessageContentRenderer'; -import { MessageEditController } from '../controllers/MessageEditController'; - -export class MessageBubble extends Component { - private element: HTMLElement | null = null; - private loadingInterval: ReturnType | null = null; +import { ToolBubbleFactory } from './factories/ToolBubbleFactory'; +import { ToolEventParser } from '../utils/ToolEventParser'; +import { normalizeToolCallForDisplay } from '../utils/toolDisplayNormalizer'; +import { MessageContentRenderer } from './renderers/MessageContentRenderer'; +import { MessageEditController } from '../controllers/MessageEditController'; + +export class MessageBubble extends Component { + private element: HTMLElement | null = null; + private loadingInterval: ReturnType | null = null; private progressiveToolAccordions: Map = new Map(); private messageBranchNavigator: MessageBranchNavigator | null = null; private toolBubbleElement: HTMLElement | null = null; private textBubbleElement: HTMLElement | null = null; private imageBubbleElement: HTMLElement | null = null; + private actionBar: MessageActionBar | null = null; constructor( private message: ConversationMessage, - private app: App, - private onCopy: (messageId: string) => void, - private onRetry: (messageId: string) => void, - private onEdit?: (messageId: string, newContent: string) => void, - private onToolEvent?: (messageId: string, event: 'detected' | 'started' | 'completed', data: Parameters[0]) => void, - private onMessageAlternativeChanged?: (messageId: string, alternativeIndex: number) => void, - private onViewBranch?: (branchId: string) => void - ) { + private app: App, + private onCopy: (messageId: string) => void, + private onRetry: (messageId: string) => void, + private onEdit?: (messageId: string, newContent: string) => void, + private onToolEvent?: (messageId: string, event: 'detected' | 'started' | 'completed', data: Parameters[0]) => void, + private onMessageAlternativeChanged?: (messageId: string, alternativeIndex: number) => void, + private onViewBranch?: (branchId: string) => void + ) { super(); } - /** - * Create the message bubble element - * For assistant messages with toolCalls or reasoning, returns a fragment containing tool bubble + text bubble - */ - createElement(): HTMLElement { - const activeToolCalls = this.getActiveToolCalls(this.message); - const activeReasoning = this.getActiveReasoning(this.message); - const showToolBubble = this.getRenderMode(this.message) === 'group'; - const activeContent = this.getActiveMessageContent(this.message); - - if (showToolBubble) { + /** + * Create the message bubble element + * For assistant messages with toolCalls or reasoning, returns a fragment containing tool bubble + text bubble + */ + createElement(): HTMLElement { + const activeToolCalls = this.getActiveToolCalls(this.message); + const activeReasoning = this.getActiveReasoning(this.message); + const showToolBubble = this.getRenderMode(this.message) === 'group'; + const activeContent = this.getActiveMessageContent(this.message); + + if (showToolBubble) { const wrapper = document.createElement('div'); wrapper.addClass('message-group'); wrapper.setAttribute('data-message-id', this.message.id); @@ -64,11 +66,11 @@ export class MessageBubble extends Component { const renderMessage: ConversationMessage = { ...this.message, toolCalls: activeToolCalls, reasoning: activeReasoning }; // Create tool bubble using factory - this.toolBubbleElement = ToolBubbleFactory.createToolBubble({ - message: renderMessage, - progressiveToolAccordions: this.progressiveToolAccordions, - component: this - }); + this.toolBubbleElement = ToolBubbleFactory.createToolBubble({ + message: renderMessage, + progressiveToolAccordions: this.progressiveToolAccordions, + component: this + }); wrapper.appendChild(this.toolBubbleElement); // Wire up onViewBranch callback to all accordions @@ -91,22 +93,18 @@ export class MessageBubble extends Component { } // Create text bubble if there's content OR if streaming (need element for StreamingController) - if (this.shouldRenderTextBubble(this.message)) { - this.textBubbleElement = ToolBubbleFactory.createTextBubble( - renderMessage, - (container, content) => this.renderContent(container, content), - this.onCopy, - (button) => this.showCopyFeedback(button), - this.messageBranchNavigator, - this.onMessageAlternativeChanged, - this + if (this.shouldRenderTextBubble(this.message)) { + this.textBubbleElement = ToolBubbleFactory.createTextBubble( + renderMessage, + (container, content) => this.renderContent(container, content), + this.messageBranchNavigator ); wrapper.appendChild(this.textBubbleElement); // Add branch navigator for assistant messages with branches - if (renderMessage.branches && renderMessage.branches.length > 0) { - const actions = this.textBubbleElement.querySelector('.message-actions-external'); - if (actions instanceof HTMLElement) { + if (renderMessage.branches && renderMessage.branches.length > 0) { + const actions = this.textBubbleElement.querySelector('.message-actions-external'); + if (actions instanceof HTMLElement) { const navigatorEvents: MessageBranchNavigatorEvents = { onAlternativeChanged: (messageId, alternativeIndex) => { if (this.onMessageAlternativeChanged) { @@ -117,17 +115,18 @@ export class MessageBubble extends Component { }; this.messageBranchNavigator = new MessageBranchNavigator(actions, navigatorEvents, this); - this.messageBranchNavigator.updateMessage(renderMessage); - } - } - - const contentElement = this.textBubbleElement.querySelector('.message-content'); - if (contentElement instanceof HTMLElement && this.message.isLoading && !activeContent.trim()) { - this.appendLoadingIndicator(contentElement); - } - } - - this.element = wrapper; + this.messageBranchNavigator.updateMessage(renderMessage); + } + } + + const contentElement = this.textBubbleElement.querySelector('.message-content'); + if (contentElement instanceof HTMLElement && this.message.isLoading && !activeContent.trim()) { + this.appendLoadingIndicator(contentElement); + } + } + + this.element = wrapper; + this.appendActionBar(wrapper, this.message); return wrapper; } @@ -164,12 +163,12 @@ export class MessageBubble extends Component { if (this.message.role === 'user') { actions = header.createDiv('message-actions-external'); } else if (this.message.role === 'assistant') { - actions = bubble.createDiv('message-actions-external'); + actions = header.createDiv('message-actions-external'); } else { actions = messageContainer.createDiv('message-actions-external'); } - this.createActionButtons(actions); + this.createActionButtons(actions); // Message content const content = bubble.createDiv('message-content'); @@ -178,28 +177,29 @@ export class MessageBubble extends Component { }); this.element = messageContainer; + this.appendActionBar(messageContainer, this.message); return messageContainer; } /** - * Create action buttons (edit, retry, copy, branch navigator) + * Create action buttons (edit, retry, branch navigator) */ - private createActionButtons(actions: HTMLElement): void { + private createActionButtons(actions: HTMLElement): void { if (this.message.role === 'user') { - // Edit button for user messages - if (this.onEdit) { - const editBtn = actions.createEl('button', { - cls: 'message-action-btn clickable-icon', - attr: { title: 'Edit message' } - }); - setIcon(editBtn, 'edit'); - const onEdit = this.onEdit; - this.registerDomEvent(editBtn, 'click', () => { - if (onEdit) { - MessageEditController.handleEdit(this.message, this.element, onEdit, this); - } - }); - } + // Edit button for user messages + if (this.onEdit) { + const editBtn = actions.createEl('button', { + cls: 'message-action-btn clickable-icon', + attr: { title: 'Edit message' } + }); + setIcon(editBtn, 'edit'); + const onEdit = this.onEdit; + this.registerDomEvent(editBtn, 'click', () => { + if (onEdit) { + MessageEditController.handleEdit(this.message, this.element, onEdit, this); + } + }); + } // Retry button for user messages const retryBtn = actions.createEl('button', { @@ -226,17 +226,6 @@ export class MessageBubble extends Component { this.onCopy(this.message.id); }); } else { - // Copy button for AI messages - const copyBtn = actions.createEl('button', { - cls: 'message-action-btn clickable-icon', - attr: { title: 'Copy message' } - }); - setIcon(copyBtn, 'copy'); - this.registerDomEvent(copyBtn, 'click', () => { - this.showCopyFeedback(copyBtn); - this.onCopy(this.message.id); - }); - // Message branch navigator for AI messages with branches if (this.message.branches && this.message.branches.length > 0) { const navigatorEvents: MessageBranchNavigatorEvents = { @@ -278,6 +267,10 @@ export class MessageBubble extends Component { * Start loading animation (animated dots) */ private startLoadingAnimation(container: HTMLElement): void { + if (this.loadingInterval) { + clearInterval(this.loadingInterval); + this.loadingInterval = null; + } const dotsElement = container.querySelector('.dots'); if (dotsElement) { let dotCount = 0; @@ -348,15 +341,15 @@ export class MessageBubble extends Component { /** * Update MessageBubble with new message data */ - updateWithNewMessage(newMessage: ConversationMessage): void { - const previousRenderMode = this.getRenderMode(this.message); - const nextRenderMode = this.getRenderMode(newMessage); - const previousHadTextBubble = this.shouldRenderTextBubble(this.message); - const nextNeedsTextBubble = this.shouldRenderTextBubble(newMessage); - - // Handle progressive accordion transition to static - const activeToolCalls = this.getActiveToolCalls(newMessage); - if (this.progressiveToolAccordions.size > 0 && activeToolCalls) { + updateWithNewMessage(newMessage: ConversationMessage): void { + const previousRenderMode = this.getRenderMode(this.message); + const nextRenderMode = this.getRenderMode(newMessage); + const previousHadTextBubble = this.shouldRenderTextBubble(this.message); + const nextNeedsTextBubble = this.shouldRenderTextBubble(newMessage); + + // Handle progressive accordion transition to static + const activeToolCalls = this.getActiveToolCalls(newMessage); + if (this.progressiveToolAccordions.size > 0 && activeToolCalls) { const hasCompletedTools = activeToolCalls.some(tc => tc.result !== undefined || tc.success !== undefined ); @@ -367,16 +360,16 @@ export class MessageBubble extends Component { this.messageBranchNavigator.updateMessage(newMessage); } return; - } - } - - if (previousRenderMode !== nextRenderMode || previousHadTextBubble !== nextNeedsTextBubble) { - this.message = newMessage; - this.rebuildElement(); - return; - } - - this.message = newMessage; + } + } + + if (previousRenderMode !== nextRenderMode || previousHadTextBubble !== nextNeedsTextBubble) { + this.message = newMessage; + this.rebuildElement(); + return; + } + + this.message = newMessage; // Clear tool accordions and tool bubble when new message has no tool calls (e.g., retry clear) if (!activeToolCalls || activeToolCalls.length === 0) { @@ -414,49 +407,51 @@ export class MessageBubble extends Component { } } - if (!this.element) return; - const contentElement = this.element.querySelector('.message-content'); - if (!(contentElement instanceof HTMLElement)) { - this.rebuildElement(); - return; - } - - contentElement.empty(); + if (!this.element) return; + const contentElement = this.element.querySelector('.message-content'); + if (!(contentElement instanceof HTMLElement)) { + this.rebuildElement(); + return; + } + + contentElement.empty(); const activeContent = this.getActiveMessageContent(newMessage); this.renderContent(contentElement, activeContent).catch(error => { console.error('[MessageBubble] Error re-rendering content:', error); - }); - - if (newMessage.isLoading && newMessage.role === 'assistant') { - this.appendLoadingIndicator(contentElement); - } - } - - /** - * Handle tool events from MessageManager - */ - handleToolEvent(event: 'detected' | 'updated' | 'started' | 'completed', data: Parameters[0]): void { - const info = ToolEventParser.getToolEventInfo(data, event); - const eventData = (data ?? {}) as { - result?: unknown; - success?: boolean; - error?: unknown; - [key: string]: unknown; - }; - const toolId = info.toolId || info.batchId || info.parentToolCallId || info.stepId; - if (!toolId) { - return; - } - - let accordion = this.progressiveToolAccordions.get(toolId); - - if (!accordion && (event === 'detected' || event === 'started' || event === 'completed')) { - accordion = new ProgressiveToolAccordion(this); - const accordionElement = accordion.createElement(); - - // Wire up onViewBranch callback for subagent navigation - if (this.onViewBranch) { + }); + + if (newMessage.isLoading && newMessage.role === 'assistant') { + this.appendLoadingIndicator(contentElement); + } else if (this.element) { + this.appendActionBar(this.element, newMessage); + } + } + + /** + * Handle tool events from MessageManager + */ + handleToolEvent(event: 'detected' | 'updated' | 'started' | 'completed', data: Parameters[0]): void { + const info = ToolEventParser.getToolEventInfo(data, event); + const eventData = (data ?? {}) as { + result?: unknown; + success?: boolean; + error?: unknown; + [key: string]: unknown; + }; + const toolId = info.toolId || info.batchId || info.parentToolCallId || info.stepId; + if (!toolId) { + return; + } + + let accordion = this.progressiveToolAccordions.get(toolId); + + if (!accordion && (event === 'detected' || event === 'started' || event === 'completed')) { + accordion = new ProgressiveToolAccordion(this); + const accordionElement = accordion.createElement(); + + // Wire up onViewBranch callback for subagent navigation + if (this.onViewBranch) { accordion.setCallbacks({ onViewBranch: this.onViewBranch }); } @@ -469,63 +464,63 @@ export class MessageBubble extends Component { toolContent.appendChild(accordionElement); } - this.progressiveToolAccordions.set(toolId, accordion); - } - - if (!accordion) { - return; - } - - const hasToolMetadata = - Boolean(data?.toolCall) || - Boolean(data?.name) || - Boolean(data?.technicalName) || - Boolean(data?.displayName); - - const isLiveBatchStep = Boolean(info.isBatchStepEvent); - const eventError = typeof eventData.error === 'string' ? eventData.error : undefined; - - if (event === 'completed' && !hasToolMetadata) { - accordion.completeTool(toolId, eventData.result, eventData.success !== false, eventError); - } else { - const currentGroup = accordion.getDisplayGroup(); - const nextDisplayGroup = isLiveBatchStep - ? normalizeToolCallForDisplay({ - ...eventData, - id: toolId, - toolId, - parentToolCallId: info.parentToolCallId ?? info.batchId ?? toolId, - batchId: info.batchId ?? toolId, - callIndex: info.callIndex, - totalCalls: info.totalCalls, - strategy: info.strategy, - stepId: info.stepId ?? undefined, - status: info.status ?? undefined, - error: eventError - }, currentGroup) - : info.displayGroup; - - const shouldPreserveCurrentBatch = - !isLiveBatchStep && - Boolean(currentGroup) && - currentGroup?.kind === 'batch' && - currentGroup.steps.length > 0 && - nextDisplayGroup.kind === 'batch' && - nextDisplayGroup.steps.length === 0 && - ( - nextDisplayGroup.technicalName === 'useTools' || - nextDisplayGroup.technicalName?.endsWith('.useTools') - ); - - const displayGroup = shouldPreserveCurrentBatch && currentGroup ? currentGroup : nextDisplayGroup; - - accordion.setDisplayGroup(displayGroup); - } - - if (event === 'completed' && eventData.success && eventData.result) { - this.checkAndRenderImageResult(eventData.result); - } - } + this.progressiveToolAccordions.set(toolId, accordion); + } + + if (!accordion) { + return; + } + + const hasToolMetadata = + Boolean(data?.toolCall) || + Boolean(data?.name) || + Boolean(data?.technicalName) || + Boolean(data?.displayName); + + const isLiveBatchStep = Boolean(info.isBatchStepEvent); + const eventError = typeof eventData.error === 'string' ? eventData.error : undefined; + + if (event === 'completed' && !hasToolMetadata) { + accordion.completeTool(toolId, eventData.result, eventData.success !== false, eventError); + } else { + const currentGroup = accordion.getDisplayGroup(); + const nextDisplayGroup = isLiveBatchStep + ? normalizeToolCallForDisplay({ + ...eventData, + id: toolId, + toolId, + parentToolCallId: info.parentToolCallId ?? info.batchId ?? toolId, + batchId: info.batchId ?? toolId, + callIndex: info.callIndex, + totalCalls: info.totalCalls, + strategy: info.strategy, + stepId: info.stepId ?? undefined, + status: info.status ?? undefined, + error: eventError + }, currentGroup) + : info.displayGroup; + + const shouldPreserveCurrentBatch = + !isLiveBatchStep && + Boolean(currentGroup) && + currentGroup?.kind === 'batch' && + currentGroup.steps.length > 0 && + nextDisplayGroup.kind === 'batch' && + nextDisplayGroup.steps.length === 0 && + ( + nextDisplayGroup.technicalName === 'useTools' || + nextDisplayGroup.technicalName?.endsWith('.useTools') + ); + + const displayGroup = shouldPreserveCurrentBatch && currentGroup ? currentGroup : nextDisplayGroup; + + accordion.setDisplayGroup(displayGroup); + } + + if (event === 'completed' && eventData.success && eventData.result) { + this.checkAndRenderImageResult(eventData.result); + } + } /** * Create tool bubble on-demand during streaming @@ -539,7 +534,7 @@ export class MessageBubble extends Component { /** * Check if a tool result contains an image path and render it */ - private checkAndRenderImageResult(result: unknown): void { + private checkAndRenderImageResult(result: unknown): void { const imageData = this.extractImageFromResult(result); if (!imageData) return; @@ -549,23 +544,23 @@ export class MessageBubble extends Component { /** * Extract image data from a tool result (supports generateImage tool format) */ - private extractImageFromResult(result: unknown): { imagePath: string; prompt?: string; dimensions?: { width: number; height: number }; model?: string } | null { - if (!result || typeof result !== 'object') return null; - - // Handle both direct result and nested data structure - const directResult = result as { data?: unknown }; - const data = directResult.data ?? result; - - // Check for imagePath which indicates an image generation result - if (data && typeof data === 'object' && typeof (data as { imagePath?: unknown }).imagePath === 'string') { - const typedData = data as { imagePath: string; prompt?: unknown; revisedPrompt?: unknown; dimensions?: { width: number; height: number }; model?: unknown }; - return { - imagePath: typedData.imagePath, - prompt: (typedData.prompt as string | undefined) || (typedData.revisedPrompt as string | undefined), - dimensions: typedData.dimensions, - model: typedData.model as string | undefined - }; - } + private extractImageFromResult(result: unknown): { imagePath: string; prompt?: string; dimensions?: { width: number; height: number }; model?: string } | null { + if (!result || typeof result !== 'object') return null; + + // Handle both direct result and nested data structure + const directResult = result as { data?: unknown }; + const data = directResult.data ?? result; + + // Check for imagePath which indicates an image generation result + if (data && typeof data === 'object' && typeof (data as { imagePath?: unknown }).imagePath === 'string') { + const typedData = data as { imagePath: string; prompt?: unknown; revisedPrompt?: unknown; dimensions?: { width: number; height: number }; model?: unknown }; + return { + imagePath: typedData.imagePath, + prompt: (typedData.prompt as string | undefined) || (typedData.revisedPrompt as string | undefined), + dimensions: typedData.dimensions, + model: typedData.model as string | undefined + }; + } return null; } @@ -625,12 +620,12 @@ export class MessageBubble extends Component { img.setAttribute('loading', 'lazy'); // Open in Obsidian button - const openButton = bubble.createEl('button', { cls: 'generated-image-open-btn' }); - setIcon(openButton, 'external-link'); - openButton.createSpan({ text: 'Open in Obsidian' }); - this.registerDomEvent(openButton, 'click', () => { - void this.app.workspace.openLinkText(imageData.imagePath, '', false); - }); + const openButton = bubble.createEl('button', { cls: 'generated-image-open-btn' }); + setIcon(openButton, 'external-link'); + openButton.createSpan({ text: 'Open in Obsidian' }); + this.registerDomEvent(openButton, 'click', () => { + void this.app.workspace.openLinkText(imageData.imagePath, '', false); + }); return imageBubble; } @@ -638,73 +633,74 @@ export class MessageBubble extends Component { /** * Get progressive tool accordions for external updates */ - getProgressiveToolAccordions(): Map { - return this.progressiveToolAccordions; - } - - /** - * Determine which DOM structure this message needs. - */ - private getRenderMode(message: ConversationMessage): 'group' | 'standard' { - const activeToolCalls = this.getActiveToolCalls(message); - const hasToolCalls = message.role === 'assistant' && !!activeToolCalls && activeToolCalls.length > 0; - const activeReasoning = this.getActiveReasoning(message); - const hasReasoning = message.role === 'assistant' && !!activeReasoning; - return hasToolCalls || hasReasoning ? 'group' : 'standard'; - } - - /** - * Tool/reasoning messages still need a text bubble while loading so streaming - * updates always have a content container to target. - */ - private shouldRenderTextBubble(message: ConversationMessage): boolean { - if (message.role !== 'assistant') { - return false; - } - - const activeContent = this.getActiveMessageContent(message); - return !!activeContent.trim() || message.state === 'streaming' || !!message.isLoading; - } - - /** - * Replace the current DOM node when the message switches between incompatible - * layouts, such as tool-only -> plain loading bubble during retry. - */ - private rebuildElement(): void { - const previousElement = this.element; - const parentElement = previousElement?.parentElement ?? null; - - this.stopLoadingAnimation(); - this.cleanupProgressiveAccordions(); - - if (this.messageBranchNavigator) { - this.messageBranchNavigator.destroy(); - this.messageBranchNavigator = null; - } - - this.toolBubbleElement = null; - this.textBubbleElement = null; - this.imageBubbleElement = null; - - const nextElement = this.createElement(); - - if (previousElement && parentElement) { - previousElement.replaceWith(nextElement); - } else { - this.element = nextElement; - } - } - - /** - * Render the inline loading indicator used after the initial bubble is on screen. - */ - private appendLoadingIndicator(contentElement: HTMLElement): void { - const loadingDiv = contentElement.createDiv('ai-loading-continuation'); - const loadingSpan = loadingDiv.createEl('span', { cls: 'ai-loading' }); - loadingSpan.appendText('Thinking'); - loadingSpan.createEl('span', { cls: 'dots', text: '...' }); - this.startLoadingAnimation(loadingDiv); - } + getProgressiveToolAccordions(): Map { + return this.progressiveToolAccordions; + } + + /** + * Determine which DOM structure this message needs. + */ + private getRenderMode(message: ConversationMessage): 'group' | 'standard' { + const activeToolCalls = this.getActiveToolCalls(message); + const hasToolCalls = message.role === 'assistant' && !!activeToolCalls && activeToolCalls.length > 0; + const activeReasoning = this.getActiveReasoning(message); + const hasReasoning = message.role === 'assistant' && !!activeReasoning; + return hasToolCalls || hasReasoning ? 'group' : 'standard'; + } + + /** + * Tool/reasoning messages still need a text bubble while loading so streaming + * updates always have a content container to target. + */ + private shouldRenderTextBubble(message: ConversationMessage): boolean { + if (message.role !== 'assistant') { + return false; + } + + const activeContent = this.getActiveMessageContent(message); + return !!activeContent.trim() || message.state === 'streaming' || !!message.isLoading; + } + + /** + * Replace the current DOM node when the message switches between incompatible + * layouts, such as tool-only -> plain loading bubble during retry. + */ + private rebuildElement(): void { + const previousElement = this.element; + const parentElement = previousElement?.parentElement ?? null; + + this.stopLoadingAnimation(); + this.cleanupProgressiveAccordions(); + + if (this.messageBranchNavigator) { + this.messageBranchNavigator.destroy(); + this.messageBranchNavigator = null; + } + + this.toolBubbleElement = null; + this.textBubbleElement = null; + this.imageBubbleElement = null; + this.cleanupActionBar(); + + const nextElement = this.createElement(); + + if (previousElement && parentElement) { + previousElement.replaceWith(nextElement); + } else { + this.element = nextElement; + } + } + + /** + * Render the inline loading indicator used after the initial bubble is on screen. + */ + private appendLoadingIndicator(contentElement: HTMLElement): void { + const loadingDiv = contentElement.createDiv('ai-loading-continuation'); + const loadingSpan = loadingDiv.createEl('span', { cls: 'ai-loading' }); + loadingSpan.appendText('Thinking'); + loadingSpan.createEl('span', { cls: 'dots', text: '...' }); + this.startLoadingAnimation(loadingDiv); + } /** * Get the active content for the message (original or from branch) @@ -733,7 +729,7 @@ export class MessageBubble extends Component { /** * Get the active tool calls for the message (original or from branch) */ - private getActiveToolCalls(message: ConversationMessage): ConversationMessage['toolCalls'] | undefined { + private getActiveToolCalls(message: ConversationMessage): ConversationMessage['toolCalls'] | undefined { const activeIndex = message.activeAlternativeIndex || 0; if (activeIndex === 0) { @@ -809,6 +805,39 @@ export class MessageBubble extends Component { this.progressiveToolAccordions.clear(); } + /** + * Populate the existing .message-actions-external pill with action buttons + * for completed assistant messages that have non-empty text content. + * Buttons are inserted into the pill that already sits in the upper-right + * corner — no new wrapper element is created. + */ + private appendActionBar(container: HTMLElement, message: ConversationMessage): void { + if (message.role !== 'assistant') return; + if (message.isLoading || message.state === 'streaming') return; + + const activeContent = this.getActiveMessageContent(message); + if (!activeContent.trim()) return; + + // Only create once per message lifecycle — rebuildElement resets this.actionBar + if (this.actionBar !== null) return; + + const actionsEl = container.querySelector('.message-actions-external'); + if (!(actionsEl instanceof HTMLElement)) return; + + this.actionBar = new MessageActionBar(activeContent, this.app); + this.actionBar.renderInto(actionsEl); + } + + /** + * Remove action buttons from the pill and unload event handlers. + */ + private cleanupActionBar(): void { + if (!this.actionBar) return; + this.actionBar.removeFromContainer(); + this.actionBar.unload(); + this.actionBar = null; + } + /** * Cleanup resources. * Calls Component.unload() to auto-clean registerDomEvent/registerInterval handlers. @@ -818,6 +847,7 @@ export class MessageBubble extends Component { cleanup(): void { this.stopLoadingAnimation(); this.cleanupProgressiveAccordions(); + this.cleanupActionBar(); if (this.messageBranchNavigator) { this.messageBranchNavigator.destroy(); diff --git a/src/ui/chat/components/MessageDisplay.ts b/src/ui/chat/components/MessageDisplay.ts index e3966175b..32ca678a3 100644 --- a/src/ui/chat/components/MessageDisplay.ts +++ b/src/ui/chat/components/MessageDisplay.ts @@ -4,28 +4,28 @@ * Shows conversation messages with user/AI bubbles and tool execution displays */ -import { ConversationData, ConversationMessage } from '../../../types/chat/ChatTypes'; -import { MessageBubble } from './MessageBubble'; -import { BranchManager } from '../services/BranchManager'; -import { App, setIcon, ButtonComponent } from 'obsidian'; -import { ToolEventInfo, ToolEventParser } from '../utils/ToolEventParser'; - -export class MessageDisplay { - private conversation: ConversationData | null = null; - private currentConversationId: string | null = null; - private messageBubbles: Map = new Map(); - private transientEventRow: HTMLElement | null = null; +import { ConversationData, ConversationMessage } from '../../../types/chat/ChatTypes'; +import { MessageBubble } from './MessageBubble'; +import { BranchManager } from '../services/BranchManager'; +import { App, setIcon, ButtonComponent } from 'obsidian'; +import { ToolEventInfo, ToolEventParser } from '../utils/ToolEventParser'; + +export class MessageDisplay { + private conversation: ConversationData | null = null; + private currentConversationId: string | null = null; + private messageBubbles: Map = new Map(); + private transientEventRow: HTMLElement | null = null; constructor( private container: HTMLElement, - private app: App, - private branchManager: BranchManager, - private onRetryMessage?: (messageId: string) => void, - private onEditMessage?: (messageId: string, newContent: string) => void, - private onToolEvent?: (messageId: string, event: 'detected' | 'updated' | 'started' | 'completed', data: ToolEventInfo) => void, - private onMessageAlternativeChanged?: (messageId: string, alternativeIndex: number) => void, - private onViewBranch?: (branchId: string) => void - ) { + private app: App, + private branchManager: BranchManager, + private onRetryMessage?: (messageId: string) => void, + private onEditMessage?: (messageId: string, newContent: string) => void, + private onToolEvent?: (messageId: string, event: 'detected' | 'updated' | 'started' | 'completed', data: ToolEventInfo) => void, + private onMessageAlternativeChanged?: (messageId: string, alternativeIndex: number) => void, + private onViewBranch?: (branchId: string) => void + ) { this.render(); } @@ -57,8 +57,8 @@ export class MessageDisplay { * Reuses existing MessageBubble instances for messages that still exist, * removes stale ones, and creates new ones -- preserving live UI state. */ - private reconcile(conversation: ConversationData): void { - const messagesContainer = this.container.querySelector('.messages-container'); + private reconcile(conversation: ConversationData): void { + const messagesContainer = this.container.querySelector('.messages-container'); if (!messagesContainer) { // No messages container yet (e.g., was showing welcome) -- fall back to full render this.render(); @@ -82,7 +82,7 @@ export class MessageDisplay { // 2. Walk new messages in order: update existing, create new, ensure DOM order let previousElement: Element | null = null; - for (const message of newMessages) { + for (const message of newMessages) { const existingBubble = this.messageBubbles.get(message.id); if (existingBubble) { @@ -113,11 +113,11 @@ export class MessageDisplay { messagesContainer.prepend(bubbleEl); } previousElement = bubbleEl; - } - } - - this.ensureTransientEventRowPosition(messagesContainer as HTMLElement); - } + } + } + + this.ensureTransientEventRowPosition(messagesContainer as HTMLElement); + } /** * Add a user message immediately (for optimistic updates) @@ -142,22 +142,22 @@ export class MessageDisplay { /** * Add a message immediately using the actual message object (prevents duplicate message creation) */ - addMessage(message: ConversationMessage): void { - const bubble = this.createMessageBubble(message); - this.container.querySelector('.messages-container')?.appendChild(bubble); - this.ensureTransientEventRowPosition(this.container.querySelector('.messages-container')); - this.scrollToBottom(); - } + addMessage(message: ConversationMessage): void { + const bubble = this.createMessageBubble(message); + this.container.querySelector('.messages-container')?.appendChild(bubble); + this.ensureTransientEventRowPosition(this.container.querySelector('.messages-container')); + this.scrollToBottom(); + } /** * Add an AI message immediately (for streaming setup) */ - addAIMessage(message: ConversationMessage): void { - const bubble = this.createMessageBubble(message); - this.container.querySelector('.messages-container')?.appendChild(bubble); - this.ensureTransientEventRowPosition(this.container.querySelector('.messages-container')); - this.scrollToBottom(); - } + addAIMessage(message: ConversationMessage): void { + const bubble = this.createMessageBubble(message); + this.container.querySelector('.messages-container')?.appendChild(bubble); + this.ensureTransientEventRowPosition(this.container.querySelector('.messages-container')); + this.scrollToBottom(); + } /** * Update a specific message content for final display (streaming handled by StreamingController) @@ -190,15 +190,6 @@ export class MessageDisplay { } } - /** - * Escape HTML for safe display - */ - private escapeHtml(text: string): string { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } - /** * Show welcome state @@ -224,7 +215,7 @@ export class MessageDisplay { * Full render - destroys all existing bubbles and rebuilds from scratch. * Used for conversation switches and initial load. */ - private render(): void { + private render(): void { // Cleanup all existing bubbles before clearing the DOM for (const bubble of this.messageBubbles.values()) { bubble.cleanup(); @@ -243,65 +234,65 @@ export class MessageDisplay { const messagesContainer = this.container.createDiv('messages-container'); // Render all messages (no branch filtering needed for message-level alternatives) - this.conversation.messages.forEach((message) => { - const messageEl = this.createMessageBubble(message); - messagesContainer.appendChild(messageEl); - }); - - this.ensureTransientEventRowPosition(messagesContainer); - - this.scrollToBottom(); - } - - showTransientEventRow(message: string): void { - if (!message.trim()) { - this.clearTransientEventRow(); - return; - } - - if (!this.transientEventRow) { - this.transientEventRow = this.createTransientEventRow(message); - } else { - const textEl = this.transientEventRow.querySelector('.message-display-event-text'); - if (textEl) { - textEl.textContent = message; - } - } - - this.ensureTransientEventRowPosition(this.container.querySelector('.messages-container')); - this.scrollToBottom(); - } - - clearTransientEventRow(): void { - if (this.transientEventRow) { - this.transientEventRow.remove(); - this.transientEventRow = null; - } - } - - private createTransientEventRow(message: string): HTMLElement { - const row = this.container.createDiv('message-display-event-row'); - row.setAttribute('role', 'status'); - row.setAttribute('aria-live', 'polite'); - row.setAttribute('aria-atomic', 'true'); - - const pill = row.createDiv('message-display-event-pill'); - pill.createSpan({ cls: 'message-display-event-dot' }); - pill.createSpan({ - cls: 'message-display-event-text', - text: message - }); - - return row; - } - - private ensureTransientEventRowPosition(messagesContainer: HTMLElement | null): void { - if (!messagesContainer || !this.transientEventRow) { - return; - } - - messagesContainer.appendChild(this.transientEventRow); - } + this.conversation.messages.forEach((message) => { + const messageEl = this.createMessageBubble(message); + messagesContainer.appendChild(messageEl); + }); + + this.ensureTransientEventRowPosition(messagesContainer); + + this.scrollToBottom(); + } + + showTransientEventRow(message: string): void { + if (!message.trim()) { + this.clearTransientEventRow(); + return; + } + + if (!this.transientEventRow) { + this.transientEventRow = this.createTransientEventRow(message); + } else { + const textEl = this.transientEventRow.querySelector('.message-display-event-text'); + if (textEl) { + textEl.textContent = message; + } + } + + this.ensureTransientEventRowPosition(this.container.querySelector('.messages-container')); + this.scrollToBottom(); + } + + clearTransientEventRow(): void { + if (this.transientEventRow) { + this.transientEventRow.remove(); + this.transientEventRow = null; + } + } + + private createTransientEventRow(message: string): HTMLElement { + const row = this.container.createDiv('message-display-event-row'); + row.setAttribute('role', 'status'); + row.setAttribute('aria-live', 'polite'); + row.setAttribute('aria-atomic', 'true'); + + const pill = row.createDiv('message-display-event-pill'); + pill.createSpan({ cls: 'message-display-event-dot' }); + pill.createSpan({ + cls: 'message-display-event-text', + text: message + }); + + return row; + } + + private ensureTransientEventRowPosition(messagesContainer: HTMLElement | null): void { + if (!messagesContainer || !this.transientEventRow) { + return; + } + + messagesContainer.appendChild(this.transientEventRow); + } /** * Create a message bubble element @@ -316,18 +307,18 @@ export class MessageDisplay { } : message; - const bubble = new MessageBubble( - displayMessage, - this.app, - (messageId: string) => this.onCopyMessage(messageId), - (messageId: string) => this.handleRetryMessage(messageId), - (messageId: string, newContent: string) => this.handleEditMessage(messageId, newContent), - this.onToolEvent - ? (messageId, event, data) => this.onToolEvent?.(messageId, event, ToolEventParser.getToolEventInfo(data, event)) - : undefined, - this.onMessageAlternativeChanged ? (messageId: string, alternativeIndex: number) => this.handleMessageAlternativeChanged(messageId, alternativeIndex) : undefined, - this.onViewBranch - ); + const bubble = new MessageBubble( + displayMessage, + this.app, + (messageId: string) => this.onCopyMessage(messageId), + (messageId: string) => this.handleRetryMessage(messageId), + (messageId: string, newContent: string) => this.handleEditMessage(messageId, newContent), + this.onToolEvent + ? (messageId, event, data) => this.onToolEvent?.(messageId, event, ToolEventParser.getToolEventInfo(data, event)) + : undefined, + this.onMessageAlternativeChanged ? (messageId: string, alternativeIndex: number) => this.handleMessageAlternativeChanged(messageId, alternativeIndex) : undefined, + this.onViewBranch + ); this.messageBubbles.set(message.id, bubble); @@ -342,16 +333,22 @@ export class MessageDisplay { * Handle copy message action */ private onCopyMessage(messageId: string): void { - const message = this.findMessage(messageId); - if (message) { - navigator.clipboard.writeText(message.content).then(() => { - // Message copied to clipboard - }).catch(() => { - // Failed to copy message - return; - }); - } - } + const message = this.findMessage(messageId); + if (message) { + // Use branch-aware content so copying page 1 gives page 1 and page 2 gives page 2. + // message.content is always the most-recently-streamed response; branches hold older + // alternatives. Reading message.content directly ignores the active page selection. + const content = this.branchManager + ? this.branchManager.getActiveMessageContent(message) + : message.content; + navigator.clipboard.writeText(content).then(() => { + // Message copied to clipboard + }).catch(() => { + // Failed to copy message + return; + }); + } + } /** * Handle retry message action @@ -457,12 +454,12 @@ export class MessageDisplay { /** * Cleanup resources */ - cleanup(): void { - for (const bubble of this.messageBubbles.values()) { - bubble.cleanup(); - } - this.messageBubbles.clear(); - this.clearTransientEventRow(); - this.currentConversationId = null; - } -} + cleanup(): void { + for (const bubble of this.messageBubbles.values()) { + bubble.cleanup(); + } + this.messageBubbles.clear(); + this.clearTransientEventRow(); + this.currentConversationId = null; + } +} diff --git a/src/ui/chat/components/ProgressiveToolAccordion.ts b/src/ui/chat/components/ProgressiveToolAccordion.ts index c7ce7e4c7..0112a0a57 100644 --- a/src/ui/chat/components/ProgressiveToolAccordion.ts +++ b/src/ui/chat/components/ProgressiveToolAccordion.ts @@ -225,23 +225,109 @@ export class ProgressiveToolAccordion { } text.textContent = formatToolGroupHeader(this.displayGroup); - this.renderGroupContent(content); + this.updateGroupContent(content); } - private renderGroupContent(content: HTMLElement): void { - content.empty(); - + /** + * Targeted content update — reconciles existing DOM with current state + * instead of wiping and rebuilding on every refresh call. + * New steps are appended; existing steps are updated in place. + */ + private updateGroupContent(content: HTMLElement): void { if (!this.displayGroup) { return; } if (this.displayGroup.kind === 'reasoning') { - this.renderReasoningItem(content, this.displayGroup.steps[0]); + const step = this.displayGroup.steps[0]; + if (!step) { + return; + } + const existingRaw = content.querySelector('.reasoning-item'); + const existing = existingRaw instanceof HTMLElement ? existingRaw : null; + if (!existing) { + this.renderReasoningItem(content, step); + } else { + this.updateReasoningItem(existing, step); + } return; } for (const step of this.displayGroup.steps) { - this.renderStepItem(content, step); + const existingRaw = content.querySelector(`[data-tool-id="${step.id}"]`); + const existing = existingRaw instanceof HTMLElement ? existingRaw : null; + if (!existing) { + this.renderStepItem(content, step); + } else { + this.updateStepItem(existing, step); + } + } + } + + private updateReasoningItem(item: HTMLElement, step: ToolDisplayStep): void { + item.className = `progressive-tool-item reasoning-item tool-${step.status}`; + + const metaRaw = item.querySelector('.tool-meta'); + const meta = metaRaw instanceof HTMLElement ? metaRaw : null; + if (meta) { + if (step.status === 'streaming' || step.status === 'executing') { + meta.textContent = 'Thinking...'; + meta.addClass('reasoning-streaming'); + } else { + meta.textContent = ''; + meta.removeClass('reasoning-streaming'); + } + } + + const reasoningContentRaw = item.querySelector('[data-reasoning-content]'); + const reasoningContent = reasoningContentRaw instanceof HTMLElement ? reasoningContentRaw : null; + if (reasoningContent) { + reasoningContent.textContent = typeof step.result === 'string' ? step.result : ''; + } + + const existingIndicator = item.querySelector('.reasoning-streaming-indicator'); + if (step.status === 'streaming' || step.status === 'executing') { + if (!existingIndicator) { + const section = item.querySelector('.reasoning-content-section'); + if (section) { + const indicator = section.createDiv('reasoning-streaming-indicator'); + indicator.textContent = '⋯'; + } + } + } else { + existingIndicator?.remove(); + } + } + + private updateStepItem(item: HTMLElement, step: ToolDisplayStep): void { + item.className = `progressive-tool-item tool-${step.status}`; + + const nameRaw = item.querySelector('.tool-name'); + const name = nameRaw instanceof HTMLElement ? nameRaw : null; + if (name) { + name.textContent = step.displayName || formatToolStepLabel(step, this.getTenseForStep(step)); + } + + const metaRaw = item.querySelector('.tool-meta'); + const meta = metaRaw instanceof HTMLElement ? metaRaw : null; + if (meta) { + this.updateExecutionMeta(meta, step); + } + + if (step.status === 'completed' && step.result !== undefined) { + const resultSectionRaw = item.querySelector(`[data-result-section="${step.id}"]`); + const resultSection = resultSectionRaw instanceof HTMLElement ? resultSectionRaw : null; + if (resultSection && resultSection.hasClass('progressive-accordion-hidden')) { + this.renderResultSection(resultSection, step); + } + } + + if (step.status === 'failed' && step.error) { + const errorSectionRaw = item.querySelector(`[data-error-section="${step.id}"]`); + const errorSection = errorSectionRaw instanceof HTMLElement ? errorSectionRaw : null; + if (errorSection && errorSection.hasClass('progressive-accordion-hidden')) { + this.renderErrorSection(errorSection, step.error); + } } } diff --git a/src/ui/chat/components/factories/ToolBubbleFactory.ts b/src/ui/chat/components/factories/ToolBubbleFactory.ts index bb8d9b2f0..7d91eaa59 100644 --- a/src/ui/chat/components/factories/ToolBubbleFactory.ts +++ b/src/ui/chat/components/factories/ToolBubbleFactory.ts @@ -94,11 +94,7 @@ export class ToolBubbleFactory { static createTextBubble( message: ConversationMessage, renderContentCallback: (content: HTMLElement, text: string) => Promise, - onCopy: (messageId: string) => void, - showCopyFeedback: (button: HTMLElement) => void, - messageBranchNavigator: MessageBranchNavigatorLike | null, - onMessageAlternativeChanged?: (messageId: string, alternativeIndex: number) => void, - component?: Component + messageBranchNavigator: MessageBranchNavigatorLike | null ): HTMLElement { const messageContainer = document.createElement('div'); messageContainer.addClass('message-container'); @@ -107,13 +103,11 @@ export class ToolBubbleFactory { const bubble = messageContainer.createDiv('message-bubble'); - // Actions inside the bubble (for sticky positioning) - const actions = bubble.createDiv('message-actions-external'); - - // Header with bot icon + // Header with bot icon; actions pill sits in the header top-right const header = bubble.createDiv('message-header'); const roleIcon = header.createDiv('message-role-icon'); setIcon(roleIcon, 'bot'); + header.createDiv('message-actions-external'); // Message content const content = bubble.createDiv('message-content'); @@ -124,22 +118,6 @@ export class ToolBubbleFactory { console.error('[ToolBubbleFactory] Error rendering text bubble content:', error); }); - // Copy button - const copyBtn = actions.createEl('button', { - cls: 'message-action-btn clickable-icon', - attr: { title: 'Copy message' } - }); - setIcon(copyBtn, 'copy'); - const copyHandler = () => { - showCopyFeedback(copyBtn); - onCopy(message.id); - }; - if (component) { - component.registerDomEvent(copyBtn, 'click', copyHandler); - } else { - copyBtn.addEventListener('click', copyHandler); - } - // Message branch navigator for messages with branches if (message.branches && message.branches.length > 0 && messageBranchNavigator) { messageBranchNavigator.updateMessage(message); diff --git a/src/ui/chat/controllers/StreamingController.ts b/src/ui/chat/controllers/StreamingController.ts index 70e950854..674ae322a 100644 --- a/src/ui/chat/controllers/StreamingController.ts +++ b/src/ui/chat/controllers/StreamingController.ts @@ -218,15 +218,6 @@ export class StreamingController { return null; } - /** - * Escape HTML for safe display - */ - private escapeHtml(text: string): string { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } - /** * Get active animation count (for debugging/monitoring) */ diff --git a/src/ui/chat/services/ConversationManager.ts b/src/ui/chat/services/ConversationManager.ts index e92b9a56f..759ab473e 100644 --- a/src/ui/chat/services/ConversationManager.ts +++ b/src/ui/chat/services/ConversationManager.ts @@ -44,7 +44,7 @@ export class ConversationManager { */ async loadConversations(): Promise { try { - this.conversations = await this.chatService.listConversations({ limit: 50 }); + this.conversations = await this.chatService.listConversations({ limit: 500 }); this.events.onConversationsChanged(); @@ -52,9 +52,9 @@ export class ConversationManager { if (this.conversations.length > 0 && !this.currentConversation) { await this.selectConversation(this.conversations[0]); } - } catch { - this.events.onError('Failed to load conversations'); - } + } catch { + this.events.onError('Failed to load conversations'); + } } /** @@ -71,9 +71,9 @@ export class ConversationManager { this.currentConversation = fullConversation; this.events.onConversationSelected(fullConversation); } - } catch { - this.events.onError('Failed to load conversation'); - } + } catch { + this.events.onError('Failed to load conversation'); + } } /** @@ -97,9 +97,9 @@ export class ConversationManager { } else { this.events.onError(result.error || 'Failed to create conversation'); } - } catch { - this.events.onError('Failed to create conversation'); - } + } catch { + this.events.onError('Failed to create conversation'); + } } /** @@ -145,9 +145,9 @@ export class ConversationManager { } else { this.events.onError(result.error || 'Failed to create conversation'); } - } catch { - this.events.onError('Failed to create conversation'); - } + } catch { + this.events.onError('Failed to create conversation'); + } } /** @@ -168,9 +168,9 @@ export class ConversationManager { } else { this.events.onError('Failed to delete conversation'); } - } catch { - this.events.onError('Failed to delete conversation'); - } + } catch { + this.events.onError('Failed to delete conversation'); + } } /** @@ -197,9 +197,9 @@ export class ConversationManager { } else { this.events.onError('Failed to rename conversation'); } - } catch { - this.events.onError('Failed to rename conversation'); - } + } catch { + this.events.onError('Failed to rename conversation'); + } } /** @@ -230,4 +230,4 @@ export class ConversationManager { }); } -} +} diff --git a/src/ui/chat/services/ModelAgentManager.ts b/src/ui/chat/services/ModelAgentManager.ts index 088509974..ff8f6dbe5 100644 --- a/src/ui/chat/services/ModelAgentManager.ts +++ b/src/ui/chat/services/ModelAgentManager.ts @@ -6,7 +6,7 @@ import { ModelOption, PromptOption } from '../types/SelectionTypes'; import { WorkspaceContext } from '../../../database/types/workspace/WorkspaceTypes'; import { MessageEnhancement } from '../components/suggesters/base/SuggesterInterfaces'; -import { SystemPromptBuilder, PromptSummary, ToolAgentInfo, ContextStatusInfo } from './SystemPromptBuilder'; +import { SystemPromptBuilder, ContextStatusInfo } from './SystemPromptBuilder'; import { ContextNotesManager } from './ContextNotesManager'; import { ModelSelectionUtility } from '../utils/ModelSelectionUtility'; import { PromptConfigurationUtility } from '../utils/PromptConfigurationUtility'; @@ -86,25 +86,6 @@ interface ConversationServiceLike { updateConversationMetadata(conversationId: string, metadata: Record): Promise; } -interface AgentToolLike { - slug?: string; - name?: string; -} - -interface AgentLike { - description?: string; - getTools?: () => AgentToolLike[]; -} - -/** - * App type with plugin registry access - */ -type AppWithPlugins = { - plugins?: { - plugins?: Record; - }; -} & Omit; - /** * Plugin interface with settings structure */ @@ -122,14 +103,6 @@ interface PluginWithSettings { defaultContextNotes?: string[]; }; }; - serviceManager?: { - getServiceIfReady?: (name: string) => unknown; - }; - connector?: { - agentRegistry?: { - getAllAgents: () => Map; - }; - }; } export interface ModelAgentManagerEvents { @@ -146,7 +119,15 @@ export class ModelAgentManager { private currentSystemPrompt: string | null = null; private selectedWorkspaceId: string | null = null; private workspaceContext: WorkspaceContext | null = null; - private loadedWorkspaceData: Record | null = null; // Full comprehensive workspace data from LoadWorkspaceTool + private loadedWorkspaceData: Record | null = null; // Full data — populated on first-message send, cleared immediately after + private pendingFullWorkspaceLoad = false; // True from workspace selection/restore until first message is sent + private selectedWorkspaceSlimData: { // Slim header data — always populated when workspace selected + id: string; + name: string; + description?: string; + purpose?: string; + rootFolder?: string; + } | null = null; private contextNotesManager: ContextNotesManager; private currentConversationId: string | null = null; private messageEnhancement: MessageEnhancement | null = null; @@ -276,6 +257,8 @@ export class ModelAgentManager { this.selectedWorkspaceId = null; this.workspaceContext = null; this.loadedWorkspaceData = null; + this.pendingFullWorkspaceLoad = false; + this.selectedWorkspaceSlimData = null; } } @@ -313,34 +296,45 @@ export class ModelAgentManager { } /** - * Restore workspace from settings - loads full comprehensive data + * Restore workspace from saved conversation metadata. + * Uses a cheap DB lookup instead of the full loadWorkspace agent call so this + * is safe to run at startup and on every conversation switch, regardless of + * agent readiness. selectedWorkspaceId is tentatively set before the await so + * the modal shows the correct selection even if the fetch fails. */ private async restoreWorkspace(workspaceId: string, sessionId?: string): Promise { + // Set tentative state immediately — prevents modal from showing empty on transient errors + this.selectedWorkspaceId = workspaceId; + this.loadedWorkspaceData = null; + this.pendingFullWorkspaceLoad = false; + this.workspaceContext = null; + this.selectedWorkspaceSlimData = null; + try { - // Load full comprehensive workspace data (same as #workspace suggester) - const fullWorkspaceData = await this.workspaceIntegration.loadWorkspace(workspaceId); + const workspace = await this.workspaceIntegration.getWorkspaceBasic(workspaceId); - if (!fullWorkspaceData) { + if (!workspace) { + // Workspace genuinely absent from DB — clear selection this.selectedWorkspaceId = null; - this.loadedWorkspaceData = null; - this.workspaceContext = null; return; } - this.selectedWorkspaceId = (fullWorkspaceData.id as string) || workspaceId; - - this.loadedWorkspaceData = fullWorkspaceData; - // Also extract basic context for backward compatibility - this.workspaceContext = fullWorkspaceData.context || fullWorkspaceData.workspaceContext || null; + this.selectedWorkspaceId = workspace.id; + this.workspaceContext = (workspace.context as WorkspaceContext) || null; + this.selectedWorkspaceSlimData = { + id: workspace.id, + name: workspace.name, + description: workspace.description, + purpose: workspace.context?.['purpose'] as string | undefined, + rootFolder: workspace.rootFolder + }; + this.pendingFullWorkspaceLoad = true; // Full load fires on first message send (G-W3) // Bind session to workspace await this.workspaceIntegration.bindSessionToWorkspace(sessionId, this.selectedWorkspaceId); } catch (error) { console.error('[ModelAgentManager] Failed to restore workspace:', error); - // Clear workspace data on failure - this.selectedWorkspaceId = null; - this.loadedWorkspaceData = null; - this.workspaceContext = null; + // Keep tentative selectedWorkspaceId — do not wipe on transient DB errors } } @@ -367,6 +361,8 @@ export class ModelAgentManager { this.selectedWorkspaceId = null; this.workspaceContext = null; this.loadedWorkspaceData = null; + this.pendingFullWorkspaceLoad = false; + this.selectedWorkspaceSlimData = null; this.contextNotesManager.clear(); this.agentProvider = null; this.agentModel = null; @@ -643,28 +639,39 @@ export class ModelAgentManager { } /** - * Set workspace context - loads full comprehensive data - * When a workspace is selected in chat settings, load the same rich data - * as the #workspace suggester (file structure, sessions, states, etc.) + * Set workspace context when user selects a workspace in chat settings. + * Populates slim header data via cheap DB lookup; full data is loaded on + * first message send (G-W3). */ - async setWorkspaceContext(workspaceId: string, context: WorkspaceContext): Promise { + async setWorkspaceContext(workspaceId: string): Promise { this.selectedWorkspaceId = workspaceId; - this.workspaceContext = context; // Keep basic context for backward compatibility + this.loadedWorkspaceData = null; + this.pendingFullWorkspaceLoad = false; - // Load full comprehensive workspace data (same as #workspace suggester) try { - const fullWorkspaceData = await this.workspaceIntegration.loadWorkspace(workspaceId); - if (fullWorkspaceData) { - this.loadedWorkspaceData = fullWorkspaceData; + const slim = await this.workspaceIntegration.getWorkspaceBasic(workspaceId); + if (slim) { + this.workspaceContext = (slim.context as WorkspaceContext) ?? null; + this.selectedWorkspaceSlimData = { + id: slim.id, + name: slim.name, + description: slim.description, + purpose: slim.context?.['purpose'] as string | undefined, + rootFolder: slim.rootFolder + }; + this.pendingFullWorkspaceLoad = true; // Full load fires on first message send (G-W3) + } else { + this.workspaceContext = null; + this.selectedWorkspaceSlimData = null; } } catch (error) { - console.error('[ModelAgentManager] Failed to load full workspace data:', error); - this.loadedWorkspaceData = null; + console.error('[ModelAgentManager] Failed to load workspace slim data:', error); + this.workspaceContext = null; + this.selectedWorkspaceSlimData = null; } // Get session ID from current conversation const sessionId = await this.getCurrentSessionId(); - if (sessionId) { await this.workspaceIntegration.bindSessionToWorkspace(sessionId, workspaceId); } @@ -679,6 +686,8 @@ export class ModelAgentManager { this.selectedWorkspaceId = null; this.workspaceContext = null; this.loadedWorkspaceData = null; + this.pendingFullWorkspaceLoad = false; + this.selectedWorkspaceSlimData = null; this.events.onSystemPromptChanged(await this.buildSystemPromptWithWorkspace()); } @@ -1014,12 +1023,32 @@ export class ModelAgentManager { thinkingEffort?: 'low' | 'medium' | 'high'; temperature?: number; }> { + // Clear previous turn's full data before deciding whether to load new data. + // This keeps loadedWorkspaceData available to subagents throughout the current + // message-processing cycle (they call getLoadedWorkspaceData() after the send + // returns), and ensures it doesn't bleed into subsequent turns' system prompts. + this.loadedWorkspaceData = null; + + // G-W3: on first message after workspace selection/restore, load full data for this turn only + if (this.pendingFullWorkspaceLoad && this.selectedWorkspaceId) { + this.pendingFullWorkspaceLoad = false; + try { + const fullData = await this.workspaceIntegration.loadWorkspace(this.selectedWorkspaceId); + if (fullData) { + this.loadedWorkspaceData = fullData; + } + } catch (error) { + console.error('[ModelAgentManager] First-message workspace load failed, using slim header:', error); + } + } + const sessionId = await this.getCurrentSessionId(); + const systemPrompt = await this.buildSystemPromptWithWorkspace() || undefined; return { provider: this.selectedModel?.providerId, model: this.selectedModel?.modelId, - systemPrompt: await this.buildSystemPromptWithWorkspace() || undefined, + systemPrompt, workspaceId: this.selectedWorkspaceId || undefined, sessionId: sessionId, enableThinking: this.thinkingSettings.enabled, @@ -1030,17 +1059,10 @@ export class ModelAgentManager { /** * Build system prompt with workspace context and dynamic context - * Dynamic context (vault structure, workspaces, agents) is always fetched fresh */ private async buildSystemPromptWithWorkspace(): Promise { const sessionId = await this.getCurrentSessionId(); - // Fetch dynamic context (always fresh) - const vaultStructure = this.workspaceIntegration.getVaultStructure(); - const availableWorkspaces = await this.workspaceIntegration.listAvailableWorkspaces(); - const availablePrompts = await this.getAvailablePromptSummaries(); - const toolAgents = this.getToolAgentInfo(); - // Skip tools section for Nexus/WebLLM - it's pre-trained on the toolset const isNexusModel = this.selectedModel?.providerId === 'webllm'; @@ -1063,13 +1085,8 @@ export class ModelAgentManager { contextNotes: this.contextNotesManager.getNotes(), messageEnhancement: this.messageEnhancement, customPrompt: this.currentSystemPrompt, - workspaceContext: this.workspaceContext, - loadedWorkspaceData: this.loadedWorkspaceData, // Full comprehensive workspace data - // Dynamic context (always loaded fresh) - vaultStructure, - availableWorkspaces, - availablePrompts, - toolAgents, + loadedWorkspaceData: this.loadedWorkspaceData, // null except on first-message send (G-W3) + selectedWorkspaceSlimData: this.selectedWorkspaceSlimData, // Nexus models are pre-trained on the toolset - skip tools section skipToolsSection: isNexusModel, // Context status for token-limited models @@ -1118,73 +1135,6 @@ export class ModelAgentManager { return this.hasCompactionFrontier(); } - /** - * Get available prompts as summaries for system prompt - * Note: These are user-created prompts, displayed in system prompt for LLM awareness - */ - private async getAvailablePromptSummaries(): Promise { - const prompts = await this.getAvailablePrompts(); - return prompts.map(prompt => ({ - id: prompt.id, - name: prompt.name, - description: prompt.description || 'Custom prompt' - })); - } - - /** - * Get tool agents info from agent registry for system prompt - * Returns agent names, descriptions, and their available tools - */ - private getToolAgentInfo(): ToolAgentInfo[] { - try { - // Access plugin from app - const appWithPlugins = this.app as unknown as AppWithPlugins; - const plugin = appWithPlugins.plugins?.plugins?.['claudesidian-mcp'] as unknown as PluginWithSettings | undefined; - if (!plugin) { - return []; - } - - // Try agentRegistrationService first (works on both desktop and mobile) - const agentService = plugin.serviceManager?.getServiceIfReady?.('agentRegistrationService'); - if (agentService) { - const typedAgentService = agentService as { getAllAgents: () => Map | Array<{ name: string } & AgentLike> }; - const agents = typedAgentService.getAllAgents(); - const agentMap = agents instanceof Map ? agents : new Map(agents.map((a) => [a.name, a])); - - return Array.from(agentMap.entries()).map(([name, agent]) => { - const agentTools = agent.getTools?.() || []; - return { - name, - description: agent.description || '', - tools: agentTools.map(t => t.slug || t.name || 'unknown') - }; - }); - } - - // Fallback to connector's agentRegistry (desktop only) - const connector = plugin.connector; - if (connector?.agentRegistry) { - const agents = connector.agentRegistry.getAllAgents(); - const result: ToolAgentInfo[] = []; - - for (const [name, agent] of agents) { - const agentTools = agent.getTools?.() || []; - result.push({ - name, - description: agent.description || '', - tools: agentTools.map(t => t.slug || t.name || 'unknown') - }); - } - - return result; - } - - return []; - } catch { - return []; - } - } - /** * Get current session ID from conversation */ diff --git a/src/ui/chat/services/SystemPromptBuilder.ts b/src/ui/chat/services/SystemPromptBuilder.ts index 87998e1bd..2cb66df9c 100644 --- a/src/ui/chat/services/SystemPromptBuilder.ts +++ b/src/ui/chat/services/SystemPromptBuilder.ts @@ -55,33 +55,41 @@ export interface ToolAgentInfo { /** * Context status for token-limited models (e.g., Nexus 4K context) */ -export interface ContextStatusInfo { - usedTokens: number; - maxTokens: number; - percentUsed: number; - status: 'ok' | 'warning' | 'critical'; - statusMessage: string; -} - -export interface LoadedWorkspaceData { - id?: string; - name?: string; - context?: { - name?: string; - [key: string]: unknown; - } | null; - [key: string]: unknown; -} +export interface ContextStatusInfo { + usedTokens: number; + maxTokens: number; + percentUsed: number; + status: 'ok' | 'warning' | 'critical'; + statusMessage: string; +} + +export interface LoadedWorkspaceData { + id?: string; + name?: string; + context?: { + name?: string; + [key: string]: unknown; + } | null; + [key: string]: unknown; +} export interface SystemPromptOptions { - sessionId?: string; - workspaceId?: string; - contextNotes?: string[]; - messageEnhancement?: MessageEnhancement | null; - customPrompt?: string | null; - workspaceContext?: WorkspaceContext | null; - // Full comprehensive workspace data from LoadWorkspaceTool (when workspace selected in settings) - loadedWorkspaceData?: LoadedWorkspaceData | null; + sessionId?: string; + workspaceId?: string; + contextNotes?: string[]; + messageEnhancement?: MessageEnhancement | null; + customPrompt?: string | null; + workspaceContext?: WorkspaceContext | null; + // Full comprehensive workspace data — only populated on first-message send (G-W3) + loadedWorkspaceData?: LoadedWorkspaceData | null; + // Slim header data — always populated when a workspace is selected + selectedWorkspaceSlimData?: { + id: string; + name: string; + description?: string; + purpose?: string; + rootFolder?: string; + } | null; // Dynamic context (always loaded fresh) vaultStructure?: VaultStructure | null; availableWorkspaces?: WorkspaceSummary[]; @@ -100,11 +108,11 @@ export interface SystemPromptOptions { previousContext?: CompactedContext | null; } -export class SystemPromptBuilder { - constructor( - private readNoteContent: (notePath: string) => Promise, - private loadWorkspace?: (workspaceId: string) => Promise - ) {} +export class SystemPromptBuilder { + constructor( + private readNoteContent: (notePath: string) => Promise, + private loadWorkspace?: (workspaceId: string) => Promise + ) {} /** * Build complete system prompt with all sections @@ -181,10 +189,10 @@ export class SystemPromptBuilder { sections.push(customPromptSection); } - // 8. Selected workspace context (full data from settings selection) + // 8. Selected workspace context const workspaceSection = this.buildSelectedWorkspaceSection( - options.loadedWorkspaceData, - options.workspaceContext + options.selectedWorkspaceSlimData, + options.loadedWorkspaceData ); if (workspaceSection) { sections.push(workspaceSection); @@ -274,12 +282,12 @@ Prefer targeted context gathering over large dumps. prompt += `\n\n`; } - // Add enhancement notes from [[suggester]] - if (hasEnhancementNotes) { - for (const note of messageEnhancement?.notes || []) { - const xmlTag = this.normalizePathToXmlTag(note.path); - prompt += `<${xmlTag}>\n`; - prompt += `${this.escapeXmlContent(note.path)}\n\n`; + // Add enhancement notes from [[suggester]] + if (hasEnhancementNotes) { + for (const note of messageEnhancement?.notes || []) { + const xmlTag = this.normalizePathToXmlTag(note.path); + prompt += `<${xmlTag}>\n`; + prompt += `${this.escapeXmlContent(note.path)}\n\n`; prompt += this.escapeXmlContent(note.content); prompt += `\n\n`; } @@ -415,22 +423,25 @@ Prefer targeted context gathering over large dumps. } /** - * Build selected workspace section with comprehensive data - * When a workspace is selected in chat settings, include the full workspace data - * (same rich context as the #workspace suggester) + * Build selected workspace section. + * + * Priority: + * 1. loadedWorkspaceData present (first-message send, G-W3) → full JSON blob for that turn + * 2. selectedWorkspaceSlimData present → slim ~100-token header (normal every-turn behavior) + * 3. Neither → omit section */ - private buildSelectedWorkspaceSection( - loadedWorkspaceData?: LoadedWorkspaceData | null, - workspaceContext?: WorkspaceContext | null - ): string | null { - // If we have full workspace data, include the complete object + private buildSelectedWorkspaceSection( + selectedWorkspaceSlimData?: { id: string; name: string; description?: string; purpose?: string; rootFolder?: string } | null, + loadedWorkspaceData?: LoadedWorkspaceData | null + ): string | null { + // Full data path — only active on first-message send (G-W3) if (loadedWorkspaceData) { const workspaceName = loadedWorkspaceData.context?.name || loadedWorkspaceData.name || 'Selected Workspace'; const workspaceId = loadedWorkspaceData.id || 'unknown'; - let prompt = `\n`; + let prompt = `\n`; prompt += 'This workspace is currently selected. Use it as the primary context.\n\n'; prompt += this.escapeXmlContent(JSON.stringify(loadedWorkspaceData, null, 2)); prompt += '\n'; @@ -438,12 +449,19 @@ Prefer targeted context gathering over large dumps. return prompt; } - // Fallback to basic context if no comprehensive data - if (!workspaceContext) { + // Slim header path — every turn when workspace is selected + if (!selectedWorkspaceSlimData) { return null; } - return `\n${this.escapeXmlContent(JSON.stringify(workspaceContext, null, 2))}\n`; + const { id, name, description, purpose, rootFolder } = selectedWorkspaceSlimData; + const lines: string[] = []; + if (description) lines.push(`Description: ${description}`); + if (purpose) lines.push(`Purpose: ${purpose}`); + if (rootFolder) lines.push(`Root folder: ${rootFolder}`); + lines.push('For file structure, sessions, or task details, call memoryManager.loadWorkspace.'); + + return `\n${lines.join('\n')}\n`; } /** diff --git a/src/ui/chat/services/WorkspaceIntegrationService.ts b/src/ui/chat/services/WorkspaceIntegrationService.ts index b10db6c67..7a930b508 100644 --- a/src/ui/chat/services/WorkspaceIntegrationService.ts +++ b/src/ui/chat/services/WorkspaceIntegrationService.ts @@ -190,6 +190,39 @@ export class WorkspaceIntegrationService { return { rootFolders, rootFiles }; } + /** + * Get basic workspace data from DB — cheap lookup, no agent execution. + * Safe to call at any startup phase. Used by restoreWorkspace() to avoid + * agent dependency at conversation-switch time. + */ + async getWorkspaceBasic(workspaceId: string): Promise<{ + id: string; + name: string; + description?: string; + rootFolder?: string; + context?: Record; + } | null> { + try { + const plugin = getNexusPlugin(this.app); + const workspaceService = await plugin?.getService('workspaceService'); + if (!workspaceService) return null; + + const workspace = await workspaceService.getWorkspaceByNameOrId(workspaceId); + if (!workspace) return null; + + return { + id: workspace.id, + name: workspace.name, + description: workspace.description ?? undefined, + rootFolder: workspace.rootFolder ?? undefined, + context: workspace.context as Record | undefined + }; + } catch (error) { + console.error('[WorkspaceIntegrationService] getWorkspaceBasic failed:', error); + return null; + } + } + /** * Get all available workspaces with summary information * Used to give the LLM awareness of what workspaces exist diff --git a/src/ui/chat/utils/ContentProcessor.ts b/src/ui/chat/utils/ContentProcessor.ts deleted file mode 100644 index 400696ff0..000000000 --- a/src/ui/chat/utils/ContentProcessor.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * ContentProcessor - Handles content formatting, escaping, and processing utilities - */ - -export class ContentProcessor { - /** - * Escape HTML for safe display - */ - static escapeHtml(text: string): string { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } - - /** - * Unescape HTML entities - */ - static unescapeHtml(html: string): string { - const doc = new DOMParser().parseFromString(html, 'text/html'); - return doc.body.textContent || ''; - } - - /** - * Process markdown content for display (basic implementation) - */ - static processMarkdown(content: string): string { - // Simple markdown processing - can be enhanced later - let processed = content; - - // Code blocks - processed = processed.replace(/```(\w*)\n([\s\S]*?)```/g, '
$2
'); - - // Inline code - processed = processed.replace(/`([^`]+)`/g, '$1'); - - // Bold - processed = processed.replace(/\*\*([^*]+)\*\*/g, '$1'); - - // Italic - processed = processed.replace(/\*([^*]+)\*/g, '$1'); - - // Headers - processed = processed.replace(/^### (.*$)/gim, '

$1

'); - processed = processed.replace(/^## (.*$)/gim, '

$1

'); - processed = processed.replace(/^# (.*$)/gim, '

$1

'); - - // Lists - processed = processed.replace(/^[\s]*\* (.+)$/gm, '
  • $1
  • '); - processed = processed.replace(/^[\s]*- (.+)$/gm, '
  • $1
  • '); - - // Wrap consecutive list items in ul tags - processed = processed.replace(/(
  • .*<\/li>)/g, '
      $1
    '); - - // Line breaks - processed = processed.replace(/\n/g, '
    '); - - return processed; - } - - /** - * Sanitize content to prevent XSS - */ - static sanitizeContent(content: string): string { - // Remove potentially dangerous tags and attributes - const dangerous = /)<[^<]*)*<\/script>/gi; - let sanitized = content.replace(dangerous, ''); - - // Remove javascript: and data: URLs - sanitized = sanitized.replace(/javascript:/gi, ''); - sanitized = sanitized.replace(/data:/gi, ''); - - // Remove on* event handlers - sanitized = sanitized.replace(/on\w+\s*=/gi, ''); - - return sanitized; - } - - /** - * Truncate text to specified length with ellipsis - */ - static truncateText(text: string, maxLength: number, ellipsis = '...'): string { - if (text.length <= maxLength) { - return text; - } - - return text.substring(0, maxLength - ellipsis.length) + ellipsis; - } - - /** - * Extract plain text from HTML content - */ - static extractPlainText(html: string): string { - const doc = new DOMParser().parseFromString(html, 'text/html'); - return doc.body.textContent || ''; - } - - /** - * Format conversation preview text - */ - static formatConversationPreview(lastMessage: string, maxLength = 100): string { - // Remove markdown formatting for preview - const preview = lastMessage - .replace(/```[\s\S]*?```/g, '[code block]') // Replace code blocks - .replace(/`([^`]+)`/g, '$1') // Remove inline code backticks - .replace(/\*\*([^*]+)\*\*/g, '$1') // Remove bold - .replace(/\*([^*]+)\*/g, '$1') // Remove italic - .replace(/^#+\s*/gm, '') // Remove headers - .replace(/^\s*[-*]\s*/gm, '') // Remove list markers - .replace(/\n+/g, ' ') // Replace newlines with spaces - .trim(); - - return this.truncateText(preview, maxLength); - } - - /** - * Validate and clean message content - */ - static cleanMessageContent(content: string): string { - // Trim whitespace - let cleaned = content.trim(); - - // Remove excessive whitespace - cleaned = cleaned.replace(/\s+/g, ' '); - - // Remove null bytes - cleaned = cleaned.replace(/\0/g, ''); - - return cleaned; - } - - /** - * Check if content is safe for display - */ - static isContentSafe(content: string): boolean { - // Check for dangerous patterns - const dangerousPatterns = [ - /