diff --git a/.nvmrc b/.nvmrc index 25649a2b..a45fd52c 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24.9.0 +24 diff --git a/CODEBASE_DOCUMENTATION.md b/CODEBASE_DOCUMENTATION.md index eadc9144..420974ad 100644 --- a/CODEBASE_DOCUMENTATION.md +++ b/CODEBASE_DOCUMENTATION.md @@ -56,6 +56,7 @@ server/notificationService.js - System notification manager server/claudeVersionChecker.js - Claude Code version detection server/tokenCounter.js - Token usage tracking (if applicable) server/userSettingsService.js - User preferences and settings management +├─ Desktop onboarding state: persists Windows/Tauri dependency-onboarding completion in `global.ui.onboarding.desktopDependencySetup` server/sessionRecoveryService.js - Session recovery state persistence (CWD, agents, conversations) ├─ Recovery filtering: stale/non-configured session entries are pruned when requested by workspace-scoped APIs ├─ Agent clearing: `clearAgent()` resets stale `lastAgent` markers when a Claude/Codex terminal falls back to plain shell @@ -538,5 +539,16 @@ LOGGING: Winston-based structured logging with rotation 9. **Mixed-repo workspaces**: Terminal naming must avoid conflicts between repos 10. **Template validation**: Always validate workspace templates against schemas + +## First-Run Dependency Onboarding (Windows) + +``` +server/setupActionService.js - Defines setup actions and launches PowerShell installers +server/index.js - Routes: GET /api/setup-actions, POST /api/setup-actions/run +client/app.js - Guided dependency onboarding steps + diagnostics integration +client/index.html - Dependency onboarding modal markup + launch button +client/styles.css - Dependency onboarding progress/step styling +``` + --- 🚨 **END OF FILE - ENSURE YOU READ EVERYTHING ABOVE** 🚨 diff --git a/client/app.js b/client/app.js index c9936ba9..a0253115 100644 --- a/client/app.js +++ b/client/app.js @@ -116,9 +116,156 @@ class ClaudeOrchestrator { // navigate Prev/Next without reopening the Queue overlay. this.reviewConsoleNav = null; // { source, createdAtMs, items: [{ id, kind, title, url, ... }], index } + this.currentTabId = null; + this.desktopLaunchTrace = { + id: this.createDesktopLaunchTraceId(), + enabled: this.shouldEnableDesktopLaunchTrace(), + seq: 0, + startedAt: new Date().toISOString() + }; + this.init(); } + shouldEnableDesktopLaunchTrace() { + return this.isDesktopWindowsRuntime(); + } + + createDesktopLaunchTraceId() { + return `launch-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + } + + isWindowsHostEnvironment() { + try { + const platform = String(navigator?.platform || '').toLowerCase(); + const userAgent = String(navigator?.userAgent || '').toLowerCase(); + return platform.includes('win') || userAgent.includes('windows'); + } catch { + return false; + } + } + + hasDesktopLaunchToken() { + try { + const params = new URLSearchParams(window?.location?.search || ''); + return !!String(params.get('token') || '').trim(); + } catch { + return false; + } + } + + isDesktopWindowsRuntime() { + return this.isWindowsHostEnvironment() && (this.hasDesktopLaunchToken() || !!window.__TAURI__); + } + + sanitizeDesktopTraceValue(value, depth = 0, seen = new WeakSet()) { + if (value == null) return value; + if (typeof value === 'boolean' || typeof value === 'number') return value; + if (typeof value === 'string') { + return value.length > 500 ? `${value.slice(0, 484)}...[truncated]` : value; + } + if (typeof value === 'function') return `[function:${value.name || 'anonymous'}]`; + if (depth >= 4) return '[depth-limit]'; + if (Array.isArray(value)) { + const items = value.slice(0, 15).map((item) => this.sanitizeDesktopTraceValue(item, depth + 1, seen)); + if (value.length > 15) items.push(`[+${value.length - 15} more]`); + return items; + } + if (typeof value === 'object') { + if (seen.has(value)) return '[circular]'; + seen.add(value); + const out = {}; + const keys = Object.keys(value).slice(0, 20); + keys.forEach((key) => { + out[key] = this.sanitizeDesktopTraceValue(value[key], depth + 1, seen); + }); + if (Object.keys(value).length > keys.length) { + out.__truncatedKeys = Object.keys(value).length - keys.length; + } + seen.delete(value); + return out; + } + return String(value); + } + + getDesktopLaunchTraceContext() { + return { + appStartedAt: this.desktopLaunchTrace?.startedAt || null, + currentWorkspaceId: this.currentWorkspace?.id || null, + currentWorkspaceName: this.currentWorkspace?.name || null, + currentTabId: this.currentTabId || null, + isDashboardMode: !!this.isDashboardMode, + socketConnected: !!this.socket?.connected, + visibilityState: document?.visibilityState || null, + location: window?.location?.pathname || null + }; + } + + withLaunchTraceHeaders(init = {}) { + if (!this.desktopLaunchTrace?.enabled) { + return init || {}; + } + return { + ...(init || {}), + headers: { + ...(init?.headers || {}), + 'x-launch-trace-id': this.desktopLaunchTrace.id + } + }; + } + + async traceDesktopLaunch(event, details = {}) { + if (!this.desktopLaunchTrace?.enabled) return false; + + const safeDetails = this.sanitizeDesktopTraceValue(details); + const payload = { + traceId: this.desktopLaunchTrace.id, + seq: (this.desktopLaunchTrace.seq += 1), + event: String(event || '').trim() || 'client.event', + source: 'desktop-app', + details: (safeDetails && typeof safeDetails === 'object' && !Array.isArray(safeDetails)) + ? { ...this.getDesktopLaunchTraceContext(), ...safeDetails } + : { ...this.getDesktopLaunchTraceContext(), value: safeDetails } + }; + + try { + await fetch('/api/desktop-launch-trace', this.withLaunchTraceHeaders({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + keepalive: true + })); + return true; + } catch { + return false; + } + } + + emitWorkspaceSwitch(workspaceId, source = 'unknown', extra = {}) { + const id = String(workspaceId || '').trim(); + if (!id || !this.socket) return; + if (this.isDesktopWindowsRuntime() && this.userSettings?.global?.ui?.onboarding?.desktopDependencySetup?.completed !== true) { + void this.traceDesktopLaunch('client.workspace-switch.blocked-onboarding', { + source, + workspaceId: id + }); + this.showToast('Finish Orchestrator Setup before opening a workspace.', 'warning'); + this.openDependencySetupWizard?.({ resetStep: false, source: 'workspace-switch-blocked' }); + return; + } + void this.traceDesktopLaunch('client.workspace-switch.emitted', { + source, + workspaceId: id, + extra + }); + this.socket.emit('switch-workspace', { + workspaceId: id, + source, + traceId: this.desktopLaunchTrace?.enabled ? this.desktopLaunchTrace.id : null, + ...extra + }); + } + isMobileLayout() { try { return window.matchMedia && window.matchMedia('(max-width: 768px)').matches; @@ -845,9 +992,16 @@ class ClaudeOrchestrator { if (!expiresAt || expiresAt <= now) this.pendingWorktreeReservations.delete(key); } } - + async init() { try { + void this.traceDesktopLaunch('client.init.start', { + tauri: !!window.__TAURI__, + desktopRuntime: this.isDesktopWindowsRuntime(), + userAgent: String(navigator?.userAgent || ''), + platform: String(navigator?.platform || '') + }); + // Initialize managers this.terminalManager = new TerminalManager(this); this.terminalManager.autosuggestEnabled = this.settings.autoSuggestions !== false; @@ -984,23 +1138,25 @@ class ClaudeOrchestrator { if (this.settings.notifications) { this.notificationManager.requestPermission(); } - + // Set up UI this.setupEventListeners(); this.applyTheme(); this.syncSettingsUI(); this.applySimpleModeConfig(); this.installAuthFetchShim(); - + // Connect to server await this.connectToServer(); await this.ensureProjectTypeTaxonomy(); // Hook panels that depend on socket events this.activityFeedPanel?.onSocketConnected?.(this.socket); - + // Load user settings from server await this.loadUserSettings(); + this.syncDependencySetupWizardPreferences?.(); + void this.bootstrapDependencySetupWizard?.(); this.applySidebarDesktopCollapsedFromPrefs(); this.refreshLicenseStatus?.().catch(() => {}); this.syncTerminalFiltersFromUserSettings(); @@ -1016,22 +1172,25 @@ class ClaudeOrchestrator { // WIP / Queue banner (process status) this.startProcessStatusBanner(); - + // Check for updates on startup this.checkForSettingsUpdates(); - + // Hide loading message if it exists const loadingMessage = document.getElementById('loading-message'); if (loadingMessage) { loadingMessage.classList.add('hidden'); } - + } catch (error) { console.error('Failed to initialize:', error); + void this.traceDesktopLaunch('client.init.failed', { + error: String(error?.message || error) + }); this.showError('Failed to initialize application'); } } - + async connectToServer() { return new Promise((resolve, reject) => { console.log('Attempting to connect to server...'); @@ -1044,32 +1203,47 @@ class ClaudeOrchestrator { const serverUrl = window.location.origin; this.socket = io(serverUrl, socketOptions); console.log(`Socket connecting to ${serverUrl}...`); - + // Connection events this.socket.on('connect', () => { console.log('Connected to server'); + void this.traceDesktopLaunch('client.socket.connected', { + serverUrl, + socketId: this.socket?.id || null + }); this.updateConnectionStatus(true); resolve(); }); - + this.socket.on('connect_error', (error) => { console.error('Connection error:', error); + void this.traceDesktopLaunch('client.socket.connect-error', { + serverUrl, + error: String(error?.message || error) + }); this.updateConnectionStatus(false); - + if (error.message === 'Authentication failed') { this.showError('Authentication failed. Please check your token.'); } reject(error); }); - + this.socket.on('disconnect', () => { console.log('Disconnected from server'); + void this.traceDesktopLaunch('client.socket.disconnected', { + socketId: this.socket?.id || null + }); this.updateConnectionStatus(false); }); - + // Session events this.socket.on('sessions', async (sessionStates) => { console.log('Received sessions event:', sessionStates); + void this.traceDesktopLaunch('client.sessions.received', { + sessionCount: Object.keys(sessionStates || {}).length, + sessionIds: Object.keys(sessionStates || {}) + }); // Pre-fetch worktree configs if we have an active workspace if (this.currentWorkspace) { @@ -1085,7 +1259,7 @@ class ClaudeOrchestrator { this.worktreeTags.set(worktreePath, tag || {}); this.buildSidebar(); }); - + this.socket.on('terminal-output', ({ sessionId, data }) => { this.terminalManager.handleOutput(sessionId, data); @@ -1133,7 +1307,7 @@ class ClaudeOrchestrator { this.sessionActivity.set(sessionId, 'active'); } }); - + this.socket.on('autosuggest-response', ({ sessionId, suggestion, prefix }) => { this.terminalManager.handleAutosuggestResponse(sessionId, suggestion, prefix); }); @@ -1142,15 +1316,15 @@ class ClaudeOrchestrator { this.updateSessionStatus(sessionId, status); this.maybeAutoSendPrompt(sessionId, status); }); - + this.socket.on('branch-update', ({ sessionId, branch, remoteUrl, defaultBranch, existingPR }) => { this.updateSessionBranch(sessionId, branch, remoteUrl, defaultBranch, existingPR); }); - + this.socket.on('notification-trigger', (notification) => { this.notificationManager.handleNotification(notification); }); - + this.socket.on('session-exited', ({ sessionId, exitCode }) => { this.handleSessionExit(sessionId, exitCode); }); @@ -1343,7 +1517,7 @@ class ClaudeOrchestrator { // Hide + persist dismissal so it doesn't resurrect on refresh/worktree-add this.hideStartupUI(sessionId); this.scheduleAutoPromptFallback(sessionId, 'claude'); - + // Enable the start button now that Claude has started const startBtn = document.getElementById(`claude-start-btn-${sessionId}`); if (startBtn) { @@ -1361,19 +1535,30 @@ class ClaudeOrchestrator { this.socket.on('claude-update-required', (updateInfo) => { this.showClaudeUpdateRequired(updateInfo); }); - + this.socket.on('user-settings-updated', (settings) => { console.log('User settings updated:', settings); + void this.traceDesktopLaunch('client.user-settings.updated', { + onboardingCompleted: settings?.global?.ui?.onboarding?.desktopDependencySetup?.completed === true, + onboardingCompletedAt: settings?.global?.ui?.onboarding?.desktopDependencySetup?.completedAt || null + }); this.userSettings = settings; this.syncUserSettingsUI(); this.applyThemeFromUserSettings(); this.applySimpleModeConfig(); this.maybeAutoOpenSimpleMode(); + this.syncDependencySetupWizardPreferences?.(); }); // Workspace events this.socket.on('workspace-info', async ({ active, available, config, workspaceTypes, frameworks, cascadedConfigs }) => { console.log('Received workspace info:', { active, available, config, workspaceTypes, frameworks, cascadedConfigs }); + void this.traceDesktopLaunch('client.workspace-info.received', { + activeWorkspaceId: active?.id || null, + activeWorkspaceName: active?.name || null, + availableWorkspaceCount: Array.isArray(available) ? available.length : 0, + startupDashboard: config?.ui?.startupDashboard === true + }); this.currentWorkspace = active; this.availableWorkspaces = available; this.orchestratorConfig = config; @@ -1449,6 +1634,11 @@ class ClaudeOrchestrator { this.socket.on('workspace-changed', async ({ workspace, sessions }) => { console.log('Workspace changed:', workspace.name); + void this.traceDesktopLaunch('client.workspace-changed.received', { + workspaceId: workspace?.id || null, + workspaceName: workspace?.name || null, + sessionCount: Object.keys(sessions || {}).length + }); // If tab manager is enabled, create a new tab for this workspace if (this.tabManager) { @@ -1589,7 +1779,7 @@ class ClaudeOrchestrator { this.socket.on('git-updated', (result) => { console.log('Git updated:', result); this.showTemporaryMessage(`Repository updated successfully! ${result.wasUpToDate ? 'Already up to date.' : 'Changes pulled.'}`, 'success'); - + // Refresh the page after successful update if (!result.wasUpToDate) { setTimeout(() => { @@ -1600,45 +1790,45 @@ class ClaudeOrchestrator { }, 3000); } }); - + // Build production events this.socket.on('build-started', ({ sessionId, worktreeNum }) => { console.log(`Build started for worktree ${worktreeNum}`); }); - + this.socket.on('build-completed', ({ sessionId, worktreeNum, zipPath }) => { console.log(`Build completed for worktree ${worktreeNum}: ${zipPath}`); - + // Restore the build button (use work{num} pattern to find buttons) this.restoreBuildButton(`work${worktreeNum}`); - + // Request to reveal the file in explorer this.socket.emit('reveal-in-explorer', { path: zipPath }); }); - + this.socket.on('build-failed', ({ sessionId, worktreeNum, error }) => { console.error(`Build failed for worktree ${worktreeNum}:`, error); this.showError(`❌ Build failed for Worktree ${worktreeNum}: ${error}`); - + // Restore the build button (use work{num} pattern to find buttons) this.restoreBuildButton(`work${worktreeNum}`); }); - + // Periodic heartbeat to keep sessions alive while UI is open this.startHeartbeats(); - + this.socket.on('server-started', ({ sessionId, port }) => { console.log(`[SERVER-STARTED EVENT] Session: ${sessionId}, Port: ${port}`); this.serverPorts.set(sessionId, port); console.log(`Server ${sessionId} started on port ${port}`); console.log('Current serverPorts:', Array.from(this.serverPorts.entries())); - + // Only open localhost automatically - Hytopia needs manual click due to popup blockers setTimeout(() => { const localhostUrl = `https://localhost:${port}`; console.log(`Opening localhost for initialization: ${localhostUrl}`); window.open(localhostUrl, '_blank'); - + // Show notification that server is ready if (this.settings.notifications) { this.showNotification('Server Ready', `Server ${sessionId.replace('-server', '')} is running on port ${port}. Click 🎮 to play!`); @@ -1657,14 +1847,14 @@ class ClaudeOrchestrator { reject(new Error('Connection timeout')); } }, 10000); - + // Clear timeout on successful connection this.socket.on('connect', () => { clearTimeout(timeoutId); }); }); } - + startHeartbeats() { if (this._heartbeatInterval) { clearInterval(this._heartbeatInterval); @@ -1676,7 +1866,7 @@ class ClaudeOrchestrator { } }, 30000); } - + setupEventListeners() { // Check if elements exist before adding listeners const elements = { @@ -1739,7 +1929,7 @@ class ClaudeOrchestrator { 'start-claude', 'cancel-claude-startup' ]); - + // Check all elements exist for (const id in elements) { elements[id] = document.getElementById(id); @@ -1747,7 +1937,7 @@ class ClaudeOrchestrator { console.warn(`Element not found: ${id}`); } } - + // Sidebar worktree clicks - use toggle instead of show if (elements['worktree-list']) { elements['worktree-list'].addEventListener('click', (e) => { @@ -1856,7 +2046,7 @@ class ClaudeOrchestrator { if (!document.body.classList.contains('sidebar-open')) return; this.closeSidebar(); }); - + // View buttons const dashboardBtn = document.getElementById('dashboard-btn'); if (dashboardBtn) { @@ -1878,26 +2068,26 @@ class ClaudeOrchestrator { this.setViewMode('all'); if (this.isMobileLayout()) this.closeSidebar(); }); - + document.getElementById('view-claude-only').addEventListener('click', () => { this.setViewMode('claude'); if (this.isMobileLayout()) this.closeSidebar(); }); - + document.getElementById('view-servers-only').addEventListener('click', () => { this.setViewMode('server'); if (this.isMobileLayout()) this.closeSidebar(); }); - + // Presets document.getElementById('view-presets').addEventListener('click', () => { document.getElementById('presets-modal').classList.remove('hidden'); }); - + document.getElementById('close-presets').addEventListener('click', () => { document.getElementById('presets-modal').classList.add('hidden'); }); - + // Preset buttons document.querySelectorAll('.preset-btn').forEach(btn => { btn.addEventListener('click', () => { @@ -1906,9 +2096,9 @@ class ClaudeOrchestrator { document.getElementById('presets-modal').classList.add('hidden'); }); }); - + // Grid layout dropdown removed - using dynamic layout now - + // Settings const settingsToggle = document.getElementById('settings-toggle'); const closeSettingsPanel = () => { @@ -1936,7 +2126,7 @@ class ClaudeOrchestrator { } else { console.error('Settings toggle button not found!'); } - + document.getElementById('close-settings').addEventListener('click', () => { closeSettingsPanel(); }); @@ -1962,7 +2152,7 @@ class ClaudeOrchestrator { if (panel.contains(e.target)) return; closeSettingsPanel(); }); - + // Settings inputs document.getElementById('enable-notifications').addEventListener('change', (e) => { this.settings.notifications = e.target.checked; @@ -1971,12 +2161,12 @@ class ClaudeOrchestrator { this.notificationManager.requestPermission(); } }); - + document.getElementById('enable-sounds').addEventListener('change', (e) => { this.settings.sounds = e.target.checked; this.saveSettings(); }); - + document.getElementById('auto-scroll').addEventListener('change', (e) => { this.settings.autoScroll = e.target.checked; this.saveSettings(); @@ -1994,7 +2184,7 @@ class ClaudeOrchestrator { } } }); - + document.getElementById('theme-select').addEventListener('change', (e) => { this.settings.theme = e.target.value; this.saveSettings(); @@ -2048,6 +2238,7 @@ class ClaudeOrchestrator { // Settings UI helpers: search + section jump so the panel doesn’t feel like an endless scroll. this.setupSettingsPanelNavigation(); this.setupDiagnosticsPanel(); + this.setupDependencySetupWizard(); const tasksThemeSelect = document.getElementById('tasks-theme-select'); if (tasksThemeSelect) { @@ -2306,7 +2497,7 @@ class ClaudeOrchestrator { document.getElementById('dismiss-git-notification').addEventListener('click', () => { document.getElementById('git-update-notification').classList.add('hidden'); }); - + // Workflow notification settings (server-persisted) const workflowNotifyMode = document.getElementById('workflow-notify-mode'); if (workflowNotifyMode) { @@ -2727,33 +2918,33 @@ class ClaudeOrchestrator { if (markReadBtn) { markReadBtn.addEventListener('click', () => this.notificationManager?.markAllAsRead?.()); } - + // Claude startup modal handlers (simplified) const cancelClaudeBtn = document.getElementById('cancel-claude-startup'); - + if (cancelClaudeBtn) { cancelClaudeBtn.addEventListener('click', () => { this.hideClaudeStartupModal(); }); } - + // Handle startup option button clicks document.addEventListener('click', (e) => { if (e.target.closest('.startup-option-btn')) { const btn = e.target.closest('.startup-option-btn'); const mode = btn.dataset.mode; - + // Check if modal YOLO is checked const modalYolo = document.getElementById('modal-yolo'); const skipPermissions = modalYolo ? modalYolo.checked : false; - + if (this.pendingClaudeSession) { this.startClaudeWithOptions(this.pendingClaudeSession, mode, skipPermissions); this.hideClaudeStartupModal(); } } }); - + // Handle window resize to fix blank terminals let resizeTimeout; window.addEventListener('resize', () => { @@ -2899,7 +3090,7 @@ class ClaudeOrchestrator { }); }); } - + setViewMode(mode, { persist = true } = {}) { const normalized = String(mode || '').toLowerCase(); if (!['all', 'claude', 'server'].includes(normalized)) return; @@ -2914,7 +3105,7 @@ class ClaudeOrchestrator { this.updateGlobalUserSetting('ui.terminals.viewMode', normalized); } } - + updateViewModeButtons() { const allBtn = document.getElementById('view-all'); const claudeBtn = document.getElementById('view-claude-only'); @@ -3544,21 +3735,21 @@ class ClaudeOrchestrator { return null; } - + matchesViewMode(sessionId) { if (this.viewMode === 'all') return true; - + const session = this.sessions.get(sessionId); const type = session?.type; - + if (this.viewMode === 'claude') { return type === 'claude' || type === 'codex' || /-(claude|codex)$/.test(String(sessionId || '')); } - + if (this.viewMode === 'server') { return type === 'server' || sessionId.includes('-server'); } - + return true; } @@ -3571,9 +3762,14 @@ class ClaudeOrchestrator { && this.matchesTierFilter(sessionId) && this.matchesWorkflowMode(sessionId); } - + handleInitialSessions(sessionStates) { console.log('Received initial sessions:', sessionStates); + void this.traceDesktopLaunch('client.sessions.rendered', { + sessionCount: Object.keys(sessionStates || {}).length, + sessionIds: Object.keys(sessionStates || {}), + workspaceId: this.currentWorkspace?.id || null + }); // Preserve per-workspace worktree visibility (hide/show toggles) when we // receive a sessions refresh for the SAME workspace (e.g. after adding a @@ -3588,7 +3784,7 @@ class ClaudeOrchestrator { this.sessions.clear(); this.sessionActivity.clear(); this.visibleTerminals.clear(); - + // Process sessions for (const [sessionId, state] of Object.entries(sessionStates)) { const sessionData = { @@ -3634,13 +3830,13 @@ class ClaudeOrchestrator { this.pruneIntentHaikuState(new Set(Object.keys(sessionStates))); this.lastSessionsWorkspaceId = currentWorkspaceId; - + // Hide loading message FIRST const loadingMessage = document.getElementById('loading-message'); if (loadingMessage) { loadingMessage.style.display = 'none'; } - + // Build sidebar this.buildSidebar(); @@ -4237,10 +4433,10 @@ class ClaudeOrchestrator { // Always ensure filter toggle exists and is updated FIRST this.ensureFilterToggleExists(); - + // Clear and rebuild the worktree list worktreeList.innerHTML = ''; - + // Group sessions by worktree and repository for mixed-repo support const worktrees = new Map(); @@ -4274,22 +4470,22 @@ class ClaudeOrchestrator { worktree.server = session; } } - + // Create sidebar items for (const [worktreeId, worktree] of worktrees) { // Check if worktree is active (has any session marked as active) const isActive = this.isWorktreeActive(worktreeId); - + // Skip inactive worktrees if filter is enabled if (this.showActiveOnly && !isActive) { continue; } - + // Check if any session in this worktree is visible. const claudeVisible = !!(worktree.claude && this.isSessionVisibleByWorktreeSelection(worktree.claude.sessionId, worktree.claude)); const serverVisible = !!(worktree.server && this.isSessionVisibleByWorktreeSelection(worktree.server.sessionId, worktree.server)); const isVisible = claudeVisible || serverVisible; - + const item = document.createElement('div'); // Only show visibility state, not activity state (activity filtering is handled separately) item.className = `worktree-item ${!isVisible ? 'hidden-terminal' : ''}`; @@ -4408,9 +4604,9 @@ class ClaudeOrchestrator { `; - + // Click handler is already attached via event delegation in setupEventListeners - + worktreeList.appendChild(item); } @@ -4791,21 +4987,21 @@ class ClaudeOrchestrator { const current = this.worktreeTags.get(worktreePath)?.readyForReview; return this.setWorktreeReadyForReview(worktreePath, !current); } - + ensureFilterToggleExists() { let filterToggle = document.getElementById('filter-toggle'); - + if (!filterToggle) { // Create the filter toggle element filterToggle = document.createElement('div'); filterToggle.className = 'filter-toggle'; filterToggle.id = 'filter-toggle'; - + // Insert it right before the worktree list const worktreeList = document.getElementById('worktree-list'); worktreeList.parentNode.insertBefore(filterToggle, worktreeList); } - + // Always update the button content const visibility = this.getUiVisibilityConfig().sidebar || {}; const showActiveFilter = visibility.activeFilter !== false; @@ -4838,7 +5034,7 @@ class ClaudeOrchestrator { if (status === 'error') return 'error'; return 'idle'; } - + isWorktreeActive(worktreeIdOrKey) { // Check if any session for this worktree has been marked as active. // For mixed-repo workspaces we may receive: @@ -4867,11 +5063,11 @@ class ClaudeOrchestrator { return false; } - + toggleActivityFilter() { this.showActiveOnly = !this.showActiveOnly; this.buildSidebar(); - + // Also update the main grid view to match the filter if (this.showActiveOnly) { this.showActiveWorktreesOnly(); @@ -4879,11 +5075,11 @@ class ClaudeOrchestrator { this.showAllTerminals(); } } - + showActiveWorktreesOnly() { // Clear visible terminals first this.visibleTerminals.clear(); - + // Add only active worktree sessions to visible set for (const [sessionId, session] of this.sessions) { const sessionWorktreeId = session.worktreeId || sessionId.split('-')[0]; @@ -4894,7 +5090,7 @@ class ClaudeOrchestrator { this.visibleTerminals.add(sessionId); } } - + // If no active sessions, show all if (this.visibleTerminals.size === 0) { this.showAllTerminals(); @@ -4903,7 +5099,7 @@ class ClaudeOrchestrator { this.buildSidebar(); } } - + resizeAllVisibleTerminals() { // Force resize all visible terminals to fit their containers this.activeView.forEach(sessionId => { @@ -5037,7 +5233,7 @@ class ClaudeOrchestrator { this.updateTerminalGrid(); this.buildSidebar(); } - + showWorktree(worktreeIdOrKey) { // Show terminals for this EXACT worktree key const claudeId = `${worktreeIdOrKey}-claude`; @@ -5049,17 +5245,17 @@ class ClaudeOrchestrator { this.updateTerminalGrid(); this.buildSidebar(); } - + showAllTerminals() { // Add all sessions to visible set for (const sessionId of this.sessions.keys()) { this.visibleTerminals.add(sessionId); } - + this.updateTerminalGrid(); this.buildSidebar(); } - + /** * Get the terminal grid container for the current tab */ @@ -5088,7 +5284,7 @@ class ClaudeOrchestrator { const allSessions = Array.from(this.sessions.keys()); this.renderTerminalsWithVisibility(allSessions); } - + renderTerminalsWithVisibility(sessionIds) { // Render all terminals but apply visibility using CSS (don't destroy DOM) this.activeView = sessionIds.filter(id => this.isSessionVisibleInCurrentView(id)); @@ -5232,18 +5428,18 @@ class ClaudeOrchestrator { this.resizeAllVisibleTerminals(); }, 200); } - + showClaudeOnly() { this.setViewMode('claude'); } - + showServersOnly() { this.setViewMode('server'); } - + applyPreset(preset) { this.visibleTerminals.clear(); - + switch (preset) { case 'all': this.showAllTerminals(); @@ -5277,9 +5473,9 @@ class ClaudeOrchestrator { break; } } - + // changeLayout method removed - using dynamic layout based on visible terminal count - + showTerminals(sessionIds) { // Legacy function - update visible set and refresh everything this.visibleTerminals.clear(); @@ -5291,31 +5487,31 @@ class ClaudeOrchestrator { this.updateTerminalGrid(); this.buildSidebar(); } - + renderTerminals(sessionIds) { // Core rendering function - just displays terminals without updating state this.activeView = sessionIds; const grid = this.getTerminalGrid(); - + // Sort sessionIds to ensure proper ordering: work1-claude, work1-server, work2-claude, work2-server, etc. const sortedSessionIds = sessionIds.slice().sort((a, b) => { // Extract worktree number const getWorkNum = (id) => parseInt(id.match(/work(\d+)/)?.[1] || 0); const numA = getWorkNum(a); const numB = getWorkNum(b); - + // First sort by worktree number if (numA !== numB) return numA - numB; - + // Then claude before server if (a.includes('claude') && b.includes('server')) return -1; if (a.includes('server') && b.includes('claude')) return 1; return 0; }); - + // Clear grid but don't destroy terminals grid.innerHTML = ''; - + // Create terminal elements for active view sortedSessionIds.forEach((sessionId) => { const session = this.sessions.get(sessionId); @@ -5324,7 +5520,7 @@ class ClaudeOrchestrator { grid.appendChild(wrapper); } }); - + // Now handle terminal instances sortedSessionIds.forEach((sessionId, index) => { const session = this.sessions.get(sessionId); @@ -5332,25 +5528,25 @@ class ClaudeOrchestrator { setTimeout(() => { const terminalEl = document.getElementById(this.getSessionDomId('terminal', sessionId)); if (!terminalEl) return; - + if (this.terminalManager.terminals.has(sessionId)) { // Re-attach existing terminal to the new element const term = this.terminalManager.terminals.get(sessionId); - + // Clear and re-open the terminal in the new element terminalEl.innerHTML = ''; term.open(terminalEl); - + // Force a resize and refresh this.terminalManager.fitTerminal(sessionId); - + // Force a screen refresh to show content term.refresh(0, term.rows - 1); } else { // Create new terminal only if it doesn't exist this.terminalManager.createTerminal(sessionId, session); } - + // Don't auto-start Claude - let user choose via modal or button }, 50 + (index * 25)); // Reduced stagger time } @@ -5576,41 +5772,41 @@ class ClaudeOrchestrator { getTicketMetaForSession(sessionId, sessionOverride = null) { const sid = String(sessionId || '').trim(); if (!sid) return null; - + const session = sessionOverride || this.sessions.get(sid); const recordIds = []; recordIds.push(`session:${sid}`); - + const worktreePath = session?.config?.cwd || session?.cwd || session?.worktreePath || null; if (worktreePath) recordIds.push(`worktree:${worktreePath}`); - + const prUrl = this.githubLinks.get(sid)?.pr || null; const prTaskId = prUrl ? this.getPRTaskIdFromUrl(prUrl) : null; if (prTaskId) recordIds.push(prTaskId); - + for (const id of recordIds) { const rec = this.taskRecords.get(id); if (!rec) continue; - + const ticketProvider = String(rec.ticketProvider || '').trim().toLowerCase(); const ticketCardId = String(rec.ticketCardId || '').trim(); const ticketCardUrl = String(rec.ticketCardUrl || '').trim(); const ticketTitle = String(rec.ticketTitle || '').trim(); - + if (!ticketTitle && !ticketCardId && !ticketCardUrl) continue; - + const rawUrl = ticketCardUrl || ((ticketProvider === 'trello' || !ticketProvider) && ticketCardId ? `https://trello.com/c/${ticketCardId}` : ''); const url = (rawUrl && /^https?:\/\//i.test(rawUrl)) ? rawUrl : ''; - + const label = ticketTitle || (ticketProvider && ticketCardId ? `${ticketProvider}:${ticketCardId}` : (ticketCardId ? ticketCardId : url)); const tooltipParts = [ ticketTitle || null, ticketProvider && ticketCardId ? `${ticketProvider}:${ticketCardId}` : null, url || null ].filter(Boolean); - + return { provider: ticketProvider || null, cardId: ticketCardId || null, @@ -5620,36 +5816,36 @@ class ClaudeOrchestrator { tooltip: tooltipParts.join(' • ') }; } - + return null; } - + updateTerminalTicketLabel(sessionId) { const sid = String(sessionId || '').trim(); if (!sid) return; - + const wrapper = this.getSessionWrapperElement(sid); if (!wrapper) return; - + const titleRow = wrapper.querySelector('.terminal-title'); if (!titleRow) return; - + const existing = titleRow.querySelector('.terminal-ticket'); const meta = this.getTicketMetaForSession(sid); - + if (!meta || !meta.label) { existing?.remove(); return; } - + const label = `🧾 ${meta.label}`; const tooltip = meta.tooltip || meta.title || meta.label; const url = meta.url || ''; - + const wantsLink = !!url; const isLink = existing && existing.tagName && existing.tagName.toLowerCase() === 'a'; const isSpan = existing && existing.tagName && existing.tagName.toLowerCase() === 'span'; - + if (wantsLink) { let el = existing; if (!isLink) { @@ -5658,7 +5854,7 @@ class ClaudeOrchestrator { el.className = 'terminal-ticket'; el.target = '_blank'; el.rel = 'noopener noreferrer'; - + const branchEl = titleRow.querySelector('.terminal-branch'); if (branchEl && branchEl.parentElement === titleRow) { branchEl.insertAdjacentElement('afterend', el); @@ -5671,7 +5867,7 @@ class ClaudeOrchestrator { el.title = tooltip; return; } - + let el = existing; if (!isSpan) { existing?.remove(); @@ -5687,7 +5883,7 @@ class ClaudeOrchestrator { el.textContent = label; el.title = tooltip; } - + refreshBranchLabels() { try { for (const [sessionId, session] of this.sessions) { @@ -5699,7 +5895,7 @@ class ClaudeOrchestrator { } this.buildSidebar(); } - + createTerminalElement(sessionId, session) { const wrapper = document.createElement('div'); wrapper.className = 'terminal-wrapper'; @@ -5709,7 +5905,7 @@ class ClaudeOrchestrator { wrapper.addEventListener('mousedown', () => { this.lastInteractedSessionId = sessionId; }); - + const sessionType = String(session?.type || '').trim().toLowerCase(); const isAgentSession = sessionType === 'claude' || sessionType === 'codex'; const isServerSession = sessionType === 'server'; @@ -5819,7 +6015,7 @@ class ClaudeOrchestrator { return wrapper; } - + updateSessionStatus(sessionId, status) { const statusElement = document.getElementById(this.getSessionDomId('status', sessionId)); // Update session data @@ -5903,7 +6099,7 @@ class ClaudeOrchestrator { apply(status); } } - + // Update quick actions for agent sessions if (/-claude$|-codex$/.test(sessionId)) { // Clear any pending notification timer if agent goes busy again @@ -5935,11 +6131,11 @@ class ClaudeOrchestrator { }, 3000); } } - + // Update sidebar this.updateSidebarStatus(sessionId, status); } - + updateSidebarStatus(sessionId, status) { const session = this.sessions.get(sessionId); const key = this.getSessionWorktreeKey(sessionId, session); @@ -5996,7 +6192,7 @@ class ClaudeOrchestrator { } } } - + updateSessionBranch(sessionId, branch, remoteUrl, defaultBranch, existingPR) { const session = this.sessions.get(sessionId); if (session) { @@ -6007,9 +6203,9 @@ class ClaudeOrchestrator { if (defaultBranch) { session.defaultBranch = defaultBranch; } - + console.log(`Branch updated for ${sessionId}: ${branch}`, existingPR ? `(existing PR: ${existingPR})` : ''); - + // If there's an existing PR, add it to GitHub links automatically if (existingPR) { const links = this.githubLinks.get(sessionId) || {}; @@ -6019,17 +6215,17 @@ class ClaudeOrchestrator { this.maybeSchedulePrIntentRefresh(sessionId, existingPR); } } - + // Update terminal branch display this.updateTerminalBranchLabel(sessionId, branch || ''); - + // Update sidebar this.buildSidebar(); - + // Update GitHub buttons with new remote URL this.updateTerminalControls(sessionId); } - + // Server control methods toggleServer(sessionId, environment = 'development') { const status = this.serverStatuses.get(sessionId); @@ -6056,22 +6252,22 @@ class ClaudeOrchestrator { }); } } - + killServer(sessionId) { // Send force kill this.socket.emit('server-control', { sessionId, action: 'kill' }); this.serverStatuses.set(sessionId, 'idle'); - + // Update UI const button = document.getElementById(`server-toggle-${sessionId}`); if (button) { button.textContent = '▶'; } - + this.updateSidebarStatus(sessionId, 'idle'); this.updateServerControls(sessionId); } - + playInHytopia(sessionId) { console.log(`[PLAY IN HYTOPIA] Session: ${sessionId}`); console.log('Available ports:', Array.from(this.serverPorts.entries())); @@ -6089,21 +6285,21 @@ class ClaudeOrchestrator { } return; } - + const serverUrl = `localhost:${port}`; const hytopiaUrl = `https://hytopia.com/play/?${serverUrl}`; - + console.log(`Opening Hytopia for ${sessionId} at ${hytopiaUrl}`); window.open(hytopiaUrl, '_blank'); } - + restoreBuildButton(sessionId) { // Find any button that might be building for this worktree const worktreeMatch = sessionId.match(/work(\d+)/); if (!worktreeMatch) return; - + const worktreeNum = worktreeMatch[1]; - + // Check both claude and server buttons for this worktree [`work${worktreeNum}-claude`, `work${worktreeNum}-server`].forEach(id => { const btn = this.buildingButtons?.get(id); @@ -6115,7 +6311,7 @@ class ClaudeOrchestrator { } }); } - + buildProduction(sessionId) { // Extract worktree number from sessionId (e.g., 'work1-claude' -> 1) const worktreeMatch = sessionId.match(/work(\d+)/); @@ -6124,10 +6320,10 @@ class ClaudeOrchestrator { this.showError('Failed to identify worktree for build'); return; } - + const worktreeNum = worktreeMatch[1]; console.log(`Building production ZIP for worktree ${worktreeNum}`); - + // Disable the build button and show loading state const wrapper = document.getElementById(this.getSessionDomId('wrapper', sessionId)); const buildBtn = wrapper ? wrapper.querySelector('button[onclick*="buildProduction"]') : null; @@ -6136,26 +6332,26 @@ class ClaudeOrchestrator { buildBtn.innerHTML = ''; buildBtn.classList.add('building'); } - + // Store the button reference for later this.buildingButtons = this.buildingButtons || new Map(); this.buildingButtons.set(sessionId, buildBtn); - + // Emit socket event to trigger build on backend - this.socket.emit('build-production', { + this.socket.emit('build-production', { sessionId, - worktreeNum + worktreeNum }); } - + detectGitHubLinks(sessionId, data) { // Look for GitHub URLs with improved pattern matching const githubUrlPattern = /https:\/\/github\.com\/[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+(?:\/(?!https:\/\/github\.com\/)[^\s\)\]\}\>\"\'\`]*)?/g; const matches = data.match(githubUrlPattern); - + if (matches) { const links = this.githubLinks.get(sessionId) || {}; - + matches.forEach(originalUrl => { // Clean up ANSI escape codes and other terminal artifacts let url = originalUrl @@ -6164,7 +6360,7 @@ class ClaudeOrchestrator { .replace(/\u001b\[[0-9;]*m/g, '') // Remove Unicode ANSI codes .replace(/[\x00-\x1F\x7F-\x9F]/g, '') // Remove other control characters .trim(); - + // Remove common trailing punctuation that might be captured url = url.replace(/[,;.!?)\]}>'"`]*$/, ''); @@ -6173,7 +6369,7 @@ class ClaudeOrchestrator { if (secondUrlIndex > 0) { url = url.slice(0, secondUrlIndex); } - + // Validate URL format try { new URL(url); @@ -6181,7 +6377,7 @@ class ClaudeOrchestrator { console.warn('Invalid GitHub URL detected:', url); return; } - + // Categorize the URL if (url.includes('/pull/') && url.match(/\/pull\/\d+\/?$/)) { if (links.pr !== url) { @@ -6201,36 +6397,36 @@ class ClaudeOrchestrator { } } }); - + this.githubLinks.set(sessionId, links); this.updateTerminalControls(sessionId); } } - + clearGitHubLinks(sessionId) { this.githubLinks.delete(sessionId); this.githubLinkLogs.delete(sessionId); this.updateTerminalControls(sessionId); } - + copyLocalhostUrl(sessionId) { const port = this.serverPorts.get(sessionId); if (!port) { console.error('No port found for server', sessionId); return; } - + const url = `https://localhost:${port}`; navigator.clipboard.writeText(url).then(() => { console.log(`Copied ${url} to clipboard`); this.showNotification('Copied!', `${url} copied to clipboard`); }); } - + openHytopiaWebsite() { window.open('https://hytopia.com', '_blank'); } - + openPRLink(url) { try { // Validate the URL @@ -6242,17 +6438,17 @@ class ClaudeOrchestrator { this.showToast('Invalid PR URL', 'error'); } } - + getGitHubButtons(sessionId) { const links = this.githubLinks.get(sessionId) || {}; let buttons = ''; const visibility = this.getTerminalVisibilityConfig(); - + // Always show branch button (uses current session's git info) const session = this.sessions.get(sessionId); if (session && session.branch && session.branch !== 'master' && session.branch !== 'main') { const worktreeId = sessionId.split('-')[0]; - + // Use dynamic remote URL if available if (session.remoteUrl) { const encodeRef = (ref) => encodeURIComponent(String(ref || '').trim()); @@ -6261,7 +6457,7 @@ class ClaudeOrchestrator { // Use the actual default branch from git, fallback to 'main' if not available const defaultBranch = session.defaultBranch || 'main'; const compareUrl = `${session.remoteUrl}/compare/${encodeRef(defaultBranch)}...${branchRef}`; - + if (visibility.viewBranchOnGithub !== false) { buttons += ``; } @@ -6273,7 +6469,7 @@ class ClaudeOrchestrator { } } } - + // Show PR button if PR link detected if (links.pr) { const lastLogged = this.githubLinkLogs.get(sessionId); @@ -6288,17 +6484,17 @@ class ClaudeOrchestrator { buttons += ``; } } - + // Check for commit URLs if (links.commit) { if (visibility.advancedDiff !== false) { buttons += ``; } } - + return buttons; } - + updateTerminalControls(sessionId) { const wrapper = document.getElementById(this.getSessionDomId('wrapper', sessionId)); if (!wrapper) return; @@ -6378,43 +6574,43 @@ class ClaudeOrchestrator { : ''; return [stopBtn, removeBtn].filter(Boolean).join('\n'); } - + updateServerStatus(sessionId, output) { // Check if server started - look for various startup messages - if (output.includes('Server started') || - output.includes('Listening on') || + if (output.includes('Server started') || + output.includes('Listening on') || output.includes('Server running') || output.includes('Started server') || output.includes('🚀')) { this.serverStatuses.set(sessionId, 'running'); this.updateSidebarStatus(sessionId, 'running'); - + const button = document.getElementById(`server-toggle-${sessionId}`); if (button) { button.textContent = '⏹'; } - + this.updateServerControls(sessionId); const linkedClaude = this.getLinkedClaudeSessionIdForServer(sessionId); if (linkedClaude) this.updateTerminalControls(linkedClaude); } - + // Check if server stopped if (output.includes('Server stopped') || output.includes('exit')) { this.serverStatuses.set(sessionId, 'idle'); this.updateSidebarStatus(sessionId, 'idle'); - + const button = document.getElementById(`server-toggle-${sessionId}`); if (button) { button.textContent = '▶'; } - + this.updateServerControls(sessionId); const linkedClaude = this.getLinkedClaudeSessionIdForServer(sessionId); if (linkedClaude) this.updateTerminalControls(linkedClaude); } } - + /** * Get dynamic launch options based on current workspace */ @@ -6485,14 +6681,14 @@ class ClaudeOrchestrator { // Use dynamic button system controlsDiv.innerHTML = this.getServerControlsHTML(sessionId); } - + handleServerError(sessionId, output) { const worktreeId = sessionId.split('-')[0]; - + // Update status this.serverStatuses.set(sessionId, 'error'); this.updateSidebarStatus(sessionId, 'error'); - + // Show notification this.notificationManager.handleNotification({ sessionId, @@ -6518,13 +6714,13 @@ class ClaudeOrchestrator { session.hasUserInput = false; this.updateSidebarStatus(sid, String(session.status || 'idle').trim().toLowerCase() || 'idle'); } - + sendTerminalInput(sessionId, data) { if (!this.socket || !this.socket.connected) { console.error('Not connected to server'); return; } - + // Mark session as active when user first provides input // But only for meaningful input (not just arrow keys, etc.) if (data.length > 0 && !data.match(/^[\x1b\x7f\r\n]/) && data.trim().length > 0) { @@ -6547,7 +6743,7 @@ class ClaudeOrchestrator { console.error('Failed to interrupt session', e); } } - + resizeTerminal(sessionId, cols, rows) { if (this.socket && this.socket.connected) { this.socket.emit('terminal-resize', { sessionId, cols, rows }); @@ -7522,7 +7718,7 @@ class ClaudeOrchestrator { } } } - + handleSessionRestart(sessionId) { console.log(`Session ${sessionId} restarted`); // Terminal will automatically reconnect and show new content @@ -7551,20 +7747,20 @@ class ClaudeOrchestrator { } } } - + restartClaudeSession(sessionId) { console.log(`Restarting Claude session: ${sessionId}`); - + if (this.socket && this.socket.connected) { this.socket.emit('restart-session', { sessionId }); - + // Update UI to show restarting this.updateSessionStatus(sessionId, 'restarting'); } else { this.showError('Not connected to server'); } } - + refreshTerminal(sessionId) { console.log('Refreshing terminal:', sessionId); const term = this.terminalManager.terminals.get(sessionId); @@ -7572,10 +7768,10 @@ class ClaudeOrchestrator { // Force fit and refresh this.terminalManager.fitTerminal(sessionId); term.refresh(0, term.rows - 1); - + // Also try scrolling to bottom to trigger redraw term.scrollToBottom(); - + // If still blank, re-attach to DOM const terminalEl = document.getElementById(this.getSessionDomId('terminal', sessionId)); if (terminalEl && terminalEl.children.length === 0) { @@ -7583,13 +7779,13 @@ class ClaudeOrchestrator { } } } - + updateConnectionStatus(connected) { const statusElement = document.getElementById('connection-status'); if (statusElement) { const dot = statusElement.querySelector('.status-dot'); const text = statusElement.querySelector('span:last-child'); - + if (connected) { dot.classList.remove('disconnected'); dot.classList.add('connected'); @@ -7601,12 +7797,12 @@ class ClaudeOrchestrator { } } } - + showError(message) { // For now, use alert. Could be improved with a toast notification alert(`Error: ${message}`); } - + showClaudeUpdateRequired(updateInfo) { // Create update banner const banner = document.createElement('div'); @@ -7621,10 +7817,10 @@ class ClaudeOrchestrator { `; - + // Add to top of page document.body.insertBefore(banner, document.body.firstChild); - + // Also show in console console.warn('Claude Update Required:', updateInfo); } @@ -7661,36 +7857,18 @@ class ClaudeOrchestrator { return; } this.lastNotificationTime[sessionId] = now; - + const worktreeId = sessionId.replace('-claude', ''); const session = this.sessions.get(sessionId); const branch = session ? session.branch : ''; - - // Create small toast notification - const toast = document.createElement('div'); - toast.className = 'ready-toast'; - toast.innerHTML = ` -
- - Claude ${worktreeId} ready ${branch ? `(${branch})` : ''} -
- `; - - // Add to page - document.body.appendChild(toast); - - // Remove after 3 seconds - setTimeout(() => { - if (toast.parentNode) { - toast.remove(); - } - }, 3000); - + + this.showToast(`Claude ${worktreeId} ready ${branch ? `(${branch})` : ''}`, 'success', { durationMs: 3000 }); + // Play notification sound if enabled if (this.settings.sounds) { this.playNotificationSound(); } - + // Browser notification if enabled if (this.settings.notifications && 'Notification' in window && Notification.permission === 'granted') { new Notification(`Claude ${worktreeId} Ready`, { @@ -7709,26 +7887,26 @@ class ClaudeOrchestrator { }); } } - + playNotificationSound() { // Create a simple notification sound const audioContext = new (window.AudioContext || window.webkitAudioContext)(); const oscillator = audioContext.createOscillator(); const gainNode = audioContext.createGain(); - + oscillator.connect(gainNode); gainNode.connect(audioContext.destination); - + oscillator.frequency.setValueAtTime(800, audioContext.currentTime); oscillator.frequency.setValueAtTime(600, audioContext.currentTime + 0.1); - + gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3); - + oscillator.start(audioContext.currentTime); oscillator.stop(audioContext.currentTime + 0.3); } - + loadSettings() { const stored = localStorage.getItem('claude-orchestrator-settings'); const defaults = { @@ -8501,11 +8679,11 @@ class ClaudeOrchestrator { globalNodeInput.value = nodeOptions.join(' '); globalArgsInput.value = gameArgs.join(' '); } - + saveSettings() { localStorage.setItem('claude-orchestrator-settings', JSON.stringify(this.settings)); } - + applyTheme() { const mode = this.settings.theme === 'light' ? 'light' : 'dark'; document.body.classList.toggle('light-theme', mode === 'light'); @@ -8683,7 +8861,7 @@ class ClaudeOrchestrator { } return normalized; } - + syncSettingsUI() { // Sync checkbox states with settings document.getElementById('enable-notifications').checked = this.settings.notifications; @@ -8702,29 +8880,29 @@ class ClaudeOrchestrator { const label = document.getElementById('skin-intensity-value'); if (label) label.textContent = `${v}%`; } - + // Sync user settings UI if loaded if (this.userSettings) { this.syncUserSettingsUI(); } } - + showCodeReviewDropdown(sessionId) { // Close any existing dropdowns document.querySelectorAll('.review-dropdown').forEach(dropdown => dropdown.remove()); - + // Get the terminal controls container const terminalWrapper = document.getElementById(this.getSessionDomId('wrapper', sessionId)); const controlsContainer = terminalWrapper.querySelector('.terminal-controls'); - + // Create dropdown const dropdown = document.createElement('div'); dropdown.className = 'review-dropdown'; dropdown.innerHTML = this.buildReviewerDropdownHTML(sessionId); - + // Position and add to DOM controlsContainer.appendChild(dropdown); - + // Close dropdown when clicking outside const closeDropdown = (e) => { if (!dropdown.contains(e.target)) { @@ -8732,22 +8910,22 @@ class ClaudeOrchestrator { document.removeEventListener('click', closeDropdown); } }; - + // Add close listener after a short delay to prevent immediate closure setTimeout(() => { document.addEventListener('click', closeDropdown); }, 100); } - + buildReviewerDropdownHTML(requestingSessionId) { const availableReviewers = this.getAvailableReviewers(requestingSessionId); - + let html = `
Assign Code Review
`; - + if (availableReviewers.length === 0) { html += `
@@ -8767,19 +8945,19 @@ class ClaudeOrchestrator { `; }); } - + return html; } - + getAvailableReviewers(requestingSessionId) { const reviewers = []; - + for (const [sessionId, session] of this.sessions) { // Only include Claude sessions that are not the requesting session if (sessionId.includes('-claude') && sessionId !== requestingSessionId) { const worktreeNumber = sessionId.replace('-claude', '').replace('work', ''); const isActive = this.sessionActivity.get(sessionId) === 'active'; - + // Prefer active sessions, but include inactive ones as backup if (isActive || session.status === 'waiting') { reviewers.push({ @@ -8792,7 +8970,7 @@ class ClaudeOrchestrator { } } } - + // Sort by preference: active + ready first, then active + busy, then inactive reviewers.sort((a, b) => { if (a.isActive && !b.isActive) return -1; @@ -8801,48 +8979,48 @@ class ClaudeOrchestrator { if (a.status !== 'waiting' && b.status === 'waiting') return 1; return 0; }); - + return reviewers; } - + async assignCodeReview(requestingSessionId, reviewerSessionId) { // Close dropdown document.querySelectorAll('.review-dropdown').forEach(dropdown => dropdown.remove()); - + try { // Extract code/PR information from the requesting session const codeInfo = await this.extractCodeForReview(requestingSessionId); - + if (!codeInfo.hasContent) { this.showToast(`No code changes detected in Claude ${requestingSessionId.replace('work', '').replace('-claude', '')}`, 'warning'); return; } - + // Format review request const reviewRequest = this.formatReviewRequest(codeInfo, requestingSessionId); - + // Send to reviewer Claude this.sendTerminalInput(reviewerSessionId, reviewRequest); - + // Mark both sessions as active this.sessionActivity.set(reviewerSessionId, 'active'); this.buildSidebar(); - + // Show success message const requestingWorktree = requestingSessionId.replace('work', '').replace('-claude', ''); const reviewerWorktree = reviewerSessionId.replace('work', '').replace('-claude', ''); this.showToast(`Code review assigned: Claude ${requestingWorktree} → Claude ${reviewerWorktree}`, 'success'); - + } catch (error) { console.error('Error assigning code review:', error); this.showToast('Failed to assign code review', 'error'); } } - + async extractCodeForReview(sessionId) { // Get terminal content const terminalContent = this.terminalManager.getTerminalContent(sessionId); - + // Look for various types of code content const codePatterns = { prUrl: /https:\/\/github\.com\/[^\s]+\/pull\/\d+/g, @@ -8851,7 +9029,7 @@ class ClaudeOrchestrator { codeBlocks: /```[\s\S]*?```/g, bashCommands: /(?:git\s+(?:diff|log|show)|gh\s+pr)/g }; - + const extracted = { prUrls: [...(terminalContent.match(codePatterns.prUrl) || [])], gitDiffs: [...(terminalContent.match(codePatterns.gitDiff) || [])], @@ -8859,20 +9037,20 @@ class ClaudeOrchestrator { recentCommands: this.extractRecentCommands(terminalContent), hasContent: false }; - + // Determine if there's reviewable content - extracted.hasContent = extracted.prUrls.length > 0 || - extracted.gitDiffs.length > 0 || + extracted.hasContent = extracted.prUrls.length > 0 || + extracted.gitDiffs.length > 0 || extracted.codeBlocks.length > 0 || extracted.recentCommands.some(cmd => cmd.includes('git') || cmd.includes('gh pr')); - + return extracted; } - + extractRecentCommands(terminalContent) { const lines = terminalContent.split('\n'); const commands = []; - + // Look for command patterns (simple approach) for (let i = lines.length - 1; i >= 0 && commands.length < 10; i--) { const line = lines[i].trim(); @@ -8880,15 +9058,15 @@ class ClaudeOrchestrator { commands.unshift(line); } } - + return commands; } - + formatReviewRequest(codeInfo, requestingSessionId) { const requestingWorktree = requestingSessionId.replace('work', '').replace('-claude', ''); - + let request = `Please review the code from Claude ${requestingWorktree}:\n\n`; - + if (codeInfo.prUrls.length > 0) { request += `**Pull Request(s):**\n`; codeInfo.prUrls.forEach(url => { @@ -8900,19 +9078,19 @@ class ClaudeOrchestrator { request += `- Suggestions for improvement\n`; request += `- Architecture and design patterns\n\n`; } - + if (codeInfo.gitDiffs.length > 0) { request += `**Git Diff:**\n\`\`\`diff\n`; request += codeInfo.gitDiffs.slice(0, 2).join('\n'); // Limit to first 2 diffs request += `\n\`\`\`\n\n`; } - + if (codeInfo.codeBlocks.length > 0) { request += `**Code Changes:**\n`; request += codeInfo.codeBlocks.slice(0, 3).join('\n\n'); // Limit to first 3 blocks request += `\n\n`; } - + if (codeInfo.recentCommands.length > 0) { request += `**Recent Commands:**\n`; codeInfo.recentCommands.forEach(cmd => { @@ -8920,9 +9098,9 @@ class ClaudeOrchestrator { }); request += `\n`; } - + request += `Please provide a thorough code review with specific feedback and suggestions.\n`; - + return request; } @@ -9267,58 +9445,1541 @@ class ClaudeOrchestrator { if (failedCount > 0) { this.showToast?.(`Auto-fix applied ${appliedCount}, failed ${failedCount}`, 'warning'); } else { - const tail = skippedManualCount > 0 ? `, ${skippedManualCount} manual step(s) left` : ''; - this.showToast?.(`Auto-fix applied ${appliedCount}${tail}`, 'success'); + const tail = skippedManualCount > 0 ? `, ${skippedManualCount} manual step(s) left` : ''; + this.showToast?.(`Auto-fix applied ${appliedCount}${tail}`, 'success'); + } + if (statusEl) statusEl.textContent = 'Safe auto-fix completed'; + } catch (error) { + this.showToast?.(`Safe auto-fix failed: ${String(error?.message || error)}`, 'error'); + if (statusEl) statusEl.textContent = ''; + } finally { + btnRepairSafe.disabled = false; + } + }); + repairEl?.addEventListener('click', async (event) => { + const target = event.target.closest('[data-diagnostics-repair]'); + if (!target) return; + const action = String(target.getAttribute('data-diagnostics-repair') || '').trim(); + if (!action) return; + target.disabled = true; + if (statusEl) statusEl.textContent = `Running repair: ${action}…`; + try { + const res = await fetch('/api/diagnostics/first-run/repair', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action }) + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data?.ok === false) { + throw new Error(String(data?.error || data?.message || `HTTP ${res.status}`)); + } + const repair = data?.repair || {}; + if (repair.manual) { + this.showToast?.(String(repair?.message || 'Manual action required'), 'warning'); + } else { + this.showToast?.(String(repair?.message || 'Repair completed'), 'success'); + } + if (data?.diagnostics) { + state.firstRun = data.diagnostics; + renderRepairActions(state.firstRun); + } else { + await refreshFirstRun(); + } + if (!state.base) await refreshBase(); + await refreshInstallWizard().catch(() => {}); + render(state.base, state.firstRun, state.wizard); + if (statusEl) statusEl.textContent = `Repair completed: ${action}`; + } catch (error) { + this.showToast?.(`Repair failed: ${String(error?.message || error)}`, 'error'); + if (statusEl) statusEl.textContent = ''; + } finally { + target.disabled = false; + } + }); + } + + openDiagnosticsPanel({ refresh = true } = {}) { + try { + document.getElementById('settings-panel')?.classList?.remove?.('hidden'); + setTimeout(() => { + try { + document.getElementById('diagnostics-output')?.scrollIntoView?.({ behavior: 'smooth', block: 'start' }); + } catch { + // ignore + } + if (!refresh) return; + if (typeof this.refreshDiagnosticsPanel === 'function') { + this.refreshDiagnosticsPanel(); + return; + } + try { + document.getElementById('diagnostics-refresh')?.click?.(); + } catch { + // ignore + } + }, 50); + } catch { + // ignore + } + } + + setupDependencySetupWizard() { + const modal = document.getElementById('dependency-setup-modal'); + const openBtn = document.getElementById('dependency-setup-open'); + const summaryEl = document.getElementById('dependency-setup-summary'); + const listEl = document.getElementById('dependency-setup-list'); + const closeBtn = document.getElementById('dependency-setup-close'); + if (!modal || !summaryEl || !listEl) return; + const body = document.body; + const isWindowsHost = this.isWindowsHostEnvironment(); + const isDesktopWindowsApp = this.isDesktopWindowsRuntime(); + let desktopCompleted = false; + const traceOnboarding = (event, details = {}) => { + void this.traceDesktopLaunch(`client.onboarding.${event}`, details); + }; + + const setBootstrapPending = (pending) => { + if (!isWindowsHost) return; + if (pending) { + body?.classList?.add?.('dependency-onboarding-booting'); + body?.classList?.remove?.('dependency-onboarding-active'); + return; + } + body?.classList?.remove?.('dependency-onboarding-booting'); + }; + if (!isWindowsHost) { + setBootstrapPending(false); + return; + } + setBootstrapPending(false); + + const dismissKey = 'orchestrator-dependency-setup-dismissed-v3'; + const completedKey = 'orchestrator-dependency-onboarding-completed-v2'; + const progressKey = 'orchestrator-dependency-onboarding-progress-v2'; + const skippedStepsKey = 'orchestrator-dependency-onboarding-skipped-v1'; + const state = { + loading: false, + diagnostics: null, + actions: [], + currentStep: 0, + showWelcome: true, + skippedActionIds: new Set(), + actionRuns: new Map(), + actionRunPollers: new Map(), + gitIdentity: { + name: '', + email: '' + }, + gitIdentityHelpVisible: false + }; + + const syncDesktopCompleted = () => { + if (!isDesktopWindowsApp) return false; + desktopCompleted = !!this.userSettings?.global?.ui?.onboarding?.desktopDependencySetup?.completed; + return desktopCompleted; + }; + syncDesktopCompleted(); + traceOnboarding('wizard-ready', { + isWindowsHost, + isDesktopWindowsApp, + desktopCompleted + }); + + const readDismissed = () => { + try { + return localStorage.getItem(dismissKey) === 'true'; + } catch { + return false; + } + }; + + const writeDismissed = (value) => { + try { + if (value) localStorage.setItem(dismissKey, 'true'); + else localStorage.removeItem(dismissKey); + } catch { + // ignore + } + }; + + const readCompleted = () => { + if (isDesktopWindowsApp) { + return syncDesktopCompleted(); + } + try { + return localStorage.getItem(completedKey) === 'true'; + } catch { + return false; + } + }; + + const writeCompleted = async (value) => { + if (isDesktopWindowsApp) { + const next = !!value; + desktopCompleted = next; + traceOnboarding('completion-write-start', { + nextCompleted: next + }); + if (this.userSettings) { + await this.updateGlobalUserSetting('ui.onboarding.desktopDependencySetup', { + completed: next, + completedAt: next ? new Date().toISOString() : null + }); + traceOnboarding('completion-write-success', { + nextCompleted: next, + persistedCompleted: !!this.userSettings?.global?.ui?.onboarding?.desktopDependencySetup?.completed, + persistedCompletedAt: this.userSettings?.global?.ui?.onboarding?.desktopDependencySetup?.completedAt || null + }); + } else { + traceOnboarding('completion-write-skipped', { + nextCompleted: next, + reason: 'user-settings-missing' + }); + } + return; + } + try { + if (value) localStorage.setItem(completedKey, 'true'); + else localStorage.removeItem(completedKey); + } catch { + // ignore + } + }; + + const readSavedStep = () => { + try { + const raw = Number.parseInt(String(localStorage.getItem(progressKey) || ''), 10); + if (Number.isFinite(raw) && raw >= 0) return raw; + return 0; + } catch { + return 0; + } + }; + + const writeSavedStep = (step) => { + try { + localStorage.setItem(progressKey, String(Math.max(0, Number(step) || 0))); + } catch { + // ignore + } + }; + + const readSkippedStepIds = () => { + try { + const raw = localStorage.getItem(skippedStepsKey); + if (!raw) return new Set(); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return new Set(); + const ids = parsed + .map((value) => String(value || '').trim()) + .filter(Boolean); + return new Set(ids); + } catch { + return new Set(); + } + }; + + const writeSkippedStepIds = () => { + try { + if (!(state.skippedActionIds instanceof Set) || state.skippedActionIds.size === 0) { + localStorage.removeItem(skippedStepsKey); + return; + } + localStorage.setItem(skippedStepsKey, JSON.stringify(Array.from(state.skippedActionIds))); + } catch { + // ignore + } + }; + + const setStepSkipped = (actionId, skipped) => { + const id = String(actionId || '').trim(); + if (!id) return; + if (skipped) state.skippedActionIds.add(id); + else state.skippedActionIds.delete(id); + writeSkippedStepIds(); + }; + + const toToolMap = (diagnostics) => { + const map = new Map(); + const tools = Array.isArray(diagnostics?.tools) ? diagnostics.tools : []; + tools.forEach((tool) => { + const id = String(tool?.id || '').trim(); + if (!id) return; + map.set(id, !!tool?.ok); + }); + return map; + }; + + const getToolResult = (diagnostics, toolId) => { + const id = String(toolId || '').trim(); + if (!id) return null; + const tools = Array.isArray(diagnostics?.tools) ? diagnostics.tools : []; + return tools.find((tool) => String(tool?.id || '').trim() === id) || null; + }; + + const parseGitIdentityVersion = (value) => { + const raw = String(value || '').trim(); + if (!raw) return { name: '', email: '' }; + const pair = raw.match(/^(.*)\s<([^<>]+)>$/); + if (pair?.[1] && pair?.[2]) { + return { + name: String(pair[1] || '').trim(), + email: String(pair[2] || '').trim() + }; + } + if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw)) { + return { name: '', email: raw }; + } + return { name: raw, email: '' }; + }; + + const stripAnsiText = (value) => String(value || '').replace(/\u001b\[[0-9;]*m/g, ''); + + const collectRunOutputLines = (runInfo, { limit = 25 } = {}) => { + const lines = Array.isArray(runInfo?.output) + ? runInfo.output + .map((entry) => stripAnsiText(String(entry?.line || '')).trim()) + .filter(Boolean) + : []; + if (!Number.isFinite(limit) || limit <= 0) return lines; + return lines.slice(-Math.max(1, Number(limit) || 1)); + }; + + const extractGithubLoginInfo = (lines = []) => { + const fallbackUrl = 'https://github.com/login/device'; + let link = fallbackUrl; + let code = ''; + let sawDeviceHint = false; + + (Array.isArray(lines) ? lines : []).forEach((lineRaw) => { + const line = String(lineRaw || '').trim(); + if (!line) return; + + if (/one[-\s]?time code|login\/device|authenticate in your web browser|copied to your clipboard|open this url/i.test(line)) { + sawDeviceHint = true; + } + + const linkMatch = line.match(/https:\/\/github\.com\/login\/device(?:\S*)?/i); + if (linkMatch?.[0]) link = linkMatch[0].trim(); + + const codeMatch = line.match(/\b([A-Z0-9]{4}-[A-Z0-9]{4})\b/i); + if (codeMatch?.[1]) code = String(codeMatch[1]).toUpperCase(); + }); + + return { + link, + code, + sawDeviceHint + }; + }; + + const hydrateGitIdentityDraft = (diagnostics) => { + const gitIdentityTool = getToolResult(diagnostics, 'gitIdentity'); + const parsed = parseGitIdentityVersion(String(gitIdentityTool?.version || '')); + if (!state.gitIdentity.name && parsed.name) { + state.gitIdentity.name = parsed.name; + } + if (!state.gitIdentity.email && parsed.email) { + state.gitIdentity.email = parsed.email; + } + }; + + const getRequirementState = (toolsMap) => { + const gitOk = !!toolsMap.get('git'); + const claudeOk = !!toolsMap.get('claude'); + const codexOk = !!toolsMap.get('codex'); + const hasAgentCli = claudeOk || codexOk; + const coreReady = gitOk && hasAgentCli; + const missingCore = []; + if (!gitOk) missingCore.push('git'); + if (!hasAgentCli) missingCore.push('agent-cli'); + return { + gitOk, + claudeOk, + codexOk, + hasAgentCli, + coreReady, + missingCore + }; + }; + + const isActionComplete = (actionId, toolsMap) => { + switch (String(actionId || '').trim()) { + case 'install-git': + return !!toolsMap.get('git'); + case 'configure-git-identity': + return !!toolsMap.get('gitIdentity'); + case 'install-node': + return !!toolsMap.get('node') && !!toolsMap.get('npm'); + case 'install-gh': + return !!toolsMap.get('gh'); + case 'gh-login': + return !!toolsMap.get('ghAuth'); + case 'install-claude': + return !!toolsMap.get('claude'); + case 'install-codex': + return !!toolsMap.get('codex'); + default: + return false; + } + }; + + const getActionLevelText = (level) => { + if (level === 'required') return 'Required'; + if (level === 'optional') return 'Optional'; + if (level === 'core-option') return 'Core option'; + return 'Recommended'; + }; + + const getActionLevelClass = (level) => { + if (level === 'optional') return 'level-optional'; + return level === 'recommended' ? 'level-recommended' : 'level-required'; + }; + + const getActionStatusText = (actionId, done) => { + const id = String(actionId || '').trim(); + if (id === 'gh-login') return done ? 'Logged in' : 'Not logged in'; + if (id === 'configure-git-identity') return done ? 'Configured' : 'Not configured'; + return done ? 'Installed' : 'Missing'; + }; + + const getResolvedSteps = () => { + const toolsMap = toToolMap(state.diagnostics); + const actions = Array.isArray(state.actions) ? state.actions : []; + return actions.map((action) => { + const id = String(action?.id || '').trim(); + const level = getActionLevel(id); + const done = isActionComplete(id, toolsMap); + return { + ...action, + id, + level, + optional: action?.optional === true || level === 'optional', + done, + levelText: getActionLevelText(level), + levelClass: getActionLevelClass(level), + statusText: getActionStatusText(id, done), + statusClass: done ? 'status-ok' : 'status-missing', + runSupported: action?.runSupported !== false + }; + }); + }; + + const syncSkippedSteps = (steps) => { + if (!(state.skippedActionIds instanceof Set)) { + state.skippedActionIds = new Set(); + } + const validSkippedIds = new Set( + (Array.isArray(steps) ? steps : []) + .filter((step) => { + const id = String(step?.id || '').trim(); + return !!id && step?.optional && !step?.done; + }) + .map((step) => String(step?.id || '').trim()) + ); + let changed = false; + for (const id of Array.from(state.skippedActionIds)) { + if (!validSkippedIds.has(id)) { + state.skippedActionIds.delete(id); + changed = true; + } + } + if (changed) writeSkippedStepIds(); + }; + + const isOnboardingLocked = () => { + if (!isWindowsHost) return false; + if (!Array.isArray(state.actions) || state.actions.length === 0) return false; + const toolsMap = toToolMap(state.diagnostics); + const req = getRequirementState(toolsMap); + if (!req?.coreReady) return true; + return !readCompleted(); + }; + + const applyOnboardingLockUI = () => { + const locked = isOnboardingLocked(); + if (closeBtn) { + closeBtn.disabled = locked; + closeBtn.style.visibility = locked ? 'hidden' : ''; + } + modal.setAttribute('data-onboarding-locked', locked ? 'true' : 'false'); + return locked; + }; + + const setCurrentStep = (nextStep, { persist = true } = {}) => { + const previousStep = state.currentStep; + const maxStep = Math.max(0, (Array.isArray(state.actions) ? state.actions.length : 0) - 1); + const parsed = Number.parseInt(String(nextStep), 10); + const safe = Number.isFinite(parsed) ? parsed : 0; + state.currentStep = Math.max(0, Math.min(safe, maxStep)); + if (state.currentStep !== previousStep) { + state.gitIdentityHelpVisible = false; + } + if (persist) writeSavedStep(state.currentStep); + return state.currentStep; + }; + + const getActionLevel = (actionId) => { + const id = String(actionId || '').trim(); + if (id === 'install-git') return 'required'; + if (id === 'configure-git-identity') return 'optional'; + if (id === 'install-gh' || id === 'gh-login') return 'optional'; + if (id === 'install-claude') return 'optional'; + if (id === 'install-codex') return 'optional'; + return 'recommended'; + }; + + const buildStepIconSvg = (iconMarkup) => ( + `` + ); + + const stepIconSvgByActionId = Object.freeze({ + 'install-git': buildStepIconSvg(''), + 'configure-git-identity': buildStepIconSvg(''), + 'install-node': buildStepIconSvg(''), + 'install-gh': buildStepIconSvg(''), + 'gh-login': buildStepIconSvg(''), + 'install-claude': buildStepIconSvg(''), + 'install-codex': buildStepIconSvg('') + }); + + const getStepIconSvg = (actionId) => { + const id = String(actionId || '').trim(); + return stepIconSvgByActionId[id] + || buildStepIconSvg(''); + }; + + const render = () => { + const toolsMap = toToolMap(state.diagnostics); + const req = getRequirementState(toolsMap); + const steps = getResolvedSteps(); + syncSkippedSteps(steps); + if (!steps.length) { + summaryEl.textContent = 'No setup actions are available for this platform.'; + listEl.innerHTML = '
No setup actions are available for this platform.
'; + return { req, steps, current: null }; + } + + setCurrentStep(state.currentStep, { persist: false }); + const current = steps[state.currentStep]; + const stepNo = state.currentStep + 1; + const totalSteps = steps.length; + const detectedCount = steps.filter((step) => step.done).length; + const doneRatio = totalSteps > 0 ? Math.round((detectedCount / totalSteps) * 100) : 0; + const missingCore = []; + if (!req.gitOk) missingCore.push('Git'); + if (!req.hasAgentCli) missingCore.push('Claude Code or Codex CLI'); + + if (state.showWelcome) { + summaryEl.textContent = ''; + listEl.innerHTML = ` +
+

Let’s get you ready in a minute.

+

+ We’ll check your system and install what’s needed. + Optional tools can be skipped. +

+
+ +
+
`; + return { req, steps, current }; + } + + summaryEl.textContent = ''; + + const currentId = String(current?.id || '').trim(); + const currentStepIconSvg = getStepIconSvg(currentId); + const currentTitle = this.escapeHtml(String(current?.title || currentId || 'Setup action')); + const currentDesc = this.escapeHtml(String(current?.description || '')); + const commandRaw = String(current?.command || ''); + const runInfo = state.actionRuns.get(currentId) || null; + const runStatus = String(runInfo?.status || '').trim().toLowerCase(); + const isRunning = runStatus === 'running'; + const isVerifying = runStatus === 'verifying'; + const isFinalizing = runStatus === 'success' || runStatus === 'completed'; + const isRunBusy = isRunning || isVerifying || isFinalizing; + const isGitIdentityStep = currentId === 'configure-git-identity'; + const runOutputAll = collectRunOutputLines(runInfo, { limit: 160 }); + const runOutput = runOutputAll.slice(-8); + const runOutputText = this.escapeHtml(runOutput.join('\n')); + const shouldShowInstallerOutput = currentId !== 'gh-login' && !isGitIdentityStep && ( + runOutput.length > 0 || + isRunBusy || + runStatus === 'failed' || + runStatus === 'needs-attention' + ); + const installerOutputText = runOutput.length + ? runOutputText + : this.escapeHtml( + isRunning + ? 'Installer started. Waiting for output...' + : (isVerifying + ? 'Installer finished. Verifying dependency...' + : 'No installer output captured yet.') + ); + const githubDeviceUrl = 'https://github.com/login/device'; + const ghInstalled = !!toolsMap.get('gh'); + const ghLoggedIn = !!toolsMap.get('ghAuth'); + const ghLoginRunInfo = state.actionRuns.get('gh-login') || null; + const ghLoginRunStatus = String(ghLoginRunInfo?.status || '').trim().toLowerCase(); + const ghLoginIsRunning = ghLoginRunStatus === 'running'; + const ghLoginIsVerifying = ghLoginRunStatus === 'verifying'; + const ghLoginIsFinalizing = ghLoginRunStatus === 'success' || ghLoginRunStatus === 'completed'; + const ghLoginIsBusy = ghLoginIsRunning || ghLoginIsVerifying || ghLoginIsFinalizing; + const ghLoginOutputAll = collectRunOutputLines(ghLoginRunInfo, { limit: 160 }); + const ghLoginInfo = extractGithubLoginInfo(ghLoginOutputAll); + const ghLoginLink = String(ghLoginRunInfo?.ghDeviceUrl || ghLoginInfo.link || githubDeviceUrl).trim() || githubDeviceUrl; + const ghLoginCode = String(ghLoginRunInfo?.ghDeviceCode || ghLoginInfo.code || '').trim().toUpperCase(); + const ghLoginHasSignal = !!( + ghLoginRunInfo?.ghHasDeviceHint + || ghLoginInfo.sawDeviceHint + || ghLoginCode + || ghLoginLink !== githubDeviceUrl + ); + const ghLoginUiPhase = (() => { + if (!ghInstalled || ghLoggedIn) return 'none'; + if (!ghLoginRunInfo) return 'start'; + if (ghLoginCode) return 'code'; + if (ghLoginIsBusy) return 'wait-code'; + return 'retry'; + })(); + const ghLoginInlineStatusText = (() => { + if (!ghInstalled) return 'Install GitHub CLI first'; + if (ghLoggedIn) return 'Logged in'; + if (ghLoginIsFinalizing) return 'Finalizing login'; + if (ghLoginIsRunning) return 'Signing in'; + if (ghLoginIsVerifying) return 'Checking login'; + return 'Not logged in'; + })(); + const ghLoginInlineStatusClass = ghLoggedIn + ? 'status-ok' + : ((ghLoginIsBusy || ghLoginRunStatus === 'needs-attention') ? 'status-pending' : 'status-missing'); + const ghLoginInlineRunLabel = (() => { + if (ghLoggedIn) return 'Logged in'; + if (ghLoginIsFinalizing) return 'Finalizing...'; + if (ghLoginIsBusy) return 'Waiting...'; + return 'Start login'; + })(); + const ghLoginInlineRunDisabled = !ghInstalled || ghLoggedIn || ghLoginIsBusy; + const showInlineGhLogin = currentId === 'install-gh' && ghInstalled; + const isGhLoginStep = currentId === 'gh-login'; + const codexNeedsNode = currentId === 'install-codex' && !(toolsMap.get('node') && toolsMap.get('npm')); + const gitIdentityName = this.escapeHtml(String(state.gitIdentity?.name || '')); + const gitIdentityEmail = this.escapeHtml(String(state.gitIdentity?.email || '')); + const showRunButton = current?.runSupported !== false && !isGitIdentityStep && !(isGhLoginStep && current?.done); + const runDisabled = !!current?.done || runStatus === 'verified' || isRunBusy || codexNeedsNode; + const runLabel = (() => { + if (current?.done || runStatus === 'verified') { + if (currentId === 'gh-login') return 'Logged in'; + if (currentId === 'configure-git-identity') return 'Configured'; + return 'Installed'; + } + if (isFinalizing) return 'Finalizing...'; + if (isRunBusy) return isGhLoginStep ? 'Waiting...' : 'Running...'; + if (currentId === 'gh-login') return 'Start login'; + return 'Run step'; + })(); + const baseStatusText = String(current?.statusText || (current?.done ? 'Installed' : 'Missing')); + const statusText = (() => { + if (runStatus === 'verified') return baseStatusText; + if (isFinalizing) return isGhLoginStep ? 'Finalizing login' : 'Finalizing'; + if (isRunning) return isGhLoginStep ? 'Signing in' : (isGitIdentityStep ? 'Saving' : 'Installing'); + if (isVerifying) return isGhLoginStep ? 'Checking login' : (isGitIdentityStep ? 'Checking' : 'Verifying'); + if (runStatus === 'failed') return isGhLoginStep ? 'Login failed' : (isGitIdentityStep ? 'Save failed' : 'Failed'); + return baseStatusText; + })(); + const statusClass = current?.done || runStatus === 'verified' + ? 'status-ok' + : ((isRunning || isVerifying || isFinalizing) ? 'status-pending' : (runStatus === 'failed' ? 'status-missing' : (current?.statusClass || 'status-missing'))); + let guidance = 'Run this step. We will detect completion automatically.'; + if (current?.done || runStatus === 'verified') { + guidance = isGhLoginStep + ? 'GitHub CLI is authenticated. Continue to the next step.' + : (currentId === 'install-gh' + ? 'GitHub CLI is installed. Optional next step: sign in with GitHub to enable PR and repo actions.' + : (isGitIdentityStep + ? 'Git identity is configured. Continue to the next step.' + : 'Already installed on this machine. Continue to the next step.')); + } else if (isRunning) { + guidance = isGhLoginStep + ? 'GitHub login started. Use the browser flow below and keep this window open; we recheck automatically.' + : (isGitIdentityStep + ? 'Saving Git identity now. Keep this window open and we will recheck automatically.' + : 'Installing now via PowerShell. Keep this window open and we will recheck automatically.'); + } else if (isVerifying) { + guidance = isGhLoginStep + ? 'Checking GitHub login automatically...' + : (isGitIdentityStep + ? 'Git identity saved. Checking your system automatically...' + : 'Install command finished. Checking your system automatically...'); + } else if (isFinalizing) { + guidance = isGhLoginStep + ? 'Login command finished. Finalizing and checking status automatically...' + : 'Install command finished. Finalizing and checking your system automatically...'; + } else if (runStatus === 'failed') { + const errorText = String(runInfo?.error || '').trim(); + guidance = errorText + ? `${isGhLoginStep ? 'Login failed' : (isGitIdentityStep ? 'Save failed' : 'Install failed')}: ${errorText}` + : `${isGhLoginStep ? 'Login failed' : (isGitIdentityStep ? 'Save failed' : 'Install failed')}. Review and run the step again.`; + } else if (runStatus === 'needs-attention') { + guidance = isGhLoginStep + ? (ghLoginHasSignal + ? 'GitHub login is not detected yet. If browser sign-in is complete, click Start login again to request a new code.' + : 'GitHub CLI did not return a one-time code. Click Start login again; if needed, reinstall GitHub CLI first.') + : (isGitIdentityStep + ? 'Git identity was saved, but it is still not detected. Check your values and save again.' + : 'Install command finished, but this dependency is still not detected. Review output below and run again.'); + } else if (isGitIdentityStep) { + guidance = 'Enter your Git name and email, then click Save identity. We will detect it automatically.'; + } else if (isGhLoginStep) { + guidance = 'Optional after GitHub CLI install. Start login when you are ready.'; + } else if (codexNeedsNode) { + guidance = 'Install Node.js LTS first. Codex uses npm and cannot be installed until Node is detected.'; + } else if (!current?.runSupported && current?.optional) { + guidance = 'Optional but strongly recommended: set Git user.name and user.email so commits and PR authorship are correct.'; + } else if (!current?.runSupported) { + guidance = 'Manual step: run the command below in your terminal. We will detect it automatically afterward.'; + } + if (currentId === 'install-gh' && (current?.done || runStatus === 'verified')) { + if (ghLoggedIn) { + guidance = 'GitHub CLI is installed and authenticated. You can continue.'; + } else if (ghLoginIsRunning) { + guidance = 'GitHub login started. Use the browser flow in this step and keep this window open.'; + } else if (ghLoginIsVerifying) { + guidance = 'Checking GitHub login automatically...'; + } else if (ghLoginIsFinalizing) { + guidance = 'Login command finished. Finalizing and checking status automatically...'; + } else if (ghLoginRunStatus === 'needs-attention') { + guidance = ghLoginHasSignal + ? 'GitHub login is not detected yet. If browser sign-in is complete, click Start login again to request a new code.' + : 'GitHub CLI did not return a one-time code. Click Start login again.'; + } + } + const canAdvance = !!current?.done || !!current?.optional; + const nextLabel = !canAdvance + ? 'Complete this step first' + : (!current?.done && current?.optional + ? 'Skip' + : (stepNo >= totalSteps ? 'Finish onboarding' : 'Next step')); + + listEl.innerHTML = ` +
+ ${steps.map((step, idx) => { + const isActive = idx === state.currentStep; + const actionId = String(step?.id || '').trim(); + const isSkipped = state.skippedActionIds.has(actionId); + const isDone = step.done || isSkipped; + const isPast = idx < state.currentStep; + const isFuture = idx > state.currentStep; + let statusClass = 'stepper-upcoming'; + if (isActive) { + statusClass = 'stepper-active'; + } else if (isPast && isDone) { + statusClass = 'stepper-done'; + } else { + statusClass = 'stepper-upcoming'; + } + const stepStateLabel = isActive + ? 'Current step' + : (isPast && isDone ? 'Completed' : (isFuture ? 'Upcoming' : 'Pending')); + return ` +
+
+ ${isActive ? `Step ${stepNo}` : ''} +
+
+
+ `; + }).join('')} +
+ +
+
+ ${currentStepIconSvg} +
+
+

${currentTitle}

+ +
+ ${current?.done ? '' : ''} +

${currentDesc} ${statusText ? `(${statusText})` : ''}

+
+ + ${isGitIdentityStep ? ` +
+
+ + + +
+
+ ` : ''} + + ${showInlineGhLogin ? ` + + ` : ''} + + ${shouldShowInstallerOutput ? ` +
+
${installerOutputText}
+
+ ` : ''} + +
+ ${showRunButton ? `` : ''} + ${!isGhLoginStep && !isGitIdentityStep ? `` : ''} +
+
+
+ +
+ + +
`; + + return { req, steps, current }; + }; + + const closeModal = ({ force = false } = {}) => { + const locked = applyOnboardingLockUI(); + traceOnboarding('modal-close-requested', { + force, + locked + }); + if (!force && locked) { + openModal(); + return false; + } + modal.classList.add('hidden'); + body?.classList?.remove?.('dependency-onboarding-active'); + setBootstrapPending(false); + return true; + }; + const openModal = ({ showWelcome = null } = {}) => { + const wasHidden = modal.classList.contains('hidden'); + traceOnboarding('modal-opened', { + wasHidden, + requestedShowWelcome: typeof showWelcome === 'boolean' ? showWelcome : null + }); + modal.classList.remove('hidden'); + setBootstrapPending(false); + body?.classList?.add?.('dependency-onboarding-active'); + if (typeof showWelcome === 'boolean') { + state.showWelcome = showWelcome; + } else if (wasHidden) { + state.showWelcome = true; + } + if (state.diagnostics && Array.isArray(state.actions) && state.actions.length > 0) { + render(); + } + applyOnboardingLockUI(); + }; + + const setLoading = (loading) => { + state.loading = !!loading; + if (openBtn) openBtn.disabled = state.loading; + if (state.loading) { + summaryEl.textContent = ''; + } + }; + + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms) || 0))); + + const loadAndRender = async ({ open = false, forceAutoShow = false, bootstrap = false, explicitOpen = false } = {}) => { + if (state.loading) return false; + traceOnboarding('load-start', { + open, + forceAutoShow, + bootstrap, + explicitOpen + }); + setLoading(true); + try { + const [diagRes, actionsRes] = await Promise.all([ + fetch('/api/diagnostics', this.withLaunchTraceHeaders()), + fetch('/api/setup-actions', this.withLaunchTraceHeaders()) + ]); + const diagData = await diagRes.json().catch(() => ({})); + const actionsData = await actionsRes.json().catch(() => ({})); + + if (!diagRes.ok || diagData?.ok === false) { + throw new Error(String(diagData?.error || `Diagnostics HTTP ${diagRes.status}`)); + } + if (!actionsRes.ok || actionsData?.ok === false) { + throw new Error(String(actionsData?.error || `Setup actions HTTP ${actionsRes.status}`)); + } + + state.diagnostics = diagData; + hydrateGitIdentityDraft(diagData); + const allActions = Array.isArray(actionsData?.actions) ? actionsData.actions : []; + const toolsMap = toToolMap(diagData); + state.actions = allActions.filter((action) => String(action?.id || '').trim() !== 'gh-login'); + const allowedActionIds = new Set( + state.actions + .map((action) => String(action?.id || '').trim()) + .filter(Boolean) + ); + const persistedSkippedIds = readSkippedStepIds(); + state.skippedActionIds = new Set( + Array.from(persistedSkippedIds).filter((id) => allowedActionIds.has(id)) + ); + if (state.actions.length > 0) { + const savedStep = readSavedStep(); + setCurrentStep(savedStep, { persist: false }); + } + const view = render(); + applyOnboardingLockUI(); + if (view.req?.coreReady) writeDismissed(false); + + let hasCompletedOnboarding = readCompleted(); + const coreReady = !!view.req?.coreReady; + if (isDesktopWindowsApp && coreReady && !hasCompletedOnboarding) { + traceOnboarding('auto-complete-triggered', { + coreReady, + hasCompletedOnboarding + }); + await writeCompleted(true); + hasCompletedOnboarding = readCompleted(); + } + const dismissed = readDismissed(); + const shouldAutoShow = isWindowsHost && !hasCompletedOnboarding && (forceAutoShow || !readDismissed()); + const shouldKeepVisible = open && !modal.classList.contains('hidden'); + traceOnboarding('load-success', { + open, + forceAutoShow, + bootstrap, + explicitOpen, + coreReady, + hasCompletedOnboarding, + dismissed, + shouldAutoShow, + shouldKeepVisible, + actionIds: state.actions.map((action) => String(action?.id || '').trim()).filter(Boolean), + toolStates: Array.isArray(diagData?.tools) + ? diagData.tools.map((tool) => ({ + id: String(tool?.id || '').trim(), + ok: !!tool?.ok + })).filter((tool) => tool.id) + : [] + }); + if (explicitOpen || shouldKeepVisible || shouldAutoShow) { + openModal(); + } else { + setBootstrapPending(false); + } + return true; + } catch (err) { + traceOnboarding('load-failed', { + open, + forceAutoShow, + bootstrap, + explicitOpen, + error: String(err?.message || err) + }); + summaryEl.textContent = `Dependency check failed: ${String(err?.message || err)}`; + listEl.innerHTML = '
Unable to load setup actions right now.
'; + const shouldOpenOnError = explicitOpen || (open && !modal.classList.contains('hidden')); + if (shouldOpenOnError) openModal(); + else if (!bootstrap) setBootstrapPending(false); + return false; + } finally { + setLoading(false); + } + }; + + const stopRunPolling = (actionId) => { + const id = String(actionId || '').trim(); + if (!id) return; + const poller = state.actionRunPollers.get(id); + if (poller?.timer) clearTimeout(poller.timer); + state.actionRunPollers.delete(id); + }; + + const updateActionRunState = (actionId, patch = {}, { rerender = true } = {}) => { + const id = String(actionId || '').trim(); + if (!id) return null; + const prev = state.actionRuns.get(id) || { actionId: id }; + const next = { + ...prev, + ...patch, + actionId: id + }; + state.actionRuns.set(id, next); + if (rerender) render(); + return next; + }; + + const fetchSetupActionRunStatus = async ({ runId = '', actionId = '' } = {}) => { + const params = new URLSearchParams(); + if (runId) params.set('runId', String(runId)); + if (actionId) params.set('actionId', String(actionId)); + const res = await fetch(`/api/setup-actions/run-status?${params.toString()}`); + const data = await res.json().catch(() => ({})); + if (!res.ok || data?.ok === false || !data?.run) { + throw new Error(String(data?.error || `HTTP ${res.status}`)); + } + return data.run; + }; + + const getVerifyPolicyForAction = (actionId) => { + const id = String(actionId || '').trim(); + if (id === 'gh-login') { + return { attempts: 14, delayMs: 900 }; + } + if (id === 'install-git' || id === 'install-node' || id === 'install-gh') { + return { attempts: 10, delayMs: 650 }; + } + return { attempts: 8, delayMs: 650 }; + }; + + const verifyActionInstalled = async (actionId, runId, options = {}) => { + const id = String(actionId || '').trim(); + if (!id) return false; + const policy = { + ...getVerifyPolicyForAction(id), + ...(options && typeof options === 'object' ? options : {}) + }; + const attempts = Math.max(1, Number(policy.attempts) || 1); + const delayMs = Math.max(250, Number(policy.delayMs) || 650); + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const runState = state.actionRuns.get(id); + if (!runState || String(runState?.runId || '') !== String(runId || '')) return false; + + updateActionRunState(id, { + status: 'verifying', + verifyAttempt: attempt, + verifyMax: attempts, + updatedAt: new Date().toISOString() + }); + + await loadAndRender({ open: true, forceAutoShow: true }); + const toolsMap = toToolMap(state.diagnostics); + if (isActionComplete(id, toolsMap)) { + updateActionRunState(id, { + status: 'verified', + verifyAttempt: attempts, + verifyMax: attempts, + updatedAt: new Date().toISOString() + }); + this.showToast('Dependency detected automatically.', 'success'); + return true; + } + await sleep(delayMs); + } + + updateActionRunState(id, { + status: 'needs-attention', + updatedAt: new Date().toISOString() + }); + this.showToast( + id === 'gh-login' + ? 'GitHub login is not detected yet. Complete sign-in in browser and try again.' + : 'Install finished but dependency is still missing. Review output and run again if needed.', + 'warning' + ); + return false; + }; + + const verifyActionWithoutRun = async (actionId, options = {}) => { + const id = String(actionId || '').trim(); + if (!id) return false; + const policy = { + ...getVerifyPolicyForAction(id), + ...(options && typeof options === 'object' ? options : {}) + }; + const attempts = Math.max(1, Number(policy.attempts) || 1); + const delayMs = Math.max(250, Number(policy.delayMs) || 650); + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + updateActionRunState(id, { + status: 'verifying', + verifyAttempt: attempt, + verifyMax: attempts, + updatedAt: new Date().toISOString() + }); + + await loadAndRender({ open: true, forceAutoShow: true }); + const toolsMap = toToolMap(state.diagnostics); + if (isActionComplete(id, toolsMap)) { + updateActionRunState(id, { + status: 'verified', + verifyAttempt: attempts, + verifyMax: attempts, + updatedAt: new Date().toISOString() + }); + return true; + } + if (attempt < attempts) { + await sleep(delayMs); + } + } + + updateActionRunState(id, { + status: 'needs-attention', + updatedAt: new Date().toISOString() + }); + return false; + }; + + const pollRunUntilDone = async (actionId, runId) => { + const id = String(actionId || '').trim(); + const rid = String(runId || '').trim(); + if (!id || !rid) return; + + stopRunPolling(id); + const pollLoop = async () => { + try { + const run = await fetchSetupActionRunStatus({ runId: rid, actionId: id }); + updateActionRunState(id, run); + + if (String(run?.status || '').toLowerCase() === 'running') { + const timer = setTimeout(pollLoop, 850); + state.actionRunPollers.set(id, { runId: rid, timer }); + return; + } + + stopRunPolling(id); + await loadAndRender({ open: true, forceAutoShow: true }); + + if (String(run?.status || '').toLowerCase() === 'success') { + await verifyActionInstalled(id, rid); + return; + } + + if (String(run?.status || '').toLowerCase() === 'failed') { + if (id === 'gh-login') { + const ghRunLines = collectRunOutputLines(run, { limit: 160 }); + const ghRunInfo = extractGithubLoginInfo(ghRunLines); + const hasDeviceSignal = ghRunInfo.sawDeviceHint || !!ghRunInfo.code || /login\/device/i.test(ghRunLines.join('\n')); + const verifyOptions = hasDeviceSignal + ? { attempts: 14, delayMs: 900 } + : { attempts: 5, delayMs: 700 }; + const detected = await verifyActionWithoutRun(id, verifyOptions); + if (detected) { + this.showToast('GitHub login detected automatically.', 'success'); + return; + } + + const runError = String(run?.error || '').trim(); + const exitCode = Number(run?.exitCode); + const interrupted = exitCode === 1 || /code\s*1/i.test(runError) || /cancel|not completed/i.test(runError); + const missingDeviceCode = !hasDeviceSignal; + updateActionRunState(id, { + status: 'needs-attention', + error: interrupted + ? 'Login was not completed in browser.' + : (missingDeviceCode + ? 'GitHub CLI did not return a one-time code.' + : (runError || 'Login was not completed.')), + updatedAt: new Date().toISOString() + }); + this.showToast( + missingDeviceCode + ? 'GitHub CLI did not return a one-time code. Click Start login again.' + : (interrupted + ? 'GitHub login is still not detected. If browser sign-in just finished, click Start login again.' + : `GitHub login failed: ${runError || 'Unknown error'}`), + (interrupted || missingDeviceCode) ? 'warning' : 'error' + ); + return; + } + this.showToast(`Install failed: ${String(run?.error || 'Unknown error')}`, 'error'); + } + } catch (err) { + stopRunPolling(id); + updateActionRunState(id, { + status: 'failed', + error: String(err?.message || err || 'Failed to fetch setup status'), + updatedAt: new Date().toISOString() + }); + this.showToast(`Install monitoring failed: ${String(err?.message || err)}`, 'error'); + } + }; + + const timer = setTimeout(pollLoop, 250); + state.actionRunPollers.set(id, { runId: rid, timer }); + }; + + const runBootstrapLoad = async () => { + if (isDesktopWindowsApp && readCompleted()) { + setBootstrapPending(false); + return; + } + setBootstrapPending(true); + const delaysMs = [0, 240, 420, 700, 1050, 1450, 1900]; + for (let attempt = 0; attempt < delaysMs.length; attempt += 1) { + if (attempt > 0) { + await sleep(delaysMs[attempt]); + } + const ok = await loadAndRender({ open: false, forceAutoShow: false, bootstrap: true }); + if (ok) return; + } + setBootstrapPending(false); + }; + + const runSetupAction = async (actionId, btnEl) => { + const id = String(actionId || '').trim(); + if (!id) return; + const button = btnEl || null; + if (button) button.disabled = true; + try { + const existingRunStatus = String(state.actionRuns.get(id)?.status || '').trim().toLowerCase(); + if (existingRunStatus === 'running' || existingRunStatus === 'verifying' || existingRunStatus === 'success' || existingRunStatus === 'completed') { + this.showToast( + id === 'gh-login' + ? 'Login is still in progress. Please wait while we finish checking.' + : 'Install is still in progress. Please wait while we finish checking.', + 'info' + ); + return; + } + + const existingStep = getResolvedSteps().find((step) => String(step?.id || '').trim() === id); + const toolsMap = toToolMap(state.diagnostics); + if (id === 'gh-login' && !toToolMap(state.diagnostics).get('gh')) { + updateActionRunState(id, { + status: 'needs-attention', + error: 'Install GitHub CLI before starting login.', + updatedAt: new Date().toISOString() + }); + this.showToast('Install GitHub CLI first. Login is optional and only available after installation.', 'warning'); + await loadAndRender({ open: true, forceAutoShow: true }); + return; + } + if (id === 'install-codex' && !(toolsMap.get('node') && toolsMap.get('npm'))) { + updateActionRunState(id, { + status: 'needs-attention', + error: 'Install Node.js LTS first. Codex requires npm.', + updatedAt: new Date().toISOString() + }); + this.showToast('Install Node.js LTS first. Codex depends on npm.', 'warning'); + await loadAndRender({ open: true, forceAutoShow: true }); + return; + } + if (existingStep?.done) { + updateActionRunState(id, { + status: 'verified', + updatedAt: new Date().toISOString() + }); + this.showToast('Dependency already detected.', 'success'); + await loadAndRender({ open: true, forceAutoShow: true }); + return; + } + + updateActionRunState(id, { + runId: null, + status: 'running', + error: null, + output: [], + verifyAttempt: 0, + verifyMax: 0, + updatedAt: new Date().toISOString() + }); + const res = await fetch('/api/setup-actions/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ actionId: id }) + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data?.ok === false) { + throw new Error(String(data?.error || `HTTP ${res.status}`)); + } + const run = (data?.run && typeof data.run === 'object') ? data.run : null; + if (run) { + updateActionRunState(id, { + ...run, + verifyAttempt: 0, + verifyMax: 0 + }); + if (run?.runId) { + await pollRunUntilDone(id, run.runId); + } else { + await loadAndRender({ open: true, forceAutoShow: true }); + } + } else { + await loadAndRender({ open: true, forceAutoShow: true }); } - if (statusEl) statusEl.textContent = 'Safe auto-fix completed'; - } catch (error) { - this.showToast?.(`Safe auto-fix failed: ${String(error?.message || error)}`, 'error'); - if (statusEl) statusEl.textContent = ''; + const defaultMessage = data?.alreadyRunning + ? (id === 'gh-login' + ? 'GitHub login is already running. Complete it in your browser.' + : 'Install is already running. Watching for completion...') + : (id === 'gh-login' + ? 'GitHub login started. Complete sign-in in your browser.' + : 'Install started. We will check this step automatically.'); + this.showToast(String(data?.message || defaultMessage), 'info'); + } catch (err) { + updateActionRunState(id, { + status: 'failed', + error: String(err?.message || err), + updatedAt: new Date().toISOString() + }); + this.showToast(`Failed to start action: ${String(err?.message || err)}`, 'error'); } finally { - btnRepairSafe.disabled = false; + if (button) button.disabled = false; } - }); - repairEl?.addEventListener('click', async (event) => { - const target = event.target.closest('[data-diagnostics-repair]'); - if (!target) return; - const action = String(target.getAttribute('data-diagnostics-repair') || '').trim(); - if (!action) return; - target.disabled = true; - if (statusEl) statusEl.textContent = `Running repair: ${action}…`; + }; + + const saveGitIdentity = async (btnEl) => { + const button = btnEl || null; + const id = 'configure-git-identity'; + const nameInput = listEl.querySelector('[data-setup-git-name]'); + const emailInput = listEl.querySelector('[data-setup-git-email]'); + const name = String(nameInput?.value || state.gitIdentity?.name || '').trim(); + const email = String(emailInput?.value || state.gitIdentity?.email || '').trim(); + + state.gitIdentity.name = name; + state.gitIdentity.email = email; + + if (!name || !email) { + this.showToast('Enter both Git name and email.', 'warning'); + return; + } + + if (button) button.disabled = true; try { - const res = await fetch('/api/diagnostics/first-run/repair', { + updateActionRunState(id, { + runId: 'manual-git-identity', + status: 'running', + error: null, + output: [], + verifyAttempt: 0, + verifyMax: 0, + updatedAt: new Date().toISOString() + }); + + const res = await fetch('/api/setup-actions/configure-git-identity', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action }) + body: JSON.stringify({ name, email }) }); const data = await res.json().catch(() => ({})); if (!res.ok || data?.ok === false) { - throw new Error(String(data?.error || data?.message || `HTTP ${res.status}`)); + throw new Error(String(data?.error || `HTTP ${res.status}`)); } - const repair = data?.repair || {}; - if (repair.manual) { - this.showToast?.(String(repair?.message || 'Manual action required'), 'warning'); + + state.gitIdentity.name = String(data?.name || name).trim(); + state.gitIdentity.email = String(data?.email || email).trim(); + + const detected = await verifyActionWithoutRun(id, { attempts: 6, delayMs: 350 }); + if (detected) { + this.showToast('Git identity saved and detected automatically.', 'success'); } else { - this.showToast?.(String(repair?.message || 'Repair completed'), 'success'); + this.showToast('Git identity saved, but detection is delayed. Try saving again in a few seconds.', 'warning'); } - if (data?.diagnostics) { - state.firstRun = data.diagnostics; - renderRepairActions(state.firstRun); - } else { - await refreshFirstRun(); - } - if (!state.base) await refreshBase(); - await refreshInstallWizard().catch(() => {}); - render(state.base, state.firstRun, state.wizard); - if (statusEl) statusEl.textContent = `Repair completed: ${action}`; - } catch (error) { - this.showToast?.(`Repair failed: ${String(error?.message || error)}`, 'error'); - if (statusEl) statusEl.textContent = ''; + } catch (err) { + updateActionRunState(id, { + status: 'failed', + error: String(err?.message || err), + updatedAt: new Date().toISOString() + }); + this.showToast(`Failed to save Git identity: ${String(err?.message || err)}`, 'error'); } finally { - target.disabled = false; + if (button) button.disabled = false; + await loadAndRender({ open: true, forceAutoShow: true }); + } + }; + + listEl.addEventListener('click', async (event) => { + const runBtn = event.target.closest('[data-setup-run]'); + if (runBtn) { + await runSetupAction(runBtn.getAttribute('data-setup-run'), runBtn); + return; + } + + const saveGitBtn = event.target.closest('[data-setup-git-save]'); + if (saveGitBtn) { + await saveGitIdentity(saveGitBtn); + return; + } + + const prevBtn = event.target.closest('[data-setup-prev]'); + if (prevBtn) { + setCurrentStep(state.currentStep - 1); + render(); + return; + } + + const nextBtn = event.target.closest('[data-setup-next]'); + if (nextBtn) { + const total = Array.isArray(state.actions) ? state.actions.length : 0; + const steps = getResolvedSteps(); + syncSkippedSteps(steps); + const currentStep = steps[state.currentStep]; + if (!currentStep?.done) { + if (!currentStep?.optional) { + this.showToast('Install this dependency before continuing.', 'warning'); + return; + } + setStepSkipped(currentStep?.id, true); + this.showToast('Skipping optional setup for now. You can configure it later.', 'warning'); + } else { + setStepSkipped(currentStep?.id, false); + } + if (state.currentStep >= (total - 1)) { + await writeCompleted(true); + writeDismissed(false); + closeModal({ force: true }); + this.showToast('Dependency onboarding complete.', 'success'); + return; + } + setCurrentStep(state.currentStep + 1); + render(); + return; + } + const beginBtn = event.target.closest('[data-setup-begin]'); + if (beginBtn) { + state.showWelcome = false; + render(); + return; + } + + const jumpBtn = event.target.closest('[data-setup-jump]'); + if (jumpBtn) { + const idx = Number.parseInt(String(jumpBtn.getAttribute('data-setup-jump') || ''), 10); + if (Number.isFinite(idx)) { + setCurrentStep(idx); + render(); + } + return; + } + + const copyBtn = event.target.closest('[data-setup-copy-id]'); + if (copyBtn) { + const actionId = String(copyBtn.getAttribute('data-setup-copy-id') || '').trim(); + const action = (Array.isArray(state.actions) ? state.actions : []).find((item) => String(item?.id || '').trim() === actionId); + const command = String(action?.command || '').trim(); + if (!command) return; + try { + await navigator.clipboard.writeText(command); + this.showToast('Command copied to clipboard.', 'success'); + } catch (err) { + this.showToast(`Copy failed: ${String(err?.message || err)}`, 'error'); + } + return; + } + + const copyGhCodeBtn = event.target.closest('[data-setup-copy-gh-code]'); + if (copyGhCodeBtn) { + event.preventDefault(); + event.stopPropagation(); + const code = String(copyGhCodeBtn.getAttribute('data-setup-copy-gh-code') || '').trim(); + if (!code) return; + try { + await navigator.clipboard.writeText(code); + this.showToast('GitHub one-time code copied.', 'success'); + } catch (err) { + this.showToast(`Copy failed: ${String(err?.message || err)}`, 'error'); + } + return; + } + + const openGhLoginBtn = event.target.closest('[data-setup-open-gh-login]'); + if (openGhLoginBtn) { + event.preventDefault(); + event.stopPropagation(); + const link = String(openGhLoginBtn.getAttribute('data-setup-open-gh-login') || '').trim(); + if (!link) return; + try { + const res = await fetch('/api/setup-actions/open-url', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: link }) + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data?.ok === false) { + throw new Error(String(data?.error || `HTTP ${res.status}`)); + } + this.showToast('Opened GitHub login in your browser.', 'info'); + } catch (err) { + this.showToast(`Could not open login link: ${String(err?.message || err)}`, 'error'); + } + return; + } + + const toggleGitHelpBtn = event.target.closest('[data-setup-toggle-git-help]'); + if (toggleGitHelpBtn) { + event.preventDefault(); + event.stopPropagation(); + state.gitIdentityHelpVisible = !state.gitIdentityHelpVisible; + render(); + return; } }); - } + + if (openBtn) { + openBtn.addEventListener('click', () => { + this.openDependencySetupWizard?.({ resetStep: true, source: 'manual-open-button' }); + }); + } + if (closeBtn) { + closeBtn.addEventListener('click', () => closeModal()); + } + + modal.addEventListener('click', (event) => { + if (event.target === modal) closeModal(); + }); + + document.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') return; + if (modal.classList.contains('hidden')) return; + closeModal(); + }); + + this.syncDependencySetupWizardPreferences = () => { + syncDesktopCompleted(); + traceOnboarding('preferences-synced', { + desktopCompleted + }); + if (isDesktopWindowsApp && desktopCompleted) { + setBootstrapPending(false); + } + applyOnboardingLockUI(); + }; + this.openDependencySetupWizard = ({ resetStep = true, source = 'manual-open' } = {}) => { + traceOnboarding('manual-open-requested', { + source, + resetStep + }); + writeDismissed(false); + if (resetStep) setCurrentStep(0); + return loadAndRender({ open: true, forceAutoShow: true, explicitOpen: true }); + }; + this.bootstrapDependencySetupWizard = () => { + syncDesktopCompleted(); + traceOnboarding('bootstrap-requested', { + desktopCompleted + }); + if (isDesktopWindowsApp && readCompleted()) { + setBootstrapPending(false); + traceOnboarding('bootstrap-skipped', { + reason: 'already-completed' + }); + return Promise.resolve(false); + } + return runBootstrapLoad(); + }; + if (this.userSettings) { + void this.bootstrapDependencySetupWizard(); + } + } notifyWorkflow({ type = 'info', message = '', sessionId = null, metadata = null } = {}) { const msg = String(message || '').trim(); @@ -9353,46 +11014,59 @@ class ClaudeOrchestrator { } } } - - showToast(message, type = 'info') { + + showToast(message, type = 'info', options = {}) { + const rawMessage = String(message || '').trim(); + if (!rawMessage) return; + + const normalizedType = (['info', 'success', 'warning', 'error'].includes(type)) ? type : 'info'; + const durationMsRaw = Number(options?.durationMs); + const durationMs = Number.isFinite(durationMsRaw) ? Math.max(1200, durationMsRaw) : 5000; + + let stack = document.getElementById('toast-stack'); + if (!stack) { + stack = document.createElement('div'); + stack.id = 'toast-stack'; + stack.className = 'toast-stack'; + document.body.appendChild(stack); + } + + const iconByType = { + info: '', + success: '', + warning: '', + error: '' + }; + const toast = document.createElement('div'); - toast.className = `toast toast-${type}`; + toast.className = `toast toast-${normalizedType}`; + toast.setAttribute('role', 'status'); toast.innerHTML = `
- ${type === 'success' ? '✅' : type === 'warning' ? '⚠️' : type === 'error' ? '❌' : 'ℹ️'} - ${message} + ${iconByType[normalizedType] || iconByType.info} + ${this.escapeHtml(rawMessage)}
+ `; - - // Add styles for different toast types - const styles = { - info: 'var(--accent-primary)', - success: 'var(--accent-success)', - warning: 'var(--accent-warning)', - error: 'var(--accent-danger)' - }; - - toast.style.cssText = ` - position: fixed; - top: calc(var(--header-height) + 20px); - right: 20px; - background: ${styles[type]}; - color: white; - padding: var(--space-sm) var(--space-md); - border-radius: var(--radius-md); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - z-index: 1000; - animation: slideInRight 0.3s ease-out, fadeOutRight 0.3s ease-in 4.7s forwards; - `; - - document.body.appendChild(toast); - - // Remove after 5 seconds - setTimeout(() => { - if (toast.parentNode) { + + const removeToast = () => { + if (!toast.parentNode) return; + if (toast.classList.contains('is-leaving')) return; + toast.classList.add('is-leaving'); + setTimeout(() => { toast.remove(); - } - }, 5000); + if (stack && !stack.children.length) { + stack.remove(); + } + }, 240); + }; + + const closeBtn = toast.querySelector('.toast-close'); + closeBtn?.addEventListener('click', removeToast); + + stack.appendChild(toast); + requestAnimationFrame(() => toast.classList.add('is-visible')); + setTimeout(removeToast, durationMs); } async launchDiffViewer(githubUrl) { @@ -9400,9 +11074,9 @@ class ClaudeOrchestrator { const prMatch = githubUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\/pull\/(\d+)/); const commitMatch = githubUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\/commit\/([a-f0-9]{40})/); const compareMatch = githubUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\/compare\/([^?#]+)/); - + let diffViewerPath = ''; - + if (prMatch) { const [, owner, repo, pr] = prMatch; diffViewerPath = `/pr/${owner}/${repo}/${pr}`; @@ -9430,7 +11104,7 @@ class ClaudeOrchestrator { this.showToast('Unable to parse GitHub URL', 'error'); return; } - + // Open a placeholder tab immediately (avoids popup blockers), then redirect once ready. const popup = window.open('', '_blank'); if (!popup) { @@ -12743,19 +14417,7 @@ class ClaudeOrchestrator { }); bodyEl.querySelectorAll?.('[data-open-diagnostics="true"]')?.forEach?.((btn) => { btn.addEventListener('click', () => { - try { - document.getElementById('settings-panel')?.classList?.remove?.('hidden'); - setTimeout(() => { - try { - document.getElementById('diagnostics-output')?.scrollIntoView?.({ behavior: 'smooth', block: 'start' }); - } catch {} - try { - document.getElementById('diagnostics-refresh')?.click?.(); - } catch {} - }, 50); - } catch { - // ignore - } + this.openDiagnosticsPanel({ refresh: true }); }); }); bodyEl.querySelectorAll('[data-pr-refresh]').forEach((btn0) => { @@ -12899,7 +14561,7 @@ class ClaudeOrchestrator { // Check URL params first const urlParams = new URLSearchParams(window.location.search); const tokenFromUrl = urlParams.get('token'); - + if (tokenFromUrl) { // Save to localStorage for future use localStorage.setItem('claude-orchestrator-token', tokenFromUrl); @@ -12907,7 +14569,7 @@ class ClaudeOrchestrator { window.history.replaceState({}, document.title, window.location.pathname); return tokenFromUrl; } - + // Check localStorage return localStorage.getItem('claude-orchestrator-token'); } @@ -13733,7 +15395,7 @@ class ClaudeOrchestrator { this.showToast?.(`Pruned ${prunedCount} old recoverable session(s)`, 'success'); return true; } - + installAuthFetchShim() { if (window.__claudeOrchestratorFetchAuthInstalled) return; if (typeof window.fetch !== 'function') return; @@ -13777,7 +15439,7 @@ class ClaudeOrchestrator { window.__claudeOrchestratorFetchAuthInstalled = true; } - + // Terminal Focus Feature - Now shows only that worktree focusTerminal(sessionId) { // Extract worktree ID from session ID @@ -13815,14 +15477,14 @@ class ClaudeOrchestrator { console.error(`Terminal instance not found for ${sessionId}`); return; } - + // Store original parent for unfocus const terminalElement = terminalWrapper.querySelector('.terminal'); if (!terminalElement) { console.error(`Terminal element not found in wrapper for ${sessionId}`); return; } - + this.focusedTerminalInfo = { sessionId: sessionId, originalParent: terminalElement.parentElement, @@ -13834,41 +15496,41 @@ class ClaudeOrchestrator { rows: xtermInstance.rows || 24 } }; - + // Add focusing animation to original terminal terminalWrapper.classList.add('focusing'); - + // Update overlay header const focusedTitle = document.getElementById('focused-title'); const focusedBranch = document.getElementById('focused-branch'); const focusedStatus = document.getElementById('focused-status'); - + const isAgentSession = /-(claude|codex)$/.test(String(sessionId || '')); const worktreeNumber = sessionId.split('-')[0].replace('work', ''); - + if (focusedTitle) focusedTitle.textContent = `${isAgentSession ? '🤖 Agent' : '💻 Server'} ${worktreeNumber}`; if (focusedBranch) focusedBranch.textContent = this.formatBranchLabel(session.branch || '', { context: 'terminal' }).text || ''; if (focusedStatus) focusedStatus.className = `status-indicator ${session.status || 'idle'}`; - + // Move the actual terminal element to focused container const focusedTerminalBody = document.getElementById('focused-terminal-body'); if (!focusedTerminalBody) { console.error('Focused terminal body container not found'); return; } - + focusedTerminalBody.innerHTML = ''; focusedTerminalBody.appendChild(terminalElement); - + // Hide original wrapper terminalWrapper.style.visibility = 'hidden'; - + // Activate focus overlay with animation const focusOverlay = document.getElementById('focus-overlay'); if (focusOverlay) { focusOverlay.classList.add('active'); } - + // Bind ESC key for unfocus this.handleEscKey = (e) => { if (e.key === 'Escape') { @@ -13876,41 +15538,41 @@ class ClaudeOrchestrator { } }; document.addEventListener('keydown', this.handleEscKey); - + // Resize terminal to fit the focused container after animation setTimeout(() => { try { // Store original font size this.focusedTerminalInfo.originalFontSize = xtermInstance.options.fontSize || 12; - + // Increase font size for better readability in focused mode const originalSize = this.focusedTerminalInfo.originalFontSize; const newFontSize = Math.round(originalSize * 1.8); // 1.8x larger (reduced from 3x by ~60%) xtermInstance.options.fontSize = newFontSize; - + const rect = focusedTerminalBody.getBoundingClientRect(); // Calculate new dimensions based on container size with larger font const charWidth = newFontSize * 0.6; // Approximate character width const lineHeight = newFontSize * 1.4; // Approximate line height - + const cols = Math.floor((rect.width - 30) / charWidth); const rows = Math.floor((rect.height - 30) / lineHeight); - + // Apply reasonable limits const finalCols = Math.min(200, Math.max(80, cols)); const finalRows = Math.min(80, Math.max(24, rows)); - + console.log(`Resizing focused terminal from ${xtermInstance.cols}x${xtermInstance.rows} to ${finalCols}x${finalRows} with font size ${newFontSize}px`); - + // Resize xterm xtermInstance.resize(finalCols, finalRows); - + // Use fit addon if available const fitAddon = this.terminalManager?.fitAddons?.get(sessionId); if (fitAddon) { fitAddon.fit(); } - + // Send resize command to backend if (this.socket) { this.socket.emit('resize', { @@ -13919,46 +15581,46 @@ class ClaudeOrchestrator { rows: finalRows }); } - + // Focus the terminal for input xtermInstance.focus(); } catch (resizeError) { console.error('Error resizing focused terminal:', resizeError); } }, 200); - + // Remove focusing animation after transition setTimeout(() => { terminalWrapper.classList.remove('focusing'); }, 300); - + } catch (error) { console.error('Error focusing terminal:', error); } } - + unfocusTerminal() { try { if (!this.focusedTerminalInfo) return; - + const { sessionId, originalParent, originalNextSibling, terminalElement, terminalWrapper, originalDimensions } = this.focusedTerminalInfo; - + // Move terminal element back to original location if (originalNextSibling) { originalParent.insertBefore(terminalElement, originalNextSibling); } else { originalParent.appendChild(terminalElement); } - + // Show original wrapper terminalWrapper.style.visibility = 'visible'; - + // Deactivate focus overlay const focusOverlay = document.getElementById('focus-overlay'); if (focusOverlay) { focusOverlay.classList.remove('active'); } - + // Restore original terminal size and font const xtermInstance = this.terminalManager?.terminals?.get(sessionId); if (xtermInstance) { @@ -13966,21 +15628,21 @@ class ClaudeOrchestrator { const originalFontSize = this.focusedTerminalInfo.originalFontSize || 12; console.log(`Restoring font size from ${xtermInstance.options.fontSize}px to ${originalFontSize}px`); xtermInstance.options.fontSize = originalFontSize; - + // Force a refresh of the terminal to apply font change xtermInstance.refresh(0, xtermInstance.rows - 1); - + if (originalDimensions) { setTimeout(() => { console.log(`Restoring terminal dimensions to ${originalDimensions.cols}x${originalDimensions.rows}`); xtermInstance.resize(originalDimensions.cols, originalDimensions.rows); - + // Use fit addon if available const fitAddon = this.terminalManager?.fitAddons?.get(sessionId); if (fitAddon) { setTimeout(() => fitAddon.fit(), 50); } - + // Send resize command to backend if (this.socket) { this.socket.emit('resize', { @@ -13992,10 +15654,10 @@ class ClaudeOrchestrator { }, 100); } } - + // Clean up this.focusedTerminalInfo = null; - + // Remove ESC key listener if (this.handleEscKey) { document.removeEventListener('keydown', this.handleEscKey); @@ -14005,14 +15667,14 @@ class ClaudeOrchestrator { console.error('Error unfocusing terminal:', error); } } - + calculateTerminalDimensions(container) { if (!container) return null; - + const rect = container.getBoundingClientRect(); const cols = Math.floor(rect.width / 9); // Approximate character width const rows = Math.floor(rect.height / 20); // Approximate line height - + return { cols: Math.max(80, cols), rows: Math.max(24, rows) }; } @@ -14149,7 +15811,7 @@ class ClaudeOrchestrator { if (startupUI) { startupUI.style.display = 'none'; } - + } catch (error) { console.error('Error auto-starting Claude:', error); this.showError('Failed to start Claude with settings'); @@ -14178,7 +15840,7 @@ class ClaudeOrchestrator { } } } - + hideClaudeStartupModal() { const modal = document.getElementById('claude-startup-modal'); if (modal) { @@ -14186,7 +15848,7 @@ class ClaudeOrchestrator { this.pendingClaudeSession = null; } } - + async startClaudeWithOptions(sessionId, mode, skipPermissions) { if (!this.socket || !this.socket.connected) { this.showError('Not connected to server'); @@ -14316,7 +15978,7 @@ class ClaudeOrchestrator { if (startupUI) startupUI.style.display = 'none'; this.dismissedStartupUI.set(sid, true); } - + quickStartClaude(sessionId, mode) { // Check if YOLO mode is enabled const yoloCheckbox = document.getElementById(`yolo-${sessionId}`); @@ -14817,7 +16479,7 @@ class ClaudeOrchestrator { document.addEventListener('keydown', handleEsc); }); } - + updateYoloState(sessionId, checked) { // Update button styles to show YOLO is active const buttons = [ @@ -14825,7 +16487,7 @@ class ClaudeOrchestrator { document.getElementById(`btn-continue-${sessionId}`), document.getElementById(`btn-resume-${sessionId}`) ]; - + buttons.forEach(btn => { if (btn) { if (checked) { @@ -14836,25 +16498,25 @@ class ClaudeOrchestrator { } }); } - + async startClaudeFromTerminal(sessionId) { if (!this.socket || !this.socket.connected) { return; } - + try { // Get effective settings for this session const response = await fetch(`/api/user-settings/effective/${sessionId}`); let effectiveSettings = { claudeFlags: { skipPermissions: false } }; - + if (response.ok) { effectiveSettings = await response.json(); } - + // Get selected options from the inline UI, but use effective settings as fallback const mode = document.querySelector(`input[name="claude-mode-${sessionId}"]:checked`)?.value || 'fresh'; const skipPermissions = document.getElementById(`skip-permissions-${sessionId}`)?.checked ?? effectiveSettings.claudeFlags.skipPermissions; - + // Send command to server this.socket.emit('start-claude', { sessionId: sessionId, @@ -14863,19 +16525,19 @@ class ClaudeOrchestrator { skipPermissions: skipPermissions } }); - + // Hide the startup UI const startupUI = document.getElementById(this.getSessionDomId('startup-ui', sessionId)); if (startupUI) { startupUI.style.display = 'none'; } - + // Enable the start button for future use const startBtn = document.getElementById(`claude-start-btn-${sessionId}`); if (startBtn) { startBtn.disabled = false; } - + } catch (error) { console.error('Error starting Claude from terminal:', error); } @@ -14883,10 +16545,10 @@ class ClaudeOrchestrator { restartClaudeSession(sessionId) { console.log(`Restarting Claude session: ${sessionId}`); - + if (this.socket && this.socket.connected) { this.socket.emit('restart-session', { sessionId }); - + // Update UI to show restarting this.updateSessionStatus(sessionId, 'restarting'); } else { @@ -14897,22 +16559,34 @@ class ClaudeOrchestrator { // User Settings Methods async loadUserSettings() { try { - const response = await fetch('/api/user-settings'); + void this.traceDesktopLaunch('client.user-settings.load-start'); + const response = await fetch('/api/user-settings', this.withLaunchTraceHeaders()); if (response.ok) { this.userSettings = await response.json(); console.log('User settings loaded:', this.userSettings); + void this.traceDesktopLaunch('client.user-settings.load-success', { + onboardingCompleted: this.userSettings?.global?.ui?.onboarding?.desktopDependencySetup?.completed === true, + onboardingCompletedAt: this.userSettings?.global?.ui?.onboarding?.desktopDependencySetup?.completedAt || null + }); this.syncUserSettingsUI(); this.applyThemeFromUserSettings(); this.applySimpleModeConfig(); this.maybeAutoOpenSimpleMode(); this.applyUiVisibility(); + this.syncDependencySetupWizardPreferences?.(); this.refreshBranchLabels(); this.updateTierFilterButtons(); } else { console.error('Failed to load user settings:', response.statusText); + void this.traceDesktopLaunch('client.user-settings.load-failed', { + statusText: response.statusText || '' + }); } } catch (error) { console.error('Error loading user settings:', error); + void this.traceDesktopLaunch('client.user-settings.load-error', { + error: String(error?.message || error) + }); } } @@ -14922,16 +16596,16 @@ class ClaudeOrchestrator { if (!this.userSettings) { console.warn('User settings not loaded, attempting to load...'); await this.loadUserSettings(); - + if (!this.userSettings) { console.error('Failed to load user settings'); return; } } - + const pathParts = path.split('.'); const newGlobal = JSON.parse(JSON.stringify(this.userSettings.global)); - + // Navigate to the correct nested property let current = newGlobal; for (let i = 0; i < pathParts.length - 1; i++) { @@ -14942,22 +16616,35 @@ class ClaudeOrchestrator { } current[pathParts[pathParts.length - 1]] = value; - const response = await fetch('/api/user-settings/global', { + const response = await fetch('/api/user-settings/global', this.withLaunchTraceHeaders({ method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ global: newGlobal }) - }); + })); if (response.ok) { const updatedSettings = await response.json(); this.userSettings = updatedSettings; console.log('Global setting updated:', path, '=', value); + void this.traceDesktopLaunch('client.user-settings.global-update-success', { + path, + onboardingCompleted: updatedSettings?.global?.ui?.onboarding?.desktopDependencySetup?.completed === true, + onboardingCompletedAt: updatedSettings?.global?.ui?.onboarding?.desktopDependencySetup?.completedAt || null + }); this.applyUiVisibility(); } else { console.error('Failed to update global setting:', response.statusText); + void this.traceDesktopLaunch('client.user-settings.global-update-failed', { + path, + statusText: response.statusText || '' + }); } } catch (error) { console.error('Error updating global setting:', error); + void this.traceDesktopLaunch('client.user-settings.global-update-error', { + path, + error: String(error?.message || error) + }); } } @@ -15556,7 +17243,7 @@ class ClaudeOrchestrator { this.userSettings = updatedSettings; this.syncUserSettingsUI(); console.log('Reset to defaults successfully'); - + // Show user feedback this.showTemporaryMessage('Settings reset to defaults'); } else { @@ -15582,7 +17269,7 @@ class ClaudeOrchestrator { if (response.ok) { console.log('Saved as default template successfully'); - + // Show user feedback with commit reminder this.showTemporaryMessage('Settings saved as default template. Remember to commit and push the changes to user-settings.default.json!', 'success'); } else { @@ -15600,7 +17287,7 @@ class ClaudeOrchestrator { const messageEl = document.createElement('div'); messageEl.className = `temporary-message ${type}`; messageEl.textContent = message; - + // Style the message messageEl.style.cssText = ` position: fixed; @@ -15616,14 +17303,14 @@ class ClaudeOrchestrator { transform: translateX(100%); transition: transform 0.3s ease; `; - + document.body.appendChild(messageEl); - + // Animate in setTimeout(() => { messageEl.style.transform = 'translateX(0)'; }, 100); - + // Remove after delay setTimeout(() => { messageEl.style.transform = 'translateX(100%)'; @@ -15642,9 +17329,9 @@ class ClaudeOrchestrator { this.showTemporaryMessage('Invalid session ID for replay viewer', 'error'); return; } - + const worktreeNum = worktreeMatch[1]; - + // Get worktree configuration from server for accurate path let worktreeConfig = null; try { @@ -15655,19 +17342,19 @@ class ClaudeOrchestrator { } catch (error) { console.warn('Could not get worktree config, using defaults:', error); } - + // Use server-hosted replay viewer (avoids browser file:// restrictions) const replayViewerUrl = `${window.location.origin}/replay-viewer/work${worktreeNum}/`; - + console.log(`Opening replay viewer for ${sessionId} at ${replayViewerUrl}`); - + // Open in new tab (simpler approach) window.open(replayViewerUrl, '_blank'); - + // Show success message with URL for reference this.showTemporaryMessage(`Opening replay viewer for work${worktreeNum}`, 'success'); console.log(`Replay viewer URL: ${replayViewerUrl}`); - + } catch (error) { console.error('Error opening replay viewer:', error); this.showTemporaryMessage('Failed to open replay viewer', 'error'); @@ -15687,7 +17374,7 @@ class ClaudeOrchestrator { setTimeout(checkAndStart, 500); // Check again in 500ms } }; - + setTimeout(checkAndStart, 1000); // Initial delay for terminal setup } @@ -15696,7 +17383,7 @@ class ClaudeOrchestrator { const response = await fetch('/api/user-settings/check-updates'); if (response.ok) { const result = await response.json(); - + if (result && result.hasUpdates) { const notification = document.getElementById('settings-update-notification'); notification.classList.remove('hidden'); @@ -15802,17 +17489,17 @@ class ClaudeOrchestrator { try { this.showTemporaryMessage('Checking for updates...', 'info'); - + const response = await fetch('/api/git/check-updates'); if (response.ok) { const result = await response.json(); - + if (result.hasUpdates) { const notification = document.getElementById('git-update-notification'); const textElement = document.getElementById('git-notification-text'); textElement.textContent = `${result.commitsBehind} update${result.commitsBehind > 1 ? 's' : ''} available on ${result.currentBranch}`; notification.classList.remove('hidden'); - + this.showTemporaryMessage(`Found ${result.commitsBehind} update${result.commitsBehind > 1 ? 's' : ''} available`, 'success'); } else if (result.hasUpdates === false) { this.showTemporaryMessage('Repository is up to date', 'success'); @@ -15835,7 +17522,7 @@ class ClaudeOrchestrator { } this.showTemporaryMessage('Pulling latest changes...', 'info'); - + const response = await fetch('/api/git/pull', { method: 'POST', headers: { 'Content-Type': 'application/json' } @@ -15843,14 +17530,14 @@ class ClaudeOrchestrator { if (response.ok) { const result = await response.json(); - + if (result.success) { // Success message will be handled by socket event const notification = document.getElementById('git-update-notification'); notification.classList.add('hidden'); } else { this.showTemporaryMessage(result.error || 'Failed to pull changes', 'error'); - + // Show specific error details if available if (result.changes && result.changes.length > 0) { console.log('Uncommitted changes:', result.changes); @@ -15966,7 +17653,7 @@ class ClaudeOrchestrator { switchToWorkspace(workspaceId) { console.log('Switching to workspace:', workspaceId); - this.socket.emit('switch-workspace', { workspaceId }); + this.emitWorkspaceSwitch(workspaceId, 'app.switchToWorkspace'); } async waitForWorkspaceActive(workspaceId, { timeoutMs = 7000 } = {}) { @@ -19679,7 +21366,7 @@ class ClaudeOrchestrator { applyView(); return; } - + state.selectedCardId = card.id || null; applyView(); @@ -21029,7 +22716,7 @@ class ClaudeOrchestrator { col.style.setProperty('--tasks-card-rows', '1'); return; } - + const containerHeight = cardsContainer.clientHeight; if (!containerHeight || containerHeight < 40) { col.style.setProperty('--tasks-card-columns', '1'); @@ -21047,7 +22734,7 @@ class ClaudeOrchestrator { return; } delete col.dataset.tasksWrapExpandRetry; - + const styles = window.getComputedStyle(cardsContainer); const rowGap = Number.parseFloat(styles.rowGap || styles.gap || '0') || 0; const columnGap = Number.parseFloat(styles.columnGap || styles.gap || '0') || 0; @@ -21092,10 +22779,10 @@ class ClaudeOrchestrator { col.style.minWidth = `${Math.round(target)}px`; } }; - + apply(rowsFit); const fits = () => (cardsContainer.scrollHeight <= cardsContainer.clientHeight + 1); - + // If we still overflow vertically, reduce rows (creating more columns) until we fit. for (let attempt = 0; attempt < 24; attempt++) { // Force reflow and then check overflow. @@ -30324,7 +32011,7 @@ class ClaudeOrchestrator { // While worktree sessions are spinning up, we reserve the worktree so it isn't recommended again. this.cleanupExpiredWorktreeReservations(); if (this.isWorktreeReserved(repoPathNorm, worktreeId)) return true; - + // Extract repo name from path for session matching const repoName = (repoNameOverride || repoPathNorm.split('/').pop() || '').toLowerCase(); diff --git a/client/dashboard.js b/client/dashboard.js index 829278c7..8b633ff2 100644 --- a/client/dashboard.js +++ b/client/dashboard.js @@ -3775,7 +3775,7 @@ class Dashboard { } // Emit workspace switch event - this.orchestrator.socket.emit('switch-workspace', { workspaceId }); + this.orchestrator.emitWorkspaceSwitch(workspaceId, 'dashboard.openWorkspace'); // Wait for workspace-changed event this.orchestrator.socket.once('workspace-changed', ({ workspace, sessions }) => { diff --git a/client/greenfield-wizard.js b/client/greenfield-wizard.js index 49a56891..c9243076 100644 --- a/client/greenfield-wizard.js +++ b/client/greenfield-wizard.js @@ -897,7 +897,7 @@ class GreenfieldWizard { // Switch to the new workspace if (this.orchestrator && this.orchestrator.socket) { - this.orchestrator.socket.emit('switch-workspace', { workspaceId }); + this.orchestrator.emitWorkspaceSwitch(workspaceId, 'greenfield-wizard.openWorkspace'); } } } diff --git a/client/index.html b/client/index.html index 0605696e..99b37010 100644 --- a/client/index.html +++ b/client/index.html @@ -1,5 +1,6 @@ + @@ -7,15 +8,17 @@ - + - + +
- +
@@ -131,7 +134,7 @@

Claude Orchestrator

- + - + - +
-

Configure board-specific Done/For Test lists in Tasks → Board Settings → Conventions.

+

Configure board-specific Done/For Test lists in Tasks → Board + Settings → Conventions.

- +

Terminal Settings

- +
- +
@@ -939,10 +958,11 @@

Terminal Settings

Per-Terminal Overrides
-

Override global settings for specific terminals (requires terminal restart)

+

Override global settings for specific terminals (requires terminal + restart)

- +
Default Template Management

Manage the default settings template committed to the repository

@@ -962,10 +982,11 @@
Default Template Management
- +
Repository Updates
-

Web/dev mode: Git pull updates. Tauri desktop mode: app updater check/install.

+

Web/dev mode: Git pull updates. Tauri desktop mode: app updater + check/install.

+ + + - + - + @@ -1044,10 +1084,12 @@

Notifications

- +
- +
@@ -1062,4 +1104,5 @@

Notifications

+ diff --git a/client/notifications.js b/client/notifications.js index 6fa218c3..7ec3ea9d 100644 --- a/client/notifications.js +++ b/client/notifications.js @@ -389,21 +389,21 @@ class NotificationManager { // Add notification styles const notificationStyles = document.createElement('style'); notificationStyles.textContent = ` - .empty-message { + .notifications-panel .empty-message { padding: var(--space-xl); text-align: center; color: var(--text-secondary); font-size: 0.875rem; } - .notification-header { + .notifications-panel .notification-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-xs); } - .notification-meta { + .notifications-panel .notification-meta { font-size: 0.75rem; color: var(--text-secondary); margin-top: var(--space-xs); diff --git a/client/quick-links.js b/client/quick-links.js index ead9bad4..e6e58fb8 100644 --- a/client/quick-links.js +++ b/client/quick-links.js @@ -682,7 +682,7 @@ class QuickLinks { if (window.orchestrator) { // If we need to switch workspace first if (window.orchestrator.currentWorkspace?.id !== workspaceId) { - window.orchestrator.socket.emit('switch-workspace', { workspaceId }); + window.orchestrator.emitWorkspaceSwitch(workspaceId, 'quick-links.openWorkspace'); } // Track this session access diff --git a/client/styles.css b/client/styles.css index eab6a28d..62b1bf22 100644 --- a/client/styles.css +++ b/client/styles.css @@ -79,6 +79,15 @@ --shadow-float: 0 18px 40px rgba(0, 0, 0, 0.45); --shadow-soft: 0 4px 12px rgba(0, 0, 0, 0.15); --focus-terminal-bg: color-mix(in srgb, var(--bg-primary) 72%, #000000 28%); + + /* Scrollbars */ + --scrollbar-size: 10px; + --scrollbar-radius: 999px; + --scrollbar-track: #1b2230; + --scrollbar-thumb: #3f5f8f; + --scrollbar-thumb-hover: #4f76ae; + --scrollbar-thumb-active: #5a84bf; + --scrollbar-thumb-border: rgba(13, 17, 23, 0.72); } /* Light Theme */ @@ -107,6 +116,30 @@ body.light-theme { --shadow-float: 0 18px 40px rgba(0, 0, 0, 0.22); --shadow-soft: 0 4px 12px rgba(0, 0, 0, 0.12); --focus-terminal-bg: color-mix(in srgb, var(--bg-primary) 94%, #000000 6%); + + --scrollbar-track: #dbe1e8; + --scrollbar-thumb: #7f91aa; + --scrollbar-thumb-hover: #6f86a8; + --scrollbar-thumb-active: #5d789d; + --scrollbar-thumb-border: rgba(255, 255, 255, 0.76); +} + +@supports (color: color-mix(in srgb, #000000 50%, #ffffff 50%)) { + :root { + --scrollbar-track: color-mix(in srgb, var(--bg-secondary) 78%, #000000 22%); + --scrollbar-thumb: color-mix(in srgb, var(--accent-primary) 46%, var(--bg-tertiary) 54%); + --scrollbar-thumb-hover: color-mix(in srgb, var(--accent-primary-hover) 60%, var(--bg-tertiary) 40%); + --scrollbar-thumb-active: color-mix(in srgb, var(--accent-primary) 72%, var(--bg-tertiary) 28%); + --scrollbar-thumb-border: color-mix(in srgb, var(--bg-primary) 76%, transparent 24%); + } + + body.light-theme { + --scrollbar-track: color-mix(in srgb, var(--bg-tertiary) 70%, #ffffff 30%); + --scrollbar-thumb: color-mix(in srgb, var(--accent-primary) 44%, #7f8a98 56%); + --scrollbar-thumb-hover: color-mix(in srgb, var(--accent-primary) 58%, #6f7c8f 42%); + --scrollbar-thumb-active: color-mix(in srgb, var(--accent-primary) 68%, #617187 32%); + --scrollbar-thumb-border: color-mix(in srgb, #ffffff 68%, transparent 32%); + } } /* Skins */ @@ -178,6 +211,133 @@ body.light-theme.skin-high-contrast { margin: 0; padding: 0; box-sizing: border-box; + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); +} + +*::-webkit-scrollbar { + width: var(--scrollbar-size); + height: var(--scrollbar-size); +} + +*::-webkit-scrollbar-track { + background: var(--scrollbar-track); + border-radius: var(--scrollbar-radius); +} + +*::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: var(--scrollbar-radius); + border: 2px solid var(--scrollbar-thumb-border); + background-clip: padding-box; +} + +*::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); +} + +*::-webkit-scrollbar-thumb:active { + background: var(--scrollbar-thumb-active); +} + +*::-webkit-scrollbar-corner { + background: var(--scrollbar-track); +} + +:where( + .onboarding-overlay, + .onboarding-container, + .dependency-setup-body, + .dependency-setup-item-output, + .diagnostics-output, + .worktree-list, + .workspace-tabs-container, + .header-actions, + .terminal .xterm-viewport, + .focused-terminal-body .xterm-viewport +) { + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); +} + +:where( + .onboarding-overlay, + .onboarding-container, + .dependency-setup-body, + .dependency-setup-item-output, + .diagnostics-output, + .worktree-list, + .workspace-tabs-container, + .header-actions, + .terminal .xterm-viewport, + .focused-terminal-body .xterm-viewport +)::-webkit-scrollbar { + width: var(--scrollbar-size); + height: var(--scrollbar-size); +} + +:where( + .onboarding-overlay, + .onboarding-container, + .dependency-setup-body, + .dependency-setup-item-output, + .diagnostics-output, + .worktree-list, + .workspace-tabs-container, + .header-actions, + .terminal .xterm-viewport, + .focused-terminal-body .xterm-viewport +)::-webkit-scrollbar-track { + background: var(--scrollbar-track); + border-radius: var(--scrollbar-radius); +} + +:where( + .onboarding-overlay, + .onboarding-container, + .dependency-setup-body, + .dependency-setup-item-output, + .diagnostics-output, + .worktree-list, + .workspace-tabs-container, + .header-actions, + .terminal .xterm-viewport, + .focused-terminal-body .xterm-viewport +)::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: var(--scrollbar-radius); + border: 2px solid var(--scrollbar-thumb-border); + background-clip: padding-box; +} + +:where( + .onboarding-overlay, + .onboarding-container, + .dependency-setup-body, + .dependency-setup-item-output, + .diagnostics-output, + .worktree-list, + .workspace-tabs-container, + .header-actions, + .terminal .xterm-viewport, + .focused-terminal-body .xterm-viewport +)::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); +} + +:where( + .onboarding-overlay, + .onboarding-container, + .dependency-setup-body, + .dependency-setup-item-output, + .diagnostics-output, + .worktree-list, + .workspace-tabs-container, + .header-actions, + .terminal .xterm-viewport, + .focused-terminal-body .xterm-viewport +)::-webkit-scrollbar-thumb:active { + background: var(--scrollbar-thumb-active); } body { @@ -2143,12 +2303,19 @@ header h1 { } .worktree-inspector-header.review-console-header::-webkit-scrollbar { - height: 6px; + height: 8px; +} + +.worktree-inspector-header.review-console-header::-webkit-scrollbar-track { + background: var(--scrollbar-track); + border-radius: var(--scrollbar-radius); } .worktree-inspector-header.review-console-header::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.16); - border-radius: 999px; + background: var(--scrollbar-thumb); + border-radius: var(--scrollbar-radius); + border: 2px solid var(--scrollbar-thumb-border); + background-clip: padding-box; } .review-console-route-bar { @@ -4354,19 +4521,23 @@ header h1 { line-height: 1.35; } -.settings-toolbar input { - flex: 1 1 auto; - min-width: 0; - padding: 0 10px; +body.dependency-onboarding-active { + overflow: hidden; + background: + radial-gradient(circle at 12% 18%, rgba(56, 139, 253, 0.16), transparent 48%), + radial-gradient(circle at 88% 8%, rgba(34, 197, 94, 0.14), transparent 44%), + var(--bg-primary); } -.settings-toolbar select { - flex: 0 0 auto; - min-width: 120px; - padding: 0 8px; +body.dependency-onboarding-booting { + overflow: hidden; + background: + radial-gradient(circle at 14% 22%, rgba(56, 139, 253, 0.14), transparent 46%), + radial-gradient(circle at 82% 10%, rgba(34, 197, 94, 0.12), transparent 42%), + var(--bg-primary); } -.settings-filter-hidden { +body.dependency-onboarding-booting > :not(#dependency-setup-modal):not(#toast-stack):not(.toast):not(.ready-toast):not(#notifications-panel):not(.cross-workspace-notifications-area) { display: none !important; } @@ -4416,57 +4587,45 @@ header h1 { border-radius: 10px; } -.setting-group { - margin-bottom: var(--space-md); +body.dependency-onboarding-active > :not(#dependency-setup-modal):not(#toast-stack):not(.toast):not(.ready-toast):not(#notifications-panel):not(.cross-workspace-notifications-area) { + display: none !important; } -.setting-group label { +body.dependency-onboarding-active #dependency-setup-modal { display: flex; align-items: center; - gap: var(--space-sm); - cursor: pointer; -} - -.setting-group select { - background: var(--bg-tertiary); - color: var(--text-primary); - border: 1px solid var(--border-color); - padding: var(--space-xs) var(--space-sm); - border-radius: var(--radius-sm); - margin-left: var(--space-sm); + justify-content: center; + padding: 24px; + z-index: 4500; } -.setting-section { - margin: var(--space-lg) 0; - padding-top: var(--space-lg); - border-top: 1px solid var(--border-color); +.dependency-setup-content { + max-width: min(760px, 94vw); + width: min(760px, 94vw); + border: 1px solid rgba(56, 139, 253, 0.2); + box-shadow: 0 24px 54px rgba(0, 0, 0, 0.35); } -.setting-section h4 { - color: var(--text-primary); - margin-bottom: var(--space-md); - font-size: 1.1em; +.dependency-setup-content .modal-header { + margin-bottom: 10px; } -.setting-section h5 { - color: var(--text-secondary); - margin-bottom: var(--space-sm); - font-size: 0.9em; - font-weight: 600; +.dependency-setup-content .modal-header h3 { + font-size: 1.2rem; + letter-spacing: 0.01em; } -.setting-group label small { - display: block; - color: var(--text-tertiary); - font-size: 0.8em; - margin-top: var(--space-xs); - margin-left: var(--space-lg); +.dependency-setup-body { + max-height: 68vh; + overflow: auto; + padding-right: 4px; } -.setting-description { - color: var(--text-tertiary); - font-size: 0.85em; - margin-bottom: var(--space-md); +.dependency-setup-list { + display: flex; + flex-direction: column; + gap: 14px; + margin-top: 12px; } .skin-gallery { @@ -4539,612 +4698,1216 @@ header h1 { .settings-glossary details { border: 1px solid var(--border-color); - border-radius: var(--radius-sm); - padding: 8px 10px; + border-radius: var(--radius-lg); background: var(--bg-tertiary); - margin: 10px 0; -} - -.settings-glossary details[open] { - background: rgba(255, 255, 255, 0.02); + padding: 14px; } -.settings-glossary summary { - cursor: pointer; +.dependency-setup-item-header { display: flex; align-items: center; - gap: 8px; - list-style: none; -} - -.settings-glossary summary::-webkit-details-marker { - display: none; + justify-content: space-between; + gap: 10px; + margin-bottom: 6px; + flex-wrap: wrap; } -.settings-glossary details > .setting-description { - margin: 10px 0 0; - opacity: 0.95; +.dependency-setup-item-title { + font-weight: 650; + font-size: 1.03rem; + color: var(--text-primary); } -.identity-saved-list { +.dependency-setup-badges { display: flex; - flex-wrap: wrap; + align-items: center; gap: 6px; + flex-wrap: wrap; } -.identity-chip { +.dependency-setup-badge { display: inline-flex; align-items: center; - gap: 6px; - padding: 2px 8px; - border-radius: var(--radius-sm); + border-radius: 999px; border: 1px solid var(--border-color); - background: rgba(255, 255, 255, 0.03); - font-family: var(--font-mono); - font-size: 0.78rem; - max-width: 100%; + padding: 2px 8px; + font-size: 0.75rem; + color: var(--text-secondary); + background: var(--bg-secondary); } -.identity-chip button { - border: none; - background: transparent; - color: var(--text-tertiary); - cursor: pointer; - padding: 0; - line-height: 1; +.dependency-setup-badge.status-ok { + color: var(--accent-success); + border-color: rgba(34, 197, 94, 0.45); } -.identity-chip button:hover { - color: var(--text-secondary); +.dependency-setup-badge.status-missing { + color: var(--accent-warning); + border-color: rgba(245, 158, 11, 0.45); } -.per-terminal-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--space-sm); - margin-bottom: var(--space-xs); - background: var(--bg-tertiary); - border-radius: var(--radius-sm); +.dependency-setup-badge.status-pending { + color: var(--accent-primary); + border-color: rgba(56, 139, 253, 0.45); } -.per-terminal-item .terminal-name { - color: var(--text-primary); - font-weight: 500; - font-family: var(--font-mono); +.dependency-setup-badge.level-required { + color: var(--accent-danger); + border-color: rgba(239, 68, 68, 0.45); } -.per-terminal-item .terminal-override { - color: var(--text-secondary); - font-size: 0.8em; +.dependency-setup-badge.level-recommended { + color: var(--accent-primary); + border-color: rgba(56, 139, 253, 0.45); } -.per-terminal-items { - margin-top: var(--space-sm); +.dependency-setup-badge.level-optional { + color: var(--accent-warning); + border-color: rgba(245, 158, 11, 0.45); } -/* Notifications Panel */ -.notifications-panel { - position: fixed; - top: var(--header-height); - right: 0; - bottom: 0; - width: 380px; +.dependency-setup-item-desc { + color: var(--text-secondary); + margin-bottom: 10px; + line-height: 1.45; +} + +.dependency-setup-item-command { + margin: 0; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); background: var(--bg-secondary); - border-left: 1px solid var(--border-color); - box-shadow: -4px 0 12px rgba(0, 0, 0, 0.15); - transform: translateX(100%); - transition: transform 0.3s; - z-index: 101; - display: flex; - flex-direction: column; + color: var(--text-secondary); + white-space: pre-wrap; + font-size: 0.85rem; } -.notifications-panel:not(.hidden) { - transform: translateX(0); +.dependency-onboarding-command-wrap { + margin-bottom: 10px; } -.notifications-content { - padding: var(--space-md); - overflow-y: auto; - flex: 1; +.dependency-onboarding-command-label { + margin: 0 0 6px; + font-size: 0.76rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-tertiary); } -.notification-list { +.dependency-setup-item-actions { display: flex; - flex-direction: column; - gap: var(--space-sm); + align-items: center; + gap: 8px; + flex-wrap: wrap; } -.notification-item { - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - background: var(--bg-tertiary); - padding: var(--space-sm); - cursor: pointer; - transition: border-color 0.15s, background 0.15s; +.dependency-setup-item-actions .btn-secondary { + width: auto; + min-height: 32px; + flex: 0 0 auto; } -.notification-item:hover { - border-color: rgba(56, 139, 253, 0.55); - background: rgba(56, 139, 253, 0.06); +.dependency-setup-item-actions a.btn-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + text-decoration: none; } -.notification-item.unread { - border-left: 4px solid var(--accent-primary); +.dependency-setup-actions { + justify-content: flex-start; + flex-wrap: wrap; + gap: 8px; } -.notification-type { - font-size: 0.75rem; - padding: 2px 6px; - border-radius: 999px; - border: 1px solid var(--border-color); +.dependency-setup-actions .btn-secondary { + flex: 0 0 auto; +} + +.dependency-setup-empty { + padding: 12px; + border: 1px dashed var(--border-color); + border-radius: var(--radius-sm); color: var(--text-secondary); } -.notification-type.waiting { - border-color: rgba(245, 158, 11, 0.45); - color: var(--accent-warning); +.dependency-onboarding-progress { + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-tertiary); + padding: 12px; } -.notification-type.completed { - border-color: rgba(34, 197, 94, 0.45); - color: var(--accent-success); +.dependency-onboarding-progress-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; + color: var(--text-secondary); } -.notification-type.error { - border-color: rgba(239, 68, 68, 0.45); - color: var(--accent-danger); +.dependency-onboarding-progress-track { + width: 100%; + height: 8px; + border-radius: 999px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + overflow: hidden; } -.notification-message { - color: var(--text-primary); - line-height: 1.35; +.dependency-onboarding-progress-bar { + height: 100%; + background: var(--accent-primary); + transition: width 180ms ease-in-out; } -.notification-actions { - display: flex; - gap: var(--space-xs); - margin-top: var(--space-sm); - justify-content: flex-end; +.dependency-onboarding-step { + margin-top: 12px; } -.notification-action { - padding: 4px 8px; - font-size: 0.8rem; - width: auto; +.dependency-onboarding-step-card { + background: + linear-gradient(150deg, rgba(56, 139, 253, 0.09), rgba(34, 197, 94, 0.03)), + var(--bg-tertiary); } -.terminal-controls { - display: flex; - align-items: center; - gap: var(--space-sm); +.dependency-onboarding-step-kicker { + margin-bottom: 6px; + font-size: 0.74rem; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-tertiary); } -.terminal-controls label { - font-size: 0.9em; - margin: 0; +.dependency-onboarding-state { + margin: 0 0 8px; + color: var(--text-secondary); + font-weight: 500; } -/* Loading spinner for build button */ -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } +.dependency-onboarding-state.status-ok { + color: var(--accent-success); } -/* Server Launch Settings */ -.server-launch-group { - display: inline-flex; - gap: 4px; - align-items: center; +.dependency-onboarding-state.status-missing { + color: var(--accent-warning); } -#launch-settings-modal { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: var(--overlay-backdrop); - display: flex; - align-items: center; - justify-content: center; - z-index: 2000; +.dependency-onboarding-state.status-pending { + color: var(--accent-primary); } -#launch-settings-modal .modal-content { - background: var(--bg-primary); - border-radius: var(--radius-lg); - max-width: 800px; - width: 90%; - max-height: 90vh; - overflow-y: auto; - box-shadow: var(--shadow-modal); +.dependency-gh-login-helper { + margin: 0 0 10px; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: + linear-gradient(145deg, rgba(56, 139, 253, 0.1), rgba(56, 139, 253, 0.02)), + var(--bg-tertiary); } -#launch-settings-modal .modal-header { - padding: var(--space-lg); - border-bottom: 1px solid var(--border-color); - display: flex; - justify-content: space-between; - align-items: center; +.dependency-git-identity-helper { + margin: 0 0 10px; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: + linear-gradient(145deg, rgba(34, 197, 94, 0.08), rgba(34, 197, 94, 0.02)), + var(--bg-tertiary); } -#launch-settings-modal .modal-header h2 { - margin: 0; - font-size: 1.5rem; - color: var(--text-primary); +.dependency-git-identity-fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: 8px; + margin-bottom: 8px; } -#launch-settings-modal .close-btn { - background: transparent; - border: none; - color: var(--text-secondary); - font-size: 1.5rem; - cursor: pointer; - padding: 0; - width: 32px; - height: 32px; +.dependency-git-identity-field { display: flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-sm); + flex-direction: column; + gap: 4px; + color: var(--text-secondary); + font-size: 0.86rem; } -#launch-settings-modal .close-btn:hover { +.dependency-git-identity-field input { + width: 100%; + min-height: 34px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); background: var(--bg-secondary); + color: var(--text-primary); + padding: 0 10px; + font-size: 0.9rem; } -#launch-settings-modal .modal-body { - padding: var(--space-lg); -} - -#launch-settings-modal .settings-section { - margin-bottom: var(--space-xl); -} - -#launch-settings-modal .settings-section:last-child { - margin-bottom: 0; -} - -#launch-settings-modal .settings-section h3 { - color: var(--accent-primary); - font-size: 1.1rem; - margin-bottom: var(--space-md); +.dependency-git-identity-field input:focus { + outline: none; + border-color: var(--accent-primary); } -#launch-settings-modal .setting-group { - margin-bottom: var(--space-md); +.dependency-git-identity-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; } -#launch-settings-modal .setting-group label { - display: block; - color: var(--text-secondary); - font-size: 0.9rem; - margin-bottom: var(--space-xs); +.dependency-git-help-btn { + min-width: 32px; + width: 32px; + padding: 0; + font-weight: 700; } -#launch-settings-modal .setting-group input { - width: 100%; - padding: var(--space-sm) var(--space-md); - background: var(--bg-secondary); +.dependency-git-help-inline { + margin-top: 8px; + padding: 10px; border: 1px solid var(--border-color); border-radius: var(--radius-sm); - color: var(--text-primary); - font-family: monospace; + background: var(--bg-secondary); } -#launch-settings-modal .setting-group input:focus { - outline: none; - border-color: var(--accent-primary); +.dependency-git-help-line { + color: var(--text-secondary); + line-height: 1.45; } -#launch-settings-modal .setting-group small { - display: block; - color: var(--text-tertiary); - font-size: 0.8rem; - margin-top: var(--space-xs); +.dependency-git-help-line + .dependency-git-help-line { + margin-top: 6px; } -#launch-settings-modal .preset-checkboxes { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: var(--space-md); +.dependency-gh-login-helper-text { + color: var(--text-secondary); + margin-bottom: 8px; } -#launch-settings-modal .preset-checkbox { +.dependency-gh-login-code-wrap { display: flex; align-items: center; - padding: var(--space-md); - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - cursor: pointer; - transition: all 0.2s; - user-select: none; -} - -#launch-settings-modal .preset-checkbox:hover { - background: var(--bg-tertiary); - border-color: var(--accent-primary); + gap: 8px; + flex-wrap: wrap; } -#launch-settings-modal .preset-checkbox input[type="checkbox"] { - margin-right: var(--space-sm); - width: 18px; - height: 18px; - cursor: pointer; +.dependency-gh-login-helper-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-top: 8px; } -#launch-settings-modal .preset-checkbox input[type="checkbox"]:checked + span { - color: var(--accent-primary); +.dependency-gh-login-code { + display: inline-block; + padding: 7px 10px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-secondary); + color: var(--text-primary); + letter-spacing: 0.06em; font-weight: 600; } -#launch-settings-modal .preset-checkbox span { - font-size: 0.9rem; - transition: all 0.2s; +.dependency-setup-item-output { + max-height: 150px; + overflow: auto; } -#launch-settings-modal .modal-footer { - padding: var(--space-lg); - border-top: 1px solid var(--border-color); +.dependency-onboarding-nav { display: flex; + align-items: center; justify-content: flex-end; - gap: var(--space-md); + gap: 8px; + margin-top: 12px; } -#launch-settings-modal .btn-save, -#launch-settings-modal .btn-cancel { - padding: var(--space-sm) var(--space-lg); - border-radius: var(--radius-sm); - cursor: pointer; - font-size: 0.9rem; - transition: all 0.2s; +.dependency-onboarding-nav .btn-secondary, +.dependency-onboarding-nav .btn-primary { + width: auto; + min-height: 34px; + flex: 0 0 auto; } -#launch-settings-modal .btn-save { - background: var(--accent-primary); - color: white; - border: none; -} +@media (max-width: 700px) { + body.dependency-onboarding-active #dependency-setup-modal { + padding: 14px; + } -#launch-settings-modal .btn-save:hover { - background: var(--accent-secondary); -} + .dependency-setup-content { + width: min(760px, 96vw); + } -#launch-settings-modal .btn-cancel { - background: var(--bg-secondary); - color: var(--text-secondary); - border: 1px solid var(--border-color); + .dependency-setup-actions { + justify-content: flex-start; + } + + .dependency-onboarding-nav { + justify-content: flex-end; + flex-wrap: wrap; + } } -#launch-settings-modal .btn-cancel:hover { - background: var(--bg-tertiary); +.settings-toolbar input { + flex: 1 1 auto; + min-width: 0; + padding: 0 10px; } -.loading-spinner { - display: inline-block; - width: 14px; - height: 14px; - border: 2px solid rgba(255, 255, 255, 0.3); - border-top: 2px solid #fff; - border-radius: 50%; - animation: spin 1s linear infinite; +.settings-toolbar select { + flex: 0 0 auto; + min-width: 120px; + padding: 0 8px; } -.control-btn.building { - background: var(--color-warning); - cursor: not-allowed; - opacity: 0.8; +.settings-filter-hidden { + display: none !important; } -.clear-override-btn { - background: var(--bg-primary); - color: var(--text-secondary); +/* Review Console */ +.review-console-warning { + margin: 10px 12px 14px; + padding: 10px 12px; border: 1px solid var(--border-color); - border-radius: var(--radius-sm); - padding: var(--space-xs); - cursor: pointer; - font-size: 0.8em; - transition: background-color 0.2s; + border-left: 4px solid var(--accent-warning); + background: rgba(210, 153, 34, 0.10); + border-radius: 10px; } -.clear-override-btn:hover { - background: var(--bg-tertiary); - color: var(--text-primary); +.setting-group { + margin-bottom: var(--space-md); } -.template-actions { +.setting-group label { display: flex; + align-items: center; gap: var(--space-sm); - margin-top: var(--space-sm); + cursor: pointer; } -.template-btn { - padding: var(--space-sm) var(--space-md); +.setting-group select { + background: var(--bg-tertiary); + color: var(--text-primary); border: 1px solid var(--border-color); + padding: var(--space-xs) var(--space-sm); border-radius: var(--radius-sm); - cursor: pointer; - font-size: 0.9em; - transition: all 0.2s; + margin-left: var(--space-sm); } -.template-btn.primary { - background: var(--accent-primary); - color: white; - border-color: var(--accent-primary); +.setting-section { + margin: var(--space-lg) 0; + padding-top: var(--space-lg); + border-top: 1px solid var(--border-color); } -.template-btn.primary:hover { - background: var(--accent-primary-hover); - border-color: var(--accent-primary-hover); +.setting-section h4 { + color: var(--text-primary); + margin-bottom: var(--space-md); + font-size: 1.1em; } -.template-btn.secondary { - background: var(--bg-primary); +.setting-section h5 { color: var(--text-secondary); - border-color: var(--border-color); + margin-bottom: var(--space-sm); + font-size: 0.9em; + font-weight: 600; } -.template-btn.secondary:hover { - background: var(--bg-tertiary); - color: var(--text-primary); +.setting-group label small { + display: block; + color: var(--text-tertiary); + font-size: 0.8em; + margin-top: var(--space-xs); + margin-left: var(--space-lg); } -.update-notification { - margin-top: var(--space-sm); - padding: var(--space-sm); - background: var(--bg-tertiary); - border: 1px solid var(--accent-warning); +.setting-description { + color: var(--text-tertiary); + font-size: 0.85em; + margin-bottom: var(--space-md); +} + +.settings-glossary details { + border: 1px solid var(--border-color); border-radius: var(--radius-sm); - transition: all 0.3s; + padding: 8px 10px; + background: var(--bg-tertiary); + margin: 10px 0; } -.update-notification.hidden { - display: none; +.settings-glossary details[open] { + background: rgba(255, 255, 255, 0.02); } -.notification-content { +.settings-glossary summary { + cursor: pointer; display: flex; align-items: center; - gap: var(--space-sm); + gap: 8px; + list-style: none; } -.notification-icon { - font-size: 1.1em; +.settings-glossary summary::-webkit-details-marker { + display: none; } -.notification-text { - flex: 1; - color: var(--text-primary); - font-size: 0.9em; +.settings-glossary details > .setting-description { + margin: 10px 0 0; + opacity: 0.95; } -.dismiss-btn { - background: none; +.identity-saved-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.identity-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-color); + background: rgba(255, 255, 255, 0.03); + font-family: var(--font-mono); + font-size: 0.78rem; + max-width: 100%; +} + +.identity-chip button { border: none; - color: var(--text-secondary); + background: transparent; + color: var(--text-tertiary); cursor: pointer; - font-size: 1.2em; padding: 0; - width: 24px; - height: 24px; + line-height: 1; +} + +.identity-chip button:hover { + color: var(--text-secondary); +} + +.per-terminal-item { display: flex; align-items: center; - justify-content: center; + justify-content: space-between; + padding: var(--space-sm); + margin-bottom: var(--space-xs); + background: var(--bg-tertiary); border-radius: var(--radius-sm); - transition: all 0.2s; } -.dismiss-btn:hover { - background: var(--bg-primary); +.per-terminal-item .terminal-name { color: var(--text-primary); + font-weight: 500; + font-family: var(--font-mono); } -/* Loading */ -.loading-message { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - text-align: center; +.per-terminal-item .terminal-override { + color: var(--text-secondary); + font-size: 0.8em; } -.spinner { - width: 40px; - height: 40px; - border: 3px solid var(--bg-tertiary); - border-top-color: var(--accent-primary); - border-radius: 50%; - animation: spin 1s linear infinite; - margin: 0 auto var(--space-md); +.per-terminal-items { + margin-top: var(--space-sm); } -@keyframes spin { - to { transform: rotate(360deg); } +/* Notifications Panel */ +.notifications-panel { + position: fixed; + top: var(--header-height); + right: 0; + bottom: 0; + width: min(420px, 96vw); + background: + linear-gradient(180deg, rgba(18, 24, 38, 0.94), rgba(10, 15, 27, 0.94)); + border-left: 1px solid rgba(147, 197, 253, 0.22); + box-shadow: -20px 0 44px rgba(2, 6, 16, 0.58); + backdrop-filter: blur(12px); + transform: translateX(100%); + transition: transform 0.24s ease; + z-index: 12040; + display: flex; + flex-direction: column; } -@keyframes pulse { - from { opacity: 0.5; } - to { opacity: 1; } +.notifications-panel:not(.hidden) { + transform: translateX(0); } -/* Toast Notifications */ -.ready-toast { - position: fixed; - top: calc(var(--header-height) + 20px); - right: 20px; - background: var(--accent-success); - color: white; - padding: var(--space-sm) var(--space-md); - border-radius: var(--radius-md); - box-shadow: var(--shadow-soft); - z-index: 1000; - animation: slideInRight 0.3s ease-out, fadeOutRight 0.3s ease-in 2.7s forwards; +.notifications-panel .panel-header { + padding: 14px 16px; + border-bottom: 1px solid rgba(147, 197, 253, 0.2); + background: + linear-gradient(180deg, rgba(35, 51, 74, 0.64), rgba(26, 38, 58, 0.38)); } -.toast-content { - display: flex; - align-items: center; - gap: var(--space-sm); +.notifications-panel .panel-header h3 { + margin: 0; + font-size: 1rem; + font-weight: 640; + letter-spacing: 0.01em; + color: #f4f8ff; } -.toast-icon { - font-size: 1.2rem; +.notifications-panel .panel-actions .icon-button { + width: 28px; + height: 28px; + border-radius: 8px; + border: 1px solid rgba(147, 197, 253, 0.24); + background: rgba(7, 12, 22, 0.56); + color: rgba(244, 248, 255, 0.9); } -.toast-text { - font-weight: 500; - font-size: 0.875rem; +.notifications-panel .panel-actions .icon-button:hover { + border-color: rgba(147, 197, 253, 0.44); + background: rgba(29, 78, 216, 0.2); } -@keyframes slideInRight { - from { - transform: translateX(100%); - opacity: 0; - } - to { - transform: translateX(0); - opacity: 1; - } +.notifications-content { + padding: 12px 14px 18px; + overflow-y: auto; + overscroll-behavior: contain; + flex: 1; } -@keyframes fadeOutRight { - from { - transform: translateX(0); - opacity: 1; - } - to { - transform: translateX(100%); - opacity: 0; - } +.notification-list { + display: flex; + flex-direction: column; + gap: 10px; } -/* Responsive - ONLY apply if data-visible-count is not set */ -@media (max-width: 1200px) { - .terminal-grid:not([data-visible-count]) { - grid-template-columns: repeat(2, 1fr); - } +.notifications-panel .empty-message { + border: 1px dashed rgba(147, 197, 253, 0.24); + border-radius: 12px; + background: rgba(12, 19, 33, 0.62); + color: rgba(223, 231, 245, 0.76); } -@media (max-width: 768px) { - .projects-chats-shell { - grid-template-columns: 1fr; - min-height: 70vh; - } +.notifications-panel .notification-item { + border: 1px solid rgba(147, 197, 253, 0.2); + border-radius: 14px; + background: + linear-gradient(180deg, rgba(17, 25, 40, 0.92), rgba(10, 16, 28, 0.92)); + padding: 12px 12px 10px; + cursor: pointer; + transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; + box-shadow: 0 8px 18px rgba(2, 6, 16, 0.34); +} - .sidebar-toggle { - display: inline-flex; - } +.notifications-panel .notification-item:hover { + transform: translateY(-1px); + border-color: rgba(96, 165, 250, 0.5); + box-shadow: 0 12px 24px rgba(9, 30, 66, 0.42); +} - header { - padding: 0 var(--space-md); - } +.notifications-panel .notification-item.unread { + border-left: 3px solid #60a5fa; + box-shadow: 0 0 0 1px rgba(96, 165, 250, 0.22), 0 12px 24px rgba(9, 30, 66, 0.38); +} - .header-content { - gap: var(--space-md); - } +.notifications-panel .notification-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 7px; + gap: 8px; +} + +.notifications-panel .notification-time { + font-size: 0.75rem; + letter-spacing: 0.01em; + color: rgba(223, 231, 245, 0.66); +} + +.notifications-panel .notification-type { + font-size: 0.69rem; + font-weight: 620; + letter-spacing: 0.055em; + text-transform: uppercase; + padding: 3px 8px; + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, 0.3); + background: rgba(15, 23, 42, 0.65); + color: rgba(226, 232, 240, 0.9); +} + +.notifications-panel .notification-type.waiting { + border-color: rgba(245, 158, 11, 0.45); + background: rgba(245, 158, 11, 0.14); + color: #fcd34d; +} + +.notifications-panel .notification-type.completed { + border-color: rgba(34, 197, 94, 0.45); + background: rgba(34, 197, 94, 0.14); + color: #86efac; +} + +.notifications-panel .notification-type.error { + border-color: rgba(239, 68, 68, 0.45); + background: rgba(239, 68, 68, 0.12); + color: #fca5a5; +} + +.notifications-panel .notification-message { + color: rgba(239, 245, 255, 0.94); + line-height: 1.42; + font-size: 0.89rem; + margin: 0; +} + +.notifications-panel .notification-meta { + font-size: 0.75rem; + color: rgba(190, 206, 230, 0.72); + margin-top: 6px; +} + +.notifications-panel .notification-actions { + display: flex; + gap: 8px; + margin-top: 10px; + justify-content: flex-end; +} + +.notifications-panel .notification-action { + min-width: 0; + width: auto; + border-radius: 8px; + border: 1px solid rgba(147, 197, 253, 0.24); + background: rgba(13, 20, 35, 0.6); + color: rgba(239, 245, 255, 0.9); + padding: 4px 10px; + font-size: 0.78rem; +} + +.notifications-panel .notification-action:hover { + border-color: rgba(147, 197, 253, 0.45); + background: rgba(37, 99, 235, 0.24); +} + +.terminal-controls { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.terminal-controls label { + font-size: 0.9em; + margin: 0; +} + +/* Loading spinner for build button */ +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Server Launch Settings */ +.server-launch-group { + display: inline-flex; + gap: 4px; + align-items: center; +} + +#launch-settings-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--overlay-backdrop); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; +} + +#launch-settings-modal .modal-content { + background: var(--bg-primary); + border-radius: var(--radius-lg); + max-width: 800px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + box-shadow: var(--shadow-modal); +} + +#launch-settings-modal .modal-header { + padding: var(--space-lg); + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; +} + +#launch-settings-modal .modal-header h2 { + margin: 0; + font-size: 1.5rem; + color: var(--text-primary); +} + +#launch-settings-modal .close-btn { + background: transparent; + border: none; + color: var(--text-secondary); + font-size: 1.5rem; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); +} + +#launch-settings-modal .close-btn:hover { + background: var(--bg-secondary); +} + +#launch-settings-modal .modal-body { + padding: var(--space-lg); +} + +#launch-settings-modal .settings-section { + margin-bottom: var(--space-xl); +} + +#launch-settings-modal .settings-section:last-child { + margin-bottom: 0; +} + +#launch-settings-modal .settings-section h3 { + color: var(--accent-primary); + font-size: 1.1rem; + margin-bottom: var(--space-md); +} + +#launch-settings-modal .setting-group { + margin-bottom: var(--space-md); +} + +#launch-settings-modal .setting-group label { + display: block; + color: var(--text-secondary); + font-size: 0.9rem; + margin-bottom: var(--space-xs); +} + +#launch-settings-modal .setting-group input { + width: 100%; + padding: var(--space-sm) var(--space-md); + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: monospace; +} + +#launch-settings-modal .setting-group input:focus { + outline: none; + border-color: var(--accent-primary); +} + +#launch-settings-modal .setting-group small { + display: block; + color: var(--text-tertiary); + font-size: 0.8rem; + margin-top: var(--space-xs); +} + +#launch-settings-modal .preset-checkboxes { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--space-md); +} + +#launch-settings-modal .preset-checkbox { + display: flex; + align-items: center; + padding: var(--space-md); + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s; + user-select: none; +} + +#launch-settings-modal .preset-checkbox:hover { + background: var(--bg-tertiary); + border-color: var(--accent-primary); +} + +#launch-settings-modal .preset-checkbox input[type="checkbox"] { + margin-right: var(--space-sm); + width: 18px; + height: 18px; + cursor: pointer; +} + +#launch-settings-modal .preset-checkbox input[type="checkbox"]:checked + span { + color: var(--accent-primary); + font-weight: 600; +} + +#launch-settings-modal .preset-checkbox span { + font-size: 0.9rem; + transition: all 0.2s; +} + +#launch-settings-modal .modal-footer { + padding: var(--space-lg); + border-top: 1px solid var(--border-color); + display: flex; + justify-content: flex-end; + gap: var(--space-md); +} + +#launch-settings-modal .btn-save, +#launch-settings-modal .btn-cancel { + padding: var(--space-sm) var(--space-lg); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.9rem; + transition: all 0.2s; +} + +#launch-settings-modal .btn-save { + background: var(--accent-primary); + color: white; + border: none; +} + +#launch-settings-modal .btn-save:hover { + background: var(--accent-secondary); +} + +#launch-settings-modal .btn-cancel { + background: var(--bg-secondary); + color: var(--text-secondary); + border: 1px solid var(--border-color); +} + +#launch-settings-modal .btn-cancel:hover { + background: var(--bg-tertiary); +} + +.loading-spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top: 2px solid #fff; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.control-btn.building { + background: var(--color-warning); + cursor: not-allowed; + opacity: 0.8; +} + +.clear-override-btn { + background: var(--bg-primary); + color: var(--text-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: var(--space-xs); + cursor: pointer; + font-size: 0.8em; + transition: background-color 0.2s; +} + +.clear-override-btn:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.template-actions { + display: flex; + gap: var(--space-sm); + margin-top: var(--space-sm); +} + +.template-btn { + padding: var(--space-sm) var(--space-md); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.9em; + transition: all 0.2s; +} + +.template-btn.primary { + background: var(--accent-primary); + color: white; + border-color: var(--accent-primary); +} + +.template-btn.primary:hover { + background: var(--accent-primary-hover); + border-color: var(--accent-primary-hover); +} + +.template-btn.secondary { + background: var(--bg-primary); + color: var(--text-secondary); + border-color: var(--border-color); +} + +.template-btn.secondary:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.update-notification { + margin-top: var(--space-sm); + padding: var(--space-sm); + background: var(--bg-tertiary); + border: 1px solid var(--accent-warning); + border-radius: var(--radius-sm); + transition: all 0.3s; +} + +.update-notification.hidden { + display: none; +} + +.notification-content { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.notification-icon { + font-size: 1.1em; +} + +.notification-text { + flex: 1; + color: var(--text-primary); + font-size: 0.9em; +} + +.dismiss-btn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 1.2em; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + transition: all 0.2s; +} + +.dismiss-btn:hover { + background: var(--bg-primary); + color: var(--text-primary); +} + +/* Loading */ +.loading-message { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid var(--bg-tertiary); + border-top-color: var(--accent-primary); + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto var(--space-md); +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@keyframes pulse { + from { opacity: 0.5; } + to { opacity: 1; } +} + +/* Toast Notifications */ +.toast-stack { + position: fixed; + top: calc(var(--header-height) + 20px); + right: 20px; + width: min(420px, calc(100vw - 24px)); + display: flex; + flex-direction: column; + gap: 10px; + z-index: 12060; + pointer-events: none; +} + +.toast, +.ready-toast { + --toast-accent: 96, 165, 250; + pointer-events: auto; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + border-radius: 14px; + border: 1px solid rgba(var(--toast-accent), 0.42); + background: + linear-gradient(180deg, rgba(17, 25, 40, 0.95), rgba(9, 14, 24, 0.95)); + box-shadow: 0 14px 32px rgba(2, 6, 16, 0.5); + color: rgba(244, 248, 255, 0.96); + padding: 11px 12px; + transform: translateX(20px); + opacity: 0; + transition: opacity 0.22s ease, transform 0.22s ease; +} + +.toast.is-visible, +.ready-toast.is-visible { + transform: translateX(0); + opacity: 1; +} + +.toast.is-leaving, +.ready-toast.is-leaving { + transform: translateX(16px); + opacity: 0; +} + +.toast.toast-success, +.ready-toast { + --toast-accent: 74, 222, 128; +} + +.toast.toast-warning { + --toast-accent: 251, 191, 36; +} + +.toast.toast-error { + --toast-accent: 248, 113, 113; +} + +.toast-content { + display: flex; + align-items: flex-start; + gap: 10px; + min-width: 0; + flex: 1; +} + +.toast-icon { + width: 24px; + height: 24px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(var(--toast-accent), 0.2); + color: rgb(var(--toast-accent)); + flex: 0 0 auto; +} + +.toast-icon svg { + width: 14px; + height: 14px; + display: block; +} + +.toast-text { + font-weight: 500; + font-size: 0.88rem; + line-height: 1.38; + color: rgba(242, 247, 255, 0.95); + word-break: break-word; +} + +.toast-close { + width: 22px; + height: 22px; + border-radius: 7px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(15, 23, 42, 0.58); + color: rgba(230, 237, 249, 0.84); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.72rem; + line-height: 1; + padding: 0; + flex: 0 0 auto; +} + +.toast-close:hover { + background: rgba(51, 65, 85, 0.62); + color: rgba(255, 255, 255, 0.97); + border-color: rgba(255, 255, 255, 0.3); +} + +@keyframes slideInRight { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes fadeOutRight { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(100%); + opacity: 0; + } +} + +/* Responsive - ONLY apply if data-visible-count is not set */ +@media (max-width: 1200px) { + .terminal-grid:not([data-visible-count]) { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + .projects-chats-shell { + grid-template-columns: 1fr; + min-height: 70vh; + } + + .sidebar-toggle { + display: inline-flex; + } + + header { + padding: 0 var(--space-md); + } + + .header-content { + gap: var(--space-md); + } header h1 { font-size: 1rem; @@ -7438,25 +8201,26 @@ header h1 { top: calc(var(--header-height) + 20px); right: 20px; width: 300px; - z-index: 1001; + z-index: 12050; pointer-events: none; } .cross-workspace-notification { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-left: 4px solid var(--accent-warning); - border-radius: var(--radius-md); + background: + linear-gradient(180deg, rgba(18, 24, 38, 0.94), rgba(10, 15, 27, 0.94)); + border: 1px solid rgba(245, 158, 11, 0.28); + border-left: 3px solid rgba(245, 158, 11, 0.85); + border-radius: 12px; margin-bottom: var(--space-md); - box-shadow: var(--shadow-soft); + box-shadow: 0 14px 28px rgba(2, 6, 16, 0.42); pointer-events: auto; - animation: slideInRight 0.3s ease-out; + animation: slideInRight 0.24s ease-out; } -.notification-header { +.cross-workspace-notification .notification-header { padding: var(--space-sm) var(--space-md); - background: var(--bg-tertiary); - border-bottom: 1px solid var(--border-color); + background: rgba(245, 158, 11, 0.1); + border-bottom: 1px solid rgba(245, 158, 11, 0.24); display: flex; justify-content: space-between; align-items: center; @@ -7468,7 +8232,7 @@ header h1 { color: var(--text-primary); } -.notification-close { +.cross-workspace-notification .notification-close { background: none; border: none; color: var(--text-secondary); @@ -7481,17 +8245,17 @@ header h1 { justify-content: center; } -.notification-body { +.cross-workspace-notification .notification-body { padding: var(--space-md); } -.notification-message { +.cross-workspace-notification .notification-message { font-size: 0.9rem; color: var(--text-primary); margin-bottom: var(--space-md); } -.notification-actions { +.cross-workspace-notification .notification-actions { display: flex; gap: var(--space-sm); } @@ -9260,395 +10024,661 @@ header h1 { font-size: 0.75rem; } -.detail-row-full { +.detail-row-full { + display: flex; + gap: var(--space-sm); + align-items: flex-start; +} + +.detail-row-full .detail-label { + color: var(--text-muted); + flex-shrink: 0; + min-width: 80px; +} + +.folder-path-full { + color: #90cdf4; + word-break: break-all; + font-family: monospace; + font-size: 0.75rem; + background: rgba(99, 179, 237, 0.1); + padding: 4px 8px; + border-radius: 4px; +} + +/* Conversation details grid */ +.conv-details-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--space-xs) var(--space-md); + margin: var(--space-sm) 0; + padding: var(--space-sm); + background: var(--bg-primary); + border-radius: var(--radius-xs); + font-size: 0.75rem; +} + +.detail-row { + display: flex; + gap: var(--space-xs); + align-items: baseline; +} + +.detail-label { + color: var(--text-muted); + min-width: 60px; + flex-shrink: 0; +} + +.detail-value { + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.detail-value.folder-path { + color: #63b3ed; + cursor: help; +} + +.detail-value.repo-name { + color: #9f7aea; +} + +.detail-value.branch-name { + color: #48bb78; +} + +.detail-value.model-name { + color: #f6ad55; +} + +.conv-actions { + display: flex; + gap: var(--space-sm); + flex-wrap: wrap; + margin-top: var(--space-sm); +} + +.conv-actions .btn-small { + padding: 6px 12px; + font-size: 0.8rem; +} + +.conv-actions .btn-small.primary { + background: var(--accent-primary); + color: var(--text-on-accent); + border: none; +} + +.conv-actions .btn-small.primary:hover { + background: var(--accent-primary-hover); +} + +.conv-actions .btn-small.secondary { + background: var(--bg-primary); + border: 1px solid var(--border-color); + color: var(--text-secondary); +} + +.conv-actions .btn-small.secondary:hover { + background: var(--bg-tertiary); +} + +.browser-footer { + padding: var(--space-sm) var(--space-lg); + border-top: 1px solid var(--border-color); + font-size: 0.8rem; + color: var(--text-muted); +} + +.no-results, .loading, .error { + text-align: center; + padding: var(--space-xl); + color: var(--text-muted); +} + +/* Conversation Details Modal */ +.conversation-details-modal .details-content { + max-width: 700px; + width: 90vw; + max-height: 85vh; + display: flex; + flex-direction: column; +} + +.details-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-md) var(--space-lg); + border-bottom: 1px solid var(--border-color); +} + +.details-meta { + padding: var(--space-md) var(--space-lg); + background: var(--bg-tertiary); + font-size: 0.85rem; +} + +.details-meta p { + margin: var(--space-xs) 0; +} + +.details-summary { + padding: var(--space-md) var(--space-lg); + border-bottom: 1px solid var(--border-color); + font-style: italic; + color: var(--text-secondary); +} + +.details-messages { + flex: 1; + overflow-y: auto; + padding: var(--space-md) var(--space-lg); +} + +.details-messages h4 { + margin-bottom: var(--space-md); +} + +.messages-list { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.messages-list .message { + padding: var(--space-sm) var(--space-md); + border-radius: var(--radius-sm); + font-size: 0.85rem; +} + +.messages-list .message.user { + background: var(--bg-tertiary); + margin-left: var(--space-lg); +} + +.messages-list .message.assistant { + background: var(--accent-primary); + background: rgba(59, 130, 246, 0.2); + margin-right: var(--space-lg); +} + +.message-header { + display: flex; + justify-content: space-between; + font-size: 0.75rem; + color: var(--text-muted); + margin-bottom: var(--space-xs); +} + +.message-content { + white-space: pre-wrap; + word-break: break-word; +} + +.tool-uses { + margin-top: var(--space-xs); + font-size: 0.7rem; + color: var(--text-muted); +} + +.more-messages { + text-align: center; + padding: var(--space-md); + color: var(--text-muted); + font-style: italic; +} + +.details-actions { + padding: var(--space-md) var(--space-lg); + border-top: 1px solid var(--border-color); + display: flex; + justify-content: flex-end; +} + +/* ============================================ + Ports Panel Styles + ============================================ */ + +.ports-modal .ports-content { + max-width: 1100px; + width: 96vw; + max-height: 92vh; + display: flex; + flex-direction: column; +} + +.ports-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-md) var(--space-lg); + border-bottom: 1px solid var(--border-color); +} + +.ports-header h2 { + margin: 0; + font-size: 1.2rem; +} + +.ports-info { + padding: var(--space-sm) var(--space-lg); + background: var(--bg-tertiary); + font-size: 0.85rem; + color: var(--text-muted); +} + +.ports-list { + flex: 1; + overflow-y: auto; + padding: var(--space-lg); + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: var(--space-md); + align-content: start; +} + +.port-item { display: flex; + flex-direction: column; gap: var(--space-sm); - align-items: flex-start; + padding: var(--space-md); + background: var(--bg-tertiary); + border-radius: var(--radius-sm); + border-left: 3px solid var(--border-color); + transition: all 0.15s; } -.detail-row-full .detail-label { - color: var(--text-muted); - flex-shrink: 0; - min-width: 80px; +.port-item:hover { + background: var(--bg-primary); } -.folder-path-full { - color: #90cdf4; - word-break: break-all; - font-family: monospace; - font-size: 0.75rem; - background: rgba(99, 179, 237, 0.1); - padding: 4px 8px; - border-radius: 4px; +.port-item.orchestrator, +.port-item.orchestrator-dev { + border-left-color: #9f7aea; } -/* Conversation details grid */ -.conv-details-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: var(--space-xs) var(--space-md); - margin: var(--space-sm) 0; - padding: var(--space-sm); - background: var(--bg-primary); - border-radius: var(--radius-xs); - font-size: 0.75rem; +.port-item.client, +.port-item.client-dev { + border-left-color: #63b3ed; } -.detail-row { - display: flex; - gap: var(--space-xs); - align-items: baseline; +.port-item.vite, +.port-item.react { + border-left-color: #48bb78; } -.detail-label { - color: var(--text-muted); - min-width: 60px; - flex-shrink: 0; +.port-item.game-server { + border-left-color: #f6ad55; } -.detail-value { - color: var(--text-secondary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.port-item.python, +.port-item.flask { + border-left-color: #ffd93d; } -.detail-value.folder-path { - color: #63b3ed; - cursor: help; +.port-item.node { + border-left-color: #68d391; } -.detail-value.repo-name { - color: #9f7aea; +.port-icon { + font-size: 1.2rem; + flex-shrink: 0; } -.detail-value.branch-name { - color: #48bb78; +.port-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-md); + min-width: 0; } -.detail-value.model-name { - color: #f6ad55; +.port-main { + display: flex; + align-items: flex-start; + gap: var(--space-md); + min-width: 0; + flex: 1; } -.conv-actions { - display: flex; - gap: var(--space-sm); - flex-wrap: wrap; - margin-top: var(--space-sm); +.port-details { + flex: 1; + min-width: 0; } -.conv-actions .btn-small { - padding: 6px 12px; - font-size: 0.8rem; +.port-name { + display: block; + font-weight: 500; + color: var(--text-primary); + font-size: 0.9rem; } -.conv-actions .btn-small.primary { - background: var(--accent-primary); - color: var(--text-on-accent); - border: none; +.port-process { + font-size: 0.75rem; + color: var(--text-muted); } -.conv-actions .btn-small.primary:hover { - background: var(--accent-primary-hover); +.port-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-xs); + flex-wrap: wrap; } -.conv-actions .btn-small.secondary { - background: var(--bg-primary); +.port-action-btn { border: 1px solid var(--border-color); - color: var(--text-secondary); + background: var(--bg-primary); + color: var(--text-primary); + border-radius: var(--radius-xs); + padding: 4px 8px; + font-size: 0.75rem; + cursor: pointer; + line-height: 1.1; + transition: background 0.15s, border-color 0.15s; } -.conv-actions .btn-small.secondary:hover { - background: var(--bg-tertiary); +.port-action-btn:hover { + background: var(--bg-secondary); + border-color: var(--accent-primary); } -.browser-footer { - padding: var(--space-sm) var(--space-lg); +.ports-footer { + padding: var(--space-md) var(--space-lg); border-top: 1px solid var(--border-color); - font-size: 0.8rem; - color: var(--text-muted); + display: flex; + justify-content: flex-end; } -.no-results, .loading, .error { +.no-ports { text-align: center; - padding: var(--space-xl); + padding: var(--space-lg); color: var(--text-muted); } -/* Conversation Details Modal */ -.conversation-details-modal .details-content { - max-width: 700px; - width: 90vw; - max-height: 85vh; +/* ============================================ + PRs Panel Styles + ============================================ */ + +.prs-modal .prs-content { + max-width: 1100px; + width: 96vw; + max-height: 92vh; display: flex; flex-direction: column; } -.details-header { +.prs-toolbar { + padding: var(--space-sm) var(--space-lg); + border-bottom: 1px solid var(--border-color); + background: var(--bg-tertiary); display: flex; - justify-content: space-between; + gap: var(--space-md); align-items: center; - padding: var(--space-md) var(--space-lg); - border-bottom: 1px solid var(--border-color); + flex-wrap: wrap; } -.details-meta { - padding: var(--space-md) var(--space-lg); - background: var(--bg-tertiary); - font-size: 0.85rem; +.prs-toolbar-group { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; } -.details-meta p { - margin: var(--space-xs) 0; +.prs-label { + font-size: 0.75rem; + color: var(--text-secondary); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; } -.details-summary { - padding: var(--space-md) var(--space-lg); - border-bottom: 1px solid var(--border-color); - font-style: italic; - color: var(--text-secondary); +.prs-search { + min-width: 220px; + flex: 1; } -.details-messages { +.prs-input { + min-width: 220px; + flex: 0 1 320px; +} + +.prs-list { flex: 1; overflow-y: auto; - padding: var(--space-md) var(--space-lg); + padding: var(--space-lg); + display: flex; + flex-direction: column; + gap: var(--space-sm); } -.details-messages h4 { - margin-bottom: var(--space-md); +.pr-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + padding: var(--space-md); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-secondary); } -.messages-list { +.pr-main { display: flex; flex-direction: column; - gap: var(--space-sm); + gap: 6px; + min-width: 0; + flex: 1; } -.messages-list .message { - padding: var(--space-sm) var(--space-md); - border-radius: var(--radius-sm); - font-size: 0.85rem; +.pr-title { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; } -.messages-list .message.user { - background: var(--bg-tertiary); - margin-left: var(--space-lg); +.pr-repo { + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--text-secondary); } -.messages-list .message.assistant { - background: var(--accent-primary); - background: rgba(59, 130, 246, 0.2); - margin-right: var(--space-lg); +.pr-number { + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--text-secondary); } -.message-header { - display: flex; - justify-content: space-between; +.pr-badge { + font-family: var(--font-mono); font-size: 0.75rem; - color: var(--text-muted); - margin-bottom: var(--space-xs); + padding: 3px 6px; + border-radius: 999px; + border: 1px solid var(--border-color); + background: var(--bg-tertiary); + color: var(--text-secondary); } -.message-content { - white-space: pre-wrap; - word-break: break-word; +.pr-badge.draft { + border-color: var(--accent-warning); } -.tool-uses { - margin-top: var(--space-xs); - font-size: 0.7rem; - color: var(--text-muted); +.pr-subtitle { + font-weight: 600; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.more-messages { - text-align: center; - padding: var(--space-md); - color: var(--text-muted); - font-style: italic; +.pr-meta { + font-size: 0.75rem; + color: var(--text-tertiary); } -.details-actions { - padding: var(--space-md) var(--space-lg); - border-top: 1px solid var(--border-color); +.pr-actions { display: flex; - justify-content: flex-end; + gap: var(--space-sm); + flex-shrink: 0; } /* ============================================ - Ports Panel Styles + Tasks Panel Styles ============================================ */ -.ports-modal .ports-content { - max-width: 1100px; - width: 96vw; - max-height: 92vh; - display: flex; - flex-direction: column; -} - -.ports-header { +.queue-summary { display: flex; - justify-content: space-between; - align-items: center; - padding: var(--space-md) var(--space-lg); - border-bottom: 1px solid var(--border-color); -} - -.ports-header h2 { - margin: 0; - font-size: 1.2rem; -} - -.ports-info { - padding: var(--space-sm) var(--space-lg); - background: var(--bg-tertiary); - font-size: 0.85rem; - color: var(--text-muted); -} - -.ports-list { - flex: 1; - overflow-y: auto; - padding: var(--space-lg); - display: grid; - grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); - gap: var(--space-md); - align-content: start; + gap: 8px; + flex-wrap: wrap; + padding: 0 0 10px 0; } -.port-item { +.tasks-modal .tasks-content { + /* Tasks is a primary workflow: prefer a robust, full-viewport panel. */ + max-width: none; + box-sizing: border-box; + width: 100%; + max-width: 100vw; + max-height: 100vh; + height: 100%; display: flex; flex-direction: column; - gap: var(--space-sm); + overflow: hidden; + position: relative; + border-radius: 0; padding: var(--space-md); - background: var(--bg-tertiary); - border-radius: var(--radius-sm); - border-left: 3px solid var(--border-color); - transition: all 0.15s; + --tasks-board-accent: var(--accent-primary); + direction: ltr; + text-align: left; } -.port-item:hover { - background: var(--bg-primary); +.tasks-modal .tasks-content.tasks-has-board-accent .tasks-toolbar { + border-bottom-color: color-mix(in srgb, var(--border-color) 55%, var(--tasks-board-accent) 45%); } -.port-item.orchestrator, -.port-item.orchestrator-dev { - border-left-color: #9f7aea; +.tasks-modal .tasks-content.tasks-has-board-accent .tasks-column-header { + background: color-mix(in srgb, var(--bg-tertiary) 86%, var(--tasks-board-accent) 14%); } -.port-item.client, -.port-item.client-dev { - border-left-color: #63b3ed; +.tasks-modal .tasks-content.tasks-has-board-accent .tasks-column-header:hover { + background: color-mix(in srgb, var(--bg-primary) 86%, var(--tasks-board-accent) 14%); } -.port-item.vite, -.port-item.react { - border-left-color: #48bb78; +.tasks-modal .tasks-body { + direction: ltr; + text-align: left; } -.port-item.game-server { - border-left-color: #f6ad55; +.tasks-modal { + background: transparent; } -.port-item.python, -.port-item.flask { - border-left-color: #ffd93d; +.tasks-modal.tasks-theme-light { + --bg-primary: #ffffff; + --bg-secondary: #f6f8fa; + --bg-tertiary: #e6e8eb; + --text-primary: #24292f; + --text-secondary: #57606a; + --text-tertiary: #6e7781; + --border-color: #d0d7de; } -.port-item.node { - border-left-color: #68d391; +.tasks-modal .modal-header { + position: sticky; + top: 0; + z-index: 5; + background: var(--bg-secondary); + padding-bottom: var(--space-sm); } -.port-icon { - font-size: 1.2rem; - flex-shrink: 0; +.tasks-modal .tasks-close-btn { + width: 44px; + height: 44px; + font-size: 1.6rem; + color: var(--accent-danger); + border: 1px solid rgba(248, 81, 73, 0.35); + background: rgba(248, 81, 73, 0.12); } -.port-card-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--space-md); - min-width: 0; +.tasks-modal .tasks-close-btn:hover { + background: rgba(248, 81, 73, 0.2); + border-color: rgba(248, 81, 73, 0.55); + color: var(--accent-danger); } -.port-main { - display: flex; - align-items: flex-start; - gap: var(--space-md); - min-width: 0; - flex: 1; +.tasks-modal .tasks-close-btn:focus-visible { + outline: 2px solid var(--accent-danger); + outline-offset: 2px; } -.port-details { - flex: 1; - min-width: 0; +.tasks-view-toggle { + display: flex; + gap: 6px; + align-items: center; } -.port-name { - display: block; - font-weight: 500; - color: var(--text-primary); - font-size: 0.9rem; +.tasks-view-btn.active { + border-color: var(--accent-primary); + box-shadow: 0 0 0 2px rgba(31, 111, 235, 0.15); } -.port-process { - font-size: 0.75rem; - color: var(--text-muted); +.tasks-filter { + position: relative; } -.port-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: var(--space-xs); - flex-wrap: wrap; +.tasks-filter summary { + list-style: none; } -.port-action-btn { - border: 1px solid var(--border-color); - background: var(--bg-primary); - color: var(--text-primary); - border-radius: var(--radius-xs); - padding: 4px 8px; - font-size: 0.75rem; - cursor: pointer; - line-height: 1.1; - transition: background 0.15s, border-color 0.15s; +.tasks-filter summary::-webkit-details-marker { + display: none; } -.port-action-btn:hover { +.tasks-filter-popover { + position: absolute; + top: calc(100% + 8px); + left: 0; + min-width: 240px; + max-height: 320px; + overflow: auto; + z-index: 5; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); background: var(--bg-secondary); - border-color: var(--accent-primary); + box-shadow: 0 10px 24px rgba(0,0,0,0.12); + padding: 10px; } -.ports-footer { - padding: var(--space-md) var(--space-lg); - border-top: 1px solid var(--border-color); +.tasks-filter-actions { display: flex; - justify-content: flex-end; + gap: 8px; + margin-bottom: 10px; } -.no-ports { - text-align: center; - padding: var(--space-lg); - color: var(--text-muted); +.tasks-filter-list { + display: flex; + flex-direction: column; + gap: 6px; } -/* ============================================ - PRs Panel Styles - ============================================ */ - -.prs-modal .prs-content { - max-width: 1100px; - width: 96vw; - max-height: 92vh; +.tasks-filter-item { display: flex; - flex-direction: column; + align-items: center; + gap: 8px; + font-size: 0.85rem; + color: var(--text-primary); +} + +.tasks-filter-item input { + accent-color: var(--accent-primary); } -.prs-toolbar { +.tasks-toolbar { padding: var(--space-sm) var(--space-lg); border-bottom: 1px solid var(--border-color); background: var(--bg-tertiary); @@ -9658,201 +10688,255 @@ header h1 { flex-wrap: wrap; } -.prs-toolbar-group { - display: flex; - gap: 8px; +.tasks-launch-defaults { + display: inline-flex; align-items: center; - flex-wrap: wrap; + gap: 6px; + padding: 4px 8px; + border: 1px solid var(--border-color); + border-radius: 999px; + background: var(--bg-secondary); } -.prs-label { - font-size: 0.75rem; - color: var(--text-secondary); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; +.tasks-launch-default-tier-group { + padding: 0; + border: none; + background: transparent; } -.prs-search { - min-width: 220px; - flex: 1; +.tasks-launch-default-agent-group { + padding: 0; + border: none; + background: transparent; } -.prs-input { - min-width: 220px; - flex: 0 1 320px; +.tasks-launch-default-mode-group { + padding: 0; + border: none; + background: transparent; } -.prs-list { - flex: 1; - overflow-y: auto; - padding: var(--space-lg); - display: flex; - flex-direction: column; - gap: var(--space-sm); +.tasks-launch-defaults-label { + font-weight: 900; + font-size: 0.85rem; + color: var(--text-secondary); } -.pr-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-md); - padding: var(--space-md); +.tasks-toggle.tasks-toggle-mini { + font-size: 0.75rem; + gap: 6px; +} + +.tasks-toggle.tasks-toggle-mini span { + opacity: 0.9; +} + +.tasks-board-accent { + width: 12px; + height: 12px; + border-radius: 999px; + background: var(--tasks-board-accent); border: 1px solid var(--border-color); - border-radius: var(--radius-md); - background: var(--bg-secondary); + box-shadow: 0 0 0 2px rgba(31, 111, 235, 0.12); } -.pr-main { - display: flex; - flex-direction: column; - gap: 6px; - min-width: 0; - flex: 1; +.tasks-board-accent.is-hidden { + display: none; } -.pr-title { - display: flex; +.tasks-board-picker { + position: relative; + display: inline-flex; align-items: center; gap: 8px; - flex-wrap: wrap; } -.pr-repo { - font-family: var(--font-mono); - font-size: 0.8rem; - color: var(--text-secondary); +.tasks-board-btn { + min-width: 200px; + text-align: left; + justify-content: flex-start; } -.pr-number { - font-family: var(--font-mono); - font-size: 0.8rem; - color: var(--text-secondary); +.tasks-select-hidden { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; } -.pr-badge { - font-family: var(--font-mono); - font-size: 0.75rem; - padding: 3px 6px; - border-radius: 999px; - border: 1px solid var(--border-color); +.tasks-board-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 10; + min-width: 320px; + max-width: 420px; + max-height: 60vh; + overflow: auto; background: var(--bg-tertiary); - color: var(--text-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + box-shadow: 0 14px 32px rgba(0, 0, 0, 0.35); + padding: 6px; } -.pr-badge.draft { - border-color: var(--accent-warning); +.tasks-board-menu-search-wrap { + position: sticky; + top: 0; + z-index: 1; + background: var(--bg-tertiary); + padding: 6px; + border-bottom: 1px solid var(--border-color); + margin: -6px -6px 6px; } -.pr-subtitle { - font-weight: 600; +.tasks-board-menu-search { + width: 100%; + background: var(--bg-secondary); color: var(--text-primary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 8px 10px; + font-size: 0.85rem; } -.pr-meta { - font-size: 0.75rem; - color: var(--text-tertiary); +.tasks-board-menu-search:focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: 2px; } -.pr-actions { +.tasks-board-menu.hidden { + display: none; +} + +.tasks-board-menu-item { + width: 100%; display: flex; - gap: var(--space-sm); - flex-shrink: 0; + align-items: center; + gap: 10px; + background: transparent; + border: 1px solid transparent; + color: var(--text-primary); + border-radius: var(--radius-md); + padding: 8px 10px; + cursor: pointer; + text-align: left; } -/* ============================================ - Tasks Panel Styles - ============================================ */ +.tasks-board-menu-item:hover { + background: rgba(31, 111, 235, 0.12); + border-color: rgba(31, 111, 235, 0.25); +} -.queue-summary { - display: flex; - gap: 8px; - flex-wrap: wrap; - padding: 0 0 10px 0; +.tasks-board-menu-item.is-active { + border-color: rgba(31, 111, 235, 0.5); } -.tasks-modal .tasks-content { - /* Tasks is a primary workflow: prefer a robust, full-viewport panel. */ - max-width: none; - box-sizing: border-box; - width: 100%; - max-width: 100vw; - max-height: 100vh; - height: 100%; - display: flex; - flex-direction: column; +.tasks-board-menu-item.is-selected { + background: rgba(31, 111, 235, 0.18); + border-color: rgba(31, 111, 235, 0.35); +} + +.tasks-board-menu-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 999px; + border: 1px solid var(--border-color); + flex: 0 0 auto; +} + +.tasks-board-menu-dot.is-hidden { + visibility: hidden; +} + +.tasks-board-menu-label { overflow: hidden; - position: relative; - border-radius: 0; - padding: var(--space-md); - --tasks-board-accent: var(--accent-primary); - direction: ltr; - text-align: left; + text-overflow: ellipsis; + white-space: nowrap; } -.tasks-modal .tasks-content.tasks-has-board-accent .tasks-toolbar { - border-bottom-color: color-mix(in srgb, var(--border-color) 55%, var(--tasks-board-accent) 45%); +.tasks-board-menu-empty { + padding: 10px 12px; + color: var(--text-tertiary); + font-size: 0.85rem; } -.tasks-modal .tasks-content.tasks-has-board-accent .tasks-column-header { - background: color-mix(in srgb, var(--bg-tertiary) 86%, var(--tasks-board-accent) 14%); +.tasks-hotkeys-overlay { + position: absolute; + inset: 0; + background: var(--overlay-backdrop); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + z-index: 20; } -.tasks-modal .tasks-content.tasks-has-board-accent .tasks-column-header:hover { - background: color-mix(in srgb, var(--bg-primary) 86%, var(--tasks-board-accent) 14%); +.tasks-launch-popover-overlay { + position: absolute; + inset: 0; + background: var(--overlay-backdrop-faint); + z-index: 19; +} + +.tasks-launch-popover { + position: absolute; + width: min(460px, 92vw); + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + box-shadow: var(--shadow-float); + padding: 12px; } -.tasks-modal .tasks-body { - direction: ltr; - text-align: left; +.tasks-launch-popover-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; } -.tasks-modal { - background: transparent; +.tasks-launch-popover-title { + font-weight: 800; + color: var(--text-primary); } -.tasks-modal.tasks-theme-light { - --bg-primary: #ffffff; - --bg-secondary: #f6f8fa; - --bg-tertiary: #e6e8eb; - --text-primary: #24292f; - --text-secondary: #57606a; - --text-tertiary: #6e7781; - --border-color: #d0d7de; +.tasks-launch-popover-meta { + font-size: 0.8rem; + color: var(--text-tertiary); + margin-bottom: 10px; } -.tasks-modal .modal-header { - position: sticky; - top: 0; - z-index: 5; +.tasks-launch-popover-warn { + font-size: 0.85rem; + color: var(--text-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 10px 12px; background: var(--bg-secondary); - padding-bottom: var(--space-sm); -} - -.tasks-modal .tasks-close-btn { - width: 44px; - height: 44px; - font-size: 1.6rem; - color: var(--accent-danger); - border: 1px solid rgba(248, 81, 73, 0.35); - background: rgba(248, 81, 73, 0.12); + margin-bottom: 10px; } -.tasks-modal .tasks-close-btn:hover { - background: rgba(248, 81, 73, 0.2); - border-color: rgba(248, 81, 73, 0.55); - color: var(--accent-danger); +.tasks-launch-popover-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + align-items: end; } -.tasks-modal .tasks-close-btn:focus-visible { - outline: 2px solid var(--accent-danger); - outline-offset: 2px; +.tasks-launch-popover-field { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.8rem; + color: var(--text-secondary); } -.tasks-view-toggle { +.tasks-launch-popover-actions { display: flex; gap: 6px; align-items: center; @@ -9860,9 +10944,15 @@ header h1 { max-width: 100%; } -.tasks-view-btn.active { - border-color: var(--accent-primary); - box-shadow: 0 0 0 2px rgba(31, 111, 235, 0.15); +.tasks-hotkeys-card { + width: min(860px, 96vw); + max-height: min(80vh, 760px); + overflow: auto; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45); + padding: 14px; } .tasks-view-btn { @@ -9873,50 +10963,58 @@ header h1 { position: relative; } -.tasks-filter summary { - list-style: none; +.tasks-hotkeys-title { + font-weight: 800; + font-size: 1rem; + color: var(--text-primary); } -.tasks-filter summary::-webkit-details-marker { - display: none; +.tasks-hotkeys-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; } -.tasks-filter-popover { - position: absolute; - top: calc(100% + 8px); - left: 0; - min-width: 240px; - max-height: 320px; - overflow: auto; - z-index: 5; +.tasks-hotkeys-group { border: 1px solid var(--border-color); - border-radius: var(--radius-md); background: var(--bg-secondary); - box-shadow: 0 10px 24px rgba(0,0,0,0.12); + border-radius: var(--radius-md); padding: 10px; } -.tasks-filter-actions { - display: flex; - gap: 8px; - margin-bottom: 10px; +.tasks-hotkeys-group-title { + font-weight: 800; + font-size: 0.85rem; + color: var(--text-primary); + margin-bottom: 8px; } -.tasks-filter-list { - display: flex; - flex-direction: column; - gap: 6px; +.tasks-hotkeys-row { + font-size: 0.85rem; + color: var(--text-secondary); + line-height: 1.3; + margin-bottom: 6px; } -.tasks-filter-item { - display: flex; +.tasks-hotkeys-row code { + font-size: 0.8rem; + background: rgba(31, 111, 235, 0.12); + border: 1px solid rgba(31, 111, 235, 0.2); + border-radius: 6px; + padding: 2px 6px; + color: var(--text-primary); +} + +.tasks-toggle { + display: inline-flex; align-items: center; gap: 8px; - font-size: 0.85rem; - color: var(--text-primary); + font-size: 0.8rem; + color: var(--text-secondary); + user-select: none; } -.tasks-filter-item input { +.tasks-toggle input { accent-color: var(--accent-primary); } @@ -9927,7 +11025,11 @@ header h1 { display: flex; gap: var(--space-sm); align-items: center; - flex-wrap: wrap; + gap: 6px; + padding: 4px 6px; + border: 1px solid var(--border-color); + border-radius: 999px; + background: var(--bg-secondary); } #queue-panel .tasks-toolbar { @@ -9999,389 +11101,460 @@ header h1 { align-items: center; gap: 6px; padding: 4px 8px; - border: 1px solid var(--border-color); border-radius: 999px; - background: var(--bg-secondary); + font-size: 0.8rem; + color: var(--text-secondary); + cursor: pointer; + user-select: none; } -.tasks-launch-default-tier-group { - padding: 0; - border: none; - background: transparent; +.tasks-radio-option input { + margin: 0; + accent-color: var(--accent-primary); } -.tasks-launch-default-agent-group { - padding: 0; - border: none; - background: transparent; +.tasks-radio-option:has(input:checked) { + background: rgba(31, 111, 235, 0.15); + color: var(--text-primary); } -.tasks-launch-default-mode-group { - padding: 0; - border: none; - background: transparent; +.tasks-radio-option:has(input:focus-visible) { + outline: 2px solid var(--accent-primary); + outline-offset: 2px; } -.tasks-launch-defaults-label { - font-weight: 900; +.tasks-select { + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 6px 10px; font-size: 0.85rem; - color: var(--text-secondary); + min-width: 160px; } -.tasks-toggle.tasks-toggle-mini { - font-size: 0.75rem; - gap: 6px; +.tasks-search { + min-width: 220px; + flex: 1; } -.tasks-toggle.tasks-toggle-mini span { - opacity: 0.9; +.tasks-body { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: 420px 1fr; + grid-template-rows: 1fr; + grid-template-areas: "cards detail"; } -.tasks-board-accent { - width: 12px; - height: 12px; - border-radius: 999px; - background: var(--tasks-board-accent); - border: 1px solid var(--border-color); - box-shadow: 0 0 0 2px rgba(31, 111, 235, 0.12); +.tasks-body.tasks-body-board { + /* Board view: show full-width kanban until a card is selected. */ + grid-template-columns: 1fr; + grid-template-areas: "cards"; + position: relative; + overflow: hidden; +} + +.tasks-body.tasks-body-board .tasks-detail { + display: none; +} + +.tasks-body.tasks-body-board .tasks-cards { + grid-column: 1; + border-left: none; +} + +.tasks-body.tasks-body-board.tasks-kanban-wrap .tasks-cards { + overflow: auto; +} + +.tasks-body.tasks-body-board.tasks-has-detail { + /* When a card is selected, show details as an overlay on the right. */ + grid-template-columns: 1fr; +} + +.tasks-body.tasks-body-board.tasks-has-detail .tasks-detail { + display: block; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 520px; + max-width: min(520px, 65vw); + border-left: 1px solid var(--border-color); + box-shadow: -10px 0 24px rgba(0, 0, 0, 0.25); + z-index: 5; +} + +.tasks-body.tasks-body-board.tasks-has-detail .tasks-cards { + grid-column: 1; + border-left: none; +} + +.tasks-cards { + grid-area: cards; + grid-column: 1; + grid-row: 1; + overflow-y: auto; + padding: var(--space-md); + border-right: 1px solid var(--border-color); + background: var(--bg-secondary); + display: flex; + flex-direction: column; + gap: var(--space-sm); + min-width: 0; } -.tasks-board-accent.is-hidden { - display: none; +.tasks-body.tasks-body-board .tasks-cards { + border-right: none; + overflow: hidden; + padding: var(--space-sm); + background: var(--bg-primary); } -.tasks-board-picker { - position: relative; - display: inline-flex; - align-items: center; - gap: 8px; +.tasks-board { + height: 100%; + display: flex; + gap: var(--space-md); + justify-content: flex-start; + overflow-x: auto; + overflow-y: hidden; + padding-bottom: var(--space-sm); + scroll-snap-type: x mandatory; } -.tasks-board-btn { - min-width: 200px; - text-align: left; - justify-content: flex-start; +.tasks-board.tasks-board-wrap { + flex-wrap: wrap; + overflow-x: hidden; + overflow-y: auto; + align-content: flex-start; + scroll-snap-type: none; } -.tasks-select-hidden { - position: absolute; - left: -9999px; - width: 1px; - height: 1px; - opacity: 0; - pointer-events: none; +.tasks-board.tasks-board-wrap.tasks-board-grid .tasks-column-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + align-content: start; } -.tasks-board-menu { - position: absolute; - top: calc(100% + 6px); - left: 0; - z-index: 10; - min-width: 320px; - max-width: 420px; - max-height: 60vh; - overflow: auto; - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - box-shadow: 0 14px 32px rgba(0, 0, 0, 0.35); - padding: 6px; +.tasks-board.tasks-board-expand .tasks-column-cards { + display: grid; + grid-auto-flow: column; + grid-template-rows: repeat(var(--tasks-card-rows, 1), min-content); + grid-template-columns: repeat(var(--tasks-card-columns, 1), minmax(180px, 1fr)); + align-content: start; + gap: var(--space-sm); + overflow: hidden; + flex: 1; + min-height: 0; } -.tasks-board-menu-search-wrap { - position: sticky; - top: 0; - z-index: 1; - background: var(--bg-tertiary); - padding: 6px; - border-bottom: 1px solid var(--border-color); - margin: -6px -6px 6px; +.tasks-board.tasks-board-grid .task-card-board { + height: fit-content; } -.tasks-board-menu-search { - width: 100%; +.tasks-column { + --tasks-col-expanded: clamp(240px, 22vw, 360px); + --tasks-col-collapsed: 56px; + --tasks-card-columns: 1; + --tasks-card-rows: 1; + width: var(--tasks-col-expanded); + min-width: var(--tasks-col-expanded); background: var(--bg-secondary); - color: var(--text-primary); border: 1px solid var(--border-color); border-radius: var(--radius-md); - padding: 8px 10px; - font-size: 0.85rem; -} - -.tasks-board-menu-search:focus-visible { - outline: 2px solid var(--accent-primary); - outline-offset: 2px; + display: flex; + flex-direction: column; + max-height: 100%; + scroll-snap-align: start; + position: relative; } -.tasks-board-menu.hidden { - display: none; +.tasks-column.hover { + border-color: var(--tasks-board-accent); + box-shadow: 0 0 0 2px rgba(31, 111, 235, 0.15); } -.tasks-board-menu-item { - width: 100%; +.tasks-column-header { + padding: var(--space-sm) var(--space-md); display: flex; - align-items: center; - gap: 10px; - background: transparent; - border: 1px solid transparent; - color: var(--text-primary); - border-radius: var(--radius-md); - padding: 8px 10px; + align-items: baseline; + justify-content: space-between; + gap: var(--space-sm); + width: 100%; + background: var(--bg-tertiary); + border: none; + border-bottom: 1px solid var(--border-color); cursor: pointer; text-align: left; + color: var(--text-primary); } -.tasks-board-menu-item:hover { - background: rgba(31, 111, 235, 0.12); - border-color: rgba(31, 111, 235, 0.25); -} - -.tasks-board-menu-item.is-active { - border-color: rgba(31, 111, 235, 0.5); -} - -.tasks-board-menu-item.is-selected { - background: rgba(31, 111, 235, 0.18); - border-color: rgba(31, 111, 235, 0.35); -} - -.tasks-board-menu-dot { - display: inline-block; - width: 10px; - height: 10px; - border-radius: 999px; - border: 1px solid var(--border-color); - flex: 0 0 auto; +.tasks-column-header:hover { + background: var(--bg-primary); } -.tasks-board-menu-dot.is-hidden { - visibility: hidden; +.tasks-column-header:focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: 2px; } -.tasks-board-menu-label { +.tasks-column-title { + font-weight: 700; + font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: var(--text-primary); } -.tasks-board-menu-empty { - padding: 10px 12px; +.tasks-column-count { + font-family: var(--font-mono); + font-size: 0.8rem; color: var(--text-tertiary); - font-size: 0.85rem; } -.tasks-hotkeys-overlay { - position: absolute; - inset: 0; - background: var(--overlay-backdrop); +.tasks-column-cards { + overflow-y: auto; + padding: var(--space-sm); display: flex; - align-items: center; - justify-content: center; - padding: 16px; - z-index: 20; -} - -.tasks-launch-popover-overlay { - position: absolute; - inset: 0; - background: var(--overlay-backdrop-faint); - z-index: 19; + flex-direction: column; + gap: var(--space-sm); } -.tasks-launch-popover { - position: absolute; - width: min(460px, 92vw); - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - box-shadow: var(--shadow-float); - padding: 12px; +.tasks-column.is-collapsed { + width: var(--tasks-col-collapsed); + min-width: var(--tasks-col-collapsed); } -.tasks-launch-popover-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 8px; +.tasks-column.is-collapsed .tasks-column-cards { + display: none; } -.tasks-launch-popover-title { +.tasks-column.is-collapsed .tasks-column-title { + writing-mode: vertical-rl; + transform: rotate(180deg); + white-space: nowrap; + overflow: visible; + text-overflow: unset; + font-size: 0.9rem; font-weight: 800; - color: var(--text-primary); + letter-spacing: 0.04em; + text-transform: uppercase; } -.tasks-launch-popover-meta { - font-size: 0.8rem; - color: var(--text-tertiary); - margin-bottom: 10px; +.tasks-column.is-collapsed .tasks-column-header { + justify-content: flex-start; + align-items: center; + flex-direction: column; + height: 100%; + padding: 10px 8px; + gap: 14px; } -.tasks-launch-popover-warn { - font-size: 0.85rem; - color: var(--text-secondary); +.tasks-column.is-collapsed .tasks-column-count { + display: inline-flex; + min-width: 30px; + height: 30px; + padding: 0 10px; + border-radius: 999px; border: 1px solid var(--border-color); - border-radius: var(--radius-md); - padding: 10px 12px; background: var(--bg-secondary); - margin-bottom: 10px; + align-items: center; + justify-content: center; + font-weight: 700; + color: var(--text-primary); + order: -1; } -.tasks-launch-popover-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; - align-items: end; +.task-card-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; } -.tasks-launch-popover-field { - display: flex; - flex-direction: column; - gap: 6px; - font-size: 0.8rem; - color: var(--text-secondary); +.task-card-top-right { + display: inline-flex; + align-items: center; + gap: 8px; + flex-shrink: 0; } -.tasks-launch-popover-actions { - display: flex; - gap: 10px; - justify-content: flex-end; - margin-top: 12px; +.task-card-quick-actions { + display: inline-flex; + align-items: center; + gap: 6px; } -.tasks-hotkeys-card { - width: min(860px, 96vw); - max-height: min(80vh, 760px); - overflow: auto; - background: var(--bg-primary); +.tasks-quick-tier-group { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px; border: 1px solid var(--border-color); - border-radius: var(--radius-md); - box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45); - padding: 14px; + border-radius: 999px; + background: var(--bg-secondary); } -.tasks-hotkeys-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 12px; +.tasks-quick-tier-btn { + padding: 3px 7px; + font-size: 0.72rem; + font-weight: 900; + line-height: 1; + border-radius: 999px; } -.tasks-hotkeys-title { - font-weight: 800; - font-size: 1rem; +.tasks-quick-tier-btn.is-selected { + border-color: var(--accent-primary); + background: rgba(31, 111, 235, 0.18); color: var(--text-primary); } -.tasks-hotkeys-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 12px; +.tasks-select.tasks-select-mini { + min-width: 64px; + padding: 4px 6px; + font-size: 0.75rem; + font-weight: 800; } -.tasks-hotkeys-group { +.tasks-quick-launch-btn { + padding: 4px 8px; + font-size: 0.8rem; + line-height: 1; +} + +.task-card-labels { + display: inline-flex; + gap: 6px; + flex-wrap: nowrap; + overflow: hidden; + min-width: 0; +} + +.tasks-label { + display: inline-flex; + align-items: center; + padding: 2px 6px; + border-radius: 999px; + font-size: 0.7rem; + line-height: 1.2; border: 1px solid var(--border-color); background: var(--bg-secondary); - border-radius: var(--radius-md); - padding: 10px; + color: var(--text-secondary); + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.tasks-hotkeys-group-title { - font-weight: 800; - font-size: 0.85rem; - color: var(--text-primary); - margin-bottom: 8px; +.tasks-label-editor { + display: flex; + flex-wrap: wrap; + gap: 6px; } -.tasks-hotkeys-row { - font-size: 0.85rem; - color: var(--text-secondary); - line-height: 1.3; - margin-bottom: 6px; +.tasks-label-toggle { + appearance: none; + cursor: pointer; } -.tasks-hotkeys-row code { - font-size: 0.8rem; - background: rgba(31, 111, 235, 0.12); - border: 1px solid rgba(31, 111, 235, 0.2); - border-radius: 6px; - padding: 2px 6px; - color: var(--text-primary); +.tasks-label-toggle.is-selected { + box-shadow: 0 0 0 2px rgba(31, 111, 235, 0.25); } -.tasks-toggle { +.tasks-checkbox { display: inline-flex; align-items: center; gap: 8px; font-size: 0.8rem; color: var(--text-secondary); - user-select: none; } -.tasks-toggle input { +.tasks-checkbox input { + margin: 0; accent-color: var(--accent-primary); } -.tasks-radio { +.tasks-label--green { border-color: #2ea043; background: rgba(46, 160, 67, 0.12); color: var(--text-primary); } +.tasks-label--yellow { border-color: #d29922; background: rgba(210, 153, 34, 0.14); color: var(--text-primary); } +.tasks-label--orange { border-color: #f78166; background: rgba(247, 129, 102, 0.14); color: var(--text-primary); } +.tasks-label--red { border-color: #f85149; background: rgba(248, 81, 73, 0.14); color: var(--text-primary); } +.tasks-label--purple { border-color: #a371f7; background: rgba(163, 113, 247, 0.14); color: var(--text-primary); } +.tasks-label--blue { border-color: #1f6feb; background: rgba(31, 111, 235, 0.14); color: var(--text-primary); } +.tasks-label--sky { border-color: #79c0ff; background: rgba(121, 192, 255, 0.14); color: var(--text-primary); } +.tasks-label--lime { border-color: #7ee787; background: rgba(126, 231, 135, 0.14); color: var(--text-primary); } +.tasks-label--pink { border-color: #ff80c8; background: rgba(255, 128, 200, 0.14); color: var(--text-primary); } +.tasks-label--black { border-color: #30363d; background: rgba(48, 54, 61, 0.6); color: var(--text-primary); } +.tasks-label--more { border-color: var(--border-color); background: var(--bg-tertiary); color: var(--text-primary); font-family: var(--font-mono); } + +.task-card-assignees { display: inline-flex; - align-items: center; gap: 6px; - padding: 4px 6px; - border: 1px solid var(--border-color); - border-radius: 999px; - background: var(--bg-secondary); + flex-shrink: 0; } -.tasks-radio-option { +.tasks-avatar { + width: 22px; + height: 22px; + border-radius: 999px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); display: inline-flex; align-items: center; - gap: 6px; - padding: 4px 8px; - border-radius: 999px; - font-size: 0.8rem; + justify-content: center; + overflow: hidden; color: var(--text-secondary); - cursor: pointer; - user-select: none; + font-size: 0.75rem; + text-decoration: none; } -.tasks-radio-option input { - margin: 0; - accent-color: var(--accent-primary); +.tasks-avatar img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; } -.tasks-radio-option:has(input:checked) { - background: rgba(31, 111, 235, 0.15); - color: var(--text-primary); +.tasks-avatar-more { + font-family: var(--font-mono); + font-size: 0.7rem; } -.tasks-radio-option:has(input:focus-visible) { - outline: 2px solid var(--accent-primary); - outline-offset: 2px; +.task-card-due { + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--text-secondary); } -.tasks-select { - background: var(--bg-secondary); - color: var(--text-primary); +.tasks-kv { + display: flex; + flex-direction: column; + gap: 6px; +} + +.tasks-kv-row { + display: grid; + grid-template-columns: 140px 1fr; + gap: 10px; + padding: 8px 10px; border: 1px solid var(--border-color); border-radius: var(--radius-md); - padding: 6px 10px; - font-size: 0.85rem; - min-width: 160px; + background: var(--bg-secondary); } -.tasks-search { - min-width: 220px; - flex: 1; +.tasks-kv-row-edit { + align-items: center; } -.tasks-body { - flex: 1; - min-height: 0; - display: grid; - grid-template-columns: 420px 1fr; - grid-template-rows: 1fr; - grid-template-areas: "cards detail"; +.tasks-kv-key { + color: var(--text-secondary); + font-size: 0.8rem; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } #queue-panel .tasks-body { @@ -10407,106 +11580,139 @@ header h1 { margin-top: var(--space-sm); } -.tasks-body.tasks-body-board { - /* Board view: show full-width kanban until a card is selected. */ - grid-template-columns: 1fr; - grid-template-areas: "cards"; - position: relative; - overflow: hidden; +.tasks-body.tasks-body-board { + /* Board view: show full-width kanban until a card is selected. */ + grid-template-columns: 1fr; + grid-template-areas: "cards"; + position: relative; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tasks-kv-val-edit { + overflow: visible; + text-overflow: unset; + white-space: normal; + display: flex; + align-items: center; + gap: 8px; +} + +.task-card-board { + cursor: grab; +} + +.task-card-board.dragging { + opacity: 0.6; + cursor: grabbing; +} + +.task-card-row { + padding: var(--space-sm) var(--space-md); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-tertiary); + cursor: pointer; + transition: border-color 0.15s, transform 0.15s; +} + +.task-card-list { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.task-card-list-main { + min-width: 0; + flex: 1; } -.tasks-body.tasks-body-board .tasks-detail { - display: none; +.task-card-list-actions { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 6px; } -.tasks-body.tasks-body-board .tasks-cards { - grid-column: 1; - border-left: none; +.task-card-row:hover { + border-color: var(--accent-primary); + transform: translateY(-1px); } -.tasks-body.tasks-body-board.tasks-kanban-wrap .tasks-cards { - overflow: auto; +.task-card-row.active { + border-color: var(--accent-primary); + box-shadow: 0 0 0 2px rgba(31, 111, 235, 0.2); } -.tasks-body.tasks-body-board.tasks-has-detail { - /* When a card is selected, show details as an overlay on the right. */ - grid-template-columns: 1fr; +.task-card-title { + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; } -.tasks-body.tasks-body-board.tasks-has-detail .tasks-detail { - display: block; - position: absolute; - top: 0; - right: 0; - bottom: 0; - width: 520px; - max-width: min(520px, 65vw); - border-left: 1px solid var(--border-color); - box-shadow: -10px 0 24px rgba(0, 0, 0, 0.25); - z-index: 5; +.tasks-card-board-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 999px; + border: 1px solid var(--border-color); + margin-right: 8px; + transform: translateY(1px); } -.tasks-body.tasks-body-board.tasks-has-detail .tasks-cards { - grid-column: 1; - border-left: none; +.task-card-meta { + font-size: 0.75rem; + color: var(--text-tertiary); } -.tasks-cards { - grid-area: cards; - grid-column: 1; +.tasks-detail { + grid-area: detail; + grid-column: 2; grid-row: 1; overflow-y: auto; - padding: var(--space-md); - border-right: 1px solid var(--border-color); - background: var(--bg-secondary); - display: flex; - flex-direction: column; - gap: var(--space-sm); + padding: var(--space-lg); + background: var(--bg-primary); min-width: 0; } -.tasks-body.tasks-body-board .tasks-cards { - border-right: none; - overflow: hidden; - padding: var(--space-sm); - background: var(--bg-primary); +.tasks-detail-empty { + color: var(--text-secondary); } -.tasks-board { - height: 100%; +.tasks-detail-header { display: flex; + align-items: flex-start; + justify-content: space-between; gap: var(--space-md); - justify-content: flex-start; - overflow-x: auto; - overflow-y: hidden; - padding-bottom: var(--space-sm); - scroll-snap-type: x mandatory; + margin-bottom: var(--space-sm); } -.tasks-board.tasks-board-wrap { - flex-wrap: wrap; - overflow-x: hidden; - overflow-y: auto; - align-content: flex-start; - scroll-snap-type: none; +.tasks-detail-title { + font-weight: 700; + font-size: 1rem; + line-height: 1.2; } -.tasks-board.tasks-board-wrap.tasks-board-grid .tasks-column-cards { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); - align-content: start; +.tasks-detail-actions { + display: flex; + gap: var(--space-sm); + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; + flex-shrink: 0; } -.tasks-board.tasks-board-expand .tasks-column-cards { - display: grid; - grid-auto-flow: column; - grid-template-rows: repeat(var(--tasks-card-rows, 1), min-content); - grid-template-columns: repeat(var(--tasks-card-columns, 1), minmax(180px, 1fr)); - align-content: start; - gap: var(--space-sm); - overflow: hidden; - flex: 1; - min-height: 0; +.tasks-input { + width: 100%; + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 8px 10px; + font-size: 0.95rem; + font-weight: 700; } .tasks-board.tasks-board-expand .task-card-title { @@ -10522,1143 +11728,1163 @@ header h1 { height: fit-content; } -.tasks-column { - --tasks-col-expanded: clamp(240px, 22vw, 360px); - --tasks-col-collapsed: 56px; - --tasks-card-columns: 1; - --tasks-card-rows: 1; - width: var(--tasks-col-expanded); - min-width: var(--tasks-col-expanded); +.tasks-textarea { + width: 100%; background: var(--bg-secondary); + color: var(--text-primary); border: 1px solid var(--border-color); border-radius: var(--radius-md); - display: flex; - flex-direction: column; - max-height: 100%; - scroll-snap-align: start; - position: relative; -} - -.tasks-column.hover { - border-color: var(--tasks-board-accent); - box-shadow: 0 0 0 2px rgba(31, 111, 235, 0.15); + padding: 8px 10px; + font-size: 0.85rem; + font-family: var(--font-mono); + resize: vertical; } -.tasks-column-header { - padding: var(--space-sm) var(--space-md); +.tasks-inline-row { display: flex; - align-items: baseline; - justify-content: space-between; gap: var(--space-sm); - width: 100%; - background: var(--bg-tertiary); - border: none; - border-bottom: 1px solid var(--border-color); - cursor: pointer; - text-align: left; - color: var(--text-primary); -} - -.tasks-column-header:hover { - background: var(--bg-primary); -} - -.tasks-column-header:focus-visible { - outline: 2px solid var(--accent-primary); - outline-offset: 2px; -} - -.tasks-column-title { - font-weight: 700; - font-size: 0.9rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--text-primary); -} - -.tasks-column-count { - font-family: var(--font-mono); - font-size: 0.8rem; - color: var(--text-tertiary); + align-items: center; + flex-wrap: wrap; } -.tasks-column-cards { - overflow-y: auto; - padding: var(--space-sm); +.tasks-combined-list { display: flex; flex-direction: column; - gap: var(--space-sm); -} - -.tasks-column.is-collapsed { - width: var(--tasks-col-collapsed); - min-width: var(--tasks-col-collapsed); -} - -.tasks-column.is-collapsed .tasks-column-cards { - display: none; -} - -.tasks-column.is-collapsed .tasks-column-title { - writing-mode: vertical-rl; - transform: rotate(180deg); - white-space: nowrap; - overflow: visible; - text-overflow: unset; - font-size: 0.9rem; - font-weight: 800; - letter-spacing: 0.04em; - text-transform: uppercase; + gap: 8px; } -.tasks-column.is-collapsed .tasks-column-header { - justify-content: flex-start; +.tasks-combined-item { + display: flex; align-items: center; - flex-direction: column; - height: 100%; - padding: 10px 8px; - gap: 14px; -} - -.tasks-column.is-collapsed .tasks-column-count { - display: inline-flex; - min-width: 30px; - height: 30px; - padding: 0 10px; - border-radius: 999px; + justify-content: space-between; + gap: 10px; + padding: 10px 12px; border: 1px solid var(--border-color); + border-radius: var(--radius-md); background: var(--bg-secondary); - align-items: center; - justify-content: center; - font-weight: 700; - color: var(--text-primary); - order: -1; } -.task-card-top { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - margin-bottom: 6px; +.tasks-combined-label { + font-size: 0.85rem; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.task-card-top-right { +.tasks-combined-actions { display: inline-flex; - align-items: center; - gap: 8px; + gap: 6px; flex-shrink: 0; } -.task-card-quick-actions { +.tasks-chips { display: inline-flex; - align-items: center; gap: 6px; + flex-wrap: wrap; + margin-left: 6px; } -.tasks-quick-tier-group { +.tasks-chip { display: inline-flex; align-items: center; - gap: 4px; - padding: 2px; - border: 1px solid var(--border-color); + gap: 6px; + padding: 4px 8px; border-radius: 999px; + border: 1px solid var(--border-color); background: var(--bg-secondary); + font-size: 0.75rem; + color: var(--text-secondary); } -.tasks-quick-tier-btn { - padding: 3px 7px; - font-size: 0.72rem; - font-weight: 900; - line-height: 1; +.tasks-chip-avatar { + width: 16px; + height: 16px; border-radius: 999px; + object-fit: cover; + display: inline-block; } -.tasks-quick-tier-btn.is-selected { - border-color: var(--accent-primary); - background: rgba(31, 111, 235, 0.18); +.tasks-chip-link { + color: var(--text-secondary); + text-decoration: none; +} + +.tasks-chip-link:hover { color: var(--text-primary); + text-decoration: underline; } -.tasks-select.tasks-select-mini { - min-width: 64px; - padding: 4px 6px; - font-size: 0.75rem; - font-weight: 800; +.tasks-chip-muted { + opacity: 0.7; } -.tasks-quick-launch-btn { - padding: 4px 8px; - font-size: 0.8rem; +.tasks-chip-x { + appearance: none; + border: none; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + font-size: 0.9rem; line-height: 1; + padding: 0 2px; } -.task-card-labels { - display: inline-flex; - gap: 6px; - flex-wrap: nowrap; - overflow: hidden; - min-width: 0; +.tasks-chip-x:hover { + color: var(--text-primary); } -.tasks-label { - display: inline-flex; +.tasks-detail-block { + margin-top: var(--space-md); +} + +.tasks-detail-block.tasks-dropzone-hover { + outline: 2px dashed var(--accent-color); + outline-offset: 6px; + border-radius: var(--radius-md); +} + +.tasks-detail-block-title { + font-size: 0.8rem; + color: var(--text-secondary); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 6px; +} + +.tasks-move-row { + display: flex; + gap: var(--space-sm); align-items: center; - padding: 2px 6px; - border-radius: 999px; - font-size: 0.7rem; - line-height: 1.2; +} + +.tasks-select-inline { + min-width: 240px; +} + +.tasks-comment-row { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.tasks-comments { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.tasks-comment { + padding: var(--space-sm) var(--space-md); border: 1px solid var(--border-color); + border-radius: var(--radius-md); background: var(--bg-secondary); - color: var(--text-secondary); - max-width: 120px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } -.tasks-label-editor { +.tasks-comment-meta { + font-size: 0.75rem; + color: var(--text-tertiary); + margin-bottom: 6px; +} + +.tasks-comment-text { + white-space: pre-wrap; + color: var(--text-primary); + line-height: 1.35; + font-size: 0.85rem; +} + +.tasks-deps { display: flex; - flex-wrap: wrap; + flex-direction: column; gap: 6px; } -.tasks-label-toggle { - appearance: none; - cursor: pointer; +.tasks-dep-row { + display: grid; + grid-template-columns: 18px 1fr auto; + gap: 10px; + align-items: center; + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-secondary); } -.tasks-label-toggle.is-selected { - box-shadow: 0 0 0 2px rgba(31, 111, 235, 0.25); +.tasks-dep-row.done { + opacity: 0.75; } -.tasks-checkbox { - display: inline-flex; - align-items: center; - gap: 8px; - font-size: 0.8rem; - color: var(--text-secondary); +.tasks-cover { + display: block; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-secondary); + overflow: hidden; } -.tasks-checkbox input { - margin: 0; - accent-color: var(--accent-primary); +.tasks-cover img { + display: block; + width: 100%; + max-height: 220px; + object-fit: cover; } -.tasks-label--green { border-color: #2ea043; background: rgba(46, 160, 67, 0.12); color: var(--text-primary); } -.tasks-label--yellow { border-color: #d29922; background: rgba(210, 153, 34, 0.14); color: var(--text-primary); } -.tasks-label--orange { border-color: #f78166; background: rgba(247, 129, 102, 0.14); color: var(--text-primary); } -.tasks-label--red { border-color: #f85149; background: rgba(248, 81, 73, 0.14); color: var(--text-primary); } -.tasks-label--purple { border-color: #a371f7; background: rgba(163, 113, 247, 0.14); color: var(--text-primary); } -.tasks-label--blue { border-color: #1f6feb; background: rgba(31, 111, 235, 0.14); color: var(--text-primary); } -.tasks-label--sky { border-color: #79c0ff; background: rgba(121, 192, 255, 0.14); color: var(--text-primary); } -.tasks-label--lime { border-color: #7ee787; background: rgba(126, 231, 135, 0.14); color: var(--text-primary); } -.tasks-label--pink { border-color: #ff80c8; background: rgba(255, 128, 200, 0.14); color: var(--text-primary); } -.tasks-label--black { border-color: #30363d; background: rgba(48, 54, 61, 0.6); color: var(--text-primary); } -.tasks-label--more { border-color: var(--border-color); background: var(--bg-tertiary); color: var(--text-primary); font-family: var(--font-mono); } +.tasks-cover.tasks-cover-color { + height: 110px; +} -.task-card-assignees { - display: inline-flex; +.tasks-attachments { + display: flex; + flex-direction: column; gap: 6px; - flex-shrink: 0; } -.tasks-avatar { - width: 22px; - height: 22px; - border-radius: 999px; +.tasks-attachment { + display: flex; + gap: 10px; + align-items: center; + padding: 8px 10px; border: 1px solid var(--border-color); + border-radius: var(--radius-md); background: var(--bg-secondary); +} + +.tasks-attachment-thumb { display: inline-flex; + width: 44px; + height: 44px; + border-radius: var(--radius-sm); + overflow: hidden; + border: 1px solid var(--border-color); + background: rgba(0, 0, 0, 0.08); + flex: 0 0 auto; align-items: center; justify-content: center; - overflow: hidden; - color: var(--text-secondary); - font-size: 0.75rem; - text-decoration: none; } -.tasks-avatar img { +.tasks-attachment-thumb.is-empty { + opacity: 0.5; +} + +.tasks-attachment-thumb img { + display: block; width: 100%; height: 100%; object-fit: cover; - display: block; } -.tasks-avatar-more { - font-family: var(--font-mono); - font-size: 0.7rem; +.tasks-attachment-body { + min-width: 0; + flex: 1; } -.task-card-due { - font-family: var(--font-mono); - font-size: 0.72rem; - color: var(--text-secondary); +.tasks-attachment-name { + color: var(--text-primary); + font-size: 0.85rem; + line-height: 1.25; + text-decoration: none; + word-break: break-word; +} + +.tasks-attachment-name:hover { + text-decoration: underline; +} + +.tasks-attachment-meta { + margin-top: 2px; + color: var(--text-tertiary); + font-size: 0.75rem; + line-height: 1.2; + word-break: break-word; } -.tasks-kv { +.tasks-checklists { display: flex; flex-direction: column; - gap: 6px; + gap: var(--space-sm); } -.tasks-kv-row { - display: grid; - grid-template-columns: 140px 1fr; - gap: 10px; - padding: 8px 10px; +.tasks-checklist { border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-secondary); + padding: 10px; } -.tasks-kv-row-edit { +.tasks-checklist-header { + display: flex; + gap: 10px; align-items: center; + justify-content: space-between; } -.tasks-kv-key { - color: var(--text-secondary); - font-size: 0.8rem; +.tasks-checklist-title { font-weight: 700; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.tasks-kv-val { color: var(--text-primary); - font-size: 0.85rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + font-size: 0.9rem; + min-width: 0; + word-break: break-word; } -.tasks-kv-val-edit { - overflow: visible; - text-overflow: unset; - white-space: normal; +.tasks-checklist-actions { display: flex; + gap: 6px; align-items: center; - gap: 8px; } -.task-card-board { - cursor: grab; -} - -.task-card-board.dragging { - opacity: 0.6; - cursor: grabbing; +.tasks-checkitems { + margin-top: 8px; + display: flex; + flex-direction: column; + gap: 6px; } -.task-card-row { - padding: var(--space-sm) var(--space-md); +.tasks-checkitem-row { + display: grid; + grid-template-columns: 18px 1fr auto; + gap: 10px; + align-items: center; + padding: 8px 10px; border: 1px solid var(--border-color); border-radius: var(--radius-md); - background: var(--bg-tertiary); + background: var(--bg-primary); +} + +.tasks-checkitem-row.done { + opacity: 0.75; +} + +.tasks-checkitem-text { + min-width: 0; + color: var(--text-primary); + font-size: 0.85rem; cursor: pointer; - transition: border-color 0.15s, transform 0.15s; + word-break: break-word; } -.task-card-list { +.tasks-checkitem-add, +.tasks-checklist-add { + margin-top: 8px; +} + +.tasks-list-manager-row { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; gap: 10px; + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-secondary); + margin-bottom: 8px; } -.task-card-list-main { +.tasks-list-manager-name { min-width: 0; flex: 1; + color: var(--text-primary); + font-weight: 600; + word-break: break-word; } -.task-card-list-actions { - flex-shrink: 0; - display: inline-flex; - align-items: center; +.tasks-list-manager-actions { + display: flex; gap: 6px; + align-items: center; } -.task-card-row:hover { - border-color: var(--accent-primary); - transform: translateY(-1px); +.tasks-dep-row.done .tasks-dep-text { + text-decoration: line-through; } -.task-card-row.active { - border-color: var(--accent-primary); - box-shadow: 0 0 0 2px rgba(31, 111, 235, 0.2); +.tasks-dep-text a { + color: var(--accent-primary); + text-decoration: none; } -.task-card-title { - font-weight: 600; - color: var(--text-primary); - margin-bottom: 4px; +.tasks-dep-text a:hover { + text-decoration: underline; } -.tasks-card-board-dot { - display: inline-block; - width: 10px; - height: 10px; - border-radius: 999px; - border: 1px solid var(--border-color); - margin-right: 8px; - transform: translateY(1px); +.tasks-dep-remove { + padding: 4px 8px; + font-weight: 700; } -.task-card-meta { - font-size: 0.75rem; +.tasks-dep-add { + margin-top: var(--space-sm); +} + +.tasks-detail-meta { + font-size: 0.8rem; color: var(--text-tertiary); + margin-bottom: var(--space-md); } -.tasks-detail { - grid-area: detail; - grid-column: 2; - grid-row: 1; - overflow-y: auto; - padding: var(--space-lg); - background: var(--bg-primary); - min-width: 0; +.tasks-detail-desc { + white-space: pre-wrap; + font-family: var(--font-mono); + font-size: 0.85rem; + padding: var(--space-md); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-secondary); + color: var(--text-primary); } -.tasks-detail-empty { - color: var(--text-secondary); +.tasks-config-hint { + padding: var(--space-lg); + border: 1px dashed var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-tertiary); } -.tasks-detail-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--space-md); +.tasks-config-title { + font-weight: 700; margin-bottom: var(--space-sm); } -.tasks-detail-title { - font-weight: 700; - font-size: 1rem; - line-height: 1.2; +.tasks-config-text { + color: var(--text-secondary); + line-height: 1.4; } -.tasks-detail-actions { - display: flex; - gap: var(--space-sm); - align-items: center; - flex-wrap: wrap; - justify-content: flex-end; - flex-shrink: 0; +/* Port panel - project detection styles */ +.port-name { + cursor: pointer; + transition: color 0.15s; } -.tasks-input { - width: 100%; - background: var(--bg-secondary); - color: var(--text-primary); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - padding: 8px 10px; - font-size: 0.95rem; - font-weight: 700; +.port-name:hover { + color: var(--accent-primary); } -.tasks-input-inline { - width: auto; - min-width: 220px; - display: inline-flex; - margin: 0 6px; - font-weight: 600; - font-size: 0.85rem; - padding: 6px 10px; +.port-name.custom-label { + color: #9f7aea; } -.tasks-textarea { - width: 100%; - background: var(--bg-secondary); - color: var(--text-primary); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - padding: 8px 10px; - font-size: 0.85rem; - font-family: var(--font-mono); - resize: vertical; +.port-path { + display: block; + font-size: 0.7rem; + color: var(--accent-primary); + opacity: 0.8; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.tasks-inline-row { +/* Port context - project/worktree info */ +.port-context { display: flex; - gap: var(--space-sm); align-items: center; - flex-wrap: wrap; + gap: 0.3rem; + font-size: 0.75rem; + margin-top: 2px; +} + +.port-project { + color: #9f7aea; + font-weight: 500; +} + +.port-worktree { + background: var(--accent-primary); + color: white; + padding: 1px 6px; + border-radius: 3px; + font-size: 0.7rem; + font-weight: 500; +} + +.port-subpath { + color: var(--text-muted); + font-size: 0.7rem; } -.tasks-combined-list { - display: flex; - flex-direction: column; - gap: 8px; +/* ============================================ + Sidebar Ports Section + ============================================ */ + +.sidebar-section { + border-top: 1px solid var(--border-color); + margin-top: auto; } -.tasks-combined-item { +.sidebar-section-header { display: flex; align-items: center; - justify-content: space-between; - gap: 10px; - padding: 10px 12px; - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - background: var(--bg-secondary); + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + cursor: pointer; + user-select: none; + font-size: 0.85rem; + font-weight: 500; + color: var(--text-secondary); + transition: background 0.15s; } -.tasks-combined-label { - font-size: 0.85rem; - color: var(--text-primary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.sidebar-section-header:hover { + background: var(--bg-tertiary); } -.tasks-combined-actions { - display: inline-flex; - gap: 6px; - flex-shrink: 0; +.ports-count { + background: var(--accent-primary); + color: white; + padding: 1px 6px; + border-radius: 10px; + font-size: 0.7rem; + font-weight: 600; + min-width: 18px; + text-align: center; } -.tasks-chips { - display: inline-flex; - gap: 6px; - flex-wrap: wrap; - margin-left: 6px; +.collapse-icon { + margin-left: auto; + font-size: 0.7rem; + transition: transform 0.2s; } -.tasks-chip { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 8px; - border-radius: 999px; - border: 1px solid var(--border-color); - background: var(--bg-secondary); - font-size: 0.75rem; - color: var(--text-secondary); +.sidebar-section.collapsed .collapse-icon { + transform: rotate(-90deg); } -.tasks-chip-avatar { - width: 16px; - height: 16px; - border-radius: 999px; - object-fit: cover; - display: inline-block; +.sidebar-section.collapsed .ports-sidebar-list { + display: none; } -.tasks-chip-link { - color: var(--text-secondary); - text-decoration: none; +.ports-sidebar-list { + max-height: 200px; + overflow-y: auto; + padding: var(--space-xs) 0; } -.tasks-chip-link:hover { - color: var(--text-primary); - text-decoration: underline; +.port-sidebar-item { + display: flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-md); + font-size: 0.75rem; + cursor: pointer; + transition: background 0.15s; + border-left: 2px solid transparent; } -.tasks-chip-muted { - opacity: 0.7; +.port-sidebar-item:hover { + background: var(--bg-tertiary); } -.tasks-chip-x { - appearance: none; - border: none; - background: transparent; - color: var(--text-tertiary); - cursor: pointer; +.port-sidebar-item.orchestrator { border-left-color: #9f7aea; } +.port-sidebar-item.node { border-left-color: #68d391; } +.port-sidebar-item.rails { border-left-color: #f56565; } +.port-sidebar-item.python { border-left-color: #ffd93d; } +.port-sidebar-item.vite { border-left-color: #48bb78; } + +.port-sidebar-icon { font-size: 0.9rem; - line-height: 1; - padding: 0 2px; + flex-shrink: 0; } -.tasks-chip-x:hover { +.port-sidebar-info { + flex: 1; + min-width: 0; + overflow: hidden; +} + +.port-sidebar-name { + display: block; + font-weight: 500; color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.tasks-detail-block { - margin-top: var(--space-md); +.port-sidebar-context { + display: block; + font-size: 0.65rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.tasks-detail-block.tasks-dropzone-hover { - outline: 2px dashed var(--accent-color); - outline-offset: 6px; - border-radius: var(--radius-md); +.port-sidebar-port { + color: var(--accent-primary); + font-weight: 500; + font-size: 0.7rem; + flex-shrink: 0; } -.tasks-detail-block-title { - font-size: 0.8rem; - color: var(--text-secondary); - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.04em; - margin-bottom: 6px; +.ports-sidebar-empty { + padding: var(--space-sm) var(--space-md); + font-size: 0.75rem; + color: var(--text-muted); + font-style: italic; } -.tasks-move-row { - display: flex; - gap: var(--space-sm); - align-items: center; +/* ============================================ + Dashboard Ports Section + ============================================ */ + +.ports-dashboard-section { + margin-top: var(--space-lg); } -.tasks-select-inline { - min-width: 240px; +.ports-dashboard-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: var(--space-md); } -.tasks-comment-row { +.port-dashboard-card { display: flex; - flex-direction: column; + align-items: center; gap: var(--space-sm); + padding: var(--space-md); + background: var(--bg-secondary); + border-radius: var(--radius-md); + border-left: 3px solid var(--border-color); + cursor: pointer; + transition: all 0.15s; } -.tasks-comments { - display: flex; - flex-direction: column; - gap: var(--space-sm); +.port-dashboard-card:hover { + background: var(--bg-tertiary); + transform: translateY(-2px); + box-shadow: var(--shadow-soft); } -.tasks-comment { - padding: var(--space-sm) var(--space-md); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - background: var(--bg-secondary); +.port-dashboard-card.orchestrator { border-left-color: #9f7aea; } +.port-dashboard-card.node { border-left-color: #68d391; } +.port-dashboard-card.rails { border-left-color: #f56565; } +.port-dashboard-card.ruby { border-left-color: #f56565; } +.port-dashboard-card.python { border-left-color: #ffd93d; } +.port-dashboard-card.vite { border-left-color: #48bb78; } + +.port-card-icon { + font-size: 1.5rem; + flex-shrink: 0; } -.tasks-comment-meta { - font-size: 0.75rem; - color: var(--text-tertiary); - margin-bottom: 6px; +.port-card-info { + flex: 1; + min-width: 0; + overflow: hidden; } -.tasks-comment-text { - white-space: pre-wrap; +.port-card-name { + display: block; + font-weight: 600; color: var(--text-primary); - line-height: 1.35; - font-size: 0.85rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.tasks-deps { - display: flex; - flex-direction: column; - gap: 6px; +.port-card-context { + display: block; + font-size: 0.75rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.tasks-dep-row { - display: grid; - grid-template-columns: 18px 1fr auto; - gap: 10px; - align-items: center; - padding: 8px 10px; - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - background: var(--bg-secondary); +.port-card-port { + font-size: 1rem; + font-weight: 700; + color: var(--accent-primary); + flex-shrink: 0; } -.tasks-dep-row.done { - opacity: 0.75; +.ports-empty, +.ports-loading { + grid-column: 1 / -1; + text-align: center; + padding: var(--space-lg); + color: var(--text-muted); + font-style: italic; } -.tasks-cover { - display: block; - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - background: var(--bg-secondary); - overflow: hidden; -} +/* ============================================ + Dashboard Split Row Layout + ============================================ */ -.tasks-cover img { - display: block; - width: 100%; - max-height: 220px; - object-fit: cover; +.dashboard-split-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-lg); + margin-top: var(--space-lg); } -.tasks-cover.tasks-cover-color { - height: 110px; +.dashboard-half { + background: var(--bg-secondary); + border-radius: var(--radius-lg); + padding: var(--space-md); + max-height: 400px; + overflow-y: auto; } -.tasks-attachments { - display: flex; - flex-direction: column; - gap: 6px; +.dashboard-half h2 { + margin: 0 0 var(--space-sm) 0; + font-size: 1rem; + position: sticky; + top: 0; + background: var(--bg-secondary); + padding-bottom: var(--space-xs); } -.tasks-attachment { - display: flex; - gap: 10px; - align-items: center; - padding: 8px 10px; - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - background: var(--bg-secondary); +/* Grid layout for ports in dashboard half */ +.dashboard-half .ports-dashboard-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: var(--space-xs); } -.tasks-attachment-thumb { - display: inline-flex; - width: 44px; - height: 44px; - border-radius: var(--radius-sm); - overflow: hidden; - border: 1px solid var(--border-color); - background: rgba(0, 0, 0, 0.08); - flex: 0 0 auto; +.dashboard-half .port-dashboard-card { + padding: var(--space-sm); + flex-direction: column; align-items: center; - justify-content: center; + text-align: center; + gap: var(--space-xs); + min-height: 70px; } -.tasks-attachment-thumb.is-empty { - opacity: 0.5; +.dashboard-half .port-card-icon { + font-size: 1.3rem; } -.tasks-attachment-thumb img { - display: block; +.dashboard-half .port-card-info { width: 100%; - height: 100%; - object-fit: cover; } -.tasks-attachment-body { - min-width: 0; - flex: 1; +.dashboard-half .port-card-name { + font-size: 0.75rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.tasks-attachment-name { - color: var(--text-primary); - font-size: 0.85rem; - line-height: 1.25; - text-decoration: none; - word-break: break-word; +.dashboard-half .port-card-context { + font-size: 0.65rem; } -.tasks-attachment-name:hover { - text-decoration: underline; +.dashboard-half .port-card-port { + font-size: 0.8rem; + margin-top: auto; } -.tasks-attachment-meta { - margin-top: 2px; - color: var(--text-tertiary); - font-size: 0.75rem; - line-height: 1.2; - word-break: break-word; +/* Grid layout for quick links in dashboard half */ +.dashboard-half .quick-links-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: var(--space-xs); } -.tasks-checklists { - display: flex; +.dashboard-half .quick-link-item { flex-direction: column; - gap: var(--space-sm); + align-items: center; + text-align: center; + padding: var(--space-sm); + min-height: 60px; + gap: var(--space-xs); + border-left: none; + border-bottom: 2px solid var(--accent-primary); } -.tasks-checklist { - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - background: var(--bg-secondary); - padding: 10px; +.dashboard-half .quick-link-item .quick-link-icon { + font-size: 1.2rem; } -.tasks-checklist-header { - display: flex; - gap: 10px; - align-items: center; - justify-content: space-between; +.dashboard-half .quick-link-item .quick-link-label { + font-size: 0.7rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; } -.tasks-checklist-title { - font-weight: 700; - color: var(--text-primary); - font-size: 0.9rem; - min-width: 0; - word-break: break-word; +/* Quick links container inside dashboard */ +.dashboard-half .quick-links-container { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: var(--space-xs); } -.tasks-checklist-actions { - display: flex; - gap: 6px; - align-items: center; +.dashboard-half .quick-links-section { + display: contents; } -.tasks-checkitems { - margin-top: 8px; - display: flex; - flex-direction: column; - gap: 6px; +.dashboard-half .quick-links-section h3 { + display: none; } -.tasks-checkitem-row { - display: grid; - grid-template-columns: 18px 1fr auto; - gap: 10px; - align-items: center; - padding: 8px 10px; - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - background: var(--bg-primary); +@media (max-width: 900px) { + .dashboard-split-row { + grid-template-columns: 1fr; + } } -.tasks-checkitem-row.done { - opacity: 0.75; -} +/* ============================================ + Quick Link Items (for dashboard half) + ============================================ */ -.tasks-checkitem-text { - min-width: 0; - color: var(--text-primary); - font-size: 0.85rem; +.quick-link-item { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + background: var(--bg-tertiary); + border-radius: var(--radius-sm); + border-left: 3px solid var(--accent-primary); cursor: pointer; - word-break: break-word; + transition: all 0.15s; + text-decoration: none; + color: inherit; + border: none; + width: 100%; + text-align: left; + font-size: inherit; + font-family: inherit; } -.tasks-checkitem-add, -.tasks-checklist-add { - margin-top: 8px; +.quick-link-item:hover { + background: var(--bg-primary); + transform: translateX(2px); } -.tasks-list-manager-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 10px 12px; - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - background: var(--bg-secondary); - margin-bottom: 8px; +.quick-link-icon { + font-size: 1rem; + flex-shrink: 0; } -.tasks-list-manager-name { - min-width: 0; +.quick-link-label { flex: 1; + font-weight: 500; color: var(--text-primary); - font-weight: 600; - word-break: break-word; } -.tasks-list-manager-actions { - display: flex; - gap: 6px; - align-items: center; -} - -.tasks-dep-row.done .tasks-dep-text { - text-decoration: line-through; +.quick-links-empty { + padding: var(--space-md); + text-align: center; + color: var(--text-muted); + font-style: italic; + grid-column: 1 / -1; } -.tasks-dep-text a { - color: var(--accent-primary); - text-decoration: none; -} +/* ============================================ + Session Recovery Dialog + ============================================ */ -.tasks-dep-text a:hover { - text-decoration: underline; +.recovery-modal .modal-content { + max-width: 600px; + width: 90vw; } -.tasks-dep-remove { - padding: 4px 8px; - font-weight: 700; +.recovery-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-md) var(--space-lg); + border-bottom: 1px solid var(--border-color); + background: var(--bg-tertiary); } -.tasks-dep-add { - margin-top: var(--space-sm); +.recovery-header h2 { + margin: 0; + font-size: 1.1rem; + display: flex; + align-items: center; + gap: var(--space-sm); } -.tasks-detail-meta { +.recovery-info { + padding: var(--space-sm) var(--space-lg); + background: var(--bg-secondary); font-size: 0.8rem; - color: var(--text-tertiary); - margin-bottom: var(--space-md); + color: var(--text-muted); + border-bottom: 1px solid var(--border-color); } -.tasks-detail-desc { - white-space: pre-wrap; - font-family: var(--font-mono); - font-size: 0.85rem; +.recovery-sessions { + max-height: 300px; + overflow-y: auto; padding: var(--space-md); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - background: var(--bg-secondary); - color: var(--text-primary); } -.tasks-config-hint { - padding: var(--space-lg); - border: 1px dashed var(--border-color); - border-radius: var(--radius-md); +.recovery-session { + display: flex; + align-items: flex-start; + gap: var(--space-md); + padding: var(--space-sm) var(--space-md); + margin-bottom: var(--space-xs); background: var(--bg-tertiary); + border-radius: var(--radius-sm); + border-left: 3px solid var(--accent-primary); } -.tasks-config-title { - font-weight: 700; - margin-bottom: var(--space-sm); -} - -.tasks-config-text { - color: var(--text-secondary); - line-height: 1.4; -} - -/* Port panel - project detection styles */ -.port-name { - cursor: pointer; - transition: color 0.15s; +.recovery-session.selected { + border-left-color: #48bb78; + background: rgba(72, 187, 120, 0.1); } -.port-name:hover { - color: var(--accent-primary); +.recovery-checkbox { + margin-top: 2px; } -.port-name.custom-label { - color: #9f7aea; +.recovery-session-info { + flex: 1; + min-width: 0; } -.port-path { - display: block; - font-size: 0.7rem; - color: var(--accent-primary); - opacity: 0.8; - max-width: 200px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.recovery-session-id { + font-weight: 600; + font-size: 0.9rem; + color: var(--text-primary); } -/* Port context - project/worktree info */ -.port-context { - display: flex; - align-items: center; - gap: 0.3rem; +.recovery-session-details { font-size: 0.75rem; + color: var(--text-muted); margin-top: 2px; } -.port-project { - color: #9f7aea; - font-weight: 500; +.recovery-session-details span { + display: inline-block; + margin-right: var(--space-sm); } -.port-worktree { - background: var(--accent-primary); - color: white; - padding: 1px 6px; - border-radius: 3px; - font-size: 0.7rem; - font-weight: 500; +.recovery-session-cwd { + color: var(--accent-primary); } -.port-subpath { - color: var(--text-muted); - font-size: 0.7rem; +.recovery-session-agent { + background: var(--bg-primary); + padding: 1px 6px; + border-radius: 3px; } -/* ============================================ - Sidebar Ports Section - ============================================ */ - -.sidebar-section { +.recovery-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-md) var(--space-lg); border-top: 1px solid var(--border-color); - margin-top: auto; + gap: var(--space-md); } -.sidebar-section-header { +.recovery-actions { display: flex; - align-items: center; gap: var(--space-sm); +} + +.recovery-skip { + color: var(--text-muted); + font-size: 0.8rem; +} + +.btn-recovery { padding: var(--space-sm) var(--space-md); + border-radius: var(--radius-sm); + border: none; cursor: pointer; - user-select: none; - font-size: 0.85rem; font-weight: 500; - color: var(--text-secondary); - transition: background 0.15s; + transition: all 0.15s; } -.sidebar-section-header:hover { - background: var(--bg-tertiary); +.btn-recovery-all { + background: #48bb78; + color: white; } -.ports-count { +.btn-recovery-all:hover { + background: #38a169; +} + +.btn-recovery-selected { background: var(--accent-primary); - color: white; - padding: 1px 6px; - border-radius: 10px; - font-size: 0.7rem; - font-weight: 600; - min-width: 18px; - text-align: center; + color: var(--text-on-accent); } -.collapse-icon { - margin-left: auto; - font-size: 0.7rem; - transition: transform 0.2s; +.btn-recovery-selected:hover { + background: var(--accent-primary-hover); } -.sidebar-section.collapsed .collapse-icon { - transform: rotate(-90deg); +.btn-recovery-clear { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); } -.sidebar-section.collapsed .ports-sidebar-list { - display: none; +.btn-recovery-clear:hover { + border-color: var(--accent-danger); + background: rgba(248, 81, 73, 0.14); } -.ports-sidebar-list { - max-height: 200px; - overflow-y: auto; - padding: var(--space-xs) 0; +.btn-recovery-skip { + background: var(--bg-tertiary); + color: var(--text-secondary); } -.port-sidebar-item { - display: flex; - align-items: center; - gap: var(--space-xs); - padding: var(--space-xs) var(--space-md); - font-size: 0.75rem; - cursor: pointer; - transition: background 0.15s; - border-left: 2px solid transparent; +.btn-recovery-skip:hover { + background: var(--bg-primary); } -.port-sidebar-item:hover { - background: var(--bg-tertiary); +.no-recovery { + text-align: center; + padding: var(--space-lg); + color: var(--text-muted); } -.port-sidebar-item.orchestrator { border-left-color: #9f7aea; } -.port-sidebar-item.node { border-left-color: #68d391; } -.port-sidebar-item.rails { border-left-color: #f56565; } -.port-sidebar-item.python { border-left-color: #ffd93d; } -.port-sidebar-item.vite { border-left-color: #48bb78; } +/* ============================================ + Activity Feed Styles + ============================================ */ -.port-sidebar-icon { - font-size: 0.9rem; - flex-shrink: 0; +.activity-feed-modal .activity-feed-content { + max-width: 1200px; + width: 94vw; + max-height: 90vh; + display: flex; + flex-direction: column; } -.port-sidebar-info { +.activity-list { flex: 1; - min-width: 0; - overflow: hidden; + overflow-y: auto; + padding: var(--space-md) var(--space-lg); } -.port-sidebar-name { - display: block; - font-weight: 500; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +.activity-empty { + padding: var(--space-lg); + color: var(--text-muted); } -.port-sidebar-context { - display: block; - font-size: 0.65rem; - color: var(--text-muted); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +.activity-event { + border: 1px solid var(--border-color); + background: var(--bg-secondary); + border-radius: var(--radius-md); + padding: var(--space-md); + margin-bottom: var(--space-sm); +} + +.activity-event.activity-failed { + border-color: rgba(248, 81, 73, 0.55); + box-shadow: 0 0 0 1px rgba(248, 81, 73, 0.18); +} + +.activity-meta { + display: flex; + gap: var(--space-sm); + align-items: center; + justify-content: space-between; } -.port-sidebar-port { - color: var(--accent-primary); - font-weight: 500; - font-size: 0.7rem; - flex-shrink: 0; +.activity-actions { + display: flex; + gap: var(--space-xs); + align-items: center; + margin-left: auto; } -.ports-sidebar-empty { - padding: var(--space-sm) var(--space-md); +.activity-action-btn { + padding: 4px 8px; font-size: 0.75rem; - color: var(--text-muted); - font-style: italic; + line-height: 1; } -/* ============================================ - Dashboard Ports Section - ============================================ */ - -.ports-dashboard-section { - margin-top: var(--space-lg); +.activity-time { + color: var(--text-muted); + font-size: 0.8rem; } -.ports-dashboard-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - gap: var(--space-md); +.activity-kind { + font-family: var(--font-mono); + font-size: 0.8rem; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid transparent; + background: var(--bg-tertiary); + color: var(--text-secondary); } -.port-dashboard-card { - display: flex; - align-items: center; - gap: var(--space-sm); - padding: var(--space-md); - background: var(--bg-secondary); - border-radius: var(--radius-md); - border-left: 3px solid var(--border-color); - cursor: pointer; - transition: all 0.15s; +.activity-kind-agent { + border-color: var(--accent-primary); + color: var(--accent-primary); + background: rgba(31, 111, 235, 0.12); } -.port-dashboard-card:hover { - background: var(--bg-tertiary); - transform: translateY(-2px); - box-shadow: var(--shadow-soft); +.activity-kind-session { + border-color: var(--accent-success); + color: var(--accent-success); + background: rgba(63, 185, 80, 0.12); } -.port-dashboard-card.orchestrator { border-left-color: #9f7aea; } -.port-dashboard-card.node { border-left-color: #68d391; } -.port-dashboard-card.rails { border-left-color: #f56565; } -.port-dashboard-card.ruby { border-left-color: #f56565; } -.port-dashboard-card.python { border-left-color: #ffd93d; } -.port-dashboard-card.vite { border-left-color: #48bb78; } +.activity-kind-server { + border-color: var(--accent-warning); + color: var(--accent-warning); + background: rgba(210, 153, 34, 0.12); +} -.port-card-icon { - font-size: 1.5rem; - flex-shrink: 0; +.activity-kind-git { + border-color: var(--accent-success); + color: var(--accent-success); + background: rgba(63, 185, 80, 0.12); } -.port-card-info { - flex: 1; - min-width: 0; - overflow: hidden; +.activity-kind-pr { + border-color: var(--accent-primary); + color: var(--accent-primary); + background: rgba(31, 111, 235, 0.12); } -.port-card-name { - display: block; - font-weight: 600; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +.activity-kind-tests { + border-color: var(--accent-warning); + color: var(--accent-warning); + background: rgba(210, 153, 34, 0.12); } -.port-card-context { - display: block; - font-size: 0.75rem; - color: var(--text-muted); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +.activity-kind-build { + border-color: var(--accent-warning); + color: var(--accent-warning); + background: rgba(210, 153, 34, 0.12); } -.port-card-port { - font-size: 1rem; - font-weight: 700; - color: var(--accent-primary); - flex-shrink: 0; +.activity-summary { + margin-top: var(--space-sm); + color: var(--text-primary); + font-size: 0.95rem; } -.ports-empty, -.ports-loading { - grid-column: 1 / -1; - text-align: center; - padding: var(--space-lg); +.activity-data { + margin-top: var(--space-sm); color: var(--text-muted); - font-style: italic; + font-family: var(--font-mono); + font-size: 0.8rem; + white-space: pre-wrap; + word-break: break-word; } -/* ============================================ - Dashboard Split Row Layout - ============================================ */ +/* ========================================================== + ONBOARDING OVERLAY (PERF-OPTIMIZED) + ========================================================== */ -.dashboard-split-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--space-lg); - margin-top: var(--space-lg); +.onboarding-overlay { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: + radial-gradient(90% 60% at 8% 12%, rgba(31, 111, 235, 0.22), transparent 75%), + radial-gradient(78% 55% at 92% 88%, rgba(0, 195, 255, 0.14), transparent 80%), + rgba(5, 8, 15, 0.86); + overflow: auto; + transition: opacity 0.18s ease-out, visibility 0.18s linear; } .dashboard-half { @@ -11669,13 +12895,18 @@ header h1 { overflow-y: auto; } -.dashboard-half h2 { - margin: 0 0 var(--space-sm) 0; - font-size: 1rem; - position: sticky; - top: 0; - background: var(--bg-secondary); - padding-bottom: var(--space-xs); +.onboarding-bg-glow { + position: absolute; + width: 52vw; + height: 52vw; + max-width: 640px; + max-height: 640px; + border-radius: 50%; + top: -16%; + left: -6%; + background: radial-gradient(circle, rgba(31, 111, 235, 0.2) 0%, rgba(31, 111, 235, 0) 72%); + opacity: 0.75; + pointer-events: none; } /* Grid layout for ports in dashboard half */ @@ -11698,8 +12929,9 @@ header h1 { font-size: 1.1rem; } -.dashboard-half .port-card-info { - width: 100%; +.onboarding-header { + margin-bottom: 4px; + text-align: center; } .dashboard-half .port-card-name { @@ -11725,9 +12957,14 @@ header h1 { gap: var(--space-xs); } -.dashboard-half .quick-link-item { - flex-direction: column; - align-items: center; +.onboarding-welcome-card { + padding: 28px; + border-radius: 18px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: + linear-gradient(155deg, rgba(56, 139, 253, 0.13), rgba(20, 29, 44, 0.42)), + rgba(255, 255, 255, 0.04); + box-shadow: 0 12px 26px rgba(0, 0, 0, 0.28); text-align: center; padding: 6px; min-height: 52px; @@ -11755,99 +12992,100 @@ header h1 { gap: var(--space-xs); } -.dashboard-half .quick-links-section { - display: contents; +.onboarding-welcome-notes { + margin-top: 14px; + display: grid; + gap: 6px; + text-align: center; } -.dashboard-half .quick-links-section h3 { - display: none; +.onboarding-welcome-notes p { + margin: 0; + color: rgba(255, 255, 255, 0.74); + font-size: 0.9rem; + line-height: 1.45; } -@media (max-width: 900px) { - .dashboard-split-row { - grid-template-columns: 1fr; - } +.onboarding-welcome-actions { + margin-top: 46px; + justify-content: center; } -/* ============================================ - Quick Link Items (for dashboard half) - ============================================ */ - -.quick-link-item { +.onboarding-stepper-row { display: flex; align-items: center; - gap: var(--space-sm); - padding: var(--space-sm) var(--space-md); - background: var(--bg-tertiary); - border-radius: var(--radius-sm); - border-left: 3px solid var(--accent-primary); - cursor: pointer; - transition: all 0.15s; - text-decoration: none; - color: inherit; - border: none; + justify-content: center; width: 100%; - text-align: left; - font-size: inherit; - font-family: inherit; + gap: 14px; + margin-bottom: 18px; + flex-wrap: wrap; + row-gap: 12px; } -.quick-link-item:hover { - background: var(--bg-primary); - transform: translateX(2px); +.onboarding-stepper-item { + display: flex; + align-items: center; + justify-content: center; } -.quick-link-icon { - font-size: 1rem; - flex-shrink: 0; +.stepper-icon-box { + display: flex; + align-items: center; + position: relative; } -.quick-link-label { - flex: 1; - font-weight: 500; - color: var(--text-primary); +.stepper-diamond { + width: 14px; + height: 14px; + border-radius: 3px; + transform: rotate(45deg); + transition: transform 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease; } -.quick-links-empty { - padding: var(--space-md); - text-align: center; - color: var(--text-muted); - font-style: italic; - grid-column: 1 / -1; +.stepper-upcoming .stepper-diamond { + background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%); + box-shadow: inset 0 0 0 1px rgba(229, 231, 235, 0.26); + opacity: 0.95; } -/* ============================================ - Session Recovery Dialog - ============================================ */ - -.recovery-modal .modal-content { - max-width: 600px; - width: 90vw; +.stepper-active .stepper-icon-box { + gap: 0; } -.recovery-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: var(--space-md) var(--space-lg); - border-bottom: 1px solid var(--border-color); - background: var(--bg-tertiary); +.stepper-active .stepper-diamond { + background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); + box-shadow: 0 0 10px rgba(59, 130, 246, 0.4), inset 0 0 0 1px rgba(255, 255, 255, 0.34); + transform: rotate(45deg) scale(1.15); + opacity: 1; } -.recovery-header h2 { - margin: 0; - font-size: 1.1rem; - display: flex; - align-items: center; - gap: var(--space-sm); +.stepper-done .stepper-diamond { + background: linear-gradient(135deg, #34d399 0%, #16a34a 100%); + box-shadow: 0 0 10px rgba(34, 197, 94, 0.38), inset 0 0 0 1px rgba(236, 253, 245, 0.34); + opacity: 1; } -.recovery-info { - padding: var(--space-sm) var(--space-lg); - background: var(--bg-secondary); - font-size: 0.8rem; - color: var(--text-muted); - border-bottom: 1px solid var(--border-color); +.stepper-active-label { + position: absolute; + top: 20px; + left: 50%; + transform: translateX(-50%); + font-size: 0.88rem; + color: rgba(255, 255, 255, 0.9); + font-weight: 520; + margin-left: 0; + white-space: nowrap; +} + +.onboarding-step-card { + position: relative; + overflow: hidden; + padding: 26px; + border-radius: 18px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.045); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); + transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; } .recovery-metrics { @@ -11893,254 +13131,318 @@ header h1 { padding: var(--space-md); } -.recovery-session { +.onboarding-step-icon { + width: 42px; + height: 42px; + margin-bottom: 18px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.11); + color: rgba(255, 255, 255, 0.94); display: flex; - align-items: flex-start; - gap: var(--space-md); - padding: var(--space-sm) var(--space-md); - margin-bottom: var(--space-xs); - background: var(--bg-tertiary); - border-radius: var(--radius-sm); - border-left: 3px solid var(--accent-primary); + align-items: center; + justify-content: center; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12); } -.recovery-session.selected { - border-left-color: #48bb78; - background: rgba(72, 187, 120, 0.1); +.onboarding-step-icon-svg { + width: 22px; + height: 22px; + display: block; } -.recovery-checkbox { - margin-top: 2px; +.onboarding-step-icon-svg * { + vector-effect: non-scaling-stroke; } -.recovery-session-info { - flex: 1; - min-width: 0; +.onboarding-step-title { + font-size: clamp(1.42rem, 2.9vw, 1.78rem); + font-weight: 620; + color: #ffffff; + margin-bottom: 10px; } -.recovery-session-id { - font-weight: 600; - font-size: 0.9rem; - color: var(--text-primary); +.onboarding-step-status-row { + display: flex; + align-items: flex-start; + gap: 10px; + margin-bottom: 20px; } -.recovery-session-details { - font-size: 0.75rem; - color: var(--text-muted); - margin-top: 2px; +.onboarding-check { + margin-top: 3px; + flex-shrink: 0; + color: #3fb950; } -.recovery-session-details span { - display: inline-block; - margin-right: var(--space-sm); +.onboarding-step-desc { + margin: 0; + font-size: 1rem; + line-height: 1.48; + color: rgba(255, 255, 255, 0.78); } -.recovery-session-cwd { - color: var(--accent-primary); +.onboarding-inline-status { + display: inline-block; + margin-left: 8px; + padding: 2px 8px; + border-radius: 12px; + font-size: 0.9em; + background: rgba(0, 0, 0, 0.3); } -.recovery-session-agent { - background: var(--bg-primary); - padding: 1px 6px; - border-radius: 3px; -} +.onboarding-inline-status.status-ok { color: #3fb950; } +.onboarding-inline-status.status-missing { color: #f85149; } +.onboarding-inline-status.status-pending { color: #58a6ff; } -.recovery-footer { +.onboarding-step-actions { display: flex; - justify-content: space-between; - align-items: center; - padding: var(--space-md) var(--space-lg); - border-top: 1px solid var(--border-color); - gap: var(--space-md); + gap: 10px; + flex-wrap: wrap; } -.recovery-actions { - display: flex; - gap: var(--space-sm); +.onboarding-btn-secondary, +.onboarding-btn-back, +.onboarding-btn-primary { + border-radius: 10px; + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease; } -.recovery-skip { - color: var(--text-muted); - font-size: 0.8rem; +.onboarding-btn-secondary { + padding: 10px 18px; + border: 1px solid rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.09); + color: rgba(255, 255, 255, 0.92); + font-size: 0.94rem; + font-weight: 520; } -.btn-recovery { - padding: var(--space-sm) var(--space-md); - border-radius: var(--radius-sm); - border: none; - cursor: pointer; - font-weight: 500; - transition: all 0.15s; +.onboarding-btn-secondary:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.16); + border-color: rgba(255, 255, 255, 0.26); + transform: translateY(-1px); } -.btn-recovery-all { - background: #48bb78; - color: white; +.onboarding-btn-secondary:disabled { + opacity: 0.52; + cursor: not-allowed; } -.btn-recovery-all:hover { - background: #38a169; +.onboarding-step-actions [data-setup-run] { + border-color: rgba(74, 222, 128, 0.55); + background: linear-gradient(135deg, #34d399 0%, #16a34a 100%); + color: #052e16; + box-shadow: 0 6px 16px rgba(22, 163, 74, 0.3); } -.btn-recovery-selected { - background: var(--accent-primary); - color: var(--text-on-accent); +.onboarding-step-actions [data-setup-run]:hover:not(:disabled) { + border-color: rgba(134, 239, 172, 0.92); + background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%); + color: #052e16; + box-shadow: 0 8px 18px rgba(22, 163, 74, 0.38); } -.btn-recovery-selected:hover { - background: var(--accent-primary-hover); +.onboarding-step-actions [data-setup-run]:disabled { + border-color: rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.55); + box-shadow: none; } -.btn-recovery-clear { - background: var(--bg-tertiary); - color: var(--text-primary); - border: 1px solid var(--border-color); +.onboarding-nav-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 6px; } -.btn-recovery-clear:hover { - border-color: var(--accent-danger); - background: rgba(248, 81, 73, 0.14); +.onboarding-nav-row.onboarding-welcome-actions { + justify-content: center; } -.btn-recovery-skip { - background: var(--bg-tertiary); - color: var(--text-secondary); +.onboarding-btn-back { + padding: 13px 24px; + border: 1px solid rgba(255, 255, 255, 0.11); + background: rgba(255, 255, 255, 0.055); + color: rgba(255, 255, 255, 0.74); + font-size: 0.98rem; + font-weight: 600; } -.btn-recovery-skip:hover { - background: var(--bg-primary); +.onboarding-btn-back:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.9); } -.no-recovery { - text-align: center; - padding: var(--space-lg); - color: var(--text-muted); +.onboarding-btn-primary { + padding: 13px 28px; + border: 1px solid rgba(255, 255, 255, 0.2); + background: linear-gradient(135deg, #388bfd 0%, #1f6feb 100%); + color: #ffffff; + font-size: 0.98rem; + font-weight: 620; + box-shadow: 0 4px 13px rgba(31, 111, 235, 0.34); } -/* ============================================ - Activity Feed Styles - ============================================ */ - -.activity-feed-modal .activity-feed-content { - max-width: 1200px; - width: 94vw; - max-height: 90vh; - display: flex; - flex-direction: column; +.onboarding-btn-primary:hover:not(:disabled) { + background: linear-gradient(135deg, #58a6ff 0%, #388bfd 100%); + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(31, 111, 235, 0.44); } -.activity-list { - flex: 1; - overflow-y: auto; - padding: var(--space-md) var(--space-lg); +.onboarding-btn-primary:disabled { + border-color: transparent; + background: rgba(255, 255, 255, 0.11); + color: rgba(255, 255, 255, 0.45); + box-shadow: none; + cursor: not-allowed; } -.activity-empty { - padding: var(--space-lg); - color: var(--text-muted); +.dependency-git-identity-fields { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 18px; } -.activity-event { - border: 1px solid var(--border-color); - background: var(--bg-secondary); - border-radius: var(--radius-md); - padding: var(--space-md); - margin-bottom: var(--space-sm); +.dependency-git-identity-field { + display: flex; + flex-direction: column; + gap: 4px; } -.activity-event.activity-failed { - border-color: rgba(248, 81, 73, 0.55); - box-shadow: 0 0 0 1px rgba(248, 81, 73, 0.18); +.dependency-git-identity-field span { + font-size: 0.83rem; + color: rgba(255, 255, 255, 0.66); } -.activity-meta { - display: flex; - gap: var(--space-sm); - align-items: center; - justify-content: space-between; +.dependency-git-identity-field input { + padding: 10px 13px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(0, 0, 0, 0.28); + color: #fff; + font-family: var(--font-sans); + font-size: 0.98rem; + transition: border-color 0.14s ease, background-color 0.14s ease, box-shadow 0.14s ease; } -.activity-actions { - display: flex; - gap: var(--space-xs); - align-items: center; - margin-left: auto; +.dependency-git-identity-field input:focus { + outline: none; + border-color: #388bfd; + background: rgba(0, 0, 0, 0.38); + box-shadow: 0 0 0 2px rgba(56, 139, 253, 0.2); } -.activity-action-btn { - padding: 4px 8px; - font-size: 0.75rem; - line-height: 1; +.dependency-gh-login-helper-text { + margin-bottom: 10px; + font-size: 0.93rem; + color: rgba(255, 255, 255, 0.82); } -.activity-time { - color: var(--text-muted); - font-size: 0.8rem; +.dependency-onboarding-command-wrap { + margin: 18px 0; + padding: 14px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.09); + background: rgba(0, 0, 0, 0.32); } -.activity-kind { +.dependency-onboarding-command-wrap pre { + margin: 0; + color: #a5d6ff; font-family: var(--font-mono); - font-size: 0.8rem; - padding: 2px 8px; - border-radius: 999px; - border: 1px solid transparent; - background: var(--bg-tertiary); - color: var(--text-secondary); + font-size: 0.83rem; + white-space: pre-wrap; } -.activity-kind-agent { - border-color: var(--accent-primary); - color: var(--accent-primary); - background: rgba(31, 111, 235, 0.12); +.dependency-gh-login-code-wrap { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; } -.activity-kind-session { - border-color: var(--accent-success); - color: var(--accent-success); - background: rgba(63, 185, 80, 0.12); +.dependency-gh-login-helper-actions { + margin-top: 10px; } -.activity-kind-server { - border-color: var(--accent-warning); - color: var(--accent-warning); - background: rgba(210, 153, 34, 0.12); +.dependency-gh-login-code { + padding: 8px 14px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(0, 0, 0, 0.5); + font-size: 1.1rem; + letter-spacing: 2px; + color: #fff; } -.activity-kind-git { - border-color: var(--accent-success); - color: var(--accent-success); - background: rgba(63, 185, 80, 0.12); +.onboarding-close-btn { + position: absolute; + top: 18px; + right: 18px; + z-index: 100; + width: 32px; + height: 32px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.78); + font-size: 16px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease; } -.activity-kind-pr { - border-color: var(--accent-primary); - color: var(--accent-primary); - background: rgba(31, 111, 235, 0.12); +.onboarding-close-btn:hover { + background: rgba(255, 255, 255, 0.18); + border-color: rgba(255, 255, 255, 0.22); + color: #fff; } -.activity-kind-tests { - border-color: var(--accent-warning); - color: var(--accent-warning); - background: rgba(210, 153, 34, 0.12); +.onboarding-close-btn.hidden { + display: none; } -.activity-kind-build { - border-color: var(--accent-warning); - color: var(--accent-warning); - background: rgba(210, 153, 34, 0.12); -} +@media (max-width: 800px) { + .onboarding-overlay { + padding: 12px; + } -.activity-summary { - margin-top: var(--space-sm); - color: var(--text-primary); - font-size: 0.95rem; + .onboarding-container { + padding: 20px; + max-height: calc(100vh - 24px); + } + + .onboarding-step-card { + padding: 20px; + } + + .onboarding-nav-row { + gap: 10px; + flex-wrap: wrap; + } + + .onboarding-btn-back, + .onboarding-btn-primary { + flex: 1 1 100%; + justify-content: center; + } } -.activity-data { - margin-top: var(--space-sm); - color: var(--text-muted); - font-family: var(--font-mono); - font-size: 0.8rem; - white-space: pre-wrap; - word-break: break-word; +@media (prefers-reduced-motion: reduce) { + .onboarding-overlay, + .onboarding-container, + .stepper-diamond, + .onboarding-step-card, + .onboarding-btn-secondary, + .onboarding-btn-back, + .onboarding-btn-primary, + .dependency-git-identity-field input, + .onboarding-close-btn { + animation: none !important; + transition: none !important; + } } diff --git a/client/styles/tabs.css b/client/styles/tabs.css index 970f58cc..044628f4 100644 --- a/client/styles/tabs.css +++ b/client/styles/tabs.css @@ -11,6 +11,8 @@ z-index: 100; overflow-x: auto; overflow-y: hidden; + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); } .workspace-tabs { @@ -233,20 +235,27 @@ /* Scrollbar for tab overflow */ .workspace-tabs-container::-webkit-scrollbar { - height: 4px; + height: var(--scrollbar-size); } .workspace-tabs-container::-webkit-scrollbar-track { - background: var(--bg-secondary); + background: var(--scrollbar-track); + border-radius: var(--scrollbar-radius); } .workspace-tabs-container::-webkit-scrollbar-thumb { - background: var(--border-color); - border-radius: 2px; + background: var(--scrollbar-thumb); + border-radius: var(--scrollbar-radius); + border: 2px solid var(--scrollbar-thumb-border); + background-clip: padding-box; } .workspace-tabs-container::-webkit-scrollbar-thumb:hover { - background: var(--text-secondary); + background: var(--scrollbar-thumb-hover); +} + +.workspace-tabs-container::-webkit-scrollbar-thumb:active { + background: var(--scrollbar-thumb-active); } /* Mobile/Small Screen Adjustments */ diff --git a/client/workspace-switcher.js b/client/workspace-switcher.js index f4835af6..fa691885 100644 --- a/client/workspace-switcher.js +++ b/client/workspace-switcher.js @@ -218,7 +218,7 @@ class WorkspaceSwitcher { } // Emit switch request - this.orchestrator.socket.emit('switch-workspace', { workspaceId }); + this.orchestrator.emitWorkspaceSwitch(workspaceId, 'workspace-switcher.switchWorkspace'); // Wait for workspace-changed event (handled in app.js) this.orchestrator.socket.once('workspace-changed', () => { @@ -267,4 +267,4 @@ class WorkspaceSwitcher { } // Make available globally -window.WorkspaceSwitcher = WorkspaceSwitcher; \ No newline at end of file +window.WorkspaceSwitcher = WorkspaceSwitcher; diff --git a/client/workspace-tab-manager.js b/client/workspace-tab-manager.js index e9a33d90..d67bfdd9 100644 --- a/client/workspace-tab-manager.js +++ b/client/workspace-tab-manager.js @@ -173,11 +173,6 @@ class WorkspaceTabManager { console.log(`Created tab ${tabId} for workspace ${workspace.name}`); - // If this is the first tab, activate it - if (this.tabs.size === 1) { - this.switchTab(tabId); - } - return tabId; } @@ -270,7 +265,7 @@ class WorkspaceTabManager { const currentWorkspaceId = this.orchestrator?.currentWorkspace?.id || null; if (this.orchestrator?.socket?.connected && targetTab.workspaceId && targetTab.workspaceId !== currentWorkspaceId) { console.log(`Requesting backend workspace switch for tab ${tabId}: ${currentWorkspaceId} → ${targetTab.workspaceId}`); - this.orchestrator.socket.emit('switch-workspace', { workspaceId: targetTab.workspaceId }); + this.orchestrator.emitWorkspaceSwitch(targetTab.workspaceId, 'workspace-tab-manager.switchTab'); return; } diff --git a/package-lock.json b/package-lock.json index a416406c..351617ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "express": "^4.18.2", "http-proxy-middleware": "^3.0.5", "multer": "^2.0.2", - "node-pty": "^1.0.0", + "node-pty": "^1.1.0", "socket.io": "^4.6.1", "winston": "^3.11.0" }, @@ -4773,12 +4773,6 @@ "node": ">= 10.16.0" } }, - "node_modules/nan": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz", - "integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==", - "license": "MIT" - }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -4811,6 +4805,12 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -4819,13 +4819,13 @@ "license": "MIT" }, "node_modules/node-pty": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", - "integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "nan": "^2.17.0" + "node-addon-api": "^7.1.0" } }, "node_modules/node-releases": { diff --git a/package.json b/package.json index d491bcea..6acf6e46 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "express": "^4.18.2", "http-proxy-middleware": "^3.0.5", "multer": "^2.0.2", - "node-pty": "^1.0.0", + "node-pty": "^1.1.0", "socket.io": "^4.6.1", "winston": "^3.11.0" }, diff --git a/patch_onboarding.py b/patch_onboarding.py new file mode 100644 index 00000000..6d083222 --- /dev/null +++ b/patch_onboarding.py @@ -0,0 +1,97 @@ +import re + +with open('client/app.js', 'r') as f: + content = f.read() + +# We need to replace the innerHTML block inside 'const render = () => {' +# Let's find: `listEl.innerHTML = \`` up to the closing `\`;` +pattern = r'(listEl\.innerHTML\s*=\s*`)(.*?)(`;\n\n\s*return\s*\{\s*req,\s*steps,\s*current\s*\};\n\s*\};)' +match = re.search(pattern, content, re.DOTALL) + +if not match: + print("Could not find listEl.innerHTML block!") +else: + new_html = """ +
+ ${steps.map((step, idx) => { + const isActive = idx === state.currentStep; + const isDone = step.done; + let statusClass = 'stepper-upcoming'; + if (isDone) statusClass = 'stepper-done'; + if (isActive) statusClass = 'stepper-active'; + return ` +
+
+ ${isActive ? `Step ${stepNo}` : ''} +
+
+
+ `; + }).join('')} +
+ +
+
+
+
+
+

${currentTitle}

+ +
+ ${current?.done ? '' : ''} +

${currentDesc} ${statusText ? `(${statusText})` : ''}

+
+ + ${isGitIdentityStep ? ` +
+
+ + + +
+
+ ` : ''} + + ${isGhLoginStep && !current?.done ? ` + + ` : ''} + + ${shouldShowInstallerOutput ? ` +
+
${installerOutputText}
+
+ ` : ''} + + ${command && !isGhLoginStep && !isGitIdentityStep && !current?.done ? ` +
+
${command}
+
+ ` : ''} + +
+ ${showRunButton ? `` : ''} + ${!isGhLoginStep && !isGitIdentityStep ? `` : ''} + ${isGhLoginStep && !current?.done && ghLoginUiPhase === 'code' ? `` : ''} +
+
+
+ +
+ + +
""" + + new_content = content[:match.start(2)] + new_html + content[match.start(3):] + with open('client/app.js', 'w') as f: + f.write(new_content) + print("Successfully patched client/app.js") diff --git a/scripts/ensure-pty.js b/scripts/ensure-pty.js index ae04856c..b3179598 100644 --- a/scripts/ensure-pty.js +++ b/scripts/ensure-pty.js @@ -1,6 +1,41 @@ #!/usr/bin/env node -const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const NODE_BINARY = path.resolve(process.env.ORCHESTRATOR_NODE_PATH || process.env.TAURI_NODE_PATH || process.execPath || process.argv[0]); +const NPM_CLI = path.join(path.dirname(NODE_BINARY), 'node_modules', 'npm', 'bin', 'npm-cli.js'); + +function runCommand(command, args) { + const logParts = [command, ...args].join(' '); + const result = spawnSync(command, args, { stdio: 'inherit', cwd: process.cwd() }); + + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(`Command "${logParts}" failed with exit code ${result.status}`); + } +} + +function runNpm(command, args) { + if (fs.existsSync(NPM_CLI)) { + console.log('[node-pty] running:', NODE_BINARY, path.basename(NPM_CLI), command, ...args); + const result = spawnSync(NODE_BINARY, [NPM_CLI, command, ...args], { stdio: 'inherit', cwd: process.cwd() }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(`npm rebuild failed with exit code ${result.status}`); + } + return; + } + + const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + console.log('[node-pty] running:', npmCommand, command, ...args); + runCommand(npmCommand, [command, ...args]); +} function tryRequirePty() { try { @@ -20,11 +55,20 @@ if (firstTry === true) { const message = firstTry && firstTry.message ? firstTry.message : String(firstTry); console.warn('node-pty load failed, attempting rebuild...', message); +const attemptRebuild = (command, args) => { + runNpm(command, args); +}; + try { - execSync('npm rebuild node-pty', { stdio: 'inherit' }); + attemptRebuild('rebuild', ['node-pty']); } catch (error) { - console.error('npm rebuild node-pty failed'); - process.exit(1); + console.warn('[node-pty] ABI mismatch rebuild failed, retrying from source:', error.message); + try { + attemptRebuild('rebuild', ['node-pty', '--build-from-source']); + } catch (sourceError) { + console.error('[node-pty] rebuild failed:', sourceError.message); + process.exit(1); + } } const secondTry = tryRequirePty(); diff --git a/scripts/tauri/prepare-backend-resources.js b/scripts/tauri/prepare-backend-resources.js index d73354d5..f04bf552 100644 --- a/scripts/tauri/prepare-backend-resources.js +++ b/scripts/tauri/prepare-backend-resources.js @@ -37,16 +37,45 @@ function run(cmd, args, opts) { } } -function runNpm(args, opts) { - // When invoked via `npm run ...`, npm provides the JS entry path, which is - // the most reliable cross-platform invocation target. - const npmExecPath = String(process.env.npm_execpath || '').trim(); - if (npmExecPath) { - run(process.execPath, [npmExecPath, ...args], opts); +function getNodeExecutable(rawPath) { + const candidate = String(rawPath || '').trim(); + if (!candidate) { + return ''; + } + return path.resolve(candidate); +} + +function getBundledNpmPath(nodeExecutable) { + const nodePath = getNodeExecutable(nodeExecutable); + if (!nodePath) { + return ''; + } + + const npmCli = path.join(path.dirname(nodePath), 'node_modules', 'npm', 'bin', 'npm-cli.js'); + if (fs.existsSync(npmCli)) { + return npmCli; + } + + return ''; +} + +function runNpmWithNode(nodeExecutable, args, opts) { + const npmCli = getBundledNpmPath(nodeExecutable); + if (npmCli) { + run(nodeExecutable, [npmCli, ...args], opts); return; } + const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'; - run(npmCmd, args, opts); + run(npmCmd, args, { + env: { + ...process.env, + // Keep the node source of truth explicit for nested helpers like ensure-pty. + ORCHESTRATOR_NODE_PATH: getNodeExecutable(nodeExecutable), + TAURI_NODE_PATH: getNodeExecutable(nodeExecutable) + }, + ...opts + }); } function main() { @@ -83,7 +112,17 @@ function main() { // Default: bundle the Node runtime we’re currently running on. // This makes `npm run tauri:build` much more “it just works” on Windows. - const bundledNodePathRaw = bundledNodePathRawFromEnv || (shouldBundleNode ? process.execPath : ''); + const bundledNodePathRaw = getNodeExecutable( + bundledNodePathRawFromEnv || (shouldBundleNode ? process.execPath : '') + ); + + const nodeEnv = bundledNodePathRaw + ? { + ...process.env, + ORCHESTRATOR_NODE_PATH: bundledNodePathRaw, + TAURI_NODE_PATH: bundledNodePathRaw + } + : process.env; if (clean && fs.existsSync(outDir)) { fs.rmSync(outDir, { recursive: true, force: true }); @@ -116,12 +155,32 @@ function main() { if (installProd) { try { - runNpm(['ci', '--omit=dev', '--no-audit', '--no-fund'], { cwd: outDir }); + runNpmWithNode( + bundledNodePathRaw || process.execPath, + ['ci', '--omit=dev', '--no-audit', '--no-fund'], + { cwd: outDir, env: nodeEnv } + ); } catch (error) { // Some Windows setups have issues with `npm ci` for native modules. // Fall back to `npm install` so contributors can still build installers. console.warn('[tauri] NOTE: npm ci failed, falling back to npm install --omit=dev'); - runNpm(['install', '--omit=dev', '--no-audit', '--no-fund'], { cwd: outDir }); + runNpmWithNode( + bundledNodePathRaw || process.execPath, + ['install', '--omit=dev', '--no-audit', '--no-fund'], + { cwd: outDir, env: nodeEnv } + ); + } + + try { + const nodePath = bundledNodePathRaw || process.execPath; + const ensureScriptPath = path.join(repoRoot, 'scripts', 'ensure-pty.js'); + run(nodePath, [ensureScriptPath], { + cwd: outDir, + env: nodeEnv + }); + } catch (error) { + console.error('[tauri] NOTE: node-pty compatibility check failed after install:', error.message); + throw error; } } diff --git a/server/claudeVersionChecker.js b/server/claudeVersionChecker.js index 5b1e45f7..81f08abd 100644 --- a/server/claudeVersionChecker.js +++ b/server/claudeVersionChecker.js @@ -15,11 +15,49 @@ const logger = winston.createLogger({ }); class ClaudeVersionChecker { - static async checkVersion() { - return new Promise((resolve) => { + static get cacheTtlMs() { + return 5 * 60 * 1000; + } + + static getCachedResult() { + const cached = ClaudeVersionChecker.versionCache; + if (!cached) return null; + if ((Date.now() - cached.timestamp) > ClaudeVersionChecker.cacheTtlMs) { + ClaudeVersionChecker.versionCache = null; + return null; + } + return cached.result; + } + + static setCachedResult(result) { + ClaudeVersionChecker.versionCache = { + result, + timestamp: Date.now() + }; + return result; + } + + static resetCache() { + ClaudeVersionChecker.versionCache = null; + ClaudeVersionChecker.versionPromise = null; + } + + static async checkVersion({ force = false } = {}) { + if (!force) { + const cached = ClaudeVersionChecker.getCachedResult(); + if (cached) { + return cached; + } + if (ClaudeVersionChecker.versionPromise) { + return ClaudeVersionChecker.versionPromise; + } + } + + const versionPromise = new Promise((resolve) => { const process = spawn('claude', ['--version'], { stdio: ['ignore', 'pipe', 'pipe'], - timeout: 5000 + timeout: 5000, + windowsHide: true }); let stdout = ''; @@ -51,34 +89,43 @@ class ClaudeVersionChecker { }; logger.info('Claude version check', result); - resolve(result); + resolve(ClaudeVersionChecker.setCachedResult(result)); } else { logger.warn('Could not parse Claude version', { stdout, stderr }); - resolve({ + resolve(ClaudeVersionChecker.setCachedResult({ version: null, isCompatible: false, error: 'Could not parse version' - }); + })); } } else { logger.error('Claude version check failed', { code, stderr }); - resolve({ + resolve(ClaudeVersionChecker.setCachedResult({ version: null, isCompatible: false, error: `Exit code ${code}: ${stderr}` - }); + })); } }); process.on('error', (error) => { logger.error('Claude version check error', { error: error.message, stack: error.stack }); - resolve({ + resolve(ClaudeVersionChecker.setCachedResult({ version: null, isCompatible: false, error: error.message - }); + })); }); }); + + ClaudeVersionChecker.versionPromise = versionPromise; + versionPromise.finally(() => { + if (ClaudeVersionChecker.versionPromise === versionPromise) { + ClaudeVersionChecker.versionPromise = null; + } + }); + + return versionPromise; } static generateUpdateInstructions(versionInfo) { @@ -102,4 +149,4 @@ class ClaudeVersionChecker { } } -module.exports = { ClaudeVersionChecker }; \ No newline at end of file +module.exports = { ClaudeVersionChecker }; diff --git a/server/commanderService.js b/server/commanderService.js index ded60491..d96836fb 100644 --- a/server/commanderService.js +++ b/server/commanderService.js @@ -62,7 +62,9 @@ class CommanderService { try { // Detect shell based on platform const shell = process.platform === 'win32' ? 'powershell.exe' : 'bash'; - const shellArgs = process.platform === 'win32' ? ['-NoExit'] : []; + const shellArgs = process.platform === 'win32' + ? ['-WindowStyle', 'Hidden', '-NoLogo', '-NoExit'] + : []; // Spawn Claude Code terminal const ptyProcess = pty.spawn(shell, shellArgs, { diff --git a/server/desktopLaunchTraceService.js b/server/desktopLaunchTraceService.js new file mode 100644 index 00000000..3daff2c4 --- /dev/null +++ b/server/desktopLaunchTraceService.js @@ -0,0 +1,74 @@ +const winston = require('winston'); + +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: winston.format.combine( + winston.format.timestamp(), + winston.format.json() + ), + transports: [ + new winston.transports.File({ + filename: 'logs/desktop-launch.log', + maxsize: 10485760, + maxFiles: 5 + }) + ] +}); + +function clampString(value, maxLength = 600) { + const text = String(value || ''); + if (text.length <= maxLength) return text; + return `${text.slice(0, Math.max(0, maxLength - 16))}...[truncated]`; +} + +function sanitizePayload(value, { depth = 0, seen = new WeakSet() } = {}) { + if (value == null) return value; + if (typeof value === 'boolean' || typeof value === 'number') return value; + if (typeof value === 'string') return clampString(value); + if (typeof value === 'function') return `[function:${value.name || 'anonymous'}]`; + if (depth >= 4) return '[depth-limit]'; + + if (Array.isArray(value)) { + const items = value.slice(0, 20).map((item) => sanitizePayload(item, { depth: depth + 1, seen })); + if (value.length > 20) items.push(`[+${value.length - 20} more]`); + return items; + } + + if (typeof value === 'object') { + if (seen.has(value)) return '[circular]'; + seen.add(value); + const out = {}; + const keys = Object.keys(value).slice(0, 25); + keys.forEach((key) => { + out[key] = sanitizePayload(value[key], { depth: depth + 1, seen }); + }); + if (Object.keys(value).length > keys.length) { + out.__truncatedKeys = Object.keys(value).length - keys.length; + } + seen.delete(value); + return out; + } + + return clampString(String(value)); +} + +function logDesktopLaunch(event, payload = {}) { + const name = String(event || '').trim() || 'event'; + const safePayload = payload && typeof payload === 'object' && !Array.isArray(payload) + ? sanitizePayload(payload) + : { value: sanitizePayload(payload) }; + + try { + logger.info(name, { + event: name, + ...safePayload + }); + } catch { + // Best-effort trace logging should never interrupt app startup. + } +} + +module.exports = { + logDesktopLaunch, + sanitizePayload +}; diff --git a/server/diagnosticsService.js b/server/diagnosticsService.js index 7021c243..93e88416 100644 --- a/server/diagnosticsService.js +++ b/server/diagnosticsService.js @@ -9,14 +9,34 @@ const execFileAsync = util.promisify(execFile); async function checkCommand(command, args, options = {}) { const timeout = Number(options.timeoutMs) || 2500; try { - const { stdout, stderr } = await execFileAsync(command, args, { + const runOptions = { timeout, windowsHide: true, maxBuffer: 1024 * 1024 - }); + }; + + const commandStr = String(command || '').trim(); + const argsArr = Array.isArray(args) ? args : []; + let result; + try { + result = await execFileAsync(commandStr, argsArr, runOptions); + } catch (error) { + const isWindowsScript = process.platform === 'win32' && /\.(cmd|bat)$/i.test(commandStr); + const shouldRetryWithCmd = isWindowsScript && (error?.code === 'EINVAL' || error?.code === 'ENOENT'); + if (!shouldRetryWithCmd) throw error; + result = await execFileAsync('cmd.exe', ['/d', '/c', commandStr, ...argsArr], runOptions); + } + + const { stdout, stderr } = result || {}; const output = String(stdout || stderr || '').trim(); const firstLine = output.split(/\r?\n/).find(Boolean) || ''; - return { ok: true, command, args, version: firstLine || null }; + return { + ok: true, + command, + args, + version: firstLine || null, + output: output || null + }; } catch (error) { const code = error?.code || null; const message = String(error?.message || error || '').trim(); @@ -35,6 +55,86 @@ async function checkFirstAvailable(candidates) { return await checkCommand(last.command, last.args, last.options); } +function uniqueCommandCandidates(candidates = []) { + const seen = new Set(); + const out = []; + for (const candidate of candidates) { + const command = String(candidate?.command || '').trim(); + if (!command) continue; + const args = Array.isArray(candidate?.args) ? candidate.args : []; + const key = `${command}::${JSON.stringify(args)}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ command, args, options: candidate?.options }); + } + return out; +} + +async function checkNpmGlobalPackage(npmCommand, packageName) { + const npm = String(npmCommand || '').trim(); + const pkg = String(packageName || '').trim(); + if (!npm || !pkg) { + return { ok: false, error: 'Missing npm command or package name' }; + } + + const res = await checkCommand(npm, ['list', '-g', pkg, '--depth=0'], { timeoutMs: 7000 }); + const combined = String(res?.output || res?.version || '').trim(); + const pkgPattern = new RegExp(`${pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}@([^\\s]+)`, 'i'); + const versionMatch = combined.match(pkgPattern); + if (!res.ok || !versionMatch?.[1]) { + return { + ok: false, + command: npm, + args: ['list', '-g', pkg, '--depth=0'], + error: String(res?.error || `Package ${pkg} not found in npm global list`) + }; + } + + return { + ok: true, + command: `npm-global:${pkg}`, + args: ['list', '-g', pkg, '--depth=0'], + version: `${pkg}@${versionMatch[1]} (npm global)` + }; +} + +async function checkGitIdentity(gitCommand, gitInstalled) { + const command = String(gitCommand || 'git').trim() || 'git'; + if (!gitInstalled) { + return { + ok: false, + command, + args: ['config', '--global', '--get', 'user.name'], + error: 'Git is not installed' + }; + } + + const nameCheck = await checkCommand(command, ['config', '--global', '--get', 'user.name']); + const emailCheck = await checkCommand(command, ['config', '--global', '--get', 'user.email']); + const name = String(nameCheck?.version || '').trim(); + const email = String(emailCheck?.version || '').trim(); + + if (name && email) { + return { + ok: true, + command, + args: ['config', '--global', '--get', 'user.name,user.email'], + version: `${name} <${email}>` + }; + } + + const missing = []; + if (!name) missing.push('user.name'); + if (!email) missing.push('user.email'); + + return { + ok: false, + command, + args: ['config', '--global', '--get', 'user.name,user.email'], + error: `Missing global Git setting(s): ${missing.join(', ')}` + }; +} + function findTool(tools, id) { if (!Array.isArray(tools)) return null; return tools.find((tool) => String(tool?.id || '') === String(id || '')) || null; @@ -86,47 +186,128 @@ async function collectDiagnostics() { const tools = []; + const nodeCandidates = uniqueCommandCandidates([ + { command: 'node', args: ['--version'] }, + { command: platform === 'win32' ? 'node.exe' : 'node', args: ['--version'] }, + platform === 'win32' ? { command: path.join(process.env.ProgramFiles || '', 'nodejs', 'node.exe'), args: ['--version'] } : null, + platform === 'win32' ? { command: path.join(process.env['ProgramFiles(x86)'] || '', 'nodejs', 'node.exe'), args: ['--version'] } : null, + platform === 'win32' ? { command: path.join(process.env.LOCALAPPDATA || '', 'Programs', 'nodejs', 'node.exe'), args: ['--version'] } : null, + { command: process.execPath || 'node', args: ['--version'] } + ]); + const nodeCheck = await checkFirstAvailable(nodeCandidates); + const nodeCommand = String(nodeCheck?.command || '').trim(); + const nodeDir = nodeCommand ? path.dirname(nodeCommand) : ''; + + const npmCandidates = uniqueCommandCandidates([ + { command: platform === 'win32' ? 'npm.cmd' : 'npm', args: ['--version'] }, + platform === 'win32' ? { command: 'npm', args: ['--version'] } : null, + platform === 'win32' && nodeDir ? { command: path.join(nodeDir, 'npm.cmd'), args: ['--version'] } : null, + platform === 'win32' ? { command: path.join(process.env.ProgramFiles || '', 'nodejs', 'npm.cmd'), args: ['--version'] } : null, + platform === 'win32' ? { command: path.join(process.env['ProgramFiles(x86)'] || '', 'nodejs', 'npm.cmd'), args: ['--version'] } : null, + platform === 'win32' ? { command: path.join(process.env.LOCALAPPDATA || '', 'Programs', 'nodejs', 'npm.cmd'), args: ['--version'] } : null + ]); + const npmCheck = await checkFirstAvailable(npmCandidates); + tools.push({ id: 'node', name: 'Node.js', - ...(await checkCommand(process.execPath || 'node', ['--version'])) + ...nodeCheck }); tools.push({ id: 'npm', name: 'npm', - ...(await checkCommand(platform === 'win32' ? 'npm.cmd' : 'npm', ['--version'])) + ...npmCheck }); + const gitCandidates = uniqueCommandCandidates([ + { command: 'git', args: ['--version'] }, + platform === 'win32' ? { command: 'git.exe', args: ['--version'] } : null, + platform === 'win32' ? { command: path.join(process.env.ProgramFiles || '', 'Git', 'cmd', 'git.exe'), args: ['--version'] } : null, + platform === 'win32' ? { command: path.join(process.env.ProgramFiles || '', 'Git', 'bin', 'git.exe'), args: ['--version'] } : null, + platform === 'win32' ? { command: path.join(process.env['ProgramFiles(x86)'] || '', 'Git', 'cmd', 'git.exe'), args: ['--version'] } : null, + platform === 'win32' ? { command: path.join(process.env['ProgramFiles(x86)'] || '', 'Git', 'bin', 'git.exe'), args: ['--version'] } : null, + platform === 'win32' ? { command: path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Git', 'cmd', 'git.exe'), args: ['--version'] } : null + ]); + tools.push({ id: 'git', name: 'Git', - ...(await checkCommand('git', ['--version'])) + ...(await checkFirstAvailable(gitCandidates)) + }); + const gitTool = tools[tools.length - 1]; + tools.push({ + id: 'gitIdentity', + name: 'Git identity', + ...(await checkGitIdentity(gitTool?.command, !!gitTool?.ok)) }); + const ghCandidates = uniqueCommandCandidates([ + { command: 'gh', args: ['--version'] }, + platform === 'win32' ? { command: 'gh.exe', args: ['--version'] } : null, + platform === 'win32' ? { command: path.join(process.env.ProgramFiles || '', 'GitHub CLI', 'gh.exe'), args: ['--version'] } : null, + platform === 'win32' ? { command: path.join(process.env['ProgramFiles(x86)'] || '', 'GitHub CLI', 'gh.exe'), args: ['--version'] } : null, + platform === 'win32' ? { command: path.join(process.env.LOCALAPPDATA || '', 'Programs', 'GitHub CLI', 'gh.exe'), args: ['--version'] } : null + ]); + const ghCheck = await checkFirstAvailable(ghCandidates); tools.push({ id: 'gh', name: 'GitHub CLI', - ...(await checkCommand('gh', ['--version'])) + ...ghCheck }); // Auth status is the most common root cause of "0 files/commits" in PR tooling on Windows. // We keep it lightweight: first line of `gh auth status` is enough to spot "not logged in". + const ghAuthCheck = ghCheck?.ok + ? await checkCommand(String(ghCheck.command || 'gh'), ['auth', 'status']) + : { + ok: false, + command: String(ghCheck?.command || 'gh'), + args: ['auth', 'status'], + error: 'GitHub CLI is not installed' + }; tools.push({ id: 'ghAuth', name: 'GitHub CLI auth', - ...(await checkCommand('gh', ['auth', 'status'])) + ...ghAuthCheck }); + const claudeCandidates = uniqueCommandCandidates([ + { command: 'claude', args: ['--version'] }, + platform === 'win32' ? { command: 'claude.cmd', args: ['--version'] } : null, + platform === 'win32' ? { command: 'claude.exe', args: ['--version'] } : null, + platform === 'win32' ? { command: path.join(process.env.APPDATA || '', 'npm', 'claude.cmd'), args: ['--version'] } : null, + platform === 'win32' ? { command: path.join(process.env.APPDATA || '', 'npm', 'claude'), args: ['--version'] } : null, + platform === 'win32' ? { command: path.join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WinGet', 'Links', 'claude.exe'), args: ['--version'] } : null, + platform === 'win32' ? { command: path.join(process.env.USERPROFILE || '', '.local', 'bin', 'claude.exe'), args: ['--version'] } : null, + platform === 'win32' ? { command: path.join(process.env.USERPROFILE || '', '.claude', 'local', 'claude.exe'), args: ['--version'] } : null, + platform === 'win32' ? { command: path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Claude', 'claude.exe'), args: ['--version'] } : null + ]); tools.push({ id: 'claude', name: 'Claude Code', - ...(await checkCommand('claude', ['--version'])) + ...(await checkFirstAvailable(claudeCandidates)) }); + const codexCandidates = uniqueCommandCandidates([ + { command: 'codex', args: ['--version'] }, + platform === 'win32' ? { command: 'codex.cmd', args: ['--version'] } : null, + platform === 'win32' ? { command: 'codex.exe', args: ['--version'] } : null, + platform === 'win32' ? { command: path.join(process.env.APPDATA || '', 'npm', 'codex.cmd'), args: ['--version'] } : null, + platform === 'win32' ? { command: path.join(process.env.APPDATA || '', 'npm', 'codex'), args: ['--version'] } : null, + platform === 'win32' ? { command: path.join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WinGet', 'Links', 'codex.exe'), args: ['--version'] } : null, + platform === 'win32' ? { command: path.join(process.env.USERPROFILE || '', '.local', 'bin', 'codex.exe'), args: ['--version'] } : null + ]); + let codexCheck = await checkFirstAvailable(codexCandidates); + if (!codexCheck?.ok && npmCheck?.ok) { + const npmPackageCheck = await checkNpmGlobalPackage(String(npmCheck.command || '').trim(), '@openai/codex'); + if (npmPackageCheck?.ok) { + codexCheck = npmPackageCheck; + } + } tools.push({ id: 'codex', name: 'Codex CLI', - ...(await checkCommand('codex', ['--version'])) + ...codexCheck }); tools.push({ diff --git a/server/githubRepoService.js b/server/githubRepoService.js index 13a2aff3..e3027576 100644 --- a/server/githubRepoService.js +++ b/server/githubRepoService.js @@ -2,8 +2,12 @@ const https = require('https'); const { execFile } = require('child_process'); const winston = require('winston'); -const execFileAsync = (command, args, options) => new Promise((resolve, reject) => { - execFile(command, args, options, (error, stdout, stderr) => { +const execFileAsync = (command, args, options = {}) => new Promise((resolve, reject) => { + const runOptions = { + ...options, + windowsHide: options.windowsHide ?? true + }; + execFile(command, args, runOptions, (error, stdout, stderr) => { if (error) { reject(error); return; diff --git a/server/index.js b/server/index.js index e3a9a62b..0ac7eafe 100644 --- a/server/index.js +++ b/server/index.js @@ -7,6 +7,7 @@ const fs = require('fs'); const os = require('os'); const crypto = require('crypto'); const winston = require('winston'); +const { logDesktopLaunch } = require('./desktopLaunchTraceService'); // Ensure log directory exists early (some services create file transports at require-time). try { @@ -42,6 +43,56 @@ const logger = winston.createLogger({ ] }); +function getLaunchTraceId(req, extras = {}) { + const extraTraceId = String(extras?.traceId || '').trim(); + if (extraTraceId) return extraTraceId; + return String( + req?.get?.('x-launch-trace-id') + || req?.body?.traceId + || req?.query?.traceId + || '' + ).trim(); +} + +function summarizeDiagnosticTools(data) { + const tools = Array.isArray(data?.tools) ? data.tools : []; + const summary = {}; + tools.forEach((tool) => { + const id = String(tool?.id || '').trim(); + if (!id) return; + summary[id] = { + ok: !!tool?.ok, + version: tool?.version ? String(tool.version) : null + }; + }); + const gitOk = !!summary.git?.ok; + const claudeOk = !!summary.claude?.ok; + const codexOk = !!summary.codex?.ok; + return { + coreReady: gitOk && (claudeOk || codexOk), + tools: summary + }; +} + +function summarizeOnboardingState(settings) { + const onboarding = settings?.global?.ui?.onboarding?.desktopDependencySetup || {}; + return { + completed: onboarding.completed === true, + completedAt: onboarding.completedAt || null + }; +} + +function traceRequest(req, event, payload = {}, extras = {}) { + const traceId = getLaunchTraceId(req, extras); + if (!traceId) return; + logDesktopLaunch(event, { + traceId, + method: req?.method || null, + path: req?.path || null, + ...payload + }); +} + // Import services const { SessionManager } = require('./sessionManager'); const { StatusDetector } = require('./statusDetector'); @@ -99,6 +150,13 @@ const voiceCommandService = require('./voiceCommandService'); const whisperService = require('./whisperService'); const sessionRecoveryService = require('./sessionRecoveryService'); const { collectDiagnostics, collectFirstRunDiagnostics, collectInstallWizard, runFirstRunRepair, runFirstRunSafeRepairs } = require('./diagnosticsService'); +const { + getSetupActions, + runSetupAction, + getSetupActionRun, + getLatestSetupActionRun, + configureGitIdentity +} = require('./setupActionService'); const { PluginLoaderService } = require('./pluginLoaderService'); const { SchedulerService } = require('./schedulerService'); const { PagerService } = require('./pagerService'); @@ -208,6 +266,26 @@ app.get('/', (req, res) => { res.sendFile(path.join(__dirname, '../client/index.html')); }); +app.post('/api/desktop-launch-trace', express.json({ limit: '64kb' }), (req, res) => { + const traceId = getLaunchTraceId(req); + if (!traceId) { + return res.status(400).json({ ok: false, error: 'traceId is required' }); + } + + const event = String(req.body?.event || '').trim() || 'client.event'; + logDesktopLaunch(event, { + traceId, + seq: Number.isFinite(Number(req.body?.seq)) ? Number(req.body.seq) : null, + source: String(req.body?.source || '').trim() || 'client', + details: req.body?.details && typeof req.body.details === 'object' ? req.body.details : {}, + userAgent: req.get('user-agent') || '', + origin: req.get('origin') || '', + referer: req.get('referer') || '' + }); + + res.json({ ok: true }); +}); + // Serve static files from client directory (but exclude index files) const clientPath = path.join(__dirname, '../client'); logger.info(`Serving static files from: ${clientPath}`); @@ -400,6 +478,7 @@ sessionManager.setGitHelper(gitHelper); // Initialize workspace system let workspaceInitialized = false; +let workspaceSystemReady = null; async function initializeWorkspaceSystem() { try { logger.info('Initializing workspace system...'); @@ -425,25 +504,28 @@ async function initializeWorkspaceSystem() { } // Initialize workspace system before starting server -initializeWorkspaceSystem().then(() => { - logger.info('Workspace system initialized'); - loadPlugins() - .then((status) => { - logger.info('Plugin loader finished', { - loaded: Array.isArray(status?.loaded) ? status.loaded.length : 0, - failed: Array.isArray(status?.failed) ? status.failed.length : 0 - }); - }) - .catch((error) => { - logger.error('Plugin loader failed', { error: error.message, stack: error.stack }); +workspaceSystemReady = initializeWorkspaceSystem() + .then(() => { + logger.info('Workspace system initialized'); + return true; + }) + .then(() => loadPlugins()) + .then((status) => { + logger.info('Plugin loader finished', { + loaded: Array.isArray(status?.loaded) ? status.loaded.length : 0, + failed: Array.isArray(status?.failed) ? status.failed.length : 0 }); -}).catch(error => { - logger.error('Workspace system initialization failed', { error: error.message, stack: error.stack }); -}); + return true; + }) + .catch(error => { + logger.error('Workspace system initialization failed', { error: error.message, stack: error.stack }); + return false; + }); // WebSocket connection handling io.on('connection', (socket) => { logger.info('Client connected', { socketId: socket.id }); + let inFlightWorkspaceSwitchId = null; // Send workspace info const activeWorkspace = workspaceManager.getActiveWorkspace(); @@ -854,26 +936,69 @@ io.on('connection', (socket) => { }); // Workspace management handlers - socket.on('switch-workspace', async ({ workspaceId }) => { + socket.on('switch-workspace', async (payload = {}) => { + const requestedWorkspaceId = String(payload?.workspaceId || '').trim(); + const traceId = String(payload?.traceId || '').trim() || null; + const source = String(payload?.source || '').trim() || 'unknown'; + logDesktopLaunch('server.workspace-switch.received', { + traceId, + source, + socketId: socket.id, + requestedWorkspaceId, + currentWorkspaceId: workspaceManager.getActiveWorkspace?.()?.id || null + }); + if (requestedWorkspaceId && inFlightWorkspaceSwitchId === requestedWorkspaceId) { + logger.info('Ignoring duplicate workspace switch request while switch is already in progress', { + workspaceId: requestedWorkspaceId, + socketId: socket.id + }); + logDesktopLaunch('server.workspace-switch.ignored-duplicate', { + traceId, + source, + socketId: socket.id, + requestedWorkspaceId + }); + return; + } + + inFlightWorkspaceSwitchId = requestedWorkspaceId || null; + let releaseInFlightWorkspaceSwitchInFinally = true; try { const previous = workspaceManager.getActiveWorkspace?.() || null; activityFeed.track('workspace.switch.requested', { fromWorkspaceId: previous?.id || null, - toWorkspaceId: String(workspaceId || '').trim() || null, + toWorkspaceId: requestedWorkspaceId || null, socketId: socket.id }); - logger.info('Workspace switch requested', { workspaceId }); + logger.info('Workspace switch requested', { workspaceId: requestedWorkspaceId }); + logDesktopLaunch('server.workspace-switch.started', { + traceId, + source, + socketId: socket.id, + previousWorkspaceId: previous?.id || null, + requestedWorkspaceId + }); - const newWorkspace = await workspaceManager.switchWorkspace(workspaceId); + const newWorkspace = await workspaceManager.switchWorkspace(requestedWorkspaceId); // Ensure worktrees exist for the new workspace logger.info('Ensuring worktrees exist for new workspace'); await worktreeHelper.ensureWorktreesExist(newWorkspace); // Switch active workspace while preserving existing PTYs for other workspace tabs. - const { sessions: newSessions, backlog } = - await sessionManager.switchWorkspacePreservingSessions(newWorkspace); + const { + sessions: newSessions, + backlog, + initializePromise + } = + await sessionManager.switchWorkspacePreservingSessions(newWorkspace, { + reason: 'workspace-switch', + traceId, + source, + socketId: socket.id, + deferInitialize: true + }); // Emit success with ONLY the new workspace sessions (active workspace map) logger.info('Sending workspace-changed event', { @@ -898,20 +1023,86 @@ io.on('connection', (socket) => { } logger.info('Workspace switched successfully', { workspace: newWorkspace.name }); + logDesktopLaunch('server.workspace-switch.completed', { + traceId, + source, + socketId: socket.id, + previousWorkspaceId: previous?.id || null, + workspaceId: newWorkspace?.id || null, + sessionCount: Object.keys(newSessions || {}).length, + backlogSessionCount: backlog && typeof backlog === 'object' ? Object.keys(backlog).length : 0, + pendingSessionInitialization: !!initializePromise + }); activityFeed.track('workspace.switch.completed', { fromWorkspaceId: previous?.id || null, toWorkspaceId: newWorkspace?.id || null, toWorkspaceName: newWorkspace?.name || null, socketId: socket.id }); + + if (initializePromise && typeof initializePromise.then === 'function') { + releaseInFlightWorkspaceSwitchInFinally = false; + initializePromise + .then(() => { + const activeWorkspaceId = workspaceManager.getActiveWorkspace?.()?.id || null; + if (activeWorkspaceId !== newWorkspace?.id) { + return; + } + const refreshedSessions = sessionManager.getSessionStates(); + socket.emit('sessions', refreshedSessions); + logDesktopLaunch('server.workspace-switch.sessions-ready', { + traceId, + source, + socketId: socket.id, + workspaceId: newWorkspace?.id || null, + sessionCount: Object.keys(refreshedSessions || {}).length + }); + }) + .catch((error) => { + logger.error('Deferred workspace session initialization failed', { + workspaceId: newWorkspace?.id || null, + error: error.message, + stack: error.stack + }); + logDesktopLaunch('server.workspace-switch.session-init-failed', { + traceId, + source, + socketId: socket.id, + workspaceId: newWorkspace?.id || null, + error: error.message + }); + if (workspaceManager.getActiveWorkspace?.()?.id === newWorkspace?.id) { + socket.emit('error', { + message: 'Failed to initialize workspace sessions', + error: error.message + }); + } + }) + .finally(() => { + if (inFlightWorkspaceSwitchId === requestedWorkspaceId) { + inFlightWorkspaceSwitchId = null; + } + }); + } } catch (error) { + logDesktopLaunch('server.workspace-switch.failed', { + traceId, + source, + socketId: socket.id, + requestedWorkspaceId, + error: error.message + }); activityFeed.track('workspace.switch.failed', { - toWorkspaceId: String(workspaceId || '').trim() || null, + toWorkspaceId: requestedWorkspaceId || null, socketId: socket.id, error: error.message }); logger.error('Failed to switch workspace', { error: error.message, stack: error.stack }); socket.emit('error', { message: 'Failed to switch workspace', error: error.message, stack: error.stack }); + } finally { + if (releaseInFlightWorkspaceSwitchInFinally && inFlightWorkspaceSwitchId === requestedWorkspaceId) { + inFlightWorkspaceSwitchId = null; + } } }); @@ -3712,6 +3903,9 @@ app.put('/api/process/automations/pr-review/config', express.json(), async (req, app.get('/api/user-settings', (req, res) => { try { const settings = userSettingsService.getAllSettings(); + traceRequest(req, 'server.user-settings.loaded', { + onboarding: summarizeOnboardingState(settings) + }); res.json(settings); } catch (error) { logger.error('Failed to get user settings', { error: error.message, stack: error.stack }); @@ -3727,6 +3921,10 @@ app.put('/api/user-settings/global', express.json(), (req, res) => { if (success) { const updatedSettings = userSettingsService.getAllSettings(); + traceRequest(req, 'server.user-settings.global-updated', { + onboarding: summarizeOnboardingState(updatedSettings), + updatedKeys: global && typeof global === 'object' ? Object.keys(global).slice(0, 20) : [] + }); res.json(updatedSettings); // Notify all clients about settings change @@ -3971,6 +4169,7 @@ app.get('/api/agents', (req, res) => { app.get('/api/diagnostics', async (req, res) => { try { const data = await collectDiagnostics(); + traceRequest(req, 'server.diagnostics.loaded', summarizeDiagnosticTools(data)); res.json({ ok: true, ...data }); } catch (error) { logger.error('Failed to collect diagnostics', { error: error.message, stack: error.stack }); @@ -4050,6 +4249,127 @@ app.get('/api/lifecycle/policy', (req, res) => { } }); +// Setup helper actions for first-run dependency wizard. +app.get('/api/setup-actions', (req, res) => { + try { + const platform = process.platform; + const actions = getSetupActions(platform); + traceRequest(req, 'server.setup-actions.loaded', { + platform, + actionIds: actions.map((action) => String(action?.id || '').trim()).filter(Boolean) + }); + res.json({ ok: true, platform, actions }); + } catch (error) { + logger.error('Failed to get setup actions', { error: error.message, stack: error.stack }); + res.status(500).json({ ok: false, error: 'Failed to get setup actions' }); + } +}); + +app.post('/api/setup-actions/run', requirePolicyAction('write'), express.json(), (req, res) => { + try { + const actionId = String(req.body?.actionId || '').trim(); + if (!actionId) { + return res.status(400).json({ ok: false, error: 'actionId is required' }); + } + + const result = runSetupAction(actionId, process.platform); + res.json({ ok: true, ...result }); + } catch (error) { + const code = String(error?.code || ''); + const status = (code === 'unsupported_platform' || code === 'unknown_action' || code === 'not_runnable') ? 400 : 500; + logger.error('Failed to run setup action', { actionId: req.body?.actionId, error: error.message, stack: error.stack }); + res.status(status).json({ ok: false, error: String(error?.message || 'Failed to run setup action') }); + } +}); + +app.post('/api/setup-actions/configure-git-identity', requirePolicyAction('write'), express.json(), async (req, res) => { + try { + const name = String(req.body?.name || '').trim(); + const email = String(req.body?.email || '').trim(); + const result = await configureGitIdentity({ name, email }, process.platform); + res.json({ ok: true, ...result }); + } catch (error) { + const code = String(error?.code || ''); + const status = ( + code === 'unsupported_platform' + || code === 'invalid_input' + || code === 'missing_git' + || code === 'verify_failed' + ) ? 400 : 500; + logger.error('Failed to configure git identity', { + error: error.message, + stack: error.stack + }); + res.status(status).json({ ok: false, error: String(error?.message || 'Failed to configure git identity') }); + } +}); + +app.get('/api/setup-actions/run-status', (req, res) => { + try { + const runId = String(req.query?.runId || '').trim(); + const actionId = String(req.query?.actionId || '').trim(); + const run = runId ? getSetupActionRun(runId) : getLatestSetupActionRun(actionId); + if (!run) { + return res.status(404).json({ ok: false, error: 'Setup action run not found' }); + } + res.json({ ok: true, run }); + } catch (error) { + logger.error('Failed to get setup action run status', { + runId: req.query?.runId, + actionId: req.query?.actionId, + error: error.message, + stack: error.stack + }); + res.status(500).json({ ok: false, error: 'Failed to get setup action run status' }); + } +}); + +app.post('/api/setup-actions/open-url', requirePolicyAction('write'), express.json(), (req, res) => { + try { + const rawUrl = String(req.body?.url || '').trim(); + if (!rawUrl) { + return res.status(400).json({ ok: false, error: 'url is required' }); + } + + let parsed; + try { + parsed = new URL(rawUrl); + } catch { + return res.status(400).json({ ok: false, error: 'Invalid URL' }); + } + + if (!['http:', 'https:'].includes(String(parsed.protocol || '').toLowerCase())) { + return res.status(400).json({ ok: false, error: 'Only http/https URLs are supported' }); + } + + const targetUrl = parsed.toString(); + const { execFile } = require('child_process'); + + const finish = (error) => { + if (error) { + logger.error('Failed to open setup URL', { url: targetUrl, error: error.message, stack: error.stack }); + return res.status(500).json({ ok: false, error: 'Failed to open URL' }); + } + res.json({ ok: true, opened: targetUrl }); + }; + + if (process.platform === 'win32') { + execFile('explorer.exe', [targetUrl], { windowsHide: true }, finish); + return; + } + + if (process.platform === 'darwin') { + execFile('open', [targetUrl], { windowsHide: true }, finish); + return; + } + + execFile('xdg-open', [targetUrl], { windowsHide: true }, finish); + } catch (error) { + logger.error('Failed to open setup URL', { error: error.message, stack: error.stack }); + res.status(500).json({ ok: false, error: 'Failed to open URL' }); + } +}); + // Port registry API endpoints app.get('/api/ports', (req, res) => { try { @@ -7836,7 +8156,13 @@ httpServer.listen(PORT, HOST, () => { } })(); - sessionManager.initializeSessions() + workspaceSystemReady + .then((workspaceReady) => { + if (!workspaceReady) { + return; + } + return sessionManager.initializeSessions({ reason: 'server-startup' }); + }) .then(() => { if (!shouldAutoEnsureDiscordServices) return; // Don’t block server startup; just best-effort keep Services running after restarts. diff --git a/server/sessionManager.js b/server/sessionManager.js index d439f289..8f8cb8cf 100644 --- a/server/sessionManager.js +++ b/server/sessionManager.js @@ -13,6 +13,7 @@ const path = require('path'); const { ClaudeVersionChecker } = require('./claudeVersionChecker'); const { UserSettingsService } = require('./userSettingsService'); const { WorktreeHelper } = require('./worktreeHelper'); +const { logDesktopLaunch } = require('./desktopLaunchTraceService'); const sessionRecoveryService = require('./sessionRecoveryService'); const { parseWorktreeKey } = require('./lifecyclePolicyService'); const { @@ -49,9 +50,9 @@ function getDefaultShell() { // Helper function to build shell args for executing commands function buildShellArgs(commands) { if (process.platform === 'win32') { - // PowerShell: join commands with ; and use -NoExit -Command to keep shell open + // PowerShell: hide the backing console window while keeping the shell interactive in the PTY. const joined = Array.isArray(commands) ? commands.join('; ') : commands.replace(/&&/g, ';'); - return ['-NoExit', '-Command', joined]; + return ['-WindowStyle', 'Hidden', '-NoLogo', '-NoExit', '-Command', joined]; } else { // Bash: join commands with && and keep the terminal open by exec'ing into an interactive shell. const joined = Array.isArray(commands) ? commands.join(' && ') : commands; @@ -60,6 +61,32 @@ function buildShellArgs(commands) { } } +function buildServerTerminalIntroCommands(worktreePath, label) { + const commands = [ + `cd "${worktreePath}"`, + `echo "=== ${label} ==="`, + `echo "Directory: ${worktreePath}"` + ]; + + // Avoid spawning extra Git-for-Windows helper processes during terminal boot on Windows. + if (process.platform !== 'win32') { + commands.push( + getShellKind() === 'powershell' + ? `$b = git branch --show-current 2>$null; if (-not $b) { $b = 'unknown' }; Write-Output "Branch: $b"` + : `echo "Branch: $(git branch --show-current 2>/dev/null || echo 'unknown')"` + ); + } + + commands.push( + `echo ""`, + `echo "Ready to run: bun index.ts"`, + `echo "Available commands: bun, npm, node"`, + `echo ""` + ); + + return commands; +} + const HOME_DIR = process.env.HOME || os.homedir(); class SessionManager extends EventEmitter { @@ -102,6 +129,24 @@ class SessionManager extends EventEmitter { this.worktrees = []; } + shouldMonitorSessionProcesses() { + // Windows desktop builds were flashing visible PowerShell/conhost windows because the + // process-limit probe shells out once per session on an interval. Skip that probe on + // Windows until we have a non-console-backed process inspection path. + return process.platform !== 'win32'; + } + + shouldPollBranches() { + return process.platform !== 'win32' && this.branchRefreshMs > 0; + } + + getGitBranchUpdateOptions(overrides = {}) { + return { + branchOnly: process.platform === 'win32', + ...(overrides && typeof overrides === 'object' ? overrides : {}) + }; + } + // Determine effective inactivity timeout per session (ms) getSessionTimeout(session) { if (!session) return this.sessionTimeout; @@ -161,7 +206,7 @@ class SessionManager extends EventEmitter { * - Restores (or creates) the session map for the new workspace id as `this.sessions` * - Ensures sessions exist for the new workspace without killing old PTYs */ - async switchWorkspacePreservingSessions(workspace) { + async switchWorkspacePreservingSessions(workspace, options = {}) { if (!workspace?.id) { throw new Error('Workspace missing id'); } @@ -178,7 +223,23 @@ class SessionManager extends EventEmitter { this.workspaceSessionMaps.set(workspace.id, this.sessions); // Ensure sessions exist for the active workspace without clearing existing ones. - await this.initializeSessions({ preserveExisting: true }); + const initializePromise = this.initializeSessions({ + preserveExisting: true, + reason: String(options.reason || '').trim() || 'workspace-switch', + traceId: String(options.traceId || '').trim() || null, + source: String(options.source || '').trim() || null, + socketId: String(options.socketId || '').trim() || null + }); + + if (options.deferInitialize) { + return { + sessions: this.getSessionStates(), + backlog: this.getUndeliveredOutputAndMarkDelivered(), + initializePromise + }; + } + + await initializePromise; // Return any buffered output that occurred while this workspace was inactive. return { @@ -243,9 +304,24 @@ class SessionManager extends EventEmitter { async initializeSessions(options = {}) { const preserveExisting = !!options.preserveExisting; + const reason = String(options.reason || '').trim() || (preserveExisting ? 'preserve-existing' : 'workspace-initialize'); + const traceId = String(options.traceId || '').trim() || null; + const source = String(options.source || '').trim() || null; + const socketId = String(options.socketId || '').trim() || null; // Set flag to prevent auto-restart during initialization this.isWorkspaceSwitching = true; + logDesktopLaunch('session-manager.initialize.begin', { + traceId, + source, + socketId, + reason, + preserveExisting, + workspaceId: this.workspace?.id || null, + workspaceName: this.workspace?.name || null, + worktreeIds: Array.isArray(this.worktrees) ? this.worktrees.map((worktree) => worktree?.id || null) : [] + }); + if (!preserveExisting) { // Clear ALL existing sessions first logger.info('Clearing existing sessions before workspace initialization'); @@ -305,6 +381,14 @@ class SessionManager extends EventEmitter { // If no workspace is set, skip session creation if (!this.workspace) { logger.warn('No workspace set, skipping session initialization'); + logDesktopLaunch('session-manager.initialize.skipped', { + traceId, + source, + socketId, + reason, + preserveExisting, + cause: 'missing-workspace' + }); this.isWorkspaceSwitching = false; return; } @@ -354,28 +438,21 @@ class SessionManager extends EventEmitter { } else { // Server terminal command = getDefaultShell(); - const header = `=== ${terminal.repository.name}/${terminal.worktree} (${terminal.id}) ===`; if (startCommand) { args = buildShellArgs([ `cd "${worktree.path}"`, - `echo "${header}"`, + `echo "=== ${terminal.repository.name}/${terminal.worktree} (${terminal.id}) ==="`, `echo "Directory: ${worktree.path}"`, `echo ""`, startCommand ]); } else { - args = buildShellArgs([ - `cd "${worktree.path}"`, - `echo "=== Server Terminal for ${terminal.repository.name}/${terminal.worktree} ==="`, - `echo "Directory: ${worktree.path}"`, - getShellKind() === 'powershell' - ? `$b = git branch --show-current 2>$null; if (-not $b) { $b = 'unknown' }; Write-Output "Branch: $b"` - : `echo "Branch: $(git branch --show-current 2>/dev/null || echo 'unknown')"`, - `echo ""`, - `echo "Ready to run: bun index.ts"`, - `echo "Available commands: bun, npm, node"`, - `echo ""` - ]); + args = buildShellArgs( + buildServerTerminalIntroCommands( + worktree.path, + `Server Terminal for ${terminal.repository.name}/${terminal.worktree}` + ) + ); } } @@ -387,7 +464,11 @@ class SessionManager extends EventEmitter { worktreeId: terminal.worktree, repositoryName: terminal.repository.name, repositoryType: terminal.repository.type, // Add repository type for dynamic launch options - timeoutMs + timeoutMs, + debugSource: reason, + launchTraceId: traceId, + launchSource: source, + launchSocketId: socketId }); }).catch(error => { logger.error(`Failed to initialize ${terminal.terminalType} session`, { @@ -412,7 +493,11 @@ class SessionManager extends EventEmitter { args: buildShellArgs(`cd "${worktree.path}"`), cwd: worktree.path, type: 'claude', - worktreeId: worktree.id + worktreeId: worktree.id, + debugSource: reason, + launchTraceId: traceId, + launchSource: source, + launchSocketId: socketId }); }).catch(error => { logger.error('Failed to initialize Claude session', { @@ -431,21 +516,14 @@ class SessionManager extends EventEmitter { } this.createSession(sessionId, { command: getDefaultShell(), - args: buildShellArgs([ - `cd "${worktree.path}"`, - `echo "=== Server Terminal for ${worktree.id} ==="`, - `echo "Directory: ${worktree.path}"`, - getShellKind() === 'powershell' - ? `$b = git branch --show-current 2>$null; if (-not $b) { $b = 'unknown' }; Write-Output "Branch: $b"` - : `echo "Branch: $(git branch --show-current 2>/dev/null || echo 'unknown')"`, - `echo ""`, - `echo "Ready to run: bun index.ts"`, - `echo "Available commands: bun, npm, node"`, - `echo ""` - ]), + args: buildShellArgs(buildServerTerminalIntroCommands(worktree.path, `Server Terminal for ${worktree.id}`)), cwd: worktree.path, type: 'server', - worktreeId: worktree.id + worktreeId: worktree.id, + debugSource: reason, + launchTraceId: traceId, + launchSource: source, + launchSocketId: socketId }); }).catch(error => { logger.error('Failed to initialize server session', { @@ -455,43 +533,30 @@ class SessionManager extends EventEmitter { }) ); - // Add git branch update to promises array - if (this.gitHelper) { - sessionPromises.push( - Promise.resolve().then(() => { - return this.updateGitBranch(worktree.id, worktree.path); - }).catch(error => { - logger.error('Failed to update git branch', { - worktree: worktree.id, - error: error.message - }); - }) - ); - } } } - // Git branch updates for all worktrees (both traditional and mixed-repo) - if (this.gitHelper) { - for (const worktree of this.worktrees) { - sessionPromises.push( - Promise.resolve().then(() => { - const worktreeIdForGit = worktree.worktreeId || worktree.id; - return this.updateGitBranch(worktreeIdForGit, worktree.path); - }).catch(error => { - logger.error('Failed to update git branch', { - worktree: worktree.id, - error: error.message - }); - }) - ); - } - } - // Wait for all sessions to be created in parallel await Promise.all(sessionPromises); logger.info('All sessions initialized', { count: sessionPromises.length }); + // Emit sessions as soon as terminals exist; git metadata can catch up afterward. + if (this.io) { + this.io.emit('sessions', this.getSessionStates()); + } + + logDesktopLaunch('session-manager.initialize.completed', { + traceId, + source, + socketId, + reason, + preserveExisting, + workspaceId: this.workspace?.id || null, + workspaceName: this.workspace?.name || null, + createdSessionCount: this.sessions.size, + sessionIds: Array.from(this.sessions.keys()) + }); + // Keep an authoritative reference from workspace id -> session map for tab switching. if (this.workspace?.id) { this.workspaceSessionMaps.set(this.workspace.id, this.sessions); @@ -505,6 +570,22 @@ class SessionManager extends EventEmitter { // Setup file watchers for instant branch detection this.setupGitWatchers(); + + // Git branch updates for all worktrees (both traditional and mixed-repo) + if (this.gitHelper) { + const branchUpdateOptions = this.getGitBranchUpdateOptions(); + await Promise.all(this.worktrees.map((worktree) => ( + Promise.resolve().then(() => { + const worktreeIdForGit = worktree.worktreeId || worktree.id; + return this.updateGitBranch(worktreeIdForGit, worktree.path, false, branchUpdateOptions); + }).catch(error => { + logger.error('Failed to update git branch', { + worktree: worktree.id, + error: error.message + }); + }) + ))); + } } startBranchRefresh() { @@ -512,6 +593,11 @@ class SessionManager extends EventEmitter { clearInterval(this.branchRefreshInterval); } + if (!this.shouldPollBranches()) { + this.branchRefreshInterval = null; + return; + } + const refreshWorktrees = () => { const refreshedPaths = new Set(); const refreshPath = (worktreeId, cwd) => { @@ -519,7 +605,7 @@ class SessionManager extends EventEmitter { if (!normalized) return; if (refreshedPaths.has(normalized)) return; refreshedPaths.add(normalized); - this.updateGitBranch(worktreeId, normalized, true); + this.updateGitBranch(worktreeId, normalized, true, this.getGitBranchUpdateOptions({ branchOnly: true })); }; this.worktrees.forEach(worktree => { @@ -551,8 +637,6 @@ class SessionManager extends EventEmitter { } }; - // Do an initial refresh immediately (don't wait for the first interval tick). - refreshWorktrees(); this.branchRefreshInterval = setInterval(refreshWorktrees, this.branchRefreshMs); } @@ -621,7 +705,12 @@ class SessionManager extends EventEmitter { setTimeout(() => { logger.debug('File watcher triggered branch update', { worktree: worktree.id }); const worktreeIdForGit = worktree.worktreeId || worktree.id; - this.updateGitBranch(worktreeIdForGit, worktree.path, true); + this.updateGitBranch( + worktreeIdForGit, + worktree.path, + true, + this.getGitBranchUpdateOptions({ branchOnly: true }) + ); }, 50); } }); @@ -735,10 +824,33 @@ class SessionManager extends EventEmitter { createSession(sessionId, config) { logger.info('Creating session', { sessionId, type: config.type }); + logDesktopLaunch('session-manager.session.create.requested', { + traceId: String(config?.launchTraceId || '').trim() || null, + source: String(config?.launchSource || '').trim() || null, + socketId: String(config?.launchSocketId || '').trim() || null, + debugSource: String(config?.debugSource || '').trim() || null, + workspaceId: this.workspace?.id || null, + sessionId, + sessionType: config?.type || null, + worktreeId: config?.worktreeId || null, + cwd: config?.cwd || null, + command: config?.command || null, + args: Array.isArray(config?.args) ? config.args : [] + }); try { if (!pty) { logger.error('Cannot create session - node-pty unavailable', { sessionId, type: config.type }); + logDesktopLaunch('session-manager.session.create.failed', { + traceId: String(config?.launchTraceId || '').trim() || null, + source: String(config?.launchSource || '').trim() || null, + socketId: String(config?.launchSocketId || '').trim() || null, + debugSource: String(config?.debugSource || '').trim() || null, + workspaceId: this.workspace?.id || null, + sessionId, + sessionType: config?.type || null, + error: 'node-pty unavailable' + }); throw new Error('node-pty unavailable'); } const homeDir = process.env.HOME || os.homedir(); @@ -763,6 +875,17 @@ class SessionManager extends EventEmitter { cwd: config.cwd, env }); + logDesktopLaunch('session-manager.session.spawned', { + traceId: String(config?.launchTraceId || '').trim() || null, + source: String(config?.launchSource || '').trim() || null, + socketId: String(config?.launchSocketId || '').trim() || null, + debugSource: String(config?.debugSource || '').trim() || null, + workspaceId: this.workspace?.id || null, + sessionId, + sessionType: config?.type || null, + worktreeId: config?.worktreeId || null, + pid: Number.isFinite(ptyProcess?.pid) ? ptyProcess.pid : null + }); const initialCwd = config.cwd || process.cwd(); @@ -841,6 +964,18 @@ class SessionManager extends EventEmitter { ptyProcess.onExit(({ exitCode, signal }) => { logger.info('Session exited', { sessionId, exitCode, signal }); const workspaceId = session.workspace || this.workspace?.id || null; + logDesktopLaunch('session-manager.session.exited', { + traceId: String(config?.launchTraceId || '').trim() || null, + source: String(config?.launchSource || '').trim() || null, + socketId: String(config?.launchSocketId || '').trim() || null, + debugSource: String(config?.debugSource || '').trim() || null, + workspaceId, + sessionId, + sessionType: session.type, + worktreeId: session.worktreeId || null, + exitCode, + signal + }); clearTimeout(session.inactivityTimer); if (session.pendingStatusTimer) { @@ -936,15 +1071,31 @@ class SessionManager extends EventEmitter { }); } - // Monitor for fork bombs (every 5 seconds) - session.processMonitor = setInterval(() => { - this.checkProcessLimit(session); - // Re-evaluate status even when there is no new output, so sessions can - // transition out of "busy" after quiet periods. - this.refreshSessionStatus(session.id, session); - }, 5000); + // Monitor for fork bombs (every 5 seconds) when the platform supports a + // non-intrusive process probe. + if (this.shouldMonitorSessionProcesses()) { + session.processMonitor = setInterval(() => { + this.checkProcessLimit(session); + // Re-evaluate status even when there is no new output, so sessions can + // transition out of "busy" after quiet periods. + this.refreshSessionStatus(session.id, session); + }, 5000); + } else { + session.processMonitor = null; + } } catch (error) { + logDesktopLaunch('session-manager.session.create.failed', { + traceId: String(config?.launchTraceId || '').trim() || null, + source: String(config?.launchSource || '').trim() || null, + socketId: String(config?.launchSocketId || '').trim() || null, + debugSource: String(config?.debugSource || '').trim() || null, + workspaceId: this.workspace?.id || null, + sessionId, + sessionType: config?.type || null, + worktreeId: config?.worktreeId || null, + error: error.message + }); logger.error('Failed to create session', { sessionId, error: error.message @@ -1145,7 +1296,12 @@ class SessionManager extends EventEmitter { worktreeId: session.worktreeId, delay: `${delay}ms` }); - this.updateGitBranch(session.worktreeId, this.getSessionCwd(session), true); + this.updateGitBranch( + session.worktreeId, + this.getSessionCwd(session), + true, + this.getGitBranchUpdateOptions({ branchOnly: true }) + ); }, delay); } @@ -1885,11 +2041,44 @@ class SessionManager extends EventEmitter { return this.isSameOrSubpath(a, b) || this.isSameOrSubpath(b, a); } - async updateGitBranch(worktreeId, worktreePath, skipCache = false) { + getSessionsForWorktreeBranchUpdate(worktreeId, worktreePath) { + const sessionsToUpdate = new Set(); + + const claudeId = `${worktreeId}-claude`; + const codexId = `${worktreeId}-codex`; + const serverId = `${worktreeId}-server`; + if (this.sessions.has(claudeId)) sessionsToUpdate.add(claudeId); + if (this.sessions.has(codexId)) sessionsToUpdate.add(codexId); + if (this.sessions.has(serverId)) sessionsToUpdate.add(serverId); + + const normalizedWorktreePath = this.normalizeCwdPath(worktreePath); + if (sessionsToUpdate.size === 0) { + for (const [sessionId, session] of this.sessions) { + if (session.worktreeId === worktreeId && session.config && + this.pathsOverlap(session.config.cwd, normalizedWorktreePath)) { + sessionsToUpdate.add(sessionId); + } + } + } + + if (sessionsToUpdate.size === 0) { + for (const [sessionId, session] of this.sessions) { + if (!session?.config?.cwd) continue; + if (!this.pathsOverlap(session.config.cwd, normalizedWorktreePath)) continue; + if (session.type !== 'claude' && session.type !== 'codex' && session.type !== 'server') continue; + sessionsToUpdate.add(sessionId); + } + } + + return sessionsToUpdate; + } + + async updateGitBranch(worktreeId, worktreePath, skipCache = false, options = {}) { logger.info('🔄 updateGitBranch called', { worktreeId, path: worktreePath, skipCache, + branchOnly: !!options?.branchOnly, timestamp: new Date().toISOString() }); @@ -1900,49 +2089,19 @@ class SessionManager extends EventEmitter { try { const branch = await this.gitHelper.getCurrentBranch(worktreePath, skipCache); - const remoteUrl = await this.gitHelper.getRemoteUrl(worktreePath); - const defaultBranch = await this.gitHelper.getDefaultBranch(worktreePath); - - // Check for existing PR for this branch - const existingPR = await this.gitHelper.checkForExistingPR(remoteUrl, branch); - - // Update claude/codex/server sessions for this worktree - // For mixed-repo workspaces, session IDs have workspace prefix (e.g., "mixed-terminals-work1-claude") - // For traditional workspaces, session IDs are just worktreeId-type (e.g., "work1-claude") - // So we need to search through sessions to find matching ones - const sessionsToUpdate = new Set(); - - // First try direct match (traditional workspaces) - const claudeId = `${worktreeId}-claude`; - const codexId = `${worktreeId}-codex`; - const serverId = `${worktreeId}-server`; - if (this.sessions.has(claudeId)) sessionsToUpdate.add(claudeId); - if (this.sessions.has(codexId)) sessionsToUpdate.add(codexId); - if (this.sessions.has(serverId)) sessionsToUpdate.add(serverId); - - // If no direct match, search by worktreeId AND path (mixed-repo workspaces) - // Important: Must match both worktreeId AND path to avoid cross-contamination - const normalizedWorktreePath = this.normalizeCwdPath(worktreePath); - if (sessionsToUpdate.size === 0) { - for (const [sessionId, session] of this.sessions) { - // Check if this session belongs to the same worktree by comparing paths - if (session.worktreeId === worktreeId && session.config && - this.pathsOverlap(session.config.cwd, normalizedWorktreePath)) { - sessionsToUpdate.add(sessionId); - } - } - } + const sessionsToUpdate = this.getSessionsForWorktreeBranchUpdate(worktreeId, worktreePath); + const matchingSessions = Array.from(sessionsToUpdate) + .map((sessionId) => this.sessions.get(sessionId)) + .filter(Boolean); - // Final fallback: match by path only. - // This handles cases where the worktreeId used for watchers/refresh differs from the - // session's stored worktreeId, but the cwd is authoritative. - if (sessionsToUpdate.size === 0) { - for (const [sessionId, session] of this.sessions) { - if (!session?.config?.cwd) continue; - if (!this.pathsOverlap(session.config.cwd, normalizedWorktreePath)) continue; - if (session.type !== 'claude' && session.type !== 'codex' && session.type !== 'server') continue; - sessionsToUpdate.add(sessionId); - } + let remoteUrl = matchingSessions.find((session) => session?.remoteUrl)?.remoteUrl || null; + let defaultBranch = matchingSessions.find((session) => session?.defaultBranch)?.defaultBranch || null; + let existingPR = matchingSessions.find((session) => session?.existingPR)?.existingPR || null; + + if (!options?.branchOnly) { + remoteUrl = await this.gitHelper.getRemoteUrl(worktreePath); + defaultBranch = await this.gitHelper.getDefaultBranch(worktreePath); + existingPR = await this.gitHelper.checkForExistingPR(remoteUrl, branch); } sessionsToUpdate.forEach(sessionId => { @@ -2364,7 +2523,14 @@ class SessionManager extends EventEmitter { if (process.platform === 'win32') { const { execFile } = require('child_process'); const psCmd = `(Get-CimInstance Win32_Process -Filter "ParentProcessId=${pid}").Count`; - execFile('powershell.exe', ['-NoProfile', '-Command', psCmd], { timeout: 2000 }, (err, stdout) => { + execFile( + 'powershell.exe', + ['-NoProfile', '-Command', psCmd], + { + timeout: 2000, + windowsHide: true + }, + (err, stdout) => { if (err) return; const processCount = parseInt(String(stdout || '').trim(), 10); if (!Number.isFinite(processCount)) return; diff --git a/server/setupActionService.js b/server/setupActionService.js new file mode 100644 index 00000000..a781c997 --- /dev/null +++ b/server/setupActionService.js @@ -0,0 +1,542 @@ +const crypto = require('crypto'); +const util = require('util'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const { spawn, execFile } = require('child_process'); + +const execFileAsync = util.promisify(execFile); + +const setupActionRuns = new Map(); +const latestRunByActionId = new Map(); +const MAX_OUTPUT_LINES = 180; +const GH_LOGIN_CODE_PATTERN = /\b([A-Z0-9]{4}-[A-Z0-9]{4})\b/i; +const GH_LOGIN_URL_PATTERN = /https:\/\/github\.com\/login\/device(?:\S*)?/i; +const GH_LOGIN_HINT_PATTERN = /one[-\s]?time code|login\/device|authenticate in your web browser|copied to your clipboard|open this url/i; + +function stripAnsi(value) { + return String(value || '').replace(/\u001b\[[0-9;]*m/g, ''); +} + +function getGhLoginDebugLogPath() { + const customDataDir = String(process.env.ORCHESTRATOR_DATA_DIR || '').trim(); + if (customDataDir) { + return path.join(customDataDir, 'logs', 'gh-login-debug.log'); + } + return path.join(os.tmpdir(), 'orchestrator-gh-login-debug.log'); +} + +function appendGhLoginDebugLog(event, payload = {}) { + const line = JSON.stringify({ + at: new Date().toISOString(), + event: String(event || '').trim() || 'event', + ...(payload && typeof payload === 'object' ? payload : {}) + }); + const logPath = getGhLoginDebugLogPath(); + try { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync(logPath, `${line}\n`, 'utf8'); + } catch { + // Best-effort debug logging; never block setup flow. + } +} + +function uniqueStrings(values = []) { + const seen = new Set(); + const out = []; + values.forEach((value) => { + const item = String(value || '').trim(); + if (!item || seen.has(item)) return; + seen.add(item); + out.push(item); + }); + return out; +} + +async function checkExecutable(command, args = ['--version']) { + const commandStr = String(command || '').trim(); + if (!commandStr) return { ok: false, error: 'Missing command' }; + + const runOptions = { + windowsHide: true, + timeout: 3000, + maxBuffer: 1024 * 1024 + }; + + try { + await execFileAsync(commandStr, Array.isArray(args) ? args : [], runOptions); + return { ok: true }; + } catch (error) { + const isWindowsScript = process.platform === 'win32' && /\.(cmd|bat)$/i.test(commandStr); + if (isWindowsScript && (error?.code === 'EINVAL' || error?.code === 'ENOENT')) { + try { + await execFileAsync('cmd.exe', ['/d', '/c', commandStr, ...(Array.isArray(args) ? args : [])], runOptions); + return { ok: true }; + } catch (fallbackError) { + return { + ok: false, + error: String(fallbackError?.message || fallbackError || 'Command check failed') + }; + } + } + return { + ok: false, + error: String(error?.message || error || 'Command check failed') + }; + } +} + +function getGitCommandCandidates(platform = process.platform) { + if (platform !== 'win32') { + return ['git']; + } + + return uniqueStrings([ + 'git', + 'git.exe', + path.join(process.env.ProgramFiles || '', 'Git', 'cmd', 'git.exe'), + path.join(process.env.ProgramFiles || '', 'Git', 'bin', 'git.exe'), + path.join(process.env['ProgramFiles(x86)'] || '', 'Git', 'cmd', 'git.exe'), + path.join(process.env['ProgramFiles(x86)'] || '', 'Git', 'bin', 'git.exe'), + path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Git', 'cmd', 'git.exe') + ]); +} + +async function resolveGitCommand(platform = process.platform) { + const candidates = getGitCommandCandidates(platform); + for (const command of candidates) { + const check = await checkExecutable(command, ['--version']); + if (check.ok) return command; + } + return ''; +} + +async function runGitCommand(command, args = []) { + try { + const result = await execFileAsync(command, Array.isArray(args) ? args : [], { + windowsHide: true, + timeout: 9000, + maxBuffer: 1024 * 1024 + }); + return String(result?.stdout || result?.stderr || ''); + } catch (error) { + const stderr = String(error?.stderr || '').trim(); + const stdout = String(error?.stdout || '').trim(); + const message = stderr || stdout || String(error?.message || error || 'Git command failed'); + const err = new Error(message); + err.code = String(error?.code || 'git_command_failed'); + throw err; + } +} + +function firstNonEmptyLine(text) { + return String(text || '') + .replace(/\r/g, '') + .split('\n') + .map((line) => line.trim()) + .find(Boolean) || ''; +} + +function getSetupActions(platform = process.platform) { + if (platform !== 'win32') { + return []; + } + + return [ + { + id: 'install-git', + title: 'Git Integration', + description: 'Required for repository and worktree access.', + command: 'winget install --id Git.Git --exact --source winget --accept-source-agreements --accept-package-agreements', + docsUrl: 'https://git-scm.com/download/win', + required: true, + runSupported: true + }, + { + id: 'configure-git-identity', + title: 'Git Identity', + description: 'Set your name and email for accurate commits.', + command: 'git config --global user.name "Your Name"\ngit config --global user.email "you@example.com"', + docsUrl: 'https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup', + required: false, + optional: true, + runSupported: false + }, + { + id: 'install-node', + title: 'Node.js LTS', + description: 'Required core dependency for running agents.', + command: 'winget install --id OpenJS.NodeJS.LTS --exact --source winget --accept-source-agreements --accept-package-agreements', + docsUrl: 'https://nodejs.org/en/download', + required: false, + runSupported: true + }, + { + id: 'install-gh', + title: 'GitHub CLI', + description: 'Optional. Install now, then continue to GitHub login in the next step.', + command: 'winget install --id GitHub.cli --exact --source winget --accept-source-agreements --accept-package-agreements', + docsUrl: 'https://cli.github.com/', + required: false, + optional: true, + runSupported: true + }, + { + id: 'gh-login', + title: 'GitHub Authentication', + description: 'Optional after GitHub CLI install. Sign in to enable PR and repo actions.', + command: [ + "$ErrorActionPreference = 'Stop'", + '$env:NO_COLOR = "1"', + '$env:GH_PAGER = ""', + '$gh = ""', + '$cmd = Get-Command gh -ErrorAction SilentlyContinue', + 'if ($cmd -and $cmd.Source) { $gh = $cmd.Source }', + 'if (-not $gh) {', + ' $candidates = @(', + ' "$env:ProgramFiles\\GitHub CLI\\gh.exe",', + ' "$env:ProgramFiles(x86)\\GitHub CLI\\gh.exe",', + ' "$env:LOCALAPPDATA\\Programs\\GitHub CLI\\gh.exe"', + ' )', + ' foreach ($candidate in $candidates) {', + ' if (Test-Path $candidate) { $gh = $candidate; break }', + ' }', + '}', + 'if (-not $gh) { throw "GitHub CLI executable not found. Install GitHub CLI first." }', + '$prevErrorAction = $ErrorActionPreference', + '$ErrorActionPreference = "Continue"', + '& $gh auth status --hostname github.com *> $null', + '$authStatusExitCode = $LASTEXITCODE', + '$ErrorActionPreference = $prevErrorAction', + 'if ($authStatusExitCode -eq 0) { Write-Output "GitHub CLI is already authenticated."; exit 0 }', + 'Write-Output "Starting GitHub CLI web login..."', + 'Write-Output "Expect a one-time code and https://github.com/login/device below."', + '& $gh auth login --hostname github.com --git-protocol https --web --skip-ssh-key', + 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }' + ].join('\n'), + docsUrl: 'https://cli.github.com/manual/gh_auth_login', + required: false, + optional: true, + runSupported: true + }, + { + id: 'install-claude', + title: 'Claude Code CLI', + description: 'Primary AI agent powered by Anthropic.', + command: 'winget install --id Anthropic.ClaudeCode --exact --source winget --accept-source-agreements --accept-package-agreements', + docsUrl: 'https://docs.claude.com/en/docs/claude-code/setup', + required: false, + optional: true, + runSupported: true + }, + { + id: 'install-codex', + title: 'Codex CLI', + description: 'Alternative AI agent tool for development.', + command: [ + "$ErrorActionPreference = 'Stop'", + '$npm = ""', + '$cmd = Get-Command npm -ErrorAction SilentlyContinue', + 'if ($cmd -and $cmd.Source) { $npm = $cmd.Source }', + 'if (-not $npm) {', + ' $candidates = @(', + ' "$env:ProgramFiles\\nodejs\\npm.cmd",', + ' "$env:ProgramFiles(x86)\\nodejs\\npm.cmd",', + ' "$env:LOCALAPPDATA\\Programs\\nodejs\\npm.cmd",', + ' "$env:APPDATA\\npm\\npm.cmd"', + ' )', + ' foreach ($candidate in $candidates) {', + ' if (Test-Path $candidate) { $npm = $candidate; break }', + ' }', + '}', + 'if (-not $npm) { throw "npm was not found. Install Node.js LTS first, then run this step again." }', + '& $npm install -g @openai/codex', + 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }' + ].join('\n'), + docsUrl: 'https://developers.openai.com/codex/cli', + required: false, + runSupported: true + } + ]; +} + +function getSetupActionById(actionId, platform = process.platform) { + const id = String(actionId || '').trim(); + if (!id) return null; + return getSetupActions(platform).find((action) => action.id === id) || null; +} + +function createRunId(actionId) { + return `setup-${String(actionId || 'action')}-${Date.now()}-${crypto.randomBytes(3).toString('hex')}`; +} + +function getRunSummary(run) { + if (!run) return null; + return { + runId: run.runId, + actionId: run.actionId, + title: run.title, + command: run.command, + status: run.status, + startedAt: run.startedAt, + endedAt: run.endedAt || null, + pid: Number.isFinite(run.pid) ? run.pid : null, + exitCode: Number.isInteger(run.exitCode) ? run.exitCode : null, + error: run.error || null, + output: Array.isArray(run.output) ? run.output.slice(-25) : [], + ghDeviceCode: run.ghDeviceCode || null, + ghDeviceUrl: run.ghDeviceUrl || null, + ghHasDeviceHint: !!run.ghHasDeviceHint, + ghDebugLogPath: run.ghDebugLogPath || null, + updatedAt: run.updatedAt || run.startedAt + }; +} + +function appendRunOutput(run, chunk, stream = 'stdout') { + if (!run) return; + const text = String(chunk || ''); + if (!text) return; + const lines = text + .replace(/\r/g, '') + .split('\n') + .map((line) => stripAnsi(line).trimEnd()) + .filter(Boolean); + if (!lines.length) return; + const at = new Date().toISOString(); + lines.forEach((line) => { + const cleanLine = String(line || '').slice(0, 1600); + run.output.push({ at, stream, line: cleanLine }); + if (run.actionId === 'gh-login') { + const codeMatch = cleanLine.match(GH_LOGIN_CODE_PATTERN); + const urlMatch = cleanLine.match(GH_LOGIN_URL_PATTERN); + if (codeMatch?.[1]) run.ghDeviceCode = String(codeMatch[1]).toUpperCase(); + if (urlMatch?.[0]) run.ghDeviceUrl = String(urlMatch[0]).trim(); + if (GH_LOGIN_HINT_PATTERN.test(cleanLine)) run.ghHasDeviceHint = true; + appendGhLoginDebugLog('output', { + runId: run.runId, + stream, + line: cleanLine + }); + } + }); + if (run.output.length > MAX_OUTPUT_LINES) { + run.output.splice(0, run.output.length - MAX_OUTPUT_LINES); + } + run.updatedAt = at; +} + +function launchPowerShellCommand(action) { + const runId = createRunId(action.id); + const run = { + runId, + actionId: action.id, + title: action.title, + command: action.command, + status: 'running', + startedAt: new Date().toISOString(), + endedAt: null, + pid: null, + exitCode: null, + error: null, + output: [], + ghDeviceCode: null, + ghDeviceUrl: null, + ghHasDeviceHint: false, + ghDebugLogPath: action.id === 'gh-login' ? getGhLoginDebugLogPath() : null, + updatedAt: null + }; + run.updatedAt = run.startedAt; + if (action.id === 'gh-login') { + appendGhLoginDebugLog('run_started', { + runId: run.runId, + title: run.title + }); + } + + setupActionRuns.set(runId, run); + latestRunByActionId.set(action.id, runId); + + try { + const child = spawn( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', String(action.command || '')], + { + detached: false, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'] + } + ); + run.pid = Number.isFinite(child?.pid) ? child.pid : null; + run.updatedAt = new Date().toISOString(); + + child.stdout.on('data', (chunk) => appendRunOutput(run, chunk, 'stdout')); + child.stderr.on('data', (chunk) => appendRunOutput(run, chunk, 'stderr')); + + child.on('error', (error) => { + run.status = 'failed'; + run.error = String(error?.message || error || 'Failed to launch setup action'); + run.endedAt = new Date().toISOString(); + run.updatedAt = run.endedAt; + if (action.id === 'gh-login') { + appendGhLoginDebugLog('run_error', { + runId: run.runId, + error: run.error + }); + } + }); + + child.on('close', (code) => { + run.exitCode = Number.isInteger(code) ? code : null; + run.status = code === 0 ? 'success' : 'failed'; + if (code !== 0 && !run.error) { + run.error = `Setup action exited with code ${String(code)}`; + } + run.endedAt = new Date().toISOString(); + run.updatedAt = run.endedAt; + if (action.id === 'gh-login') { + appendGhLoginDebugLog('run_closed', { + runId: run.runId, + status: run.status, + exitCode: run.exitCode, + error: run.error || null, + parsedCode: run.ghDeviceCode || null, + parsedUrl: run.ghDeviceUrl || null, + sawHint: !!run.ghHasDeviceHint + }); + } + }); + } catch (error) { + run.status = 'failed'; + run.error = String(error?.message || error || 'Failed to launch setup action'); + run.endedAt = new Date().toISOString(); + run.updatedAt = run.endedAt; + if (action.id === 'gh-login') { + appendGhLoginDebugLog('run_launch_failed', { + runId: run.runId, + error: run.error + }); + } + } + + return run; +} + +function getSetupActionRun(runId) { + const key = String(runId || '').trim(); + if (!key) return null; + return getRunSummary(setupActionRuns.get(key)); +} + +function getLatestSetupActionRun(actionId) { + const id = String(actionId || '').trim(); + if (!id) return null; + const runId = latestRunByActionId.get(id); + if (!runId) return null; + return getRunSummary(setupActionRuns.get(runId)); +} + +function runSetupAction(actionId, platform = process.platform) { + if (platform !== 'win32') { + const err = new Error('Setup actions are currently implemented for Windows only.'); + err.code = 'unsupported_platform'; + throw err; + } + + const action = getSetupActionById(actionId, platform); + if (!action) { + const err = new Error(`Unknown setup action: ${String(actionId || '')}`); + err.code = 'unknown_action'; + throw err; + } + + if (!action.runSupported || !action.command) { + const err = new Error(`Action "${action.id}" cannot be launched from the app.`); + err.code = 'not_runnable'; + throw err; + } + + const latestRun = getLatestSetupActionRun(action.id); + if (latestRun && latestRun.status === 'running') { + return { + id: action.id, + title: action.title, + started: true, + alreadyRunning: true, + run: latestRun, + message: `${action.title} is already running.` + }; + } + + const run = launchPowerShellCommand(action); + const runSummary = getRunSummary(run); + + return { + id: action.id, + title: action.title, + started: true, + alreadyRunning: false, + run: runSummary, + message: `Started ${action.title}. Progress updates are now tracked in onboarding.` + }; +} + +async function configureGitIdentity({ name, email } = {}, platform = process.platform) { + if (platform !== 'win32') { + const err = new Error('Git identity setup is currently implemented for Windows only.'); + err.code = 'unsupported_platform'; + throw err; + } + + const normalizedName = String(name || '').trim(); + const normalizedEmail = String(email || '').trim(); + if (!normalizedName || !normalizedEmail) { + const err = new Error('Both name and email are required.'); + err.code = 'invalid_input'; + throw err; + } + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) { + const err = new Error('Enter a valid email address.'); + err.code = 'invalid_input'; + throw err; + } + + const gitCommand = await resolveGitCommand(platform); + if (!gitCommand) { + const err = new Error('Git is not installed or not available on PATH.'); + err.code = 'missing_git'; + throw err; + } + + await runGitCommand(gitCommand, ['config', '--global', 'user.name', normalizedName]); + await runGitCommand(gitCommand, ['config', '--global', 'user.email', normalizedEmail]); + + const savedName = firstNonEmptyLine(await runGitCommand(gitCommand, ['config', '--global', '--get', 'user.name'])); + const savedEmail = firstNonEmptyLine(await runGitCommand(gitCommand, ['config', '--global', '--get', 'user.email'])); + + if (!savedName || !savedEmail) { + const err = new Error('Git identity was saved, but verification failed.'); + err.code = 'verify_failed'; + throw err; + } + + return { + id: 'configure-git-identity', + title: 'Configure Git identity', + ok: true, + gitCommand, + name: savedName, + email: savedEmail, + summary: `${savedName} <${savedEmail}>`, + message: 'Git identity saved successfully.' + }; +} + +module.exports = { + getSetupActions, + getSetupActionById, + runSetupAction, + getSetupActionRun, + getLatestSetupActionRun, + configureGitIdentity +}; diff --git a/server/userSettingsService.js b/server/userSettingsService.js index 1bf9c88f..d6a22707 100644 --- a/server/userSettingsService.js +++ b/server/userSettingsService.js @@ -153,6 +153,12 @@ class UserSettingsService { skin: 'blue', // 0..100 (applied as 0..1 multiplier for skin tint in CSS) skinIntensity: 100, + onboarding: { + desktopDependencySetup: { + completed: false, + completedAt: null + } + }, visibility: { processBanner: false, header: { @@ -690,6 +696,21 @@ class UserSettingsService { }; } + if (ui.onboarding && typeof ui.onboarding === 'object') { + const defaultsOnboarding = (uiDefaults.onboarding && typeof uiDefaults.onboarding === 'object') + ? uiDefaults.onboarding + : {}; + const nextOnboarding = ui.onboarding || {}; + merged.global.ui.onboarding = { + ...defaultsOnboarding, + ...nextOnboarding, + desktopDependencySetup: { + ...(defaultsOnboarding.desktopDependencySetup || {}), + ...(nextOnboarding.desktopDependencySetup || {}) + } + }; + } + if (ui.visibility && typeof ui.visibility === 'object') { const defaultsVisibility = uiDefaults.visibility || {}; const nextVisibility = ui.visibility || {}; diff --git a/server/workspaceManager.js b/server/workspaceManager.js index 06c2aaf3..86de1346 100644 --- a/server/workspaceManager.js +++ b/server/workspaceManager.js @@ -707,20 +707,31 @@ class WorkspaceManager { // 3. First available workspace // 4. None (show dashboard) - // Don't auto-select workspace - let user choose from dashboard - // if (this.config.activeWorkspace && this.workspaces.has(this.config.activeWorkspace)) { - // this.activeWorkspace = this.workspaces.get(this.config.activeWorkspace); - // logger.info(`Set active workspace from config: ${this.activeWorkspace.name}`); - // return; - // } - - // Don't auto-select first workspace - show dashboard instead - // if (this.workspaces.size > 0) { - // const firstWorkspace = Array.from(this.workspaces.values())[0]; - // this.activeWorkspace = firstWorkspace; - // logger.info(`Set active workspace (first available): ${this.activeWorkspace.name}`); - // return; - // } + const rememberLastWorkspace = this.config?.ui?.rememberLastWorkspace !== false; + const configuredWorkspaceId = String(this.config?.activeWorkspace || '').trim(); + + if (rememberLastWorkspace && configuredWorkspaceId && this.workspaces.has(configuredWorkspaceId)) { + this.activeWorkspace = this.workspaces.get(configuredWorkspaceId); + logger.info(`Set active workspace from config: ${this.activeWorkspace.name}`); + return; + } + + if (rememberLastWorkspace && configuredWorkspaceId && !this.workspaces.has(configuredWorkspaceId)) { + logger.warn(`Configured active workspace missing: ${configuredWorkspaceId}`); + } + + if (rememberLastWorkspace && this.workspaces.size > 0) { + const sorted = Array.from(this.workspaces.values()) + .sort((a, b) => { + const aTime = a.lastAccess ? new Date(a.lastAccess).getTime() : 0; + const bTime = b.lastAccess ? new Date(b.lastAccess).getTime() : 0; + return bTime - aTime; + }); + const firstWorkspace = sorted[0]; + this.activeWorkspace = firstWorkspace; + logger.info(`Set active workspace by fallback: ${this.activeWorkspace.name}`); + return; + } logger.info('No active workspace set (no workspaces available)'); } @@ -1004,7 +1015,7 @@ class WorkspaceManager { enabled: true, count: pairs, namingPattern: 'work{n}', - autoCreate: false + autoCreate: true }, terminals: { pairs diff --git a/server/worktreeHelper.js b/server/worktreeHelper.js index d57967c2..89f92e6d 100644 --- a/server/worktreeHelper.js +++ b/server/worktreeHelper.js @@ -268,20 +268,24 @@ class WorktreeHelper { executeGitCommand(command, cwd) { return new Promise((resolve, reject) => { const [cmd, ...args] = command.split(' '); - const process = spawn(cmd, args, { cwd, stdio: 'pipe' }); + const child = spawn(cmd, args, { + cwd, + stdio: 'pipe', + windowsHide: globalThis.process.platform === 'win32' + }); let stdout = ''; let stderr = ''; - process.stdout.on('data', (data) => { + child.stdout.on('data', (data) => { stdout += data.toString(); }); - process.stderr.on('data', (data) => { + child.stderr.on('data', (data) => { stderr += data.toString(); }); - process.on('close', (code) => { + child.on('close', (code) => { if (code === 0) { resolve(stdout.trim()); } else { @@ -289,7 +293,7 @@ class WorktreeHelper { } }); - process.on('error', (error) => { + child.on('error', (error) => { reject(new Error(`Failed to execute git command: ${command}\nError: ${error.message}`)); }); }); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f4ad68bb..f66beca8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -17,6 +17,7 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-shell = "2" +tauri-plugin-single-instance = "2" tauri-plugin-updater = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 076c0231..6841fc0b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -10,6 +10,14 @@ use uuid::Uuid; use tauri_plugin_updater::UpdaterExt; use url::Url; +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; + +#[cfg(target_os = "windows")] +const CREATE_NO_WINDOW: u32 = 0x08000000; +#[cfg(target_os = "windows")] +const DETACHED_PROCESS: u32 = 0x00000008; + mod terminal; mod file_watcher; use terminal::{TerminalManager, TerminalOutput}; @@ -521,6 +529,16 @@ fn main() { std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "0"); tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { + if let Some(window) = app + .get_webview_window("main") + .or_else(|| app.webview_windows().values().next().cloned()) + { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } + })) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .setup(|app| { @@ -602,6 +620,8 @@ fn main() { cmd.arg(entry); cmd.current_dir(&data_dir); cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::null()); + cmd.stderr(Stdio::null()); cmd.env("ORCHESTRATOR_HOST", "127.0.0.1"); cmd.env("ORCHESTRATOR_PORT", port.to_string()); cmd.env("AUTH_TOKEN", token.clone()); @@ -612,6 +632,11 @@ fn main() { cmd.env("AUTO_START_DIFF_VIEWER", "false"); } + #[cfg(target_os = "windows")] + { + cmd.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS); + } + match cmd.spawn() { Err(err) => { let details = format!( diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e6e4e6c7..e920716f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -42,5 +42,10 @@ "resources/backend/client/*", "resources/backend/node_modules" ] + }, + "plugins": { + "updater": { + "pubkey": "" + } } } diff --git a/tests/unit/claudeVersionChecker.test.js b/tests/unit/claudeVersionChecker.test.js new file mode 100644 index 00000000..93af67b6 --- /dev/null +++ b/tests/unit/claudeVersionChecker.test.js @@ -0,0 +1,56 @@ +jest.mock('child_process', () => ({ + spawn: jest.fn() +})); + +const { spawn } = require('child_process'); +const { ClaudeVersionChecker } = require('../../server/claudeVersionChecker'); + +describe('ClaudeVersionChecker', () => { + beforeEach(() => { + ClaudeVersionChecker.resetCache(); + spawn.mockReset(); + }); + + test('caches successful version checks', async () => { + let stdoutHandler = null; + let stderrHandler = null; + let closeHandler = null; + let errorHandler = null; + + spawn.mockImplementation(() => ({ + stdout: { + on: (event, handler) => { + if (event === 'data') stdoutHandler = handler; + } + }, + stderr: { + on: (event, handler) => { + if (event === 'data') stderrHandler = handler; + } + }, + on: (event, handler) => { + if (event === 'close') closeHandler = handler; + if (event === 'error') errorHandler = handler; + } + })); + + const firstPromise = ClaudeVersionChecker.checkVersion(); + expect(stdoutHandler).toBeInstanceOf(Function); + expect(stderrHandler).toBeInstanceOf(Function); + expect(closeHandler).toBeInstanceOf(Function); + expect(errorHandler).toBeInstanceOf(Function); + + stdoutHandler(Buffer.from('claude 1.2.3')); + closeHandler(0); + + const first = await firstPromise; + const second = await ClaudeVersionChecker.checkVersion(); + + expect(spawn).toHaveBeenCalledTimes(1); + expect(first).toEqual(expect.objectContaining({ + version: '1.2.3', + isCompatible: true + })); + expect(second).toEqual(first); + }); +}); diff --git a/tests/unit/githubRepoService.listRepos.test.js b/tests/unit/githubRepoService.listRepos.test.js index b5d7a78f..7100755c 100644 --- a/tests/unit/githubRepoService.listRepos.test.js +++ b/tests/unit/githubRepoService.listRepos.test.js @@ -26,6 +26,12 @@ describe('GitHubRepoService listRepos', () => { { nameWithOwner: 'foo/bar', name: 'bar', owner: 'foo', isPrivate: false, isFork: true, visibility: 'public' }, { nameWithOwner: 'acme/secret', name: 'secret', owner: 'acme', isPrivate: true, isFork: false, visibility: 'private' } ]); + expect(execFile).toHaveBeenCalledWith( + 'gh', + ['repo', 'list', '--limit', '50', '--json', 'nameWithOwner,name,owner,isPrivate,visibility,isFork'], + expect.objectContaining({ windowsHide: true }), + expect.any(Function) + ); }); it('caches list results (no force)', async () => { diff --git a/tests/unit/sessionManager.branchUpdate.test.js b/tests/unit/sessionManager.branchUpdate.test.js index ff45c990..971bf87c 100644 --- a/tests/unit/sessionManager.branchUpdate.test.js +++ b/tests/unit/sessionManager.branchUpdate.test.js @@ -28,8 +28,18 @@ describe('SessionManager branch updates', () => { sessionManager.startBranchRefresh(); jest.advanceTimersByTime(11); - expect(updateSpy).toHaveBeenCalledWith('work2', toPlatformPath('/tmp/repo-a/work2'), true); - expect(updateSpy).toHaveBeenCalledWith('work1', toPlatformPath('/tmp/repo-a/work1'), true); + expect(updateSpy).toHaveBeenCalledWith( + 'work2', + toPlatformPath('/tmp/repo-a/work2'), + true, + expect.objectContaining({ branchOnly: true }) + ); + expect(updateSpy).toHaveBeenCalledWith( + 'work1', + toPlatformPath('/tmp/repo-a/work1'), + true, + expect.objectContaining({ branchOnly: true }) + ); }); test('startBranchRefresh also refreshes loose sessions (not in worktrees)', () => { @@ -53,7 +63,44 @@ describe('SessionManager branch updates', () => { sessionManager.startBranchRefresh(); jest.advanceTimersByTime(11); - expect(updateSpy).toHaveBeenCalledWith('adhoc', toPlatformPath('/tmp/repo-z/adhoc'), true); + expect(updateSpy).toHaveBeenCalledWith( + 'adhoc', + toPlatformPath('/tmp/repo-z/adhoc'), + true, + expect.objectContaining({ branchOnly: true }) + ); + }); + + test('startBranchRefresh is disabled on Windows', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }); + + try { + const io = { emit: jest.fn() }; + const sessionManager = new SessionManager(io, null); + sessionManager.branchRefreshMs = 10; + sessionManager.worktrees = [ + { id: 'work1', path: 'C:\\repo\\work1' } + ]; + + const updateSpy = jest + .spyOn(sessionManager, 'updateGitBranch') + .mockImplementation(() => Promise.resolve()); + + sessionManager.startBranchRefresh(); + jest.advanceTimersByTime(25); + + expect(updateSpy).not.toHaveBeenCalled(); + expect(sessionManager.branchRefreshInterval).toBeNull(); + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }); + } }); test('updateGitBranch falls back to matching by cwd path', async () => { @@ -161,4 +208,40 @@ describe('SessionManager branch updates', () => { expect.objectContaining({ sessionId: 'repo-a-work2-codex', branch: 'feature/test' }) ); }); + + test('updateGitBranch can skip expensive metadata lookups', async () => { + const io = { emit: jest.fn() }; + const sessionManager = new SessionManager(io, null); + const gitHelper = { + getCurrentBranch: jest.fn(async () => 'feature/test'), + getRemoteUrl: jest.fn(async () => 'git@github.com:owner/repo.git'), + getDefaultBranch: jest.fn(async () => 'main'), + checkForExistingPR: jest.fn(async () => 'https://github.com/owner/repo/pull/123') + }; + sessionManager.setGitHelper(gitHelper); + + sessionManager.sessions.set('work2-claude', { + id: 'work2-claude', + type: 'claude', + worktreeId: 'work2', + config: { cwd: '/tmp/repo-a/work2/' }, + branch: 'unknown', + remoteUrl: 'https://github.com/owner/repo', + defaultBranch: 'main', + existingPR: 'https://github.com/owner/repo/pull/99' + }); + + await sessionManager.updateGitBranch('work2', '/tmp/repo-a/work2', true, { branchOnly: true }); + + expect(gitHelper.getCurrentBranch).toHaveBeenCalledTimes(1); + expect(gitHelper.getRemoteUrl).not.toHaveBeenCalled(); + expect(gitHelper.getDefaultBranch).not.toHaveBeenCalled(); + expect(gitHelper.checkForExistingPR).not.toHaveBeenCalled(); + expect(sessionManager.sessions.get('work2-claude')).toEqual(expect.objectContaining({ + branch: 'feature/test', + remoteUrl: 'https://github.com/owner/repo', + defaultBranch: 'main', + existingPR: 'https://github.com/owner/repo/pull/99' + })); + }); }); diff --git a/tests/unit/sessionManager.initializeSessions.test.js b/tests/unit/sessionManager.initializeSessions.test.js new file mode 100644 index 00000000..35066caa --- /dev/null +++ b/tests/unit/sessionManager.initializeSessions.test.js @@ -0,0 +1,219 @@ +jest.mock('../../server/claudeVersionChecker', () => ({ + ClaudeVersionChecker: { + checkVersion: jest.fn().mockResolvedValue({ + version: '1.0.24', + isCompatible: true + }) + } +})); + +const fs = require('fs'); +const { SessionManager } = require('../../server/sessionManager'); + +describe('SessionManager.initializeSessions', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('updates each single-repo worktree branch only once during initialization', async () => { + jest.spyOn(fs.promises, 'access').mockResolvedValue(undefined); + + const io = { emit: jest.fn() }; + const agentManager = { getAllAgents: () => [] }; + const sm = new SessionManager(io, agentManager); + + sm.workspace = { + name: 'test', + worktrees: { enabled: false, autoCreate: false }, + terminals: { pairs: 2 } + }; + sm.worktrees = [ + { id: 'work1', path: '/tmp/test/work1' }, + { id: 'work2', path: '/tmp/test/work2' } + ]; + sm.sessions = new Map(); + sm.gitHelper = {}; + sm.cleanupAllSessions = jest.fn(); + sm.stopBranchRefresh = jest.fn(); + sm.cleanupGitWatchers = jest.fn(); + sm.startBranchRefresh = jest.fn(); + sm.setupGitWatchers = jest.fn(); + sm.createSession = jest.fn((sessionId, config) => { + sm.sessions.set(sessionId, { + id: sessionId, + type: config.type, + worktreeId: config.worktreeId, + config + }); + }); + sm.updateGitBranch = jest.fn().mockResolvedValue(undefined); + + await sm.initializeSessions({ preserveExisting: true }); + + expect(sm.createSession).toHaveBeenCalledTimes(4); + expect(sm.updateGitBranch).toHaveBeenCalledTimes(2); + expect(sm.updateGitBranch).toHaveBeenNthCalledWith( + 1, + 'work1', + '/tmp/test/work1', + false, + expect.objectContaining({ branchOnly: false }) + ); + expect(sm.updateGitBranch).toHaveBeenNthCalledWith( + 2, + 'work2', + '/tmp/test/work2', + false, + expect.objectContaining({ branchOnly: false }) + ); + expect(io.emit).toHaveBeenCalledWith('sessions', expect.any(Object)); + }); + + test('uses hidden PowerShell startup args for Windows sessions', async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }); + + jest.spyOn(fs.promises, 'access').mockResolvedValue(undefined); + + const io = { emit: jest.fn() }; + const agentManager = { getAllAgents: () => [] }; + const sm = new SessionManager(io, agentManager); + + sm.workspace = { + name: 'test', + worktrees: { enabled: false, autoCreate: false }, + terminals: { pairs: 1 } + }; + sm.worktrees = [ + { id: 'work1', path: 'C:\\test\\work1' } + ]; + sm.sessions = new Map(); + sm.gitHelper = {}; + sm.cleanupAllSessions = jest.fn(); + sm.stopBranchRefresh = jest.fn(); + sm.cleanupGitWatchers = jest.fn(); + sm.startBranchRefresh = jest.fn(); + sm.setupGitWatchers = jest.fn(); + sm.createSession = jest.fn((sessionId, config) => { + sm.sessions.set(sessionId, { + id: sessionId, + type: config.type, + worktreeId: config.worktreeId, + config + }); + }); + sm.updateGitBranch = jest.fn().mockResolvedValue(undefined); + + try { + await sm.initializeSessions({ preserveExisting: true }); + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }); + } + + const claudeConfig = sm.createSession.mock.calls.find(([sessionId]) => sessionId === 'work1-claude')?.[1]; + const serverConfig = sm.createSession.mock.calls.find(([sessionId]) => sessionId === 'work1-server')?.[1]; + + expect(claudeConfig).toBeTruthy(); + expect(serverConfig).toBeTruthy(); + expect(claudeConfig.command).toBe('powershell.exe'); + expect(serverConfig.command).toBe('powershell.exe'); + expect(claudeConfig.args.slice(0, 4)).toEqual(['-WindowStyle', 'Hidden', '-NoLogo', '-NoExit']); + expect(serverConfig.args.slice(0, 4)).toEqual(['-WindowStyle', 'Hidden', '-NoLogo', '-NoExit']); + expect(claudeConfig.args).toContain('-Command'); + expect(serverConfig.args).toContain('-Command'); + expect(sm.updateGitBranch).toHaveBeenCalledWith( + 'work1', + 'C:\\test\\work1', + false, + expect.objectContaining({ branchOnly: true }) + ); + }); + + test('skips the external process monitor on Windows', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }); + + try { + const io = { emit: jest.fn() }; + const agentManager = { getAllAgents: () => [] }; + const sm = new SessionManager(io, agentManager); + expect(sm.shouldMonitorSessionProcesses()).toBe(false); + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }); + } + }); + + test('can defer workspace initialization while returning restored sessions immediately', async () => { + const io = { emit: jest.fn() }; + const agentManager = { getAllAgents: () => [] }; + const sm = new SessionManager(io, agentManager); + + const previousSessions = new Map([ + ['work0-claude', { id: 'work0-claude' }] + ]); + const restoredSessions = new Map([ + ['work1-claude', { id: 'work1-claude' }] + ]); + + sm.workspace = { + id: 'previous', + name: 'previous', + repository: { path: '/tmp/previous' }, + worktrees: { namingPattern: 'work{n}' }, + terminals: { pairs: 1 } + }; + sm.sessions = previousSessions; + sm.workspaceSessionMaps.set('next', restoredSessions); + + let resolveInitialization; + sm.initializeSessions = jest.fn(() => new Promise((resolve) => { + resolveInitialization = resolve; + })); + sm.getSessionStates = jest.fn(() => ({ + 'work1-claude': { id: 'work1-claude' } + })); + sm.getUndeliveredOutputAndMarkDelivered = jest.fn(() => ({ + 'work1-claude': 'buffered output' + })); + + const result = await sm.switchWorkspacePreservingSessions({ + id: 'next', + name: 'next', + repository: { path: '/tmp/next' }, + worktrees: { namingPattern: 'work{n}' }, + terminals: { pairs: 1 } + }, { + deferInitialize: true, + reason: 'workspace-switch' + }); + + expect(sm.workspaceSessionMaps.get('previous')).toBe(previousSessions); + expect(sm.sessions).toBe(restoredSessions); + expect(result.sessions).toEqual({ + 'work1-claude': { id: 'work1-claude' } + }); + expect(result.backlog).toEqual({ + 'work1-claude': 'buffered output' + }); + expect(result.initializePromise).toBeInstanceOf(Promise); + expect(sm.initializeSessions).toHaveBeenCalledWith(expect.objectContaining({ + preserveExisting: true, + reason: 'workspace-switch' + })); + + resolveInitialization(); + await result.initializePromise; + }); +}); diff --git a/tests/unit/userSettingsDefaults.test.js b/tests/unit/userSettingsDefaults.test.js index e2b29f2b..5e64ca90 100644 --- a/tests/unit/userSettingsDefaults.test.js +++ b/tests/unit/userSettingsDefaults.test.js @@ -46,6 +46,14 @@ describe('UserSettingsService defaults', () => { expect(defaults.global.ui.workflow.notifications.mode).toBeTruthy(); }); + test('includes desktop onboarding defaults', () => { + const defaults = UserSettingsService.prototype.getDefaultSettings.call({}); + const onboarding = defaults?.global?.ui?.onboarding?.desktopDependencySetup; + expect(onboarding).toBeTruthy(); + expect(onboarding.completed).toBe(false); + expect(onboarding.completedAt).toBeNull(); + }); + test('includes ui.worktrees auto-create defaults', () => { const defaults = UserSettingsService.prototype.getDefaultSettings.call({}); expect(defaults?.global?.ui?.worktrees).toBeTruthy(); @@ -186,6 +194,10 @@ describe('UserSettingsService defaults', () => { // Does not drop workflow defaults when only mode is provided. expect(merged.global.ui.workflow.focus).toBeTruthy(); expect(merged.global.ui.workflow.notifications).toBeTruthy(); + // Keeps onboarding defaults while allowing desktop completion to persist. + expect(merged.global.ui.onboarding).toBeTruthy(); + expect(merged.global.ui.onboarding.desktopDependencySetup.completed).toBe(false); + expect(merged.global.ui.onboarding.desktopDependencySetup.completedAt).toBeNull(); // Keeps ui.skin when provided. expect(merged.global.ui.skin).toBe('blue'); // Keeps simpleMode defaults while allowing partial override. @@ -214,4 +226,23 @@ describe('UserSettingsService defaults', () => { expect(merged.global.pager.doneCheck.enabled).toBe(true); expect(typeof merged.global.pager.doneCheck.token).toBe('string'); }); + + test('mergeSettings deep-merges desktop onboarding state', () => { + const defaults = UserSettingsService.prototype.getDefaultSettings.call({}); + const merged = UserSettingsService.prototype.mergeSettings.call({}, defaults, { + global: { + ui: { + onboarding: { + desktopDependencySetup: { + completed: true, + completedAt: '2026-03-06T00:00:00.000Z' + } + } + } + } + }); + + expect(merged.global.ui.onboarding.desktopDependencySetup.completed).toBe(true); + expect(merged.global.ui.onboarding.desktopDependencySetup.completedAt).toBe('2026-03-06T00:00:00.000Z'); + }); }); diff --git a/windows-onboarding-clean-main-d696ca7.zip b/windows-onboarding-clean-main-d696ca7.zip new file mode 100644 index 00000000..8dca4313 Binary files /dev/null and b/windows-onboarding-clean-main-d696ca7.zip differ diff --git a/windows-onboarding-clean-main.zip b/windows-onboarding-clean-main.zip new file mode 100644 index 00000000..7bba7a47 Binary files /dev/null and b/windows-onboarding-clean-main.zip differ