mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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 --- */}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
1
src/renderer/global.d.ts
vendored
1
src/renderer/global.d.ts
vendored
@@ -21,6 +21,7 @@ interface AgentConfig {
|
||||
path?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
interface DirectoryEntry {
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user