mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
A floating subagent or ultracode window whose parent session's row the
rail search hid drew its connector from the viewport's top-left corner.
The hidden row is display:none, and getBoundingClientRect() still
answers it with an all-zero DOMRect, which is truthy, so the
`if (!tabRect) continue` paths never skipped it and _tabAnchor() put the
line at (0, 0) ("M 0 0 C 350 0, 350 400, 700 400" in Chromium). The
sidebar filter already had the same defect; #580 brought it to the rail,
and the keystroke redraw from 5f373a93 now ran it on every keystroke that
hides the parent.
A new _paintedSessionTab() in app.js returns the parent's row only while
getClientRects() is non-empty, and every place a floating window
measures its parent tab goes through it:
- the shared `tab:<id>` rect cache, filled by the subagent connectors
and by both ultracode connector layers (all three fill it, so guarding
only the first would let the next one cache the zero rect itself);
- the ultracode window's spawn position, which now cascades;
- the subagent window's fly-from-tab spawn, the same defect, not in the
report, now positioned as a window without a tab;
- the ultracode genie on minimize, which now tears down at once.
Lineage lines need nothing: they measure on a cache miss and computeTree
already drops zero-size rects.
Tests: in the gate, with the page laid out by hand (jsdom has no
layout), a subagent window, an ultracode run window and an ultracode
agent window from a hidden row draw no line and draw it again from the
row once the search is cleared, an ultracode window spawns from a
painted row and cascades from a hidden one, and the genie is skipped for
a hidden row. In the browser suite, real Chromium shows the hidden row's
rect is all zero and the connector is gone, then back after Clear. All
four fail on the previous commit. CLAUDE.md and the invariants'
Connectors bullet record the rule.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1306 lines
53 KiB
JavaScript
1306 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).
|
|
// Only a painted row is cached: a row a search or filter hid has no line
|
|
// (_paintedSessionTab), rather than one drawn from the viewport's corner.
|
|
if (!wizardOpen) {
|
|
for (const { agentId } of visibleSubagentWindows) {
|
|
const parentSessionId = this.subagentParentMap.get(agentId);
|
|
if (!parentSessionId || rects.has('tab:' + parentSessionId)) continue;
|
|
const tab = this._paintedSessionTab(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 painted (session closed, collapsed group, or hidden by a search)
|
|
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 (a hidden row spawns normally)
|
|
const parentTab = this._paintedSessionTab(parentSessionId);
|
|
|
|
// 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);
|
|
}
|
|
},
|
|
});
|