mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
- Single agent: "AGENT" - Multiple: "AGENTS (2)", "AGENTS (3)", etc. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
6849 lines
238 KiB
JavaScript
6849 lines
238 KiB
JavaScript
// Claudeman App - Tab-based Terminal UI
|
||
|
||
// ============================================================================
|
||
// Constants
|
||
// ============================================================================
|
||
|
||
// Default terminal scrollback (can be changed via settings)
|
||
const DEFAULT_SCROLLBACK = 5000;
|
||
|
||
// Timing constants
|
||
const STUCK_THRESHOLD_DEFAULT_MS = 600000; // 10 minutes - default for stuck detection
|
||
const GROUPING_TIMEOUT_MS = 5000; // 5 seconds - notification grouping window
|
||
const NOTIFICATION_LIST_CAP = 100; // Max notifications in list
|
||
const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate
|
||
const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications
|
||
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
|
||
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
|
||
const TERMINAL_CHUNK_SIZE = 64 * 1024; // 64KB chunks for terminal data
|
||
const TERMINAL_TAIL_SIZE = 256 * 1024; // 256KB tail for initial load
|
||
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
|
||
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
|
||
|
||
// DEC mode 2026 - Synchronized Output
|
||
// Wrap terminal writes with these markers to prevent partial-frame flicker.
|
||
// Terminal buffers all output between markers and renders atomically.
|
||
// Supported by: WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal
|
||
// xterm.js doesn't support DEC 2026 natively, so we implement buffering ourselves.
|
||
const DEC_SYNC_START = '\x1b[?2026h';
|
||
const DEC_SYNC_END = '\x1b[?2026l';
|
||
|
||
/**
|
||
* Process data containing DEC 2026 sync markers.
|
||
* Strips markers and returns segments that should be written atomically.
|
||
* Each returned segment represents content between SYNC_START and SYNC_END.
|
||
* Content outside sync blocks is returned as-is.
|
||
*
|
||
* @param {string} data - Raw terminal data with potential sync markers
|
||
* @returns {string[]} - Array of content segments to write (markers stripped)
|
||
*/
|
||
function extractSyncSegments(data) {
|
||
const segments = [];
|
||
let remaining = data;
|
||
|
||
while (remaining.length > 0) {
|
||
const startIdx = remaining.indexOf(DEC_SYNC_START);
|
||
|
||
if (startIdx === -1) {
|
||
// No more sync blocks, return rest as-is
|
||
if (remaining.length > 0) {
|
||
segments.push(remaining);
|
||
}
|
||
break;
|
||
}
|
||
|
||
// Content before sync block (if any)
|
||
if (startIdx > 0) {
|
||
segments.push(remaining.slice(0, startIdx));
|
||
}
|
||
|
||
// Find matching end marker
|
||
const afterStart = remaining.slice(startIdx + DEC_SYNC_START.length);
|
||
const endIdx = afterStart.indexOf(DEC_SYNC_END);
|
||
|
||
if (endIdx === -1) {
|
||
// No end marker found - sync block continues in next chunk
|
||
// Include the start marker so it can be handled when more data arrives
|
||
segments.push(remaining.slice(startIdx));
|
||
break;
|
||
}
|
||
|
||
// Extract synchronized content (without markers)
|
||
const syncContent = afterStart.slice(0, endIdx);
|
||
if (syncContent.length > 0) {
|
||
segments.push(syncContent);
|
||
}
|
||
|
||
// Continue with content after end marker
|
||
remaining = afterStart.slice(endIdx + DEC_SYNC_END.length);
|
||
}
|
||
|
||
return segments;
|
||
}
|
||
|
||
// Notification Manager - Multi-layer browser notification system
|
||
class NotificationManager {
|
||
constructor(app) {
|
||
this.app = app;
|
||
this.notifications = [];
|
||
this.unreadCount = 0;
|
||
this.isTabVisible = !document.hidden;
|
||
this.isDrawerOpen = false;
|
||
this.originalTitle = document.title;
|
||
this.titleFlashInterval = null;
|
||
this.titleFlashState = false;
|
||
this.lastBrowserNotifTime = 0;
|
||
this.audioCtx = null;
|
||
this.renderScheduled = false;
|
||
|
||
// Debounce grouping: Map<key, {notification, timeout}>
|
||
this.groupingMap = new Map();
|
||
|
||
// Load preferences
|
||
this.preferences = this.loadPreferences();
|
||
|
||
// Visibility tracking
|
||
document.addEventListener('visibilitychange', () => {
|
||
this.isTabVisible = !document.hidden;
|
||
if (this.isTabVisible) {
|
||
this.onTabVisible();
|
||
}
|
||
});
|
||
}
|
||
|
||
loadPreferences() {
|
||
const defaults = {
|
||
enabled: true,
|
||
browserNotifications: true,
|
||
audioAlerts: false,
|
||
stuckThresholdMs: STUCK_THRESHOLD_DEFAULT_MS,
|
||
muteCritical: false,
|
||
muteWarning: false,
|
||
muteInfo: false,
|
||
_version: 2,
|
||
};
|
||
try {
|
||
const saved = localStorage.getItem('claudeman-notification-prefs');
|
||
if (saved) {
|
||
const prefs = JSON.parse(saved);
|
||
// Migrate: v1 had browserNotifications defaulting to false
|
||
if (!prefs._version || prefs._version < 2) {
|
||
prefs.browserNotifications = true;
|
||
prefs._version = 2;
|
||
localStorage.setItem('claudeman-notification-prefs', JSON.stringify(prefs));
|
||
}
|
||
return { ...defaults, ...prefs };
|
||
}
|
||
} catch (_e) { /* ignore */ }
|
||
return defaults;
|
||
}
|
||
|
||
savePreferences() {
|
||
localStorage.setItem('claudeman-notification-prefs', JSON.stringify(this.preferences));
|
||
}
|
||
|
||
notify({ urgency, category, sessionId, sessionName, title, message }) {
|
||
if (!this.preferences.enabled) return;
|
||
|
||
// Check urgency muting
|
||
if (urgency === 'critical' && this.preferences.muteCritical) return;
|
||
if (urgency === 'warning' && this.preferences.muteWarning) return;
|
||
if (urgency === 'info' && this.preferences.muteInfo) return;
|
||
|
||
// Grouping: same category+session within 5s updates count instead of new entry
|
||
const groupKey = `${category}:${sessionId || 'global'}`;
|
||
const existing = this.groupingMap.get(groupKey);
|
||
if (existing) {
|
||
existing.notification.count = (existing.notification.count || 1) + 1;
|
||
existing.notification.message = message;
|
||
existing.notification.timestamp = Date.now();
|
||
clearTimeout(existing.timeout);
|
||
existing.timeout = setTimeout(() => this.groupingMap.delete(groupKey), GROUPING_TIMEOUT_MS);
|
||
this.scheduleRender();
|
||
return;
|
||
}
|
||
|
||
const notification = {
|
||
id: Date.now() + '-' + Math.random().toString(36).slice(2, 7),
|
||
urgency,
|
||
category,
|
||
sessionId,
|
||
sessionName,
|
||
title,
|
||
message,
|
||
timestamp: Date.now(),
|
||
read: false,
|
||
count: 1,
|
||
};
|
||
|
||
// Add to log (cap at 100)
|
||
this.notifications.unshift(notification);
|
||
if (this.notifications.length > 100) this.notifications.pop();
|
||
|
||
// Track for grouping
|
||
const timeout = setTimeout(() => this.groupingMap.delete(groupKey), GROUPING_TIMEOUT_MS);
|
||
this.groupingMap.set(groupKey, { notification, timeout });
|
||
|
||
// Update unread
|
||
this.unreadCount++;
|
||
this.updateBadge();
|
||
this.scheduleRender();
|
||
|
||
// Layer 2: Tab title (when tab unfocused)
|
||
if (!this.isTabVisible) {
|
||
this.updateTabTitle();
|
||
}
|
||
|
||
// Layer 3: Browser notification (critical/warning always, info only when tab hidden)
|
||
if (urgency === 'critical' || urgency === 'warning' || !this.isTabVisible) {
|
||
this.sendBrowserNotif(title, message, category, sessionId);
|
||
}
|
||
|
||
// Layer 4: Audio alert (critical only)
|
||
if (urgency === 'critical' && this.preferences.audioAlerts) {
|
||
this.playAudioAlert();
|
||
}
|
||
}
|
||
|
||
// Layer 1: Drawer rendering
|
||
scheduleRender() {
|
||
if (this.renderScheduled) return;
|
||
this.renderScheduled = true;
|
||
requestAnimationFrame(() => {
|
||
this.renderScheduled = false;
|
||
this.renderDrawer();
|
||
});
|
||
}
|
||
|
||
renderDrawer() {
|
||
const list = document.getElementById('notifList');
|
||
const empty = document.getElementById('notifEmpty');
|
||
if (!list || !empty) return;
|
||
|
||
if (this.notifications.length === 0) {
|
||
list.style.display = 'none';
|
||
empty.style.display = 'flex';
|
||
return;
|
||
}
|
||
|
||
list.style.display = 'block';
|
||
empty.style.display = 'none';
|
||
|
||
list.innerHTML = this.notifications.map(n => {
|
||
const urgencyClass = `notif-item-${n.urgency}`;
|
||
const readClass = n.read ? '' : ' unread';
|
||
const countLabel = n.count > 1 ? `<span class="notif-item-count">×${n.count}</span>` : '';
|
||
const sessionChip = n.sessionName ? `<span class="notif-item-session">${this.escapeHtml(n.sessionName)}</span>` : '';
|
||
return `<div class="notif-item ${urgencyClass}${readClass}" data-notif-id="${n.id}" data-session-id="${n.sessionId || ''}" onclick="app.notificationManager.clickNotification('${n.id}')">
|
||
<div class="notif-item-header">
|
||
<span class="notif-item-title">${this.escapeHtml(n.title)}${countLabel}</span>
|
||
<span class="notif-item-time">${this.relativeTime(n.timestamp)}</span>
|
||
</div>
|
||
<div class="notif-item-message">${this.escapeHtml(n.message)}</div>
|
||
${sessionChip}
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
// Layer 2: Tab title with unread count
|
||
updateTabTitle() {
|
||
if (this.unreadCount > 0 && !this.isTabVisible) {
|
||
if (!this.titleFlashInterval) {
|
||
this.titleFlashInterval = setInterval(() => {
|
||
this.titleFlashState = !this.titleFlashState;
|
||
document.title = this.titleFlashState
|
||
? `\u26A0\uFE0F (${this.unreadCount}) Claudeman`
|
||
: this.originalTitle;
|
||
}, TITLE_FLASH_INTERVAL_MS);
|
||
// Set immediately
|
||
document.title = `\u26A0\uFE0F (${this.unreadCount}) Claudeman`;
|
||
}
|
||
}
|
||
}
|
||
|
||
stopTitleFlash() {
|
||
if (this.titleFlashInterval) {
|
||
clearInterval(this.titleFlashInterval);
|
||
this.titleFlashInterval = null;
|
||
this.titleFlashState = false;
|
||
document.title = this.originalTitle;
|
||
}
|
||
}
|
||
|
||
// Layer 3: Web Notification API
|
||
sendBrowserNotif(title, body, tag, sessionId) {
|
||
if (!this.preferences.browserNotifications) return;
|
||
if (typeof Notification === 'undefined') return;
|
||
if (Notification.permission === 'default') {
|
||
// Auto-request on first notification attempt
|
||
Notification.requestPermission().then(result => {
|
||
if (result === 'granted') {
|
||
// Re-send this notification now that we have permission
|
||
this.sendBrowserNotif(title, body, tag, sessionId);
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
if (Notification.permission !== 'granted') return;
|
||
|
||
// Rate limit: max 1 per 3 seconds
|
||
const now = Date.now();
|
||
if (now - this.lastBrowserNotifTime < 3000) return;
|
||
this.lastBrowserNotifTime = now;
|
||
|
||
const notif = new Notification(`Claudeman: ${title}`, {
|
||
body,
|
||
tag, // Groups same-tag notifications
|
||
icon: '/favicon.ico',
|
||
silent: true, // We handle audio ourselves
|
||
});
|
||
|
||
notif.onclick = () => {
|
||
window.focus();
|
||
if (sessionId && this.app.sessions.has(sessionId)) {
|
||
this.app.selectSession(sessionId);
|
||
}
|
||
notif.close();
|
||
};
|
||
|
||
// Auto-close after 8s
|
||
setTimeout(() => notif.close(), 8000);
|
||
}
|
||
|
||
async requestPermission() {
|
||
if (typeof Notification === 'undefined') {
|
||
this.app.showToast('Browser notifications not supported', 'warning');
|
||
return;
|
||
}
|
||
const result = await Notification.requestPermission();
|
||
const statusEl = document.getElementById('notifPermissionStatus');
|
||
if (statusEl) statusEl.textContent = `Status: ${result}`;
|
||
if (result === 'granted') {
|
||
this.preferences.browserNotifications = true;
|
||
this.savePreferences();
|
||
this.app.showToast('Notifications enabled', 'success');
|
||
} else {
|
||
this.app.showToast(`Permission ${result}`, 'warning');
|
||
}
|
||
}
|
||
|
||
// Layer 4: Audio alert via Web Audio API
|
||
playAudioAlert() {
|
||
try {
|
||
if (!this.audioCtx) {
|
||
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||
}
|
||
const ctx = this.audioCtx;
|
||
const oscillator = ctx.createOscillator();
|
||
const gain = ctx.createGain();
|
||
oscillator.connect(gain);
|
||
gain.connect(ctx.destination);
|
||
oscillator.type = 'sine';
|
||
oscillator.frequency.setValueAtTime(660, ctx.currentTime);
|
||
gain.gain.setValueAtTime(0.15, ctx.currentTime);
|
||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15);
|
||
oscillator.start(ctx.currentTime);
|
||
oscillator.stop(ctx.currentTime + 0.15);
|
||
} catch (_e) { /* Audio not available */ }
|
||
}
|
||
|
||
// UI interactions
|
||
toggleDrawer() {
|
||
const drawer = document.getElementById('notifDrawer');
|
||
if (!drawer) return;
|
||
this.isDrawerOpen = !this.isDrawerOpen;
|
||
drawer.classList.toggle('open', this.isDrawerOpen);
|
||
if (this.isDrawerOpen) {
|
||
this.renderDrawer();
|
||
}
|
||
}
|
||
|
||
clickNotification(notifId) {
|
||
const notif = this.notifications.find(n => n.id === notifId);
|
||
if (!notif) return;
|
||
|
||
// Mark as read
|
||
if (!notif.read) {
|
||
notif.read = true;
|
||
this.unreadCount = Math.max(0, this.unreadCount - 1);
|
||
this.updateBadge();
|
||
}
|
||
|
||
// Switch to session if available
|
||
if (notif.sessionId && this.app.sessions.has(notif.sessionId)) {
|
||
this.app.selectSession(notif.sessionId);
|
||
this.toggleDrawer();
|
||
}
|
||
|
||
this.scheduleRender();
|
||
}
|
||
|
||
markAllRead() {
|
||
this.notifications.forEach(n => { n.read = true; });
|
||
this.unreadCount = 0;
|
||
this.updateBadge();
|
||
this.stopTitleFlash();
|
||
this.scheduleRender();
|
||
}
|
||
|
||
clearAll() {
|
||
this.notifications = [];
|
||
this.unreadCount = 0;
|
||
this.updateBadge();
|
||
this.stopTitleFlash();
|
||
this.scheduleRender();
|
||
}
|
||
|
||
updateBadge() {
|
||
const badge = document.getElementById('notifBadge');
|
||
if (!badge) return;
|
||
if (this.unreadCount > 0) {
|
||
badge.style.display = 'flex';
|
||
badge.textContent = this.unreadCount > 99 ? '99+' : String(this.unreadCount);
|
||
} else {
|
||
badge.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
onTabVisible() {
|
||
this.stopTitleFlash();
|
||
// If drawer is open, mark all as read
|
||
if (this.isDrawerOpen) {
|
||
this.markAllRead();
|
||
}
|
||
}
|
||
|
||
// Utilities
|
||
relativeTime(ts) {
|
||
const diff = Math.floor((Date.now() - ts) / 1000);
|
||
if (diff < 60) return 'now';
|
||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||
return `${Math.floor(diff / 86400)}d ago`;
|
||
}
|
||
|
||
escapeHtml(text) {
|
||
if (!text) return '';
|
||
return text.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
||
}
|
||
}
|
||
|
||
class ClaudemanApp {
|
||
constructor() {
|
||
this.sessions = new Map();
|
||
this.cases = [];
|
||
this.currentRun = null;
|
||
this.totalTokens = 0;
|
||
this.globalStats = null; // Global token/cost stats across all sessions
|
||
this.eventSource = null;
|
||
this.terminal = null;
|
||
this.fitAddon = null;
|
||
this.activeSessionId = null;
|
||
this.respawnStatus = {};
|
||
this.respawnTimers = {}; // Track timed respawn timers
|
||
this.respawnCountdownTimers = {}; // { sessionId: { timerName: { endsAt, totalMs, reason } } }
|
||
this.respawnActionLogs = {}; // { sessionId: [action, action, ...] } (max 20)
|
||
this.timerCountdownInterval = null; // Interval for updating countdown display
|
||
this.terminalBuffers = new Map(); // Store terminal content per session
|
||
this.editingSessionId = null; // Session being edited in options modal
|
||
this.pendingCloseSessionId = null; // Session pending close confirmation
|
||
this.screenSessions = []; // Screen sessions for process monitor
|
||
|
||
// Ralph loop/todo state per session
|
||
this.ralphStates = new Map(); // Map<sessionId, { loop, todos }>
|
||
|
||
// Subagent (Claude Code background agent) tracking
|
||
this.subagents = new Map(); // Map<agentId, SubagentInfo>
|
||
this.subagentActivity = new Map(); // Map<agentId, activity[]> - recent tool calls/progress
|
||
this.subagentToolResults = new Map(); // Map<agentId, Map<toolUseId, result>> - tool results by toolUseId
|
||
this.activeSubagentId = null; // Currently selected subagent for detail view
|
||
this.subagentPanelVisible = false;
|
||
this.subagentWindows = new Map(); // Map<agentId, { element, position }>
|
||
this.subagentWindowZIndex = 1000;
|
||
this.minimizedSubagents = new Map(); // Map<sessionId, Set<agentId>> - minimized to tab
|
||
this._subagentHideTimeout = null; // Timeout for hover-based dropdown hide
|
||
this.ralphStatePanelCollapsed = true; // Default to collapsed
|
||
|
||
// Project Insights tracking (active Bash tools with clickable file paths)
|
||
this.projectInsights = new Map(); // Map<sessionId, ActiveBashTool[]>
|
||
this.logViewerWindows = new Map(); // Map<windowId, { element, eventSource, filePath }>
|
||
this.logViewerWindowZIndex = 2000;
|
||
this.projectInsightsPanelVisible = false;
|
||
this.currentSessionWorkingDir = null; // Track current session's working dir for path normalization
|
||
|
||
// Tab alert states: Map<sessionId, 'action' | 'idle'>
|
||
this.tabAlerts = new Map();
|
||
|
||
// Terminal write batching with DEC 2026 sync support
|
||
this.pendingWrites = '';
|
||
this.writeFrameScheduled = false;
|
||
this._wasAtBottomBeforeWrite = true; // Default to true for sticky scroll
|
||
this.syncWaitTimeout = null; // Timeout for incomplete sync blocks
|
||
|
||
// Render debouncing
|
||
this.renderSessionTabsTimeout = null;
|
||
this.renderRalphStatePanelTimeout = null;
|
||
this.renderTaskPanelTimeout = null;
|
||
this.renderScreenSessionsTimeout = null;
|
||
|
||
// System stats polling
|
||
this.systemStatsInterval = null;
|
||
|
||
// Notification system
|
||
this.notificationManager = new NotificationManager(this);
|
||
this.idleTimers = new Map(); // Map<sessionId, timeout> for stuck detection
|
||
|
||
// DOM element cache for performance (avoid repeated getElementById calls)
|
||
this._elemCache = {};
|
||
|
||
this.init();
|
||
}
|
||
|
||
// Cached element getter - avoids repeated DOM queries
|
||
$(id) {
|
||
if (!this._elemCache[id]) {
|
||
this._elemCache[id] = document.getElementById(id);
|
||
}
|
||
return this._elemCache[id];
|
||
}
|
||
|
||
// Format token count: 1000k -> 1m, 1450k -> 1.45m, 500 -> 500
|
||
formatTokens(count) {
|
||
if (count >= 1000000) {
|
||
const m = count / 1000000;
|
||
return m >= 10 ? `${m.toFixed(1)}m` : `${m.toFixed(2)}m`;
|
||
} else if (count >= 1000) {
|
||
const k = count / 1000;
|
||
return k >= 100 ? `${k.toFixed(0)}k` : `${k.toFixed(1)}k`;
|
||
}
|
||
return String(count);
|
||
}
|
||
|
||
// Estimate cost from tokens using Claude Opus pricing
|
||
// Input: $15/M tokens, Output: $75/M tokens
|
||
estimateCost(inputTokens, outputTokens) {
|
||
const inputCost = (inputTokens / 1000000) * 15;
|
||
const outputCost = (outputTokens / 1000000) * 75;
|
||
return inputCost + outputCost;
|
||
}
|
||
|
||
init() {
|
||
this.initTerminal();
|
||
this.loadFontSize();
|
||
this.applyHeaderVisibilitySettings();
|
||
this.applyMonitorVisibility();
|
||
this.connectSSE();
|
||
this.loadState();
|
||
this.loadQuickStartCases();
|
||
this.setupEventListeners();
|
||
// Start system stats polling
|
||
this.startSystemStatsPolling();
|
||
// Load server-stored settings (async, re-applies visibility after load)
|
||
this.loadAppSettingsFromServer().then(() => {
|
||
this.applyHeaderVisibilitySettings();
|
||
this.applyMonitorVisibility();
|
||
});
|
||
}
|
||
|
||
initTerminal() {
|
||
// Load scrollback setting from localStorage (default 5000)
|
||
const scrollback = parseInt(localStorage.getItem('claudeman-scrollback')) || DEFAULT_SCROLLBACK;
|
||
|
||
this.terminal = new Terminal({
|
||
theme: {
|
||
background: '#0d0d0d',
|
||
foreground: '#e0e0e0',
|
||
cursor: '#e0e0e0',
|
||
cursorAccent: '#0d0d0d',
|
||
selection: 'rgba(255, 255, 255, 0.3)',
|
||
black: '#0d0d0d',
|
||
red: '#ff6b6b',
|
||
green: '#51cf66',
|
||
yellow: '#ffd43b',
|
||
blue: '#339af0',
|
||
magenta: '#cc5de8',
|
||
cyan: '#22b8cf',
|
||
white: '#e0e0e0',
|
||
brightBlack: '#495057',
|
||
brightRed: '#ff8787',
|
||
brightGreen: '#69db7c',
|
||
brightYellow: '#ffe066',
|
||
brightBlue: '#5c7cfa',
|
||
brightMagenta: '#da77f2',
|
||
brightCyan: '#66d9e8',
|
||
brightWhite: '#ffffff',
|
||
},
|
||
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
|
||
fontSize: 14,
|
||
lineHeight: 1.2,
|
||
cursorBlink: true,
|
||
cursorStyle: 'block',
|
||
scrollback: scrollback,
|
||
allowTransparency: true,
|
||
});
|
||
|
||
this.fitAddon = new FitAddon.FitAddon();
|
||
this.terminal.loadAddon(this.fitAddon);
|
||
|
||
const container = document.getElementById('terminalContainer');
|
||
this.terminal.open(container);
|
||
this.fitAddon.fit();
|
||
|
||
// Register link provider for clickable file paths in Bash tool output
|
||
this.registerFilePathLinkProvider();
|
||
|
||
// Always use mouse wheel for terminal scrollback, never forward to application.
|
||
// Prevents Claude's Ink UI (plan mode selector) from capturing scroll as option navigation.
|
||
container.addEventListener('wheel', (ev) => {
|
||
ev.preventDefault();
|
||
const lines = Math.round(ev.deltaY / 25) || (ev.deltaY > 0 ? 1 : -1);
|
||
this.terminal.scrollLines(lines);
|
||
}, { passive: false });
|
||
|
||
// Welcome message
|
||
this.showWelcome();
|
||
|
||
// Handle resize with throttling for performance
|
||
this._resizeTimeout = null;
|
||
this._lastResizeDims = null;
|
||
|
||
const throttledResize = () => {
|
||
if (this._resizeTimeout) return;
|
||
this._resizeTimeout = setTimeout(() => {
|
||
this._resizeTimeout = null;
|
||
if (this.fitAddon) {
|
||
this.fitAddon.fit();
|
||
if (this.activeSessionId) {
|
||
const dims = this.fitAddon.proposeDimensions();
|
||
// Only send resize if dimensions actually changed
|
||
if (dims && (!this._lastResizeDims ||
|
||
dims.cols !== this._lastResizeDims.cols ||
|
||
dims.rows !== this._lastResizeDims.rows)) {
|
||
this._lastResizeDims = { cols: dims.cols, rows: dims.rows };
|
||
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ cols: dims.cols, rows: dims.rows })
|
||
});
|
||
}
|
||
}
|
||
}
|
||
// Update subagent connection lines when viewport resizes
|
||
this.updateConnectionLines();
|
||
}, 100); // Throttle to 100ms
|
||
};
|
||
|
||
window.addEventListener('resize', throttledResize);
|
||
const resizeObserver = new ResizeObserver(throttledResize);
|
||
resizeObserver.observe(container);
|
||
|
||
// Handle keyboard input with batching for rapid keystrokes
|
||
this._pendingInput = '';
|
||
this._inputFlushTimeout = null;
|
||
this._inputFlushDelay = 16; // Flush at 60fps max
|
||
|
||
const flushInput = () => {
|
||
if (this._pendingInput && this.activeSessionId) {
|
||
const input = this._pendingInput;
|
||
this._pendingInput = '';
|
||
fetch(`/api/sessions/${this.activeSessionId}/input`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ input })
|
||
});
|
||
}
|
||
this._inputFlushTimeout = null;
|
||
};
|
||
|
||
this.terminal.onData((data) => {
|
||
if (this.activeSessionId) {
|
||
this._pendingInput += data;
|
||
|
||
// Flush immediately for control characters (Enter, Ctrl+C, etc.)
|
||
if (data.charCodeAt(0) < 32 || data.length > 1) {
|
||
if (this._inputFlushTimeout) {
|
||
clearTimeout(this._inputFlushTimeout);
|
||
this._inputFlushTimeout = null;
|
||
}
|
||
flushInput();
|
||
return;
|
||
}
|
||
|
||
// Batch regular input at 60fps
|
||
if (!this._inputFlushTimeout) {
|
||
this._inputFlushTimeout = setTimeout(flushInput, this._inputFlushDelay);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Register a custom link provider for xterm.js that detects file paths
|
||
* in terminal output and makes them clickable.
|
||
* When clicked, opens a floating log viewer window with live streaming.
|
||
*/
|
||
registerFilePathLinkProvider() {
|
||
const self = this;
|
||
|
||
// Debug: Track if provider is being invoked
|
||
let lastInvokedLine = -1;
|
||
|
||
this.terminal.registerLinkProvider({
|
||
provideLinks(bufferLineNumber, callback) {
|
||
// Debug logging - only log if line changed to avoid spam
|
||
if (bufferLineNumber !== lastInvokedLine) {
|
||
lastInvokedLine = bufferLineNumber;
|
||
console.debug('[LinkProvider] Checking line:', bufferLineNumber);
|
||
}
|
||
|
||
const buffer = self.terminal.buffer.active;
|
||
const line = buffer.getLine(bufferLineNumber);
|
||
|
||
if (!line) {
|
||
callback(undefined);
|
||
return;
|
||
}
|
||
|
||
// Get line text - translateToString handles wrapped lines
|
||
const lineText = line.translateToString(true);
|
||
|
||
if (!lineText || !lineText.includes('/')) {
|
||
callback(undefined);
|
||
return;
|
||
}
|
||
|
||
const links = [];
|
||
|
||
// Pattern 1: Commands with file paths (tail -f, cat, head, grep pattern, etc.)
|
||
// Handles: tail -f /path, grep pattern /path, cat -n /path
|
||
const cmdPattern = /(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]*\s+)*(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
|
||
|
||
// Pattern 2: Paths with common extensions
|
||
const extPattern = /(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
|
||
|
||
// Pattern 3: Bash() tool output
|
||
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
|
||
|
||
const addLink = (filePath, matchIndex) => {
|
||
const startCol = lineText.indexOf(filePath, matchIndex);
|
||
if (startCol === -1) return;
|
||
|
||
// Skip if already have link at this position
|
||
if (links.some(l => l.range.start.x === startCol + 1)) return;
|
||
|
||
links.push({
|
||
text: filePath,
|
||
range: {
|
||
start: { x: startCol + 1, y: bufferLineNumber }, // 1-based
|
||
end: { x: startCol + filePath.length + 1, y: bufferLineNumber }
|
||
},
|
||
decorations: {
|
||
pointerCursor: true,
|
||
underline: true
|
||
},
|
||
activate(event, text) {
|
||
self.openLogViewerWindow(text, self.activeSessionId);
|
||
}
|
||
});
|
||
};
|
||
|
||
// Match all patterns
|
||
let match;
|
||
|
||
cmdPattern.lastIndex = 0;
|
||
while ((match = cmdPattern.exec(lineText)) !== null) {
|
||
addLink(match[2], match.index);
|
||
}
|
||
|
||
extPattern.lastIndex = 0;
|
||
while ((match = extPattern.exec(lineText)) !== null) {
|
||
addLink(match[1], match.index);
|
||
}
|
||
|
||
bashPattern.lastIndex = 0;
|
||
while ((match = bashPattern.exec(lineText)) !== null) {
|
||
addLink(match[1], match.index);
|
||
}
|
||
|
||
if (links.length > 0) {
|
||
console.debug('[LinkProvider] Found links:', links.map(l => l.text));
|
||
}
|
||
callback(links.length > 0 ? links : undefined);
|
||
}
|
||
});
|
||
|
||
console.log('[LinkProvider] File path link provider registered');
|
||
}
|
||
|
||
showWelcome() {
|
||
this.terminal.writeln('\x1b[1;36m Claudeman Terminal\x1b[0m');
|
||
this.terminal.writeln('');
|
||
this.terminal.writeln('\x1b[90m Each instance opens a persistent GNU Screen session running Claude or Shell.\x1b[0m');
|
||
this.terminal.writeln('\x1b[90m Sessions stay alive for autonomous work, even if you close this browser.\x1b[0m');
|
||
this.terminal.writeln('\x1b[90m Use the +/- controls to set how many instances to launch at once.\x1b[0m');
|
||
this.terminal.writeln('');
|
||
this.terminal.writeln('\x1b[90m Press \x1b[1;37mCtrl+Enter\x1b[0m\x1b[90m to start Claude\x1b[0m');
|
||
this.terminal.writeln('');
|
||
}
|
||
|
||
/**
|
||
* Check if terminal viewport is at or near the bottom.
|
||
* Used to implement "sticky scroll" behavior - keep user at bottom if they were there.
|
||
*/
|
||
isTerminalAtBottom() {
|
||
if (!this.terminal) return true;
|
||
const buffer = this.terminal.buffer.active;
|
||
// viewportY is the top line of the viewport, baseY is where scrollback starts
|
||
// If viewportY >= baseY, we're showing the latest content (at bottom)
|
||
// Allow 2 lines tolerance for edge cases
|
||
return buffer.viewportY >= buffer.baseY - 2;
|
||
}
|
||
|
||
batchTerminalWrite(data) {
|
||
// Check if at bottom BEFORE adding data (captures user's scroll position)
|
||
// Only update if not already scheduled (preserve the first check's result)
|
||
if (!this.writeFrameScheduled) {
|
||
this._wasAtBottomBeforeWrite = this.isTerminalAtBottom();
|
||
}
|
||
|
||
// Accumulate raw data (may contain DEC 2026 markers)
|
||
this.pendingWrites += data;
|
||
|
||
if (!this.writeFrameScheduled) {
|
||
this.writeFrameScheduled = true;
|
||
requestAnimationFrame(() => {
|
||
if (this.pendingWrites && this.terminal) {
|
||
// Check if we have an incomplete sync block (SYNC_START without SYNC_END)
|
||
const hasStart = this.pendingWrites.includes(DEC_SYNC_START);
|
||
const hasEnd = this.pendingWrites.includes(DEC_SYNC_END);
|
||
|
||
if (hasStart && !hasEnd) {
|
||
// Incomplete sync block - wait for more data (up to 50ms max)
|
||
if (!this.syncWaitTimeout) {
|
||
this.syncWaitTimeout = setTimeout(() => {
|
||
this.syncWaitTimeout = null;
|
||
// Force flush after timeout to prevent stuck state
|
||
this.flushPendingWrites();
|
||
}, 50);
|
||
}
|
||
this.writeFrameScheduled = false;
|
||
return;
|
||
}
|
||
|
||
// Clear any pending sync wait timeout
|
||
if (this.syncWaitTimeout) {
|
||
clearTimeout(this.syncWaitTimeout);
|
||
this.syncWaitTimeout = null;
|
||
}
|
||
|
||
this.flushPendingWrites();
|
||
}
|
||
this.writeFrameScheduled = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Flush pending writes to terminal, processing DEC 2026 sync markers.
|
||
* Strips markers and writes content atomically within a single frame.
|
||
*/
|
||
flushPendingWrites() {
|
||
if (!this.pendingWrites || !this.terminal) return;
|
||
|
||
// Extract segments, stripping DEC 2026 markers
|
||
// This implements synchronized output for xterm.js which doesn't support DEC 2026 natively
|
||
const segments = extractSyncSegments(this.pendingWrites);
|
||
this.pendingWrites = '';
|
||
|
||
// Write all segments in a single batch (atomic within this frame)
|
||
// xterm.js internally batches multiple write() calls within same frame
|
||
for (const segment of segments) {
|
||
if (segment && !segment.startsWith(DEC_SYNC_START)) {
|
||
this.terminal.write(segment);
|
||
}
|
||
}
|
||
|
||
// Sticky scroll: if user was at bottom, keep them there after new output
|
||
if (this._wasAtBottomBeforeWrite) {
|
||
this.terminal.scrollToBottom();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Write large buffer to terminal in chunks to avoid UI jank.
|
||
* Uses requestAnimationFrame to spread work across frames.
|
||
* @param {string} buffer - The full terminal buffer to write
|
||
* @param {number} chunkSize - Size of each chunk (default 64KB for smooth 60fps)
|
||
* @returns {Promise<void>} - Resolves when all chunks written
|
||
*/
|
||
chunkedTerminalWrite(buffer, chunkSize = 64 * 1024) {
|
||
return new Promise((resolve) => {
|
||
if (!buffer || buffer.length === 0) {
|
||
resolve();
|
||
return;
|
||
}
|
||
|
||
// Strip any DEC 2026 markers that might be in the buffer
|
||
// (from historical SSE data that was stored with markers)
|
||
const cleanBuffer = buffer
|
||
.replaceAll(DEC_SYNC_START, '')
|
||
.replaceAll(DEC_SYNC_END, '');
|
||
|
||
// For small buffers, write directly
|
||
if (cleanBuffer.length <= chunkSize) {
|
||
this.terminal.write(cleanBuffer);
|
||
resolve();
|
||
return;
|
||
}
|
||
|
||
let offset = 0;
|
||
const writeChunk = () => {
|
||
if (offset >= cleanBuffer.length) {
|
||
// Wait one more frame for xterm to finish rendering before resolving
|
||
requestAnimationFrame(() => resolve());
|
||
return;
|
||
}
|
||
|
||
const chunk = cleanBuffer.slice(offset, offset + chunkSize);
|
||
this.terminal.write(chunk);
|
||
offset += chunkSize;
|
||
|
||
// Schedule next chunk on next frame
|
||
requestAnimationFrame(writeChunk);
|
||
};
|
||
|
||
// Start writing
|
||
requestAnimationFrame(writeChunk);
|
||
});
|
||
}
|
||
|
||
setupEventListeners() {
|
||
// Use capture to handle before terminal
|
||
document.addEventListener('keydown', (e) => {
|
||
// Escape - close panels and modals
|
||
if (e.key === 'Escape') {
|
||
this.closeAllPanels();
|
||
this.closeHelp();
|
||
}
|
||
|
||
// Ctrl/Cmd + ? - help
|
||
if ((e.ctrlKey || e.metaKey) && (e.key === '?' || e.key === '/')) {
|
||
e.preventDefault();
|
||
this.showHelp();
|
||
}
|
||
|
||
// Ctrl/Cmd + Enter - quick start
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||
e.preventDefault();
|
||
this.quickStart();
|
||
}
|
||
|
||
// Ctrl/Cmd + W - close active session
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
|
||
e.preventDefault();
|
||
this.killActiveSession();
|
||
}
|
||
|
||
// Ctrl/Cmd + Tab - next session
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'Tab') {
|
||
e.preventDefault();
|
||
this.nextSession();
|
||
}
|
||
|
||
// Ctrl/Cmd + K - kill all
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||
e.preventDefault();
|
||
this.killAllSessions();
|
||
}
|
||
|
||
// Ctrl/Cmd + L - clear terminal
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
||
e.preventDefault();
|
||
this.clearTerminal();
|
||
}
|
||
|
||
// Ctrl/Cmd + +/- - font size
|
||
if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+')) {
|
||
e.preventDefault();
|
||
this.increaseFontSize();
|
||
}
|
||
if ((e.ctrlKey || e.metaKey) && e.key === '-') {
|
||
e.preventDefault();
|
||
this.decreaseFontSize();
|
||
}
|
||
}, true); // Use capture phase to handle before terminal
|
||
|
||
// Token stats click handler
|
||
const tokenEl = this.$('headerTokens');
|
||
if (tokenEl) {
|
||
tokenEl.classList.add('clickable');
|
||
tokenEl.addEventListener('click', () => this.openTokenStats());
|
||
}
|
||
}
|
||
|
||
// ========== SSE Connection ==========
|
||
|
||
connectSSE() {
|
||
// Close existing EventSource before creating new one to prevent duplicate connections
|
||
if (this.eventSource) {
|
||
this.eventSource.close();
|
||
this.eventSource = null;
|
||
}
|
||
|
||
this.eventSource = new EventSource('/api/events');
|
||
|
||
this.eventSource.onopen = () => this.setConnectionStatus('connected');
|
||
this.eventSource.onerror = () => {
|
||
this.setConnectionStatus('disconnected');
|
||
// Close the failed connection before scheduling reconnect
|
||
if (this.eventSource) {
|
||
this.eventSource.close();
|
||
this.eventSource = null;
|
||
}
|
||
setTimeout(() => this.connectSSE(), 3000);
|
||
};
|
||
|
||
this.eventSource.addEventListener('init', (e) => {
|
||
try {
|
||
this.handleInit(JSON.parse(e.data));
|
||
} catch (err) {
|
||
console.error('[SSE] Failed to parse init event:', err);
|
||
}
|
||
});
|
||
|
||
this.eventSource.addEventListener('session:created', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
this.sessions.set(data.id, data);
|
||
this.renderSessionTabs();
|
||
this.updateCost();
|
||
});
|
||
|
||
this.eventSource.addEventListener('session:updated', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
const session = data.session || data;
|
||
this.sessions.set(session.id, session);
|
||
this.renderSessionTabs();
|
||
this.updateCost();
|
||
// Update tokens display if this is the active session
|
||
if (session.id === this.activeSessionId && session.tokens) {
|
||
this.updateRespawnTokens(session.tokens);
|
||
}
|
||
});
|
||
|
||
this.eventSource.addEventListener('session:deleted', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
this.sessions.delete(data.id);
|
||
this.terminalBuffers.delete(data.id);
|
||
this.ralphStates.delete(data.id); // Clean up ralph state for this session
|
||
this.projectInsights.delete(data.id); // Clean up project insights for this session
|
||
this.closeSessionLogViewerWindows(data.id); // Close log viewer windows for this session
|
||
this.closeSessionSubagentWindows(data.id); // Close subagent windows for this session
|
||
// Clean up idle timer for this session
|
||
const idleTimer = this.idleTimers.get(data.id);
|
||
if (idleTimer) {
|
||
clearTimeout(idleTimer);
|
||
this.idleTimers.delete(data.id);
|
||
}
|
||
if (this.activeSessionId === data.id) {
|
||
this.activeSessionId = null;
|
||
this.terminal.clear();
|
||
this.showWelcome();
|
||
}
|
||
this.renderSessionTabs();
|
||
this.renderRalphStatePanel(); // Update ralph panel after session deleted
|
||
this.renderProjectInsightsPanel(); // Update project insights panel after session deleted
|
||
});
|
||
|
||
this.eventSource.addEventListener('session:terminal', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
if (data.id === this.activeSessionId) {
|
||
this.batchTerminalWrite(data.data);
|
||
}
|
||
});
|
||
|
||
this.eventSource.addEventListener('session:clearTerminal', async (e) => {
|
||
const data = JSON.parse(e.data);
|
||
if (data.id === this.activeSessionId) {
|
||
// Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed)
|
||
try {
|
||
const res = await fetch(`/api/sessions/${data.id}/terminal`);
|
||
const termData = await res.json();
|
||
|
||
this.terminal.clear();
|
||
this.terminal.reset();
|
||
if (termData.terminalBuffer) {
|
||
// Strip any DEC 2026 markers and write raw content
|
||
// (markers don't help here - this is a static buffer reload, not live Ink redraws)
|
||
const cleanBuffer = termData.terminalBuffer
|
||
.replaceAll(DEC_SYNC_START, '')
|
||
.replaceAll(DEC_SYNC_END, '');
|
||
this.terminal.write(cleanBuffer);
|
||
}
|
||
|
||
// Send resize to ensure proper dimensions
|
||
const dims = this.fitAddon.proposeDimensions();
|
||
if (dims) {
|
||
await fetch(`/api/sessions/${data.id}/resize`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ cols: dims.cols, rows: dims.rows })
|
||
});
|
||
}
|
||
} catch (err) {
|
||
console.error('clearTerminal refresh failed:', err);
|
||
}
|
||
}
|
||
});
|
||
|
||
this.eventSource.addEventListener('session:completion', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
this.totalCost += data.cost || 0;
|
||
this.updateCost();
|
||
if (data.id === this.activeSessionId) {
|
||
this.terminal.writeln('');
|
||
this.terminal.writeln(`\x1b[1;32m Done (Cost: $${(data.cost || 0).toFixed(4)})\x1b[0m`);
|
||
}
|
||
});
|
||
|
||
this.eventSource.addEventListener('session:error', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
if (data.id === this.activeSessionId) {
|
||
this.terminal.writeln(`\x1b[1;31m Error: ${data.error}\x1b[0m`);
|
||
}
|
||
const session = this.sessions.get(data.id);
|
||
this.notificationManager?.notify({
|
||
urgency: 'critical',
|
||
category: 'session-error',
|
||
sessionId: data.id,
|
||
sessionName: session?.name || data.id?.slice(0, 8),
|
||
title: 'Session Error',
|
||
message: data.error || 'Unknown error',
|
||
});
|
||
});
|
||
|
||
this.eventSource.addEventListener('session:exit', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
const session = this.sessions.get(data.id);
|
||
if (session) {
|
||
session.status = 'stopped';
|
||
this.renderSessionTabs();
|
||
}
|
||
// Notify on unexpected exit (non-zero code)
|
||
if (data.code && data.code !== 0) {
|
||
this.notificationManager?.notify({
|
||
urgency: 'critical',
|
||
category: 'session-crash',
|
||
sessionId: data.id,
|
||
sessionName: session?.name || data.id?.slice(0, 8),
|
||
title: 'Session Crashed',
|
||
message: `Exited with code ${data.code}`,
|
||
});
|
||
}
|
||
});
|
||
|
||
this.eventSource.addEventListener('session:idle', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
console.log('[DEBUG] session:idle event for:', data.id);
|
||
const session = this.sessions.get(data.id);
|
||
if (session) {
|
||
session.status = 'idle';
|
||
this.renderSessionTabs();
|
||
this.sendPendingCtrlL(data.id);
|
||
}
|
||
// Start stuck detection timer (only if no respawn running)
|
||
if (!this.respawnStatus[data.id]?.enabled) {
|
||
const threshold = this.notificationManager?.preferences?.stuckThresholdMs || 600000;
|
||
clearTimeout(this.idleTimers.get(data.id));
|
||
this.idleTimers.set(data.id, setTimeout(() => {
|
||
const s = this.sessions.get(data.id);
|
||
this.notificationManager?.notify({
|
||
urgency: 'warning',
|
||
category: 'session-stuck',
|
||
sessionId: data.id,
|
||
sessionName: s?.name || data.id?.slice(0, 8),
|
||
title: 'Session Idle',
|
||
message: `Idle for ${Math.round(threshold / 60000)}+ minutes`,
|
||
});
|
||
this.idleTimers.delete(data.id);
|
||
}, threshold));
|
||
}
|
||
});
|
||
|
||
this.eventSource.addEventListener('session:working', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
console.log('[DEBUG] session:working event for:', data.id);
|
||
const session = this.sessions.get(data.id);
|
||
if (session) {
|
||
session.status = 'busy';
|
||
this.tabAlerts.delete(data.id);
|
||
this.renderSessionTabs();
|
||
this.sendPendingCtrlL(data.id);
|
||
}
|
||
// Clear stuck detection timer
|
||
const timer = this.idleTimers.get(data.id);
|
||
if (timer) {
|
||
clearTimeout(timer);
|
||
this.idleTimers.delete(data.id);
|
||
}
|
||
});
|
||
|
||
// Scheduled run events
|
||
this.eventSource.addEventListener('scheduled:created', (e) => {
|
||
this.currentRun = JSON.parse(e.data);
|
||
this.showTimer();
|
||
});
|
||
|
||
this.eventSource.addEventListener('scheduled:updated', (e) => {
|
||
this.currentRun = JSON.parse(e.data);
|
||
this.updateTimer();
|
||
});
|
||
|
||
this.eventSource.addEventListener('scheduled:completed', (e) => {
|
||
this.currentRun = JSON.parse(e.data);
|
||
this.hideTimer();
|
||
this.showToast('Scheduled run completed!', 'success');
|
||
});
|
||
|
||
this.eventSource.addEventListener('scheduled:stopped', (e) => {
|
||
this.currentRun = null;
|
||
this.hideTimer();
|
||
});
|
||
|
||
// Respawn events
|
||
this.eventSource.addEventListener('respawn:started', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
this.respawnStatus[data.sessionId] = data.status;
|
||
if (data.sessionId === this.activeSessionId) {
|
||
this.showRespawnBanner();
|
||
}
|
||
});
|
||
|
||
this.eventSource.addEventListener('respawn:stopped', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
delete this.respawnStatus[data.sessionId];
|
||
if (data.sessionId === this.activeSessionId) {
|
||
this.hideRespawnBanner();
|
||
}
|
||
});
|
||
|
||
this.eventSource.addEventListener('respawn:stateChanged', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
if (this.respawnStatus[data.sessionId]) {
|
||
this.respawnStatus[data.sessionId].state = data.state;
|
||
}
|
||
if (data.sessionId === this.activeSessionId) {
|
||
this.updateRespawnBanner(data.state);
|
||
}
|
||
});
|
||
|
||
this.eventSource.addEventListener('respawn:cycleStarted', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
if (this.respawnStatus[data.sessionId]) {
|
||
this.respawnStatus[data.sessionId].cycleCount = data.cycleNumber;
|
||
}
|
||
if (data.sessionId === this.activeSessionId) {
|
||
document.getElementById('respawnCycleCount').textContent = data.cycleNumber;
|
||
}
|
||
});
|
||
|
||
this.eventSource.addEventListener('respawn:stepSent', (_e) => {
|
||
// Step info is shown via state label (e.g., "Sending prompt", "Clearing context")
|
||
});
|
||
|
||
this.eventSource.addEventListener('respawn:autoAcceptSent', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
const session = this.sessions.get(data.sessionId);
|
||
this.notificationManager?.notify({
|
||
urgency: 'info',
|
||
category: 'auto-accept',
|
||
sessionId: data.sessionId,
|
||
sessionName: session?.name || data.sessionId?.slice(0, 8),
|
||
title: 'Plan Accepted',
|
||
message: `Accepted plan mode for ${session?.name || 'session'}`,
|
||
});
|
||
});
|
||
|
||
this.eventSource.addEventListener('respawn:detectionUpdate', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
if (this.respawnStatus[data.sessionId]) {
|
||
this.respawnStatus[data.sessionId].detection = data.detection;
|
||
}
|
||
if (data.sessionId === this.activeSessionId) {
|
||
this.updateDetectionDisplay(data.detection);
|
||
}
|
||
});
|
||
|
||
this.eventSource.addEventListener('respawn:aiCheckStarted', (_e) => {
|
||
// AI check status shown via updateDetectionDisplay
|
||
});
|
||
|
||
this.eventSource.addEventListener('respawn:aiCheckCompleted', (_e) => {
|
||
// AI check status shown via updateDetectionDisplay
|
||
});
|
||
|
||
this.eventSource.addEventListener('respawn:aiCheckFailed', (_e) => {
|
||
// AI check status shown via updateDetectionDisplay
|
||
});
|
||
|
||
this.eventSource.addEventListener('respawn:aiCheckCooldown', (_e) => {
|
||
// AI check status shown via updateDetectionDisplay
|
||
});
|
||
|
||
// Respawn run timer events (timed respawn runs)
|
||
this.eventSource.addEventListener('respawn:timerStarted', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
this.respawnTimers[data.sessionId] = {
|
||
endAt: data.endAt,
|
||
startedAt: data.startedAt,
|
||
durationMinutes: data.durationMinutes
|
||
};
|
||
if (data.sessionId === this.activeSessionId) {
|
||
this.showRespawnTimer();
|
||
}
|
||
});
|
||
|
||
// Respawn controller countdown timer events (internal timers)
|
||
this.eventSource.addEventListener('respawn:timerStarted', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
// This may fire for both run timers and controller timers - check for timer object
|
||
if (data.timer) {
|
||
const { sessionId, timer } = data;
|
||
if (!this.respawnCountdownTimers[sessionId]) {
|
||
this.respawnCountdownTimers[sessionId] = {};
|
||
}
|
||
this.respawnCountdownTimers[sessionId][timer.name] = {
|
||
endsAt: timer.endsAt,
|
||
totalMs: timer.durationMs,
|
||
reason: timer.reason
|
||
};
|
||
if (sessionId === this.activeSessionId) {
|
||
this.updateCountdownTimerDisplay();
|
||
this.startCountdownInterval();
|
||
}
|
||
}
|
||
});
|
||
|
||
this.eventSource.addEventListener('respawn:timerCancelled', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
const { sessionId, timerName } = data;
|
||
if (this.respawnCountdownTimers[sessionId]) {
|
||
delete this.respawnCountdownTimers[sessionId][timerName];
|
||
}
|
||
if (sessionId === this.activeSessionId) {
|
||
this.updateCountdownTimerDisplay();
|
||
}
|
||
});
|
||
|
||
this.eventSource.addEventListener('respawn:timerCompleted', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
const { sessionId, timerName } = data;
|
||
if (this.respawnCountdownTimers[sessionId]) {
|
||
delete this.respawnCountdownTimers[sessionId][timerName];
|
||
}
|
||
if (sessionId === this.activeSessionId) {
|
||
this.updateCountdownTimerDisplay();
|
||
}
|
||
});
|
||
|
||
this.eventSource.addEventListener('respawn:actionLog', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
const { sessionId, action } = data;
|
||
this.addActionLogEntry(sessionId, action);
|
||
if (sessionId === this.activeSessionId) {
|
||
this.updateCountdownTimerDisplay(); // Show row if hidden
|
||
this.updateActionLogDisplay();
|
||
}
|
||
});
|
||
|
||
// Auto-clear event
|
||
this.eventSource.addEventListener('session:autoClear', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
if (data.sessionId === this.activeSessionId) {
|
||
this.showToast(`Auto-cleared at ${data.tokens.toLocaleString()} tokens`, 'info');
|
||
this.updateRespawnTokens(0);
|
||
}
|
||
const session = this.sessions.get(data.sessionId);
|
||
this.notificationManager?.notify({
|
||
urgency: 'info',
|
||
category: 'auto-clear',
|
||
sessionId: data.sessionId,
|
||
sessionName: session?.name || data.sessionId?.slice(0, 8),
|
||
title: 'Auto-Cleared',
|
||
message: `Context reset at ${(data.tokens || 0).toLocaleString()} tokens`,
|
||
});
|
||
});
|
||
|
||
// Background task events
|
||
this.eventSource.addEventListener('task:created', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
this.renderSessionTabs();
|
||
if (data.sessionId === this.activeSessionId) {
|
||
this.renderTaskPanel();
|
||
}
|
||
});
|
||
|
||
this.eventSource.addEventListener('task:completed', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
this.renderSessionTabs();
|
||
if (data.sessionId === this.activeSessionId) {
|
||
this.renderTaskPanel();
|
||
}
|
||
});
|
||
|
||
this.eventSource.addEventListener('task:failed', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
this.renderSessionTabs();
|
||
if (data.sessionId === this.activeSessionId) {
|
||
this.renderTaskPanel();
|
||
}
|
||
});
|
||
|
||
this.eventSource.addEventListener('task:updated', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
if (data.sessionId === this.activeSessionId) {
|
||
this.renderTaskPanel();
|
||
}
|
||
});
|
||
|
||
// Screen events
|
||
this.eventSource.addEventListener('screen:created', (e) => {
|
||
const screen = JSON.parse(e.data);
|
||
this.screenSessions.push(screen);
|
||
this.renderScreenSessions();
|
||
});
|
||
|
||
this.eventSource.addEventListener('screen:killed', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
this.screenSessions = this.screenSessions.filter(s => s.sessionId !== data.sessionId);
|
||
this.renderScreenSessions();
|
||
});
|
||
|
||
this.eventSource.addEventListener('screen:died', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
this.screenSessions = this.screenSessions.filter(s => s.sessionId !== data.sessionId);
|
||
this.renderScreenSessions();
|
||
this.showToast('Screen session died: ' + data.sessionId.slice(0, 8), 'warning');
|
||
});
|
||
|
||
this.eventSource.addEventListener('screen:statsUpdated', (e) => {
|
||
this.screenSessions = JSON.parse(e.data);
|
||
if (document.getElementById('monitorPanel').classList.contains('open')) {
|
||
this.renderScreenSessions();
|
||
}
|
||
});
|
||
|
||
// Ralph loop/todo events
|
||
this.eventSource.addEventListener('session:ralphLoopUpdate', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
this.updateRalphState(data.sessionId, { loop: data.state });
|
||
});
|
||
|
||
this.eventSource.addEventListener('session:ralphTodoUpdate', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
this.updateRalphState(data.sessionId, { todos: data.todos });
|
||
});
|
||
|
||
this.eventSource.addEventListener('session:ralphCompletionDetected', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
// Prevent duplicate notifications for the same completion
|
||
const completionKey = `${data.sessionId}:${data.phrase}`;
|
||
if (this._shownCompletions?.has(completionKey)) {
|
||
return;
|
||
}
|
||
if (!this._shownCompletions) {
|
||
this._shownCompletions = new Set();
|
||
}
|
||
this._shownCompletions.add(completionKey);
|
||
// Clear after 30 seconds to allow re-notification if loop restarts
|
||
setTimeout(() => this._shownCompletions?.delete(completionKey), 30000);
|
||
|
||
// Update ralph state to mark loop as inactive
|
||
const existing = this.ralphStates.get(data.sessionId) || {};
|
||
if (existing.loop) {
|
||
existing.loop.active = false;
|
||
this.updateRalphState(data.sessionId, existing);
|
||
}
|
||
|
||
const session = this.sessions.get(data.sessionId);
|
||
this.notificationManager?.notify({
|
||
urgency: 'warning',
|
||
category: 'ralph-complete',
|
||
sessionId: data.sessionId,
|
||
sessionName: session?.name || data.sessionId?.slice(0, 8),
|
||
title: 'Loop Complete',
|
||
message: `Completion: ${data.phrase || 'unknown'}`,
|
||
});
|
||
});
|
||
|
||
// Active Bash tool events (for clickable file paths)
|
||
this.eventSource.addEventListener('session:bashToolStart', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
this.handleBashToolStart(data.sessionId, data.tool);
|
||
});
|
||
|
||
this.eventSource.addEventListener('session:bashToolEnd', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
this.handleBashToolEnd(data.sessionId, data.tool);
|
||
});
|
||
|
||
this.eventSource.addEventListener('session:bashToolsUpdate', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
this.handleBashToolsUpdate(data.sessionId, data.tools);
|
||
});
|
||
|
||
// Spawn agent notification events
|
||
this.eventSource.addEventListener('spawn:failed', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
this.notificationManager?.notify({
|
||
urgency: 'critical',
|
||
category: 'spawn-failed',
|
||
sessionId: data.sessionId,
|
||
sessionName: data.agentId || 'agent',
|
||
title: 'Agent Failed',
|
||
message: `Agent "${data.agentId}" failed: ${data.reason || 'unknown'}`,
|
||
});
|
||
});
|
||
|
||
this.eventSource.addEventListener('spawn:timeout', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
this.notificationManager?.notify({
|
||
urgency: 'critical',
|
||
category: 'spawn-timeout',
|
||
sessionId: data.sessionId,
|
||
sessionName: data.agentId || 'agent',
|
||
title: 'Agent Timeout',
|
||
message: `Agent "${data.agentId}" exceeded time limit`,
|
||
});
|
||
});
|
||
|
||
this.eventSource.addEventListener('spawn:budgetWarning', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
this.notificationManager?.notify({
|
||
urgency: 'warning',
|
||
category: 'spawn-budget',
|
||
sessionId: data.sessionId,
|
||
sessionName: data.agentId || 'agent',
|
||
title: 'Budget Warning',
|
||
message: `Agent "${data.agentId}" at ${data.percent || 80}% budget`,
|
||
});
|
||
});
|
||
|
||
this.eventSource.addEventListener('spawn:completed', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
this.notificationManager?.notify({
|
||
urgency: 'info',
|
||
category: 'spawn-completed',
|
||
sessionId: data.sessionId,
|
||
sessionName: data.agentId || 'agent',
|
||
title: 'Agent Complete',
|
||
message: `Agent "${data.agentId}" finished successfully`,
|
||
});
|
||
});
|
||
|
||
// Hook events (from Claude Code hooks system)
|
||
this.eventSource.addEventListener('hook:idle_prompt', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
const session = this.sessions.get(data.sessionId);
|
||
if (data.sessionId && data.sessionId !== this.activeSessionId) {
|
||
this.tabAlerts.set(data.sessionId, 'idle');
|
||
this.renderSessionTabs();
|
||
}
|
||
this.notificationManager?.notify({
|
||
urgency: 'warning',
|
||
category: 'hook-idle',
|
||
sessionId: data.sessionId,
|
||
sessionName: session?.name || data.sessionId,
|
||
title: 'Waiting for Input',
|
||
message: data.message || 'Claude is idle and waiting for a prompt',
|
||
});
|
||
});
|
||
|
||
this.eventSource.addEventListener('hook:permission_prompt', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
const session = this.sessions.get(data.sessionId);
|
||
if (data.sessionId && data.sessionId !== this.activeSessionId) {
|
||
this.tabAlerts.set(data.sessionId, 'action');
|
||
this.renderSessionTabs();
|
||
}
|
||
const toolInfo = data.tool ? `${data.tool}${data.command ? ': ' + data.command : data.file ? ': ' + data.file : ''}` : '';
|
||
this.notificationManager?.notify({
|
||
urgency: 'critical',
|
||
category: 'hook-permission',
|
||
sessionId: data.sessionId,
|
||
sessionName: session?.name || data.sessionId,
|
||
title: 'Permission Required',
|
||
message: toolInfo || 'Claude needs tool approval to continue',
|
||
});
|
||
});
|
||
|
||
this.eventSource.addEventListener('hook:elicitation_dialog', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
const session = this.sessions.get(data.sessionId);
|
||
if (data.sessionId && data.sessionId !== this.activeSessionId) {
|
||
this.tabAlerts.set(data.sessionId, 'action');
|
||
this.renderSessionTabs();
|
||
}
|
||
this.notificationManager?.notify({
|
||
urgency: 'critical',
|
||
category: 'hook-elicitation',
|
||
sessionId: data.sessionId,
|
||
sessionName: session?.name || data.sessionId,
|
||
title: 'Question Asked',
|
||
message: data.question || 'Claude is asking a question and waiting for your answer',
|
||
});
|
||
});
|
||
|
||
this.eventSource.addEventListener('hook:stop', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
const session = this.sessions.get(data.sessionId);
|
||
this.notificationManager?.notify({
|
||
urgency: 'info',
|
||
category: 'hook-stop',
|
||
sessionId: data.sessionId,
|
||
sessionName: session?.name || data.sessionId,
|
||
title: 'Response Complete',
|
||
message: data.reason || 'Claude has finished responding',
|
||
});
|
||
});
|
||
|
||
// ========== Subagent Events (Claude Code Background Agents) ==========
|
||
|
||
this.eventSource.addEventListener('subagent:discovered', async (e) => {
|
||
const data = JSON.parse(e.data);
|
||
this.subagents.set(data.agentId, data);
|
||
this.subagentActivity.set(data.agentId, []);
|
||
this.renderSubagentPanel();
|
||
|
||
// Find which Claudeman session owns this subagent (by working directory)
|
||
await this.findParentSessionForSubagent(data.agentId);
|
||
|
||
// Auto-open window for new active agents
|
||
if (data.status === 'active') {
|
||
this.openSubagentWindow(data.agentId);
|
||
}
|
||
});
|
||
|
||
this.eventSource.addEventListener('subagent:updated', (e) => {
|
||
const data = JSON.parse(e.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);
|
||
}
|
||
});
|
||
|
||
this.eventSource.addEventListener('subagent:tool_call', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
const activity = this.subagentActivity.get(data.agentId) || [];
|
||
activity.push({ type: 'tool', ...data });
|
||
if (activity.length > 100) activity.shift(); // Keep last 100 entries
|
||
this.subagentActivity.set(data.agentId, activity);
|
||
if (this.activeSubagentId === data.agentId) {
|
||
this.renderSubagentDetail();
|
||
}
|
||
this.renderSubagentPanel();
|
||
// Update floating window
|
||
if (this.subagentWindows.has(data.agentId)) {
|
||
this.renderSubagentWindowContent(data.agentId);
|
||
}
|
||
});
|
||
|
||
this.eventSource.addEventListener('subagent:progress', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
const activity = this.subagentActivity.get(data.agentId) || [];
|
||
activity.push({ type: 'progress', ...data });
|
||
if (activity.length > 100) activity.shift();
|
||
this.subagentActivity.set(data.agentId, activity);
|
||
if (this.activeSubagentId === data.agentId) {
|
||
this.renderSubagentDetail();
|
||
}
|
||
// Update floating window
|
||
if (this.subagentWindows.has(data.agentId)) {
|
||
this.renderSubagentWindowContent(data.agentId);
|
||
}
|
||
});
|
||
|
||
this.eventSource.addEventListener('subagent:message', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
const activity = this.subagentActivity.get(data.agentId) || [];
|
||
activity.push({ type: 'message', ...data });
|
||
if (activity.length > 100) activity.shift();
|
||
this.subagentActivity.set(data.agentId, activity);
|
||
if (this.activeSubagentId === data.agentId) {
|
||
this.renderSubagentDetail();
|
||
}
|
||
// Update floating window
|
||
if (this.subagentWindows.has(data.agentId)) {
|
||
this.renderSubagentWindowContent(data.agentId);
|
||
}
|
||
});
|
||
|
||
this.eventSource.addEventListener('subagent:tool_result', (e) => {
|
||
const data = JSON.parse(e.data);
|
||
// Store tool result by toolUseId for later lookup
|
||
if (!this.subagentToolResults.has(data.agentId)) {
|
||
this.subagentToolResults.set(data.agentId, new Map());
|
||
}
|
||
this.subagentToolResults.get(data.agentId).set(data.toolUseId, data);
|
||
|
||
// Add to activity stream
|
||
const activity = this.subagentActivity.get(data.agentId) || [];
|
||
activity.push({ type: 'tool_result', ...data });
|
||
if (activity.length > 100) activity.shift();
|
||
this.subagentActivity.set(data.agentId, activity);
|
||
|
||
if (this.activeSubagentId === data.agentId) {
|
||
this.renderSubagentDetail();
|
||
}
|
||
// Update floating window
|
||
if (this.subagentWindows.has(data.agentId)) {
|
||
this.renderSubagentWindowContent(data.agentId);
|
||
}
|
||
});
|
||
|
||
this.eventSource.addEventListener('subagent:completed', (e) => {
|
||
const data = JSON.parse(e.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) {
|
||
this.closeSubagentWindow(data.agentId); // This minimizes to tab
|
||
this.saveSubagentWindowStates(); // Persist the minimized state
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
setConnectionStatus(status) {
|
||
const dot = document.querySelector('.status-dot');
|
||
const text = document.querySelector('.status-text');
|
||
dot.className = 'status-dot ' + status;
|
||
text.textContent = status === 'connected' ? 'Connected' : 'Disconnected';
|
||
}
|
||
|
||
handleInit(data) {
|
||
// Update version display
|
||
if (data.version) {
|
||
const versionEl = this.$('versionDisplay');
|
||
if (versionEl) {
|
||
versionEl.textContent = `v${data.version}`;
|
||
versionEl.title = `Claudeman v${data.version}`;
|
||
}
|
||
}
|
||
|
||
this.sessions.clear();
|
||
this.ralphStates.clear();
|
||
data.sessions.forEach(s => {
|
||
this.sessions.set(s.id, s);
|
||
// Load ralph state from session data
|
||
if (s.ralphLoop || s.ralphTodos) {
|
||
this.ralphStates.set(s.id, {
|
||
loop: s.ralphLoop || null,
|
||
todos: s.ralphTodos || []
|
||
});
|
||
}
|
||
});
|
||
|
||
if (data.respawnStatus) {
|
||
this.respawnStatus = data.respawnStatus;
|
||
}
|
||
|
||
// Store global stats for aggregate tracking
|
||
if (data.globalStats) {
|
||
this.globalStats = data.globalStats;
|
||
}
|
||
|
||
this.totalCost = data.sessions.reduce((sum, s) => sum + (s.totalCost || 0), 0);
|
||
this.totalCost += data.scheduledRuns.reduce((sum, r) => sum + (r.totalCost || 0), 0);
|
||
|
||
const activeRun = data.scheduledRuns.find(r => r.status === 'running');
|
||
if (activeRun) {
|
||
this.currentRun = activeRun;
|
||
this.showTimer();
|
||
}
|
||
|
||
this.updateCost();
|
||
this.renderSessionTabs();
|
||
|
||
// Load subagents
|
||
if (data.subagents) {
|
||
this.subagents.clear();
|
||
data.subagents.forEach(s => {
|
||
this.subagents.set(s.agentId, s);
|
||
});
|
||
this.renderSubagentPanel();
|
||
|
||
// Restore subagent window states (minimized/open) after subagents are loaded
|
||
this.restoreSubagentWindowStates();
|
||
}
|
||
|
||
// Auto-select first session if any
|
||
if (this.sessions.size > 0 && !this.activeSessionId) {
|
||
const firstSession = this.sessions.values().next().value;
|
||
this.selectSession(firstSession.id);
|
||
}
|
||
}
|
||
|
||
async loadState() {
|
||
try {
|
||
const res = await fetch('/api/status');
|
||
const data = await res.json();
|
||
this.handleInit(data);
|
||
} catch (err) {
|
||
console.error('Failed to load state:', err);
|
||
}
|
||
}
|
||
|
||
// ========== Session Tabs ==========
|
||
|
||
renderSessionTabs() {
|
||
// Debounce renders at 100ms to prevent excessive DOM updates
|
||
if (this.renderSessionTabsTimeout) {
|
||
clearTimeout(this.renderSessionTabsTimeout);
|
||
}
|
||
this.renderSessionTabsTimeout = setTimeout(() => {
|
||
this._renderSessionTabsImmediate();
|
||
}, 100);
|
||
}
|
||
|
||
_renderSessionTabsImmediate() {
|
||
const container = this.$('sessionTabs');
|
||
const existingTabs = container.querySelectorAll('.session-tab[data-id]');
|
||
const existingIds = new Set([...existingTabs].map(t => t.dataset.id));
|
||
const currentIds = new Set(this.sessions.keys());
|
||
|
||
// Check if we can do incremental update (same session IDs)
|
||
const canIncremental = existingIds.size === currentIds.size &&
|
||
[...existingIds].every(id => currentIds.has(id));
|
||
|
||
if (canIncremental) {
|
||
// Incremental update - only modify changed properties
|
||
for (const [id, session] of this.sessions) {
|
||
const tab = container.querySelector(`.session-tab[data-id="${id}"]`);
|
||
if (!tab) continue;
|
||
|
||
const isActive = id === this.activeSessionId;
|
||
const status = session.status || 'idle';
|
||
const name = this.getSessionName(session);
|
||
const taskStats = session.taskStats || { running: 0, total: 0 };
|
||
const hasRunningTasks = taskStats.running > 0;
|
||
|
||
// Update active class
|
||
if (isActive && !tab.classList.contains('active')) {
|
||
tab.classList.add('active');
|
||
} else if (!isActive && tab.classList.contains('active')) {
|
||
tab.classList.remove('active');
|
||
}
|
||
|
||
// Update alert class
|
||
const alertType = this.tabAlerts.get(id);
|
||
const wantAction = alertType === 'action';
|
||
const wantIdle = alertType === 'idle';
|
||
const hasAction = tab.classList.contains('tab-alert-action');
|
||
const hasIdle = tab.classList.contains('tab-alert-idle');
|
||
if (wantAction && !hasAction) { tab.classList.add('tab-alert-action'); tab.classList.remove('tab-alert-idle'); }
|
||
else if (wantIdle && !hasIdle) { tab.classList.add('tab-alert-idle'); tab.classList.remove('tab-alert-action'); }
|
||
else if (!alertType && (hasAction || hasIdle)) { tab.classList.remove('tab-alert-action', 'tab-alert-idle'); }
|
||
|
||
// Update status indicator
|
||
const statusEl = tab.querySelector('.tab-status');
|
||
if (statusEl && !statusEl.classList.contains(status)) {
|
||
statusEl.className = `tab-status ${status}`;
|
||
}
|
||
|
||
// Update name if changed
|
||
const nameEl = tab.querySelector('.tab-name');
|
||
if (nameEl && nameEl.textContent !== name) {
|
||
nameEl.textContent = name;
|
||
}
|
||
|
||
// Update task badge
|
||
const badgeEl = tab.querySelector('.tab-badge');
|
||
if (hasRunningTasks) {
|
||
if (badgeEl) {
|
||
if (badgeEl.textContent !== String(taskStats.running)) {
|
||
badgeEl.textContent = taskStats.running;
|
||
}
|
||
} else {
|
||
// Need to add badge - do full rebuild
|
||
this._fullRenderSessionTabs();
|
||
return;
|
||
}
|
||
} else if (badgeEl) {
|
||
// Need to remove badge - do full rebuild
|
||
this._fullRenderSessionTabs();
|
||
return;
|
||
}
|
||
|
||
// Update subagent badge - check if count changed
|
||
const subagentBadgeEl = tab.querySelector('.tab-subagent-badge');
|
||
const minimizedAgents = this.minimizedSubagents.get(id);
|
||
const minimizedCount = minimizedAgents?.size || 0;
|
||
const currentCount = subagentBadgeEl ? parseInt(subagentBadgeEl.querySelector('.subagent-count')?.textContent || '0') : 0;
|
||
if (minimizedCount !== currentCount) {
|
||
// Count changed - need full rebuild for dropdown update
|
||
this._fullRenderSessionTabs();
|
||
return;
|
||
}
|
||
}
|
||
} else {
|
||
// Full rebuild needed (sessions added/removed)
|
||
this._fullRenderSessionTabs();
|
||
}
|
||
|
||
// Auto-focus: if there's exactly one session and none is active, select it
|
||
if (this.sessions.size === 1 && !this.activeSessionId) {
|
||
const [sessionId] = this.sessions.keys();
|
||
this.selectSession(sessionId);
|
||
}
|
||
}
|
||
|
||
_fullRenderSessionTabs() {
|
||
const container = this.$('sessionTabs');
|
||
|
||
// Clean up any orphaned dropdowns before re-rendering
|
||
document.querySelectorAll('body > .subagent-dropdown').forEach(d => d.remove());
|
||
this.cancelHideSubagentDropdown();
|
||
|
||
// Build tabs HTML using array for better string concatenation performance
|
||
const parts = [];
|
||
for (const [id, session] of this.sessions) {
|
||
const isActive = id === this.activeSessionId;
|
||
const status = session.status || 'idle';
|
||
const name = this.getSessionName(session);
|
||
const mode = session.mode || 'claude';
|
||
const taskStats = session.taskStats || { running: 0, total: 0 };
|
||
const hasRunningTasks = taskStats.running > 0;
|
||
const alertType = this.tabAlerts.get(id);
|
||
const alertClass = alertType === 'action' ? ' tab-alert-action' : alertType === 'idle' ? ' tab-alert-idle' : '';
|
||
|
||
// Get minimized subagents for this session
|
||
const minimizedAgents = this.minimizedSubagents.get(id);
|
||
const minimizedCount = minimizedAgents?.size || 0;
|
||
const subagentBadge = minimizedCount > 0 ? this.renderSubagentTabBadge(id, minimizedAgents) : '';
|
||
|
||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}" data-id="${id}" onclick="app.selectSession('${id}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${id}')">
|
||
<span class="tab-status ${status}"></span>
|
||
${mode === 'shell' ? '<span class="tab-mode shell">sh</span>' : ''}
|
||
<span class="tab-name" data-session-id="${id}">${this.escapeHtml(name)}</span>
|
||
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()">${taskStats.running}</span>` : ''}
|
||
${subagentBadge}
|
||
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${id}')" title="Session options">⚙</span>
|
||
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${id}')">×</span>
|
||
</div>`);
|
||
}
|
||
|
||
container.innerHTML = parts.join('');
|
||
// Update connection lines after tabs change (positions may have shifted)
|
||
this.updateConnectionLines();
|
||
}
|
||
|
||
// Render subagent badge with dropdown for minimized agents on a tab
|
||
renderSubagentTabBadge(sessionId, minimizedAgents) {
|
||
if (!minimizedAgents || minimizedAgents.size === 0) return '';
|
||
|
||
const agentItems = [];
|
||
for (const agentId of minimizedAgents) {
|
||
const agent = this.subagents.get(agentId);
|
||
const displayName = agent?.description || agentId.substring(0, 12);
|
||
const truncatedName = displayName.length > 25 ? displayName.substring(0, 25) + '…' : displayName;
|
||
const statusClass = agent?.status || 'idle';
|
||
agentItems.push(`
|
||
<div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreMinimizedSubagent('${agentId}', '${sessionId}')" title="Click to restore">
|
||
<span class="subagent-dropdown-status ${statusClass}"></span>
|
||
<span class="subagent-dropdown-name">${this.escapeHtml(truncatedName)}</span>
|
||
<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.permanentlyCloseMinimizedSubagent('${agentId}', '${sessionId}')" title="Dismiss">×</span>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
// Compact badge - shows on hover, click to pin open
|
||
const count = minimizedAgents.size;
|
||
const label = count === 1 ? 'AGENT' : `AGENTS (${count})`;
|
||
return `
|
||
<span class="tab-subagent-badge"
|
||
onmouseenter="app.showSubagentDropdown(this)"
|
||
onmouseleave="app.scheduleHideSubagentDropdown(this)"
|
||
onclick="event.stopPropagation(); app.pinSubagentDropdown(this);">
|
||
<span class="subagent-label">${label}</span>
|
||
<div class="subagent-dropdown" onmouseenter="app.cancelHideSubagentDropdown()" onmouseleave="app.scheduleHideSubagentDropdown(this.parentElement)">
|
||
${agentItems.join('')}
|
||
</div>
|
||
</span>
|
||
`;
|
||
}
|
||
|
||
// Restore a minimized subagent window
|
||
restoreMinimizedSubagent(agentId, sessionId) {
|
||
// Remove from minimized set
|
||
const minimizedAgents = this.minimizedSubagents.get(sessionId);
|
||
if (minimizedAgents) {
|
||
minimizedAgents.delete(agentId);
|
||
if (minimizedAgents.size === 0) {
|
||
this.minimizedSubagents.delete(sessionId);
|
||
}
|
||
}
|
||
|
||
// Restore the window
|
||
this.restoreSubagentWindow(agentId);
|
||
|
||
// Re-render tabs to update badge
|
||
this.renderSessionTabs();
|
||
|
||
// Persist the state change
|
||
this.saveSubagentWindowStates();
|
||
}
|
||
|
||
// Show subagent dropdown on hover
|
||
showSubagentDropdown(badgeEl) {
|
||
this.cancelHideSubagentDropdown();
|
||
const dropdown = badgeEl.querySelector('.subagent-dropdown');
|
||
if (!dropdown || dropdown.classList.contains('open')) return;
|
||
|
||
// Close other dropdowns first
|
||
document.querySelectorAll('.subagent-dropdown.open').forEach(d => {
|
||
d.classList.remove('open', 'pinned');
|
||
if (d.parentElement === document.body && d._originalParent) {
|
||
d._originalParent.appendChild(d);
|
||
}
|
||
});
|
||
|
||
// Move to body to escape clipping
|
||
dropdown._originalParent = badgeEl;
|
||
document.body.appendChild(dropdown);
|
||
|
||
// Position below badge
|
||
const rect = badgeEl.getBoundingClientRect();
|
||
dropdown.style.top = `${rect.bottom + 2}px`;
|
||
dropdown.style.left = `${rect.left + rect.width / 2}px`;
|
||
dropdown.style.transform = 'translateX(-50%)';
|
||
dropdown.classList.add('open');
|
||
}
|
||
|
||
// Schedule hide after delay (allows moving mouse to dropdown)
|
||
scheduleHideSubagentDropdown(badgeEl) {
|
||
this._subagentHideTimeout = setTimeout(() => {
|
||
const dropdown = badgeEl?.querySelector?.('.subagent-dropdown') ||
|
||
document.querySelector('.subagent-dropdown.open');
|
||
if (dropdown && !dropdown.classList.contains('pinned')) {
|
||
dropdown.classList.remove('open');
|
||
if (dropdown._originalParent) {
|
||
dropdown._originalParent.appendChild(dropdown);
|
||
}
|
||
}
|
||
}, 150);
|
||
}
|
||
|
||
// Cancel scheduled hide
|
||
cancelHideSubagentDropdown() {
|
||
if (this._subagentHideTimeout) {
|
||
clearTimeout(this._subagentHideTimeout);
|
||
this._subagentHideTimeout = null;
|
||
}
|
||
}
|
||
|
||
// Pin dropdown open on click (stays until clicking outside)
|
||
pinSubagentDropdown(badgeEl) {
|
||
const dropdown = document.querySelector('.subagent-dropdown.open');
|
||
if (!dropdown) {
|
||
this.showSubagentDropdown(badgeEl);
|
||
return;
|
||
}
|
||
dropdown.classList.toggle('pinned');
|
||
|
||
if (dropdown.classList.contains('pinned')) {
|
||
// Close on outside click
|
||
const closeHandler = (e) => {
|
||
if (!badgeEl.contains(e.target) && !dropdown.contains(e.target)) {
|
||
dropdown.classList.remove('open', 'pinned');
|
||
if (dropdown._originalParent) {
|
||
dropdown._originalParent.appendChild(dropdown);
|
||
}
|
||
document.removeEventListener('click', closeHandler);
|
||
}
|
||
};
|
||
setTimeout(() => document.addEventListener('click', closeHandler), 0);
|
||
}
|
||
}
|
||
|
||
// Legacy toggle for backwards compat
|
||
toggleSubagentDropdown(badgeEl) {
|
||
this.pinSubagentDropdown(badgeEl);
|
||
}
|
||
|
||
// Permanently close a minimized subagent (remove from DOM and minimized set)
|
||
permanentlyCloseMinimizedSubagent(agentId, sessionId) {
|
||
// Remove from minimized set
|
||
const minimizedAgents = this.minimizedSubagents.get(sessionId);
|
||
if (minimizedAgents) {
|
||
minimizedAgents.delete(agentId);
|
||
if (minimizedAgents.size === 0) {
|
||
this.minimizedSubagents.delete(sessionId);
|
||
}
|
||
}
|
||
|
||
// Force close the window (removes from DOM)
|
||
this.forceCloseSubagentWindow(agentId);
|
||
|
||
// Re-render tabs to update badge
|
||
this.renderSessionTabs();
|
||
this.updateConnectionLines();
|
||
|
||
// Persist the state change
|
||
this.saveSubagentWindowStates();
|
||
}
|
||
|
||
getSessionName(session) {
|
||
// Use custom name if set
|
||
if (session.name) {
|
||
return session.name;
|
||
}
|
||
// Fall back to directory name
|
||
if (session.workingDir) {
|
||
return session.workingDir.split('/').pop() || session.workingDir;
|
||
}
|
||
return session.id.slice(0, 8);
|
||
}
|
||
|
||
async selectSession(sessionId) {
|
||
if (this.activeSessionId === sessionId) return;
|
||
|
||
this.activeSessionId = sessionId;
|
||
this.tabAlerts.delete(sessionId);
|
||
this.renderSessionTabs();
|
||
|
||
// Check if this is a restored session that needs to be attached
|
||
const session = this.sessions.get(sessionId);
|
||
|
||
// Track working directory for path normalization in Project Insights
|
||
this.currentSessionWorkingDir = session?.workingDir || null;
|
||
if (session && session.pid === null && session.status === 'idle') {
|
||
// This is a restored session - attach to the existing screen
|
||
try {
|
||
await fetch(`/api/sessions/${sessionId}/interactive`, { method: 'POST' });
|
||
// Update local session state
|
||
session.status = 'busy';
|
||
} catch (err) {
|
||
console.error('Failed to attach to restored session:', err);
|
||
}
|
||
}
|
||
|
||
// Load terminal buffer for this session
|
||
// Use tail mode for faster initial load (256KB is enough for recent visible content)
|
||
try {
|
||
const tailSize = 256 * 1024;
|
||
const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${tailSize}`);
|
||
const data = await res.json();
|
||
|
||
this.terminal.clear();
|
||
this.terminal.reset();
|
||
if (data.terminalBuffer) {
|
||
// Show truncation indicator if buffer was cut
|
||
if (data.truncated) {
|
||
this.terminal.write('\x1b[90m... (earlier output truncated for performance) ...\x1b[0m\r\n\r\n');
|
||
}
|
||
// Use chunked write for large buffers to avoid UI jank
|
||
await this.chunkedTerminalWrite(data.terminalBuffer);
|
||
// Ensure terminal is scrolled to bottom after buffer load
|
||
this.terminal.scrollToBottom();
|
||
}
|
||
|
||
// Send resize and Ctrl+L to trigger Claude to redraw at correct size
|
||
const dims = this.fitAddon.proposeDimensions();
|
||
if (dims) {
|
||
await fetch(`/api/sessions/${sessionId}/resize`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ cols: dims.cols, rows: dims.rows })
|
||
});
|
||
}
|
||
|
||
// Update respawn banner
|
||
if (this.respawnStatus[sessionId]) {
|
||
this.showRespawnBanner();
|
||
this.updateRespawnBanner(this.respawnStatus[sessionId].state);
|
||
document.getElementById('respawnCycleCount').textContent = this.respawnStatus[sessionId].cycleCount || 0;
|
||
// Update countdown timers and action log for this session
|
||
this.updateCountdownTimerDisplay();
|
||
this.updateActionLogDisplay();
|
||
if (Object.keys(this.respawnCountdownTimers[sessionId] || {}).length > 0) {
|
||
this.startCountdownInterval();
|
||
}
|
||
} else {
|
||
this.hideRespawnBanner();
|
||
this.stopCountdownInterval();
|
||
}
|
||
|
||
// Update task panel if open
|
||
const taskPanel = document.getElementById('taskPanel');
|
||
if (taskPanel && taskPanel.classList.contains('open')) {
|
||
this.renderTaskPanel();
|
||
}
|
||
|
||
// Update ralph state panel for this session
|
||
const session = this.sessions.get(sessionId);
|
||
if (session && (session.ralphLoop || session.ralphTodos)) {
|
||
this.updateRalphState(sessionId, {
|
||
loop: session.ralphLoop,
|
||
todos: session.ralphTodos
|
||
});
|
||
}
|
||
this.renderRalphStatePanel();
|
||
|
||
// Update project insights panel for this session
|
||
this.renderProjectInsightsPanel();
|
||
|
||
// Update subagent window visibility for active session
|
||
this.updateSubagentWindowVisibility();
|
||
|
||
this.terminal.focus();
|
||
this.terminal.scrollToBottom();
|
||
} catch (err) {
|
||
console.error('Failed to load session terminal:', err);
|
||
}
|
||
}
|
||
|
||
async closeSession(sessionId, killScreen = true) {
|
||
try {
|
||
await fetch(`/api/sessions/${sessionId}?killScreen=${killScreen}`, { method: 'DELETE' });
|
||
this.sessions.delete(sessionId);
|
||
this.terminalBuffers.delete(sessionId);
|
||
this.ralphStates.delete(sessionId);
|
||
this.clearCountdownTimers(sessionId);
|
||
|
||
if (this.activeSessionId === sessionId) {
|
||
this.activeSessionId = null;
|
||
// Select another session or show welcome
|
||
if (this.sessions.size > 0) {
|
||
const nextSession = this.sessions.values().next().value;
|
||
this.selectSession(nextSession.id);
|
||
} else {
|
||
this.terminal.clear();
|
||
this.showWelcome();
|
||
this.renderRalphStatePanel(); // Clear ralph panel when no sessions
|
||
}
|
||
}
|
||
|
||
this.renderSessionTabs();
|
||
|
||
if (killScreen) {
|
||
this.showToast('Session closed and screen killed', 'success');
|
||
} else {
|
||
this.showToast('Tab hidden, screen still running', 'info');
|
||
}
|
||
} catch (err) {
|
||
this.showToast('Failed to close session', 'error');
|
||
}
|
||
}
|
||
|
||
// Request confirmation before closing a session
|
||
requestCloseSession(sessionId) {
|
||
const session = this.sessions.get(sessionId);
|
||
if (!session) return;
|
||
|
||
this.pendingCloseSessionId = sessionId;
|
||
|
||
// Show session name in confirmation dialog
|
||
const name = this.getSessionName(session);
|
||
const sessionNameEl = document.getElementById('closeConfirmSessionName');
|
||
sessionNameEl.textContent = name;
|
||
|
||
document.getElementById('closeConfirmModal').classList.add('active');
|
||
}
|
||
|
||
cancelCloseSession() {
|
||
this.pendingCloseSessionId = null;
|
||
document.getElementById('closeConfirmModal').classList.remove('active');
|
||
}
|
||
|
||
async confirmCloseSession(killScreen = true) {
|
||
const sessionId = this.pendingCloseSessionId;
|
||
this.cancelCloseSession();
|
||
|
||
if (sessionId) {
|
||
await this.closeSession(sessionId, killScreen);
|
||
}
|
||
}
|
||
|
||
nextSession() {
|
||
const ids = Array.from(this.sessions.keys());
|
||
if (ids.length <= 1) return;
|
||
|
||
const currentIndex = ids.indexOf(this.activeSessionId);
|
||
const nextIndex = (currentIndex + 1) % ids.length;
|
||
this.selectSession(ids[nextIndex]);
|
||
}
|
||
|
||
// ========== Quick Start ==========
|
||
|
||
async loadQuickStartCases(selectCaseName = null) {
|
||
try {
|
||
// Load settings to get lastUsedCase
|
||
let lastUsedCase = null;
|
||
try {
|
||
const settingsRes = await fetch('/api/settings');
|
||
if (settingsRes.ok) {
|
||
const settings = await settingsRes.json();
|
||
lastUsedCase = settings.lastUsedCase || null;
|
||
}
|
||
} catch {
|
||
// Ignore settings load errors
|
||
}
|
||
|
||
// Add cache-busting to ensure fresh data
|
||
const res = await fetch('/api/cases?_t=' + Date.now());
|
||
const cases = await res.json();
|
||
this.cases = cases;
|
||
console.log('[loadQuickStartCases] Loaded cases:', cases.map(c => c.name), 'lastUsedCase:', lastUsedCase);
|
||
|
||
const select = document.getElementById('quickStartCase');
|
||
|
||
// Build options - existing cases first, then testcase as fallback if not present
|
||
let options = '';
|
||
const hasTestcase = cases.some(c => c.name === 'testcase');
|
||
|
||
cases.forEach(c => {
|
||
options += `<option value="${c.name}">${c.name}</option>`;
|
||
});
|
||
|
||
// Add testcase option if it doesn't exist (will be created on first run)
|
||
if (!hasTestcase) {
|
||
options = `<option value="testcase">testcase</option>` + options;
|
||
}
|
||
|
||
select.innerHTML = options;
|
||
console.log('[loadQuickStartCases] Set options:', select.innerHTML.substring(0, 200));
|
||
|
||
// If a specific case was requested, select it
|
||
if (selectCaseName) {
|
||
select.value = selectCaseName;
|
||
this.updateDirDisplayForCase(selectCaseName);
|
||
} else if (lastUsedCase && cases.some(c => c.name === lastUsedCase)) {
|
||
// Use lastUsedCase if available and exists
|
||
select.value = lastUsedCase;
|
||
this.updateDirDisplayForCase(lastUsedCase);
|
||
} else if (cases.length > 0) {
|
||
// Fallback to testcase or first case
|
||
const firstCase = cases.find(c => c.name === 'testcase') || cases[0];
|
||
select.value = firstCase.name;
|
||
this.updateDirDisplayForCase(firstCase.name);
|
||
} else {
|
||
// No cases exist yet - show the default case name as directory
|
||
select.value = 'testcase';
|
||
document.getElementById('dirDisplay').textContent = '~/claudeman-cases/testcase';
|
||
}
|
||
|
||
// Only add event listener once (on first load)
|
||
if (!select.dataset.listenerAdded) {
|
||
select.addEventListener('change', () => {
|
||
this.updateDirDisplayForCase(select.value);
|
||
this.saveLastUsedCase(select.value);
|
||
});
|
||
select.dataset.listenerAdded = 'true';
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load cases:', err);
|
||
}
|
||
}
|
||
|
||
async updateDirDisplayForCase(caseName) {
|
||
try {
|
||
const res = await fetch(`/api/cases/${caseName}`);
|
||
const data = await res.json();
|
||
if (data.path) {
|
||
document.getElementById('dirDisplay').textContent = data.path;
|
||
document.getElementById('dirInput').value = data.path;
|
||
}
|
||
} catch (err) {
|
||
document.getElementById('dirDisplay').textContent = caseName;
|
||
}
|
||
}
|
||
|
||
async saveLastUsedCase(caseName) {
|
||
try {
|
||
// Get current settings
|
||
const res = await fetch('/api/settings');
|
||
const settings = res.ok ? await res.json() : {};
|
||
// Update lastUsedCase
|
||
settings.lastUsedCase = caseName;
|
||
// Save back
|
||
await fetch('/api/settings', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(settings)
|
||
});
|
||
} catch (err) {
|
||
console.error('Failed to save last used case:', err);
|
||
}
|
||
}
|
||
|
||
async quickStart() {
|
||
// Alias for backward compatibility
|
||
return this.runClaude();
|
||
}
|
||
|
||
// Tab count stepper functions
|
||
incrementTabCount() {
|
||
const input = document.getElementById('tabCount');
|
||
const current = parseInt(input.value) || 1;
|
||
input.value = Math.min(20, current + 1);
|
||
}
|
||
|
||
decrementTabCount() {
|
||
const input = document.getElementById('tabCount');
|
||
const current = parseInt(input.value) || 1;
|
||
input.value = Math.max(1, current - 1);
|
||
}
|
||
|
||
// Shell count stepper functions
|
||
incrementShellCount() {
|
||
const input = document.getElementById('shellCount');
|
||
const current = parseInt(input.value) || 1;
|
||
input.value = Math.min(20, current + 1);
|
||
}
|
||
|
||
decrementShellCount() {
|
||
const input = document.getElementById('shellCount');
|
||
const current = parseInt(input.value) || 1;
|
||
input.value = Math.max(1, current - 1);
|
||
}
|
||
|
||
async runClaude() {
|
||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||
const tabCount = Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1));
|
||
|
||
this.terminal.clear();
|
||
this.terminal.writeln(`\x1b[1;32m Starting ${tabCount} Claude session(s) in ${caseName}...\x1b[0m`);
|
||
this.terminal.writeln('');
|
||
|
||
try {
|
||
// Get case path first
|
||
const caseRes = await fetch(`/api/cases/${caseName}`);
|
||
let caseData = await caseRes.json();
|
||
|
||
// Create the case if it doesn't exist
|
||
if (!caseData.path) {
|
||
const createCaseRes = await fetch('/api/cases', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: caseName, description: '' })
|
||
});
|
||
const createCaseData = await createCaseRes.json();
|
||
if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case');
|
||
// Use the newly created case data (API returns { success, case: { name, path } })
|
||
caseData = createCaseData.case;
|
||
}
|
||
|
||
const workingDir = caseData.path;
|
||
if (!workingDir) throw new Error('Case path not found');
|
||
let firstSessionId = null;
|
||
|
||
// Find the highest existing w-number for THIS case to avoid duplicates
|
||
let startNumber = 1;
|
||
for (const [, session] of this.sessions) {
|
||
const match = session.name && session.name.match(/^w(\d+)-(.+)$/);
|
||
if (match && match[2] === caseName) {
|
||
const num = parseInt(match[1]);
|
||
if (num >= startNumber) {
|
||
startNumber = num + 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Get global Ralph tracker setting
|
||
const ralphEnabled = this.isRalphTrackerEnabledByDefault();
|
||
|
||
// Create all sessions in parallel for speed
|
||
const sessionNames = [];
|
||
for (let i = 0; i < tabCount; i++) {
|
||
sessionNames.push(`w${startNumber + i}-${caseName}`);
|
||
}
|
||
|
||
// Step 1: Create all sessions in parallel
|
||
this.terminal.writeln(`\x1b[90m Creating ${tabCount} session(s)...\x1b[0m`);
|
||
const createPromises = sessionNames.map(name =>
|
||
fetch('/api/sessions', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ workingDir, name })
|
||
}).then(r => r.json())
|
||
);
|
||
const createResults = await Promise.all(createPromises);
|
||
|
||
// Collect created session IDs
|
||
const sessionIds = [];
|
||
for (const result of createResults) {
|
||
if (!result.success) throw new Error(result.error);
|
||
sessionIds.push(result.session.id);
|
||
}
|
||
firstSessionId = sessionIds[0];
|
||
|
||
// Step 2: Configure Ralph for all sessions in parallel
|
||
await Promise.all(sessionIds.map(id =>
|
||
fetch(`/api/sessions/${id}/ralph-config`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ enabled: ralphEnabled, disableAutoEnable: !ralphEnabled })
|
||
})
|
||
));
|
||
|
||
// Step 3: Start all sessions in parallel (biggest speedup)
|
||
this.terminal.writeln(`\x1b[90m Starting ${tabCount} session(s) in parallel...\x1b[0m`);
|
||
await Promise.all(sessionIds.map(id =>
|
||
fetch(`/api/sessions/${id}/interactive`, { method: 'POST' })
|
||
));
|
||
|
||
this.terminal.writeln(`\x1b[90m All ${tabCount} sessions ready\x1b[0m`);
|
||
|
||
// Auto-switch to the new session using selectSession (does proper refresh)
|
||
if (firstSessionId) {
|
||
await this.selectSession(firstSessionId);
|
||
this.loadQuickStartCases();
|
||
}
|
||
|
||
this.terminal.focus();
|
||
} catch (err) {
|
||
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
||
}
|
||
}
|
||
|
||
async runShell() {
|
||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||
const shellCount = Math.min(20, Math.max(1, parseInt(document.getElementById('shellCount').value) || 1));
|
||
|
||
this.terminal.clear();
|
||
this.terminal.writeln(`\x1b[1;33m Starting ${shellCount} Shell session(s) in ${caseName}...\x1b[0m`);
|
||
this.terminal.writeln('');
|
||
|
||
try {
|
||
// Get the case path
|
||
const caseRes = await fetch(`/api/cases/${caseName}`);
|
||
const caseData = await caseRes.json();
|
||
const workingDir = caseData.path;
|
||
if (!workingDir) throw new Error('Case path not found');
|
||
|
||
// Find the highest existing s-number for THIS case to avoid duplicates
|
||
let startNumber = 1;
|
||
for (const [, session] of this.sessions) {
|
||
const match = session.name && session.name.match(/^s(\d+)-(.+)$/);
|
||
if (match && match[2] === caseName) {
|
||
const num = parseInt(match[1]);
|
||
if (num >= startNumber) {
|
||
startNumber = num + 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Create all shell sessions in parallel
|
||
const sessionNames = [];
|
||
for (let i = 0; i < shellCount; i++) {
|
||
sessionNames.push(`s${startNumber + i}-${caseName}`);
|
||
}
|
||
|
||
// Step 1: Create all sessions in parallel
|
||
const createPromises = sessionNames.map(name =>
|
||
fetch('/api/sessions', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ workingDir, mode: 'shell', name })
|
||
}).then(r => r.json())
|
||
);
|
||
const createResults = await Promise.all(createPromises);
|
||
|
||
const sessionIds = [];
|
||
for (const result of createResults) {
|
||
if (!result.success) throw new Error(result.error);
|
||
sessionIds.push(result.session.id);
|
||
}
|
||
|
||
// Step 2: Start all shells in parallel
|
||
await Promise.all(sessionIds.map(id =>
|
||
fetch(`/api/sessions/${id}/shell`, { method: 'POST' })
|
||
));
|
||
|
||
// Step 3: Resize all in parallel
|
||
const dims = this.fitAddon.proposeDimensions();
|
||
if (dims) {
|
||
await Promise.all(sessionIds.map(id =>
|
||
fetch(`/api/sessions/${id}/resize`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ cols: dims.cols, rows: dims.rows })
|
||
})
|
||
));
|
||
}
|
||
|
||
// Switch to first session
|
||
if (sessionIds.length > 0) {
|
||
this.activeSessionId = sessionIds[0];
|
||
await this.selectSession(sessionIds[0]);
|
||
}
|
||
|
||
this.terminal.focus();
|
||
} catch (err) {
|
||
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
||
}
|
||
}
|
||
|
||
// ========== Directory Input ==========
|
||
|
||
toggleDirInput() {
|
||
const btn = document.querySelector('#dirDisplay').parentElement;
|
||
const input = document.getElementById('dirInput');
|
||
|
||
if (input.classList.contains('hidden')) {
|
||
input.classList.remove('hidden');
|
||
btn.style.display = 'none';
|
||
input.focus();
|
||
}
|
||
}
|
||
|
||
hideDirInput() {
|
||
const btn = document.querySelector('#dirDisplay').parentElement;
|
||
const input = document.getElementById('dirInput');
|
||
|
||
setTimeout(() => {
|
||
input.classList.add('hidden');
|
||
btn.style.display = '';
|
||
|
||
const value = input.value.trim();
|
||
document.getElementById('dirDisplay').textContent = value || 'No directory';
|
||
}, 100);
|
||
}
|
||
|
||
// ========== Respawn Banner ==========
|
||
|
||
showRespawnBanner() {
|
||
this.$('respawnBanner').style.display = 'flex';
|
||
// Also show timer if there's a timed respawn
|
||
if (this.activeSessionId && this.respawnTimers[this.activeSessionId]) {
|
||
this.showRespawnTimer();
|
||
}
|
||
// Show tokens if session has token data
|
||
const session = this.sessions.get(this.activeSessionId);
|
||
if (session && session.tokens) {
|
||
this.updateRespawnTokens(session.tokens);
|
||
}
|
||
}
|
||
|
||
hideRespawnBanner() {
|
||
this.$('respawnBanner').style.display = 'none';
|
||
this.hideRespawnTimer();
|
||
}
|
||
|
||
// Human-friendly state labels
|
||
getStateLabel(state) {
|
||
const labels = {
|
||
'stopped': 'Stopped',
|
||
'watching': 'Watching',
|
||
'confirming_idle': 'Confirming idle',
|
||
'ai_checking': 'AI checking',
|
||
'sending_update': 'Sending prompt',
|
||
'waiting_update': 'Running prompt',
|
||
'sending_clear': 'Clearing context',
|
||
'waiting_clear': 'Clearing...',
|
||
'sending_init': 'Initializing',
|
||
'waiting_init': 'Initializing...',
|
||
'monitoring_init': 'Waiting for work',
|
||
'sending_kickstart': 'Kickstarting',
|
||
'waiting_kickstart': 'Kickstarting...',
|
||
};
|
||
return labels[state] || state.replace(/_/g, ' ');
|
||
}
|
||
|
||
updateRespawnBanner(state) {
|
||
this.$('respawnState').textContent = this.getStateLabel(state);
|
||
}
|
||
|
||
updateDetectionDisplay(detection) {
|
||
if (!detection) return;
|
||
|
||
const statusEl = this.$('detectionStatus');
|
||
const waitingEl = this.$('detectionWaiting');
|
||
const confidenceEl = this.$('detectionConfidence');
|
||
const aiCheckEl = document.getElementById('detectionAiCheck');
|
||
const hookEl = document.getElementById('detectionHook');
|
||
|
||
// Hook-based detection indicator (highest priority signals)
|
||
if (hookEl) {
|
||
if (detection.stopHookReceived || detection.idlePromptReceived) {
|
||
const hookType = detection.idlePromptReceived ? 'idle' : 'stop';
|
||
hookEl.textContent = `🎯 ${hookType} hook`;
|
||
hookEl.className = 'detection-hook hook-active';
|
||
hookEl.style.display = '';
|
||
} else {
|
||
hookEl.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// Simplified status - only show when meaningful
|
||
if (detection.statusText && detection.statusText !== 'Watching...') {
|
||
statusEl.textContent = detection.statusText;
|
||
statusEl.style.display = '';
|
||
} else {
|
||
statusEl.style.display = 'none';
|
||
}
|
||
|
||
// Hide "waiting for" text - it's redundant with the state label
|
||
waitingEl.style.display = 'none';
|
||
|
||
// Show confidence only when confirming (>0%)
|
||
const confidence = detection.confidenceLevel || 0;
|
||
if (confidence > 0) {
|
||
confidenceEl.textContent = `${confidence}%`;
|
||
confidenceEl.style.display = '';
|
||
confidenceEl.className = 'detection-confidence';
|
||
// Hook signals give 100% confidence
|
||
if (detection.stopHookReceived || detection.idlePromptReceived) {
|
||
confidenceEl.classList.add('hook-confirmed');
|
||
} else if (confidence >= 60) {
|
||
confidenceEl.classList.add('high');
|
||
} else if (confidence >= 30) {
|
||
confidenceEl.classList.add('medium');
|
||
}
|
||
} else {
|
||
confidenceEl.style.display = 'none';
|
||
}
|
||
|
||
// AI check display - compact format
|
||
if (aiCheckEl && detection.aiCheck) {
|
||
const ai = detection.aiCheck;
|
||
let aiText = '';
|
||
let aiClass = 'detection-ai-check';
|
||
|
||
if (ai.status === 'checking') {
|
||
aiText = '🔍 AI checking...';
|
||
aiClass += ' ai-checking';
|
||
} else if (ai.status === 'cooldown' && ai.cooldownEndsAt) {
|
||
const remaining = Math.ceil((ai.cooldownEndsAt - Date.now()) / 1000);
|
||
if (remaining > 0) {
|
||
if (ai.lastVerdict === 'WORKING') {
|
||
aiText = `⏳ Working, retry ${remaining}s`;
|
||
aiClass += ' ai-working';
|
||
} else {
|
||
aiText = `✓ Idle, wait ${remaining}s`;
|
||
aiClass += ' ai-idle';
|
||
}
|
||
}
|
||
} else if (ai.status === 'disabled') {
|
||
aiText = '⚠ AI disabled';
|
||
aiClass += ' ai-disabled';
|
||
} else if (ai.lastVerdict && ai.lastCheckTime) {
|
||
const ago = Math.round((Date.now() - ai.lastCheckTime) / 1000);
|
||
if (ago < 120) {
|
||
aiText = ai.lastVerdict === 'IDLE'
|
||
? `✓ Idle (${ago}s)`
|
||
: `⏳ Working (${ago}s)`;
|
||
aiClass += ai.lastVerdict === 'IDLE' ? ' ai-idle' : ' ai-working';
|
||
}
|
||
}
|
||
|
||
aiCheckEl.textContent = aiText;
|
||
aiCheckEl.className = aiClass;
|
||
aiCheckEl.style.display = aiText ? '' : 'none';
|
||
} else if (aiCheckEl) {
|
||
aiCheckEl.style.display = 'none';
|
||
}
|
||
|
||
// Manage row2 visibility - hide if nothing visible
|
||
const row2 = this.$('respawnStatusRow2');
|
||
if (row2) {
|
||
const hasVisibleContent =
|
||
(hookEl && hookEl.style.display !== 'none') ||
|
||
(aiCheckEl && aiCheckEl.style.display !== 'none') ||
|
||
(statusEl && statusEl.style.display !== 'none') ||
|
||
(this.respawnCountdownTimers[this.activeSessionId] &&
|
||
Object.keys(this.respawnCountdownTimers[this.activeSessionId]).length > 0);
|
||
row2.style.display = hasVisibleContent ? '' : 'none';
|
||
}
|
||
}
|
||
|
||
showRespawnTimer() {
|
||
const timerEl = this.$('respawnTimer');
|
||
timerEl.style.display = '';
|
||
this.updateRespawnTimer();
|
||
// Update every second
|
||
if (this.respawnTimerInterval) clearInterval(this.respawnTimerInterval);
|
||
this.respawnTimerInterval = setInterval(() => this.updateRespawnTimer(), 1000);
|
||
}
|
||
|
||
hideRespawnTimer() {
|
||
this.$('respawnTimer').style.display = 'none';
|
||
if (this.respawnTimerInterval) {
|
||
clearInterval(this.respawnTimerInterval);
|
||
this.respawnTimerInterval = null;
|
||
}
|
||
}
|
||
|
||
updateRespawnTimer() {
|
||
if (!this.activeSessionId || !this.respawnTimers[this.activeSessionId]) {
|
||
this.hideRespawnTimer();
|
||
return;
|
||
}
|
||
|
||
const timer = this.respawnTimers[this.activeSessionId];
|
||
// Guard against invalid timer data
|
||
if (!timer.endAt || isNaN(timer.endAt)) {
|
||
this.hideRespawnTimer();
|
||
return;
|
||
}
|
||
|
||
const now = Date.now();
|
||
const remaining = Math.max(0, timer.endAt - now);
|
||
|
||
if (remaining <= 0) {
|
||
this.$('respawnTimer').textContent = 'Time up';
|
||
delete this.respawnTimers[this.activeSessionId];
|
||
this.hideRespawnTimer();
|
||
return;
|
||
}
|
||
|
||
this.$('respawnTimer').textContent = this.formatTime(remaining);
|
||
}
|
||
|
||
updateRespawnTokens(tokens) {
|
||
const tokensEl = this.$('respawnTokens');
|
||
// Support both old format (number) and new format (object with input/output/total)
|
||
const isObject = tokens && typeof tokens === 'object';
|
||
const total = isObject ? tokens.total : tokens;
|
||
const input = isObject ? (tokens.input || 0) : Math.round(total * 0.6);
|
||
const output = isObject ? (tokens.output || 0) : Math.round(total * 0.4);
|
||
|
||
if (total > 0) {
|
||
tokensEl.style.display = '';
|
||
const tokenStr = this.formatTokens(total);
|
||
const estimatedCost = this.estimateCost(input, output);
|
||
tokensEl.textContent = `${tokenStr} tokens · $${estimatedCost.toFixed(2)}`;
|
||
} else {
|
||
tokensEl.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// ========== Countdown Timer Display Methods ==========
|
||
|
||
addActionLogEntry(sessionId, action) {
|
||
// Only keep truly interesting events - no spam
|
||
// KEEP: command (inputs), hook events, AI verdicts, plan verdicts
|
||
// SKIP: timer, timer-cancel, state changes, routine detection, step confirmations
|
||
|
||
const interestingTypes = ['command', 'hook'];
|
||
|
||
// Always keep commands and hooks
|
||
if (interestingTypes.includes(action.type)) {
|
||
// ok, keep it
|
||
}
|
||
// AI check: only verdicts (IDLE, WORKING) and errors, not "Spawning"
|
||
else if (action.type === 'ai-check') {
|
||
if (action.detail.includes('Spawning')) return;
|
||
}
|
||
// Plan check: only verdicts, not "Spawning"
|
||
else if (action.type === 'plan-check') {
|
||
if (action.detail.includes('Spawning')) return;
|
||
}
|
||
// Transcript: keep completion/plan detection
|
||
else if (action.type === 'transcript') {
|
||
// keep it
|
||
}
|
||
// Skip everything else (timer, timer-cancel, state, detection, step)
|
||
else {
|
||
return;
|
||
}
|
||
|
||
if (!this.respawnActionLogs[sessionId]) {
|
||
this.respawnActionLogs[sessionId] = [];
|
||
}
|
||
this.respawnActionLogs[sessionId].unshift(action);
|
||
// Keep reasonable history
|
||
if (this.respawnActionLogs[sessionId].length > 30) {
|
||
this.respawnActionLogs[sessionId].pop();
|
||
}
|
||
}
|
||
|
||
startCountdownInterval() {
|
||
if (this.timerCountdownInterval) return;
|
||
this.timerCountdownInterval = setInterval(() => {
|
||
if (this.activeSessionId && this.respawnCountdownTimers[this.activeSessionId]) {
|
||
this.updateCountdownTimerDisplay();
|
||
}
|
||
}, 100);
|
||
}
|
||
|
||
stopCountdownInterval() {
|
||
if (this.timerCountdownInterval) {
|
||
clearInterval(this.timerCountdownInterval);
|
||
this.timerCountdownInterval = null;
|
||
}
|
||
}
|
||
|
||
updateCountdownTimerDisplay() {
|
||
const timersContainer = this.$('respawnCountdownTimers');
|
||
const row2 = this.$('respawnStatusRow2');
|
||
if (!timersContainer) return;
|
||
|
||
const timers = this.respawnCountdownTimers[this.activeSessionId];
|
||
const hasTimers = timers && Object.keys(timers).length > 0;
|
||
|
||
if (!hasTimers) {
|
||
timersContainer.innerHTML = '';
|
||
// Update row2 visibility
|
||
if (row2) {
|
||
const hookEl = document.getElementById('detectionHook');
|
||
const aiCheckEl = document.getElementById('detectionAiCheck');
|
||
const statusEl = this.$('detectionStatus');
|
||
const hasVisibleContent =
|
||
(hookEl && hookEl.style.display !== 'none') ||
|
||
(aiCheckEl && aiCheckEl.style.display !== 'none') ||
|
||
(statusEl && statusEl.style.display !== 'none');
|
||
row2.style.display = hasVisibleContent ? '' : 'none';
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Show row2 since we have timers
|
||
if (row2) row2.style.display = '';
|
||
|
||
const now = Date.now();
|
||
let html = '';
|
||
|
||
for (const [name, timer] of Object.entries(timers)) {
|
||
const remainingMs = Math.max(0, timer.endsAt - now);
|
||
const remainingSec = (remainingMs / 1000).toFixed(1);
|
||
const percent = Math.max(0, Math.min(100, (remainingMs / timer.totalMs) * 100));
|
||
|
||
// Shorter timer name display
|
||
const displayName = name.replace(/-/g, ' ').replace(/^\w/, c => c.toUpperCase());
|
||
|
||
html += `<div class="respawn-countdown-timer" title="${timer.reason || ''}">
|
||
<span class="timer-name">${displayName}</span>
|
||
<span class="timer-value">${remainingSec}s</span>
|
||
<div class="respawn-timer-bar">
|
||
<div class="respawn-timer-progress" style="width: ${percent}%"></div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
timersContainer.innerHTML = html;
|
||
}
|
||
|
||
updateActionLogDisplay() {
|
||
const logContainer = this.$('respawnActionLog');
|
||
if (!logContainer) return;
|
||
|
||
const actions = this.respawnActionLogs[this.activeSessionId];
|
||
if (!actions || actions.length === 0) {
|
||
logContainer.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
// Show fewer entries for compact view
|
||
for (const action of actions.slice(0, 5)) {
|
||
const time = new Date(action.timestamp).toLocaleTimeString('en-US', {
|
||
hour12: false,
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit'
|
||
});
|
||
const isCommand = action.type === 'command';
|
||
const extraClass = isCommand ? ' action-command' : '';
|
||
// Compact format: time [type] detail
|
||
html += `<div class="respawn-action-entry${extraClass}">
|
||
<span class="action-time">${time}</span>
|
||
<span class="action-type">[${action.type}]</span>
|
||
<span class="action-detail">${action.detail}</span>
|
||
</div>`;
|
||
}
|
||
|
||
logContainer.innerHTML = html;
|
||
}
|
||
|
||
clearCountdownTimers(sessionId) {
|
||
delete this.respawnCountdownTimers[sessionId];
|
||
delete this.respawnActionLogs[sessionId];
|
||
if (sessionId === this.activeSessionId) {
|
||
this.updateCountdownTimerDisplay();
|
||
this.updateActionLogDisplay();
|
||
}
|
||
}
|
||
|
||
async stopRespawn() {
|
||
if (!this.activeSessionId) return;
|
||
try {
|
||
await fetch(`/api/sessions/${this.activeSessionId}/respawn/stop`, { method: 'POST' });
|
||
delete this.respawnTimers[this.activeSessionId];
|
||
this.clearCountdownTimers(this.activeSessionId);
|
||
} catch (err) {
|
||
this.showToast('Failed to stop respawn', 'error');
|
||
}
|
||
}
|
||
|
||
// ========== Kill Sessions ==========
|
||
|
||
async killActiveSession() {
|
||
if (!this.activeSessionId) {
|
||
this.showToast('No active session', 'warning');
|
||
return;
|
||
}
|
||
await this.closeSession(this.activeSessionId);
|
||
}
|
||
|
||
async killAllSessions() {
|
||
if (this.sessions.size === 0) return;
|
||
|
||
if (!confirm(`Kill all ${this.sessions.size} session(s)?`)) return;
|
||
|
||
try {
|
||
await fetch('/api/sessions', { method: 'DELETE' });
|
||
this.sessions.clear();
|
||
this.terminalBuffers.clear();
|
||
this.activeSessionId = null;
|
||
this.respawnStatus = {};
|
||
this.respawnCountdownTimers = {};
|
||
this.respawnActionLogs = {};
|
||
this.stopCountdownInterval();
|
||
this.hideRespawnBanner();
|
||
this.renderSessionTabs();
|
||
this.terminal.clear();
|
||
this.showWelcome();
|
||
this.showToast('All sessions killed', 'success');
|
||
} catch (err) {
|
||
this.showToast('Failed to kill sessions', 'error');
|
||
}
|
||
}
|
||
|
||
// ========== Terminal Controls ==========
|
||
|
||
clearTerminal() {
|
||
this.terminal.clear();
|
||
}
|
||
|
||
// Send Ctrl+L to fix display for newly created sessions once Claude is running
|
||
sendPendingCtrlL(sessionId) {
|
||
console.log('[DEBUG] sendPendingCtrlL called for:', sessionId, 'pending:', this.pendingCtrlL ? [...this.pendingCtrlL] : 'none');
|
||
if (!this.pendingCtrlL || !this.pendingCtrlL.has(sessionId)) {
|
||
console.log('[DEBUG] No pending Ctrl+L for this session');
|
||
return;
|
||
}
|
||
this.pendingCtrlL.delete(sessionId);
|
||
|
||
// Only send if this is the active session
|
||
if (sessionId !== this.activeSessionId) {
|
||
console.log('[DEBUG] Not active session, skipping Ctrl+L');
|
||
return;
|
||
}
|
||
|
||
console.log('[DEBUG] Sending resize + Ctrl+L for session:', sessionId);
|
||
// Send resize + Ctrl+L to fix the display
|
||
const dims = this.fitAddon.proposeDimensions();
|
||
if (dims) {
|
||
fetch(`/api/sessions/${sessionId}/resize`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ cols: dims.cols, rows: dims.rows })
|
||
}).then(() => {
|
||
console.log('[DEBUG] Resize sent, now sending Ctrl+L');
|
||
fetch(`/api/sessions/${sessionId}/input`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ input: '\x0c' })
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
async copyTerminal() {
|
||
try {
|
||
const buffer = this.terminal.buffer.active;
|
||
let text = '';
|
||
for (let i = 0; i < buffer.length; i++) {
|
||
const line = buffer.getLine(i);
|
||
if (line) text += line.translateToString(true) + '\n';
|
||
}
|
||
await navigator.clipboard.writeText(text.replace(/\n+$/, '\n'));
|
||
this.showToast('Copied to clipboard', 'success');
|
||
} catch (err) {
|
||
this.showToast('Failed to copy', 'error');
|
||
}
|
||
}
|
||
|
||
increaseFontSize() {
|
||
const current = this.terminal.options.fontSize || 14;
|
||
this.setFontSize(Math.min(current + 2, 24));
|
||
}
|
||
|
||
decreaseFontSize() {
|
||
const current = this.terminal.options.fontSize || 14;
|
||
this.setFontSize(Math.max(current - 2, 10));
|
||
}
|
||
|
||
setFontSize(size) {
|
||
this.terminal.options.fontSize = size;
|
||
document.getElementById('fontSizeDisplay').textContent = size;
|
||
this.fitAddon.fit();
|
||
localStorage.setItem('claudeman-font-size', size);
|
||
}
|
||
|
||
loadFontSize() {
|
||
const saved = localStorage.getItem('claudeman-font-size');
|
||
if (saved) {
|
||
const size = parseInt(saved, 10);
|
||
if (size >= 10 && size <= 24) {
|
||
this.terminal.options.fontSize = size;
|
||
document.getElementById('fontSizeDisplay').textContent = size;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ========== Timer ==========
|
||
|
||
showTimer() {
|
||
document.getElementById('timerBanner').style.display = 'flex';
|
||
this.updateTimer();
|
||
this.timerInterval = setInterval(() => this.updateTimer(), 1000);
|
||
}
|
||
|
||
hideTimer() {
|
||
document.getElementById('timerBanner').style.display = 'none';
|
||
if (this.timerInterval) {
|
||
clearInterval(this.timerInterval);
|
||
this.timerInterval = null;
|
||
}
|
||
}
|
||
|
||
updateTimer() {
|
||
if (!this.currentRun || this.currentRun.status !== 'running') return;
|
||
|
||
const now = Date.now();
|
||
const remaining = Math.max(0, this.currentRun.endAt - now);
|
||
const total = this.currentRun.endAt - this.currentRun.startedAt;
|
||
const elapsed = now - this.currentRun.startedAt;
|
||
const percent = Math.min(100, (elapsed / total) * 100);
|
||
|
||
document.getElementById('timerValue').textContent = this.formatTime(remaining);
|
||
document.getElementById('timerProgress').style.width = `${percent}%`;
|
||
document.getElementById('timerMeta').textContent =
|
||
`${this.currentRun.completedTasks} tasks | $${this.currentRun.totalCost.toFixed(2)}`;
|
||
}
|
||
|
||
async stopCurrentRun() {
|
||
if (!this.currentRun) return;
|
||
try {
|
||
await fetch(`/api/scheduled/${this.currentRun.id}`, { method: 'DELETE' });
|
||
} catch (err) {
|
||
this.showToast('Failed to stop run', 'error');
|
||
}
|
||
}
|
||
|
||
formatTime(ms) {
|
||
const s = Math.floor(ms / 1000);
|
||
const h = Math.floor(s / 3600);
|
||
const m = Math.floor((s % 3600) / 60);
|
||
const sec = s % 60;
|
||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
|
||
}
|
||
|
||
// ========== Tokens ==========
|
||
|
||
updateCost() {
|
||
// Now updates tokens instead of cost
|
||
this.updateTokens();
|
||
}
|
||
|
||
updateTokens() {
|
||
// Use global stats if available (includes deleted sessions)
|
||
let totalInput = 0;
|
||
let totalOutput = 0;
|
||
if (this.globalStats) {
|
||
totalInput = this.globalStats.totalInputTokens || 0;
|
||
totalOutput = this.globalStats.totalOutputTokens || 0;
|
||
} else {
|
||
// Fallback to active sessions only
|
||
this.sessions.forEach(s => {
|
||
if (s.tokens) {
|
||
totalInput += s.tokens.input || 0;
|
||
totalOutput += s.tokens.output || 0;
|
||
}
|
||
});
|
||
}
|
||
const total = totalInput + totalOutput;
|
||
this.totalTokens = total;
|
||
const display = this.formatTokens(total);
|
||
|
||
// Estimate cost from tokens (more accurate than stored cost in interactive mode)
|
||
const estimatedCost = this.estimateCost(totalInput, totalOutput);
|
||
const tokenEl = this.$('headerTokens');
|
||
if (tokenEl) {
|
||
tokenEl.textContent = total > 0 ? `${display} tokens · $${estimatedCost.toFixed(2)}` : '0 tokens';
|
||
tokenEl.title = this.globalStats
|
||
? `Lifetime: ${this.globalStats.totalSessionsCreated} sessions created\nEstimated cost based on Claude Opus pricing`
|
||
: 'Token usage across active sessions\nEstimated cost based on Claude Opus pricing';
|
||
}
|
||
}
|
||
|
||
// ========== Session Options Modal ==========
|
||
|
||
openSessionOptions(sessionId) {
|
||
const session = this.sessions.get(sessionId);
|
||
if (!session) return;
|
||
|
||
this.editingSessionId = sessionId;
|
||
|
||
// Reset to Respawn tab
|
||
this.switchOptionsTab('respawn');
|
||
|
||
// Update respawn status display and buttons
|
||
const respawnStatus = document.getElementById('sessionRespawnStatus');
|
||
const enableBtn = document.getElementById('modalEnableRespawnBtn');
|
||
const stopBtn = document.getElementById('modalStopRespawnBtn');
|
||
|
||
if (this.respawnStatus[sessionId]) {
|
||
respawnStatus.classList.add('active');
|
||
respawnStatus.querySelector('.respawn-status-text').textContent =
|
||
this.respawnStatus[sessionId].state || 'Active';
|
||
enableBtn.style.display = 'none';
|
||
stopBtn.style.display = '';
|
||
} else {
|
||
respawnStatus.classList.remove('active');
|
||
respawnStatus.querySelector('.respawn-status-text').textContent = 'Not active';
|
||
enableBtn.style.display = '';
|
||
stopBtn.style.display = 'none';
|
||
}
|
||
|
||
// Only show respawn section for claude mode sessions with a running process
|
||
const respawnSection = document.getElementById('sessionRespawnSection');
|
||
if (session.mode === 'claude' && session.pid) {
|
||
respawnSection.style.display = '';
|
||
} else {
|
||
respawnSection.style.display = 'none';
|
||
}
|
||
|
||
// Reset duration presets to default (unlimited)
|
||
this.selectDurationPreset('');
|
||
|
||
// Populate respawn config from saved state
|
||
this.loadSavedRespawnConfig(sessionId);
|
||
|
||
// Populate auto-compact/clear from session state
|
||
document.getElementById('modalAutoCompactEnabled').checked = session.autoCompactEnabled ?? false;
|
||
document.getElementById('modalAutoCompactThreshold').value = session.autoCompactThreshold ?? 110000;
|
||
document.getElementById('modalAutoCompactPrompt').value = session.autoCompactPrompt ?? '';
|
||
document.getElementById('modalAutoClearEnabled').checked = session.autoClearEnabled ?? false;
|
||
document.getElementById('modalAutoClearThreshold').value = session.autoClearThreshold ?? 140000;
|
||
|
||
// Populate Ralph Wiggum form with current session values
|
||
const ralphState = this.ralphStates.get(sessionId);
|
||
this.populateRalphForm({
|
||
enabled: ralphState?.loop?.enabled ?? session.ralphLoop?.enabled ?? false,
|
||
completionPhrase: ralphState?.loop?.completionPhrase || session.ralphLoop?.completionPhrase || '',
|
||
maxIterations: ralphState?.loop?.maxIterations || session.ralphLoop?.maxIterations || 0,
|
||
});
|
||
|
||
document.getElementById('sessionOptionsModal').classList.add('active');
|
||
}
|
||
|
||
async autoSaveAutoCompact() {
|
||
if (!this.editingSessionId) return;
|
||
try {
|
||
await fetch(`/api/sessions/${this.editingSessionId}/auto-compact`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
enabled: document.getElementById('modalAutoCompactEnabled').checked,
|
||
threshold: parseInt(document.getElementById('modalAutoCompactThreshold').value) || 110000,
|
||
prompt: document.getElementById('modalAutoCompactPrompt').value.trim() || undefined
|
||
})
|
||
});
|
||
} catch { /* silent */ }
|
||
}
|
||
|
||
async autoSaveAutoClear() {
|
||
if (!this.editingSessionId) return;
|
||
try {
|
||
await fetch(`/api/sessions/${this.editingSessionId}/auto-clear`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
enabled: document.getElementById('modalAutoClearEnabled').checked,
|
||
threshold: parseInt(document.getElementById('modalAutoClearThreshold').value) || 140000
|
||
})
|
||
});
|
||
} catch { /* silent */ }
|
||
}
|
||
|
||
async autoSaveRespawnConfig() {
|
||
if (!this.editingSessionId) return;
|
||
const config = {
|
||
updatePrompt: document.getElementById('modalRespawnPrompt').value,
|
||
sendClear: document.getElementById('modalRespawnSendClear').checked,
|
||
sendInit: document.getElementById('modalRespawnSendInit').checked,
|
||
kickstartPrompt: document.getElementById('modalRespawnKickstart').value.trim() || undefined,
|
||
autoAcceptPrompts: document.getElementById('modalRespawnAutoAccept').checked,
|
||
};
|
||
try {
|
||
await fetch(`/api/sessions/${this.editingSessionId}/respawn/config`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(config)
|
||
});
|
||
} catch {
|
||
// Silent save - don't interrupt user
|
||
}
|
||
}
|
||
|
||
async loadSavedRespawnConfig(sessionId) {
|
||
try {
|
||
const res = await fetch(`/api/sessions/${sessionId}/respawn/config`);
|
||
const data = await res.json();
|
||
if (data.success && data.config) {
|
||
const c = data.config;
|
||
document.getElementById('modalRespawnPrompt').value = c.updatePrompt || 'update all the docs and CLAUDE.md';
|
||
document.getElementById('modalRespawnSendClear').checked = c.sendClear ?? true;
|
||
document.getElementById('modalRespawnSendInit').checked = c.sendInit ?? true;
|
||
document.getElementById('modalRespawnKickstart').value = c.kickstartPrompt || '';
|
||
document.getElementById('modalRespawnAutoAccept').checked = c.autoAcceptPrompts ?? true;
|
||
// Restore duration if set
|
||
if (c.durationMinutes) {
|
||
const presetBtn = document.querySelector(`.duration-preset-btn[data-minutes="${c.durationMinutes}"]`);
|
||
if (presetBtn) {
|
||
this.selectDurationPreset(String(c.durationMinutes));
|
||
} else {
|
||
this.selectDurationPreset('custom');
|
||
document.getElementById('modalRespawnDuration').value = c.durationMinutes;
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// Ignore - use defaults
|
||
}
|
||
}
|
||
|
||
// Handle duration preset selection
|
||
selectDurationPreset(value) {
|
||
// Remove active from all buttons
|
||
document.querySelectorAll('.duration-preset-btn').forEach(btn => btn.classList.remove('active'));
|
||
|
||
// Find and activate the clicked button
|
||
const btn = document.querySelector(`.duration-preset-btn[data-minutes="${value}"]`);
|
||
if (btn) btn.classList.add('active');
|
||
|
||
// Show/hide custom input
|
||
const customInput = document.querySelector('.duration-custom-input');
|
||
const durationInput = document.getElementById('modalRespawnDuration');
|
||
|
||
if (value === 'custom') {
|
||
customInput.classList.add('visible');
|
||
durationInput.focus();
|
||
} else {
|
||
customInput.classList.remove('visible');
|
||
durationInput.value = ''; // Clear custom value when using preset
|
||
}
|
||
}
|
||
|
||
// Get selected duration from preset buttons or custom input
|
||
getSelectedDuration() {
|
||
const customInput = document.querySelector('.duration-custom-input');
|
||
const durationInput = document.getElementById('modalRespawnDuration');
|
||
|
||
if (customInput.classList.contains('visible')) {
|
||
// Custom mode - use input value
|
||
return durationInput.value ? parseInt(durationInput.value) : null;
|
||
} else {
|
||
// Preset mode - get from active button
|
||
const activeBtn = document.querySelector('.duration-preset-btn.active');
|
||
const minutes = activeBtn?.dataset.minutes;
|
||
return minutes ? parseInt(minutes) : null;
|
||
}
|
||
}
|
||
|
||
// Get respawn config from modal inputs
|
||
getModalRespawnConfig() {
|
||
const updatePrompt = document.getElementById('modalRespawnPrompt').value;
|
||
const sendClear = document.getElementById('modalRespawnSendClear').checked;
|
||
const sendInit = document.getElementById('modalRespawnSendInit').checked;
|
||
const kickstartPrompt = document.getElementById('modalRespawnKickstart').value.trim() || undefined;
|
||
const autoAcceptPrompts = document.getElementById('modalRespawnAutoAccept').checked;
|
||
const durationMinutes = this.getSelectedDuration();
|
||
|
||
// Auto-compact settings
|
||
const autoCompactEnabled = document.getElementById('modalAutoCompactEnabled').checked;
|
||
const autoCompactThreshold = parseInt(document.getElementById('modalAutoCompactThreshold').value) || 110000;
|
||
const autoCompactPrompt = document.getElementById('modalAutoCompactPrompt').value.trim() || undefined;
|
||
|
||
// Auto-clear settings
|
||
const autoClearEnabled = document.getElementById('modalAutoClearEnabled').checked;
|
||
const autoClearThreshold = parseInt(document.getElementById('modalAutoClearThreshold').value) || 140000;
|
||
|
||
return {
|
||
respawnConfig: {
|
||
enabled: true, // Fix: ensure enabled is set so pre-saved configs with enabled: false get overridden
|
||
updatePrompt,
|
||
sendClear,
|
||
sendInit,
|
||
kickstartPrompt,
|
||
autoAcceptPrompts,
|
||
},
|
||
durationMinutes,
|
||
autoCompactEnabled,
|
||
autoCompactThreshold,
|
||
autoCompactPrompt,
|
||
autoClearEnabled,
|
||
autoClearThreshold
|
||
};
|
||
}
|
||
|
||
async enableRespawnFromModal() {
|
||
if (!this.editingSessionId) {
|
||
this.showToast('No session selected', 'warning');
|
||
return;
|
||
}
|
||
|
||
const {
|
||
respawnConfig,
|
||
durationMinutes,
|
||
autoCompactEnabled,
|
||
autoCompactThreshold,
|
||
autoCompactPrompt,
|
||
autoClearEnabled,
|
||
autoClearThreshold
|
||
} = this.getModalRespawnConfig();
|
||
|
||
try {
|
||
// Enable respawn on the session
|
||
const res = await fetch(`/api/sessions/${this.editingSessionId}/respawn/enable`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ config: respawnConfig, durationMinutes })
|
||
});
|
||
const data = await res.json();
|
||
if (data.error) throw new Error(data.error);
|
||
|
||
// Set auto-compact if enabled
|
||
if (autoCompactEnabled) {
|
||
await fetch(`/api/sessions/${this.editingSessionId}/auto-compact`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ enabled: true, threshold: autoCompactThreshold, prompt: autoCompactPrompt })
|
||
});
|
||
}
|
||
|
||
// Set auto-clear if enabled
|
||
if (autoClearEnabled) {
|
||
await fetch(`/api/sessions/${this.editingSessionId}/auto-clear`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ enabled: true, threshold: autoClearThreshold })
|
||
});
|
||
}
|
||
|
||
// Update UI
|
||
const respawnStatus = document.getElementById('sessionRespawnStatus');
|
||
respawnStatus.classList.add('active');
|
||
respawnStatus.querySelector('.respawn-status-text').textContent = 'WATCHING';
|
||
document.getElementById('modalEnableRespawnBtn').style.display = 'none';
|
||
document.getElementById('modalStopRespawnBtn').style.display = '';
|
||
|
||
this.showToast('Respawn enabled', 'success');
|
||
} catch (err) {
|
||
this.showToast('Failed to enable respawn: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async stopRespawnFromModal() {
|
||
if (!this.editingSessionId) return;
|
||
try {
|
||
await fetch(`/api/sessions/${this.editingSessionId}/respawn/stop`, { method: 'POST' });
|
||
delete this.respawnTimers[this.editingSessionId];
|
||
|
||
// Update the modal display
|
||
const respawnStatus = document.getElementById('sessionRespawnStatus');
|
||
respawnStatus.classList.remove('active');
|
||
respawnStatus.querySelector('.respawn-status-text').textContent = 'Not active';
|
||
document.getElementById('modalEnableRespawnBtn').style.display = '';
|
||
document.getElementById('modalStopRespawnBtn').style.display = 'none';
|
||
|
||
this.showToast('Respawn stopped', 'success');
|
||
} catch (err) {
|
||
this.showToast('Failed to stop respawn', 'error');
|
||
}
|
||
}
|
||
|
||
closeSessionOptions() {
|
||
this.editingSessionId = null;
|
||
// Stop run summary auto-refresh if it was running
|
||
this.stopRunSummaryAutoRefresh();
|
||
document.getElementById('sessionOptionsModal').classList.remove('active');
|
||
}
|
||
|
||
// ========== Run Summary Modal ==========
|
||
|
||
async openRunSummary(sessionId) {
|
||
// Open session options modal and switch to summary tab
|
||
this.openSessionOptions(sessionId);
|
||
this.switchOptionsTab('summary');
|
||
|
||
this.runSummarySessionId = sessionId;
|
||
this.runSummaryFilter = 'all';
|
||
|
||
// Reset filter buttons
|
||
document.querySelectorAll('.run-summary-filters .filter-btn').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.filter === 'all');
|
||
});
|
||
|
||
// Load summary data
|
||
await this.loadRunSummary(sessionId);
|
||
}
|
||
|
||
closeRunSummary() {
|
||
this.runSummarySessionId = null;
|
||
this.stopRunSummaryAutoRefresh();
|
||
// Close session options modal (summary is now a tab in it)
|
||
this.closeSessionOptions();
|
||
}
|
||
|
||
async refreshRunSummary() {
|
||
const sessionId = this.runSummarySessionId || this.editingSessionId;
|
||
if (!sessionId) return;
|
||
await this.loadRunSummary(sessionId);
|
||
}
|
||
|
||
toggleRunSummaryAutoRefresh() {
|
||
const checkbox = document.getElementById('runSummaryAutoRefresh');
|
||
if (checkbox.checked) {
|
||
this.startRunSummaryAutoRefresh();
|
||
} else {
|
||
this.stopRunSummaryAutoRefresh();
|
||
}
|
||
}
|
||
|
||
startRunSummaryAutoRefresh() {
|
||
if (this.runSummaryAutoRefreshTimer) return;
|
||
this.runSummaryAutoRefreshTimer = setInterval(() => {
|
||
if (this.runSummarySessionId) {
|
||
this.loadRunSummary(this.runSummarySessionId);
|
||
}
|
||
}, 5000); // Refresh every 5 seconds
|
||
}
|
||
|
||
stopRunSummaryAutoRefresh() {
|
||
if (this.runSummaryAutoRefreshTimer) {
|
||
clearInterval(this.runSummaryAutoRefreshTimer);
|
||
this.runSummaryAutoRefreshTimer = null;
|
||
}
|
||
const checkbox = document.getElementById('runSummaryAutoRefresh');
|
||
if (checkbox) checkbox.checked = false;
|
||
}
|
||
|
||
exportRunSummary(format) {
|
||
if (!this.runSummaryData) {
|
||
this.showToast('No summary data to export', 'error');
|
||
return;
|
||
}
|
||
|
||
const { stats, events, sessionName, startedAt, lastUpdatedAt } = this.runSummaryData;
|
||
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||
const filename = `run-summary-${sessionName || 'session'}-${timestamp}`;
|
||
|
||
if (format === 'json') {
|
||
const json = JSON.stringify(this.runSummaryData, null, 2);
|
||
this.downloadFile(`${filename}.json`, json, 'application/json');
|
||
} else if (format === 'md') {
|
||
const duration = lastUpdatedAt - startedAt;
|
||
let md = `# Run Summary: ${sessionName || 'Session'}\n\n`;
|
||
md += `**Duration**: ${this.formatDuration(duration)}\n`;
|
||
md += `**Started**: ${new Date(startedAt).toLocaleString()}\n`;
|
||
md += `**Last Update**: ${new Date(lastUpdatedAt).toLocaleString()}\n\n`;
|
||
|
||
md += `## Statistics\n\n`;
|
||
md += `| Metric | Value |\n`;
|
||
md += `|--------|-------|\n`;
|
||
md += `| Respawn Cycles | ${stats.totalRespawnCycles} |\n`;
|
||
md += `| Peak Tokens | ${this.formatTokens(stats.peakTokens)} |\n`;
|
||
md += `| Active Time | ${this.formatDuration(stats.totalTimeActiveMs)} |\n`;
|
||
md += `| Idle Time | ${this.formatDuration(stats.totalTimeIdleMs)} |\n`;
|
||
md += `| Errors | ${stats.errorCount} |\n`;
|
||
md += `| Warnings | ${stats.warningCount} |\n`;
|
||
md += `| AI Checks | ${stats.aiCheckCount} |\n`;
|
||
md += `| State Transitions | ${stats.stateTransitions} |\n\n`;
|
||
|
||
md += `## Event Timeline\n\n`;
|
||
if (events.length === 0) {
|
||
md += `No events recorded.\n`;
|
||
} else {
|
||
md += `| Time | Type | Severity | Title | Details |\n`;
|
||
md += `|------|------|----------|-------|----------|\n`;
|
||
for (const event of events) {
|
||
const time = new Date(event.timestamp).toLocaleTimeString('en-US', { hour12: false });
|
||
const details = event.details ? event.details.replace(/\|/g, '\\|') : '-';
|
||
md += `| ${time} | ${event.type} | ${event.severity} | ${event.title} | ${details} |\n`;
|
||
}
|
||
}
|
||
|
||
this.downloadFile(`${filename}.md`, md, 'text/markdown');
|
||
}
|
||
|
||
this.showToast(`Exported as ${format.toUpperCase()}`, 'success');
|
||
}
|
||
|
||
downloadFile(filename, content, mimeType) {
|
||
const blob = new Blob([content], { type: mimeType });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
async loadRunSummary(sessionId) {
|
||
const timeline = document.getElementById('runSummaryTimeline');
|
||
timeline.innerHTML = '<p class="empty-message">Loading summary...</p>';
|
||
|
||
try {
|
||
const response = await fetch(`/api/sessions/${sessionId}/run-summary`);
|
||
const data = await response.json();
|
||
|
||
if (!data.success) {
|
||
timeline.innerHTML = `<p class="empty-message">Failed to load summary: ${data.error}</p>`;
|
||
return;
|
||
}
|
||
|
||
this.runSummaryData = data.summary;
|
||
this.renderRunSummary();
|
||
} catch (err) {
|
||
console.error('Failed to load run summary:', err);
|
||
timeline.innerHTML = '<p class="empty-message">Failed to load summary</p>';
|
||
}
|
||
}
|
||
|
||
renderRunSummary() {
|
||
if (!this.runSummaryData) return;
|
||
|
||
const { stats, events, sessionName, startedAt, lastUpdatedAt } = this.runSummaryData;
|
||
|
||
// Update stats
|
||
document.getElementById('rsStat-cycles').textContent = stats.totalRespawnCycles;
|
||
document.getElementById('rsStat-tokens').textContent = this.formatTokens(stats.peakTokens || stats.totalTokensUsed);
|
||
document.getElementById('rsStat-active').textContent = this.formatDuration(stats.totalTimeActiveMs);
|
||
document.getElementById('rsStat-issues').textContent = stats.errorCount + stats.warningCount;
|
||
|
||
// Update session info
|
||
const duration = lastUpdatedAt - startedAt;
|
||
document.getElementById('runSummarySessionInfo').textContent =
|
||
`${sessionName || 'Session'} - ${this.formatDuration(duration)} total`;
|
||
|
||
// Filter and render events
|
||
const filteredEvents = this.filterRunSummaryEvents(events);
|
||
this.renderRunSummaryTimeline(filteredEvents);
|
||
}
|
||
|
||
filterRunSummaryEvents(events) {
|
||
if (this.runSummaryFilter === 'all') return events;
|
||
|
||
return events.filter(event => {
|
||
switch (this.runSummaryFilter) {
|
||
case 'errors': return event.severity === 'error';
|
||
case 'warnings': return event.severity === 'warning' || event.severity === 'error';
|
||
case 'respawn': return event.type.startsWith('respawn_') || event.type === 'state_stuck';
|
||
case 'idle': return event.type === 'idle_detected' || event.type === 'working_detected';
|
||
default: return true;
|
||
}
|
||
});
|
||
}
|
||
|
||
filterRunSummary(filter) {
|
||
this.runSummaryFilter = filter;
|
||
|
||
// Update active state on buttons
|
||
document.querySelectorAll('.run-summary-filters .filter-btn').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.filter === filter);
|
||
});
|
||
|
||
this.renderRunSummary();
|
||
}
|
||
|
||
renderRunSummaryTimeline(events) {
|
||
const timeline = document.getElementById('runSummaryTimeline');
|
||
|
||
if (!events || events.length === 0) {
|
||
timeline.innerHTML = '<p class="empty-message">No events recorded yet</p>';
|
||
return;
|
||
}
|
||
|
||
// Reverse to show most recent first
|
||
const reversedEvents = [...events].reverse();
|
||
|
||
const html = reversedEvents.map(event => {
|
||
const time = new Date(event.timestamp).toLocaleTimeString('en-US', { hour12: false });
|
||
const severityClass = `event-${event.severity}`;
|
||
const icon = this.getEventIcon(event.type, event.severity);
|
||
|
||
return `
|
||
<div class="timeline-event ${severityClass}">
|
||
<div class="event-icon">${icon}</div>
|
||
<div class="event-content">
|
||
<div class="event-header">
|
||
<span class="event-title">${this.escapeHtml(event.title)}</span>
|
||
<span class="event-time">${time}</span>
|
||
</div>
|
||
${event.details ? `<div class="event-details">${this.escapeHtml(event.details)}</div>` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
timeline.innerHTML = html;
|
||
}
|
||
|
||
getEventIcon(type, severity) {
|
||
if (severity === 'error') return '❌'; // Red X
|
||
if (severity === 'warning') return '⚠'; // Warning triangle
|
||
if (severity === 'success') return '✔'; // Checkmark
|
||
|
||
switch (type) {
|
||
case 'session_started': return '🚀'; // Rocket
|
||
case 'session_stopped': return '🛑'; // Stop sign
|
||
case 'respawn_cycle_started': return '🔄'; // Cycle
|
||
case 'respawn_cycle_completed': return '✅'; // Green check
|
||
case 'respawn_state_change': return '➡'; // Arrow
|
||
case 'token_milestone': return '💰'; // Money bag
|
||
case 'idle_detected': return '💤'; // Zzz
|
||
case 'working_detected': return '💻'; // Laptop
|
||
case 'ai_check_result': return '🤖'; // Robot
|
||
case 'hook_event': return '🔔'; // Bell
|
||
default: return '•'; // Bullet
|
||
}
|
||
}
|
||
|
||
formatTokens(tokens) {
|
||
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
|
||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`;
|
||
return String(tokens || 0);
|
||
}
|
||
|
||
formatDuration(ms) {
|
||
if (!ms || ms < 0) return '0s';
|
||
const seconds = Math.floor(ms / 1000);
|
||
const minutes = Math.floor(seconds / 60);
|
||
const hours = Math.floor(minutes / 60);
|
||
|
||
if (hours > 0) return `${hours}h ${minutes % 60}m`;
|
||
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
|
||
return `${seconds}s`;
|
||
}
|
||
|
||
saveSessionOptions() {
|
||
// Session options are applied immediately via individual controls
|
||
// This just closes the modal
|
||
this.closeSessionOptions();
|
||
}
|
||
|
||
// ========== Session Options Modal Tabs ==========
|
||
|
||
switchOptionsTab(tabName) {
|
||
// Toggle active class on tab buttons
|
||
document.querySelectorAll('#sessionOptionsModal .modal-tab-btn').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.tab === tabName);
|
||
});
|
||
|
||
// Toggle hidden class on tab content
|
||
document.getElementById('respawn-tab').classList.toggle('hidden', tabName !== 'respawn');
|
||
document.getElementById('context-tab').classList.toggle('hidden', tabName !== 'context');
|
||
document.getElementById('ralph-tab').classList.toggle('hidden', tabName !== 'ralph');
|
||
document.getElementById('summary-tab').classList.toggle('hidden', tabName !== 'summary');
|
||
|
||
// Load run summary data when switching to summary tab
|
||
if (tabName === 'summary' && this.editingSessionId) {
|
||
this.loadRunSummary(this.editingSessionId);
|
||
}
|
||
}
|
||
|
||
getRalphConfig() {
|
||
return {
|
||
enabled: document.getElementById('modalRalphEnabled').checked,
|
||
completionPhrase: document.getElementById('modalRalphPhrase').value.trim(),
|
||
maxIterations: parseInt(document.getElementById('modalRalphMaxIterations').value) || 0,
|
||
maxTodos: parseInt(document.getElementById('modalRalphMaxTodos').value) || 50,
|
||
todoExpirationMinutes: parseInt(document.getElementById('modalRalphTodoExpiration').value) || 60
|
||
};
|
||
}
|
||
|
||
populateRalphForm(config) {
|
||
document.getElementById('modalRalphEnabled').checked = config?.enabled ?? false;
|
||
document.getElementById('modalRalphPhrase').value = config?.completionPhrase || '';
|
||
document.getElementById('modalRalphMaxIterations').value = config?.maxIterations || 0;
|
||
document.getElementById('modalRalphMaxTodos').value = config?.maxTodos || 50;
|
||
document.getElementById('modalRalphTodoExpiration').value = config?.todoExpirationMinutes || 60;
|
||
}
|
||
|
||
async saveRalphConfig() {
|
||
if (!this.editingSessionId) {
|
||
this.showToast('No session selected', 'warning');
|
||
return;
|
||
}
|
||
|
||
const config = this.getRalphConfig();
|
||
|
||
try {
|
||
const res = await fetch(`/api/sessions/${this.editingSessionId}/ralph-config`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(config)
|
||
});
|
||
const data = await res.json();
|
||
if (data.error) throw new Error(data.error);
|
||
|
||
this.showToast('Ralph config saved', 'success');
|
||
} catch (err) {
|
||
this.showToast('Failed to save Ralph config: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Inline rename on right-click
|
||
startInlineRename(sessionId) {
|
||
const session = this.sessions.get(sessionId);
|
||
if (!session) return;
|
||
|
||
const tabName = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`);
|
||
if (!tabName) return;
|
||
|
||
const currentName = this.getSessionName(session);
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.value = session.name || '';
|
||
input.placeholder = currentName;
|
||
input.className = 'tab-rename-input';
|
||
input.style.cssText = 'width: 80px; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;';
|
||
|
||
const originalContent = tabName.textContent;
|
||
tabName.textContent = '';
|
||
tabName.appendChild(input);
|
||
input.focus();
|
||
input.select();
|
||
|
||
const finishRename = async () => {
|
||
const newName = input.value.trim();
|
||
tabName.textContent = newName || originalContent;
|
||
|
||
if (newName && newName !== session.name) {
|
||
try {
|
||
await fetch(`/api/sessions/${sessionId}/name`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: newName })
|
||
});
|
||
} catch (err) {
|
||
tabName.textContent = originalContent;
|
||
this.showToast('Failed to rename', 'error');
|
||
}
|
||
}
|
||
};
|
||
|
||
input.addEventListener('blur', finishRename);
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
input.blur();
|
||
} else if (e.key === 'Escape') {
|
||
input.value = '';
|
||
input.blur();
|
||
}
|
||
});
|
||
}
|
||
|
||
// ========== App Settings Modal ==========
|
||
|
||
openAppSettings() {
|
||
// Load current settings
|
||
const settings = this.loadAppSettingsFromStorage();
|
||
document.getElementById('appSettingsClaudeMdPath').value = settings.defaultClaudeMdPath || '';
|
||
document.getElementById('appSettingsDefaultDir').value = settings.defaultWorkingDir || '';
|
||
document.getElementById('appSettingsRalphEnabled').checked = settings.ralphTrackerEnabled ?? false;
|
||
// Header visibility settings (default to true/enabled)
|
||
document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? true;
|
||
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? true;
|
||
document.getElementById('appSettingsShowTokenCount').checked = settings.showTokenCount ?? true;
|
||
document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? true;
|
||
document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? true;
|
||
document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? true;
|
||
document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? false;
|
||
document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? true;
|
||
// Claude CLI settings
|
||
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
|
||
const allowedToolsRow = document.getElementById('allowedToolsRow');
|
||
claudeModeSelect.value = settings.claudeMode || 'dangerously-skip-permissions';
|
||
document.getElementById('appSettingsAllowedTools').value = settings.allowedTools || '';
|
||
allowedToolsRow.style.display = claudeModeSelect.value === 'allowedTools' ? '' : 'none';
|
||
// Toggle allowed tools row visibility based on mode selection
|
||
claudeModeSelect.onchange = () => {
|
||
allowedToolsRow.style.display = claudeModeSelect.value === 'allowedTools' ? '' : 'none';
|
||
};
|
||
// Notification settings
|
||
const notifPrefs = this.notificationManager?.preferences || {};
|
||
document.getElementById('appSettingsNotifEnabled').checked = notifPrefs.enabled ?? true;
|
||
document.getElementById('appSettingsNotifBrowser').checked = notifPrefs.browserNotifications ?? false;
|
||
document.getElementById('appSettingsNotifAudio').checked = notifPrefs.audioAlerts ?? false;
|
||
document.getElementById('appSettingsNotifStuckMins').value = Math.round((notifPrefs.stuckThresholdMs || 600000) / 60000);
|
||
document.getElementById('appSettingsNotifCritical').checked = !notifPrefs.muteCritical;
|
||
document.getElementById('appSettingsNotifWarning').checked = !notifPrefs.muteWarning;
|
||
document.getElementById('appSettingsNotifInfo').checked = !notifPrefs.muteInfo;
|
||
// Update permission status display
|
||
const permStatus = document.getElementById('notifPermissionStatus');
|
||
if (permStatus && typeof Notification !== 'undefined') {
|
||
permStatus.textContent = `Status: ${Notification.permission}`;
|
||
}
|
||
// Reset to first tab and wire up tab switching
|
||
this.switchSettingsTab('settings-display');
|
||
const modal = document.getElementById('appSettingsModal');
|
||
modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => {
|
||
btn.onclick = () => this.switchSettingsTab(btn.dataset.tab);
|
||
});
|
||
modal.classList.add('active');
|
||
}
|
||
|
||
switchSettingsTab(tabName) {
|
||
const modal = document.getElementById('appSettingsModal');
|
||
// Toggle active class on tab buttons
|
||
modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.tab === tabName);
|
||
});
|
||
// Toggle hidden class on tab content
|
||
modal.querySelectorAll('.modal-tab-content').forEach(content => {
|
||
content.classList.toggle('hidden', content.id !== tabName);
|
||
});
|
||
}
|
||
|
||
closeAppSettings() {
|
||
document.getElementById('appSettingsModal').classList.remove('active');
|
||
}
|
||
|
||
async saveAppSettings() {
|
||
const settings = {
|
||
defaultClaudeMdPath: document.getElementById('appSettingsClaudeMdPath').value.trim(),
|
||
defaultWorkingDir: document.getElementById('appSettingsDefaultDir').value.trim(),
|
||
ralphTrackerEnabled: document.getElementById('appSettingsRalphEnabled').checked,
|
||
// Header visibility settings
|
||
showFontControls: document.getElementById('appSettingsShowFontControls').checked,
|
||
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
|
||
showTokenCount: document.getElementById('appSettingsShowTokenCount').checked,
|
||
showMonitor: document.getElementById('appSettingsShowMonitor').checked,
|
||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||
showSubagents: document.getElementById('appSettingsShowSubagents').checked,
|
||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||
showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked,
|
||
// Claude CLI settings
|
||
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
||
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
|
||
};
|
||
|
||
// Save to localStorage
|
||
localStorage.setItem('claudeman-app-settings', JSON.stringify(settings));
|
||
|
||
// Save notification preferences separately
|
||
const notifPrefsToSave = {
|
||
enabled: document.getElementById('appSettingsNotifEnabled').checked,
|
||
browserNotifications: document.getElementById('appSettingsNotifBrowser').checked,
|
||
audioAlerts: document.getElementById('appSettingsNotifAudio').checked,
|
||
stuckThresholdMs: (parseInt(document.getElementById('appSettingsNotifStuckMins').value) || 10) * 60000,
|
||
muteCritical: !document.getElementById('appSettingsNotifCritical').checked,
|
||
muteWarning: !document.getElementById('appSettingsNotifWarning').checked,
|
||
muteInfo: !document.getElementById('appSettingsNotifInfo').checked,
|
||
};
|
||
if (this.notificationManager) {
|
||
this.notificationManager.preferences = notifPrefsToSave;
|
||
this.notificationManager.savePreferences();
|
||
}
|
||
|
||
// Apply header visibility immediately
|
||
this.applyHeaderVisibilitySettings();
|
||
this.applyMonitorVisibility();
|
||
this.renderProjectInsightsPanel(); // Re-render to apply visibility setting
|
||
this.updateSubagentWindowVisibility(); // Apply subagent window visibility setting
|
||
|
||
// Save to server (includes notification prefs for cross-browser persistence)
|
||
try {
|
||
await fetch('/api/settings', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ ...settings, notificationPreferences: notifPrefsToSave })
|
||
});
|
||
this.showToast('Settings saved', 'success');
|
||
} catch (err) {
|
||
// Server save failed but localStorage succeeded
|
||
this.showToast('Settings saved locally', 'warning');
|
||
}
|
||
|
||
this.closeAppSettings();
|
||
}
|
||
|
||
// Get the global Ralph tracker enabled setting
|
||
isRalphTrackerEnabledByDefault() {
|
||
const settings = this.loadAppSettingsFromStorage();
|
||
return settings.ralphTrackerEnabled ?? false;
|
||
}
|
||
|
||
loadAppSettingsFromStorage() {
|
||
try {
|
||
const saved = localStorage.getItem('claudeman-app-settings');
|
||
if (saved) {
|
||
return JSON.parse(saved);
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load app settings:', err);
|
||
}
|
||
return {};
|
||
}
|
||
|
||
applyHeaderVisibilitySettings() {
|
||
const settings = this.loadAppSettingsFromStorage();
|
||
// Default all to true (enabled) if not set
|
||
const showFontControls = settings.showFontControls ?? true;
|
||
const showSystemStats = settings.showSystemStats ?? true;
|
||
const showTokenCount = settings.showTokenCount ?? true;
|
||
|
||
const fontControlsEl = document.querySelector('.header-font-controls');
|
||
const systemStatsEl = document.getElementById('headerSystemStats');
|
||
const tokenCountEl = document.getElementById('headerTokens');
|
||
|
||
if (fontControlsEl) {
|
||
fontControlsEl.style.display = showFontControls ? '' : 'none';
|
||
}
|
||
if (systemStatsEl) {
|
||
systemStatsEl.style.display = showSystemStats ? '' : 'none';
|
||
}
|
||
if (tokenCountEl) {
|
||
tokenCountEl.style.display = showTokenCount ? '' : 'none';
|
||
}
|
||
|
||
// Hide notification bell when notifications are disabled
|
||
const notifEnabled = this.notificationManager?.preferences?.enabled ?? true;
|
||
const notifBtn = document.querySelector('.btn-notifications');
|
||
if (notifBtn) {
|
||
notifBtn.style.display = notifEnabled ? '' : 'none';
|
||
}
|
||
// Close the drawer if notifications got disabled while it's open
|
||
if (!notifEnabled) {
|
||
const drawer = document.getElementById('notifDrawer');
|
||
if (drawer) drawer.classList.remove('open');
|
||
}
|
||
}
|
||
|
||
applyMonitorVisibility() {
|
||
const settings = this.loadAppSettingsFromStorage();
|
||
const showMonitor = settings.showMonitor ?? true;
|
||
const showSubagents = settings.showSubagents ?? true;
|
||
|
||
const monitorPanel = document.getElementById('monitorPanel');
|
||
if (monitorPanel) {
|
||
monitorPanel.style.display = showMonitor ? '' : 'none';
|
||
}
|
||
|
||
const subagentsPanel = document.getElementById('subagentsPanel');
|
||
if (subagentsPanel) {
|
||
if (showSubagents) {
|
||
subagentsPanel.classList.remove('hidden');
|
||
} else {
|
||
subagentsPanel.classList.add('hidden');
|
||
}
|
||
}
|
||
}
|
||
|
||
closeMonitor() {
|
||
// Hide the monitor panel
|
||
const monitorPanel = document.getElementById('monitorPanel');
|
||
if (monitorPanel) {
|
||
monitorPanel.classList.remove('open');
|
||
monitorPanel.style.display = 'none';
|
||
}
|
||
// Save the setting
|
||
const settings = this.loadAppSettingsFromStorage();
|
||
settings.showMonitor = false;
|
||
localStorage.setItem('claudeman-app-settings', JSON.stringify(settings));
|
||
}
|
||
|
||
closeSubagentsPanel() {
|
||
// Hide the subagents panel
|
||
const subagentsPanel = document.getElementById('subagentsPanel');
|
||
if (subagentsPanel) {
|
||
subagentsPanel.classList.remove('open');
|
||
subagentsPanel.classList.add('hidden');
|
||
}
|
||
this.subagentPanelVisible = false;
|
||
// Save the setting
|
||
const settings = this.loadAppSettingsFromStorage();
|
||
settings.showSubagents = false;
|
||
localStorage.setItem('claudeman-app-settings', JSON.stringify(settings));
|
||
}
|
||
|
||
toggleSubagentsPanel() {
|
||
const panel = document.getElementById('subagentsPanel');
|
||
const toggleBtn = document.getElementById('subagentsToggleBtn');
|
||
if (!panel) return;
|
||
|
||
// If hidden, show it first
|
||
if (panel.classList.contains('hidden')) {
|
||
panel.classList.remove('hidden');
|
||
// Save setting
|
||
const settings = this.loadAppSettingsFromStorage();
|
||
settings.showSubagents = true;
|
||
localStorage.setItem('claudeman-app-settings', JSON.stringify(settings));
|
||
}
|
||
|
||
// Toggle open/collapsed state
|
||
panel.classList.toggle('open');
|
||
this.subagentPanelVisible = panel.classList.contains('open');
|
||
|
||
// Update toggle button icon
|
||
if (toggleBtn) {
|
||
toggleBtn.innerHTML = this.subagentPanelVisible ? '▼' : '▲'; // Down when open, up when collapsed
|
||
}
|
||
|
||
if (this.subagentPanelVisible) {
|
||
this.renderSubagentPanel();
|
||
}
|
||
}
|
||
|
||
async loadAppSettingsFromServer() {
|
||
try {
|
||
const res = await fetch('/api/settings');
|
||
if (res.ok) {
|
||
const settings = await res.json();
|
||
// Extract notification prefs before merging app settings
|
||
const { notificationPreferences, ...appSettings } = settings;
|
||
// Merge app settings with localStorage (server takes precedence)
|
||
const localSettings = this.loadAppSettingsFromStorage();
|
||
const merged = { ...localSettings, ...appSettings };
|
||
localStorage.setItem('claudeman-app-settings', JSON.stringify(merged));
|
||
|
||
// Apply notification prefs from server if present (only if localStorage has none)
|
||
if (notificationPreferences && this.notificationManager) {
|
||
const localNotifPrefs = localStorage.getItem('claudeman-notification-prefs');
|
||
if (!localNotifPrefs) {
|
||
this.notificationManager.preferences = notificationPreferences;
|
||
this.notificationManager.savePreferences();
|
||
}
|
||
}
|
||
|
||
return merged;
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load settings from server:', err);
|
||
}
|
||
return this.loadAppSettingsFromStorage();
|
||
}
|
||
|
||
// ========== Subagent Window State Persistence ==========
|
||
|
||
/**
|
||
* Save subagent window states (minimized/open) to server for cross-browser persistence.
|
||
* Called when a window is minimized, restored, or auto-minimized on completion.
|
||
*/
|
||
async saveSubagentWindowStates() {
|
||
// Build state object: which agents are minimized per session
|
||
const minimizedState = {};
|
||
for (const [sessionId, agentIds] of this.minimizedSubagents) {
|
||
minimizedState[sessionId] = Array.from(agentIds);
|
||
}
|
||
|
||
// Also track which windows are open (not minimized)
|
||
const openWindows = [];
|
||
for (const [agentId, windowData] of this.subagentWindows) {
|
||
if (!windowData.minimized) {
|
||
openWindows.push({
|
||
agentId,
|
||
position: windowData.position || null
|
||
});
|
||
}
|
||
}
|
||
|
||
const windowStates = { minimized: minimizedState, open: openWindows };
|
||
|
||
// Save to localStorage for quick restore
|
||
localStorage.setItem('claudeman-subagent-window-states', JSON.stringify(windowStates));
|
||
|
||
// Save to server for cross-browser persistence
|
||
try {
|
||
await fetch('/api/subagent-window-states', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(windowStates)
|
||
});
|
||
} catch (err) {
|
||
console.error('Failed to save subagent window states to server:', err);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Load subagent window states from server (or localStorage fallback).
|
||
* Called on page load to restore minimized/open window states.
|
||
*/
|
||
async loadSubagentWindowStates() {
|
||
let states = null;
|
||
|
||
// Try server first for cross-browser sync
|
||
try {
|
||
const res = await fetch('/api/subagent-window-states');
|
||
if (res.ok) {
|
||
states = await res.json();
|
||
// Also update localStorage
|
||
localStorage.setItem('claudeman-subagent-window-states', JSON.stringify(states));
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load subagent window states from server:', err);
|
||
}
|
||
|
||
// Fallback to localStorage
|
||
if (!states) {
|
||
try {
|
||
const saved = localStorage.getItem('claudeman-subagent-window-states');
|
||
if (saved) {
|
||
states = JSON.parse(saved);
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load subagent window states from localStorage:', err);
|
||
}
|
||
}
|
||
|
||
return states || { minimized: {}, open: [] };
|
||
}
|
||
|
||
/**
|
||
* Restore subagent window states after loading subagents.
|
||
* Opens windows that were open before, keeps minimized ones minimized.
|
||
*/
|
||
async restoreSubagentWindowStates() {
|
||
const states = await this.loadSubagentWindowStates();
|
||
|
||
// Restore minimized state
|
||
for (const [sessionId, agentIds] of Object.entries(states.minimized || {})) {
|
||
if (Array.isArray(agentIds) && agentIds.length > 0) {
|
||
// Verify agents still exist
|
||
const validAgents = agentIds.filter(id => this.subagents.has(id));
|
||
if (validAgents.length > 0) {
|
||
this.minimizedSubagents.set(sessionId, new Set(validAgents));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Restore open windows (for non-completed agents only)
|
||
for (const { agentId, position } of (states.open || [])) {
|
||
const agent = this.subagents.get(agentId);
|
||
// Only restore window if agent exists and is still active/idle (not completed)
|
||
if (agent && agent.status !== 'completed') {
|
||
this.openSubagentWindow(agentId);
|
||
// Restore position if saved (with viewport bounds check)
|
||
if (position) {
|
||
const windowData = this.subagentWindows.get(agentId);
|
||
if (windowData && windowData.element) {
|
||
// Parse position values and clamp to viewport
|
||
let left = parseInt(position.left, 10) || 50;
|
||
let top = parseInt(position.top, 10) || 120;
|
||
const viewportWidth = window.innerWidth;
|
||
const viewportHeight = window.innerHeight;
|
||
const windowWidth = 420;
|
||
const windowHeight = 350;
|
||
left = Math.max(10, Math.min(left, viewportWidth - windowWidth - 10));
|
||
top = Math.max(10, Math.min(top, viewportHeight - windowHeight - 10));
|
||
windowData.element.style.left = `${left}px`;
|
||
windowData.element.style.top = `${top}px`;
|
||
windowData.position = { left: `${left}px`, top: `${top}px` };
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
this.renderSessionTabs(); // Update tab badges
|
||
}
|
||
|
||
// ========== Help Modal ==========
|
||
|
||
showHelp() {
|
||
document.getElementById('helpModal').classList.add('active');
|
||
}
|
||
|
||
closeHelp() {
|
||
document.getElementById('helpModal').classList.remove('active');
|
||
}
|
||
|
||
closeAllPanels() {
|
||
this.closeSessionOptions();
|
||
this.closeAppSettings();
|
||
this.cancelCloseSession();
|
||
this.closeTokenStats();
|
||
document.getElementById('monitorPanel').classList.remove('open');
|
||
// Collapse subagents panel (don't hide it permanently)
|
||
const subagentsPanel = document.getElementById('subagentsPanel');
|
||
if (subagentsPanel) {
|
||
subagentsPanel.classList.remove('open');
|
||
}
|
||
this.subagentPanelVisible = false;
|
||
}
|
||
|
||
// ========== Token Statistics Modal ==========
|
||
|
||
async openTokenStats() {
|
||
try {
|
||
const response = await fetch('/api/token-stats');
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
this.renderTokenStats(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 = `
|
||
<div class="stat-card">
|
||
<span class="stat-card-label">Today</span>
|
||
<span class="stat-card-value">${this.formatTokens(todayData.inputTokens + todayData.outputTokens)}</span>
|
||
<span class="stat-card-cost">~$${todayData.estimatedCost.toFixed(2)}</span>
|
||
</div>
|
||
<div class="stat-card">
|
||
<span class="stat-card-label">7 Days</span>
|
||
<span class="stat-card-value">${this.formatTokens(weekInput + weekOutput)}</span>
|
||
<span class="stat-card-cost">~$${weekCost.toFixed(2)}</span>
|
||
</div>
|
||
<div class="stat-card">
|
||
<span class="stat-card-label">Lifetime</span>
|
||
<span class="stat-card-value">${this.formatTokens(lifetimeInput + lifetimeOutput)}</span>
|
||
<span class="stat-card-cost">~$${lifetimeCost.toFixed(2)}</span>
|
||
</div>
|
||
`;
|
||
|
||
// 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 `<div class="bar" style="height: ${height}%" data-tooltip="${tooltip}"></div>`;
|
||
}).join('');
|
||
|
||
daysEl.innerHTML = chartData.map(d => `<span>${d.dayName}</span>`).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 = '<div class="stats-no-data">No usage data recorded yet</div>';
|
||
} else {
|
||
tableEl.innerHTML = `
|
||
<div class="stats-table-header">
|
||
<span>Date</span>
|
||
<span>Input</span>
|
||
<span>Output</span>
|
||
<span>Cost</span>
|
||
</div>
|
||
${tableData.map(d => {
|
||
const dateObj = new Date(d.date + 'T00:00:00');
|
||
const dateStr = dateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||
return `
|
||
<div class="stats-table-row">
|
||
<span class="cell cell-date">${dateStr}</span>
|
||
<span class="cell">${this.formatTokens(d.inputTokens)}</span>
|
||
<span class="cell">${this.formatTokens(d.outputTokens)}</span>
|
||
<span class="cell cell-cost">$${d.estimatedCost.toFixed(2)}</span>
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
`;
|
||
}
|
||
}
|
||
|
||
closeTokenStats() {
|
||
const modal = document.getElementById('tokenStatsModal');
|
||
if (modal) {
|
||
modal.classList.remove('active');
|
||
}
|
||
}
|
||
|
||
// ========== Monitor Panel (combined Screen Sessions + Background Tasks) ==========
|
||
|
||
async toggleMonitorPanel() {
|
||
const panel = document.getElementById('monitorPanel');
|
||
const toggleBtn = document.getElementById('monitorToggleBtn');
|
||
panel.classList.toggle('open');
|
||
|
||
if (panel.classList.contains('open')) {
|
||
// Load screens and start stats collection
|
||
await this.loadScreens();
|
||
await fetch('/api/screens/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/screens/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 onMouseDown = (e) => {
|
||
// Only drag from header, not from buttons
|
||
if (e.target.closest('button')) return;
|
||
if (!panel.classList.contains('detached')) return;
|
||
|
||
isDragging = true;
|
||
startX = e.clientX;
|
||
startY = e.clientY;
|
||
const rect = panel.getBoundingClientRect();
|
||
startLeft = rect.left;
|
||
startTop = rect.top;
|
||
|
||
document.addEventListener('mousemove', onMouseMove);
|
||
document.addEventListener('mouseup', onMouseUp);
|
||
e.preventDefault();
|
||
};
|
||
|
||
const onMouseMove = (e) => {
|
||
if (!isDragging) return;
|
||
|
||
const dx = e.clientX - startX;
|
||
const dy = e.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 onMouseUp = () => {
|
||
isDragging = false;
|
||
document.removeEventListener('mousemove', onMouseMove);
|
||
document.removeEventListener('mouseup', onMouseUp);
|
||
};
|
||
|
||
// Remove existing listeners before adding new ones
|
||
header.removeEventListener('mousedown', header._dragHandler);
|
||
header._dragHandler = onMouseDown;
|
||
header.addEventListener('mousedown', onMouseDown);
|
||
}
|
||
|
||
// ========== 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 onMouseDown = (e) => {
|
||
// Only drag from header, not from buttons
|
||
if (e.target.closest('button')) return;
|
||
if (!panel.classList.contains('detached')) return;
|
||
|
||
isDragging = true;
|
||
startX = e.clientX;
|
||
startY = e.clientY;
|
||
const rect = panel.getBoundingClientRect();
|
||
startLeft = rect.left;
|
||
startTop = rect.top;
|
||
|
||
document.addEventListener('mousemove', onMouseMove);
|
||
document.addEventListener('mouseup', onMouseUp);
|
||
e.preventDefault();
|
||
};
|
||
|
||
const onMouseMove = (e) => {
|
||
if (!isDragging) return;
|
||
|
||
const dx = e.clientX - startX;
|
||
const dy = e.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 onMouseUp = () => {
|
||
isDragging = false;
|
||
document.removeEventListener('mousemove', onMouseMove);
|
||
document.removeEventListener('mouseup', onMouseUp);
|
||
};
|
||
|
||
// Remove existing listeners before adding new ones
|
||
header.removeEventListener('mousedown', header._dragHandler);
|
||
header._dragHandler = onMouseDown;
|
||
header.addEventListener('mousedown', onMouseDown);
|
||
}
|
||
|
||
renderTaskPanel() {
|
||
// Debounce renders at 100ms to prevent excessive DOM updates
|
||
if (this.renderTaskPanelTimeout) {
|
||
clearTimeout(this.renderTaskPanelTimeout);
|
||
}
|
||
this.renderTaskPanelTimeout = setTimeout(() => {
|
||
this._renderTaskPanelImmediate();
|
||
}, 100);
|
||
}
|
||
|
||
_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 = '<div class="task-children">';
|
||
for (const childId of task.children) {
|
||
// Find child task in allTasks map
|
||
const childTask = allTasks.find(t => t.id === childId);
|
||
if (childTask) {
|
||
childrenHtml += `<div class="task-node">${renderTask(childTask, allTasks)}</div>`;
|
||
}
|
||
}
|
||
childrenHtml += '</div>';
|
||
}
|
||
|
||
return `
|
||
<div class="task-item">
|
||
<span class="task-status-icon ${task.status}">${statusIcon}</span>
|
||
<div class="task-info">
|
||
<div class="task-description">${this.escapeHtml(task.description)}</div>
|
||
<div class="task-meta">
|
||
<span class="task-type">${task.subagentType}</span>
|
||
<span>${duration}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
${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 = '<div class="task-tree">';
|
||
for (const task of session.taskTree) {
|
||
html += `<div class="task-node">${renderTask(task, allTasks)}</div>`;
|
||
}
|
||
html += '</div>';
|
||
|
||
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;
|
||
}
|
||
|
||
// ========== Enhanced Ralph Wiggum Loop Panel ==========
|
||
|
||
updateRalphState(sessionId, updates) {
|
||
const existing = this.ralphStates.get(sessionId) || { loop: null, todos: [] };
|
||
const updated = { ...existing, ...updates };
|
||
this.ralphStates.set(sessionId, updated);
|
||
|
||
// Re-render if this is the active session
|
||
if (sessionId === this.activeSessionId) {
|
||
this.renderRalphStatePanel();
|
||
}
|
||
}
|
||
|
||
toggleRalphStatePanel() {
|
||
// Preserve xterm scroll position to prevent jump when panel height changes
|
||
const xtermViewport = this.terminal?.element?.querySelector('.xterm-viewport');
|
||
const scrollTop = xtermViewport?.scrollTop;
|
||
|
||
this.ralphStatePanelCollapsed = !this.ralphStatePanelCollapsed;
|
||
this.renderRalphStatePanel();
|
||
|
||
// Restore scroll position and refit terminal after layout change
|
||
requestAnimationFrame(() => {
|
||
// Restore xterm scroll position
|
||
if (xtermViewport && scrollTop !== undefined) {
|
||
xtermViewport.scrollTop = scrollTop;
|
||
}
|
||
// Refit terminal to new container size
|
||
if (this.terminal && this.fitAddon) {
|
||
this.fitAddon.fit();
|
||
}
|
||
});
|
||
}
|
||
|
||
async closeRalphTracker() {
|
||
if (!this.activeSessionId) return;
|
||
|
||
// Disable tracker via API
|
||
await fetch(`/api/sessions/${this.activeSessionId}/ralph-config`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ enabled: false })
|
||
});
|
||
|
||
// Clear local state and hide panel
|
||
this.ralphStates.delete(this.activeSessionId);
|
||
this.renderRalphStatePanel();
|
||
}
|
||
|
||
toggleRalphDetach() {
|
||
const panel = this.$('ralphStatePanel');
|
||
const detachBtn = this.$('ralphDetachBtn');
|
||
|
||
if (!panel) return;
|
||
|
||
if (panel.classList.contains('detached')) {
|
||
// Re-attach to original position
|
||
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');
|
||
// Expand when detaching for better visibility
|
||
this.ralphStatePanelCollapsed = false;
|
||
panel.classList.remove('collapsed');
|
||
if (detachBtn) {
|
||
detachBtn.innerHTML = '⊞'; // Attach icon (squared plus - dock back)
|
||
detachBtn.title = 'Attach panel';
|
||
}
|
||
// Setup drag functionality
|
||
this.setupRalphDrag();
|
||
}
|
||
this.renderRalphStatePanel();
|
||
}
|
||
|
||
setupRalphDrag() {
|
||
const panel = this.$('ralphStatePanel');
|
||
const header = this.$('ralphSummary');
|
||
|
||
if (!panel || !header) return;
|
||
|
||
let isDragging = false;
|
||
let startX, startY, startLeft, startTop;
|
||
|
||
const onMouseDown = (e) => {
|
||
// Only drag from header, not from buttons or toggle
|
||
if (e.target.closest('button') || e.target.closest('.ralph-toggle')) return;
|
||
if (!panel.classList.contains('detached')) return;
|
||
|
||
isDragging = true;
|
||
startX = e.clientX;
|
||
startY = e.clientY;
|
||
const rect = panel.getBoundingClientRect();
|
||
startLeft = rect.left;
|
||
startTop = rect.top;
|
||
|
||
document.addEventListener('mousemove', onMouseMove);
|
||
document.addEventListener('mouseup', onMouseUp);
|
||
e.preventDefault();
|
||
};
|
||
|
||
const onMouseMove = (e) => {
|
||
if (!isDragging) return;
|
||
|
||
const dx = e.clientX - startX;
|
||
const dy = e.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 onMouseUp = () => {
|
||
isDragging = false;
|
||
document.removeEventListener('mousemove', onMouseMove);
|
||
document.removeEventListener('mouseup', onMouseUp);
|
||
};
|
||
|
||
// Remove existing listeners before adding new ones
|
||
header.removeEventListener('mousedown', header._ralphDragHandler);
|
||
header._ralphDragHandler = onMouseDown;
|
||
header.addEventListener('mousedown', onMouseDown);
|
||
}
|
||
|
||
renderRalphStatePanel() {
|
||
// Debounce renders at 50ms to prevent excessive DOM updates
|
||
if (this.renderRalphStatePanelTimeout) {
|
||
clearTimeout(this.renderRalphStatePanelTimeout);
|
||
}
|
||
this.renderRalphStatePanelTimeout = setTimeout(() => {
|
||
this._renderRalphStatePanelImmediate();
|
||
}, 50);
|
||
}
|
||
|
||
_renderRalphStatePanelImmediate() {
|
||
const panel = this.$('ralphStatePanel');
|
||
const toggle = this.$('ralphToggle');
|
||
|
||
if (!panel) return;
|
||
|
||
const state = this.ralphStates.get(this.activeSessionId);
|
||
|
||
// Check if there's anything to show
|
||
// Only show panel if tracker is enabled OR there's active state to display
|
||
const isEnabled = state?.loop?.enabled === true;
|
||
const hasLoop = state?.loop?.active || state?.loop?.completionPhrase;
|
||
const hasTodos = state?.todos?.length > 0;
|
||
|
||
if (!isEnabled && !hasLoop && !hasTodos) {
|
||
panel.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
panel.style.display = '';
|
||
|
||
// Calculate completion percentage
|
||
const todos = state?.todos || [];
|
||
const completed = todos.filter(t => t.status === 'completed').length;
|
||
const total = todos.length;
|
||
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||
|
||
// Update progress rings
|
||
this.updateRalphRing(percent);
|
||
|
||
// Update status badge (pass completion info)
|
||
this.updateRalphStatus(state?.loop, completed, total);
|
||
|
||
// Update stats
|
||
this.updateRalphStats(state?.loop, completed, total);
|
||
|
||
// Handle collapsed/expanded state
|
||
if (this.ralphStatePanelCollapsed) {
|
||
panel.classList.add('collapsed');
|
||
if (toggle) toggle.innerHTML = '▼'; // Down arrow when collapsed (click to expand)
|
||
} else {
|
||
panel.classList.remove('collapsed');
|
||
if (toggle) toggle.innerHTML = '▲'; // Up arrow when expanded (click to collapse)
|
||
|
||
// Update expanded view content
|
||
this.updateRalphExpandedView(state);
|
||
}
|
||
}
|
||
|
||
updateRalphRing(percent) {
|
||
// Mini ring (in summary)
|
||
const miniProgress = this.$('ralphRingMiniProgress');
|
||
const miniText = this.$('ralphRingMiniText');
|
||
if (miniProgress) {
|
||
// Circumference = 2 * PI * r = 2 * PI * 15.9 ≈ 100
|
||
const offset = 100 - percent;
|
||
miniProgress.style.strokeDashoffset = offset;
|
||
}
|
||
if (miniText) {
|
||
miniText.textContent = `${percent}%`;
|
||
}
|
||
|
||
// Large ring (in expanded view)
|
||
const largeProgress = this.$('ralphRingProgress');
|
||
const largePercent = this.$('ralphRingPercent');
|
||
if (largeProgress) {
|
||
// Circumference = 2 * PI * r = 2 * PI * 42 ≈ 264
|
||
const offset = 264 - (264 * percent / 100);
|
||
largeProgress.style.strokeDashoffset = offset;
|
||
}
|
||
if (largePercent) {
|
||
largePercent.textContent = `${percent}%`;
|
||
}
|
||
}
|
||
|
||
updateRalphStatus(loop, completed = 0, total = 0) {
|
||
const badge = this.$('ralphStatusBadge');
|
||
const statusText = badge?.querySelector('.ralph-status-text');
|
||
if (!badge || !statusText) return;
|
||
|
||
badge.classList.remove('active', 'completed', 'tracking');
|
||
|
||
if (loop?.active) {
|
||
badge.classList.add('active');
|
||
statusText.textContent = 'Running';
|
||
} else if (total > 0 && completed === total) {
|
||
// Only show "Complete" when all todos are actually done
|
||
badge.classList.add('completed');
|
||
statusText.textContent = 'Complete';
|
||
} else if (loop?.enabled || total > 0) {
|
||
badge.classList.add('tracking');
|
||
statusText.textContent = 'Tracking';
|
||
} else {
|
||
statusText.textContent = 'Idle';
|
||
}
|
||
}
|
||
|
||
updateRalphStats(loop, completed, total) {
|
||
// Time stat
|
||
const timeEl = this.$('ralphStatTime');
|
||
if (timeEl) {
|
||
if (loop?.elapsedHours !== null && loop?.elapsedHours !== undefined) {
|
||
timeEl.textContent = this.formatRalphTime(loop.elapsedHours);
|
||
} else if (loop?.startedAt) {
|
||
const hours = (Date.now() - loop.startedAt) / (1000 * 60 * 60);
|
||
timeEl.textContent = this.formatRalphTime(hours);
|
||
} else {
|
||
timeEl.textContent = '0m';
|
||
}
|
||
}
|
||
|
||
// Cycles stat
|
||
const cyclesEl = this.$('ralphStatCycles');
|
||
if (cyclesEl) {
|
||
if (loop?.maxIterations) {
|
||
cyclesEl.textContent = `${loop.cycleCount || 0}/${loop.maxIterations}`;
|
||
} else {
|
||
cyclesEl.textContent = String(loop?.cycleCount || 0);
|
||
}
|
||
}
|
||
|
||
// Tasks stat
|
||
const tasksEl = this.$('ralphStatTasks');
|
||
if (tasksEl) {
|
||
tasksEl.textContent = `${completed}/${total}`;
|
||
}
|
||
}
|
||
|
||
formatRalphTime(hours) {
|
||
if (hours < 0.0167) return '0m'; // < 1 minute
|
||
if (hours < 1) {
|
||
const minutes = Math.round(hours * 60);
|
||
return `${minutes}m`;
|
||
}
|
||
const h = Math.floor(hours);
|
||
const m = Math.round((hours - h) * 60);
|
||
if (m === 0) return `${h}h`;
|
||
return `${h}h ${m}m`;
|
||
}
|
||
|
||
updateRalphExpandedView(state) {
|
||
// Update phrase
|
||
const phraseEl = this.$('ralphPhrase');
|
||
if (phraseEl) {
|
||
phraseEl.textContent = state?.loop?.completionPhrase || '--';
|
||
}
|
||
|
||
// Update elapsed
|
||
const elapsedEl = this.$('ralphElapsed');
|
||
if (elapsedEl) {
|
||
if (state?.loop?.elapsedHours !== null && state?.loop?.elapsedHours !== undefined) {
|
||
elapsedEl.textContent = this.formatRalphTime(state.loop.elapsedHours);
|
||
} else if (state?.loop?.startedAt) {
|
||
const hours = (Date.now() - state.loop.startedAt) / (1000 * 60 * 60);
|
||
elapsedEl.textContent = this.formatRalphTime(hours);
|
||
} else {
|
||
elapsedEl.textContent = '0m';
|
||
}
|
||
}
|
||
|
||
// Update iterations
|
||
const iterationsEl = this.$('ralphIterations');
|
||
if (iterationsEl) {
|
||
if (state?.loop?.maxIterations) {
|
||
iterationsEl.textContent = `${state.loop.cycleCount || 0} / ${state.loop.maxIterations}`;
|
||
} else {
|
||
iterationsEl.textContent = String(state?.loop?.cycleCount || 0);
|
||
}
|
||
}
|
||
|
||
// Update tasks count
|
||
const todos = state?.todos || [];
|
||
const completed = todos.filter(t => t.status === 'completed').length;
|
||
const tasksCountEl = this.$('ralphTasksCount');
|
||
if (tasksCountEl) {
|
||
tasksCountEl.textContent = `${completed}/${todos.length}`;
|
||
}
|
||
|
||
// Render task cards
|
||
this.renderRalphTasks(todos);
|
||
}
|
||
|
||
renderRalphTasks(todos) {
|
||
const grid = this.$('ralphTasksGrid');
|
||
if (!grid) return;
|
||
|
||
if (todos.length === 0) {
|
||
if (grid.children.length !== 1 || !grid.querySelector('.ralph-state-empty')) {
|
||
grid.innerHTML = '<div class="ralph-state-empty">No tasks detected</div>';
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Sort: in_progress first, then pending, then completed
|
||
const sorted = [...todos].sort((a, b) => {
|
||
const order = { in_progress: 0, pending: 1, completed: 2 };
|
||
return (order[a.status] || 1) - (order[b.status] || 1);
|
||
});
|
||
|
||
// Incremental DOM update - reuse existing elements where possible
|
||
const existingCards = grid.querySelectorAll('.ralph-task-card');
|
||
const fragment = document.createDocumentFragment();
|
||
let needsRebuild = existingCards.length !== sorted.length;
|
||
|
||
// Check if we can do incremental update
|
||
if (!needsRebuild) {
|
||
// Update existing cards in place
|
||
sorted.forEach((todo, i) => {
|
||
const card = existingCards[i];
|
||
const statusClass = `task-${todo.status.replace('_', '-')}`;
|
||
const icon = this.getRalphTaskIcon(todo.status);
|
||
|
||
// Update class if changed
|
||
if (!card.classList.contains(statusClass)) {
|
||
card.className = `ralph-task-card ${statusClass}`;
|
||
}
|
||
|
||
// Update icon if changed
|
||
const iconEl = card.querySelector('.ralph-task-icon');
|
||
if (iconEl && iconEl.textContent !== icon) {
|
||
iconEl.textContent = icon;
|
||
}
|
||
|
||
// Update content if changed
|
||
const contentEl = card.querySelector('.ralph-task-content');
|
||
if (contentEl && contentEl.textContent !== todo.content) {
|
||
contentEl.textContent = todo.content;
|
||
}
|
||
});
|
||
} else {
|
||
// Full rebuild needed - use DocumentFragment for efficiency
|
||
sorted.forEach(todo => {
|
||
const card = document.createElement('div');
|
||
const statusClass = `task-${todo.status.replace('_', '-')}`;
|
||
card.className = `ralph-task-card ${statusClass}`;
|
||
|
||
const iconSpan = document.createElement('span');
|
||
iconSpan.className = 'ralph-task-icon';
|
||
iconSpan.textContent = this.getRalphTaskIcon(todo.status);
|
||
|
||
const contentSpan = document.createElement('span');
|
||
contentSpan.className = 'ralph-task-content';
|
||
contentSpan.textContent = todo.content;
|
||
|
||
card.appendChild(iconSpan);
|
||
card.appendChild(contentSpan);
|
||
fragment.appendChild(card);
|
||
});
|
||
|
||
grid.innerHTML = '';
|
||
grid.appendChild(fragment);
|
||
}
|
||
}
|
||
|
||
getRalphTaskIcon(status) {
|
||
switch (status) {
|
||
case 'completed': return '✓';
|
||
case 'in_progress': return '◐';
|
||
case 'pending':
|
||
default: return '○';
|
||
}
|
||
}
|
||
|
||
// Legacy method for backwards compatibility
|
||
getTodoIcon(status) {
|
||
return this.getRalphTaskIcon(status);
|
||
}
|
||
|
||
// ========== 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() {
|
||
const list = this.$('subagentList');
|
||
if (!list) return;
|
||
|
||
// Always update badge count
|
||
this.updateSubagentBadge();
|
||
|
||
// If panel is not visible, don't render content
|
||
if (!this.subagentPanelVisible) {
|
||
return;
|
||
}
|
||
|
||
// Render subagent list
|
||
if (this.subagents.size === 0) {
|
||
list.innerHTML = '<div class="subagent-empty">No background agents detected</div>';
|
||
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 new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime();
|
||
});
|
||
|
||
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
|
||
? `<span class="subagent-model-badge ${agent.modelShort}">${agent.modelShort}</span>`
|
||
: '';
|
||
|
||
const displayName = agent.description || agent.agentId.substring(0, 7);
|
||
html.push(`
|
||
<div class="subagent-item ${statusClass} ${isActive ? 'selected' : ''}"
|
||
onclick="app.selectSubagent('${agent.agentId}')"
|
||
ondblclick="app.openSubagentWindow('${agent.agentId}')"
|
||
title="Double-click to open tracking window">
|
||
<div class="subagent-header">
|
||
<span class="subagent-icon">🤖</span>
|
||
<span class="subagent-id" title="${this.escapeHtml(agent.description || agent.agentId)}">${this.escapeHtml(displayName.length > 40 ? displayName.substring(0, 40) + '...' : displayName)}</span>
|
||
${modelBadge}
|
||
<span class="subagent-status ${statusClass}">${agent.status}</span>
|
||
${canKill ? `<button class="subagent-kill-btn" onclick="event.stopPropagation(); app.killSubagent('${agent.agentId}')" title="Kill agent">✕</button>` : ''}
|
||
<button class="subagent-window-btn" onclick="event.stopPropagation(); app.${hasWindow ? 'closeSubagentWindow' : 'openSubagentWindow'}('${agent.agentId}')" title="${hasWindow ? 'Close window' : 'Open in window'}">
|
||
${hasWindow ? '✕' : '⧉'}
|
||
</button>
|
||
</div>
|
||
<div class="subagent-meta">
|
||
<span class="subagent-tools">${agent.toolCallCount} tools</span>
|
||
${lastTool ? `<span class="subagent-last-tool">${this.getToolIcon(lastTool)} ${lastTool}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
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 = '<div class="subagent-empty">Select an agent to view details</div>';
|
||
return;
|
||
}
|
||
|
||
const agent = this.subagents.get(this.activeSubagentId);
|
||
const activity = this.subagentActivity.get(this.activeSubagentId) || [];
|
||
|
||
if (!agent) {
|
||
detail.innerHTML = '<div class="subagent-empty">Agent not found</div>';
|
||
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 `<div class="subagent-activity tool" data-tool-use-id="${a.toolUseId || ''}">
|
||
<span class="time">${time}</span>
|
||
<span class="icon">${this.getToolIcon(a.tool)}</span>
|
||
<span class="name">${a.tool}</span>
|
||
<span class="detail">${toolDetail.primary}</span>
|
||
${toolDetail.hasMore ? `<button class="tool-expand-btn" onclick="app.toggleToolParams('${a.toolUseId}')">▶</button>` : ''}
|
||
${toolDetail.hasMore ? `<div class="tool-params-expanded" id="tool-params-${a.toolUseId}" style="display:none;"><pre>${this.escapeHtml(JSON.stringify(a.fullInput || a.input, null, 2))}</pre></div>` : ''}
|
||
</div>`;
|
||
} 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 `<div class="subagent-activity tool-result ${statusClass}">
|
||
<span class="time">${time}</span>
|
||
<span class="icon">${icon}</span>
|
||
<span class="name">${a.tool || 'result'}</span>
|
||
<span class="detail">${this.escapeHtml(preview)}${sizeInfo}</span>
|
||
</div>`;
|
||
} 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 `<div class="subagent-activity progress${hookClass}">
|
||
<span class="time">${time}</span>
|
||
<span class="icon">${icon}</span>
|
||
<span class="detail">${displayText}</span>
|
||
</div>`;
|
||
} else if (a.type === 'message') {
|
||
const preview = a.text.length > 100 ? a.text.substring(0, 100) + '...' : a.text;
|
||
return `<div class="subagent-activity message">
|
||
<span class="time">${time}</span>
|
||
<span class="icon">💬</span>
|
||
<span class="detail">${this.escapeHtml(preview)}</span>
|
||
</div>`;
|
||
}
|
||
return '';
|
||
}).join('');
|
||
|
||
const detailTitle = agent.description || `Agent ${agent.agentId}`;
|
||
const modelBadge = agent.modelShort
|
||
? `<span class="subagent-model-badge ${agent.modelShort}">${agent.modelShort}</span>`
|
||
: '';
|
||
const tokenStats = (agent.totalInputTokens || agent.totalOutputTokens)
|
||
? `<span>Tokens: ${this.formatTokenCount(agent.totalInputTokens || 0)}↓ ${this.formatTokenCount(agent.totalOutputTokens || 0)}↑</span>`
|
||
: '';
|
||
|
||
detail.innerHTML = `
|
||
<div class="subagent-detail-header">
|
||
<span class="subagent-id" title="${this.escapeHtml(agent.description || agent.agentId)}">${this.escapeHtml(detailTitle.length > 60 ? detailTitle.substring(0, 60) + '...' : detailTitle)}</span>
|
||
${modelBadge}
|
||
<span class="subagent-status ${agent.status}">${agent.status}</span>
|
||
<button class="subagent-transcript-btn" onclick="app.viewSubagentTranscript('${agent.agentId}')">
|
||
View Full Transcript
|
||
</button>
|
||
</div>
|
||
<div class="subagent-detail-stats">
|
||
<span>Tools: ${agent.toolCallCount}</span>
|
||
<span>Entries: ${agent.entryCount}</span>
|
||
<span>Size: ${(agent.fileSize / 1024).toFixed(1)}KB</span>
|
||
${tokenStats}
|
||
</div>
|
||
<div class="subagent-activity-log">
|
||
${activityHtml || '<div class="subagent-empty">No activity yet</div>'}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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 fetch(`/api/subagents/${agentId}`, { method: 'DELETE' });
|
||
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(`
|
||
<html>
|
||
<head>
|
||
<title>Subagent ${agentId} Transcript</title>
|
||
<style>
|
||
body { background: #1a1a2e; color: #eee; font-family: monospace; padding: 20px; }
|
||
pre { white-space: pre-wrap; word-wrap: break-word; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h2>Subagent ${agentId} Transcript (${data.data.entryCount} entries)</h2>
|
||
<pre>${this.escapeHtml(content)}</pre>
|
||
</body>
|
||
</html>
|
||
`);
|
||
} catch (err) {
|
||
console.error('Failed to load transcript:', err);
|
||
alert('Failed to load transcript: ' + err.message);
|
||
}
|
||
}
|
||
|
||
// ========== Subagent Parent Session Tracking ==========
|
||
|
||
async findParentSessionForSubagent(agentId) {
|
||
const agent = this.subagents.get(agentId);
|
||
if (!agent) return;
|
||
|
||
// Helper to check a session and set parent if found
|
||
const checkSession = async (sessionId, session) => {
|
||
try {
|
||
const resp = await fetch(`/api/sessions/${sessionId}/subagents`);
|
||
if (!resp.ok) return false;
|
||
const result = await resp.json();
|
||
const subagents = result.data || result.subagents || [];
|
||
if (subagents.some(s => s.agentId === agentId)) {
|
||
agent.parentSessionId = sessionId;
|
||
agent.parentSessionName = this.getSessionName(session);
|
||
this.subagents.set(agentId, agent);
|
||
// Update window if already open
|
||
this.updateSubagentWindowParent(agentId);
|
||
// Update visibility based on whether this is the active session
|
||
this.updateSubagentWindowVisibility();
|
||
return true;
|
||
}
|
||
} catch (err) {
|
||
// Ignore errors
|
||
}
|
||
return false;
|
||
};
|
||
|
||
// First, check the active session (most likely parent for new agents)
|
||
if (this.activeSessionId && this.sessions.has(this.activeSessionId)) {
|
||
const found = await checkSession(this.activeSessionId, this.sessions.get(this.activeSessionId));
|
||
if (found) return;
|
||
}
|
||
|
||
// Then check other sessions
|
||
for (const [sessionId, session] of this.sessions) {
|
||
if (sessionId === this.activeSessionId) continue; // Already checked
|
||
const found = await checkSession(sessionId, session);
|
||
if (found) return;
|
||
}
|
||
}
|
||
|
||
updateSubagentWindowParent(agentId) {
|
||
const windowData = this.subagentWindows.get(agentId);
|
||
if (!windowData) return;
|
||
const agent = this.subagents.get(agentId);
|
||
if (!agent?.parentSessionId) return;
|
||
|
||
// Check if parent header already exists
|
||
const win = windowData.element;
|
||
if (win.querySelector('.subagent-window-parent')) return;
|
||
|
||
// Insert 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 = agent.parentSessionId;
|
||
parentDiv.innerHTML = `
|
||
<span class="parent-label">from</span>
|
||
<span class="parent-name" onclick="app.selectSession('${agent.parentSessionId}')">${this.escapeHtml(agent.parentSessionName)}</span>
|
||
`;
|
||
header.insertAdjacentElement('afterend', parentDiv);
|
||
}
|
||
|
||
// Update connection lines
|
||
this.updateConnectionLines();
|
||
}
|
||
|
||
// ========== Subagent Connection Lines ==========
|
||
|
||
updateConnectionLines() {
|
||
const svg = document.getElementById('connectionLines');
|
||
if (!svg) return;
|
||
|
||
svg.innerHTML = '';
|
||
|
||
for (const [agentId, windowInfo] of this.subagentWindows) {
|
||
if (windowInfo.minimized || windowInfo.hidden) continue;
|
||
|
||
const agent = this.subagents.get(agentId);
|
||
if (!agent?.parentSessionId) continue;
|
||
|
||
const tab = document.querySelector(`.session-tab[data-id="${agent.parentSessionId}"]`);
|
||
const win = windowInfo.element;
|
||
if (!tab || !win) continue;
|
||
|
||
const tabRect = tab.getBoundingClientRect();
|
||
const winRect = win.getBoundingClientRect();
|
||
|
||
// Draw curved line from tab bottom-center to window top-center
|
||
const x1 = tabRect.left + tabRect.width / 2;
|
||
const y1 = tabRect.bottom;
|
||
const x2 = winRect.left + winRect.width / 2;
|
||
const y2 = winRect.top;
|
||
|
||
// Bezier curve control points for smooth curve
|
||
const midY = (y1 + y2) / 2;
|
||
const path = `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
|
||
|
||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||
line.setAttribute('d', path);
|
||
line.setAttribute('class', 'connection-line');
|
||
line.setAttribute('data-agent-id', agentId);
|
||
svg.appendChild(line);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show/hide subagent windows based on active session.
|
||
* Behavior controlled by "Subagents for Active Tab Only" setting.
|
||
*/
|
||
updateSubagentWindowVisibility() {
|
||
const settings = this.loadAppSettingsFromStorage();
|
||
const activeTabOnly = settings.subagentActiveTabOnly ?? false;
|
||
|
||
for (const [agentId, windowInfo] of this.subagentWindows) {
|
||
const agent = this.subagents.get(agentId);
|
||
|
||
// Determine visibility based on setting
|
||
let shouldShow;
|
||
if (activeTabOnly) {
|
||
// Show if: no parent known yet, or parent matches active session
|
||
const hasKnownParent = agent?.parentSessionId;
|
||
shouldShow = !hasKnownParent || agent.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';
|
||
}
|
||
windowInfo.hidden = false;
|
||
} else {
|
||
// Hide window (but don't close it)
|
||
windowInfo.element.style.display = 'none';
|
||
windowInfo.hidden = true;
|
||
}
|
||
}
|
||
// Update connection lines after visibility changes
|
||
this.updateConnectionLines();
|
||
}
|
||
|
||
// ========== Subagent Floating Windows ==========
|
||
|
||
openSubagentWindow(agentId) {
|
||
// If window already exists, focus it
|
||
if (this.subagentWindows.has(agentId)) {
|
||
const existing = this.subagentWindows.get(agentId);
|
||
const agent = this.subagents.get(agentId);
|
||
const settings = this.loadAppSettingsFromStorage();
|
||
const activeTabOnly = settings.subagentActiveTabOnly ?? false;
|
||
|
||
// If window is hidden (different tab) and activeTabOnly is enabled, switch to parent tab
|
||
if (existing.hidden && agent?.parentSessionId && activeTabOnly) {
|
||
this.selectSession(agent.parentSessionId);
|
||
return;
|
||
}
|
||
|
||
// If not activeTabOnly mode, just show the window
|
||
if (existing.hidden && !activeTabOnly) {
|
||
existing.element.style.display = 'flex';
|
||
existing.hidden = false;
|
||
}
|
||
|
||
existing.element.style.zIndex = ++this.subagentWindowZIndex;
|
||
if (existing.minimized) {
|
||
this.restoreSubagentWindow(agentId);
|
||
}
|
||
return;
|
||
}
|
||
|
||
const agent = this.subagents.get(agentId);
|
||
if (!agent) return;
|
||
|
||
// Calculate final position - grid layout to avoid overlaps
|
||
const windowCount = this.subagentWindows.size;
|
||
const windowWidth = 420;
|
||
const windowHeight = 350;
|
||
const gap = 20;
|
||
const startX = 50;
|
||
const startY = 120;
|
||
const viewportWidth = window.innerWidth;
|
||
const viewportHeight = window.innerHeight;
|
||
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; // Wrap rows to stay in viewport
|
||
let finalX = startX + col * (windowWidth + gap);
|
||
let finalY = startY + row * (windowHeight + gap);
|
||
|
||
// Ensure window stays within viewport bounds
|
||
finalX = Math.max(10, Math.min(finalX, viewportWidth - windowWidth - 10));
|
||
finalY = Math.max(10, Math.min(finalY, viewportHeight - windowHeight - 10));
|
||
|
||
// Get parent tab position for spawn animation
|
||
const parentTab = agent.parentSessionId
|
||
? document.querySelector(`.session-tab[data-id="${agent.parentSessionId}"]`)
|
||
: null;
|
||
|
||
// Create window element
|
||
const win = document.createElement('div');
|
||
win.className = 'subagent-window';
|
||
win.id = `subagent-window-${agentId}`;
|
||
win.style.zIndex = ++this.subagentWindowZIndex;
|
||
|
||
// Build parent header if we have parent info
|
||
const parentHeader = agent.parentSessionId && agent.parentSessionName
|
||
? `<div class="subagent-window-parent" data-parent-session="${agent.parentSessionId}">
|
||
<span class="parent-label">from</span>
|
||
<span class="parent-name" onclick="app.selectSession('${agent.parentSessionId}')">${this.escapeHtml(agent.parentSessionName)}</span>
|
||
</div>`
|
||
: '';
|
||
|
||
const windowTitle = agent.description || agentId.substring(0, 7);
|
||
const truncatedTitle = windowTitle.length > 50 ? windowTitle.substring(0, 50) + '...' : windowTitle;
|
||
const modelBadge = agent.modelShort
|
||
? `<span class="subagent-model-badge ${agent.modelShort}">${agent.modelShort}</span>`
|
||
: '';
|
||
win.innerHTML = `
|
||
<div class="subagent-window-header">
|
||
<div class="subagent-window-title" title="${this.escapeHtml(agent.description || agentId)}">
|
||
<span class="icon">🤖</span>
|
||
<span class="id">${this.escapeHtml(truncatedTitle)}</span>
|
||
${modelBadge}
|
||
<span class="status ${agent.status}">${agent.status}</span>
|
||
</div>
|
||
<div class="subagent-window-actions">
|
||
<button onclick="app.closeSubagentWindow('${agentId}')" title="Minimize to tab">─</button>
|
||
</div>
|
||
</div>
|
||
${parentHeader}
|
||
<div class="subagent-window-body" id="subagent-window-body-${agentId}">
|
||
<div class="subagent-empty">Loading activity...</div>
|
||
</div>
|
||
`;
|
||
|
||
// If we have a parent tab, start window at tab position for spawn animation
|
||
if (parentTab) {
|
||
const tabRect = parentTab.getBoundingClientRect();
|
||
win.style.left = `${tabRect.left}px`;
|
||
win.style.top = `${tabRect.bottom}px`;
|
||
win.style.transform = 'scale(0.3)';
|
||
win.style.opacity = '0';
|
||
win.classList.add('spawning');
|
||
} else {
|
||
// No parent tab, just position normally
|
||
win.style.left = `${finalX}px`;
|
||
win.style.top = `${finalY}px`;
|
||
}
|
||
|
||
document.body.appendChild(win);
|
||
|
||
// Make draggable (with connection line update callback)
|
||
this.makeWindowDraggable(win, win.querySelector('.subagent-window-header'));
|
||
|
||
// Check if this window should be visible based on settings
|
||
const settings = this.loadAppSettingsFromStorage();
|
||
const activeTabOnly = settings.subagentActiveTabOnly ?? false;
|
||
let shouldHide = false;
|
||
if (activeTabOnly) {
|
||
const hasKnownParent = agent.parentSessionId;
|
||
const isForActiveSession = !hasKnownParent || agent.parentSessionId === this.activeSessionId;
|
||
shouldHide = !isForActiveSession;
|
||
}
|
||
|
||
// Store reference
|
||
this.subagentWindows.set(agentId, {
|
||
element: win,
|
||
minimized: false,
|
||
hidden: shouldHide,
|
||
});
|
||
|
||
// Hide window if not for active session
|
||
if (shouldHide) {
|
||
win.style.display = 'none';
|
||
}
|
||
|
||
// Render content
|
||
this.renderSubagentWindowContent(agentId);
|
||
|
||
// Focus on click
|
||
win.addEventListener('mousedown', () => {
|
||
win.style.zIndex = ++this.subagentWindowZIndex;
|
||
});
|
||
|
||
// Update connection lines when window is resized
|
||
const resizeObserver = new ResizeObserver(() => {
|
||
this.updateConnectionLines();
|
||
});
|
||
resizeObserver.observe(win);
|
||
|
||
// Store observer for cleanup
|
||
this.subagentWindows.get(agentId).resizeObserver = resizeObserver;
|
||
|
||
// Animate to final position if spawning from tab
|
||
if (parentTab) {
|
||
requestAnimationFrame(() => {
|
||
win.style.transition = 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)';
|
||
win.style.left = `${finalX}px`;
|
||
win.style.top = `${finalY}px`;
|
||
win.style.transform = 'scale(1)';
|
||
win.style.opacity = '1';
|
||
|
||
// Clean up after animation
|
||
setTimeout(() => {
|
||
win.style.transition = '';
|
||
win.classList.remove('spawning');
|
||
this.updateConnectionLines();
|
||
}, 400);
|
||
});
|
||
} else {
|
||
// No animation, just update connection lines
|
||
this.updateConnectionLines();
|
||
}
|
||
|
||
// Persist the state change (new window opened)
|
||
this.saveSubagentWindowStates();
|
||
}
|
||
|
||
closeSubagentWindow(agentId) {
|
||
const windowData = this.subagentWindows.get(agentId);
|
||
if (!windowData) return;
|
||
|
||
const agent = this.subagents.get(agentId);
|
||
const parentSessionId = agent?.parentSessionId || this.activeSessionId;
|
||
|
||
// Always minimize to tab (use active session if no parent)
|
||
windowData.element.style.display = 'none';
|
||
windowData.minimized = true;
|
||
|
||
// Track minimized agent for the session
|
||
if (parentSessionId) {
|
||
if (!this.minimizedSubagents.has(parentSessionId)) {
|
||
this.minimizedSubagents.set(parentSessionId, new Set());
|
||
}
|
||
this.minimizedSubagents.get(parentSessionId).add(agentId);
|
||
|
||
// Update tab badge to show minimized agents
|
||
this.renderSessionTabs();
|
||
}
|
||
|
||
// Persist the state change
|
||
this.saveSubagentWindowStates();
|
||
this.updateConnectionLines();
|
||
}
|
||
|
||
// Close all subagent windows for a session (fully removes them, not minimize)
|
||
closeSessionSubagentWindows(sessionId) {
|
||
const toClose = [];
|
||
for (const [agentId, _windowData] of this.subagentWindows) {
|
||
const agent = this.subagents.get(agentId);
|
||
if (agent?.parentSessionId === sessionId) {
|
||
toClose.push(agentId);
|
||
}
|
||
}
|
||
for (const agentId of toClose) {
|
||
this.forceCloseSubagentWindow(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();
|
||
}
|
||
windowData.element.remove();
|
||
this.subagentWindows.delete(agentId);
|
||
}
|
||
}
|
||
|
||
minimizeSubagentWindow(agentId) {
|
||
const windowData = this.subagentWindows.get(agentId);
|
||
if (windowData) {
|
||
windowData.element.style.display = 'none';
|
||
windowData.minimized = true;
|
||
this.updateConnectionLines();
|
||
}
|
||
}
|
||
|
||
restoreSubagentWindow(agentId) {
|
||
const windowData = this.subagentWindows.get(agentId);
|
||
const agent = this.subagents.get(agentId);
|
||
|
||
// If window doesn't exist but agent does, recreate it
|
||
if (!windowData && agent) {
|
||
this.createSubagentWindow(agentId);
|
||
return;
|
||
}
|
||
|
||
if (windowData) {
|
||
const settings = this.loadAppSettingsFromStorage();
|
||
const activeTabOnly = settings.subagentActiveTabOnly ?? false;
|
||
|
||
// Determine if we should show the window
|
||
let shouldShow = true;
|
||
if (activeTabOnly) {
|
||
// Only restore if the window belongs to the active session (or has no parent)
|
||
shouldShow = !agent?.parentSessionId || agent.parentSessionId === this.activeSessionId;
|
||
}
|
||
|
||
if (shouldShow) {
|
||
windowData.element.style.display = 'flex';
|
||
windowData.element.style.zIndex = ++this.subagentWindowZIndex;
|
||
windowData.hidden = false;
|
||
}
|
||
windowData.minimized = false;
|
||
this.updateConnectionLines();
|
||
}
|
||
}
|
||
|
||
makeWindowDraggable(win, handle) {
|
||
let isDragging = false;
|
||
let startX, startY, startLeft, startTop;
|
||
let dragUpdateScheduled = false;
|
||
|
||
handle.addEventListener('mousedown', (e) => {
|
||
if (e.target.tagName === 'BUTTON') return;
|
||
isDragging = true;
|
||
startX = e.clientX;
|
||
startY = e.clientY;
|
||
startLeft = parseInt(win.style.left) || 0;
|
||
startTop = parseInt(win.style.top) || 0;
|
||
e.preventDefault();
|
||
});
|
||
|
||
document.addEventListener('mousemove', (e) => {
|
||
if (!isDragging) return;
|
||
const dx = e.clientX - startX;
|
||
const dy = e.clientY - startY;
|
||
// Constrain to viewport bounds
|
||
const winWidth = win.offsetWidth || 420;
|
||
const winHeight = win.offsetHeight || 350;
|
||
const maxX = window.innerWidth - winWidth - 10;
|
||
const maxY = window.innerHeight - winHeight - 10;
|
||
const newLeft = Math.max(10, Math.min(startLeft + dx, maxX));
|
||
const newTop = Math.max(10, Math.min(startTop + dy, maxY));
|
||
win.style.left = `${newLeft}px`;
|
||
win.style.top = `${newTop}px`;
|
||
// Throttle connection line updates during drag
|
||
if (!dragUpdateScheduled) {
|
||
dragUpdateScheduled = true;
|
||
requestAnimationFrame(() => {
|
||
this.updateConnectionLines();
|
||
dragUpdateScheduled = false;
|
||
});
|
||
}
|
||
});
|
||
|
||
document.addEventListener('mouseup', () => {
|
||
if (isDragging) {
|
||
isDragging = false;
|
||
// Save position after drag ends
|
||
this.saveSubagentWindowStates();
|
||
}
|
||
});
|
||
}
|
||
|
||
renderSubagentWindowContent(agentId) {
|
||
const body = document.getElementById(`subagent-window-body-${agentId}`);
|
||
if (!body) return;
|
||
|
||
const activity = this.subagentActivity.get(agentId) || [];
|
||
|
||
if (activity.length === 0) {
|
||
body.innerHTML = '<div class="subagent-empty">No activity yet</div>';
|
||
return;
|
||
}
|
||
|
||
const html = activity.slice(-100).map(a => {
|
||
const time = new Date(a.timestamp).toLocaleTimeString('en-US', { hour12: false });
|
||
if (a.type === 'tool') {
|
||
return `<div class="activity-line">
|
||
<span class="time">${time}</span>
|
||
<span class="tool-icon">${this.getToolIcon(a.tool)}</span>
|
||
<span class="tool-name">${a.tool}</span>
|
||
<span class="tool-detail">${this.escapeHtml(this.getToolDetail(a.tool, a.input))}</span>
|
||
</div>`;
|
||
} 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 `<div class="activity-line result-line${statusClass}">
|
||
<span class="time">${time}</span>
|
||
<span class="tool-icon">${icon}</span>
|
||
<span class="tool-name">${a.tool || '→'}</span>
|
||
<span class="tool-detail">${this.escapeHtml(preview)}${sizeInfo}</span>
|
||
</div>`;
|
||
} else if (a.type === 'progress') {
|
||
// Check for hook events
|
||
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 `<div class="activity-line progress-line${isHook ? ' hook-line' : ''}">
|
||
<span class="time">${time}</span>
|
||
<span class="tool-icon">${icon}</span>
|
||
<span class="tool-detail">${this.escapeHtml(displayText)}</span>
|
||
</div>`;
|
||
} else if (a.type === 'message') {
|
||
const preview = a.text.length > 150 ? a.text.substring(0, 150) + '...' : a.text;
|
||
return `<div class="message-line">
|
||
<span class="time">${time}</span> 💬 ${this.escapeHtml(preview)}
|
||
</div>`;
|
||
}
|
||
return '';
|
||
}).join('');
|
||
|
||
body.innerHTML = html;
|
||
body.scrollTop = body.scrollHeight;
|
||
}
|
||
|
||
// 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 windowTitle = agent.description || agentId.substring(0, 7);
|
||
const truncatedTitle = windowTitle.length > 50 ? windowTitle.substring(0, 50) + '...' : windowTitle;
|
||
idEl.textContent = truncatedTitle;
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ========== 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 ?? true;
|
||
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(`
|
||
<div class="project-insight-item" data-tool-id="${tool.id}">
|
||
<div class="project-insight-command">
|
||
<span class="icon">💻</span>
|
||
<span class="cmd" title="${this.escapeHtml(tool.command)}">${this.escapeHtml(cmdDisplay)}</span>
|
||
<span class="project-insight-status ${tool.status}">${tool.status}</span>
|
||
${tool.timeout ? `<span class="project-insight-timeout">${this.escapeHtml(tool.timeout)}</span>` : ''}
|
||
</div>
|
||
<div class="project-insight-paths">
|
||
`);
|
||
|
||
for (const path of filteredPaths) {
|
||
const fileName = path.split('/').pop();
|
||
html.push(`
|
||
<span class="project-insight-filepath"
|
||
onclick="app.openLogViewerWindow('${this.escapeHtml(path)}', '${tool.sessionId}')"
|
||
title="${this.escapeHtml(path)}">${this.escapeHtml(fileName)}</span>
|
||
`);
|
||
}
|
||
|
||
html.push(`
|
||
</div>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
list.innerHTML = html.join('');
|
||
}
|
||
|
||
closeProjectInsightsPanel() {
|
||
const panel = this.$('projectInsightsPanel');
|
||
if (panel) {
|
||
panel.classList.remove('visible');
|
||
this.projectInsightsPanelVisible = false;
|
||
}
|
||
}
|
||
|
||
// ========== Log Viewer Windows (Floating File Streamers) ==========
|
||
|
||
openLogViewerWindow(filePath, sessionId) {
|
||
sessionId = sessionId || this.activeSessionId;
|
||
if (!sessionId) return;
|
||
|
||
// Create unique window ID
|
||
const windowId = `${sessionId}-${filePath.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
||
|
||
// If window already exists, focus it
|
||
if (this.logViewerWindows.has(windowId)) {
|
||
const existing = this.logViewerWindows.get(windowId);
|
||
existing.element.style.zIndex = ++this.logViewerWindowZIndex;
|
||
return;
|
||
}
|
||
|
||
// Calculate position (cascade from top-left)
|
||
const windowCount = this.logViewerWindows.size;
|
||
const offsetX = 100 + (windowCount % 5) * 30;
|
||
const offsetY = 100 + (windowCount % 5) * 30;
|
||
|
||
// Get filename for title
|
||
const fileName = filePath.split('/').pop();
|
||
|
||
// Create window element
|
||
const win = document.createElement('div');
|
||
win.className = 'log-viewer-window';
|
||
win.id = `log-viewer-window-${windowId}`;
|
||
win.style.left = `${offsetX}px`;
|
||
win.style.top = `${offsetY}px`;
|
||
win.style.zIndex = ++this.logViewerWindowZIndex;
|
||
|
||
win.innerHTML = `
|
||
<div class="log-viewer-window-header">
|
||
<div class="log-viewer-window-title" title="${this.escapeHtml(filePath)}">
|
||
<span class="icon">📄</span>
|
||
<span class="filename">${this.escapeHtml(fileName)}</span>
|
||
<span class="status streaming">streaming</span>
|
||
</div>
|
||
<div class="log-viewer-window-actions">
|
||
<button onclick="app.closeLogViewerWindow('${windowId}')" title="Close">×</button>
|
||
</div>
|
||
</div>
|
||
<div class="log-viewer-window-body" id="log-viewer-body-${windowId}">
|
||
<div class="log-info">Connecting to ${this.escapeHtml(filePath)}...</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(win);
|
||
|
||
// Make draggable
|
||
this.makeWindowDraggable(win, win.querySelector('.log-viewer-window-header'));
|
||
|
||
// Connect to SSE stream
|
||
const eventSource = new EventSource(
|
||
`/api/sessions/${sessionId}/tail-file?path=${encodeURIComponent(filePath)}&lines=50`
|
||
);
|
||
|
||
eventSource.onmessage = (e) => {
|
||
const data = JSON.parse(e.data);
|
||
const body = document.getElementById(`log-viewer-body-${windowId}`);
|
||
if (!body) return;
|
||
|
||
switch (data.type) {
|
||
case 'connected':
|
||
body.innerHTML = '';
|
||
break;
|
||
case 'data':
|
||
// Append data, auto-scroll
|
||
const wasAtBottom = body.scrollTop + body.clientHeight >= body.scrollHeight - 10;
|
||
const content = this.escapeHtml(data.content);
|
||
body.innerHTML += content;
|
||
if (wasAtBottom) {
|
||
body.scrollTop = body.scrollHeight;
|
||
}
|
||
// Trim if too large
|
||
if (body.innerHTML.length > 500000) {
|
||
body.innerHTML = body.innerHTML.slice(-400000);
|
||
}
|
||
break;
|
||
case 'end':
|
||
this.updateLogViewerStatus(windowId, 'disconnected', 'ended');
|
||
break;
|
||
case 'error':
|
||
body.innerHTML += `<div class="log-error">${this.escapeHtml(data.error)}</div>`;
|
||
this.updateLogViewerStatus(windowId, 'error', 'error');
|
||
break;
|
||
}
|
||
};
|
||
|
||
eventSource.onerror = () => {
|
||
this.updateLogViewerStatus(windowId, 'disconnected', 'connection error');
|
||
};
|
||
|
||
// Store reference
|
||
this.logViewerWindows.set(windowId, {
|
||
element: win,
|
||
eventSource,
|
||
filePath,
|
||
sessionId,
|
||
});
|
||
}
|
||
|
||
updateLogViewerStatus(windowId, statusClass, statusText) {
|
||
const statusEl = document.querySelector(`#log-viewer-window-${windowId} .status`);
|
||
if (statusEl) {
|
||
statusEl.className = `status ${statusClass}`;
|
||
statusEl.textContent = statusText;
|
||
}
|
||
}
|
||
|
||
closeLogViewerWindow(windowId) {
|
||
const windowData = this.logViewerWindows.get(windowId);
|
||
if (!windowData) return;
|
||
|
||
// Close SSE connection
|
||
if (windowData.eventSource) {
|
||
windowData.eventSource.close();
|
||
}
|
||
|
||
// Remove element
|
||
windowData.element.remove();
|
||
|
||
// Remove from map
|
||
this.logViewerWindows.delete(windowId);
|
||
}
|
||
|
||
// Close all log viewer windows for a session
|
||
closeSessionLogViewerWindows(sessionId) {
|
||
for (const [windowId, data] of this.logViewerWindows) {
|
||
if (data.sessionId === sessionId) {
|
||
this.closeLogViewerWindow(windowId);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ========== Screen Sessions (in Monitor Panel) ==========
|
||
|
||
async loadScreens() {
|
||
try {
|
||
const res = await fetch('/api/screens');
|
||
const data = await res.json();
|
||
this.screenSessions = data.screens || [];
|
||
this.renderScreenSessions();
|
||
} catch (err) {
|
||
console.error('Failed to load screens:', err);
|
||
}
|
||
}
|
||
|
||
killAllSessions() {
|
||
const count = this.screenSessions?.length || 0;
|
||
if (count === 0) {
|
||
alert('No sessions to kill');
|
||
return;
|
||
}
|
||
|
||
// Show the kill all modal
|
||
document.getElementById('killAllCount').textContent = count;
|
||
document.getElementById('killAllModal').classList.add('active');
|
||
}
|
||
|
||
closeKillAllModal() {
|
||
document.getElementById('killAllModal').classList.remove('active');
|
||
}
|
||
|
||
async confirmKillAll(killScreens) {
|
||
this.closeKillAllModal();
|
||
|
||
try {
|
||
if (killScreens) {
|
||
// Kill everything including screens
|
||
const res = await fetch('/api/sessions', { method: 'DELETE' });
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
this.sessions.clear();
|
||
this.screenSessions = [];
|
||
this.activeSessionId = null;
|
||
this.renderSessionTabs();
|
||
this.renderScreenSessions();
|
||
this.terminal.clear();
|
||
this.terminal.reset();
|
||
this.toast('All sessions and screens killed', 'success');
|
||
}
|
||
} else {
|
||
// Just remove tabs, keep screens running
|
||
this.sessions.clear();
|
||
this.activeSessionId = null;
|
||
this.renderSessionTabs();
|
||
this.terminal.clear();
|
||
this.terminal.reset();
|
||
this.toast('All tabs removed, screens still running', 'info');
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to kill sessions:', err);
|
||
this.toast('Failed to kill sessions: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ========== Create Case Modal ==========
|
||
|
||
showCreateCaseModal() {
|
||
document.getElementById('newCaseName').value = '';
|
||
document.getElementById('newCaseDescription').value = '';
|
||
document.getElementById('linkCaseName').value = '';
|
||
document.getElementById('linkCasePath').value = '';
|
||
// Reset to first tab
|
||
this.caseModalTab = 'case-create';
|
||
this.switchCaseModalTab('case-create');
|
||
// Wire up tab buttons
|
||
const modal = document.getElementById('createCaseModal');
|
||
modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => {
|
||
btn.onclick = () => this.switchCaseModalTab(btn.dataset.tab);
|
||
});
|
||
modal.classList.add('active');
|
||
document.getElementById('newCaseName').focus();
|
||
}
|
||
|
||
switchCaseModalTab(tabName) {
|
||
this.caseModalTab = tabName;
|
||
const modal = document.getElementById('createCaseModal');
|
||
// Toggle active class on tab buttons
|
||
modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.tab === tabName);
|
||
});
|
||
// Toggle hidden class on tab content
|
||
modal.querySelectorAll('.modal-tab-content').forEach(content => {
|
||
content.classList.toggle('hidden', content.id !== tabName);
|
||
});
|
||
// Update submit button text
|
||
const submitBtn = document.getElementById('caseModalSubmit');
|
||
submitBtn.textContent = tabName === 'case-create' ? 'Create' : 'Link';
|
||
// Focus appropriate input
|
||
if (tabName === 'case-create') {
|
||
document.getElementById('newCaseName').focus();
|
||
} else {
|
||
document.getElementById('linkCaseName').focus();
|
||
}
|
||
}
|
||
|
||
closeCreateCaseModal() {
|
||
document.getElementById('createCaseModal').classList.remove('active');
|
||
}
|
||
|
||
async submitCaseModal() {
|
||
if (this.caseModalTab === 'case-create') {
|
||
await this.createCase();
|
||
} else {
|
||
await this.linkCase();
|
||
}
|
||
}
|
||
|
||
async createCase() {
|
||
const name = document.getElementById('newCaseName').value.trim();
|
||
const description = document.getElementById('newCaseDescription').value.trim();
|
||
|
||
if (!name) {
|
||
this.showToast('Please enter a case name', 'error');
|
||
return;
|
||
}
|
||
|
||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||
this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch('/api/cases', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name, description })
|
||
});
|
||
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
this.closeCreateCaseModal();
|
||
this.showToast(`Case "${name}" created`, 'success');
|
||
// Reload cases and select the new one
|
||
await this.loadQuickStartCases(name);
|
||
// Save as last used case
|
||
await this.saveLastUsedCase(name);
|
||
} else {
|
||
this.showToast(data.error || 'Failed to create case', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to create case:', err);
|
||
this.showToast('Failed to create case: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async linkCase() {
|
||
const name = document.getElementById('linkCaseName').value.trim();
|
||
const path = document.getElementById('linkCasePath').value.trim();
|
||
|
||
if (!name) {
|
||
this.showToast('Please enter a case name', 'error');
|
||
return;
|
||
}
|
||
|
||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||
this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
|
||
return;
|
||
}
|
||
|
||
if (!path) {
|
||
this.showToast('Please enter a folder path', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch('/api/cases/link', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name, path })
|
||
});
|
||
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
this.closeCreateCaseModal();
|
||
this.showToast(`Case "${name}" linked to ${path}`, 'success');
|
||
// Reload cases and select the new one
|
||
await this.loadQuickStartCases(name);
|
||
// Save as last used case
|
||
await this.saveLastUsedCase(name);
|
||
} else {
|
||
this.showToast(data.error || 'Failed to link case', 'error');
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to link case:', err);
|
||
this.showToast('Failed to link case: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
renderScreenSessions() {
|
||
// Debounce renders at 100ms to prevent excessive DOM updates
|
||
if (this.renderScreenSessionsTimeout) {
|
||
clearTimeout(this.renderScreenSessionsTimeout);
|
||
}
|
||
this.renderScreenSessionsTimeout = setTimeout(() => {
|
||
this._renderScreenSessionsImmediate();
|
||
}, 100);
|
||
}
|
||
|
||
_renderScreenSessionsImmediate() {
|
||
const body = document.getElementById('screenSessionsBody');
|
||
|
||
if (!this.screenSessions || this.screenSessions.length === 0) {
|
||
body.innerHTML = '<div class="monitor-empty">No screen sessions</div>';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
for (const screen of this.screenSessions) {
|
||
const stats = screen.stats || { memoryMB: 0, cpuPercent: 0, childCount: 0 };
|
||
const modeClass = screen.mode === 'shell' ? 'shell' : '';
|
||
|
||
html += `
|
||
<div class="process-item">
|
||
<span class="process-mode ${modeClass}">${screen.mode}</span>
|
||
<div class="process-info">
|
||
<div class="process-name">${this.escapeHtml(screen.name || screen.screenName)}</div>
|
||
<div class="process-meta">
|
||
<span class="process-stat memory">${stats.memoryMB}MB</span>
|
||
<span class="process-stat cpu">${stats.cpuPercent}%</span>
|
||
<span class="process-stat children">${stats.childCount} children</span>
|
||
<span>PID: ${screen.pid}</span>
|
||
</div>
|
||
</div>
|
||
<div class="process-actions">
|
||
<button class="btn-toolbar btn-sm btn-danger" onclick="app.killScreen('${screen.sessionId}')" title="Kill screen">Kill</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
body.innerHTML = html;
|
||
}
|
||
|
||
async killScreen(sessionId) {
|
||
if (!confirm('Kill this screen session?')) return;
|
||
|
||
try {
|
||
await fetch(`/api/screens/${sessionId}`, { method: 'DELETE' });
|
||
this.screenSessions = this.screenSessions.filter(s => s.sessionId !== sessionId);
|
||
this.renderScreenSessions();
|
||
this.showToast('Screen killed', 'success');
|
||
} catch (err) {
|
||
this.showToast('Failed to kill screen', 'error');
|
||
}
|
||
}
|
||
|
||
async reconcileScreens() {
|
||
try {
|
||
const res = await fetch('/api/screens/reconcile', { method: 'POST' });
|
||
const data = await res.json();
|
||
|
||
if (data.dead && data.dead.length > 0) {
|
||
this.showToast(`Found ${data.dead.length} dead screen(s)`, 'warning');
|
||
await this.loadScreens();
|
||
} else {
|
||
this.showToast('All screens are alive', 'success');
|
||
}
|
||
} catch (err) {
|
||
this.showToast('Failed to reconcile screens', 'error');
|
||
}
|
||
}
|
||
|
||
// ========== Toast ==========
|
||
|
||
// Cached toast container for performance
|
||
_toastContainer = null;
|
||
|
||
toggleNotifications() {
|
||
this.notificationManager?.toggleDrawer();
|
||
}
|
||
|
||
// Alias for showToast
|
||
toast(message, type = 'info') {
|
||
return this.showToast(message, type);
|
||
}
|
||
|
||
showToast(message, type = 'info') {
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast toast-${type}`;
|
||
toast.textContent = message;
|
||
|
||
// Cache toast container reference
|
||
if (!this._toastContainer) {
|
||
this._toastContainer = document.querySelector('.toast-container');
|
||
if (!this._toastContainer) {
|
||
this._toastContainer = document.createElement('div');
|
||
this._toastContainer.className = 'toast-container';
|
||
document.body.appendChild(this._toastContainer);
|
||
}
|
||
}
|
||
this._toastContainer.appendChild(toast);
|
||
|
||
requestAnimationFrame(() => toast.classList.add('show'));
|
||
|
||
setTimeout(() => {
|
||
toast.classList.remove('show');
|
||
setTimeout(() => toast.remove(), 200);
|
||
}, 3000);
|
||
}
|
||
|
||
// ========== System Stats ==========
|
||
|
||
startSystemStatsPolling() {
|
||
// Initial fetch
|
||
this.fetchSystemStats();
|
||
|
||
// Poll every 2 seconds
|
||
this.systemStatsInterval = setInterval(() => {
|
||
this.fetchSystemStats();
|
||
}, 2000);
|
||
}
|
||
|
||
async fetchSystemStats() {
|
||
try {
|
||
const res = await fetch('/api/system/stats');
|
||
const stats = await res.json();
|
||
this.updateSystemStatsDisplay(stats);
|
||
} catch (err) {
|
||
// Silently fail - system stats are not critical
|
||
}
|
||
}
|
||
|
||
updateSystemStatsDisplay(stats) {
|
||
const cpuEl = this.$('statCpu');
|
||
const cpuBar = this.$('statCpuBar');
|
||
const memEl = this.$('statMem');
|
||
const memBar = this.$('statMemBar');
|
||
|
||
if (cpuEl && cpuBar) {
|
||
cpuEl.textContent = `${stats.cpu}%`;
|
||
cpuBar.style.width = `${Math.min(100, stats.cpu)}%`;
|
||
|
||
// Color classes based on usage
|
||
cpuBar.classList.remove('medium', 'high');
|
||
cpuEl.classList.remove('high');
|
||
if (stats.cpu > 80) {
|
||
cpuBar.classList.add('high');
|
||
cpuEl.classList.add('high');
|
||
} else if (stats.cpu > 50) {
|
||
cpuBar.classList.add('medium');
|
||
}
|
||
}
|
||
|
||
if (memEl && memBar) {
|
||
const memGB = (stats.memory.usedMB / 1024).toFixed(1);
|
||
memEl.textContent = `${memGB}G`;
|
||
memBar.style.width = `${Math.min(100, stats.memory.percent)}%`;
|
||
|
||
// Color classes based on usage
|
||
memBar.classList.remove('medium', 'high');
|
||
memEl.classList.remove('high');
|
||
if (stats.memory.percent > 80) {
|
||
memBar.classList.add('high');
|
||
memEl.classList.add('high');
|
||
} else if (stats.memory.percent > 50) {
|
||
memBar.classList.add('medium');
|
||
}
|
||
}
|
||
}
|
||
|
||
// ========== Utility ==========
|
||
|
||
// Pre-compiled HTML escape map for performance (avoids DOM element creation)
|
||
static _htmlEscapeMap = {
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": '''
|
||
};
|
||
static _htmlEscapePattern = /[&<>"']/g;
|
||
|
||
escapeHtml(text) {
|
||
if (!text) return '';
|
||
return text.replace(ClaudemanApp._htmlEscapePattern, char => ClaudemanApp._htmlEscapeMap[char]);
|
||
}
|
||
}
|
||
|
||
// Initialize
|
||
const app = new ClaudemanApp();
|