/** * @fileoverview Subagent panel (discovery, detail view, kill), subagent parent tracking, * agent teams (tasks panel, teammate badges, terminals), project insights (bash tool tracking), * file browser (directory tree, preview), log viewer (floating file streamers), * image popups (auto-popup for screenshots), mux sessions, monitor panel, * token statistics, toast notifications, and system stats. * Includes 19 SSE handlers for tasks, mux, bash tools, subagents, and images. * * @mixin Extends CodemanApp.prototype via Object.assign * @dependency app.js (CodemanApp class, this.subagents, this.subagentWindows, this.sessions) * @dependency constants.js (escapeHtml, ZINDEX_* constants) * @dependency subagent-windows.js (openSubagentWindow, closeSubagentWindow) * @loadorder 11 of 15 — loaded after settings-ui.js, before session-ui.js */ const AWAY_DIGEST_LAST_VIEWED_KEY = 'codeman-away-digest-last-viewed'; const FILE_BROWSER_SHOW_HIDDEN_KEY = 'codeman:fileBrowserShowHidden'; // Bounds for the by-id text preview, mirroring what the workspace text preview // already does server-side (500 lines). The byte cap rides a Range request, so // a huge log is a partial read rather than a download the viewer throws away. const TEXT_PREVIEW_MAX_BYTES = 512 * 1024; const TEXT_PREVIEW_MAX_LINES = 500; const AWAY_DIGEST_SECTIONS = [ ['needsAttention', 'Needs Attention'], ['completed', 'Completed'], ['stillRunning', 'Still Running'], ['idle', 'Idle'], ['informational', 'Informational'], ]; Object.assign(CodemanApp.prototype, { _addActivityEntry(agentId, entry, maxSize = 50) { const activity = this.subagentActivity.get(agentId) || []; activity.push(entry); if (activity.length > maxSize) activity.shift(); this.subagentActivity.set(agentId, activity); }, // Tasks _onTaskCreated(data) { this.renderSessionTabs(); if (data.sessionId === this.activeSessionId) { this.renderTaskPanel(); } }, _onTaskCompleted(data) { this.renderSessionTabs(); if (data.sessionId === this.activeSessionId) { this.renderTaskPanel(); } }, _onTaskFailed(data) { this.renderSessionTabs(); if (data.sessionId === this.activeSessionId) { this.renderTaskPanel(); } }, _onTaskUpdated(data) { if (data.sessionId === this.activeSessionId) { this.renderTaskPanel(); } }, // Mux (tmux) _onMuxCreated(data) { this.muxSessions.push(data); this.renderMuxSessions(); }, _onMuxKilled(data) { this.muxSessions = this.muxSessions.filter(s => s.sessionId !== data.sessionId); this.renderMuxSessions(); }, _onMuxDied(data) { this.muxSessions = this.muxSessions.filter(s => s.sessionId !== data.sessionId); this.renderMuxSessions(); this.showToast('Mux session died: ' + this.getShortId(data.sessionId), 'warning'); }, _onMuxStatsUpdated(data) { this.muxSessions = data; if (document.getElementById('monitorPanel').classList.contains('open')) { this.renderMuxSessions(); } }, // Remote auto-reconnect (COD-108) _onRemoteSessionReconnected(data) { const id = this.getShortId(data.sessionId); this.showToast(`Remote session ${id} reconnected`, 'success'); }, _onRemoteReconnectExhausted(data) { const sessionId = data.sessionId; const id = this.getShortId(sessionId); // Auto-reconnect gave up after the bounded backoff. Surface a manual // "Reconnect" affordance that re-triggers the attach path (force-reload the // session, which re-runs the create/attach flow against the durable remote). this.showToast(`Remote session ${id} dropped — auto-reconnect gave up`, 'error', { duration: 15000, action: { label: 'Reconnect', onClick: () => { if (this.sessions && this.sessions.has(sessionId)) { this.selectSession(sessionId, { forceReload: true }); } else { this.showToast('Session no longer available', 'warning'); } }, }, }); }, // Bash tools _onBashToolStart(data) { this.handleBashToolStart(data.sessionId, data.tool); }, _onBashToolEnd(data) { this.handleBashToolEnd(data.sessionId, data.tool); }, _onBashToolsUpdate(data) { this.handleBashToolsUpdate(data.sessionId, data.tools); }, // Subagents (Claude Code background agents) _onSubagentDiscovered(data) { // Clear all old data for this agentId (in case of ID reuse) this.subagents.set(data.agentId, data); this.subagentActivity.set(data.agentId, []); this.subagentToolResults.delete(data.agentId); // Close any existing window for this agentId (will be reopened fresh) if (this.subagentWindows.has(data.agentId)) { this.forceCloseSubagentWindow(data.agentId); } this.renderSubagentPanel(); // Find which Codeman session owns this subagent (direct claudeSessionId match only) this.findParentSessionForSubagent(data.agentId); // Subagent windows are no longer auto-opened on discovery. // Users can open them manually from the monitor panel or via openAllActiveSubagentWindows(). // Ensure connection lines are updated after window is created and DOM settles requestAnimationFrame(() => { this.updateConnectionLines(); }); // Notify about new subagent discovery const parentId = this.subagentParentMap.get(data.agentId); this._notifySession(parentId || data.sessionId, 'info', 'subagent-spawn', 'Subagent Spawned', data.description || 'New background agent started'); }, _onSubagentUpdated(data) { const existing = this.subagents.get(data.agentId); if (existing) { // Merge updated fields (especially description) Object.assign(existing, data); this.subagents.set(data.agentId, existing); } else { this.subagents.set(data.agentId, data); } this.renderSubagentPanel(); // Update floating window if open (content + header/title) if (this.subagentWindows.has(data.agentId)) { this.renderSubagentWindowContent(data.agentId); this.updateSubagentWindowHeader(data.agentId); } }, _onSubagentToolCall(data) { this._addActivityEntry(data.agentId, { type: 'tool', ...data }); if (this.activeSubagentId === data.agentId) { this.renderSubagentDetail(); } this.renderSubagentPanel(); // Update floating window (debounced — tool_call events fire rapidly) if (this.subagentWindows.has(data.agentId)) { this.scheduleSubagentWindowRender(data.agentId); } }, _onSubagentProgress(data) { this._addActivityEntry(data.agentId, { type: 'progress', ...data }); if (this.activeSubagentId === data.agentId) { this.renderSubagentDetail(); } // Update floating window (debounced) if (this.subagentWindows.has(data.agentId)) { this.scheduleSubagentWindowRender(data.agentId); } }, _onSubagentMessage(data) { this._addActivityEntry(data.agentId, { type: 'message', ...data }); if (this.activeSubagentId === data.agentId) { this.renderSubagentDetail(); } // Update floating window (debounced) if (this.subagentWindows.has(data.agentId)) { this.scheduleSubagentWindowRender(data.agentId); } }, _onSubagentToolResult(data) { // Store tool result by toolUseId for later lookup (cap at 50 per agent) if (!this.subagentToolResults.has(data.agentId)) { this.subagentToolResults.set(data.agentId, new Map()); } const resultsMap = this.subagentToolResults.get(data.agentId); resultsMap.set(data.toolUseId, data); if (resultsMap.size > 50) { const oldest = resultsMap.keys().next().value; resultsMap.delete(oldest); } // Add to activity stream this._addActivityEntry(data.agentId, { type: 'tool_result', ...data }); if (this.activeSubagentId === data.agentId) { this.renderSubagentDetail(); } // Update floating window (debounced) if (this.subagentWindows.has(data.agentId)) { this.scheduleSubagentWindowRender(data.agentId); } }, async _onSubagentCompleted(data) { const existing = this.subagents.get(data.agentId); if (existing) { existing.status = 'completed'; this.subagents.set(data.agentId, existing); } this.renderSubagentPanel(); this.updateSubagentWindows(); // Auto-minimize completed subagent windows if (this.subagentWindows.has(data.agentId)) { const windowData = this.subagentWindows.get(data.agentId); if (windowData && !windowData.minimized) { await this.closeSubagentWindow(data.agentId); // This minimizes to tab this.saveSubagentWindowStates(); // Persist the minimized state } } // Notify about subagent completion const parentId = this.subagentParentMap.get(data.agentId); this._notifySession(parentId || existing?.sessionId || data.sessionId, 'info', 'subagent-complete', 'Subagent Completed', existing?.description || data.description || 'Background agent finished'); // Clean up activity/tool data for completed agents after 5 minutes // This prevents memory leaks from long-running sessions with many subagents setTimeout(() => { const agent = this.subagents.get(data.agentId); // Only clean up if agent is still completed (not restarted) if (agent?.status === 'completed') { this.subagentActivity.delete(data.agentId); this.subagentToolResults.delete(data.agentId); } }, 5 * 60 * 1000); // 5 minutes // Prune stale completed agents from main maps after 30 minutes // Keeps subagents/subagentParentMap from growing unbounded in 24h sessions setTimeout(() => { const agent = this.subagents.get(data.agentId); if (agent?.status === 'completed' && !this.subagentWindows.has(data.agentId)) { this.subagents.delete(data.agentId); this.subagentParentMap.delete(data.agentId); } }, 30 * 60 * 1000); // 30 minutes }, // Images _onImageDetected(data) { console.log('[Image Detected]', data); this.openImagePopup(data); }, // ═══════════════════════════════════════════════════════════════ // Command Palette (COD-153) // Fast Cmd/Ctrl+K switcher for currently open sessions, plus launch-new. // ═══════════════════════════════════════════════════════════════ shouldOpenCommandPaletteFromShortcut(e) { if (!e) return false; // Every palette chord requires Ctrl/Cmd/Alt (capture enforces the same for // rebinds), so plain typing exits before any registry work — this runs on // the document AND xterm keydown hot paths. if (!e.ctrlKey && !e.metaKey && !e.altKey) return false; // Registry-aware chord check (COD-157): honors a rebound or disabled // palette shortcut. Falls back to the default Ctrl/Cmd/Alt+K chord when the // registry isn't available (isolated test harnesses). const registryAvailable = typeof this.getShortcutRegistry === 'function' && typeof this.matchesShortcutEvent === 'function'; const palette = registryAvailable ? this.getShortcutRegistry().find((s) => s.id === 'command-palette') : null; if (palette) { if (palette.disabled || !this.matchesShortcutEvent(e, palette)) return false; } else { const key = (e.key || '').toLowerCase(); if (key !== 'k' && e.code !== 'KeyK') return false; // Don't hijack chords with extra modifiers (Ctrl+Shift+K is the Firefox // devtools console; matchesShortcutEvent applies the same rule above). if (e.shiftKey) return false; } const target = e.target; if (!target) return true; const tagName = (target.tagName || '').toUpperCase(); const className = typeof target.className === 'string' ? target.className : ''; const isXtermHelper = target.classList?.contains?.('xterm-helper-textarea') || className.includes('xterm-helper-textarea'); if (isXtermHelper) return true; if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') return false; if (target.isContentEditable) return false; if (typeof target.closest === 'function' && target.closest('[contenteditable="true"]')) return false; return true; }, openCommandPalette() { const modal = document.getElementById('commandPaletteModal'); const search = document.getElementById('commandPaletteSearch'); if (!modal || !search) return; this.commandPaletteActiveIndex = 0; search.value = ''; modal.classList.add('active'); this._wireCommandPalette(); this.renderCommandPalette(); search.focus(); search.select?.(); }, closeCommandPalette() { const modal = document.getElementById('commandPaletteModal'); if (modal) modal.classList.remove('active'); }, _wireCommandPalette() { if (this._commandPaletteWired) return; this._commandPaletteWired = true; const modal = document.getElementById('commandPaletteModal'); const search = document.getElementById('commandPaletteSearch'); const list = document.getElementById('commandPaletteList'); search?.addEventListener('input', () => { this.commandPaletteActiveIndex = 0; this.renderCommandPalette(); }); search?.addEventListener('keydown', async (e) => { if (e.key === 'ArrowDown') { e.preventDefault(); this.moveCommandPaletteSelection(1); return; } if (e.key === 'ArrowUp') { e.preventDefault(); this.moveCommandPaletteSelection(-1); return; } if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); await this.activateCommandPaletteItem(); return; } if (e.key === 'Escape') { e.preventDefault(); this.closeCommandPalette(); } }); modal?.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); this.closeCommandPalette(); } }); list?.addEventListener?.('click', (e) => { const row = e.target?.closest?.('[data-command-index]'); if (!row) return; this.commandPaletteActiveIndex = Number(row.dataset.commandIndex) || 0; void this.activateCommandPaletteItem(); }); }, buildCommandPaletteItems(query = '') { const needle = query.trim().toLowerCase(); const orderedIds = [ ...(Array.isArray(this.sessionOrder) ? this.sessionOrder : []), ...Array.from(this.sessions?.keys?.() || []).filter((id) => !this.sessionOrder?.includes?.(id)), ]; const seen = new Set(); const sessionItems = []; for (const sessionId of orderedIds) { if (seen.has(sessionId)) continue; seen.add(sessionId); const session = this.sessions?.get?.(sessionId); if (!session) continue; const title = this.getSessionName?.(session) || session.name || session.title || sessionId.slice(0, 8); const subtitleParts = [session.workingDir, session.mode, session.status].filter(Boolean); const haystack = [title, session.workingDir, session.mode, session.status, sessionId].filter(Boolean).join(' ').toLowerCase(); if (needle && !haystack.includes(needle)) continue; sessionItems.push({ id: `session:${sessionId}`, type: 'session', sessionId, title, subtitle: subtitleParts.join(' · '), }); } sessionItems.push(this._buildCommandPaletteNewSessionItem(query)); sessionItems.push({ id: 'browse-sessions', type: 'browse-sessions', title: 'Browse all sessions…', subtitle: 'Open Session Manager' }); return sessionItems; }, _buildCommandPaletteNewSessionItem(query = '') { const mode = this.runMode || this._runMode || 'claude'; const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi' }; const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase'; return { id: 'new-session', type: 'new-session', caseName, title: 'New session', subtitle: `Run ${labels[mode] || mode} in ${caseName}`, }; }, _findCommandPaletteCaseMatch(query = '') { const needle = query.trim().toLowerCase(); if (!needle || !Array.isArray(this.cases)) return null; const scoreCase = (caseItem) => { const name = String(caseItem?.name || '').trim(); if (!name) return 0; const haystack = [ name, caseItem?.path, caseItem?.casePath, caseItem?.workingDir, caseItem?.remote?.path, caseItem?.remote?.hostId, ] .filter(Boolean) .join(' ') .toLowerCase(); const lowerName = name.toLowerCase(); if (lowerName === needle) return 100; if (lowerName.startsWith(needle)) return 90; if (lowerName.includes(needle)) return 80; if (haystack.includes(needle)) return 60; return 0; }; let best = null; let bestScore = 0; for (const caseItem of this.cases) { const score = scoreCase(caseItem); if (score > bestScore) { best = caseItem; bestScore = score; } } return best?.name || null; }, renderCommandPalette() { const search = document.getElementById('commandPaletteSearch'); const list = document.getElementById('commandPaletteList'); if (!list) return; const query = search?.value || ''; const items = this.buildCommandPaletteItems(query); this.commandPaletteItems = items; this.commandPaletteActiveIndex = Math.max(0, Math.min(this.commandPaletteActiveIndex || 0, items.length - 1)); list.innerHTML = items .map((item, index) => { const active = index === this.commandPaletteActiveIndex ? ' active' : ''; const icon = item.type === 'new-session' ? '+' : item.type === 'browse-sessions' ? '≡' : '›'; const browse = item.type === 'browse-sessions' ? ' command-palette-item--browse' : ''; return ` `; }) .join(''); }, moveCommandPaletteSelection(delta) { const items = this.commandPaletteItems || this.buildCommandPaletteItems(document.getElementById('commandPaletteSearch')?.value || ''); if (!items.length) return; this.commandPaletteActiveIndex = (this.commandPaletteActiveIndex + delta + items.length) % items.length; this.renderCommandPalette(); }, async activateCommandPaletteItem(index = this.commandPaletteActiveIndex || 0) { const item = (this.commandPaletteItems || [])[index]; if (!item) return; this.closeCommandPalette(); if (item.type === 'session' && item.sessionId) { await this.selectSession(item.sessionId); return; } if (item.type === 'browse-sessions') { this.openSessionManager(); return; } if (item.type === 'new-session') { const caseSelect = document.getElementById('quickStartCase'); if (caseSelect && item.caseName) { if ( caseSelect.tagName === 'SELECT' && typeof caseSelect.appendChild === 'function' && !Array.from(caseSelect.options || []).some((option) => option.value === item.caseName) ) { const option = document.createElement('option'); option.value = item.caseName; option.textContent = item.caseName; caseSelect.appendChild(option); } // selectQuickStartCase keeps the searchable combobox, dir display, and // persisted last-used case in sync with the palette's pick (COD-151); // fall back to a bare value set when the picker mixin isn't loaded. if (typeof this.selectQuickStartCase === 'function') { this.selectQuickStartCase(item.caseName); } else { caseSelect.value = item.caseName; } } await this.run(); } }, // ═══════════════════════════════════════════════════════════════ // Session Manager Modal (COD-121) // Unified session list (GET /api/sessions/unified) reachable mid-session, // with a server-side search box. Reuses the history item renderer; clicking // a live row switches to it, a history row resumes the conversation. // ═══════════════════════════════════════════════════════════════ async openSessionManager() { const modal = document.getElementById('sessionManagerModal'); if (modal) { modal.classList.add('active'); // Escape closes the modal even while focus is in the search input. A // modal-scoped listener is robust regardless of the global Escape chain // (which runs other close handlers first and can short-circuit). Wire once. if (!this._sessionManagerEscWired) { this._sessionManagerEscWired = true; modal.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); this.closeSessionManager(); } }); } } // Ensure cases are loaded so item subtitles can show "#caseName" labels. // Mirror loadHistorySessions(): prefer already-loaded this.cases. if (!Array.isArray(this.cases) || this.cases.length === 0) { try { const r = await fetch('/api/cases'); const d = r.ok ? await r.json() : null; this.cases = d?.data || []; } catch { this.cases = this.cases || []; } } const search = document.getElementById('sessionManagerSearch'); if (search) { // Wire the debounced search input once (lazy — the element exists by // the time the modal is first opened, and mixin methods are bound). if (!this._sessionManagerSearchWired) { this._sessionManagerSearchWired = true; search.addEventListener('input', () => { const value = search.value.trim(); this._debouncedCall('sessionManagerSearch', () => this._loadSessionManagerList(value), 200); }); } search.value = ''; search.focus(); } await this._loadSessionManagerList(''); }, closeSessionManager() { const modal = document.getElementById('sessionManagerModal'); if (modal) modal.classList.remove('active'); }, /** Replace the Session Manager list body with a single status line. */ _setSessionManagerMessage(list, message) { list.replaceChildren(); const line = document.createElement('p'); line.className = 'empty-message'; line.textContent = message; list.appendChild(line); }, async _loadSessionManagerList(q = '') { this._sessionManagerQuery = q; const list = document.getElementById('sessionManagerList'); if (!list) return; try { const url = '/api/sessions/unified?limit=200' + (q ? '&q=' + encodeURIComponent(q) : ''); const res = await fetch(url); const data = await res.json().catch(() => null); // ApiResponse envelope: { success: true, data: { sessions, total } }. // Surface failures instead of rendering them as an empty result set. if (!res.ok || !data || data.success === false || !data.data) { this._setSessionManagerMessage(list, data?.error || `Failed to load sessions (HTTP ${res.status})`); return; } const sessions = data.data.sessions || []; list.replaceChildren(); if (sessions.length === 0) { this._setSessionManagerMessage(list, q ? 'No sessions match your search' : 'No sessions found'); return; } for (const s of sessions) { // Adapt UnifiedSessionItem (lastActivityAt epoch-ms, optional fields) to // the history-record shape _buildHistoryItem renders (lastModified date // string, sizeBytes, firstPrompt). const record = { sessionId: s.sessionId, workingDir: s.workingDir || '', sizeBytes: s.sizeBytes ?? 0, lastModified: new Date(s.lastActivityAt ?? s.createdAt ?? Date.now()).toISOString(), firstPrompt: s.firstPrompt || s.name || '', // Must be carried explicitly: this record is a re-projection, so any // field omitted here silently vanishes from the Cmd+K list (#266). gitBranch: s.gitBranch, worktreeName: s.worktreeName, worktreeRepo: s.worktreeRepo, }; const isLive = !!this.sessions?.has?.(s.sessionId); const item = this._buildHistoryItem(record, this.cases, { showViewAll: false, onActivate: () => { this.closeSessionManager(); if (isLive) { void this.selectSession(s.sessionId); } else if (record.workingDir) { // History rows are keyed by the Claude conversation UUID; resumed // sessions carry theirs separately as claudeSessionId. void this.resumeHistorySession(s.claudeSessionId || s.sessionId, record.workingDir); } }, }); list.appendChild(item); } } catch (err) { console.error('[_loadSessionManagerList]', err); this._setSessionManagerMessage(list, 'Failed to load sessions'); } }, // ═══════════════════════════════════════════════════════════════ // Away Digest Modal // ═══════════════════════════════════════════════════════════════ async openAwayDigest(range = 'since-last-visit') { this.awayDigestRange = range; this._awayDigestLoadedSuccessfully = false; this._awayDigestSinceLastVisitGeneratedAt = undefined; const modal = document.getElementById('awayDigestModal'); if (modal) modal.classList.add('active'); this.updateAwayDigestRangeControls(); await this.loadAwayDigest(); }, closeAwayDigest() { const modal = document.getElementById('awayDigestModal'); const generatedAt = this._awayDigestSinceLastVisitGeneratedAt; if (Number.isFinite(generatedAt)) { try { localStorage.setItem(AWAY_DIGEST_LAST_VIEWED_KEY, String(generatedAt)); } catch (err) { console.warn('Failed to save away digest last-viewed marker:', err); } } if (modal) modal.classList.remove('active'); }, /** * COD-121: live-refresh the unified session list when sessions change * (created/updated/deleted via SSE). Only touches surfaces that are currently * showing — the open Session Manager modal and/or the visible welcome list — * and is debounced so an event burst collapses into one re-fetch. The current * search query is preserved. */ _onSessionListMaybeChanged() { const modal = document.getElementById('sessionManagerModal'); if (modal && modal.classList.contains('active')) { this._debouncedCall( 'sessionManagerRefresh', () => this._loadSessionManagerList(this._sessionManagerQuery || ''), 400 ); } const welcome = document.getElementById('welcomeOverlay'); if (welcome && welcome.classList.contains('visible')) { this._debouncedCall('welcomeHistoryRefresh', () => this.loadHistorySessions(), 600); } }, setAwayDigestRange(range) { this.awayDigestRange = range; this._awayDigestLoadedSuccessfully = false; this.updateAwayDigestRangeControls(); this.loadAwayDigest(); }, updateAwayDigestRangeControls() { const range = this.awayDigestRange || 'since-last-visit'; document.querySelectorAll('[data-away-range]').forEach(btn => { btn.classList.toggle('active', btn.dataset.awayRange === range); }); const customRange = document.getElementById('awayDigestCustomRange'); if (customRange) customRange.classList.toggle('active', range === 'custom'); if (range === 'custom') this.ensureAwayDigestCustomDefaults(); }, ensureAwayDigestCustomDefaults() { const sinceInput = document.getElementById('awayDigestCustomSince'); const untilInput = document.getElementById('awayDigestCustomUntil'); if (!sinceInput || !untilInput) return; const now = new Date(); if (!untilInput.value) untilInput.value = this.formatAwayDigestDateTimeLocal(now); if (!sinceInput.value) { const since = new Date(now.getTime() - 60 * 60 * 1000); sinceInput.value = this.formatAwayDigestDateTimeLocal(since); } }, formatAwayDigestDateTimeLocal(date) { const pad = value => String(value).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; }, async loadAwayDigest() { const summaryEl = document.getElementById('awayDigestSummary'); const freshnessEl = document.getElementById('awayDigestFreshness'); const sectionsEl = document.getElementById('awayDigestSections'); if (summaryEl) summaryEl.innerHTML = '
${escapeHtml(content)}
`);
} catch (err) {
console.error('Failed to load transcript:', err);
alert('Failed to load transcript: ' + err.message);
}
},
// ═══════════════════════════════════════════════════════════════
// Subagent Parent TAB Tracking
// ═══════════════════════════════════════════════════════════════
//
// CRITICAL: This system tracks which TAB an agent window connects to.
// The association is stored in `subagentParentMap` (agentId -> sessionId).
// The sessionId IS the tab identifier (tabs have data-id="${sessionId}").
// Once set, this association is PERMANENT and persisted across restarts.
/**
* Find and assign the parent TAB for a subagent.
*
* Matching strategy (in order):
* 1. Use existing stored association from subagentParentMap (permanent)
* 2. Match via claudeSessionId (agent.sessionId === session.claudeSessionId)
* 3. FALLBACK: Use the currently active session (since that's where the user typed the command)
*
* Once found, the association is stored PERMANENTLY in subagentParentMap.
*/
findParentSessionForSubagent(agentId) {
// Check if we already have a permanent association
if (this.subagentParentMap.has(agentId)) {
// Already have a parent - update agent object from stored value
const storedSessionId = this.subagentParentMap.get(agentId);
// Verify the session still exists
if (this.sessions.has(storedSessionId)) {
const agent = this.subagents.get(agentId);
if (agent && !agent.parentSessionId) {
agent.parentSessionId = storedSessionId;
const session = this.sessions.get(storedSessionId);
if (session) {
agent.parentSessionName = this.getSessionName(session);
}
this.subagents.set(agentId, agent);
this.updateSubagentWindowParent(agentId);
}
return;
}
// Stored session no longer exists - clear and re-discover
this.subagentParentMap.delete(agentId);
}
const agent = this.subagents.get(agentId);
if (!agent) return;
// Strategy 1: Match via claudeSessionId (most accurate)
if (agent.sessionId) {
for (const [sessionId, session] of this.sessions) {
if (session.claudeSessionId === agent.sessionId) {
// FOUND! Store this association PERMANENTLY
this.setAgentParentSessionId(agentId, sessionId);
this.updateSubagentWindowParent(agentId);
this.updateSubagentWindowVisibility();
this.updateConnectionLines();
return;
}
}
}
// Strategy 2: FALLBACK - Use the currently active session
// This works because agents spawn from where the user typed the command
if (this.activeSessionId && this.sessions.has(this.activeSessionId)) {
this.setAgentParentSessionId(agentId, this.activeSessionId);
this.updateSubagentWindowParent(agentId);
this.updateSubagentWindowVisibility();
this.updateConnectionLines();
return;
}
// Strategy 3: If no active session, use the first session
if (this.sessions.size > 0) {
const firstSessionId = this.sessions.keys().next().value;
this.setAgentParentSessionId(agentId, firstSessionId);
this.updateSubagentWindowParent(agentId);
this.updateSubagentWindowVisibility();
this.updateConnectionLines();
}
},
/**
* Re-check all orphan subagents (those without a parent TAB) when a session updates.
* Called when session:updated fires with claudeSessionId.
*
* Also re-validates existing associations when claudeSessionId becomes available,
* in case the fallback association was wrong.
*/
recheckOrphanSubagents() {
let anyChanged = false;
for (const [agentId, agent] of this.subagents) {
// Check if this agent has no parent in the persistent map
if (!this.subagentParentMap.has(agentId)) {
this.findParentSessionForSubagent(agentId);
if (this.subagentParentMap.has(agentId)) {
anyChanged = true;
}
} else if (agent.sessionId) {
// Agent has a stored parent, but check if we can now do a proper claudeSessionId match
// This handles the case where fallback was used but now the real parent is known
const storedParent = this.subagentParentMap.get(agentId);
const storedSession = this.sessions.get(storedParent);
// If the stored session doesn't have a matching claudeSessionId, try to find the real match
if (storedSession && storedSession.claudeSessionId !== agent.sessionId) {
for (const [sessionId, session] of this.sessions) {
if (session.claudeSessionId === agent.sessionId) {
// Found the real parent - update the association
this.subagentParentMap.set(agentId, sessionId);
agent.parentSessionId = sessionId;
agent.parentSessionName = this.getSessionName(session);
this.subagents.set(agentId, agent);
this.updateSubagentWindowParent(agentId);
anyChanged = true;
break;
}
}
}
}
}
if (anyChanged) {
this.saveSubagentParentMap();
this.updateConnectionLines();
}
},
/**
* Update parentSessionName for all subagents belonging to a TAB.
* Called when a session is renamed to keep cached names fresh.
*/
updateSubagentParentNames(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) return;
const newName = this.getSessionName(session);
// Skip iteration if name hasn't changed (avoids O(n) loop on every session:updated)
const cachedName = this._parentNameCache?.get(sessionId);
if (cachedName === newName) return;
if (!this._parentNameCache) this._parentNameCache = new Map();
this._parentNameCache.set(sessionId, newName);
for (const [agentId, storedSessionId] of this.subagentParentMap) {
if (storedSessionId === sessionId) {
const agent = this.subagents.get(agentId);
if (agent) {
agent.parentSessionName = newName;
this.subagents.set(agentId, agent);
// Update the window header if open
const windowData = this.subagentWindows.get(agentId);
if (windowData) {
const parentNameEl = windowData.element.querySelector('.subagent-window-parent .parent-name');
if (parentNameEl) {
parentNameEl.textContent = newName;
}
}
}
}
}
},
/**
* Add parent header to an agent window, showing which TAB it belongs to.
*/
updateSubagentWindowParent(agentId) {
const windowData = this.subagentWindows.get(agentId);
if (!windowData) return;
// Get parent from persistent map (THE source of truth)
const parentSessionId = this.subagentParentMap.get(agentId);
if (!parentSessionId) return;
const session = this.sessions.get(parentSessionId);
const parentName = session ? this.getSessionName(session) : 'Unknown';
// Check if parent header already exists
const win = windowData.element;
const existingParent = win.querySelector('.subagent-window-parent');
if (existingParent) {
// Update existing
existingParent.dataset.parentSession = parentSessionId;
const nameEl = existingParent.querySelector('.parent-name');
if (nameEl) {
nameEl.textContent = parentName;
nameEl.onclick = () => this.selectSession(parentSessionId);
}
return;
}
// Insert new parent header after the main header
const header = win.querySelector('.subagent-window-header');
if (header) {
const parentDiv = document.createElement('div');
parentDiv.className = 'subagent-window-parent';
parentDiv.dataset.parentSession = parentSessionId;
parentDiv.innerHTML = `
from
${escapeHtml(parentName)}
`;
header.insertAdjacentElement('afterend', parentDiv);
}
},
/**
* Show/hide subagent windows based on active session.
* Behavior controlled by "Subagents for Active Tab Only" setting.
* Uses the PERSISTENT subagentParentMap for accurate tab-based visibility.
*/
updateSubagentWindowVisibility() {
const settings = this.loadAppSettingsFromStorage();
const activeTabOnly = settings.subagentActiveTabOnly ?? true;
for (const [agentId, windowInfo] of this.subagentWindows) {
// Get parent from PERSISTENT map (THE source of truth)
const storedParent = this.subagentParentMap.get(agentId);
const agent = this.subagents.get(agentId);
const parentSessionId = storedParent || agent?.parentSessionId;
// Determine visibility based on setting
let shouldShow;
if (activeTabOnly) {
// Show if: no parent known yet, or parent matches active session
const hasKnownParent = !!parentSessionId;
shouldShow = !hasKnownParent || parentSessionId === this.activeSessionId;
} else {
// Show all windows (original behavior)
shouldShow = true;
}
if (shouldShow) {
// Show window (unless it was minimized by user)
if (!windowInfo.minimized) {
windowInfo.element.style.display = 'flex';
// Lazily re-create teammate terminal if it was disposed when hidden
if (windowInfo._lazyTerminal) {
this._restoreTeammateTerminalFromLazy(agentId);
}
}
windowInfo.hidden = false;
} else {
// Hide window (but don't close it)
// Dispose teammate terminal to free memory while hidden on inactive tab
this._disposeTeammateTerminalForMinimize(agentId);
windowInfo.element.style.display = 'none';
windowInfo.hidden = true;
}
}
// Update connection lines after visibility changes
this.updateConnectionLines();
// Restack mobile windows after visibility changes
this.relayoutMobileSubagentWindows();
},
// Close all subagent windows for a session (fully removes them, not minimize)
// If cleanupData is true, also remove activity and toolResults data to prevent memory leaks
closeSessionSubagentWindows(sessionId, cleanupData = false) {
const toClose = [];
for (const [agentId, _windowData] of this.subagentWindows) {
const agent = this.subagents.get(agentId);
// Check both subagent parentSessionId and subagentParentMap
// (standalone pane windows use subagentParentMap, not subagents map)
const parentFromMap = this.subagentParentMap.get(agentId);
if (agent?.parentSessionId === sessionId || parentFromMap === sessionId) {
toClose.push(agentId);
}
}
for (const agentId of toClose) {
this.forceCloseSubagentWindow(agentId);
// Clean up activity and tool results data if requested (prevents memory leaks)
if (cleanupData) {
this.subagents.delete(agentId);
this.subagentActivity.delete(agentId);
this.subagentToolResults.delete(agentId);
this.subagentParentMap.delete(agentId);
}
}
// Also clean up minimized agents for this session
this.minimizedSubagents.delete(sessionId);
this.renderSessionTabs();
},
// Fully close a subagent window (removes from DOM, not minimize)
forceCloseSubagentWindow(agentId) {
const windowData = this.subagentWindows.get(agentId);
if (windowData) {
// Clean up resize observer
if (windowData.resizeObserver) {
windowData.resizeObserver.disconnect();
}
// Clean up drag event listeners (both document-level and handle-level)
if (windowData.dragListeners) {
document.removeEventListener('mousemove', windowData.dragListeners.move);
document.removeEventListener('mouseup', windowData.dragListeners.up);
if (windowData.dragListeners.touchMove) {
document.removeEventListener('touchmove', windowData.dragListeners.touchMove);
document.removeEventListener('touchend', windowData.dragListeners.up);
document.removeEventListener('touchcancel', windowData.dragListeners.up);
}
// Remove handle-level listeners before DOM removal
if (windowData.dragListeners.handle) {
windowData.dragListeners.handle.removeEventListener('mousedown', windowData.dragListeners.handleMouseDown);
windowData.dragListeners.handle.removeEventListener('touchstart', windowData.dragListeners.handleTouchStart);
}
}
windowData.element.remove();
this.subagentWindows.delete(agentId);
}
// Clean up teammate terminal if present
const termData = this.teammateTerminals.get(agentId);
if (termData) {
if (termData.resizeObserver) {
termData.resizeObserver.disconnect();
}
if (termData.terminal) {
try { termData.terminal.dispose(); } catch {}
}
this.teammateTerminals.delete(agentId);
}
},
minimizeSubagentWindow(agentId) {
const windowData = this.subagentWindows.get(agentId);
if (windowData) {
// Dispose teammate terminal on minimize to free DOM/memory (lazy re-creation on restore)
this._disposeTeammateTerminalForMinimize(agentId);
windowData.element.style.display = 'none';
windowData.minimized = true;
this.updateConnectionLines();
}
},
// Debounced wrapper — coalesces rapid subagent events (tool_call, progress,
// message) into a single DOM update per 100ms per agent window.
scheduleSubagentWindowRender(agentId) {
// Skip DOM updates for windows with lazy (disposed) terminals — they're minimized
const windowData = this.subagentWindows.get(agentId);
if (windowData?.minimized) return;
if (!this._subagentWindowRenderTimeouts) this._subagentWindowRenderTimeouts = new Map();
if (this._subagentWindowRenderTimeouts.has(agentId)) {
clearTimeout(this._subagentWindowRenderTimeouts.get(agentId));
}
this._subagentWindowRenderTimeouts.set(agentId, setTimeout(() => {
this._subagentWindowRenderTimeouts.delete(agentId);
scheduleBackground(() => this.renderSubagentWindowContent(agentId));
}, 100));
},
renderSubagentWindowContent(agentId) {
// Skip if this window has a live terminal (don't overwrite xterm with activity HTML)
if (this.teammateTerminals.has(agentId)) return;
// Skip if this window has a lazy (disposed) terminal — it will be re-created on restore
const windowData = this.subagentWindows.get(agentId);
if (windowData?._lazyTerminal) return;
const body = document.getElementById(`subagent-window-body-${agentId}`);
if (!body) return;
const activity = this.subagentActivity.get(agentId) || [];
if (activity.length === 0) {
body.innerHTML = '