mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Brings in https://github.com/christianhaberl/Codeman/pull/4 (three commits, authorship preserved) and adapts it across the 211 commits master gained since the branch was cut: - App Settings control re-authored for the set-* surface (PR #278): a set-row in Layout -> Tabs, replacing the old settings-item markup the branch targeted. i18n description synced. - Lineage arcs (PR #291, post-branch) are SKIPPED in sidebar layout: computeLineagePath()'s U-bridge geometry hangs from the horizontal strip's bottom edge and has no meaning against a vertical list. The lineage strip-scroll listener now also redraws subagent/ultracode connectors while the sidebar scrolls vertically. - The desktop home tab rail (post-branch) defers to the sidebar: both dock the session list flush left, and the rail would render z-ordered under it. - Active-row reveal unified into _scrollActiveTabIntoView() (#257 landed on master after the branch): sidebar mode branches to scrollIntoView block:'nearest', and _fullRenderSessionTabs() restores scrollTop alongside the #257 scrollLeft restore so ambient rebuilds cannot yank a mid-scroll sidebar back to the top. - Mobile active-tab hoisting the branch guarded against no longer exists on master (removed by #257); kept master's order-stable render. Verified: typecheck, lint, format:check, check:frontend-syntax, check:public-assets, PostCSS parse of both merged stylesheets, the 26 new jsdom tests, the structural guard suites, and the headless-Chromium harness (scripts/verify-session-sidebar.mts) green across all seven layout states at 1600/1000/393px against current master. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1304 lines
53 KiB
JavaScript
1304 lines
53 KiB
JavaScript
/**
|
|
* @fileoverview Subagent floating window management mixed into CodemanApp.prototype.
|
|
*
|
|
* Extends CodemanApp with methods for managing floating terminal windows that display
|
|
* Claude Code background agent (subagent) output. Each subagent window has its own
|
|
* xterm.js terminal instance, drag/resize handles, minimize/close controls, and
|
|
* connection lines drawn to the parent session tab.
|
|
*
|
|
* Key functionality:
|
|
* - Tab badge dropdown showing minimized agents per session
|
|
* - Minimize/restore/permanently-close lifecycle for subagent windows
|
|
* - Cross-browser state persistence (localStorage + server-backed PUT /api/subagent-window-states)
|
|
* - Window state saved on every minimize/restore/close action
|
|
*
|
|
* @mixin Extends CodemanApp.prototype via Object.assign
|
|
* @dependency app.js (CodemanApp class, this.subagents, this.subagentWindows, this.minimizedSubagents)
|
|
* @dependency constants.js (escapeHtml)
|
|
* @loadorder 15 of 15 — loaded last, after api-client.js
|
|
*/
|
|
|
|
// Codeman — Subagent window management for CodemanApp
|
|
// Loaded after app.js (needs CodemanApp class defined)
|
|
|
|
Object.assign(CodemanApp.prototype, {
|
|
// Render subagent badge with dropdown for minimized agents on a tab
|
|
renderSubagentTabBadge(sessionId, minimizedAgents) {
|
|
if (!minimizedAgents || minimizedAgents.size === 0) return '';
|
|
|
|
const agentItems = [];
|
|
for (const agentId of minimizedAgents) {
|
|
const agent = this.subagents.get(agentId);
|
|
const displayName = agent?.description || agentId.substring(0, 12);
|
|
const truncatedName = displayName.length > 25 ? displayName.substring(0, 25) + '…' : displayName;
|
|
const statusClass = agent?.status || 'idle';
|
|
agentItems.push(`
|
|
<div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreMinimizedSubagent(${escapeHtml(JSON.stringify(agentId))}, ${escapeHtml(JSON.stringify(sessionId))})" title="Click to restore">
|
|
<span class="subagent-dropdown-status ${statusClass}"></span>
|
|
<span class="subagent-dropdown-name">${escapeHtml(truncatedName)}</span>
|
|
<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.permanentlyCloseMinimizedSubagent(${escapeHtml(JSON.stringify(agentId))}, ${escapeHtml(JSON.stringify(sessionId))})" title="Dismiss">×</span>
|
|
</div>
|
|
`);
|
|
}
|
|
|
|
// Compact badge - shows on hover, click to pin open
|
|
const count = minimizedAgents.size;
|
|
const label = count === 1 ? 'AGENT' : `AGENTS (${count})`;
|
|
return `
|
|
<span class="tab-subagent-badge"
|
|
onmouseenter="app.showSubagentDropdown(this)"
|
|
onmouseleave="app.scheduleHideSubagentDropdown(this)"
|
|
onclick="event.stopPropagation(); app.pinSubagentDropdown(this);">
|
|
<span class="subagent-label">${label}</span>
|
|
<div class="subagent-dropdown" onmouseenter="app.cancelHideSubagentDropdown()" onmouseleave="app.scheduleHideSubagentDropdown(this.parentElement)">
|
|
${agentItems.join('')}
|
|
</div>
|
|
</span>
|
|
`;
|
|
},
|
|
|
|
// Restore a minimized subagent window
|
|
restoreMinimizedSubagent(agentId, sessionId) {
|
|
// Remove from minimized set
|
|
const minimizedAgents = this.minimizedSubagents.get(sessionId);
|
|
if (minimizedAgents) {
|
|
minimizedAgents.delete(agentId);
|
|
if (minimizedAgents.size === 0) {
|
|
this.minimizedSubagents.delete(sessionId);
|
|
}
|
|
}
|
|
|
|
// Restore the window
|
|
this.restoreSubagentWindow(agentId);
|
|
|
|
// Re-render tabs to update badge
|
|
this.renderSessionTabs();
|
|
|
|
// Persist the state change
|
|
this.saveSubagentWindowStates();
|
|
},
|
|
|
|
// Permanently close a minimized subagent (remove from DOM and minimized set)
|
|
permanentlyCloseMinimizedSubagent(agentId, sessionId) {
|
|
// Remove from minimized set
|
|
const minimizedAgents = this.minimizedSubagents.get(sessionId);
|
|
if (minimizedAgents) {
|
|
minimizedAgents.delete(agentId);
|
|
if (minimizedAgents.size === 0) {
|
|
this.minimizedSubagents.delete(sessionId);
|
|
}
|
|
}
|
|
|
|
// Force close the window (removes from DOM)
|
|
this.forceCloseSubagentWindow(agentId);
|
|
|
|
// Re-render tabs to update badge
|
|
this.renderSessionTabs();
|
|
this.updateConnectionLines();
|
|
|
|
// Persist the state change
|
|
this.saveSubagentWindowStates();
|
|
},
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Subagent Window State Persistence
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Save subagent window states (minimized/open) to server for cross-browser persistence.
|
|
* Called when a window is minimized, restored, or auto-minimized on completion.
|
|
*/
|
|
async saveSubagentWindowStates() {
|
|
// Build state object: which agents are minimized per session
|
|
const minimizedState = {};
|
|
for (const [sessionId, agentIds] of this.minimizedSubagents) {
|
|
minimizedState[sessionId] = Array.from(agentIds);
|
|
}
|
|
|
|
// Also track which windows are open (not minimized)
|
|
const openWindows = [];
|
|
for (const [agentId, windowData] of this.subagentWindows) {
|
|
if (!windowData.minimized) {
|
|
openWindows.push({
|
|
agentId,
|
|
position: windowData.position || null,
|
|
});
|
|
}
|
|
}
|
|
|
|
const windowStates = { minimized: minimizedState, open: openWindows };
|
|
|
|
// Save to localStorage for quick restore
|
|
localStorage.setItem('codeman-subagent-window-states', JSON.stringify(windowStates));
|
|
|
|
// Save to server for cross-browser persistence
|
|
try {
|
|
await this._apiPut('/api/subagent-window-states', windowStates);
|
|
} catch (err) {
|
|
console.error('Failed to save subagent window states to server:', err);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Restore subagent window states after loading subagents.
|
|
* Opens windows that were open before, keeps minimized ones minimized.
|
|
* IMPORTANT: Parent associations are loaded from subagentParentMap BEFORE this is called.
|
|
*/
|
|
async restoreSubagentWindowStates() {
|
|
const states = await this.loadSubagentWindowStates();
|
|
|
|
// Restore minimized state using the PERSISTENT parent map
|
|
// Skip old agents from previous runs to avoid confusion
|
|
const cutoffTime = Date.now() - 10 * 60 * 1000; // 10 minutes
|
|
for (const [savedSessionId, agentIds] of Object.entries(states.minimized || {})) {
|
|
if (Array.isArray(agentIds) && agentIds.length > 0) {
|
|
for (const agentId of agentIds) {
|
|
const agent = this.subagents.get(agentId);
|
|
if (!agent) continue; // Agent no longer exists
|
|
|
|
// Skip completed or old agents
|
|
const agentStartTime = agent.startedAt || 0;
|
|
if (agent.status === 'completed' || agentStartTime < cutoffTime) continue;
|
|
|
|
// Use the PERSISTENT parent map (THE source of truth)
|
|
// Fall back to saved sessionId only if it exists in current sessions
|
|
const parentFromMap = this.subagentParentMap.get(agentId);
|
|
const correctSessionId = parentFromMap || (this.sessions.has(savedSessionId) ? savedSessionId : null);
|
|
|
|
if (correctSessionId) {
|
|
// Ensure the parent map has this association
|
|
if (!parentFromMap && this.sessions.has(savedSessionId)) {
|
|
this.setAgentParentSessionId(agentId, savedSessionId);
|
|
}
|
|
|
|
if (!this.minimizedSubagents.has(correctSessionId)) {
|
|
this.minimizedSubagents.set(correctSessionId, new Set());
|
|
}
|
|
this.minimizedSubagents.get(correctSessionId).add(agentId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Restore open windows (for recent, non-completed agents only)
|
|
const now = Date.now();
|
|
const maxAgeMs = 10 * 60 * 1000; // 10 minutes - don't restore windows for old agents
|
|
for (const { agentId, position } of states.open || []) {
|
|
const agent = this.subagents.get(agentId);
|
|
// Only restore window if agent exists, is recent, and is still active/idle
|
|
const agentAge = agent?.startedAt ? now - agent.startedAt : Infinity;
|
|
if (agent && agent.status !== 'completed' && agentAge < maxAgeMs) {
|
|
this.openSubagentWindow(agentId);
|
|
// Restore position if saved (with viewport bounds check)
|
|
if (position) {
|
|
const windowData = this.subagentWindows.get(agentId);
|
|
if (windowData && windowData.element) {
|
|
// Parse position values and clamp to viewport
|
|
let left = parseInt(position.left, 10) || 50;
|
|
let top = parseInt(position.top, 10) || WINDOW_INITIAL_TOP_PX;
|
|
const viewportWidth = window.innerWidth;
|
|
const viewportHeight = window.innerHeight;
|
|
const windowWidth = 420;
|
|
const windowHeight = 350;
|
|
left = Math.max(10, Math.min(left, viewportWidth - windowWidth - 10));
|
|
top = Math.max(10, Math.min(top, viewportHeight - windowHeight - 10));
|
|
windowData.element.style.left = `${left}px`;
|
|
windowData.element.style.top = `${top}px`;
|
|
windowData.position = { left: `${left}px`, top: `${top}px` };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
this.renderSessionTabs(); // Update tab badges
|
|
this.saveSubagentWindowStates(); // Persist corrected mappings
|
|
|
|
// Update connection lines after all windows are restored (use rAF to ensure DOM is ready)
|
|
requestAnimationFrame(() => {
|
|
this.updateConnectionLines();
|
|
});
|
|
},
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Subagent Connection Lines
|
|
// ═══════════════════════════════════════════════════════════════
|
|
//
|
|
// Connection lines are drawn from agent windows to their parent TABs.
|
|
// The parent TAB is determined by the PERSISTENT subagentParentMap.
|
|
// This map stores agentId -> sessionId, where sessionId is the tab's data-id.
|
|
|
|
updateConnectionLines() {
|
|
// Coalesce multiple calls — uses background scheduler priority to avoid
|
|
// competing with terminal writes for main thread time
|
|
if (!this._connectionLinesScheduled) {
|
|
this._connectionLinesScheduled = true;
|
|
scheduleBackground(() => {
|
|
this._connectionLinesScheduled = false;
|
|
this._updateConnectionLinesImmediate();
|
|
});
|
|
}
|
|
},
|
|
|
|
_updateConnectionLinesImmediate() {
|
|
const svg = document.getElementById('connectionLines');
|
|
if (!svg) return;
|
|
|
|
// Check if Ralph wizard modal is open
|
|
const wizardModal = document.getElementById('ralphWizardModal');
|
|
const wizardOpen = wizardModal?.classList.contains('active');
|
|
const wizardContent = wizardOpen ? wizardModal.querySelector('.modal-content') : null;
|
|
|
|
// Collect visible regular subagent windows
|
|
const visibleSubagentWindows = [];
|
|
for (const [agentId, windowInfo] of this.subagentWindows) {
|
|
if (windowInfo.minimized || windowInfo.hidden) continue;
|
|
const win = windowInfo.element;
|
|
if (!win) continue;
|
|
visibleSubagentWindows.push({ agentId, windowInfo, win });
|
|
}
|
|
|
|
// Get plan subagent windows as array for distribution
|
|
const planSubagentArray = Array.from(this.planSubagents.entries())
|
|
.filter(([, data]) => data.element)
|
|
.map(([id, data]) => ({ id, ...data }));
|
|
|
|
// === PHASE 1: Batch all layout reads (getBoundingClientRect) ===
|
|
// Reading layout properties forces the browser to calculate layout.
|
|
// By batching all reads before any writes, we avoid repeated forced reflows.
|
|
const rects = new Map();
|
|
|
|
// Read all subagent window rects
|
|
for (const { agentId, win } of visibleSubagentWindows) {
|
|
rects.set('sub:' + agentId, win.getBoundingClientRect());
|
|
}
|
|
|
|
// Read all plan subagent rects
|
|
for (const planAgent of planSubagentArray) {
|
|
rects.set('plan:' + planAgent.id, planAgent.element.getBoundingClientRect());
|
|
}
|
|
|
|
// Read wizard rect (if open)
|
|
let wizardRect = null;
|
|
if (wizardOpen && wizardContent) {
|
|
wizardRect = wizardContent.getBoundingClientRect();
|
|
}
|
|
|
|
// Read tab rects for normal mode (only tabs that are actually needed)
|
|
if (!wizardOpen) {
|
|
for (const { agentId } of visibleSubagentWindows) {
|
|
const parentSessionId = this.subagentParentMap.get(agentId);
|
|
if (!parentSessionId || rects.has('tab:' + parentSessionId)) continue;
|
|
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
|
|
if (tab) rects.set('tab:' + parentSessionId, tab.getBoundingClientRect());
|
|
}
|
|
}
|
|
|
|
// Read plan window rects for wizard-to-plan lines
|
|
if (wizardOpen && wizardContent && this.planSubagents.size > 0 && !this.planAgentsMinimized) {
|
|
for (const [agentId, windowData] of this.planSubagents) {
|
|
if (!windowData.element) continue;
|
|
const key = 'planwin:' + agentId;
|
|
if (!rects.has(key)) rects.set(key, windowData.element.getBoundingClientRect());
|
|
}
|
|
}
|
|
|
|
// === PHASE 2: DOM writes using cached rects (no more layout reads) ===
|
|
svg.innerHTML = '';
|
|
|
|
for (const { agentId } of visibleSubagentWindows) {
|
|
const winRect = rects.get('sub:' + agentId);
|
|
|
|
// If wizard is open with plan subagents, connect regular subagents to plan subagent windows
|
|
if (wizardOpen && wizardContent && planSubagentArray.length > 0) {
|
|
// Find the nearest plan subagent window to connect to
|
|
let nearestPlanAgent = null;
|
|
let nearestDistance = Infinity;
|
|
|
|
for (const planAgent of planSubagentArray) {
|
|
const planRect = rects.get('plan:' + planAgent.id);
|
|
const planCenterX = planRect.left + planRect.width / 2;
|
|
const planCenterY = planRect.top + planRect.height / 2;
|
|
const winCenterX = winRect.left + winRect.width / 2;
|
|
const winCenterY = winRect.top + winRect.height / 2;
|
|
const distance = Math.hypot(planCenterX - winCenterX, planCenterY - winCenterY);
|
|
|
|
if (distance < nearestDistance) {
|
|
nearestDistance = distance;
|
|
nearestPlanAgent = planAgent;
|
|
}
|
|
}
|
|
|
|
if (nearestPlanAgent) {
|
|
const planRect = rects.get('plan:' + nearestPlanAgent.id);
|
|
|
|
// Draw line from plan subagent window to regular subagent window
|
|
let x1, y1, x2, y2;
|
|
const planCenterX = planRect.left + planRect.width / 2;
|
|
const winCenterX = winRect.left + winRect.width / 2;
|
|
|
|
if (winCenterX < planCenterX) {
|
|
x1 = planRect.left;
|
|
y1 = planRect.top + planRect.height / 2;
|
|
x2 = winRect.right;
|
|
y2 = winRect.top + winRect.height / 2;
|
|
} else {
|
|
x1 = planRect.right;
|
|
y1 = planRect.top + planRect.height / 2;
|
|
x2 = winRect.left;
|
|
y2 = winRect.top + winRect.height / 2;
|
|
}
|
|
|
|
const midX = (x1 + x2) / 2;
|
|
const path = `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`;
|
|
|
|
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
line.setAttribute('d', path);
|
|
line.setAttribute('class', 'connection-line plan-to-subagent-line');
|
|
line.setAttribute('data-agent-id', agentId);
|
|
line.setAttribute('data-plan-agent-id', nearestPlanAgent.id);
|
|
svg.appendChild(line);
|
|
}
|
|
} else if (wizardOpen && wizardContent) {
|
|
// Wizard open but no plan subagents - connect directly to wizard
|
|
const winCenterX = winRect.left + winRect.width / 2;
|
|
const wizardCenterX = wizardRect.left + wizardRect.width / 2;
|
|
|
|
let x1, y1, x2, y2;
|
|
|
|
if (winCenterX < wizardCenterX) {
|
|
x1 = wizardRect.left;
|
|
y1 = wizardRect.top + wizardRect.height / 2;
|
|
x2 = winRect.right;
|
|
y2 = winRect.top + winRect.height / 2;
|
|
} else {
|
|
x1 = wizardRect.right;
|
|
y1 = wizardRect.top + wizardRect.height / 2;
|
|
x2 = winRect.left;
|
|
y2 = winRect.top + winRect.height / 2;
|
|
}
|
|
|
|
const midX = (x1 + x2) / 2;
|
|
const path = `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`;
|
|
|
|
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
line.setAttribute('d', path);
|
|
line.setAttribute('class', 'connection-line wizard-connection');
|
|
line.setAttribute('data-agent-id', agentId);
|
|
svg.appendChild(line);
|
|
} else {
|
|
// NORMAL MODE: Connect agent window to its parent TAB
|
|
// Use the PERSISTENT subagentParentMap as the ONLY source of truth
|
|
const parentSessionId = this.subagentParentMap.get(agentId);
|
|
|
|
if (!parentSessionId) {
|
|
// No parent known yet - skip this agent
|
|
continue;
|
|
}
|
|
|
|
const tabRect = rects.get('tab:' + parentSessionId);
|
|
if (!tabRect) {
|
|
// Tab not in DOM (might be scrolled out or session closed)
|
|
continue;
|
|
}
|
|
|
|
// Draw a curved line from the tab to the window. Header strip: tab
|
|
// bottom-center → window top-center (vertical). Sidebar: tab right-edge →
|
|
// window left-edge (horizontal), otherwise the curve loops backwards
|
|
// underneath the sidebar. _tabAnchor/_tabConnectorPath live in app.js.
|
|
const anchor = this._tabAnchor(tabRect);
|
|
const path = this._tabConnectorPath(anchor, winRect);
|
|
|
|
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
line.setAttribute('d', path);
|
|
line.setAttribute('class', 'connection-line');
|
|
line.setAttribute('data-agent-id', agentId);
|
|
line.setAttribute('data-parent-tab', parentSessionId);
|
|
svg.appendChild(line);
|
|
}
|
|
}
|
|
|
|
// Draw lines from wizard to plan subagent windows (Opus agents during plan generation)
|
|
// Skip if agents are minimized to tab
|
|
if (wizardOpen && wizardContent && this.planSubagents.size > 0 && !this.planAgentsMinimized) {
|
|
for (const [agentId] of this.planSubagents) {
|
|
const winRect = rects.get('planwin:' + agentId);
|
|
if (!winRect) continue;
|
|
|
|
// Determine which side of wizard the window is on
|
|
const winCenterX = winRect.left + winRect.width / 2;
|
|
const wizardCenterX = wizardRect.left + wizardRect.width / 2;
|
|
|
|
let x1, y1, x2, y2;
|
|
|
|
if (winCenterX < wizardCenterX) {
|
|
x1 = wizardRect.left;
|
|
y1 = wizardRect.top + wizardRect.height / 3 + (this.planSubagents.size > 3 ? 0 : 50);
|
|
x2 = winRect.right;
|
|
y2 = winRect.top + winRect.height / 2;
|
|
} else {
|
|
x1 = wizardRect.right;
|
|
y1 = wizardRect.top + wizardRect.height / 3 + (this.planSubagents.size > 3 ? 0 : 50);
|
|
x2 = winRect.left;
|
|
y2 = winRect.top + winRect.height / 2;
|
|
}
|
|
|
|
const midX = (x1 + x2) / 2;
|
|
const path = `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`;
|
|
|
|
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
line.setAttribute('d', path);
|
|
line.setAttribute('class', 'connection-line wizard-connection plan-subagent-line');
|
|
line.setAttribute('data-plan-agent-id', agentId);
|
|
svg.appendChild(line);
|
|
}
|
|
}
|
|
|
|
// Ultracode floating run windows → parent tab (additional layer, ultracode-windows.js).
|
|
// Drawn into the same SVG and same batched read/write pass; the tab-rect cache is shared.
|
|
if (typeof this._appendUltracodeConnectionLines === 'function') {
|
|
this._appendUltracodeConnectionLines(svg, rects);
|
|
}
|
|
// Agent-transcript windows → their run window / tab (ultracode-windows.js).
|
|
if (typeof this._appendUltracodeAgentConnectionLines === 'function') {
|
|
this._appendUltracodeAgentConnectionLines(svg, rects);
|
|
}
|
|
// Tab → tab it spawned (session-lineage.js). Same shared read/write pass and the
|
|
// same tab-rect cache; desktop-only and gated on its own setting inside.
|
|
if (typeof this._appendLineageConnectionLines === 'function') {
|
|
this._appendLineageConnectionLines(svg, rects);
|
|
}
|
|
|
|
// Every path above was just created from scratch, so any line entrance in
|
|
// flight has to be re-attached here (resumed via a negative animation-delay).
|
|
this._applyLineEntrances?.(svg);
|
|
},
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Lazy Terminal Lifecycle
|
|
// ═══════════════════════════════════════════════════════════════
|
|
//
|
|
// Teammate terminal windows use xterm.js Terminal instances that consume
|
|
// ~75KB of DOM memory each. With 50 agents minimized, that's ~3.75MB of
|
|
// invisible terminals. To avoid this, we dispose the Terminal when a
|
|
// window is minimized and lazily re-create it when restored.
|
|
//
|
|
// Flow:
|
|
// minimize → _disposeTeammateTerminalForMinimize() → sets _lazyTerminal flag
|
|
// restore → _restoreTeammateTerminalFromLazy() → re-creates Terminal
|
|
// create (hidden/minimized) → skip initTeammateTerminal, set _lazyTerminal
|
|
//
|
|
// The tmux pane buffer is re-fetched from the API on restore. Regular
|
|
// (non-teammate) subagent windows use activity HTML and are unaffected
|
|
// by this optimization.
|
|
|
|
/**
|
|
* Dispose a teammate terminal when its window is minimized.
|
|
* Saves pane metadata so the terminal can be re-created on restore.
|
|
* No-op if the window has no teammate terminal.
|
|
*/
|
|
_disposeTeammateTerminalForMinimize(agentId) {
|
|
const termData = this.teammateTerminals.get(agentId);
|
|
if (!termData) return; // Not a teammate terminal window
|
|
|
|
const windowData = this.subagentWindows.get(agentId);
|
|
|
|
// Save pane metadata needed to re-create the terminal on restore
|
|
if (windowData) {
|
|
windowData._lazyTerminal = true;
|
|
windowData._lazyPaneTarget = termData.paneTarget;
|
|
windowData._lazySessionId = termData.sessionId;
|
|
}
|
|
|
|
// Dispose the resize observer
|
|
if (termData.resizeObserver) {
|
|
termData.resizeObserver.disconnect();
|
|
}
|
|
|
|
// Dispose the xterm.js Terminal instance (frees DOM nodes and internal buffers)
|
|
if (termData.terminal) {
|
|
try {
|
|
termData.terminal.dispose();
|
|
} catch {}
|
|
}
|
|
|
|
// Remove from teammateTerminals map so renderSubagentWindowContent won't skip this window
|
|
// (the activity HTML can serve as a lightweight placeholder while minimized)
|
|
this.teammateTerminals.delete(agentId);
|
|
},
|
|
|
|
/**
|
|
* Re-create a teammate terminal when its window is restored from minimized state.
|
|
* Fetches the current pane buffer from the API (tmux is the source of truth).
|
|
* No-op if the window doesn't have the _lazyTerminal flag.
|
|
*/
|
|
_restoreTeammateTerminalFromLazy(agentId) {
|
|
const windowData = this.subagentWindows.get(agentId);
|
|
if (!windowData || !windowData._lazyTerminal) return;
|
|
|
|
const paneTarget = windowData._lazyPaneTarget;
|
|
const sessionId = windowData._lazySessionId;
|
|
|
|
// Clear lazy state
|
|
windowData._lazyTerminal = false;
|
|
windowData._lazyPaneTarget = null;
|
|
windowData._lazySessionId = null;
|
|
|
|
if (!paneTarget || !sessionId) return;
|
|
|
|
// Re-create the terminal using the same initTeammateTerminal flow
|
|
const paneInfo = { paneTarget, sessionId };
|
|
this.initTeammateTerminal(agentId, paneInfo, windowData.element);
|
|
},
|
|
|
|
// ═══════════════════════════════════════════════════════════════
|
|
// Subagent Floating Windows
|
|
// ═══════════════════════════════════════════════════════════════
|
|
|
|
openSubagentWindow(agentId) {
|
|
// If window already exists, focus it
|
|
if (this.subagentWindows.has(agentId)) {
|
|
const existing = this.subagentWindows.get(agentId);
|
|
const agent = this.subagents.get(agentId);
|
|
const settings = this.loadAppSettingsFromStorage();
|
|
const activeTabOnly = settings.subagentActiveTabOnly ?? true;
|
|
|
|
// If window is hidden (different tab) and activeTabOnly is enabled, switch to parent tab
|
|
if (existing.hidden && agent?.parentSessionId && activeTabOnly) {
|
|
this.selectSession(agent.parentSessionId);
|
|
return;
|
|
}
|
|
|
|
// If not activeTabOnly mode, just show the window
|
|
if (existing.hidden && !activeTabOnly) {
|
|
existing.element.style.display = 'flex';
|
|
existing.hidden = false;
|
|
}
|
|
|
|
existing.element.style.zIndex = ++this.subagentWindowZIndex;
|
|
if (existing.minimized) {
|
|
this.restoreSubagentWindow(agentId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const agent = this.subagents.get(agentId);
|
|
if (!agent) return;
|
|
|
|
// Only open windows for agents that belong to a Codeman-managed session tab.
|
|
// Agents from external Claude sessions (not tracked by Codeman) should not pop up.
|
|
if (agent.sessionId) {
|
|
const hasMatchingTab = Array.from(this.sessions.values()).some((s) => s.claudeSessionId === agent.sessionId);
|
|
if (!hasMatchingTab) return;
|
|
}
|
|
|
|
// Calculate final position - grid layout to avoid overlaps
|
|
const windowCount = this.subagentWindows.size;
|
|
const isMobile = MobileDetection.getDeviceType() === 'mobile';
|
|
const mobileCardHeight = 110;
|
|
const mobileCardGap = 4;
|
|
const windowWidth = isMobile ? window.innerWidth : 420;
|
|
const windowHeight = isMobile ? mobileCardHeight : 350;
|
|
const gap = 20;
|
|
const viewportWidth = window.innerWidth;
|
|
const viewportHeight = window.innerHeight;
|
|
|
|
let finalX = 0;
|
|
let finalY = 0;
|
|
|
|
if (isMobile) {
|
|
// Mobile: stack compact cards. Count visible (non-minimized) windows.
|
|
let visibleCount = 0;
|
|
for (const [, data] of this.subagentWindows) {
|
|
if (!data.minimized && !data.hidden) visibleCount++;
|
|
}
|
|
finalX = 4;
|
|
const keyboardUp = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible;
|
|
if (keyboardUp) {
|
|
// Keyboard visible: stack from bottom above toolbar
|
|
const toolbarHeight = 40;
|
|
const bottomOffset = toolbarHeight + visibleCount * (mobileCardHeight + mobileCardGap);
|
|
finalY = viewportHeight - bottomOffset - mobileCardHeight;
|
|
} else {
|
|
// Keyboard hidden: stack from top below header with spacing
|
|
const headerHeight = document.querySelector('.header')?.offsetHeight || 36;
|
|
const topStart = headerHeight + 8;
|
|
finalY = topStart + visibleCount * (mobileCardHeight + mobileCardGap);
|
|
}
|
|
} else {
|
|
// Check if Ralph wizard modal is open - if so, position windows on the sides
|
|
const wizardModal = document.getElementById('ralphWizardModal');
|
|
const wizardOpen = wizardModal?.classList.contains('active');
|
|
|
|
let startX, startY, maxCols;
|
|
|
|
if (wizardOpen) {
|
|
// Wizard is ~720px wide, centered. Position windows on left/right sides
|
|
const wizardWidth = 720;
|
|
const centerX = viewportWidth / 2;
|
|
const wizardLeft = centerX - wizardWidth / 2;
|
|
const wizardRight = centerX + wizardWidth / 2;
|
|
|
|
// Alternate between left and right sides of the wizard
|
|
const leftSideSpace = wizardLeft - 20;
|
|
const rightSideSpace = viewportWidth - wizardRight - 20;
|
|
|
|
if (windowCount % 2 === 0 && rightSideSpace >= windowWidth) {
|
|
// Even windows go to the right
|
|
startX = wizardRight + 20;
|
|
maxCols = Math.floor(rightSideSpace / (windowWidth + gap)) || 1;
|
|
} else if (leftSideSpace >= windowWidth) {
|
|
// Odd windows go to the left
|
|
startX = Math.max(10, wizardLeft - windowWidth - 20);
|
|
maxCols = 1; // Usually only room for 1 column on left
|
|
} else {
|
|
// Not enough side space, use right side
|
|
startX = wizardRight + 20;
|
|
maxCols = 1;
|
|
}
|
|
startY = 80; // Start higher when wizard is open
|
|
} else {
|
|
// Normal positioning
|
|
startX = 50;
|
|
startY = WINDOW_INITIAL_TOP_PX;
|
|
maxCols = Math.floor((viewportWidth - startX - 50) / (windowWidth + gap)) || 1;
|
|
}
|
|
|
|
const maxRows = Math.floor((viewportHeight - startY - 50) / (windowHeight + gap)) || 1;
|
|
const col = windowCount % maxCols;
|
|
const row = Math.floor(windowCount / maxCols) % maxRows; // Wrap rows to stay in viewport
|
|
finalX = startX + col * (windowWidth + gap);
|
|
finalY = startY + row * (windowHeight + gap);
|
|
|
|
// Ensure window stays within viewport bounds
|
|
finalX = Math.max(10, Math.min(finalX, viewportWidth - windowWidth - 10));
|
|
finalY = Math.max(10, Math.min(finalY, viewportHeight - windowHeight - 10));
|
|
}
|
|
|
|
// Get parent session from PERSISTENT map (THE source of truth for tab connections)
|
|
const parentSessionId = this.subagentParentMap.get(agentId) || agent.parentSessionId;
|
|
let parentSessionName = null;
|
|
|
|
if (parentSessionId) {
|
|
const parentSession = this.sessions.get(parentSessionId);
|
|
if (parentSession) {
|
|
parentSessionName = this.getSessionName(parentSession);
|
|
// Ensure the agent object is also updated for consistency
|
|
if (!agent.parentSessionId) {
|
|
agent.parentSessionId = parentSessionId;
|
|
agent.parentSessionName = parentSessionName;
|
|
this.subagents.set(agentId, agent);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get parent TAB element for spawn animation
|
|
const parentTab = parentSessionId ? document.querySelector(`.session-tab[data-id="${parentSessionId}"]`) : null;
|
|
|
|
// Create window element
|
|
const win = document.createElement('div');
|
|
win.className = 'subagent-window';
|
|
win.id = `subagent-window-${agentId}`;
|
|
win.style.zIndex = ++this.subagentWindowZIndex;
|
|
|
|
// Build parent header if we have parent info
|
|
const parentHeader =
|
|
parentSessionId && parentSessionName
|
|
? `<div class="subagent-window-parent" data-parent-session="${parentSessionId}">
|
|
<span class="parent-label">from</span>
|
|
<span class="parent-name" onclick="app.selectSession(${escapeHtml(JSON.stringify(parentSessionId))})">${escapeHtml(parentSessionName)}</span>
|
|
</div>`
|
|
: '';
|
|
|
|
const teammateInfo = this.getTeammateInfo(agent);
|
|
const windowTitle = teammateInfo ? teammateInfo.name : agent.description || agentId.substring(0, 7);
|
|
const maxTitleLen = isMobile ? 30 : 50;
|
|
const truncatedTitle =
|
|
windowTitle.length > maxTitleLen ? windowTitle.substring(0, maxTitleLen) + '...' : windowTitle;
|
|
const modelBadge = agent.modelShort
|
|
? `<span class="subagent-model-badge ${agent.modelShort}">${agent.modelShort}</span>`
|
|
: '';
|
|
win.innerHTML = `
|
|
<div class="subagent-window-header">
|
|
<div class="subagent-window-title" title="${escapeHtml(agent.description || agentId)}">
|
|
<span class="icon">🤖</span>
|
|
<span class="id">${escapeHtml(truncatedTitle)}</span>
|
|
${modelBadge}
|
|
<span class="status ${agent.status}">${agent.status}</span>
|
|
</div>
|
|
<div class="subagent-window-actions">
|
|
<button onclick="app.closeSubagentWindow(${escapeHtml(JSON.stringify(agentId))})" title="Minimize to tab">─</button>
|
|
</div>
|
|
</div>
|
|
${parentHeader}
|
|
<div class="subagent-window-body" id="subagent-window-body-${agentId}">
|
|
<div class="subagent-empty">Loading activity...</div>
|
|
</div>
|
|
`;
|
|
|
|
// Only the `fly` entrance style parks the window on its parent tab; every
|
|
// CSS-animated style (entrance-animations.js) needs it to start at its
|
|
// resting position, or the animation would play at the wrong place.
|
|
const flyFromTab = !!parentTab && !isMobile && this.windowEntranceFliesFromTab?.() !== false;
|
|
|
|
// If we have a parent tab, start window at tab position for spawn animation
|
|
if (isMobile) {
|
|
// Mobile: position using top (keyboard-aware positioning calculated above)
|
|
win.style.top = `${finalY}px`;
|
|
win.style.bottom = 'auto';
|
|
} else if (flyFromTab) {
|
|
// Spawn at the tab: below it in header layout, to its RIGHT in sidebar
|
|
// layout — spawning at tabRect.left there would land on top of the sidebar.
|
|
const anchor = this._tabAnchor(parentTab.getBoundingClientRect());
|
|
win.style.left = `${anchor.spawnLeft}px`;
|
|
win.style.top = `${anchor.spawnTop}px`;
|
|
win.style.transform = 'scale(0.3)';
|
|
win.style.opacity = '0';
|
|
win.classList.add('spawning');
|
|
} else {
|
|
// No parent tab, just position normally (desktop/tablet)
|
|
win.style.left = `${finalX}px`;
|
|
win.style.top = `${finalY}px`;
|
|
}
|
|
|
|
document.body.appendChild(win);
|
|
|
|
// Make draggable (returns listener refs for cleanup)
|
|
const dragListeners = this.makeWindowDraggable(win, win.querySelector('.subagent-window-header'));
|
|
|
|
// Check if this window should be visible based on settings
|
|
// Use the PERSISTENT parent map for accurate tab-based visibility
|
|
const settings = this.loadAppSettingsFromStorage();
|
|
const activeTabOnly = settings.subagentActiveTabOnly ?? true;
|
|
let shouldHide = false;
|
|
if (activeTabOnly) {
|
|
const storedParent = this.subagentParentMap.get(agentId);
|
|
const hasKnownParent = storedParent || agent.parentSessionId;
|
|
const parentId = storedParent || agent.parentSessionId;
|
|
const isForActiveSession = !hasKnownParent || parentId === this.activeSessionId;
|
|
shouldHide = !isForActiveSession;
|
|
}
|
|
|
|
// Store reference (including drag listeners for cleanup)
|
|
this.subagentWindows.set(agentId, {
|
|
element: win,
|
|
minimized: false,
|
|
hidden: shouldHide,
|
|
dragListeners, // Store for cleanup to prevent memory leaks
|
|
});
|
|
|
|
// Hide window if not for active session
|
|
if (shouldHide) {
|
|
win.style.display = 'none';
|
|
}
|
|
|
|
// Render content — check if this teammate has a tmux pane
|
|
const paneInfo = teammateInfo ? this.teammatePanesByName.get(teammateInfo.name) : null;
|
|
if (paneInfo) {
|
|
if (shouldHide) {
|
|
// Window starts hidden — defer terminal creation until visible (lazy init).
|
|
// Saves ~75KB of DOM memory per hidden teammate terminal window.
|
|
const windowEntry = this.subagentWindows.get(agentId);
|
|
if (windowEntry) {
|
|
windowEntry._lazyTerminal = true;
|
|
windowEntry._lazyPaneTarget = paneInfo.paneTarget;
|
|
windowEntry._lazySessionId = paneInfo.sessionId;
|
|
}
|
|
} else {
|
|
this.initTeammateTerminal(agentId, paneInfo, win);
|
|
}
|
|
} else {
|
|
this.renderSubagentWindowContent(agentId);
|
|
}
|
|
|
|
// Focus on click
|
|
win.addEventListener('mousedown', () => {
|
|
win.style.zIndex = ++this.subagentWindowZIndex;
|
|
});
|
|
|
|
// Update connection lines when window is resized
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
this.updateConnectionLines();
|
|
});
|
|
resizeObserver.observe(win);
|
|
|
|
// Store observer for cleanup
|
|
this.subagentWindows.get(agentId).resizeObserver = resizeObserver;
|
|
|
|
// Animate to final position if spawning from tab (desktop only)
|
|
if (flyFromTab) {
|
|
requestAnimationFrame(() => {
|
|
win.style.transition = 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)';
|
|
win.style.left = `${finalX}px`;
|
|
win.style.top = `${finalY}px`;
|
|
win.style.transform = 'scale(1)';
|
|
win.style.opacity = '1';
|
|
|
|
// Clean up after animation
|
|
setTimeout(() => {
|
|
win.style.transition = '';
|
|
win.classList.remove('spawning');
|
|
// The line only becomes meaningful once the window has landed, so its
|
|
// draw-in starts here rather than at spawn.
|
|
this.markConnectionLineEntering?.(agentId);
|
|
this.updateConnectionLines();
|
|
}, 400);
|
|
});
|
|
} else {
|
|
// CSS-animated entrance styles (and mobile) run in place. The window is
|
|
// already at its resting position, so the connection line can be drawn
|
|
// against a correct rect right away and animate alongside it.
|
|
//
|
|
// Skipped when the window spawns hidden (its agent belongs to a background
|
|
// tab): a display:none element never runs its animation, so `animationend`
|
|
// would never fire and the entrance class plus its inline custom property
|
|
// would stick to the window forever. Nothing is visible to animate anyway,
|
|
// and revealing it later is a tab switch, not a spawn.
|
|
if (!shouldHide) {
|
|
this.applyWindowEntrance?.(win);
|
|
this.markConnectionLineEntering?.(agentId);
|
|
}
|
|
this.updateConnectionLines();
|
|
}
|
|
|
|
// Persist the state change (new window opened)
|
|
this.saveSubagentWindowStates();
|
|
},
|
|
|
|
closeSubagentWindow(agentId) {
|
|
const windowData = this.subagentWindows.get(agentId);
|
|
if (!windowData) return;
|
|
|
|
const agent = this.subagents.get(agentId);
|
|
|
|
// Get parent from PERSISTENT map (THE source of truth)
|
|
// Fall back to agent's parentSessionId, then to active session
|
|
const storedParent = this.subagentParentMap.get(agentId);
|
|
let parentSessionId = storedParent || agent?.parentSessionId || this.activeSessionId;
|
|
|
|
// If we don't have a stored parent yet, store it now
|
|
if (!storedParent && parentSessionId && this.sessions.has(parentSessionId)) {
|
|
this.setAgentParentSessionId(agentId, parentSessionId);
|
|
}
|
|
|
|
// Dispose teammate terminal on minimize to free DOM/memory (~75KB per instance).
|
|
// The terminal will be lazily re-created on restore via initTeammateTerminal().
|
|
this._disposeTeammateTerminalForMinimize(agentId);
|
|
|
|
// Always minimize to tab
|
|
windowData.element.style.display = 'none';
|
|
windowData.minimized = true;
|
|
|
|
// Track minimized agent for the session (use the TAB's session ID)
|
|
if (parentSessionId) {
|
|
if (!this.minimizedSubagents.has(parentSessionId)) {
|
|
this.minimizedSubagents.set(parentSessionId, new Set());
|
|
}
|
|
this.minimizedSubagents.get(parentSessionId).add(agentId);
|
|
|
|
// Update tab badge to show minimized agents
|
|
this.renderSessionTabs();
|
|
}
|
|
|
|
// Persist the state change
|
|
this.saveSubagentWindowStates();
|
|
this.updateConnectionLines();
|
|
// Restack remaining visible mobile windows to fill the gap
|
|
this.relayoutMobileSubagentWindows();
|
|
},
|
|
|
|
/** Reposition all visible mobile subagent windows (called on keyboard show/hide). */
|
|
relayoutMobileSubagentWindows() {
|
|
if (MobileDetection.getDeviceType() !== 'mobile') return;
|
|
const mobileCardHeight = 110;
|
|
const mobileCardGap = 4;
|
|
const keyboardUp = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible;
|
|
let idx = 0;
|
|
for (const [, data] of this.subagentWindows) {
|
|
if (data.minimized || data.hidden) continue;
|
|
const el = data.element;
|
|
// Reset left to proper position (drag may have set an arbitrary value)
|
|
el.style.left = '4px';
|
|
if (keyboardUp) {
|
|
// Stack from bottom above toolbar
|
|
const bottomPx = 40 + idx * (mobileCardHeight + mobileCardGap);
|
|
el.style.bottom = `${bottomPx}px`;
|
|
el.style.top = 'auto';
|
|
} else {
|
|
// Stack from top below header
|
|
const headerHeight = document.querySelector('.header')?.offsetHeight || 36;
|
|
const topPx = headerHeight + 8 + idx * (mobileCardHeight + mobileCardGap);
|
|
el.style.top = `${topPx}px`;
|
|
el.style.bottom = 'auto';
|
|
}
|
|
idx++;
|
|
}
|
|
},
|
|
|
|
// Clean up ALL floating windows (called during handleInit to prevent memory leaks on reconnect)
|
|
cleanupAllFloatingWindows() {
|
|
// Clean up all subagent windows with their ResizeObservers and drag listeners
|
|
for (const [agentId, windowData] of this.subagentWindows) {
|
|
if (windowData.resizeObserver) {
|
|
windowData.resizeObserver.disconnect();
|
|
}
|
|
if (windowData.dragListeners) {
|
|
document.removeEventListener('mousemove', windowData.dragListeners.move);
|
|
document.removeEventListener('mouseup', windowData.dragListeners.up);
|
|
if (windowData.dragListeners.touchMove) {
|
|
document.removeEventListener('touchmove', windowData.dragListeners.touchMove);
|
|
document.removeEventListener('touchend', windowData.dragListeners.up);
|
|
document.removeEventListener('touchcancel', windowData.dragListeners.up);
|
|
}
|
|
}
|
|
windowData.element.remove();
|
|
}
|
|
this.subagentWindows.clear();
|
|
|
|
// Clean up all teammate terminals
|
|
for (const [, termData] of this.teammateTerminals) {
|
|
if (termData.resizeObserver) termData.resizeObserver.disconnect();
|
|
if (termData.terminal) {
|
|
try {
|
|
termData.terminal.dispose();
|
|
} catch {}
|
|
}
|
|
}
|
|
this.teammateTerminals.clear();
|
|
this.teammatePanesByName.clear();
|
|
|
|
// Clean up all log viewer windows with their EventSources and drag listeners
|
|
for (const [windowId, data] of this.logViewerWindows) {
|
|
if (data.eventSource) {
|
|
data.eventSource.close();
|
|
}
|
|
if (data.dragListeners) {
|
|
document.removeEventListener('mousemove', data.dragListeners.move);
|
|
document.removeEventListener('mouseup', data.dragListeners.up);
|
|
if (data.dragListeners.touchMove) {
|
|
document.removeEventListener('touchmove', data.dragListeners.touchMove);
|
|
document.removeEventListener('touchend', data.dragListeners.up);
|
|
document.removeEventListener('touchcancel', data.dragListeners.up);
|
|
}
|
|
}
|
|
data.element.remove();
|
|
}
|
|
this.logViewerWindows.clear();
|
|
|
|
// Clean up plan subagent windows (wizard agents)
|
|
if (this.planSubagents) {
|
|
for (const [agentId, windowData] of this.planSubagents) {
|
|
if (windowData.dragListeners) {
|
|
document.removeEventListener('mousemove', windowData.dragListeners.move);
|
|
document.removeEventListener('mouseup', windowData.dragListeners.up);
|
|
}
|
|
if (windowData.element) {
|
|
windowData.element.remove();
|
|
}
|
|
}
|
|
this.planSubagents.clear();
|
|
}
|
|
|
|
// Clean up all image popup windows with their drag listeners
|
|
for (const [imageId, popupData] of this.imagePopups) {
|
|
if (popupData.dragListeners) {
|
|
document.removeEventListener('mousemove', popupData.dragListeners.move);
|
|
document.removeEventListener('mouseup', popupData.dragListeners.up);
|
|
if (popupData.dragListeners.touchMove) {
|
|
document.removeEventListener('touchmove', popupData.dragListeners.touchMove);
|
|
document.removeEventListener('touchend', popupData.dragListeners.up);
|
|
document.removeEventListener('touchcancel', popupData.dragListeners.up);
|
|
}
|
|
}
|
|
popupData.element.remove();
|
|
}
|
|
this.imagePopups.clear();
|
|
|
|
// Clean up ultracode floating run windows (re-seeded from data.workflowRuns on reconnect)
|
|
if (typeof this.removeAllUltracodeWindows === 'function') this.removeAllUltracodeWindows();
|
|
|
|
// Clear orphaned plan generation state
|
|
this.activePlanOrchestratorId = null;
|
|
this._planProgressHandler = null;
|
|
this.planGenerationStopped = true;
|
|
if (this.planGenerationAbortController) {
|
|
this.planGenerationAbortController.abort();
|
|
this.planGenerationAbortController = null;
|
|
}
|
|
|
|
// Clean up wizard-specific timers (leak fix: not cleared on SSE reconnect)
|
|
if (this.wizardMinimizedTimer) {
|
|
clearInterval(this.wizardMinimizedTimer);
|
|
this.wizardMinimizedTimer = null;
|
|
}
|
|
|
|
// Clean up wizard drag listeners (leak fix: document-level handlers)
|
|
if (typeof this.cleanupWizardDragging === 'function') this.cleanupWizardDragging();
|
|
|
|
// Deactivate focus trap if wizard was open (leak fix: keydown listener)
|
|
if (this.activeFocusTrap) {
|
|
this.activeFocusTrap.deactivate();
|
|
this.activeFocusTrap = null;
|
|
}
|
|
|
|
// Clean up team tasks panel drag listeners
|
|
if (this.teamTasksDragListeners) {
|
|
document.removeEventListener('mousemove', this.teamTasksDragListeners.move);
|
|
document.removeEventListener('mouseup', this.teamTasksDragListeners.up);
|
|
if (this.teamTasksDragListeners.touchMove) {
|
|
document.removeEventListener('touchmove', this.teamTasksDragListeners.touchMove);
|
|
document.removeEventListener('touchend', this.teamTasksDragListeners.up);
|
|
document.removeEventListener('touchcancel', this.teamTasksDragListeners.up);
|
|
}
|
|
this.teamTasksDragListeners = null;
|
|
}
|
|
|
|
// Clear minimized agents tracking
|
|
this.minimizedSubagents.clear();
|
|
|
|
// Update monitor panel
|
|
this.renderMonitorPlanAgents();
|
|
|
|
// Update connection lines (should be empty now)
|
|
this.updateConnectionLines();
|
|
},
|
|
|
|
restoreSubagentWindow(agentId) {
|
|
const windowData = this.subagentWindows.get(agentId);
|
|
const agent = this.subagents.get(agentId);
|
|
|
|
// If window doesn't exist but agent does, recreate it
|
|
if (!windowData && agent) {
|
|
this.openSubagentWindow(agentId);
|
|
return;
|
|
}
|
|
|
|
if (windowData) {
|
|
const settings = this.loadAppSettingsFromStorage();
|
|
const activeTabOnly = settings.subagentActiveTabOnly ?? true;
|
|
|
|
// Get parent from PERSISTENT map (THE source of truth)
|
|
const storedParent = this.subagentParentMap.get(agentId);
|
|
const parentSessionId = storedParent || agent?.parentSessionId;
|
|
|
|
// Determine if we should show the window
|
|
let shouldShow = true;
|
|
if (activeTabOnly) {
|
|
// Only restore if the window belongs to the active session (or has no parent)
|
|
shouldShow = !parentSessionId || parentSessionId === this.activeSessionId;
|
|
}
|
|
|
|
if (shouldShow) {
|
|
windowData.element.style.display = 'flex';
|
|
windowData.element.style.zIndex = ++this.subagentWindowZIndex;
|
|
windowData.hidden = false;
|
|
}
|
|
windowData.minimized = false;
|
|
|
|
// Lazily re-create teammate terminal if it was disposed on minimize.
|
|
// Only re-create when the window is actually becoming visible.
|
|
if (shouldShow && windowData._lazyTerminal) {
|
|
this._restoreTeammateTerminalFromLazy(agentId);
|
|
}
|
|
|
|
this.updateConnectionLines();
|
|
// Restack all visible mobile windows so restored ones don't overlap
|
|
this.relayoutMobileSubagentWindows();
|
|
}
|
|
},
|
|
|
|
// Returns drag listener references for cleanup (prevents memory leaks)
|
|
makeWindowDraggable(win, handle) {
|
|
let isDragging = false;
|
|
let startX, startY, startLeft, startTop;
|
|
let dragUpdateScheduled = false;
|
|
|
|
const startDrag = (clientX, clientY) => {
|
|
isDragging = true;
|
|
startX = clientX;
|
|
startY = clientY;
|
|
startLeft = parseInt(win.style.left) || win.getBoundingClientRect().left;
|
|
startTop = parseInt(win.style.top) || win.getBoundingClientRect().top;
|
|
// On drag start, switch from bottom-positioned to top-positioned so left/top work
|
|
win.style.bottom = 'auto';
|
|
};
|
|
|
|
const moveDrag = (clientX, clientY) => {
|
|
if (!isDragging) return;
|
|
const dx = clientX - startX;
|
|
const dy = clientY - startY;
|
|
// Constrain to viewport bounds
|
|
const winWidth = win.offsetWidth || 420;
|
|
const winHeight = win.offsetHeight || 350;
|
|
const maxX = window.innerWidth - winWidth - 4;
|
|
const maxY = window.innerHeight - winHeight - 4;
|
|
const newLeft = Math.max(4, Math.min(startLeft + dx, maxX));
|
|
const newTop = Math.max(4, Math.min(startTop + dy, maxY));
|
|
win.style.left = `${newLeft}px`;
|
|
win.style.top = `${newTop}px`;
|
|
// Throttle connection line updates during drag
|
|
if (!dragUpdateScheduled) {
|
|
dragUpdateScheduled = true;
|
|
requestAnimationFrame(() => {
|
|
this.updateConnectionLines();
|
|
dragUpdateScheduled = false;
|
|
});
|
|
}
|
|
};
|
|
|
|
const endDrag = () => {
|
|
if (isDragging) {
|
|
isDragging = false;
|
|
// Save position after drag ends
|
|
this.saveSubagentWindowStates();
|
|
}
|
|
};
|
|
|
|
// Named handle-level listeners (stored for explicit cleanup on window close)
|
|
const handleMouseDown = (e) => {
|
|
if (e.target.tagName === 'BUTTON') return;
|
|
startDrag(e.clientX, e.clientY);
|
|
e.preventDefault();
|
|
};
|
|
const handleTouchStart = (e) => {
|
|
if (e.target.tagName === 'BUTTON') return;
|
|
const touch = e.touches[0];
|
|
startDrag(touch.clientX, touch.clientY);
|
|
};
|
|
|
|
handle.addEventListener('mousedown', handleMouseDown);
|
|
handle.addEventListener('touchstart', handleTouchStart, { passive: true });
|
|
|
|
// Store references to document-level listeners so they can be removed on window close
|
|
const moveListener = (e) => {
|
|
moveDrag(e.clientX, e.clientY);
|
|
};
|
|
|
|
const touchMoveListener = (e) => {
|
|
if (!isDragging) return;
|
|
e.preventDefault(); // Prevent page scroll while dragging
|
|
const touch = e.touches[0];
|
|
moveDrag(touch.clientX, touch.clientY);
|
|
};
|
|
|
|
const upListener = () => {
|
|
endDrag();
|
|
};
|
|
|
|
document.addEventListener('mousemove', moveListener);
|
|
document.addEventListener('mouseup', upListener);
|
|
document.addEventListener('touchmove', touchMoveListener, { passive: false });
|
|
document.addEventListener('touchend', upListener);
|
|
document.addEventListener('touchcancel', upListener);
|
|
|
|
// Return all listener references for cleanup (both handle-level and document-level)
|
|
return {
|
|
move: moveListener,
|
|
up: upListener,
|
|
touchMove: touchMoveListener,
|
|
handle,
|
|
handleMouseDown,
|
|
handleTouchStart,
|
|
};
|
|
},
|
|
|
|
// Show subagent dropdown on hover
|
|
showSubagentDropdown(badgeEl) {
|
|
this.cancelHideSubagentDropdown();
|
|
const dropdown = badgeEl.querySelector('.subagent-dropdown');
|
|
if (!dropdown || dropdown.classList.contains('open')) return;
|
|
|
|
// Close other dropdowns first
|
|
document.querySelectorAll('.subagent-dropdown.open').forEach((d) => {
|
|
d.classList.remove('open', 'pinned');
|
|
if (d.parentElement === document.body && d._originalParent) {
|
|
d._originalParent.appendChild(d);
|
|
}
|
|
});
|
|
|
|
// Move to body to escape clipping
|
|
dropdown._originalParent = badgeEl;
|
|
document.body.appendChild(dropdown);
|
|
|
|
// Position below badge
|
|
const rect = badgeEl.getBoundingClientRect();
|
|
dropdown.style.top = `${rect.bottom + 2}px`;
|
|
dropdown.style.left = `${rect.left + rect.width / 2}px`;
|
|
dropdown.style.transform = 'translateX(-50%)';
|
|
dropdown.classList.add('open');
|
|
|
|
// Keep it on screen. A badge in the left sidebar — and above all one in the
|
|
// 44px collapsed rail — sits so far left that a centre-anchored dropdown
|
|
// hangs off the viewport. Measured after .open so it has a box; a no-op
|
|
// whenever the centred position already fits, so header layout is unchanged.
|
|
const dropRect = dropdown.getBoundingClientRect();
|
|
const overflowLeft = 8 - dropRect.left;
|
|
const overflowRight = dropRect.right - (window.innerWidth - 8);
|
|
if (overflowLeft > 0) {
|
|
dropdown.style.transform = `translateX(calc(-50% + ${Math.round(overflowLeft)}px))`;
|
|
} else if (overflowRight > 0) {
|
|
dropdown.style.transform = `translateX(calc(-50% - ${Math.round(overflowRight)}px))`;
|
|
}
|
|
},
|
|
|
|
// Schedule hide after delay (allows moving mouse to dropdown)
|
|
scheduleHideSubagentDropdown(badgeEl) {
|
|
this._subagentHideTimeout = setTimeout(() => {
|
|
const dropdown =
|
|
badgeEl?.querySelector?.('.subagent-dropdown') || document.querySelector('.subagent-dropdown.open');
|
|
if (dropdown && !dropdown.classList.contains('pinned')) {
|
|
dropdown.classList.remove('open');
|
|
if (dropdown._originalParent) {
|
|
dropdown._originalParent.appendChild(dropdown);
|
|
}
|
|
}
|
|
}, 150);
|
|
},
|
|
|
|
// Cancel scheduled hide
|
|
cancelHideSubagentDropdown() {
|
|
if (this._subagentHideTimeout) {
|
|
clearTimeout(this._subagentHideTimeout);
|
|
this._subagentHideTimeout = null;
|
|
}
|
|
},
|
|
|
|
// Pin dropdown open on click (stays until clicking outside)
|
|
pinSubagentDropdown(badgeEl) {
|
|
const dropdown = document.querySelector('.subagent-dropdown.open');
|
|
if (!dropdown) {
|
|
this.showSubagentDropdown(badgeEl);
|
|
// On mobile/touch, pin immediately so onmouseleave doesn't close it
|
|
const openedDropdown = document.querySelector('.subagent-dropdown.open');
|
|
if (openedDropdown) {
|
|
openedDropdown.classList.add('pinned');
|
|
const closeHandler = (e) => {
|
|
if (!badgeEl.contains(e.target) && !openedDropdown.contains(e.target)) {
|
|
openedDropdown.classList.remove('open', 'pinned');
|
|
if (openedDropdown._originalParent) {
|
|
openedDropdown._originalParent.appendChild(openedDropdown);
|
|
}
|
|
document.removeEventListener('click', closeHandler);
|
|
}
|
|
};
|
|
setTimeout(() => document.addEventListener('click', closeHandler), 0);
|
|
}
|
|
return;
|
|
}
|
|
dropdown.classList.toggle('pinned');
|
|
|
|
if (dropdown.classList.contains('pinned')) {
|
|
// Close on outside click
|
|
const closeHandler = (e) => {
|
|
if (!badgeEl.contains(e.target) && !dropdown.contains(e.target)) {
|
|
dropdown.classList.remove('open', 'pinned');
|
|
if (dropdown._originalParent) {
|
|
dropdown._originalParent.appendChild(dropdown);
|
|
}
|
|
document.removeEventListener('click', closeHandler);
|
|
}
|
|
};
|
|
setTimeout(() => document.addEventListener('click', closeHandler), 0);
|
|
}
|
|
},
|
|
});
|