|
| 1 | +# Issue #16: Merge Conversations |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +Allow users to select multiple conversations and merge them into a single new conversation. This handles the case where the auto-conversation tracker splits what should be one continuous conversation into multiple pieces. |
| 6 | + |
| 7 | +## User Flow |
| 8 | + |
| 9 | +1. User enters multi-select mode on the Conversations tab |
| 10 | +2. Selects 2+ conversations |
| 11 | +3. Taps **Merge** button in the bottom bar |
| 12 | +4. A **Merge Drawer** appears with: |
| 13 | + - Title: "Merge {N} Conversations?" |
| 14 | + - Description explaining what will happen |
| 15 | + - Checkbox: "Move original conversations to trash after merge" (default: checked) |
| 16 | + - **Merge** button (primary, dark) |
| 17 | + - **Cancel** button |
| 18 | +5. On confirm: |
| 19 | + - Segments from all selected conversations are loaded and sorted by timestamp (oldest → newest) |
| 20 | + - A new conversation is created with all accumulated segments |
| 21 | + - AI generates a new summary and title for the merged conversation |
| 22 | + - If "move to trash" is checked, original conversations are trashed (not permanently deleted) |
| 23 | + - The linked notes on the original conversations are NOT carried over (treated as a fresh conversation) |
| 24 | + - User exits selection mode, sees the new merged conversation in the list |
| 25 | + |
| 26 | +## Segment Ordering |
| 27 | + |
| 28 | +Segments are sorted chronologically across all source conversations: |
| 29 | +- Oldest segments first, newest last |
| 30 | +- If conversations span multiple dates (e.g., yesterday + today), segments from yesterday come before today's |
| 31 | +- Within the same date, sort by timestamp |
| 32 | + |
| 33 | +## Edge Cases (Basic) |
| 34 | + |
| 35 | +| Case | Behavior | |
| 36 | +|------|----------| |
| 37 | +| Conversations have linked AI notes | Notes are ignored — merge creates a fresh conversation with no noteId | |
| 38 | +| Conversations span multiple dates | New conversation uses the earliest date as its `date`, with `startTime` from earliest segment and `endTime` from latest | |
| 39 | +| One conversation is active/paused | Active/paused conversations cannot be selected, so this can't happen | |
| 40 | +| Only 1 conversation selected | Merge button is disabled (need 2+) | |
| 41 | +| Conversations are from trash | Merge button hidden when in trash filter | |
| 42 | +| Original conversations trashed | Optional via checkbox — moves to trash, not permanent delete | |
| 43 | +| Merged conversation has 0 segments | Shouldn't happen since we require ended conversations with chunks, but fallback: use chunks text if segments fail to load | |
| 44 | + |
| 45 | +## Edge Cases (Deep Investigation) |
| 46 | + |
| 47 | +### HIGH RISK |
| 48 | + |
| 49 | +#### Chunk References & Orphaning |
| 50 | +- Each `TranscriptChunk` has a `conversationId` field. When merging, all chunks from source conversations must be reassigned to the merged conversation's ID |
| 51 | +- If any chunk is missed, it becomes orphaned — queryable only by time range, breaking conversation integrity |
| 52 | +- `chunkIds` array must be merged in chronological order by `chunkIndex`/`startTime`, not insertion order |
| 53 | +- If a chunk was already deleted (cleanup), the merged conversation will silently have fewer segments |
| 54 | +- **Mitigation:** Reassign chunk `conversationId` in bulk via `TranscriptChunk.updateMany()`, deduplicate chunk IDs before merging |
| 55 | + |
| 56 | +#### Frontend Sync & State Mismatch |
| 57 | +- The frontend syncs via `conversations.set()` which replaces the entire list |
| 58 | +- If merging happens server-side without properly syncing, the frontend still has old conversation objects |
| 59 | +- Clicking a trashed/deleted source conversation after merge crashes the UI |
| 60 | +- **Mitigation:** Atomic sync — add the new conversation and remove/update the old ones in a single `conversations.mutate()` call |
| 61 | + |
| 62 | +#### Stale Embeddings (Semantic Search) |
| 63 | +- Conversations store an `embedding` field (vector for semantic search), generated when `aiSummary` is set |
| 64 | +- Merging changes the conversation's content, making old embeddings irrelevant |
| 65 | +- If not regenerated, semantic search returns incorrect results |
| 66 | +- **Mitigation:** Regenerate embedding after generating the new AI summary |
| 67 | + |
| 68 | +#### Concurrency & Race Conditions |
| 69 | +- If a merge is in progress and new chunks are being added to one of the source conversations, the merge could miss chunks |
| 70 | +- If a note is being generated from a source conversation simultaneously, it might fail or generate from partial data |
| 71 | +- **Mitigation:** Only allow merging of "ended" conversations (already enforced). Add a merge lock to prevent concurrent merges on the same conversations |
| 72 | + |
| 73 | +### MEDIUM RISK |
| 74 | + |
| 75 | +#### Cross-Date Conversations |
| 76 | +- A conversation's `date` field stores ONE date (start date in user's timezone) |
| 77 | +- Merging conversations across different dates forces a choice — merged conversation can only belong to one date |
| 78 | +- This breaks the assumption that conversations are grouped by day |
| 79 | +- **Mitigation:** Use the earliest date. Show a note in the merge drawer if conversations span multiple dates |
| 80 | + |
| 81 | +#### Running Summary Word Count |
| 82 | +- Each conversation has a `runningSummary` (compressed transcript text, max 300 words) |
| 83 | +- Merging 3 conversations with 300-word summaries = 900 words, exceeding the compression target |
| 84 | +- The `runningSummary` is used by the LLM for chunk classification and resumption checks |
| 85 | +- **Mitigation:** Don't concatenate running summaries — regenerate from the AI summary of the merged conversation |
| 86 | + |
| 87 | +#### Orphaned Notes |
| 88 | +- Source conversations may have linked notes (`noteId`). After merging: |
| 89 | + - The notes still exist but now reference conversations that may be trashed |
| 90 | + - The `From: conversation title` label on those notes becomes stale |
| 91 | +- **Mitigation:** Don't delete notes. They remain as standalone notes. Their `From:` label might show a trashed conversation title — acceptable |
| 92 | + |
| 93 | +#### Segment Loading from Different Sources |
| 94 | +- Segments come from a 3-tier fallback: in-memory → MongoDB → R2 |
| 95 | +- Merging conversations from different dates may hit different sources |
| 96 | +- If R2 data was deleted for one conversation (transcript deleted feature), those segments are silently lost |
| 97 | +- **Mitigation:** Log a warning if any source conversation returns 0 segments. Show in the merge drawer if data is missing |
| 98 | + |
| 99 | +#### Title Decision |
| 100 | +- Each source conversation has a different title |
| 101 | +- **Mitigation:** Regenerate title from the merged AI summary (LLM call). Don't try to combine existing titles |
| 102 | + |
| 103 | +### LOW RISK |
| 104 | + |
| 105 | +#### Favourite/Archive/Trash Status Conflicts |
| 106 | +- Source conversations may have different statuses (one favourited, one archived) |
| 107 | +- **Mitigation:** New conversation starts as default (not favourited, not archived, not trashed). User can favourite after |
| 108 | + |
| 109 | +#### Resumption Metadata |
| 110 | +- Conversations have `resumedFrom` field for lineage tracking |
| 111 | +- Merging breaks the lineage chain |
| 112 | +- **Mitigation:** Set `resumedFrom: null` on merged conversation. Not user-facing |
| 113 | + |
| 114 | +#### Metadata & Timestamps |
| 115 | +- `createdAt`/`updatedAt` on the merged conversation should use current time |
| 116 | +- Chunks have `metadata` field that may have conflicting data |
| 117 | +- **Mitigation:** Use current time for `createdAt`. Chunk metadata is preserved as-is |
| 118 | + |
| 119 | +## Implementation Plan |
| 120 | + |
| 121 | +### Phase 1: Backend RPC |
| 122 | + |
| 123 | +**File:** `src/backend/session/managers/ConversationManager.ts` |
| 124 | + |
| 125 | +Add new RPC method: |
| 126 | + |
| 127 | +```typescript |
| 128 | +@rpc |
| 129 | +async mergeConversations( |
| 130 | + conversationIds: string[], |
| 131 | + trashOriginals: boolean |
| 132 | +): Promise<string> // returns new conversation ID |
| 133 | +``` |
| 134 | + |
| 135 | +Steps: |
| 136 | +1. Validate: all conversations exist, status is "ended", at least 2 IDs |
| 137 | +2. Sort source conversations by `startTime` ascending (chronological order) |
| 138 | +3. For each conversation, load segments via `getSegmentsForConversation()` |
| 139 | +4. Merge all segments into one array, sort by `timestamp` ascending, deduplicate |
| 140 | +5. Merge all `chunkIds` from source conversations, sorted chronologically |
| 141 | +6. Reassign chunk `conversationId` to the new conversation ID via bulk update |
| 142 | +7. Create new conversation record with: |
| 143 | + - `date`: earliest conversation's date |
| 144 | + - `startTime`: earliest segment/chunk timestamp |
| 145 | + - `endTime`: latest segment/chunk timestamp |
| 146 | + - `status`: "ended" |
| 147 | + - `noteId`: null |
| 148 | + - `isFavourite`: false, `isArchived`: false, `isTrashed`: false |
| 149 | + - Combined `chunkIds` |
| 150 | + - `runningSummary`: empty (will be regenerated) |
| 151 | +8. Generate AI summary + title via existing `generateAISummary()` flow |
| 152 | +9. Generate embedding from the new AI summary |
| 153 | +10. If `trashOriginals`: call `trashConversation()` for each source (NOT delete — preserves data) |
| 154 | +11. Sync to frontend: add new conversation, update trashed ones in a single `conversations.mutate()` |
| 155 | +12. Return the new conversation ID |
| 156 | + |
| 157 | +### Phase 2: Frontend — Merge Button (DONE) |
| 158 | + |
| 159 | +**File:** `src/frontend/pages/home/HomePage.tsx` |
| 160 | + |
| 161 | +- ✅ `MergeIcon` added to `MultiSelectBar.tsx` |
| 162 | +- ✅ Merge action added to `convSelectActions` |
| 163 | +- TODO: Disable merge when < 2 conversations selected |
| 164 | +- TODO: Wire onClick to open merge drawer |
| 165 | + |
| 166 | +### Phase 3: Frontend — Merge Drawer |
| 167 | + |
| 168 | +**File:** `src/frontend/pages/home/HomePage.tsx` (inline) |
| 169 | + |
| 170 | +Vaul Drawer with: |
| 171 | +- Title: "Merge {N} Conversations?" |
| 172 | +- List of conversation titles being merged (scrollable if many) |
| 173 | +- Warning if conversations span multiple dates |
| 174 | +- Warning if any source conversation has 0 loadable segments |
| 175 | +- Checkbox: "Move originals to trash" (default checked) |
| 176 | +- Merge button (calls the RPC, shows loading state) |
| 177 | +- Cancel button |
| 178 | + |
| 179 | +### Phase 4: Type Updates |
| 180 | + |
| 181 | +**File:** `src/shared/types.ts` |
| 182 | + |
| 183 | +Add to `ConversationManagerI`: |
| 184 | +```typescript |
| 185 | +mergeConversations(conversationIds: string[], trashOriginals: boolean): Promise<string>; |
| 186 | +``` |
| 187 | + |
| 188 | +## Files to Modify |
| 189 | + |
| 190 | +| File | Change | |
| 191 | +|------|--------| |
| 192 | +| `src/shared/types.ts` | Add `mergeConversations` to `ConversationManagerI` | |
| 193 | +| `src/backend/session/managers/ConversationManager.ts` | Add `mergeConversations` RPC | |
| 194 | +| `src/backend/models/conversation.model.ts` | May need bulk chunk reassignment helper | |
| 195 | +| `src/frontend/components/shared/MultiSelectBar.tsx` | ✅ `MergeIcon` added | |
| 196 | +| `src/frontend/pages/home/HomePage.tsx` | Add merge drawer, handler, state | |
| 197 | + |
| 198 | +## Design Notes |
| 199 | + |
| 200 | +- Merge button uses a "combine" icon (two arrows merging into one line) |
| 201 | +- Button sits between Export and Favorite in the bottom bar |
| 202 | +- Only enabled when 2+ conversations are selected |
| 203 | +- Drawer matches the existing delete confirmation drawer style (vaul, warm stone design) |
| 204 | +- Merge is an async operation — show a loading spinner on the button while in progress |
| 205 | +- After merge completes, auto-navigate to the new conversation detail page (optional) |
| 206 | + |
| 207 | +## Decisions |
| 208 | + |
| 209 | +1. **No auto-navigate after merge.** Spinner on merge button → generates → exits selection mode → merged conversation appears in the list naturally. |
| 210 | +2. **Max 10 conversations** can be merged at once. Show error/disable if > 10 selected. |
| 211 | +3. **No preview** in the merge drawer. Keep it simple — title, description, trash checkbox, merge button. |
0 commit comments