## CHANGES

- Clarified SSH session identifiers and safest fallback patterns everywhere 🧭
- Auto Run now reads, lists, writes docs over SSH remotes 🛰️
- Participant cards display SSH remote name pill for remote agents 🖥️
- Command execution CWD now correctly detects terminal-only SSH sessions 🧷
- Document Graph rebuilt around focus-file BFS traversal with depth control 🧠
- Default Document Graph node load bumped from 50 to 200 📈
- Graph legend supports controlled expand state and layer-stack behavior 🗂️
- MindMap center-node matching massively improved with fuzzy path handling 🧩
- MindMap zoom/pan stabilized via unified transform state and wheel listener 🛞
- Usage heatmap upgraded to GitHub-style grid with month labels 🗓️
- Markdown tables now scroll horizontally with responsive overflow wrapper 📑
- File explorer context menu now labels “Document Graph” action clearly 🧾
This commit is contained in:
Pedram Amini
2025-12-31 07:52:12 -06:00
parent af7b6398c4
commit 3b75ab0faf
20 changed files with 799 additions and 327 deletions

View File

@@ -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

View File

@@ -57,6 +57,8 @@ export interface SessionOverrides {
customModel?: string;
customArgs?: string;
customEnvVars?: Record<string, string>;
/** 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

View File

@@ -40,6 +40,8 @@ export interface SessionInfo {
customArgs?: string;
customEnvVars?: Record<string, string>;
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);

View File

@@ -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;
}
/**

View File

@@ -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

View File

@@ -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 {

View File

@@ -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}
/>
)}

View File

@@ -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<string | null>(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 && (
<GraphLegend theme={theme} showExternalLinks={includeExternalLinks} />
<GraphLegend
theme={theme}
showExternalLinks={includeExternalLinks}
isExpanded={legendExpanded}
onExpandedChange={setLegendExpanded}
/>
)}
{/* Context Menu */}

View File

@@ -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 (
<div

View File

@@ -274,68 +274,83 @@ function calculateMindMapLayout(
let centerNode: MindMapNode | undefined;
let actualCenterNodeId: string = '';
// Try exact match first
const centerNodeId = `doc-${centerFilePath}`;
centerNode = allNodes.find(n => 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<string, MindMapNode>();
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<number, MindMapNode[]>();
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<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// State
// State - combine zoom and pan into single transform state to avoid jitter
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
const [focusedNodeId, setFocusedNodeId] = useState<string | null>(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 (
<div
@@ -1208,7 +1245,6 @@ export function MindMap({
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
onContextMenu={handleContextMenu}
onWheel={handleWheel}
/>
</div>
);

View File

@@ -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<Parsed
}
/**
* Build graph data from a directory of markdown files
* Build graph data starting from a focus file and traversing outward via links.
* Uses BFS to discover connected documents up to maxDepth levels.
*
* @param options - Build configuration options
* @returns GraphData with nodes and edges
*/
export async function buildGraphData(options: BuildOptions): Promise<GraphData> {
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<string, ParsedFile>();
// BFS queue: [relativePath, depth]
const queue: Array<{ path: string; depth: number }> = [];
// Track visited paths to avoid re-processing
const visited = new Set<string>();
// 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<string, { count: number; urls: string[] }>();
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<GraphData>
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<GraphData>
}
}
// 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<GraphData>
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<GraphData>
}
}
// 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<GraphData>
});
}
// 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<GraphData>
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,

View File

