feat: Enhance achievement system with canvas-confetti and share functionality

- Replace custom CSS confetti with canvas-confetti library for massive
  Raycast-style explosions (850+ initial particles with continuous bursts)
- Add circular progress ring around Maestro icon showing 11 badge segments
- Add share button to AchievementCard with copy-to-clipboard and download
- Fire confetti immediately on Standing Ovation mount and on dismiss
- Fix infinite loop error in PlaygroundPanel layer registration
- Fix badge tooltip positioning and click-to-toggle behavior
- Improve empty badge cell visibility with dashed borders

Claude ID: 97a10f0d-145d-4352-babd-6d9caed0f9dc
Maestro ID: b9bc0d08-5be2-4fdf-93cd-5618a8d53b35
This commit is contained in:
Pedram Amini
2025-12-02 11:59:00 -06:00
parent c6b3ebb954
commit e14ec426a0
11 changed files with 637 additions and 260 deletions

19
package-lock.json generated
View File

@@ -18,6 +18,7 @@
"@fastify/websocket": "^9.0.0",
"@types/dompurify": "^3.0.5",
"ansi-to-html": "^0.7.2",
"canvas-confetti": "^1.9.4",
"diff": "^8.0.2",
"dompurify": "^3.3.0",
"electron-store": "^8.1.0",
@@ -35,6 +36,7 @@
"ws": "^8.16.0"
},
"devDependencies": {
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^20.10.6",
"@types/qrcode": "^1.5.6",
"@types/react": "^18.2.47",
@@ -2100,6 +2102,13 @@
"@types/responselike": "^1.0.0"
}
},
"node_modules/@types/canvas-confetti": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz",
"integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@@ -3604,6 +3613,16 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvas-confetti": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz",
"integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==",
"license": "ISC",
"funding": {
"type": "donate",
"url": "https://www.paypal.me/kirilvatev"
}
},
"node_modules/ccount": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",

View File

