@peers-app/peers-ui 0.15.4 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,11 +13,19 @@ interface ITableInfo {
13
13
  bytes?: number;
14
14
  }
15
15
 
16
+ interface IDeletedTableInfo {
17
+ tableName: string;
18
+ tableId: string;
19
+ deletedAt: string;
20
+ }
21
+
16
22
  type DataExplorerElectronApi = {
17
23
  getCurrentContext: () => Promise<{ contextName: string }>;
18
24
  getAllTables: () => Promise<ITableInfo[]>;
19
25
  compactDatabase: (beforeTimestamp?: number) => Promise<void>;
20
26
  resetChangeTracking: () => Promise<void>;
27
+ deleteTable: (tableName: string) => Promise<void>;
28
+ getDeletedTables: () => Promise<IDeletedTableInfo[]>;
21
29
  };
22
30
 
23
31
  type WindowWithDataExplorer = Window & {
@@ -28,11 +36,112 @@ function getDataExplorerApi(): DataExplorerElectronApi | undefined {
28
36
  return (window as WindowWithDataExplorer).electronAPI?.dataExplorer;
29
37
  }
30
38
 
39
+ function formatRelativeTime(isoString: string): string {
40
+ const date = new Date(isoString);
41
+ const now = Date.now();
42
+ const diffMs = now - date.getTime();
43
+ const diffMin = Math.floor(diffMs / 60_000);
44
+ if (diffMin < 1) return "just now";
45
+ if (diffMin < 60) return `${diffMin}m ago`;
46
+ const diffHrs = Math.floor(diffMin / 60);
47
+ if (diffHrs < 24) return `${diffHrs}h ago`;
48
+ const diffDays = Math.floor(diffHrs / 24);
49
+ if (diffDays < 30) return `${diffDays}d ago`;
50
+ return date.toLocaleDateString();
51
+ }
52
+
53
+ function DeleteConfirmModal({
54
+ tableName,
55
+ onConfirm,
56
+ onCancel,
57
+ }: {
58
+ tableName: string;
59
+ onConfirm: () => void;
60
+ onCancel: () => void;
61
+ }) {
62
+ const [confirmText, setConfirmText] = useState("");
63
+ const [deleting, setDeleting] = useState(false);
64
+
65
+ const handleConfirm = async () => {
66
+ setDeleting(true);
67
+ try {
68
+ await onConfirm();
69
+ } finally {
70
+ setDeleting(false);
71
+ }
72
+ };
73
+
74
+ return (
75
+ <div
76
+ className="position-fixed top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center"
77
+ style={{ zIndex: 1050, backgroundColor: "rgba(0,0,0,0.5)" }}
78
+ onClick={onCancel}
79
+ >
80
+ <div
81
+ className="card shadow-lg"
82
+ style={{ maxWidth: 480, width: "100%" }}
83
+ onClick={(e) => e.stopPropagation()}
84
+ >
85
+ <div className="card-header bg-danger text-white d-flex justify-content-between align-items-center">
86
+ <strong>Delete Table</strong>
87
+ <button type="button" className="btn-close btn-close-white" onClick={onCancel} />
88
+ </div>
89
+ <div className="card-body">
90
+ <div className="alert alert-danger mb-3">
91
+ <i className="bi bi-exclamation-triangle-fill me-2" />
92
+ This will permanently delete all data for <strong>{tableName}</strong> on this device
93
+ and all synced devices once synced.
94
+ </div>
95
+ <label className="form-label">
96
+ Type <strong>{tableName}</strong> to confirm:
97
+ </label>
98
+ <input
99
+ type="text"
100
+ className="form-control"
101
+ value={confirmText}
102
+ onChange={(e) => setConfirmText(e.target.value)}
103
+ placeholder={tableName}
104
+ // biome-ignore lint/a11y/noAutofocus: confirmation input needs immediate focus
105
+ autoFocus
106
+ />
107
+ </div>
108
+ <div className="card-footer d-flex justify-content-end gap-2">
109
+ <button
110
+ type="button"
111
+ className="btn btn-secondary"
112
+ onClick={onCancel}
113
+ disabled={deleting}
114
+ >
115
+ Cancel
116
+ </button>
117
+ <button
118
+ type="button"
119
+ className="btn btn-danger"
120
+ disabled={confirmText !== tableName || deleting}
121
+ onClick={handleConfirm}
122
+ >
123
+ {deleting ? (
124
+ <>
125
+ <span className="spinner-border spinner-border-sm me-1" />
126
+ Deleting...
127
+ </>
128
+ ) : (
129
+ "Delete Table"
130
+ )}
131
+ </button>
132
+ </div>
133
+ </div>
134
+ </div>
135
+ );
136
+ }
137
+
31
138
  export function DataExplorerList() {
32
139
  const [tables, setTables] = useState<ITableInfo[]>([]);
140
+ const [deletedTables, setDeletedTables] = useState<IDeletedTableInfo[]>([]);
33
141
  const [loading, setLoading] = useState(true);
34
142
  const [currentContext, setCurrentContext] = useState<string>("");
35
143
  const [compacting, setCompacting] = useState(false);
144
+ const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
36
145
 
37
146
  const formatBytes = (bytes: number | undefined) => {
38
147
  if (bytes === undefined) return "-";
@@ -48,10 +157,10 @@ export function DataExplorerList() {
48
157
  const kb = 1024;
49
158
  const mb = kb * 1024;
50
159
 
51
- if (bytes < 10 * kb) return "text-secondary"; // 0 to 10KB: secondary (gray)
52
- if (bytes < 10 * mb) return "text-primary"; // 10KB to 10MB: primary (blue)
53
- if (bytes < 100 * mb) return "text-warning"; // 10MB to 100MB: warning (orange)
54
- return "text-danger"; // >100MB: danger (red)
160
+ if (bytes < 10 * kb) return "text-secondary";
161
+ if (bytes < 10 * mb) return "text-primary";
162
+ if (bytes < 100 * mb) return "text-warning";
163
+ return "text-danger";
55
164
  };
56
165
 
57
166
  const loadTables = useCallback(async () => {
@@ -64,22 +173,21 @@ export function DataExplorerList() {
64
173
  return;
65
174
  }
66
175
 
67
- // Get current context info
68
176
  const contextInfo = await api.getCurrentContext();
69
177
  setCurrentContext(contextInfo.contextName);
70
178
 
71
- // Get all tables from IPC
72
179
  const allTablesInfo = await api.getAllTables();
73
-
74
- // Sort by registered status first, then by table name
75
180
  allTablesInfo.sort((a: ITableInfo, b: ITableInfo) => {
76
181
  if (a.isRegistered !== b.isRegistered) {
77
182
  return a.isRegistered ? -1 : 1;
78
183
  }
79
184
  return a.tableName.localeCompare(b.tableName);
80
185
  });
81
-
82
186
  setTables(allTablesInfo);
187
+
188
+ const deleted = await api.getDeletedTables();
189
+ deleted.sort((a, b) => a.tableName.localeCompare(b.tableName));
190
+ setDeletedTables(deleted);
83
191
  } catch (error) {
84
192
  console.error("Error loading tables:", error);
85
193
  } finally {
@@ -87,6 +195,22 @@ export function DataExplorerList() {
87
195
  }
88
196
  }, []);
89
197
 
198
+ const handleDeleteTable = async () => {
199
+ if (!deleteTarget) return;
200
+ const api = getDataExplorerApi();
201
+ if (!api) return;
202
+
203
+ try {
204
+ await api.deleteTable(deleteTarget);
205
+ setDeleteTarget(null);
206
+ setLoading(true);
207
+ await loadTables();
208
+ } catch (error: unknown) {
209
+ console.error("Error deleting table:", error);
210
+ alert(`Error deleting table: ${error instanceof Error ? error.message : String(error)}`);
211
+ }
212
+ };
213
+
90
214
  const compactDatabase = async (beforeTimestamp?: number) => {
91
215
  try {
92
216
  setCompacting(true);
@@ -110,7 +234,6 @@ export function DataExplorerList() {
110
234
  await api.compactDatabase(beforeTimestamp);
111
235
  alert(`Database compacted successfully!${isExtreme ? " (Extreme mode)" : ""}`);
112
236
 
113
- // Reload tables to show updated sizes
114
237
  setLoading(true);
115
238
  await loadTables();
116
239
  } catch (error: unknown) {
@@ -141,7 +264,6 @@ export function DataExplorerList() {
141
264
  await api.resetChangeTracking();
142
265
  alert("Change tracking reset successfully!");
143
266
 
144
- // Reload tables to show updated sizes
145
267
  setLoading(true);
146
268
  await loadTables();
147
269
  } catch (error: unknown) {
@@ -180,8 +302,19 @@ export function DataExplorerList() {
180
302
  );
181
303
  }
182
304
 
305
+ const registeredTables = tables.filter((t) => t.isRegistered);
306
+ const unregisteredTables = tables.filter((t) => !t.isRegistered);
307
+
183
308
  return (
184
309
  <div className="container-fluid p-4">
310
+ {deleteTarget && (
311
+ <DeleteConfirmModal
312
+ tableName={deleteTarget}
313
+ onConfirm={handleDeleteTable}
314
+ onCancel={() => setDeleteTarget(null)}
315
+ />
316
+ )}
317
+
185
318
  {/* Navigation Tabs */}
186
319
  <ul className="nav nav-tabs mb-4">
187
320
  <li className="nav-item">
@@ -303,14 +436,14 @@ export function DataExplorerList() {
303
436
  <div className="card mb-4">
304
437
  <div className="card-body">
305
438
  <h5 className="card-title">
306
- Registered Tables ({tables.filter((t) => t.isRegistered).length})
439
+ Registered Tables ({registeredTables.length})
307
440
  <span className="badge bg-success ms-2">Active</span>
308
441
  </h5>
309
442
  <p className="text-muted small">
310
443
  These tables are registered with the TableContainer and actively used by the
311
444
  application.
312
445
  </p>
313
- {tables.filter((t) => t.isRegistered).length === 0 ? (
446
+ {registeredTables.length === 0 ? (
314
447
  <p className="text-muted">No registered tables found</p>
315
448
  ) : (
316
449
  <div className="table-responsive">
@@ -321,27 +454,36 @@ export function DataExplorerList() {
321
454
  <th>Table ID</th>
322
455
  <th className="text-end">Row Count</th>
323
456
  <th className="text-end">Bytes</th>
457
+ <th style={{ width: "1%" }}></th>
324
458
  </tr>
325
459
  </thead>
326
460
  <tbody>
327
- {tables
328
- .filter((t) => t.isRegistered)
329
- .map((table) => (
330
- <tr key={table.tableName}>
331
- <td>
332
- <strong>{table.tableName}</strong>
333
- </td>
334
- <td>
335
- <code className="text-muted small">{table.tableId || "system"}</code>
336
- </td>
337
- <td className="text-end">
338
- {table.rowCount !== undefined ? table.rowCount.toLocaleString() : "-"}
339
- </td>
340
- <td className={`text-end fw-bold ${getBytesColorClass(table.bytes)}`}>
341
- {formatBytes(table.bytes)}
342
- </td>
343
- </tr>
344
- ))}
461
+ {registeredTables.map((table) => (
462
+ <tr key={table.tableName}>
463
+ <td>
464
+ <strong>{table.tableName}</strong>
465
+ </td>
466
+ <td>
467
+ <code className="text-muted small">{table.tableId || "system"}</code>
468
+ </td>
469
+ <td className="text-end">
470
+ {table.rowCount !== undefined ? table.rowCount.toLocaleString() : "-"}
471
+ </td>
472
+ <td className={`text-end fw-bold ${getBytesColorClass(table.bytes)}`}>
473
+ {formatBytes(table.bytes)}
474
+ </td>
475
+ <td>
476
+ <button
477
+ type="button"
478
+ className="btn btn-sm btn-outline-danger border-0"
479
+ title={`Delete ${table.tableName}`}
480
+ onClick={() => setDeleteTarget(table.tableName)}
481
+ >
482
+ <i className="bi bi-trash" />
483
+ </button>
484
+ </td>
485
+ </tr>
486
+ ))}
345
487
  </tbody>
346
488
  </table>
347
489
  </div>
@@ -350,11 +492,11 @@ export function DataExplorerList() {
350
492
  </div>
351
493
 
352
494
  {/* Unregistered Tables (Raw SQLite) */}
353
- {tables.some((t) => !t.isRegistered) && (
354
- <div className="card">
495
+ {unregisteredTables.length > 0 && (
496
+ <div className="card mb-4">
355
497
  <div className="card-body">
356
498
  <h5 className="card-title">
357
- Unregistered Tables ({tables.filter((t) => !t.isRegistered).length})
499
+ Unregistered Tables ({unregisteredTables.length})
358
500
  <span className="badge bg-warning text-dark ms-2">Raw SQLite</span>
359
501
  </h5>
360
502
  <p className="text-muted small">
@@ -370,51 +512,104 @@ export function DataExplorerList() {
370
512
  <th className="text-end">Row Count</th>
371
513
  <th className="text-end">Bytes</th>
372
514
  <th>SQL Definition</th>
515
+ <th style={{ width: "1%" }}></th>
516
+ </tr>
517
+ </thead>
518
+ <tbody>
519
+ {unregisteredTables.map((table) => (
520
+ <tr key={table.tableName}>
521
+ <td>
522
+ <strong>{table.tableName}</strong>
523
+ </td>
524
+ <td>
525
+ <span className="badge bg-secondary">{table.type || "table"}</span>
526
+ </td>
527
+ <td className="text-end">
528
+ {table.rowCount !== undefined ? table.rowCount.toLocaleString() : "-"}
529
+ </td>
530
+ <td className={`text-end fw-bold ${getBytesColorClass(table.bytes)}`}>
531
+ {formatBytes(table.bytes)}
532
+ </td>
533
+ <td>
534
+ {table.sql ? (
535
+ <details>
536
+ <summary
537
+ className="cursor-pointer text-primary"
538
+ style={{ cursor: "pointer" }}
539
+ >
540
+ <small>View SQL</small>
541
+ </summary>
542
+ <pre
543
+ className="mt-2 p-2 bg-body-secondary border rounded"
544
+ style={{
545
+ fontSize: "0.75rem",
546
+ maxHeight: "300px",
547
+ overflow: "auto",
548
+ }}
549
+ >
550
+ <code className="text-body">{table.sql}</code>
551
+ </pre>
552
+ </details>
553
+ ) : (
554
+ <span className="text-muted small">-</span>
555
+ )}
556
+ </td>
557
+ <td>
558
+ <button
559
+ type="button"
560
+ className="btn btn-sm btn-outline-danger border-0"
561
+ title={`Delete ${table.tableName}`}
562
+ onClick={() => setDeleteTarget(table.tableName)}
563
+ >
564
+ <i className="bi bi-trash" />
565
+ </button>
566
+ </td>
567
+ </tr>
568
+ ))}
569
+ </tbody>
570
+ </table>
571
+ </div>
572
+ </div>
573
+ </div>
574
+ )}
575
+
576
+ {/* Deleted Tables */}
577
+ {deletedTables.length > 0 && (
578
+ <div className="card mb-4">
579
+ <div className="card-body">
580
+ <h5 className="card-title">
581
+ Deleted Tables ({deletedTables.length})
582
+ <span className="badge bg-danger ms-2">Deleted</span>
583
+ </h5>
584
+ <p className="text-muted small">
585
+ These tables have been deleted. Their definitions are retained so other devices know
586
+ to stop syncing data for them.
587
+ </p>
588
+ <div className="table-responsive">
589
+ <table className="table table-hover table-sm">
590
+ <thead>
591
+ <tr>
592
+ <th>Table Name</th>
593
+ <th>Table ID</th>
594
+ <th>Deleted At</th>
373
595
  </tr>
374
596
  </thead>
375
597
  <tbody>
376
- {tables
377
- .filter((t) => !t.isRegistered)
378
- .map((table) => (
379
- <tr key={table.tableName}>
380
- <td>
381
- <strong>{table.tableName}</strong>
382
- </td>
383
- <td>
384
- <span className="badge bg-secondary">{table.type || "table"}</span>
385
- </td>
386
- <td className="text-end">
387
- {table.rowCount !== undefined ? table.rowCount.toLocaleString() : "-"}
388
- </td>
389
- <td className={`text-end fw-bold ${getBytesColorClass(table.bytes)}`}>
390
- {formatBytes(table.bytes)}
391
- </td>
392
- <td>
393
- {table.sql ? (
394
- <details>
395
- <summary
396
- className="cursor-pointer text-primary"
397
- style={{ cursor: "pointer" }}
398
- >
399
- <small>View SQL</small>
400
- </summary>
401
- <pre
402
- className="mt-2 p-2 bg-body-secondary border rounded"
403
- style={{
404
- fontSize: "0.75rem",
405
- maxHeight: "300px",
406
- overflow: "auto",
407
- }}
408
- >
409
- <code className="text-body">{table.sql}</code>
410
- </pre>
411
- </details>
412
- ) : (
413
- <span className="text-muted small">-</span>
414
- )}
415
- </td>
416
- </tr>
417
- ))}
598
+ {deletedTables.map((table) => (
599
+ <tr key={table.tableId}>
600
+ <td>
601
+ <strong className="text-muted">{table.tableName}</strong>
602
+ </td>
603
+ <td>
604
+ <code className="text-muted small">{table.tableId}</code>
605
+ </td>
606
+ <td>
607
+ <span title={new Date(table.deletedAt).toLocaleString()}>
608
+ {formatRelativeTime(table.deletedAt)}
609
+ </span>
610
+ </td>
611
+ </tr>
612
+ ))}
418
613
  </tbody>
419
614
  </table>
420
615
  </div>
@@ -1,201 +0,0 @@
1
- # Conversation Tab Implementation Plan
2
-
3
- ## Current State Analysis
4
-
5
- ### Existing Thread/Conversation System
6
- The codebase has a robust thread/conversation system with the following components:
7
-
8
- #### **Data Model**
9
- - **IMessage interface** with hierarchical threading via `messageParentId`
10
- - **Thread Logic**: Parent message + replies with same `messageParentId`
11
- - **Channel Integration**: Threads exist within channels but are filtered from main channel view
12
-
13
- #### **State Management**
14
- ```typescript
15
- // Existing persistent variables in globals.tsx
16
- openThreads: persistentVar<(string | IMessage)[]> // Array of open thread IDs
17
- threadViewOpen: persistentVar<boolean> // Controls side panel visibility
18
- openThread(thread): Function // Opens thread in side panel
19
- ```
20
-
21
- #### **Existing Components**
22
- - **ThreadContainer**: Main thread UI with tabbed interface for multiple threads
23
- - **ThreadMessageList**: Displays messages within specific thread
24
- - **MessageDisplay**: Individual message rendering with thread context
25
- - **OpenThreads**: Dropdown for thread switching
26
-
27
- #### **Current Limitations**
28
- - ❌ No URL routing for threads (`/threads/[id]`)
29
- - ❌ No standalone thread detail views (always side panel)
30
- - ❌ No deep-linking to specific conversations
31
- - ❌ No integration with tabs system
32
-
33
- ## Implementation Plan: Phase 1 - Conversation Tabs
34
-
35
- ### **Goal**: Integrate existing thread system with tabs architecture
36
-
37
- ### **Step 1: Thread Routing Integration**
38
- Add thread detail routes to main Router component:
39
-
40
- ```typescript
41
- // In router.tsx
42
- if (path.match(/^threads\/([a-zA-Z0-9]{25})/)) {
43
- const threadId = path.split('/')[1];
44
- return <ThreadDetailView threadId={threadId} />;
45
- }
46
- ```
47
-
48
- ### **Step 2: Create ThreadDetailView Component**
49
- New component that renders thread as main content instead of side panel:
50
-
51
- ```typescript
52
- // src/screens/threads/thread-details.tsx
53
- export const ThreadDetails = (props: { threadId: string }) => {
54
- const thread = usePromise(async () => {
55
- const parentMessage = await Messages().get(props.threadId);
56
- if (!parentMessage) return null;
57
- updateActiveTabTitle(`Thread: ${parentMessage.message.slice(0, 30)}...`);
58
- return parentMessage;
59
- }, [props.threadId]);
60
-
61
- // Render thread messages in main content area
62
- return <ThreadMessageList threadId={props.threadId} />;
63
- };
64
- ```
65
-
66
- ### **Step 3: Thread System App Enhancement**
67
- Update the threads system app to support individual thread opening:
68
-
69
- ```typescript
70
- // src/system-apps/threads.app.ts
71
- export const threadsApp: IAppNav = {
72
- name: 'Threads',
73
- iconClassName: 'bi-cpu',
74
- navigationPath: 'shell', // Main shell for overview
75
- alwaysNewTab: false // Allow switching to existing thread list
76
- };
77
-
78
- // New system app for individual conversations
79
- export const conversationApp: IAppNav = {
80
- name: 'Conversation',
81
- iconClassName: 'bi-chat-dots',
82
- navigationPath: 'threads/', // Will be dynamic per thread
83
- alwaysNewTab: true // Each conversation gets its own tab
84
- };
85
- ```
86
-
87
- ### **Step 4: Thread Opening Integration**
88
- Modify existing `openThread()` function to support both side panel and tab modes:
89
-
90
- ```typescript
91
- // Enhanced openThread function
92
- const openThreadInTab = (threadId: string, messagePreview?: string) => {
93
- const threadPath = `threads/${threadId}`;
94
- const threadTitle = messagePreview
95
- ? `Thread: ${messagePreview.slice(0, 30)}...`
96
- : 'Thread';
97
-
98
- // Open thread in new tab
99
- onOpenTab({
100
- packageId: 'system-apps',
101
- path: threadPath,
102
- title: threadTitle,
103
- iconClassName: 'bi-chat-dots',
104
- closable: true
105
- }, true); // Always new tab for threads
106
- };
107
- ```
108
-
109
- ### **Step 5: State Migration Strategy**
110
- **Option A: Merge openThreads into activeTabs**
111
- - Convert existing `openThreads` entries to tab format
112
- - Remove separate thread state management
113
- - Rely on activeTabs for persistence
114
-
115
- **Option B: Parallel Systems (Recommended Phase 1)**
116
- - Keep existing `openThreads` for side panel (backwards compatibility)
117
- - Add new tab-based thread opening
118
- - Allow users to choose: quick side panel vs dedicated tab
119
-
120
- ### **Step 6: Thread List Enhancement**
121
- Create a thread list view for the main "Threads" app:
122
-
123
- ```typescript
124
- // src/screens/threads/thread-list.tsx
125
- export const ThreadList = () => {
126
- // Show user's recent threads/conversations
127
- // Each thread gets "Open in Tab" button
128
- // Integration with search functionality
129
- };
130
- ```
131
-
132
- ## Benefits of Tab-Based Threads
133
-
134
- ### **User Experience**
135
- - **Multiple Conversations**: Several AI chats open simultaneously
136
- - **Context Preservation**: Threads survive app reloads
137
- - **Deep Linking**: Direct URLs to specific conversations
138
- - **Mobile Friendly**: Tabs work better than sliding panels on mobile
139
-
140
- ### **Technical Benefits**
141
- - **Consistent Architecture**: Threads follow same patterns as other detail screens
142
- - **Dynamic Titles**: Smart titles showing conversation context
143
- - **Search Integration**: Threads appear in app launcher search
144
- - **Grid Layout Ready**: Perfect foundation for Phase 2 grid system
145
-
146
- ## Implementation Phases
147
-
148
- ### **Phase 1: Basic Thread Tabs** (This Plan)
149
- - [ ] Add thread routing to Router component
150
- - [ ] Create ThreadDetails component
151
- - [ ] Update openThread function for tab support
152
- - [ ] Add thread list view
153
- - [ ] Update system apps configuration
154
-
155
- ### **Phase 2: State Unification**
156
- - [ ] Migrate openThreads data to activeTabs
157
- - [ ] Remove duplicate thread state management
158
- - [ ] Enhance thread persistence and sync
159
-
160
- ### **Phase 3: Advanced Features**
161
- - [ ] Thread search and filtering
162
- - [ ] Conversation previews in app launcher
163
- - [ ] Thread grouping and organization
164
- - [ ] Integration with AI assistant selection
165
-
166
- ### **Phase 4: Grid Layout Integration** (Future)
167
- - [ ] Split screen: content + conversation
168
- - [ ] Multiple conversations in grid cells
169
- - [ ] Flexible conversation positioning
170
- - [ ] Context-aware conversation suggestions
171
-
172
- ## Technical Considerations
173
-
174
- ### **Thread ID Format**
175
- - Existing thread IDs are message IDs (25-character strings)
176
- - URL pattern: `/threads/[messageId]`
177
- - Compatible with existing message lookup
178
-
179
- ### **Title Generation Strategy**
180
- ```typescript
181
- const generateThreadTitle = (parentMessage: IMessage): string => {
182
- // Use first 30 characters of parent message
183
- const preview = parentMessage.message.slice(0, 30);
184
- return `Thread: ${preview}${parentMessage.message.length > 30 ? '...' : ''}`;
185
- };
186
- ```
187
-
188
- ### **Integration Points**
189
- - **Router**: Add thread routing patterns
190
- - **ThreadMessageList**: Reuse existing component
191
- - **TabsLayout**: Thread tabs with conversation icons
192
- - **App Launcher**: Threads searchable and launchable
193
-
194
- ## Migration Path
195
-
196
- 1. **Implement parallel systems** (old side panel + new tabs)
197
- 2. **User choice** during transition period
198
- 3. **Gradual migration** of state and preferences
199
- 4. **Remove legacy** side panel system once tabs proven
200
-
201
- This approach ensures no disruption to existing users while providing enhanced capabilities for power users who want multiple conversations open simultaneously.