@launchsecure/launch-kit 0.0.1

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.
Files changed (64) hide show
  1. package/README.md +37 -0
  2. package/dist/client/assets/index-C8GAsRGO.css +32 -0
  3. package/dist/client/assets/index-CcHIoRl6.js +286 -0
  4. package/dist/client/index.html +22 -0
  5. package/dist/server/cli.js +8853 -0
  6. package/dist/server/fb-wizard.js +136 -0
  7. package/dist/server/graph-mcp-entry.js +1542 -0
  8. package/dist/server/public/app.js +1312 -0
  9. package/dist/server/public/icons.js +36 -0
  10. package/dist/server/public/index.html +159 -0
  11. package/dist/server/public/plan-detector.js +186 -0
  12. package/dist/server/public/session-manager.js +1129 -0
  13. package/dist/server/public/splits.js +569 -0
  14. package/dist/server/public/style.css +1620 -0
  15. package/package.json +73 -0
  16. package/prompts/analysis.md +992 -0
  17. package/prompts/architect-reconcile.md +931 -0
  18. package/prompts/architecture-sync.md +902 -0
  19. package/prompts/be-contract.md +709 -0
  20. package/prompts/be-impl.md +565 -0
  21. package/prompts/be-policy.md +551 -0
  22. package/prompts/be-test.md +591 -0
  23. package/prompts/bug-diagnosis.md +653 -0
  24. package/prompts/bug-intake.md +563 -0
  25. package/prompts/change-request-intake.md +593 -0
  26. package/prompts/db-contract.md +644 -0
  27. package/prompts/db-impl.md +522 -0
  28. package/prompts/db-interaction.md +569 -0
  29. package/prompts/db-test.md +630 -0
  30. package/prompts/decision-pack.md +654 -0
  31. package/prompts/fe-contract.md +992 -0
  32. package/prompts/fe-flow.md +537 -0
  33. package/prompts/fe-impl.md +597 -0
  34. package/prompts/fe-reconcile.md +506 -0
  35. package/prompts/fe-review.md +550 -0
  36. package/prompts/fe-test.md +705 -0
  37. package/prompts/fix-planner.md +1219 -0
  38. package/prompts/global-db-patterns.md +588 -0
  39. package/prompts/global-env-config.md +460 -0
  40. package/prompts/global-integrations.md +504 -0
  41. package/prompts/global-middleware.md +442 -0
  42. package/prompts/global-navigation.md +502 -0
  43. package/prompts/global-security.md +603 -0
  44. package/prompts/global-services.md +427 -0
  45. package/prompts/greenfield-classifier.md +590 -0
  46. package/prompts/llm-council.md +597 -0
  47. package/prompts/module-sequencer.md +529 -0
  48. package/prompts/normalize.md +611 -0
  49. package/prompts/optimization.md +633 -0
  50. package/prompts/prd-generation.md +544 -0
  51. package/prompts/prd-reconcile.md +584 -0
  52. package/prompts/prd-review.md +504 -0
  53. package/prompts/pre-code-analysis.md +565 -0
  54. package/prompts/pre-code-global-analysis.md +169 -0
  55. package/prompts/production-bootstrap.md +577 -0
  56. package/prompts/research.md +702 -0
  57. package/prompts/retrofit-analysis.md +845 -0
  58. package/prompts/spike.md +850 -0
  59. package/prompts/theming.md +835 -0
  60. package/prompts/triage.md +599 -0
  61. package/prompts/unified-reconcile.md +628 -0
  62. package/prompts/unified-review.md +592 -0
  63. package/prompts/user-stories.md +486 -0
  64. package/prompts/wireframe.md +576 -0
