mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
OAuth enabled but no valid token found. Starting authentication...
Found expired OAuth token, attempting refresh... Token refresh successful ## CHANGES - Added `rehype-slug` for automatic heading IDs in markdown previews 🔗 - Enabled smooth in-page anchor link navigation across markdown renderers 🧭 - Improved worktree session detection by normalizing paths, avoiding duplicates 🧹 - Broadcast session updates when working directory changes, not just state 📣 - Added “stopping” batch-session tracking and surfaced it throughout the UI 🛑 - Refined Auto Run indicators: STOPPING label, red tint, no pulse 🎛️ - Prevented repeated stop clicks with stricter disabled button behavior 🚫 - Memoized batch-derived flags to cut rerenders from new array references ⚡ - Fixed HMR stale-closure issues via ref-based batch state broadcaster 🧩 - Mermaid diagrams now fully theme-aware using app color variables 🎨
This commit is contained in:
50
package-lock.json
generated
50
package-lock.json
generated
@@ -40,6 +40,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-frontmatter": "^5.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"ws": "^8.16.0"
|
||||
@@ -9929,6 +9930,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/github-slugger": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
|
||||
"integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
@@ -10223,6 +10230,19 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-heading-rank": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz",
|
||||
"integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-parse-selector": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
|
||||
@@ -10331,6 +10351,19 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-string": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz",
|
||||
"integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-whitespace": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
|
||||
@@ -15317,6 +15350,23 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/rehype-slug": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz",
|
||||
"integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"github-slugger": "^2.0.0",
|
||||
"hast-util-heading-rank": "^3.0.0",
|
||||
"hast-util-to-string": "^3.0.0",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-frontmatter": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-5.0.0.tgz",
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-frontmatter": "^5.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"ws": "^8.16.0"
|
||||
|
||||
@@ -136,10 +136,11 @@ export function registerPersistenceHandlers(deps: PersistenceHandlerDependencies
|
||||
for (const session of sessions) {
|
||||
const prevSession = previousSessionMap.get(session.id);
|
||||
if (prevSession) {
|
||||
// Session exists - check if state changed
|
||||
// Session exists - check if state or other tracked properties changed
|
||||
if (prevSession.state !== session.state ||
|
||||
prevSession.inputMode !== session.inputMode ||
|
||||
prevSession.name !== session.name ||
|
||||
prevSession.cwd !== session.cwd ||
|
||||
JSON.stringify(prevSession.cliActivity) !== JSON.stringify(session.cliActivity)) {
|
||||
webServer.broadcastSessionStateChange(session.id, session.state, {
|
||||
name: session.name,
|
||||
|
||||
@@ -1005,16 +1005,20 @@ function MaestroConsoleInner() {
|
||||
}
|
||||
|
||||
// Check if a session already exists for this worktree
|
||||
const existingSession = sessions.find(s =>
|
||||
(s.parentSessionId === parentSession.id && s.worktreeBranch === subdir.branch) ||
|
||||
s.cwd === subdir.path
|
||||
);
|
||||
// Normalize paths for comparison (remove trailing slashes)
|
||||
const normalizedSubdirPath = subdir.path.replace(/\/+$/, '');
|
||||
const existingSession = sessions.find(s => {
|
||||
const normalizedCwd = s.cwd.replace(/\/+$/, '');
|
||||
// Check if same path (regardless of parent) or same branch under same parent
|
||||
return normalizedCwd === normalizedSubdirPath ||
|
||||
(s.parentSessionId === parentSession.id && s.worktreeBranch === subdir.branch);
|
||||
});
|
||||
if (existingSession) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Also check in sessions we're about to add
|
||||
if (newWorktreeSessions.some(s => s.cwd === subdir.path)) {
|
||||
if (newWorktreeSessions.some(s => s.cwd.replace(/\/+$/, '') === normalizedSubdirPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -3311,6 +3315,7 @@ function MaestroConsoleInner() {
|
||||
batchRunStates: _batchRunStates,
|
||||
getBatchState,
|
||||
activeBatchSessionIds,
|
||||
stoppingBatchSessionIds,
|
||||
startBatchRun,
|
||||
stopBatchRun,
|
||||
// Error handling (Phase 5.10)
|
||||
@@ -3532,17 +3537,7 @@ function MaestroConsoleInner() {
|
||||
// This is session-specific so users can edit docs in other sessions while one runs
|
||||
// Quick Win 4: Memoized to prevent unnecessary re-calculations
|
||||
const currentSessionBatchState = useMemo(() => {
|
||||
const state = activeSession ? getBatchState(activeSession.id) : null;
|
||||
// DEBUG: Log currentSessionBatchState computation
|
||||
if (state) {
|
||||
console.log('[App:currentSessionBatchState] Computed:', {
|
||||
sessionId: activeSession?.id,
|
||||
loopIteration: state.loopIteration,
|
||||
completedTasksAcrossAllDocs: state.completedTasksAcrossAllDocs,
|
||||
totalTasksAcrossAllDocs: state.totalTasksAcrossAllDocs,
|
||||
});
|
||||
}
|
||||
return state;
|
||||
return activeSession ? getBatchState(activeSession.id) : null;
|
||||
}, [activeSession, getBatchState]);
|
||||
|
||||
// Get batch state for display - prioritize the session with an active batch run,
|
||||
@@ -3800,10 +3795,14 @@ function MaestroConsoleInner() {
|
||||
if (!parentSession) return;
|
||||
|
||||
// Check if session already exists for this worktree
|
||||
const existingSession = currentSessions.find(s =>
|
||||
(s.parentSessionId === sessionId && s.worktreeBranch === worktree.branch) ||
|
||||
s.cwd === worktree.path
|
||||
);
|
||||
// Normalize paths for comparison (remove trailing slashes)
|
||||
const normalizedWorktreePath = worktree.path.replace(/\/+$/, '');
|
||||
const existingSession = currentSessions.find(s => {
|
||||
const normalizedCwd = s.cwd.replace(/\/+$/, '');
|
||||
// Check if same path (regardless of parent) or same branch under same parent
|
||||
return normalizedCwd === normalizedWorktreePath ||
|
||||
(s.parentSessionId === sessionId && s.worktreeBranch === worktree.branch);
|
||||
});
|
||||
if (existingSession) return;
|
||||
|
||||
// Create new worktree session
|
||||
@@ -8444,6 +8443,7 @@ function MaestroConsoleInner() {
|
||||
));
|
||||
}}
|
||||
activeBatchSessionIds={activeBatchSessionIds}
|
||||
stoppingBatchSessionIds={stoppingBatchSessionIds}
|
||||
showSessionJumpNumbers={showSessionJumpNumbers}
|
||||
visibleSessions={visibleSessions}
|
||||
autoRunStats={autoRunStats}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useRef, useEffect, useLayoutEffect, useCallback, memo, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeSlug from 'rehype-slug';
|
||||
import { Eye, Edit, Play, Square, HelpCircle, Loader2, Image, X, Search, ChevronDown, ChevronRight, FolderOpen, FileText, RefreshCw, Maximize2, AlertTriangle, SkipForward, XCircle } from 'lucide-react';
|
||||
import { getEncoder, formatTokenCount } from '../utils/tokenCounter';
|
||||
import type { BatchRunState, SessionState, Theme, Shortcut } from '../types';
|
||||
@@ -1142,6 +1143,8 @@ const AutoRunInner = forwardRef<AutoRunHandle, AutoRunProps>(function AutoRunInn
|
||||
onFileClick: handleFileClick,
|
||||
// Open external links in system browser
|
||||
onExternalLinkClick: (href) => window.maestro.shell.openExternal(href),
|
||||
// Provide container ref for anchor link scrolling
|
||||
containerRef: previewRef,
|
||||
// Add search highlighting when search is active with matches
|
||||
searchHighlight: searchOpen && searchQuery.trim() && totalMatches > 0
|
||||
? {
|
||||
@@ -1614,6 +1617,7 @@ const AutoRunInner = forwardRef<AutoRunHandle, AutoRunProps>(function AutoRunInn
|
||||
<style>{proseStyles}</style>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={[rehypeSlug]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{localContent || '*No content yet. Switch to Edit mode to start writing.*'}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSlug from 'rehype-slug';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { FileCode, X, Eye, ChevronUp, ChevronDown, ChevronLeft, ChevronRight, Clipboard, Loader2, Image, Globe, Save, Edit, FolderOpen, AlertTriangle } from 'lucide-react';
|
||||
@@ -1534,7 +1535,7 @@ export function FilePreview({ file, onClose, theme, markdownEditMode, setMarkdow
|
||||
? [[remarkFileLinks, { fileTree, cwd }] as any]
|
||||
: [])
|
||||
]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
rehypePlugins={[rehypeRaw, rehypeSlug]}
|
||||
components={{
|
||||
a: ({ node: _node, href, children, ...props }) => {
|
||||
// Check for maestro-file:// protocol OR data-maestro-file attribute
|
||||
@@ -1543,6 +1544,10 @@ export function FilePreview({ file, onClose, theme, markdownEditMode, setMarkdow
|
||||
const isMaestroFile = href?.startsWith('maestro-file://') || !!dataFilePath;
|
||||
const filePath = dataFilePath || (href?.startsWith('maestro-file://') ? href.replace('maestro-file://', '') : null);
|
||||
|
||||
// Check for anchor links (same-page navigation)
|
||||
const isAnchorLink = href?.startsWith('#') ?? false;
|
||||
const anchorId = isAnchorLink && href ? href.slice(1) : null;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
@@ -1551,6 +1556,14 @@ export function FilePreview({ file, onClose, theme, markdownEditMode, setMarkdow
|
||||
e.preventDefault();
|
||||
if (isMaestroFile && filePath && onFileClick) {
|
||||
onFileClick(filePath);
|
||||
} else if (isAnchorLink && anchorId) {
|
||||
// Handle anchor links - scroll to the target element
|
||||
const targetElement = markdownContainerRef.current
|
||||
? markdownContainerRef.current.querySelector(`#${CSS.escape(anchorId)}`)
|
||||
: document.getElementById(anchorId);
|
||||
if (targetElement) {
|
||||
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
} else if (href) {
|
||||
window.maestro.shell.openExternal(href);
|
||||
}
|
||||
|
||||
@@ -687,8 +687,9 @@ export const MainPanel = React.memo(forwardRef<MainPanelHandle, MainPanelProps>(
|
||||
disabled={isCurrentSessionStopping}
|
||||
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg font-bold text-xs transition-all ${isCurrentSessionStopping ? 'cursor-not-allowed' : 'hover:opacity-90 cursor-pointer'}`}
|
||||
style={{
|
||||
backgroundColor: theme.colors.error,
|
||||
color: 'white'
|
||||
backgroundColor: isCurrentSessionStopping ? theme.colors.warning : theme.colors.error,
|
||||
color: isCurrentSessionStopping ? theme.colors.bgMain : 'white',
|
||||
pointerEvents: isCurrentSessionStopping ? 'none' : 'auto'
|
||||
}}
|
||||
title={isCurrentSessionStopping ? 'Stopping after current task...' : 'Click to stop batch run'}
|
||||
>
|
||||
|
||||
@@ -1,17 +1,170 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import mermaid from 'mermaid';
|
||||
import DOMPurify from 'dompurify';
|
||||
import type { Theme } from '../types';
|
||||
|
||||
interface MermaidRendererProps {
|
||||
chart: string;
|
||||
theme: any;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
// Initialize mermaid with custom theme settings
|
||||
const initMermaid = (isDarkTheme: boolean) => {
|
||||
/**
|
||||
* Convert hex color to RGB components
|
||||
*/
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
} : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a slightly lighter/darker version of a color
|
||||
*/
|
||||
function adjustBrightness(hex: string, percent: number): string {
|
||||
const rgb = hexToRgb(hex);
|
||||
if (!rgb) return hex;
|
||||
|
||||
const adjust = (value: number) => Math.min(255, Math.max(0, Math.round(value + (255 * percent / 100))));
|
||||
const r = adjust(rgb.r);
|
||||
const g = adjust(rgb.g);
|
||||
const b = adjust(rgb.b);
|
||||
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize mermaid with theme-aware settings using the app's color scheme
|
||||
*/
|
||||
const initMermaid = (theme: Theme) => {
|
||||
const colors = theme.colors;
|
||||
|
||||
// Determine if this is a dark theme by checking background luminance
|
||||
const bgRgb = hexToRgb(colors.bgMain);
|
||||
const isDark = bgRgb ? (bgRgb.r * 0.299 + bgRgb.g * 0.587 + bgRgb.b * 0.114) < 128 : true;
|
||||
|
||||
// Create theme variables from the app's color scheme
|
||||
const themeVariables = {
|
||||
// Base colors
|
||||
primaryColor: colors.accent,
|
||||
primaryTextColor: colors.textMain,
|
||||
primaryBorderColor: colors.border,
|
||||
|
||||
// Secondary colors (derived from accent)
|
||||
secondaryColor: adjustBrightness(colors.accent, isDark ? -20 : 20),
|
||||
secondaryTextColor: colors.textMain,
|
||||
secondaryBorderColor: colors.border,
|
||||
|
||||
// Tertiary colors
|
||||
tertiaryColor: colors.bgActivity,
|
||||
tertiaryTextColor: colors.textMain,
|
||||
tertiaryBorderColor: colors.border,
|
||||
|
||||
// Background and text
|
||||
background: colors.bgMain,
|
||||
mainBkg: colors.bgActivity,
|
||||
textColor: colors.textMain,
|
||||
titleColor: colors.textMain,
|
||||
|
||||
// Line colors
|
||||
lineColor: colors.textDim,
|
||||
|
||||
// Node colors for flowcharts
|
||||
nodeBkg: colors.bgActivity,
|
||||
nodeTextColor: colors.textMain,
|
||||
nodeBorder: colors.border,
|
||||
|
||||
// Cluster (subgraph) colors
|
||||
clusterBkg: colors.bgSidebar,
|
||||
clusterBorder: colors.border,
|
||||
|
||||
// Edge labels
|
||||
edgeLabelBackground: colors.bgMain,
|
||||
|
||||
// State diagram colors
|
||||
labelColor: colors.textMain,
|
||||
altBackground: colors.bgSidebar,
|
||||
|
||||
// Sequence diagram colors
|
||||
actorBkg: colors.bgActivity,
|
||||
actorBorder: colors.border,
|
||||
actorTextColor: colors.textMain,
|
||||
actorLineColor: colors.textDim,
|
||||
signalColor: colors.textMain,
|
||||
signalTextColor: colors.textMain,
|
||||
labelBoxBkgColor: colors.bgActivity,
|
||||
labelBoxBorderColor: colors.border,
|
||||
labelTextColor: colors.textMain,
|
||||
loopTextColor: colors.textMain,
|
||||
noteBkgColor: colors.bgActivity,
|
||||
noteBorderColor: colors.border,
|
||||
noteTextColor: colors.textMain,
|
||||
activationBkgColor: colors.bgActivity,
|
||||
activationBorderColor: colors.accent,
|
||||
sequenceNumberColor: colors.textMain,
|
||||
|
||||
// Class diagram colors
|
||||
classText: colors.textMain,
|
||||
|
||||
// Git graph colors
|
||||
git0: colors.accent,
|
||||
git1: colors.success,
|
||||
git2: colors.warning,
|
||||
git3: colors.error,
|
||||
gitBranchLabel0: colors.textMain,
|
||||
gitBranchLabel1: colors.textMain,
|
||||
gitBranchLabel2: colors.textMain,
|
||||
gitBranchLabel3: colors.textMain,
|
||||
|
||||
// Gantt colors
|
||||
sectionBkgColor: colors.bgActivity,
|
||||
altSectionBkgColor: colors.bgSidebar,
|
||||
sectionBkgColor2: colors.bgActivity,
|
||||
taskBkgColor: colors.accent,
|
||||
taskTextColor: colors.textMain,
|
||||
taskTextLightColor: colors.textMain,
|
||||
taskTextOutsideColor: colors.textMain,
|
||||
activeTaskBkgColor: colors.accent,
|
||||
activeTaskBorderColor: colors.border,
|
||||
doneTaskBkgColor: colors.success,
|
||||
doneTaskBorderColor: colors.border,
|
||||
critBkgColor: colors.error,
|
||||
critBorderColor: colors.error,
|
||||
gridColor: colors.border,
|
||||
todayLineColor: colors.warning,
|
||||
|
||||
// Pie chart colors
|
||||
pie1: colors.accent,
|
||||
pie2: colors.success,
|
||||
pie3: colors.warning,
|
||||
pie4: colors.error,
|
||||
pie5: adjustBrightness(colors.accent, 30),
|
||||
pie6: adjustBrightness(colors.success, 30),
|
||||
pie7: adjustBrightness(colors.warning, 30),
|
||||
pieTitleTextColor: colors.textMain,
|
||||
pieSectionTextColor: colors.textMain,
|
||||
pieLegendTextColor: colors.textMain,
|
||||
|
||||
// Relationship colors for ER diagrams
|
||||
relationColor: colors.textDim,
|
||||
relationLabelColor: colors.textMain,
|
||||
relationLabelBackground: colors.bgMain,
|
||||
|
||||
// Requirement diagram
|
||||
requirementBkgColor: colors.bgActivity,
|
||||
requirementBorderColor: colors.border,
|
||||
requirementTextColor: colors.textMain,
|
||||
|
||||
// Mindmap
|
||||
mindmapBkg: colors.bgActivity,
|
||||
};
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: isDarkTheme ? 'dark' : 'default',
|
||||
theme: 'base', // Use 'base' theme to fully customize with themeVariables
|
||||
themeVariables,
|
||||
securityLevel: 'strict',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace',
|
||||
flowchart: {
|
||||
@@ -56,13 +209,8 @@ export function MermaidRenderer({ chart, theme }: MermaidRendererProps) {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Determine if theme is dark by checking background color
|
||||
const isDarkTheme = theme.colors.bgMain.toLowerCase().includes('#1') ||
|
||||
theme.colors.bgMain.toLowerCase().includes('#2') ||
|
||||
theme.colors.bgMain.toLowerCase().includes('#0');
|
||||
|
||||
// Initialize mermaid with the current theme
|
||||
initMermaid(isDarkTheme);
|
||||
// Initialize mermaid with the app's theme colors
|
||||
initMermaid(theme);
|
||||
|
||||
try {
|
||||
// Generate a unique ID for this diagram
|
||||
@@ -100,7 +248,7 @@ export function MermaidRenderer({ chart, theme }: MermaidRendererProps) {
|
||||
};
|
||||
|
||||
renderChart();
|
||||
}, [chart, theme.colors.bgMain]);
|
||||
}, [chart, theme]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface SessionItemProps {
|
||||
groupId?: string; // The group ID context for generating editing key
|
||||
gitFileCount?: number;
|
||||
isInBatch?: boolean;
|
||||
isBatchStopping?: boolean; // Whether the batch is in stopping state
|
||||
jumpNumber?: string | null; // Session jump shortcut number (1-9, 0)
|
||||
|
||||
// Handlers
|
||||
@@ -74,6 +75,7 @@ export const SessionItem = memo(function SessionItem({
|
||||
groupId,
|
||||
gitFileCount,
|
||||
isInBatch = false,
|
||||
isBatchStopping = false,
|
||||
jumpNumber,
|
||||
onSelect,
|
||||
onDragStart,
|
||||
@@ -210,12 +212,15 @@ export const SessionItem = memo(function SessionItem({
|
||||
{/* AUTO Mode Indicator */}
|
||||
{isInBatch && (
|
||||
<div
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase animate-pulse"
|
||||
style={{ backgroundColor: theme.colors.warning + '30', color: theme.colors.warning }}
|
||||
title="Auto Run active"
|
||||
className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${isBatchStopping ? '' : 'animate-pulse'}`}
|
||||
style={{
|
||||
backgroundColor: isBatchStopping ? theme.colors.error + '30' : theme.colors.warning + '30',
|
||||
color: isBatchStopping ? theme.colors.error : theme.colors.warning
|
||||
}}
|
||||
title={isBatchStopping ? 'Auto Run stopping...' : 'Auto Run active'}
|
||||
>
|
||||
<Bot className="w-2.5 h-2.5" />
|
||||
AUTO
|
||||
{isBatchStopping ? 'STOPPING' : 'AUTO'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -495,6 +495,8 @@ interface SessionTooltipContentProps {
|
||||
theme: Theme;
|
||||
gitFileCount?: number;
|
||||
groupName?: string; // Optional group name (for skinny mode)
|
||||
isInBatch?: boolean; // Whether session is running in auto mode
|
||||
isBatchStopping?: boolean; // Whether batch is in stopping state
|
||||
}
|
||||
|
||||
function SessionTooltipContent({
|
||||
@@ -502,6 +504,8 @@ function SessionTooltipContent({
|
||||
theme,
|
||||
gitFileCount,
|
||||
groupName,
|
||||
isInBatch = false,
|
||||
isBatchStopping = false,
|
||||
}: SessionTooltipContentProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -523,6 +527,19 @@ function SessionTooltipContent({
|
||||
{session.isGitRepo ? 'GIT' : 'LOCAL'}
|
||||
</span>
|
||||
)}
|
||||
{/* AUTO Mode Indicator */}
|
||||
{isInBatch && (
|
||||
<span
|
||||
className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${isBatchStopping ? '' : 'animate-pulse'}`}
|
||||
style={{
|
||||
backgroundColor: isBatchStopping ? theme.colors.error + '30' : theme.colors.warning + '30',
|
||||
color: isBatchStopping ? theme.colors.error : theme.colors.warning
|
||||
}}
|
||||
>
|
||||
<Bot className="w-2.5 h-2.5" />
|
||||
{isBatchStopping ? 'STOPPING' : 'AUTO'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] capitalize mb-2" style={{ color: theme.colors.textDim }}>{session.state} • {session.toolType}</div>
|
||||
|
||||
@@ -686,6 +703,7 @@ interface SessionListProps {
|
||||
|
||||
// Auto mode props
|
||||
activeBatchSessionIds?: string[]; // Session IDs that are running in auto mode
|
||||
stoppingBatchSessionIds?: string[]; // Session IDs that are in stopping state
|
||||
|
||||
// Session jump shortcut props (Opt+Cmd+NUMBER)
|
||||
showSessionJumpNumbers?: boolean;
|
||||
@@ -750,6 +768,7 @@ export function SessionList(props: SessionListProps) {
|
||||
onOpenWorktreeConfig,
|
||||
onDeleteWorktree,
|
||||
activeBatchSessionIds = [],
|
||||
stoppingBatchSessionIds = [],
|
||||
showSessionJumpNumbers = false,
|
||||
visibleSessions = [],
|
||||
autoRunStats,
|
||||
@@ -984,6 +1003,8 @@ export function SessionList(props: SessionListProps) {
|
||||
session={s}
|
||||
theme={theme}
|
||||
gitFileCount={gitFileCounts.get(s.id)}
|
||||
isInBatch={activeBatchSessionIds.includes(s.id)}
|
||||
isBatchStopping={stoppingBatchSessionIds.includes(s.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1026,6 +1047,7 @@ export function SessionList(props: SessionListProps) {
|
||||
groupId={options.groupId}
|
||||
gitFileCount={gitFileCounts.get(session.id)}
|
||||
isInBatch={activeBatchSessionIds.includes(session.id)}
|
||||
isBatchStopping={stoppingBatchSessionIds.includes(session.id)}
|
||||
jumpNumber={getSessionJumpNumber(session.id)}
|
||||
onSelect={() => setActiveSessionId(session.id)}
|
||||
onDragStart={() => handleDragStart(session.id)}
|
||||
@@ -1069,7 +1091,7 @@ export function SessionList(props: SessionListProps) {
|
||||
>
|
||||
{/* Worktree children list */}
|
||||
<div>
|
||||
{worktreeChildren.sort((a, b) => compareSessionNames(a.worktreeBranch || a.name, b.worktreeBranch || b.name)).map(child => {
|
||||
{worktreeChildren.sort((a, b) => compareSessionNames(a.name, b.name)).map(child => {
|
||||
const childGlobalIdx = sortedSessions.findIndex(s => s.id === child.id);
|
||||
const isChildKeyboardSelected = activeFocus === 'sidebar' && childGlobalIdx === selectedSidebarIndex;
|
||||
return (
|
||||
@@ -1085,6 +1107,7 @@ export function SessionList(props: SessionListProps) {
|
||||
leftSidebarOpen={leftSidebarOpen}
|
||||
gitFileCount={gitFileCounts.get(child.id)}
|
||||
isInBatch={activeBatchSessionIds.includes(child.id)}
|
||||
isBatchStopping={stoppingBatchSessionIds.includes(child.id)}
|
||||
jumpNumber={getSessionJumpNumber(child.id)}
|
||||
onSelect={() => setActiveSessionId(child.id)}
|
||||
onDragStart={() => handleDragStart(child.id)}
|
||||
@@ -2017,14 +2040,16 @@ export function SessionList(props: SessionListProps) {
|
||||
<div className="flex-1 flex flex-col items-center py-4 gap-2 overflow-y-auto overflow-x-visible no-scrollbar">
|
||||
{sortedSessions.map(session => {
|
||||
const isInBatch = activeBatchSessionIds.includes(session.id);
|
||||
const isBatchStopping = stoppingBatchSessionIds.includes(session.id);
|
||||
const hasUnreadTabs = session.aiTabs?.some(tab => tab.hasUnread);
|
||||
// Sessions in Auto Run mode should show yellow/warning color
|
||||
// Sessions in Auto Run mode should show yellow/warning color, red if stopping
|
||||
const effectiveStatusColor = isInBatch
|
||||
? theme.colors.warning
|
||||
? (isBatchStopping ? theme.colors.error : theme.colors.warning)
|
||||
: (session.toolType === 'claude' && !session.agentSessionId
|
||||
? undefined // Will use border style instead
|
||||
: getStatusColor(session.state, theme));
|
||||
const shouldPulse = session.state === 'busy' || isInBatch;
|
||||
// Don't pulse when stopping
|
||||
const shouldPulse = (session.state === 'busy' || isInBatch) && !isBatchStopping;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -2069,6 +2094,8 @@ export function SessionList(props: SessionListProps) {
|
||||
theme={theme}
|
||||
gitFileCount={gitFileCounts.get(session.id)}
|
||||
groupName={groups.find(g => g.id === session.groupId)?.name}
|
||||
isInBatch={isInBatch}
|
||||
isBatchStopping={isBatchStopping}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -237,14 +237,15 @@ const AutoRunPill = memo(({
|
||||
style={{ backgroundColor: theme.colors.border }}
|
||||
/>
|
||||
<button
|
||||
onClick={onStop}
|
||||
onClick={() => !isStopping && onStop()}
|
||||
disabled={isStopping}
|
||||
className={`flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium transition-colors ${
|
||||
isStopping ? 'opacity-50 cursor-not-allowed' : 'hover:opacity-80'
|
||||
isStopping ? 'cursor-not-allowed' : 'hover:opacity-80'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: theme.colors.error,
|
||||
color: 'white'
|
||||
backgroundColor: isStopping ? theme.colors.warning : theme.colors.error,
|
||||
color: isStopping ? theme.colors.bgMain : 'white',
|
||||
pointerEvents: isStopping ? 'none' : 'auto'
|
||||
}}
|
||||
title={isStopping ? 'Stopping after current task...' : 'Stop auto-run after current task'}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef, useReducer, useEffect } from 'react';
|
||||
import { useState, useCallback, useRef, useReducer, useEffect, useMemo } from 'react';
|
||||
import type { BatchRunState, BatchRunConfig, Session, HistoryEntry, UsageStats, Group, AutoRunStats, AgentError, ToolType } from '../../types';
|
||||
import { getBadgeForTime, getNextBadge, formatTimeRemaining } from '../../constants/conductorBadges';
|
||||
import { formatElapsedTime } from '../../../shared/formatters';
|
||||
@@ -61,6 +61,8 @@ interface UseBatchProcessorReturn {
|
||||
hasAnyActiveBatch: boolean;
|
||||
// Get list of session IDs with active batches
|
||||
activeBatchSessionIds: string[];
|
||||
// Get list of session IDs that are in stopping state
|
||||
stoppingBatchSessionIds: string[];
|
||||
// Start batch run for a specific session with multi-document support
|
||||
startBatchRun: (sessionId: string, config: BatchRunConfig, folderPath: string) => Promise<void>;
|
||||
// Stop batch run for a specific session
|
||||
@@ -193,6 +195,9 @@ export function useBatchProcessor({
|
||||
const batchRunStatesRef = useRef(batchRunStates);
|
||||
batchRunStatesRef.current = batchRunStates;
|
||||
|
||||
// Ref to track latest updateBatchStateAndBroadcast for async callbacks (fixes HMR stale closure)
|
||||
const updateBatchStateAndBroadcastRef = useRef<typeof updateBatchStateAndBroadcast | null>(null);
|
||||
|
||||
// Error resolution promises to pause batch processing until user action (per session)
|
||||
const errorResolutionRefs = useRef<Record<string, ErrorResolutionEntry>>({});
|
||||
|
||||
@@ -313,13 +318,27 @@ export function useBatchProcessor({
|
||||
return batchRunStates[sessionId] || DEFAULT_BATCH_STATE;
|
||||
}, [batchRunStates]);
|
||||
|
||||
// Check if any session has an active batch
|
||||
const hasAnyActiveBatch = Object.values(batchRunStates).some(state => state.isRunning);
|
||||
// Check if any session has an active batch (memoized to prevent re-renders on unrelated state changes)
|
||||
const hasAnyActiveBatch = useMemo(() =>
|
||||
Object.values(batchRunStates).some(state => state.isRunning),
|
||||
[batchRunStates]
|
||||
);
|
||||
|
||||
// Get list of session IDs with active batches
|
||||
const activeBatchSessionIds = Object.entries(batchRunStates)
|
||||
.filter(([_, state]) => state.isRunning)
|
||||
.map(([sessionId]) => sessionId);
|
||||
// Get list of session IDs with active batches (memoized to prevent new array references)
|
||||
const activeBatchSessionIds = useMemo(() =>
|
||||
Object.entries(batchRunStates)
|
||||
.filter(([, state]) => state.isRunning)
|
||||
.map(([sessionId]) => sessionId),
|
||||
[batchRunStates]
|
||||
);
|
||||
|
||||
// Get list of session IDs that are in stopping state (memoized to prevent new array references)
|
||||
const stoppingBatchSessionIds = useMemo(() =>
|
||||
Object.entries(batchRunStates)
|
||||
.filter(([, state]) => state.isRunning && state.isStopping)
|
||||
.map(([sessionId]) => sessionId),
|
||||
[batchRunStates]
|
||||
);
|
||||
|
||||
// Set custom prompt for a session
|
||||
const setCustomPrompt = useCallback((sessionId: string, prompt: string) => {
|
||||
@@ -340,7 +359,10 @@ export function useBatchProcessor({
|
||||
immediate: boolean = false
|
||||
) => {
|
||||
scheduleDebouncedUpdate(sessionId, updater, immediate);
|
||||
}, [scheduleDebouncedUpdate])
|
||||
}, [scheduleDebouncedUpdate]);
|
||||
|
||||
// Update ref to always have latest updateBatchStateAndBroadcast (fixes HMR stale closure)
|
||||
updateBatchStateAndBroadcastRef.current = updateBatchStateAndBroadcast;
|
||||
|
||||
// Use readDocAndCountTasks from the extracted documentProcessor hook
|
||||
// This replaces the previous inline helper function
|
||||
@@ -659,7 +681,7 @@ export function useBatchProcessor({
|
||||
await window.maestro.autorun.writeDoc(folderPath, docEntry.filename + '.md', resetContent);
|
||||
// Update task count in state
|
||||
const resetTaskCount = countUnfinishedTasks(resetContent);
|
||||
updateBatchStateAndBroadcast(sessionId, prev => ({
|
||||
updateBatchStateAndBroadcastRef.current!(sessionId, prev => ({
|
||||
...prev,
|
||||
[sessionId]: {
|
||||
...prev[sessionId],
|
||||
@@ -698,7 +720,7 @@ export function useBatchProcessor({
|
||||
);
|
||||
|
||||
// Update state to show current document
|
||||
updateBatchStateAndBroadcast(sessionId, prev => ({
|
||||
updateBatchStateAndBroadcastRef.current!(sessionId, prev => ({
|
||||
...prev,
|
||||
[sessionId]: {
|
||||
...prev[sessionId],
|
||||
@@ -814,7 +836,7 @@ export function useBatchProcessor({
|
||||
docTasksTotal += addedUncheckedTasks;
|
||||
}
|
||||
|
||||
updateBatchStateAndBroadcast(sessionId, prev => {
|
||||
updateBatchStateAndBroadcastRef.current!(sessionId, prev => {
|
||||
const prevState = prev[sessionId] || DEFAULT_BATCH_STATE;
|
||||
const nextTotalAcrossAllDocs = Math.max(0, prevState.totalTasksAcrossAllDocs + addedUncheckedTasks);
|
||||
const nextTotalTasks = Math.max(0, prevState.totalTasks + addedUncheckedTasks);
|
||||
@@ -985,7 +1007,7 @@ export function useBatchProcessor({
|
||||
// Count tasks in restored content for loop mode
|
||||
if (loopEnabled) {
|
||||
const { taskCount: resetTaskCount } = await readDocAndCountTasks(folderPath, docEntry.filename);
|
||||
updateBatchStateAndBroadcast(sessionId, prev => ({
|
||||
updateBatchStateAndBroadcastRef.current!(sessionId, prev => ({
|
||||
...prev,
|
||||
[sessionId]: {
|
||||
...prev[sessionId],
|
||||
@@ -1005,7 +1027,7 @@ export function useBatchProcessor({
|
||||
|
||||
if (loopEnabled) {
|
||||
const resetTaskCount = countUnfinishedTasks(resetContent);
|
||||
updateBatchStateAndBroadcast(sessionId, prev => ({
|
||||
updateBatchStateAndBroadcastRef.current!(sessionId, prev => ({
|
||||
...prev,
|
||||
[sessionId]: {
|
||||
...prev[sessionId],
|
||||
@@ -1023,7 +1045,7 @@ export function useBatchProcessor({
|
||||
|
||||
if (loopEnabled) {
|
||||
const resetTaskCount = countUnfinishedTasks(resetContent);
|
||||
updateBatchStateAndBroadcast(sessionId, prev => ({
|
||||
updateBatchStateAndBroadcastRef.current!(sessionId, prev => ({
|
||||
...prev,
|
||||
[sessionId]: {
|
||||
...prev[sessionId],
|
||||
@@ -1173,7 +1195,7 @@ export function useBatchProcessor({
|
||||
// Continue looping
|
||||
loopIteration++;
|
||||
|
||||
updateBatchStateAndBroadcast(sessionId, prev => ({
|
||||
updateBatchStateAndBroadcastRef.current!(sessionId, prev => ({
|
||||
...prev,
|
||||
[sessionId]: {
|
||||
...prev[sessionId],
|
||||
@@ -1353,7 +1375,8 @@ export function useBatchProcessor({
|
||||
timeTracking.stopTracking(sessionId);
|
||||
delete errorResolutionRefs.current[sessionId];
|
||||
delete stopRequestedRefs.current[sessionId];
|
||||
}, [onUpdateSession, onSpawnAgent, onSpawnSynopsis, onAddHistoryEntry, onComplete, onPRResult, audioFeedbackEnabled, audioFeedbackCommand, updateBatchStateAndBroadcast, timeTracking]);
|
||||
// Note: updateBatchStateAndBroadcast is accessed via ref to avoid stale closure in long-running async
|
||||
}, [onUpdateSession, onSpawnAgent, onSpawnSynopsis, onAddHistoryEntry, onComplete, onPRResult, audioFeedbackEnabled, audioFeedbackCommand, timeTracking]);
|
||||
|
||||
/**
|
||||
* Request to stop the batch run for a specific session after current task completes
|
||||
@@ -1530,6 +1553,7 @@ export function useBatchProcessor({
|
||||
getBatchState,
|
||||
hasAnyActiveBatch,
|
||||
activeBatchSessionIds,
|
||||
stoppingBatchSessionIds,
|
||||
startBatchRun,
|
||||
stopBatchRun,
|
||||
customPrompts,
|
||||
|
||||
@@ -65,7 +65,7 @@ export function useSortedSessions(deps: UseSortedSessionsDeps): UseSortedSession
|
||||
}
|
||||
// Sort each group once
|
||||
for (const [, children] of map) {
|
||||
children.sort((a, b) => compareNamesIgnoringEmojis(a.worktreeBranch || a.name, b.worktreeBranch || b.name));
|
||||
children.sort((a, b) => compareNamesIgnoringEmojis(a.name, b.name));
|
||||
}
|
||||
return map;
|
||||
}, [sessions]);
|
||||
|
||||
@@ -50,6 +50,10 @@ export interface MarkdownComponentsOptions {
|
||||
onFileClick?: (filePath: string) => void;
|
||||
/** Callback when external link is clicked - if not provided, uses default browser behavior */
|
||||
onExternalLinkClick?: (href: string) => void;
|
||||
/** Callback when anchor link is clicked (same-page #section links) */
|
||||
onAnchorClick?: (anchorId: string) => void;
|
||||
/** Container ref for scrolling to anchors - if not provided, uses document.getElementById */
|
||||
containerRef?: React.RefObject<HTMLElement>;
|
||||
/** Search highlighting options */
|
||||
searchHighlight?: {
|
||||
query: string;
|
||||
@@ -286,7 +290,7 @@ function highlightSearchMatches(
|
||||
}
|
||||
|
||||
export function createMarkdownComponents(options: MarkdownComponentsOptions): Partial<Components> {
|
||||
const { theme, imageRenderer, customLanguageRenderers = {}, onFileClick, onExternalLinkClick, searchHighlight } = options;
|
||||
const { theme, imageRenderer, customLanguageRenderers = {}, onFileClick, onExternalLinkClick, onAnchorClick, containerRef, searchHighlight } = options;
|
||||
|
||||
// Reset match counter at start of each render
|
||||
globalMatchCounter = 0;
|
||||
@@ -365,8 +369,8 @@ export function createMarkdownComponents(options: MarkdownComponentsOptions): Pa
|
||||
};
|
||||
}
|
||||
|
||||
// Link handler - supports both internal file links and external links
|
||||
if (onFileClick || onExternalLinkClick) {
|
||||
// Link handler - supports internal file links, anchor links, and external links
|
||||
if (onFileClick || onExternalLinkClick || onAnchorClick) {
|
||||
components.a = ({ node: _node, href, children, ...props }: any) => {
|
||||
// Check for maestro-file:// protocol OR data-maestro-file attribute
|
||||
// (data attribute is fallback when rehype strips custom protocols)
|
||||
@@ -374,6 +378,10 @@ export function createMarkdownComponents(options: MarkdownComponentsOptions): Pa
|
||||
const isMaestroFile = href?.startsWith('maestro-file://') || !!dataFilePath;
|
||||
const filePath = dataFilePath || (href?.startsWith('maestro-file://') ? href.replace('maestro-file://', '') : null);
|
||||
|
||||
// Check for anchor links (same-page navigation)
|
||||
const isAnchorLink = href?.startsWith('#');
|
||||
const anchorId = isAnchorLink ? href.slice(1) : null;
|
||||
|
||||
return React.createElement(
|
||||
'a',
|
||||
{
|
||||
@@ -383,6 +391,19 @@ export function createMarkdownComponents(options: MarkdownComponentsOptions): Pa
|
||||
e.preventDefault();
|
||||
if (isMaestroFile && filePath && onFileClick) {
|
||||
onFileClick(filePath);
|
||||
} else if (isAnchorLink && anchorId) {
|
||||
// Handle anchor links - scroll to the target element
|
||||
if (onAnchorClick) {
|
||||
onAnchorClick(anchorId);
|
||||
} else {
|
||||
// Default behavior: find element by ID and scroll to it
|
||||
const targetElement = containerRef?.current
|
||||
? containerRef.current.querySelector(`#${CSS.escape(anchorId)}`)
|
||||
: document.getElementById(anchorId);
|
||||
if (targetElement) {
|
||||
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
} else if (href && onExternalLinkClick) {
|
||||
onExternalLinkClick(href);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user