feat: Title bar context, achievement UX, and UI polish

Title Bar:
- Add centered context text showing Group | Agent | Session info
- Text remains draggable and non-selectable

Standing Ovation Achievements:
- Replace white glow background with confetti burst animation
- Confetti shoots from center and animates behind the modal
- Logarithmic scale for time sliders in Developer Playground
- Better control over badge level testing across the full range

UI/UX Improvements:
- Markdown list rendering: proper spacing, indentation, inline paragraphs
- Process Monitor: fix text truncation with flex layout
- LogViewer: persist selected filter levels across sessions
- Tab completion: prevent focus change on empty input in terminal mode
- SessionList: fix useEffect dependency causing unnecessary re-renders
- NewInstanceModal: hide internal 'terminal' agent from selection UI

Bug Fixes:
- Session persistence: properly filter non-persistable tab fields
- Agent detector: add hidden flag for internal-only agents
This commit is contained in:
Pedram Amini
2025-12-01 15:42:05 -06:00
parent a7665f3519
commit 0904e8ca69
13 changed files with 275 additions and 104 deletions

View File

@@ -23,9 +23,19 @@ export interface AgentConfig {
path?: string;
requiresPty?: boolean; // Whether this agent needs a pseudo-terminal
configOptions?: AgentConfigOption[]; // Agent-specific configuration
hidden?: boolean; // If true, agent is hidden from UI (internal use only)
}
const AGENT_DEFINITIONS: Omit<AgentConfig, 'available' | 'path'>[] = [
{
id: 'terminal',
name: 'Terminal',
binaryName: 'bash',
command: 'bash',
args: [],
requiresPty: true,
hidden: true, // Internal agent, not shown in UI
},
{
id: 'claude-code',
name: 'Claude Code',

View File

@@ -248,6 +248,12 @@ export default function MaestroConsole() {
// Flash notification state (for inline notifications like "Commands disabled while agent is working")
const [flashNotification, setFlashNotification] = useState<string | null>(null);
// @ mention file completion state (AI mode only, desktop only)
const [atMentionOpen, setAtMentionOpen] = useState(false);
const [atMentionFilter, setAtMentionFilter] = useState('');
const [atMentionStartIndex, setAtMentionStartIndex] = useState(-1); // Position of @ in input
const [selectedAtMentionIndex, setSelectedAtMentionIndex] = useState(0);
// Note: Images are now stored per-tab in AITab.stagedImages
// See stagedImages/setStagedImages computed from active tab below
@@ -5003,19 +5009,23 @@ export default function MaestroConsole() {
setCommandHistorySelectedIndex(0);
}
} else if (e.key === 'Tab') {
// Tab completion only in terminal mode when not showing slash commands
if (activeSession?.inputMode === 'terminal' && !slashCommandOpen && inputValue.trim()) {
// Tab completion in terminal mode when not showing slash commands
// Always prevent default Tab behavior in terminal mode to avoid focus change
if (activeSession?.inputMode === 'terminal' && !slashCommandOpen) {
e.preventDefault();
// Get suggestions and show dropdown if there are any
const suggestions = getTabCompletionSuggestions(inputValue);
if (suggestions.length > 0) {
// If only one suggestion, auto-complete it
if (suggestions.length === 1) {
setInputValue(suggestions[0].value);
} else {
// Show dropdown for multiple suggestions
setSelectedTabCompletionIndex(0);
setTabCompletionOpen(true);
// Only show suggestions if there's input
if (inputValue.trim()) {
const suggestions = getTabCompletionSuggestions(inputValue);
if (suggestions.length > 0) {
// If only one suggestion, auto-complete it
if (suggestions.length === 1) {
setInputValue(suggestions[0].value);
} else {
// Show dropdown for multiple suggestions
setSelectedTabCompletionIndex(0);
setTabCompletionOpen(true);
}
}
}
}
@@ -5364,11 +5374,41 @@ export default function MaestroConsole() {
{/* --- DRAGGABLE TITLE BAR (hidden in mobile landscape) --- */}
{!isMobileLandscape && (
<div
className="fixed top-0 left-0 right-0 h-10"
className="fixed top-0 left-0 right-0 h-10 flex items-center justify-center"
style={{
WebkitAppRegion: 'drag',
} as React.CSSProperties}
/>
>
{activeSession && (
<span
className="text-xs select-none opacity-50"
style={{ color: theme.colors.textDim }}
>
{(() => {
const parts: string[] = [];
// Group name (if grouped)
const group = groups.find(g => g.id === activeSession.groupId);
if (group) {
parts.push(`${group.emoji} ${group.name}`);
}
// Agent name mapping
const agentNames: Record<string, string> = {
'claude-code': 'Claude Code',
'claude': 'Claude',
'aider': 'Aider',
'opencode': 'OpenCode',
'terminal': 'Terminal',
};
parts.push(agentNames[activeSession.toolType] || activeSession.toolType);
// Session name or UUID octet
const sessionLabel = activeSession.name ||
(activeSession.claudeSessionId ? activeSession.claudeSessionId.split('-')[0].toUpperCase() : 'New');
parts.push(sessionLabel);
return parts.join(' | ');
})()}
</span>
)}
</div>
)}
{/* --- MODALS --- */}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { Search, X, Trash2, Download, ChevronRight, ChevronDown, ChevronsDownUp, ChevronsUpDown } from 'lucide-react';
import type { Theme } from '../types';
import { useLayerStack } from '../contexts/LayerStackContext';
@@ -17,6 +17,8 @@ interface LogViewerProps {
theme: Theme;
onClose: () => void;
logLevel?: string; // Current log level setting (debug, info, warn, error)
savedSelectedLevels?: string[]; // Persisted filter selections
onSelectedLevelsChange?: (levels: string[]) => void; // Callback to persist filter changes
}
// Log level priority for determining which levels are enabled
@@ -27,7 +29,7 @@ const LOG_LEVEL_PRIORITY: Record<string, number> = {
error: 3,
};
export function LogViewer({ theme, onClose, logLevel = 'info' }: LogViewerProps) {
export function LogViewer({ theme, onClose, logLevel = 'info', savedSelectedLevels, onSelectedLevelsChange }: LogViewerProps) {
const [logs, setLogs] = useState<SystemLogEntry[]>([]);
const [filteredLogs, setFilteredLogs] = useState<SystemLogEntry[]>([]);
const [searchOpen, setSearchOpen] = useState(false);
@@ -43,10 +45,25 @@ export function LogViewer({ theme, onClose, logLevel = 'info' }: LogViewerProps)
// Toast is always enabled (it's a special notification level)
enabledLevels.add('toast');
// Only allow selecting levels that are enabled
const [selectedLevels, setSelectedLevels] = useState<Set<'debug' | 'info' | 'warn' | 'error' | 'toast'>>(
new Set(['debug', 'info', 'warn', 'error', 'toast'])
);
// Initialize selectedLevels from saved settings if available
const [selectedLevels, setSelectedLevelsState] = useState<Set<'debug' | 'info' | 'warn' | 'error' | 'toast'>>(() => {
if (savedSelectedLevels && savedSelectedLevels.length > 0) {
return new Set(savedSelectedLevels as ('debug' | 'info' | 'warn' | 'error' | 'toast')[]);
}
return new Set(['debug', 'info', 'warn', 'error', 'toast']);
});
// Wrapper to persist changes when selectedLevels changes
const setSelectedLevels = useCallback((updater: Set<'debug' | 'info' | 'warn' | 'error' | 'toast'> | ((prev: Set<'debug' | 'info' | 'warn' | 'error' | 'toast'>) => Set<'debug' | 'info' | 'warn' | 'error' | 'toast'>)) => {
setSelectedLevelsState(prev => {
const newSet = typeof updater === 'function' ? updater(prev) : updater;
// Persist to settings
if (onSelectedLevelsChange) {
onSelectedLevelsChange(Array.from(newSet));
}
return newSet;
});
}, [onSelectedLevelsChange]);
const [expandedData, setExpandedData] = useState<Set<number>>(new Set());
const [showClearConfirm, setShowClearConfirm] = useState(false);
const logsEndRef = useRef<HTMLDivElement>(null);

View File

@@ -52,6 +52,8 @@ interface MainPanelProps {
gitDiffPreview: string | null;
fileTreeFilterOpen: boolean;
logLevel?: string; // Current log level setting for LogViewer
logViewerSelectedLevels: string[]; // Persisted filter selections for LogViewer
setLogViewerSelectedLevels: (levels: string[]) => void;
// Setters
setGitDiffPreview: (preview: string | null) => void;
@@ -299,7 +301,13 @@ export function MainPanel(props: MainPanelProps) {
if (logViewerOpen) {
return (
<div className="flex-1 flex flex-col min-w-0 relative" style={{ backgroundColor: theme.colors.bgMain }}>
<LogViewer theme={theme} onClose={() => setLogViewerOpen(false)} logLevel={logLevel} />
<LogViewer
theme={theme}
onClose={() => setLogViewerOpen(false)}
logLevel={logLevel}
savedSelectedLevels={props.logViewerSelectedLevels}
onSelectedLevelsChange={props.setLogViewerSelectedLevels}
/>
</div>
);
}

View File

@@ -211,20 +211,26 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, defaultAgen
<div className="text-sm opacity-50">Loading agents...</div>
) : (
<div className="space-y-2">
{agents.map((agent) => (
<button
{agents.filter(a => !a.hidden).map((agent) => (
<div
key={agent.id}
disabled={agent.id !== 'claude-code' || !agent.available}
onClick={() => setSelectedAgent(agent.id)}
onClick={() => {
if (agent.id === 'claude-code' && agent.available) {
setSelectedAgent(agent.id);
}
}}
className={`w-full text-left p-3 rounded border transition-all ${
selectedAgent === agent.id ? 'ring-2' : ''
} ${(agent.id !== 'claude-code' || !agent.available) ? 'opacity-40 cursor-not-allowed' : 'hover:bg-opacity-10'}`}
} ${(agent.id !== 'claude-code' || !agent.available) ? 'opacity-40 cursor-not-allowed' : 'hover:bg-opacity-10 cursor-pointer'}`}
style={{
borderColor: theme.colors.border,
backgroundColor: selectedAgent === agent.id ? theme.colors.accentDim : 'transparent',
ringColor: theme.colors.accent,
color: theme.colors.textMain,
}}
role="option"
aria-selected={selectedAgent === agent.id}
tabIndex={agent.id === 'claude-code' && agent.available ? 0 : -1}
>
<div className="flex items-center justify-between">
<div>
@@ -264,7 +270,7 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, defaultAgen
)}
</div>
</div>
</button>
</div>
))}
</div>
)}

View File

@@ -126,6 +126,28 @@ export function PlaygroundPanel({ theme, themeMode, onClose }: PlaygroundPanelPr
return `${seconds}s`;
};
// Logarithmic scale for time slider - matches badge progression
// Maps 0-100 slider value to 0-10years using logarithmic scale
const MIN_TIME = 1000; // 1 second minimum
const MAX_TIME = 315360000000; // 10 years in ms
const LOG_MIN = Math.log(MIN_TIME);
const LOG_MAX = Math.log(MAX_TIME);
const sliderToTime = (sliderValue: number): number => {
if (sliderValue === 0) return 0;
// Map 0-100 to logarithmic scale
const logValue = LOG_MIN + (sliderValue / 100) * (LOG_MAX - LOG_MIN);
return Math.round(Math.exp(logValue));
};
const timeToSlider = (timeMs: number): number => {
if (timeMs <= 0) return 0;
if (timeMs < MIN_TIME) return 0;
// Map logarithmic time back to 0-100
const logValue = Math.log(timeMs);
return Math.round(((logValue - LOG_MIN) / (LOG_MAX - LOG_MIN)) * 100);
};
return (
<>
<div
@@ -230,9 +252,9 @@ export function PlaygroundPanel({ theme, themeMode, onClose }: PlaygroundPanelPr
<input
type="range"
min={0}
max={315360000000} // 10 years in ms
value={mockCumulativeTime}
onChange={e => setMockCumulativeTime(Number(e.target.value))}
max={100}
value={timeToSlider(mockCumulativeTime)}
onChange={e => setMockCumulativeTime(sliderToTime(Number(e.target.value)))}
className="w-full"
/>
</div>
@@ -243,9 +265,9 @@ export function PlaygroundPanel({ theme, themeMode, onClose }: PlaygroundPanelPr
<input
type="range"
min={0}
max={86400000 * 7} // 7 days in ms
value={mockLongestRun}
onChange={e => setMockLongestRun(Number(e.target.value))}
max={100}
value={timeToSlider(mockLongestRun)}
onChange={e => setMockLongestRun(sliderToTime(Number(e.target.value)))}
className="w-full"
/>
</div>

View File

@@ -445,9 +445,9 @@ export function ProcessMonitor(props: ProcessMonitorProps) {
)}
{!hasChildren && <div className="w-4 h-4 flex-shrink-0" />}
<span className="mr-2">{node.emoji}</span>
<span className="font-medium">{node.label}</span>
<span className="font-medium flex-1 truncate">{node.label}</span>
{hasChildren && (
<span className="text-xs ml-auto" style={{ color: theme.colors.textDim }}>
<span className="text-xs flex-shrink-0" style={{ color: theme.colors.textDim }}>
{node.children!.length} {node.children!.length === 1 ? 'session' : 'sessions'}
</span>
)}
@@ -488,8 +488,8 @@ export function ProcessMonitor(props: ProcessMonitorProps) {
)}
{!hasChildren && <div className="w-4 h-4 flex-shrink-0" />}
<Activity className="w-4 h-4 flex-shrink-0" style={{ color: activeCount > 0 ? theme.colors.success : theme.colors.textDim }} />
<span>{node.label}</span>
<span className="text-xs ml-auto flex items-center gap-2" style={{ color: theme.colors.textDim }}>
<span className="flex-1 truncate">{node.label}</span>
<span className="text-xs flex items-center gap-2 flex-shrink-0" style={{ color: theme.colors.textDim }}>
{activeCount > 0 && (
<span
className="px-1.5 py-0.5 rounded text-xs"
@@ -536,8 +536,8 @@ export function ProcessMonitor(props: ProcessMonitorProps) {
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: statusColor }}
/>
<span className="text-sm">{node.label}</span>
<span className="text-xs ml-auto font-mono" style={{ color: theme.colors.textDim }}>
<span className="text-sm flex-1 truncate">{node.label}</span>
<span className="text-xs font-mono flex-shrink-0" style={{ color: theme.colors.textDim }}>
{node.pid ? `PID: ${node.pid}` : 'No PID'}
</span>
<span

View File

@@ -491,6 +491,7 @@ export function SessionList(props: SessionListProps) {
: sessions;
// Temporarily expand groups when filtering to show matching sessions
// Note: Only depend on sessionFilter and sessions (not filteredSessions which changes reference each render)
useEffect(() => {
if (sessionFilter) {
// Save current group states before filtering
@@ -502,7 +503,7 @@ export function SessionList(props: SessionListProps) {
// Find groups that contain matching sessions
const groupsWithMatches = new Set<string>();
filteredSessions.forEach(session => {
sessions.filter(s => s.name.toLowerCase().includes(sessionFilter.toLowerCase())).forEach(session => {
if (session.groupId) {
groupsWithMatches.add(session.groupId);
}
@@ -523,7 +524,8 @@ export function SessionList(props: SessionListProps) {
setPreFilterGroupStates(new Map());
}
}
}, [sessionFilter, filteredSessions]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionFilter]);
// Get the jump number (1-9, 0=10th) for a session based on its position in visibleSessions
const getSessionJumpNumber = (sessionId: string): string | null => {

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { ExternalLink, Trophy, Clock, Star, Share2, Copy, Download, Check } from 'lucide-react';
import type { Theme, ThemeMode } from '../types';
import type { ConductorBadge } from '../constants/conductorBadges';
@@ -7,6 +7,20 @@ import { MODAL_PRIORITIES } from '../constants/modalPriorities';
import { AnimatedMaestro } from './MaestroSilhouette';
import { formatCumulativeTime, formatTimeRemaining, getNextBadge } from '../constants/conductorBadges';
// Confetti particle type
interface ConfettiParticle {
id: number;
x: number;
y: number;
rotation: number;
color: string;
size: number;
velocityX: number;
velocityY: number;
rotationSpeed: number;
delay: number;
}
interface StandingOvationOverlayProps {
theme: Theme;
themeMode: ThemeMode;
@@ -74,6 +88,34 @@ export function StandingOvationOverlay({
const goldColor = '#FFD700';
const purpleAccent = theme.colors.accent;
// Generate confetti particles that shoot from center
const confettiParticles = useMemo<ConfettiParticle[]>(() => {
const particles: ConfettiParticle[] = [];
const colors = [goldColor, purpleAccent, '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD'];
const numParticles = 80;
for (let i = 0; i < numParticles; i++) {
// Random angle for burst direction (full 360 degrees)
const angle = (Math.random() * Math.PI * 2);
// Random speed for variety
const speed = 200 + Math.random() * 400;
particles.push({
id: i,
x: 50, // Start at center (%)
y: 50, // Start at center (%)
rotation: Math.random() * 360,
color: colors[Math.floor(Math.random() * colors.length)],
size: 6 + Math.random() * 10,
velocityX: Math.cos(angle) * speed,
velocityY: Math.sin(angle) * speed,
rotationSpeed: (Math.random() - 0.5) * 720,
delay: Math.random() * 0.3, // Stagger the burst
});
}
return particles;
}, [purpleAccent]);
// Generate shareable achievement card as canvas
const generateShareImage = useCallback(async (): Promise<HTMLCanvasElement> => {
const canvas = document.createElement('canvas');
@@ -246,40 +288,57 @@ export function StandingOvationOverlay({
tabIndex={-1}
onClick={onClose}
style={{
background: isDark
? 'radial-gradient(ellipse at center, rgba(139, 92, 246, 0.3) 0%, rgba(0, 0, 0, 0.95) 70%)'
: 'radial-gradient(ellipse at center, rgba(139, 92, 246, 0.2) 0%, rgba(255, 255, 255, 0.98) 70%)',
backgroundColor: isDark ? 'rgba(0, 0, 0, 0.9)' : 'rgba(0, 0, 0, 0.8)',
}}
>
{/* Sparkle effects */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{[...Array(20)].map((_, i) => (
{/* Confetti burst effect - behind the modal (z-index: 0) */}
<div className="absolute inset-0 overflow-hidden pointer-events-none" style={{ zIndex: 0 }}>
{confettiParticles.map((particle) => (
<div
key={i}
className="absolute animate-pulse"
key={particle.id}
className="absolute"
style={{
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
width: `${4 + Math.random() * 8}px`,
height: `${4 + Math.random() * 8}px`,
borderRadius: '50%',
background: i % 3 === 0 ? goldColor : purpleAccent,
opacity: 0.3 + Math.random() * 0.4,
animationDelay: `${Math.random() * 2}s`,
animationDuration: `${1 + Math.random() * 2}s`,
}}
left: `${particle.x}%`,
top: `${particle.y}%`,
width: `${particle.size}px`,
height: `${particle.size * 0.6}px`,
backgroundColor: particle.color,
borderRadius: '2px',
transform: `rotate(${particle.rotation}deg)`,
opacity: 0,
animation: `confetti-burst 3s ease-out ${particle.delay}s forwards`,
// CSS custom properties for the animation
'--vx': `${particle.velocityX}px`,
'--vy': `${particle.velocityY}px`,
'--rot': `${particle.rotationSpeed}deg`,
} as React.CSSProperties}
/>
))}
</div>
{/* Main content card */}
{/* Keyframe animation style */}
<style>{`
@keyframes confetti-burst {
0% {
opacity: 1;
transform: translate(0, 0) rotate(0deg);
}
100% {
opacity: 0;
transform: translate(var(--vx), calc(var(--vy) + 200px)) rotate(var(--rot));
}
}
`}</style>
{/* Main content card - z-index: 1 to be above confetti */}
<div
className="relative max-w-lg w-full mx-4 rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-500"
onClick={(e) => e.stopPropagation()}
style={{
backgroundColor: theme.colors.bgSidebar,
border: `2px solid ${goldColor}`,
boxShadow: `0 0 60px ${purpleAccent}40, 0 0 100px ${goldColor}20`,
boxShadow: `0 0 40px rgba(0, 0, 0, 0.5)`,
zIndex: 1,
}}
>
{/* Header with glow */}

View File

@@ -518,9 +518,10 @@ const LogItemComponent = memo(({
.prose h5 { color: ${theme.colors.textMain}; font-size: 0.9em; font-weight: bold; margin: 0.3em 0 0.15em 0; opacity: 0.8; line-height: 1.3; }
.prose h6 { color: ${theme.colors.textDim}; font-size: 0.85em; font-weight: bold; margin: 0.3em 0 0.15em 0; line-height: 1.3; }
.prose p { color: ${theme.colors.textMain}; margin: 0.25em 0; line-height: 1.5; }
.prose ul, .prose ol { color: ${theme.colors.textMain}; margin: 0.1em 0; padding-left: 1.5em; }
.prose li { margin: 0; padding: 0; line-height: 1.35; }
.prose li > p { margin: 0; }
.prose > ul, .prose > ol { color: ${theme.colors.textMain}; margin: 0.75em 0; padding-left: 2em; }
.prose ul ul, .prose ul ol, .prose ol ul, .prose ol ol { margin: 0; padding-left: 1.5em; }
.prose li { margin: 0; padding: 0; line-height: 1.5; }
.prose li > p { margin: 0; display: inline; }
.prose li:has(> input[type="checkbox"]) { list-style: none; margin-left: -1.5em; }
.prose code { background-color: ${theme.colors.bgSidebar}; color: ${theme.colors.textMain}; padding: 0.15em 0.3em; border-radius: 3px; font-size: 0.9em; }
.prose pre { background-color: ${theme.colors.bgSidebar}; color: ${theme.colors.textMain}; padding: 0.75em; border-radius: 6px; overflow-x: auto; margin: 0.4em 0; }
@@ -665,9 +666,10 @@ const LogItemComponent = memo(({
.prose h5 { color: ${theme.colors.textMain}; font-size: 0.9em; font-weight: bold; margin: 0.3em 0 0.15em 0; opacity: 0.8; line-height: 1.3; }
.prose h6 { color: ${theme.colors.textDim}; font-size: 0.85em; font-weight: bold; margin: 0.3em 0 0.15em 0; line-height: 1.3; }
.prose p { color: ${theme.colors.textMain}; margin: 0.25em 0; line-height: 1.5; }
.prose ul, .prose ol { color: ${theme.colors.textMain}; margin: 0.1em 0; padding-left: 1.5em; }
.prose li { margin: 0; padding: 0; line-height: 1.35; }
.prose li > p { margin: 0; }
.prose > ul, .prose > ol { color: ${theme.colors.textMain}; margin: 0.75em 0; padding-left: 2em; }
.prose ul ul, .prose ul ol, .prose ol ul, .prose ol ol { margin: 0; padding-left: 1.5em; }
.prose li { margin: 0; padding: 0; line-height: 1.5; }
.prose li > p { margin: 0; display: inline; }
.prose li:has(> input[type="checkbox"]) { list-style: none; margin-left: -1.5em; }
.prose code { background-color: ${theme.colors.bgSidebar}; color: ${theme.colors.textMain}; padding: 0.15em 0.3em; border-radius: 3px; font-size: 0.9em; }
.prose pre { background-color: ${theme.colors.bgSidebar}; color: ${theme.colors.textMain}; padding: 0.75em; border-radius: 6px; overflow-x: auto; margin: 0.4em 0; }
@@ -795,9 +797,10 @@ const LogItemComponent = memo(({
.prose h5 { color: ${theme.colors.textMain}; font-size: 0.9em; font-weight: bold; margin: 0.3em 0 0.15em 0; opacity: 0.8; line-height: 1.3; }
.prose h6 { color: ${theme.colors.textDim}; font-size: 0.85em; font-weight: bold; margin: 0.3em 0 0.15em 0; line-height: 1.3; }
.prose p { color: ${theme.colors.textMain}; margin: 0.25em 0; line-height: 1.5; }
.prose ul, .prose ol { color: ${theme.colors.textMain}; margin: 0.1em 0; padding-left: 1.5em; }
.prose li { margin: 0; padding: 0; line-height: 1.35; }
.prose li > p { margin: 0; }
.prose > ul, .prose > ol { color: ${theme.colors.textMain}; margin: 0.75em 0; padding-left: 2em; }
.prose ul ul, .prose ul ol, .prose ol ul, .prose ol ol { margin: 0; padding-left: 1.5em; }
.prose li { margin: 0; padding: 0; line-height: 1.5; }
.prose li > p { margin: 0; display: inline; }
.prose li:has(> input[type="checkbox"]) { list-style: none; margin-left: -1.5em; }
.prose code { background-color: ${theme.colors.bgSidebar}; color: ${theme.colors.textMain}; padding: 0.15em 0.3em; border-radius: 3px; font-size: 0.9em; }
.prose pre { background-color: ${theme.colors.bgSidebar}; color: ${theme.colors.textMain}; padding: 0.75em; border-radius: 6px; overflow-x: auto; margin: 0.4em 0; }

View File

@@ -21,6 +21,7 @@ interface AgentConfig {
path?: string;
command?: string;
args?: string[];
hidden?: boolean;
}
interface DirectoryEntry {

View File

@@ -39,20 +39,25 @@ const prepareSessionForPersistence = (session: Session): Session => {
return session;
}
// Truncate logs in each tab to the last MAX_PERSISTED_LOGS_PER_TAB entries
const truncatedTabs = session.aiTabs.map(tab => {
if (tab.logs.length > MAX_PERSISTED_LOGS_PER_TAB) {
return {
...tab,
logs: tab.logs.slice(-MAX_PERSISTED_LOGS_PER_TAB)
};
}
return tab;
});
// Truncate logs in each tab and reset runtime-only tab state
const truncatedTabs = session.aiTabs.map(tab => ({
...tab,
logs: tab.logs.length > MAX_PERSISTED_LOGS_PER_TAB
? tab.logs.slice(-MAX_PERSISTED_LOGS_PER_TAB)
: tab.logs,
// Reset runtime-only tab state - processes don't survive app restart
state: 'idle' as const,
thinkingStartTime: undefined,
}));
return {
...session,
aiTabs: truncatedTabs,
// Reset runtime-only session state - processes don't survive app restart
state: 'idle',
busySource: undefined,
thinkingStartTime: undefined,
currentCycleTokens: undefined,
// Explicitly exclude closedTabHistory - it's runtime-only
closedTabHistory: []
};
@@ -194,16 +199,9 @@ export function useSessionManager(): UseSessionManagerReturn {
return;
}
// Get terminal agent definition
const terminalAgent = await window.maestro.agents.get('terminal');
if (!terminalAgent) {
console.error('Terminal agent not found');
return;
}
// Spawn BOTH processes - this is the dual-process architecture
// Spawn AI process (terminal uses runCommand which spawns fresh shells per command)
try {
// 1. Spawn AI agent process (skip for Claude batch mode)
// Spawn AI agent process (skip for Claude batch mode)
const isClaudeBatchMode = agentId === 'claude-code';
let aiSpawnResult = { pid: 0, success: true }; // Default for batch mode
@@ -221,18 +219,7 @@ export function useSessionManager(): UseSessionManagerReturn {
}
}
// 2. Spawn terminal process
const terminalSpawnResult = await window.maestro.process.spawn({
sessionId: `${newId}-terminal`,
toolType: 'terminal',
cwd: workingDir,
command: terminalAgent.command,
args: terminalAgent.args || []
});
if (!terminalSpawnResult.success || terminalSpawnResult.pid <= 0) {
throw new Error('Failed to spawn terminal process');
}
// Terminal processes are spawned lazily via runCommand() - no persistent PTY needed
// Check if the working directory is a Git repository
const isGitRepo = await gitService.isRepo(workingDir);
@@ -251,9 +238,9 @@ export function useSessionManager(): UseSessionManagerReturn {
scratchPadContent: '',
contextUsage: 0,
inputMode: agentId === 'terminal' ? 'terminal' : 'ai',
// Store both PIDs - each session now has two processes
// AI process PID (terminal uses runCommand which spawns fresh shells)
aiPid: aiSpawnResult.pid,
terminalPid: terminalSpawnResult.pid,
terminalPid: 0,
port: 3000 + Math.floor(Math.random() * 100),
isLive: false,
changedFiles: [],

View File

@@ -128,6 +128,10 @@ export interface UseSettingsReturn {
toastDuration: number;
setToastDuration: (value: number) => void;
// Log Viewer settings
logViewerSelectedLevels: string[];
setLogViewerSelectedLevels: (value: string[]) => void;
// Shortcuts
shortcuts: Record<string, Shortcut>;
setShortcuts: (value: Record<string, Shortcut>) => void;
@@ -195,6 +199,9 @@ export function useSettings(): UseSettingsReturn {
const [audioFeedbackCommand, setAudioFeedbackCommandState] = useState('say'); // Default: macOS say command
const [toastDuration, setToastDurationState] = useState(20); // Default: 20 seconds, 0 = never auto-dismiss
// Log Viewer Config
const [logViewerSelectedLevels, setLogViewerSelectedLevelsState] = useState<string[]>(['debug', 'info', 'warn', 'error', 'toast']);
// Shortcuts
const [shortcuts, setShortcutsState] = useState<Record<string, Shortcut>>(DEFAULT_SHORTCUTS);
@@ -333,6 +340,11 @@ export function useSettings(): UseSettingsReturn {
window.maestro.settings.set('toastDuration', value);
};
const setLogViewerSelectedLevels = (value: string[]) => {
setLogViewerSelectedLevelsState(value);
window.maestro.settings.set('logViewerSelectedLevels', value);
};
const setCustomAICommands = (value: CustomAICommand[]) => {
setCustomAICommandsState(value);
window.maestro.settings.set('customAICommands', value);
@@ -472,6 +484,7 @@ export function useSettings(): UseSettingsReturn {
const savedAudioFeedbackEnabled = await window.maestro.settings.get('audioFeedbackEnabled');
const savedAudioFeedbackCommand = await window.maestro.settings.get('audioFeedbackCommand');
const savedToastDuration = await window.maestro.settings.get('toastDuration');
const savedLogViewerSelectedLevels = await window.maestro.settings.get('logViewerSelectedLevels');
const savedCustomAICommands = await window.maestro.settings.get('customAICommands');
const savedGlobalStats = await window.maestro.settings.get('globalStats');
const savedAutoRunStats = await window.maestro.settings.get('autoRunStats');
@@ -501,6 +514,7 @@ export function useSettings(): UseSettingsReturn {
if (savedAudioFeedbackEnabled !== undefined) setAudioFeedbackEnabledState(savedAudioFeedbackEnabled);
if (savedAudioFeedbackCommand !== undefined) setAudioFeedbackCommandState(savedAudioFeedbackCommand);
if (savedToastDuration !== undefined) setToastDurationState(savedToastDuration);
if (savedLogViewerSelectedLevels !== undefined) setLogViewerSelectedLevelsState(savedLogViewerSelectedLevels);
// Merge saved shortcuts with defaults (in case new shortcuts were added)
if (savedShortcuts !== undefined) {
@@ -595,6 +609,8 @@ export function useSettings(): UseSettingsReturn {
setAudioFeedbackCommand,
toastDuration,
setToastDuration,
logViewerSelectedLevels,
setLogViewerSelectedLevels,
shortcuts,
setShortcuts,
customAICommands,