diff --git a/src/__tests__/renderer/components/TabBar.test.tsx b/src/__tests__/renderer/components/TabBar.test.tsx index aea4c81c7..88eb29815 100644 --- a/src/__tests__/renderer/components/TabBar.test.tsx +++ b/src/__tests__/renderer/components/TabBar.test.tsx @@ -5645,3 +5645,267 @@ describe('Performance: Many file tabs (10+)', () => { expect(inactiveFileTab).toHaveStyle({ marginBottom: '0' }); }); }); + +describe('TabBar description section', () => { + const mockOnTabSelect = vi.fn(); + const mockOnTabClose = vi.fn(); + const mockOnNewTab = vi.fn(); + const mockOnUpdateTabDescription = vi.fn(); + const mockOnTabStar = vi.fn(); + const mockOnRequestRename = vi.fn(); + + const mockThemeDesc: Theme = { + id: 'test-theme', + name: 'Test Theme', + mode: 'dark', + colors: { + bgMain: '#1a1a1a', + bgSidebar: '#2a2a2a', + bgActivity: '#3a3a3a', + textMain: '#ffffff', + textDim: '#888888', + accent: '#007acc', + border: '#444444', + error: '#ff4444', + success: '#44ff44', + warning: '#ffaa00', + vibe: '#ff00ff', + agentStatus: '#00ff00', + }, + }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + Element.prototype.scrollTo = vi.fn(); + Element.prototype.scrollIntoView = vi.fn(); + Object.assign(navigator, { + clipboard: { + writeText: vi.fn().mockResolvedValue(undefined), + }, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + function openOverlay(tabName: string) { + const tab = screen.getByText(tabName).closest('[data-tab-id]')!; + fireEvent.mouseEnter(tab); + act(() => { + vi.advanceTimersByTime(450); + }); + } + + it('renders description section when onUpdateTabDescription is provided', () => { + const tabs = [ + createTab({ + id: 'tab-1', + name: 'Tab 1', + agentSessionId: 'session-1', + }), + ]; + + render( + + ); + + openOverlay('Tab 1'); + + expect(screen.getByText('Add description...')).toBeInTheDocument(); + }); + + it('does NOT render description section when onUpdateTabDescription is undefined', () => { + const tabs = [ + createTab({ + id: 'tab-1', + name: 'Tab 1', + agentSessionId: 'session-1', + }), + ]; + + render( + + ); + + openOverlay('Tab 1'); + + expect(screen.queryByText('Add description...')).not.toBeInTheDocument(); + }); + + it('shows "Add description..." placeholder when tab has no description', () => { + const tabs = [ + createTab({ + id: 'tab-1', + name: 'Tab 1', + agentSessionId: 'session-1', + }), + ]; + + render( + + ); + + openOverlay('Tab 1'); + + expect(screen.getByText('Add description...')).toBeInTheDocument(); + expect(screen.getByLabelText('Add tab description')).toBeInTheDocument(); + }); + + it('shows existing description text when tab has one', () => { + const tabs = [ + createTab({ + id: 'tab-1', + name: 'Tab 1', + agentSessionId: 'session-1', + description: 'My existing description', + } as any), + ]; + + render( + + ); + + openOverlay('Tab 1'); + + expect(screen.getByText('My existing description')).toBeInTheDocument(); + expect(screen.getByLabelText('Edit tab description')).toBeInTheDocument(); + }); + + it('clicking description area enters edit mode (textarea appears)', () => { + const tabs = [ + createTab({ + id: 'tab-1', + name: 'Tab 1', + agentSessionId: 'session-1', + }), + ]; + + render( + + ); + + openOverlay('Tab 1'); + + const descButton = screen.getByLabelText('Add tab description'); + fireEvent.click(descButton); + + expect(screen.getByLabelText('Tab description')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Add a description...')).toBeInTheDocument(); + }); + + it('pressing Enter in textarea calls onUpdateTabDescription with trimmed value', () => { + const tabs = [ + createTab({ + id: 'tab-1', + name: 'Tab 1', + agentSessionId: 'session-1', + }), + ]; + + render( + + ); + + openOverlay('Tab 1'); + + const descButton = screen.getByLabelText('Add tab description'); + fireEvent.click(descButton); + + const textarea = screen.getByLabelText('Tab description'); + fireEvent.change(textarea, { target: { value: ' New description ' } }); + fireEvent.keyDown(textarea, { key: 'Enter' }); + + expect(mockOnUpdateTabDescription).toHaveBeenCalledWith('tab-1', 'New description'); + }); + + it('pressing Escape in textarea exits edit mode without calling handler', () => { + const tabs = [ + createTab({ + id: 'tab-1', + name: 'Tab 1', + agentSessionId: 'session-1', + description: 'Original description', + } as any), + ]; + + render( + + ); + + openOverlay('Tab 1'); + + const descButton = screen.getByLabelText('Edit tab description'); + fireEvent.click(descButton); + + const textarea = screen.getByLabelText('Tab description'); + fireEvent.change(textarea, { target: { value: 'Changed text' } }); + fireEvent.keyDown(textarea, { key: 'Escape' }); + + // Should not call the handler + expect(mockOnUpdateTabDescription).not.toHaveBeenCalled(); + // Should exit edit mode — textarea gone, button visible + expect(screen.queryByLabelText('Tab description')).not.toBeInTheDocument(); + expect(screen.getByText('Original description')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/renderer/hooks/useTabHandlers.test.ts b/src/__tests__/renderer/hooks/useTabHandlers.test.ts index 703155a81..b0bebbec8 100644 --- a/src/__tests__/renderer/hooks/useTabHandlers.test.ts +++ b/src/__tests__/renderer/hooks/useTabHandlers.test.ts @@ -762,6 +762,88 @@ describe('useTabHandlers', () => { expect(getSession().aiTabs[0].showThinking).toBe('off'); }); + it('handleUpdateTabDescription sets description on tab', () => { + const tab = createMockAITab({ id: 'tab-1' }); + const { result } = renderWithSession([tab]); + act(() => { + result.current.handleUpdateTabDescription('tab-1', 'My description'); + }); + + const session = getSession(); + expect(session.aiTabs[0].description).toBe('My description'); + }); + + it('handleUpdateTabDescription trims whitespace', () => { + const tab = createMockAITab({ id: 'tab-1' }); + const { result } = renderWithSession([tab]); + act(() => { + result.current.handleUpdateTabDescription('tab-1', ' spaces around '); + }); + + const session = getSession(); + expect(session.aiTabs[0].description).toBe('spaces around'); + }); + + it('handleUpdateTabDescription sets undefined for empty string', () => { + const tab = createMockAITab({ id: 'tab-1', description: 'existing' } as any); + const { result } = renderWithSession([tab]); + act(() => { + result.current.handleUpdateTabDescription('tab-1', ''); + }); + + const session = getSession(); + expect(session.aiTabs[0].description).toBeUndefined(); + }); + + it('handleUpdateTabDescription sets undefined for whitespace-only string', () => { + const tab = createMockAITab({ id: 'tab-1', description: 'existing' } as any); + const { result } = renderWithSession([tab]); + act(() => { + result.current.handleUpdateTabDescription('tab-1', ' '); + }); + + const session = getSession(); + expect(session.aiTabs[0].description).toBeUndefined(); + }); + + it('handleUpdateTabDescription does not modify tabs in non-active sessions', () => { + const tab = createMockAITab({ id: 'tab-1' }); + setupSessionWithTabs([tab]); + + // Add a second non-active session with its own tab + const otherSession = createMockSession({ + id: 'other-session', + aiTabs: [createMockAITab({ id: 'other-tab' })], + activeTabId: 'other-tab', + unifiedTabOrder: [{ type: 'ai', id: 'other-tab' }], + }); + useSessionStore.setState((prev: any) => ({ + sessions: [...prev.sessions, otherSession], + })); + + const { result } = renderHook(() => useTabHandlers()); + act(() => { + result.current.handleUpdateTabDescription('other-tab', 'should not apply'); + }); + + const state = useSessionStore.getState(); + const other = state.sessions.find((s) => s.id === 'other-session')!; + expect(other.aiTabs[0].description).toBeUndefined(); + }); + + it('handleUpdateTabDescription does not modify other tabs in the same session', () => { + const tab1 = createMockAITab({ id: 'tab-1' }); + const tab2 = createMockAITab({ id: 'tab-2', description: 'original' } as any); + const { result } = renderWithSession([tab1, tab2]); + act(() => { + result.current.handleUpdateTabDescription('tab-1', 'New description'); + }); + + const session = getSession(); + expect(session.aiTabs[0].description).toBe('New description'); + expect(session.aiTabs[1].description).toBe('original'); + }); + it('handleUpdateTabByClaudeSessionId updates tab by agent session id', () => { const tab = createMockAITab({ id: 'tab-1', diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 4f31351c3..0a8f917f1 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -847,6 +847,7 @@ function MaestroConsoleInner() { handleCloseCurrentTab, handleRequestTabRename, handleUpdateTabByClaudeSessionId, + handleUpdateTabDescription, handleTabStar, handleTabMarkUnread, handleToggleTabReadOnlyMode, @@ -3212,6 +3213,9 @@ function MaestroConsoleInner() { handleTabReorder, handleUnifiedTabReorder, handleUpdateTabByClaudeSessionId, + handleUpdateTabDescription: encoreFeatures.tabDescription + ? handleUpdateTabDescription + : undefined, handleTabStar, handleTabMarkUnread, handleToggleTabReadOnlyMode, diff --git a/src/renderer/components/MainPanel.tsx b/src/renderer/components/MainPanel.tsx index 86fe27ff2..9384d5af2 100644 --- a/src/renderer/components/MainPanel.tsx +++ b/src/renderer/components/MainPanel.tsx @@ -167,6 +167,7 @@ interface MainPanelProps { onRequestTabRename?: (tabId: string) => void; onTabReorder?: (fromIndex: number, toIndex: number) => void; onUnifiedTabReorder?: (fromIndex: number, toIndex: number) => void; + onUpdateTabDescription?: (tabId: string, description: string) => void; onTabStar?: (tabId: string, starred: boolean) => void; onTabMarkUnread?: (tabId: string) => void; onUpdateTabByClaudeSessionId?: ( @@ -455,6 +456,7 @@ export const MainPanel = React.memo( onRequestTabRename, onTabReorder, onUnifiedTabReorder, + onUpdateTabDescription, onTabStar, onTabMarkUnread, onToggleUnreadFilter, @@ -1470,6 +1472,7 @@ export const MainPanel = React.memo( onRequestRename={onRequestTabRename} onTabReorder={onTabReorder} onUnifiedTabReorder={onUnifiedTabReorder} + onUpdateTabDescription={onUpdateTabDescription} onTabStar={onTabStar} onTabMarkUnread={onTabMarkUnread} onMergeWith={onMergeWith} diff --git a/src/renderer/components/TabBar.tsx b/src/renderer/components/TabBar.tsx index 6cd3090e9..1d5448082 100644 --- a/src/renderer/components/TabBar.tsx +++ b/src/renderer/components/TabBar.tsx @@ -20,6 +20,7 @@ import { Loader2, ExternalLink, FolderOpen, + FileText, } from 'lucide-react'; import type { AITab, Theme, FilePreviewTab, UnifiedTab } from '../types'; import { hasDraft } from '../utils/tabHelpers'; @@ -38,6 +39,7 @@ interface TabBarProps { onTabReorder?: (fromIndex: number, toIndex: number) => void; /** Handler to reorder tabs in unified tab order (AI + file tabs) */ onUnifiedTabReorder?: (fromIndex: number, toIndex: number) => void; + onUpdateTabDescription?: (tabId: string, description: string) => void; onTabStar?: (tabId: string, starred: boolean) => void; onTabMarkUnread?: (tabId: string) => void; /** Handler to open merge session modal with this tab as source */ @@ -118,6 +120,8 @@ interface TabProps { onExportHtml?: (tabId: string) => void; /** Stable callback - receives tabId */ onPublishGist?: (tabId: string) => void; + /** Stable callback - receives tabId and new description */ + onUpdateTabDescription?: (tabId: string, description: string) => void; /** Stable callback - receives tabId */ onMoveToFirst?: (tabId: string) => void; /** Stable callback - receives tabId */ @@ -214,6 +218,7 @@ const Tab = memo(function Tab({ onCopyContext, onExportHtml, onPublishGist, + onUpdateTabDescription, onMoveToFirst, onMoveToLast, isFirstTab, @@ -231,6 +236,9 @@ const Tab = memo(function Tab({ const [isHovered, setIsHovered] = useState(false); const [overlayOpen, setOverlayOpen] = useState(false); const [showCopied, setShowCopied] = useState(false); + const [isEditingDescription, setIsEditingDescription] = useState(false); + const [descriptionDraft, setDescriptionDraft] = useState(tab.description ?? ''); + const descriptionDraftRef = useRef(descriptionDraft); const [overlayPosition, setOverlayPosition] = useState<{ top: number; left: number; @@ -453,6 +461,73 @@ const Tab = memo(function Tab({ [onCloseTabsRight, tabId] ); + // Description editing handlers + const descriptionButtonRef = useRef(null); + + const handleDescriptionSave = useCallback( + (value: string) => { + const trimmed = value.trim(); + if (trimmed !== (tab.description ?? '')) { + onUpdateTabDescription?.(tabId, trimmed); + } + setIsEditingDescription(false); + setDescriptionDraft(trimmed || (tab.description ?? '')); + requestAnimationFrame(() => { + descriptionButtonRef.current?.focus(); + }); + }, + [onUpdateTabDescription, tabId, tab.description] + ); + + const handleDescriptionCancel = useCallback(() => { + setDescriptionDraft(tab.description ?? ''); + setIsEditingDescription(false); + requestAnimationFrame(() => { + descriptionButtonRef.current?.focus(); + }); + }, [tab.description]); + + const handleDescriptionKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleDescriptionSave(descriptionDraft); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleDescriptionCancel(); + } + }, + [descriptionDraft, handleDescriptionSave, handleDescriptionCancel] + ); + + const handleDescriptionBlur = useCallback(() => { + handleDescriptionSave(descriptionDraft); + }, [descriptionDraft, handleDescriptionSave]); + + // Sync draft with tab.description when it changes externally + useEffect(() => { + if (!isEditingDescription) { + setDescriptionDraft(tab.description ?? ''); + } + }, [tab.description, isEditingDescription]); + + // Keep ref in sync with latest draft value to avoid stale closures in cleanup + useEffect(() => { + descriptionDraftRef.current = descriptionDraft; + }, [descriptionDraft]); + + // Save description draft when overlay closes while editing + useEffect(() => { + if (!overlayOpen && isEditingDescription) { + const draft = descriptionDraftRef.current.trim(); + if (draft !== (tab.description ?? '')) { + onUpdateTabDescription?.(tabId, draft); + } + setIsEditingDescription(false); + setDescriptionDraft(draft || (tab.description ?? '')); + } + }, [overlayOpen, isEditingDescription, tab.description, onUpdateTabDescription, tabId]); + // Handlers for drag events using stable tabId const handleTabSelect = useCallback(() => { onSelect(tabId); @@ -681,6 +756,66 @@ const Tab = memo(function Tab({ )} + {/* Description section - only render when feature is enabled */} + {onUpdateTabDescription && ( +
+ {isEditingDescription ? ( +