diff --git a/docs/encore-features.md b/docs/encore-features.md index 9b4928de7..81b7cfcb1 100644 --- a/docs/encore-features.md +++ b/docs/encore-features.md @@ -16,11 +16,10 @@ Open **Settings** (`Cmd+,` / `Ctrl+,`) and navigate to the **Encore Features** t ## Available Features -| Feature | Shortcut | Description | -| ------------------------------------ | ------------------------------ | --------------------------------------------------------------- | -| [Director's Notes](./director-notes) | `Cmd+Shift+O` / `Ctrl+Shift+O` | Unified timeline of all agent activity with AI-powered synopses | - -More features will be added here as they ship. +| Feature | Shortcut | Description | +| ------------------------------------ | ------------------------------ | ----------------------------------------------------------------- | +| [Director's Notes](./director-notes) | `Cmd+Shift+O` / `Ctrl+Shift+O` | Unified timeline of all agent activity with AI-powered synopses | +| Unified Inbox | `Opt+I` / `Alt+I` | Cross-agent inbox to triage and reply to all active conversations | ## For Developers diff --git a/docs/keyboard-shortcuts.md b/docs/keyboard-shortcuts.md index 7dc5e6918..0333c1d9c 100644 --- a/docs/keyboard-shortcuts.md +++ b/docs/keyboard-shortcuts.md @@ -42,6 +42,10 @@ The command palette is your gateway to nearly every action in Maestro. Press `Cm | Maestro Symphony | `Cmd+Shift+Y` | `Ctrl+Shift+Y` | | Cycle Focus Areas | `Tab` | `Tab` | | Cycle Focus Backwards | `Shift+Tab` | `Shift+Tab` | +| Unified Inbox \* | `Opt+I` | `Alt+I` | +| Director's Notes \* | `Cmd+Shift+O` | `Ctrl+Shift+O` | + +\* Requires the corresponding [Encore Feature](./encore-features) to be enabled in Settings. ## Panel Shortcuts diff --git a/src/__tests__/renderer/components/AgentInbox/AgentInbox.test.tsx b/src/__tests__/renderer/components/AgentInbox/AgentInbox.test.tsx new file mode 100644 index 000000000..c830ac91c --- /dev/null +++ b/src/__tests__/renderer/components/AgentInbox/AgentInbox.test.tsx @@ -0,0 +1,511 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, within, act } from '@testing-library/react'; +import type { Theme, Session, Group } from '../../../../renderer/types'; +import type { InboxItem } from '../../../../renderer/types/agent-inbox'; + +// --------------------------------------------------------------------------- +// Mocks — must be declared before component imports +// --------------------------------------------------------------------------- + +vi.mock('../../../../renderer/contexts/LayerStackContext', () => ({ + useLayerStack: () => ({ + registerLayer: vi.fn(() => 'layer-inbox'), + unregisterLayer: vi.fn(), + updateLayerHandler: vi.fn(), + }), +})); + +vi.mock('../../../../renderer/constants/modalPriorities', () => ({ + MODAL_PRIORITIES: { AGENT_INBOX: 555 }, +})); + +const mockUpdateAgentInboxData = vi.fn(); +vi.mock('../../../../renderer/stores/modalStore', () => ({ + useModalStore: vi.fn(() => null), + selectModalData: vi.fn(() => () => null), + getModalActions: () => ({ + updateAgentInboxData: mockUpdateAgentInboxData, + }), +})); + +vi.mock('../../../../renderer/utils/formatters', () => ({ + formatRelativeTime: vi.fn(() => '2m ago'), +})); + +vi.mock('../../../../renderer/utils/shortcutFormatter', () => ({ + formatShortcutKeys: vi.fn((keys: string[]) => keys.join('+')), + isMacOS: vi.fn(() => false), +})); + +vi.mock('../../../../renderer/utils/markdownConfig', () => ({ + generateTerminalProseStyles: vi.fn(() => ''), +})); + +// Mock MarkdownRenderer used in FocusModeView (named export) +vi.mock('../../../../renderer/components/MarkdownRenderer', () => ({ + MarkdownRenderer: ({ content }: { content: string }) => ( + {content} + ), +})); + +// Mock @tanstack/react-virtual — return virtual items matching the item count +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: (opts: { count: number }) => ({ + getVirtualItems: () => + Array.from({ length: Math.min(opts.count, 50) }, (_, i) => ({ + index: i, + start: i * 132, + size: 132, + key: `virtual-${i}`, + })), + getTotalSize: () => opts.count * 132, + scrollToIndex: vi.fn(), + measureElement: vi.fn(), + }), +})); + +// --------------------------------------------------------------------------- +// Import component AFTER mocks +// --------------------------------------------------------------------------- +import AgentInbox from '../../../../renderer/components/AgentInbox'; + +// --------------------------------------------------------------------------- +// Factories +// --------------------------------------------------------------------------- + +const mockTheme: Theme = { + id: 'dark', + name: 'Dark', + mode: 'dark', + colors: { + bgMain: '#1a1a2e', + bgSidebar: '#16213e', + bgActivity: '#0f3460', + bgTerminal: '#1a1a2e', + textMain: '#eaeaea', + textDim: '#888', + accent: '#e94560', + accentForeground: '#ffffff', + error: '#ff6b6b', + border: '#333', + success: '#4ecdc4', + warning: '#ffd93d', + terminalCursor: '#e94560', + }, +}; + +const makeTab = (overrides: Record = {}) => ({ + id: `tab-${Math.random().toString(36).slice(2, 8)}`, + agentSessionId: null, + name: null, + starred: false, + logs: [{ id: 'log-1', timestamp: Date.now(), source: 'ai' as const, text: 'Hello world' }], + inputValue: '', + stagedImages: [], + createdAt: 1000, + state: 'idle' as const, + hasUnread: true, + ...overrides, +}); + +const makeSession = (overrides: Partial = {}): Session => + ({ + id: `s-${Math.random().toString(36).slice(2, 8)}`, + name: 'Agent Alpha', + toolType: 'claude-code', + state: 'idle', + cwd: '/test', + fullPath: '/test', + projectRoot: '/test', + port: 0, + aiPid: 0, + terminalPid: 0, + inputMode: 'ai', + aiTabs: [makeTab()], + activeTabId: 'default-tab', + closedTabHistory: [], + aiLogs: [], + shellLogs: [], + workLog: [], + executionQueue: [], + contextUsage: 0, + isGitRepo: false, + changedFiles: [], + fileTree: [], + fileExplorerExpanded: [], + fileExplorerScrollPos: 0, + isLive: false, + ...overrides, + }) as unknown as Session; + +const makeGroup = (overrides: Partial = {}): Group => ({ + id: `g-${Math.random().toString(36).slice(2, 8)}`, + name: 'Group', + emoji: '', + collapsed: false, + ...overrides, +}); + +// Default props factory +const createDefaultProps = (overrides: Record = {}) => ({ + theme: mockTheme, + sessions: [] as Session[], + groups: [] as Group[], + onClose: vi.fn(), + onNavigateToSession: vi.fn(), + onQuickReply: vi.fn(), + onOpenAndReply: vi.fn(), + onMarkAsRead: vi.fn(), + onToggleThinking: vi.fn(), + ...overrides, +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('AgentInbox', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Mock requestAnimationFrame for auto-focus + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + cb(0); + return 0; + }); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ==================================================================== + // 1. List mode renders inbox items + // ==================================================================== + + describe('list mode rendering', () => { + it('renders the dialog with correct ARIA attributes', () => { + const props = createDefaultProps(); + render(); + + const dialog = screen.getByRole('dialog'); + expect(dialog).toBeInTheDocument(); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(dialog).toHaveAttribute('aria-label', 'Unified Inbox'); + }); + + it('renders inbox items from sessions', () => { + const tab1 = makeTab({ id: 't1', hasUnread: true }); + const tab2 = makeTab({ id: 't2', hasUnread: true }); + const s1 = makeSession({ id: 's1', name: 'Agent One', aiTabs: [tab1] }); + const s2 = makeSession({ id: 's2', name: 'Agent Two', aiTabs: [tab2] }); + + const props = createDefaultProps({ sessions: [s1, s2] }); + render(); + + // Items render with role="option" + const options = screen.getAllByRole('option'); + expect(options.length).toBeGreaterThanOrEqual(2); + }); + + it('renders empty state when no sessions match filter', () => { + const tab = makeTab({ id: 't1', hasUnread: false }); + const session = makeSession({ id: 's1', aiTabs: [tab] }); + + // Default filter is 'unread' — a session with no unread tabs shows empty + const props = createDefaultProps({ sessions: [session] }); + render(); + + const emptyState = screen.getByTestId('inbox-empty-state'); + expect(emptyState).toBeInTheDocument(); + }); + + it('renders session names in item cards', () => { + const tab = makeTab({ id: 't1', hasUnread: true }); + const session = makeSession({ id: 's1', name: 'Claude Worker', aiTabs: [tab] }); + + const props = createDefaultProps({ sessions: [session] }); + render(); + + expect(screen.getByText('Claude Worker')).toBeInTheDocument(); + }); + }); + + // ==================================================================== + // 2. Filter pills change visible items + // ==================================================================== + + describe('filter pills', () => { + it('renders filter segmented control with All/Unread/Read/Starred options', () => { + const props = createDefaultProps(); + render(); + + const filterControl = screen.getByLabelText('Filter agents'); + expect(within(filterControl).getByText('All')).toBeInTheDocument(); + expect(within(filterControl).getByText('Unread')).toBeInTheDocument(); + expect(within(filterControl).getByText('Read')).toBeInTheDocument(); + expect(within(filterControl).getByText('Starred')).toBeInTheDocument(); + }); + + it('clicking "All" filter shows all items (unread + read)', () => { + const tabUnread = makeTab({ id: 't1', hasUnread: true }); + const tabRead = makeTab({ id: 't2', hasUnread: false }); + const s1 = makeSession({ id: 's1', name: 'Unread Agent', aiTabs: [tabUnread] }); + const s2 = makeSession({ id: 's2', name: 'Read Agent', aiTabs: [tabRead] }); + + const props = createDefaultProps({ sessions: [s1, s2] }); + render(); + + // Default filter is 'unread' — only unread tab shows + const optionsBefore = screen.getAllByRole('option'); + expect(optionsBefore).toHaveLength(1); + + // Click 'All' filter (scoped to filter control) + const filterControl = screen.getByLabelText('Filter agents'); + fireEvent.click(within(filterControl).getByText('All')); + + // Now both should be visible + const optionsAfter = screen.getAllByRole('option'); + expect(optionsAfter).toHaveLength(2); + }); + + it('clicking "Starred" filter shows only starred items', () => { + const tabStarred = makeTab({ id: 't1', hasUnread: true, starred: true }); + const tabNormal = makeTab({ id: 't2', hasUnread: true, starred: false }); + const s1 = makeSession({ id: 's1', name: 'Starred', aiTabs: [tabStarred] }); + const s2 = makeSession({ id: 's2', name: 'Normal', aiTabs: [tabNormal] }); + + const props = createDefaultProps({ sessions: [s1, s2] }); + render(); + + // Click 'Starred' filter (scoped to filter control) + const filterControl = screen.getByLabelText('Filter agents'); + fireEvent.click(within(filterControl).getByText('Starred')); + + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(1); + }); + + it('clicking "Read" filter shows only non-unread items', () => { + const tabUnread = makeTab({ id: 't1', hasUnread: true }); + const tabRead = makeTab({ id: 't2', hasUnread: false }); + const session = makeSession({ id: 's1', aiTabs: [tabUnread, tabRead] }); + + const props = createDefaultProps({ sessions: [session] }); + render(); + + // Click 'Read' filter (scoped to filter control) + const filterControl = screen.getByLabelText('Filter agents'); + fireEvent.click(within(filterControl).getByText('Read')); + + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(1); + }); + }); + + // ==================================================================== + // 3. Keyboard navigation (up/down arrows, Enter to select) + // ==================================================================== + + describe('keyboard navigation', () => { + it('ArrowDown moves selection to next item', () => { + const tab1 = makeTab({ id: 't1', hasUnread: true }); + const tab2 = makeTab({ id: 't2', hasUnread: true }); + const s1 = makeSession({ id: 's1', name: 'First', aiTabs: [tab1] }); + const s2 = makeSession({ id: 's2', name: 'Second', aiTabs: [tab2] }); + + const props = createDefaultProps({ sessions: [s1, s2] }); + render(); + + const listbox = screen.getByRole('listbox'); + + // First item should be selected initially + const optionsBefore = screen.getAllByRole('option'); + expect(optionsBefore[0]).toHaveAttribute('aria-selected', 'true'); + + // Press ArrowDown on the listbox + fireEvent.keyDown(listbox, { key: 'ArrowDown' }); + + // Second item should now be selected + const optionsAfter = screen.getAllByRole('option'); + expect(optionsAfter[1]).toHaveAttribute('aria-selected', 'true'); + }); + + it('ArrowUp moves selection to previous item', () => { + const tab1 = makeTab({ id: 't1', hasUnread: true }); + const tab2 = makeTab({ id: 't2', hasUnread: true }); + const s1 = makeSession({ id: 's1', name: 'First', aiTabs: [tab1] }); + const s2 = makeSession({ id: 's2', name: 'Second', aiTabs: [tab2] }); + + const props = createDefaultProps({ sessions: [s1, s2] }); + render(); + + const listbox = screen.getByRole('listbox'); + + // Move down first, then up + fireEvent.keyDown(listbox, { key: 'ArrowDown' }); + fireEvent.keyDown(listbox, { key: 'ArrowUp' }); + + const options = screen.getAllByRole('option'); + expect(options[0]).toHaveAttribute('aria-selected', 'true'); + }); + + it('Enter on an item calls onNavigateToSession and onClose', () => { + const tab = makeTab({ id: 't1', hasUnread: true }); + const session = makeSession({ id: 's1', name: 'Agent', aiTabs: [tab] }); + + const onNavigate = vi.fn(); + const onClose = vi.fn(); + const props = createDefaultProps({ + sessions: [session], + onNavigateToSession: onNavigate, + onClose, + }); + render(); + + const listbox = screen.getByRole('listbox'); + fireEvent.keyDown(listbox, { key: 'Enter' }); + + expect(onNavigate).toHaveBeenCalledWith('s1', 't1'); + expect(onClose).toHaveBeenCalled(); + }); + }); + + // ==================================================================== + // 4. Escape closes the modal + // ==================================================================== + + describe('Escape closes modal', () => { + it('clicking overlay background calls onClose', () => { + const onClose = vi.fn(); + const props = createDefaultProps({ onClose }); + render(); + + // The overlay is the outermost div with modal-overlay class + const overlay = screen.getByRole('dialog').parentElement!; + fireEvent.click(overlay); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('clicking close button calls onClose', () => { + const tab = makeTab({ id: 't1', hasUnread: true }); + const session = makeSession({ id: 's1', aiTabs: [tab] }); + const onClose = vi.fn(); + const props = createDefaultProps({ sessions: [session], onClose }); + render(); + + // Close button has title="Close (Esc)" + const closeBtn = screen.getByTitle('Close (Esc)'); + fireEvent.click(closeBtn); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + }); + + // ==================================================================== + // 5. Focus mode entry (Enter or double-click) + // ==================================================================== + + describe('focus mode entry', () => { + it('pressing F enters focus mode', () => { + const tab = makeTab({ id: 't1', hasUnread: true }); + const session = makeSession({ id: 's1', name: 'Agent', aiTabs: [tab] }); + + const props = createDefaultProps({ sessions: [session] }); + render(); + + const dialog = screen.getByRole('dialog'); + + // Press F on the dialog container to enter focus mode + fireEvent.keyDown(dialog, { key: 'f' }); + + // In focus mode, the listbox disappears and focus view renders + // We can detect focus mode by checking for the reply textarea + const textarea = screen.queryByLabelText('Reply to agent'); + expect(textarea).toBeInTheDocument(); + }); + + it('double-clicking an item enters focus mode', () => { + const tab = makeTab({ id: 't1', hasUnread: true }); + const session = makeSession({ id: 's1', name: 'Agent', aiTabs: [tab] }); + + const props = createDefaultProps({ sessions: [session] }); + render(); + + // Double-click the first option + const option = screen.getByRole('option'); + fireEvent.doubleClick(option); + + // Should enter focus mode — reply textarea appears + const textarea = screen.queryByLabelText('Reply to agent'); + expect(textarea).toBeInTheDocument(); + }); + + it('Escape in focus mode returns to list mode', () => { + const tab = makeTab({ id: 't1', hasUnread: true }); + const session = makeSession({ id: 's1', name: 'Agent', aiTabs: [tab] }); + + const props = createDefaultProps({ sessions: [session] }); + render(); + + const dialog = screen.getByRole('dialog'); + + // Enter focus mode + fireEvent.keyDown(dialog, { key: 'f' }); + expect(screen.queryByLabelText('Reply to agent')).toBeInTheDocument(); + + // Press Escape — should go back to list mode, not close + fireEvent.keyDown(dialog, { key: 'Escape' }); + + // Listbox should be back + const listbox = screen.queryByRole('listbox'); + expect(listbox).toBeInTheDocument(); + }); + + it('Backspace in focus mode (not in textarea) returns to list mode', () => { + const tab = makeTab({ id: 't1', hasUnread: true }); + const session = makeSession({ id: 's1', name: 'Agent', aiTabs: [tab] }); + + const props = createDefaultProps({ sessions: [session] }); + render(); + + const dialog = screen.getByRole('dialog'); + + // Enter focus mode + fireEvent.keyDown(dialog, { key: 'f' }); + expect(screen.queryByLabelText('Reply to agent')).toBeInTheDocument(); + + // Focus the dialog container (not textarea) before pressing Backspace + dialog.focus(); + + // Press Backspace while dialog itself is focused (not textarea) + fireEvent.keyDown(dialog, { key: 'Backspace' }); + + // Should return to list mode + const listbox = screen.queryByRole('listbox'); + expect(listbox).toBeInTheDocument(); + }); + }); + + // ==================================================================== + // 6. Reply sends input to correct tab (simpler assertion) + // ==================================================================== + + describe('reply in focus mode', () => { + it('renders reply textarea in focus mode', () => { + const tab = makeTab({ id: 't1', hasUnread: true }); + const session = makeSession({ id: 's1', name: 'Agent', aiTabs: [tab] }); + + const props = createDefaultProps({ sessions: [session] }); + render(); + + const dialog = screen.getByRole('dialog'); + fireEvent.keyDown(dialog, { key: 'f' }); + + const textarea = screen.getByLabelText('Reply to agent'); + expect(textarea).toBeInTheDocument(); + expect(textarea.tagName).toBe('TEXTAREA'); + }); + }); +}); diff --git a/src/__tests__/renderer/components/InputArea.test.tsx b/src/__tests__/renderer/components/InputArea.test.tsx index a77a65881..b605e438b 100644 --- a/src/__tests__/renderer/components/InputArea.test.tsx +++ b/src/__tests__/renderer/components/InputArea.test.tsx @@ -694,10 +694,10 @@ describe('InputArea', () => { }); render(); - // The second command (/help) should have accent background - // Find the parent div that has the background style (px-4 py-3 class) - const helpCmd = screen.getByText('/help').closest('.px-4'); - expect(helpCmd).toHaveStyle({ backgroundColor: mockTheme.colors.accent }); + // With fuzzy scoring, /help (shorter) scores higher than /clear for query "/" + // So order is: /help (0), /clear (1). Index 1 = /clear has accent background. + const clearCmd = screen.getByText('/clear').closest('.px-4'); + expect(clearCmd).toHaveStyle({ backgroundColor: mockTheme.colors.accent }); }); it('updates selection on mouse enter', () => { @@ -709,8 +709,9 @@ describe('InputArea', () => { }); render(); - const helpCmd = screen.getByText('/help').closest('.px-4'); - fireEvent.mouseEnter(helpCmd!); + // With fuzzy scoring, /help is at index 0, /clear at index 1 + const clearCmd = screen.getByText('/clear').closest('.px-4'); + fireEvent.mouseEnter(clearCmd!); expect(setSelectedSlashCommandIndex).toHaveBeenCalledWith(1); }); @@ -812,8 +813,9 @@ describe('InputArea', () => { }); render(); - const helpCmd = screen.getByText('/help').closest('.px-4'); - fireEvent.click(helpCmd!); + // With fuzzy scoring, /help is at index 0, /clear at index 1 + const clearCmd = screen.getByText('/clear').closest('.px-4'); + fireEvent.click(clearCmd!); // Single click should update selection expect(setSelectedSlashCommandIndex).toHaveBeenCalledWith(1); @@ -841,13 +843,13 @@ describe('InputArea', () => { }); render(); - // The second item (/help) should NOT have accent background since index 0 is selected + // With fuzzy scoring, order is: /help (0), /clear (1) + // Index 0 is selected, so /help has accent background const helpCmd = screen.getByText('/help').closest('.px-4'); - // Unselected items don't have the accent color background - expect(helpCmd).not.toHaveStyle({ backgroundColor: mockTheme.colors.accent }); - // First item (selected) should have accent background + expect(helpCmd).toHaveStyle({ backgroundColor: mockTheme.colors.accent }); + // /clear (index 1) should NOT have accent background const clearCmd = screen.getByText('/clear').closest('.px-4'); - expect(clearCmd).toHaveStyle({ backgroundColor: mockTheme.colors.accent }); + expect(clearCmd).not.toHaveStyle({ backgroundColor: mockTheme.colors.accent }); }); it('scrolls selected item into view via refs', () => { diff --git a/src/__tests__/renderer/components/TabBar.test.tsx b/src/__tests__/renderer/components/TabBar.test.tsx index aea4c81c7..d0a81dc0d 100644 --- a/src/__tests__/renderer/components/TabBar.test.tsx +++ b/src/__tests__/renderer/components/TabBar.test.tsx @@ -5645,3 +5645,334 @@ 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', + }), + ]; + + 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 Enter with whitespace-only description clears to empty string', () => { + const tabs = [ + createTab({ + id: 'tab-1', + name: 'Tab 1', + agentSessionId: 'session-1', + description: 'Old description', + }), + ]; + + 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: ' ' } }); + fireEvent.keyDown(textarea, { key: 'Enter' }); + + expect(mockOnUpdateTabDescription).toHaveBeenCalledWith('tab-1', ''); + }); + + it('pressing Enter with whitespace-only on tab without description does not call handler', () => { + 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: ' ' } }); + fireEvent.keyDown(textarea, { key: 'Enter' }); + + expect(mockOnUpdateTabDescription).not.toHaveBeenCalled(); + }); + + 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', + }), + ]; + + 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/useAgentInbox.test.ts b/src/__tests__/renderer/hooks/useAgentInbox.test.ts new file mode 100644 index 000000000..c0dbf2ffe --- /dev/null +++ b/src/__tests__/renderer/hooks/useAgentInbox.test.ts @@ -0,0 +1,384 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { + useAgentInbox, + truncate, + generateSmartSummary, +} from '../../../renderer/hooks/useAgentInbox'; +import type { Session, Group } from '../../../renderer/types'; +import type { InboxFilterMode, InboxSortMode } from '../../../renderer/types/agent-inbox'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Minimal AITab factory. Fields not relevant to inbox are given sensible defaults. */ +const makeTab = (overrides: Record = {}) => ({ + id: `tab-${Math.random().toString(36).slice(2, 8)}`, + agentSessionId: null, + name: null, + starred: false, + logs: [], + inputValue: '', + stagedImages: [], + createdAt: 1000, + state: 'idle' as const, + hasUnread: false, + ...overrides, +}); + +/** Minimal Session factory. Only fields consumed by useAgentInbox need real values. */ +const makeSession = (overrides: Partial = {}): Session => + ({ + id: `s-${Math.random().toString(36).slice(2, 8)}`, + name: 'Agent A', + toolType: 'claude-code', + state: 'idle', + cwd: '/test', + fullPath: '/test', + projectRoot: '/test', + port: 0, + aiPid: 0, + terminalPid: 0, + inputMode: 'ai', + aiTabs: [makeTab()], + activeTabId: 'default-tab', + closedTabHistory: [], + aiLogs: [], + shellLogs: [], + workLog: [], + executionQueue: [], + contextUsage: 0, + isGitRepo: false, + changedFiles: [], + fileTree: [], + fileExplorerExpanded: [], + fileExplorerScrollPos: 0, + isLive: false, + ...overrides, + }) as unknown as Session; + +const makeGroup = (overrides: Partial = {}): Group => ({ + id: `g-${Math.random().toString(36).slice(2, 8)}`, + name: 'Group', + emoji: '', + collapsed: false, + ...overrides, +}); + +// --------------------------------------------------------------------------- +// truncate() +// --------------------------------------------------------------------------- + +describe('truncate', () => { + it('returns short text unchanged', () => { + expect(truncate('hello')).toBe('hello'); + }); + + it('returns text exactly at MAX_MESSAGE_LENGTH unchanged', () => { + const text = 'a'.repeat(90); + expect(truncate(text)).toBe(text); + }); + + it('truncates text longer than 90 chars with ellipsis', () => { + const text = 'a'.repeat(100); + const result = truncate(text); + expect(result.length).toBe(90); + expect(result.endsWith('...')).toBe(true); + // 90 - 3 (ellipsis) = 87 'a' chars + expect(result).toBe('a'.repeat(87) + '...'); + }); +}); + +// --------------------------------------------------------------------------- +// generateSmartSummary() +// --------------------------------------------------------------------------- + +describe('generateSmartSummary', () => { + it('returns default message for empty logs', () => { + expect(generateSmartSummary([], 'idle')).toBe('No activity yet'); + }); + + it('returns default message for undefined logs', () => { + expect(generateSmartSummary(undefined, 'idle')).toBe('No activity yet'); + }); + + it('prefixes with "Waiting:" when session is waiting_input', () => { + const logs = [ + { id: '1', timestamp: 1000, source: 'ai' as const, text: 'What should I do next?' }, + ]; + const result = generateSmartSummary(logs, 'waiting_input'); + expect(result.startsWith('Waiting: ')).toBe(true); + expect(result).toContain('What should I do next?'); + }); + + it('shows question directly when AI message ends with "?"', () => { + const logs = [{ id: '1', timestamp: 1000, source: 'ai' as const, text: 'Shall I proceed?' }]; + const result = generateSmartSummary(logs, 'idle'); + expect(result).toBe('Shall I proceed?'); + }); + + it('prefixes with "Done:" for AI statement', () => { + const logs = [ + { id: '1', timestamp: 1000, source: 'ai' as const, text: 'Refactored the module.' }, + ]; + const result = generateSmartSummary(logs, 'idle'); + expect(result).toBe('Done: Refactored the module.'); + }); + + it('falls back to last log text when no AI source found', () => { + const logs = [ + { id: '1', timestamp: 1000, source: 'user' as const, text: 'Fix the bug please' }, + ]; + const result = generateSmartSummary(logs, 'idle'); + expect(result).toBe('Fix the bug please'); + }); + + it('truncates long summaries', () => { + const longText = 'A'.repeat(200); + const logs = [{ id: '1', timestamp: 1000, source: 'ai' as const, text: longText }]; + const result = generateSmartSummary(logs, 'idle'); + expect(result.length).toBe(90); + expect(result.endsWith('...')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// useAgentInbox — Filter Logic +// --------------------------------------------------------------------------- + +describe('useAgentInbox — filter logic', () => { + it('filter "all" returns every tab from every session', () => { + const tab1 = makeTab({ id: 't1', hasUnread: true }); + const tab2 = makeTab({ id: 't2', hasUnread: false }); + const session = makeSession({ id: 's1', aiTabs: [tab1, tab2] }); + + const { result } = renderHook(() => useAgentInbox([session], [], 'all', 'newest')); + expect(result.current).toHaveLength(2); + }); + + it('filter "unread" returns only unread items', () => { + const tab1 = makeTab({ id: 't1', hasUnread: true }); + const tab2 = makeTab({ id: 't2', hasUnread: false }); + const session = makeSession({ id: 's1', aiTabs: [tab1, tab2] }); + + const { result } = renderHook(() => useAgentInbox([session], [], 'unread', 'newest')); + expect(result.current).toHaveLength(1); + expect(result.current[0].tabId).toBe('t1'); + expect(result.current[0].hasUnread).toBe(true); + }); + + it('filter "read" returns only non-unread items', () => { + const tab1 = makeTab({ id: 't1', hasUnread: true }); + const tab2 = makeTab({ id: 't2', hasUnread: false }); + const session = makeSession({ id: 's1', aiTabs: [tab1, tab2] }); + + const { result } = renderHook(() => useAgentInbox([session], [], 'read', 'newest')); + expect(result.current).toHaveLength(1); + expect(result.current[0].tabId).toBe('t2'); + expect(result.current[0].hasUnread).toBe(false); + }); + + it('filter "starred" returns only starred items', () => { + const tab1 = makeTab({ id: 't1', starred: true }); + const tab2 = makeTab({ id: 't2', starred: false }); + const session = makeSession({ id: 's1', aiTabs: [tab1, tab2] }); + + const { result } = renderHook(() => useAgentInbox([session], [], 'starred', 'newest')); + expect(result.current).toHaveLength(1); + expect(result.current[0].tabId).toBe('t1'); + expect(result.current[0].starred).toBe(true); + }); + + it('skips sessions with falsy id', () => { + const session = makeSession({ id: '', aiTabs: [makeTab({ hasUnread: true })] }); + + const { result } = renderHook(() => useAgentInbox([session], [], 'all', 'newest')); + expect(result.current).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// useAgentInbox — Sort Logic +// --------------------------------------------------------------------------- + +describe('useAgentInbox — sort logic', () => { + const now = Date.now(); + + it('sort "newest" orders by timestamp descending', () => { + const tab1 = makeTab({ + id: 't1', + logs: [{ id: '1', timestamp: now - 1000, source: 'ai', text: 'a' }], + }); + const tab2 = makeTab({ + id: 't2', + logs: [{ id: '2', timestamp: now, source: 'ai', text: 'b' }], + }); + const session = makeSession({ id: 's1', aiTabs: [tab1, tab2] }); + + const { result } = renderHook(() => useAgentInbox([session], [], 'all', 'newest')); + expect(result.current[0].tabId).toBe('t2'); + expect(result.current[1].tabId).toBe('t1'); + }); + + it('sort "oldest" orders by timestamp ascending', () => { + const tab1 = makeTab({ + id: 't1', + logs: [{ id: '1', timestamp: now - 1000, source: 'ai', text: 'a' }], + }); + const tab2 = makeTab({ + id: 't2', + logs: [{ id: '2', timestamp: now, source: 'ai', text: 'b' }], + }); + const session = makeSession({ id: 's1', aiTabs: [tab1, tab2] }); + + const { result } = renderHook(() => useAgentInbox([session], [], 'all', 'oldest')); + expect(result.current[0].tabId).toBe('t1'); + expect(result.current[1].tabId).toBe('t2'); + }); + + it('sort "grouped" groups by groupName then by timestamp within group', () => { + const groupA = makeGroup({ id: 'gA', name: 'Alpha' }); + const groupB = makeGroup({ id: 'gB', name: 'Beta' }); + + const tab1 = makeTab({ + id: 't1', + logs: [{ id: '1', timestamp: now - 2000, source: 'ai', text: 'a' }], + }); + const tab2 = makeTab({ + id: 't2', + logs: [{ id: '2', timestamp: now, source: 'ai', text: 'b' }], + }); + const tab3 = makeTab({ + id: 't3', + logs: [{ id: '3', timestamp: now - 1000, source: 'ai', text: 'c' }], + }); + + const sessionAlpha = makeSession({ id: 's1', name: 'S1', groupId: 'gA', aiTabs: [tab1] }); + const sessionBeta1 = makeSession({ id: 's2', name: 'S2', groupId: 'gB', aiTabs: [tab2] }); + const sessionBeta2 = makeSession({ id: 's3', name: 'S3', groupId: 'gB', aiTabs: [tab3] }); + + const { result } = renderHook(() => + useAgentInbox([sessionAlpha, sessionBeta1, sessionBeta2], [groupA, groupB], 'all', 'grouped') + ); + + // Alpha group first, then Beta + expect(result.current[0].groupName).toBe('Alpha'); + // Beta items sorted by newest first within group + expect(result.current[1].groupName).toBe('Beta'); + expect(result.current[1].tabId).toBe('t2'); // newer + expect(result.current[2].groupName).toBe('Beta'); + expect(result.current[2].tabId).toBe('t3'); // older + }); + + it('sort "grouped" puts ungrouped items last', () => { + const groupA = makeGroup({ id: 'gA', name: 'Alpha' }); + const tabGrouped = makeTab({ id: 't1' }); + const tabUngrouped = makeTab({ id: 't2' }); + + const s1 = makeSession({ id: 's1', groupId: 'gA', aiTabs: [tabGrouped] }); + const s2 = makeSession({ id: 's2', aiTabs: [tabUngrouped] }); + + const { result } = renderHook(() => useAgentInbox([s1, s2], [groupA], 'all', 'grouped')); + + expect(result.current[0].groupName).toBe('Alpha'); + expect(result.current[1].groupName).toBeUndefined(); + }); + + it('sort "byAgent" groups by sessionId, not sessionName', () => { + // Two sessions with same name but different IDs + const tab1 = makeTab({ id: 't1', hasUnread: true }); + const tab2 = makeTab({ id: 't2', hasUnread: false }); + const s1 = makeSession({ id: 'agent-aaa', name: 'Same Name', aiTabs: [tab1] }); + const s2 = makeSession({ id: 'agent-bbb', name: 'Same Name', aiTabs: [tab2] }); + + const { result } = renderHook(() => useAgentInbox([s1, s2], [], 'all', 'byAgent')); + + // agent-aaa has unread (1) so it should come first + expect(result.current[0].sessionId).toBe('agent-aaa'); + expect(result.current[1].sessionId).toBe('agent-bbb'); + }); + + it('sort "byAgent" puts agents with unreads first', () => { + const tabUnread = makeTab({ id: 't1', hasUnread: true }); + const tabRead = makeTab({ id: 't2', hasUnread: false }); + const sNoUnread = makeSession({ id: 'agent-zzz', name: 'Alpha', aiTabs: [tabRead] }); + const sWithUnread = makeSession({ id: 'agent-aaa', name: 'Zulu', aiTabs: [tabUnread] }); + + const { result } = renderHook(() => + useAgentInbox([sNoUnread, sWithUnread], [], 'all', 'byAgent') + ); + + // Agent with unread comes first regardless of alphabetical name + expect(result.current[0].sessionId).toBe('agent-aaa'); + expect(result.current[1].sessionId).toBe('agent-zzz'); + }); +}); + +// --------------------------------------------------------------------------- +// useAgentInbox — Summary Truncation +// --------------------------------------------------------------------------- + +describe('useAgentInbox — summary truncation', () => { + it('truncates lastMessage to 90 characters', () => { + const longMessage = 'X'.repeat(200); + const tab = makeTab({ + id: 't1', + logs: [{ id: '1', timestamp: 1000, source: 'ai', text: longMessage }], + }); + const session = makeSession({ id: 's1', aiTabs: [tab] }); + + const { result } = renderHook(() => useAgentInbox([session], [], 'all', 'newest')); + expect(result.current[0].lastMessage.length).toBe(90); + expect(result.current[0].lastMessage.endsWith('...')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// useAgentInbox — Timestamp Fallback +// --------------------------------------------------------------------------- + +describe('useAgentInbox — timestamp fallback', () => { + it('uses last log timestamp when available', () => { + const tab = makeTab({ + id: 't1', + logs: [{ id: '1', timestamp: 42000, source: 'ai', text: 'hi' }], + }); + const session = makeSession({ id: 's1', aiTabs: [tab] }); + + const { result } = renderHook(() => useAgentInbox([session], [], 'all', 'newest')); + expect(result.current[0].timestamp).toBe(42000); + }); + + it('falls back to tab createdAt when logs are empty', () => { + const tab = makeTab({ id: 't1', logs: [], createdAt: 99000 }); + const session = makeSession({ id: 's1', aiTabs: [tab] }); + + const { result } = renderHook(() => useAgentInbox([session], [], 'all', 'newest')); + expect(result.current[0].timestamp).toBe(99000); + }); + + it('falls back to Date.now() when log timestamp and createdAt are invalid', () => { + const before = Date.now(); + const tab = makeTab({ id: 't1', logs: [], createdAt: 0 }); + const session = makeSession({ id: 's1', aiTabs: [tab] }); + + const { result } = renderHook(() => useAgentInbox([session], [], 'all', 'newest')); + const after = Date.now(); + + expect(result.current[0].timestamp).toBeGreaterThanOrEqual(before); + expect(result.current[0].timestamp).toBeLessThanOrEqual(after); + }); + + it('skips non-finite log timestamps and falls back', () => { + const tab = makeTab({ + id: 't1', + logs: [{ id: '1', timestamp: NaN, source: 'ai', text: 'hi' }], + createdAt: 55000, + }); + const session = makeSession({ id: 's1', aiTabs: [tab] }); + + const { result } = renderHook(() => useAgentInbox([session], [], 'all', 'newest')); + expect(result.current[0].timestamp).toBe(55000); + }); +}); diff --git a/src/__tests__/renderer/hooks/useAppInitialization.test.ts b/src/__tests__/renderer/hooks/useAppInitialization.test.ts index f321926f5..ee062e3da 100644 --- a/src/__tests__/renderer/hooks/useAppInitialization.test.ts +++ b/src/__tests__/renderer/hooks/useAppInitialization.test.ts @@ -294,6 +294,21 @@ describe('useAppInitialization', () => { expect(result.current.ghCliAvailable).toBe(false); }); + + it('should not crash when maestro bridge is unavailable', async () => { + const originalMaestro = (window as any).maestro; + (window as any).maestro = undefined; + + try { + const { result } = renderHook(() => useAppInitialization()); + await act(flushPromises); + + expect(result.current.ghCliAvailable).toBe(false); + expect(mockCheckGhCli).not.toHaveBeenCalled(); + } finally { + (window as any).maestro = originalMaestro; + } + }); }); // --- Windows warning modal --- diff --git a/src/__tests__/renderer/hooks/useInputKeyDown.test.ts b/src/__tests__/renderer/hooks/useInputKeyDown.test.ts index 4283d4dc0..f82a70ada 100644 --- a/src/__tests__/renderer/hooks/useInputKeyDown.test.ts +++ b/src/__tests__/renderer/hooks/useInputKeyDown.test.ts @@ -553,8 +553,8 @@ describe('Slash command autocomplete', () => { it('filters out aiOnly commands in terminal mode', () => { setActiveSession({ inputMode: 'terminal' }); // Only /run is aiOnly, so it should be filtered out - // Input is '/r' which only matches /run - const deps = createMockDeps({ inputValue: '/r', allSlashCommands: commands }); + // Input is '/ru' which only fuzzy-matches /run (not /help or /clear) + const deps = createMockDeps({ inputValue: '/ru', allSlashCommands: commands }); const { result } = renderHook(() => useInputKeyDown(deps)); const e = createKeyEvent('Enter'); 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/__tests__/renderer/stores/modalStore.test.ts b/src/__tests__/renderer/stores/modalStore.test.ts index b648e2b70..65a6b44b2 100644 --- a/src/__tests__/renderer/stores/modalStore.test.ts +++ b/src/__tests__/renderer/stores/modalStore.test.ts @@ -192,12 +192,13 @@ describe('modalStore', () => { expect(data?.allowDelete).toBe(true); }); - it('does nothing for unopened modals', () => { - const { updateModalData, getData } = useModalStore.getState(); + it('stores data even for unopened modals', () => { + const { updateModalData, getData, isOpen } = useModalStore.getState(); updateModalData('settings', { tab: 'general' }); - expect(getData('settings')).toBeUndefined(); + expect(getData('settings')).toEqual({ tab: 'general' }); + expect(isOpen('settings')).toBe(false); }); it('does not change open state', () => { diff --git a/src/__tests__/renderer/utils/search.test.ts b/src/__tests__/renderer/utils/search.test.ts index 58d4b2d7b..f1aa862f3 100644 --- a/src/__tests__/renderer/utils/search.test.ts +++ b/src/__tests__/renderer/utils/search.test.ts @@ -431,6 +431,97 @@ describe('search utils', () => { }); }); + describe('scoring - exact vs fuzzy vs substring ranking', () => { + it('exact match scores higher than scattered fuzzy match', () => { + const exact = fuzzyMatchWithScore('abc', 'abc'); + const scattered = fuzzyMatchWithScore('aXbXc', 'abc'); + + expect(exact.matches).toBe(true); + expect(scattered.matches).toBe(true); + expect(exact.score).toBeGreaterThan(scattered.score); + }); + + it('substring match scores higher than scattered fuzzy match', () => { + const substring = fuzzyMatchWithScore('xxabcxx', 'abc'); + const scattered = fuzzyMatchWithScore('aXXbXXc', 'abc'); + + expect(substring.matches).toBe(true); + expect(scattered.matches).toBe(true); + expect(substring.score).toBeGreaterThan(scattered.score); + }); + + it('exact match scores higher than substring match', () => { + const exact = fuzzyMatchWithScore('hello', 'hello'); + const substring = fuzzyMatchWithScore('say hello there', 'hello'); + + expect(exact.matches).toBe(true); + expect(substring.matches).toBe(true); + expect(exact.score).toBeGreaterThan(substring.score); + }); + }); + + describe('case-insensitive matching in scored search', () => { + it('matches regardless of case', () => { + const upper = fuzzyMatchWithScore('HELLO WORLD', 'hello'); + const lower = fuzzyMatchWithScore('hello world', 'HELLO'); + const mixed = fuzzyMatchWithScore('HeLLo WoRLd', 'hElLo'); + + expect(upper.matches).toBe(true); + expect(lower.matches).toBe(true); + expect(mixed.matches).toBe(true); + }); + + it('case-matching query scores higher than mismatched case', () => { + const caseMatch = fuzzyMatchWithScore('Hello', 'Hello'); + const caseMismatch = fuzzyMatchWithScore('Hello', 'hello'); + + expect(caseMatch.matches).toBe(true); + expect(caseMismatch.matches).toBe(true); + expect(caseMatch.score).toBeGreaterThan(caseMismatch.score); + }); + }); + + describe('special characters in query', () => { + it('does not crash with regex special characters in query', () => { + expect(() => fuzzyMatchWithScore('some text', '.*+?^${}()|[]\\')).not.toThrow(); + expect(() => fuzzyMatch('some text', '.*+?^${}()|[]\\')).not.toThrow(); + }); + + it('does not crash with brackets and parens in query', () => { + const result = fuzzyMatchWithScore('function()', '()'); + expect(result).toHaveProperty('matches'); + expect(result).toHaveProperty('score'); + }); + + it('does not crash with unicode in query', () => { + const result = fuzzyMatchWithScore('caf\u00e9 latte', 'caf\u00e9'); + expect(result.matches).toBe(true); + expect(result.score).toBeGreaterThan(0); + }); + + it('does not crash with emoji in query', () => { + expect(() => fuzzyMatchWithScore('hello world', '\ud83d\ude00')).not.toThrow(); + expect(() => fuzzyMatch('hello world', '\ud83d\ude00')).not.toThrow(); + }); + }); + + describe('deterministic scoring', () => { + it('produces identical scores for identical inputs across multiple calls', () => { + const results = Array.from({ length: 10 }, () => + fuzzyMatchWithScore('src/renderer/utils/search.ts', 'search') + ); + + const firstScore = results[0].score; + expect(results.every((r) => r.score === firstScore)).toBe(true); + }); + + it('produces identical match results for identical inputs', () => { + const results = Array.from({ length: 10 }, () => fuzzyMatch('handleUserInput', 'hui')); + + expect(results.every((r) => r === true)).toBe(true); + }); + }); + describe('integration scenarios', () => { it('handles real file search scenario', () => { const files = [ diff --git a/src/main/stats/stats-db.ts b/src/main/stats/stats-db.ts index b2cbbbd8e..cdd627949 100644 --- a/src/main/stats/stats-db.ts +++ b/src/main/stats/stats-db.ts @@ -797,19 +797,19 @@ export class StatsDB { */ getEarliestTimestamp(): number | null { try { - // Query the minimum startTime from query_events table + // Query the minimum start_time from query_events table const queryResult = this.database - .prepare('SELECT MIN(startTime) as earliest FROM query_events') + .prepare('SELECT MIN(start_time) as earliest FROM query_events') .get() as { earliest: number | null } | undefined; - // Query the minimum startTime from auto_run_sessions table + // Query the minimum start_time from auto_run_sessions table const autoRunResult = this.database - .prepare('SELECT MIN(startTime) as earliest FROM auto_run_sessions') + .prepare('SELECT MIN(start_time) as earliest FROM auto_run_sessions') .get() as { earliest: number | null } | undefined; - // Query the minimum createdAt from session_lifecycle table + // Query the minimum created_at from session_lifecycle table const lifecycleResult = this.database - .prepare('SELECT MIN(createdAt) as earliest FROM session_lifecycle') + .prepare('SELECT MIN(created_at) as earliest FROM session_lifecycle') .get() as { earliest: number | null } | undefined; // Find the minimum across all tables diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 4f31351c3..6af346dda 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -41,6 +41,7 @@ const DocumentGraphView = lazy(() => const DirectorNotesModal = lazy(() => import('./components/DirectorNotes').then((m) => ({ default: m.DirectorNotesModal })) ); +const AgentInbox = lazy(() => import('./components/AgentInbox')); // Re-import the type for SymphonyContributionData (types don't need lazy loading) import type { SymphonyContributionData } from './components/SymphonyModal'; @@ -344,6 +345,9 @@ function MaestroConsoleInner() { // Director's Notes Modal directorNotesOpen, setDirectorNotesOpen, + // Agent Inbox Modal (Unified Inbox) + agentInboxOpen, + setAgentInboxOpen, } = useModalActions(); // --- MOBILE LANDSCAPE MODE (reading-only view) --- @@ -847,6 +851,7 @@ function MaestroConsoleInner() { handleCloseCurrentTab, handleRequestTabRename, handleUpdateTabByClaudeSessionId, + handleUpdateTabDescription, handleTabStar, handleTabMarkUnread, handleToggleTabReadOnlyMode, @@ -1372,6 +1377,171 @@ function MaestroConsoleInner() { } }, [activeSession?.id, handleResumeSession]); + // --- AGENT INBOX SESSION NAVIGATION --- + // Close inbox modal and switch to the target agent session + const handleAgentInboxNavigateToSession = useCallback( + (sessionId: string, tabId?: string) => { + setAgentInboxOpen(false); + setActiveSessionId(sessionId); + if (tabId) { + setSessions((prev) => + prev.map((s) => + s.id === sessionId + ? { ...s, activeTabId: tabId, activeFileTabId: null, inputMode: 'ai' as const } + : s + ) + ); + } + }, + [setAgentInboxOpen, setActiveSessionId, setSessions] + ); + + // Ref for processInput — populated after useInputHandlers (declared later in component). + // Handlers below close over this ref so they always call the latest version. + const inboxProcessInputRef = useRef<(text?: string) => void>(() => {}); + const [pendingInboxQuickReply, setPendingInboxQuickReply] = useState<{ + targetSessionId: string; + previousActiveSessionId: string | null; + text: string; + } | null>(null); + // Synchronous guard ref — prevents the effect from re-firing before React commits + // the batched setPendingInboxQuickReply(null). Without this, the Zustand setSessions() + // inside processInput triggers a synchronous store update → sessions dep changes → + // effect re-runs → pendingInboxQuickReply is still non-null (batched) → infinite loop. + const inboxQuickReplyFiredRef = useRef(false); + + // Flush pending quick-reply once target session is active (deterministic, no RAF timing chain). + useEffect(() => { + if (!pendingInboxQuickReply) return; + // Synchronous guard: processInput triggers Zustand setSessions (sync) which re-runs + // this effect before React commits the batched setPendingInboxQuickReply(null). + if (inboxQuickReplyFiredRef.current) return; + // Guard: if the target session was removed before activation, clear the stale pending. + if (!sessions.some((s) => s.id === pendingInboxQuickReply.targetSessionId)) { + setPendingInboxQuickReply(null); + return; + } + if (activeSession?.id !== pendingInboxQuickReply.targetSessionId) return; + + // Mark as fired BEFORE calling processInput to prevent re-entry + inboxQuickReplyFiredRef.current = true; + inboxProcessInputRef.current(pendingInboxQuickReply.text); + const previousActiveSessionId = pendingInboxQuickReply.previousActiveSessionId; + setPendingInboxQuickReply(null); + + if ( + previousActiveSessionId && + previousActiveSessionId !== pendingInboxQuickReply.targetSessionId && + sessions.some((s) => s.id === previousActiveSessionId) + ) { + queueMicrotask(() => { + setActiveSessionId(previousActiveSessionId); + }); + } + }, [pendingInboxQuickReply, activeSession?.id, setActiveSessionId, sessions]); + + // Agent Inbox: Quick Reply — sends text to target session/tab via processInput + const handleAgentInboxQuickReply = useCallback( + (sessionId: string, tabId: string, text: string) => { + // Guard: ignore if a quick reply is already in progress + if (pendingInboxQuickReply) return; + + // Reset the synchronous guard so the effect can fire for this new reply + inboxQuickReplyFiredRef.current = false; + + // Save current active session so we can restore it after sending. + const previousActiveSessionId = activeSessionIdRef.current; + + // Activate the target tab and mark as read (processInput adds the user log entry) + setSessions((prev) => + prev.map((s) => { + if (s.id !== sessionId) return s; + return { + ...s, + activeTabId: tabId, + activeFileTabId: null, + inputMode: 'ai' as const, + aiTabs: s.aiTabs.map((t) => (t.id === tabId ? { ...t, hasUnread: false } : t)), + }; + }) + ); + + // Switch to target session and let the effect above send once state is committed. + setPendingInboxQuickReply({ + targetSessionId: sessionId, + previousActiveSessionId, + text, + }); + setActiveSessionId(sessionId); + }, + [pendingInboxQuickReply, setSessions, setActiveSessionId, activeSessionIdRef] + ); + + // Agent Inbox: Open & Reply — navigates to session with pre-filled input + const handleAgentInboxOpenAndReply = useCallback( + (sessionId: string, tabId: string, text: string) => { + setActiveSessionId(sessionId); + setSessions((prev) => + prev.map((s) => { + if (s.id !== sessionId) return s; + return { + ...s, + activeTabId: tabId, + activeFileTabId: null, + inputMode: 'ai' as const, + aiTabs: s.aiTabs.map((t) => + t.id === tabId ? { ...t, inputValue: text, hasUnread: false } : t + ), + }; + }) + ); + setAgentInboxOpen(false); + }, + [setActiveSessionId, setSessions, setAgentInboxOpen] + ); + + // Agent Inbox: Mark as Read — dismiss unread badge without replying + const handleAgentInboxMarkAsRead = useCallback( + (sessionId: string, tabId: string) => { + setSessions((prev) => + prev.map((s) => { + if (s.id !== sessionId) return s; + return { + ...s, + aiTabs: s.aiTabs.map((t) => (t.id === tabId ? { ...t, hasUnread: false } : t)), + }; + }) + ); + }, + [setSessions] + ); + + // Agent Inbox: Toggle thinking mode on a specific tab + const handleAgentInboxToggleThinking = useCallback( + (sessionId: string, tabId: string, mode: ThinkingMode) => { + setSessions((prev) => + prev.map((s) => { + if (s.id !== sessionId) return s; + return { + ...s, + aiTabs: s.aiTabs.map((t) => { + if (t.id !== tabId) return t; + if (mode === 'off') { + return { + ...t, + showThinking: 'off', + logs: t.logs.filter((l) => l.source !== 'thinking' && l.source !== 'tool'), + }; + } + return { ...t, showThinking: mode }; + }), + }; + }) + ); + }, + [setSessions] + ); + // --- BATCH HANDLERS (Auto Run processing, quit confirmation, error handling) --- const { startBatchRun, @@ -1590,6 +1760,9 @@ function MaestroConsoleInner() { activeSessionIdRef, }); + // Bind the ref so Inbox Quick Reply handlers always call the latest processInput. + inboxProcessInputRef.current = processInput; + // This is used by context transfer to automatically send the transferred context to the agent useEffect(() => { if (!activeSession) return; @@ -3009,6 +3182,7 @@ function MaestroConsoleInner() { setMarketplaceModalOpen, setSymphonyModalOpen, setDirectorNotesOpen, + setAgentInboxOpen: encoreFeatures.unifiedInbox ? setAgentInboxOpen : undefined, encoreFeatures, setShowNewGroupChatModal, deleteGroupChatWithConfirmation, @@ -3212,6 +3386,9 @@ function MaestroConsoleInner() { handleTabReorder, handleUnifiedTabReorder, handleUpdateTabByClaudeSessionId, + handleUpdateTabDescription: encoreFeatures.tabDescription + ? handleUpdateTabDescription + : undefined, handleTabStar, handleTabMarkUnread, handleToggleTabReadOnlyMode, @@ -3708,6 +3885,7 @@ function MaestroConsoleInner() { onOpenDirectorNotes={ encoreFeatures.directorNotes ? () => setDirectorNotesOpen(true) : undefined } + onOpenAgentInbox={encoreFeatures.unifiedInbox ? () => setAgentInboxOpen(true) : undefined} autoScrollAiMode={autoScrollAiMode} setAutoScrollAiMode={setAutoScrollAiMode} tabSwitcherOpen={tabSwitcherOpen} @@ -4103,6 +4281,25 @@ function MaestroConsoleInner() { )} + {/* --- AGENT INBOX MODAL (lazy-loaded, Encore Feature) --- */} + {encoreFeatures.unifiedInbox && agentInboxOpen && ( + + setAgentInboxOpen(false)} + onNavigateToSession={handleAgentInboxNavigateToSession} + onQuickReply={handleAgentInboxQuickReply} + onOpenAndReply={handleAgentInboxOpenAndReply} + onMarkAsRead={handleAgentInboxMarkAsRead} + onToggleThinking={handleAgentInboxToggleThinking} + /> + + )} + {/* --- GIST PUBLISH MODAL --- */} {/* Supports both file preview tabs and tab context gist publishing */} {gistPublishModalOpen && (activeFileTab || tabGistContent) && ( diff --git a/src/renderer/components/AgentInbox/FocusModeView.tsx b/src/renderer/components/AgentInbox/FocusModeView.tsx new file mode 100644 index 000000000..9effa5432 --- /dev/null +++ b/src/renderer/components/AgentInbox/FocusModeView.tsx @@ -0,0 +1,1328 @@ +import { useMemo, useRef, useEffect, useState, useCallback } from 'react'; +import { + ArrowLeft, + X, + ArrowUp, + ExternalLink, + ChevronLeft, + ChevronRight, + ChevronDown, + Eye, + Brain, + Pin, + FileText, +} from 'lucide-react'; +import type { Theme, Session, LogEntry, ThinkingMode } from '../../types'; +import type { InboxItem, InboxFilterMode, InboxSortMode } from '../../types/agent-inbox'; +import { STATUS_LABELS, STATUS_COLORS } from '../../types/agent-inbox'; +import { resolveContextUsageColor } from './InboxListView'; +import { formatRelativeTime } from '../../utils/formatters'; +import { MarkdownRenderer } from '../MarkdownRenderer'; +import { generateTerminalProseStyles } from '../../utils/markdownConfig'; +import { slashCommands } from '../../slashCommands'; +import { fuzzyMatchWithScore } from '../../utils/search'; + +/* POLISH-04 Token Audit (@architect) + * Line 166: bgSidebar in user bubble color-mix — CORRECT (chrome blend for user messages) + * Line 210: bgActivity for AI bubble — CORRECT (content) + * Line 429: bgSidebar for sidebar group header — CORRECT (chrome) + * Line ~792: bgSidebar for unified focus header — CORRECT (chrome) + * Line 904: bgSidebar for sidebar bg — CORRECT (chrome) + * Line 1025: bgActivity → bgMain (textarea is nested input, needs contrast) + * All other usages: CORRECT + */ + +/* POLISH-03 Design Spec (@ux-design-expert) + * BUBBLES: + * - All corners: rounded-xl (uniform, no sharp edges) + * - Padding: p-4 (remove pb-10 hack) + * - Timestamp: inline flex row below content, text-[10px] textDim opacity 0.6, justify-end mt-2 + * - Left border: user = 3px solid success, AI = 3px solid accent + * - Max width: 85% (unchanged) + * + * SIDEBAR ITEMS: + * - Height: 48px (was 36px) + * - Layout: status dot + vertical(name, preview) + indicators + * - Preview: text-[10px] truncate, textDim opacity 0.5, max 60 chars, strip markdown + * - Indicators: alignSelf flex-start, marginTop 2 + */ + +// @architect: lastMessage available via InboxItem type (agent-inbox.ts:13) — sidebar scroll OK at 48px (overflow-y-auto, no max-height constraint) + +const MAX_LOG_ENTRIES = 50; + +function FocusLogEntry({ + log, + theme, + showRawMarkdown, + onToggleRaw, +}: { + log: LogEntry; + theme: Theme; + showRawMarkdown: boolean; + onToggleRaw: () => void; +}) { + const isUser = log.source === 'user'; + const isAI = log.source === 'ai' || log.source === 'stdout'; + const isThinking = log.source === 'thinking'; + const isTool = log.source === 'tool'; + + // Thinking entry — left border accent + badge + if (isThinking) { + return ( +
+
+ + thinking + + + {formatRelativeTime(log.timestamp)} + +
+
+ {log.text} +
+
+ ); + } + + // Tool entry — compact badge with status + if (isTool) { + const toolInput = (log.metadata as any)?.toolState?.input as + | Record + | undefined; + const safeStr = (v: unknown): string | null => (typeof v === 'string' ? v : null); + const toolDetail = toolInput + ? safeStr(toolInput.command) || + safeStr(toolInput.pattern) || + safeStr(toolInput.file_path) || + safeStr(toolInput.query) || + safeStr(toolInput.description) || + safeStr(toolInput.prompt) || + safeStr(toolInput.task_id) || + null + : null; + const toolStatus = (log.metadata as any)?.toolState?.status as string | undefined; + + return ( +
+
+ + {log.text} + + {toolStatus === 'running' && ( + + ● + + )} + {toolStatus === 'completed' && ( + + ✓ + + )} + {toolDetail && ( + + {toolDetail} + + )} +
+
+ ); + } + + // User entry — right-aligned, matching main chat bubble style + if (isUser) { + return ( +
+
+ {formatRelativeTime(log.timestamp)} +
+
+
+ {log.text} +
+
+
+ ); + } + + // AI / stdout entry — left-aligned, matching main chat bubble style + if (isAI) { + const handleCopy = (text: string) => { + navigator.clipboard.writeText(text).catch(() => {}); + }; + + return ( +
+
+ {formatRelativeTime(log.timestamp)} +
+
+ {/* Raw/rendered toggle */} +
+ +
+ + {showRawMarkdown ? ( +
+ {log.text} +
+ ) : ( + + )} +
+
+ ); + } + + // Fallback — should not reach here given the filter + return null; +} + +interface FocusModeViewProps { + theme: Theme; + item: InboxItem; + items: InboxItem[]; // Full filtered+sorted list for prev/next + sessions: Session[]; // For accessing AITab.logs + currentIndex: number; // Position of item in items[] + enterToSendAI?: boolean; // false = Cmd+Enter sends, true = Enter sends + slashCommands?: Array<{ + command: string; + description: string; + terminalOnly?: boolean; + aiOnly?: boolean; + }>; + filterMode?: InboxFilterMode; + setFilterMode?: (mode: InboxFilterMode) => void; + sortMode?: InboxSortMode; + onClose: () => void; // Close the entire modal + onExitFocus: () => void; // Return to list view + onNavigateItem: (index: number) => void; // Jump to item at index + onNavigateToSession?: (sessionId: string, tabId?: string) => void; + onQuickReply?: (sessionId: string, tabId: string, text: string) => void; + onOpenAndReply?: (sessionId: string, tabId: string, text: string) => void; + onMarkAsRead?: (sessionId: string, tabId: string) => void; + onToggleThinking?: (sessionId: string, tabId: string, mode: ThinkingMode) => void; +} + +// Maps STATUS_COLORS key to actual hex from theme +function resolveStatusColor(state: InboxItem['state'], theme: Theme): string { + const colorKey = STATUS_COLORS[state]; + const colorMap: Record = { + success: theme.colors.success, + warning: theme.colors.warning, + error: theme.colors.error, + info: theme.colors.accent, + textMuted: theme.colors.textDim, + }; + return colorMap[colorKey] ?? theme.colors.textDim; +} + +// ============================================================================ +// Compact filter control for sidebar +// ============================================================================ +const FILTER_OPTIONS: { value: InboxFilterMode; label: string }[] = [ + { value: 'all', label: 'All' }, + { value: 'unread', label: 'Unread' }, + { value: 'starred', label: 'Starred' }, +]; + +function SidebarFilter({ + value, + onChange, + theme, +}: { + value: InboxFilterMode; + onChange: (v: InboxFilterMode) => void; + theme: Theme; +}) { + return ( +
+ {FILTER_OPTIONS.map((opt) => { + const isActive = value === opt.value; + return ( + + ); + })} +
+ ); +} + +// ============================================================================ +// FocusSidebar — condensed navigable list of inbox items with agent grouping +// ============================================================================ +function FocusSidebar({ + items, + currentIndex, + theme, + sortMode, + filterMode, + setFilterMode, + onNavigateItem, +}: { + items: InboxItem[]; + currentIndex: number; + theme: Theme; + sortMode?: InboxSortMode; + filterMode?: InboxFilterMode; + setFilterMode?: (mode: InboxFilterMode) => void; + onNavigateItem: (index: number) => void; +}) { + const currentRowRef = useRef(null); + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + + // Auto-scroll to keep the current item visible + useEffect(() => { + currentRowRef.current?.scrollIntoView({ block: 'nearest' }); + }, [currentIndex]); + + // Build grouped rows — always group by agent/group to avoid duplicate headers + const rows = useMemo(() => { + const effectiveSort = sortMode ?? 'newest'; + const useGroupName = effectiveSort === 'grouped'; + + // Collect items per unique group key, preserving original index + const groupMap = new Map< + string, + { groupName: string; items: { item: InboxItem; index: number }[] } + >(); + const groupOrder: string[] = []; + items.forEach((itm, idx) => { + const groupKey = useGroupName ? (itm.groupId ?? itm.groupName ?? 'Ungrouped') : itm.sessionId; + const groupName = useGroupName ? (itm.groupName ?? 'Ungrouped') : itm.sessionName; + if (!groupMap.has(groupKey)) { + groupMap.set(groupKey, { groupName, items: [] }); + groupOrder.push(groupKey); + } + groupMap.get(groupKey)!.items.push({ item: itm, index: idx }); + }); + + const result: ( + | { type: 'header'; groupKey: string; groupName: string; count: number } + | { type: 'item'; item: InboxItem; index: number } + )[] = []; + for (const groupKey of groupOrder) { + const group = groupMap.get(groupKey)!; + result.push({ + type: 'header', + groupKey, + groupName: group.groupName, + count: group.items.length, + }); + for (const entry of group.items) { + result.push({ type: 'item', item: entry.item, index: entry.index }); + } + } + return result; + }, [items, sortMode]); + + return ( +
+ {/* Filter control header */} + {filterMode !== undefined && setFilterMode && ( +
+ +
+ )} + {/* Item list */} +
+ {(() => { + let activeGroup: string | null = null; + return rows.map((row, rowIdx) => { + if (row.type === 'header') { + activeGroup = row.groupKey; + return ( +
{ + setCollapsedGroups((prev) => { + const next = new Set(prev); + if (next.has(row.groupKey)) next.delete(row.groupKey); + else next.add(row.groupKey); + return next; + }); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setCollapsedGroups((prev) => { + const next = new Set(prev); + if (next.has(row.groupKey)) next.delete(row.groupKey); + else next.add(row.groupKey); + return next; + }); + } + }} + className="flex items-center gap-1.5 px-3 py-1.5 text-[10px] uppercase tracking-wider cursor-pointer" + onFocus={(e) => { + e.currentTarget.style.boxShadow = `inset 0 0 0 2px ${theme.colors.accent}`; + }} + onBlur={(e) => { + e.currentTarget.style.boxShadow = 'none'; + }} + style={{ + color: theme.colors.textDim, + fontWeight: 600, + backgroundColor: theme.colors.bgSidebar, + outline: 'none', + }} + > + {collapsedGroups.has(row.groupKey) ? ( + + ) : ( + + )} + {row.groupName} + + {row.type === 'header' ? row.count : 0} + +
+ ); + } + + // Skip items in collapsed groups + if (activeGroup && collapsedGroups.has(activeGroup)) return null; + + const itm = row.item; + const idx = row.index; + const isCurrent = idx === currentIndex; + const statusColor = resolveStatusColor(itm.state, theme); + + const previewText = itm.lastMessage + ? itm.lastMessage.replace(/[#*`>]/g, '').slice(0, 60) + : ''; + + return ( +
onNavigateItem(idx)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onNavigateItem(idx); + } + }} + className="flex items-center gap-2 px-3 cursor-pointer transition-colors" + style={{ + height: 48, + backgroundColor: isCurrent ? `${theme.colors.accent}15` : 'transparent', + borderLeft: isCurrent + ? `2px solid ${theme.colors.accent}` + : '2px solid transparent', + }} + onMouseEnter={(e) => { + if (!isCurrent) + e.currentTarget.style.backgroundColor = `${theme.colors.accent}08`; + }} + onMouseLeave={(e) => { + if (!isCurrent) e.currentTarget.style.backgroundColor = 'transparent'; + }} + onFocus={(e) => { + if (!isCurrent) + e.currentTarget.style.backgroundColor = `${theme.colors.accent}08`; + }} + onBlur={(e) => { + if (!isCurrent) e.currentTarget.style.backgroundColor = 'transparent'; + }} + > + {/* Status dot with unread badge (matches Left Bar) */} +
+
+ {itm.hasUnread && ( +
+ )} +
+ {/* Name + preview vertical stack */} +
+ + {itm.tabName || 'Tab'} + + {previewText && ( + + {previewText} + + )} +
+
+ ); + }); + })()} +
+
+ ); +} + +export default function FocusModeView({ + theme, + item, + items, + sessions, + currentIndex, + enterToSendAI, + slashCommands: slashCommandsProp, + filterMode, + setFilterMode, + sortMode, + onClose, + onExitFocus, + onNavigateItem, + onQuickReply, + onOpenAndReply, + onMarkAsRead, + onToggleThinking, +}: FocusModeViewProps) { + const statusColor = resolveStatusColor(item.state, theme); + const hasValidContext = item.contextUsage !== undefined && !isNaN(item.contextUsage); + + // ---- Resizable sidebar ---- + const [sidebarWidth, setSidebarWidth] = useState(300); + const sidebarWidthRef = useRef(sidebarWidth); + sidebarWidthRef.current = sidebarWidth; + const isResizingRef = useRef(false); + const resizeCleanupRef = useRef<(() => void) | null>(null); + + // Unmount safety: clean up resize listeners if component unmounts mid-drag + useEffect(() => { + return () => { + resizeCleanupRef.current?.(); + resizeCleanupRef.current = null; + }; + }, []); + + const handleResizeStart = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + // Clean up any lingering listeners from rapid clicks + resizeCleanupRef.current?.(); + isResizingRef.current = true; + const startX = e.clientX; + const startWidth = sidebarWidthRef.current; + + const onMouseMove = (ev: MouseEvent) => { + if (!isResizingRef.current) return; + const newWidth = Math.max(200, Math.min(440, startWidth + (ev.clientX - startX))); + setSidebarWidth(newWidth); + }; + + const cleanup = () => { + isResizingRef.current = false; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + resizeCleanupRef.current = null; + }; + + const onMouseUp = () => { + cleanup(); + }; + + resizeCleanupRef.current = cleanup; + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }, []); + const contextColor = hasValidContext + ? resolveContextUsageColor(item.contextUsage!, theme) + : undefined; + + // Truncate helper + const truncate = (str: string, max: number) => + str.length > max ? str.slice(0, max) + '...' : str; + + // Session existence check (session may be deleted while focus mode is open) + const sessionExists = sessions.some((s) => s.id === item.sessionId); + + // ---- Thinking toggle state (3-state: off → on → sticky → off) ---- + // Read showThinking from the actual tab property (synced with main app) + const showThinking: ThinkingMode = useMemo(() => { + const session = sessions.find((s) => s.id === item.sessionId); + if (!session) return 'off'; + const tab = session.aiTabs.find((t) => t.id === item.tabId); + return tab?.showThinking ?? 'off'; + }, [sessions, item.sessionId, item.tabId]); + + const cycleThinking = useCallback(() => { + const nextMode: ThinkingMode = + showThinking === 'off' ? 'on' : showThinking === 'on' ? 'sticky' : 'off'; + if (onToggleThinking) { + onToggleThinking(item.sessionId, item.tabId, nextMode); + } + }, [showThinking, item.sessionId, item.tabId, onToggleThinking]); + + // ---- Raw markdown toggle (per-session, not per-log) ---- + const [showRawMarkdown, setShowRawMarkdown] = useState(false); + + // Compute conversation tail — last N renderable log entries + const logs = useMemo(() => { + const session = sessions.find((s) => s.id === item.sessionId); + if (!session) return []; + const tab = session.aiTabs.find((t) => t.id === item.tabId); + if (!tab) return []; + // Include all renderable log types + const relevant = tab.logs.filter( + (log) => + log.source === 'ai' || + log.source === 'stdout' || + log.source === 'user' || + log.source === 'thinking' || + log.source === 'tool' + ); + // Take last N entries + return relevant.slice(-MAX_LOG_ENTRIES); + }, [sessions, item.sessionId, item.tabId]); + + // Filter out thinking/tool when toggle is off + const visibleLogs = useMemo(() => { + if (showThinking !== 'off') return logs; + return logs.filter((log) => log.source !== 'thinking' && log.source !== 'tool'); + }, [logs, showThinking]); + + // Memoized prose styles — same as TerminalOutput, scoped to .focus-mode-prose + const proseStyles = useMemo( + () => generateTerminalProseStyles(theme, '.focus-mode-prose'), + [theme] + ); + + // Auto-scroll to bottom ONLY if user is near bottom (within 150px) or item changed + const scrollRef = useRef(null); + const prevScrollItemRef = useRef(''); + + useEffect(() => { + if (!scrollRef.current) return; + const el = scrollRef.current; + const itemKey = `${item.sessionId}:${item.tabId}`; + const isNewItem = prevScrollItemRef.current !== itemKey; + if (isNewItem) { + prevScrollItemRef.current = itemKey; + el.scrollTop = el.scrollHeight; + return; + } + // Only auto-scroll if user is near bottom + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + if (distanceFromBottom < 150) { + el.scrollTop = el.scrollHeight; + } + }, [visibleLogs, item.sessionId, item.tabId]); + + // ---- Reply state ---- + const [replyText, setReplyText] = useState(''); + const replyInputRef = useRef(null); + + // ---- Slash command autocomplete state ---- + const [slashCommandOpen, setSlashCommandOpen] = useState(false); + const slashCommandOpenRef = useRef(false); + slashCommandOpenRef.current = slashCommandOpen; + const [selectedSlashCommandIndex, setSelectedSlashCommandIndex] = useState(0); + + // Use prop (full merged list from App.tsx) with fallback to built-in commands + const effectiveSlashCommands = slashCommandsProp ?? slashCommands; + + // PERF: Only run fuzzy matching when dropdown is open — avoids work on every keystroke + const filteredSlashCommands = useMemo(() => { + if (!slashCommandOpen) return []; + const query = replyText.toLowerCase(); + return effectiveSlashCommands + .filter((cmd) => !cmd.terminalOnly) // Focus mode is always AI mode + .map((cmd) => { + const result = fuzzyMatchWithScore(cmd.command, query); + if (!result.matches) return null; + return { cmd, score: result.score }; + }) + .filter( + (item): item is { cmd: (typeof effectiveSlashCommands)[number]; score: number } => + item !== null + ) + .sort((a, b) => b.score - a.score) + .map((item) => item.cmd); + }, [effectiveSlashCommands, replyText, slashCommandOpen]); + + const safeSlashIndex = Math.min( + Math.max(0, selectedSlashCommandIndex), + Math.max(0, filteredSlashCommands.length - 1) + ); + + // Auto-focus reply input when entering focus mode or switching items. + useEffect(() => { + const rafId = requestAnimationFrame(() => { + replyInputRef.current?.focus(); + }); + return () => cancelAnimationFrame(rafId); + }, [item.sessionId, item.tabId]); + + // Reset reply text, slash command state, and textarea height when item changes (prev/next navigation) + useEffect(() => { + setReplyText(''); + setSlashCommandOpen(false); + if (replyInputRef.current) { + replyInputRef.current.style.height = 'auto'; + } + }, [item.sessionId, item.tabId]); + + const handleQuickReply = useCallback(() => { + const text = replyText.trim(); + if (!text || !onQuickReply) return; + onQuickReply(item.sessionId, item.tabId, text); + onMarkAsRead?.(item.sessionId, item.tabId); + setReplyText(''); + }, [replyText, item, onQuickReply, onMarkAsRead]); + + const handleOpenAndReply = useCallback(() => { + const text = replyText.trim(); + if (!text || !onOpenAndReply) return; + onOpenAndReply(item.sessionId, item.tabId, text); + onMarkAsRead?.(item.sessionId, item.tabId); + }, [replyText, item, onOpenAndReply, onMarkAsRead]); + + // ---- Smooth transition on item change ---- + const [isTransitioning, setIsTransitioning] = useState(false); + const prevItemRef = useRef(`${item.sessionId}-${item.tabId}`); + + useEffect(() => { + const currentKey = `${item.sessionId}-${item.tabId}`; + if (prevItemRef.current !== currentKey) { + setIsTransitioning(true); + const timer = setTimeout(() => setIsTransitioning(false), 150); + prevItemRef.current = currentKey; + return () => clearTimeout(timer); + } + }, [item.sessionId, item.tabId]); + + // Mark as read only on explicit interaction (reply), not on view. + // This preserves the Unread filter — items stay unread until the user acts. + + return ( +
+ {/* Header bar — single h-16 bar matching MainPanel */} +
+ {/* Left: Back button */} + + + {/* Center: Group | Agent Name | Tab — matches app title bar pattern */} +
+ + {(() => { + const parts: string[] = []; + if (item.groupName) parts.push(item.groupName); + parts.push(item.sessionName); + if (item.tabName) parts.push(item.tabName); + return parts.join(' | '); + })()} + +
+ + {/* Right: metadata badges + thinking toggle + close */} +
+ {item.gitBranch && ( + + {truncate(item.gitBranch, 20)} + + )} + {hasValidContext && ( + + {item.contextUsage}% + + )} + + {STATUS_LABELS[item.state]} + + {/* Thinking toggle — 3-state: off → on → sticky → off */} + + +
+
+ + {/* Prose styles for markdown rendering — injected once at container level */} + + + {/* Two-column layout: sidebar + main content */} +
+ {/* Sidebar mini-list */} +
+ +
+ + {/* Resize handle */} +
{ + if (e.key === 'ArrowLeft') { + e.preventDefault(); + setSidebarWidth((w) => Math.max(200, w - 16)); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + setSidebarWidth((w) => Math.min(440, w + 16)); + } + }} + style={{ + width: 4, + cursor: 'col-resize', + backgroundColor: 'transparent', + borderRight: `1px solid ${theme.colors.border}`, + flexShrink: 0, + }} + onMouseEnter={(e) => { + e.currentTarget.style.backgroundColor = `${theme.colors.accent}30`; + }} + onMouseLeave={(e) => { + if (!isResizingRef.current) { + e.currentTarget.style.backgroundColor = 'transparent'; + } + }} + onFocus={(e) => { + e.currentTarget.style.backgroundColor = `${theme.colors.accent}30`; + }} + onBlur={(e) => { + if (!isResizingRef.current) { + e.currentTarget.style.backgroundColor = 'transparent'; + } + }} + /> + + {/* Main content: conversation body + reply input */} +
+ {/* Body — conversation tail */} + {!sessionExists ? ( +
+ Agent no longer available +
+ ) : ( +
+ {visibleLogs.length === 0 ? ( +
+ No conversation yet +
+ ) : ( +
+ {visibleLogs.map((log) => ( + setShowRawMarkdown((v) => !v)} + /> + ))} +
+ )} +
+ )} + + {/* Reply input bar */} +
+ {/* Slash Command Autocomplete Dropdown */} + {slashCommandOpen && filteredSlashCommands.length > 0 && ( +
+
+ {filteredSlashCommands.map((cmd, idx) => ( + + ))} +
+
+ )} +