';
for (const childId of task.children) {
// Find child task in allTasks map
const childTask = allTasks.find(t => t.id === childId);
if (childTask) {
childrenHtml += `
${renderTask(childTask, allTasks)}
`;
}
}
childrenHtml += '
';
}
return `
${statusIcon}
${escapeHtml(task.description)}
${task.subagentType}${duration}
${childrenHtml}
`;
};
// Flatten all tasks for lookup
const allTasks = this.flattenTaskTree(session.taskTree);
// Render only root tasks (those without parents or with null parentId)
let html = '
';
for (const task of session.taskTree) {
html += `
${renderTask(task, allTasks)}
`;
}
html += '
';
body.innerHTML = html;
},
flattenTaskTree(tasks, result = []) {
for (const task of tasks) {
result.push(task);
// Children are stored as IDs, not nested objects in taskTree
// The task tree from server already has the structure we need
}
return result;
},
// ═══════════════════════════════════════════════════════════════
// Subagent Panel (Claude Code Background Agents)
// ═══════════════════════════════════════════════════════════════
// Legacy alias
toggleSubagentPanel() {
this.toggleSubagentsPanel();
},
updateSubagentBadge() {
const badge = this.$('subagentCountBadge');
const activeCount = Array.from(this.subagents.values()).filter(s => s.status === 'active' || s.status === 'idle').length;
// Update badge with active count
if (badge) {
badge.textContent = activeCount > 0 ? activeCount : '';
}
},
renderSubagentPanel() {
// Debounce renders at 150ms to prevent excessive DOM updates from rapid subagent events
if (this._subagentPanelRenderTimeout) {
clearTimeout(this._subagentPanelRenderTimeout);
}
this._subagentPanelRenderTimeout = setTimeout(() => {
scheduleBackground(() => this._renderSubagentPanelImmediate());
}, 150);
},
_renderSubagentPanelImmediate() {
const list = this.$('subagentList');
if (!list) return;
// Always update badge count
this.updateSubagentBadge();
// Always update monitor panel (even if subagent panel is hidden)
this.renderMonitorSubagents();
// If panel is not visible, don't render content
if (!this.subagentPanelVisible) {
return;
}
// Render subagent list
if (this.subagents.size === 0) {
list.innerHTML = '
`);
} catch (err) {
console.error('Failed to load transcript:', err);
alert('Failed to load transcript: ' + err.message);
}
},
// ═══════════════════════════════════════════════════════════════
// Subagent Parent TAB Tracking
// ═══════════════════════════════════════════════════════════════
//
// CRITICAL: This system tracks which TAB an agent window connects to.
// The association is stored in `subagentParentMap` (agentId -> sessionId).
// The sessionId IS the tab identifier (tabs have data-id="${sessionId}").
// Once set, this association is PERMANENT and persisted across restarts.
/**
* Find and assign the parent TAB for a subagent.
*
* Matching strategy (in order):
* 1. Use existing stored association from subagentParentMap (permanent)
* 2. Match via claudeSessionId (agent.sessionId === session.claudeSessionId)
* 3. FALLBACK: Use the currently active session (since that's where the user typed the command)
*
* Once found, the association is stored PERMANENTLY in subagentParentMap.
*/
findParentSessionForSubagent(agentId) {
// Check if we already have a permanent association
if (this.subagentParentMap.has(agentId)) {
// Already have a parent - update agent object from stored value
const storedSessionId = this.subagentParentMap.get(agentId);
// Verify the session still exists
if (this.sessions.has(storedSessionId)) {
const agent = this.subagents.get(agentId);
if (agent && !agent.parentSessionId) {
agent.parentSessionId = storedSessionId;
const session = this.sessions.get(storedSessionId);
if (session) {
agent.parentSessionName = this.getSessionName(session);
}
this.subagents.set(agentId, agent);
this.updateSubagentWindowParent(agentId);
}
return;
}
// Stored session no longer exists - clear and re-discover
this.subagentParentMap.delete(agentId);
}
const agent = this.subagents.get(agentId);
if (!agent) return;
// Strategy 1: Match via claudeSessionId (most accurate)
if (agent.sessionId) {
for (const [sessionId, session] of this.sessions) {
if (session.claudeSessionId === agent.sessionId) {
// FOUND! Store this association PERMANENTLY
this.setAgentParentSessionId(agentId, sessionId);
this.updateSubagentWindowParent(agentId);
this.updateSubagentWindowVisibility();
this.updateConnectionLines();
return;
}
}
}
// Strategy 2: FALLBACK - Use the currently active session
// This works because agents spawn from where the user typed the command
if (this.activeSessionId && this.sessions.has(this.activeSessionId)) {
this.setAgentParentSessionId(agentId, this.activeSessionId);
this.updateSubagentWindowParent(agentId);
this.updateSubagentWindowVisibility();
this.updateConnectionLines();
return;
}
// Strategy 3: If no active session, use the first session
if (this.sessions.size > 0) {
const firstSessionId = this.sessions.keys().next().value;
this.setAgentParentSessionId(agentId, firstSessionId);
this.updateSubagentWindowParent(agentId);
this.updateSubagentWindowVisibility();
this.updateConnectionLines();
}
},
/**
* Re-check all orphan subagents (those without a parent TAB) when a session updates.
* Called when session:updated fires with claudeSessionId.
*
* Also re-validates existing associations when claudeSessionId becomes available,
* in case the fallback association was wrong.
*/
recheckOrphanSubagents() {
let anyChanged = false;
for (const [agentId, agent] of this.subagents) {
// Check if this agent has no parent in the persistent map
if (!this.subagentParentMap.has(agentId)) {
this.findParentSessionForSubagent(agentId);
if (this.subagentParentMap.has(agentId)) {
anyChanged = true;
}
} else if (agent.sessionId) {
// Agent has a stored parent, but check if we can now do a proper claudeSessionId match
// This handles the case where fallback was used but now the real parent is known
const storedParent = this.subagentParentMap.get(agentId);
const storedSession = this.sessions.get(storedParent);
// If the stored session doesn't have a matching claudeSessionId, try to find the real match
if (storedSession && storedSession.claudeSessionId !== agent.sessionId) {
for (const [sessionId, session] of this.sessions) {
if (session.claudeSessionId === agent.sessionId) {
// Found the real parent - update the association
this.subagentParentMap.set(agentId, sessionId);
agent.parentSessionId = sessionId;
agent.parentSessionName = this.getSessionName(session);
this.subagents.set(agentId, agent);
this.updateSubagentWindowParent(agentId);
anyChanged = true;
break;
}
}
}
}
}
if (anyChanged) {
this.saveSubagentParentMap();
this.updateConnectionLines();
}
},
/**
* Update parentSessionName for all subagents belonging to a TAB.
* Called when a session is renamed to keep cached names fresh.
*/
updateSubagentParentNames(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) return;
const newName = this.getSessionName(session);
// Skip iteration if name hasn't changed (avoids O(n) loop on every session:updated)
const cachedName = this._parentNameCache?.get(sessionId);
if (cachedName === newName) return;
if (!this._parentNameCache) this._parentNameCache = new Map();
this._parentNameCache.set(sessionId, newName);
for (const [agentId, storedSessionId] of this.subagentParentMap) {
if (storedSessionId === sessionId) {
const agent = this.subagents.get(agentId);
if (agent) {
agent.parentSessionName = newName;
this.subagents.set(agentId, agent);
// Update the window header if open
const windowData = this.subagentWindows.get(agentId);
if (windowData) {
const parentNameEl = windowData.element.querySelector('.subagent-window-parent .parent-name');
if (parentNameEl) {
parentNameEl.textContent = newName;
}
}
}
}
}
},
/**
* Add parent header to an agent window, showing which TAB it belongs to.
*/
updateSubagentWindowParent(agentId) {
const windowData = this.subagentWindows.get(agentId);
if (!windowData) return;
// Get parent from persistent map (THE source of truth)
const parentSessionId = this.subagentParentMap.get(agentId);
if (!parentSessionId) return;
const session = this.sessions.get(parentSessionId);
const parentName = session ? this.getSessionName(session) : 'Unknown';
// Check if parent header already exists
const win = windowData.element;
const existingParent = win.querySelector('.subagent-window-parent');
if (existingParent) {
// Update existing
existingParent.dataset.parentSession = parentSessionId;
const nameEl = existingParent.querySelector('.parent-name');
if (nameEl) {
nameEl.textContent = parentName;
nameEl.onclick = () => this.selectSession(parentSessionId);
}
return;
}
// Insert new parent header after the main header
const header = win.querySelector('.subagent-window-header');
if (header) {
const parentDiv = document.createElement('div');
parentDiv.className = 'subagent-window-parent';
parentDiv.dataset.parentSession = parentSessionId;
parentDiv.innerHTML = `
from${escapeHtml(parentName)}
`;
header.insertAdjacentElement('afterend', parentDiv);
}
},
/**
* Show/hide subagent windows based on active session.
* Behavior controlled by "Subagents for Active Tab Only" setting.
* Uses the PERSISTENT subagentParentMap for accurate tab-based visibility.
*/
updateSubagentWindowVisibility() {
const settings = this.loadAppSettingsFromStorage();
const activeTabOnly = settings.subagentActiveTabOnly ?? true;
for (const [agentId, windowInfo] of this.subagentWindows) {
// Get parent from PERSISTENT map (THE source of truth)
const storedParent = this.subagentParentMap.get(agentId);
const agent = this.subagents.get(agentId);
const parentSessionId = storedParent || agent?.parentSessionId;
// Determine visibility based on setting
let shouldShow;
if (activeTabOnly) {
// Show if: no parent known yet, or parent matches active session
const hasKnownParent = !!parentSessionId;
shouldShow = !hasKnownParent || parentSessionId === this.activeSessionId;
} else {
// Show all windows (original behavior)
shouldShow = true;
}
if (shouldShow) {
// Show window (unless it was minimized by user)
if (!windowInfo.minimized) {
windowInfo.element.style.display = 'flex';
// Lazily re-create teammate terminal if it was disposed when hidden
if (windowInfo._lazyTerminal) {
this._restoreTeammateTerminalFromLazy(agentId);
}
}
windowInfo.hidden = false;
} else {
// Hide window (but don't close it)
// Dispose teammate terminal to free memory while hidden on inactive tab
this._disposeTeammateTerminalForMinimize(agentId);
windowInfo.element.style.display = 'none';
windowInfo.hidden = true;
}
}
// Update connection lines after visibility changes
this.updateConnectionLines();
// Restack mobile windows after visibility changes
this.relayoutMobileSubagentWindows();
},
// Close all subagent windows for a session (fully removes them, not minimize)
// If cleanupData is true, also remove activity and toolResults data to prevent memory leaks
closeSessionSubagentWindows(sessionId, cleanupData = false) {
const toClose = [];
for (const [agentId, _windowData] of this.subagentWindows) {
const agent = this.subagents.get(agentId);
// Check both subagent parentSessionId and subagentParentMap
// (standalone pane windows use subagentParentMap, not subagents map)
const parentFromMap = this.subagentParentMap.get(agentId);
if (agent?.parentSessionId === sessionId || parentFromMap === sessionId) {
toClose.push(agentId);
}
}
for (const agentId of toClose) {
this.forceCloseSubagentWindow(agentId);
// Clean up activity and tool results data if requested (prevents memory leaks)
if (cleanupData) {
this.subagents.delete(agentId);
this.subagentActivity.delete(agentId);
this.subagentToolResults.delete(agentId);
this.subagentParentMap.delete(agentId);
}
}
// Also clean up minimized agents for this session
this.minimizedSubagents.delete(sessionId);
this.renderSessionTabs();
},
// Fully close a subagent window (removes from DOM, not minimize)
forceCloseSubagentWindow(agentId) {
const windowData = this.subagentWindows.get(agentId);
if (windowData) {
// Clean up resize observer
if (windowData.resizeObserver) {
windowData.resizeObserver.disconnect();
}
// Clean up drag event listeners (both document-level and handle-level)
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);
}
// Remove handle-level listeners before DOM removal
if (windowData.dragListeners.handle) {
windowData.dragListeners.handle.removeEventListener('mousedown', windowData.dragListeners.handleMouseDown);
windowData.dragListeners.handle.removeEventListener('touchstart', windowData.dragListeners.handleTouchStart);
}
}
windowData.element.remove();
this.subagentWindows.delete(agentId);
}
// Clean up teammate terminal if present
const termData = this.teammateTerminals.get(agentId);
if (termData) {
if (termData.resizeObserver) {
termData.resizeObserver.disconnect();
}
if (termData.terminal) {
try { termData.terminal.dispose(); } catch {}
}
this.teammateTerminals.delete(agentId);
}
},
minimizeSubagentWindow(agentId) {
const windowData = this.subagentWindows.get(agentId);
if (windowData) {
// Dispose teammate terminal on minimize to free DOM/memory (lazy re-creation on restore)
this._disposeTeammateTerminalForMinimize(agentId);
windowData.element.style.display = 'none';
windowData.minimized = true;
this.updateConnectionLines();
}
},
// Debounced wrapper — coalesces rapid subagent events (tool_call, progress,
// message) into a single DOM update per 100ms per agent window.
scheduleSubagentWindowRender(agentId) {
// Skip DOM updates for windows with lazy (disposed) terminals — they're minimized
const windowData = this.subagentWindows.get(agentId);
if (windowData?.minimized) return;
if (!this._subagentWindowRenderTimeouts) this._subagentWindowRenderTimeouts = new Map();
if (this._subagentWindowRenderTimeouts.has(agentId)) {
clearTimeout(this._subagentWindowRenderTimeouts.get(agentId));
}
this._subagentWindowRenderTimeouts.set(agentId, setTimeout(() => {
this._subagentWindowRenderTimeouts.delete(agentId);
scheduleBackground(() => this.renderSubagentWindowContent(agentId));
}, 100));
},
renderSubagentWindowContent(agentId) {
// Skip if this window has a live terminal (don't overwrite xterm with activity HTML)
if (this.teammateTerminals.has(agentId)) return;
// Skip if this window has a lazy (disposed) terminal — it will be re-created on restore
const windowData = this.subagentWindows.get(agentId);
if (windowData?._lazyTerminal) return;
const body = document.getElementById(`subagent-window-body-${agentId}`);
if (!body) return;
const activity = this.subagentActivity.get(agentId) || [];
if (activity.length === 0) {
body.innerHTML = '
No activity yet
';
return;
}
// Incremental rendering: track how many items are already rendered
const renderedCount = body.dataset.renderedCount ? parseInt(body.dataset.renderedCount, 10) : 0;
const maxItems = 100;
const visibleActivity = activity.slice(-maxItems);
// If activity was trimmed or this is a fresh render, do full rebuild
if (renderedCount === 0 || renderedCount > visibleActivity.length || body.children.length === 0 ||
(body.children.length === 1 && body.querySelector('.subagent-empty'))) {
// Full rebuild
const html = visibleActivity.map(a => this._renderActivityItem(a)).join('');
body.innerHTML = html;
body.dataset.renderedCount = String(visibleActivity.length);
} else {
// Incremental: only append new items
const newItems = visibleActivity.slice(renderedCount);
if (newItems.length > 0) {
const newHtml = newItems.map(a => this._renderActivityItem(a)).join('');
body.insertAdjacentHTML('beforeend', newHtml);
body.dataset.renderedCount = String(visibleActivity.length);
// Trim excess children from the front if over maxItems
while (body.children.length > maxItems) {
body.removeChild(body.firstChild);
}
}
}
body.scrollTop = body.scrollHeight;
},
_renderActivityItem(a) {
const time = new Date(a.timestamp).toLocaleTimeString('en-US', { hour12: false });
if (a.type === 'tool') {
return `
`;
}).join('');
listEl.innerHTML = html;
},
/** Hide team tasks panel and clean up drag listeners */
hideTeamTasksPanel() {
const panel = document.getElementById('teamTasksPanel');
if (panel) panel.style.display = 'none';
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);
}
if (this.teamTasksDragListeners.handle) {
this.teamTasksDragListeners.handle.removeEventListener('mousedown', this.teamTasksDragListeners.handleMouseDown);
this.teamTasksDragListeners.handle.removeEventListener('touchstart', this.teamTasksDragListeners.handleTouchStart);
}
this.teamTasksDragListeners = null;
}
},
/** Get teammate color by name */
getTeammateColor(name) {
const info = this.teammateMap.get(name);
return info?.color || 'blue';
},
// ═══════════════════════════════════════════════════════════════
// Project Insights Panel (Bash Tools with Clickable File Paths)
// ═══════════════════════════════════════════════════════════════
/**
* Normalize a file path to its canonical form for comparison.
* - Expands ~ to home directory approximation
* - Resolves relative paths against working directory (case folder)
* - Normalizes . and .. components
*/
normalizeFilePath(path, workingDir) {
if (!path) return '';
let normalized = path.trim();
const homeDir = '/home/' + (window.USER || 'user'); // Approximation
// Expand ~ to home directory
if (normalized.startsWith('~/')) {
normalized = homeDir + normalized.slice(1);
} else if (normalized === '~') {
normalized = homeDir;
}
// If not absolute, resolve against working directory (case folder)
if (!normalized.startsWith('/') && workingDir) {
normalized = workingDir + '/' + normalized;
}
// Normalize path components (resolve . and ..)
const parts = normalized.split('/');
const stack = [];
for (const part of parts) {
if (part === '' || part === '.') {
continue;
} else if (part === '..') {
if (stack.length > 1) {
stack.pop();
}
} else {
stack.push(part);
}
}
return '/' + stack.join('/');
},
/**
* Extract just the filename from a path.
*/
getFilename(path) {
const parts = path.split('/');
return parts[parts.length - 1] || '';
},
/**
* Check if a path is a "shallow root path" - an absolute path with only one
* component after root (e.g., /test.txt, /file.log).
* These are often typos where the user meant a relative path in the case folder.
*/
isShallowRootPath(path) {
if (!path.startsWith('/')) return false;
const parts = path.split('/').filter(p => p !== '');
return parts.length === 1;
},
/**
* Check if a path is inside (or is) the working directory (case folder).
*/
isPathInWorkingDir(path, workingDir) {
if (!workingDir) return false;
const normalized = this.normalizeFilePath(path, workingDir);
return normalized.startsWith(workingDir + '/') || normalized === workingDir;
},
/**
* Smart path equivalence check.
* Two paths are considered equivalent if:
* 1. They normalize to the same path (standard case)
* 2. One is a "shallow root path" (e.g., /test.txt) and the other is the
* same filename inside the case folder - the shallow root path
* is likely a typo and they probably meant the same file.
*/
pathsAreEquivalent(path1, path2, workingDir) {
const norm1 = this.normalizeFilePath(path1, workingDir);
const norm2 = this.normalizeFilePath(path2, workingDir);
// Standard check: exact normalized match
if (norm1 === norm2) return true;
// Smart check: shallow root path vs case folder path with same filename
const file1 = this.getFilename(norm1);
const file2 = this.getFilename(norm2);
if (file1 !== file2) return false; // Different filenames, can't be equivalent
const shallow1 = this.isShallowRootPath(path1);
const shallow2 = this.isShallowRootPath(path2);
const inWorkDir1 = this.isPathInWorkingDir(norm1, workingDir);
const inWorkDir2 = this.isPathInWorkingDir(norm2, workingDir);
// If one is shallow root (e.g., /test.txt) and other is in case folder
// with same filename, treat as equivalent (user likely made a typo)
if (shallow1 && inWorkDir2) return true;
if (shallow2 && inWorkDir1) return true;
return false;
},
/**
* Pick the "better" of two paths that resolve to the same file.
* Prefers paths inside the case folder, longer/more explicit paths, and absolute paths.
*/
pickBetterPath(path1, path2, workingDir) {
// Prefer paths inside the case folder (working directory)
if (workingDir) {
const inWorkDir1 = this.isPathInWorkingDir(path1, workingDir);
const inWorkDir2 = this.isPathInWorkingDir(path2, workingDir);
if (inWorkDir1 && !inWorkDir2) return path1;
if (inWorkDir2 && !inWorkDir1) return path2;
}
// Prefer absolute paths
const abs1 = path1.startsWith('/');
const abs2 = path2.startsWith('/');
if (abs1 && !abs2) return path1;
if (abs2 && !abs1) return path2;
// Both absolute or both relative - prefer longer (more explicit)
if (path1.length !== path2.length) {
return path1.length > path2.length ? path1 : path2;
}
// Prefer paths without ~
if (!path1.includes('~') && path2.includes('~')) return path1;
if (!path2.includes('~') && path1.includes('~')) return path2;
return path1;
},
/**
* Deduplicate file paths across all tools, keeping the "best" version.
* Uses smart equivalence checking:
* - Standard normalization for relative vs absolute paths
* - Detects likely typos (e.g., /file.txt when caseFolder/file.txt exists)
* - Prefers paths inside the case folder (working directory)
* - Prefers longer, more explicit paths
* Returns a Map of normalized path -> best raw path.
*/
deduplicateProjectInsightPaths(tools, workingDir) {
// Collect all paths with their tool IDs
const allPaths = [];
for (const tool of tools) {
for (const rawPath of tool.filePaths) {
allPaths.push({ rawPath, toolId: tool.id });
}
}
if (allPaths.length <= 1) {
const pathMap = new Map();
for (const p of allPaths) {
pathMap.set(this.normalizeFilePath(p.rawPath, workingDir), p);
}
return pathMap;
}
// Sort paths: prefer paths in case folder first, then by length (longer first)
allPaths.sort((a, b) => {
const aInWorkDir = this.isPathInWorkingDir(a.rawPath, workingDir);
const bInWorkDir = this.isPathInWorkingDir(b.rawPath, workingDir);
if (aInWorkDir && !bInWorkDir) return -1;
if (bInWorkDir && !aInWorkDir) return 1;
return b.rawPath.length - a.rawPath.length; // Longer paths first
});
const result = new Map(); // normalized -> { rawPath, toolId }
const seenNormalized = new Set();
for (const { rawPath, toolId } of allPaths) {
const normalized = this.normalizeFilePath(rawPath, workingDir);
// Check if we've already seen an equivalent path
let isDuplicate = false;
for (const [, existing] of result) {
if (this.pathsAreEquivalent(rawPath, existing.rawPath, workingDir)) {
isDuplicate = true;
break;
}
}
if (!isDuplicate && !seenNormalized.has(normalized)) {
result.set(normalized, { rawPath, toolId });
seenNormalized.add(normalized);
}
}
return result;
},
handleBashToolStart(sessionId, tool) {
let tools = this.projectInsights.get(sessionId) || [];
// Add new tool
tools = tools.filter(t => t.id !== tool.id);
tools.push(tool);
this.projectInsights.set(sessionId, tools);
this.renderProjectInsightsPanel();
},
handleBashToolEnd(sessionId, tool) {
const tools = this.projectInsights.get(sessionId) || [];
const existing = tools.find(t => t.id === tool.id);
if (existing) {
existing.status = 'completed';
}
this.renderProjectInsightsPanel();
// Remove after a short delay
setTimeout(() => {
const current = this.projectInsights.get(sessionId) || [];
this.projectInsights.set(sessionId, current.filter(t => t.id !== tool.id));
this.renderProjectInsightsPanel();
}, 2000);
},
handleBashToolsUpdate(sessionId, tools) {
this.projectInsights.set(sessionId, tools);
this.renderProjectInsightsPanel();
},
renderProjectInsightsPanel() {
const panel = this.$('projectInsightsPanel');
const list = this.$('projectInsightsList');
if (!panel || !list) return;
// Check if panel is enabled in settings
const settings = this.loadAppSettingsFromStorage();
const showProjectInsights = settings.showProjectInsights ?? false;
if (!showProjectInsights) {
panel.classList.remove('visible');
this.projectInsightsPanelVisible = false;
return;
}
// Get tools for active session only
const tools = this.projectInsights.get(this.activeSessionId) || [];
const runningTools = tools.filter(t => t.status === 'running');
if (runningTools.length === 0) {
panel.classList.remove('visible');
this.projectInsightsPanelVisible = false;
return;
}
panel.classList.add('visible');
this.projectInsightsPanelVisible = true;
// Get working directory for path normalization
const session = this.sessions.get(this.activeSessionId);
const workingDir = session?.workingDir || this.currentSessionWorkingDir;
// Smart deduplication: collect all unique paths across all tools
// Paths that resolve to the same file are deduplicated, keeping the most complete version
const deduplicatedPaths = this.deduplicateProjectInsightPaths(runningTools, workingDir);
// Build a set of paths to show (only the best version of each unique file)
const pathsToShow = new Set(Array.from(deduplicatedPaths.values()).map(p => p.rawPath));
const html = [];
for (const tool of runningTools) {
// Filter this tool's paths to only include those that weren't deduplicated away
const filteredPaths = tool.filePaths.filter(p => pathsToShow.has(p));
// Skip tools with no paths to show (all were duplicates of better paths elsewhere)
if (filteredPaths.length === 0) continue;
const cmdDisplay = tool.command.length > 50
? tool.command.substring(0, 50) + '...'
: tool.command;
html.push(`