Skip to content

Commit 0936ab3

Browse files
committed
final testing
1 parent e2ed9ce commit 0936ab3

13 files changed

Lines changed: 586 additions & 147 deletions

File tree

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
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.

src/backend/session/managers/ConversationManager.ts

Lines changed: 118 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
*/
1010

1111
import { SyncedManager, synced, rpc } from "../../../lib/sync";
12-
import { getAllConversations, deleteConversation, updateConversation } from "../../models/conversation.model";
12+
import { getAllConversations, deleteConversation, updateConversation, createConversation } from "../../models/conversation.model";
1313
import { getChunksByConversationId, getChunksByTimeRange } from "../../models/transcript-chunk.model";
1414
import { getDailyTranscript } from "../../models";
1515
import type { ConversationI } from "../../models/conversation.model";
@@ -365,10 +365,16 @@ export class ConversationManager extends SyncedManager {
365365
});
366366

367367
try {
368-
// Fetch ALL chunks in the conversation's time range (not just linked ones),
369-
// so filler/skipped segments between meaningful ones are included in the summary transcript.
370-
const endTime = conv.endTime ?? new Date();
371-
const chunks = await getChunksByTimeRange(conv.userId, conv.startTime, endTime);
368+
// For merged conversations (many chunkIds spanning multiple time ranges),
369+
// use chunkIds directly. For normal conversations, use time range to include filler chunks.
370+
let chunks: TranscriptChunkI[];
371+
if (conv.chunkIds.length > 0) {
372+
const { TranscriptChunk } = await import("../../models/transcript-chunk.model");
373+
chunks = await TranscriptChunk.find({ _id: { $in: conv.chunkIds } }).sort({ startTime: 1 });
374+
} else {
375+
const endTime = conv.endTime ?? new Date();
376+
chunks = await getChunksByTimeRange(conv.userId, conv.startTime, endTime);
377+
}
372378
if (chunks.length === 0) {
373379
console.warn(`[ConvManager] No chunks for conversation ${convId}, skipping summary`);
374380
await updateConversation(convId, { generatingSummary: false });
@@ -389,12 +395,17 @@ export class ConversationManager extends SyncedManager {
389395
})
390396
.join("\n\n");
391397

398+
const isMerged = conv.chunkIds.length > 0 && chunks.length > 5;
399+
const mergeNote = isMerged
400+
? "\nNote: This is a merged conversation combining multiple discussions. The title should capture the overall theme across all topics discussed, not just one subtopic.\n"
401+
: "";
402+
392403
const prompt = `Summarize this conversation. Respond with EXACTLY this format:
393404
394405
TITLE: <max 5 words>
395406
396407
<2-3 sentences max. Key points and decisions only. Be extremely concise.>
397-
408+
${mergeNote}
398409
Transcript:
399410
---
400411
${transcript}
@@ -696,6 +707,107 @@ ${transcript}
696707
return parts.join("\n\n---\n\n");
697708
}
698709

710+
@rpc
711+
async mergeConversations(conversationIds: string[], trashOriginals: boolean): Promise<string> {
712+
if (conversationIds.length < 2) throw new Error("Need at least 2 conversations to merge");
713+
if (conversationIds.length > 10) throw new Error("Cannot merge more than 10 conversations at once");
714+
715+
const userId = this._session?.userId;
716+
if (!userId) throw new Error("No user session");
717+
718+
console.log(`[ConvManager] Merging ${conversationIds.length} conversations for ${userId}`);
719+
720+
// 1. Load and validate all source conversations from DB
721+
const { default: mongoose } = await import("mongoose");
722+
const ConversationModel = mongoose.model("Conversation");
723+
const sourceConvs: ConversationI[] = [];
724+
for (const id of conversationIds) {
725+
const conv = await ConversationModel.findById(id) as ConversationI | null;
726+
if (!conv) throw new Error(`Conversation ${id} not found`);
727+
if (conv.status !== "ended") throw new Error(`Conversation ${id} is not ended (status: ${conv.status})`);
728+
sourceConvs.push(conv);
729+
}
730+
731+
// 2. Sort source conversations by startTime (chronological)
732+
sourceConvs.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
733+
734+
// 3. Collect all chunk IDs from source conversations' stored arrays (not DB query,
735+
// because previous merges may have reassigned chunk conversationId)
736+
const allChunkIds: string[] = [];
737+
const seenChunkIds = new Set<string>();
738+
for (const conv of sourceConvs) {
739+
for (const chunkId of conv.chunkIds) {
740+
const id = chunkId.toString();
741+
if (!seenChunkIds.has(id)) {
742+
seenChunkIds.add(id);
743+
allChunkIds.push(id);
744+
}
745+
}
746+
}
747+
748+
// 4. Determine merged conversation date/time range
749+
// startTime/endTime span the full range (needed for AI summary to find chunks)
750+
// For list positioning: we store the actual range but set startTime slightly after
751+
// the latest source so it appears right after it in the descending sort
752+
const earliestConv = sourceConvs[0];
753+
const latestConv = sourceConvs[sourceConvs.length - 1];
754+
const mergedDate = latestConv.date;
755+
const actualStartTime = earliestConv.startTime;
756+
const actualEndTime = latestConv.endTime ?? new Date();
757+
// 5. Create the merged conversation with actual time range (for chunk queries)
758+
const mergedConv = await createConversation({
759+
userId,
760+
date: mergedDate,
761+
startTime: actualStartTime,
762+
});
763+
const mergedId = mergedConv._id!.toString();
764+
765+
// 6. Update merged conversation with full data
766+
await updateConversation(mergedId, {
767+
status: "ended",
768+
endTime: actualEndTime,
769+
chunkIds: allChunkIds,
770+
runningSummary: "",
771+
noteId: null,
772+
silenceCount: 0,
773+
} as any);
774+
775+
// 7. Reassign all chunks to the merged conversation
776+
const { TranscriptChunk } = await import("../../models/transcript-chunk.model");
777+
await TranscriptChunk.updateMany(
778+
{ _id: { $in: allChunkIds } },
779+
{ $set: { conversationId: mergedId } },
780+
);
781+
782+
console.log(`[ConvManager] Merged conversation ${mergedId} created with ${allChunkIds.length} chunks`);
783+
784+
// 8. Add merged conversation to frontend state FIRST (so AI summary sync can find it)
785+
const initialFrontendConv = await this.toFrontendConversation(
786+
(await ConversationModel.findById(mergedId)) as ConversationI,
787+
{ skipSegments: true },
788+
);
789+
this.conversations.mutate((list) => {
790+
list.unshift(initialFrontendConv);
791+
});
792+
793+
// 9. Generate AI summary + title (syncs to frontend via mutate internally)
794+
const freshConv = await ConversationModel.findById(mergedId) as ConversationI;
795+
if (freshConv) {
796+
await this.generateAISummary(freshConv);
797+
}
798+
799+
// 10. If trashOriginals, trash source conversations
800+
if (trashOriginals) {
801+
for (const conv of sourceConvs) {
802+
await this.trashConversation(conv._id!.toString());
803+
}
804+
}
805+
806+
const finalTitle = (await ConversationModel.findById(mergedId) as ConversationI)?.title || "Merged Conversation";
807+
console.log(`[ConvManager] Merge complete: "${finalTitle}" — ${mergedId} (${allChunkIds.length} chunks, ${sourceConvs.length} sources${trashOriginals ? ", originals trashed" : ""})`);
808+
return mergedId;
809+
}
810+
699811
// =========================================================================
700812
// Helpers
701813
// =========================================================================

0 commit comments

Comments
 (0)