claude-code-web
Version:
Web-based interface for Claude Code CLI accessible via browser
1,125 lines (966 loc) • 42.9 kB
JavaScript
class SessionTabManager {
constructor(claudeInterface) {
this.claudeInterface = claudeInterface;
this.tabs = new Map(); // sessionId -> tab element
this.activeSessions = new Map(); // sessionId -> session data
this.activeTabId = null;
this.tabOrder = []; // visual order of tabs
this.tabHistory = []; // most recently used order
this.notificationsEnabled = false;
this.requestNotificationPermission();
}
getAlias(kind) {
if (this.claudeInterface && typeof this.claudeInterface.getAlias === 'function') {
return this.claudeInterface.getAlias(kind);
}
return kind === 'codex' ? 'Codex' : 'Claude';
}
requestNotificationPermission() {
if ('Notification' in window) {
if (Notification.permission === 'default') {
// Request permission
Notification.requestPermission().then(permission => {
this.notificationsEnabled = permission === 'granted';
if (this.notificationsEnabled) {
console.log('Desktop notifications enabled');
} else {
console.log('Desktop notifications denied');
}
});
} else if (Notification.permission === 'granted') {
this.notificationsEnabled = true;
console.log('Desktop notifications already enabled');
} else {
this.notificationsEnabled = false;
console.log('Desktop notifications blocked');
}
} else {
console.log('Desktop notifications not supported in this browser');
}
}
sendNotification(title, body, sessionId) {
// Don't send notification for active tab
if (sessionId === this.activeTabId) return;
// Only send notifications if the page is not visible
if (document.visibilityState === 'visible') return;
// Try desktop notifications first (won't work on iOS Safari)
if ('Notification' in window && Notification.permission === 'granted') {
try {
const notification = new Notification(title, {
body: body,
icon: '/favicon.ico',
tag: sessionId,
requireInteraction: false,
silent: false
});
notification.onclick = () => {
window.focus();
this.switchToTab(sessionId);
notification.close();
};
setTimeout(() => notification.close(), 5000);
console.log(`Desktop notification sent: ${title}`);
return; // Exit if desktop notification worked
} catch (error) {
console.error('Desktop notification failed:', error);
}
}
// Fallback for mobile: Use visual + audio/vibration
this.showMobileNotification(title, body, sessionId);
}
showMobileNotification(title, body, sessionId) {
// Update page title to show notification
const originalTitle = document.title;
let flashCount = 0;
const flashInterval = setInterval(() => {
document.title = flashCount % 2 === 0 ? `• ${title}` : originalTitle;
flashCount++;
if (flashCount > 6) {
clearInterval(flashInterval);
document.title = originalTitle;
}
}, 1000);
// Try to vibrate if available (Android)
if ('vibrate' in navigator) {
try {
navigator.vibrate([200, 100, 200]);
} catch (e) {
console.log('Vibration not available');
}
}
// Show a toast-style notification at the top of the screen
const toast = document.createElement('div');
toast.className = 'mobile-notification';
toast.style.cssText = `
position: fixed;
top: 10px;
left: 50%;
transform: translateX(-50%);
background: #3b82f6;
color: white;
padding: 12px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 10001;
max-width: 90%;
text-align: center;
cursor: pointer;
animation: slideDown 0.3s ease-out;
`;
toast.innerHTML = `
<div style="font-weight: bold; margin-bottom: 4px;">${title}</div>
<div style="font-size: 14px; opacity: 0.9;">${body}</div>
`;
// Add CSS animation
if (!document.querySelector('#mobileNotificationStyles')) {
const style = document.createElement('style');
style.id = 'mobileNotificationStyles';
style.textContent = `
@keyframes slideDown {
from {
transform: translateX(-50%) translateY(-100%);
opacity: 0;
}
to {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
to {
transform: translateX(-50%) translateY(-100%);
opacity: 0;
}
}
`;
document.head.appendChild(style);
}
toast.onclick = () => {
this.switchToTab(sessionId);
toast.style.animation = 'slideUp 0.3s ease-out';
setTimeout(() => toast.remove(), 300);
};
document.body.appendChild(toast);
// Auto-remove after 5 seconds
setTimeout(() => {
if (toast.parentNode) {
toast.style.animation = 'slideUp 0.3s ease-out';
setTimeout(() => toast.remove(), 300);
}
}, 5000);
// Play a sound if possible (create a simple beep)
try {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 800;
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.2);
} catch (e) {
console.log('Audio notification not available');
}
}
getOrderedTabIds() {
// Filter out any ids that may have been removed without updating the order array
this.tabOrder = this.tabOrder.filter(id => this.tabs.has(id));
return [...this.tabOrder];
}
getOrderedTabElements() {
return this.getOrderedTabIds()
.map(id => this.tabs.get(id))
.filter(Boolean);
}
syncOrderFromDom() {
const tabsContainer = document.getElementById('tabsContainer');
if (!tabsContainer) return;
const ids = Array.from(tabsContainer.querySelectorAll('.session-tab'))
.map(tab => tab.dataset.sessionId)
.filter(Boolean);
if (ids.length) {
this.tabOrder = ids;
}
}
ensureTabVisible(sessionId) {
const tab = this.tabs.get(sessionId);
if (!tab) return;
const scrollContainer = tab.closest('.tabs-section');
if (!scrollContainer) return;
const tabRect = tab.getBoundingClientRect();
const containerRect = scrollContainer.getBoundingClientRect();
if (tabRect.left < containerRect.left) {
scrollContainer.scrollLeft += tabRect.left - containerRect.left - 16;
} else if (tabRect.right > containerRect.right) {
scrollContainer.scrollLeft += tabRect.right - containerRect.right + 16;
}
}
updateTabHistory(sessionId) {
this.tabHistory = this.tabHistory.filter(id => id !== sessionId && this.tabs.has(id));
this.tabHistory.unshift(sessionId);
if (this.tabHistory.length > 50) {
this.tabHistory.length = 50;
}
}
removeFromHistory(sessionId) {
this.tabHistory = this.tabHistory.filter(id => id !== sessionId);
}
async init() {
this.setupTabBar();
this.setupKeyboardShortcuts();
this.setupOverflowDropdown();
await this.loadSessions();
this.updateTabOverflow();
// Show notification permission prompt after a slight delay
setTimeout(() => {
this.checkAndPromptForNotifications();
}, 2000);
}
checkAndPromptForNotifications() {
if ('Notification' in window && Notification.permission === 'default') {
// Create a small prompt to enable notifications
const promptDiv = document.createElement('div');
promptDiv.style.cssText = `
position: fixed;
top: 60px;
right: 20px;
background: #1e293b;
border: 1px solid #475569;
border-radius: 8px;
padding: 12px 16px;
color: #e2e8f0;
font-size: 14px;
z-index: 10000;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
max-width: 300px;
`;
promptDiv.innerHTML = `
<div style="margin-bottom: 10px;">
<strong>Enable Desktop Notifications?</strong><br>
Get notified when ${this.getAlias('claude')} completes tasks in background tabs.
</div>
<div style="display: flex; gap: 10px;">
<button id="enableNotifications" style="
background: #3b82f6;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
">Enable</button>
<button id="dismissNotifications" style="
background: #475569;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
">Not Now</button>
</div>
`;
document.body.appendChild(promptDiv);
document.getElementById('enableNotifications').onclick = () => {
this.requestNotificationPermission();
promptDiv.remove();
};
document.getElementById('dismissNotifications').onclick = () => {
promptDiv.remove();
};
// Auto-dismiss after 10 seconds
setTimeout(() => {
if (promptDiv.parentNode) {
promptDiv.remove();
}
}, 10000);
}
}
setupTabBar() {
const tabsContainer = document.getElementById('tabsContainer');
const newTabBtn = document.getElementById('tabNewBtn');
// New tab button
newTabBtn?.addEventListener('click', () => {
this.createNewSession();
});
// Enable drag and drop for tabs
if (tabsContainer) {
tabsContainer.addEventListener('dragstart', (e) => {
if (e.target.classList.contains('session-tab')) {
e.dataTransfer.effectAllowed = 'copyMove';
const sid = e.target.dataset.sessionId;
if (sid) {
e.dataTransfer.setData('text/plain', sid);
e.dataTransfer.setData('application/x-session-id', sid);
e.dataTransfer.setData('x-source-pane', '-1');
}
e.target.classList.add('dragging');
}
});
tabsContainer.addEventListener('dragend', (e) => {
if (e.target.classList.contains('session-tab')) {
e.target.classList.remove('dragging');
this.syncOrderFromDom();
this.updateTabOverflow();
this.updateOverflowMenu();
}
});
tabsContainer.addEventListener('dragover', (e) => {
e.preventDefault();
const draggingTab = tabsContainer.querySelector('.dragging');
if (!draggingTab) return;
const afterElement = this.getDragAfterElement(tabsContainer, e.clientX);
if (afterElement == null) {
tabsContainer.appendChild(draggingTab);
} else {
tabsContainer.insertBefore(draggingTab, afterElement);
}
});
tabsContainer.addEventListener('drop', (e) => {
e.preventDefault();
});
}
}
setupOverflowDropdown() {
const overflowBtn = document.getElementById('tabOverflowBtn');
const overflowMenu = document.getElementById('tabOverflowMenu');
if (overflowBtn) {
overflowBtn.addEventListener('click', (e) => {
e.stopPropagation();
overflowMenu.classList.toggle('active');
this.updateOverflowMenu();
});
}
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!overflowMenu?.contains(e.target) && !overflowBtn?.contains(e.target)) {
overflowMenu?.classList.remove('active');
}
});
// Update overflow on window resize
window.addEventListener('resize', () => {
this.updateTabOverflow();
this.updateOverflowMenu();
});
}
updateTabOverflow() {
const isMobile = window.innerWidth <= 768;
const overflowWrapper = document.getElementById('tabOverflowWrapper');
const overflowCount = document.querySelector('.tab-overflow-count');
if (!isMobile) {
// On desktop, show all tabs and hide overflow
this.tabs.forEach(tab => {
tab.style.display = '';
});
if (overflowWrapper) {
overflowWrapper.style.display = 'none';
}
if (overflowCount) overflowCount.textContent = '';
return;
}
// On mobile, show only first 2 tabs
const tabsArray = this.getOrderedTabElements();
tabsArray.forEach((tab, index) => {
if (index < 2) {
tab.style.display = ''; // Show first 2 tabs
} else {
tab.style.display = 'none'; // Hide rest
}
});
if (tabsArray.length > 2) {
// Show overflow button with count
if (overflowWrapper) {
overflowWrapper.style.display = 'flex';
if (overflowCount) {
overflowCount.textContent = tabsArray.length - 2;
}
}
} else {
// Hide overflow button
if (overflowWrapper) {
overflowWrapper.style.display = 'none';
}
if (overflowCount) {
overflowCount.textContent = '';
}
}
}
updateOverflowMenu() {
const menu = document.getElementById('tabOverflowMenu');
if (!menu) return;
const overflowIds = this.getOrderedTabIds().slice(2);
menu.innerHTML = '';
overflowIds.forEach((sessionId) => {
const tabElement = this.tabs.get(sessionId);
if (!tabElement) return;
const session = this.activeSessions.get(sessionId);
if (!session) return;
const item = document.createElement('div');
item.className = 'overflow-tab-item';
if (sessionId === this.activeTabId) {
item.classList.add('active');
}
item.innerHTML = `
<span class="overflow-tab-name">${tabElement.querySelector('.tab-name').textContent}</span>
<span class="overflow-tab-close" data-session-id="${sessionId}" title="Close tab">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</span>
`;
// Click to switch to tab
item.addEventListener('click', async (e) => {
if (!e.target.classList.contains('overflow-tab-close')) {
await this.switchToTab(sessionId);
menu.classList.remove('active');
// Update menu contents after switching - use a slightly longer delay to ensure UI updates
setTimeout(() => {
this.updateTabOverflow();
this.updateOverflowMenu();
}, 150);
}
});
// Close button
const closeBtn = item.querySelector('.overflow-tab-close');
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.closeSession(sessionId);
menu.classList.remove('active');
});
menu.appendChild(item);
});
}
setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// Ctrl/Cmd + T: New tab
if ((e.ctrlKey || e.metaKey) && e.key === 't') {
e.preventDefault();
this.createNewSession();
}
// Ctrl/Cmd + W: Close current tab
if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
e.preventDefault();
if (this.activeTabId) {
this.closeSession(this.activeTabId);
}
}
// Ctrl/Cmd + Tab: Next tab
if ((e.ctrlKey || e.metaKey) && e.key === 'Tab' && !e.shiftKey) {
e.preventDefault();
this.switchToNextTab();
}
// Ctrl/Cmd + Shift + Tab: Previous tab
if ((e.ctrlKey || e.metaKey) && e.key === 'Tab' && e.shiftKey) {
e.preventDefault();
this.switchToPreviousTab();
}
// Alt + 1-9: Switch to tab by number
if (e.altKey && e.key >= '1' && e.key <= '9') {
e.preventDefault();
const index = parseInt(e.key) - 1;
this.switchToTabByIndex(index);
}
});
}
async loadSessions() {
try {
console.log('[SessionManager.loadSessions] Fetching sessions from server...');
const authHeaders = window.authManager ? window.authManager.getAuthHeaders() : {};
const response = await fetch('/api/sessions/list', {
headers: authHeaders
});
const data = await response.json();
console.log('[SessionManager.loadSessions] Got data:', data);
// Sort sessions by creation time (assuming older sessions should be less recent)
// This provides a default order that will be updated as tabs are accessed
const sessions = data.sessions || [];
console.log('[SessionManager.loadSessions] Processing', sessions.length, 'sessions');
sessions.forEach((session, index) => {
console.log('[SessionManager.loadSessions] Adding tab for:', session.id);
// Don't auto-switch when loading existing sessions
this.addTab(session.id, session.name, session.active ? 'active' : 'idle', session.workingDir, false);
// Set initial timestamps based on order (older sessions get older timestamps)
const sessionData = this.activeSessions.get(session.id);
if (sessionData) {
sessionData.lastAccessed = Date.now() - (sessions.length - index) * 1000;
}
});
// Reorder tabs based on the initial timestamps (mobile only)
if (window.innerWidth <= 768) {
this.reorderTabsByLastAccessed();
}
console.log('[SessionManager.loadSessions] Final tabs.size:', this.tabs.size);
return sessions;
} catch (error) {
console.error('Failed to load sessions:', error);
return [];
}
}
addTab(sessionId, sessionName, status = 'idle', workingDir = null, autoSwitch = true) {
const tabsContainer = document.getElementById('tabsContainer');
if (!tabsContainer) return;
// Check if tab already exists
if (this.tabs.has(sessionId)) {
return;
}
const tab = document.createElement('div');
tab.className = 'session-tab';
tab.dataset.sessionId = sessionId;
tab.draggable = true;
// Determine display name:
// 1. If session name is customized (not default "Session ..."), use it
// 2. Otherwise, use folder name if available
// 3. Fall back to session name
const isDefaultSessionName = sessionName.startsWith('Session ') && sessionName.includes(':');
const folderName = workingDir ? workingDir.split('/').pop() || '/' : null;
const displayName = !isDefaultSessionName ? sessionName : (folderName || sessionName);
tab.innerHTML = `
<div class="tab-content">
<span class="tab-status ${status}"></span>
<span class="tab-name" title="${workingDir || sessionName}">${displayName}</span>
</div>
<span class="tab-close" title="Close tab">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</span>
`;
// Tab click handler
tab.addEventListener('click', async (e) => {
if (!e.target.closest('.tab-close')) {
await this.switchToTab(sessionId);
}
});
// Close button handler
const closeBtn = tab.querySelector('.tab-close');
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.closeSession(sessionId);
});
// Double-click to rename
tab.addEventListener('dblclick', (e) => {
if (!e.target.closest('.tab-close')) {
this.renameTab(sessionId);
}
});
// Middle click to close (VS Code behavior)
tab.addEventListener('auxclick', (e) => {
if (e.button === 1) {
e.preventDefault();
e.stopPropagation();
this.closeSession(sessionId);
}
});
// Context menu: Close Others, Split Right, Move to Split
tab.addEventListener('contextmenu', (e) => {
e.preventDefault();
this.openTabContextMenu(sessionId, e.clientX, e.clientY);
});
tabsContainer.appendChild(tab);
this.tabs.set(sessionId, tab);
if (!this.tabOrder.includes(sessionId)) {
this.tabOrder.push(sessionId);
}
// Store session data with timestamp and activity tracking
this.activeSessions.set(sessionId, {
id: sessionId,
name: sessionName,
status: status,
workingDir: workingDir,
lastAccessed: Date.now(),
lastActivity: Date.now(),
unreadOutput: false,
hasError: false
});
// Update overflow on mobile
this.updateTabOverflow();
this.updateOverflowMenu();
// If this is the first tab and autoSwitch is enabled, make it active
if (this.tabs.size === 1 && autoSwitch) {
this.switchToTab(sessionId);
}
}
async switchToTab(sessionId, options = {}) {
if (!this.tabs.has(sessionId)) return;
const { skipHistoryUpdate = false } = options;
// Remove active class from all tabs
this.tabs.forEach(tab => tab.classList.remove('active'));
// Add active class to selected tab
const tab = this.tabs.get(sessionId);
if (!tab) return;
tab.classList.add('active');
this.activeTabId = sessionId;
this.ensureTabVisible(sessionId);
// Update last accessed timestamp and clear unread indicator
const session = this.activeSessions.get(sessionId);
if (session) {
session.lastAccessed = Date.now();
if (session.unreadOutput) this.updateUnreadIndicator(sessionId, false);
}
if (!skipHistoryUpdate) {
this.updateTabHistory(sessionId);
}
if (window.innerWidth <= 768) {
const tabIndex = this.getOrderedTabIds().indexOf(sessionId);
if (tabIndex >= 2) this.reorderTabsByLastAccessed();
}
this.updateOverflowMenu();
// If tile view is enabled, tabs target the active pane (VS Code-style)
await this.claudeInterface.joinSession(sessionId);
this.updateHeaderInfo(sessionId);
}
reorderTabsByLastAccessed() {
const tabsContainer = document.getElementById('tabsContainer');
if (!tabsContainer) return;
// Get all tabs sorted by last accessed time (most recent first)
const sortedIds = this.getOrderedTabIds()
.sort((a, b) => {
const sessionA = this.activeSessions.get(a);
const sessionB = this.activeSessions.get(b);
const timeA = sessionA ? sessionA.lastAccessed : 0;
const timeB = sessionB ? sessionB.lastAccessed : 0;
return timeB - timeA; // Most recent first
});
sortedIds.forEach((sessionId) => {
const tabElement = this.tabs.get(sessionId);
if (tabElement) {
tabsContainer.appendChild(tabElement);
}
});
this.tabOrder = sortedIds;
// Update overflow on mobile
this.updateTabOverflow();
}
closeSession(sessionId, { skipServerRequest = false } = {}) {
const tab = this.tabs.get(sessionId);
if (!tab) return;
const orderedIds = this.getOrderedTabIds();
const closedIndex = orderedIds.indexOf(sessionId);
// Remove tab
tab.remove();
this.tabs.delete(sessionId);
this.activeSessions.delete(sessionId);
this.tabOrder = orderedIds.filter(id => id !== sessionId);
this.removeFromHistory(sessionId);
// Update overflow on mobile
this.updateTabOverflow();
this.updateOverflowMenu();
if (!skipServerRequest) {
const authHeaders = window.authManager ? window.authManager.getAuthHeaders() : {};
fetch(`/api/sessions/${sessionId}`, {
method: 'DELETE',
headers: authHeaders
})
.catch(err => console.error('Failed to delete session:', err));
}
// If this was the active tab, switch to another
if (this.activeTabId === sessionId) {
this.activeTabId = null;
let fallbackId = this.tabHistory.find(id => this.tabs.has(id));
if (!fallbackId && this.tabOrder.length > 0) {
const nextIndex = closedIndex >= 0 ? Math.min(closedIndex, this.tabOrder.length - 1) : 0;
fallbackId = this.tabOrder[nextIndex];
}
if (fallbackId) {
this.switchToTab(fallbackId);
}
}
}
renameTab(sessionId) {
const tab = this.tabs.get(sessionId);
if (!tab) return;
const nameSpan = tab.querySelector('.tab-name');
const currentName = nameSpan.textContent;
const input = document.createElement('input');
input.type = 'text';
input.value = currentName;
input.className = 'tab-name-input';
input.style.width = '100%';
nameSpan.replaceWith(input);
input.focus();
input.select();
const saveNewName = () => {
const newName = input.value.trim() || currentName;
const newNameSpan = document.createElement('span');
newNameSpan.className = 'tab-name';
newNameSpan.textContent = newName;
input.replaceWith(newNameSpan);
// Update session data
const session = this.activeSessions.get(sessionId);
if (session) {
session.name = newName;
}
};
input.addEventListener('blur', saveNewName);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
saveNewName();
} else if (e.key === 'Escape') {
input.value = currentName;
saveNewName();
}
});
}
// Close all other tabs except the given session
closeOthers(sessionId) {
const ids = this.getOrderedTabIds();
ids.forEach(id => { if (id !== sessionId) this.closeSession(id); });
}
// Context menu for a session tab
openTabContextMenu(sessionId, clientX, clientY) {
// Remove existing menus
document.querySelectorAll('.pane-session-menu').forEach(m => m.remove());
const menu = document.createElement('div');
menu.className = 'pane-session-menu';
const addItem = (label, fn, disabled = false) => {
const el = document.createElement('div');
el.className = 'pane-session-item' + (disabled ? ' used' : '');
el.textContent = label;
if (!disabled) el.onclick = () => { try { fn(); } finally { menu.remove(); } };
return el;
};
menu.appendChild(addItem('Close Others', () => this.closeOthers(sessionId)));
document.body.appendChild(menu);
menu.style.top = `${clientY + 4}px`;
menu.style.left = `${clientX + 4}px`;
const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', close, true); } };
setTimeout(() => document.addEventListener('mousedown', close, true), 0);
}
createNewSession() {
// Set flag to indicate we're creating a new session
if (this.claudeInterface) {
this.claudeInterface.isCreatingNewSession = true;
// Show the folder browser to let user pick a folder for the new session
if (this.claudeInterface.showFolderBrowser) {
this.claudeInterface.showFolderBrowser();
}
} else {
// Fallback: show the folder browser modal directly
document.getElementById('folderBrowserModal').classList.add('active');
}
}
switchToNextTab() {
if (this.tabHistory.length > 1) {
const nextId = this.tabHistory.find((id) => id !== this.activeTabId && this.tabs.has(id));
if (nextId) {
this.switchToTab(nextId);
return;
}
}
const tabIds = this.getOrderedTabIds();
if (tabIds.length === 0) return;
const currentIndex = tabIds.indexOf(this.activeTabId);
const nextIndex = currentIndex >= 0 ? (currentIndex + 1) % tabIds.length : 0;
this.switchToTab(tabIds[nextIndex]);
}
switchToPreviousTab() {
const tabIds = this.getOrderedTabIds();
if (tabIds.length === 0) return;
const currentIndex = tabIds.indexOf(this.activeTabId);
const prevIndex = currentIndex >= 0 ? (currentIndex - 1 + tabIds.length) % tabIds.length : tabIds.length - 1;
this.switchToTab(tabIds[prevIndex]);
}
switchToTabByIndex(index) {
const tabIds = this.getOrderedTabIds();
if (index < tabIds.length) {
this.switchToTab(tabIds[index]);
}
}
updateHeaderInfo(sessionId) {
const session = this.activeSessions.get(sessionId);
if (session) {
const workingDirEl = document.getElementById('workingDir');
if (workingDirEl && session.workingDir) {
workingDirEl.textContent = session.workingDir;
}
}
}
updateTabStatus(sessionId, status) {
const tab = this.tabs.get(sessionId);
if (tab) {
const statusEl = tab.querySelector('.tab-status');
if (statusEl) {
// Get current session info
const session = this.activeSessions.get(sessionId);
const wasActive = session && session.status === 'active';
// Preserve unread class if it exists
const hasUnread = statusEl.classList.contains('unread');
statusEl.className = `tab-status ${status}`;
// When transitioning from active to idle for background tabs, mark as unread
if (wasActive && status === 'idle' && sessionId !== this.activeTabId) {
statusEl.classList.add('unread');
if (session) {
session.unreadOutput = true;
}
} else if (hasUnread) {
statusEl.classList.add('unread');
}
// Update visual indicator based on status
if (status === 'active') {
statusEl.classList.add('pulse');
} else {
statusEl.classList.remove('pulse');
}
}
const session = this.activeSessions.get(sessionId);
if (session) {
session.status = status;
session.lastActivity = Date.now();
// Clear error state if status is not error
if (status !== 'error') {
session.hasError = false;
}
}
}
}
markSessionActivity(sessionId, hasOutput = false, outputData = '') {
const session = this.activeSessions.get(sessionId);
if (!session) return;
const previousActivity = session.lastActivity || 0;
const wasActive = session.status === 'active';
session.lastActivity = Date.now();
// Update status to active if there's output
if (hasOutput) {
this.updateTabStatus(sessionId, 'active');
// Don't mark as unread immediately - wait for completion
// This prevents the blue indicator from showing while Claude is still working
// Clear any existing timeouts
clearTimeout(session.idleTimeout);
clearTimeout(session.workCompleteTimeout);
// Set a 90-second timeout to detect when Claude has likely finished working
session.workCompleteTimeout = setTimeout(() => {
const currentSession = this.activeSessions.get(sessionId);
if (currentSession && currentSession.status === 'active') {
// Claude has been idle for 90 seconds - likely finished working
this.updateTabStatus(sessionId, 'idle');
// Only notify and mark as unread if Claude was previously active
if (wasActive) {
const sessionName = currentSession.name || 'Session';
const duration = Date.now() - previousActivity;
// Mark as unread if this is a background tab (blue indicator)
if (sessionId !== this.activeTabId) {
currentSession.unreadOutput = true;
this.updateUnreadIndicator(sessionId, true);
// Send notification that Claude appears to have finished
this.sendNotification(
`${sessionName} — ${this.getAlias('claude')} appears finished`,
`No output for 90 seconds (worked for ${Math.round(duration / 1000)}s)`,
sessionId
);
}
}
}
}, 90000); // 90 seconds
// Keep the original 5-minute timeout for full idle state
session.idleTimeout = setTimeout(() => {
const currentSession = this.activeSessions.get(sessionId);
if (currentSession && currentSession.status === 'idle') {
// Already marked as idle by the 90-second timeout, no need to do anything
}
}, 300000); // 5 minutes
}
// Check for command completion patterns
if (hasOutput && outputData) {
this.checkForCommandCompletion(sessionId, outputData, previousActivity);
}
}
checkForCommandCompletion(sessionId, outputData, previousActivity) {
const session = this.activeSessions.get(sessionId);
if (!session) return;
// Pattern matching for common completion indicators
const completionPatterns = [
/build\s+successful/i,
/compilation\s+finished/i,
/tests?\s+passed/i,
/deployment\s+complete/i,
/npm\s+install.*completed/i,
/successfully\s+compiled/i,
/✓\s+All\s+tests\s+passed/i,
/Done\s+in\s+\d+\.\d+s/i
];
const hasCompletion = completionPatterns.some(pattern => pattern.test(outputData));
if (hasCompletion && sessionId !== this.activeTabId) {
const duration = Date.now() - previousActivity;
const sessionName = session.name || 'Session';
// Extract a meaningful message from the output
let message = 'Task completed successfully';
if (/build\s+successful/i.test(outputData)) {
message = 'Build completed successfully';
} else if (/tests?\s+passed/i.test(outputData)) {
message = 'All tests passed';
} else if (/deployment\s+complete/i.test(outputData)) {
message = 'Deployment completed';
}
// Mark tab as unread (blue indicator) for completed tasks
session.unreadOutput = true;
this.updateUnreadIndicator(sessionId, true);
this.sendNotification(
`${sessionName}`,
message,
sessionId
);
}
}
updateUnreadIndicator(sessionId, hasUnread) {
const tab = this.tabs.get(sessionId);
if (tab) {
const statusEl = tab.querySelector('.tab-status');
if (hasUnread) {
tab.classList.add('has-unread');
// Add unread class to status indicator instead of creating new element
if (statusEl) {
statusEl.classList.add('unread');
}
} else {
tab.classList.remove('has-unread');
// Remove unread class from status indicator
if (statusEl) {
statusEl.classList.remove('unread');
}
}
}
const session = this.activeSessions.get(sessionId);
if (session) {
session.unreadOutput = hasUnread;
}
}
markSessionError(sessionId, hasError = true) {
const session = this.activeSessions.get(sessionId);
if (session) {
session.hasError = hasError;
if (hasError) {
this.updateTabStatus(sessionId, 'error');
// Send notification for error in background session
const sessionName = session.name || 'Session';
this.sendNotification(
`Error in ${sessionName}`,
'A command has failed or the session encountered an error',
sessionId
);
}
}
}
getDragAfterElement(container, x) {
const draggableElements = [...container.querySelectorAll('.session-tab:not(.dragging)')];
return draggableElements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = x - box.left - box.width / 2;
if (offset < 0 && offset > closest.offset) {
return { offset: offset, element: child };
} else {
return closest;
}
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
}
// Export for use in app.js
window.SessionTabManager = SessionTabManager;