/** * @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 = '
Loading digest...
'; if (freshnessEl) freshnessEl.textContent = ''; if (sectionsEl) sectionsEl.innerHTML = ''; try { const range = this.awayDigestRange || 'since-last-visit'; const params = new URLSearchParams({ range }); if (range === 'since-last-visit') { const lastViewed = this.readAwayDigestLastViewed(); if (Number.isFinite(lastViewed)) params.set('lastViewed', String(lastViewed)); } if (range === 'custom') { this.ensureAwayDigestCustomDefaults(); const since = this.readAwayDigestDateTimeInput('awayDigestCustomSince'); const until = this.readAwayDigestDateTimeInput('awayDigestCustomUntil'); if (!Number.isFinite(since)) { throw new Error('Choose a custom start time'); } params.set('since', String(since)); if (Number.isFinite(until)) params.set('until', String(until)); } const response = await fetch(`/api/away-digest?${params.toString()}`); const data = await response.json(); if (!response.ok || !data.success) { throw new Error(data.error || 'Failed to load away digest'); } this._awayDigestLoadedSuccessfully = true; this._awayDigestGeneratedAt = data.digest.generatedAt; if (range === 'since-last-visit') { this._awayDigestSinceLastVisitGeneratedAt = data.digest.generatedAt; } this.renderAwayDigest(data.digest); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to load away digest'; console.error('Failed to fetch away digest:', err); if (summaryEl) summaryEl.innerHTML = '
Failed to load away digest
'; if (sectionsEl) { sectionsEl.innerHTML = `
${escapeHtml(message)}
`; } this.showToast(message, 'error'); } }, readAwayDigestLastViewed() { try { const value = localStorage.getItem(AWAY_DIGEST_LAST_VIEWED_KEY); const parsed = Number(value); return Number.isFinite(parsed) ? parsed : undefined; } catch { return undefined; } }, readAwayDigestDateTimeInput(id) { const input = document.getElementById(id); if (!input || !input.value) return undefined; const parsed = Date.parse(input.value); return Number.isFinite(parsed) ? parsed : undefined; }, renderAwayDigest(digest) { const summaryEl = document.getElementById('awayDigestSummary'); const freshnessEl = document.getElementById('awayDigestFreshness'); const sectionsEl = document.getElementById('awayDigestSections'); if (!summaryEl || !freshnessEl || !sectionsEl) return; const inputTokens = digest.totals.inputTokens || 0; const outputTokens = digest.totals.outputTokens || 0; const estimatedCost = digest.totals.estimatedCost || 0; summaryEl.innerHTML = `
Needs Attention ${digest.totals.needsAttention}
Completed ${digest.totals.completed}
Active Sessions ${digest.totals.activeSessions}
Tokens ${this.formatTokens(inputTokens + outputTokens)} ~$${estimatedCost.toFixed(2)}
`; const freshnessNotes = []; if (digest.dataFreshness.runSummariesLiveOnly || digest.dataFreshness.subagentsLiveOnly) { freshnessNotes.push('Run summaries and subagent completions use recent live state; lifecycle and token stats are persisted.'); } if (digest.totals.tokenWindowPrecision === 'day') { freshnessNotes.push('Token totals are aggregated at day precision.'); } freshnessEl.textContent = freshnessNotes.join(' '); sectionsEl.innerHTML = AWAY_DIGEST_SECTIONS .map(([key, title]) => this.renderAwayDigestSection(title, digest.sections[key] || [])) .join(''); this.attachAwayDigestActions(); }, renderAwayDigestSection(title, items) { const count = items.length; const body = count ? items.map(item => this.renderAwayDigestItem(item)).join('') : '
No items
'; return `

${escapeHtml(title)}

${count}
${body}
`; }, renderAwayDigestItem(item) { const sourceLabel = this.formatAwayDigestSource(item.source); const sessionLabel = item.sessionName || item.sessionId || ''; const detail = item.detail ? `
${escapeHtml(item.detail)}
` : ''; const action = item.link ? ` ` : ''; return `
${escapeHtml(this.formatAwayDigestTimestamp(item.timestamp))} ${escapeHtml(sourceLabel)} ${sessionLabel ? `${escapeHtml(sessionLabel)}` : ''}
${escapeHtml(item.title)}
${detail}
${action}
`; }, attachAwayDigestActions() { const sectionsEl = document.getElementById('awayDigestSections'); if (!sectionsEl) return; sectionsEl.querySelectorAll('[data-away-link-type]').forEach(button => { button.addEventListener('click', () => { this.openAwayDigestItem(button.dataset.awayLinkType, button.dataset.awaySessionId || undefined); }); }); }, async openAwayDigestItem(type, sessionId) { if (type === 'session' && sessionId) { await this.selectSession(sessionId); this.closeAwayDigest(); return; } if (type === 'run_summary' && sessionId) { await this.openRunSummary(sessionId); this.closeAwayDigest(); return; } if (type === 'lifecycle') { this.openLifecycleLog(); this.closeAwayDigest(); } }, formatAwayDigestTimestamp(timestamp) { if (!Number.isFinite(timestamp)) return ''; return new Date(timestamp).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', }); }, formatAwayDigestSource(source) { const labels = { lifecycle: 'Lifecycle', run_summary: 'Run Summary', status: 'Status', token_stats: 'Token Stats', subagent: 'Subagent', }; return labels[source] || source; }, // ═══════════════════════════════════════════════════════════════ // Token Statistics Modal // ═══════════════════════════════════════════════════════════════ async openTokenStats() { try { const response = await fetch('/api/token-stats'); const data = await response.json(); if (data.success) { this.renderTokenStats(data.data); document.getElementById('tokenStatsModal').classList.add('active'); } else { this.showToast('Failed to load token stats', 'error'); } } catch (err) { console.error('Failed to fetch token stats:', err); this.showToast('Failed to load token stats', 'error'); } }, renderTokenStats(data) { const { daily, totals } = data; // Calculate period totals const today = new Date().toISOString().split('T')[0]; const todayData = daily.find(d => d.date === today) || { inputTokens: 0, outputTokens: 0, estimatedCost: 0 }; // Last 7 days totals (for summary card) const sevenDaysAgo = new Date(); sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); const last7Days = daily.filter(d => new Date(d.date) >= sevenDaysAgo); const weekInput = last7Days.reduce((sum, d) => sum + d.inputTokens, 0); const weekOutput = last7Days.reduce((sum, d) => sum + d.outputTokens, 0); const weekCost = this.estimateCost(weekInput, weekOutput); // Lifetime totals (from aggregate stats) const lifetimeInput = totals.totalInputTokens; const lifetimeOutput = totals.totalOutputTokens; const lifetimeCost = this.estimateCost(lifetimeInput, lifetimeOutput); // Render summary cards const summaryEl = document.getElementById('statsSummary'); summaryEl.innerHTML = `
Today ${this.formatTokens(todayData.inputTokens + todayData.outputTokens)} ~$${todayData.estimatedCost.toFixed(2)}
7 Days ${this.formatTokens(weekInput + weekOutput)} ~$${weekCost.toFixed(2)}
Lifetime ${this.formatTokens(lifetimeInput + lifetimeOutput)} ~$${lifetimeCost.toFixed(2)}
`; // Render bar chart (last 7 days) const chartEl = document.getElementById('statsChart'); const daysEl = document.getElementById('statsChartDays'); // Get last 7 days (fill gaps with empty data) const chartData = []; for (let i = 6; i >= 0; i--) { const date = new Date(); date.setDate(date.getDate() - i); const dateStr = date.toISOString().split('T')[0]; const dayData = daily.find(d => d.date === dateStr); chartData.push({ date: dateStr, dayName: date.toLocaleDateString('en-US', { weekday: 'short' }), tokens: dayData ? dayData.inputTokens + dayData.outputTokens : 0, cost: dayData ? dayData.estimatedCost : 0, }); } // Find max for scaling const maxTokens = Math.max(...chartData.map(d => d.tokens), 1); chartEl.innerHTML = chartData.map(d => { const height = Math.max((d.tokens / maxTokens) * 100, 3); const tooltip = `${d.dayName}: ${this.formatTokens(d.tokens)} (~$${d.cost.toFixed(2)})`; return `
`; }).join(''); daysEl.innerHTML = chartData.map(d => `${d.dayName}`).join(''); // Render table (last 14 days with data) const tableEl = document.getElementById('statsTable'); const tableData = daily.slice(0, 14); if (tableData.length === 0) { tableEl.innerHTML = '
No usage data recorded yet
'; } else { tableEl.innerHTML = `
Date Input Output Cost
${tableData.map(d => { const dateObj = new Date(d.date + 'T00:00:00'); const dateStr = dateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); return `
${dateStr} ${this.formatTokens(d.inputTokens)} ${this.formatTokens(d.outputTokens)} $${d.estimatedCost.toFixed(2)}
`; }).join('')} `; } }, closeTokenStats() { const modal = document.getElementById('tokenStatsModal'); if (modal) { modal.classList.remove('active'); } }, // ═══════════════════════════════════════════════════════════════ // Monitor Panel (combined Mux Sessions + Background Tasks) // ═══════════════════════════════════════════════════════════════ async toggleMonitorPanel() { const panel = document.getElementById('monitorPanel'); const toggleBtn = document.getElementById('monitorToggleBtn'); panel.classList.toggle('open'); if (panel.classList.contains('open')) { // applyMonitorVisibility() sets inline display:none when the "Show Monitor" // setting is off — clear it so transient opens (session-tab task badge) work panel.style.display = ''; // Load screens and start stats collection await this.loadMuxSessions(); await fetch('/api/mux-sessions/stats/start', { method: 'POST' }); this.renderTaskPanel(); if (toggleBtn) toggleBtn.innerHTML = '▼'; // Down arrow when open } else { // Stop stats collection when panel is closed await fetch('/api/mux-sessions/stats/stop', { method: 'POST' }); if (toggleBtn) toggleBtn.innerHTML = '▲'; // Up arrow when closed } }, // Legacy alias for task panel toggle (used by session tab badge) toggleTaskPanel() { this.toggleMonitorPanel(); }, // ═══════════════════════════════════════════════════════════════ // Monitor Panel Detach & Drag // ═══════════════════════════════════════════════════════════════ toggleMonitorDetach() { const panel = document.getElementById('monitorPanel'); const detachBtn = document.getElementById('monitorDetachBtn'); if (panel.classList.contains('detached')) { // Re-attach to bottom panel.classList.remove('detached'); panel.style.top = ''; panel.style.left = ''; panel.style.width = ''; panel.style.height = ''; if (detachBtn) { detachBtn.innerHTML = '⧉'; // Detach icon (two overlapping squares) detachBtn.title = 'Detach panel'; } } else { // Detach as floating window panel.classList.add('detached'); panel.classList.add('open'); // Ensure it's visible if (detachBtn) { detachBtn.innerHTML = '⊞'; // Attach icon (squared plus - dock back) detachBtn.title = 'Attach panel'; } // Setup drag functionality this.setupMonitorDrag(); } }, setupMonitorDrag() { const panel = document.getElementById('monitorPanel'); const header = document.getElementById('monitorPanelHeader'); if (!panel || !header) return; let isDragging = false; let startX, startY, startLeft, startTop; const onStart = (e) => { // Only drag from header, not from buttons if (e.target.closest('button')) return; if (!panel.classList.contains('detached')) return; isDragging = true; const coords = getEventCoords(e); startX = coords.clientX; startY = coords.clientY; const rect = panel.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onEnd); document.addEventListener('touchmove', onMove, { passive: false }); document.addEventListener('touchend', onEnd); e.preventDefault(); }; const onMove = (e) => { if (!isDragging) return; const coords = getEventCoords(e); const dx = coords.clientX - startX; const dy = coords.clientY - startY; let newLeft = startLeft + dx; let newTop = startTop + dy; // Keep within viewport bounds const rect = panel.getBoundingClientRect(); newLeft = Math.max(0, Math.min(window.innerWidth - rect.width, newLeft)); newTop = Math.max(0, Math.min(window.innerHeight - rect.height, newTop)); panel.style.left = newLeft + 'px'; panel.style.top = newTop + 'px'; }; const onEnd = () => { isDragging = false; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onEnd); document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', onEnd); }; // Remove existing listeners before adding new ones header.removeEventListener('mousedown', header._dragHandler); header.removeEventListener('touchstart', header._touchDragHandler); header._dragHandler = onStart; header._touchDragHandler = onStart; header.addEventListener('mousedown', onStart); header.addEventListener('touchstart', onStart, { passive: false }); }, // ═══════════════════════════════════════════════════════════════ // Subagents Panel Detach & Drag // ═══════════════════════════════════════════════════════════════ toggleSubagentsDetach() { const panel = document.getElementById('subagentsPanel'); const detachBtn = document.getElementById('subagentsDetachBtn'); if (panel.classList.contains('detached')) { // Re-attach to bottom panel.classList.remove('detached'); panel.style.top = ''; panel.style.left = ''; panel.style.width = ''; panel.style.height = ''; if (detachBtn) { detachBtn.innerHTML = '⧉'; // Detach icon detachBtn.title = 'Detach panel'; } } else { // Detach as floating window panel.classList.add('detached'); panel.classList.add('open'); // Ensure it's visible if (detachBtn) { detachBtn.innerHTML = '⊞'; // Attach icon detachBtn.title = 'Attach panel'; } // Setup drag functionality this.setupSubagentsDrag(); } }, setupSubagentsDrag() { const panel = document.getElementById('subagentsPanel'); const header = document.getElementById('subagentsPanelHeader'); if (!panel || !header) return; let isDragging = false; let startX, startY, startLeft, startTop; const onStart = (e) => { // Only drag from header, not from buttons if (e.target.closest('button')) return; if (!panel.classList.contains('detached')) return; isDragging = true; const coords = getEventCoords(e); startX = coords.clientX; startY = coords.clientY; const rect = panel.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onEnd); document.addEventListener('touchmove', onMove, { passive: false }); document.addEventListener('touchend', onEnd); e.preventDefault(); }; const onMove = (e) => { if (!isDragging) return; const coords = getEventCoords(e); const dx = coords.clientX - startX; const dy = coords.clientY - startY; let newLeft = startLeft + dx; let newTop = startTop + dy; // Keep within viewport bounds const rect = panel.getBoundingClientRect(); newLeft = Math.max(0, Math.min(window.innerWidth - rect.width, newLeft)); newTop = Math.max(0, Math.min(window.innerHeight - rect.height, newTop)); panel.style.left = newLeft + 'px'; panel.style.top = newTop + 'px'; }; const onEnd = () => { isDragging = false; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onEnd); document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', onEnd); }; // Remove existing listeners before adding new ones header.removeEventListener('mousedown', header._dragHandler); header.removeEventListener('touchstart', header._touchDragHandler); header._dragHandler = onStart; header._touchDragHandler = onStart; header.addEventListener('mousedown', onStart); header.addEventListener('touchstart', onStart, { passive: false }); }, renderTaskPanel() { this._debouncedCall('taskPanel', this._renderTaskPanelImmediate); }, _renderTaskPanelImmediate() { const session = this.sessions.get(this.activeSessionId); const body = document.getElementById('backgroundTasksBody'); const stats = document.getElementById('taskPanelStats'); const section = document.getElementById('backgroundTasksSection'); if (!session || !session.taskTree || session.taskTree.length === 0) { // Hide the entire section when there are no background tasks if (section) section.style.display = 'none'; body.innerHTML = ''; stats.textContent = '0 tasks'; return; } // Show the section when there are tasks if (section) section.style.display = ''; const taskStats = session.taskStats || { running: 0, completed: 0, failed: 0, total: 0 }; stats.textContent = `${taskStats.running} running, ${taskStats.completed} done`; // Render task tree recursively const renderTask = (task, allTasks) => { const statusIcon = task.status === 'running' ? '' : task.status === 'completed' ? '✓' : '✗'; const duration = task.endTime ? `${((task.endTime - task.startTime) / 1000).toFixed(1)}s` : `${((Date.now() - task.startTime) / 1000).toFixed(0)}s...`; let childrenHtml = ''; if (task.children && task.children.length > 0) { childrenHtml = '
'; for (const childId of task.children) { // Find child task in allTasks map const childTask = allTasks.find(t => t.id === childId); if (childTask) { childrenHtml += `
${renderTask(childTask, allTasks)}
`; } } childrenHtml += '
'; } return `
${statusIcon}
${escapeHtml(task.description)}
${task.subagentType} ${duration}
${childrenHtml} `; }; // Flatten all tasks for lookup const allTasks = this.flattenTaskTree(session.taskTree); // Render only root tasks (those without parents or with null parentId) let html = '
'; for (const task of session.taskTree) { html += `
${renderTask(task, allTasks)}
`; } html += '
'; body.innerHTML = html; }, flattenTaskTree(tasks, result = []) { for (const task of tasks) { result.push(task); // Children are stored as IDs, not nested objects in taskTree // The task tree from server already has the structure we need } return result; }, // ═══════════════════════════════════════════════════════════════ // Subagent Panel (Claude Code Background Agents) // ═══════════════════════════════════════════════════════════════ // Legacy alias toggleSubagentPanel() { this.toggleSubagentsPanel(); }, updateSubagentBadge() { const badge = this.$('subagentCountBadge'); const activeCount = Array.from(this.subagents.values()).filter(s => s.status === 'active' || s.status === 'idle').length; // Update badge with active count if (badge) { badge.textContent = activeCount > 0 ? activeCount : ''; } }, renderSubagentPanel() { // Debounce renders at 150ms to prevent excessive DOM updates from rapid subagent events if (this._subagentPanelRenderTimeout) { clearTimeout(this._subagentPanelRenderTimeout); } this._subagentPanelRenderTimeout = setTimeout(() => { scheduleBackground(() => this._renderSubagentPanelImmediate()); }, 150); }, _renderSubagentPanelImmediate() { const list = this.$('subagentList'); if (!list) return; // Always update badge count this.updateSubagentBadge(); // Always update monitor panel (even if subagent panel is hidden) this.renderMonitorSubagents(); // If panel is not visible, don't render content if (!this.subagentPanelVisible) { return; } // Render subagent list if (this.subagents.size === 0) { list.innerHTML = '
No background agents detected
'; return; } const html = []; const sorted = Array.from(this.subagents.values()).sort((a, b) => { // Active first, then by last activity if (a.status === 'active' && b.status !== 'active') return -1; if (b.status === 'active' && a.status !== 'active') return 1; return (b.lastActivityAt || 0) - (a.lastActivityAt || 0); }); for (const agent of sorted) { const isActive = this.activeSubagentId === agent.agentId; const statusClass = agent.status === 'active' ? 'active' : agent.status === 'idle' ? 'idle' : 'completed'; const activity = this.subagentActivity.get(agent.agentId) || []; const lastActivity = activity[activity.length - 1]; const lastTool = lastActivity?.type === 'tool' ? lastActivity.tool : null; const hasWindow = this.subagentWindows.has(agent.agentId); const canKill = agent.status === 'active' || agent.status === 'idle'; const modelBadge = agent.modelShort ? `${escapeHtml(agent.modelShort)}` : ''; const teammateInfo = this.getTeammateInfo(agent); const displayName = teammateInfo ? teammateInfo.name : (agent.description || agent.agentId.substring(0, 7)); const teammateBadge = this.getTeammateBadgeHtml(agent); const agentIcon = teammateInfo ? `●` : '🤖'; html.push(`
${agentIcon} ${escapeHtml(displayName.length > 40 ? displayName.substring(0, 40) + '...' : displayName)} ${teammateBadge} ${modelBadge} ${agent.status} ${canKill ? `` : ''}
${agent.toolCallCount} tools ${lastTool ? `${this.getToolIcon(lastTool)} ${lastTool}` : ''}
`); } list.innerHTML = html.join(''); }, selectSubagent(agentId) { this.activeSubagentId = agentId; this.renderSubagentPanel(); this.renderSubagentDetail(); }, renderSubagentDetail() { const detail = this.$('subagentDetail'); if (!detail) return; if (!this.activeSubagentId) { detail.innerHTML = '
Select an agent to view details
'; return; } const agent = this.subagents.get(this.activeSubagentId); const activity = this.subagentActivity.get(this.activeSubagentId) || []; if (!agent) { detail.innerHTML = '
Agent not found
'; return; } const activityHtml = activity.slice(-30).map(a => { const time = new Date(a.timestamp).toLocaleTimeString('en-US', { hour12: false }); if (a.type === 'tool') { const toolDetail = this.getToolDetailExpanded(a.tool, a.input, a.fullInput, a.toolUseId); return `
${time} ${this.getToolIcon(a.tool)} ${escapeHtml(a.tool)} ${escapeHtml(toolDetail.primary)} ${toolDetail.hasMore ? `` : ''} ${toolDetail.hasMore ? `` : ''}
`; } else if (a.type === 'tool_result') { const icon = a.isError ? '❌' : '📄'; const statusClass = a.isError ? 'error' : ''; const sizeInfo = a.contentLength > 500 ? ` (${this.formatBytes(a.contentLength)})` : ''; const preview = a.preview.length > 80 ? a.preview.substring(0, 80) + '...' : a.preview; return `
${time} ${icon} ${escapeHtml(a.tool || 'result')} ${escapeHtml(preview)}${sizeInfo}
`; } else if (a.type === 'progress') { // Check for hook events const isHook = a.hookEvent || a.hookName; const icon = isHook ? '🪝' : (a.progressType === 'query_update' ? '⟳' : '✓'); const hookClass = isHook ? ' hook' : ''; const displayText = isHook ? (a.hookName || a.hookEvent) : (a.query || a.progressType); return `
${time} ${icon} ${escapeHtml(displayText)}
`; } else if (a.type === 'message') { const preview = a.text.length > 100 ? a.text.substring(0, 100) + '...' : a.text; return `
${time} 💬 ${escapeHtml(preview)}
`; } return ''; }).join(''); const detailTitle = agent.description || `Agent ${agent.agentId}`; const modelBadge = agent.modelShort ? `${escapeHtml(agent.modelShort)}` : ''; const tokenStats = (agent.totalInputTokens || agent.totalOutputTokens) ? `Tokens: ${this.formatTokenCount(agent.totalInputTokens || 0)}↓ ${this.formatTokenCount(agent.totalOutputTokens || 0)}↑` : ''; detail.innerHTML = `
${escapeHtml(detailTitle.length > 60 ? detailTitle.substring(0, 60) + '...' : detailTitle)} ${modelBadge} ${agent.status}
Tools: ${agent.toolCallCount} Entries: ${agent.entryCount} Size: ${(agent.fileSize / 1024).toFixed(1)}KB ${tokenStats}
${activityHtml || '
No activity yet
'}
`; }, toggleToolParams(toolUseId) { const el = document.getElementById(`tool-params-${toolUseId}`); if (!el) return; const btn = el.previousElementSibling; if (el.style.display === 'none') { el.style.display = 'block'; if (btn) btn.textContent = '▼'; } else { el.style.display = 'none'; if (btn) btn.textContent = '▶'; } }, formatTokenCount(count) { if (count >= 1000000) return (count / 1000000).toFixed(1) + 'M'; if (count >= 1000) return (count / 1000).toFixed(1) + 'k'; return count.toString(); }, formatBytes(bytes) { if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + 'MB'; if (bytes >= 1024) return (bytes / 1024).toFixed(1) + 'KB'; return bytes + 'B'; }, getToolIcon(tool) { const icons = { WebSearch: '🔍', WebFetch: '🌐', Read: '📖', Write: '📝', Edit: '✏️', Bash: '💻', Glob: '📁', Grep: '🔎', Task: '🤖', }; return icons[tool] || '🔧'; }, getToolDetail(tool, input) { if (!input) return ''; if (tool === 'WebSearch' && input.query) return `"${input.query}"`; if (tool === 'WebFetch' && input.url) return input.url; if (tool === 'Read' && input.file_path) return input.file_path; if ((tool === 'Write' || tool === 'Edit') && input.file_path) return input.file_path; if (tool === 'Bash' && input.command) { const cmd = input.command; return cmd.length > 40 ? cmd.substring(0, 40) + '...' : cmd; } if (tool === 'Glob' && input.pattern) return input.pattern; if (tool === 'Grep' && input.pattern) return input.pattern; return ''; }, getToolDetailExpanded(tool, input, fullInput, toolUseId) { const primary = this.getToolDetail(tool, input); // Check if there are additional params beyond the primary one const primaryKeys = ['query', 'url', 'file_path', 'command', 'pattern']; const inputKeys = Object.keys(fullInput || input || {}); const extraKeys = inputKeys.filter(k => !primaryKeys.includes(k)); const hasMore = extraKeys.length > 0 || (fullInput && JSON.stringify(fullInput).length > 100); return { primary, hasMore, fullInput: fullInput || input }; }, async killSubagent(agentId) { try { const res = await this._apiDelete(`/api/subagents/${agentId}`); const data = await res?.json(); if (data?.success) { // Update local state const agent = this.subagents.get(agentId); if (agent) { agent.status = 'completed'; this.subagents.set(agentId, agent); } this.renderSubagentPanel(); this.renderSubagentDetail(); this.updateSubagentWindows(); this.showToast(`Subagent ${agentId.substring(0, 7)} killed`, 'success'); } else { this.showToast(data.error || 'Failed to kill subagent', 'error'); } } catch (err) { console.error('Failed to kill subagent:', err); this.showToast('Failed to kill subagent: ' + err.message, 'error'); } }, async viewSubagentTranscript(agentId) { try { const res = await fetch(`/api/subagents/${agentId}/transcript?format=formatted`); const data = await res.json(); if (!data.success) { alert('Failed to load transcript'); return; } // Show in a modal or new window const content = data.data.formatted.join('\n'); const win = window.open('', '_blank', 'width=800,height=600'); win.document.write(` Subagent ${escapeHtml(agentId)} Transcript

Subagent ${escapeHtml(agentId)} Transcript (${data.data.entryCount} entries)

${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 = '
No activity yet
'; return; } // Incremental rendering: track how many items are already rendered const renderedCount = body.dataset.renderedCount ? parseInt(body.dataset.renderedCount, 10) : 0; const maxItems = 100; const visibleActivity = activity.slice(-maxItems); // If activity was trimmed or this is a fresh render, do full rebuild if (renderedCount === 0 || renderedCount > visibleActivity.length || body.children.length === 0 || (body.children.length === 1 && body.querySelector('.subagent-empty'))) { // Full rebuild const html = visibleActivity.map(a => this._renderActivityItem(a)).join(''); body.innerHTML = html; body.dataset.renderedCount = String(visibleActivity.length); } else { // Incremental: only append new items const newItems = visibleActivity.slice(renderedCount); if (newItems.length > 0) { const newHtml = newItems.map(a => this._renderActivityItem(a)).join(''); body.insertAdjacentHTML('beforeend', newHtml); body.dataset.renderedCount = String(visibleActivity.length); // Trim excess children from the front if over maxItems while (body.children.length > maxItems) { body.removeChild(body.firstChild); } } } body.scrollTop = body.scrollHeight; }, _renderActivityItem(a) { const time = new Date(a.timestamp).toLocaleTimeString('en-US', { hour12: false }); if (a.type === 'tool') { return `
${time} ${this.getToolIcon(a.tool)} ${escapeHtml(a.tool)} ${escapeHtml(this.getToolDetail(a.tool, a.input))}
`; } else if (a.type === 'tool_result') { const icon = a.isError ? '❌' : '📄'; const statusClass = a.isError ? ' error' : ''; const sizeInfo = a.contentLength > 500 ? ` (${this.formatBytes(a.contentLength)})` : ''; const preview = a.preview.length > 60 ? a.preview.substring(0, 60) + '...' : a.preview; return `
${time} ${icon} ${escapeHtml(a.tool || '→')} ${escapeHtml(preview)}${sizeInfo}
`; } else if (a.type === 'progress') { const isHook = a.hookEvent || a.hookName; const icon = isHook ? '🪝' : (a.progressType === 'query_update' ? '⟳' : '✓'); const displayText = isHook ? (a.hookName || a.hookEvent) : (a.query || a.progressType); return `
${time} ${icon} ${escapeHtml(displayText)}
`; } else if (a.type === 'message') { const preview = a.text.length > 150 ? a.text.substring(0, 150) + '...' : a.text; return `
${time} 💬 ${escapeHtml(preview)}
`; } return ''; }, // Update all open subagent windows updateSubagentWindows() { for (const agentId of this.subagentWindows.keys()) { this.renderSubagentWindowContent(agentId); this.updateSubagentWindowHeader(agentId); } }, // Update subagent window header (title and status) updateSubagentWindowHeader(agentId) { const agent = this.subagents.get(agentId); if (!agent) return; const win = document.getElementById(`subagent-window-${agentId}`); if (!win) return; // Update title/id element with description if available const idEl = win.querySelector('.subagent-window-title .id'); if (idEl) { const teammateInfo = this.getTeammateInfo(agent); const windowTitle = teammateInfo ? teammateInfo.name : (agent.description || agentId.substring(0, 7)); const truncatedTitle = windowTitle.length > 50 ? windowTitle.substring(0, 50) + '...' : windowTitle; idEl.textContent = truncatedTitle; } // Add or update teammate badge let tmBadge = win.querySelector('.teammate-badge'); const teammateInfo = this.getTeammateInfo(agent); if (teammateInfo && !tmBadge) { const titleContainer = win.querySelector('.subagent-window-title'); if (titleContainer) { const badge = document.createElement('span'); badge.className = `teammate-badge teammate-color-${teammateInfo.color}`; badge.title = `Team: ${teammateInfo.teamName}`; badge.textContent = `@${teammateInfo.name}`; const statusEl = titleContainer.querySelector('.status'); if (statusEl) statusEl.insertAdjacentElement('beforebegin', badge); } } // Update full tooltip const titleContainer = win.querySelector('.subagent-window-title'); if (titleContainer) { titleContainer.title = agent.description || agentId; } // Update or add model badge let modelBadge = win.querySelector('.subagent-window-title .subagent-model-badge'); if (agent.modelShort) { if (!modelBadge) { modelBadge = document.createElement('span'); modelBadge.className = `subagent-model-badge ${agent.modelShort}`; const statusEl = win.querySelector('.subagent-window-title .status'); if (statusEl) { statusEl.insertAdjacentElement('beforebegin', modelBadge); } } modelBadge.className = `subagent-model-badge ${agent.modelShort}`; modelBadge.textContent = agent.modelShort; } // Update status const statusEl = win.querySelector('.subagent-window-title .status'); if (statusEl) { statusEl.className = `status ${agent.status}`; statusEl.textContent = agent.status; } }, // Open windows for all active subagents openAllActiveSubagentWindows() { for (const [agentId, agent] of this.subagents) { if (agent.status === 'active' && !this.subagentWindows.has(agentId)) { this.openSubagentWindow(agentId); } } }, // ═══════════════════════════════════════════════════════════════ // Agent Teams // ═══════════════════════════════════════════════════════════════ /** Initialize an xterm.js terminal for a teammate's tmux pane */ initTeammateTerminal(agentId, paneInfo, windowElement) { const body = windowElement.querySelector('.subagent-window-body'); if (!body) return; // Clear the activity log content body.innerHTML = ''; body.classList.add('teammate-terminal-body'); windowElement.classList.add('has-terminal'); const sessionId = paneInfo.sessionId; // Buffer incoming terminal data until xterm is ready const pendingData = []; this.teammateTerminals.set(agentId, { terminal: null, fitAddon: null, paneTarget: paneInfo.paneTarget, sessionId, resizeObserver: null, pendingData, }); // Defer terminal creation to next frame so the body element has computed dimensions requestAnimationFrame(() => { // Safety: if window was closed before we got here, bail out if (!document.contains(body)) { this.teammateTerminals.delete(agentId); return; } const terminal = new Terminal({ theme: { ...window.codemanCurrentXtermTheme() }, minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1, fontFamily: window.CodemanTerminalFont.resolve(this.loadAppSettingsFromStorage?.().terminalFontFamily), fontSize: 12, lineHeight: 1.2, cursorBlink: true, cursorStyle: 'block', scrollback: DEFAULT_SCROLLBACK, allowTransparency: true, allowProposedApi: true, }); const fitAddon = new FitAddon.FitAddon(); terminal.loadAddon(fitAddon); if (typeof Unicode11Addon !== 'undefined') { try { const unicode11Addon = new Unicode11Addon.Unicode11Addon(); terminal.loadAddon(unicode11Addon); terminal.unicode.activeVersion = '11'; } catch (_e) { /* Unicode11 addon failed */ } } try { terminal.open(body); } catch (err) { console.warn('[TeammateTerminal] Failed to open terminal:', err); this.teammateTerminals.delete(agentId); return; } // Wait for terminal renderer to fully initialize before any writes. // xterm.js needs a few frames after open() before write() is safe. setTimeout(() => { try { fitAddon.fit(); } catch {} // Fetch initial pane buffer fetch(`/api/sessions/${sessionId}/teammate-pane-buffer/${encodeURIComponent(paneInfo.paneTarget)}`) .then(r => r.json()) .then(resp => { if (resp.success && resp.data?.buffer) { try { terminal.write(resp.data.buffer); } catch {} } }) .catch(err => console.error('[TeammateTerminal] Failed to fetch buffer:', err)); // Flush any data that arrived while terminal was initializing for (const chunk of pendingData) { try { terminal.write(chunk); } catch {} } pendingData.length = 0; }, 100); // Forward keyboard input to the teammate's pane terminal.onData((data) => { fetch(`/api/sessions/${sessionId}/teammate-pane-input`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paneTarget: paneInfo.paneTarget, input: data }), }).catch(err => console.error('[TeammateTerminal] Failed to send input:', err)); }); // Resize observer to refit terminal when window is resized const resizeObserver = new ResizeObserver(() => { requestAnimationFrame(() => { try { fitAddon.fit(); } catch {} }); }); resizeObserver.observe(body); // Update the stored entry with the real terminal const entry = this.teammateTerminals.get(agentId); if (entry) { entry.terminal = terminal; entry.fitAddon = fitAddon; entry.resizeObserver = resizeObserver; } }); }, /** Open a standalone terminal window for a tmux-pane teammate (no subagent entry needed) */ openTeammateTerminalWindow(paneData) { // Only open if the session has a tab in Codeman if (!this.sessions.has(paneData.sessionId)) return; // Use pane target as the unique ID for this window const windowId = `pane-${paneData.paneTarget}`; // If window already exists, focus it if (this.subagentWindows.has(windowId)) { const existing = this.subagentWindows.get(windowId); if (existing.hidden) { existing.element.style.display = 'flex'; existing.hidden = false; } existing.element.style.zIndex = ++this.subagentWindowZIndex; if (existing.minimized) { this.restoreSubagentWindow(windowId); } return; } // Calculate position const windowCount = this.subagentWindows.size; const windowWidth = 550; const windowHeight = 400; const gap = 20; const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const startX = 50; const startY = 120; const maxCols = Math.floor((viewportWidth - startX - 50) / (windowWidth + gap)) || 1; const maxRows = Math.floor((viewportHeight - startY - 50) / (windowHeight + gap)) || 1; const col = windowCount % maxCols; const row = Math.floor(windowCount / maxCols) % maxRows; let finalX = startX + col * (windowWidth + gap); let finalY = startY + row * (windowHeight + gap); finalX = Math.max(10, Math.min(finalX, viewportWidth - windowWidth - 10)); finalY = Math.max(10, Math.min(finalY, viewportHeight - windowHeight - 10)); // Color badge const colorClass = paneData.color || 'blue'; // Create window element const win = document.createElement('div'); win.className = 'subagent-window has-terminal'; win.id = `subagent-window-${windowId}`; win.style.zIndex = ++this.subagentWindowZIndex; win.style.left = `${finalX}px`; win.style.top = `${finalY}px`; win.style.width = `${windowWidth}px`; win.style.height = `${windowHeight}px`; win.innerHTML = `
⬤ ${escapeHtml(paneData.teammateName)} terminal
`; document.body.appendChild(win); // Make draggable const dragListeners = this.makeWindowDraggable(win, win.querySelector('.subagent-window-header')); // Make resizable if method exists if (typeof this.makeWindowResizable === 'function') { this.makeWindowResizable(win); } // Check visibility based on active session const settings = this.loadAppSettingsFromStorage(); const activeTabOnly = settings.subagentActiveTabOnly ?? true; const shouldHide = activeTabOnly && paneData.sessionId !== this.activeSessionId; // Store reference this.subagentWindows.set(windowId, { element: win, minimized: false, hidden: shouldHide, dragListeners, description: `Teammate: ${paneData.teammateName}`, }); // Also add to subagentParentMap for tab-based visibility this.subagentParentMap.set(windowId, paneData.sessionId); if (shouldHide) { win.style.display = 'none'; } // Focus on click win.addEventListener('mousedown', () => { win.style.zIndex = ++this.subagentWindowZIndex; }); // Resize observer for connection lines const resizeObserver = new ResizeObserver(() => { this.updateConnectionLines(); }); resizeObserver.observe(win); this.subagentWindows.get(windowId).resizeObserver = resizeObserver; // Init the xterm.js terminal (lazy if hidden) if (shouldHide) { // Window starts hidden — defer terminal creation until visible (lazy init) const windowEntry = this.subagentWindows.get(windowId); if (windowEntry) { windowEntry._lazyTerminal = true; windowEntry._lazyPaneTarget = paneData.paneTarget; windowEntry._lazySessionId = paneData.sessionId; } } else { this.initTeammateTerminal(windowId, paneData, win); } // Animate in requestAnimationFrame(() => { win.style.transition = 'transform 0.3s ease, opacity 0.3s ease'; win.style.transform = 'scale(1)'; win.style.opacity = '1'; }); }, /** Rebuild the teammate lookup map from all team configs */ rebuildTeammateMap() { this.teammateMap.clear(); for (const [teamName, team] of this.teams) { for (const member of team.members) { if (member.agentType !== 'team-lead') { // Use name as key prefix for matching subagent descriptions this.teammateMap.set(member.name, { name: member.name, color: member.color || 'blue', teamName, agentId: member.agentId, }); } } } }, /** Check if a subagent is a teammate and return its info */ getTeammateInfo(agent) { if (!agent?.description) return null; // Teammate descriptions start with \s]+)/); if (!match) return null; const teammateId = match[1]; // Extract name from teammate_id (format: name@teamName) const name = teammateId.split('@')[0]; return this.teammateMap.get(name) || { name, color: 'blue', teamName: 'unknown' }; }, /** Get teammate badge HTML for a subagent */ getTeammateBadgeHtml(agent) { const info = this.getTeammateInfo(agent); if (!info) return ''; return `@${escapeHtml(info.name)}`; }, /** Render the team tasks panel */ renderTeamTasksPanel() { const panel = document.getElementById('teamTasksPanel'); if (!panel) return; // Find team for active session let activeTeam = null; let activeTeamName = null; if (this.activeSessionId) { for (const [name, team] of this.teams) { if (team.leadSessionId === this.activeSessionId) { activeTeam = team; activeTeamName = name; break; } } } if (!activeTeam) { panel.style.display = 'none'; return; } // Set initial position and make draggable on first show const wasHidden = panel.style.display === 'none'; panel.style.display = 'flex'; if (wasHidden && !this.teamTasksDragListeners) { // Position bottom-right const panelWidth = 360; const panelHeight = 300; panel.style.left = `${Math.max(10, window.innerWidth - panelWidth - 20)}px`; panel.style.top = `${Math.max(10, window.innerHeight - panelHeight - 70)}px`; // Make draggable const header = panel.querySelector('.team-tasks-header'); if (header) { this.teamTasksDragListeners = this.makeWindowDraggable(panel, header); } } const tasks = this.teamTasks.get(activeTeamName) || []; const completed = tasks.filter(t => t.status === 'completed').length; const total = tasks.length; const pct = total > 0 ? Math.round((completed / total) * 100) : 0; const headerEl = panel.querySelector('.team-tasks-header-text'); if (headerEl) { const teammateCount = activeTeam.members.filter(m => m.agentType !== 'team-lead').length; headerEl.textContent = `Team Tasks (${teammateCount} teammates)`; } const progressEl = panel.querySelector('.team-tasks-progress-fill'); if (progressEl) { progressEl.style.width = `${pct}%`; } const progressText = panel.querySelector('.team-tasks-progress-text'); if (progressText) { progressText.textContent = `${completed}/${total}`; } const listEl = panel.querySelector('.team-tasks-list'); if (!listEl) return; if (tasks.length === 0) { listEl.innerHTML = '
No tasks yet
'; return; } const html = tasks.map(task => { const statusIcon = task.status === 'completed' ? '✓' : task.status === 'in_progress' ? '◉' : '○'; const statusClass = task.status.replace('_', '-'); const ownerBadge = task.owner ? `${escapeHtml(task.owner)}` : ''; return `
${statusIcon} ${escapeHtml(task.subject)} ${ownerBadge}
`; }).join(''); listEl.innerHTML = html; }, /** Hide team tasks panel and clean up drag listeners */ hideTeamTasksPanel() { const panel = document.getElementById('teamTasksPanel'); if (panel) panel.style.display = 'none'; if (this.teamTasksDragListeners) { document.removeEventListener('mousemove', this.teamTasksDragListeners.move); document.removeEventListener('mouseup', this.teamTasksDragListeners.up); if (this.teamTasksDragListeners.touchMove) { document.removeEventListener('touchmove', this.teamTasksDragListeners.touchMove); document.removeEventListener('touchend', this.teamTasksDragListeners.up); document.removeEventListener('touchcancel', this.teamTasksDragListeners.up); } if (this.teamTasksDragListeners.handle) { this.teamTasksDragListeners.handle.removeEventListener('mousedown', this.teamTasksDragListeners.handleMouseDown); this.teamTasksDragListeners.handle.removeEventListener('touchstart', this.teamTasksDragListeners.handleTouchStart); } this.teamTasksDragListeners = null; } }, /** Get teammate color by name */ getTeammateColor(name) { const info = this.teammateMap.get(name); return info?.color || 'blue'; }, // ═══════════════════════════════════════════════════════════════ // Project Insights Panel (Bash Tools with Clickable File Paths) // ═══════════════════════════════════════════════════════════════ /** * Normalize a file path to its canonical form for comparison. * - Expands ~ to home directory approximation * - Resolves relative paths against working directory (case folder) * - Normalizes . and .. components */ normalizeFilePath(path, workingDir) { if (!path) return ''; let normalized = path.trim(); const homeDir = '/home/' + (window.USER || 'user'); // Approximation // Expand ~ to home directory if (normalized.startsWith('~/')) { normalized = homeDir + normalized.slice(1); } else if (normalized === '~') { normalized = homeDir; } // If not absolute, resolve against working directory (case folder) if (!normalized.startsWith('/') && workingDir) { normalized = workingDir + '/' + normalized; } // Normalize path components (resolve . and ..) const parts = normalized.split('/'); const stack = []; for (const part of parts) { if (part === '' || part === '.') { continue; } else if (part === '..') { if (stack.length > 1) { stack.pop(); } } else { stack.push(part); } } return '/' + stack.join('/'); }, /** * Extract just the filename from a path. */ getFilename(path) { const parts = path.split('/'); return parts[parts.length - 1] || ''; }, /** * Check if a path is a "shallow root path" - an absolute path with only one * component after root (e.g., /test.txt, /file.log). * These are often typos where the user meant a relative path in the case folder. */ isShallowRootPath(path) { if (!path.startsWith('/')) return false; const parts = path.split('/').filter(p => p !== ''); return parts.length === 1; }, /** * Check if a path is inside (or is) the working directory (case folder). */ isPathInWorkingDir(path, workingDir) { if (!workingDir) return false; const normalized = this.normalizeFilePath(path, workingDir); return normalized.startsWith(workingDir + '/') || normalized === workingDir; }, /** * Smart path equivalence check. * Two paths are considered equivalent if: * 1. They normalize to the same path (standard case) * 2. One is a "shallow root path" (e.g., /test.txt) and the other is the * same filename inside the case folder - the shallow root path * is likely a typo and they probably meant the same file. */ pathsAreEquivalent(path1, path2, workingDir) { const norm1 = this.normalizeFilePath(path1, workingDir); const norm2 = this.normalizeFilePath(path2, workingDir); // Standard check: exact normalized match if (norm1 === norm2) return true; // Smart check: shallow root path vs case folder path with same filename const file1 = this.getFilename(norm1); const file2 = this.getFilename(norm2); if (file1 !== file2) return false; // Different filenames, can't be equivalent const shallow1 = this.isShallowRootPath(path1); const shallow2 = this.isShallowRootPath(path2); const inWorkDir1 = this.isPathInWorkingDir(norm1, workingDir); const inWorkDir2 = this.isPathInWorkingDir(norm2, workingDir); // If one is shallow root (e.g., /test.txt) and other is in case folder // with same filename, treat as equivalent (user likely made a typo) if (shallow1 && inWorkDir2) return true; if (shallow2 && inWorkDir1) return true; return false; }, /** * Pick the "better" of two paths that resolve to the same file. * Prefers paths inside the case folder, longer/more explicit paths, and absolute paths. */ pickBetterPath(path1, path2, workingDir) { // Prefer paths inside the case folder (working directory) if (workingDir) { const inWorkDir1 = this.isPathInWorkingDir(path1, workingDir); const inWorkDir2 = this.isPathInWorkingDir(path2, workingDir); if (inWorkDir1 && !inWorkDir2) return path1; if (inWorkDir2 && !inWorkDir1) return path2; } // Prefer absolute paths const abs1 = path1.startsWith('/'); const abs2 = path2.startsWith('/'); if (abs1 && !abs2) return path1; if (abs2 && !abs1) return path2; // Both absolute or both relative - prefer longer (more explicit) if (path1.length !== path2.length) { return path1.length > path2.length ? path1 : path2; } // Prefer paths without ~ if (!path1.includes('~') && path2.includes('~')) return path1; if (!path2.includes('~') && path1.includes('~')) return path2; return path1; }, /** * Deduplicate file paths across all tools, keeping the "best" version. * Uses smart equivalence checking: * - Standard normalization for relative vs absolute paths * - Detects likely typos (e.g., /file.txt when caseFolder/file.txt exists) * - Prefers paths inside the case folder (working directory) * - Prefers longer, more explicit paths * Returns a Map of normalized path -> best raw path. */ deduplicateProjectInsightPaths(tools, workingDir) { // Collect all paths with their tool IDs const allPaths = []; for (const tool of tools) { for (const rawPath of tool.filePaths) { allPaths.push({ rawPath, toolId: tool.id }); } } if (allPaths.length <= 1) { const pathMap = new Map(); for (const p of allPaths) { pathMap.set(this.normalizeFilePath(p.rawPath, workingDir), p); } return pathMap; } // Sort paths: prefer paths in case folder first, then by length (longer first) allPaths.sort((a, b) => { const aInWorkDir = this.isPathInWorkingDir(a.rawPath, workingDir); const bInWorkDir = this.isPathInWorkingDir(b.rawPath, workingDir); if (aInWorkDir && !bInWorkDir) return -1; if (bInWorkDir && !aInWorkDir) return 1; return b.rawPath.length - a.rawPath.length; // Longer paths first }); const result = new Map(); // normalized -> { rawPath, toolId } const seenNormalized = new Set(); for (const { rawPath, toolId } of allPaths) { const normalized = this.normalizeFilePath(rawPath, workingDir); // Check if we've already seen an equivalent path let isDuplicate = false; for (const [, existing] of result) { if (this.pathsAreEquivalent(rawPath, existing.rawPath, workingDir)) { isDuplicate = true; break; } } if (!isDuplicate && !seenNormalized.has(normalized)) { result.set(normalized, { rawPath, toolId }); seenNormalized.add(normalized); } } return result; }, handleBashToolStart(sessionId, tool) { let tools = this.projectInsights.get(sessionId) || []; // Add new tool tools = tools.filter(t => t.id !== tool.id); tools.push(tool); this.projectInsights.set(sessionId, tools); this.renderProjectInsightsPanel(); }, handleBashToolEnd(sessionId, tool) { const tools = this.projectInsights.get(sessionId) || []; const existing = tools.find(t => t.id === tool.id); if (existing) { existing.status = 'completed'; } this.renderProjectInsightsPanel(); // Remove after a short delay setTimeout(() => { const current = this.projectInsights.get(sessionId) || []; this.projectInsights.set(sessionId, current.filter(t => t.id !== tool.id)); this.renderProjectInsightsPanel(); }, 2000); }, handleBashToolsUpdate(sessionId, tools) { this.projectInsights.set(sessionId, tools); this.renderProjectInsightsPanel(); }, renderProjectInsightsPanel() { const panel = this.$('projectInsightsPanel'); const list = this.$('projectInsightsList'); if (!panel || !list) return; // Check if panel is enabled in settings const settings = this.loadAppSettingsFromStorage(); const showProjectInsights = settings.showProjectInsights ?? false; if (!showProjectInsights) { panel.classList.remove('visible'); this.projectInsightsPanelVisible = false; return; } // Get tools for active session only const tools = this.projectInsights.get(this.activeSessionId) || []; const runningTools = tools.filter(t => t.status === 'running'); if (runningTools.length === 0) { panel.classList.remove('visible'); this.projectInsightsPanelVisible = false; return; } panel.classList.add('visible'); this.projectInsightsPanelVisible = true; // Get working directory for path normalization const session = this.sessions.get(this.activeSessionId); const workingDir = session?.workingDir || this.currentSessionWorkingDir; // Smart deduplication: collect all unique paths across all tools // Paths that resolve to the same file are deduplicated, keeping the most complete version const deduplicatedPaths = this.deduplicateProjectInsightPaths(runningTools, workingDir); // Build a set of paths to show (only the best version of each unique file) const pathsToShow = new Set(Array.from(deduplicatedPaths.values()).map(p => p.rawPath)); const html = []; for (const tool of runningTools) { // Filter this tool's paths to only include those that weren't deduplicated away const filteredPaths = tool.filePaths.filter(p => pathsToShow.has(p)); // Skip tools with no paths to show (all were duplicates of better paths elsewhere) if (filteredPaths.length === 0) continue; const cmdDisplay = tool.command.length > 50 ? tool.command.substring(0, 50) + '...' : tool.command; html.push(`
💻 ${escapeHtml(cmdDisplay)} ${tool.status} ${tool.timeout ? `${escapeHtml(tool.timeout)}` : ''}
`); for (const path of filteredPaths) { const fileName = path.split('/').pop(); html.push(` ${escapeHtml(fileName)} `); } html.push(`
`); } list.innerHTML = html.join(''); }, closeProjectInsightsPanel() { const panel = this.$('projectInsightsPanel'); if (panel) { panel.classList.remove('visible'); this.projectInsightsPanelVisible = false; } }, // ═══════════════════════════════════════════════════════════════ // File Browser Panel // ═══════════════════════════════════════════════════════════════ // Hidden files/folders (dot-prefixed) are filtered SERVER-side by // GET /api/sessions/:id/files, so the toggle re-fetches rather than // re-rendering the cached tree (issue #221). The flag is per-device and lives // in its own localStorage key instead of the app-settings object: that object // is rebuilt from the settings-modal DOM on every save, so a key toggled from // outside the modal would be dropped the next time settings are saved. _loadFileBrowserShowHidden() { try { return localStorage.getItem(FILE_BROWSER_SHOW_HIDDEN_KEY) === '1'; } catch { return false; } }, _syncFileBrowserHiddenBtn() { const btn = this.$('fileBrowserHiddenBtn'); if (!btn) return; const on = this.fileBrowserShowHidden === true; btn.classList.toggle('active', on); btn.setAttribute('aria-pressed', String(on)); const label = on ? 'Hide hidden files and folders' : 'Show hidden files and folders'; btn.setAttribute('title', label); btn.setAttribute('aria-label', label); }, async toggleFileBrowserHidden() { this.fileBrowserShowHidden = !this.fileBrowserShowHidden; try { localStorage.setItem(FILE_BROWSER_SHOW_HIDDEN_KEY, this.fileBrowserShowHidden ? '1' : '0'); } catch {} this._syncFileBrowserHiddenBtn(); // Expanded-directory state is deliberately preserved so toggling does not // collapse the tree the user just navigated. if (this.activeSessionId) await this.loadFileBrowser(this.activeSessionId); }, async loadFileBrowser(sessionId) { if (!sessionId) return; const treeEl = this.$('fileBrowserTree'); const statusEl = this.$('fileBrowserStatus'); this._syncFileBrowserHiddenBtn(); if (!treeEl) return; // Show loading state treeEl.innerHTML = '
Loading files...
'; try { const showHidden = this.fileBrowserShowHidden === true; const res = await fetch(`/api/sessions/${sessionId}/files?depth=5&showHidden=${showHidden}`); if (!res.ok) throw new Error('Failed to load files'); const result = await res.json(); if (!result.success) throw new Error(result.error || 'Failed to load files'); this.fileBrowserData = result.data; this.renderFileBrowserTree(); // Update status if (statusEl) { const { totalFiles, totalDirectories, truncated } = result.data; statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}${showHidden ? ' · hidden shown' : ''}`; } } catch (err) { console.error('Failed to load file browser:', err); treeEl.innerHTML = `
Failed to load files: ${escapeHtml(err.message)}
`; } }, renderFileBrowserTree() { const treeEl = this.$('fileBrowserTree'); if (!treeEl || !this.fileBrowserData) return; const { tree } = this.fileBrowserData; if (!tree || tree.length === 0) { treeEl.innerHTML = '
No files found
'; return; } const html = []; const filter = this.fileBrowserFilter.toLowerCase(); const renderNode = (node, depth) => { const isDir = node.type === 'directory'; const isExpanded = this.fileBrowserExpandedDirs.has(node.path); const matchesFilter = !filter || node.name.toLowerCase().includes(filter); // For directories, check if any children match let hasMatchingChildren = false; if (isDir && filter && node.children) { hasMatchingChildren = this.hasMatchingChild(node, filter); } const shouldShow = matchesFilter || hasMatchingChildren; const hiddenClass = !shouldShow && filter ? ' hidden-by-filter' : ''; const icon = isDir ? (isExpanded ? '\uD83D\uDCC2' : '\uD83D\uDCC1') : this.getFileIcon(node.extension); const expandIcon = isDir ? `\u25B6` : ''; const sizeStr = !isDir && node.size !== undefined ? `${this.formatFileSize(node.size)}` : ''; const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name'; const downloadBtn = !isDir ? `⬇` : ''; html.push(`
${expandIcon} ${icon} ${escapeHtml(node.name)} ${sizeStr} ${downloadBtn}
`); // Render children if directory is expanded if (isDir && isExpanded && node.children) { for (const child of node.children) { renderNode(child, depth + 1); } } }; for (const node of tree) { renderNode(node, 0); } treeEl.innerHTML = html.join(''); // Add click handlers treeEl.querySelectorAll('.file-tree-item').forEach(item => { item.addEventListener('click', () => { const path = item.dataset.path; const type = item.dataset.type; if (type === 'directory') { this.toggleFileBrowserFolder(path); } else { this.openFilePreview(path); } }); }); }, hasMatchingChild(node, filter) { if (!node.children) return false; for (const child of node.children) { if (child.name.toLowerCase().includes(filter)) return true; if (child.type === 'directory' && this.hasMatchingChild(child, filter)) return true; } return false; }, toggleFileBrowserFolder(path) { if (this.fileBrowserExpandedDirs.has(path)) { this.fileBrowserExpandedDirs.delete(path); } else { this.fileBrowserExpandedDirs.add(path); } this.renderFileBrowserTree(); }, filterFileBrowser(value) { this.fileBrowserFilter = value; // Auto-expand all if filtering if (value) { this.expandAllDirectories(this.fileBrowserData?.tree || []); } this.renderFileBrowserTree(); }, expandAllDirectories(nodes) { for (const node of nodes) { if (node.type === 'directory') { this.fileBrowserExpandedDirs.add(node.path); if (node.children) { this.expandAllDirectories(node.children); } } } }, collapseAllDirectories() { this.fileBrowserExpandedDirs.clear(); }, toggleFileBrowserExpand() { this.fileBrowserAllExpanded = !this.fileBrowserAllExpanded; const btn = this.$('fileBrowserExpandBtn'); if (this.fileBrowserAllExpanded) { this.expandAllDirectories(this.fileBrowserData?.tree || []); if (btn) btn.innerHTML = '\u229F'; // Collapse icon } else { this.collapseAllDirectories(); if (btn) btn.innerHTML = '\u229E'; // Expand icon } this.renderFileBrowserTree(); }, refreshFileBrowser() { if (this.activeSessionId) { this.fileBrowserExpandedDirs.clear(); this.fileBrowserFilter = ''; this.fileBrowserAllExpanded = false; const searchInput = this.$('fileBrowserSearch'); if (searchInput) searchInput.value = ''; this.loadFileBrowser(this.activeSessionId); } }, // Header "File Viewer" button (opt-in via App Settings → Header Displays → // File Viewer). Toggles the file browser panel open/closed without a trip // through settings. Persists via the same `showFileBrowser` flag the Panels // section + the panel's own close (X) use, so the three stay in sync. toggleFileBrowserButton() { const panel = this.$('fileBrowserPanel'); const isOpen = panel?.classList.contains('visible'); const btn = document.querySelector('.btn-file-viewer'); if (isOpen) { this.closeFileBrowserPanel(); if (btn) btn.setAttribute('aria-expanded', 'false'); return; } if (!this.activeSessionId) { this.showToast('Open a session to browse its files', 'info'); return; } const settings = this.loadAppSettingsFromStorage(); settings.showFileBrowser = true; this.saveAppSettingsToStorage(settings); const checkbox = document.getElementById('appSettingsShowFileBrowser'); if (checkbox) checkbox.checked = true; this.applyMonitorVisibility(); if (btn) btn.setAttribute('aria-expanded', 'true'); }, closeFileBrowserPanel() { const panel = this.$('fileBrowserPanel'); if (panel) { panel.classList.remove('visible'); // Reset position so it reopens at default location panel.style.left = ''; panel.style.top = ''; panel.style.bottom = ''; panel.style.right = ''; } // Clean up drag listeners if (this.fileBrowserDragListeners) { const dl = this.fileBrowserDragListeners; document.removeEventListener('mousemove', dl.move); document.removeEventListener('mouseup', dl.up); document.removeEventListener('touchmove', dl.touchMove); document.removeEventListener('touchend', dl.up); document.removeEventListener('touchcancel', dl.up); if (dl.handle) { dl.handle.removeEventListener('mousedown', dl.handleMouseDown); dl.handle.removeEventListener('touchstart', dl.handleTouchStart); if (dl._onFirstDrag) { dl.handle.removeEventListener('mousedown', dl._onFirstDrag); dl.handle.removeEventListener('touchstart', dl._onFirstDrag); } } this.fileBrowserDragListeners = null; } // Save setting const settings = this.loadAppSettingsFromStorage(); settings.showFileBrowser = false; this.saveAppSettingsToStorage(settings); const checkbox = document.getElementById('appSettingsShowFileBrowser'); if (checkbox) checkbox.checked = false; const headerBtn = document.querySelector('.btn-file-viewer'); if (headerBtn) headerBtn.setAttribute('aria-expanded', 'false'); }, /** * Whether a path is absolute and provably OUTSIDE this session's workspace. * * `file-content` / `file-raw` resolve every path against `workingDir` and * refuse anything that escapes it, so an absolute path elsewhere on the host * (an agent's `/tmp` scratchpad capture, a screenshot, another checkout) can * only ever 404 there — it has to go through the attachment routes instead. * * A string compare is enough for ROUTING; the real containment decision stays * server-side (realpath + guard) on whichever route the request lands on. An * unknown workingDir answers false, leaving the historical path untouched. */ _isExternalPreviewPath(filePath, sessionId) { if (typeof filePath !== 'string' || !filePath.startsWith('/')) return false; const workingDir = this.sessions.get(sessionId)?.workingDir; if (!workingDir) return false; const root = workingDir.endsWith('/') ? workingDir : `${workingDir}/`; return filePath !== workingDir && !filePath.startsWith(root); }, /** * Register an out-of-workspace path as a live external attachment and return * its id, so the preview can render it through the by-id attachment routes. * * `notify: false` keeps this quiet: the caller is already opening the file in * the overlay, so the usual attachment card + unread badge would be noise on * top of the thing the user just asked to see. The server still enforces the * full attachment guard (blocked secret trees, extension allowlist, symlinks * resolved), so a refusal here is a policy answer worth showing verbatim. * * @returns {Promise<{attachmentId?: string, size?: number, error?: string}>} */ async _registerExternalPreview(filePath, sessionId) { try { const res = await fetch(`/api/sessions/${sessionId}/attachments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: filePath, notify: false }), }); const result = await res.json().catch(() => null); if (res.ok && result?.success && result.data?.attachmentId) { return { attachmentId: result.data.attachmentId, size: result.data.size || 0 }; } const reason = result?.error || `Cannot open this file (HTTP ${res.status})`; // The registry's type answer is a policy term, not an explanation, and the // user just clicked a file they can see on disk. Say what IS previewable // from outside the workspace instead. if (/unsupported/i.test(reason)) { const ext = (filePath.split('.').pop() || '').toLowerCase(); return { error: `Cannot preview .${ext} from outside the session workspace (images, video, audio, PDF, Office documents and text files only).`, }; } return { error: reason }; } catch (err) { return { error: err.message || 'Cannot open this file' }; } }, async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) { if (!sessionId || !filePath) return; const overlay = this.$('filePreviewOverlay'); const titleEl = this.$('filePreviewTitle'); const bodyEl = this.$('filePreviewBody'); const footerEl = this.$('filePreviewFooter'); if (!overlay || !bodyEl) return; // Edit mode: reset any prior editor state whenever a preview (re)loads. this._resetFilePreviewEdit(); // Stop whatever the previous preview was playing. Overwriting innerHTML // only DETACHES a