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.
|
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
|
## Code Conventions
|
||||||
|
|
||||||
### TypeScript
|
### TypeScript
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ export interface SessionOverrides {
|
|||||||
customModel?: string;
|
customModel?: string;
|
||||||
customArgs?: string;
|
customArgs?: string;
|
||||||
customEnvVars?: Record<string, string>;
|
customEnvVars?: Record<string, string>;
|
||||||
|
/** SSH remote name for display in participant card */
|
||||||
|
sshRemoteName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -182,6 +184,7 @@ export async function addParticipant(
|
|||||||
agentId,
|
agentId,
|
||||||
sessionId,
|
sessionId,
|
||||||
addedAt: Date.now(),
|
addedAt: Date.now(),
|
||||||
|
sshRemoteName: sessionOverrides?.sshRemoteName,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Store the session mapping
|
// Store the session mapping
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export interface SessionInfo {
|
|||||||
customArgs?: string;
|
customArgs?: string;
|
||||||
customEnvVars?: Record<string, string>;
|
customEnvVars?: Record<string, string>;
|
||||||
customModel?: string;
|
customModel?: string;
|
||||||
|
/** SSH remote name for display in participant card */
|
||||||
|
sshRemoteName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -276,11 +278,12 @@ export async function routeUserMessage(
|
|||||||
agentDetector,
|
agentDetector,
|
||||||
agentConfigValues,
|
agentConfigValues,
|
||||||
customEnvVars,
|
customEnvVars,
|
||||||
// Pass session-specific overrides (customModel, customArgs, customEnvVars from session)
|
// Pass session-specific overrides (customModel, customArgs, customEnvVars, sshRemoteName from session)
|
||||||
{
|
{
|
||||||
customModel: matchingSession.customModel,
|
customModel: matchingSession.customModel,
|
||||||
customArgs: matchingSession.customArgs,
|
customArgs: matchingSession.customArgs,
|
||||||
customEnvVars: matchingSession.customEnvVars,
|
customEnvVars: matchingSession.customEnvVars,
|
||||||
|
sshRemoteName: matchingSession.sshRemoteName,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
existingParticipantNames.add(participantName);
|
existingParticipantNames.add(participantName);
|
||||||
@@ -565,11 +568,12 @@ export async function routeModeratorResponse(
|
|||||||
agentDetector,
|
agentDetector,
|
||||||
agentConfigValues,
|
agentConfigValues,
|
||||||
customEnvVars,
|
customEnvVars,
|
||||||
// Pass session-specific overrides (customModel, customArgs, customEnvVars from session)
|
// Pass session-specific overrides (customModel, customArgs, customEnvVars, sshRemoteName from session)
|
||||||
{
|
{
|
||||||
customModel: matchingSession.customModel,
|
customModel: matchingSession.customModel,
|
||||||
customArgs: matchingSession.customArgs,
|
customArgs: matchingSession.customArgs,
|
||||||
customEnvVars: matchingSession.customEnvVars,
|
customEnvVars: matchingSession.customEnvVars,
|
||||||
|
sshRemoteName: matchingSession.sshRemoteName,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
existingParticipantNames.add(participantName);
|
existingParticipantNames.add(participantName);
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export interface GroupChatParticipant {
|
|||||||
processingTimeMs?: number;
|
processingTimeMs?: number;
|
||||||
/** Total cost in USD (optional, depends on provider) */
|
/** Total cost in USD (optional, depends on provider) */
|
||||||
totalCost?: number;
|
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
|
// Set up callback for group chat router to lookup sessions for auto-add @mentions
|
||||||
setGetSessionsCallback(() => {
|
setGetSessionsCallback(() => {
|
||||||
const sessions = sessionsStore.get('sessions', []);
|
const sessions = sessionsStore.get('sessions', []);
|
||||||
return sessions.map((s: any) => ({
|
return sessions.map((s: any) => {
|
||||||
id: s.id,
|
// Resolve SSH remote name if session has SSH config
|
||||||
name: s.name,
|
let sshRemoteName: string | undefined;
|
||||||
toolType: s.toolType,
|
if (s.sessionSshRemoteConfig?.enabled && s.sessionSshRemoteConfig.remoteId) {
|
||||||
cwd: s.cwd || s.fullPath || process.env.HOME || '/tmp',
|
const sshConfig = getSshRemoteById(s.sessionSshRemoteConfig.remoteId);
|
||||||
customArgs: s.customArgs,
|
sshRemoteName = sshConfig?.name;
|
||||||
customEnvVars: s.customEnvVars,
|
}
|
||||||
customModel: s.customModel,
|
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
|
// 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
|
// 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
|
// When SSH is enabled for the session, the command runs on the remote host
|
||||||
// For SSH sessions, use remoteCwd; for local, use shellCwd
|
// 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.remoteCwd || session.sessionSshRemoteConfig?.workingDirOverride || session.cwd)
|
||||||
: (session.shellCwd || session.cwd);
|
: (session.shellCwd || session.cwd);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1147,7 +1147,7 @@ export function AppUtilityModals({
|
|||||||
onFolderSelected={onAutoRunFolderSelected}
|
onFolderSelected={onAutoRunFolderSelected}
|
||||||
currentFolder={activeSession?.autoRunFolderPath}
|
currentFolder={activeSession?.autoRunFolderPath}
|
||||||
sessionName={activeSession?.name}
|
sessionName={activeSession?.name}
|
||||||
sshRemoteId={activeSession?.sshRemoteId}
|
sshRemoteId={activeSession?.sshRemoteId || (activeSession?.sessionSshRemoteConfig?.enabled ? activeSession?.sessionSshRemoteConfig?.remoteId : undefined) || undefined}
|
||||||
sshRemoteHost={activeSession?.sshRemote?.host}
|
sshRemoteHost={activeSession?.sshRemote?.host}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import { GraphLegend } from './GraphLegend';
|
|||||||
/** Debounce delay for graph rebuilds when settings change (ms) */
|
/** Debounce delay for graph rebuilds when settings change (ms) */
|
||||||
const GRAPH_REBUILD_DEBOUNCE_DELAY = 300;
|
const GRAPH_REBUILD_DEBOUNCE_DELAY = 300;
|
||||||
/** Default maximum number of nodes to load initially */
|
/** 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" */
|
/** Number of additional nodes to load when clicking "Load more" */
|
||||||
const LOAD_MORE_INCREMENT = 25;
|
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
|
// Initially set from props, but can change when user double-clicks a node
|
||||||
const [activeFocusFile, setActiveFocusFile] = useState<string | null>(focusFilePath);
|
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
|
* Handle escape - show confirmation modal
|
||||||
*/
|
*/
|
||||||
@@ -185,6 +188,42 @@ export function DocumentGraphView({
|
|||||||
}
|
}
|
||||||
}, [isOpen, registerLayer, unregisterLayer, handleEscapeRequest]);
|
}, [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
|
* Focus container on open
|
||||||
*/
|
*/
|
||||||
@@ -239,7 +278,8 @@ export function DocumentGraphView({
|
|||||||
|
|
||||||
const graphData = await buildGraphData({
|
const graphData = await buildGraphData({
|
||||||
rootPath,
|
rootPath,
|
||||||
includeExternalLinks,
|
focusFile: focusFilePath,
|
||||||
|
maxDepth: neighborDepth > 0 ? neighborDepth : 10, // Use large depth for "all"
|
||||||
maxNodes: resetPagination ? defaultMaxNodes : maxNodes,
|
maxNodes: resetPagination ? defaultMaxNodes : maxNodes,
|
||||||
onProgress: handleProgress,
|
onProgress: handleProgress,
|
||||||
});
|
});
|
||||||
@@ -308,7 +348,7 @@ export function DocumentGraphView({
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [rootPath, includeExternalLinks, maxNodes, defaultMaxNodes, handleProgress, focusFilePath]);
|
}, [rootPath, includeExternalLinks, maxNodes, defaultMaxNodes, handleProgress, focusFilePath, neighborDepth]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Debounced version of loadGraphData for settings changes
|
* Debounced version of loadGraphData for settings changes
|
||||||
@@ -490,7 +530,8 @@ export function DocumentGraphView({
|
|||||||
try {
|
try {
|
||||||
const graphData = await buildGraphData({
|
const graphData = await buildGraphData({
|
||||||
rootPath,
|
rootPath,
|
||||||
includeExternalLinks,
|
focusFile: activeFocusFile || focusFilePath,
|
||||||
|
maxDepth: neighborDepth > 0 ? neighborDepth : 10,
|
||||||
maxNodes: newMaxNodes,
|
maxNodes: newMaxNodes,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -510,7 +551,7 @@ export function DocumentGraphView({
|
|||||||
} finally {
|
} finally {
|
||||||
setLoadingMore(false);
|
setLoadingMore(false);
|
||||||
}
|
}
|
||||||
}, [hasMore, loadingMore, maxNodes, rootPath, includeExternalLinks]);
|
}, [hasMore, loadingMore, maxNodes, rootPath, activeFocusFile, focusFilePath, neighborDepth]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle context menu open
|
* Handle context menu open
|
||||||
@@ -1046,7 +1087,12 @@ export function DocumentGraphView({
|
|||||||
|
|
||||||
{/* Graph Legend */}
|
{/* Graph Legend */}
|
||||||
{!loading && !error && nodes.length > 0 && (
|
{!loading && !error && nodes.length > 0 && (
|
||||||
<GraphLegend theme={theme} showExternalLinks={includeExternalLinks} />
|
<GraphLegend
|
||||||
|
theme={theme}
|
||||||
|
showExternalLinks={includeExternalLinks}
|
||||||
|
isExpanded={legendExpanded}
|
||||||
|
onExpandedChange={setLegendExpanded}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Context Menu */}
|
{/* Context Menu */}
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ export interface GraphLegendProps {
|
|||||||
theme: Theme;
|
theme: Theme;
|
||||||
/** Whether external links are currently shown in the graph */
|
/** Whether external links are currently shown in the graph */
|
||||||
showExternalLinks: boolean;
|
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;
|
defaultExpanded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,13 +277,24 @@ const KeyboardBadge = memo(function KeyboardBadge({
|
|||||||
export const GraphLegend = memo(function GraphLegend({
|
export const GraphLegend = memo(function GraphLegend({
|
||||||
theme,
|
theme,
|
||||||
showExternalLinks,
|
showExternalLinks,
|
||||||
|
isExpanded: controlledExpanded,
|
||||||
|
onExpandedChange,
|
||||||
defaultExpanded = false,
|
defaultExpanded = false,
|
||||||
}: GraphLegendProps) {
|
}: 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(() => {
|
const toggleExpanded = useCallback(() => {
|
||||||
setIsExpanded((prev) => !prev);
|
const newValue = !isExpanded;
|
||||||
}, []);
|
if (onExpandedChange) {
|
||||||
|
onExpandedChange(newValue);
|
||||||
|
}
|
||||||
|
if (!isControlled) {
|
||||||
|
setUncontrolledExpanded(newValue);
|
||||||
|
}
|
||||||
|
}, [isExpanded, onExpandedChange, isControlled]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -274,68 +274,83 @@ function calculateMindMapLayout(
|
|||||||
let centerNode: MindMapNode | undefined;
|
let centerNode: MindMapNode | undefined;
|
||||||
let actualCenterNodeId: string = '';
|
let actualCenterNodeId: string = '';
|
||||||
|
|
||||||
// Try exact match first
|
// Get all document nodes for searching
|
||||||
const centerNodeId = `doc-${centerFilePath}`;
|
const documentNodes = allNodes.filter(n => n.nodeType === 'document');
|
||||||
centerNode = allNodes.find(n => n.id === centerNodeId);
|
|
||||||
if (centerNode) {
|
|
||||||
actualCenterNodeId = centerNodeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try without leading slashes
|
// Build a list of all node IDs and filePaths for matching
|
||||||
if (!centerNode) {
|
const nodeIdSet = new Set(documentNodes.map(n => n.id));
|
||||||
const normalizedPath = centerFilePath.replace(/^\/+/, '');
|
const filePathToNode = new Map<string, MindMapNode>();
|
||||||
const normalizedNodeId = `doc-${normalizedPath}`;
|
documentNodes.forEach(n => {
|
||||||
centerNode = allNodes.find(n => n.id === normalizedNodeId);
|
if (n.filePath) {
|
||||||
if (centerNode) {
|
// Index by full path
|
||||||
actualCenterNodeId = normalizedNodeId;
|
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)
|
// Generate all possible variations of the centerFilePath
|
||||||
if (!centerNode) {
|
const searchVariations = [
|
||||||
const filename = centerFilePath.split('/').pop() || centerFilePath;
|
centerFilePath,
|
||||||
const filenameNodeId = `doc-${filename}`;
|
centerFilePath.replace(/^\/+/, ''),
|
||||||
centerNode = allNodes.find(n => n.id === filenameNodeId);
|
centerFilePath.split('/').pop() || centerFilePath,
|
||||||
if (centerNode) {
|
];
|
||||||
actualCenterNodeId = filenameNodeId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try matching by filePath property with various normalizations
|
// Try to find by node ID first
|
||||||
if (!centerNode) {
|
for (const variation of searchVariations) {
|
||||||
const pathVariations = [
|
const nodeId = `doc-${variation}`;
|
||||||
centerFilePath,
|
if (nodeIdSet.has(nodeId)) {
|
||||||
centerFilePath.replace(/^\/+/, ''),
|
centerNode = documentNodes.find(n => n.id === nodeId);
|
||||||
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())
|
|
||||||
);
|
|
||||||
if (centerNode) {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: if still not found and we have document nodes, use the first one
|
// Fallback: if still not found and we have document nodes, use the first one
|
||||||
if (!centerNode) {
|
if (!centerNode && documentNodes.length > 0) {
|
||||||
const documentNodes = allNodes.filter(n => n.nodeType === 'document');
|
console.warn(
|
||||||
if (documentNodes.length > 0) {
|
`[MindMap] Center node not found for path: "${centerFilePath}"`,
|
||||||
console.warn(
|
'\nSearched variations:', searchVariations,
|
||||||
`[MindMap] Center node not found for path: "${centerFilePath}", falling back to first document`,
|
'\nAvailable document nodes:',
|
||||||
'\nAvailable document nodes:',
|
documentNodes.map(n => ({ id: n.id, filePath: n.filePath })).slice(0, 10),
|
||||||
documentNodes.map(n => n.filePath).slice(0, 10),
|
documentNodes.length > 10 ? `... and ${documentNodes.length - 10} more` : ''
|
||||||
documentNodes.length > 10 ? `... and ${documentNodes.length - 10} more` : ''
|
);
|
||||||
);
|
centerNode = documentNodes[0];
|
||||||
centerNode = documentNodes[0];
|
actualCenterNodeId = centerNode.id;
|
||||||
actualCenterNodeId = centerNode.id;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!centerNode) {
|
if (!centerNode) {
|
||||||
@@ -382,8 +397,8 @@ function calculateMindMapLayout(
|
|||||||
return visited.has(n.id);
|
return visited.has(n.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Separate document and external nodes
|
// Separate document and external nodes from the filtered set
|
||||||
const documentNodes = nodesInRange.filter(n => n.nodeType === 'document');
|
const visibleDocumentNodes = nodesInRange.filter(n => n.nodeType === 'document');
|
||||||
const externalNodes = nodesInRange.filter(n => n.nodeType === 'external');
|
const externalNodes = nodesInRange.filter(n => n.nodeType === 'external');
|
||||||
|
|
||||||
// Position center node
|
// Position center node
|
||||||
@@ -409,7 +424,7 @@ function calculateMindMapLayout(
|
|||||||
|
|
||||||
// Group nodes by depth
|
// Group nodes by depth
|
||||||
const nodesByDepth = new Map<number, MindMapNode[]>();
|
const nodesByDepth = new Map<number, MindMapNode[]>();
|
||||||
documentNodes.forEach(node => {
|
visibleDocumentNodes.forEach(node => {
|
||||||
if (node.id === actualCenterNodeId) return;
|
if (node.id === actualCenterNodeId) return;
|
||||||
const depth = visited.get(node.id) || 1;
|
const depth = visited.get(node.id) || 1;
|
||||||
if (!nodesByDepth.has(depth)) nodesByDepth.set(depth, []);
|
if (!nodesByDepth.has(depth)) nodesByDepth.set(depth, []);
|
||||||
@@ -738,14 +753,17 @@ export function MindMap({
|
|||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(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 [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
|
||||||
const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null);
|
const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null);
|
||||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
const [transform, setTransform] = useState({ zoom: 1, panX: 0, panY: 0 });
|
||||||
const [zoom, setZoom] = useState(1);
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
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
|
// Double-click detection
|
||||||
const lastClickRef = useRef<{ nodeId: string; time: number } | null>(null);
|
const lastClickRef = useRef<{ nodeId: string; time: number } | null>(null);
|
||||||
const DOUBLE_CLICK_THRESHOLD = 300;
|
const DOUBLE_CLICK_THRESHOLD = 300;
|
||||||
@@ -969,13 +987,14 @@ export function MindMap({
|
|||||||
// Center on the center node
|
// Center on the center node
|
||||||
const centerNode = layout.nodes.find(n => n.isFocused);
|
const centerNode = layout.nodes.find(n => n.isFocused);
|
||||||
if (centerNode) {
|
if (centerNode) {
|
||||||
setPan({
|
setTransform(prev => ({
|
||||||
x: width / 2 - centerNode.x * zoom,
|
...prev,
|
||||||
y: height / 2 - centerNode.y * zoom,
|
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
|
// Mouse event handlers
|
||||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
@@ -1006,18 +1025,19 @@ export function MindMap({
|
|||||||
} else {
|
} else {
|
||||||
// Click on background - start panning
|
// Click on background - start panning
|
||||||
setIsDragging(true);
|
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);
|
onNodeSelect(null);
|
||||||
setFocusedNodeId(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) => {
|
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
setPan({
|
setTransform(prev => ({
|
||||||
x: e.clientX - dragStart.x,
|
...prev,
|
||||||
y: e.clientY - dragStart.y,
|
panX: e.clientX - dragStart.x,
|
||||||
});
|
panY: e.clientY - dragStart.y,
|
||||||
|
}));
|
||||||
} else {
|
} else {
|
||||||
const { x, y } = screenToCanvas(e.clientX, e.clientY);
|
const { x, y } = screenToCanvas(e.clientX, e.clientY);
|
||||||
const node = findNodeAtPoint(x, y);
|
const node = findNodeAtPoint(x, y);
|
||||||
@@ -1052,7 +1072,9 @@ export function MindMap({
|
|||||||
}
|
}
|
||||||
}, [screenToCanvas, findNodeAtPoint, onNodeContextMenu]);
|
}, [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();
|
e.preventDefault();
|
||||||
|
|
||||||
const rect = canvasRef.current?.getBoundingClientRect();
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
@@ -1061,18 +1083,30 @@ export function MindMap({
|
|||||||
const mouseX = e.clientX - rect.left;
|
const mouseX = e.clientX - rect.left;
|
||||||
const mouseY = e.clientY - rect.top;
|
const mouseY = e.clientY - rect.top;
|
||||||
|
|
||||||
// Calculate new zoom
|
setTransform(prev => {
|
||||||
const delta = -e.deltaY * 0.001;
|
// Calculate new zoom
|
||||||
const newZoom = Math.min(Math.max(zoom + delta * zoom, 0.2), 3);
|
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
|
// Adjust pan to zoom towards mouse position
|
||||||
const zoomRatio = newZoom / zoom;
|
const zoomRatio = newZoom / prev.zoom;
|
||||||
const newPanX = mouseX - (mouseX - pan.x) * zoomRatio;
|
const newPanX = mouseX - (mouseX - prev.panX) * zoomRatio;
|
||||||
const newPanY = mouseY - (mouseY - pan.y) * zoomRatio;
|
const newPanY = mouseY - (mouseY - prev.panY) * zoomRatio;
|
||||||
|
|
||||||
setZoom(newZoom);
|
return { zoom: newZoom, panX: newPanX, panY: newPanY };
|
||||||
setPan({ x: newPanX, y: newPanY });
|
});
|
||||||
}, [zoom, pan]);
|
}, []); // 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
|
// Keyboard navigation
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
@@ -1164,30 +1198,33 @@ export function MindMap({
|
|||||||
onNodeSelect(nextNode);
|
onNodeSelect(nextNode);
|
||||||
|
|
||||||
// Pan to keep focused node visible
|
// Pan to keep focused node visible
|
||||||
const nodeScreenX = nextNode.x * zoom + pan.x;
|
setTransform(prev => {
|
||||||
const nodeScreenY = nextNode.y * zoom + pan.y;
|
const nodeScreenX = nextNode.x * prev.zoom + prev.panX;
|
||||||
const padding = 100;
|
const nodeScreenY = nextNode.y * prev.zoom + prev.panY;
|
||||||
|
const padding = 100;
|
||||||
|
|
||||||
let newPanX = pan.x;
|
let newPanX = prev.panX;
|
||||||
let newPanY = pan.y;
|
let newPanY = prev.panY;
|
||||||
|
|
||||||
if (nodeScreenX < padding) {
|
if (nodeScreenX < padding) {
|
||||||
newPanX = padding - nextNode.x * zoom;
|
newPanX = padding - nextNode.x * prev.zoom;
|
||||||
} else if (nodeScreenX > width - padding) {
|
} else if (nodeScreenX > width - padding) {
|
||||||
newPanX = width - padding - nextNode.x * zoom;
|
newPanX = width - padding - nextNode.x * prev.zoom;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nodeScreenY < padding) {
|
if (nodeScreenY < padding) {
|
||||||
newPanY = padding - nextNode.y * zoom;
|
newPanY = padding - nextNode.y * prev.zoom;
|
||||||
} else if (nodeScreenY > height - padding) {
|
} else if (nodeScreenY > height - padding) {
|
||||||
newPanY = height - padding - nextNode.y * zoom;
|
newPanY = height - padding - nextNode.y * prev.zoom;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPanX !== pan.x || newPanY !== pan.y) {
|
if (newPanX !== prev.panX || newPanY !== prev.panY) {
|
||||||
setPan({ x: newPanX, y: newPanY });
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -1208,7 +1245,6 @@ export function MindMap({
|
|||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
onWheel={handleWheel}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -78,14 +78,14 @@ export type ProgressCallback = (progress: ProgressData) => void;
|
|||||||
* Options for building the graph data
|
* Options for building the graph data
|
||||||
*/
|
*/
|
||||||
export interface BuildOptions {
|
export interface BuildOptions {
|
||||||
/** Whether to include external link nodes in the graph */
|
|
||||||
includeExternalLinks: boolean;
|
|
||||||
/** Root directory path to scan for markdown files */
|
/** Root directory path to scan for markdown files */
|
||||||
rootPath: string;
|
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;
|
maxNodes?: number;
|
||||||
/** Number of nodes to skip (for pagination/load more) */
|
|
||||||
offset?: number;
|
|
||||||
/** Optional callback for progress updates during scanning and parsing */
|
/** Optional callback for progress updates during scanning and parsing */
|
||||||
onProgress?: ProgressCallback;
|
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
|
* @param options - Build configuration options
|
||||||
* @returns GraphData with nodes and edges
|
* @returns GraphData with nodes and edges
|
||||||
*/
|
*/
|
||||||
export async function buildGraphData(options: BuildOptions): Promise<GraphData> {
|
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();
|
const buildStart = perfMetrics.start();
|
||||||
|
|
||||||
// Step 1: Scan for all markdown files
|
console.log('[DocumentGraph] Building graph from focus file:', { rootPath, focusFile, maxDepth, maxNodes });
|
||||||
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
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 2: Apply pagination if maxNodes is set
|
// Track parsed files by path for deduplication
|
||||||
let pathsToProcess = markdownPaths;
|
const parsedFileMap = new Map<string, ParsedFile>();
|
||||||
if (maxNodes !== undefined && maxNodes > 0) {
|
// BFS queue: [relativePath, depth]
|
||||||
pathsToProcess = markdownPaths.slice(offset, offset + maxNodes);
|
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
|
parsedFileMap.set(focusFile, focusParsed);
|
||||||
// We yield to the event loop every BATCH_SIZE_BEFORE_YIELD files to prevent UI blocking
|
visited.add(focusFile);
|
||||||
const parseStart = perfMetrics.start();
|
|
||||||
const parsedFiles: ParsedFile[] = [];
|
|
||||||
let runningInternalLinkCount = 0;
|
|
||||||
let runningExternalLinkCount = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < pathsToProcess.length; i++) {
|
// Add linked files to queue
|
||||||
const relativePath = pathsToProcess[i];
|
for (const link of focusParsed.internalLinks) {
|
||||||
|
if (!visited.has(link)) {
|
||||||
const parsed = await parseFile(rootPath, relativePath);
|
queue.push({ path: link, depth: 1 });
|
||||||
if (parsed) {
|
visited.add(link);
|
||||||
parsedFiles.push(parsed);
|
|
||||||
// Update running link counts
|
|
||||||
runningInternalLinkCount += parsed.internalLinks.length;
|
|
||||||
runningExternalLinkCount += parsed.externalLinks.length;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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) {
|
if (onProgress) {
|
||||||
onProgress({
|
onProgress({
|
||||||
phase: 'parsing',
|
phase: 'parsing',
|
||||||
current: i + 1,
|
current: filesProcessed,
|
||||||
total: pathsToProcess.length,
|
total: filesProcessed + queue.length,
|
||||||
currentFile: relativePath,
|
currentFile: path,
|
||||||
internalLinksFound: runningInternalLinkCount,
|
internalLinksFound: totalInternalLinks,
|
||||||
externalLinksFound: runningExternalLinkCount,
|
externalLinksFound: totalExternalLinks,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Yield to event loop periodically to prevent UI blocking
|
// Add linked files to queue (if not at max depth)
|
||||||
// This is especially important when processing many files or large files
|
if (depth < maxDepth) {
|
||||||
if ((i + 1) % BATCH_SIZE_BEFORE_YIELD === 0) {
|
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();
|
await yieldToEventLoop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
perfMetrics.end(parseStart, 'buildGraphData:parse', {
|
|
||||||
fileCount: pathsToProcess.length,
|
const parsedFiles = Array.from(parsedFileMap.values());
|
||||||
parsedCount: parsedFiles.length,
|
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
|
// Step 3: Build document nodes and collect external link data
|
||||||
// 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)
|
|
||||||
const documentNodes: GraphNode[] = [];
|
const documentNodes: GraphNode[] = [];
|
||||||
const internalEdges: GraphEdge[] = [];
|
const internalEdges: GraphEdge[] = [];
|
||||||
|
|
||||||
// Always track external domains for caching (regardless of includeExternalLinks setting)
|
|
||||||
const externalDomains = new Map<string, { count: number; urls: string[] }>();
|
const externalDomains = new Map<string, { count: number; urls: string[] }>();
|
||||||
const externalEdges: GraphEdge[] = [];
|
const externalEdges: GraphEdge[] = [];
|
||||||
let totalExternalLinkCount = 0;
|
let totalExternalLinkCount = 0;
|
||||||
let internalLinkCount = 0;
|
let internalLinkCount = 0;
|
||||||
|
|
||||||
for (let i = 0; i < parsedFiles.length; i++) {
|
for (const file of parsedFiles) {
|
||||||
const file = parsedFiles[i];
|
|
||||||
const nodeId = `doc-${file.relativePath}`;
|
const nodeId = `doc-${file.relativePath}`;
|
||||||
|
|
||||||
// Identify broken links (links to files that don't exist in the scanned directory)
|
// Identify broken links
|
||||||
const brokenLinks = file.allInternalLinkPaths.filter((link) => !knownPaths.has(link));
|
const brokenLinks = file.allInternalLinkPaths.filter((link) => !loadedPaths.has(link) && !visited.has(link));
|
||||||
|
|
||||||
// Create document node
|
// Create document node
|
||||||
documentNodes.push({
|
documentNodes.push({
|
||||||
@@ -422,15 +463,13 @@ export async function buildGraphData(options: BuildOptions): Promise<GraphData>
|
|||||||
data: {
|
data: {
|
||||||
nodeType: 'document',
|
nodeType: 'document',
|
||||||
...file.stats,
|
...file.stats,
|
||||||
// Only include brokenLinks if there are any
|
|
||||||
...(brokenLinks.length > 0 ? { brokenLinks } : {}),
|
...(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) {
|
for (const internalLink of file.internalLinks) {
|
||||||
// Only create edge if target file exists AND is loaded (to avoid dangling edges)
|
if (loadedPaths.has(internalLink)) {
|
||||||
if (knownPaths.has(internalLink) && loadedPaths.has(internalLink)) {
|
|
||||||
const targetNodeId = `doc-${internalLink}`;
|
const targetNodeId = `doc-${internalLink}`;
|
||||||
internalEdges.push({
|
internalEdges.push({
|
||||||
id: `edge-${nodeId}-${targetNodeId}`,
|
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) {
|
for (const externalLink of file.externalLinks) {
|
||||||
totalExternalLinkCount++;
|
totalExternalLinkCount++;
|
||||||
const existing = externalDomains.get(externalLink.domain);
|
const existing = externalDomains.get(externalLink.domain);
|
||||||
@@ -452,13 +491,9 @@ export async function buildGraphData(options: BuildOptions): Promise<GraphData>
|
|||||||
existing.urls.push(externalLink.url);
|
existing.urls.push(externalLink.url);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
externalDomains.set(externalLink.domain, {
|
externalDomains.set(externalLink.domain, { count: 1, urls: [externalLink.url] });
|
||||||
count: 1,
|
|
||||||
urls: [externalLink.url],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create edge from document to external domain (cached for later use)
|
|
||||||
const externalNodeId = `ext-${externalLink.domain}`;
|
const externalNodeId = `ext-${externalLink.domain}`;
|
||||||
externalEdges.push({
|
externalEdges.push({
|
||||||
id: `edge-${nodeId}-${externalNodeId}`,
|
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[] = [];
|
const externalNodes: GraphNode[] = [];
|
||||||
for (const [domain, data] of externalDomains) {
|
for (const [domain, data] of externalDomains) {
|
||||||
externalNodes.push({
|
externalNodes.push({
|
||||||
@@ -484,15 +519,7 @@ export async function buildGraphData(options: BuildOptions): Promise<GraphData>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: Assemble final nodes/edges based on includeExternalLinks setting
|
// Build cached external data
|
||||||
const nodes: GraphNode[] = includeExternalLinks
|
|
||||||
? [...documentNodes, ...externalNodes]
|
|
||||||
: documentNodes;
|
|
||||||
const edges: GraphEdge[] = includeExternalLinks
|
|
||||||
? [...internalEdges, ...externalEdges]
|
|
||||||
: internalEdges;
|
|
||||||
|
|
||||||
// Build cached external data for instant toggling
|
|
||||||
const cachedExternalData: CachedExternalData = {
|
const cachedExternalData: CachedExternalData = {
|
||||||
externalNodes,
|
externalNodes,
|
||||||
externalEdges,
|
externalEdges,
|
||||||
@@ -500,36 +527,34 @@ export async function buildGraphData(options: BuildOptions): Promise<GraphData>
|
|||||||
totalLinkCount: totalExternalLinkCount,
|
totalLinkCount: totalExternalLinkCount,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate pagination info
|
// Determine if there are more documents (queue had remaining items or hit maxNodes)
|
||||||
const loadedDocuments = parsedFiles.length;
|
const hasMore = queue.length > 0 || parsedFiles.length >= maxNodes;
|
||||||
const hasMore = maxNodes !== undefined && maxNodes > 0 && offset + loadedDocuments < totalDocuments;
|
|
||||||
|
|
||||||
// Log total build time with performance threshold check
|
// Log total build time with performance threshold check
|
||||||
const totalBuildTime = perfMetrics.end(buildStart, 'buildGraphData:total', {
|
const totalBuildTime = perfMetrics.end(buildStart, 'buildGraphData:total', {
|
||||||
totalDocuments,
|
totalDocuments: visited.size,
|
||||||
loadedDocuments,
|
loadedDocuments: parsedFiles.length,
|
||||||
nodeCount: nodes.length,
|
nodeCount: documentNodes.length,
|
||||||
edgeCount: edges.length,
|
edgeCount: internalEdges.length,
|
||||||
includeExternalLinks,
|
|
||||||
externalDomainsCached: externalDomains.size,
|
externalDomainsCached: externalDomains.size,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Warn if build time exceeds thresholds
|
// Warn if build time exceeds thresholds
|
||||||
const threshold = totalDocuments < 100
|
const threshold = parsedFiles.length < 100
|
||||||
? PERFORMANCE_THRESHOLDS.GRAPH_BUILD_SMALL
|
? PERFORMANCE_THRESHOLDS.GRAPH_BUILD_SMALL
|
||||||
: PERFORMANCE_THRESHOLDS.GRAPH_BUILD_LARGE;
|
: PERFORMANCE_THRESHOLDS.GRAPH_BUILD_LARGE;
|
||||||
if (totalBuildTime > threshold) {
|
if (totalBuildTime > threshold) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[DocumentGraph] buildGraphData took ${totalBuildTime.toFixed(0)}ms (threshold: ${threshold}ms)`,
|
`[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 {
|
return {
|
||||||
nodes,
|
nodes: documentNodes,
|
||||||
edges,
|
edges: internalEdges,
|
||||||
totalDocuments,
|
totalDocuments: visited.size,
|
||||||
loadedDocuments,
|
loadedDocuments: parsedFiles.length,
|
||||||
hasMore,
|
hasMore,
|
||||||
cachedExternalData,
|
cachedExternalData,
|
||||||
internalLinkCount,
|
internalLinkCount,
|
||||||
|
|||||||
@@ -688,7 +688,7 @@ function FileExplorerPanelInner(props: FileExplorerPanelProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="p-1">
|
<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.type === 'file' &&
|
||||||
(contextMenu.node.name.endsWith('.md') || contextMenu.node.name.endsWith('.markdown')) &&
|
(contextMenu.node.name.endsWith('.md') || contextMenu.node.name.endsWith('.markdown')) &&
|
||||||
onFocusFileInGraph && (
|
onFocusFileInGraph && (
|
||||||
@@ -699,7 +699,7 @@ function FileExplorerPanelInner(props: FileExplorerPanelProps) {
|
|||||||
style={{ color: theme.colors.textMain }}
|
style={{ color: theme.colors.textMain }}
|
||||||
>
|
>
|
||||||
<Target className="w-3.5 h-3.5" style={{ color: theme.colors.accent }} />
|
<Target className="w-3.5 h-3.5" style={{ color: theme.colors.accent }} />
|
||||||
<span>Focus in Graph</span>
|
<span>Document Graph</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="my-1 border-t" style={{ borderColor: theme.colors.border }} />
|
<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' && (
|
{session.inputMode === 'terminal' && (
|
||||||
<div className="text-xs font-mono opacity-60 px-2" style={{ color: theme.colors.textDim }}>
|
<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 */}
|
{/* 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.remoteCwd || session.sessionSshRemoteConfig?.workingDirOverride || session.cwd)
|
||||||
: (session.shellCwd || session.cwd)
|
: (session.shellCwd || session.cwd)
|
||||||
)?.replace(/^\/Users\/[^\/]+/, '~').replace(/^\/home\/[^\/]+/, '~') || '~'}
|
)?.replace(/^\/Users\/[^\/]+/, '~').replace(/^\/home\/[^\/]+/, '~') || '~'}
|
||||||
|
|||||||
@@ -286,7 +286,19 @@ export const MarkdownRenderer = memo(({ content, theme, onCopy, className = '',
|
|||||||
sshRemoteId={sshRemoteId}
|
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}
|
{content}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* session ID, context usage, stats, and cost.
|
* 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 { useState, useCallback } from 'react';
|
||||||
import type { Theme, GroupChatParticipant, SessionState } from '../types';
|
import type { Theme, GroupChatParticipant, SessionState } from '../types';
|
||||||
import { getStatusColor } from '../utils/theme';
|
import { getStatusColor } from '../utils/theme';
|
||||||
@@ -96,7 +96,7 @@ export function ParticipantCard({
|
|||||||
borderLeftColor: color || theme.colors.accent,
|
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 justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<div
|
<div
|
||||||
@@ -110,6 +110,16 @@ export function ParticipantCard({
|
|||||||
>
|
>
|
||||||
{participant.name}
|
{participant.name}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
{/* Session ID pill - top right */}
|
{/* Session ID pill - top right */}
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ export const RightPanel = memo(forwardRef<RightPanelHandle, RightPanelProps>(fun
|
|||||||
const autoRunSharedProps = {
|
const autoRunSharedProps = {
|
||||||
theme,
|
theme,
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
sshRemoteId: session.sshRemoteId,
|
sshRemoteId: session.sshRemoteId || (session.sessionSshRemoteConfig?.enabled ? session.sessionSshRemoteConfig?.remoteId : undefined) || undefined,
|
||||||
folderPath: session.autoRunFolderPath || null,
|
folderPath: session.autoRunFolderPath || null,
|
||||||
selectedFile: session.autoRunSelectedFile || null,
|
selectedFile: session.autoRunSelectedFile || null,
|
||||||
documentList: autoRunDocumentList,
|
documentList: autoRunDocumentList,
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* ActivityHeatmap
|
* 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 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:
|
* 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
|
* - 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)
|
* - 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 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 { Theme } from '../../types';
|
||||||
import type { StatsTimeRange, StatsAggregation } from '../../hooks/useStats';
|
import type { StatsTimeRange, StatsAggregation } from '../../hooks/useStats';
|
||||||
import { COLORBLIND_HEATMAP_SCALE } from '../../constants/colorblindPalettes';
|
import { COLORBLIND_HEATMAP_SCALE } from '../../constants/colorblindPalettes';
|
||||||
@@ -39,6 +40,28 @@ interface DayColumn {
|
|||||||
hours: HourData[];
|
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 {
|
interface ActivityHeatmapProps {
|
||||||
/** Aggregated stats data from the API */
|
/** Aggregated stats data from the API */
|
||||||
data: StatsAggregation;
|
data: StatsAggregation;
|
||||||
@@ -111,6 +134,133 @@ function calculateIntensity(value: number, maxValue: number): number {
|
|||||||
return 4;
|
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
|
* 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) {
|
export function ActivityHeatmap({ data, timeRange, theme, colorBlindMode = false }: ActivityHeatmapProps) {
|
||||||
const [metricMode, setMetricMode] = useState<MetricMode>('count');
|
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 [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
const singleDayMode = shouldUseSingleDayMode(timeRange);
|
const useGitHubLayout = shouldUseSingleDayMode(timeRange);
|
||||||
|
|
||||||
// Convert byDay data to a lookup map
|
// Convert byDay data to a lookup map
|
||||||
const dayDataMap = useMemo(() => {
|
const dayDataMap = useMemo(() => {
|
||||||
@@ -185,19 +335,26 @@ export function ActivityHeatmap({ data, timeRange, theme, colorBlindMode = false
|
|||||||
return map;
|
return map;
|
||||||
}, [data.byDay]);
|
}, [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(() => {
|
const { dayColumns, hourLabels } = useMemo(() => {
|
||||||
|
if (useGitHubLayout) {
|
||||||
|
return { dayColumns: [], hourLabels: [] };
|
||||||
|
}
|
||||||
|
|
||||||
const numDays = getDaysForRange(timeRange);
|
const numDays = getDaysForRange(timeRange);
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const columns: DayColumn[] = [];
|
const columns: DayColumn[] = [];
|
||||||
|
|
||||||
// Determine hour rows based on mode
|
// Determine hour rows based on mode
|
||||||
// Single day mode: one row (whole day), hourly mode: 24 rows
|
const hours = Array.from({ length: 24 }, (_, i) => i);
|
||||||
const hours = singleDayMode ? [0] : 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'];
|
||||||
// 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'];
|
|
||||||
|
|
||||||
// Track max values for intensity calculation
|
// Track max values for intensity calculation
|
||||||
let maxCount = 0;
|
let maxCount = 0;
|
||||||
@@ -209,25 +366,14 @@ export function ActivityHeatmap({ data, timeRange, theme, colorBlindMode = false
|
|||||||
const dateString = format(date, 'yyyy-MM-dd');
|
const dateString = format(date, 'yyyy-MM-dd');
|
||||||
const dayStats = dayDataMap.get(dateString) || { count: 0, duration: 0 };
|
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) => {
|
const hourData: HourData[] = hours.map((hour) => {
|
||||||
let count: number;
|
// Distribute evenly across hours (simplified - real data would have hourly breakdown)
|
||||||
let duration: number;
|
let count = Math.floor(dayStats.count / 24);
|
||||||
|
let duration = Math.floor(dayStats.duration / 24);
|
||||||
if (singleDayMode) {
|
// Distribute remainder to typical work hours (9-17)
|
||||||
// Single day mode: use full day stats
|
if (hour >= 9 && hour <= 17) {
|
||||||
count = dayStats.count;
|
count += Math.floor((dayStats.count % 24) / 9);
|
||||||
duration = dayStats.duration;
|
duration += Math.floor((dayStats.duration % 24) / 9);
|
||||||
} 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
maxCount = Math.max(maxCount, count);
|
maxCount = Math.max(maxCount, count);
|
||||||
@@ -240,7 +386,7 @@ export function ActivityHeatmap({ data, timeRange, theme, colorBlindMode = false
|
|||||||
hourKey: `${dateString}-${hour.toString().padStart(2, '0')}`,
|
hourKey: `${dateString}-${hour.toString().padStart(2, '0')}`,
|
||||||
count,
|
count,
|
||||||
duration,
|
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,
|
dayColumns: columns,
|
||||||
hourLabels: labels,
|
hourLabels: labels,
|
||||||
};
|
};
|
||||||
}, [dayDataMap, metricMode, timeRange, singleDayMode]);
|
}, [dayDataMap, metricMode, timeRange, useGitHubLayout]);
|
||||||
|
|
||||||
// Handle mouse events for tooltip
|
// Handle mouse events for tooltip (HourData for day/week, DayCell for month+)
|
||||||
const handleMouseEnter = useCallback(
|
const handleMouseEnterHour = useCallback(
|
||||||
(cell: HourData, event: React.MouseEvent<HTMLDivElement>) => {
|
(cell: HourData, event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
setHoveredCell(cell);
|
setHoveredCell(cell);
|
||||||
const rect = event.currentTarget.getBoundingClientRect();
|
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({
|
setTooltipPos({
|
||||||
x: rect.left + rect.width / 2,
|
x: rect.left + rect.width / 2,
|
||||||
y: rect.top,
|
y: rect.top,
|
||||||
@@ -287,12 +444,15 @@ export function ActivityHeatmap({ data, timeRange, theme, colorBlindMode = false
|
|||||||
setTooltipPos(null);
|
setTooltipPos(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Day of week labels for GitHub layout (Sun-Sat)
|
||||||
|
const dayOfWeekLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="p-4 rounded-lg"
|
className="p-4 rounded-lg"
|
||||||
style={{ backgroundColor: theme.colors.bgMain }}
|
style={{ backgroundColor: theme.colors.bgMain }}
|
||||||
role="figure"
|
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 */}
|
{/* Header with title and metric toggle */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
@@ -354,10 +514,89 @@ export function ActivityHeatmap({ data, timeRange, theme, colorBlindMode = false
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Heatmap grid */}
|
{/* GitHub-style heatmap for month/year/all views */}
|
||||||
<div className="flex gap-2">
|
{useGitHubLayout && gitHubGrid && (
|
||||||
{/* Hour labels (Y-axis) - only show for hourly mode */}
|
<div className="flex gap-2">
|
||||||
{!singleDayMode && (
|
{/* 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 }}>
|
<div className="flex flex-col flex-shrink-0" style={{ width: 28, paddingTop: 18 }}>
|
||||||
{hourLabels.map((label, idx) => (
|
{hourLabels.map((label, idx) => (
|
||||||
<div
|
<div
|
||||||
@@ -373,56 +612,56 @@ export function ActivityHeatmap({ data, timeRange, theme, colorBlindMode = false
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Grid of cells */}
|
{/* Grid of cells */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex gap-[3px]">
|
<div className="flex gap-[3px]">
|
||||||
{dayColumns.map((col) => (
|
{dayColumns.map((col) => (
|
||||||
<div
|
|
||||||
key={col.dateString}
|
|
||||||
className="flex flex-col gap-[2px] flex-1"
|
|
||||||
style={{ minWidth: 20 }}
|
|
||||||
>
|
|
||||||
{/* Day label */}
|
|
||||||
<div
|
<div
|
||||||
className="text-xs text-center truncate h-[16px] flex items-center justify-center"
|
key={col.dateString}
|
||||||
style={{ color: theme.colors.textDim }}
|
className="flex flex-col gap-[2px] flex-1"
|
||||||
title={format(col.date, 'EEEE, MMM d')}
|
style={{ minWidth: 20 }}
|
||||||
>
|
>
|
||||||
{col.dayLabel}
|
{/* Day label */}
|
||||||
</div>
|
|
||||||
{/* Hour cells (or single day cell) */}
|
|
||||||
{col.hours.map((hourData) => (
|
|
||||||
<div
|
<div
|
||||||
key={hourData.hourKey}
|
className="text-xs text-center truncate h-[16px] flex items-center justify-center"
|
||||||
className="rounded-sm cursor-default"
|
style={{ color: theme.colors.textDim }}
|
||||||
style={{
|
title={format(col.date, 'EEEE, MMM d')}
|
||||||
height: singleDayMode ? 20 : 14,
|
>
|
||||||
backgroundColor: getIntensityColor(
|
{col.dayLabel}
|
||||||
hourData.intensity,
|
</div>
|
||||||
theme,
|
{/* Hour cells */}
|
||||||
colorBlindMode
|
{col.hours.map((hourData) => (
|
||||||
),
|
<div
|
||||||
outline:
|
key={hourData.hourKey}
|
||||||
hoveredCell?.hourKey === hourData.hourKey
|
className="rounded-sm cursor-default"
|
||||||
? `2px solid ${theme.colors.accent}`
|
style={{
|
||||||
: 'none',
|
height: 14,
|
||||||
outlineOffset: -1,
|
backgroundColor: getIntensityColor(
|
||||||
transition: 'background-color 0.3s ease, outline 0.15s ease',
|
hourData.intensity,
|
||||||
}}
|
theme,
|
||||||
onMouseEnter={(e) => handleMouseEnter(hourData, e)}
|
colorBlindMode
|
||||||
onMouseLeave={handleMouseLeave}
|
),
|
||||||
role="gridcell"
|
outline:
|
||||||
aria-label={`${format(hourData.date, 'MMM d')}${singleDayMode ? '' : ` ${hourData.hour}:00`}: ${hourData.count} ${hourData.count === 1 ? 'query' : 'queries'}${hourData.duration > 0 ? `, ${formatDuration(hourData.duration)}` : ''}`}
|
hoveredCell && 'hourKey' in hoveredCell && hoveredCell.hourKey === hourData.hourKey
|
||||||
tabIndex={0}
|
? `2px solid ${theme.colors.accent}`
|
||||||
/>
|
: 'none',
|
||||||
))}
|
outlineOffset: -1,
|
||||||
</div>
|
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>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div className="flex items-center justify-end gap-2 mt-3" role="list" aria-label="Activity intensity scale from less to more">
|
<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
|
// Check if tooltip would overflow top - if so, show below
|
||||||
const wouldOverflowTop = tooltipPos.y - tooltipHeight - margin < 0;
|
const wouldOverflowTop = tooltipPos.y - tooltipHeight - margin < 0;
|
||||||
|
|
||||||
|
// Determine if this is a DayCell or HourData
|
||||||
|
const isDayCell = 'dayOfWeek' in hoveredCell;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed z-50 px-2 py-1.5 rounded text-xs whitespace-nowrap pointer-events-none shadow-lg"
|
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">
|
<div className="font-medium mb-0.5">
|
||||||
{format(hoveredCell.date, 'EEEE, MMM d')}
|
{format(hoveredCell.date, 'EEEE, MMM d, yyyy')}
|
||||||
{!singleDayMode && ` at ${hoveredCell.hour}:00`}
|
{!isDayCell && 'hour' in hoveredCell && ` at ${hoveredCell.hour}:00`}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: theme.colors.textDim }}>
|
<div style={{ color: theme.colors.textDim }}>
|
||||||
{hoveredCell.count} {hoveredCell.count === 1 ? 'query' : 'queries'}
|
{hoveredCell.count} {hoveredCell.count === 1 ? 'query' : 'queries'}
|
||||||
|
|||||||
@@ -65,6 +65,18 @@ export interface UseAutoRunHandlersReturn {
|
|||||||
handleAutoRunCreateDocument: (filename: string) => Promise<boolean>;
|
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.
|
* Hook that provides handlers for Auto Run operations.
|
||||||
* Extracted from App.tsx to reduce file size and improve maintainability.
|
* Extracted from App.tsx to reduce file size and improve maintainability.
|
||||||
@@ -96,11 +108,12 @@ export function useAutoRunHandlers(
|
|||||||
const handleAutoRunFolderSelected = useCallback(async (folderPath: string) => {
|
const handleAutoRunFolderSelected = useCallback(async (folderPath: string) => {
|
||||||
if (!activeSession) return;
|
if (!activeSession) return;
|
||||||
|
|
||||||
|
const sshRemoteId = getSshRemoteId(activeSession);
|
||||||
let result: { success: boolean; files?: string[]; tree?: AutoRunTreeNode[] } | null = null;
|
let result: { success: boolean; files?: string[]; tree?: AutoRunTreeNode[] } | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load the document list from the folder
|
// Load the document list from the folder (use SSH if remote session)
|
||||||
result = await window.maestro.autorun.listDocs(folderPath);
|
result = await window.maestro.autorun.listDocs(folderPath, sshRemoteId);
|
||||||
} catch {
|
} catch {
|
||||||
result = null;
|
result = null;
|
||||||
}
|
}
|
||||||
@@ -113,7 +126,7 @@ export function useAutoRunHandlers(
|
|||||||
// Load content of first document
|
// Load content of first document
|
||||||
let firstFileContent = '';
|
let firstFileContent = '';
|
||||||
if (firstFile) {
|
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) {
|
if (contentResult.success) {
|
||||||
firstFileContent = contentResult.content || '';
|
firstFileContent = contentResult.content || '';
|
||||||
}
|
}
|
||||||
@@ -174,12 +187,13 @@ export function useAutoRunHandlers(
|
|||||||
// Memoized function to get task count for a document (used by BatchRunnerModal)
|
// Memoized function to get task count for a document (used by BatchRunnerModal)
|
||||||
const getDocumentTaskCount = useCallback(async (filename: string) => {
|
const getDocumentTaskCount = useCallback(async (filename: string) => {
|
||||||
if (!activeSession?.autoRunFolderPath) return 0;
|
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;
|
if (!result.success || !result.content) return 0;
|
||||||
// Count unchecked tasks: - [ ] pattern
|
// Count unchecked tasks: - [ ] pattern
|
||||||
const matches = result.content.match(/^[\s]*-\s*\[\s*\]\s*.+$/gm);
|
const matches = result.content.match(/^[\s]*-\s*\[\s*\]\s*.+$/gm);
|
||||||
return matches ? matches.length : 0;
|
return matches ? matches.length : 0;
|
||||||
}, [activeSession?.autoRunFolderPath]);
|
}, [activeSession?.autoRunFolderPath, activeSession?.sshRemoteId, activeSession?.sessionSshRemoteConfig]);
|
||||||
|
|
||||||
// Auto Run document content change handler
|
// Auto Run document content change handler
|
||||||
// Updates content in the session state (per-session, not global)
|
// Updates content in the session state (per-session, not global)
|
||||||
@@ -222,10 +236,12 @@ export function useAutoRunHandlers(
|
|||||||
const handleAutoRunSelectDocument = useCallback(async (filename: string) => {
|
const handleAutoRunSelectDocument = useCallback(async (filename: string) => {
|
||||||
if (!activeSession?.autoRunFolderPath) return;
|
if (!activeSession?.autoRunFolderPath) return;
|
||||||
|
|
||||||
|
const sshRemoteId = getSshRemoteId(activeSession);
|
||||||
// Load new document content
|
// Load new document content
|
||||||
const result = await window.maestro.autorun.readDoc(
|
const result = await window.maestro.autorun.readDoc(
|
||||||
activeSession.autoRunFolderPath,
|
activeSession.autoRunFolderPath,
|
||||||
filename + '.md'
|
filename + '.md',
|
||||||
|
sshRemoteId
|
||||||
);
|
);
|
||||||
const newContent = result.success ? (result.content || '') : '';
|
const newContent = result.success ? (result.content || '') : '';
|
||||||
|
|
||||||
@@ -246,10 +262,11 @@ export function useAutoRunHandlers(
|
|||||||
// Auto Run refresh handler - reload document list and show flash notification
|
// Auto Run refresh handler - reload document list and show flash notification
|
||||||
const handleAutoRunRefresh = useCallback(async () => {
|
const handleAutoRunRefresh = useCallback(async () => {
|
||||||
if (!activeSession?.autoRunFolderPath) return;
|
if (!activeSession?.autoRunFolderPath) return;
|
||||||
|
const sshRemoteId = getSshRemoteId(activeSession);
|
||||||
const previousCount = autoRunDocumentList.length;
|
const previousCount = autoRunDocumentList.length;
|
||||||
setAutoRunIsLoadingDocuments(true);
|
setAutoRunIsLoadingDocuments(true);
|
||||||
try {
|
try {
|
||||||
const result = await window.maestro.autorun.listDocs(activeSession.autoRunFolderPath);
|
const result = await window.maestro.autorun.listDocs(activeSession.autoRunFolderPath, sshRemoteId);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const newFiles = result.files || [];
|
const newFiles = result.files || [];
|
||||||
setAutoRunDocumentList(newFiles);
|
setAutoRunDocumentList(newFiles);
|
||||||
@@ -272,7 +289,7 @@ export function useAutoRunHandlers(
|
|||||||
} finally {
|
} finally {
|
||||||
setAutoRunIsLoadingDocuments(false);
|
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
|
// Auto Run open setup handler
|
||||||
const handleAutoRunOpenSetup = useCallback(() => {
|
const handleAutoRunOpenSetup = useCallback(() => {
|
||||||
@@ -283,17 +300,19 @@ export function useAutoRunHandlers(
|
|||||||
const handleAutoRunCreateDocument = useCallback(async (filename: string): Promise<boolean> => {
|
const handleAutoRunCreateDocument = useCallback(async (filename: string): Promise<boolean> => {
|
||||||
if (!activeSession?.autoRunFolderPath) return false;
|
if (!activeSession?.autoRunFolderPath) return false;
|
||||||
|
|
||||||
|
const sshRemoteId = getSshRemoteId(activeSession);
|
||||||
try {
|
try {
|
||||||
// Create the document with empty content so placeholder hint shows
|
// Create the document with empty content so placeholder hint shows
|
||||||
const result = await window.maestro.autorun.writeDoc(
|
const result = await window.maestro.autorun.writeDoc(
|
||||||
activeSession.autoRunFolderPath,
|
activeSession.autoRunFolderPath,
|
||||||
filename + '.md',
|
filename + '.md',
|
||||||
''
|
'',
|
||||||
|
sshRemoteId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Refresh the document list
|
// 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) {
|
if (listResult.success) {
|
||||||
setAutoRunDocumentList(listResult.files || []);
|
setAutoRunDocumentList(listResult.files || []);
|
||||||
setAutoRunDocumentTree(listResult.tree || []);
|
setAutoRunDocumentTree(listResult.tree || []);
|
||||||
@@ -319,7 +338,7 @@ export function useAutoRunHandlers(
|
|||||||
console.error('Failed to create document:', error);
|
console.error('Failed to create document:', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [activeSession, setSessions, setAutoRunDocumentList]);
|
}, [activeSession, setSessions, setAutoRunDocumentList, setAutoRunDocumentTree]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleAutoRunFolderSelected,
|
handleAutoRunFolderSelected,
|
||||||
|
|||||||
@@ -347,7 +347,8 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces
|
|||||||
|
|
||||||
// Track shell CWD changes when in terminal mode
|
// Track shell CWD changes when in terminal mode
|
||||||
// For SSH sessions, use remoteCwd; for local sessions, use shellCwd
|
// 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 newShellCwd = activeSession.shellCwd || activeSession.cwd;
|
||||||
let newRemoteCwd = activeSession.remoteCwd;
|
let newRemoteCwd = activeSession.remoteCwd;
|
||||||
let cwdChanged = false;
|
let cwdChanged = false;
|
||||||
@@ -420,9 +421,11 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify the directory exists before updating CWD
|
// 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 {
|
try {
|
||||||
await window.maestro.fs.readDir(candidatePath, activeSession.sshRemoteId);
|
await window.maestro.fs.readDir(candidatePath, sshIdForVerify);
|
||||||
// Directory exists, update the appropriate CWD
|
// Directory exists, update the appropriate CWD
|
||||||
if (isRemoteSession) {
|
if (isRemoteSession) {
|
||||||
remoteCwdChanged = true;
|
remoteCwdChanged = true;
|
||||||
@@ -516,7 +519,9 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces
|
|||||||
if (cwdChanged || remoteCwdChanged) {
|
if (cwdChanged || remoteCwdChanged) {
|
||||||
(async () => {
|
(async () => {
|
||||||
const cwdToCheck = remoteCwdChanged && newRemoteCwd ? newRemoteCwd : newShellCwd;
|
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) =>
|
setSessions((prev) =>
|
||||||
prev.map((s) => (s.id === activeSessionId ? { ...s, isGitRepo } : s))
|
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
|
// 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
|
// 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
|
// 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.remoteCwd || activeSession.sessionSshRemoteConfig?.workingDirOverride || activeSession.cwd)
|
||||||
: (activeSession.shellCwd || activeSession.cwd);
|
: (activeSession.shellCwd || activeSession.cwd);
|
||||||
window.maestro.process
|
window.maestro.process
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ export interface GroupChatParticipant {
|
|||||||
processingTimeMs?: number;
|
processingTimeMs?: number;
|
||||||
/** Total cost in USD (optional, depends on provider) */
|
/** Total cost in USD (optional, depends on provider) */
|
||||||
totalCost?: number;
|
totalCost?: number;
|
||||||
|
/** SSH remote name (displayed as pill when running on SSH remote) */
|
||||||
|
sshRemoteName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user