ai-agent-session-center 1.0.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.
Files changed (41) hide show
  1. package/README.md +618 -0
  2. package/bin/cli.js +20 -0
  3. package/hooks/dashboard-hook-codex.sh +67 -0
  4. package/hooks/dashboard-hook-gemini.sh +102 -0
  5. package/hooks/dashboard-hook.ps1 +147 -0
  6. package/hooks/dashboard-hook.sh +142 -0
  7. package/hooks/dashboard-hooks-backup.json +103 -0
  8. package/hooks/install-hooks.js +543 -0
  9. package/hooks/reset.js +357 -0
  10. package/hooks/setup-wizard.js +156 -0
  11. package/package.json +52 -0
  12. package/public/css/dashboard.css +10200 -0
  13. package/public/index.html +915 -0
  14. package/public/js/analyticsPanel.js +467 -0
  15. package/public/js/app.js +1148 -0
  16. package/public/js/browserDb.js +806 -0
  17. package/public/js/chartUtils.js +383 -0
  18. package/public/js/historyPanel.js +298 -0
  19. package/public/js/movementManager.js +155 -0
  20. package/public/js/navController.js +32 -0
  21. package/public/js/robotManager.js +526 -0
  22. package/public/js/sceneManager.js +7 -0
  23. package/public/js/sessionPanel.js +2477 -0
  24. package/public/js/settingsManager.js +924 -0
  25. package/public/js/soundManager.js +249 -0
  26. package/public/js/statsPanel.js +118 -0
  27. package/public/js/terminalManager.js +391 -0
  28. package/public/js/timelinePanel.js +278 -0
  29. package/public/js/wsClient.js +88 -0
  30. package/server/apiRouter.js +321 -0
  31. package/server/config.js +120 -0
  32. package/server/hookProcessor.js +55 -0
  33. package/server/hookRouter.js +18 -0
  34. package/server/hookStats.js +107 -0
  35. package/server/index.js +314 -0
  36. package/server/logger.js +67 -0
  37. package/server/mqReader.js +218 -0
  38. package/server/serverConfig.js +27 -0
  39. package/server/sessionStore.js +1049 -0
  40. package/server/sshManager.js +339 -0
  41. package/server/wsManager.js +83 -0