@@ -102,6 +102,7 @@
"@fastify/websocket": "^9.0.0",
"@types/dompurify": "^3.0.5",
"ansi-to-html": "^0.7.2",
"canvas-confetti": "^1.9.4",
"diff": "^8.0.2",
"dompurify": "^3.3.0",
"electron-store": "^8.1.0",
@@ -119,6 +120,7 @@
"ws": "^8.16.0"
},
"devDependencies": {
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^20.10.6",
"@types/qrcode": "^1.5.6",
"@types/react": "^18.2.47",

View File

@@ -2990,6 +2990,12 @@ export default function MaestroConsole() {
else if (ctx.isShortcut(e, 'goToFiles')) { e.preventDefault(); ctx.setRightPanelOpen(true); ctx.setActiveRightTab('files'); ctx.setActiveFocus('right'); }
else if (ctx.isShortcut(e, 'goToHistory')) { e.preventDefault(); ctx.setRightPanelOpen(true); ctx.setActiveRightTab('history'); ctx.setActiveFocus('right'); }
else if (ctx.isShortcut(e, 'goToScratchpad')) { e.preventDefault(); ctx.setRightPanelOpen(true); ctx.setActiveRightTab('scratchpad'); ctx.setActiveFocus('right'); }
else if (ctx.isShortcut(e, 'openImageCarousel')) {
e.preventDefault();
if (ctx.stagedImages.length > 0) {
ctx.handleSetLightboxImage(ctx.stagedImages[0], ctx.stagedImages);
}
}
else if (ctx.isShortcut(e, 'focusInput')) {
e.preventDefault();
ctx.setActiveFocus('main');
@@ -3613,7 +3619,7 @@ export default function MaestroConsole() {
setSessions, createTab, closeTab, reopenClosedTab, getActiveTab, setRenameTabId, setRenameTabInitialName,
setRenameTabModalOpen, navigateToNextTab, navigateToPrevTab, navigateToTabByIndex, navigateToLastTab,
setFileTreeFilterOpen, isShortcut, isTabShortcut, handleNavBack, handleNavForward, toggleUnreadFilter,
setTabSwitcherOpen, showUnreadOnly
setTabSwitcherOpen, showUnreadOnly, stagedImages, handleSetLightboxImage
};
const toggleGroup = (groupId: string) => {

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import { Trophy, Clock, Zap, Star, ExternalLink, ChevronDown, History } from 'lucide-react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Trophy, Clock, Zap, Star, ExternalLink, ChevronDown, History, Share2, Copy, Download, Check } from 'lucide-react';
import type { Theme } from '../types';
import type { AutoRunStats } from '../types';
import {
@@ -245,7 +245,10 @@ function BadgeTooltip({ badge, theme, isUnlocked, position, onClose }: BadgeTool
export function AchievementCard({ theme, autoRunStats, onEscapeWithBadgeOpen }: AchievementCardProps & { onEscapeWithBadgeOpen?: (handler: (() => boolean) | null) => void }) {
const [selectedBadge, setSelectedBadge] = useState<number | null>(null);
const [historyExpanded, setHistoryExpanded] = useState(false);
const [shareMenuOpen, setShareMenuOpen] = useState(false);
const [copySuccess, setCopySuccess] = useState(false);
const badgeContainerRef = useRef<HTMLDivElement>(null);
const shareMenuRef = useRef<HTMLDivElement>(null);
// Register escape handler with parent when badge is selected
useEffect(() => {
@@ -299,6 +302,191 @@ export function AchievementCard({ theme, autoRunStats, onEscapeWithBadgeOpen }:
);
const currentLevel = currentBadge?.level || 0;
const goldColor = '#FFD700';
const purpleAccent = theme.colors.accent;
// Close share menu when clicking outside
useEffect(() => {
if (!shareMenuOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (shareMenuRef.current && !shareMenuRef.current.contains(e.target as Node)) {
setShareMenuOpen(false);
}
};
const timeoutId = setTimeout(() => {
document.addEventListener('click', handleClickOutside);
}, 0);
return () => {
clearTimeout(timeoutId);
document.removeEventListener('click', handleClickOutside);
};
}, [shareMenuOpen]);
// Helper to wrap text for canvas
const wrapText = (ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] => {
const words = text.split(' ');
const lines: string[] = [];
let currentLine = '';
words.forEach(word => {
const testLine = currentLine ? `${currentLine} ${word}` : word;
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && currentLine) {
lines.push(currentLine);
currentLine = word;
} else {
currentLine = testLine;
}
});
if (currentLine) lines.push(currentLine);
return lines;
};
// Generate shareable achievement card as canvas
const generateShareImage = useCallback(async (): Promise<HTMLCanvasElement> => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
const width = 600;
const height = 400;
canvas.width = width;
canvas.height = height;
// Background gradient
const bgGradient = ctx.createLinearGradient(0, 0, width, height);
bgGradient.addColorStop(0, '#1a1a2e');
bgGradient.addColorStop(1, '#16213e');
ctx.fillStyle = bgGradient;
ctx.roundRect(0, 0, width, height, 16);
ctx.fill();
// Border
ctx.strokeStyle = goldColor;
ctx.lineWidth = 3;
ctx.roundRect(0, 0, width, height, 16);
ctx.stroke();
// Header accent
const headerGradient = ctx.createLinearGradient(0, 0, width, 100);
headerGradient.addColorStop(0, `${purpleAccent}40`);
headerGradient.addColorStop(1, 'transparent');
ctx.fillStyle = headerGradient;
ctx.fillRect(0, 0, width, 100);
// Trophy icon (simplified circle)
ctx.beginPath();
ctx.arc(width / 2, 60, 30, 0, Math.PI * 2);
const trophyGradient = ctx.createRadialGradient(width / 2, 60, 0, width / 2, 60, 30);
trophyGradient.addColorStop(0, '#FFA500');
trophyGradient.addColorStop(1, goldColor);
ctx.fillStyle = trophyGradient;
ctx.fill();
// Trophy emoji
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 28px system-ui';
ctx.textAlign = 'center';
ctx.fillText('🏆', width / 2, 70);
// Title
ctx.font = 'bold 24px system-ui';
ctx.fillStyle = goldColor;
ctx.fillText('MAESTRO ACHIEVEMENTS', width / 2, 120);
if (currentBadge) {
// Level badge
ctx.font = 'bold 18px system-ui';
ctx.fillStyle = goldColor;
ctx.fillText(`⭐ Level ${currentBadge.level} of 11 ⭐`, width / 2, 155);
// Badge name
ctx.font = 'bold 28px system-ui';
ctx.fillStyle = purpleAccent;
ctx.fillText(currentBadge.name, width / 2, 190);
// Flavor text
ctx.font = 'italic 14px system-ui';
ctx.fillStyle = '#CCCCCC';
const flavorLines = wrapText(ctx, `"${currentBadge.flavorText}"`, width - 80);
let yOffset = 225;
flavorLines.forEach(line => {
ctx.fillText(line, width / 2, yOffset);
yOffset += 18;
});
} else {
// No badge yet
ctx.font = 'bold 20px system-ui';
ctx.fillStyle = '#AAAAAA';
ctx.fillText('Journey Just Beginning...', width / 2, 170);
ctx.font = '14px system-ui';
ctx.fillStyle = '#888888';
ctx.fillText('Complete 15 minutes of AutoRun to unlock first badge', width / 2, 200);
}
// Stats box
const statsY = 300;
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)';
ctx.roundRect(50, statsY - 10, width - 100, 50, 8);
ctx.fill();
ctx.font = '14px system-ui';
ctx.fillStyle = '#AAAAAA';
ctx.textAlign = 'left';
ctx.fillText('Total AutoRun:', 70, statsY + 15);
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 14px system-ui';
ctx.fillText(formatCumulativeTime(autoRunStats.cumulativeTimeMs), 180, statsY + 15);
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px system-ui';
ctx.fillText('Longest Run:', 350, statsY + 15);
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 14px system-ui';
ctx.fillText(formatCumulativeTime(autoRunStats.longestRunMs), 450, statsY + 15);
// Footer branding
ctx.font = 'bold 12px system-ui';
ctx.fillStyle = '#666666';
ctx.textAlign = 'center';
ctx.fillText('MAESTRO • Agent Orchestration Command Center', width / 2, height - 20);
return canvas;
}, [currentBadge, autoRunStats.cumulativeTimeMs, autoRunStats.longestRunMs, purpleAccent]);
// Copy to clipboard
const copyToClipboard = useCallback(async () => {
try {
const canvas = await generateShareImage();
canvas.toBlob(async (blob) => {
if (blob) {
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob })
]);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
}
}, 'image/png');
} catch (error) {
console.error('Failed to copy to clipboard:', error);
}
}, [generateShareImage]);
// Download as image
const downloadImage = useCallback(async () => {
try {
const canvas = await generateShareImage();
const link = document.createElement('a');
link.download = `maestro-achievement-level-${currentLevel}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
} catch (error) {
console.error('Failed to download image:', error);
}
}, [generateShareImage, currentLevel]);
return (
<div
@@ -309,11 +497,62 @@ export function AchievementCard({ theme, autoRunStats, onEscapeWithBadgeOpen }:
}}
>
{/* Header */}
<div className="flex items-center gap-2 mb-3">
<Trophy className="w-4 h-4" style={{ color: '#FFD700' }} />
<span className="text-sm font-bold" style={{ color: theme.colors.textMain }}>
Maestro Achievements
</span>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Trophy className="w-4 h-4" style={{ color: '#FFD700' }} />
<span className="text-sm font-bold" style={{ color: theme.colors.textMain }}>
Maestro Achievements
</span>
</div>
{/* Share button */}
<div className="relative" ref={shareMenuRef}>
<button
onClick={() => setShareMenuOpen(!shareMenuOpen)}
className="p-1.5 rounded-md transition-colors hover:bg-white/10"
style={{ color: theme.colors.textDim }}
title="Share achievements"
>
<Share2 className="w-4 h-4" />
</button>
{shareMenuOpen && (
<div
className="absolute right-0 top-full mt-1 p-1.5 rounded-lg shadow-xl z-50 min-w-[160px]"
style={{
backgroundColor: theme.colors.bgSidebar,
border: `1px solid ${theme.colors.border}`,
}}
>
<button
onClick={() => {
copyToClipboard();
setShareMenuOpen(false);
}}
className="w-full flex items-center gap-2 px-3 py-2 rounded text-sm hover:bg-white/10 transition-colors"
>
{copySuccess ? (
<Check className="w-4 h-4" style={{ color: theme.colors.success }} />
) : (
<Copy className="w-4 h-4" style={{ color: theme.colors.textDim }} />
)}
<span style={{ color: theme.colors.textMain }}>
{copySuccess ? 'Copied!' : 'Copy to Clipboard'}
</span>
</button>
<button
onClick={() => {
downloadImage();
setShareMenuOpen(false);
}}
className="w-full flex items-center gap-2 px-3 py-2 rounded text-sm hover:bg-white/10 transition-colors"
>
<Download className="w-4 h-4" style={{ color: theme.colors.textDim }} />
<span style={{ color: theme.colors.textMain }}>Save as Image</span>
</button>
</div>
)}
</div>
</div>
{/* Current badge display */}
@@ -476,9 +715,7 @@ export function AchievementCard({ theme, autoRunStats, onEscapeWithBadgeOpen }:
onClick={() => setSelectedBadge(isSelected ? null : badge.level)}
>
<div
className={`h-3 rounded-full cursor-pointer transition-all hover:scale-110 ${
isCurrent ? 'ring-2 ring-offset-1' : ''
}`}
className="h-3 rounded-full cursor-pointer transition-all hover:scale-110"
style={{
backgroundColor: isUnlocked
? badge.level <= 3
@@ -487,9 +724,9 @@ export function AchievementCard({ theme, autoRunStats, onEscapeWithBadgeOpen }:
? '#FFD700'
: '#FF6B35'
: theme.colors.border,
ringColor: isCurrent ? '#FFD700' : 'transparent',
opacity: isUnlocked ? 1 : 0.5,
border: isUnlocked ? 'none' : `1px dashed ${theme.colors.textDim}`,
boxShadow: isCurrent ? `0 0 0 2px ${theme.colors.bgActivity}, 0 0 0 4px #FFD700` : 'none',
}}
title={`${badge.name} - Click to view details`}
/>

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import { X, Bot, User, Copy, Check, CheckCircle, XCircle, Trash2, Clock, Cpu, Zap, Play } from 'lucide-react';
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { X, Bot, User, Copy, Check, CheckCircle, XCircle, Trash2, Clock, Cpu, Zap, Play, ChevronLeft, ChevronRight } from 'lucide-react';
import type { Theme, HistoryEntry } from '../types';
import { useLayerStack } from '../contexts/LayerStackContext';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
@@ -33,6 +33,10 @@ interface HistoryDetailModalProps {
onResumeSession?: (claudeSessionId: string) => void;
onDelete?: (entryId: string) => void;
onUpdate?: (entryId: string, updates: { validated?: boolean }) => Promise<boolean>;
// Navigation props for prev/next
filteredEntries?: HistoryEntry[];
currentIndex?: number;
onNavigate?: (entry: HistoryEntry, index: number) => void;
}
// Get context bar color based on usage percentage
@@ -49,7 +53,10 @@ export function HistoryDetailModal({
onJumpToClaudeSession,
onResumeSession,
onDelete,
onUpdate
onUpdate,
filteredEntries,
currentIndex,
onNavigate
}: HistoryDetailModalProps) {
const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack();
const layerIdRef = useRef<string>();
@@ -59,6 +66,26 @@ export function HistoryDetailModal({
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const deleteButtonRef = useRef<HTMLButtonElement>(null);
// Navigation state
const canNavigate = filteredEntries && currentIndex !== undefined && onNavigate;
const hasPrev = canNavigate && currentIndex > 0;
const hasNext = canNavigate && currentIndex < filteredEntries.length - 1;
// Navigation handlers
const goToPrev = useCallback(() => {
if (hasPrev && filteredEntries && onNavigate) {
const newIndex = currentIndex! - 1;
onNavigate(filteredEntries[newIndex], newIndex);
}
}, [hasPrev, filteredEntries, currentIndex, onNavigate]);
const goToNext = useCallback(() => {
if (hasNext && filteredEntries && onNavigate) {
const newIndex = currentIndex! + 1;
onNavigate(filteredEntries[newIndex], newIndex);
}
}, [hasNext, filteredEntries, currentIndex, onNavigate]);
// Register layer on mount
useEffect(() => {
const id = registerLayer({
@@ -93,6 +120,25 @@ export function HistoryDetailModal({
}
}, [showDeleteConfirm]);
// Keyboard navigation for prev/next with arrow keys
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't handle if delete confirmation is showing
if (showDeleteConfirm) return;
if (e.key === 'ArrowLeft') {
e.preventDefault();
goToPrev();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
goToNext();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [goToPrev, goToNext, showDeleteConfirm]);
// Format timestamp
const formatTime = (timestamp: number) => {
const date = new Date(timestamp);
@@ -369,7 +415,7 @@ export function HistoryDetailModal({
{/* Footer */}
<div
className="flex justify-between px-6 py-4 border-t shrink-0"
className="flex items-center justify-between px-6 py-4 border-t shrink-0"
style={{ borderColor: theme.colors.border }}
>
{/* Delete button */}
@@ -387,6 +433,44 @@ export function HistoryDetailModal({
Delete
</button>
{/* Prev/Next navigation buttons - centered */}
{canNavigate && (
<div className="flex items-center gap-3">
<button
onClick={goToPrev}
disabled={!hasPrev}
className="flex items-center gap-1 px-3 py-2 rounded text-sm font-medium transition-colors"
style={{
backgroundColor: hasPrev ? theme.colors.bgActivity : 'transparent',
color: hasPrev ? theme.colors.textMain : theme.colors.textDim,
border: `1px solid ${hasPrev ? theme.colors.border : theme.colors.border + '40'}`,
opacity: hasPrev ? 1 : 0.4,
cursor: hasPrev ? 'pointer' : 'default'
}}
title={hasPrev ? 'Previous entry (←)' : 'No previous entry'}
>
<ChevronLeft className="w-4 h-4" />
Prev
</button>
<button
onClick={goToNext}
disabled={!hasNext}
className="flex items-center gap-1 px-3 py-2 rounded text-sm font-medium transition-colors"
style={{
backgroundColor: hasNext ? theme.colors.bgActivity : 'transparent',
color: hasNext ? theme.colors.textMain : theme.colors.textDim,
border: `1px solid ${hasNext ? theme.colors.border : theme.colors.border + '40'}`,
opacity: hasNext ? 1 : 0.4,
cursor: hasNext ? 'pointer' : 'default'
}}
title={hasNext ? 'Next entry (→)' : 'No next entry'}
>
Next
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
<button
onClick={onClose}
className="px-4 py-2 rounded text-sm font-medium transition-colors hover:opacity-90"

View File

@@ -846,6 +846,17 @@ export const HistoryPanel = React.memo(forwardRef<HistoryPanelHandle, HistoryPan
}
return success;
}}
// Navigation props - use allFilteredEntries (respects filters)
filteredEntries={allFilteredEntries}
currentIndex={selectedIndex}
onNavigate={(entry, index) => {
setSelectedIndex(index);
setDetailModalEntry(entry);
// Ensure the entry is visible in the list (expand displayCount if needed)
if (index >= displayCount) {
setDisplayCount(Math.min(index + LOAD_MORE_COUNT, allFilteredEntries.length));
}
}}
/>
)}

View File

@@ -652,9 +652,9 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) {
tabSaveToHistory ? '' : 'opacity-40 hover:opacity-70'
}`}
style={{
backgroundColor: tabSaveToHistory ? `${theme.colors.info}25` : 'transparent',
color: tabSaveToHistory ? theme.colors.info : theme.colors.textDim,
border: tabSaveToHistory ? `1px solid ${theme.colors.info}50` : '1px solid transparent'
backgroundColor: tabSaveToHistory ? `${theme.colors.accent}25` : 'transparent',
color: tabSaveToHistory ? theme.colors.accent : theme.colors.textDim,
border: tabSaveToHistory ? `1px solid ${theme.colors.accent}50` : '1px solid transparent'
}}
title="Save to History (Cmd+S) - Synopsis added after each completion"
>

View File

@@ -243,12 +243,23 @@ export function ProcessMonitor(props: ProcessMonitorProps) {
// Look up Claude session ID from the tab if this is an AI process
let claudeSessionId: string | undefined;
let tabId: string | undefined;
if (processType === 'ai') {
if (processType === 'ai' || processType === 'batch' || processType === 'synopsis') {
tabId = parseTabId(proc.sessionId) || undefined;
if (tabId && session.aiTabs) {
const tab = session.aiTabs.find(t => t.id === tabId);
if (tab?.claudeSessionId) {
claudeSessionId = tab.claudeSessionId;
if (session.aiTabs) {
// First try to find by tab ID
if (tabId) {
const tab = session.aiTabs.find(t => t.id === tabId);
if (tab?.claudeSessionId) {
claudeSessionId = tab.claudeSessionId;
}
}
// Fall back to active tab if no tab ID match
if (!claudeSessionId) {
const activeTab = session.aiTabs.find(t => t.id === session.activeTabId);
if (activeTab?.claudeSessionId) {
claudeSessionId = activeTab.claudeSessionId;
tabId = activeTab.id;
}
}
}
}
@@ -268,59 +279,48 @@ export function ProcessMonitor(props: ProcessMonitorProps) {
});
});
// If no active processes, show placeholder based on session state
if (sessionNode.children!.length === 0) {
// Show what could be running based on session type
if (session.toolType !== 'terminal') {
sessionNode.children!.push({
id: `process-${session.id}-ai-inactive`,
type: 'process',
label: `AI Agent (${session.toolType})`,
pid: session.aiPid > 0 ? session.aiPid : undefined,
processType: 'ai',
sessionId: session.id,
isAlive: false
});
}
sessionNode.children!.push({
id: `process-${session.id}-terminal-inactive`,
type: 'process',
label: 'Terminal Shell',
pid: session.terminalPid > 0 ? session.terminalPid : undefined,
processType: 'terminal',
sessionId: session.id,
isAlive: false
});
}
// Only return session node if it has active processes
return sessionNode;
};
// Add grouped sessions
// Add grouped sessions (only include sessions with active processes)
groups.forEach(group => {
const groupSessions = sessionsByGroup.get(group.id) || [];
const groupNode: ProcessNode = {
id: `group-${group.id}`,
type: 'group',
label: group.name,
emoji: group.emoji,
expanded: expandedNodes.has(`group-${group.id}`),
children: groupSessions.map(session => buildSessionNode(session))
};
tree.push(groupNode);
const sessionNodes = groupSessions
.map(session => buildSessionNode(session))
.filter(node => node.children && node.children.length > 0);
// Only add group if it has sessions with active processes
if (sessionNodes.length > 0) {
const groupNode: ProcessNode = {
id: `group-${group.id}`,
type: 'group',
label: group.name,
emoji: group.emoji,
expanded: expandedNodes.has(`group-${group.id}`),
children: sessionNodes
};
tree.push(groupNode);
}
});
// Add ungrouped sessions (root level)
// Add ungrouped sessions (root level, only with active processes)
if (ungroupedSessions.length > 0) {
const rootNode: ProcessNode = {
id: 'group-root',
type: 'group',
label: 'UNGROUPED',
emoji: '📁',
expanded: expandedNodes.has('group-root'),
children: ungroupedSessions.map(session => buildSessionNode(session))
};
tree.push(rootNode);
const sessionNodes = ungroupedSessions
.map(session => buildSessionNode(session))
.filter(node => node.children && node.children.length > 0);
if (sessionNodes.length > 0) {
const rootNode: ProcessNode = {
id: 'group-root',
type: 'group',
label: 'UNGROUPED',
emoji: '📁',
expanded: expandedNodes.has('group-root'),
children: sessionNodes
};
tree.push(rootNode);
}
}
return tree;
@@ -535,9 +535,6 @@ export function ProcessMonitor(props: ProcessMonitorProps) {
}
if (node.type === 'process') {
const statusColor = node.isAlive ? theme.colors.success : theme.colors.textDim;
const statusText = node.isAlive ? 'Running' : 'Idle';
return (
<div
ref={isSelected ? selectedNodeRef as React.RefObject<HTMLDivElement> : null}
@@ -558,7 +555,7 @@ export function ProcessMonitor(props: ProcessMonitorProps) {
<div className="w-4 h-4 flex-shrink-0" />
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: statusColor }}
style={{ backgroundColor: theme.colors.success }}
/>
<span className="text-sm flex-1 truncate">{node.label}</span>
{node.claudeSessionId && node.sessionId && onNavigateToSession && (
@@ -581,16 +578,16 @@ export function ProcessMonitor(props: ProcessMonitorProps) {
</span>
)}
<span className="text-xs font-mono flex-shrink-0" style={{ color: theme.colors.textDim }}>
{node.pid ? `PID: ${node.pid}` : 'No PID'}
PID: {node.pid}
</span>
<span
className="text-xs px-2 py-0.5 rounded"
style={{
backgroundColor: node.isAlive ? `${theme.colors.success}20` : `${theme.colors.textDim}20`,
color: statusColor
backgroundColor: `${theme.colors.success}20`,
color: theme.colors.success
}}
>
{statusText}
Running
</span>
</div>
);
@@ -702,7 +699,7 @@ export function ProcessMonitor(props: ProcessMonitorProps) {
className="px-6 py-8 text-center"
style={{ color: theme.colors.textDim }}
>
No active sessions
No running processes
</div>
) : (
<div className="py-2">
@@ -726,8 +723,6 @@ export function ProcessMonitor(props: ProcessMonitorProps) {
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: theme.colors.success }} />
<span>Running</span>
<div className="w-2 h-2 rounded-full ml-3" style={{ backgroundColor: theme.colors.textDim }} />
<span>Idle</span>
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { ExternalLink, Trophy, Clock, Star, Share2, Copy, Download, Check } from 'lucide-react';
import confetti from 'canvas-confetti';
import type { Theme, ThemeMode } from '../types';
import type { ConductorBadge } from '../constants/conductorBadges';
import { useLayerStack } from '../contexts/LayerStackContext';
@@ -7,22 +8,6 @@ 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;
shape: 'rect' | 'square' | 'circle' | 'star';
duration: number;
}
interface StandingOvationOverlayProps {
theme: Theme;
themeMode: ThemeMode;
@@ -52,6 +37,186 @@ export function StandingOvationOverlay({
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
// Ref for the close handler that includes confetti animation
const handleCloseRef = useRef<() => void>(() => {});
// State
const nextBadge = getNextBadge(badge);
const isDark = themeMode === 'dark';
const maestroVariant = isDark ? 'light' : 'dark';
const [shareMenuOpen, setShareMenuOpen] = useState(false);
const [copySuccess, setCopySuccess] = useState(false);
const [isClosing, setIsClosing] = useState(false);
// Accent colors
const goldColor = '#FFD700';
const purpleAccent = theme.colors.accent;
// Confetti colors matching our theme
const confettiColors = [
goldColor, purpleAccent,
'#FF6B6B', '#FF8E53', '#FFA726', // Warm colors
'#4ECDC4', '#45B7D1', '#64B5F6', // Cool colors
'#96CEB4', '#81C784', // Greens
'#FFEAA7', '#FFD54F', // Yellows
'#DDA0DD', '#BA68C8', '#9575CD', // Purples
'#F48FB1', '#FF80AB', // Pinks
];
// Fire confetti burst - MASSIVE Raycast style explosion
const fireConfetti = useCallback(() => {
const duration = 4000;
const animationEnd = Date.now() + duration;
const defaults = {
startVelocity: 55,
spread: 360,
ticks: 200,
zIndex: 999999,
colors: confettiColors,
disableForReducedMotion: true,
gravity: 0.8,
};
// Helper to get random value in range
const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min;
// INITIAL MASSIVE BURST - fire immediately from multiple points
// Center explosion
confetti({
particleCount: 300,
spread: 120,
origin: { x: 0.5, y: 0.5 },
colors: confettiColors,
startVelocity: 70,
zIndex: 999999,
scalar: 1.3,
gravity: 0.7,
ticks: 300,
});
// Left burst
confetti({
particleCount: 200,
spread: 80,
origin: { x: 0.2, y: 0.5 },
colors: confettiColors,
startVelocity: 65,
zIndex: 999999,
scalar: 1.2,
angle: 60,
gravity: 0.8,
ticks: 250,
});
// Right burst
confetti({
particleCount: 200,
spread: 80,
origin: { x: 0.8, y: 0.5 },
colors: confettiColors,
startVelocity: 65,
zIndex: 999999,
scalar: 1.2,
angle: 120,
gravity: 0.8,
ticks: 250,
});
// Top burst
confetti({
particleCount: 150,
spread: 100,
origin: { x: 0.5, y: 0.3 },
colors: confettiColors,
startVelocity: 60,
zIndex: 999999,
scalar: 1.1,
gravity: 1,
ticks: 200,
});
// Fire continuous bursts
const interval = setInterval(() => {
const timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) {
clearInterval(interval);
return;
}
const particleCount = 250 * (timeLeft / duration);
// Fire from 5 random origins for maximum chaos
for (let i = 0; i < 5; i++) {
confetti({
...defaults,
particleCount: Math.floor(particleCount / 3),
origin: { x: randomInRange(0.1, 0.9), y: randomInRange(0.2, 0.6) },
scalar: randomInRange(0.8, 1.5),
startVelocity: randomInRange(45, 70),
});
}
}, 100);
// Secondary wave after 200ms
setTimeout(() => {
confetti({
particleCount: 250,
spread: 140,
origin: { x: 0.5, y: 0.45 },
colors: confettiColors,
startVelocity: 75,
zIndex: 999999,
scalar: 1.4,
gravity: 0.6,
ticks: 300,
});
}, 200);
// Third wave
setTimeout(() => {
confetti({
particleCount: 200,
spread: 160,
origin: { x: 0.3, y: 0.4 },
colors: confettiColors,
startVelocity: 60,
zIndex: 999999,
scalar: 1.2,
gravity: 0.9,
});
confetti({
particleCount: 200,
spread: 160,
origin: { x: 0.7, y: 0.4 },
colors: confettiColors,
startVelocity: 60,
zIndex: 999999,
scalar: 1.2,
gravity: 0.9,
});
}, 400);
}, [confettiColors]);
// Fire confetti on mount - immediately!
useEffect(() => {
fireConfetti();
}, [fireConfetti]);
// Handle graceful close with confetti
const handleTakeABow = useCallback(() => {
if (isClosing) return;
setIsClosing(true);
// Fire closing confetti burst
fireConfetti();
// Wait for confetti animation then close
setTimeout(() => {
onClose();
}, 1500);
}, [isClosing, onClose, fireConfetti]);
// Register with layer stack
useEffect(() => {
const id = registerLayer({
@@ -61,7 +226,7 @@ export function StandingOvationOverlay({
capturesFocus: true,
focusTrap: 'strict',
ariaLabel: 'Standing Ovation Achievement',
onEscape: () => onCloseRef.current(),
onEscape: () => handleCloseRef.current(),
});
layerIdRef.current = id;
@@ -74,77 +239,16 @@ export function StandingOvationOverlay({
};
}, [registerLayer, unregisterLayer]);
// Update close handler ref when handleTakeABow changes
useEffect(() => {
handleCloseRef.current = handleTakeABow;
}, [handleTakeABow]);
useEffect(() => {
if (layerIdRef.current) {
updateLayerHandler(layerIdRef.current, onClose);
updateLayerHandler(layerIdRef.current, () => handleCloseRef.current());
}
}, [onClose, updateLayerHandler]);
const nextBadge = getNextBadge(badge);
const isDark = themeMode === 'dark';
const maestroVariant = isDark ? 'light' : 'dark';
const [shareMenuOpen, setShareMenuOpen] = useState(false);
const [copySuccess, setCopySuccess] = useState(false);
// Accent colors
const goldColor = '#FFD700';
const purpleAccent = theme.colors.accent;
// Generate confetti particles - Ray Cast style explosion with multiple waves
const confettiParticles = useMemo<ConfettiParticle[]>(() => {
const particles: ConfettiParticle[] = [];
const colors = [
goldColor, purpleAccent,
'#FF6B6B', '#FF8E53', '#FFA726', // Warm colors
'#4ECDC4', '#45B7D1', '#64B5F6', // Cool colors
'#96CEB4', '#81C784', // Greens
'#FFEAA7', '#FFD54F', // Yellows
'#DDA0DD', '#BA68C8', '#9575CD', // Purples
'#F48FB1', '#FF80AB', // Pinks
'#FFFFFF', '#E0E0E0', // Whites/silvers
];
// Multiple burst waves for that explosive Ray Cast feel
const waves = [
{ count: 400, speedMin: 600, speedMax: 1200, delayBase: 0, delaySpread: 0.1 }, // Initial explosion
{ count: 300, speedMin: 400, speedMax: 900, delayBase: 0.05, delaySpread: 0.15 }, // Second wave
{ count: 200, speedMin: 300, speedMax: 700, delayBase: 0.1, delaySpread: 0.2 }, // Third wave
{ count: 150, speedMin: 200, speedMax: 500, delayBase: 0.2, delaySpread: 0.3 }, // Slower trailing pieces
{ count: 100, speedMin: 100, speedMax: 300, delayBase: 0.3, delaySpread: 0.5 }, // Floaty pieces
];
let id = 0;
const shapes: Array<'rect' | 'square' | 'circle' | 'star'> = ['rect', 'rect', 'rect', 'square', 'square', 'circle', 'star'];
for (const wave of waves) {
for (let i = 0; i < wave.count; i++) {
// Random angle for burst direction (full 360 degrees)
const angle = Math.random() * Math.PI * 2;
// Random speed within wave's range
const speed = wave.speedMin + Math.random() * (wave.speedMax - wave.speedMin);
// Add some vertical bias for gravity feel
const gravityBias = Math.random() * 150;
particles.push({
id: id++,
x: 50 + (Math.random() - 0.5) * 5, // Slight spread from center
y: 50 + (Math.random() - 0.5) * 5,
rotation: Math.random() * 360,
color: colors[Math.floor(Math.random() * colors.length)],
size: 4 + Math.random() * 12, // Varied sizes
velocityX: Math.cos(angle) * speed,
velocityY: Math.sin(angle) * speed + gravityBias,
rotationSpeed: (Math.random() - 0.5) * 1080, // More spin
delay: wave.delayBase + Math.random() * wave.delaySpread,
shape: shapes[Math.floor(Math.random() * shapes.length)],
duration: 2.5 + Math.random() * 2.5, // 2.5-5 seconds
});
}
}
return particles;
}, [purpleAccent]);
}, [updateLayerHandler]);
// Generate shareable achievement card as canvas
const generateShareImage = useCallback(async (): Promise<HTMLCanvasElement> => {
@@ -316,105 +420,21 @@ export function StandingOvationOverlay({
aria-modal="true"
aria-label="Standing Ovation Achievement"
tabIndex={-1}
onClick={onClose}
onClick={handleTakeABow}
style={{
backgroundColor: 'rgba(0, 0, 0, 0.95)',
}}
>
{/* 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) => {
// Determine shape styling
let width: string;
let height: string;
let borderRadius: string;
let boxShadow: string;
switch (particle.shape) {
case 'circle':
width = `${particle.size}px`;
height = `${particle.size}px`;
borderRadius = '50%';
boxShadow = `0 0 ${particle.size / 2}px ${particle.color}40`;
break;
case 'square':
width = `${particle.size}px`;
height = `${particle.size}px`;
borderRadius = '2px';
boxShadow = `0 0 ${particle.size / 3}px ${particle.color}30`;
break;
case 'star':
width = `${particle.size}px`;
height = `${particle.size}px`;
borderRadius = '0';
boxShadow = `0 0 ${particle.size}px ${particle.color}60`;
break;
case 'rect':
default:
width = `${particle.size}px`;
height = `${particle.size * 0.4}px`;
borderRadius = '1px';
boxShadow = 'none';
break;
}
return (
<div
key={particle.id}
className="absolute"
style={{
left: `${particle.x}%`,
top: `${particle.y}%`,
width,
height,
backgroundColor: particle.color,
borderRadius,
boxShadow,
transform: `rotate(${particle.rotation}deg)`,
opacity: 0,
animation: `confetti-burst ${particle.duration}s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${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>
{/* Keyframe animation style - enhanced physics */}
<style>{`
@keyframes confetti-burst {
0% {
opacity: 1;
transform: translate(0, 0) rotate(0deg) scale(1);
}
10% {
opacity: 1;
}
50% {
opacity: 0.9;
}
80% {
opacity: 0.5;
}
100% {
opacity: 0;
transform: translate(var(--vx), calc(var(--vy) + 600px)) rotate(var(--rot)) scale(0.5);
}
}
`}</style>
{/* Main content card - z-index: 1 to be above confetti */}
{/* Main content card */}
<div
className="relative max-w-lg w-full mx-4 rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-500"
className={`relative max-w-lg w-full mx-4 rounded-2xl shadow-2xl overflow-hidden transition-all duration-500 ${
isClosing ? 'opacity-0 scale-95' : 'animate-in zoom-in-95'
}`}
onClick={(e) => e.stopPropagation()}
style={{
backgroundColor: theme.colors.bgSidebar,
border: `2px solid ${goldColor}`,
boxShadow: `0 0 40px rgba(0, 0, 0, 0.5)`,
zIndex: 1,
}}
>
{/* Header with glow */}
@@ -590,15 +610,16 @@ export function StandingOvationOverlay({
{/* Buttons */}
<div className="px-8 pb-8 space-y-3">
<button
onClick={onClose}
className="w-full py-3 rounded-lg font-medium transition-all hover:scale-[1.02]"
onClick={handleTakeABow}
disabled={isClosing}
className="w-full py-3 rounded-lg font-medium transition-all hover:scale-[1.02] disabled:opacity-70 disabled:cursor-not-allowed"
style={{
background: `linear-gradient(135deg, ${purpleAccent} 0%, ${goldColor} 100%)`,
color: '#FFFFFF',
boxShadow: `0 4px 20px ${purpleAccent}40`,
}}
>
Take a Bow
{isClosing ? '🎉 Bravo! 🎉' : 'Take a Bow'}
</button>
{/* Share options */}

View File

@@ -570,9 +570,10 @@ export function TabBar({
const unreadCount = tabs.filter(t => t.hasUnread).length;
// Filter tabs based on unread filter state
// When filter is on, show ONLY unread tabs (can be empty)
// When filter is on, show unread tabs + the active tab (so user sees what they're viewing)
// The active tab disappears from the filtered list when user navigates away from it
const displayedTabs = showUnreadOnly
? tabs.filter(t => t.hasUnread)
? tabs.filter(t => t.hasUnread || t.id === activeTabId)
: tabs;
// Check if tabs overflow the container (need sticky + button)

View File

@@ -29,6 +29,7 @@ export const DEFAULT_SHORTCUTS: Record<string, Shortcut> = {
jumpToBottom: { id: 'jumpToBottom', label: 'Jump to Bottom', keys: ['Meta', 'Shift', 'j'] },
prevTab: { id: 'prevTab', label: 'Previous Tab', keys: ['Meta', 'Shift', '['] },
nextTab: { id: 'nextTab', label: 'Next Tab', keys: ['Meta', 'Shift', ']'] },
openImageCarousel: { id: 'openImageCarousel', label: 'Open Image Carousel', keys: ['Meta', 'y'] },
};
// Non-editable shortcuts (displayed in help but not configurable)