@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.
- package/README.md +37 -0
- package/dist/client/assets/index-C8GAsRGO.css +32 -0
- package/dist/client/assets/index-CcHIoRl6.js +286 -0
- package/dist/client/index.html +22 -0
- package/dist/server/cli.js +8853 -0
- package/dist/server/fb-wizard.js +136 -0
- package/dist/server/graph-mcp-entry.js +1542 -0
- package/dist/server/public/app.js +1312 -0
- package/dist/server/public/icons.js +36 -0
- package/dist/server/public/index.html +159 -0
- package/dist/server/public/plan-detector.js +186 -0
- package/dist/server/public/session-manager.js +1129 -0
- package/dist/server/public/splits.js +569 -0
- package/dist/server/public/style.css +1620 -0
- package/package.json +73 -0
- package/prompts/analysis.md +992 -0
- package/prompts/architect-reconcile.md +931 -0
- package/prompts/architecture-sync.md +902 -0
- package/prompts/be-contract.md +709 -0
- package/prompts/be-impl.md +565 -0
- package/prompts/be-policy.md +551 -0
- package/prompts/be-test.md +591 -0
- package/prompts/bug-diagnosis.md +653 -0
- package/prompts/bug-intake.md +563 -0
- package/prompts/change-request-intake.md +593 -0
- package/prompts/db-contract.md +644 -0
- package/prompts/db-impl.md +522 -0
- package/prompts/db-interaction.md +569 -0
- package/prompts/db-test.md +630 -0
- package/prompts/decision-pack.md +654 -0
- package/prompts/fe-contract.md +992 -0
- package/prompts/fe-flow.md +537 -0
- package/prompts/fe-impl.md +597 -0
- package/prompts/fe-reconcile.md +506 -0
- package/prompts/fe-review.md +550 -0
- package/prompts/fe-test.md +705 -0
- package/prompts/fix-planner.md +1219 -0
- package/prompts/global-db-patterns.md +588 -0
- package/prompts/global-env-config.md +460 -0
- package/prompts/global-integrations.md +504 -0
- package/prompts/global-middleware.md +442 -0
- package/prompts/global-navigation.md +502 -0
- package/prompts/global-security.md +603 -0
- package/prompts/global-services.md +427 -0
- package/prompts/greenfield-classifier.md +590 -0
- package/prompts/llm-council.md +597 -0
- package/prompts/module-sequencer.md +529 -0
- package/prompts/normalize.md +611 -0
- package/prompts/optimization.md +633 -0
- package/prompts/prd-generation.md +544 -0
- package/prompts/prd-reconcile.md +584 -0
- package/prompts/prd-review.md +504 -0
- package/prompts/pre-code-analysis.md +565 -0
- package/prompts/pre-code-global-analysis.md +169 -0
- package/prompts/production-bootstrap.md +577 -0
- package/prompts/research.md +702 -0
- package/prompts/retrofit-analysis.md +845 -0
- package/prompts/spike.md +850 -0
- package/prompts/theming.md +835 -0
- package/prompts/triage.md +599 -0
- package/prompts/unified-reconcile.md +628 -0
- package/prompts/unified-review.md +592 -0
- package/prompts/user-stories.md +486 -0
- 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;
|