@@ -0,0 +1,1148 @@
1
+ import * as robotManager from './robotManager.js';
2
+ import { createOrUpdateCard, removeCard, updateDurations, showToast, getSelectedSessionId, setSelectedSessionId, deselectSession, archiveAllEnded, isMuted, toggleMuteAll, initGroups, createOrUpdateTeamCard, removeTeamCard, getTeamsData, getSessionsData, loadQueue, pinSession } from './sessionPanel.js';
3
+ import * as statsPanel from './statsPanel.js';
4
+ import * as wsClient from './wsClient.js';
5
+ import * as navController from './navController.js';
6
+ import * as historyPanel from './historyPanel.js';
7
+ import * as timelinePanel from './timelinePanel.js';
8
+ import * as analyticsPanel from './analyticsPanel.js';
9
+ import * as settingsManager from './settingsManager.js';
10
+ import * as soundManager from './soundManager.js';
11
+ import * as movementManager from './movementManager.js';
12
+ import * as terminalManager from './terminalManager.js';
13
+ import { openDB, persistSessionUpdate, put, del, getAll, clear } from './browserDb.js';
14
+
15
+ let allSessions = {};
16
+ const approvalAlarmTimers = new Map(); // sessionId -> intervalId for repeating alarm
17
+
18
+ // Block accidental refresh/close when terminal sessions are active
19
+ window.addEventListener('beforeunload', (e) => {
20
+ if (terminalManager.getActiveTerminalId()) {
21
+ e.preventDefault();
22
+ e.returnValue = '';
23
+ }
24
+ });
25
+
26
+ // Block refresh keyboard shortcuts (Cmd+R, Ctrl+R, F5) entirely
27
+ window.addEventListener('keydown', (e) => {
28
+ const isRefresh =
29
+ e.key === 'F5' ||
30
+ ((e.metaKey || e.ctrlKey) && e.key === 'r');
31
+ if (isRefresh) {
32
+ e.preventDefault();
33
+ e.stopPropagation();
34
+ }
35
+ }, true);
36
+
37
+ async function init() {
38
+ // Initialize browser IndexedDB (persistence layer)
39
+ await openDB();
40
+
41
+ // Load settings first
42
+ await settingsManager.loadSettings();
43
+ settingsManager.initSettingsUI();
44
+ soundManager.init();
45
+ movementManager.init();
46
+
47
+ // Load cached sessions from IndexedDB for instant display (before WS connects)
48
+ try {
49
+ const cachedSessions = await getAll('sessions');
50
+ // Only restore non-ended sessions as live cards; ended ones live in history only
51
+ const liveSessions = (cachedSessions || []).filter(s => s.status && s.status !== 'ended');
52
+ if (liveSessions.length > 0) {
53
+ for (const cached of liveSessions) {
54
+ // Convert IndexedDB record to session-like object for createOrUpdateCard
55
+ const session = {
56
+ sessionId: cached.id,
57
+ projectPath: cached.projectPath,
58
+ projectName: cached.projectName || 'Unknown',
59
+ title: cached.title || '',
60
+ status: cached.status || 'ended',
61
+ model: cached.model || '',
62
+ source: cached.source || 'ssh',
63
+ startedAt: cached.startedAt,
64
+ lastActivityAt: cached.lastActivityAt,
65
+ endedAt: cached.endedAt,
66
+ totalToolCalls: cached.totalToolCalls || 0,
67
+ totalPrompts: cached.totalPrompts || 0,
68
+ archived: cached.archived || 0,
69
+ characterModel: cached.characterModel,
70
+ accentColor: cached.accentColor,
71
+ teamId: cached.teamId,
72
+ terminalId: cached.terminalId,
73
+ queueCount: cached.queueCount || 0,
74
+ label: cached.label || null,
75
+ // Historical sessions have no live data
76
+ promptHistory: [],
77
+ toolUsage: {},
78
+ toolLog: [],
79
+ responseLog: [],
80
+ events: [],
81
+ subagentCount: 0,
82
+ animationState: cached.status === 'ended' ? 'Death' : 'Idle',
83
+ isHistorical: true,
84
+ };
85
+ allSessions[session.sessionId] = session;
86
+ createOrUpdateCard(session);
87
+ robotManager.updateRobot(session);
88
+ }
89
+ statsPanel.update(allSessions);
90
+ updateTabTitle(allSessions);
91
+ toggleEmptyState(Object.keys(allSessions).length === 0);
92
+ }
93
+ } catch (e) {
94
+ console.warn('[app] Failed to load cached sessions:', e);
95
+ }
96
+
97
+ // Connect WebSocket
98
+ // Pass WS reference to terminal manager once connected
99
+ document.addEventListener('ws-status', (e) => {
100
+ if (e.detail === 'connected') {
101
+ terminalManager.setWs(wsClient.getWs());
102
+ }
103
+ });
104
+
105
+ wsClient.connect({
106
+ onTerminalOutputCb(terminalId, data) {
107
+ terminalManager.onTerminalOutput(terminalId, data);
108
+ },
109
+ onTerminalReadyCb(terminalId) {
110
+ terminalManager.onTerminalReady(terminalId);
111
+ },
112
+ onTerminalClosedCb(terminalId, reason) {
113
+ terminalManager.onTerminalClosed(terminalId, reason);
114
+ },
115
+ onSnapshotCb(sessions, teams) {
116
+ // Server snapshot is the source of truth — remove any cached sessions
117
+ // that no longer exist on the server (e.g. stale re-keyed terminal IDs)
118
+ const serverIds = new Set(Object.keys(sessions));
119
+ for (const cachedId of Object.keys(allSessions)) {
120
+ if (!serverIds.has(cachedId)) {
121
+ removeCard(cachedId);
122
+ robotManager.removeRobot(cachedId);
123
+ del('sessions', cachedId).catch(() => {});
124
+ delete allSessions[cachedId];
125
+ }
126
+ }
127
+ for (const [id, session] of Object.entries(sessions)) {
128
+ allSessions[id] = session;
129
+ }
130
+ for (const session of Object.values(sessions)) {
131
+ createOrUpdateCard(session);
132
+ robotManager.updateRobot(session);
133
+ // Persist to IndexedDB (fire-and-forget)
134
+ persistSessionUpdate(session).catch(() => {});
135
+ }
136
+ // Process teams from snapshot
137
+ if (teams) {
138
+ for (const team of Object.values(teams)) {
139
+ createOrUpdateTeamCard(team);
140
+ put('teams', team).catch(() => {});
141
+ }
142
+ }
143
+ statsPanel.update(allSessions);
144
+ updateTabTitle(allSessions);
145
+ toggleEmptyState(Object.keys(allSessions).length === 0);
146
+ },
147
+ onSessionUpdateCb(session, team) {
148
+ // Handle re-keyed terminal sessions (pre-session → real Claude session)
149
+ if (session.replacesId) {
150
+ const wasSelected = getSelectedSessionId() === session.replacesId;
151
+ // Transfer selection BEFORE removing old card so deselectSession() doesn't fire
152
+ if (wasSelected) setSelectedSessionId(session.sessionId);
153
+ delete allSessions[session.replacesId];
154
+ removeCard(session.replacesId);
155
+ robotManager.removeRobot(session.replacesId);
156
+ // Clean up the old IndexedDB record so it doesn't resurrect on refresh
157
+ del('sessions', session.replacesId).catch(() => {});
158
+ }
159
+ allSessions[session.sessionId] = session;
160
+ createOrUpdateCard(session);
161
+ robotManager.updateRobot(session);
162
+ statsPanel.update(allSessions);
163
+ updateTabTitle(allSessions);
164
+ // Persist to IndexedDB (fire-and-forget)
165
+ persistSessionUpdate(session).catch(() => {});
166
+
167
+ // Auto-refresh queue panel if this session's detail panel is open on the terminal tab
168
+ if (session.sessionId === getSelectedSessionId()) {
169
+ const activeTab = document.querySelector('.detail-tabs .tab.active');
170
+ if (activeTab && activeTab.dataset.tab === 'terminal') {
171
+ loadQueue(session.sessionId);
172
+ }
173
+ }
174
+
175
+ // If session belongs to a team, refresh the team card
176
+ if (team) {
177
+ createOrUpdateTeamCard(team);
178
+ } else if (session.teamId) {
179
+ const existingTeam = getTeamsData().get(session.teamId);
180
+ if (existingTeam) createOrUpdateTeamCard(existingTeam);
181
+ }
182
+
183
+ const lastEvt = session.events[session.events.length - 1];
184
+ if (lastEvt && !isMuted(session.sessionId)) {
185
+ switch (lastEvt.type) {
186
+ case 'SessionStart':
187
+ soundManager.play('sessionStart');
188
+ movementManager.trigger('sessionStart', session.sessionId);
189
+ break;
190
+ case 'UserPromptSubmit':
191
+ soundManager.play('promptSubmit');
192
+ movementManager.trigger('promptSubmit', session.sessionId);
193
+ break;
194
+ case 'PreToolUse': {
195
+ const toolMap = {
196
+ Read: 'toolRead', Write: 'toolWrite', Edit: 'toolEdit',
197
+ Bash: 'toolBash', Grep: 'toolGrep', Glob: 'toolGlob',
198
+ WebFetch: 'toolWebFetch', Task: 'toolTask'
199
+ };
200
+ const toolName = lastEvt.tool_name || '';
201
+ const action = toolMap[toolName] || 'toolOther';
202
+ soundManager.play(action);
203
+ movementManager.trigger(action, session.sessionId);
204
+ break;
205
+ }
206
+ case 'Stop':
207
+ soundManager.play('taskComplete');
208
+ movementManager.trigger('taskComplete', session.sessionId);
209
+ break;
210
+ case 'SessionEnd':
211
+ soundManager.play('sessionEnd');
212
+ movementManager.trigger('sessionEnd', session.sessionId);
213
+ break;
214
+ }
215
+ }
216
+
217
+ // Approval alarm: play urgent sound when session enters approval state (repeats every 10s)
218
+ if (session.status === 'approval' && !isMuted(session.sessionId)) {
219
+ if (!approvalAlarmTimers.has(session.sessionId)) {
220
+ soundManager.play('approvalNeeded');
221
+ movementManager.trigger('approvalNeeded', session.sessionId);
222
+ const intervalId = setInterval(() => {
223
+ const current = allSessions[session.sessionId];
224
+ if (!current || current.status !== 'approval' || isMuted(session.sessionId)) {
225
+ clearInterval(intervalId);
226
+ approvalAlarmTimers.delete(session.sessionId);
227
+ return;
228
+ }
229
+ soundManager.play('approvalNeeded');
230
+ }, 10000);
231
+ approvalAlarmTimers.set(session.sessionId, intervalId);
232
+ }
233
+ } else if (session.status !== 'approval' && approvalAlarmTimers.has(session.sessionId)) {
234
+ clearInterval(approvalAlarmTimers.get(session.sessionId));
235
+ approvalAlarmTimers.delete(session.sessionId);
236
+ }
237
+
238
+ // Input notification: play a softer sound once when Claude is asking a question (no repeat)
239
+ if (session.status === 'input' && !isMuted(session.sessionId)) {
240
+ if (!approvalAlarmTimers.has('input-' + session.sessionId)) {
241
+ soundManager.play('inputNeeded');
242
+ approvalAlarmTimers.set('input-' + session.sessionId, true);
243
+ }
244
+ } else if (session.status !== 'input' && approvalAlarmTimers.has('input-' + session.sessionId)) {
245
+ approvalAlarmTimers.delete('input-' + session.sessionId);
246
+ }
247
+
248
+ addActivityEntry(session);
249
+ toggleEmptyState(Object.keys(allSessions).length === 0);
250
+
251
+ // Label completion alerts — fire configured sound + movement for ONEOFF / HEAVY / IMPORTANT
252
+ if (session.status === 'ended' && !isMuted(session.sessionId)) {
253
+ const labelUpper = (session.label || '').toUpperCase();
254
+ const labelCfg = settingsManager.getLabelSettings();
255
+ if (labelCfg[labelUpper]) {
256
+ const cfg = labelCfg[labelUpper];
257
+ if (cfg.sound && cfg.sound !== 'none') soundManager.previewSound(cfg.sound);
258
+ if (cfg.movement && cfg.movement !== 'none') movementManager.trigger('alert', session.sessionId);
259
+ // Also apply the specific movement directly to the card character
260
+ const card = document.querySelector(`.session-card[data-session-id="${session.sessionId}"] .css-robot`);
261
+ if (card && cfg.movement && cfg.movement !== 'none') {
262
+ card.removeAttribute('data-movement');
263
+ void card.offsetWidth;
264
+ card.setAttribute('data-movement', cfg.movement);
265
+ setTimeout(() => card.removeAttribute('data-movement'), 5000);
266
+ }
267
+ }
268
+ // ONEOFF — also show review reminder toast
269
+ if (labelUpper === 'ONEOFF') {
270
+ showOneoffReviewToast(session);
271
+ }
272
+ }
273
+
274
+ // SSH sessions persist as disconnected cards — don't auto-remove
275
+ // Non-SSH sessions auto-remove after a brief delay
276
+ if (session.status === 'ended' && session.source !== 'ssh') {
277
+ setTimeout(() => {
278
+ removeCard(session.sessionId, true);
279
+ robotManager.removeRobot(session.sessionId);
280
+ delete allSessions[session.sessionId];
281
+ statsPanel.update(allSessions);
282
+ updateTabTitle(allSessions);
283
+ toggleEmptyState(Object.keys(allSessions).length === 0);
284
+ }, 2000);
285
+ }
286
+ },
287
+ onSessionRemovedCb(sessionId) {
288
+ // Permanent deletion — remove card, robot, and IndexedDB cache
289
+ removeCard(sessionId, true);
290
+ robotManager.removeRobot(sessionId);
291
+ delete allSessions[sessionId];
292
+ // Remove from IndexedDB
293
+ del('sessions', sessionId).catch(() => {});
294
+ statsPanel.update(allSessions);
295
+ updateTabTitle(allSessions);
296
+ toggleEmptyState(Object.keys(allSessions).length === 0);
297
+ },
298
+ onTeamUpdateCb(team) {
299
+ if (team) {
300
+ createOrUpdateTeamCard(team);
301
+ // Check if all members ended — remove team card
302
+ const allIds = [team.parentSessionId, ...(team.childSessionIds || [])];
303
+ const allEnded = allIds.every(sid => {
304
+ const s = allSessions[sid];
305
+ return !s || s.status === 'ended';
306
+ });
307
+ if (allEnded) {
308
+ setTimeout(() => removeTeamCard(team.teamId), 3000);
309
+ }
310
+ }
311
+ },
312
+ onHookStatsCb(stats) {
313
+ statsPanel.updateHookStats(stats);
314
+ },
315
+ onDurationAlertCb(data) {
316
+ showToast('DURATION ALERT', `Session "${data.projectName}" exceeded ${Math.round(data.thresholdMs / 60000)} min (running: ${Math.round(data.elapsedMs / 60000)} min)`);
317
+ },
318
+ async onClearBrowserDbCb() {
319
+ // Server reset — clear all IndexedDB stores and remove all cards
320
+ for (const id of Object.keys(allSessions)) {
321
+ removeCard(id, true);
322
+ robotManager.removeRobot(id);
323
+ }
324
+ allSessions = {};
325
+ const stores = ['sessions', 'prompts', 'responses', 'toolCalls', 'events', 'notes', 'promptQueue', 'alerts', 'teams'];
326
+ for (const store of stores) {
327
+ await clear(store).catch(() => {});
328
+ }
329
+ statsPanel.update(allSessions);
330
+ updateTabTitle(allSessions);
331
+ toggleEmptyState(true);
332
+ showToast('RESET', 'All browser data cleared');
333
+ }
334
+ });
335
+
336
+ // Duration timer
337
+ setInterval(() => {
338
+ updateDurations();
339
+ }, 1000);
340
+
341
+ // Initialize navigation
342
+ navController.init();
343
+
344
+ // Initialize session groups (localStorage-based, drag-and-drop)
345
+ initGroups();
346
+
347
+ // Initialize history panel (populate project filter, wire event listeners)
348
+ historyPanel.init();
349
+
350
+ // Wire view switching callbacks
351
+ navController.onViewChange('history', () => historyPanel.refresh());
352
+ navController.onViewChange('timeline', () => timelinePanel.refresh());
353
+ navController.onViewChange('analytics', () => analyticsPanel.refresh());
354
+
355
+ // Handle card dismiss — clean runtime state, preserve IndexedDB for history
356
+ document.addEventListener('card-dismissed', (e) => {
357
+ const sid = e.detail.sessionId;
358
+ robotManager.removeRobot(sid);
359
+ // Clear approval/input alarm timers
360
+ if (approvalAlarmTimers.has(sid)) {
361
+ clearInterval(approvalAlarmTimers.get(sid));
362
+ approvalAlarmTimers.delete(sid);
363
+ }
364
+ approvalAlarmTimers.delete('input-' + sid);
365
+ delete allSessions[sid];
366
+ statsPanel.update(allSessions);
367
+ updateTabTitle(allSessions);
368
+ toggleEmptyState(Object.keys(allSessions).length === 0);
369
+ });
370
+
371
+ // Load historical stats after WS connects
372
+ statsPanel.loadHistoricalStats();
373
+
374
+ // Wire up keyboard shortcuts
375
+ initKeyboardShortcuts();
376
+
377
+ // Wire up quick actions
378
+ initQuickActions();
379
+
380
+ // Wire up shortcuts panel
381
+ initShortcutsPanel();
382
+
383
+ }
384
+
385
+ // ---- Tab Title ----
386
+ function updateTabTitle(sessions) {
387
+ const list = Object.values(sessions);
388
+ const activeCount = list.filter(s => s.status !== 'ended').length;
389
+ if (activeCount > 0) {
390
+ document.title = `(${activeCount}) AI Agent Session Center`;
391
+ } else {
392
+ document.title = 'AI Agent Session Center';
393
+ }
394
+ }
395
+
396
+ // ---- Keyboard Shortcuts ----
397
+ function initKeyboardShortcuts() {
398
+ document.addEventListener('keydown', (e) => {
399
+ // Cmd+Enter (Mac) / Ctrl+Enter (Win/Linux) / Alt+Enter → open New Session modal (works even in terminal)
400
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey || e.altKey)) {
401
+ e.preventDefault();
402
+ const modal = document.getElementById('new-session-modal');
403
+ if (modal) {
404
+ modal.classList.remove('hidden');
405
+ loadSshKeysOnInit().then(restoreLastSession);
406
+ }
407
+ return;
408
+ }
409
+
410
+ // Skip if user is typing in an input/textarea
411
+ const tag = e.target.tagName;
412
+ // Never intercept xterm terminal keypresses (xterm uses a hidden textarea)
413
+ if (e.target.classList.contains('xterm-helper-textarea')) return;
414
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || e.target.isContentEditable) {
415
+ if (e.key === 'Escape') {
416
+ e.target.blur();
417
+ }
418
+ return;
419
+ }
420
+
421
+ // Don't intercept when modifiers are held (Ctrl, Meta, Alt)
422
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
423
+
424
+ switch (e.key) {
425
+ case '/': {
426
+ e.preventDefault();
427
+ const searchInput = document.getElementById('live-search');
428
+ if (searchInput) searchInput.focus();
429
+ break;
430
+ }
431
+ case 'Escape': {
432
+ // Close in priority order: kill, alert, summarize, team, shortcuts, settings, detail
433
+ const kill = document.getElementById('kill-modal');
434
+ const alert = document.getElementById('alert-modal');
435
+ const summarizeModal = document.getElementById('summarize-modal');
436
+ const teamModal = document.getElementById('team-modal');
437
+ const newSessionModal = document.getElementById('new-session-modal');
438
+ const quickSessionModal = document.getElementById('quick-session-modal');
439
+ const shortcutsModal = document.getElementById('shortcuts-modal');
440
+ const settings = document.getElementById('settings-modal');
441
+ const detail = document.getElementById('session-detail-overlay');
442
+
443
+ if (kill && !kill.classList.contains('hidden')) {
444
+ kill.classList.add('hidden');
445
+ } else if (alert && !alert.classList.contains('hidden')) {
446
+ alert.classList.add('hidden');
447
+ } else if (summarizeModal && !summarizeModal.classList.contains('hidden')) {
448
+ summarizeModal.classList.add('hidden');
449
+ } else if (newSessionModal && !newSessionModal.classList.contains('hidden')) {
450
+ newSessionModal.classList.add('hidden');
451
+ } else if (quickSessionModal && !quickSessionModal.classList.contains('hidden')) {
452
+ quickSessionModal.classList.add('hidden');
453
+ } else if (teamModal && !teamModal.classList.contains('hidden')) {
454
+ teamModal.classList.add('hidden');
455
+ } else if (shortcutsModal && !shortcutsModal.classList.contains('hidden')) {
456
+ shortcutsModal.classList.add('hidden');
457
+ } else if (settings && !settings.classList.contains('hidden')) {
458
+ settings.classList.add('hidden');
459
+ } else if (detail && !detail.classList.contains('hidden')) {
460
+ deselectSession();
461
+ }
462
+ break;
463
+ }
464
+ case '?': {
465
+ e.preventDefault();
466
+ const scModal = document.getElementById('shortcuts-modal');
467
+ if (scModal) scModal.classList.toggle('hidden');
468
+ break;
469
+ }
470
+ case 's':
471
+ case 'S': {
472
+ e.preventDefault();
473
+ const settingsModal = document.getElementById('settings-modal');
474
+ if (settingsModal) settingsModal.classList.toggle('hidden');
475
+ break;
476
+ }
477
+ case 'k':
478
+ case 'K': {
479
+ if (getSelectedSessionId()) {
480
+ document.getElementById('ctrl-kill')?.click();
481
+ }
482
+ break;
483
+ }
484
+ case 'a':
485
+ case 'A': {
486
+ if (getSelectedSessionId()) {
487
+ document.getElementById('ctrl-archive')?.click();
488
+ }
489
+ break;
490
+ }
491
+ case 't':
492
+ case 'T': {
493
+ // Open New Session modal
494
+ document.getElementById('new-session-modal')?.classList.remove('hidden');
495
+ break;
496
+ }
497
+ case 'm':
498
+ case 'M': {
499
+ document.getElementById('qa-mute-all')?.click();
500
+ break;
501
+ }
502
+ }
503
+ });
504
+
505
+ }
506
+
507
+ // ---- Quick Actions (in nav bar) ----
508
+ function initQuickActions() {
509
+ const muteAllBtn = document.getElementById('qa-mute-all');
510
+ if (muteAllBtn) {
511
+ muteAllBtn.addEventListener('click', () => {
512
+ const muted = toggleMuteAll();
513
+ muteAllBtn.innerHTML = muted ? '♫ UNMUTE ALL' : '♫ MUTE ALL';
514
+ muteAllBtn.title = muted ? 'Unmute all sessions' : 'Mute all sessions';
515
+ muteAllBtn.classList.toggle('active', muted);
516
+ });
517
+ }
518
+
519
+ const archiveBtn = document.getElementById('qa-archive-ended');
520
+ if (archiveBtn) {
521
+ archiveBtn.addEventListener('click', () => archiveAllEnded());
522
+ }
523
+
524
+ // + NEW SESSION button
525
+ const newSessionBtn = document.getElementById('qa-new-session');
526
+ if (newSessionBtn) {
527
+ newSessionBtn.addEventListener('click', () => {
528
+ const modal = document.getElementById('new-session-modal');
529
+ modal.classList.remove('hidden');
530
+ loadSshKeysOnInit().then(restoreLastSession);
531
+ populateLabelSuggestions('label-suggestions');
532
+ });
533
+ }
534
+
535
+ // QUICK SESSION button
536
+ const quickBtn = document.getElementById('qa-quick-session');
537
+ if (quickBtn) {
538
+ quickBtn.addEventListener('click', () => {
539
+ const modal = document.getElementById('quick-session-modal');
540
+ modal.classList.remove('hidden');
541
+ populateQuickLabelChips();
542
+ populateLabelSuggestions('quick-label-suggestions');
543
+ // Restore last working directory
544
+ const saved = (() => {
545
+ try { return JSON.parse(localStorage.getItem('lastSession') || '{}'); } catch { return {}; }
546
+ })();
547
+ const workdirInput = document.getElementById('quick-workdir');
548
+ if (workdirInput) workdirInput.value = saved.workingDir || '~';
549
+ });
550
+ }
551
+
552
+ // ONEOFF button — open quick modal with ONEOFF label pre-filled
553
+ const oneoffBtn = document.getElementById('qa-oneoff');
554
+ if (oneoffBtn) {
555
+ oneoffBtn.addEventListener('click', () => {
556
+ const modal = document.getElementById('quick-session-modal');
557
+ modal.classList.remove('hidden');
558
+ document.getElementById('quick-label-input').value = 'ONEOFF';
559
+ populateQuickLabelChips();
560
+ populateLabelSuggestions('quick-label-suggestions');
561
+ // Restore last working directory
562
+ const saved = (() => {
563
+ try { return JSON.parse(localStorage.getItem('lastSession') || '{}'); } catch { return {}; }
564
+ })();
565
+ const workdirInput = document.getElementById('quick-workdir');
566
+ if (workdirInput) workdirInput.value = saved.workingDir || '~';
567
+ });
568
+ }
569
+
570
+ // HEAVY button — open quick modal with HEAVY label pre-filled
571
+ const heavyBtn = document.getElementById('qa-heavy');
572
+ if (heavyBtn) {
573
+ heavyBtn.addEventListener('click', () => {
574
+ const modal = document.getElementById('quick-session-modal');
575
+ modal.classList.remove('hidden');
576
+ document.getElementById('quick-label-input').value = 'HEAVY';
577
+ populateQuickLabelChips();
578
+ populateLabelSuggestions('quick-label-suggestions');
579
+ // Restore last working directory
580
+ const saved = (() => {
581
+ try { return JSON.parse(localStorage.getItem('lastSession') || '{}'); } catch { return {}; }
582
+ })();
583
+ const workdirInput = document.getElementById('quick-workdir');
584
+ if (workdirInput) workdirInput.value = saved.workingDir || '~';
585
+ });
586
+ }
587
+
588
+ // IMPORTANT button — open quick modal with IMPORTANT label pre-filled
589
+ const importantBtn = document.getElementById('qa-important');
590
+ if (importantBtn) {
591
+ importantBtn.addEventListener('click', () => {
592
+ const modal = document.getElementById('quick-session-modal');
593
+ modal.classList.remove('hidden');
594
+ document.getElementById('quick-label-input').value = 'IMPORTANT';
595
+ populateQuickLabelChips();
596
+ populateLabelSuggestions('quick-label-suggestions');
597
+ // Restore last working directory
598
+ const saved = (() => {
599
+ try { return JSON.parse(localStorage.getItem('lastSession') || '{}'); } catch { return {}; }
600
+ })();
601
+ const workdirInput = document.getElementById('quick-workdir');
602
+ if (workdirInput) workdirInput.value = saved.workingDir || '~';
603
+ });
604
+ }
605
+
606
+ // Quick Session modal buttons
607
+ initQuickSessionModal();
608
+
609
+ // New Session modal buttons
610
+ initNewSessionModal();
611
+
612
+ // Nav actions collapse/expand toggle
613
+ const navActionsToggle = document.getElementById('nav-actions-toggle');
614
+ const navActions = document.getElementById('nav-actions');
615
+ if (navActionsToggle && navActions) {
616
+ navActionsToggle.addEventListener('click', () => {
617
+ navActions.classList.toggle('collapsed');
618
+ });
619
+ }
620
+
621
+ // Activity feed collapse/expand toggle
622
+ const feedCollapseBtn = document.getElementById('feed-collapse-btn');
623
+ const feedEl = document.getElementById('activity-feed');
624
+ if (feedCollapseBtn && feedEl) {
625
+ feedCollapseBtn.addEventListener('click', () => {
626
+ feedEl.classList.toggle('collapsed');
627
+ });
628
+ }
629
+ }
630
+
631
+ // ---- Keyboard Shortcuts Panel ----
632
+ function initShortcutsPanel() {
633
+ const btn = document.getElementById('shortcuts-btn');
634
+ const modal = document.getElementById('shortcuts-modal');
635
+ const closeBtn = document.getElementById('shortcuts-close');
636
+ if (!btn || !modal) return;
637
+
638
+ btn.addEventListener('click', () => modal.classList.toggle('hidden'));
639
+ if (closeBtn) closeBtn.addEventListener('click', () => modal.classList.add('hidden'));
640
+ modal.addEventListener('click', (e) => {
641
+ if (e.target === modal) modal.classList.add('hidden');
642
+ });
643
+ }
644
+
645
+ function showOneoffReviewToast(session) {
646
+ const container = document.getElementById('toast-container');
647
+ const toast = document.createElement('div');
648
+ toast.className = 'toast oneoff-review-toast';
649
+ const title = session.title || session.projectName || 'ONEOFF session';
650
+ toast.innerHTML = `
651
+ <div class="toast-title">ONEOFF DONE — Review needed</div>
652
+ <div class="toast-msg">${title.replace(/</g, '&lt;')}</div>
653
+ <div class="oneoff-review-actions">
654
+ <button class="oneoff-review-btn" data-action="review">REVIEW</button>
655
+ <button class="oneoff-delete-btn" data-action="delete">DELETE</button>
656
+ <button class="oneoff-dismiss-btn" data-action="dismiss">DISMISS</button>
657
+ </div>
658
+ `;
659
+ container.appendChild(toast);
660
+
661
+ toast.querySelector('[data-action="review"]').addEventListener('click', () => {
662
+ // Select the session card to open detail panel
663
+ import('./sessionPanel.js').then(sp => {
664
+ const card = document.querySelector(`.session-card[data-session-id="${session.sessionId}"]`);
665
+ if (card) card.click();
666
+ });
667
+ toast.remove();
668
+ });
669
+
670
+ toast.querySelector('[data-action="delete"]').addEventListener('click', async () => {
671
+ const sid = session.sessionId;
672
+ await fetch(`/api/sessions/${sid}`, { method: 'DELETE' }).catch(() => {});
673
+ await del('sessions', sid).catch(() => {});
674
+ removeCard(sid, true);
675
+ robotManager.removeRobot(sid);
676
+ delete allSessions[sid];
677
+ statsPanel.update(allSessions);
678
+ updateTabTitle(allSessions);
679
+ toggleEmptyState(Object.keys(allSessions).length === 0);
680
+ showToast('DELETED', 'ONEOFF session removed');
681
+ toast.remove();
682
+ });
683
+
684
+ toast.querySelector('[data-action="dismiss"]').addEventListener('click', () => {
685
+ toast.remove();
686
+ });
687
+
688
+ // Auto-dismiss after 30s
689
+ setTimeout(() => { if (toast.parentNode) toast.remove(); }, 30000);
690
+ }
691
+
692
+ function toggleEmptyState(show) {
693
+ document.getElementById('empty-state').classList.toggle('hidden', !show);
694
+ document.getElementById('sessions-grid').classList.toggle('hidden', show);
695
+ }
696
+
697
+ function addActivityEntry(session) {
698
+ const feed = document.getElementById('feed-entries');
699
+ const lastEvent = session.events[session.events.length - 1];
700
+ if (!lastEvent) return;
701
+
702
+ const time = new Date(lastEvent.timestamp).toLocaleTimeString('en-US', { hour12: false });
703
+ // Team role prefix
704
+ let rolePrefix = '';
705
+ if (session.teamRole === 'leader') {
706
+ rolePrefix = '<span class="feed-role">[Leader]</span>';
707
+ } else if (session.teamRole === 'member' && session.agentType) {
708
+ rolePrefix = `<span class="feed-role">[${session.agentType}]</span>`;
709
+ }
710
+ const entry = document.createElement('div');
711
+ entry.className = 'feed-entry';
712
+ entry.innerHTML = `<span class="feed-time">${time}</span> ` +
713
+ `<span class="feed-project">[${session.projectName}]</span> ` +
714
+ `${rolePrefix}` +
715
+ `<span class="feed-detail">${lastEvent.type}: ${lastEvent.detail}</span>`;
716
+ feed.appendChild(entry);
717
+
718
+ // Keep last 100
719
+ while (feed.children.length > 100) feed.removeChild(feed.firstChild);
720
+ feed.scrollTop = feed.scrollHeight;
721
+ }
722
+
723
+ // ---- New Session Modal ----
724
+ async function loadSshKeysOnInit() {
725
+ loadSshKeys();
726
+ }
727
+
728
+ async function loadSshKeys() {
729
+ try {
730
+ const resp = await fetch('/api/ssh-keys');
731
+ const { keys } = await resp.json();
732
+ const select = document.getElementById('ssh-key-select');
733
+ select.innerHTML = '';
734
+ for (const k of keys) {
735
+ const opt = document.createElement('option');
736
+ opt.value = k.path;
737
+ opt.textContent = k.name;
738
+ select.appendChild(opt);
739
+ }
740
+ if (keys.length === 0) {
741
+ const opt = document.createElement('option');
742
+ opt.value = '';
743
+ opt.textContent = 'No keys found in ~/.ssh/';
744
+ select.appendChild(opt);
745
+ }
746
+ } catch (e) {
747
+ console.error('[app] Failed to load SSH keys:', e);
748
+ }
749
+ }
750
+
751
+ function restoreLastSession() {
752
+ try {
753
+ const saved = localStorage.getItem('lastSession');
754
+ if (!saved) return;
755
+ const s = JSON.parse(saved);
756
+
757
+ // Restore individual fields
758
+ if (s.host) document.getElementById('ssh-host').value = s.host;
759
+ if (s.port) document.getElementById('ssh-port').value = s.port;
760
+ if (s.username) document.getElementById('ssh-username').value = s.username;
761
+ if (s.authMethod) {
762
+ document.getElementById('ssh-auth-method').value = s.authMethod;
763
+ document.getElementById('ssh-auth-method').dispatchEvent(new Event('change'));
764
+ }
765
+ if (s.privateKeyPath) {
766
+ const keySelect = document.getElementById('ssh-key-select');
767
+ for (const opt of keySelect.options) {
768
+ if (opt.value === s.privateKeyPath) { keySelect.value = s.privateKeyPath; break; }
769
+ }
770
+ }
771
+ if (s.workingDir) document.getElementById('ssh-workdir').value = s.workingDir;
772
+ if (s.command) {
773
+ const presetSelect = document.getElementById('ssh-command-preset');
774
+ let matched = false;
775
+ for (const opt of presetSelect.options) {
776
+ if (opt.value === s.command) { presetSelect.value = s.command; matched = true; break; }
777
+ }
778
+ if (!matched) {
779
+ presetSelect.value = 'custom';
780
+ document.getElementById('ssh-custom-command').value = s.command;
781
+ document.getElementById('ssh-custom-command').classList.remove('hidden');
782
+ }
783
+ presetSelect.dispatchEvent(new Event('change'));
784
+ }
785
+ if (s.terminalTheme) {
786
+ const themeSelect = document.getElementById('ssh-terminal-theme');
787
+ if (themeSelect) themeSelect.value = s.terminalTheme;
788
+ }
789
+ } catch (e) {
790
+ console.warn('[app] Failed to restore last session:', e);
791
+ }
792
+ }
793
+
794
+ function initNewSessionModal() {
795
+ const modal = document.getElementById('new-session-modal');
796
+ if (!modal) return;
797
+
798
+ // Close buttons
799
+ document.getElementById('new-session-close')?.addEventListener('click', () => modal.classList.add('hidden'));
800
+ document.getElementById('ssh-cancel')?.addEventListener('click', () => modal.classList.add('hidden'));
801
+
802
+ // Auth method toggle
803
+ document.getElementById('ssh-auth-method')?.addEventListener('change', (e) => {
804
+ const val = e.target.value;
805
+ document.getElementById('ssh-password-row').classList.toggle('hidden', val !== 'password');
806
+ document.getElementById('ssh-key-row').classList.toggle('hidden', val !== 'key');
807
+ });
808
+
809
+ // Command preset toggle
810
+ document.getElementById('ssh-command-preset')?.addEventListener('change', (e) => {
811
+ document.getElementById('ssh-custom-row').classList.toggle('hidden', e.target.value !== 'custom');
812
+ });
813
+
814
+ // Session mode toggle (New / Attach tmux / Wrap in tmux)
815
+ let selectedTmuxSession = null;
816
+ let currentSshMode = 'new';
817
+ document.querySelectorAll('.ssh-mode-btn').forEach(btn => {
818
+ btn.addEventListener('click', () => {
819
+ document.querySelectorAll('.ssh-mode-btn').forEach(b => b.classList.remove('active'));
820
+ btn.classList.add('active');
821
+ currentSshMode = btn.dataset.mode;
822
+ const tmuxRow = document.getElementById('ssh-tmux-row');
823
+ tmuxRow.classList.toggle('hidden', currentSshMode !== 'tmux-attach');
824
+ selectedTmuxSession = null;
825
+ // Hide command fields when attaching to existing tmux session
826
+ const commandFields = [document.getElementById('ssh-command-preset')?.closest('.ssh-field'), document.getElementById('ssh-custom-row')];
827
+ commandFields.forEach(el => { if (el) el.classList.toggle('hidden', currentSshMode === 'tmux-attach'); });
828
+ });
829
+ });
830
+
831
+ // Tmux refresh
832
+ document.getElementById('ssh-tmux-refresh')?.addEventListener('click', () => fetchTmuxSessions());
833
+
834
+ async function fetchTmuxSessions() {
835
+ const listEl = document.getElementById('ssh-tmux-list');
836
+ if (!listEl) return;
837
+ listEl.innerHTML = '<div class="ssh-tmux-loading">Loading...</div>';
838
+ selectedTmuxSession = null;
839
+ try {
840
+ const body = {
841
+ host: document.getElementById('ssh-host').value,
842
+ port: parseInt(document.getElementById('ssh-port').value) || 22,
843
+ username: document.getElementById('ssh-username').value,
844
+ authMethod: document.getElementById('ssh-auth-method').value,
845
+ password: document.getElementById('ssh-password').value || undefined,
846
+ privateKeyPath: document.getElementById('ssh-key-select').value,
847
+ };
848
+ const resp = await fetch('/api/tmux-sessions', {
849
+ method: 'POST',
850
+ headers: { 'Content-Type': 'application/json' },
851
+ body: JSON.stringify(body),
852
+ });
853
+ const data = await resp.json();
854
+ if (!resp.ok) throw new Error(data.error || 'Failed to list tmux sessions');
855
+ if (!data.sessions || data.sessions.length === 0) {
856
+ listEl.innerHTML = '<div class="ssh-tmux-empty">No tmux sessions found</div>';
857
+ return;
858
+ }
859
+ listEl.innerHTML = '';
860
+ for (const s of data.sessions) {
861
+ const item = document.createElement('div');
862
+ item.className = 'ssh-tmux-item';
863
+ item.dataset.name = s.name;
864
+ const age = formatAge(s.created);
865
+ item.innerHTML = `
866
+ <span class="ssh-tmux-name">${escapeHtml(s.name)}</span>
867
+ <span class="ssh-tmux-meta">${s.windows} win${s.windows !== 1 ? 's' : ''} · ${s.attached ? 'attached' : 'detached'} · ${age}</span>
868
+ `;
869
+ item.addEventListener('click', () => {
870
+ listEl.querySelectorAll('.ssh-tmux-item').forEach(i => i.classList.remove('selected'));
871
+ item.classList.add('selected');
872
+ selectedTmuxSession = s.name;
873
+ });
874
+ listEl.appendChild(item);
875
+ }
876
+ } catch (e) {
877
+ listEl.innerHTML = `<div class="ssh-tmux-empty ssh-tmux-error">${escapeHtml(e.message)}</div>`;
878
+ }
879
+ }
880
+
881
+ function formatAge(ts) {
882
+ if (!ts) return '';
883
+ const sec = Math.floor((Date.now() - ts) / 1000);
884
+ if (sec < 60) return `${sec}s ago`;
885
+ if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
886
+ if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
887
+ return `${Math.floor(sec / 86400)}d ago`;
888
+ }
889
+
890
+ function escapeHtml(str) {
891
+ const d = document.createElement('div');
892
+ d.textContent = str;
893
+ return d.innerHTML;
894
+ }
895
+
896
+ // Connect & Launch
897
+ document.getElementById('ssh-connect')?.addEventListener('click', async () => {
898
+ const connectBtn = document.getElementById('ssh-connect');
899
+
900
+ // Validate tmux-attach mode
901
+ if (currentSshMode === 'tmux-attach' && !selectedTmuxSession) {
902
+ showToast('ERROR', 'Select a tmux session to attach');
903
+ return;
904
+ }
905
+
906
+ connectBtn.disabled = true;
907
+ connectBtn.textContent = 'CONNECTING...';
908
+ try {
909
+ const labelVal = document.getElementById('ssh-session-label')?.value.trim() || undefined;
910
+ const body = {
911
+ host: document.getElementById('ssh-host').value,
912
+ port: parseInt(document.getElementById('ssh-port').value) || 22,
913
+ username: document.getElementById('ssh-username').value,
914
+ authMethod: document.getElementById('ssh-auth-method').value,
915
+ password: document.getElementById('ssh-password').value || undefined,
916
+ privateKeyPath: document.getElementById('ssh-key-select').value,
917
+ workingDir: document.getElementById('ssh-workdir').value,
918
+ command: getSelectedCommand(),
919
+ apiKey: document.getElementById('ssh-api-key')?.value || getApiKeyForCommand(getSelectedCommand()) || undefined,
920
+ terminalTheme: document.getElementById('ssh-terminal-theme')?.value || 'default',
921
+ sessionTitle: document.getElementById('ssh-session-title')?.value || undefined,
922
+ label: labelVal,
923
+ };
924
+
925
+ // Add tmux params based on mode
926
+ if (currentSshMode === 'tmux-attach') {
927
+ body.tmuxSession = selectedTmuxSession;
928
+ } else if (currentSshMode === 'tmux-wrap') {
929
+ body.useTmux = true;
930
+ }
931
+ const resp = await fetch('/api/terminals', {
932
+ method: 'POST',
933
+ headers: { 'Content-Type': 'application/json' },
934
+ body: JSON.stringify(body),
935
+ });
936
+ const result = await resp.json();
937
+ if (!resp.ok) throw new Error(result.error || 'Connection failed');
938
+
939
+ // Save last used settings to localStorage for next time
940
+ try {
941
+ localStorage.setItem('lastSession', JSON.stringify({
942
+ host: body.host,
943
+ port: body.port,
944
+ username: body.username,
945
+ authMethod: body.authMethod,
946
+ privateKeyPath: body.privateKeyPath,
947
+ workingDir: body.workingDir,
948
+ command: getSelectedCommand(),
949
+ terminalTheme: body.terminalTheme,
950
+ }));
951
+ } catch (_) {}
952
+
953
+ // Save label to localStorage for future suggestions
954
+ if (labelVal) saveLabel(labelVal);
955
+
956
+ modal.classList.add('hidden');
957
+ showToast('CONNECTED', `Terminal ${result.terminalId} launched`);
958
+
959
+ // Store theme preference for this terminal
960
+ const theme = document.getElementById('ssh-terminal-theme')?.value || 'default';
961
+ terminalManager.setTerminalTheme(result.terminalId, theme);
962
+ } catch (e) {
963
+ showToast('ERROR', e.message);
964
+ } finally {
965
+ connectBtn.disabled = false;
966
+ connectBtn.textContent = 'CONNECT & LAUNCH';
967
+ }
968
+ });
969
+ }
970
+
971
+ function getSelectedCommand() {
972
+ const preset = document.getElementById('ssh-command-preset').value;
973
+ if (preset === 'custom') {
974
+ return document.getElementById('ssh-custom-command').value || 'claude';
975
+ }
976
+ return preset;
977
+ }
978
+
979
+ function getApiKeyForCommand(command) {
980
+ if (!command) return settingsManager.get('anthropicApiKey');
981
+ if (command.startsWith('codex')) return settingsManager.get('openaiApiKey');
982
+ if (command.startsWith('gemini')) return settingsManager.get('geminiApiKey');
983
+ return settingsManager.get('anthropicApiKey');
984
+ }
985
+
986
+ // ---- Label Persistence ----
987
+ function getSavedLabels() {
988
+ try {
989
+ return JSON.parse(localStorage.getItem('sessionLabels') || '[]');
990
+ } catch { return []; }
991
+ }
992
+
993
+ function saveLabel(label) {
994
+ if (!label) return;
995
+ const labels = getSavedLabels();
996
+ // Move to front if exists, otherwise prepend
997
+ const idx = labels.indexOf(label);
998
+ if (idx !== -1) labels.splice(idx, 1);
999
+ labels.unshift(label);
1000
+ // Keep max 30
1001
+ localStorage.setItem('sessionLabels', JSON.stringify(labels.slice(0, 30)));
1002
+ }
1003
+
1004
+ function populateLabelSuggestions(datalistId) {
1005
+ const dl = document.getElementById(datalistId);
1006
+ if (!dl) return;
1007
+ dl.innerHTML = '';
1008
+ for (const label of getSavedLabels()) {
1009
+ const opt = document.createElement('option');
1010
+ opt.value = label;
1011
+ dl.appendChild(opt);
1012
+ }
1013
+ }
1014
+
1015
+ function populateQuickLabelChips() {
1016
+ const container = document.getElementById('quick-label-chips');
1017
+ if (!container) return;
1018
+ container.innerHTML = '';
1019
+ const labels = getSavedLabels();
1020
+ if (labels.length === 0) {
1021
+ container.innerHTML = '<span class="quick-label-empty">No labels yet — type one below</span>';
1022
+ return;
1023
+ }
1024
+ for (const label of labels) {
1025
+ const chip = document.createElement('button');
1026
+ chip.className = 'quick-label-chip';
1027
+
1028
+ const labelText = document.createElement('span');
1029
+ labelText.className = 'label-text';
1030
+ labelText.textContent = label;
1031
+
1032
+ const deleteIcon = document.createElement('span');
1033
+ deleteIcon.className = 'label-delete';
1034
+ deleteIcon.textContent = '×';
1035
+ deleteIcon.addEventListener('click', (e) => {
1036
+ e.stopPropagation();
1037
+ deleteLabel(label);
1038
+ populateQuickLabelChips();
1039
+ populateLabelSuggestions('quick-label-suggestions');
1040
+ });
1041
+
1042
+ chip.appendChild(labelText);
1043
+ chip.appendChild(deleteIcon);
1044
+
1045
+ chip.addEventListener('click', () => {
1046
+ container.querySelectorAll('.quick-label-chip').forEach(c => c.classList.remove('active'));
1047
+ chip.classList.add('active');
1048
+ document.getElementById('quick-label-input').value = label;
1049
+ });
1050
+ container.appendChild(chip);
1051
+ }
1052
+ }
1053
+
1054
+ function deleteLabel(label) {
1055
+ const labels = getSavedLabels();
1056
+ const idx = labels.indexOf(label);
1057
+ if (idx !== -1) {
1058
+ labels.splice(idx, 1);
1059
+ localStorage.setItem('sessionLabels', JSON.stringify(labels));
1060
+ }
1061
+ }
1062
+
1063
+ // ---- Quick Session Modal ----
1064
+ function initQuickSessionModal() {
1065
+ const modal = document.getElementById('quick-session-modal');
1066
+ if (!modal) return;
1067
+
1068
+ document.getElementById('quick-session-close')?.addEventListener('click', () => modal.classList.add('hidden'));
1069
+ document.getElementById('quick-session-cancel')?.addEventListener('click', () => modal.classList.add('hidden'));
1070
+
1071
+ document.getElementById('quick-session-launch')?.addEventListener('click', async () => {
1072
+ const launchBtn = document.getElementById('quick-session-launch');
1073
+ const label = document.getElementById('quick-label-input').value.trim();
1074
+ const workingDir = document.getElementById('quick-workdir')?.value.trim() || '~';
1075
+
1076
+ // Need saved session config
1077
+ const saved = (() => {
1078
+ try { return JSON.parse(localStorage.getItem('lastSession') || '{}'); } catch { return {}; }
1079
+ })();
1080
+
1081
+ if (!saved.username) {
1082
+ showToast('ERROR', 'No saved session config. Use "+ NEW SESSION" first.');
1083
+ return;
1084
+ }
1085
+
1086
+ launchBtn.disabled = true;
1087
+ launchBtn.textContent = 'LAUNCHING...';
1088
+ try {
1089
+ const body = {
1090
+ host: saved.host || 'localhost',
1091
+ port: saved.port || 22,
1092
+ username: saved.username,
1093
+ authMethod: saved.authMethod || 'key',
1094
+ privateKeyPath: saved.privateKeyPath,
1095
+ workingDir: workingDir,
1096
+ command: saved.command || 'claude',
1097
+ terminalTheme: saved.terminalTheme || 'default',
1098
+ label: label || undefined,
1099
+ };
1100
+
1101
+ // Use global API key matching the CLI command
1102
+ const globalKey = getApiKeyForCommand(body.command);
1103
+ if (globalKey) body.apiKey = globalKey;
1104
+
1105
+ const resp = await fetch('/api/terminals', {
1106
+ method: 'POST',
1107
+ headers: { 'Content-Type': 'application/json' },
1108
+ body: JSON.stringify(body),
1109
+ });
1110
+ const result = await resp.json();
1111
+ if (!resp.ok) throw new Error(result.error || 'Connection failed');
1112
+
1113
+ if (label) saveLabel(label);
1114
+
1115
+ // Save last used working directory
1116
+ try {
1117
+ const lastSession = JSON.parse(localStorage.getItem('lastSession') || '{}');
1118
+ lastSession.workingDir = workingDir;
1119
+ localStorage.setItem('lastSession', JSON.stringify(lastSession));
1120
+ } catch (_) {}
1121
+
1122
+ modal.classList.add('hidden');
1123
+
1124
+ // Store theme preference for this terminal
1125
+ terminalManager.setTerminalTheme(result.terminalId, body.terminalTheme);
1126
+
1127
+ // Auto-pin HEAVY and IMPORTANT sessions
1128
+ if (label === 'HEAVY') {
1129
+ setTimeout(() => pinSession(result.terminalId), 500);
1130
+ showToast('HEAVY SESSION', 'High-priority session launched & pinned');
1131
+ } else if (label === 'IMPORTANT') {
1132
+ setTimeout(() => pinSession(result.terminalId), 500);
1133
+ showToast('IMPORTANT SESSION', 'Important session launched & pinned — alert on completion');
1134
+ } else if (label === 'ONEOFF') {
1135
+ showToast('ONEOFF SESSION', 'One-off session launched — review when done');
1136
+ } else {
1137
+ showToast('CONNECTED', `Quick session launched`);
1138
+ }
1139
+ } catch (e) {
1140
+ showToast('ERROR', e.message);
1141
+ } finally {
1142
+ launchBtn.disabled = false;
1143
+ launchBtn.textContent = 'LAUNCH';
1144
+ }
1145
+ });
1146
+ }
1147
+
1148
+ init().catch(console.error);