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:
Pedram Amini
2025-12-26 02:23:05 -06:00
parent 289457d272
commit b78f9523c4
14 changed files with 364 additions and 68 deletions

50
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

@@ -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.*'}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]);

View File

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