@@ -0,0 +1,1129 @@
1
+ class SessionTabManager {
2
+ constructor(claudeInterface) {
3
+ this.claudeInterface = claudeInterface;
4
+ this.tabs = new Map(); // sessionId -> tab element
5
+ this.activeSessions = new Map(); // sessionId -> session data
6
+ this.activeTabId = null;
7
+ this.tabOrder = []; // visual order of tabs
8
+ this.tabHistory = []; // most recently used order
9
+ this.notificationsEnabled = false;
10
+ this.requestNotificationPermission();
11
+ }
12
+
13
+ getAlias(kind) {
14
+ if (this.claudeInterface && typeof this.claudeInterface.getAlias === 'function') {
15
+ return this.claudeInterface.getAlias(kind);
16
+ }
17
+ return kind === 'codex' ? 'Codex' : 'Claude';
18
+ }
19
+
20
+ requestNotificationPermission() {
21
+ if ('Notification' in window) {
22
+ if (Notification.permission === 'default') {
23
+ // Request permission
24
+ Notification.requestPermission().then(permission => {
25
+ this.notificationsEnabled = permission === 'granted';
26
+ if (this.notificationsEnabled) {
27
+ console.log('Desktop notifications enabled');
28
+ } else {
29
+ console.log('Desktop notifications denied');
30
+ }
31
+ });
32
+ } else if (Notification.permission === 'granted') {
33
+ this.notificationsEnabled = true;
34
+ console.log('Desktop notifications already enabled');
35
+ } else {
36
+ this.notificationsEnabled = false;
37
+ console.log('Desktop notifications blocked');
38
+ }
39
+ } else {
40
+ console.log('Desktop notifications not supported in this browser');
41
+ }
42
+ }
43
+
44
+ sendNotification(title, body, sessionId) {
45
+ // Don't send notification for active tab
46
+ if (sessionId === this.activeTabId) return;
47
+
48
+ // Only send notifications if the page is not visible
49
+ if (document.visibilityState === 'visible') return;
50
+
51
+ // Try desktop notifications first (won't work on iOS Safari)
52
+ if ('Notification' in window && Notification.permission === 'granted') {
53
+ try {
54
+ const notification = new Notification(title, {
55
+ body: body,
56
+ icon: '/favicon.ico',
57
+ tag: sessionId,
58
+ requireInteraction: false,
59
+ silent: false
60
+ });
61
+
62
+ notification.onclick = () => {
63
+ window.focus();
64
+ this.switchToTab(sessionId);
65
+ notification.close();
66
+ };
67
+
68
+ setTimeout(() => notification.close(), 5000);
69
+ console.log(`Desktop notification sent: ${title}`);
70
+ return; // Exit if desktop notification worked
71
+ } catch (error) {
72
+ console.error('Desktop notification failed:', error);
73
+ }
74
+ }
75
+
76
+ // Fallback for mobile: Use visual + audio/vibration
77
+ this.showMobileNotification(title, body, sessionId);
78
+ }
79
+
80
+ showMobileNotification(title, body, sessionId) {
81
+ // Update page title to show notification
82
+ const originalTitle = document.title;
83
+ let flashCount = 0;
84
+ const flashInterval = setInterval(() => {
85
+ document.title = flashCount % 2 === 0 ? `• ${title}` : originalTitle;
86
+ flashCount++;
87
+ if (flashCount > 6) {
88
+ clearInterval(flashInterval);
89
+ document.title = originalTitle;
90
+ }
91
+ }, 1000);
92
+
93
+ // Try to vibrate if available (Android)
94
+ if ('vibrate' in navigator) {
95
+ try {
96
+ navigator.vibrate([200, 100, 200]);
97
+ } catch (e) {
98
+ console.log('Vibration not available');
99
+ }
100
+ }
101
+
102
+ // Show a toast-style notification at the top of the screen
103
+ const toast = document.createElement('div');
104
+ toast.className = 'mobile-notification';
105
+ toast.style.cssText = `
106
+ position: fixed;
107
+ top: 10px;
108
+ left: 50%;
109
+ transform: translateX(-50%);
110
+ background: #3b82f6;
111
+ color: white;
112
+ padding: 12px 20px;
113
+ border-radius: 8px;
114
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
115
+ z-index: 10001;
116
+ max-width: 90%;
117
+ text-align: center;
118
+ cursor: pointer;
119
+ animation: slideDown 0.3s ease-out;
120
+ `;
121
+
122
+ toast.innerHTML = `
123
+ <div style="font-weight: bold; margin-bottom: 4px;">${title}</div>
124
+ <div style="font-size: 14px; opacity: 0.9;">${body}</div>
125
+ `;
126
+
127
+ // Add CSS animation
128
+ if (!document.querySelector('#mobileNotificationStyles')) {
129
+ const style = document.createElement('style');
130
+ style.id = 'mobileNotificationStyles';
131
+ style.textContent = `
132
+ @keyframes slideDown {
133
+ from {
134
+ transform: translateX(-50%) translateY(-100%);
135
+ opacity: 0;
136
+ }
137
+ to {
138
+ transform: translateX(-50%) translateY(0);
139
+ opacity: 1;
140
+ }
141
+ }
142
+ @keyframes slideUp {
143
+ from {
144
+ transform: translateX(-50%) translateY(0);
145
+ opacity: 1;
146
+ }
147
+ to {
148
+ transform: translateX(-50%) translateY(-100%);
149
+ opacity: 0;
150
+ }
151
+ }
152
+ `;
153
+ document.head.appendChild(style);
154
+ }
155
+
156
+ toast.onclick = () => {
157
+ this.switchToTab(sessionId);
158
+ toast.style.animation = 'slideUp 0.3s ease-out';
159
+ setTimeout(() => toast.remove(), 300);
160
+ };
161
+
162
+ document.body.appendChild(toast);
163
+
164
+ // Auto-remove after 5 seconds
165
+ setTimeout(() => {
166
+ if (toast.parentNode) {
167
+ toast.style.animation = 'slideUp 0.3s ease-out';
168
+ setTimeout(() => toast.remove(), 300);
169
+ }
170
+ }, 5000);
171
+
172
+ // Play a sound if possible (create a simple beep)
173
+ try {
174
+ const audioContext = new (window.AudioContext || window.webkitAudioContext)();
175
+ const oscillator = audioContext.createOscillator();
176
+ const gainNode = audioContext.createGain();
177
+
178
+ oscillator.connect(gainNode);
179
+ gainNode.connect(audioContext.destination);
180
+
181
+ oscillator.frequency.value = 800;
182
+ oscillator.type = 'sine';
183
+
184
+ gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
185
+ gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
186
+
187
+ oscillator.start(audioContext.currentTime);
188
+ oscillator.stop(audioContext.currentTime + 0.2);
189
+ } catch (e) {
190
+ console.log('Audio notification not available');
191
+ }
192
+ }
193
+
194
+ getOrderedTabIds() {
195
+ // Filter out any ids that may have been removed without updating the order array
196
+ this.tabOrder = this.tabOrder.filter(id => this.tabs.has(id));
197
+ return [...this.tabOrder];
198
+ }
199
+
200
+ getOrderedTabElements() {
201
+ return this.getOrderedTabIds()
202
+ .map(id => this.tabs.get(id))
203
+ .filter(Boolean);
204
+ }
205
+
206
+ syncOrderFromDom() {
207
+ const tabsContainer = document.getElementById('tabsContainer');
208
+ if (!tabsContainer) return;
209
+ const ids = Array.from(tabsContainer.querySelectorAll('.session-tab'))
210
+ .map(tab => tab.dataset.sessionId)
211
+ .filter(Boolean);
212
+ if (ids.length) {
213
+ this.tabOrder = ids;
214
+ }
215
+ }
216
+
217
+ ensureTabVisible(sessionId) {
218
+ const tab = this.tabs.get(sessionId);
219
+ if (!tab) return;
220
+ const scrollContainer = tab.closest('.tabs-section');
221
+ if (!scrollContainer) return;
222
+ const tabRect = tab.getBoundingClientRect();
223
+ const containerRect = scrollContainer.getBoundingClientRect();
224
+
225
+ if (tabRect.left < containerRect.left) {
226
+ scrollContainer.scrollLeft += tabRect.left - containerRect.left - 16;
227
+ } else if (tabRect.right > containerRect.right) {
228
+ scrollContainer.scrollLeft += tabRect.right - containerRect.right + 16;
229
+ }
230
+ }
231
+
232
+ updateTabHistory(sessionId) {
233
+ this.tabHistory = this.tabHistory.filter(id => id !== sessionId && this.tabs.has(id));
234
+ this.tabHistory.unshift(sessionId);
235
+ if (this.tabHistory.length > 50) {
236
+ this.tabHistory.length = 50;
237
+ }
238
+ }
239
+
240
+ removeFromHistory(sessionId) {
241
+ this.tabHistory = this.tabHistory.filter(id => id !== sessionId);
242
+ }
243
+
244
+ async init() {
245
+ this.setupTabBar();
246
+ this.setupKeyboardShortcuts();
247
+ this.setupOverflowDropdown();
248
+ await this.loadSessions();
249
+ this.updateTabOverflow();
250
+
251
+ // Show notification permission prompt after a slight delay
252
+ setTimeout(() => {
253
+ this.checkAndPromptForNotifications();
254
+ }, 2000);
255
+ }
256
+
257
+ checkAndPromptForNotifications() {
258
+ if ('Notification' in window && Notification.permission === 'default') {
259
+ // Create a small prompt to enable notifications
260
+ const promptDiv = document.createElement('div');
261
+ promptDiv.style.cssText = `
262
+ position: fixed;
263
+ top: 60px;
264
+ right: 20px;
265
+ background: #1e293b;
266
+ border: 1px solid #475569;
267
+ border-radius: 8px;
268
+ padding: 12px 16px;
269
+ color: #e2e8f0;
270
+ font-size: 14px;
271
+ z-index: 10000;
272
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
273
+ max-width: 300px;
274
+ `;
275
+ promptDiv.innerHTML = `
276
+ <div style="margin-bottom: 10px;">
277
+ <strong>Enable Desktop Notifications?</strong><br>
278
+ Get notified when ${this.getAlias('claude')} completes tasks in background tabs.
279
+ </div>
280
+ <div style="display: flex; gap: 10px;">
281
+ <button id="enableNotifications" style="
282
+ background: #3b82f6;
283
+ color: white;
284
+ border: none;
285
+ padding: 6px 12px;
286
+ border-radius: 4px;
287
+ cursor: pointer;
288
+ font-size: 13px;
289
+ ">Enable</button>
290
+ <button id="dismissNotifications" style="
291
+ background: #475569;
292
+ color: white;
293
+ border: none;
294
+ padding: 6px 12px;
295
+ border-radius: 4px;
296
+ cursor: pointer;
297
+ font-size: 13px;
298
+ ">Not Now</button>
299
+ </div>
300
+ `;
301
+ document.body.appendChild(promptDiv);
302
+
303
+ document.getElementById('enableNotifications').onclick = () => {
304
+ this.requestNotificationPermission();
305
+ promptDiv.remove();
306
+ };
307
+
308
+ document.getElementById('dismissNotifications').onclick = () => {
309
+ promptDiv.remove();
310
+ };
311
+
312
+ // Auto-dismiss after 10 seconds
313
+ setTimeout(() => {
314
+ if (promptDiv.parentNode) {
315
+ promptDiv.remove();
316
+ }
317
+ }, 10000);
318
+ }
319
+ }
320
+
321
+ setupTabBar() {
322
+ const tabsContainer = document.getElementById('tabsContainer');
323
+ const newTabBtn = document.getElementById('tabNewBtn');
324
+
325
+ // New tab button - create session immediately with defaults
326
+ newTabBtn?.addEventListener('click', () => {
327
+ this.createNewSession();
328
+ });
329
+
330
+ // Enable drag and drop for tabs
331
+ if (tabsContainer) {
332
+ tabsContainer.addEventListener('dragstart', (e) => {
333
+ if (e.target.classList.contains('session-tab')) {
334
+ e.dataTransfer.effectAllowed = 'copyMove';
335
+ const sid = e.target.dataset.sessionId;
336
+ if (sid) {
337
+ e.dataTransfer.setData('text/plain', sid);
338
+ e.dataTransfer.setData('application/x-session-id', sid);
339
+ e.dataTransfer.setData('x-source-pane', '-1');
340
+ }
341
+ e.target.classList.add('dragging');
342
+ }
343
+ });
344
+
345
+ tabsContainer.addEventListener('dragend', (e) => {
346
+ if (e.target.classList.contains('session-tab')) {
347
+ e.target.classList.remove('dragging');
348
+ this.syncOrderFromDom();
349
+ this.updateTabOverflow();
350
+ this.updateOverflowMenu();
351
+ }
352
+ });
353
+
354
+ tabsContainer.addEventListener('dragover', (e) => {
355
+ e.preventDefault();
356
+ const draggingTab = tabsContainer.querySelector('.dragging');
357
+ if (!draggingTab) return;
358
+ const afterElement = this.getDragAfterElement(tabsContainer, e.clientX);
359
+
360
+ if (afterElement == null) {
361
+ tabsContainer.appendChild(draggingTab);
362
+ } else {
363
+ tabsContainer.insertBefore(draggingTab, afterElement);
364
+ }
365
+ });
366
+
367
+ tabsContainer.addEventListener('drop', (e) => {
368
+ e.preventDefault();
369
+ });
370
+ }
371
+ }
372
+
373
+
374
+ setupOverflowDropdown() {
375
+ const overflowBtn = document.getElementById('tabOverflowBtn');
376
+ const overflowMenu = document.getElementById('tabOverflowMenu');
377
+
378
+ if (overflowBtn) {
379
+ overflowBtn.addEventListener('click', (e) => {
380
+ e.stopPropagation();
381
+ overflowMenu.classList.toggle('active');
382
+ this.updateOverflowMenu();
383
+ });
384
+ }
385
+
386
+ // Close dropdown when clicking outside
387
+ document.addEventListener('click', (e) => {
388
+ if (!overflowMenu?.contains(e.target) && !overflowBtn?.contains(e.target)) {
389
+ overflowMenu?.classList.remove('active');
390
+ }
391
+ });
392
+
393
+ // Update overflow on window resize
394
+ window.addEventListener('resize', () => {
395
+ this.updateTabOverflow();
396
+ this.updateOverflowMenu();
397
+ });
398
+ }
399
+
400
+ updateTabOverflow() {
401
+ const isMobile = window.innerWidth <= 768;
402
+ const overflowWrapper = document.getElementById('tabOverflowWrapper');
403
+ const overflowCount = document.querySelector('.tab-overflow-count');
404
+
405
+ if (!isMobile) {
406
+ // On desktop, show all tabs and hide overflow
407
+ this.tabs.forEach(tab => {
408
+ tab.style.display = '';
409
+ });
410
+ if (overflowWrapper) {
411
+ overflowWrapper.style.display = 'none';
412
+ }
413
+ if (overflowCount) overflowCount.textContent = '';
414
+ return;
415
+ }
416
+
417
+ // On mobile, show only first 2 tabs
418
+ const tabsArray = this.getOrderedTabElements();
419
+
420
+ tabsArray.forEach((tab, index) => {
421
+ if (index < 2) {
422
+ tab.style.display = ''; // Show first 2 tabs
423
+ } else {
424
+ tab.style.display = 'none'; // Hide rest
425
+ }
426
+ });
427
+
428
+ if (tabsArray.length > 2) {
429
+ // Show overflow button with count
430
+ if (overflowWrapper) {
431
+ overflowWrapper.style.display = 'flex';
432
+ if (overflowCount) {
433
+ overflowCount.textContent = tabsArray.length - 2;
434
+ }
435
+ }
436
+ } else {
437
+ // Hide overflow button
438
+ if (overflowWrapper) {
439
+ overflowWrapper.style.display = 'none';
440
+ }
441
+ if (overflowCount) {
442
+ overflowCount.textContent = '';
443
+ }
444
+ }
445
+ }
446
+
447
+ updateOverflowMenu() {
448
+ const menu = document.getElementById('tabOverflowMenu');
449
+ if (!menu) return;
450
+
451
+ const overflowIds = this.getOrderedTabIds().slice(2);
452
+
453
+ menu.innerHTML = '';
454
+
455
+ overflowIds.forEach((sessionId) => {
456
+ const tabElement = this.tabs.get(sessionId);
457
+ if (!tabElement) return;
458
+ const session = this.activeSessions.get(sessionId);
459
+ if (!session) return;
460
+
461
+ const item = document.createElement('div');
462
+ item.className = 'overflow-tab-item';
463
+ if (sessionId === this.activeTabId) {
464
+ item.classList.add('active');
465
+ }
466
+
467
+ item.innerHTML = `
468
+ <span class="overflow-tab-name">${tabElement.querySelector('.tab-name').textContent}</span>
469
+ <span class="overflow-tab-close" data-session-id="${sessionId}" title="Close tab">
470
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
471
+ <line x1="18" y1="6" x2="6" y2="18"/>
472
+ <line x1="6" y1="6" x2="18" y2="18"/>
473
+ </svg>
474
+ </span>
475
+ `;
476
+
477
+ // Click to switch to tab
478
+ item.addEventListener('click', async (e) => {
479
+ if (!e.target.classList.contains('overflow-tab-close')) {
480
+ await this.switchToTab(sessionId);
481
+ menu.classList.remove('active');
482
+ // Update menu contents after switching
483
+ setTimeout(() => {
484
+ this.updateTabOverflow();
485
+ this.updateOverflowMenu();
486
+ }, 150);
487
+ }
488
+ });
489
+
490
+ // Close button
491
+ const closeBtn = item.querySelector('.overflow-tab-close');
492
+ closeBtn.addEventListener('click', (e) => {
493
+ e.stopPropagation();
494
+ this.closeSession(sessionId);
495
+ menu.classList.remove('active');
496
+ });
497
+
498
+ menu.appendChild(item);
499
+ });
500
+ }
501
+
502
+ setupKeyboardShortcuts() {
503
+ document.addEventListener('keydown', (e) => {
504
+ // Ctrl/Cmd + T: New tab
505
+ if ((e.ctrlKey || e.metaKey) && e.key === 't') {
506
+ e.preventDefault();
507
+ this.createNewSession();
508
+ }
509
+
510
+ // Ctrl/Cmd + W: Close current tab
511
+ if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
512
+ e.preventDefault();
513
+ if (this.activeTabId) {
514
+ this.closeSession(this.activeTabId);
515
+ }
516
+ }
517
+
518
+ // Ctrl/Cmd + Tab: Next tab
519
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Tab' && !e.shiftKey) {
520
+ e.preventDefault();
521
+ this.switchToNextTab();
522
+ }
523
+
524
+ // Ctrl/Cmd + Shift + Tab: Previous tab
525
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Tab' && e.shiftKey) {
526
+ e.preventDefault();
527
+ this.switchToPreviousTab();
528
+ }
529
+
530
+ // Alt + 1-9: Switch to tab by number
531
+ if (e.altKey && e.key >= '1' && e.key <= '9') {
532
+ e.preventDefault();
533
+ const index = parseInt(e.key) - 1;
534
+ this.switchToTabByIndex(index);
535
+ }
536
+
537
+ });
538
+ }
539
+
540
+ async loadSessions() {
541
+ try {
542
+ console.log('[SessionManager.loadSessions] Fetching sessions from server...');
543
+ const response = await fetch('/terminal/api/sessions/list');
544
+ const data = await response.json();
545
+
546
+ console.log('[SessionManager.loadSessions] Got data:', data);
547
+
548
+ // Sort sessions by creation time
549
+ const sessions = data.sessions || [];
550
+
551
+ console.log('[SessionManager.loadSessions] Processing', sessions.length, 'sessions');
552
+
553
+ sessions.forEach((session, index) => {
554
+ console.log('[SessionManager.loadSessions] Adding tab for:', session.id);
555
+ // Don't auto-switch when loading existing sessions
556
+ this.addTab(session.id, session.name, session.active ? 'active' : 'idle', session.workingDir, false);
557
+ // Set initial timestamps based on order (older sessions get older timestamps)
558
+ const sessionData = this.activeSessions.get(session.id);
559
+ if (sessionData) {
560
+ sessionData.lastAccessed = Date.now() - (sessions.length - index) * 1000;
561
+ }
562
+ });
563
+
564
+ // Reorder tabs based on the initial timestamps (mobile only)
565
+ if (window.innerWidth <= 768) {
566
+ this.reorderTabsByLastAccessed();
567
+ }
568
+
569
+ console.log('[SessionManager.loadSessions] Final tabs.size:', this.tabs.size);
570
+
571
+ return sessions;
572
+ } catch (error) {
573
+ console.error('Failed to load sessions:', error);
574
+ return [];
575
+ }
576
+ }
577
+
578
+ addTab(sessionId, sessionName, status = 'idle', workingDir = null, autoSwitch = true) {
579
+ const tabsContainer = document.getElementById('tabsContainer');
580
+ if (!tabsContainer) return;
581
+
582
+ // Check if tab already exists
583
+ if (this.tabs.has(sessionId)) {
584
+ return;
585
+ }
586
+
587
+ const tab = document.createElement('div');
588
+ tab.className = 'session-tab';
589
+ tab.dataset.sessionId = sessionId;
590
+ tab.draggable = true;
591
+
592
+ // Determine display name
593
+ const isDefaultSessionName = sessionName.startsWith('Session ') && sessionName.includes(':');
594
+ const folderName = workingDir ? workingDir.split('/').pop() || '/' : null;
595
+ const displayName = !isDefaultSessionName ? sessionName : (folderName || sessionName);
596
+
597
+ tab.innerHTML = `
598
+ <div class="tab-content">
599
+ <span class="tab-status ${status}"></span>
600
+ <span class="tab-name" title="${workingDir || sessionName}">${displayName}</span>
601
+ </div>
602
+ <span class="tab-close" title="Close tab">
603
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
604
+ <line x1="18" y1="6" x2="6" y2="18"/>
605
+ <line x1="6" y1="6" x2="18" y2="18"/>
606
+ </svg>
607
+ </span>
608
+ `;
609
+
610
+ // Tab click handler
611
+ tab.addEventListener('click', async (e) => {
612
+ if (!e.target.closest('.tab-close')) {
613
+ await this.switchToTab(sessionId);
614
+ }
615
+ });
616
+
617
+ // Close button handler
618
+ const closeBtn = tab.querySelector('.tab-close');
619
+ closeBtn.addEventListener('click', (e) => {
620
+ e.stopPropagation();
621
+ this.closeSession(sessionId);
622
+ });
623
+
624
+ // Double-click to rename
625
+ tab.addEventListener('dblclick', (e) => {
626
+ if (!e.target.closest('.tab-close')) {
627
+ this.renameTab(sessionId);
628
+ }
629
+ });
630
+
631
+ // Middle click to close (VS Code behavior)
632
+ tab.addEventListener('auxclick', (e) => {
633
+ if (e.button === 1) {
634
+ e.preventDefault();
635
+ e.stopPropagation();
636
+ this.closeSession(sessionId);
637
+ }
638
+ });
639
+
640
+ // Context menu: Close Others, Split Right, Move to Split
641
+ tab.addEventListener('contextmenu', (e) => {
642
+ e.preventDefault();
643
+ this.openTabContextMenu(sessionId, e.clientX, e.clientY);
644
+ });
645
+
646
+ tabsContainer.appendChild(tab);
647
+ this.tabs.set(sessionId, tab);
648
+ if (!this.tabOrder.includes(sessionId)) {
649
+ this.tabOrder.push(sessionId);
650
+ }
651
+
652
+ // Store session data with timestamp and activity tracking
653
+ this.activeSessions.set(sessionId, {
654
+ id: sessionId,
655
+ name: sessionName,
656
+ status: status,
657
+ workingDir: workingDir,
658
+ lastAccessed: Date.now(),
659
+ lastActivity: Date.now(),
660
+ unreadOutput: false,
661
+ hasError: false
662
+ });
663
+
664
+ // Update overflow on mobile
665
+ this.updateTabOverflow();
666
+ this.updateOverflowMenu();
667
+
668
+ // If this is the first tab and autoSwitch is enabled, make it active
669
+ if (this.tabs.size === 1 && autoSwitch) {
670
+ this.switchToTab(sessionId);
671
+ }
672
+ }
673
+
674
+ async switchToTab(sessionId, options = {}) {
675
+ if (!this.tabs.has(sessionId)) return;
676
+
677
+ const { skipHistoryUpdate = false } = options;
678
+
679
+ // Remove active class from all tabs
680
+ this.tabs.forEach(tab => tab.classList.remove('active'));
681
+
682
+ // Add active class to selected tab
683
+ const tab = this.tabs.get(sessionId);
684
+ if (!tab) return;
685
+ tab.classList.add('active');
686
+ this.activeTabId = sessionId;
687
+ this.ensureTabVisible(sessionId);
688
+
689
+ // Update last accessed timestamp and clear unread indicator
690
+ const session = this.activeSessions.get(sessionId);
691
+ if (session) {
692
+ session.lastAccessed = Date.now();
693
+ if (session.unreadOutput) this.updateUnreadIndicator(sessionId, false);
694
+ }
695
+
696
+ if (!skipHistoryUpdate) {
697
+ this.updateTabHistory(sessionId);
698
+ }
699
+
700
+ if (window.innerWidth <= 768) {
701
+ const tabIndex = this.getOrderedTabIds().indexOf(sessionId);
702
+ if (tabIndex >= 2) this.reorderTabsByLastAccessed();
703
+ }
704
+
705
+ this.updateOverflowMenu();
706
+
707
+ // If tile view is enabled, tabs target the active pane (VS Code-style)
708
+ await this.claudeInterface.joinSession(sessionId);
709
+ this.updateHeaderInfo(sessionId);
710
+ }
711
+
712
+ reorderTabsByLastAccessed() {
713
+ const tabsContainer = document.getElementById('tabsContainer');
714
+ if (!tabsContainer) return;
715
+
716
+ // Get all tabs sorted by last accessed time (most recent first)
717
+ const sortedIds = this.getOrderedTabIds()
718
+ .sort((a, b) => {
719
+ const sessionA = this.activeSessions.get(a);
720
+ const sessionB = this.activeSessions.get(b);
721
+ const timeA = sessionA ? sessionA.lastAccessed : 0;
722
+ const timeB = sessionB ? sessionB.lastAccessed : 0;
723
+ return timeB - timeA; // Most recent first
724
+ });
725
+
726
+ sortedIds.forEach((sessionId) => {
727
+ const tabElement = this.tabs.get(sessionId);
728
+ if (tabElement) {
729
+ tabsContainer.appendChild(tabElement);
730
+ }
731
+ });
732
+
733
+ this.tabOrder = sortedIds;
734
+
735
+ // Update overflow on mobile
736
+ this.updateTabOverflow();
737
+ }
738
+
739
+ closeSession(sessionId, { skipServerRequest = false } = {}) {
740
+ const tab = this.tabs.get(sessionId);
741
+ if (!tab) return;
742
+
743
+ const orderedIds = this.getOrderedTabIds();
744
+ const closedIndex = orderedIds.indexOf(sessionId);
745
+
746
+ // Remove tab
747
+ tab.remove();
748
+ this.tabs.delete(sessionId);
749
+ this.activeSessions.delete(sessionId);
750
+ this.tabOrder = orderedIds.filter(id => id !== sessionId);
751
+ this.removeFromHistory(sessionId);
752
+
753
+ // Update overflow on mobile
754
+ this.updateTabOverflow();
755
+ this.updateOverflowMenu();
756
+
757
+ if (!skipServerRequest) {
758
+ fetch(`/terminal/api/sessions/${sessionId}`, {
759
+ method: 'DELETE'
760
+ })
761
+ .catch(err => console.error('Failed to delete session:', err));
762
+ }
763
+
764
+ // If this was the active tab, switch to another
765
+ if (this.activeTabId === sessionId) {
766
+ this.activeTabId = null;
767
+ let fallbackId = this.tabHistory.find(id => this.tabs.has(id));
768
+ if (!fallbackId && this.tabOrder.length > 0) {
769
+ const nextIndex = closedIndex >= 0 ? Math.min(closedIndex, this.tabOrder.length - 1) : 0;
770
+ fallbackId = this.tabOrder[nextIndex];
771
+ }
772
+
773
+ if (fallbackId) {
774
+ this.switchToTab(fallbackId);
775
+ }
776
+ }
777
+
778
+ }
779
+
780
+ renameTab(sessionId) {
781
+ const tab = this.tabs.get(sessionId);
782
+ if (!tab) return;
783
+
784
+ const nameSpan = tab.querySelector('.tab-name');
785
+ const currentName = nameSpan.textContent;
786
+
787
+ const input = document.createElement('input');
788
+ input.type = 'text';
789
+ input.value = currentName;
790
+ input.className = 'tab-name-input';
791
+ input.style.width = '100%';
792
+
793
+ nameSpan.replaceWith(input);
794
+ input.focus();
795
+ input.select();
796
+
797
+ const saveNewName = () => {
798
+ const newName = input.value.trim() || currentName;
799
+ const newNameSpan = document.createElement('span');
800
+ newNameSpan.className = 'tab-name';
801
+ newNameSpan.textContent = newName;
802
+ input.replaceWith(newNameSpan);
803
+
804
+ // Update session data
805
+ const session = this.activeSessions.get(sessionId);
806
+ if (session) {
807
+ session.name = newName;
808
+ }
809
+ };
810
+
811
+ input.addEventListener('blur', saveNewName);
812
+ input.addEventListener('keydown', (e) => {
813
+ if (e.key === 'Enter') {
814
+ saveNewName();
815
+ } else if (e.key === 'Escape') {
816
+ input.value = currentName;
817
+ saveNewName();
818
+ }
819
+ });
820
+ }
821
+
822
+ // Close all other tabs except the given session
823
+ closeOthers(sessionId) {
824
+ const ids = this.getOrderedTabIds();
825
+ ids.forEach(id => { if (id !== sessionId) this.closeSession(id); });
826
+ }
827
+
828
+ // Context menu for a session tab
829
+ openTabContextMenu(sessionId, clientX, clientY) {
830
+ // Remove existing menus
831
+ document.querySelectorAll('.pane-session-menu').forEach(m => m.remove());
832
+ const menu = document.createElement('div');
833
+ menu.className = 'pane-session-menu';
834
+ const addItem = (label, fn, disabled = false) => {
835
+ const el = document.createElement('div');
836
+ el.className = 'pane-session-item' + (disabled ? ' used' : '');
837
+ el.textContent = label;
838
+ if (!disabled) el.onclick = () => { try { fn(); } finally { menu.remove(); } };
839
+ return el;
840
+ };
841
+ menu.appendChild(addItem('Close Others', () => this.closeOthers(sessionId)));
842
+ document.body.appendChild(menu);
843
+ menu.style.top = `${clientY + 4}px`;
844
+ menu.style.left = `${clientX + 4}px`;
845
+ const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', close, true); } };
846
+ setTimeout(() => document.addEventListener('mousedown', close, true), 0);
847
+ }
848
+
849
+ async createNewSession(name) {
850
+ // Create session immediately with defaults (no modal)
851
+ const sessionName = name || `Session ${this.tabs.size + 1}`;
852
+ const workingDir = this.claudeInterface?.selectedWorkingDir || null;
853
+
854
+ try {
855
+ const response = await fetch('/terminal/api/sessions/create', {
856
+ method: 'POST',
857
+ headers: { 'Content-Type': 'application/json' },
858
+ body: JSON.stringify({ name: sessionName, workingDir })
859
+ });
860
+
861
+ if (!response.ok) throw new Error('Failed to create session');
862
+
863
+ const data = await response.json();
864
+
865
+ // Add tab for the new session
866
+ this.addTab(data.sessionId, sessionName, 'idle', workingDir);
867
+ // switchToTab will handle joining the session
868
+ await this.switchToTab(data.sessionId);
869
+
870
+ // Update sessions list
871
+ if (this.claudeInterface) {
872
+ this.claudeInterface.loadSessions();
873
+ }
874
+ } catch (error) {
875
+ console.error('Failed to create session:', error);
876
+ if (this.claudeInterface) {
877
+ this.claudeInterface.showError('Failed to create session');
878
+ }
879
+ }
880
+ }
881
+
882
+ switchToNextTab() {
883
+ if (this.tabHistory.length > 1) {
884
+ const nextId = this.tabHistory.find((id) => id !== this.activeTabId && this.tabs.has(id));
885
+ if (nextId) {
886
+ this.switchToTab(nextId);
887
+ return;
888
+ }
889
+ }
890
+
891
+ const tabIds = this.getOrderedTabIds();
892
+ if (tabIds.length === 0) return;
893
+ const currentIndex = tabIds.indexOf(this.activeTabId);
894
+ const nextIndex = currentIndex >= 0 ? (currentIndex + 1) % tabIds.length : 0;
895
+ this.switchToTab(tabIds[nextIndex]);
896
+ }
897
+
898
+ switchToPreviousTab() {
899
+ const tabIds = this.getOrderedTabIds();
900
+ if (tabIds.length === 0) return;
901
+ const currentIndex = tabIds.indexOf(this.activeTabId);
902
+ const prevIndex = currentIndex >= 0 ? (currentIndex - 1 + tabIds.length) % tabIds.length : tabIds.length - 1;
903
+ this.switchToTab(tabIds[prevIndex]);
904
+ }
905
+
906
+ switchToTabByIndex(index) {
907
+ const tabIds = this.getOrderedTabIds();
908
+ if (index < tabIds.length) {
909
+ this.switchToTab(tabIds[index]);
910
+ }
911
+ }
912
+
913
+
914
+ updateHeaderInfo(sessionId) {
915
+ const session = this.activeSessions.get(sessionId);
916
+ if (session) {
917
+ const workingDirEl = document.getElementById('workingDir');
918
+ if (workingDirEl && session.workingDir) {
919
+ workingDirEl.textContent = session.workingDir;
920
+ }
921
+ }
922
+ }
923
+
924
+ updateTabStatus(sessionId, status) {
925
+ const tab = this.tabs.get(sessionId);
926
+ if (tab) {
927
+ const statusEl = tab.querySelector('.tab-status');
928
+ if (statusEl) {
929
+ // Get current session info
930
+ const session = this.activeSessions.get(sessionId);
931
+ const wasActive = session && session.status === 'active';
932
+
933
+ // Preserve unread class if it exists
934
+ const hasUnread = statusEl.classList.contains('unread');
935
+ statusEl.className = `tab-status ${status}`;
936
+
937
+ // When transitioning from active to idle for background tabs, mark as unread
938
+ if (wasActive && status === 'idle' && sessionId !== this.activeTabId) {
939
+ statusEl.classList.add('unread');
940
+ if (session) {
941
+ session.unreadOutput = true;
942
+ }
943
+ } else if (hasUnread) {
944
+ statusEl.classList.add('unread');
945
+ }
946
+
947
+ // Update visual indicator based on status
948
+ if (status === 'active') {
949
+ statusEl.classList.add('pulse');
950
+ } else {
951
+ statusEl.classList.remove('pulse');
952
+ }
953
+ }
954
+
955
+ const session = this.activeSessions.get(sessionId);
956
+ if (session) {
957
+ session.status = status;
958
+ session.lastActivity = Date.now();
959
+
960
+ // Clear error state if status is not error
961
+ if (status !== 'error') {
962
+ session.hasError = false;
963
+ }
964
+ }
965
+ }
966
+ }
967
+
968
+ markSessionActivity(sessionId, hasOutput = false, outputData = '') {
969
+ const session = this.activeSessions.get(sessionId);
970
+ if (!session) return;
971
+
972
+ const previousActivity = session.lastActivity || 0;
973
+ const wasActive = session.status === 'active';
974
+ session.lastActivity = Date.now();
975
+
976
+ // Update status to active if there's output
977
+ if (hasOutput) {
978
+ this.updateTabStatus(sessionId, 'active');
979
+
980
+ // Clear any existing timeouts
981
+ clearTimeout(session.idleTimeout);
982
+ clearTimeout(session.workCompleteTimeout);
983
+
984
+ // Set a 90-second timeout to detect when Claude has likely finished working
985
+ session.workCompleteTimeout = setTimeout(() => {
986
+ const currentSession = this.activeSessions.get(sessionId);
987
+ if (currentSession && currentSession.status === 'active') {
988
+ // Claude has been idle for 90 seconds - likely finished working
989
+ this.updateTabStatus(sessionId, 'idle');
990
+
991
+ // Only notify and mark as unread if Claude was previously active
992
+ if (wasActive) {
993
+ const sessionName = currentSession.name || 'Session';
994
+ const duration = Date.now() - previousActivity;
995
+
996
+ // Mark as unread if this is a background tab (blue indicator)
997
+ if (sessionId !== this.activeTabId) {
998
+ currentSession.unreadOutput = true;
999
+ this.updateUnreadIndicator(sessionId, true);
1000
+
1001
+ // Send notification that Claude appears to have finished
1002
+ this.sendNotification(
1003
+ `${sessionName} — ${this.getAlias('claude')} appears finished`,
1004
+ `No output for 90 seconds (worked for ${Math.round(duration / 1000)}s)`,
1005
+ sessionId
1006
+ );
1007
+ }
1008
+ }
1009
+ }
1010
+ }, 90000); // 90 seconds
1011
+
1012
+ // Keep the original 5-minute timeout for full idle state
1013
+ session.idleTimeout = setTimeout(() => {
1014
+ const currentSession = this.activeSessions.get(sessionId);
1015
+ if (currentSession && currentSession.status === 'idle') {
1016
+ // Already marked as idle by the 90-second timeout
1017
+ }
1018
+ }, 300000); // 5 minutes
1019
+ }
1020
+
1021
+ // Check for command completion patterns
1022
+ if (hasOutput && outputData) {
1023
+ this.checkForCommandCompletion(sessionId, outputData, previousActivity);
1024
+ }
1025
+ }
1026
+
1027
+ checkForCommandCompletion(sessionId, outputData, previousActivity) {
1028
+ const session = this.activeSessions.get(sessionId);
1029
+ if (!session) return;
1030
+
1031
+ // Pattern matching for common completion indicators
1032
+ const completionPatterns = [
1033
+ /build\s+successful/i,
1034
+ /compilation\s+finished/i,
1035
+ /tests?\s+passed/i,
1036
+ /deployment\s+complete/i,
1037
+ /npm\s+install.*completed/i,
1038
+ /successfully\s+compiled/i,
1039
+ /✓\s+All\s+tests\s+passed/i,
1040
+ /Done\s+in\s+\d+\.\d+s/i
1041
+ ];
1042
+
1043
+ const hasCompletion = completionPatterns.some(pattern => pattern.test(outputData));
1044
+
1045
+ if (hasCompletion && sessionId !== this.activeTabId) {
1046
+ const duration = Date.now() - previousActivity;
1047
+ const sessionName = session.name || 'Session';
1048
+
1049
+ // Extract a meaningful message from the output
1050
+ let message = 'Task completed successfully';
1051
+ if (/build\s+successful/i.test(outputData)) {
1052
+ message = 'Build completed successfully';
1053
+ } else if (/tests?\s+passed/i.test(outputData)) {
1054
+ message = 'All tests passed';
1055
+ } else if (/deployment\s+complete/i.test(outputData)) {
1056
+ message = 'Deployment completed';
1057
+ }
1058
+
1059
+ // Mark tab as unread (blue indicator) for completed tasks
1060
+ session.unreadOutput = true;
1061
+ this.updateUnreadIndicator(sessionId, true);
1062
+
1063
+ this.sendNotification(
1064
+ `${sessionName}`,
1065
+ message,
1066
+ sessionId
1067
+ );
1068
+ }
1069
+ }
1070
+
1071
+ updateUnreadIndicator(sessionId, hasUnread) {
1072
+ const tab = this.tabs.get(sessionId);
1073
+ if (tab) {
1074
+ const statusEl = tab.querySelector('.tab-status');
1075
+ if (hasUnread) {
1076
+ tab.classList.add('has-unread');
1077
+ if (statusEl) {
1078
+ statusEl.classList.add('unread');
1079
+ }
1080
+ } else {
1081
+ tab.classList.remove('has-unread');
1082
+ if (statusEl) {
1083
+ statusEl.classList.remove('unread');
1084
+ }
1085
+ }
1086
+ }
1087
+
1088
+ const session = this.activeSessions.get(sessionId);
1089
+ if (session) {
1090
+ session.unreadOutput = hasUnread;
1091
+ }
1092
+ }
1093
+
1094
+ markSessionError(sessionId, hasError = true) {
1095
+ const session = this.activeSessions.get(sessionId);
1096
+ if (session) {
1097
+ session.hasError = hasError;
1098
+ if (hasError) {
1099
+ this.updateTabStatus(sessionId, 'error');
1100
+
1101
+ // Send notification for error in background session
1102
+ const sessionName = session.name || 'Session';
1103
+ this.sendNotification(
1104
+ `Error in ${sessionName}`,
1105
+ 'A command has failed or the session encountered an error',
1106
+ sessionId
1107
+ );
1108
+ }
1109
+ }
1110
+ }
1111
+
1112
+ getDragAfterElement(container, x) {
1113
+ const draggableElements = [...container.querySelectorAll('.session-tab:not(.dragging)')];
1114
+
1115
+ return draggableElements.reduce((closest, child) => {
1116
+ const box = child.getBoundingClientRect();
1117
+ const offset = x - box.left - box.width / 2;
1118
+
1119
+ if (offset < 0 && offset > closest.offset) {
1120
+ return { offset: offset, element: child };
1121
+ } else {
1122
+ return closest;
1123
+ }
1124
+ }, { offset: Number.NEGATIVE_INFINITY }).element;
1125
+ }
1126
+ }
1127
+
1128
+ // Export for use in app.js
1129
+ window.SessionTabManager = SessionTabManager;