mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
## 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:
40
CLAUDE.md
40
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }} />
|
||||
</>
|
||||
|
||||
@@ -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\/[^\/]+/, '~') || '~'}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user