From 04745f38d56baa757f3d5d578e64a9cf5d16897c Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Tue, 2 Dec 2025 21:25:31 -0600 Subject: [PATCH 01/62] =?UTF-8?q?=E2=80=A2=20Fixed=20race=20condition=20in?= =?UTF-8?q?=20session=20state=20updates=20for=20tab=20creation=20?= =?UTF-8?q?=F0=9F=90=9B=20=E2=80=A2=20Enhanced=20tab=20navigation=20to=20s?= =?UTF-8?q?upport=20Cmd+1-9=20and=20Cmd+0=20shortcuts=20=F0=9F=8E=AF=20?= =?UTF-8?q?=E2=80=A2=20Added=20draft=20indicator=20(pencil=20icon)=20for?= =?UTF-8?q?=20unsent=20messages=20in=20tabs=20=E2=9C=8F=EF=B8=8F=20?= =?UTF-8?q?=E2=80=A2=20Removed=20verbose=20console=20logging=20from=20proc?= =?UTF-8?q?ess=20manager=20and=20IPC=20handlers=20=F0=9F=A7=B9=20=E2=80=A2?= =?UTF-8?q?=20Added=20interrupt=20button=20to=20ThinkingStatusPill=20for?= =?UTF-8?q?=20stopping=20AI=20responses=20=F0=9F=9B=91=20=E2=80=A2=20Fixed?= =?UTF-8?q?=20list=20styling=20in=20markdown=20rendering=20with=20proper?= =?UTF-8?q?=20indentation=20=F0=9F=93=9D=20=E2=80=A2=20Improved=20tab=20sc?= =?UTF-8?q?rolling=20behavior=20to=20center=20active=20tab=20in=20view=20?= =?UTF-8?q?=F0=9F=8E=AF=20=E2=80=A2=20Added=20confetti=20playground=20with?= =?UTF-8?q?=20customizable=20particle=20effects=20and=20settings=20?= =?UTF-8?q?=F0=9F=8E=8A=20=E2=80=A2=20Fixed=20share=20menu=20positioning?= =?UTF-8?q?=20in=20achievement=20card=20to=20prevent=20overflow=20?= =?UTF-8?q?=F0=9F=93=90=20=E2=80=A2=20Updated=20keyboard=20shortcuts=20to?= =?UTF-8?q?=20prevent=20conflicts=20with=20system=20commands=20=E2=8C=A8?= =?UTF-8?q?=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 2 - src/main/process-manager.ts | 22 - src/renderer/App.tsx | 42 +- src/renderer/components/AchievementCard.tsx | 12 +- src/renderer/components/InputArea.tsx | 41 +- src/renderer/components/PlaygroundPanel.tsx | 573 +++++++++++++++++- src/renderer/components/Scratchpad.tsx | 1 + .../components/StandingOvationOverlay.tsx | 11 +- src/renderer/components/TabBar.tsx | 20 +- src/renderer/components/TabSwitcherModal.tsx | 14 +- src/renderer/components/TerminalOutput.tsx | 18 +- .../components/ThinkingStatusPill.tsx | 33 +- src/renderer/constants/shortcuts.ts | 3 +- src/renderer/utils/tabHelpers.ts | 37 +- 14 files changed, 718 insertions(+), 111 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 42303423b..e1d54bfb8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3215,7 +3215,6 @@ function setupIpcHandlers() { function setupProcessListeners() { if (processManager) { processManager.on('data', (sessionId: string, data: string) => { - console.log('[IPC] Forwarding process:data to renderer:', { sessionId, dataLength: data.length, hasMainWindow: !!mainWindow }); mainWindow?.webContents.send('process:data', sessionId, data); // Broadcast to web clients - extract base session ID (remove -ai or -terminal suffix) @@ -3283,7 +3282,6 @@ function setupProcessListeners() { totalCostUsd: number; contextWindow: number; }) => { - console.log('[IPC] Forwarding process:usage to renderer:', { sessionId, usageStats }); mainWindow?.webContents.send('process:usage', sessionId, usageStats); }); } diff --git a/src/main/process-manager.ts b/src/main/process-manager.ts index ff3c191de..05a55174b 100644 --- a/src/main/process-manager.ts +++ b/src/main/process-manager.ts @@ -297,18 +297,8 @@ export class ProcessManager extends EventEmitter { console.error('[ProcessManager] stdout error:', err); }); childProcess.stdout.on('data', (data: Buffer | string) => { - console.log('[ProcessManager] >>> STDOUT EVENT FIRED <<<'); - console.log('[ProcessManager] stdout event fired for session:', sessionId); const output = data.toString(); - console.log('[ProcessManager] stdout data received:', { - sessionId, - isBatchMode, - isStreamJsonMode, - dataLength: output.length, - dataPreview: output.substring(0, 200) - }); - if (isStreamJsonMode) { // In stream-json mode, each line is a JSONL message // Accumulate and process complete lines @@ -379,12 +369,10 @@ export class ProcessManager extends EventEmitter { contextWindow }; - console.log('[ProcessManager] Emitting usage stats from stream-json:', usageStats); this.emit('usage', sessionId, usageStats); } } catch (e) { // If it's not valid JSON, emit as raw text - console.log('[ProcessManager] Non-JSON line in stream-json mode:', line.substring(0, 100)); this.emit('data', sessionId, line); } } @@ -487,18 +475,8 @@ export class ProcessManager extends EventEmitter { contextWindow }; - console.log('[ProcessManager] Emitting usage stats:', usageStats); this.emit('usage', sessionId, usageStats); } - - // Emit full response for debugging - console.log('[ProcessManager] Batch mode JSON response:', { - sessionId, - hasResult: !!jsonResponse.result, - hasSessionId: !!jsonResponse.session_id, - sessionIdValue: jsonResponse.session_id, - hasCost: jsonResponse.total_cost_usd !== undefined - }); } catch (error) { console.error('[ProcessManager] Failed to parse JSON response:', error); // Emit raw buffer as fallback diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 7b98a9277..b46106af0 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2430,20 +2430,21 @@ export default function MaestroConsole() { } } - // Create a new tab with the session data using the helper function - const { session: updatedSession } = createTab(activeSession, { - claudeSessionId, - logs: messages, - name, - starred: isStarred - }); - // Update the session and switch to AI mode - setSessions(prev => prev.map(s => - s.id === activeSession.id - ? { ...updatedSession, inputMode: 'ai' } - : s - )); + // IMPORTANT: Use functional update to get fresh session state and avoid race conditions + setSessions(prev => prev.map(s => { + if (s.id !== activeSession.id) return s; + + // Create tab from the CURRENT session state (not stale closure value) + const { session: updatedSession } = createTab(s, { + claudeSessionId, + logs: messages, + name, + starred: isStarred + }); + + return { ...updatedSession, inputMode: 'ai' }; + })); setActiveClaudeSessionId(claudeSessionId); } catch (error) { console.error('Failed to resume session:', error); @@ -3158,9 +3159,9 @@ export default function MaestroConsole() { )); } } - // Cmd+1 through Cmd+8: Jump to specific tab by index (disabled in unread-only mode) + // Cmd+1 through Cmd+9: Jump to specific tab by index (disabled in unread-only mode) if (!ctx.showUnreadOnly) { - for (let i = 1; i <= 8; i++) { + for (let i = 1; i <= 9; i++) { if (ctx.isTabShortcut(e, `goToTab${i}`)) { e.preventDefault(); const result = ctx.navigateToTabByIndex(ctx.activeSession, i - 1); @@ -3172,7 +3173,7 @@ export default function MaestroConsole() { break; } } - // Cmd+9: Jump to last tab + // Cmd+0: Jump to last tab if (ctx.isTabShortcut(e, 'goToLastTab')) { e.preventDefault(); const result = ctx.navigateToLastTab(ctx.activeSession); @@ -3741,7 +3742,16 @@ export default function MaestroConsole() { const processInput = async (overrideInputValue?: string) => { const effectiveInputValue = overrideInputValue ?? inputValue; + console.log('[processInput] Called with:', { + overrideInputValue, + inputValue, + effectiveInputValue, + activeSessionId: activeSession?.id, + inputMode: activeSession?.inputMode, + stagedImagesCount: stagedImages.length + }); if (!activeSession || (!effectiveInputValue.trim() && stagedImages.length === 0)) { + console.log('[processInput] Early return - no session or empty input'); return; } diff --git a/src/renderer/components/AchievementCard.tsx b/src/renderer/components/AchievementCard.tsx index 77e97c7ea..17760562e 100644 --- a/src/renderer/components/AchievementCard.tsx +++ b/src/renderer/components/AchievementCard.tsx @@ -518,7 +518,7 @@ export function AchievementCard({ theme, autoRunStats, onEscapeWithBadgeOpen }: {shareMenuOpen && (
{copySuccess ? ( - + ) : ( - + )} {copySuccess ? 'Copied!' : 'Copy to Clipboard'} @@ -545,9 +545,9 @@ export function AchievementCard({ theme, autoRunStats, onEscapeWithBadgeOpen }: downloadImage(); setShareMenuOpen(false); }} - className="w-full flex items-center gap-2 px-3 py-2 rounded text-sm hover:bg-white/10 transition-colors" + className="w-full flex items-center gap-2 px-3 py-2 rounded text-sm whitespace-nowrap hover:bg-white/10 transition-colors" > - + Save as Image
diff --git a/src/renderer/components/InputArea.tsx b/src/renderer/components/InputArea.tsx index 525cdb270..123869e82 100644 --- a/src/renderer/components/InputArea.tsx +++ b/src/renderer/components/InputArea.tsx @@ -1,5 +1,5 @@ import React, { useRef, useEffect, useMemo } from 'react'; -import { Terminal, Cpu, Keyboard, ImageIcon, X, ArrowUp, StopCircle, Eye, History, File, Folder, GitBranch, Tag, PenLine } from 'lucide-react'; +import { Terminal, Cpu, Keyboard, ImageIcon, X, ArrowUp, Eye, History, File, Folder, GitBranch, Tag, PenLine } from 'lucide-react'; import type { Session, Theme, BatchRunState } from '../types'; import type { TabCompletionSuggestion, TabCompletionFilter } from '../hooks/useTabCompletion'; import { ThinkingStatusPill } from './ThinkingStatusPill'; @@ -223,6 +223,7 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) { autoRunState={autoRunState} activeSessionId={session.id} onStopAutoRun={onStopAutoRun} + onInterrupt={handleInterrupt} /> )} @@ -707,6 +708,7 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) { {/* Mode Toggle & Send/Interrupt Button - Right Side */}
- {/* Show interrupt button only in AI mode when busy. Terminal mode always shows send button - because terminal doesn't block - you can send commands while others are running */} - {session.state === 'busy' && session.inputMode === 'ai' ? ( - - ) : ( - - )} + {/* Send button - always visible. Stop button is now in ThinkingStatusPill */} +
diff --git a/src/renderer/components/PlaygroundPanel.tsx b/src/renderer/components/PlaygroundPanel.tsx index 7f1907f62..07ce17443 100644 --- a/src/renderer/components/PlaygroundPanel.tsx +++ b/src/renderer/components/PlaygroundPanel.tsx @@ -1,5 +1,6 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { X, Trophy, FlaskConical, Play, RotateCcw } from 'lucide-react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { X, Trophy, FlaskConical, Play, RotateCcw, Sparkles, Copy, Check } from 'lucide-react'; +import confetti from 'canvas-confetti'; import type { Theme, AutoRunStats } from '../types'; import { useLayerStack } from '../contexts/LayerStackContext'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; @@ -13,7 +14,7 @@ interface PlaygroundPanelProps { onClose: () => void; } -type TabId = 'achievements'; +type TabId = 'achievements' | 'confetti'; interface Tab { id: TabId; @@ -23,6 +24,37 @@ interface Tab { const TABS: Tab[] = [ { id: 'achievements', label: 'Achievements', icon: }, + { id: 'confetti', label: 'Confetti', icon: }, +]; + +// Available confetti shapes +type ConfettiShape = 'square' | 'circle' | 'star'; +const CONFETTI_SHAPES: ConfettiShape[] = ['square', 'circle', 'star']; + +// Grid position labels +const GRID_LABELS = [ + ['Top Left', 'Top Center', 'Top Right'], + ['Middle Left', 'Center', 'Middle Right'], + ['Bottom Left', 'Bottom Center', 'Bottom Right'], +]; + +// Grid position coordinates (x, y) +const GRID_POSITIONS: [number, number][][] = [ + [[0, 0], [0.5, 0], [1, 0]], + [[0, 0.5], [0.5, 0.5], [1, 0.5]], + [[0, 1], [0.5, 1], [1, 1]], +]; + +// Default confetti colors +const DEFAULT_CONFETTI_COLORS = [ + '#FFD700', // Gold + '#FF6B6B', // Red + '#4ECDC4', // Teal + '#45B7D1', // Blue + '#FFA726', // Orange + '#BA68C8', // Purple + '#F48FB1', // Pink + '#FFEAA7', // Yellow ]; export function PlaygroundPanel({ theme, themeMode, onClose }: PlaygroundPanelProps) { @@ -45,6 +77,49 @@ export function PlaygroundPanel({ theme, themeMode, onClose }: PlaygroundPanelPr const [ovationBadgeLevel, setOvationBadgeLevel] = useState(1); const [ovationIsNewRecord, setOvationIsNewRecord] = useState(false); + // Confetti playground state + const [confettiParticleCount, setConfettiParticleCount] = useState(100); + const [confettiAngle, setConfettiAngle] = useState(90); + const [confettiSpread, setConfettiSpread] = useState(45); + const [confettiStartVelocity, setConfettiStartVelocity] = useState(45); + const [confettiGravity, setConfettiGravity] = useState(1); + const [confettiDecay, setConfettiDecay] = useState(0.9); + const [confettiDrift, setConfettiDrift] = useState(0); + const [confettiScalar, setConfettiScalar] = useState(1); + const [confettiTicks, setConfettiTicks] = useState(200); + const [confettiFlat, setConfettiFlat] = useState(false); + const [confettiShapes, setConfettiShapes] = useState(['square', 'circle']); + const [confettiColors, setConfettiColors] = useState(DEFAULT_CONFETTI_COLORS); + const [selectedOrigins, setSelectedOrigins] = useState>(new Set(['2-1'])); // Default: bottom center + const [copySuccess, setCopySuccess] = useState(false); + + // Handle keyboard shortcuts for tab switching + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Cmd+Shift+[ or Cmd+Shift+] to switch tabs + if (e.metaKey && e.shiftKey) { + if (e.key === '[' || e.key === '{') { + e.preventDefault(); + setActiveTab(prev => { + const currentIdx = TABS.findIndex(t => t.id === prev); + const newIdx = currentIdx <= 0 ? TABS.length - 1 : currentIdx - 1; + return TABS[newIdx].id; + }); + } else if (e.key === ']' || e.key === '}') { + e.preventDefault(); + setActiveTab(prev => { + const currentIdx = TABS.findIndex(t => t.id === prev); + const newIdx = currentIdx >= TABS.length - 1 ? 0 : currentIdx + 1; + return TABS[newIdx].id; + }); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, []); + // Register layer on mount useEffect(() => { const id = registerLayer({ @@ -148,6 +223,164 @@ export function PlaygroundPanel({ theme, themeMode, onClose }: PlaygroundPanelPr return Math.round(((logValue - LOG_MIN) / (LOG_MAX - LOG_MIN)) * 100); }; + // Toggle origin grid selection + const toggleOrigin = (row: number, col: number) => { + const key = `${row}-${col}`; + setSelectedOrigins(prev => { + const newSet = new Set(prev); + if (newSet.has(key)) { + newSet.delete(key); + } else { + newSet.add(key); + } + return newSet; + }); + }; + + // Toggle confetti shape + const toggleShape = (shape: ConfettiShape) => { + setConfettiShapes(prev => { + if (prev.includes(shape)) { + // Don't allow removing last shape + if (prev.length === 1) return prev; + return prev.filter(s => s !== shape); + } + return [...prev, shape]; + }); + }; + + // Fire confetti with current settings + const firePlaygroundConfetti = useCallback(() => { + if (selectedOrigins.size === 0) return; + + const origins: { x: number; y: number }[] = []; + selectedOrigins.forEach(key => { + const [row, col] = key.split('-').map(Number); + const [x, y] = GRID_POSITIONS[row][col]; + origins.push({ x, y }); + }); + + origins.forEach(origin => { + confetti({ + particleCount: Math.round(confettiParticleCount / origins.length), + angle: confettiAngle, + spread: confettiSpread, + startVelocity: confettiStartVelocity, + gravity: confettiGravity, + decay: confettiDecay, + drift: confettiDrift, + scalar: confettiScalar, + ticks: confettiTicks, + flat: confettiFlat, + shapes: confettiShapes, + colors: confettiColors, + origin, + zIndex: 99999, + disableForReducedMotion: false, + }); + }); + }, [ + selectedOrigins, + confettiParticleCount, + confettiAngle, + confettiSpread, + confettiStartVelocity, + confettiGravity, + confettiDecay, + confettiDrift, + confettiScalar, + confettiTicks, + confettiFlat, + confettiShapes, + confettiColors, + ]); + + // Reset confetti settings to defaults + const resetConfettiSettings = () => { + setConfettiParticleCount(100); + setConfettiAngle(90); + setConfettiSpread(45); + setConfettiStartVelocity(45); + setConfettiGravity(1); + setConfettiDecay(0.9); + setConfettiDrift(0); + setConfettiScalar(1); + setConfettiTicks(200); + setConfettiFlat(false); + setConfettiShapes(['square', 'circle']); + setConfettiColors(DEFAULT_CONFETTI_COLORS); + setSelectedOrigins(new Set(['2-1'])); + }; + + // Copy confetti settings to clipboard + const copyConfettiSettings = useCallback(async () => { + // Build origins array from selected grid positions + const origins: { x: number; y: number }[] = []; + selectedOrigins.forEach(key => { + const [row, col] = key.split('-').map(Number); + const [x, y] = GRID_POSITIONS[row][col]; + origins.push({ x, y }); + }); + + const settings = { + particleCount: confettiParticleCount, + angle: confettiAngle, + spread: confettiSpread, + startVelocity: confettiStartVelocity, + gravity: confettiGravity, + decay: confettiDecay, + drift: confettiDrift, + scalar: confettiScalar, + ticks: confettiTicks, + flat: confettiFlat, + shapes: confettiShapes, + colors: confettiColors, + origins, + }; + + // Format as readable code snippet + const codeSnippet = `// Confetti Settings +confetti({ + particleCount: ${settings.particleCount}, + angle: ${settings.angle}, + spread: ${settings.spread}, + startVelocity: ${settings.startVelocity}, + gravity: ${settings.gravity}, + decay: ${settings.decay}, + drift: ${settings.drift}, + scalar: ${settings.scalar}, + ticks: ${settings.ticks}, + flat: ${settings.flat}, + shapes: ${JSON.stringify(settings.shapes)}, + colors: ${JSON.stringify(settings.colors, null, 2).replace(/\n/g, '\n ')}, + origin: ${settings.origins.length === 1 + ? `{ x: ${settings.origins[0].x}, y: ${settings.origins[0].y} }` + : `// Multiple origins:\n // ${settings.origins.map(o => `{ x: ${o.x}, y: ${o.y} }`).join('\n // ')}`}, +});`; + + try { + await navigator.clipboard.writeText(codeSnippet); + setCopySuccess(true); + setTimeout(() => setCopySuccess(false), 2000); + } catch (err) { + console.error('Failed to copy settings:', err); + } + }, [ + selectedOrigins, + confettiParticleCount, + confettiAngle, + confettiSpread, + confettiStartVelocity, + confettiGravity, + confettiDecay, + confettiDrift, + confettiScalar, + confettiTicks, + confettiFlat, + confettiShapes, + confettiColors, + ]); + return ( <>
)} + + {activeTab === 'confetti' && ( +
+ {/* Left column - Controls */} +
+ {/* Origin Grid */} +
+

+ Launch Origins (click to toggle) +

+
+ {GRID_LABELS.map((row, rowIdx) => + row.map((label, colIdx) => { + const key = `${rowIdx}-${colIdx}`; + const isSelected = selectedOrigins.has(key); + return ( + + ); + }) + )} +
+

+ {selectedOrigins.size === 0 + ? 'Select at least one origin' + : `${selectedOrigins.size} origin${selectedOrigins.size > 1 ? 's' : ''} selected`} +

+
+ + {/* Basic Parameters */} +
+

+ Basic Parameters +

+
+
+ + setConfettiParticleCount(Number(e.target.value))} + className="w-full" + /> +
+
+ + setConfettiAngle(Number(e.target.value))} + className="w-full" + /> +
+
+ + setConfettiSpread(Number(e.target.value))} + className="w-full" + /> +
+
+ + setConfettiStartVelocity(Number(e.target.value))} + className="w-full" + /> +
+
+
+ + {/* Shapes */} +
+

