diff --git a/CLAUDE.md b/CLAUDE.md index e02399e8..2154deb6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -319,6 +319,46 @@ const handleMouseEnter = () => { See `src/renderer/components/TabBar.tsx` (Tab component) for implementation details. +### 10. SSH Remote Sessions + +Sessions can execute commands on remote hosts via SSH. **Critical:** There are two different SSH identifiers with different lifecycles: + +```typescript +// Set AFTER AI agent spawns (via onSshRemote callback) +session.sshRemoteId: string | undefined + +// Set BEFORE spawn (user configuration) +session.sessionSshRemoteConfig: { + enabled: boolean; + remoteId: string | null; // The SSH config ID + workingDirOverride?: string; +} +``` + +**Common pitfall:** `sshRemoteId` is only populated after the AI agent spawns. For terminal-only SSH sessions (no AI agent), it remains `undefined`. Always use both as fallback: + +```typescript +// WRONG - fails for terminal-only SSH sessions +const sshId = session.sshRemoteId; + +// CORRECT - works for all SSH sessions +const sshId = session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId; +``` + +This applies to any operation that needs to run on the remote: +- `window.maestro.fs.readDir(path, sshId)` +- `gitService.isRepo(path, sshId)` +- Directory existence checks for `cd` command tracking + +Similarly for checking if a session is remote: +```typescript +// WRONG +const isRemote = !!session.sshRemoteId; + +// CORRECT +const isRemote = !!session.sshRemoteId || !!session.sessionSshRemoteConfig?.enabled; +``` + ## Code Conventions ### TypeScript diff --git a/src/main/group-chat/group-chat-agent.ts b/src/main/group-chat/group-chat-agent.ts index 86c4f44b..ecca7510 100644 --- a/src/main/group-chat/group-chat-agent.ts +++ b/src/main/group-chat/group-chat-agent.ts @@ -57,6 +57,8 @@ export interface SessionOverrides { customModel?: string; customArgs?: string; customEnvVars?: Record; + /** SSH remote name for display in participant card */ + sshRemoteName?: string; } /** @@ -182,6 +184,7 @@ export async function addParticipant( agentId, sessionId, addedAt: Date.now(), + sshRemoteName: sessionOverrides?.sshRemoteName, }; // Store the session mapping diff --git a/src/main/group-chat/group-chat-router.ts b/src/main/group-chat/group-chat-router.ts index dc4e5a39..fa1b059a 100644 --- a/src/main/group-chat/group-chat-router.ts +++ b/src/main/group-chat/group-chat-router.ts @@ -40,6 +40,8 @@ export interface SessionInfo { customArgs?: string; customEnvVars?: Record; customModel?: string; + /** SSH remote name for display in participant card */ + sshRemoteName?: string; } /** @@ -276,11 +278,12 @@ export async function routeUserMessage( agentDetector, agentConfigValues, customEnvVars, - // Pass session-specific overrides (customModel, customArgs, customEnvVars from session) + // Pass session-specific overrides (customModel, customArgs, customEnvVars, sshRemoteName from session) { customModel: matchingSession.customModel, customArgs: matchingSession.customArgs, customEnvVars: matchingSession.customEnvVars, + sshRemoteName: matchingSession.sshRemoteName, } ); existingParticipantNames.add(participantName); @@ -565,11 +568,12 @@ export async function routeModeratorResponse( agentDetector, agentConfigValues, customEnvVars, - // Pass session-specific overrides (customModel, customArgs, customEnvVars from session) + // Pass session-specific overrides (customModel, customArgs, customEnvVars, sshRemoteName from session) { customModel: matchingSession.customModel, customArgs: matchingSession.customArgs, customEnvVars: matchingSession.customEnvVars, + sshRemoteName: matchingSession.sshRemoteName, } ); existingParticipantNames.add(participantName); diff --git a/src/main/group-chat/group-chat-storage.ts b/src/main/group-chat/group-chat-storage.ts index a4913295..fdad2a2d 100644 --- a/src/main/group-chat/group-chat-storage.ts +++ b/src/main/group-chat/group-chat-storage.ts @@ -59,6 +59,8 @@ export interface GroupChatParticipant { processingTimeMs?: number; /** Total cost in USD (optional, depends on provider) */ totalCost?: number; + /** SSH remote name (displayed as pill when running on SSH remote) */ + sshRemoteName?: string; } /** diff --git a/src/main/index.ts b/src/main/index.ts index 134b39e5..aeb912a6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1146,15 +1146,24 @@ function setupIpcHandlers() { // Set up callback for group chat router to lookup sessions for auto-add @mentions setGetSessionsCallback(() => { const sessions = sessionsStore.get('sessions', []); - return sessions.map((s: any) => ({ - id: s.id, - name: s.name, - toolType: s.toolType, - cwd: s.cwd || s.fullPath || process.env.HOME || '/tmp', - customArgs: s.customArgs, - customEnvVars: s.customEnvVars, - customModel: s.customModel, - })); + return sessions.map((s: any) => { + // Resolve SSH remote name if session has SSH config + let sshRemoteName: string | undefined; + if (s.sessionSshRemoteConfig?.enabled && s.sessionSshRemoteConfig.remoteId) { + const sshConfig = getSshRemoteById(s.sessionSshRemoteConfig.remoteId); + sshRemoteName = sshConfig?.name; + } + return { + id: s.id, + name: s.name, + toolType: s.toolType, + cwd: s.cwd || s.fullPath || process.env.HOME || '/tmp', + customArgs: s.customArgs, + customEnvVars: s.customEnvVars, + customModel: s.customModel, + sshRemoteName, + }; + }); }); // Set up callback for group chat router to lookup custom env vars for agents diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d7abff4b..f31aa37b 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -6159,7 +6159,8 @@ function MaestroConsoleInner() { // This spawns a fresh shell with -l -c to run the command // When SSH is enabled for the session, the command runs on the remote host // For SSH sessions, use remoteCwd; for local, use shellCwd - const commandCwd = session.sshRemoteId + const isRemote = !!session.sshRemoteId || !!session.sessionSshRemoteConfig?.enabled; + const commandCwd = isRemote ? (session.remoteCwd || session.sessionSshRemoteConfig?.workingDirOverride || session.cwd) : (session.shellCwd || session.cwd); try { diff --git a/src/renderer/components/AppModals.tsx b/src/renderer/components/AppModals.tsx index 6d24a383..54008af2 100644 --- a/src/renderer/components/AppModals.tsx +++ b/src/renderer/components/AppModals.tsx @@ -1147,7 +1147,7 @@ export function AppUtilityModals({ onFolderSelected={onAutoRunFolderSelected} currentFolder={activeSession?.autoRunFolderPath} sessionName={activeSession?.name} - sshRemoteId={activeSession?.sshRemoteId} + sshRemoteId={activeSession?.sshRemoteId || (activeSession?.sessionSshRemoteConfig?.enabled ? activeSession?.sessionSshRemoteConfig?.remoteId : undefined) || undefined} sshRemoteHost={activeSession?.sshRemote?.host} /> )} diff --git a/src/renderer/components/DocumentGraph/DocumentGraphView.tsx b/src/renderer/components/DocumentGraph/DocumentGraphView.tsx index 75595e53..cbca22f7 100644 --- a/src/renderer/components/DocumentGraph/DocumentGraphView.tsx +++ b/src/renderer/components/DocumentGraph/DocumentGraphView.tsx @@ -39,7 +39,7 @@ import { GraphLegend } from './GraphLegend'; /** Debounce delay for graph rebuilds when settings change (ms) */ const GRAPH_REBUILD_DEBOUNCE_DELAY = 300; /** Default maximum number of nodes to load initially */ -const DEFAULT_MAX_NODES = 50; +const DEFAULT_MAX_NODES = 200; /** Number of additional nodes to load when clicking "Load more" */ const LOAD_MORE_INCREMENT = 25; @@ -161,6 +161,9 @@ export function DocumentGraphView({ // Initially set from props, but can change when user double-clicks a node const [activeFocusFile, setActiveFocusFile] = useState(focusFilePath); + // Track if legend is expanded for layer stack + const [legendExpanded, setLegendExpanded] = useState(false); + /** * Handle escape - show confirmation modal */ @@ -185,6 +188,42 @@ export function DocumentGraphView({ } }, [isOpen, registerLayer, unregisterLayer, handleEscapeRequest]); + /** + * Register depth slider dropdown with layer stack when open + */ + useEffect(() => { + if (showDepthSlider) { + const id = registerLayer({ + type: 'overlay', + priority: MODAL_PRIORITIES.DOCUMENT_GRAPH + 1, + blocksLowerLayers: false, + capturesFocus: false, + focusTrap: 'none', + allowClickOutside: true, + onEscape: () => setShowDepthSlider(false), + }); + return () => unregisterLayer(id); + } + }, [showDepthSlider, registerLayer, unregisterLayer]); + + /** + * Register legend with layer stack when expanded + */ + useEffect(() => { + if (legendExpanded) { + const id = registerLayer({ + type: 'overlay', + priority: MODAL_PRIORITIES.DOCUMENT_GRAPH + 1, + blocksLowerLayers: false, + capturesFocus: false, + focusTrap: 'none', + allowClickOutside: true, + onEscape: () => setLegendExpanded(false), + }); + return () => unregisterLayer(id); + } + }, [legendExpanded, registerLayer, unregisterLayer]); + /** * Focus container on open */ @@ -239,7 +278,8 @@ export function DocumentGraphView({ const graphData = await buildGraphData({ rootPath, - includeExternalLinks, + focusFile: focusFilePath, + maxDepth: neighborDepth > 0 ? neighborDepth : 10, // Use large depth for "all" maxNodes: resetPagination ? defaultMaxNodes : maxNodes, onProgress: handleProgress, }); @@ -308,7 +348,7 @@ export function DocumentGraphView({ } finally { setLoading(false); } - }, [rootPath, includeExternalLinks, maxNodes, defaultMaxNodes, handleProgress, focusFilePath]); + }, [rootPath, includeExternalLinks, maxNodes, defaultMaxNodes, handleProgress, focusFilePath, neighborDepth]); /** * Debounced version of loadGraphData for settings changes @@ -490,7 +530,8 @@ export function DocumentGraphView({ try { const graphData = await buildGraphData({ rootPath, - includeExternalLinks, + focusFile: activeFocusFile || focusFilePath, + maxDepth: neighborDepth > 0 ? neighborDepth : 10, maxNodes: newMaxNodes, }); @@ -510,7 +551,7 @@ export function DocumentGraphView({ } finally { setLoadingMore(false); } - }, [hasMore, loadingMore, maxNodes, rootPath, includeExternalLinks]); + }, [hasMore, loadingMore, maxNodes, rootPath, activeFocusFile, focusFilePath, neighborDepth]); /** * Handle context menu open @@ -1046,7 +1087,12 @@ export function DocumentGraphView({ {/* Graph Legend */} {!loading && !error && nodes.length > 0 && ( - + )} {/* Context Menu */} diff --git a/src/renderer/components/DocumentGraph/GraphLegend.tsx b/src/renderer/components/DocumentGraph/GraphLegend.tsx index ea0702ba..e7987413 100644 --- a/src/renderer/components/DocumentGraph/GraphLegend.tsx +++ b/src/renderer/components/DocumentGraph/GraphLegend.tsx @@ -23,7 +23,11 @@ export interface GraphLegendProps { theme: Theme; /** Whether external links are currently shown in the graph */ showExternalLinks: boolean; - /** Initial expanded state (default: false) */ + /** Controlled expanded state (if provided, component is controlled) */ + isExpanded?: boolean; + /** Callback when expanded state changes (required for controlled mode) */ + onExpandedChange?: (expanded: boolean) => void; + /** Initial expanded state for uncontrolled mode (default: false) */ defaultExpanded?: boolean; } @@ -273,13 +277,24 @@ const KeyboardBadge = memo(function KeyboardBadge({ export const GraphLegend = memo(function GraphLegend({ theme, showExternalLinks, + isExpanded: controlledExpanded, + onExpandedChange, defaultExpanded = false, }: GraphLegendProps) { - const [isExpanded, setIsExpanded] = useState(defaultExpanded); + // Support both controlled and uncontrolled modes + const [uncontrolledExpanded, setUncontrolledExpanded] = useState(defaultExpanded); + const isControlled = controlledExpanded !== undefined; + const isExpanded = isControlled ? controlledExpanded : uncontrolledExpanded; const toggleExpanded = useCallback(() => { - setIsExpanded((prev) => !prev); - }, []); + const newValue = !isExpanded; + if (onExpandedChange) { + onExpandedChange(newValue); + } + if (!isControlled) { + setUncontrolledExpanded(newValue); + } + }, [isExpanded, onExpandedChange, isControlled]); return (
n.id === centerNodeId); - if (centerNode) { - actualCenterNodeId = centerNodeId; - } + // Get all document nodes for searching + const documentNodes = allNodes.filter(n => n.nodeType === 'document'); - // Try without leading slashes - if (!centerNode) { - const normalizedPath = centerFilePath.replace(/^\/+/, ''); - const normalizedNodeId = `doc-${normalizedPath}`; - centerNode = allNodes.find(n => n.id === normalizedNodeId); - if (centerNode) { - actualCenterNodeId = normalizedNodeId; + // Build a list of all node IDs and filePaths for matching + const nodeIdSet = new Set(documentNodes.map(n => n.id)); + const filePathToNode = new Map(); + documentNodes.forEach(n => { + if (n.filePath) { + // Index by full path + filePathToNode.set(n.filePath, n); + // Index by filename only + const filename = n.filePath.split('/').pop(); + if (filename && !filePathToNode.has(filename)) { + filePathToNode.set(filename, n); + } } - } + }); - // Try just the filename (last path segment) - if (!centerNode) { - const filename = centerFilePath.split('/').pop() || centerFilePath; - const filenameNodeId = `doc-${filename}`; - centerNode = allNodes.find(n => n.id === filenameNodeId); - if (centerNode) { - actualCenterNodeId = filenameNodeId; - } - } + // Generate all possible variations of the centerFilePath + const searchVariations = [ + centerFilePath, + centerFilePath.replace(/^\/+/, ''), + centerFilePath.split('/').pop() || centerFilePath, + ]; - // Try matching by filePath property with various normalizations - if (!centerNode) { - const pathVariations = [ - centerFilePath, - centerFilePath.replace(/^\/+/, ''), - centerFilePath.split('/').pop() || centerFilePath, - ]; - - for (const variation of pathVariations) { - centerNode = allNodes.find(n => - n.nodeType === 'document' && - (n.filePath === variation || - n.filePath?.replace(/^\/+/, '') === variation || - n.filePath?.split('/').pop() === variation.split('/').pop()) - ); + // Try to find by node ID first + for (const variation of searchVariations) { + const nodeId = `doc-${variation}`; + if (nodeIdSet.has(nodeId)) { + centerNode = documentNodes.find(n => n.id === nodeId); if (centerNode) { - actualCenterNodeId = centerNode.id; + actualCenterNodeId = nodeId; + break; + } + } + } + + // Try to find by filePath + if (!centerNode) { + for (const variation of searchVariations) { + const node = filePathToNode.get(variation); + if (node) { + centerNode = node; + actualCenterNodeId = node.id; + break; + } + } + } + + // Try fuzzy filename match (case-insensitive, without extension) + if (!centerNode) { + const targetFilename = (centerFilePath.split('/').pop() || centerFilePath).toLowerCase(); + const targetBasename = targetFilename.replace(/\.md$/i, ''); + + for (const node of documentNodes) { + const nodeFilename = (node.filePath?.split('/').pop() || node.label || '').toLowerCase(); + const nodeBasename = nodeFilename.replace(/\.md$/i, ''); + + if (nodeFilename === targetFilename || nodeBasename === targetBasename) { + centerNode = node; + actualCenterNodeId = node.id; break; } } } // Fallback: if still not found and we have document nodes, use the first one - if (!centerNode) { - const documentNodes = allNodes.filter(n => n.nodeType === 'document'); - if (documentNodes.length > 0) { - console.warn( - `[MindMap] Center node not found for path: "${centerFilePath}", falling back to first document`, - '\nAvailable document nodes:', - documentNodes.map(n => n.filePath).slice(0, 10), - documentNodes.length > 10 ? `... and ${documentNodes.length - 10} more` : '' - ); - centerNode = documentNodes[0]; - actualCenterNodeId = centerNode.id; - } + if (!centerNode && documentNodes.length > 0) { + console.warn( + `[MindMap] Center node not found for path: "${centerFilePath}"`, + '\nSearched variations:', searchVariations, + '\nAvailable document nodes:', + documentNodes.map(n => ({ id: n.id, filePath: n.filePath })).slice(0, 10), + documentNodes.length > 10 ? `... and ${documentNodes.length - 10} more` : '' + ); + centerNode = documentNodes[0]; + actualCenterNodeId = centerNode.id; } if (!centerNode) { @@ -382,8 +397,8 @@ function calculateMindMapLayout( return visited.has(n.id); }); - // Separate document and external nodes - const documentNodes = nodesInRange.filter(n => n.nodeType === 'document'); + // Separate document and external nodes from the filtered set + const visibleDocumentNodes = nodesInRange.filter(n => n.nodeType === 'document'); const externalNodes = nodesInRange.filter(n => n.nodeType === 'external'); // Position center node @@ -409,7 +424,7 @@ function calculateMindMapLayout( // Group nodes by depth const nodesByDepth = new Map(); - documentNodes.forEach(node => { + visibleDocumentNodes.forEach(node => { if (node.id === actualCenterNodeId) return; const depth = visited.get(node.id) || 1; if (!nodesByDepth.has(depth)) nodesByDepth.set(depth, []); @@ -738,14 +753,17 @@ export function MindMap({ const canvasRef = useRef(null); const containerRef = useRef(null); - // State + // State - combine zoom and pan into single transform state to avoid jitter const [hoveredNodeId, setHoveredNodeId] = useState(null); const [focusedNodeId, setFocusedNodeId] = useState(null); - const [pan, setPan] = useState({ x: 0, y: 0 }); - const [zoom, setZoom] = useState(1); + const [transform, setTransform] = useState({ zoom: 1, panX: 0, panY: 0 }); const [isDragging, setIsDragging] = useState(false); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + // Derived values for convenience + const zoom = transform.zoom; + const pan = { x: transform.panX, y: transform.panY }; + // Double-click detection const lastClickRef = useRef<{ nodeId: string; time: number } | null>(null); const DOUBLE_CLICK_THRESHOLD = 300; @@ -969,13 +987,14 @@ export function MindMap({ // Center on the center node const centerNode = layout.nodes.find(n => n.isFocused); if (centerNode) { - setPan({ - x: width / 2 - centerNode.x * zoom, - y: height / 2 - centerNode.y * zoom, - }); + setTransform(prev => ({ + ...prev, + panX: width / 2 - centerNode.x * prev.zoom, + panY: height / 2 - centerNode.y * prev.zoom, + })); } } - }, [centerFilePath, width, height, layout.nodes, zoom]); + }, [centerFilePath, width, height, layout.nodes]); // Mouse event handlers const handleMouseDown = useCallback((e: React.MouseEvent) => { @@ -1006,18 +1025,19 @@ export function MindMap({ } else { // Click on background - start panning setIsDragging(true); - setDragStart({ x: e.clientX - pan.x, y: e.clientY - pan.y }); + setDragStart({ x: e.clientX - transform.panX, y: e.clientY - transform.panY }); onNodeSelect(null); setFocusedNodeId(null); } - }, [screenToCanvas, findNodeAtPoint, isClickOnOpenIcon, onOpenFile, onNodeDoubleClick, onNodeSelect, pan]); + }, [screenToCanvas, findNodeAtPoint, isClickOnOpenIcon, onOpenFile, onNodeDoubleClick, onNodeSelect, transform.panX, transform.panY]); const handleMouseMove = useCallback((e: React.MouseEvent) => { if (isDragging) { - setPan({ - x: e.clientX - dragStart.x, - y: e.clientY - dragStart.y, - }); + setTransform(prev => ({ + ...prev, + panX: e.clientX - dragStart.x, + panY: e.clientY - dragStart.y, + })); } else { const { x, y } = screenToCanvas(e.clientX, e.clientY); const node = findNodeAtPoint(x, y); @@ -1052,7 +1072,9 @@ export function MindMap({ } }, [screenToCanvas, findNodeAtPoint, onNodeContextMenu]); - const handleWheel = useCallback((e: React.WheelEvent) => { + // Wheel handler for zooming - must be attached manually with passive: false + // Uses functional updater to avoid stale closures and jitter + const handleWheel = useCallback((e: WheelEvent) => { e.preventDefault(); const rect = canvasRef.current?.getBoundingClientRect(); @@ -1061,18 +1083,30 @@ export function MindMap({ const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; - // Calculate new zoom - const delta = -e.deltaY * 0.001; - const newZoom = Math.min(Math.max(zoom + delta * zoom, 0.2), 3); + setTransform(prev => { + // Calculate new zoom + const delta = -e.deltaY * 0.001; + const newZoom = Math.min(Math.max(prev.zoom + delta * prev.zoom, 0.2), 3); - // Adjust pan to zoom towards mouse position - const zoomRatio = newZoom / zoom; - const newPanX = mouseX - (mouseX - pan.x) * zoomRatio; - const newPanY = mouseY - (mouseY - pan.y) * zoomRatio; + // Adjust pan to zoom towards mouse position + const zoomRatio = newZoom / prev.zoom; + const newPanX = mouseX - (mouseX - prev.panX) * zoomRatio; + const newPanY = mouseY - (mouseY - prev.panY) * zoomRatio; - setZoom(newZoom); - setPan({ x: newPanX, y: newPanY }); - }, [zoom, pan]); + return { zoom: newZoom, panX: newPanX, panY: newPanY }; + }); + }, []); // No dependencies - stable callback + + // Attach wheel event listener with passive: false to allow preventDefault + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + canvas.addEventListener('wheel', handleWheel, { passive: false }); + return () => { + canvas.removeEventListener('wheel', handleWheel); + }; + }, [handleWheel]); // Keyboard navigation const handleKeyDown = useCallback((e: React.KeyboardEvent) => { @@ -1164,30 +1198,33 @@ export function MindMap({ onNodeSelect(nextNode); // Pan to keep focused node visible - const nodeScreenX = nextNode.x * zoom + pan.x; - const nodeScreenY = nextNode.y * zoom + pan.y; - const padding = 100; + setTransform(prev => { + const nodeScreenX = nextNode.x * prev.zoom + prev.panX; + const nodeScreenY = nextNode.y * prev.zoom + prev.panY; + const padding = 100; - let newPanX = pan.x; - let newPanY = pan.y; + let newPanX = prev.panX; + let newPanY = prev.panY; - if (nodeScreenX < padding) { - newPanX = padding - nextNode.x * zoom; - } else if (nodeScreenX > width - padding) { - newPanX = width - padding - nextNode.x * zoom; - } + if (nodeScreenX < padding) { + newPanX = padding - nextNode.x * prev.zoom; + } else if (nodeScreenX > width - padding) { + newPanX = width - padding - nextNode.x * prev.zoom; + } - if (nodeScreenY < padding) { - newPanY = padding - nextNode.y * zoom; - } else if (nodeScreenY > height - padding) { - newPanY = height - padding - nextNode.y * zoom; - } + if (nodeScreenY < padding) { + newPanY = padding - nextNode.y * prev.zoom; + } else if (nodeScreenY > height - padding) { + newPanY = height - padding - nextNode.y * prev.zoom; + } - if (newPanX !== pan.x || newPanY !== pan.y) { - setPan({ x: newPanX, y: newPanY }); - } + if (newPanX !== prev.panX || newPanY !== prev.panY) { + return { ...prev, panX: newPanX, panY: newPanY }; + } + return prev; + }); } - }, [focusedNodeId, nodesWithState, onNodeSelect, onNodeDoubleClick, onOpenFile, zoom, pan, width, height]); + }, [focusedNodeId, nodesWithState, onNodeSelect, onNodeDoubleClick, onOpenFile, width, height]); return (
); diff --git a/src/renderer/components/DocumentGraph/graphDataBuilder.ts b/src/renderer/components/DocumentGraph/graphDataBuilder.ts index c925fc51..62336b20 100644 --- a/src/renderer/components/DocumentGraph/graphDataBuilder.ts +++ b/src/renderer/components/DocumentGraph/graphDataBuilder.ts @@ -78,14 +78,14 @@ export type ProgressCallback = (progress: ProgressData) => void; * Options for building the graph data */ export interface BuildOptions { - /** Whether to include external link nodes in the graph */ - includeExternalLinks: boolean; /** Root directory path to scan for markdown files */ rootPath: string; - /** Maximum number of document nodes to include (for performance with large directories) */ + /** Starting file path (relative to rootPath) - the center of the graph */ + focusFile: string; + /** Maximum depth to traverse from the focus file (default: 3) */ + maxDepth?: number; + /** Maximum number of document nodes to include (for performance) */ maxNodes?: number; - /** Number of nodes to skip (for pagination/load more) */ - offset?: number; /** Optional callback for progress updates during scanning and parsing */ onProgress?: ProgressCallback; } @@ -326,94 +326,135 @@ async function parseFile(rootPath: string, relativePath: string): Promise { - const { rootPath, includeExternalLinks, maxNodes, offset = 0, onProgress } = options; + const { rootPath, focusFile, maxDepth = 3, maxNodes = 100, onProgress } = options; const buildStart = perfMetrics.start(); - // Step 1: Scan for all markdown files - const scanStart = perfMetrics.start(); - const markdownPaths = await scanMarkdownFiles(rootPath, onProgress); - const totalDocuments = markdownPaths.length; - perfMetrics.end(scanStart, 'buildGraphData:scan', { - totalDocuments, - rootPath: rootPath.split('/').slice(-2).join('/'), // Last 2 path segments for privacy - }); + console.log('[DocumentGraph] Building graph from focus file:', { rootPath, focusFile, maxDepth, maxNodes }); - // Step 2: Apply pagination if maxNodes is set - let pathsToProcess = markdownPaths; - if (maxNodes !== undefined && maxNodes > 0) { - pathsToProcess = markdownPaths.slice(offset, offset + maxNodes); + // Track parsed files by path for deduplication + const parsedFileMap = new Map(); + // BFS queue: [relativePath, depth] + const queue: Array<{ path: string; depth: number }> = []; + // Track visited paths to avoid re-processing + const visited = new Set(); + + // Step 1: Parse the focus file first + const focusParsed = await parseFile(rootPath, focusFile); + if (!focusParsed) { + console.error(`[DocumentGraph] Failed to parse focus file: ${focusFile}`); + return { + nodes: [], + edges: [], + totalDocuments: 0, + loadedDocuments: 0, + hasMore: false, + cachedExternalData: { externalNodes: [], externalEdges: [], domainCount: 0, totalLinkCount: 0 }, + internalLinkCount: 0, + }; } - // Step 3: Parse the files we're processing - // We yield to the event loop every BATCH_SIZE_BEFORE_YIELD files to prevent UI blocking - const parseStart = perfMetrics.start(); - const parsedFiles: ParsedFile[] = []; - let runningInternalLinkCount = 0; - let runningExternalLinkCount = 0; + parsedFileMap.set(focusFile, focusParsed); + visited.add(focusFile); - for (let i = 0; i < pathsToProcess.length; i++) { - const relativePath = pathsToProcess[i]; - - const parsed = await parseFile(rootPath, relativePath); - if (parsed) { - parsedFiles.push(parsed); - // Update running link counts - runningInternalLinkCount += parsed.internalLinks.length; - runningExternalLinkCount += parsed.externalLinks.length; + // Add linked files to queue + for (const link of focusParsed.internalLinks) { + if (!visited.has(link)) { + queue.push({ path: link, depth: 1 }); + visited.add(link); } + } - // Report parsing progress with link counts + // Report initial progress + if (onProgress) { + onProgress({ + phase: 'parsing', + current: 1, + total: 1 + queue.length, + currentFile: focusFile, + internalLinksFound: focusParsed.internalLinks.length, + externalLinksFound: focusParsed.externalLinks.length, + }); + } + + // Step 2: BFS traversal to discover connected documents + let filesProcessed = 1; + let totalInternalLinks = focusParsed.internalLinks.length; + let totalExternalLinks = focusParsed.externalLinks.length; + + while (queue.length > 0 && parsedFileMap.size < maxNodes) { + const { path, depth } = queue.shift()!; + + // Skip if beyond max depth + if (depth > maxDepth) continue; + + // Parse the file + const parsed = await parseFile(rootPath, path); + if (!parsed) continue; // File doesn't exist or failed to parse + + parsedFileMap.set(path, parsed); + filesProcessed++; + totalInternalLinks += parsed.internalLinks.length; + totalExternalLinks += parsed.externalLinks.length; + + // Report progress if (onProgress) { onProgress({ phase: 'parsing', - current: i + 1, - total: pathsToProcess.length, - currentFile: relativePath, - internalLinksFound: runningInternalLinkCount, - externalLinksFound: runningExternalLinkCount, + current: filesProcessed, + total: filesProcessed + queue.length, + currentFile: path, + internalLinksFound: totalInternalLinks, + externalLinksFound: totalExternalLinks, }); } - // Yield to event loop periodically to prevent UI blocking - // This is especially important when processing many files or large files - if ((i + 1) % BATCH_SIZE_BEFORE_YIELD === 0) { + // Add linked files to queue (if not at max depth) + if (depth < maxDepth) { + for (const link of parsed.internalLinks) { + if (!visited.has(link)) { + queue.push({ path: link, depth: depth + 1 }); + visited.add(link); + } + } + } + + // Yield to event loop periodically + if (filesProcessed % BATCH_SIZE_BEFORE_YIELD === 0) { await yieldToEventLoop(); } } - perfMetrics.end(parseStart, 'buildGraphData:parse', { - fileCount: pathsToProcess.length, - parsedCount: parsedFiles.length, + + const parsedFiles = Array.from(parsedFileMap.values()); + const loadedPaths = new Set(parsedFileMap.keys()); + + console.log('[DocumentGraph] BFS traversal complete:', { + focusFile, + filesLoaded: parsedFiles.length, + maxDepth, + queueRemaining: queue.length, }); - // Create a set of known file paths for validating internal links - // Note: We use ALL known paths (not just loaded ones) to allow edges to connect properly - const knownPaths = new Set(markdownPaths); - // Track which files we've loaded for edge filtering - const loadedPaths = new Set(parsedFiles.map((f) => f.relativePath)); - - // Step 4: Build document nodes and ALWAYS collect external link data (for caching) + // Step 3: Build document nodes and collect external link data const documentNodes: GraphNode[] = []; const internalEdges: GraphEdge[] = []; - - // Always track external domains for caching (regardless of includeExternalLinks setting) const externalDomains = new Map(); const externalEdges: GraphEdge[] = []; let totalExternalLinkCount = 0; let internalLinkCount = 0; - for (let i = 0; i < parsedFiles.length; i++) { - const file = parsedFiles[i]; + for (const file of parsedFiles) { const nodeId = `doc-${file.relativePath}`; - // Identify broken links (links to files that don't exist in the scanned directory) - const brokenLinks = file.allInternalLinkPaths.filter((link) => !knownPaths.has(link)); + // Identify broken links + const brokenLinks = file.allInternalLinkPaths.filter((link) => !loadedPaths.has(link) && !visited.has(link)); // Create document node documentNodes.push({ @@ -422,15 +463,13 @@ export async function buildGraphData(options: BuildOptions): Promise data: { nodeType: 'document', ...file.stats, - // Only include brokenLinks if there are any ...(brokenLinks.length > 0 ? { brokenLinks } : {}), }, }); - // Create edges for internal links + // Create edges for internal links (only if target is loaded) for (const internalLink of file.internalLinks) { - // Only create edge if target file exists AND is loaded (to avoid dangling edges) - if (knownPaths.has(internalLink) && loadedPaths.has(internalLink)) { + if (loadedPaths.has(internalLink)) { const targetNodeId = `doc-${internalLink}`; internalEdges.push({ id: `edge-${nodeId}-${targetNodeId}`, @@ -442,7 +481,7 @@ export async function buildGraphData(options: BuildOptions): Promise } } - // Always collect external links for caching (even if not currently displayed) + // Collect external links for (const externalLink of file.externalLinks) { totalExternalLinkCount++; const existing = externalDomains.get(externalLink.domain); @@ -452,13 +491,9 @@ export async function buildGraphData(options: BuildOptions): Promise existing.urls.push(externalLink.url); } } else { - externalDomains.set(externalLink.domain, { - count: 1, - urls: [externalLink.url], - }); + externalDomains.set(externalLink.domain, { count: 1, urls: [externalLink.url] }); } - // Create edge from document to external domain (cached for later use) const externalNodeId = `ext-${externalLink.domain}`; externalEdges.push({ id: `edge-${nodeId}-${externalNodeId}`, @@ -469,7 +504,7 @@ export async function buildGraphData(options: BuildOptions): Promise } } - // Step 5: Build external domain nodes (always, for caching) + // Step 4: Build external domain nodes const externalNodes: GraphNode[] = []; for (const [domain, data] of externalDomains) { externalNodes.push({ @@ -484,15 +519,7 @@ export async function buildGraphData(options: BuildOptions): Promise }); } - // Step 6: Assemble final nodes/edges based on includeExternalLinks setting - const nodes: GraphNode[] = includeExternalLinks - ? [...documentNodes, ...externalNodes] - : documentNodes; - const edges: GraphEdge[] = includeExternalLinks - ? [...internalEdges, ...externalEdges] - : internalEdges; - - // Build cached external data for instant toggling + // Build cached external data const cachedExternalData: CachedExternalData = { externalNodes, externalEdges, @@ -500,36 +527,34 @@ export async function buildGraphData(options: BuildOptions): Promise totalLinkCount: totalExternalLinkCount, }; - // Calculate pagination info - const loadedDocuments = parsedFiles.length; - const hasMore = maxNodes !== undefined && maxNodes > 0 && offset + loadedDocuments < totalDocuments; + // Determine if there are more documents (queue had remaining items or hit maxNodes) + const hasMore = queue.length > 0 || parsedFiles.length >= maxNodes; // Log total build time with performance threshold check const totalBuildTime = perfMetrics.end(buildStart, 'buildGraphData:total', { - totalDocuments, - loadedDocuments, - nodeCount: nodes.length, - edgeCount: edges.length, - includeExternalLinks, + totalDocuments: visited.size, + loadedDocuments: parsedFiles.length, + nodeCount: documentNodes.length, + edgeCount: internalEdges.length, externalDomainsCached: externalDomains.size, }); // Warn if build time exceeds thresholds - const threshold = totalDocuments < 100 + const threshold = parsedFiles.length < 100 ? PERFORMANCE_THRESHOLDS.GRAPH_BUILD_SMALL : PERFORMANCE_THRESHOLDS.GRAPH_BUILD_LARGE; if (totalBuildTime > threshold) { console.warn( `[DocumentGraph] buildGraphData took ${totalBuildTime.toFixed(0)}ms (threshold: ${threshold}ms)`, - { totalDocuments, nodeCount: nodes.length, edgeCount: edges.length } + { totalDocuments: visited.size, nodeCount: documentNodes.length, edgeCount: internalEdges.length } ); } return { - nodes, - edges, - totalDocuments, - loadedDocuments, + nodes: documentNodes, + edges: internalEdges, + totalDocuments: visited.size, + loadedDocuments: parsedFiles.length, hasMore, cachedExternalData, internalLinkCount, diff --git a/src/renderer/components/FileExplorerPanel.tsx b/src/renderer/components/FileExplorerPanel.tsx index 5edcd4e5..13d9d327 100644 --- a/src/renderer/components/FileExplorerPanel.tsx +++ b/src/renderer/components/FileExplorerPanel.tsx @@ -688,7 +688,7 @@ function FileExplorerPanelInner(props: FileExplorerPanelProps) { }} >
- {/* Focus in Graph option - only for markdown files */} + {/* Document Graph option - only for markdown files */} {contextMenu.node.type === 'file' && (contextMenu.node.name.endsWith('.md') || contextMenu.node.name.endsWith('.markdown')) && onFocusFileInGraph && ( @@ -699,7 +699,7 @@ function FileExplorerPanelInner(props: FileExplorerPanelProps) { style={{ color: theme.colors.textMain }} > - Focus in Graph + Document Graph
diff --git a/src/renderer/components/InputArea.tsx b/src/renderer/components/InputArea.tsx index f8ecbafe..e3b23426 100644 --- a/src/renderer/components/InputArea.tsx +++ b/src/renderer/components/InputArea.tsx @@ -719,7 +719,7 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) { {session.inputMode === 'terminal' && (
{/* For SSH sessions, show remoteCwd; for local sessions, show shellCwd */} - {(session.sshRemoteId + {((session.sshRemoteId || session.sessionSshRemoteConfig?.enabled) ? (session.remoteCwd || session.sessionSshRemoteConfig?.workingDirOverride || session.cwd) : (session.shellCwd || session.cwd) )?.replace(/^\/Users\/[^\/]+/, '~').replace(/^\/home\/[^\/]+/, '~') || '~'} diff --git a/src/renderer/components/MarkdownRenderer.tsx b/src/renderer/components/MarkdownRenderer.tsx index 8abccde6..118194f0 100644 --- a/src/renderer/components/MarkdownRenderer.tsx +++ b/src/renderer/components/MarkdownRenderer.tsx @@ -286,7 +286,19 @@ export const MarkdownRenderer = memo(({ content, theme, onCopy, className = '', sshRemoteId={sshRemoteId} /> ); - } + }, + table: ({ node: _node, style, ...props }: any) => ( +
+ + + ), }} > {content} diff --git a/src/renderer/components/ParticipantCard.tsx b/src/renderer/components/ParticipantCard.tsx index 1fce8215..86d6bd2e 100644 --- a/src/renderer/components/ParticipantCard.tsx +++ b/src/renderer/components/ParticipantCard.tsx @@ -5,7 +5,7 @@ * session ID, context usage, stats, and cost. */ -import { MessageSquare, Copy, Check, DollarSign, RotateCcw } from 'lucide-react'; +import { MessageSquare, Copy, Check, DollarSign, RotateCcw, Server } from 'lucide-react'; import { useState, useCallback } from 'react'; import type { Theme, GroupChatParticipant, SessionState } from '../types'; import { getStatusColor } from '../utils/theme'; @@ -96,7 +96,7 @@ export function ParticipantCard({ borderLeftColor: color || theme.colors.accent, }} > - {/* Header row: status + name on left, session ID pill on right */} + {/* Header row: status + name on left, SSH pill + session ID pill on right */}
{participant.name} + {/* SSH Remote pill - shown when running on SSH remote */} + {participant.sshRemoteName && ( + + + {participant.sshRemoteName} + + )}
{/* Session ID pill - top right */} {isPending ? ( diff --git a/src/renderer/components/RightPanel.tsx b/src/renderer/components/RightPanel.tsx index 59186ba4..8ef78eea 100644 --- a/src/renderer/components/RightPanel.tsx +++ b/src/renderer/components/RightPanel.tsx @@ -259,7 +259,7 @@ export const RightPanel = memo(forwardRef(fun const autoRunSharedProps = { theme, sessionId: session.id, - sshRemoteId: session.sshRemoteId, + sshRemoteId: session.sshRemoteId || (session.sessionSshRemoteConfig?.enabled ? session.sessionSshRemoteConfig?.remoteId : undefined) || undefined, folderPath: session.autoRunFolderPath || null, selectedFile: session.autoRunSelectedFile || null, documentList: autoRunDocumentList, diff --git a/src/renderer/components/UsageDashboard/ActivityHeatmap.tsx b/src/renderer/components/UsageDashboard/ActivityHeatmap.tsx index cc2d98b3..e3f0e7b6 100644 --- a/src/renderer/components/UsageDashboard/ActivityHeatmap.tsx +++ b/src/renderer/components/UsageDashboard/ActivityHeatmap.tsx @@ -1,20 +1,21 @@ /** * ActivityHeatmap * - * Heatmap showing AI usage activity by hour of day. + * GitHub-style contribution heatmap showing AI usage activity. * For day/week view: shows hours (0-23) on Y-axis, days on X-axis. - * For month+ view: single row with one pixel per day. + * For month/year/all view: GitHub-style grid with weeks as columns, days of week as rows. * * Features: - * - X-axis: days, Y-axis: hours (or single row for month+) + * - GitHub-style layout for month+ views (weeks as columns, Mon-Sun as rows) * - Color intensity toggle between query count and duration - * - Tooltip on hover showing exact time and count/duration + * - Tooltip on hover showing exact date and count/duration * - Theme-aware gradient colors (bgSecondary → accent) - * - Fills available width + * - Fits within viewport width for year-long data + * - Month labels above the grid for navigation */ import React, { useState, useMemo, useCallback } from 'react'; -import { format, subDays } from 'date-fns'; +import { format, subDays, startOfWeek, addDays, getDay } from 'date-fns'; import type { Theme } from '../../types'; import type { StatsTimeRange, StatsAggregation } from '../../hooks/useStats'; import { COLORBLIND_HEATMAP_SCALE } from '../../constants/colorblindPalettes'; @@ -39,6 +40,28 @@ interface DayColumn { hours: HourData[]; } +// GitHub-style data structures +interface DayCell { + date: Date; + dateString: string; + dayOfWeek: number; // 0 = Sunday, 6 = Saturday + count: number; + duration: number; + intensity: number; + isPlaceholder?: boolean; // For empty cells before start date +} + +interface WeekColumn { + weekStart: Date; + days: DayCell[]; +} + +interface MonthLabel { + month: string; + colSpan: number; + startCol: number; +} + interface ActivityHeatmapProps { /** Aggregated stats data from the API */ data: StatsAggregation; @@ -111,6 +134,133 @@ function calculateIntensity(value: number, maxValue: number): number { return 4; } +/** + * Build GitHub-style week columns for the heatmap + * Returns weeks as columns with 7 days each (Sun-Sat or Mon-Sun based on locale) + */ +function buildGitHubGrid( + numDays: number, + dayDataMap: Map, + metricMode: MetricMode +): { weeks: WeekColumn[]; monthLabels: MonthLabel[]; maxCount: number; maxDuration: number } { + const today = new Date(); + const startDate = subDays(today, numDays - 1); + + // Find the Sunday before or on the start date (week starts on Sunday like GitHub) + const gridStart = startOfWeek(startDate, { weekStartsOn: 0 }); + + const weeks: WeekColumn[] = []; + const monthLabels: MonthLabel[] = []; + let maxCount = 0; + let maxDuration = 0; + + let currentDate = gridStart; + let currentWeek: DayCell[] = []; + let lastMonth = ''; + + // Build grid until we pass today + let weekIndex = 0; + while (currentDate <= today || currentWeek.length > 0) { + const dateString = format(currentDate, 'yyyy-MM-dd'); + const dayOfWeek = getDay(currentDate); // 0 = Sunday + const isBeforeStart = currentDate < startDate; + const isAfterEnd = currentDate > today; + + // Track month changes for labels + const monthStr = format(currentDate, 'MMM'); + if (monthStr !== lastMonth && !isBeforeStart && !isAfterEnd) { + // Start of a new month + if (lastMonth !== '') { + // Close out the previous month label + const lastLabel = monthLabels[monthLabels.length - 1]; + if (lastLabel) { + lastLabel.colSpan = weekIndex - lastLabel.startCol; + } + } + monthLabels.push({ + month: monthStr, + colSpan: 1, // Will be updated when month ends + startCol: weekIndex, + }); + lastMonth = monthStr; + } + + const dayStats = dayDataMap.get(dateString) || { count: 0, duration: 0 }; + + if (!isBeforeStart && !isAfterEnd) { + maxCount = Math.max(maxCount, dayStats.count); + maxDuration = Math.max(maxDuration, dayStats.duration); + } + + currentWeek.push({ + date: new Date(currentDate), + dateString, + dayOfWeek, + count: isBeforeStart || isAfterEnd ? 0 : dayStats.count, + duration: isBeforeStart || isAfterEnd ? 0 : dayStats.duration, + intensity: 0, // Calculated later + isPlaceholder: isBeforeStart || isAfterEnd, + }); + + // When we complete a week (Saturday = day 6) + if (dayOfWeek === 6) { + weeks.push({ + weekStart: startOfWeek(currentDate, { weekStartsOn: 0 }), + days: currentWeek, + }); + currentWeek = []; + weekIndex++; + } + + currentDate = addDays(currentDate, 1); + + // Stop if we've gone past today and completed the week + if (isAfterEnd && dayOfWeek === 6) { + break; + } + } + + // Handle partial last week + if (currentWeek.length > 0) { + // Fill remaining days as placeholders + while (currentWeek.length < 7) { + const nextDate = addDays(currentWeek[currentWeek.length - 1].date, 1); + currentWeek.push({ + date: nextDate, + dateString: format(nextDate, 'yyyy-MM-dd'), + dayOfWeek: getDay(nextDate), + count: 0, + duration: 0, + intensity: 0, + isPlaceholder: true, + }); + } + weeks.push({ + weekStart: startOfWeek(currentWeek[0].date, { weekStartsOn: 0 }), + days: currentWeek, + }); + } + + // Close out the last month label + if (monthLabels.length > 0) { + const lastLabel = monthLabels[monthLabels.length - 1]; + lastLabel.colSpan = weeks.length - lastLabel.startCol; + } + + // Calculate intensities + const maxVal = metricMode === 'count' ? Math.max(maxCount, 1) : Math.max(maxDuration, 1); + weeks.forEach((week) => { + week.days.forEach((day) => { + if (!day.isPlaceholder) { + const value = metricMode === 'count' ? day.count : day.duration; + day.intensity = calculateIntensity(value, maxVal); + } + }); + }); + + return { weeks, monthLabels, maxCount, maxDuration }; +} + /** * Get color for a given intensity level */ @@ -171,10 +321,10 @@ function getIntensityColor(intensity: number, theme: Theme, colorBlindMode?: boo export function ActivityHeatmap({ data, timeRange, theme, colorBlindMode = false }: ActivityHeatmapProps) { const [metricMode, setMetricMode] = useState('count'); - const [hoveredCell, setHoveredCell] = useState(null); + const [hoveredCell, setHoveredCell] = useState(null); const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null); - const singleDayMode = shouldUseSingleDayMode(timeRange); + const useGitHubLayout = shouldUseSingleDayMode(timeRange); // Convert byDay data to a lookup map const dayDataMap = useMemo(() => { @@ -185,19 +335,26 @@ export function ActivityHeatmap({ data, timeRange, theme, colorBlindMode = false return map; }, [data.byDay]); - // Generate hour-based data for the heatmap + // GitHub-style grid data for month/year/all views + const gitHubGrid = useMemo(() => { + if (!useGitHubLayout) return null; + const numDays = getDaysForRange(timeRange); + return buildGitHubGrid(numDays, dayDataMap, metricMode); + }, [useGitHubLayout, timeRange, dayDataMap, metricMode]); + + // Generate hour-based data for the heatmap (day/week views) const { dayColumns, hourLabels } = useMemo(() => { + if (useGitHubLayout) { + return { dayColumns: [], hourLabels: [] }; + } + const numDays = getDaysForRange(timeRange); const today = new Date(); const columns: DayColumn[] = []; // Determine hour rows based on mode - // Single day mode: one row (whole day), hourly mode: 24 rows - const hours = singleDayMode ? [0] : Array.from({ length: 24 }, (_, i) => i); - // Labels for Y-axis - const labels = singleDayMode - ? [''] // No label needed for single row - : ['12a', '1a', '2a', '3a', '4a', '5a', '6a', '7a', '8a', '9a', '10a', '11a', '12p', '1p', '2p', '3p', '4p', '5p', '6p', '7p', '8p', '9p', '10p', '11p']; + const hours = Array.from({ length: 24 }, (_, i) => i); + const labels = ['12a', '1a', '2a', '3a', '4a', '5a', '6a', '7a', '8a', '9a', '10a', '11a', '12p', '1p', '2p', '3p', '4p', '5p', '6p', '7p', '8p', '9p', '10p', '11p']; // Track max values for intensity calculation let maxCount = 0; @@ -209,25 +366,14 @@ export function ActivityHeatmap({ data, timeRange, theme, colorBlindMode = false const dateString = format(date, 'yyyy-MM-dd'); const dayStats = dayDataMap.get(dateString) || { count: 0, duration: 0 }; - // For single day mode, use full day stats - // For hourly mode, distribute evenly (since we don't have hourly granularity in data) const hourData: HourData[] = hours.map((hour) => { - let count: number; - let duration: number; - - if (singleDayMode) { - // Single day mode: use full day stats - count = dayStats.count; - duration = dayStats.duration; - } else { - // Distribute evenly across hours (simplified - real data would have hourly breakdown) - count = Math.floor(dayStats.count / 24); - duration = Math.floor(dayStats.duration / 24); - // Distribute remainder to typical work hours (9-17) - if (hour >= 9 && hour <= 17) { - count += Math.floor((dayStats.count % 24) / 9); - duration += Math.floor((dayStats.duration % 24) / 9); - } + // Distribute evenly across hours (simplified - real data would have hourly breakdown) + let count = Math.floor(dayStats.count / 24); + let duration = Math.floor(dayStats.duration / 24); + // Distribute remainder to typical work hours (9-17) + if (hour >= 9 && hour <= 17) { + count += Math.floor((dayStats.count % 24) / 9); + duration += Math.floor((dayStats.duration % 24) / 9); } maxCount = Math.max(maxCount, count); @@ -240,7 +386,7 @@ export function ActivityHeatmap({ data, timeRange, theme, colorBlindMode = false hourKey: `${dateString}-${hour.toString().padStart(2, '0')}`, count, duration, - intensity: 0, // Will be calculated after we know max values + intensity: 0, }; }); @@ -265,15 +411,26 @@ export function ActivityHeatmap({ data, timeRange, theme, colorBlindMode = false dayColumns: columns, hourLabels: labels, }; - }, [dayDataMap, metricMode, timeRange, singleDayMode]); + }, [dayDataMap, metricMode, timeRange, useGitHubLayout]); - // Handle mouse events for tooltip - const handleMouseEnter = useCallback( + // Handle mouse events for tooltip (HourData for day/week, DayCell for month+) + const handleMouseEnterHour = useCallback( (cell: HourData, event: React.MouseEvent) => { setHoveredCell(cell); const rect = event.currentTarget.getBoundingClientRect(); + setTooltipPos({ + x: rect.left + rect.width / 2, + y: rect.top, + }); + }, + [] + ); - // Position tooltip centered above the cell + const handleMouseEnterDay = useCallback( + (cell: DayCell, event: React.MouseEvent) => { + if (cell.isPlaceholder) return; + setHoveredCell(cell); + const rect = event.currentTarget.getBoundingClientRect(); setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top, @@ -287,12 +444,15 @@ export function ActivityHeatmap({ data, timeRange, theme, colorBlindMode = false setTooltipPos(null); }, []); + // Day of week labels for GitHub layout (Sun-Sat) + const dayOfWeekLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + return (
{/* Header with title and metric toggle */}
@@ -354,10 +514,89 @@ export function ActivityHeatmap({ data, timeRange, theme, colorBlindMode = false
- {/* Heatmap grid */} -
- {/* Hour labels (Y-axis) - only show for hourly mode */} - {!singleDayMode && ( + {/* GitHub-style heatmap for month/year/all views */} + {useGitHubLayout && gitHubGrid && ( +
+ {/* Day of week labels (Y-axis) */} +
+ {dayOfWeekLabels.map((label, idx) => ( +
+ {/* Only show Mon, Wed, Fri for cleaner look */} + {idx % 2 === 1 ? label : ''} +
+ ))} +
+ + {/* Grid container */} +
+ {/* Month labels row */} +
+ {gitHubGrid.monthLabels.map((monthLabel, idx) => ( +
+ {monthLabel.colSpan >= 3 ? monthLabel.month : ''} +
+ ))} +
+ + {/* Week columns */} +
+ {gitHubGrid.weeks.map((week, weekIdx) => ( +
+ {week.days.map((day) => ( +
handleMouseEnterDay(day, e)} + onMouseLeave={handleMouseLeave} + role="gridcell" + aria-label={day.isPlaceholder ? '' : `${format(day.date, 'MMM d, yyyy')}: ${day.count} ${day.count === 1 ? 'query' : 'queries'}${day.duration > 0 ? `, ${formatDuration(day.duration)}` : ''}`} + tabIndex={day.isPlaceholder ? -1 : 0} + /> + ))} +
+ ))} +
+
+
+ )} + + {/* Original hourly heatmap for day/week views */} + {!useGitHubLayout && ( +
+ {/* Hour labels (Y-axis) */}
{hourLabels.map((label, idx) => (
))}
- )} - {/* Grid of cells */} -
-
- {dayColumns.map((col) => ( -
- {/* Day label */} + {/* Grid of cells */} +
+
+ {dayColumns.map((col) => (
- {col.dayLabel} -
- {/* Hour cells (or single day cell) */} - {col.hours.map((hourData) => ( + {/* Day label */}
handleMouseEnter(hourData, e)} - onMouseLeave={handleMouseLeave} - role="gridcell" - aria-label={`${format(hourData.date, 'MMM d')}${singleDayMode ? '' : ` ${hourData.hour}:00`}: ${hourData.count} ${hourData.count === 1 ? 'query' : 'queries'}${hourData.duration > 0 ? `, ${formatDuration(hourData.duration)}` : ''}`} - tabIndex={0} - /> - ))} -
- ))} + className="text-xs text-center truncate h-[16px] flex items-center justify-center" + style={{ color: theme.colors.textDim }} + title={format(col.date, 'EEEE, MMM d')} + > + {col.dayLabel} +
+ {/* Hour cells */} + {col.hours.map((hourData) => ( +
handleMouseEnterHour(hourData, e)} + onMouseLeave={handleMouseLeave} + role="gridcell" + aria-label={`${format(hourData.date, 'MMM d')} ${hourData.hour}:00: ${hourData.count} ${hourData.count === 1 ? 'query' : 'queries'}${hourData.duration > 0 ? `, ${formatDuration(hourData.duration)}` : ''}`} + tabIndex={0} + /> + ))} +
+ ))} +
-
+ )} {/* Legend */}
@@ -480,6 +719,9 @@ export function ActivityHeatmap({ data, timeRange, theme, colorBlindMode = false // Check if tooltip would overflow top - if so, show below const wouldOverflowTop = tooltipPos.y - tooltipHeight - margin < 0; + // Determine if this is a DayCell or HourData + const isDayCell = 'dayOfWeek' in hoveredCell; + return (
- {format(hoveredCell.date, 'EEEE, MMM d')} - {!singleDayMode && ` at ${hoveredCell.hour}:00`} + {format(hoveredCell.date, 'EEEE, MMM d, yyyy')} + {!isDayCell && 'hour' in hoveredCell && ` at ${hoveredCell.hour}:00`}
{hoveredCell.count} {hoveredCell.count === 1 ? 'query' : 'queries'} diff --git a/src/renderer/hooks/batch/useAutoRunHandlers.ts b/src/renderer/hooks/batch/useAutoRunHandlers.ts index 7c7084f3..53421d48 100644 --- a/src/renderer/hooks/batch/useAutoRunHandlers.ts +++ b/src/renderer/hooks/batch/useAutoRunHandlers.ts @@ -65,6 +65,18 @@ export interface UseAutoRunHandlersReturn { handleAutoRunCreateDocument: (filename: string) => Promise; } +/** + * Get the SSH remote ID for a session, checking both runtime and config values. + * Returns undefined for local sessions. + * + * Note: sshRemoteId is only set after AI agent spawns. For terminal-only SSH sessions, + * we must fall back to sessionSshRemoteConfig.remoteId. See CLAUDE.md "SSH Remote Sessions". + */ +function getSshRemoteId(session: Session | null): string | undefined { + if (!session) return undefined; + return session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId || undefined; +} + /** * Hook that provides handlers for Auto Run operations. * Extracted from App.tsx to reduce file size and improve maintainability. @@ -96,11 +108,12 @@ export function useAutoRunHandlers( const handleAutoRunFolderSelected = useCallback(async (folderPath: string) => { if (!activeSession) return; + const sshRemoteId = getSshRemoteId(activeSession); let result: { success: boolean; files?: string[]; tree?: AutoRunTreeNode[] } | null = null; try { - // Load the document list from the folder - result = await window.maestro.autorun.listDocs(folderPath); + // Load the document list from the folder (use SSH if remote session) + result = await window.maestro.autorun.listDocs(folderPath, sshRemoteId); } catch { result = null; } @@ -113,7 +126,7 @@ export function useAutoRunHandlers( // Load content of first document let firstFileContent = ''; if (firstFile) { - const contentResult = await window.maestro.autorun.readDoc(folderPath, firstFile + '.md'); + const contentResult = await window.maestro.autorun.readDoc(folderPath, firstFile + '.md', sshRemoteId); if (contentResult.success) { firstFileContent = contentResult.content || ''; } @@ -174,12 +187,13 @@ export function useAutoRunHandlers( // Memoized function to get task count for a document (used by BatchRunnerModal) const getDocumentTaskCount = useCallback(async (filename: string) => { if (!activeSession?.autoRunFolderPath) return 0; - const result = await window.maestro.autorun.readDoc(activeSession.autoRunFolderPath, filename + '.md'); + const sshRemoteId = getSshRemoteId(activeSession); + const result = await window.maestro.autorun.readDoc(activeSession.autoRunFolderPath, filename + '.md', sshRemoteId); if (!result.success || !result.content) return 0; // Count unchecked tasks: - [ ] pattern const matches = result.content.match(/^[\s]*-\s*\[\s*\]\s*.+$/gm); return matches ? matches.length : 0; - }, [activeSession?.autoRunFolderPath]); + }, [activeSession?.autoRunFolderPath, activeSession?.sshRemoteId, activeSession?.sessionSshRemoteConfig]); // Auto Run document content change handler // Updates content in the session state (per-session, not global) @@ -222,10 +236,12 @@ export function useAutoRunHandlers( const handleAutoRunSelectDocument = useCallback(async (filename: string) => { if (!activeSession?.autoRunFolderPath) return; + const sshRemoteId = getSshRemoteId(activeSession); // Load new document content const result = await window.maestro.autorun.readDoc( activeSession.autoRunFolderPath, - filename + '.md' + filename + '.md', + sshRemoteId ); const newContent = result.success ? (result.content || '') : ''; @@ -246,10 +262,11 @@ export function useAutoRunHandlers( // Auto Run refresh handler - reload document list and show flash notification const handleAutoRunRefresh = useCallback(async () => { if (!activeSession?.autoRunFolderPath) return; + const sshRemoteId = getSshRemoteId(activeSession); const previousCount = autoRunDocumentList.length; setAutoRunIsLoadingDocuments(true); try { - const result = await window.maestro.autorun.listDocs(activeSession.autoRunFolderPath); + const result = await window.maestro.autorun.listDocs(activeSession.autoRunFolderPath, sshRemoteId); if (result.success) { const newFiles = result.files || []; setAutoRunDocumentList(newFiles); @@ -272,7 +289,7 @@ export function useAutoRunHandlers( } finally { setAutoRunIsLoadingDocuments(false); } - }, [activeSession?.autoRunFolderPath, autoRunDocumentList.length, setAutoRunDocumentList, setAutoRunDocumentTree, setAutoRunIsLoadingDocuments, setSuccessFlashNotification]); + }, [activeSession?.autoRunFolderPath, activeSession?.sshRemoteId, activeSession?.sessionSshRemoteConfig, autoRunDocumentList.length, setAutoRunDocumentList, setAutoRunDocumentTree, setAutoRunIsLoadingDocuments, setSuccessFlashNotification]); // Auto Run open setup handler const handleAutoRunOpenSetup = useCallback(() => { @@ -283,17 +300,19 @@ export function useAutoRunHandlers( const handleAutoRunCreateDocument = useCallback(async (filename: string): Promise => { if (!activeSession?.autoRunFolderPath) return false; + const sshRemoteId = getSshRemoteId(activeSession); try { // Create the document with empty content so placeholder hint shows const result = await window.maestro.autorun.writeDoc( activeSession.autoRunFolderPath, filename + '.md', - '' + '', + sshRemoteId ); if (result.success) { // Refresh the document list - const listResult = await window.maestro.autorun.listDocs(activeSession.autoRunFolderPath); + const listResult = await window.maestro.autorun.listDocs(activeSession.autoRunFolderPath, sshRemoteId); if (listResult.success) { setAutoRunDocumentList(listResult.files || []); setAutoRunDocumentTree(listResult.tree || []); @@ -319,7 +338,7 @@ export function useAutoRunHandlers( console.error('Failed to create document:', error); return false; } - }, [activeSession, setSessions, setAutoRunDocumentList]); + }, [activeSession, setSessions, setAutoRunDocumentList, setAutoRunDocumentTree]); return { handleAutoRunFolderSelected, diff --git a/src/renderer/hooks/input/useInputProcessing.ts b/src/renderer/hooks/input/useInputProcessing.ts index 7946d08b..548be17d 100644 --- a/src/renderer/hooks/input/useInputProcessing.ts +++ b/src/renderer/hooks/input/useInputProcessing.ts @@ -347,7 +347,8 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces // Track shell CWD changes when in terminal mode // For SSH sessions, use remoteCwd; for local sessions, use shellCwd - const isRemoteSession = !!activeSession.sshRemoteId; + // Check both sshRemoteId (set after spawn) and sessionSshRemoteConfig.enabled (set before spawn) + const isRemoteSession = !!activeSession.sshRemoteId || !!activeSession.sessionSshRemoteConfig?.enabled; let newShellCwd = activeSession.shellCwd || activeSession.cwd; let newRemoteCwd = activeSession.remoteCwd; let cwdChanged = false; @@ -420,9 +421,11 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces } // Verify the directory exists before updating CWD - // Pass SSH remote ID for remote sessions + // Pass SSH remote ID for remote sessions - use sessionSshRemoteConfig.remoteId as fallback + // because sshRemoteId is only set after AI agent spawns, not for terminal-only SSH sessions + const sshIdForVerify = activeSession.sshRemoteId || activeSession.sessionSshRemoteConfig?.remoteId || undefined; try { - await window.maestro.fs.readDir(candidatePath, activeSession.sshRemoteId); + await window.maestro.fs.readDir(candidatePath, sshIdForVerify); // Directory exists, update the appropriate CWD if (isRemoteSession) { remoteCwdChanged = true; @@ -516,7 +519,9 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces if (cwdChanged || remoteCwdChanged) { (async () => { const cwdToCheck = remoteCwdChanged && newRemoteCwd ? newRemoteCwd : newShellCwd; - const isGitRepo = await gitService.isRepo(cwdToCheck, activeSession.sshRemoteId); + // Use sessionSshRemoteConfig.remoteId as fallback for terminal-only SSH sessions + const sshIdForGit = activeSession.sshRemoteId || activeSession.sessionSshRemoteConfig?.remoteId || undefined; + const isGitRepo = await gitService.isRepo(cwdToCheck, sshIdForGit); setSessions((prev) => prev.map((s) => (s.id === activeSessionId ? { ...s, isGitRepo } : s)) ); @@ -753,7 +758,8 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces // This spawns a fresh shell with -l -c to run the command, ensuring aliases work // When SSH is enabled for the session, the command runs on the remote host // For SSH sessions, use remoteCwd (updated by cd commands); for local, use shellCwd - const commandCwd = activeSession.sshRemoteId + const isRemote = !!activeSession.sshRemoteId || !!activeSession.sessionSshRemoteConfig?.enabled; + const commandCwd = isRemote ? (activeSession.remoteCwd || activeSession.sessionSshRemoteConfig?.workingDirOverride || activeSession.cwd) : (activeSession.shellCwd || activeSession.cwd); window.maestro.process diff --git a/src/shared/group-chat-types.ts b/src/shared/group-chat-types.ts index ec39882c..173982ea 100644 --- a/src/shared/group-chat-types.ts +++ b/src/shared/group-chat-types.ts @@ -57,6 +57,8 @@ export interface GroupChatParticipant { processingTimeMs?: number; /** Total cost in USD (optional, depends on provider) */ totalCost?: number; + /** SSH remote name (displayed as pill when running on SSH remote) */ + sshRemoteName?: string; } /**