@@ -688,7 +688,7 @@ function FileExplorerPanelInner(props: FileExplorerPanelProps) {
}}
>
<div className="p-1">
{/* 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 }}
>
<Target className="w-3.5 h-3.5" style={{ color: theme.colors.accent }} />
<span>Focus in Graph</span>
<span>Document Graph</span>
</button>
<div className="my-1 border-t" style={{ borderColor: theme.colors.border }} />
</>

View File

@@ -719,7 +719,7 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) {
{session.inputMode === 'terminal' && (
<div className="text-xs font-mono opacity-60 px-2" style={{ color: theme.colors.textDim }}>
{/* 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\/[^\/]+/, '~') || '~'}

View File

@@ -286,7 +286,19 @@ export const MarkdownRenderer = memo(({ content, theme, onCopy, className = '',
sshRemoteId={sshRemoteId}
/>
);
}
},
table: ({ node: _node, style, ...props }: any) => (
<div className="overflow-x-auto scrollbar-thin">
<table
{...props}
style={{
minWidth: '100%',
width: 'max-content',
...(style || {}),
}}
/>
</div>
),
}}
>
{content}

View File

@@ -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 */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div
@@ -110,6 +110,16 @@ export function ParticipantCard({
>
{participant.name}
</span>
{/* SSH Remote pill - shown when running on SSH remote */}
{participant.sshRemoteName && (
<span
className="flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full shrink-0 border border-purple-500/30 text-purple-500 bg-purple-500/10 max-w-[80px]"
title={`SSH Remote: ${participant.sshRemoteName}`}
>
<Server className="w-2.5 h-2.5 shrink-0" />
<span className="truncate uppercase">{participant.sshRemoteName}</span>
</span>
)}
</div>
{/* Session ID pill - top right */}
{isPending ? (

View File

@@ -259,7 +259,7 @@ export const RightPanel = memo(forwardRef<RightPanelHandle, RightPanelProps>(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,

View File

@@ -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<string, { count: number; duration: number }>,
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<MetricMode>('count');
const [hoveredCell, setHoveredCell] = useState<HourData | null>(null);
const [hoveredCell, setHoveredCell] = useState<HourData | DayCell | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<div
className="p-4 rounded-lg"
style={{ backgroundColor: theme.colors.bgMain }}
role="figure"
aria-label={`Activity heatmap showing ${metricMode === 'count' ? 'query activity' : 'duration'} ${singleDayMode ? 'per day' : 'by hour'} over ${getDaysForRange(timeRange)} days.`}
aria-label={`Activity heatmap showing ${metricMode === 'count' ? 'query activity' : 'duration'} over ${getDaysForRange(timeRange)} days.`}
>
{/* Header with title and metric toggle */}
<div className="flex items-center justify-between mb-4">
@@ -354,10 +514,89 @@ export function ActivityHeatmap({ data, timeRange, theme, colorBlindMode = false
</div>
</div>
{/* Heatmap grid */}
<div className="flex gap-2">
{/* Hour labels (Y-axis) - only show for hourly mode */}
{!singleDayMode && (
{/* GitHub-style heatmap for month/year/all views */}
{useGitHubLayout && gitHubGrid && (
<div className="flex gap-2">
{/* Day of week labels (Y-axis) */}
<div className="flex flex-col flex-shrink-0" style={{ width: 32, paddingTop: 20 }}>
{dayOfWeekLabels.map((label, idx) => (
<div
key={idx}
className="text-xs text-right flex items-center justify-end pr-1"
style={{
color: theme.colors.textDim,
height: 13,
}}
>
{/* Only show Mon, Wed, Fri for cleaner look */}
{idx % 2 === 1 ? label : ''}
</div>
))}
</div>
{/* Grid container */}
<div className="flex-1 overflow-x-auto">
{/* Month labels row */}
<div className="flex" style={{ marginBottom: 4, height: 16 }}>
{gitHubGrid.monthLabels.map((monthLabel, idx) => (
<div
key={`${monthLabel.month}-${idx}`}
className="text-xs"
style={{
color: theme.colors.textDim,
width: monthLabel.colSpan * 13, // 11px cell + 2px gap
paddingLeft: 2,
flexShrink: 0,
}}
>
{monthLabel.colSpan >= 3 ? monthLabel.month : ''}
</div>
))}
</div>
{/* Week columns */}
<div className="flex gap-[2px]">
{gitHubGrid.weeks.map((week, weekIdx) => (
<div
key={weekIdx}
className="flex flex-col gap-[2px]"
style={{ width: 11, flexShrink: 0 }}
>
{week.days.map((day) => (
<div
key={day.dateString}
className="rounded-sm cursor-default"
style={{
width: 11,
height: 11,
backgroundColor: day.isPlaceholder
? 'transparent'
: getIntensityColor(day.intensity, theme, colorBlindMode),
outline:
hoveredCell && 'dateString' in hoveredCell && hoveredCell.dateString === day.dateString && !day.isPlaceholder
? `2px solid ${theme.colors.accent}`
: 'none',
outlineOffset: -1,
transition: 'background-color 0.3s ease, outline 0.15s ease',
}}
onMouseEnter={(e) => 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}
/>
))}
</div>
))}
</div>
</div>
</div>
)}
{/* Original hourly heatmap for day/week views */}
{!useGitHubLayout && (
<div className="flex gap-2">
{/* Hour labels (Y-axis) */}
<div className="flex flex-col flex-shrink-0" style={{ width: 28, paddingTop: 18 }}>
{hourLabels.map((label, idx) => (
<div
@@ -373,56 +612,56 @@ export function ActivityHeatmap({ data, timeRange, theme, colorBlindMode = false
</div>
))}
</div>
)}
{/* Grid of cells */}
<div className="flex-1">
<div className="flex gap-[3px]">
{dayColumns.map((col) => (
<div
key={col.dateString}
className="flex flex-col gap-[2px] flex-1"
style={{ minWidth: 20 }}
>
{/* Day label */}
{/* Grid of cells */}
<div className="flex-1">
<div className="flex gap-[3px]">
{dayColumns.map((col) => (
<div
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')}
key={col.dateString}
className="flex flex-col gap-[2px] flex-1"
style={{ minWidth: 20 }}
>
{col.dayLabel}
</div>
{/* Hour cells (or single day cell) */}
{col.hours.map((hourData) => (
{/* Day label */}
<div
key={hourData.hourKey}
className="rounded-sm cursor-default"
style={{
height: singleDayMode ? 20 : 14,
backgroundColor: getIntensityColor(
hourData.intensity,
theme,
colorBlindMode
),
outline:
hoveredCell?.hourKey === hourData.hourKey
? `2px solid ${theme.colors.accent}`
: 'none',
outlineOffset: -1,
transition: 'background-color 0.3s ease, outline 0.15s ease',
}}
onMouseEnter={(e) => 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}
/>
))}
</div>
))}
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}
</div>
{/* Hour cells */}
{col.hours.map((hourData) => (
<div
key={hourData.hourKey}
className="rounded-sm cursor-default"
style={{
height: 14,
backgroundColor: getIntensityColor(
hourData.intensity,
theme,
colorBlindMode
),
outline:
hoveredCell && 'hourKey' in hoveredCell && hoveredCell.hourKey === hourData.hourKey
? `2px solid ${theme.colors.accent}`
: 'none',
outlineOffset: -1,
transition: 'background-color 0.3s ease, outline 0.15s ease',
}}
onMouseEnter={(e) => 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}
/>
))}
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* Legend */}
<div className="flex items-center justify-end gap-2 mt-3" role="list" aria-label="Activity intensity scale from less to more">
@@ -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 (
<div
className="fixed z-50 px-2 py-1.5 rounded text-xs whitespace-nowrap pointer-events-none shadow-lg"
@@ -493,8 +735,8 @@ export function ActivityHeatmap({ data, timeRange, theme, colorBlindMode = false
}}
>
<div className="font-medium mb-0.5">
{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`}
</div>
<div style={{ color: theme.colors.textDim }}>
{hoveredCell.count} {hoveredCell.count === 1 ? 'query' : 'queries'}

View File

@@ -65,6 +65,18 @@ export interface UseAutoRunHandlersReturn {
handleAutoRunCreateDocument: (filename: string) => Promise<boolean>;
}
/**
* 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<boolean> => {
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,

View File

@@ -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

View File

@@ -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;
}
/**