diff --git a/.gitignore b/.gitignore index 48b7410ca..528f6699a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,12 +17,16 @@ BCPs.md CLAUDE.md docs/* +!docs/fork_divergence.md !docs/plans/ docs/plans/* !docs/plans/*.md !docs/research/ docs/research/* !docs/research/*.md +!docs/review/ +docs/review/* +!docs/review/*.md !docs/mockups/ docs/mockups/* !docs/mockups/*.html diff --git a/docs/fork_divergence.md b/docs/fork_divergence.md new file mode 100644 index 000000000..10788adeb --- /dev/null +++ b/docs/fork_divergence.md @@ -0,0 +1,116 @@ +# Fork Divergence Registry + +This file is the authoritative record of every file in `my-custom-branch` that intentionally +diverges from upstream (`ProfSynapse/nexus`). Load it at the start of every upstream merge +session to know which files require manual resolution and which can be auto-merged. + +**Last audited against:** upstream/main HEAD (`f4e49fd3`) — PRs #118, #119, #121 +**Audit date:** 2026-04-08 +**Next merge target:** next upstream/main HEAD (watch for new PRs) + +--- + +## Tier 1 — Always conflict on upstream merge + +These files contain fork-specific additions that upstream will never have. Every upstream merge +requires manual resolution using the pattern: accept upstream base, then layer back the fork additions. + +| File | Fork change | Resolution pattern | +|------|-------------|-------------------| +| `src/ui/chat/components/MessageBubble.ts` | Action bar: `import MessageActionBar`, `private actionBar` field, `appendActionBar()`, `cleanupActionBar()`, call sites in createElement/updateWithNewMessage/cleanup | Take upstream as base; layer back all action bar insertions; fix `createTextBubble` call back to 3-arg (upstream keeps reverting to 7-arg) | +| `src/ui/chat/components/factories/ToolBubbleFactory.ts` | `createTextBubble` is 3-param (onCopy/showCopyFeedback removed — action bar owns copy). **Note:** upstream base is 7-param but upstream has not changed this file — git auto-keeps our 3-param. The recurring risk is the **call site in MessageBubble.ts** — every merge where upstream touches MessageBubble risks reverting it to 7 args. Always check after merge and fix if needed. | Git auto-keeps 3-param; verify MessageBubble.ts call site is 3-arg | + +**Fork-only files (no upstream counterpart — always rebase cleanly):** +- `src/ui/chat/components/MessageActionBar.ts` +- `src/ui/chat/components/CreateFileModal.ts` + +--- + +## Tier 2 — Conflict only when upstream touches them + +These files have fork additions that are self-contained. Upstream rarely touches them, but when +they do a conflict will occur. Resolution is always: take upstream base, then restore the fork block. + +| File | Fork change | Fork block to restore | +|------|-------------|----------------------| +| `styles.css` | Sticky assistant header rule | CSS rule block labelled `/* fork: sticky assistant header */` | +| `src/ui/chat/builders/ChatLayoutBuilder.ts` | Banner removal (beta/experimental warning stripped) | Remove the banner call after taking upstream | +| `src/database/schema/SchemaMigrator.ts` | Convention comment + fork migrations v12–v19 | Comment block + all migrations numbered ≥ 20 (our convention: upstream ≤ 19, fork ≥ 20) | +| `src/database/adapters/HybridStorageAdapter.ts` | `pruneOrphanedConversationFiles()` runs on startup — **TEMPORARY**; remove once vault reports zero pruned files for several consecutive sessions. Also upstream touched this file in PR #119 and likely will again. | Re-insert `if (syncState) { await pruneOrphanedConversationFiles() }` block after `getSyncState` call, before rebuild/sync. Keep upstream's `initialized = true` block ABOVE it. | + +--- + +## Tier 3 — Fork bug fixes (low conflict risk, but track for awareness) + +These files contain fixes for bugs present in upstream or data-quality issues specific to this +installation. They are unlikely to conflict because upstream is not touching the same lines, but +they must be reviewed on each merge to ensure upstream hasn't shipped a conflicting fix. + +### Null-safe `workspace.name` fixes +Upstream has a historical record with `name: null` that crashes `.toLowerCase()`. Fixed with +optional chaining. If upstream fixes this themselves, take their version and drop ours. + +| File | Change | +|------|--------| +| `src/agents/searchManager/services/MemorySearchProcessor.ts` | `state.name?.toLowerCase()`, `workspace.name?.toLowerCase()` | +| `src/agents/toolManager/services/ToolBatchExecutionService.ts` | `workspace.name?.toLowerCase()` | +| `src/services/WorkspaceService.ts` | `(a.name ?? '').localeCompare(b.name ?? '')`, two `ws.name?.toLowerCase()` guards | + +### JSONL data quality fixes +Fixes for streaming write amplification and large-file read limits. ConversationRepository now +uses upstream's tombstone approach (no fork divergence); pruning still needed for pre-tombstone orphans. + +| File | Change | +|------|--------| +| `src/database/adapters/HybridStorageAdapter.ts` | `pruneOrphanedConversationFiles()` runs on startup to clean orphaned `.jsonl` files — **temporary**: remove once vault reports zero pruned files at startup for several consecutive sessions | +| `src/database/repositories/MessageRepository.ts` | Skips JSONL write during streaming states (`draft`/`streaming`) — prevents O(n²) storage growth | +| `src/database/storage/JSONLWriter.ts` | `readEventsStreaming()` fallback via Node.js readline for files >50 MB; `stat?.()` optional-chain safe for test environments | +| `eslint.config.mjs` | Added `JSONLWriter.ts` to `import/no-nodejs-modules` exceptions (uses `require('fs')`, `require('readline')`) | + +### Schema / embedding fix + +| File | Change | +|------|--------| +| `src/database/storage/SQLiteMaintenanceService.ts` | `fixVec0TableDimensions()` — drops and recreates `note_embeddings` / `block_embeddings` if they were created with `float[768]` (legacy Nomic era); no-op when dimensions correct | +| `src/database/storage/SQLiteCacheManager.ts` | Calls `getMaintenanceService().fixVec0TableDimensions()` after migrations in `initialize()` | + +### Provider / HTTP fixes + +| File | Change | +|------|--------| +| `src/settings/tabs/ProvidersTab.ts` | `onSave` simplified from IIFE `void (async () => {...})()` to direct `async` callback | +| `src/components/LLMProviderModal.ts` | `onSave` type widened to `void \| Promise`; auto-save path awaits the callback with try/catch | + +### UI / UX fixes + +| File | Change | +|------|--------| +| `src/ui/chat/components/ContextProgressBar.ts` | Uses `removeAttribute('class') + addClass()` instead of `className =` (Obsidian API correctness) | +| `src/components/shared/ChatSettingsRenderer.ts` | Removed `void` from `this.syncWorkspacePrompt(value)` call | + +**Retired entries (absorbed by upstream PR #119):** +- `ChatView.ts` — `active-leaf-change` handler: now in upstream's ChatView (line 607). No longer fork-divergent. +- `BranchHeader.ts` — JSDoc: BranchHeader ownership moved to `ChatBranchViewCoordinator`. No longer fork-divergent. + +--- + +## Special case — connectorContent.ts + +`src/utils/connectorContent.ts` is generated during build (timestamp in header). It should be +**reset to upstream before every merge** with: + +``` +git checkout upstream/main -- src/utils/connectorContent.ts +``` + +Do not treat timestamp-only diffs as fork divergences. + +--- + +## How to use this file + +1. Before each upstream merge, run: `git diff --name-status upstream/main..my-custom-branch` +2. Cross-reference each modified file against this registry +3. Files not listed here should match upstream exactly — investigate any that don't +4. After resolving conflicts, re-run the diff to confirm no unintended divergences remain +5. If new fork additions are made, add them to this file before committing diff --git a/docs/plans/upstream-merge-next.md b/docs/plans/upstream-merge-next.md new file mode 100644 index 000000000..3dbe2cf3e --- /dev/null +++ b/docs/plans/upstream-merge-next.md @@ -0,0 +1,339 @@ +# Upstream Merge Plan — Post-v5.6.10 + +**Target:** `upstream/main` HEAD (`35bed848`) +**Baseline:** `5.6.10` tag (`425a568f`) +**Our branch:** `my-custom-branch` (`bc69732f` after congruence cleanup) +**Created:** 2026-04-07 + +--- + +## Upstream PRs to merge (since v5.6.10) + +| PR | Title | Our impact | +|----|-------|-----------| +| #103 | fix-mobile-chat | ProviderHttpClient (overlaps our fix) | +| #106 | audit-midway-prs + remove dead per-turn fetches | SystemPromptBuilder cleanup | +| #107 | fix/workspace-context-guard | WorkspaceService guard removal | +| (no PR) | plugin-scoped storage + mobile sync | HybridStorageAdapter overhaul | +| #112 | model-agent-manager-refactor | ModelAgentManager split (5 new files) | +| #113 | sqlite-cache-manager-refactor | SQLiteCacheManager split (6 new files) | +| #114 | build-fixes-system-prompt-model-selection | WorkflowRunService, settings fixes | +| **#115** | **message-bubble-refactor** | **CRITICAL — Tier 1 file** | +| (post-PR) | two-stage migration, cache.db path routing | HybridStorageAdapter init | + +--- + +## Pre-merge setup + +```bash +git fetch upstream +git checkout my-custom-branch + +# Confirm clean state +git diff --name-only 5.6.10..my-custom-branch # should match fork_divergence.md exactly +``` + +Reset `connectorContent.ts` BEFORE merge (already done in bc69732f but do it again post-merge): +```bash +git checkout upstream/main -- src/utils/connectorContent.ts +``` + +--- + +## File-by-file resolution guide + +### TIER 1 — Manual surgery required + +--- + +#### `src/ui/chat/components/MessageBubble.ts` ⚠️ CRITICAL + +**What upstream did (PR #115):** Reduced from 877 → 527 lines by extracting 4 helper classes: +- `helpers/MessageBubbleBranchNavigatorBinder.ts` (new) +- `helpers/MessageBubbleImageRenderer.ts` (new) +- `helpers/MessageBubbleToolEventCoordinator.ts` (new) +- `helpers/MessageBubbleStateResolver.ts` (new) + +Constructor now initializes `branchNavigatorBinder`, `imageRenderer`, `toolEventCoordinator` +instead of holding them inline. `MessageBranchNavigator` import removed from `MessageBubble.ts` +(moved into `MessageBubbleBranchNavigatorBinder`). + +**Our additions to layer back:** +1. `import { MessageActionBar } from './MessageActionBar';` +2. `private actionBar: MessageActionBar | null = null;` field +3. `appendActionBar()` method (currently at line ~814 in our branch) +4. `cleanupActionBar()` method +5. Call sites: action bar init on mount, cleanup on destroy + +**Resolution steps:** +1. `git checkout upstream/main -- src/ui/chat/components/MessageBubble.ts` +2. Accept all 4 new helper files: `git checkout upstream/main -- src/ui/chat/components/helpers/` +3. Open `MessageBubble.ts` and add back the 5 action bar items above +4. Verify the constructor, mount, and destroy hooks are the correct insertion points +5. Build and test that the action bar still renders and cleans up + +**Risk:** Medium. Upstream changed the class structure significantly but our additions are +self-contained. The `appendActionBar()` call site currently triggers after `createElement()` — +verify that hook still exists in the refactored version. + +--- + +#### `src/ui/chat/components/factories/ToolBubbleFactory.ts` + +**What upstream did:** Likely whitespace-only (not in upstream diff since 5.6.10). Auto-merge. + +**Our addition:** 3-param `createTextBubble` (removed `onCopy`/`showCopyFeedback`). + +**Resolution:** Likely clean auto-merge. If conflict, take upstream base + restore 3-param signature. + +--- + +### TIER 2 — Accept upstream, drop/reconsider our version + +--- + +#### `src/services/llm/adapters/shared/ProviderHttpClient.ts` + +**What upstream did (PR #103):** Ships a BETTER version of our fix: +- Uses `desktopRequire('node:https')` instead of our raw `require()` +- Also adds `isDesktop()` guard before `hasNodeRuntime()` +- Replaces `new Readable(...)` (requires `node:stream`) with a plain `AsyncIterable` + — completely eliminates Node.js stream dependency in the mobile fallback path + +**Action: Take upstream's version entirely. Drop our fork's ProviderHttpClient changes.** + +The upstream version is architecturally better (uses `desktopRequire` consistently, avoids +`node:stream` entirely). Our `resRef?.destroy(err)` timeout fix should be checked — see if +upstream kept or dropped that. If they dropped it, we may want to keep it as a Tier 3 fork fix. + +```bash +git checkout upstream/main -- src/services/llm/adapters/shared/ProviderHttpClient.ts +``` + +Then check if `resRef?.destroy(err)` (our timeout improvement) is present. If not, add it back. + +--- + +#### `src/database/repositories/ConversationRepository.ts` + +**What upstream did:** Ships a tombstone approach for conversation deletion: +- Writes a `ConversationDeletedEvent` to JSONL BEFORE deleting from SQLite +- This ensures reconciliation can detect the deletion even if SQLite is rebuilt from JSONL +- Does NOT delete the JSONL file + +**Our approach:** Deleted the JSONL file entirely (simpler, removes orphan files). + +**Action: Take upstream's tombstone approach. Drop our JSONL-file-delete from `delete()`.** + +Upstream's approach is safer for mobile sync (JSONL is source of truth; deleting it breaks +rebuild). Our approach was a symptom-fix for the orphan file problem — the tombstone is the +correct architectural solution. + +Also: upstream has `ConversationDeletedEvent` — ensure `StorageEvents.ts` is taken from upstream +(it will be in the auto-merge since upstream added this event type). + +```bash +git checkout upstream/main -- src/database/repositories/ConversationRepository.ts +# Verify no action-bar-related code is in this file (there isn't any) +``` + +--- + +#### `src/database/repositories/MessageRepository.ts` + +**What upstream did:** Likely no functional change (not in upstream diff list). + +**Our addition:** Skip JSONL writes during streaming states (`draft`/`streaming`). + +**Action:** Likely clean auto-merge. Keep our streaming optimization. + +--- + +### TIER 3 — Significant upstream refactors with fork additions to preserve + +--- + +#### `src/database/storage/SQLiteCacheManager.ts` + new split files (PR #113) + +**What upstream did:** Split into 6 files: +- `SQLiteMaintenanceService.ts` (new) +- `SQLitePersistenceService.ts` (new) +- `SQLiteSyncStateStore.ts` (new) +- `SQLiteTransactionCoordinator.ts` (new) +- `SQLiteWasmBridge.ts` (new) +- `SQLiteCacheManager.ts` (kept as facade/orchestrator, now smaller) + +**Our addition:** `fixVec0TableDimensions()` — drops/recreates `note_embeddings` and +`block_embeddings` vec0 tables if they were built with `float[768]` (legacy Nomic era). +Runs once after migrations during `initialize()`. + +**Action:** Take all 6 new upstream files. Then find where `initialize()` lives after the split +(probably `SQLiteCacheManager.ts` or `SQLiteMaintenanceService.ts`) and add the +`fixVec0TableDimensions()` call back in the correct post-migration position. + +**Note:** This fix is fork-specific (our installation had a bad vec0 dimension from the abandoned +Nomic embedding experiment). It is harmless if dimensions are already correct (no-op path). + +```bash +# Accept all new files +git checkout upstream/main -- src/database/storage/SQLiteCacheManager.ts +git checkout upstream/main -- src/database/storage/SQLiteMaintenanceService.ts +git checkout upstream/main -- src/database/storage/SQLitePersistenceService.ts +git checkout upstream/main -- src/database/storage/SQLiteSyncStateStore.ts +git checkout upstream/main -- src/database/storage/SQLiteTransactionCoordinator.ts +git checkout upstream/main -- src/database/storage/SQLiteWasmBridge.ts +# Then re-add fixVec0TableDimensions() to the correct location +``` + +--- + +#### `src/database/adapters/HybridStorageAdapter.ts` + +**What upstream did:** Major changes: +- Plugin-scoped storage path routing (uses `PluginStoragePathResolver`) +- Two-stage migration logic with new `cache.db` path detection +- `await storage adapter ready before embedding init` fix +- Step numbering updated (5→6, 6→7 for existing steps) — upstream added their own step 5 + +**Our addition:** `pruneOrphanedConversationFiles()` — step 5 in our version, runs on startup +to clean JSONL files for conversations not in SQLite. + +**Action:** Take upstream as base. Preserve our `pruneOrphanedConversationFiles()` method. + +However, given that upstream now uses tombstone deletion (ConversationDeletedEvent), the orphan +file problem is resolved going forward. Consider whether the startup pruning is still needed: +- **Yes, keep it:** Still useful for one-time cleanup of pre-tombstone orphans that exist in + our current `.nexus/conversations/` directory from before this fix +- Renumber our step to fit after upstream's new steps + +```bash +git checkout upstream/main -- src/database/adapters/HybridStorageAdapter.ts +# Add back pruneOrphanedConversationFiles() and its startup call +``` + +--- + +#### `src/database/storage/JSONLWriter.ts` + +**What upstream did:** Added tests (`tests/unit/JSONLWriter.test.ts`). The implementation file +itself may have minor changes. + +**Our addition:** `readEventsStreaming()` — readline fallback for files >50 MB. Also `listFiles()` +and `deleteFile()` methods (added to support our pruning feature). + +**Note on `deleteFile()`:** Since upstream uses tombstone approach, `deleteFile()` is no longer +called from `ConversationRepository.delete()`. It is still called from +`pruneOrphanedConversationFiles()`. Keep it. + +**Action:** Check if upstream's `JSONLWriter.ts` changed. If so, take upstream base + layer back +our 3 added methods. If not, auto-merge. + +--- + +### TIER 4 — Pure upstream acceptance (no fork additions here) + +These files have upstream changes and no fork-specific content. **Take upstream entirely:** + +| File | Upstream change | +|------|----------------| +| `src/ui/chat/services/ModelAgentManager.ts` | Facade over new split services | +| `src/ui/chat/services/ModelAgentCompactionState.ts` | New (PR #112 split) | +| `src/ui/chat/services/ModelAgentConversationSettingsStore.ts` | New (PR #112 split) | +| `src/ui/chat/services/ModelAgentDefaultsResolver.ts` | New (PR #112 split) | +| `src/ui/chat/services/ModelAgentPromptContextAssembler.ts` | New (PR #112 split) | +| `src/ui/chat/services/ModelAgentWorkspaceContextService.ts` | New (PR #112 split) | +| `src/database/migration/PluginScopedStorageCoordinator.ts` | New | +| `src/database/storage/PluginStoragePathResolver.ts` | New | +| `src/utils/pluginDataLock.ts` | New | +| `src/ui/chat/services/SystemPromptBuilder.ts` | Remove dead per-turn fetches (PR #106) | +| `src/core/PluginLifecycleManager.ts` | Updated for new service split | +| `src/core/commands/MaintenanceCommandManager.ts` | Minor updates | +| `src/core/services/ServiceDefinitions.ts` | New service registrations | +| `src/database/sync/ConversationEventApplier.ts` | Tombstone delete handling | +| `src/database/interfaces/StorageEvents.ts` | `ConversationDeletedEvent` added | +| `src/services/workflows/WorkflowRunService.ts` | Type error fixes (PR #114) | +| `src/settings.ts` | Updated for new services | +| `src/types/plugin/PluginTypes.ts` | New type definitions | +| `src/types/sqlite3-vec-wasm.d.ts` | New WASM type declarations | + +--- + +## Downstream impact: our fork fixes to reconsider + +### `eslint.config.mjs` +We added `JSONLWriter.ts` to `import/no-nodejs-modules` exceptions because our streaming code +uses `require('fs')` and `require('readline')`. After the merge, if upstream's `JSONLWriter.ts` +retains our streaming methods, keep the exception. Otherwise remove it. + +### `src/settings/tabs/ProvidersTab.ts` and `src/components/LLMProviderModal.ts` +Our changes simplified the `onSave` handler. Check if upstream's PR #114 touched these files. +If upstream modified them, reconcile carefully — our changes were a real improvement. + +### `src/components/shared/ChatSettingsRenderer.ts` +Minor `void` removal. Likely no conflict with upstream. + +--- + +## Merge execution sequence + +Execute in this order to minimize conflict cascades: + +``` +1. git fetch upstream + +2. Accept all Tier 4 pure-upstream files first (git checkout upstream/main -- ) + +3. Accept new split files (ModelAgentManager helpers, SQLiteCacheManager split, helpers/) + +4. Merge conflict files one by one in this order: + a. ProviderHttpClient.ts (take upstream, check timeout fix) + b. ConversationRepository.ts (take upstream's tombstone approach) + c. HybridStorageAdapter.ts (take upstream, add back pruneOrphanedConversationFiles) + d. SQLiteCacheManager.ts (take upstream, re-add fixVec0TableDimensions) + e. JSONLWriter.ts (merge, keep readEventsStreaming + listFiles + deleteFile) + f. MessageBubble.ts (take upstream, layer back action bar additions) + +5. Verify fork-only files untouched: + - MessageActionBar.ts (should be unmodified by merge) + - CreateFileModal.ts (should be unmodified by merge) + +6. Post-merge checks: + - git diff upstream/main..my-custom-branch --name-status + - Verify only expected fork divergences remain (compare to fork_divergence.md) + - npm run build + - npm run test + - Manual test: action bar renders, copy works, context progress bar updates + - Reset connectorContent.ts: git checkout upstream/main -- src/utils/connectorContent.ts + +7. Tag and deploy: + - git tag merge/upstream-post-5.6.10 (or whatever upstream tags the new version) + - npm run deploy +``` + +--- + +## Key decision: pruneOrphanedConversationFiles() + +Upstream explicitly rejected this feature (PR audit: "inverts JSONL-as-source-of-truth +architecture"). Their concern is valid for forward-looking deletions. However: + +- Our vault already HAS orphaned files from the pre-tombstone era +- Running the pruner once cleans them up safely (SQLite is accurate at this point) +- After the tombstone fix lands, no new orphans will be created + +**Recommendation:** Keep `pruneOrphanedConversationFiles()` as a fork-only startup cleanup. +Once we've confirmed no orphans remain (a few weeks of use), we can remove it. Add a log +message counting existing orphans vs. pruned count on each startup to track progress to zero. + +--- + +## Notes on upstream's PR audit of our work + +The upstream team (`docs/review/midway65-pr-audit-2026-04-07.md`) reviewed our PRs #104/#105 +and reached these conclusions (relevant to this merge): + +- **Schema v12–v19:** Rejected (fork-specific). Our `fixVec0TableDimensions()` stays fork-only. +- **Action bar:** Rejected (UI noise). Stays fork-only. +- **Orphaned JSONL pruning:** Rejected (architectural concern). We keep it as a one-time cleanup fork addition with the reasoning above. +- **ProviderHttpClient fix:** Accepted separately — they shipped a better version. Take theirs. +- **Workspace optimization (G-W1–W4):** Approved — but these are the commits we REVERTED. They were merged upstream as PR #106/107 separately. Do not re-add them from our reverted commits — just take upstream. 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 f79e93d05..f7ddb2efd 100644 --- a/src/components/shared/ChatSettingsRenderer.ts +++ b/src/components/shared/ChatSettingsRenderer.ts @@ -542,7 +542,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/database/adapters/HybridStorageAdapter.ts b/src/database/adapters/HybridStorageAdapter.ts index 3ea17478f..cf738d9ad 100644 --- a/src/database/adapters/HybridStorageAdapter.ts +++ b/src/database/adapters/HybridStorageAdapter.ts @@ -230,27 +230,27 @@ export class HybridStorageAdapter implements IStorageAdapter { /** * Perform the actual initialization work */ - private async performInitialization(): Promise { - try { - const migrator = new LegacyMigrator(this.app); - const migrationNeeded = await migrator.isMigrationNeeded(); - let actuallyMigrated = false; - - if (migrationNeeded) { - const migrationResult = await migrator.migrate(); - // Only count as "actually migrated" if something was migrated - actuallyMigrated = migrationResult.needed && - (migrationResult.stats.workspacesMigrated > 0 || migrationResult.stats.conversationsMigrated > 0); - } - - const storagePlan = await this.storageCoordinator.prepareStoragePlan(); - this.applyStoragePlan(storagePlan); - - // 1. Initialize SQLite cache - await this.sqliteCache.initialize(); - - // 2. Ensure JSONL directories exist - await this.jsonlWriter.ensureDirectory('workspaces'); + private async performInitialization(): Promise { + try { + const migrator = new LegacyMigrator(this.app); + const migrationNeeded = await migrator.isMigrationNeeded(); + let actuallyMigrated = false; + + if (migrationNeeded) { + const migrationResult = await migrator.migrate(); + // Only count as "actually migrated" if something was migrated + actuallyMigrated = migrationResult.needed && + (migrationResult.stats.workspacesMigrated > 0 || migrationResult.stats.conversationsMigrated > 0); + } + + const storagePlan = await this.storageCoordinator.prepareStoragePlan(); + this.applyStoragePlan(storagePlan); + + // 1. Initialize SQLite cache + await this.sqliteCache.initialize(); + + // 2. Ensure JSONL directories exist + await this.jsonlWriter.ensureDirectory('workspaces'); await this.jsonlWriter.ensureDirectory('conversations'); await this.jsonlWriter.ensureDirectory('tasks'); @@ -263,25 +263,42 @@ export class HybridStorageAdapter implements IStorageAdapter { // 4. Perform initial sync (rebuild cache from JSONL) in background // 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()); - if (!syncState || actuallyMigrated) { - try { - await this.syncCoordinator.fullRebuild(); - } catch (rebuildError) { - console.error('[HybridStorageAdapter] Full rebuild failed:', rebuildError); - } - } else { - try { - await this.syncCoordinator.sync(); - } catch (syncError) { - console.error('[HybridStorageAdapter] Incremental sync failed:', syncError); - } - - // 5. Reconcile JSONL workspaces missing from SQLite - try { - await this.reconcileMissingWorkspaces(); - } catch (reconcileError) { + // The UI will show incrementally as data syncs in. + const syncState = await this.sqliteCache.getSyncState(this.jsonlWriter.getDeviceId()); + + // Fork: 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 and can be safely deleted. + // Running BEFORE rebuild prevents fullRebuild from resurrecting deleted conversations. + // Skip on first-ever startup (!syncState) — SQLite is empty and every file + // would look orphaned. TEMPORARY: remove once vault reports zero pruned files + // for several consecutive sessions. + 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(); + } catch (rebuildError) { + console.error('[HybridStorageAdapter] Full rebuild failed:', rebuildError); + } + } else { + try { + await this.syncCoordinator.sync(); + } catch (syncError) { + console.error('[HybridStorageAdapter] Incremental sync failed:', syncError); + } + + // 5. Reconcile JSONL workspaces missing from SQLite + try { + await this.reconcileMissingWorkspaces(); + } catch (reconcileError) { console.error('[HybridStorageAdapter] Workspace reconciliation failed:', reconcileError); } @@ -295,10 +312,10 @@ export class HybridStorageAdapter implements IStorageAdapter { // 7. Reconcile JSONL tasks missing from SQLite try { await this.reconcileMissingTasks(); - } catch (reconcileError) { - console.error('[HybridStorageAdapter] Task reconciliation failed:', reconcileError); - } - } + } catch (reconcileError) { + console.error('[HybridStorageAdapter] Task reconciliation failed:', reconcileError); + } + } // Copy fully-populated cache.db to plugin-scoped storage after sync completes. // Must happen AFTER rebuild/sync so the copy includes sync state. @@ -306,19 +323,19 @@ export class HybridStorageAdapter implements IStorageAdapter { try { await this.storageCoordinator.backgroundMigration; const dataRoot = this.storageCoordinator.roots.dataRoot; - const legacyCacheDb = `${this.basePath}/cache.db`; - const newCacheDb = `${dataRoot}/cache.db`; - if (await this.app.vault.adapter.exists(legacyCacheDb)) { - const content = await this.app.vault.adapter.readBinary(legacyCacheDb); - await this.app.vault.adapter.writeBinary(newCacheDb, content); - } - } catch (cacheError) { - console.warn('[HybridStorageAdapter] cache.db copy failed (will rebuild on next boot):', cacheError); - } - } - } catch (error) { - console.error('[HybridStorageAdapter] Initialization failed:', error); - this.initError = error as Error; + const legacyCacheDb = `${this.basePath}/cache.db`; + const newCacheDb = `${dataRoot}/cache.db`; + if (await this.app.vault.adapter.exists(legacyCacheDb)) { + const content = await this.app.vault.adapter.readBinary(legacyCacheDb); + await this.app.vault.adapter.writeBinary(newCacheDb, content); + } + } catch (cacheError) { + console.warn('[HybridStorageAdapter] cache.db copy failed (will rebuild on next boot):', cacheError); + } + } + } catch (error) { + console.error('[HybridStorageAdapter] Initialization failed:', error); + this.initError = error as Error; if (this.initResolve) { this.initResolve(); // Resolve even on error so waiters don't hang } @@ -870,4 +887,36 @@ export class HybridStorageAdapter implements IStorageAdapter { throw new Error('HybridStorageAdapter not initialized. Call initialize() first.'); } + + /** + * Fork: Prune orphaned conversation JSONL files that have no corresponding SQLite record. + * Left behind by the pre-fix deleteConversation bug. Runs once per startup on the + * incremental sync path (syncState present). TEMPORARY — remove once vault shows zero + * pruned files at startup for several consecutive sessions. + */ + 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)`); + } + } } 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..2ce444e46 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,131 @@ 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: [] + }, + + // ======================================================================== + // FORK MIGRATION NUMBERING CONVENTION + // + // This fork's local stubs occupy versions 12–19. Upstream (nexus published plugin) + // is currently at v11 and will release v12, v13, ... in future updates. + // + // RULE: When merging an upstream migration numbered N where N ≤ 19, renumber it + // to the next available version above 19 (i.e. 20, 21, 22 ...) in this array. + // Once upstream's version counter exceeds 19, merge their migrations as-is. + // + // Example — upstream publishes v12: + // { + // version: 20, // renumbered from upstream v12 + // description: '[upstream v12] ', + // sql: [ /* their SQL unchanged */ ] + // } + // Then set CURRENT_SCHEMA_VERSION = 20. + // + // This ensures the migrator (which skips anything ≤ MAX(schema_version) in the DB) + // actually runs the upstream schema change on existing installs. + // ======================================================================== + + // ======================================================================== + // 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 a098ae134..bb784304d 100644 --- a/src/database/storage/JSONLWriter.ts +++ b/src/database/storage/JSONLWriter.ts @@ -28,6 +28,9 @@ import { StorageEvent, BaseStorageEvent } from '../interfaces/StorageEvents'; import { v4 as uuidv4 } from '../../utils/uuid'; import { NamedLocks } from '../../utils/AsyncLock'; +/** Fork: Max file size (bytes) before readEvents falls back to streaming readline (50 MB) */ +const MAX_FILE_SIZE_FOR_IN_MEMORY_READ = 50 * 1024 * 1024; + /** * Configuration options for JSONLWriter */ @@ -335,20 +338,33 @@ export class JSONLWriter { const dedupedEvents = new Map(); for (const fullPath of readablePaths) { - const content = await this.app.vault.adapter.read(fullPath); - const lines = content.split('\n').filter(line => line.trim()); - - for (let i = 0; i < lines.length; i++) { - try { - const event = JSON.parse(lines[i]) as T; - const eventId = typeof (event as { id?: unknown }).id === 'string' - ? String((event as { id: string }).id) - : `${fullPath}:${i}:${lines[i]}`; - if (!dedupedEvents.has(eventId)) { - dedupedEvents.set(eventId, event); + // Fork: Fall back to streaming readline for files >50 MB (avoids V8 string length crash) + const stat = await this.app.vault.adapter.stat?.(fullPath); + const fileSize = stat?.size ?? 0; + + let fileEvents: T[]; + if (fileSize > MAX_FILE_SIZE_FOR_IN_MEMORY_READ) { + fileEvents = await this.readEventsStreaming(fullPath); + } else { + const content = await this.app.vault.adapter.read(fullPath); + const lines = content.split('\n').filter(line => line.trim()); + fileEvents = []; + for (const line of lines) { + try { + fileEvents.push(JSON.parse(line) as T); + } catch { + continue; } - } catch { - continue; + } + } + + for (let i = 0; i < fileEvents.length; i++) { + const event = fileEvents[i]; + const eventId = typeof (event as { id?: unknown }).id === 'string' + ? String((event as { id: string }).id) + : `${fullPath}:${i}:${JSON.stringify(event)}`; + if (!dedupedEvents.has(eventId)) { + dedupedEvents.set(eventId, event); } } } @@ -360,6 +376,40 @@ export class JSONLWriter { } } + /** + * Fork: 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 3e145ac33..c19ac6b6d 100644 --- a/src/database/storage/SQLiteCacheManager.ts +++ b/src/database/storage/SQLiteCacheManager.ts @@ -22,27 +22,27 @@ * - Works in Electron renderer (no native bindings) */ -import { App } from 'obsidian'; +import { App } from 'obsidian'; import { PaginatedResult, PaginationParams } from '../../types/pagination/PaginationTypes'; import { IStorageBackend, RunResult, DatabaseStats } from '../interfaces/IStorageBackend'; -import type { SyncState, ISQLiteCacheManager } from '../sync/SyncCoordinator'; -import { SQLiteSearchService } from './SQLiteSearchService'; -import { QueryParams } from '../repositories/base/BaseRepository'; -import { - SQLiteWasmBridge, - SQLiteWasmModule, - SQLiteDatabaseHandle -} from './SQLiteWasmBridge'; -import { SQLiteTransactionCoordinator } from './SQLiteTransactionCoordinator'; -import { SQLiteSyncStateStore } from './SQLiteSyncStateStore'; -import { SQLitePersistenceService } from './SQLitePersistenceService'; -import { SQLiteMaintenanceService, SQLiteMaintenanceStatistics } from './SQLiteMaintenanceService'; +import type { SyncState, ISQLiteCacheManager } from '../sync/SyncCoordinator'; +import { SQLiteSearchService } from './SQLiteSearchService'; +import { QueryParams } from '../repositories/base/BaseRepository'; +import { + SQLiteWasmBridge, + SQLiteWasmModule, + SQLiteDatabaseHandle +} from './SQLiteWasmBridge'; +import { SQLiteTransactionCoordinator } from './SQLiteTransactionCoordinator'; +import { SQLiteSyncStateStore } from './SQLiteSyncStateStore'; +import { SQLitePersistenceService } from './SQLitePersistenceService'; +import { SQLiteMaintenanceService, SQLiteMaintenanceStatistics } from './SQLiteMaintenanceService'; // Import schema from TypeScript module (esbuild compatible) import { SCHEMA_SQL } from '../schema/schema'; import { SchemaMigrator } from '../schema/SchemaMigrator'; -export interface SQLiteCacheManagerOptions { +export interface SQLiteCacheManagerOptions { app: App; dbPath: string; // e.g., '.nexus/cache.db' wasmPath?: string; @@ -54,25 +54,25 @@ export interface QueryResult { totalCount?: number; } -/** - * Database adapter that wraps raw WASM SQLite database to provide - * exec() and run() methods for MigratableDatabase interface. - */ -class DatabaseAdapter { - constructor( - private readonly bridge: SQLiteWasmBridge, - private readonly rawDb: SQLiteDatabaseHandle - ) {} - - exec(sql: string): { values: unknown[][] }[] { - const results = this.bridge.collectValues(this.rawDb, sql); - return results.length > 0 ? [{ values: results }] : []; - } - - run(sql: string, params?: QueryParams): void { - this.bridge.executeStatement(this.rawDb, sql, params); - } -} +/** + * Database adapter that wraps raw WASM SQLite database to provide + * exec() and run() methods for MigratableDatabase interface. + */ +class DatabaseAdapter { + constructor( + private readonly bridge: SQLiteWasmBridge, + private readonly rawDb: SQLiteDatabaseHandle + ) {} + + exec(sql: string): { values: unknown[][] }[] { + const results = this.bridge.collectValues(this.rawDb, sql); + return results.length > 0 ? [{ values: results }] : []; + } + + run(sql: string, params?: QueryParams): void { + this.bridge.executeStatement(this.rawDb, sql, params); + } +} /** * SQLite cache manager using @dao-xyz/sqlite3-vec WASM @@ -86,86 +86,86 @@ class DatabaseAdapter { * - Transaction support */ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager { - private app: App; - private dbPath: string; // Relative path within vault - private wasmPath?: string; - private readonly bridge: SQLiteWasmBridge; - private sqlite3: SQLiteWasmModule | null = null; // The sqlite3 WASM module - private db: SQLiteDatabaseHandle | null = null; // The oo1.DB instance + private app: App; + private dbPath: string; // Relative path within vault + private wasmPath?: string; + private readonly bridge: SQLiteWasmBridge; + private sqlite3: SQLiteWasmModule | null = null; // The sqlite3 WASM module + private db: SQLiteDatabaseHandle | null = null; // The oo1.DB instance private isInitialized = false; private searchService: SQLiteSearchService; private hasUnsavedData = false; - private autoSaveInterval: number; - private autoSaveTimer: NodeJS.Timeout | null = null; - private readonly transactionCoordinator: SQLiteTransactionCoordinator; - private readonly syncStateStore: SQLiteSyncStateStore; - private readonly persistenceService: SQLitePersistenceService; - private maintenanceService?: SQLiteMaintenanceService; - - constructor(options: SQLiteCacheManagerOptions) { - this.app = options.app; - this.dbPath = options.dbPath; - this.wasmPath = options.wasmPath; - this.autoSaveInterval = options.autoSaveInterval ?? 30000; // 30 seconds default - this.bridge = new SQLiteWasmBridge(); - this.transactionCoordinator = new SQLiteTransactionCoordinator(); - this.persistenceService = new SQLitePersistenceService({ - app: this.app, - dbPath: this.dbPath, - bridge: this.bridge - }); - this.syncStateStore = new SQLiteSyncStateStore( - (sql: string, params?: QueryParams) => this.query(sql, params), - (sql: string, params?: QueryParams) => this.queryOne(sql, params), - (sql: string, params?: QueryParams) => this.run(sql, params) - ); - this.searchService = new SQLiteSearchService(this); - } - - /** - * Update the database path before initialization. - * Must be called before initialize() — has no effect after the DB is open. - */ - setDbPath(path: string): void { - if (this.isInitialized) { - console.warn('[SQLiteCacheManager] setDbPath called after initialization — ignoring'); - return; - } - - this.dbPath = path; - this.persistenceService.setDbPath(path); - if (this.maintenanceService) { - this.maintenanceService.setDbPath(path); - } - } - - private getMaintenanceService(): SQLiteMaintenanceService { - if (!this.maintenanceService) { - this.maintenanceService = new SQLiteMaintenanceService({ - app: this.app, - dbPath: this.dbPath, - bridge: this.bridge, - getDb: () => this.getDbOrThrow(), - queryOne: (sql: string, params?: QueryParams) => this.queryOne(sql, params), - transaction: (fn: () => Promise) => this.transaction(fn) - }); - } - return this.maintenanceService; - } - - private getSqlite3OrThrow(): SQLiteWasmModule { - if (!this.sqlite3) { - throw new Error('SQLite module not initialized'); - } - return this.sqlite3; - } - - private getDbOrThrow(): SQLiteDatabaseHandle { - if (!this.db) { - throw new Error('Database not initialized'); - } - return this.db; - } + private autoSaveInterval: number; + private autoSaveTimer: NodeJS.Timeout | null = null; + private readonly transactionCoordinator: SQLiteTransactionCoordinator; + private readonly syncStateStore: SQLiteSyncStateStore; + private readonly persistenceService: SQLitePersistenceService; + private maintenanceService?: SQLiteMaintenanceService; + + constructor(options: SQLiteCacheManagerOptions) { + this.app = options.app; + this.dbPath = options.dbPath; + this.wasmPath = options.wasmPath; + this.autoSaveInterval = options.autoSaveInterval ?? 30000; // 30 seconds default + this.bridge = new SQLiteWasmBridge(); + this.transactionCoordinator = new SQLiteTransactionCoordinator(); + this.persistenceService = new SQLitePersistenceService({ + app: this.app, + dbPath: this.dbPath, + bridge: this.bridge + }); + this.syncStateStore = new SQLiteSyncStateStore( + (sql: string, params?: QueryParams) => this.query(sql, params), + (sql: string, params?: QueryParams) => this.queryOne(sql, params), + (sql: string, params?: QueryParams) => this.run(sql, params) + ); + this.searchService = new SQLiteSearchService(this); + } + + /** + * Update the database path before initialization. + * Must be called before initialize() — has no effect after the DB is open. + */ + setDbPath(path: string): void { + if (this.isInitialized) { + console.warn('[SQLiteCacheManager] setDbPath called after initialization — ignoring'); + return; + } + + this.dbPath = path; + this.persistenceService.setDbPath(path); + if (this.maintenanceService) { + this.maintenanceService.setDbPath(path); + } + } + + private getMaintenanceService(): SQLiteMaintenanceService { + if (!this.maintenanceService) { + this.maintenanceService = new SQLiteMaintenanceService({ + app: this.app, + dbPath: this.dbPath, + bridge: this.bridge, + getDb: () => this.getDbOrThrow(), + queryOne: (sql: string, params?: QueryParams) => this.queryOne(sql, params), + transaction: (fn: () => Promise) => this.transaction(fn) + }); + } + return this.maintenanceService; + } + + private getSqlite3OrThrow(): SQLiteWasmModule { + if (!this.sqlite3) { + throw new Error('SQLite module not initialized'); + } + return this.sqlite3; + } + + private getDbOrThrow(): SQLiteDatabaseHandle { + if (!this.db) { + throw new Error('Database not initialized'); + } + return this.db; + } /** * Resolve the sqlite3.wasm path for the currently-installed plugin folder. @@ -241,11 +241,11 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager } }; - try { - this.sqlite3 = await this.bridge.initializeModule(wasmBinary); - } finally { - consoleRef.warn = originalWarn; - consoleRef.log = originalLog; + try { + this.sqlite3 = await this.bridge.initializeModule(wasmBinary); + } finally { + consoleRef.warn = originalWarn; + consoleRef.log = originalLog; } // Ensure parent directory exists @@ -258,25 +258,28 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager // Check if database file exists const dbExists = await this.app.vault.adapter.exists(this.dbPath); - if (dbExists) { - // Load existing database from file - await this.loadFromFile(); - } else { - const sqlite3 = this.getSqlite3OrThrow(); - const db = this.persistenceService.createFreshDatabase(sqlite3, SCHEMA_SQL); - this.db = db; - await this.saveToFile(); - } - - // Run schema migrations for existing databases - // Wrap raw database in adapter to provide exec() and run() methods - const dbAdapter = new DatabaseAdapter(this.bridge, this.getDbOrThrow()); - const migrator = new SchemaMigrator(dbAdapter); + if (dbExists) { + // Load existing database from file + await this.loadFromFile(); + } else { + const sqlite3 = this.getSqlite3OrThrow(); + const db = this.persistenceService.createFreshDatabase(sqlite3, SCHEMA_SQL); + this.db = db; + await this.saveToFile(); + } + + // Run schema migrations for existing databases + // Wrap raw database in adapter to provide exec() and run() methods + const dbAdapter = new DatabaseAdapter(this.bridge, this.getDbOrThrow()); + const migrator = new SchemaMigrator(dbAdapter); const migrationResult = await migrator.migrate(); if (migrationResult.applied > 0) { await this.saveToFile(); // Save after migrations } + // Fork: Fix vec0 table dimensions if created with legacy float[768] (Nomic era) + await this.getMaintenanceService().fixVec0TableDimensions(); + // Start auto-save timer if (this.autoSaveInterval > 0) { this.autoSaveTimer = setInterval(() => { @@ -299,40 +302,40 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager * Load database from file using sqlite3_deserialize * Includes corruption detection and auto-recovery */ - private async loadFromFile(): Promise { - const sqlite3 = this.getSqlite3OrThrow(); - this.db = await this.persistenceService.loadDatabase(sqlite3, SCHEMA_SQL); - this.hasUnsavedData = false; - } + private async loadFromFile(): Promise { + const sqlite3 = this.getSqlite3OrThrow(); + this.db = await this.persistenceService.loadDatabase(sqlite3, SCHEMA_SQL); + this.hasUnsavedData = false; + } /** * Recreate database after corruption detected * Deletes corrupt file and creates fresh database */ - private async recreateCorruptedDatabase(): Promise { - const sqlite3 = this.getSqlite3OrThrow(); - if (this.db) { - try { - this.bridge.close(this.db); - } catch { - void 0; - } - this.db = null; - } - - this.db = await this.persistenceService.recreateCorruptedDatabase(sqlite3, SCHEMA_SQL); - this.hasUnsavedData = false; - } + private async recreateCorruptedDatabase(): Promise { + const sqlite3 = this.getSqlite3OrThrow(); + if (this.db) { + try { + this.bridge.close(this.db); + } catch { + void 0; + } + this.db = null; + } + + this.db = await this.persistenceService.recreateCorruptedDatabase(sqlite3, SCHEMA_SQL); + this.hasUnsavedData = false; + } /** * Save database to file using sqlite3_js_db_export */ - private async saveToFile(): Promise { - const db = this.getDbOrThrow(); - const sqlite3 = this.getSqlite3OrThrow(); - await this.persistenceService.saveDatabase(sqlite3, db); - this.hasUnsavedData = false; - } + private async saveToFile(): Promise { + const db = this.getDbOrThrow(); + const sqlite3 = this.getSqlite3OrThrow(); + await this.persistenceService.saveDatabase(sqlite3, db); + this.hasUnsavedData = false; + } /** * Close the database and save to file @@ -350,10 +353,10 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager await this.saveToFile(); } - if (this.db) { - this.bridge.close(this.db); - this.db = null; - } + if (this.db) { + this.bridge.close(this.db); + this.db = null; + } this.isInitialized = false; } catch (error) { console.error('[SQLiteCacheManager] Error closing database:', error); @@ -365,13 +368,13 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager * Execute raw SQL (for schema creation and multi-statement execution) * NOTE: Does not support parameters - use run() or query() for parameterized queries */ - exec(sql: string): Promise { - if (!this.db) return Promise.reject(new Error('Database not initialized')); - - try { - this.bridge.exec(this.db, sql); - this.hasUnsavedData = true; - return Promise.resolve(); + exec(sql: string): Promise { + if (!this.db) return Promise.reject(new Error('Database not initialized')); + + try { + this.bridge.exec(this.db, sql); + this.hasUnsavedData = true; + return Promise.resolve(); } catch (error) { console.error('[SQLiteCacheManager] Exec failed:', error); throw error; @@ -381,26 +384,26 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager /** * Query returning multiple rows */ - query(sql: string, params?: QueryParams): Promise { - try { - const results = this.bridge.query(this.getDbOrThrow(), sql, params); - return Promise.resolve(results); - } catch (error) { - console.error('[SQLiteCacheManager] Query failed:', error, { sql, params }); - throw error; + query(sql: string, params?: QueryParams): Promise { + try { + const results = this.bridge.query(this.getDbOrThrow(), sql, params); + return Promise.resolve(results); + } catch (error) { + console.error('[SQLiteCacheManager] Query failed:', error, { sql, params }); + throw error; } } /** * Query returning single row */ - queryOne(sql: string, params?: QueryParams): Promise { - try { - const result = this.bridge.queryOne(this.getDbOrThrow(), sql, params); - return Promise.resolve(result); - } catch (error) { - console.error('[SQLiteCacheManager] QueryOne failed:', error, { sql, params }); - throw error; + queryOne(sql: string, params?: QueryParams): Promise { + try { + const result = this.bridge.queryOne(this.getDbOrThrow(), sql, params); + return Promise.resolve(result); + } catch (error) { + console.error('[SQLiteCacheManager] QueryOne failed:', error, { sql, params }); + throw error; } } @@ -408,14 +411,14 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager * Run a statement (INSERT, UPDATE, DELETE) * Returns changes count and last insert rowid */ - run(sql: string, params?: QueryParams): Promise { - try { - const db = this.getDbOrThrow(); - const sqlite3 = this.getSqlite3OrThrow(); - const { changes, lastInsertRowid } = this.bridge.run(db, sqlite3, sql, params); - - this.hasUnsavedData = true; - return Promise.resolve({ changes, lastInsertRowid }); + run(sql: string, params?: QueryParams): Promise { + try { + const db = this.getDbOrThrow(); + const sqlite3 = this.getSqlite3OrThrow(); + const { changes, lastInsertRowid } = this.bridge.run(db, sqlite3, sql, params); + + this.hasUnsavedData = true; + return Promise.resolve({ changes, lastInsertRowid }); } catch (error) { console.error('[SQLiteCacheManager] Run failed:', error, { sql, params }); throw error; @@ -425,40 +428,40 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager /** * Begin a transaction */ - beginTransaction(): Promise { - this.bridge.exec(this.getDbOrThrow(), 'BEGIN TRANSACTION'); - return Promise.resolve(); - } + beginTransaction(): Promise { + this.bridge.exec(this.getDbOrThrow(), 'BEGIN TRANSACTION'); + return Promise.resolve(); + } /** * Commit a transaction */ - commit(): Promise { - this.bridge.exec(this.getDbOrThrow(), 'COMMIT'); - this.hasUnsavedData = true; - return Promise.resolve(); - } + commit(): Promise { + this.bridge.exec(this.getDbOrThrow(), 'COMMIT'); + this.hasUnsavedData = true; + return Promise.resolve(); + } /** * Rollback a transaction */ - rollback(): Promise { - this.bridge.exec(this.getDbOrThrow(), 'ROLLBACK'); - return Promise.resolve(); - } + rollback(): Promise { + this.bridge.exec(this.getDbOrThrow(), 'ROLLBACK'); + return Promise.resolve(); + } /** * Execute a function within a transaction * Handles concurrent access via lock and nested transactions via depth tracking */ - async transaction(fn: () => Promise): Promise { - return this.transactionCoordinator.run( - () => this.beginTransaction(), - () => this.commit(), - () => this.rollback(), - fn - ); - } + async transaction(fn: () => Promise): Promise { + return this.transactionCoordinator.run( + () => this.beginTransaction(), + () => this.commit(), + () => this.rollback(), + fn + ); + } // ==================== Higher-level query methods ==================== @@ -500,54 +503,54 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager /** * Check if an event has already been applied */ - async isEventApplied(eventId: string): Promise { - return this.syncStateStore.isEventApplied(eventId); - } + async isEventApplied(eventId: string): Promise { + return this.syncStateStore.isEventApplied(eventId); + } /** * Mark an event as applied */ - async markEventApplied(eventId: string): Promise { - await this.syncStateStore.markEventApplied(eventId); - } + async markEventApplied(eventId: string): Promise { + await this.syncStateStore.markEventApplied(eventId); + } /** * Get list of applied event IDs after a timestamp */ - async getAppliedEventsAfter(timestamp: number): Promise { - return this.syncStateStore.getAppliedEventsAfter(timestamp); - } + async getAppliedEventsAfter(timestamp: number): Promise { + return this.syncStateStore.getAppliedEventsAfter(timestamp); + } // ==================== Sync state ==================== /** * Get sync state for a device */ - async getSyncState(deviceId: string): Promise { - return this.syncStateStore.getSyncState(deviceId); - } + async getSyncState(deviceId: string): Promise { + return this.syncStateStore.getSyncState(deviceId); + } /** * Update sync state for a device */ - async updateSyncState(deviceId: string, lastEventTimestamp: number, fileTimestamps: Record): Promise { - await this.syncStateStore.updateSyncState(deviceId, lastEventTimestamp, fileTimestamps); - } + async updateSyncState(deviceId: string, lastEventTimestamp: number, fileTimestamps: Record): Promise { + await this.syncStateStore.updateSyncState(deviceId, lastEventTimestamp, fileTimestamps); + } // ==================== Data management ==================== - async clearAllData(): Promise { - await this.getMaintenanceService().clearAllData(); - } - - async rebuildFTSIndexes(): Promise { - await this.getMaintenanceService().rebuildFTSIndexes(); - } - - async vacuum(): Promise { - await this.getMaintenanceService().vacuum(); - this.hasUnsavedData = true; - } + async clearAllData(): Promise { + await this.getMaintenanceService().clearAllData(); + } + + async rebuildFTSIndexes(): Promise { + await this.getMaintenanceService().rebuildFTSIndexes(); + } + + async vacuum(): Promise { + await this.getMaintenanceService().vacuum(); + this.hasUnsavedData = true; + } // ==================== Full-text search ==================== // Delegated to SQLiteSearchService for single responsibility @@ -585,9 +588,9 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager /** * Get database statistics */ - async getStatistics(): Promise { - return this.getMaintenanceService().getStatistics(); - } + async getStatistics(): Promise { + return this.getMaintenanceService().getStatistics(); + } // ==================== Utilities ==================== @@ -638,7 +641,7 @@ export class SQLiteCacheManager implements IStorageBackend, ISQLiteCacheManager /** * Get database statistics (IStorageBackend requirement) */ - async getStats(): Promise { - return this.getMaintenanceService().getStats(); - } -} + async getStats(): Promise { + return this.getMaintenanceService().getStats(); + } +} diff --git a/src/database/storage/SQLiteMaintenanceService.ts b/src/database/storage/SQLiteMaintenanceService.ts index 1e1298486..ed99c105b 100644 --- a/src/database/storage/SQLiteMaintenanceService.ts +++ b/src/database/storage/SQLiteMaintenanceService.ts @@ -159,4 +159,48 @@ export class SQLiteMaintenanceService { walMode: false }; } + + /** + * Fork: Fix vec0 virtual table dimensions if the tables were created with float[768] + * (legacy Nomic embedding era). Drops and recreates them as float[384]. + * vec0 tables cannot be ALTERed — must be dropped and recreated. + * Called after migrations run during initialize(). No-op if dimensions are correct. + */ + async fixVec0TableDimensions(): Promise { + const db = this.getDb(); + let fixed = false; + + try { + const noteResult = await this.queryOne<{ sql: string }>( + "SELECT sql FROM sqlite_master WHERE name='note_embeddings'" + ); + if (noteResult?.sql?.includes('float[768]')) { + this.bridge.exec(db, 'DROP TABLE IF EXISTS note_embeddings'); + this.bridge.exec(db, 'CREATE VIRTUAL TABLE IF NOT EXISTS note_embeddings USING vec0(embedding float[384])'); + this.bridge.exec(db, 'DELETE FROM embedding_metadata'); + fixed = true; + } + } catch (error) { + console.error('[SQLiteMaintenanceService] Failed to fix note_embeddings dimensions:', error); + } + + try { + const blockResult = await this.queryOne<{ sql: string }>( + "SELECT sql FROM sqlite_master WHERE name='block_embeddings'" + ); + if (blockResult?.sql?.includes('float[768]')) { + this.bridge.exec(db, 'DROP TABLE IF EXISTS block_embeddings'); + this.bridge.exec(db, 'CREATE VIRTUAL TABLE IF NOT EXISTS block_embeddings USING vec0(embedding float[384])'); + fixed = true; + } + } catch (error) { + console.error('[SQLiteMaintenanceService] Failed to fix block_embeddings dimensions:', error); + } + + if (fixed) { + console.warn('[SQLiteMaintenanceService] Fixed vec0 embedding table dimensions (768→384)'); + } + + return fixed; + } } 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/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/builders/ChatLayoutBuilder.ts b/src/ui/chat/builders/ChatLayoutBuilder.ts index 5cc2f6a1a..7b624f334 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. @@ -43,9 +42,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); @@ -124,29 +120,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 7ebf0c3c4..062f393eb 100644 --- a/src/ui/chat/components/BranchHeader.ts +++ b/src/ui/chat/components/BranchHeader.ts @@ -60,7 +60,9 @@ 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; 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/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 f91c2fd36..93105c6bf 100644 --- a/src/ui/chat/components/MessageBubble.ts +++ b/src/ui/chat/components/MessageBubble.ts @@ -1,20 +1,20 @@ -/** - * MessageBubble - Individual message bubble component - * Location: /src/ui/chat/components/MessageBubble.ts - * - * Renders user/AI messages with copy, retry, and edit actions. - * Delegates rendering responsibilities to specialized classes following SOLID principles. - * - * Used by MessageDisplay to render individual messages in the chat interface. - * Coordinates with ReferenceBadgeRenderer, ToolBubbleFactory, ToolEventParser, - * MessageContentRenderer, and MessageEditController for specific concerns. - */ - +/** + * MessageBubble - Individual message bubble component + * Location: /src/ui/chat/components/MessageBubble.ts + * + * Renders user/AI messages with copy, retry, and edit actions. + * Delegates rendering responsibilities to specialized classes following SOLID principles. + * + * Used by MessageDisplay to render individual messages in the chat interface. + * Coordinates with ReferenceBadgeRenderer, ToolBubbleFactory, ToolEventParser, + * MessageContentRenderer, and MessageEditController for specific concerns. + */ + import { ConversationMessage } from '../../../types/chat/ChatTypes'; import { ProgressiveToolAccordion } from './ProgressiveToolAccordion'; import { setIcon, Component, App } from 'obsidian'; - -// Extracted classes + +// Extracted classes import { ReferenceBadgeRenderer } from './renderers/ReferenceBadgeRenderer'; import { ToolBubbleFactory } from './factories/ToolBubbleFactory'; import { ToolEventParser } from '../utils/ToolEventParser'; @@ -24,7 +24,8 @@ import { MessageBubbleBranchNavigatorBinder } from './helpers/MessageBubbleBranc import { MessageBubbleImageRenderer } from './helpers/MessageBubbleImageRenderer'; import { MessageBubbleToolEventCoordinator } from './helpers/MessageBubbleToolEventCoordinator'; import { MessageBubbleStateResolver } from './helpers/MessageBubbleStateResolver'; - +import { MessageActionBar } from './MessageActionBar'; + export class MessageBubble extends Component { private element: HTMLElement | null = null; private loadingInterval: ReturnType | null = null; @@ -35,16 +36,17 @@ export class MessageBubble extends Component { private toolBubbleElement: HTMLElement | null = null; private textBubbleElement: HTMLElement | null = null; private imageBubbleElement: HTMLElement | 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 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 ) { super(); this.branchNavigatorBinder = new MessageBubbleBranchNavigatorBinder({ @@ -76,7 +78,7 @@ export class MessageBubble extends Component { imageRenderer: this.imageRenderer }); } - + /** * Create the message bubble element * For assistant messages with toolCalls or reasoning, returns a fragment containing tool bubble + text bubble @@ -87,42 +89,38 @@ export class MessageBubble extends Component { const activeReasoning = state.activeReasoning; const showToolBubble = state.renderMode === 'group'; const activeContent = state.activeContent; - - if (showToolBubble) { - const wrapper = document.createElement('div'); - wrapper.addClass('message-group'); - wrapper.setAttribute('data-message-id', this.message.id); - - // Render using the active alternative's tool calls and reasoning so retries/branches preserve them - 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 - }); - wrapper.appendChild(this.toolBubbleElement); - - // Wire up onViewBranch callback to all accordions - if (this.onViewBranch) { - this.progressiveToolAccordions.forEach(accordion => { - accordion.setCallbacks({ onViewBranch: this.onViewBranch }); - }); - } - + + if (showToolBubble) { + const wrapper = document.createElement('div'); + wrapper.addClass('message-group'); + wrapper.setAttribute('data-message-id', this.message.id); + + // Render using the active alternative's tool calls and reasoning so retries/branches preserve them + 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 + }); + wrapper.appendChild(this.toolBubbleElement); + + // Wire up onViewBranch callback to all accordions + if (this.onViewBranch) { + this.progressiveToolAccordions.forEach(accordion => { + accordion.setCallbacks({ onViewBranch: this.onViewBranch }); + }); + } + this.imageRenderer.renderLoadedToolResults(activeToolCalls, wrapper); - - // Create text bubble if there's content OR if streaming (need element for StreamingController) + + // Create text bubble if there's content OR if streaming (need element for StreamingController) if (state.shouldRenderTextBubble) { this.textBubbleElement = ToolBubbleFactory.createTextBubble( renderMessage, (container, content) => this.renderContent(container, content), - this.onCopy, - (button) => this.showCopyFeedback(button), - this.branchNavigatorBinder.getNavigator(), - this.onMessageAlternativeChanged, - this + this.branchNavigatorBinder.getNavigator() ); wrapper.appendChild(this.textBubbleElement); @@ -133,118 +131,120 @@ export class MessageBubble extends Component { this.branchNavigatorBinder.sync(actions, renderMessage); } } - - const contentElement = this.textBubbleElement.querySelector('.message-content'); - if (contentElement instanceof HTMLElement && this.message.isLoading && !activeContent.trim()) { - this.appendLoadingIndicator(contentElement); - } - } - - this.element = wrapper; - return wrapper; - } - - // Normal single bubble for user messages or assistant without tools - const messageContainer = document.createElement('div'); - messageContainer.addClass('message-container'); - messageContainer.addClass(`message-${this.message.role}`); - messageContainer.setAttribute('data-message-id', this.message.id); - - const bubble = messageContainer.createDiv('message-bubble'); - - // Message header with role icon only - const header = bubble.createDiv('message-header'); - const roleIcon = header.createDiv('message-role-icon'); - if (this.message.role === 'user') { - setIcon(roleIcon, 'user'); - } else if (this.message.role === 'tool') { - setIcon(roleIcon, 'wrench'); - } else { - setIcon(roleIcon, 'bot'); - } - - // Add loading state in header if AI message is loading with empty content - if (this.message.role === 'assistant' && this.message.isLoading && !this.message.content.trim()) { - const loadingSpan = header.createEl('span', { cls: 'ai-loading-header' }); - loadingSpan.appendText('Thinking'); - loadingSpan.createEl('span', { cls: 'dots', text: '...' }); - this.startLoadingAnimation(loadingSpan); - } - - // Create actions in header for user messages (next to icon), elsewhere for others - // This prevents action buttons from overlapping message content on mobile - let actions: HTMLElement; - if (this.message.role === 'user') { - actions = header.createDiv('message-actions-external'); - } else if (this.message.role === 'assistant') { - actions = bubble.createDiv('message-actions-external'); - } else { - actions = messageContainer.createDiv('message-actions-external'); - } - - this.createActionButtons(actions); - - // Message content - const content = bubble.createDiv('message-content'); - this.renderContent(content, activeContent).catch(error => { - console.error('[MessageBubble] Error rendering initial content:', error); - }); - - this.element = messageContainer; - return messageContainer; - } - - /** - * Create action buttons (edit, retry, copy, branch navigator) - */ - 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); - } - }); - } - - // Retry button for user messages - const retryBtn = actions.createEl('button', { - cls: 'message-action-btn clickable-icon', - attr: { title: 'Retry message' } - }); - setIcon(retryBtn, 'rotate-ccw'); - this.registerDomEvent(retryBtn, 'click', (event) => { - event.preventDefault(); - event.stopPropagation(); - if (this.onRetry) { - this.onRetry(this.message.id); - } - }); - } else if (this.message.role === 'tool') { - // Tool messages get minimal actions - just copy for debugging - const copyBtn = actions.createEl('button', { - cls: 'message-action-btn clickable-icon', - attr: { title: 'Copy tool execution details' } - }); - setIcon(copyBtn, 'copy'); - this.registerDomEvent(copyBtn, 'click', () => { - this.showCopyFeedback(copyBtn); - this.onCopy(this.message.id); - }); + + 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; + } + + // Normal single bubble for user messages or assistant without tools + const messageContainer = document.createElement('div'); + messageContainer.addClass('message-container'); + messageContainer.addClass(`message-${this.message.role}`); + messageContainer.setAttribute('data-message-id', this.message.id); + + const bubble = messageContainer.createDiv('message-bubble'); + + // Message header with role icon only + const header = bubble.createDiv('message-header'); + const roleIcon = header.createDiv('message-role-icon'); + if (this.message.role === 'user') { + setIcon(roleIcon, 'user'); + } else if (this.message.role === 'tool') { + setIcon(roleIcon, 'wrench'); + } else { + setIcon(roleIcon, 'bot'); + } + + // Add loading state in header if AI message is loading with empty content + if (this.message.role === 'assistant' && this.message.isLoading && !this.message.content.trim()) { + const loadingSpan = header.createEl('span', { cls: 'ai-loading-header' }); + loadingSpan.appendText('Thinking'); + loadingSpan.createEl('span', { cls: 'dots', text: '...' }); + this.startLoadingAnimation(loadingSpan); + } + + // Create actions in header for user messages (next to icon), elsewhere for others + // This prevents action buttons from overlapping message content on mobile + let actions: HTMLElement; + if (this.message.role === 'user') { + actions = header.createDiv('message-actions-external'); + } else if (this.message.role === 'assistant') { + actions = bubble.createDiv('message-actions-external'); + } else { + actions = messageContainer.createDiv('message-actions-external'); + } + + this.createActionButtons(actions); + + // Message content + const content = bubble.createDiv('message-content'); + this.renderContent(content, activeContent).catch(error => { + console.error('[MessageBubble] Error rendering initial content:', error); + }); + + this.element = messageContainer; + this.appendActionBar(messageContainer, this.message); + return messageContainer; + } + + /** + * Create action buttons (edit, retry, copy, branch navigator) + */ + 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); + } + }); + } + + // Retry button for user messages + const retryBtn = actions.createEl('button', { + cls: 'message-action-btn clickable-icon', + attr: { title: 'Retry message' } + }); + setIcon(retryBtn, 'rotate-ccw'); + this.registerDomEvent(retryBtn, 'click', (event) => { + event.preventDefault(); + event.stopPropagation(); + if (this.onRetry) { + this.onRetry(this.message.id); + } + }); + } else if (this.message.role === 'tool') { + // Tool messages get minimal actions - just copy for debugging + const copyBtn = actions.createEl('button', { + cls: 'message-action-btn clickable-icon', + attr: { title: 'Copy tool execution details' } + }); + setIcon(copyBtn, 'copy'); + this.registerDomEvent(copyBtn, 'click', () => { + this.showCopyFeedback(copyBtn); + 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'); + }); + setIcon(copyBtn, 'copy'); this.registerDomEvent(copyBtn, 'click', () => { this.showCopyFeedback(copyBtn); this.onCopy(this.message.id); @@ -253,10 +253,10 @@ export class MessageBubble extends Component { this.branchNavigatorBinder.sync(actions, this.message); } } - - /** - * Render message content using enhanced markdown renderer - */ + + /** + * Render message content using enhanced markdown renderer + */ private async renderContent(container: HTMLElement, content: string): Promise { // Skip rendering if loading with empty content if (this.message.isLoading && this.message.role === 'assistant' && !content.trim()) { @@ -354,90 +354,90 @@ export class MessageBubble extends Component { return url; } } - - /** - * Get the DOM element - */ - getElement(): HTMLElement | null { - return this.element; - } - - /** - * 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; - this.loadingInterval = setInterval(() => { - dotCount = (dotCount + 1) % 4; - dotsElement.textContent = '.'.repeat(dotCount); - }, 500); - } - } - - /** - * Stop loading animation and remove loading UI - */ - stopLoadingAnimation(): void { - if (this.loadingInterval) { - clearInterval(this.loadingInterval); - this.loadingInterval = null; - } - - if (this.element) { - const loadingElement = this.element.querySelector('.ai-loading-header'); - if (loadingElement) { - loadingElement.remove(); - } - } - } - - /** - * Update static message content - */ - updateContent(content: string): void { - if (!this.element) return; - - const contentElement = this.element.querySelector('.message-content'); - if (!contentElement) return; - - this.stopLoadingAnimation(); - - // Preserve progressive accordions during content update - const progressiveAccordions: HTMLElement[] = []; - if (this.progressiveToolAccordions.size > 0) { - const accordionElements = contentElement.querySelectorAll('.progressive-tool-accordion'); - accordionElements.forEach(el => { - if (el instanceof HTMLElement) { - progressiveAccordions.push(el); - el.remove(); - } - }); - } - - contentElement.empty(); - - this.renderContent(contentElement as HTMLElement, content).catch(error => { - console.error('[MessageBubble] Error rendering content:', error); - const fallbackDiv = document.createElement('div'); - fallbackDiv.textContent = content; - contentElement.appendChild(fallbackDiv); - }); - - // Re-append progressive accordions if they were preserved - if (this.progressiveToolAccordions.size > 0 && progressiveAccordions.length > 0) { - progressiveAccordions.forEach(accordion => { - contentElement.appendChild(accordion); - }); - } - } - + + /** + * Get the DOM element + */ + getElement(): HTMLElement | null { + return this.element; + } + + /** + * 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; + this.loadingInterval = setInterval(() => { + dotCount = (dotCount + 1) % 4; + dotsElement.textContent = '.'.repeat(dotCount); + }, 500); + } + } + + /** + * Stop loading animation and remove loading UI + */ + stopLoadingAnimation(): void { + if (this.loadingInterval) { + clearInterval(this.loadingInterval); + this.loadingInterval = null; + } + + if (this.element) { + const loadingElement = this.element.querySelector('.ai-loading-header'); + if (loadingElement) { + loadingElement.remove(); + } + } + } + + /** + * Update static message content + */ + updateContent(content: string): void { + if (!this.element) return; + + const contentElement = this.element.querySelector('.message-content'); + if (!contentElement) return; + + this.stopLoadingAnimation(); + + // Preserve progressive accordions during content update + const progressiveAccordions: HTMLElement[] = []; + if (this.progressiveToolAccordions.size > 0) { + const accordionElements = contentElement.querySelectorAll('.progressive-tool-accordion'); + accordionElements.forEach(el => { + if (el instanceof HTMLElement) { + progressiveAccordions.push(el); + el.remove(); + } + }); + } + + contentElement.empty(); + + this.renderContent(contentElement as HTMLElement, content).catch(error => { + console.error('[MessageBubble] Error rendering content:', error); + const fallbackDiv = document.createElement('div'); + fallbackDiv.textContent = content; + contentElement.appendChild(fallbackDiv); + }); + + // Re-append progressive accordions if they were preserved + if (this.progressiveToolAccordions.size > 0 && progressiveAccordions.length > 0) { + progressiveAccordions.forEach(accordion => { + contentElement.appendChild(accordion); + }); + } + } + /** * Update MessageBubble with new message data */ @@ -461,17 +461,17 @@ export class MessageBubble extends Component { this.branchNavigatorBinder.getNavigator()?.updateMessage(newMessage); return; } - } - - 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 (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) { this.cleanupProgressiveAccordions(); @@ -489,36 +489,38 @@ export class MessageBubble extends Component { this.branchNavigatorBinder.sync(actions, newMessage); } } - - 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 = nextState.activeContent; - 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 - */ + this.renderContent(contentElement, activeContent).catch(error => { + console.error('[MessageBubble] Error re-rendering content:', error); + }); + + if (newMessage.isLoading && newMessage.role === 'assistant') { + this.appendLoadingIndicator(contentElement); + } + + this.appendActionBar(this.element, newMessage); + } + + /** + * Handle tool events from MessageManager + */ handleToolEvent(event: 'detected' | 'updated' | 'started' | 'completed', data: Parameters[0]): void { this.toolEventCoordinator.handleToolEvent(event, data); } - - /** - * Get progressive tool accordions for external updates - */ + + /** + * Get progressive tool accordions for external updates + */ getProgressiveToolAccordions(): Map { return this.progressiveToolAccordions; } @@ -527,31 +529,31 @@ export class MessageBubble extends Component { * 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; - + private rebuildElement(): void { + const previousElement = this.element; + const parentElement = previousElement?.parentElement ?? null; + this.stopLoadingAnimation(); this.cleanupProgressiveAccordions(); this.branchNavigatorBinder.destroy(); - - 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. - */ + + 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' }); @@ -563,53 +565,87 @@ export class MessageBubble extends Component { /** * Show visual feedback when copy button is clicked */ - private showCopyFeedback(button: HTMLElement): void { - const originalTitle = button.getAttribute('title') || ''; - setIcon(button, 'check'); - button.setAttribute('title', 'Copied!'); - button.classList.add('copy-success'); - - setTimeout(() => { - setIcon(button, 'copy'); - button.setAttribute('title', originalTitle); - button.classList.remove('copy-success'); - }, 1500); - } - - /** - * Clean up progressive tool accordions - */ - private cleanupProgressiveAccordions(): void { - this.progressiveToolAccordions.forEach(accordion => { - const element = accordion.getElement(); - if (element) { - element.remove(); - } - accordion.cleanup(); - }); - - this.progressiveToolAccordions.clear(); - } - - /** - * Cleanup resources. - * Calls Component.unload() to auto-clean registerDomEvent/registerInterval handlers. - */ - private isUnloaded = false; - + private showCopyFeedback(button: HTMLElement): void { + const originalTitle = button.getAttribute('title') || ''; + setIcon(button, 'check'); + button.setAttribute('title', 'Copied!'); + button.classList.add('copy-success'); + + setTimeout(() => { + setIcon(button, 'copy'); + button.setAttribute('title', originalTitle); + button.classList.remove('copy-success'); + }, 1500); + } + + /** + * Clean up progressive tool accordions + */ + private cleanupProgressiveAccordions(): void { + this.progressiveToolAccordions.forEach(accordion => { + const element = accordion.getElement(); + if (element) { + element.remove(); + } + accordion.cleanup(); + }); + + this.progressiveToolAccordions.clear(); + } + + /** + * Append action bar buttons (Copy, Insert, Append, Create File) into the existing + * .message-actions-external pill. Only created once per message lifecycle. + * Only appears for completed assistant messages with non-empty text content. + */ + private appendActionBar(container: HTMLElement | null, message: ConversationMessage): void { + if (!container) return; + if (message.role !== 'assistant') return; + if (message.isLoading || message.state === 'streaming') return; + + const activeContent = MessageBubbleStateResolver.resolve(message).activeContent; + 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. + */ + private isUnloaded = false; + cleanup(): void { this.stopLoadingAnimation(); this.cleanupProgressiveAccordions(); + this.cleanupActionBar(); this.branchNavigatorBinder.destroy(); - - this.element = null; - - // Call Component.unload() to release registerDomEvent and registerInterval handlers. - // Guard against double-unload since unload() is not idempotent. - if (!this.isUnloaded) { - this.isUnloaded = true; - this.unload(); - } - } -} + + this.element = null; + + // Call Component.unload() to release registerDomEvent and registerInterval handlers. + // Guard against double-unload since unload() is not idempotent. + if (!this.isUnloaded) { + this.isUnloaded = true; + this.unload(); + } + } +} 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/styles.css b/styles.css index 43e899d7d..ec775ad02 100644 --- a/styles.css +++ b/styles.css @@ -747,6 +747,19 @@ flex-direction: row-reverse; } +/* Assistant message header sticks to the top of the scroll pane while the bubble is in view. + padding-bottom replaces margin-bottom so the background covers the gap and + scrolling content cannot bleed through underneath. */ +.message-container.message-assistant .message-header { + position: sticky; + top: 0; + z-index: 1; + background: var(--background-secondary); + padding-bottom: 0.5rem; + margin-bottom: 0; + border-radius: 0.75rem 0.75rem 0 0; +} + .message-role-icon { width: 20px; height: 20px;