ai-agent-session-center 2.0.2 → 2.0.3

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 (58) hide show
  1. package/README.md +484 -429
  2. package/docs/3D/ADAPTATION_GUIDE.md +592 -0
  3. package/docs/3D/index.html +754 -0
  4. package/docs/AGENT_TEAM_TASKS.md +716 -0
  5. package/docs/CYBERDROME_V2_SPEC.md +531 -0
  6. package/docs/ERROR_185_ANALYSIS.md +263 -0
  7. package/docs/PLATFORM_FEATURES_PROMPT.md +296 -0
  8. package/docs/SESSION_DETAIL_FEATURES.md +98 -0
  9. package/docs/_3d_multimedia_features.md +1080 -0
  10. package/docs/_frontend_features.md +1057 -0
  11. package/docs/_server_features.md +1077 -0
  12. package/docs/session-duplication-fixes.md +271 -0
  13. package/docs/session-terminal-linkage.md +412 -0
  14. package/package.json +63 -5
  15. package/public/apple-touch-icon.svg +21 -0
  16. package/public/css/dashboard.css +0 -161
  17. package/public/css/detail-panel.css +25 -0
  18. package/public/css/layout.css +18 -1
  19. package/public/css/modals.css +0 -26
  20. package/public/css/settings.css +0 -150
  21. package/public/css/terminal.css +34 -0
  22. package/public/favicon.svg +18 -0
  23. package/public/index.html +6 -26
  24. package/public/js/alarmManager.js +0 -21
  25. package/public/js/app.js +21 -7
  26. package/public/js/detailPanel.js +63 -64
  27. package/public/js/historyPanel.js +61 -55
  28. package/public/js/quickActions.js +132 -48
  29. package/public/js/sessionCard.js +5 -20
  30. package/public/js/sessionControls.js +8 -0
  31. package/public/js/settingsManager.js +0 -142
  32. package/server/apiRouter.js +60 -15
  33. package/server/apiRouter.ts +774 -0
  34. package/server/approvalDetector.ts +94 -0
  35. package/server/authManager.ts +144 -0
  36. package/server/autoIdleManager.ts +110 -0
  37. package/server/config.ts +121 -0
  38. package/server/constants.ts +150 -0
  39. package/server/db.ts +475 -0
  40. package/server/hookInstaller.d.ts +3 -0
  41. package/server/hookProcessor.ts +108 -0
  42. package/server/hookRouter.ts +18 -0
  43. package/server/hookStats.ts +116 -0
  44. package/server/index.js +15 -1
  45. package/server/index.ts +230 -0
  46. package/server/logger.ts +75 -0
  47. package/server/mqReader.ts +349 -0
  48. package/server/portManager.ts +55 -0
  49. package/server/processMonitor.ts +239 -0
  50. package/server/serverConfig.ts +29 -0
  51. package/server/sessionMatcher.js +17 -6
  52. package/server/sessionMatcher.ts +403 -0
  53. package/server/sessionStore.js +109 -3
  54. package/server/sessionStore.ts +1145 -0
  55. package/server/sshManager.js +167 -24
  56. package/server/sshManager.ts +671 -0
  57. package/server/teamManager.ts +289 -0
  58. package/server/wsManager.ts +200 -0
package/public/index.html CHANGED
@@ -4,7 +4,9 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
6
6
  <title>AI Agent Session Center</title>
