class WebMonitor {
constructor() {
this.ws = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
this.reconnectDelay = 1000;
this.pollIntervalId = null;
this.pollIntervalMs = 5000;
this.isMobile = window.matchMedia('(max-width: 767px)').matches;
this.previousConnectionStatus = null;
this.elements = {
connectionStatus: document.getElementById('connection-status'),
statusText: document.querySelector('.status-text'),
totalChanges: document.getElementById('total-changes'),
completedChanges: document.getElementById('completed-changes'),
inProgressChanges: document.getElementById('in-progress-changes'),
pendingChanges: document.getElementById('pending-changes'),
loading: document.getElementById('loading'),
changesList: document.getElementById('changes-list'),
emptyState: document.getElementById('empty-state'),
lastUpdated: document.getElementById('last-updated'),
toastContainer: document.getElementById('toast-container'),
ptrIndicator: document.getElementById('ptr-indicator'),
ptrText: document.querySelector('.ptr-text'),
overallProgressFill: document.getElementById('overall-progress-fill'),
overallProgressTasks: document.getElementById('overall-progress-tasks'),
overallProgressPercent: document.getElementById('overall-progress-percent'),
btnRun: document.getElementById('btn-run'),
btnStop: document.getElementById('btn-stop'),
btnCancelStop: document.getElementById('btn-cancel-stop'),
btnForceStop: document.getElementById('btn-force-stop'),
btnRetry: document.getElementById('btn-retry'),
};
this.appMode = 'select';
this.touchState = {
startX: 0,
startY: 0,
currentX: 0,
currentY: 0,
isDragging: false,
element: null,
};
this.ptrState = {
startY: 0,
isPulling: false,
isRefreshing: false,
threshold: 80,
};
this.setupMediaQueryListener();
this.setupTouchHandlers();
this.setupPullToRefresh();
this.setupControlButtons();
this.fetchState();
this.connect();
}
setupControlButtons() {
this.elements.btnRun.addEventListener('click', () => this.handleControlCommand('start'));
this.elements.btnStop.addEventListener('click', () => this.handleControlCommand('stop'));
this.elements.btnCancelStop.addEventListener('click', () => this.handleControlCommand('cancel-stop'));
this.elements.btnForceStop.addEventListener('click', () => this.handleControlCommand('force-stop'));
this.elements.btnRetry.addEventListener('click', () => this.handleControlCommand('retry'));
}
async handleControlCommand(command) {
try {
const response = await fetch(`/api/control/${command}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
const data = await response.json();
if (response.ok) {
this.showToast(data.message, 'success');
} else {
this.showToast(data.error || 'Operation failed', 'error');
}
} catch (error) {
console.error(`Control command ${command} failed:`, error);
this.showToast(`Failed to ${command}: ${error.message}`, 'error');
}
}
updateControlButtons(appMode) {
this.appMode = appMode || 'select';
this.elements.btnRun.disabled = true;
this.elements.btnStop.disabled = true;
this.elements.btnCancelStop.disabled = true;
this.elements.btnForceStop.disabled = true;
this.elements.btnRetry.disabled = true;
switch (this.appMode) {
case 'select':
this.elements.btnRun.disabled = false;
this.elements.btnRun.querySelector('.btn-text').textContent = 'Run';
break;
case 'running':
this.elements.btnStop.disabled = false;
break;
case 'stopping':
this.elements.btnCancelStop.disabled = false;
this.elements.btnForceStop.disabled = false;
break;
case 'stopped':
this.elements.btnRun.disabled = false;
this.elements.btnRun.querySelector('.btn-text').textContent = 'Resume';
break;
case 'error':
this.elements.btnRetry.disabled = false;
break;
}
}
setupMediaQueryListener() {
const mq = window.matchMedia('(max-width: 767px)');
mq.addEventListener('change', (e) => {
this.isMobile = e.matches;
});
}
setupTouchHandlers() {
this.elements.changesList.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: true });
this.elements.changesList.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: true });
this.elements.changesList.addEventListener('touchend', this.handleTouchEnd.bind(this));
this.elements.changesList.addEventListener('click', this.handleCardClick.bind(this));
}
setupPullToRefresh() {
document.addEventListener('touchstart', this.handlePtrStart.bind(this), { passive: true });
document.addEventListener('touchmove', this.handlePtrMove.bind(this), { passive: false });
document.addEventListener('touchend', this.handlePtrEnd.bind(this));
}
handlePtrStart(e) {
if (!this.isMobile || this.ptrState.isRefreshing) return;
if (window.scrollY > 0) return;
this.ptrState.startY = e.touches[0].clientY;
this.ptrState.isPulling = true;
}
handlePtrMove(e) {
if (!this.ptrState.isPulling || this.ptrState.isRefreshing) return;
const currentY = e.touches[0].clientY;
const pullDistance = currentY - this.ptrState.startY;
if (pullDistance > 0 && window.scrollY === 0) {
e.preventDefault();
const indicator = this.elements.ptrIndicator;
const progress = Math.min(pullDistance / this.ptrState.threshold, 1);
if (pullDistance > 10) {
indicator.classList.add('visible');
}
if (pullDistance >= this.ptrState.threshold) {
this.elements.ptrText.textContent = 'Release to refresh';
} else {
this.elements.ptrText.textContent = 'Pull to refresh';
}
const icon = indicator.querySelector('.ptr-icon');
icon.style.transform = `rotate(${progress * 180}deg)`;
}
}
handlePtrEnd() {
if (!this.ptrState.isPulling || this.ptrState.isRefreshing) return;
const indicator = this.elements.ptrIndicator;
const currentY = this.ptrState.startY;
this.ptrState.isPulling = false;
const touchEndY = event.changedTouches?.[0]?.clientY || currentY;
const pullDistance = touchEndY - this.ptrState.startY;
if (pullDistance >= this.ptrState.threshold) {
this.triggerRefresh();
} else {
indicator.classList.remove('visible');
this.elements.ptrText.textContent = 'Pull to refresh';
const icon = indicator.querySelector('.ptr-icon');
icon.style.transform = '';
}
}
triggerRefresh() {
this.ptrState.isRefreshing = true;
const indicator = this.elements.ptrIndicator;
indicator.classList.add('refreshing');
this.elements.ptrText.textContent = 'Refreshing...';
const icon = indicator.querySelector('.ptr-icon');
icon.style.transform = '';
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.fetchState();
this.showToast('Refreshing data...', 'info');
} else {
this.showToast('Not connected. Reconnecting...', 'warning');
this.connect();
}
setTimeout(() => {
indicator.classList.remove('visible', 'refreshing');
this.elements.ptrText.textContent = 'Pull to refresh';
this.ptrState.isRefreshing = false;
}, 1500);
}
handleTouchStart(e) {
const card = e.target.closest('.change-card');
if (!card) return;
const touch = e.touches[0];
this.touchState = {
startX: touch.clientX,
startY: touch.clientY,
currentX: touch.clientX,
currentY: touch.clientY,
isDragging: true,
element: card,
};
}
handleTouchMove(e) {
if (!this.touchState.isDragging) return;
const touch = e.touches[0];
this.touchState.currentX = touch.clientX;
this.touchState.currentY = touch.clientY;
const deltaX = this.touchState.currentX - this.touchState.startX;
const deltaY = this.touchState.currentY - this.touchState.startY;
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 10) {
this.touchState.element.classList.add('swiping');
}
}
handleTouchEnd() {
if (!this.touchState.isDragging) return;
const deltaX = this.touchState.currentX - this.touchState.startX;
const deltaY = this.touchState.currentY - this.touchState.startY;
const card = this.touchState.element;
card.classList.remove('swiping');
if (Math.abs(deltaX) > 50 && Math.abs(deltaX) > Math.abs(deltaY)) {
if (deltaX > 0) {
card.classList.add('expanded');
} else {
card.classList.remove('expanded');
}
}
this.touchState = {
startX: 0,
startY: 0,
currentX: 0,
currentY: 0,
isDragging: false,
element: null,
};
}
handleCardClick(e) {
const card = e.target.closest('.change-card');
if (!card || !this.isMobile) return;
if (e.target.closest('a, button')) return;
card.classList.toggle('expanded');
}
async fetchState() {
try {
const response = await fetch('/api/state', { cache: 'no-store' });
if (!response.ok) {
throw new Error(`Failed to fetch state: ${response.status}`);
}
const state = await response.json();
this.renderFullState(state);
} catch (error) {
console.error('Failed to fetch state:', error);
this.showToast('Failed to refresh state', 'error');
}
}
startPolling() {
if (this.pollIntervalId) return;
this.pollIntervalId = setInterval(() => {
this.fetchState();
}, this.pollIntervalMs);
this.fetchState();
}
stopPolling() {
if (!this.pollIntervalId) return;
clearInterval(this.pollIntervalId);
this.pollIntervalId = null;
}
connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
this.updateConnectionStatus('connecting');
try {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('WebSocket connected');
this.reconnectAttempts = 0;
this.updateConnectionStatus('connected');
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.handleMessage(data);
} catch (e) {
console.error('Failed to parse message:', e);
}
};
this.ws.onclose = () => {
console.log('WebSocket disconnected');
this.updateConnectionStatus('disconnected');
this.scheduleReconnect();
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
this.updateConnectionStatus('disconnected');
};
} catch (e) {
console.error('Failed to create WebSocket:', e);
this.updateConnectionStatus('disconnected');
this.scheduleReconnect();
}
}
scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.log('Max reconnect attempts reached');
this.elements.statusText.textContent = 'Connection failed';
this.showToast('Connection failed. Please refresh the page.', 'error');
return;
}
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1);
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
this.elements.statusText.textContent = `Reconnecting (${this.reconnectAttempts})...`;
setTimeout(() => this.connect(), delay);
}
updateConnectionStatus(status) {
const el = this.elements.connectionStatus;
el.classList.remove('connected', 'disconnected');
if (this.previousConnectionStatus !== null && this.previousConnectionStatus !== status) {
if (status === 'connected') {
this.showToast('Connected to server', 'success');
} else if (status === 'disconnected') {
this.showToast('Disconnected from server', 'warning');
}
}
this.previousConnectionStatus = status;
switch (status) {
case 'connected':
el.classList.add('connected');
this.elements.statusText.textContent = 'Connected';
this.stopPolling();
break;
case 'disconnected':
el.classList.add('disconnected');
this.elements.statusText.textContent = 'Disconnected';
this.startPolling();
break;
default:
this.elements.statusText.textContent = 'Connecting...';
}
}
showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
toast.setAttribute('role', 'alert');
this.elements.toastContainer.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease-out forwards';
setTimeout(() => toast.remove(), 300);
}, 4000);
}
handleMessage(data) {
switch (data.type) {
case 'initial_state':
this.renderFullState(data.state);
break;
case 'state_update':
this.renderChanges(data.changes);
this.updateTimestamp(data.timestamp);
if (data.app_mode) {
this.updateControlButtons(data.app_mode);
}
if (data.logs && data.logs.length > 0) {
data.logs.forEach(log => {
if (log.level === 'error') {
this.showToast(log.message, 'error');
} else if (log.level === 'warning') {
this.showToast(log.message, 'warning');
}
});
}
break;
default:
console.log('Unknown message type:', data.type);
}
}
renderFullState(state) {
this.elements.loading.style.display = 'none';
this.elements.totalChanges.textContent = state.total_changes;
this.elements.completedChanges.textContent = state.completed_changes;
this.elements.inProgressChanges.textContent = state.in_progress_changes;
this.elements.pendingChanges.textContent = state.pending_changes;
this.updateOverallProgress(state.changes);
this.renderChanges(state.changes);
this.updateTimestamp(state.last_updated);
if (state.app_mode) {
this.updateControlButtons(state.app_mode);
}
}
renderChanges(changes) {
if (!changes || changes.length === 0) {
this.elements.changesList.innerHTML = '';
this.elements.emptyState.style.display = 'block';
return;
}
this.elements.emptyState.style.display = 'none';
const stats = this.calculateStats(changes);
this.elements.totalChanges.textContent = stats.total;
this.elements.completedChanges.textContent = stats.completed;
this.elements.inProgressChanges.textContent = stats.inProgress;
this.elements.pendingChanges.textContent = stats.pending;
this.updateOverallProgress(changes);
this.elements.changesList.innerHTML = changes.map(change =>
this.renderChangeCard(change)
).join('');
}
updateOverallProgress(changes) {
if (!changes || changes.length === 0) {
this.elements.overallProgressFill.style.width = '0%';
this.elements.overallProgressTasks.textContent = '0 / 0 tasks';
this.elements.overallProgressPercent.textContent = '0%';
return;
}
const totalCompleted = changes.reduce((sum, c) => sum + c.completed_tasks, 0);
const totalTasks = changes.reduce((sum, c) => sum + c.total_tasks, 0);
const overallPercent = totalTasks > 0 ? (totalCompleted / totalTasks) * 100 : 0;
this.elements.overallProgressFill.style.width = `${overallPercent.toFixed(1)}%`;
this.elements.overallProgressTasks.textContent = `${totalCompleted} / ${totalTasks} tasks`;
this.elements.overallProgressPercent.textContent = `${overallPercent.toFixed(1)}%`;
const progressBar = this.elements.overallProgressFill.parentElement;
progressBar.setAttribute('aria-valuenow', overallPercent.toFixed(1));
}
calculateStats(changes) {
const inProgressStates = ['applying', 'accepting', 'archiving', 'resolving'];
const completedStates = ['archived', 'merged'];
return {
total: changes.length,
completed: changes.filter(c => completedStates.includes(c.queue_status)).length,
inProgress: changes.filter(c => inProgressStates.includes(c.queue_status)).length,
pending: changes.filter(c => c.queue_status === 'queued').length,
};
}
renderChangeCard(change) {
const progressPercent = change.progress_percent.toFixed(1);
const isComplete = ['archived', 'merged'].includes(change.queue_status);
const displayStatus = change.queue_status || 'not queued';
const statusClass = displayStatus.replace(' ', '-');
const statusIcons = {
'not queued': '○',
'queued': '⏳',
'blocked': '🚫',
'applying': '⚙️',
'accepting': '✓',
'archiving': '📦',
'archived': '📥',
'merged': '🔀',
'merge wait': '⏸️',
'resolving': '🔧',
'error': '❌',
};
const statusIcon = statusIcons[displayStatus] || '•';
const statusDisplay = change.iteration_number
? `${displayStatus}:${change.iteration_number}`
: displayStatus;
const dependenciesHtml = change.dependencies && change.dependencies.length > 0
? `<div class="change-dependencies">
<div class="dependencies-label">Dependencies:</div>
<div class="dependencies-list">
${change.dependencies.map(dep =>
`<span class="dependency-tag">${this.escapeHtml(dep)}</span>`
).join('')}
</div>
</div>`
: '';
const expandHintHtml = this.isMobile
? `<div class="expand-hint">
<span class="expand-hint-icon" aria-hidden="true">▼</span>
<span>Tap or swipe to expand</span>
</div>`
: '';
return `
<article class="change-card" data-change-id="${this.escapeHtml(change.id)}" role="listitem" tabindex="0">
<div class="change-header">
<span class="change-id">${this.escapeHtml(change.id)}</span>
<div class="change-status-row">
<span class="badge badge-status ${statusClass}">
<span class="status-icon" aria-hidden="true">${statusIcon}</span>
${statusDisplay}
</span>
</div>
</div>
<div class="progress-container">
<div class="progress-bar" role="progressbar" aria-valuenow="${progressPercent}" aria-valuemin="0" aria-valuemax="100">
<div class="progress-fill ${isComplete ? 'complete' : ''}"
style="width: ${progressPercent}%"></div>
</div>
<div class="progress-text">
<span>${change.completed_tasks} / ${change.total_tasks} tasks</span>
<span>${progressPercent}%</span>
</div>
</div>
<div class="change-details">
${dependenciesHtml}
</div>
${expandHintHtml}
</article>
`;
}
updateTimestamp(timestamp) {
if (!timestamp) return;
const date = new Date(timestamp);
const formatted = date.toLocaleString();
this.elements.lastUpdated.textContent = `Last updated: ${formatted}`;
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
function throttle(func, limit) {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
function debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
function initTabs() {
const tabButtons = document.querySelectorAll('.tab-btn');
const changesContainer = document.getElementById('changes-container');
const worktreesContainer = document.getElementById('worktrees-container');
tabButtons.forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab;
tabButtons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
if (tab === 'changes') {
changesContainer.style.display = 'block';
worktreesContainer.style.display = 'none';
} else if (tab === 'worktrees') {
changesContainer.style.display = 'none';
worktreesContainer.style.display = 'block';
fetchWorktrees();
}
});
});
}
async function fetchWorktrees() {
try {
const response = await fetch('/api/worktrees');
if (!response.ok) throw new Error('Failed to fetch worktrees');
const worktrees = await response.json();
renderWorktrees(worktrees);
} catch (error) {
console.error('Error fetching worktrees:', error);
showToast('Failed to fetch worktrees', 'error');
}
}
function renderWorktrees(worktrees) {
const container = document.getElementById('worktrees-list');
const emptyState = document.getElementById('worktrees-empty-state');
if (!worktrees || worktrees.length === 0) {
container.innerHTML = '';
emptyState.style.display = 'block';
return;
}
emptyState.style.display = 'none';
container.innerHTML = worktrees.map(wt => {
const badges = [];
if (wt.is_main) badges.push('<span class="worktree-badge main">Main</span>');
if (wt.has_commits_ahead) badges.push('<span class="worktree-badge ahead">Ahead</span>');
if (wt.merge_conflict) badges.push('<span class="worktree-badge conflict">Conflict</span>');
const canDelete = !wt.is_main && !wt.has_commits_ahead;
const canMerge = !wt.is_main && wt.has_commits_ahead && !wt.merge_conflict;
return `
<div class="worktree-card">
<div class="worktree-header">
<div class="worktree-branch">${escapeHtml(wt.branch)}</div>
<div>${badges.join(' ')}</div>
</div>
<div class="worktree-info">
<div>HEAD: ${escapeHtml(wt.head)}</div>
<div>Path: ${escapeHtml(wt.path)}</div>
</div>
<div class="worktree-actions">
<button class="worktree-btn merge"
data-branch="${escapeHtml(wt.branch)}"
${canMerge ? '' : 'disabled'}>
Merge
</button>
<button class="worktree-btn delete"
data-branch="${escapeHtml(wt.branch)}"
${canDelete ? '' : 'disabled'}>
Delete
</button>
</div>
</div>
`;
}).join('');
container.querySelectorAll('.worktree-btn.merge').forEach(btn => {
btn.addEventListener('click', () => mergeWorktree(btn.dataset.branch));
});
container.querySelectorAll('.worktree-btn.delete').forEach(btn => {
btn.addEventListener('click', () => confirmDeleteWorktree(btn.dataset.branch));
});
}
async function mergeWorktree(branch) {
try {
const response = await fetch('/api/worktrees/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ branch_name: branch })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Merge failed');
}
showToast(`Merged ${branch} successfully`, 'success');
fetchWorktrees();
} catch (error) {
console.error('Merge error:', error);
showToast(error.message, 'error');
}
}
function confirmDeleteWorktree(branch) {
const overlay = document.createElement('div');
overlay.className = 'dialog-overlay';
overlay.innerHTML = `
<div class="dialog">
<div class="dialog-title">Delete Worktree</div>
<div class="dialog-message">
Are you sure you want to delete the worktree for branch "${escapeHtml(branch)}"?
</div>
<div class="dialog-actions">
<button class="dialog-btn cancel">Cancel</button>
<button class="dialog-btn confirm">Delete</button>
</div>
</div>
`;
document.body.appendChild(overlay);
overlay.querySelector('.cancel').addEventListener('click', () => {
document.body.removeChild(overlay);
});
overlay.querySelector('.confirm').addEventListener('click', async () => {
document.body.removeChild(overlay);
await deleteWorktree(branch);
});
}
async function deleteWorktree(branch) {
try {
const response = await fetch('/api/worktrees/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ branch_name: branch })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Delete failed');
}
showToast(`Deleted ${branch} successfully`, 'success');
fetchWorktrees();
} catch (error) {
console.error('Delete error:', error);
showToast(error.message, 'error');
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
document.addEventListener('DOMContentLoaded', () => {
window.monitor = new WebMonitor();
initTabs();
});