+ Shapes +

+
+ {CONFETTI_SHAPES.map(shape => ( + + ))} +
+
+
+ + {/* Right column - More Controls */} +
+ {/* Physics Parameters */} +
+

+ Physics +

+
+
+ + setConfettiGravity(Number(e.target.value))} + className="w-full" + /> +
+
+ + setConfettiDecay(Number(e.target.value))} + className="w-full" + /> +
+
+ + setConfettiDrift(Number(e.target.value))} + className="w-full" + /> +
+
+ + setConfettiScalar(Number(e.target.value))} + className="w-full" + /> +
+
+ + setConfettiTicks(Number(e.target.value))} + className="w-full" + /> +
+
+ setConfettiFlat(e.target.checked)} + /> + +
+
+
+ + {/* Colors */} +
+

+ Colors +

+
+ {confettiColors.map((color, idx) => ( +
+ { + const newColors = [...confettiColors]; + newColors[idx] = e.target.value; + setConfettiColors(newColors); + }} + className="w-8 h-8 rounded cursor-pointer border-2" + style={{ borderColor: theme.colors.border }} + /> + {confettiColors.length > 1 && ( + + )} +
+ ))} + {confettiColors.length < 12 && ( + + )} +
+
+ + {/* Action Buttons */} + + + + + +
+
+ )} diff --git a/src/renderer/components/Scratchpad.tsx b/src/renderer/components/Scratchpad.tsx index 9d50c492b..7e893e7e3 100644 --- a/src/renderer/components/Scratchpad.tsx +++ b/src/renderer/components/Scratchpad.tsx @@ -1048,6 +1048,7 @@ function ScratchpadInner({ onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'e') { e.preventDefault(); + e.stopPropagation(); toggleMode(); } // CMD+F to open search (works in both modes from container) diff --git a/src/renderer/components/StandingOvationOverlay.tsx b/src/renderer/components/StandingOvationOverlay.tsx index fb5007c80..c7844e29c 100644 --- a/src/renderer/components/StandingOvationOverlay.tsx +++ b/src/renderer/components/StandingOvationOverlay.tsx @@ -52,15 +52,15 @@ export function StandingOvationOverlay({ const goldColor = '#FFD700'; const purpleAccent = theme.colors.accent; - // Confetti colors - celebratory mix - const confettiColors = [ + // Confetti colors - celebratory mix (memoized to prevent re-renders) + const confettiColors = React.useMemo(() => [ goldColor, purpleAccent, '#FF6B6B', '#FF8E53', '#FFA726', // Warm colors '#4ECDC4', '#45B7D1', '#64B5F6', // Cool colors '#FFEAA7', '#FFD54F', // Yellows '#DDA0DD', '#BA68C8', // Purples '#F48FB1', // Pink - ]; + ], [purpleAccent]); // Z-index layering: backdrop (99997) < confetti (99998) < modal (99999) const CONFETTI_Z_INDEX = 99998; @@ -152,10 +152,11 @@ export function StandingOvationOverlay({ }, 500); }, [confettiColors]); - // Fire confetti on mount - immediately! + // Fire confetti on mount only - empty deps to run once useEffect(() => { fireConfetti(); - }, [fireConfetti]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Handle graceful close with confetti const handleTakeABow = useCallback(() => { diff --git a/src/renderer/components/TabBar.tsx b/src/renderer/components/TabBar.tsx index 6ca32f549..2d56177ae 100644 --- a/src/renderer/components/TabBar.tsx +++ b/src/renderer/components/TabBar.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef, useCallback, useEffect } from 'react'; import { createPortal } from 'react-dom'; -import { X, Plus, Star, Copy, Edit2, Mail } from 'lucide-react'; +import { X, Plus, Star, Copy, Edit2, Mail, Pencil } from 'lucide-react'; import type { AITab, Theme } from '../types'; interface TabBarProps { @@ -38,6 +38,7 @@ interface TabProps { onStar?: (starred: boolean) => void; shortcutHint?: number | null; registerRef?: (el: HTMLDivElement | null) => void; + hasDraft?: boolean; } /** @@ -78,7 +79,8 @@ function Tab({ onRename, onStar, shortcutHint, - registerRef + registerRef, + hasDraft }: TabProps) { const [isHovered, setIsHovered] = useState(false); const [overlayOpen, setOverlayOpen] = useState(false); @@ -233,6 +235,15 @@ function Tab({ /> )} + {/* Draft indicator - pencil icon for tabs with unsent input or staged images */} + {hasDraft && ( + + )} + {/* Shortcut hint badge - shows tab number for Cmd+1-9 navigation */} {shortcutHint !== null && shortcutHint !== undefined && (