7
- <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230a0a1a'/%3E%3Ccircle cx='16' cy='13' r='7' fill='none' stroke='%2300e5ff' stroke-width='2'/%3E%3Ccircle cx='13.5' cy='12' r='1.5' fill='%2300e5ff'/%3E%3Ccircle cx='18.5' cy='12' r='1.5' fill='%2300e5ff'/%3E%3Cpath d='M13 16 Q16 19 19 16' fill='none' stroke='%2300e5ff' stroke-width='1' stroke-linecap='round'/%3E%3Crect x='14.5' y='4' width='3' height='3' rx='1' fill='%2300e5ff'/%3E%3Cline x1='16' y1='7' x2='16' y2='6' stroke='%2300e5ff' stroke-width='1.5'/%3E%3Crect x='9' y='22' width='14' height='6' rx='2' fill='none' stroke='%23ff9100' stroke-width='1.5'/%3E%3Cline x1='12' y1='24' x2='12' y2='26' stroke='%23ff9100' stroke-width='1' stroke-linecap='round'/%3E%3Cline x1='16' y1='24' x2='16' y2='26' stroke='%23ff9100' stroke-width='1' stroke-linecap='round'/%3E%3Cline x1='20' y1='24' x2='20' y2='26' stroke='%23ff9100' stroke-width='1' stroke-linecap='round'/%3E%3C/svg%3E">
7
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
8
+ <link rel="apple-touch-icon" href="/apple-touch-icon.svg">
9
+ <meta name="theme-color" content="#0a0a1a">
8
10
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
11
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&display=swap" rel="stylesheet">
10
12
  <link rel="stylesheet" href="css/main.css">
@@ -56,9 +58,6 @@
56
58
  <div id="nav-actions-items">
57
59
  <button id="qa-new-session" class="qa-btn qa-btn-terminal" title="Launch a new Claude session via SSH">+ NEW SESSION</button>
58
60
  <button id="qa-quick-session" class="qa-btn qa-btn-quick" title="Quick launch with last config + label">&#9889; QUICK</button>
59
- <button id="qa-oneoff" class="qa-btn qa-btn-oneoff" title="Launch a one-off session (auto-review reminder)">ONEOFF</button>
60
- <button id="qa-heavy" class="qa-btn qa-btn-heavy" title="Launch a high-priority session (auto-pinned, bold frame)">&#9733; HEAVY</button>
61
- <button id="qa-important" class="qa-btn qa-btn-important" title="Launch an important session (alert on completion)">&#9888; IMPORTANT</button>
62
61
  <button id="qa-new-group" class="qa-btn" title="Create a new group">+ NEW GROUP</button>
63
62
  <div class="qa-separator"></div>
64
63
  <button id="qa-mute-all" class="qa-btn" title="Mute all sessions">&#9835; MUTE ALL</button>
@@ -98,18 +97,6 @@
98
97
  <span class="mobile-qa-icon">&#9889;</span>
99
98
  <span class="mobile-qa-label">QUICK</span>
100
99
  </button>
101
- <button class="mobile-qa-item" data-action="oneoff" title="One-off Session">
102
- <span class="mobile-qa-icon">&#128260;</span>
103
- <span class="mobile-qa-label">ONEOFF</span>
104
- </button>
105
- <button class="mobile-qa-item" data-action="heavy" title="Heavy Session">
106
- <span class="mobile-qa-icon">&#9733;</span>
107
- <span class="mobile-qa-label">HEAVY</span>
108
- </button>
109
- <button class="mobile-qa-item" data-action="important" title="Important Session">
110
- <span class="mobile-qa-icon">&#9888;</span>
111
- <span class="mobile-qa-label">IMPORTANT</span>
112
- </button>
113
100
  <button class="mobile-qa-item" data-action="new-group" title="New Group">
114
101
  <span class="mobile-qa-icon">&#128194;</span>
115
102
  <span class="mobile-qa-label">GROUP</span>
@@ -284,6 +271,9 @@
284
271
  <h3 id="detail-project-name"></h3>
285
272
  <div class="detail-title-row">
286
273
  <input type="text" id="detail-title" placeholder="Add session title..." class="detail-title-input">
274
+ <button class="detail-title-edit-btn" id="detail-title-edit-btn" title="Edit title">
275
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
276
+ </button>
287
277
  <input type="text" id="detail-label" list="detail-label-suggestions" placeholder="Label..." class="detail-label-input" autocomplete="off">
288
278
  <datalist id="detail-label-suggestions"></datalist>
289
279
  </div>
@@ -487,7 +477,6 @@
487
477
  <div class="settings-tabs">
488
478
  <button class="settings-tab active" data-settings-tab="appearance">Appearance</button>
489
479
  <button class="settings-tab" data-settings-tab="sounds">Sounds</button>
490
- <button class="settings-tab" data-settings-tab="labels">Labels</button>
491
480
  <button class="settings-tab" data-settings-tab="advanced">Advanced</button>
492
481
  </div>
493
482
 
@@ -764,15 +753,6 @@
764
753
  </div>
765
754
  </div>
766
755
 
767
- <!-- Labels Tab -->
768
- <div class="settings-tab-content" id="settings-tab-labels">
769
- <div class="settings-section">
770
- <h4>Built-in Label Alerts</h4>
771
- <p class="settings-hint">Configure which sound and movement effect plays when a labeled session completes. These alerts fire on session end for ONEOFF, HEAVY, and IMPORTANT sessions.</p>
772
- <div id="label-settings-grid" class="label-settings-grid"></div>
773
- </div>
774
- </div>
775
-
776
756
  <!-- Advanced Tab -->
777
757
  <div class="settings-tab-content" id="settings-tab-advanced">
778
758
  <div class="settings-section">
@@ -86,24 +86,3 @@ export function checkAlarms(session, allSessions) {
86
86
  approvalAlarmTimers.delete('input-' + session.sessionId);
87
87
  }
88
88
  }
89
-
90
- // Label completion alerts
91
- export function handleLabelAlerts(session) {
92
- if (session.status !== 'ended' || isMuted(session.sessionId)) return;
93
-
94
- const labelUpper = (session.label || '').toUpperCase();
95
- const labelCfg = settingsManager.getLabelSettings();
96
- if (labelCfg[labelUpper]) {
97
- const cfg = labelCfg[labelUpper];
98
- if (cfg.sound && cfg.sound !== 'none') soundManager.previewSound(cfg.sound);
99
- if (cfg.movement && cfg.movement !== 'none') movementManager.trigger('alert', session.sessionId);
100
- const card = document.querySelector(`.session-card[data-session-id="${session.sessionId}"] .css-robot`);
101
- if (card && cfg.movement && cfg.movement !== 'none') {
102
- card.removeAttribute('data-movement');
103
- void card.offsetWidth;
104
- card.setAttribute('data-movement', cfg.movement);
105
- setTimeout(() => card.removeAttribute('data-movement'), 5000);
106
- }
107
- }
108
-
109
- }
package/public/js/app.js CHANGED
@@ -14,7 +14,7 @@ import { openDB, persistSessionUpdate, put, del, getAll, clear, getQueue, migrat
14
14
  import { escapeHtml as utilEscapeHtml, debugLog, debugWarn } from './utils.js';
15
15
  import { initKeyboardShortcuts } from './keyboardShortcuts.js';
16
16
  import { initQuickActions, initShortcutsPanel } from './quickActions.js';
17
- import { handleEventSounds, checkAlarms, handleLabelAlerts, clearAlarm } from './alarmManager.js';
17
+ import { handleEventSounds, checkAlarms, clearAlarm } from './alarmManager.js';
18
18
 
19
19
  let allSessions = {};
20
20
  let hasRestoredSelection = false;
@@ -150,18 +150,35 @@ async function init() {
150
150
  },
151
151
  onSnapshotCb(sessions, teams) {
152
152
  const serverIds = new Set(Object.keys(sessions));
153
+ // Also build a set of sessionId values to catch key/sessionId mismatches
154
+ const serverSessionIds = new Set();
155
+ for (const s of Object.values(sessions)) {
156
+ if (s.sessionId) serverSessionIds.add(s.sessionId);
157
+ }
153
158
  for (const cachedId of Object.keys(allSessions)) {
154
- if (!serverIds.has(cachedId)) {
159
+ // Remove cached entries not on the server (by key OR by sessionId)
160
+ if (!serverIds.has(cachedId) && !serverSessionIds.has(cachedId)) {
155
161
  removeCard(cachedId);
156
162
  robotManager.removeRobot(cachedId);
157
163
  del('sessions', cachedId).catch(() => {});
158
164
  delete allSessions[cachedId];
159
165
  }
160
166
  }
167
+ // Deduplicate: if server sends entries with different keys but same sessionId,
168
+ // keep only the most recent one to prevent duplicate cards.
169
+ const deduped = new Map();
161
170
  for (const [id, session] of Object.entries(sessions)) {
162
- allSessions[id] = session;
171
+ const sid = session.sessionId || id;
172
+ const existing = deduped.get(sid);
173
+ if (!existing || (session.lastActivityAt || 0) > (existing.lastActivityAt || 0)) {
174
+ deduped.set(sid, session);
175
+ }
163
176
  }
164
- for (const session of Object.values(sessions)) {
177
+ // Use sessionId as the allSessions key (not the server's Map key) for consistency
178
+ for (const [sid, session] of deduped) {
179
+ allSessions[sid] = session;
180
+ }
181
+ for (const session of deduped.values()) {
165
182
  createOrUpdateCard(session);
166
183
  robotManager.updateRobot(session);
167
184
  persistSessionUpdate(session).catch(() => {});
@@ -230,9 +247,6 @@ async function init() {
230
247
  addActivityEntry(session);
231
248
  toggleEmptyState(Object.keys(allSessions).length === 0);
232
249
 
233
- // Label completion alerts
234
- handleLabelAlerts(session);
235
-
236
250
  // SSH sessions persist as disconnected cards; non-SSH auto-remove
237
251
  if (session.status === 'ended' && session.source !== 'ssh') {
238
252
  setTimeout(() => {
@@ -219,10 +219,38 @@ export function populateDetailPanel(session) {
219
219
  }
220
220
  });
221
221
  } else if (activeTab && activeTab.dataset.tab === 'terminal' && !session.terminalId) {
222
- import('./terminalManager.js').then(tm => tm.detachTerminal());
222
+ import('./terminalManager.js').then(tm => {
223
+ tm.detachTerminal();
224
+ _showExternalSourceHint(session);
225
+ });
223
226
  }
224
227
  }
225
228
 
229
+ const SOURCE_LABELS = {
230
+ vscode: 'VS Code', jetbrains: 'JetBrains', iterm: 'iTerm',
231
+ warp: 'Warp', kitty: 'Kitty', ghostty: 'Ghostty',
232
+ alacritty: 'Alacritty', wezterm: 'WezTerm', hyper: 'Hyper',
233
+ terminal: 'Terminal', tmux: 'tmux',
234
+ };
235
+
236
+ /** Show an informational hint in the terminal container for external-source sessions. */
237
+ function _showExternalSourceHint(session) {
238
+ const isExternal = session.source && session.source !== 'ssh';
239
+ const container = document.getElementById('terminal-container');
240
+ if (!container || !isExternal) return;
241
+
242
+ const label = SOURCE_LABELS[session.source] || session.source;
243
+ container.innerHTML = `
244
+ <div class="terminal-external-hint">
245
+ <div class="terminal-external-hint-icon">&#x1F4E1;</div>
246
+ <div class="terminal-external-hint-title">External Session &mdash; ${escapeHtml(label)}</div>
247
+ <div class="terminal-external-hint-text">
248
+ This session was detected from <strong>${escapeHtml(label)}</strong> via hooks.<br>
249
+ No managed terminal is attached. Use the <strong>RECONNECT</strong> button to open a terminal and resume this session, or interact with it directly in ${escapeHtml(label)}.
250
+ </div>
251
+ </div>`;
252
+ }
253
+
226
254
  /**
227
255
  * Restore the previously selected session + active tab after a page refresh.
228
256
  * Called from app.js after the snapshot arrives and sessions are populated.
@@ -328,77 +356,44 @@ export async function openSessionDetailFromHistory(sessionId) {
328
356
  data.session.accentColor || null
329
357
  );
330
358
 
331
- // Populate conversation tab
359
+ // Populate conversation tab — tag raw data in-place, sort once
360
+ const prompts = data.prompts || [];
361
+ const toolCalls = data.tool_calls || [];
362
+ const responses = data.responses || [];
363
+ const events = data.events || [];
364
+
332
365
  const histConvItems = [];
333
- for (const p of (data.prompts || [])) {
334
- histConvItems.push({ type: 'user', text: p.text, timestamp: p.timestamp });
335
- }
336
- for (const t of (data.tool_calls || [])) {
337
- histConvItems.push({ type: 'tool', tool: t.toolName, input: t.toolInputSummary, timestamp: t.timestamp });
338
- }
339
- for (const r of (data.responses || [])) {
340
- histConvItems.push({ type: 'claude', text: r.textExcerpt, timestamp: r.timestamp });
341
- }
342
- histConvItems.sort((a, b) => b.timestamp - a.timestamp);
366
+ for (const p of prompts) histConvItems.push({ k: 'u', ts: p.timestamp, a: p.text, b: '' });
367
+ for (const t of toolCalls) histConvItems.push({ k: 't', ts: t.timestamp, a: t.toolName, b: t.toolInputSummary });
368
+ for (const r of responses) histConvItems.push({ k: 'c', ts: r.timestamp, a: r.textExcerpt, b: '' });
369
+ histConvItems.sort((a, b) => b.ts - a.ts);
370
+ const CONV_CFG = { u: ['conv-user', 'USER'], t: ['conv-tool', 'TOOL'], c: ['conv-claude', 'CLAUDE'] };
343
371
  const histConvContainer = document.getElementById('detail-conversation');
344
372
  histConvContainer.innerHTML = histConvItems.length > 0
345
- ? histConvItems.map(item => {
346
- if (item.type === 'user') {
347
- return `<div class="conv-entry conv-user">
348
- <div class="conv-header"><span class="conv-role">USER</span><span class="conv-time">${formatTime(item.timestamp)}</span><button class="conv-copy" title="Copy">COPY</button></div>
349
- <div class="conv-text">${escapeHtml(item.text)}</div>
350
- </div>`;
351
- } else if (item.type === 'tool') {
352
- return `<div class="conv-entry conv-tool">
353
- <div class="conv-header"><span class="conv-role">TOOL</span><span class="conv-time">${formatTime(item.timestamp)}</span><button class="conv-copy" title="Copy">COPY</button></div>
354
- <span class="conv-tool-name">${escapeHtml(item.tool)}</span>
355
- <span class="conv-tool-input">${escapeHtml(item.input)}</span>
356
- </div>`;
357
- } else {
358
- return `<div class="conv-entry conv-claude">
359
- <div class="conv-header"><span class="conv-role">CLAUDE</span><span class="conv-time">${formatTime(item.timestamp)}</span><button class="conv-copy" title="Copy">COPY</button></div>
360
- <div class="conv-text">${escapeHtml(item.text)}</div>
361
- </div>`;
362
- }
373
+ ? histConvItems.map(h => {
374
+ const [cls, role] = CONV_CFG[h.k];
375
+ const header = `<div class="conv-header"><span class="conv-role">${role}</span><span class="conv-time">${formatTime(h.ts)}</span><button class="conv-copy" title="Copy">COPY</button></div>`;
376
+ return h.k === 't'
377
+ ? `<div class="conv-entry ${cls}">${header}<span class="conv-tool-name">${escapeHtml(h.a)}</span><span class="conv-tool-input">${escapeHtml(h.b)}</span></div>`
378
+ : `<div class="conv-entry ${cls}">${header}<div class="conv-text">${escapeHtml(h.a)}</div></div>`;
363
379
  }).join('')
364
380
  : '<div class="tab-empty">No conversation recorded</div>';
365
381
 
366
- // Populate activity tab
367
- const histActivityItems = [];
368
- for (const t of (data.tool_calls || [])) {
369
- histActivityItems.push({ kind: 'tool', tool: t.toolName, input: t.toolInputSummary, timestamp: t.timestamp });
370
- }
371
- for (const e of (data.events || [])) {
372
- histActivityItems.push({ kind: 'event', type: e.eventType, detail: e.detail, timestamp: e.timestamp });
373
- }
374
- for (const r of (data.responses || [])) {
375
- histActivityItems.push({ kind: 'response', text: r.textExcerpt || r.text, timestamp: r.timestamp });
376
- }
377
- histActivityItems.sort((a, b) => b.timestamp - a.timestamp);
382
+ // Populate activity tab — reuse raw arrays, no intermediate copies
378
383
  const actEl = document.getElementById('detail-activity-log');
379
384
  if (actEl) {
380
- actEl.innerHTML = histActivityItems.length > 0
381
- ? histActivityItems.map(item => {
382
- if (item.kind === 'tool') {
383
- return `<div class="activity-entry activity-tool">
384
- <span class="activity-time">${formatTime(item.timestamp)}</span>
385
- <span class="activity-badge activity-badge-tool">${escapeHtml(item.tool)}</span>
386
- <span class="activity-detail">${escapeHtml(item.input)}</span>
387
- </div>`;
388
- } else if (item.kind === 'response') {
389
- return `<div class="activity-entry activity-response">
390
- <span class="activity-time">${formatTime(item.timestamp)}</span>
391
- <span class="activity-badge activity-badge-response">RESPONSE</span>
392
- <span class="activity-detail">${escapeHtml(item.text)}</span>
393
- </div>`;
394
- } else {
395
- return `<div class="activity-entry activity-event">
396
- <span class="activity-time">${formatTime(item.timestamp)}</span>
397
- <span class="activity-badge activity-badge-event">${escapeHtml(item.type)}</span>
398
- <span class="activity-detail">${escapeHtml(item.detail)}</span>
399
- </div>`;
400
- }
401
- }).join('')
385
+ const actItems = [];
386
+ for (const t of toolCalls) actItems.push({ k: 't', ts: t.timestamp, a: t.toolName, b: t.toolInputSummary });
387
+ for (const e of events) actItems.push({ k: 'e', ts: e.timestamp, a: e.eventType, b: e.detail });
388
+ for (const r of responses) actItems.push({ k: 'r', ts: r.timestamp, a: 'RESPONSE', b: r.textExcerpt || r.text });
389
+ actItems.sort((a, b) => b.ts - a.ts);
390
+ const ACT_CLS = { t: 'tool', e: 'event', r: 'response' };
391
+ actEl.innerHTML = actItems.length > 0
392
+ ? actItems.map(h => `<div class="activity-entry activity-${ACT_CLS[h.k]}">
393
+ <span class="activity-time">${formatTime(h.ts)}</span>
394
+ <span class="activity-badge activity-badge-${ACT_CLS[h.k]}">${escapeHtml(h.a)}</span>
395
+ <span class="activity-detail">${escapeHtml(h.b)}</span>
396
+ </div>`).join('')
402
397
  : '<div class="tab-empty">No activity recorded</div>';
403
398
  }
404
399
 
@@ -492,6 +487,10 @@ export function initDetailPanelHandlers() {
492
487
  const showBtn = !!(session.terminalId || session.lastTerminalId || session.status === 'ended');
493
488
  rbtn.classList.toggle('hidden', !showBtn);
494
489
  }
490
+ // Show external source hint when no terminal is attached
491
+ if (session && !session.terminalId) {
492
+ _showExternalSourceHint(session);
493
+ }
495
494
  }
496
495
  }
497
496
  });
@@ -1,4 +1,5 @@
1
1
  import { escapeHtml as _escapeHtml, formatDuration as _formatDuration, formatTime as _formatTime, sanitizeColor } from './utils.js';
2
+ import { switchTo } from './navController.js';
2
3
 
3
4
  let currentPage = 1;
4
5
  let debounceTimer = null;
@@ -73,19 +74,7 @@ async function loadSessions() {
73
74
 
74
75
  const result = await apiFetch(`/api/db/sessions?${params}`);
75
76
 
76
- // DB returns snake_case fields directly
77
- const mapped = result.sessions.map(s => ({
78
- id: s.id,
79
- title: s.title || '',
80
- project_name: s.project_name || '',
81
- started_at: s.started_at,
82
- ended_at: s.ended_at,
83
- status: s.status,
84
- total_prompts: s.total_prompts || 0,
85
- total_tool_calls: s.total_tool_calls || 0,
86
- git_branch: '',
87
- }));
88
- renderResults(mapped, result.total, result.page, result.pageSize);
77
+ renderResults(result.sessions, result.total, result.page, result.pageSize);
89
78
  }
90
79
 
91
80
  function renderResults(sessions, total, page, pageSize) {
@@ -104,15 +93,16 @@ function renderResults(sessions, total, page, pageSize) {
104
93
  year: 'numeric', month: 'short', day: 'numeric',
105
94
  hour: '2-digit', minute: '2-digit', hour12: false,
106
95
  });
107
- return `<div class="history-row" data-session-id="${s.id}">
108
- <span class="history-title">${escapeHtml(s.title)}</span>
109
- <span class="history-project">${escapeHtml(s.project_name)}</span>
96
+ return `<div class="history-row" data-session-id="${s.id}" data-project-path="${escapeHtml(s.project_path || '')}">
97
+ <span class="history-title">${escapeHtml(s.title || '')}</span>
98
+ <span class="history-project">${escapeHtml(s.project_name || '')}</span>
110
99
  <span class="history-date">${date}</span>
111
100
  <span class="history-duration">${duration}</span>
112
101
  <span class="history-status ${s.status}">${s.status.toUpperCase()}</span>
113
- <span class="history-prompts">${s.total_prompts} prompts</span>
114
- <span class="history-tools">${s.total_tool_calls} tools</span>
102
+ <span class="history-prompts">${s.total_prompts || 0} prompts</span>
103
+ <span class="history-tools">${s.total_tool_calls || 0} tools</span>
115
104
  <span class="history-branch">${escapeHtml(s.git_branch || '')}</span>
105
+ <button class="history-resume" title="Resume session">&#9654;</button>
116
106
  <button class="history-delete" title="Delete session">&times;</button>
117
107
  </div>`;
118
108
  }).join('');
@@ -120,11 +110,44 @@ function renderResults(sessions, total, page, pageSize) {
120
110
  // Click handler for rows
121
111
  container.querySelectorAll('.history-row').forEach(row => {
122
112
  row.addEventListener('click', (e) => {
123
- if (e.target.closest('.history-delete')) return;
113
+ if (e.target.closest('.history-delete') || e.target.closest('.history-resume')) return;
124
114
  openHistoryDetail(row.dataset.sessionId);
125
115
  });
126
116
  });
127
117
 
118
+ // Resume button handler — create terminal with claude --resume
119
+ container.querySelectorAll('.history-resume').forEach(btn => {
120
+ btn.addEventListener('click', async (e) => {
121
+ e.stopPropagation();
122
+ const row = btn.closest('.history-row');
123
+ const sid = row.dataset.sessionId;
124
+ if (!sid || !/^[a-zA-Z0-9_-]+$/.test(sid)) return;
125
+ const projectPath = row.dataset.projectPath || '';
126
+ btn.disabled = true;
127
+ btn.textContent = '...';
128
+ try {
129
+ const body = { command: `claude --resume ${sid}` };
130
+ if (projectPath) body.workingDir = projectPath;
131
+ const resp = await fetch('/api/terminals', {
132
+ method: 'POST',
133
+ headers: { 'Content-Type': 'application/json' },
134
+ body: JSON.stringify(body),
135
+ });
136
+ const result = await resp.json();
137
+ if (result.ok) {
138
+ switchTo('live');
139
+ } else {
140
+ alert(result.error || 'Failed to resume session');
141
+ }
142
+ } catch (err) {
143
+ alert('Resume error: ' + err.message);
144
+ } finally {
145
+ btn.disabled = false;
146
+ btn.textContent = '\u25B6';
147
+ }
148
+ });
149
+ });
150
+
128
151
  // Delete button handler — delete from server DB
129
152
  container.querySelectorAll('.history-delete').forEach(btn => {
130
153
  btn.addEventListener('click', async (e) => {
@@ -196,8 +219,8 @@ async function openHistoryDetail(sessionId) {
196
219
 
197
220
  const sess = data.session;
198
221
  const prompts = data.prompts || [];
199
- const responses = (data.responses || []).map(r => ({ ...r, text: r.text_excerpt || r.text || '' }));
200
- const tools = (data.tool_calls || []).map(t => ({ tool: t.tool_name, input: t.tool_input_summary || '', timestamp: t.timestamp }));
222
+ const responses = data.responses || [];
223
+ const tools = data.tool_calls || [];
201
224
  const events = data.events || [];
202
225
 
203
226
  // Populate header
@@ -236,47 +259,30 @@ async function openHistoryDetail(sessionId) {
236
259
  });
237
260
  }
238
261
 
239
- // Conversation tab (interleaved prompts + responses)
262
+ // Conversation tab (interleaved prompts + responses — single pass)
240
263
  const convoEl = document.getElementById('detail-conversation');
241
- const allEntries = [
242
- ...prompts.map(p => ({ type: 'prompt', timestamp: p.timestamp, text: p.text })),
243
- ...responses.map(r => ({ type: 'response', timestamp: r.timestamp, text: r.text })),
244
- ].sort((a, b) => a.timestamp - b.timestamp);
245
- convoEl.innerHTML = allEntries.map(e => {
246
- const cls = e.type === 'prompt' ? 'prompt-entry' : 'response-entry';
247
- return `<div class="${cls}">
264
+ const allEntries = [];
265
+ for (const p of prompts) allEntries.push({ type: 'prompt', timestamp: p.timestamp, text: p.text });
266
+ for (const r of responses) allEntries.push({ type: 'response', timestamp: r.timestamp, text: r.text_excerpt || r.text || '' });
267
+ allEntries.sort((a, b) => a.timestamp - b.timestamp);
268
+ convoEl.innerHTML = allEntries.map(e => `<div class="${e.type}-entry">
248
269
  <span class="${e.type}-time">${formatTime(e.timestamp)}</span>
249
270
  <div class="${e.type}-text">${escapeHtml(e.text)}</div>
250
- </div>`;
251
- }).join('');
271
+ </div>`).join('');
252
272
 
253
- // Activity tab (merged tool calls + events)
254
- const histItems = [];
255
- for (const t of tools) {
256
- histItems.push({ kind: 'tool', tool: t.tool, input: t.input, timestamp: t.timestamp });
257
- }
258
- for (const e of events) {
259
- histItems.push({ kind: 'event', type: e.event_type, detail: e.detail, timestamp: e.timestamp });
260
- }
261
- histItems.sort((a, b) => b.timestamp - a.timestamp);
273
+ // Activity tab (merged tool calls + events — tag in-place, sort once, render)
262
274
  const actEl = document.getElementById('detail-activity-log');
263
275
  if (actEl) {
276
+ const histItems = [];
277
+ for (const t of tools) histItems.push({ k: 't', ts: t.timestamp, a: t.tool_name, b: t.tool_input_summary || '' });
278
+ for (const e of events) histItems.push({ k: 'e', ts: e.timestamp, a: e.event_type, b: e.detail });
279
+ histItems.sort((a, b) => b.ts - a.ts);
264
280
  actEl.innerHTML = histItems.length > 0
265
- ? histItems.map(item => {
266
- if (item.kind === 'tool') {
267
- return `<div class="activity-entry activity-tool">
268
- <span class="activity-time">${formatTime(item.timestamp)}</span>
269
- <span class="activity-badge activity-badge-tool">${escapeHtml(item.tool)}</span>
270
- <span class="activity-detail">${escapeHtml(item.input)}</span>
271
- </div>`;
272
- } else {
273
- return `<div class="activity-entry activity-event">
274
- <span class="activity-time">${formatTime(item.timestamp)}</span>
275
- <span class="activity-badge activity-badge-event">${escapeHtml(item.type)}</span>
276
- <span class="activity-detail">${escapeHtml(item.detail)}</span>
277
- </div>`;
278
- }
279
- }).join('')
281
+ ? histItems.map(h => `<div class="activity-entry activity-${h.k === 't' ? 'tool' : 'event'}">
282
+ <span class="activity-time">${formatTime(h.ts)}</span>
283
+ <span class="activity-badge activity-badge-${h.k === 't' ? 'tool' : 'event'}">${escapeHtml(h.a)}</span>
284
+ <span class="activity-detail">${escapeHtml(h.b)}</span>
285
+ </div>`).join('')
280
286
  : '<div class="tab-empty">No activity recorded</div>';
281
287
  }
282
288