diff --git a/docs/docs.json b/docs/docs.json index 069f0c48b..e786b6ea5 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -56,7 +56,7 @@ "symphony", "git-worktrees", "group-chat", - "remote-access", + "remote-control", "ssh-remote-execution", "configuration" ] diff --git a/docs/features.md b/docs/features.md index 8c556e17f..13dc13e80 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,6 +1,6 @@ --- title: Features -description: Explore Maestro's power features including Git Worktrees, Auto Run, Group Chat, and Remote Access. +description: Explore Maestro's power features including Git Worktrees, Auto Run, Group Chat, and Remote Control. icon: sparkles --- @@ -11,7 +11,7 @@ icon: sparkles - šŸŖ **[Playbook Exchange](./playbook-exchange)** - Browse and import community-contributed playbooks directly into your Auto Run folder. Categories, search, and one-click import get you started with proven workflows for security audits, code reviews, documentation, and more. - šŸŽµ **[Maestro Symphony](./symphony)** - Contribute to open source by donating AI tokens. Browse registered projects, select GitHub issues, and let Maestro clone, process Auto Run docs, and create PRs automatically. Distributed computing for AI-assisted development. - šŸ’¬ **[Group Chat](./group-chat)** - Coordinate multiple AI agents in a single conversation. A moderator AI orchestrates discussions, routing questions to the right agents and synthesizing their responses for cross-project questions and architecture discussions. -- 🌐 **[Remote Access](./remote-access)** - Built-in web server with QR code access. Monitor and control all your agents from your phone. Supports local network access and remote tunneling via Cloudflare for access from anywhere. +- 🌐 **[Remote Control](./remote-control)** - Built-in web server with QR code access. Monitor and control all your agents from your phone. Supports local network access and remote tunneling via Cloudflare for access from anywhere. - šŸ”— **[SSH Remote Execution](./ssh-remote-execution)** - Run AI agents on remote hosts via SSH. Leverage powerful cloud VMs, access tools not installed locally, or work with projects requiring specific environments — all while controlling everything from your local Maestro instance. - šŸ’» **[Command Line Interface](./cli)** - Full CLI (`maestro-cli`) for headless operation. List agents/groups, run playbooks from cron jobs or CI/CD pipelines, with human-readable or JSONL output for scripting. - šŸš€ **Multi-Agent Management** - Run unlimited agents in parallel. Each agent has its own workspace, conversation history, and isolated context. diff --git a/docs/remote-access.md b/docs/remote-control.md similarity index 97% rename from docs/remote-access.md rename to docs/remote-control.md index 645461a64..706c509e2 100644 --- a/docs/remote-access.md +++ b/docs/remote-control.md @@ -1,5 +1,5 @@ --- -title: Remote Access +title: Remote Control description: Control Maestro from your phone via the built-in web server and Cloudflare tunnels. icon: wifi --- @@ -45,13 +45,13 @@ The mobile web interface provides a comprehensive remote control experience: The web interface uses your local IP address (e.g., `192.168.x.x`) for LAN accessibility. Both devices must be on the same network. -## Remote Access (Outside Your Network) +## Remote Control (Outside Your Network) To access Maestro from outside your local network (e.g., on mobile data or from another location): 1. Install cloudflared: `brew install cloudflared` (macOS) or [download for other platforms](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) 2. Enable the web interface (**OFFLINE** → **LIVE**) -3. Toggle **Remote Access** in the Live overlay panel +3. Toggle **Remote Control** in the Live overlay panel 4. A secure Cloudflare tunnel URL (e.g., `https://abc123.trycloudflare.com`) will be generated within ~30 seconds 5. Use the **Local/Remote** pill selector to switch between QR codes 6. The tunnel stays active as long as Maestro is running — no time limits, no Cloudflare account required diff --git a/src/__tests__/cli/commands/send.test.ts b/src/__tests__/cli/commands/send.test.ts index 85241621e..53f3e31a8 100644 --- a/src/__tests__/cli/commands/send.test.ts +++ b/src/__tests__/cli/commands/send.test.ts @@ -81,7 +81,8 @@ describe('send command', () => { 'claude-code', '/path/to/project', 'Hello world', - undefined + undefined, + { readOnlyMode: undefined } ); expect(consoleSpy).toHaveBeenCalledTimes(1); @@ -128,7 +129,8 @@ describe('send command', () => { 'claude-code', '/path/to/project', 'Continue from before', - 'session-xyz-789' + 'session-xyz-789', + { readOnlyMode: undefined } ); const output = JSON.parse(consoleSpy.mock.calls[0][0]); @@ -153,7 +155,8 @@ describe('send command', () => { 'claude-code', '/custom/project/path', 'Do something', - undefined + undefined, + { readOnlyMode: undefined } ); }); @@ -173,7 +176,30 @@ describe('send command', () => { expect(detectCodex).toHaveBeenCalled(); expect(detectClaude).not.toHaveBeenCalled(); - expect(spawnAgent).toHaveBeenCalledWith('codex', expect.any(String), 'Use codex', undefined); + expect(spawnAgent).toHaveBeenCalledWith('codex', expect.any(String), 'Use codex', undefined, { + readOnlyMode: undefined, + }); + }); + + it('should pass readOnlyMode when --read-only flag is set', async () => { + vi.mocked(resolveAgentId).mockReturnValue('agent-abc-123'); + vi.mocked(getSessionById).mockReturnValue(mockAgent()); + vi.mocked(detectClaude).mockResolvedValue({ available: true, path: '/usr/bin/claude' }); + vi.mocked(spawnAgent).mockResolvedValue({ + success: true, + response: 'Read-only response', + agentSessionId: 'session-ro', + }); + + await send('agent-abc', 'Analyze this code', { readOnly: true }); + + expect(spawnAgent).toHaveBeenCalledWith( + 'claude-code', + '/path/to/project', + 'Analyze this code', + undefined, + { readOnlyMode: true } + ); }); it('should exit with error when agent ID is not found', async () => { diff --git a/src/__tests__/cli/services/agent-spawner.test.ts b/src/__tests__/cli/services/agent-spawner.test.ts index 3e69a0772..6fa1000ec 100644 --- a/src/__tests__/cli/services/agent-spawner.test.ts +++ b/src/__tests__/cli/services/agent-spawner.test.ts @@ -1075,6 +1075,42 @@ Some text with [x] in it that's not a checkbox } }); + it('should include read-only args for Claude when readOnlyMode is true', async () => { + const resultPromise = spawnAgent('claude-code', '/project', 'prompt', undefined, { + readOnlyMode: true, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const [, args] = mockSpawn.mock.calls[0]; + // Should include Claude's read-only args from centralized definitions + expect(args).toContain('--permission-mode'); + expect(args).toContain('plan'); + // Should still have base args + expect(args).toContain('--print'); + expect(args).toContain('--dangerously-skip-permissions'); + + mockStdout.emit('data', Buffer.from('{"type":"result","result":"Done"}\n')); + mockChild.emit('close', 0); + await resultPromise; + }); + + it('should not include read-only args when readOnlyMode is false', async () => { + const resultPromise = spawnAgent('claude-code', '/project', 'prompt', undefined, { + readOnlyMode: false, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const [, args] = mockSpawn.mock.calls[0]; + expect(args).not.toContain('--permission-mode'); + expect(args).not.toContain('plan'); + + mockStdout.emit('data', Buffer.from('{"type":"result","result":"Done"}\n')); + mockChild.emit('close', 0); + await resultPromise; + }); + it('should generate unique session-id for each spawn', async () => { // First spawn const promise1 = spawnAgent('claude-code', '/project', 'prompt1'); diff --git a/src/__tests__/integration/AutoRunBatchProcessing.test.tsx b/src/__tests__/integration/AutoRunBatchProcessing.test.tsx index 7fcead213..01a55e6f5 100644 --- a/src/__tests__/integration/AutoRunBatchProcessing.test.tsx +++ b/src/__tests__/integration/AutoRunBatchProcessing.test.tsx @@ -46,6 +46,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); vi.mock('../../renderer/components/AutoRunnerHelpModal', () => ({ diff --git a/src/__tests__/integration/AutoRunRightPanel.test.tsx b/src/__tests__/integration/AutoRunRightPanel.test.tsx index bd86b4d32..7166462af 100644 --- a/src/__tests__/integration/AutoRunRightPanel.test.tsx +++ b/src/__tests__/integration/AutoRunRightPanel.test.tsx @@ -35,6 +35,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); vi.mock('../../renderer/components/AutoRunnerHelpModal', () => ({ diff --git a/src/__tests__/integration/AutoRunSessionList.test.tsx b/src/__tests__/integration/AutoRunSessionList.test.tsx index 420b44820..291021ba5 100644 --- a/src/__tests__/integration/AutoRunSessionList.test.tsx +++ b/src/__tests__/integration/AutoRunSessionList.test.tsx @@ -52,6 +52,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); vi.mock('../../renderer/components/AutoRunnerHelpModal', () => ({ diff --git a/src/__tests__/main/ipc/handlers/director-notes.test.ts b/src/__tests__/main/ipc/handlers/director-notes.test.ts index 778946efc..655d60892 100644 --- a/src/__tests__/main/ipc/handlers/director-notes.test.ts +++ b/src/__tests__/main/ipc/handlers/director-notes.test.ts @@ -244,6 +244,37 @@ describe('director-notes IPC handlers', () => { expect(result.stats.totalCount).toBe(3); }); + it('should only count agents with entries in lookback window for agentCount', async () => { + const now = Date.now(); + const twoDaysAgo = now - 2 * 24 * 60 * 60 * 1000; + const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000; + + // 3 sessions on disk, but only 2 have entries within 7-day lookback + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue([ + 'session-1', + 'session-2', + 'session-3', + ]); + + vi.mocked(mockHistoryManager.getEntries) + .mockReturnValueOnce([ + createMockEntry({ id: 'e1', timestamp: twoDaysAgo, agentSessionId: 'as-1' }), + ]) + .mockReturnValueOnce([ + // session-2 only has old entries outside lookback + createMockEntry({ id: 'e2', timestamp: tenDaysAgo, agentSessionId: 'as-2' }), + ]) + .mockReturnValueOnce([ + createMockEntry({ id: 'e3', timestamp: twoDaysAgo, agentSessionId: 'as-3' }), + ]); + + const handler = handlers.get('director-notes:getUnifiedHistory'); + const result = await handler!({} as any, { lookbackDays: 7 }); + + expect(result.stats.agentCount).toBe(2); // Only 2 agents had entries in window + expect(result.entries).toHaveLength(2); + }); + it('should filter by lookbackDays', async () => { const now = Date.now(); const twoDaysAgo = now - 2 * 24 * 60 * 60 * 1000; diff --git a/src/__tests__/performance/AutoRunLargeDocument.test.tsx b/src/__tests__/performance/AutoRunLargeDocument.test.tsx index d17223e47..3c731ecdd 100644 --- a/src/__tests__/performance/AutoRunLargeDocument.test.tsx +++ b/src/__tests__/performance/AutoRunLargeDocument.test.tsx @@ -44,6 +44,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); vi.mock('../../renderer/components/AutoRunnerHelpModal', () => ({ diff --git a/src/__tests__/performance/AutoRunManyDocuments.test.tsx b/src/__tests__/performance/AutoRunManyDocuments.test.tsx index 05da0da35..a11d0d561 100644 --- a/src/__tests__/performance/AutoRunManyDocuments.test.tsx +++ b/src/__tests__/performance/AutoRunManyDocuments.test.tsx @@ -48,6 +48,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); vi.mock('../../renderer/components/AutoRunnerHelpModal', () => ({ diff --git a/src/__tests__/performance/AutoRunMemoryLeaks.test.tsx b/src/__tests__/performance/AutoRunMemoryLeaks.test.tsx index fae14ea32..7ebc0d7c5 100644 --- a/src/__tests__/performance/AutoRunMemoryLeaks.test.tsx +++ b/src/__tests__/performance/AutoRunMemoryLeaks.test.tsx @@ -47,6 +47,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); vi.mock('../../renderer/components/AutoRunnerHelpModal', () => ({ diff --git a/src/__tests__/performance/AutoRunRapidInteractions.test.tsx b/src/__tests__/performance/AutoRunRapidInteractions.test.tsx index e09c0f88d..37a7e22bb 100644 --- a/src/__tests__/performance/AutoRunRapidInteractions.test.tsx +++ b/src/__tests__/performance/AutoRunRapidInteractions.test.tsx @@ -45,6 +45,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); vi.mock('../../renderer/components/AutoRunnerHelpModal', () => ({ diff --git a/src/__tests__/renderer/components/AutoRun.test.tsx b/src/__tests__/renderer/components/AutoRun.test.tsx index 6b55abe3d..b17fb8af0 100644 --- a/src/__tests__/renderer/components/AutoRun.test.tsx +++ b/src/__tests__/renderer/components/AutoRun.test.tsx @@ -55,6 +55,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); vi.mock('../../../renderer/components/AutoRunnerHelpModal', () => ({ diff --git a/src/__tests__/renderer/components/AutoRunBlurSaveTiming.test.tsx b/src/__tests__/renderer/components/AutoRunBlurSaveTiming.test.tsx index 366a130bf..6513a0b24 100644 --- a/src/__tests__/renderer/components/AutoRunBlurSaveTiming.test.tsx +++ b/src/__tests__/renderer/components/AutoRunBlurSaveTiming.test.tsx @@ -52,6 +52,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); vi.mock('../../../renderer/components/AutoRunnerHelpModal', () => ({ diff --git a/src/__tests__/renderer/components/AutoRunContentSync.test.tsx b/src/__tests__/renderer/components/AutoRunContentSync.test.tsx index a86c30218..c22a38824 100644 --- a/src/__tests__/renderer/components/AutoRunContentSync.test.tsx +++ b/src/__tests__/renderer/components/AutoRunContentSync.test.tsx @@ -45,6 +45,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); vi.mock('../../../renderer/components/AutoRunnerHelpModal', () => ({ diff --git a/src/__tests__/renderer/components/AutoRunSessionIsolation.test.tsx b/src/__tests__/renderer/components/AutoRunSessionIsolation.test.tsx index 063309ba8..6cec49599 100644 --- a/src/__tests__/renderer/components/AutoRunSessionIsolation.test.tsx +++ b/src/__tests__/renderer/components/AutoRunSessionIsolation.test.tsx @@ -45,6 +45,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); vi.mock('../../../renderer/components/AutoRunnerHelpModal', () => ({ diff --git a/src/__tests__/renderer/components/ContextWarningSash.test.tsx b/src/__tests__/renderer/components/ContextWarningSash.test.tsx index ef7c86a8f..88224315f 100644 --- a/src/__tests__/renderer/components/ContextWarningSash.test.tsx +++ b/src/__tests__/renderer/components/ContextWarningSash.test.tsx @@ -544,4 +544,45 @@ describe('ContextWarningSash', () => { expect(container.firstChild).toBeNull(); }); }); + + describe('light mode contrast', () => { + const lightTheme: Theme = { + ...theme, + id: 'light-test', + name: 'Light Test', + mode: 'light', + }; + + it('should use dark text colors in light mode for yellow warning', () => { + render( + + ); + const warningText = screen.getByText(/reaching/); + // yellow-800 (#854d0e) for light mode instead of yellow-300 + expect(warningText).toHaveStyle({ color: '#854d0e' }); + }); + + it('should use dark text colors in light mode for red warning', () => { + render( + + ); + const warningText = screen.getByText(/consider compacting/); + // red-800 (#991b1b) for light mode instead of red-300 + expect(warningText).toHaveStyle({ color: '#991b1b' }); + }); + }); }); diff --git a/src/__tests__/renderer/components/FilePreview.test.tsx b/src/__tests__/renderer/components/FilePreview.test.tsx index 9325c856e..e4db17741 100644 --- a/src/__tests__/renderer/components/FilePreview.test.tsx +++ b/src/__tests__/renderer/components/FilePreview.test.tsx @@ -49,6 +49,7 @@ vi.mock('react-syntax-highlighter', () => ({ })); vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); // Mock unist-util-visit @@ -164,6 +165,7 @@ vi.mock('../../../shared/gitUtils', () => ({ })); const mockTheme = { + mode: 'dark', colors: { bgMain: '#1a1a2e', bgActivity: '#16213e', diff --git a/src/__tests__/renderer/components/History/HistoryEntryItem.test.tsx b/src/__tests__/renderer/components/History/HistoryEntryItem.test.tsx index b1c4be32e..e10c9715f 100644 --- a/src/__tests__/renderer/components/History/HistoryEntryItem.test.tsx +++ b/src/__tests__/renderer/components/History/HistoryEntryItem.test.tsx @@ -250,6 +250,25 @@ describe('HistoryEntryItem', () => { expect(screen.getByText('ABC12345')).toBeInTheDocument(); }); + it('session name pill is shrinkable to avoid date collision', () => { + const entry = createMockEntry({ + agentSessionId: 'abc12345-def6-7890', + sessionName: 'A Very Long Session Name That Should Truncate', + }); + render( + + ); + const sessionButton = screen.getByTitle('A Very Long Session Name That Should Truncate'); + expect(sessionButton).toHaveClass('flex-shrink'); + expect(sessionButton).not.toHaveClass('flex-shrink-0'); + }); + it('shows session name when both sessionName and agentSessionId are present', () => { const entry = createMockEntry({ agentSessionId: 'abc12345-def6-7890', diff --git a/src/__tests__/renderer/components/MarkdownRenderer.test.tsx b/src/__tests__/renderer/components/MarkdownRenderer.test.tsx index 5dd664e39..15a60f5db 100644 --- a/src/__tests__/renderer/components/MarkdownRenderer.test.tsx +++ b/src/__tests__/renderer/components/MarkdownRenderer.test.tsx @@ -11,6 +11,7 @@ vi.mock('react-syntax-highlighter', () => ({ })); vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); // Mock lucide-react icons @@ -22,6 +23,7 @@ vi.mock('lucide-react', () => ({ const mockTheme = { id: 'test-theme', + mode: 'dark', colors: { bgMain: '#1a1a2e', bgActivity: '#16213e', diff --git a/src/__tests__/renderer/components/QuickActionsModal.test.tsx b/src/__tests__/renderer/components/QuickActionsModal.test.tsx index 3401f0252..5b77da1d0 100644 --- a/src/__tests__/renderer/components/QuickActionsModal.test.tsx +++ b/src/__tests__/renderer/components/QuickActionsModal.test.tsx @@ -1615,4 +1615,79 @@ describe('QuickActionsModal', () => { expect(screen.queryByText('Context: Send to Agent')).not.toBeInTheDocument(); }); }); + + describe('Create Worktree action', () => { + it('shows Create Worktree action for git repo sessions with callback', () => { + const onQuickCreateWorktree = vi.fn(); + const props = createDefaultProps({ + sessions: [createMockSession({ isGitRepo: true })], + onQuickCreateWorktree, + }); + render(); + + expect(screen.getByText('Create Worktree')).toBeInTheDocument(); + }); + + it('calls onQuickCreateWorktree with active session and closes modal', () => { + const onQuickCreateWorktree = vi.fn(); + const session = createMockSession({ isGitRepo: true }); + const props = createDefaultProps({ + sessions: [session], + onQuickCreateWorktree, + }); + render(); + + fireEvent.click(screen.getByText('Create Worktree')); + + expect(onQuickCreateWorktree).toHaveBeenCalledWith(session); + expect(props.setQuickActionOpen).toHaveBeenCalledWith(false); + }); + + it('resolves to parent session when active session is a worktree child', () => { + const onQuickCreateWorktree = vi.fn(); + const parentSession = createMockSession({ + id: 'parent-1', + name: 'Parent', + isGitRepo: true, + }); + const childSession = createMockSession({ + id: 'child-1', + name: 'Child', + isGitRepo: true, + parentSessionId: 'parent-1', + worktreeBranch: 'feature-1', + }); + const props = createDefaultProps({ + sessions: [parentSession, childSession], + activeSessionId: 'child-1', + onQuickCreateWorktree, + }); + render(); + + fireEvent.click(screen.getByText('Create Worktree')); + + // Should resolve to parent, not the child + expect(onQuickCreateWorktree).toHaveBeenCalledWith(parentSession); + }); + + it('does not show Create Worktree when session is not a git repo', () => { + const onQuickCreateWorktree = vi.fn(); + const props = createDefaultProps({ + sessions: [createMockSession({ isGitRepo: false })], + onQuickCreateWorktree, + }); + render(); + + expect(screen.queryByText('Create Worktree')).not.toBeInTheDocument(); + }); + + it('does not show Create Worktree when callback is not provided', () => { + const props = createDefaultProps({ + sessions: [createMockSession({ isGitRepo: true })], + }); + render(); + + expect(screen.queryByText('Create Worktree')).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/__tests__/renderer/components/RightPanel.test.tsx b/src/__tests__/renderer/components/RightPanel.test.tsx index 97b8ece1d..7aa6856d2 100644 --- a/src/__tests__/renderer/components/RightPanel.test.tsx +++ b/src/__tests__/renderer/components/RightPanel.test.tsx @@ -986,6 +986,32 @@ describe('RightPanel', () => { expect(setActiveRightTab).toHaveBeenCalledWith('history'); }); + it('should show "View history" link when on files tab during batch run', () => { + useUIStore.setState({ activeRightTab: 'files' }); + const setActiveRightTab = vi.fn(); + const currentSessionBatchState: BatchRunState = { + isRunning: true, + isStopping: false, + documents: ['doc1'], + currentDocumentIndex: 0, + totalTasks: 10, + completedTasks: 5, + currentDocTasksTotal: 10, + currentDocTasksCompleted: 5, + totalTasksAcrossAllDocs: 10, + completedTasksAcrossAllDocs: 5, + loopEnabled: false, + loopIteration: 0, + }; + const props = createDefaultProps({ currentSessionBatchState, setActiveRightTab }); + render(); + + const link = screen.getByText('View history'); + expect(link).toBeInTheDocument(); + fireEvent.click(link); + expect(setActiveRightTab).toHaveBeenCalledWith('history'); + }); + it('should not show "View history" link when on history tab during batch run', () => { useUIStore.setState({ activeRightTab: 'history' }); const currentSessionBatchState: BatchRunState = { diff --git a/src/__tests__/renderer/components/SessionList.test.tsx b/src/__tests__/renderer/components/SessionList.test.tsx index 91f18ee29..660fb1922 100644 --- a/src/__tests__/renderer/components/SessionList.test.tsx +++ b/src/__tests__/renderer/components/SessionList.test.tsx @@ -2084,10 +2084,10 @@ describe('SessionList', () => { }); // ============================================================================ - // Tunnel/Remote Access Tests + // Tunnel/Remote Control Tests // ============================================================================ - describe('Tunnel and Remote Access', () => { + describe('Tunnel and Remote Control', () => { it('checks cloudflared installation when live overlay opens', async () => { const mockIsInstalled = vi.fn().mockResolvedValue(true); (window.maestro as Record).tunnel = { @@ -2154,12 +2154,12 @@ describe('SessionList', () => { // Wait for cloudflared check to complete await waitFor(() => { - const toggleButton = screen.getByTitle('Enable remote access'); + const toggleButton = screen.getByTitle('Enable remote control'); expect(toggleButton).toBeInTheDocument(); }); // Click the toggle to start tunnel - const toggleButton = screen.getByTitle('Enable remote access'); + const toggleButton = screen.getByTitle('Enable remote control'); fireEvent.click(toggleButton); await waitFor(() => { @@ -2188,19 +2188,19 @@ describe('SessionList', () => { fireEvent.click(screen.getByText('LIVE')); await waitFor(() => { - const toggleButton = screen.getByTitle('Enable remote access'); + const toggleButton = screen.getByTitle('Enable remote control'); expect(toggleButton).toBeInTheDocument(); }); // Start tunnel first - fireEvent.click(screen.getByTitle('Enable remote access')); + fireEvent.click(screen.getByTitle('Enable remote control')); await waitFor(() => { - expect(screen.getByTitle('Disable remote access')).toBeInTheDocument(); + expect(screen.getByTitle('Disable remote control')).toBeInTheDocument(); }); // Now stop tunnel - fireEvent.click(screen.getByTitle('Disable remote access')); + fireEvent.click(screen.getByTitle('Disable remote control')); await waitFor(() => { expect(mockStop).toHaveBeenCalled(); @@ -2225,7 +2225,7 @@ describe('SessionList', () => { fireEvent.click(screen.getByText('LIVE')); await waitFor(() => { - const toggleButton = screen.getByTitle('Enable remote access'); + const toggleButton = screen.getByTitle('Enable remote control'); fireEvent.click(toggleButton); }); @@ -2252,7 +2252,7 @@ describe('SessionList', () => { fireEvent.click(screen.getByText('LIVE')); await waitFor(() => { - const toggleButton = screen.getByTitle('Enable remote access'); + const toggleButton = screen.getByTitle('Enable remote control'); fireEvent.click(toggleButton); }); @@ -2281,7 +2281,7 @@ describe('SessionList', () => { fireEvent.click(screen.getByText('LIVE')); await waitFor(() => { - fireEvent.click(screen.getByTitle('Enable remote access')); + fireEvent.click(screen.getByTitle('Enable remote control')); }); await waitFor(() => { @@ -2310,7 +2310,7 @@ describe('SessionList', () => { fireEvent.click(screen.getByText('LIVE')); await waitFor(() => { - fireEvent.click(screen.getByTitle('Enable remote access')); + fireEvent.click(screen.getByTitle('Enable remote control')); }); await waitFor(() => { @@ -2347,7 +2347,7 @@ describe('SessionList', () => { fireEvent.click(screen.getByText('LIVE')); await waitFor(() => { - fireEvent.click(screen.getByTitle('Enable remote access')); + fireEvent.click(screen.getByTitle('Enable remote control')); }); await waitFor(() => { @@ -2381,7 +2381,7 @@ describe('SessionList', () => { fireEvent.click(screen.getByText('LIVE')); await waitFor(() => { - fireEvent.click(screen.getByTitle('Enable remote access')); + fireEvent.click(screen.getByTitle('Enable remote control')); }); await waitFor(() => { @@ -3059,7 +3059,7 @@ describe('SessionList', () => { fireEvent.click(screen.getByText('LIVE')); await waitFor(() => { - fireEvent.click(screen.getByTitle('Enable remote access')); + fireEvent.click(screen.getByTitle('Enable remote control')); }); await waitFor(() => { diff --git a/src/__tests__/renderer/components/SessionList/LiveOverlayPanel.test.tsx b/src/__tests__/renderer/components/SessionList/LiveOverlayPanel.test.tsx index f0a181a08..a13b0b111 100644 --- a/src/__tests__/renderer/components/SessionList/LiveOverlayPanel.test.tsx +++ b/src/__tests__/renderer/components/SessionList/LiveOverlayPanel.test.tsx @@ -125,17 +125,17 @@ describe('LiveOverlayPanel', () => { // ----------------------------------------------------------------------- // Remote Access // ----------------------------------------------------------------------- - describe('remote access', () => { - it('renders Remote Access section', () => { + describe('remote control', () => { + it('renders Remote Control section', () => { render(); - expect(screen.getByText('Remote Access')).toBeTruthy(); + expect(screen.getByText('Remote Control')).toBeTruthy(); }); it('calls handleTunnelToggle when toggle button is clicked', () => { const handleTunnelToggle = vi.fn(); render(); - const toggleBtn = screen.getByTitle('Enable remote access'); + const toggleBtn = screen.getByTitle('Enable remote control'); fireEvent.click(toggleBtn); expect(handleTunnelToggle).toHaveBeenCalledOnce(); }); @@ -155,7 +155,7 @@ describe('LiveOverlayPanel', () => { it('disables toggle when tunnel is starting', () => { render(); - const toggleBtn = screen.getByTitle('Enable remote access'); + const toggleBtn = screen.getByTitle('Enable remote control'); expect(toggleBtn).toBeDisabled(); }); @@ -180,7 +180,7 @@ describe('LiveOverlayPanel', () => { it('shows disconnect title when tunnel is connected', () => { render(); - expect(screen.getByTitle('Disable remote access')).toBeTruthy(); + expect(screen.getByTitle('Disable remote control')).toBeTruthy(); }); }); diff --git a/src/__tests__/renderer/components/SessionList/SidebarActions.test.tsx b/src/__tests__/renderer/components/SessionList/SidebarActions.test.tsx index 4c7d74095..709d3665b 100644 --- a/src/__tests__/renderer/components/SessionList/SidebarActions.test.tsx +++ b/src/__tests__/renderer/components/SessionList/SidebarActions.test.tsx @@ -22,6 +22,7 @@ const mockTheme: Theme = { const defaultShortcuts = { toggleSidebar: { keys: ['Cmd', 'B'], label: 'Toggle Sidebar' }, + filterUnreadAgents: { keys: ['Meta', 'Shift', 'u'], label: 'Filter Unread Agents' }, } as any; function createProps(overrides: Partial[0]> = {}) { @@ -30,9 +31,12 @@ function createProps(overrides: Partial[0]> = leftSidebarOpen: true, hasNoSessions: false, shortcuts: defaultShortcuts, + showUnreadAgentsOnly: false, + hasUnreadAgents: false, addNewSession: vi.fn(), openWizard: vi.fn(), setLeftSidebarOpen: vi.fn(), + toggleShowUnreadAgentsOnly: vi.fn(), ...overrides, }; } @@ -110,4 +114,22 @@ describe('SidebarActions', () => { fireEvent.click(expandBtn); expect(setLeftSidebarOpen).toHaveBeenCalledWith(true); }); + + it('renders unread agents filter button', () => { + render(); + expect(screen.getByTitle(/Filter unread agents/)).toBeTruthy(); + }); + + it('calls toggleShowUnreadAgentsOnly when unread filter button is clicked', () => { + const toggleShowUnreadAgentsOnly = vi.fn(); + render(); + + fireEvent.click(screen.getByTitle(/Filter unread agents/)); + expect(toggleShowUnreadAgentsOnly).toHaveBeenCalledOnce(); + }); + + it('shows active state when showUnreadAgentsOnly is true', () => { + render(); + expect(screen.getByTitle(/Showing unread agents only/)).toBeTruthy(); + }); }); diff --git a/src/__tests__/renderer/components/TerminalOutput.test.tsx b/src/__tests__/renderer/components/TerminalOutput.test.tsx index da3a49757..e6ebfeaba 100644 --- a/src/__tests__/renderer/components/TerminalOutput.test.tsx +++ b/src/__tests__/renderer/components/TerminalOutput.test.tsx @@ -26,6 +26,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); vi.mock('react-markdown', () => ({ diff --git a/src/__tests__/renderer/components/Wizard/WizardIntegration.test.tsx b/src/__tests__/renderer/components/Wizard/WizardIntegration.test.tsx index 7c7cf9718..be1fdb93d 100644 --- a/src/__tests__/renderer/components/Wizard/WizardIntegration.test.tsx +++ b/src/__tests__/renderer/components/Wizard/WizardIntegration.test.tsx @@ -128,6 +128,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); // Mock remark-gfm diff --git a/src/__tests__/renderer/components/Wizard/WizardKeyboardNavigation.test.tsx b/src/__tests__/renderer/components/Wizard/WizardKeyboardNavigation.test.tsx index 62805d53b..04c46aa95 100644 --- a/src/__tests__/renderer/components/Wizard/WizardKeyboardNavigation.test.tsx +++ b/src/__tests__/renderer/components/Wizard/WizardKeyboardNavigation.test.tsx @@ -103,6 +103,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); // Mock remark-gfm diff --git a/src/__tests__/renderer/components/Wizard/WizardThemeStyles.test.tsx b/src/__tests__/renderer/components/Wizard/WizardThemeStyles.test.tsx index ec1ebee2d..53ea84c8c 100644 --- a/src/__tests__/renderer/components/Wizard/WizardThemeStyles.test.tsx +++ b/src/__tests__/renderer/components/Wizard/WizardThemeStyles.test.tsx @@ -125,6 +125,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); // Mock remark-gfm diff --git a/src/__tests__/renderer/components/auto-scroll.test.tsx b/src/__tests__/renderer/components/auto-scroll.test.tsx index 5f1525942..83d597d18 100644 --- a/src/__tests__/renderer/components/auto-scroll.test.tsx +++ b/src/__tests__/renderer/components/auto-scroll.test.tsx @@ -26,6 +26,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); vi.mock('react-markdown', () => ({ diff --git a/src/__tests__/renderer/hooks/useFileExplorerEffects.test.ts b/src/__tests__/renderer/hooks/useFileExplorerEffects.test.ts index ba28108bf..41f3ebcb1 100644 --- a/src/__tests__/renderer/hooks/useFileExplorerEffects.test.ts +++ b/src/__tests__/renderer/hooks/useFileExplorerEffects.test.ts @@ -423,6 +423,7 @@ describe('useFileExplorerEffects', () => { renderHook(() => useFileExplorerEffects(deps)); expect(flattenTree).toHaveBeenCalledWith(tree, new Set(['src'])); + expect(useFileExplorerStore.getState().filteredFileTree).toEqual(tree); expect(useFileExplorerStore.getState().flatFileList).toEqual(flatResult); }); @@ -437,6 +438,7 @@ describe('useFileExplorerEffects', () => { const deps = createDeps(); renderHook(() => useFileExplorerEffects(deps)); + expect(useFileExplorerStore.getState().filteredFileTree).toEqual([]); expect(useFileExplorerStore.getState().flatFileList).toEqual([]); }); diff --git a/src/__tests__/renderer/hooks/useInputHandlers.test.ts b/src/__tests__/renderer/hooks/useInputHandlers.test.ts index 20f1aab48..2b17f35fc 100644 --- a/src/__tests__/renderer/hooks/useInputHandlers.test.ts +++ b/src/__tests__/renderer/hooks/useInputHandlers.test.ts @@ -1635,6 +1635,38 @@ describe('useInputHandlers', () => { const tab = sessions[0].aiTabs.find((t: any) => t.id === 'tab-1'); expect(tab?.stagedImages).toEqual(['existing.png']); }); + + it('preserves draft input value after replay sends', () => { + const { result } = renderHook(() => useInputHandlers(createMockDeps())); + + // Type a draft message + act(() => { + result.current.setInputValue('my draft message'); + }); + + expect(result.current.inputValue).toBe('my draft message'); + + // Simulate processInput clearing the input (as it does in real usage) + mockProcessInput.mockImplementation(() => { + result.current.setInputValue(''); + }); + + // Replay a previous message + act(() => { + result.current.handleReplayMessage('replayed message'); + }); + + act(() => { + vi.runAllTimers(); + }); + + // Draft should be restored after replay + expect(result.current.inputValue).toBe('my draft message'); + expect(mockProcessInput).toHaveBeenCalledWith('replayed message'); + + // Clean up mock + mockProcessInput.mockReset(); + }); }); // ======================================================================== diff --git a/src/__tests__/renderer/hooks/useMainKeyboardHandler.test.ts b/src/__tests__/renderer/hooks/useMainKeyboardHandler.test.ts index f6b04e8f2..98ee131e7 100644 --- a/src/__tests__/renderer/hooks/useMainKeyboardHandler.test.ts +++ b/src/__tests__/renderer/hooks/useMainKeyboardHandler.test.ts @@ -1,6 +1,7 @@ import { renderHook, act } from '@testing-library/react'; import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { useMainKeyboardHandler } from '../../../renderer/hooks'; +import { useSettingsStore } from '../../../renderer/stores/settingsStore'; /** * Creates a minimal mock context with all required handler functions. @@ -1393,60 +1394,19 @@ describe('useMainKeyboardHandler', () => { }); }); - describe('Cmd+0 (jump to last tab)', () => { - it('should jump to last tab in unified order', () => { + describe('Cmd+0 (font size reset takes priority over goToLastTab)', () => { + it('should reset font size instead of jumping to last tab', () => { const { result } = renderHook(() => useMainKeyboardHandler()); - const mockSession = { - id: 'session-1', - aiTabs: [{ id: 'ai-tab-1', name: 'AI Tab 1', logs: [] }], - activeTabId: 'ai-tab-1', - filePreviewTabs: [ - { id: 'file-tab-2', path: '/test/file2.ts', name: 'file2', extension: '.ts' }, - ], - activeFileTabId: null, - unifiedTabOrder: ['ai-tab-1', 'file-tab-2'], - inputMode: 'ai', - }; - const mockNavigateToLastUnifiedTab = vi.fn().mockReturnValue({ - session: { ...mockSession, activeFileTabId: 'file-tab-2' }, - }); - const mockSetSessions = vi.fn((updater: unknown) => { - if (typeof updater === 'function') { - (updater as (prev: unknown[]) => unknown[])([mockSession]); - } - }); - - result.current.keyboardHandlerRef.current = createUnifiedTabContext({ - isTabShortcut: (_e: KeyboardEvent, actionId: string) => actionId === 'goToLastTab', - navigateToLastUnifiedTab: mockNavigateToLastUnifiedTab, - setSessions: mockSetSessions, - activeSession: mockSession, - }); - - act(() => { - window.dispatchEvent( - new KeyboardEvent('keydown', { - key: '0', - metaKey: true, - bubbles: true, - }) - ); - }); - - expect(mockNavigateToLastUnifiedTab).toHaveBeenCalledWith(mockSession); - expect(mockSetSessions).toHaveBeenCalled(); - }); - - it('should not execute when showUnreadOnly is active', () => { - const { result } = renderHook(() => useMainKeyboardHandler()); + // Set font size to non-default + useSettingsStore.setState({ fontSize: 20 }); const mockNavigateToLastUnifiedTab = vi.fn(); result.current.keyboardHandlerRef.current = createUnifiedTabContext({ isTabShortcut: (_e: KeyboardEvent, actionId: string) => actionId === 'goToLastTab', navigateToLastUnifiedTab: mockNavigateToLastUnifiedTab, - showUnreadOnly: true, + recordShortcutUsage: vi.fn().mockReturnValue({ newLevel: null }), }); act(() => { @@ -1459,8 +1419,9 @@ describe('useMainKeyboardHandler', () => { ); }); - // Should NOT be called when showUnreadOnly is active + // Font size reset takes priority - goToLastTab should NOT fire expect(mockNavigateToLastUnifiedTab).not.toHaveBeenCalled(); + expect(useSettingsStore.getState().fontSize).toBe(14); }); }); @@ -1791,4 +1752,188 @@ describe('useMainKeyboardHandler', () => { expect(mockSetChatRawTextMode).toHaveBeenCalledWith(false); }); }); + + describe('font size shortcuts', () => { + beforeEach(() => { + // Reset font size to default before each test + useSettingsStore.setState({ fontSize: 14 }); + }); + + it('should increase font size with Cmd+=', () => { + const { result } = renderHook(() => useMainKeyboardHandler()); + + result.current.keyboardHandlerRef.current = createMockContext({ + recordShortcutUsage: vi.fn().mockReturnValue({ newLevel: null }), + }); + + const event = new KeyboardEvent('keydown', { + key: '=', + metaKey: true, + bubbles: true, + }); + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + + act(() => { + window.dispatchEvent(event); + }); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(useSettingsStore.getState().fontSize).toBe(16); + }); + + it('should increase font size with Cmd++', () => { + const { result } = renderHook(() => useMainKeyboardHandler()); + + result.current.keyboardHandlerRef.current = createMockContext({ + recordShortcutUsage: vi.fn().mockReturnValue({ newLevel: null }), + }); + + act(() => { + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: '+', + metaKey: true, + bubbles: true, + }) + ); + }); + + expect(useSettingsStore.getState().fontSize).toBe(16); + }); + + it('should decrease font size with Cmd+-', () => { + const { result } = renderHook(() => useMainKeyboardHandler()); + + result.current.keyboardHandlerRef.current = createMockContext({ + recordShortcutUsage: vi.fn().mockReturnValue({ newLevel: null }), + }); + + const event = new KeyboardEvent('keydown', { + key: '-', + metaKey: true, + bubbles: true, + }); + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + + act(() => { + window.dispatchEvent(event); + }); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(useSettingsStore.getState().fontSize).toBe(12); + }); + + it('should reset font size to default (14) with Cmd+0', () => { + const { result } = renderHook(() => useMainKeyboardHandler()); + + // Set font size to something other than default + useSettingsStore.setState({ fontSize: 20 }); + + result.current.keyboardHandlerRef.current = createMockContext({ + recordShortcutUsage: vi.fn().mockReturnValue({ newLevel: null }), + }); + + const event = new KeyboardEvent('keydown', { + key: '0', + metaKey: true, + bubbles: true, + }); + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + + act(() => { + window.dispatchEvent(event); + }); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(useSettingsStore.getState().fontSize).toBe(14); + }); + + it('should not exceed maximum font size (24)', () => { + const { result } = renderHook(() => useMainKeyboardHandler()); + + useSettingsStore.setState({ fontSize: 24 }); + + result.current.keyboardHandlerRef.current = createMockContext({ + recordShortcutUsage: vi.fn().mockReturnValue({ newLevel: null }), + }); + + act(() => { + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: '=', + metaKey: true, + bubbles: true, + }) + ); + }); + + expect(useSettingsStore.getState().fontSize).toBe(24); + }); + + it('should not go below minimum font size (10)', () => { + const { result } = renderHook(() => useMainKeyboardHandler()); + + useSettingsStore.setState({ fontSize: 10 }); + + result.current.keyboardHandlerRef.current = createMockContext({ + recordShortcutUsage: vi.fn().mockReturnValue({ newLevel: null }), + }); + + act(() => { + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: '-', + metaKey: true, + bubbles: true, + }) + ); + }); + + expect(useSettingsStore.getState().fontSize).toBe(10); + }); + + it('should work when modal is open (font size is a benign viewing preference)', () => { + const { result } = renderHook(() => useMainKeyboardHandler()); + + result.current.keyboardHandlerRef.current = createMockContext({ + hasOpenLayers: () => true, + hasOpenModal: () => true, + recordShortcutUsage: vi.fn().mockReturnValue({ newLevel: null }), + }); + + act(() => { + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: '=', + metaKey: true, + bubbles: true, + }) + ); + }); + + expect(useSettingsStore.getState().fontSize).toBe(16); + }); + + it('should not trigger with Alt modifier (avoids conflict with session jump)', () => { + const { result } = renderHook(() => useMainKeyboardHandler()); + + result.current.keyboardHandlerRef.current = createMockContext({ + recordShortcutUsage: vi.fn().mockReturnValue({ newLevel: null }), + }); + + act(() => { + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: '=', + metaKey: true, + altKey: true, + bubbles: true, + }) + ); + }); + + // Font size should remain unchanged with Alt held + expect(useSettingsStore.getState().fontSize).toBe(14); + }); + }); }); diff --git a/src/__tests__/renderer/hooks/useSessionCategories.test.ts b/src/__tests__/renderer/hooks/useSessionCategories.test.ts index a6f9162cf..e3d8e301b 100644 --- a/src/__tests__/renderer/hooks/useSessionCategories.test.ts +++ b/src/__tests__/renderer/hooks/useSessionCategories.test.ts @@ -241,6 +241,76 @@ describe('useSessionCategories', () => { }); }); + // ----------------------------------------------------------------------- + // Unread agents filter + // ----------------------------------------------------------------------- + describe('showUnreadAgentsOnly', () => { + it('returns all sessions when showUnreadAgentsOnly is false', () => { + const s1 = makeSession({ name: 'Alpha' }); + const s2 = makeSession({ name: 'Beta' }); + resetStore([s1, s2]); + + const { result } = renderHook(() => useSessionCategories('', [s1, s2], false)); + expect(result.current.sortedFilteredSessions).toHaveLength(2); + }); + + it('filters to only sessions with unread tabs when showUnreadAgentsOnly is true', () => { + const s1 = makeSession({ + name: 'Has Unread', + aiTabs: [{ id: 't1', hasUnread: true } as any], + }); + const s2 = makeSession({ + name: 'No Unread', + aiTabs: [{ id: 't2', hasUnread: false } as any], + }); + const s3 = makeSession({ name: 'No Tabs' }); + resetStore([s1, s2, s3]); + + const { result } = renderHook(() => useSessionCategories('', [s1, s2, s3], true)); + + expect(result.current.sortedFilteredSessions).toHaveLength(1); + expect(result.current.sortedFilteredSessions[0].name).toBe('Has Unread'); + }); + + it('includes busy agents even without unread tabs when showUnreadAgentsOnly is true', () => { + const s1 = makeSession({ + name: 'Has Unread', + aiTabs: [{ id: 't1', hasUnread: true } as any], + }); + const s2 = makeSession({ + name: 'Busy Agent', + state: 'busy', + aiTabs: [{ id: 't2', hasUnread: false } as any], + }); + const s3 = makeSession({ name: 'Idle No Unread' }); + resetStore([s1, s2, s3]); + + const { result } = renderHook(() => useSessionCategories('', [s1, s2, s3], true)); + + expect(result.current.sortedFilteredSessions).toHaveLength(2); + const names = result.current.sortedFilteredSessions.map((s) => s.name); + expect(names).toContain('Has Unread'); + expect(names).toContain('Busy Agent'); + }); + + it('combines unread filter with text filter', () => { + const s1 = makeSession({ + name: 'Frontend', + aiTabs: [{ id: 't1', hasUnread: true } as any], + }); + const s2 = makeSession({ + name: 'Backend', + aiTabs: [{ id: 't2', hasUnread: true } as any], + }); + resetStore([s1, s2]); + + const { result } = renderHook(() => useSessionCategories('front', [s1, s2], true)); + + expect(result.current.sortedFilteredSessions).toHaveLength(1); + expect(result.current.sortedFilteredSessions[0].name).toBe('Frontend'); + }); + }); + // ----------------------------------------------------------------------- // Categorization: bookmarked // ----------------------------------------------------------------------- diff --git a/src/__tests__/renderer/hooks/useWorktreeHandlers.test.ts b/src/__tests__/renderer/hooks/useWorktreeHandlers.test.ts index cc3c552e0..d3d0226c3 100644 --- a/src/__tests__/renderer/hooks/useWorktreeHandlers.test.ts +++ b/src/__tests__/renderer/hooks/useWorktreeHandlers.test.ts @@ -578,6 +578,24 @@ describe('handleCreateWorktreeFromConfig', () => { expect(parent?.worktreesExpanded).toBe(true); }); + it('auto-focuses the new worktree session after creation', async () => { + useSessionStore.setState({ + sessions: [mockParentSession], + activeSessionId: 'parent-1', + } as any); + + const { result } = renderHook(() => useWorktreeHandlers()); + + await act(async () => { + await result.current.handleCreateWorktreeFromConfig('feature-new', '/projects/worktrees'); + }); + + const sessions = useSessionStore.getState().sessions; + const newSession = sessions.find((s) => s.worktreeBranch === 'feature-new'); + expect(newSession).toBeDefined(); + expect(useSessionStore.getState().activeSessionId).toBe(newSession!.id); + }); + it('shows error toast on IPC failure and re-throws error', async () => { useSessionStore.setState({ sessions: [mockParentSession], @@ -674,6 +692,21 @@ describe('handleCreateWorktree', () => { expect(sessions.some((s) => s.worktreeBranch === 'new-branch')).toBe(true); }); + it('auto-focuses the new worktree session after creation', async () => { + getModalActions().setCreateWorktreeSession(mockParentSession); + + const { result } = renderHook(() => useWorktreeHandlers()); + + await act(async () => { + await result.current.handleCreateWorktree('new-branch'); + }); + + const sessions = useSessionStore.getState().sessions; + const newSession = sessions.find((s) => s.worktreeBranch === 'new-branch'); + expect(newSession).toBeDefined(); + expect(useSessionStore.getState().activeSessionId).toBe(newSession!.id); + }); + it('uses default basePath (parent cwd + /worktrees) when no worktreeConfig', async () => { const sessionNoConfig = { ...mockParentSession, diff --git a/src/__tests__/renderer/stores/uiStore.test.ts b/src/__tests__/renderer/stores/uiStore.test.ts index a41f80f8b..e65d70baf 100644 --- a/src/__tests__/renderer/stores/uiStore.test.ts +++ b/src/__tests__/renderer/stores/uiStore.test.ts @@ -15,6 +15,7 @@ function resetStore() { bookmarksCollapsed: false, groupChatsExpanded: true, showUnreadOnly: false, + showUnreadAgentsOnly: false, preFilterActiveTabId: null, preTerminalFileTabId: null, selectedSidebarIndex: 0, @@ -166,6 +167,19 @@ describe('uiStore', () => { expect(useUIStore.getState().showUnreadOnly).toBe(false); }); + it('sets show unread agents only', () => { + useUIStore.getState().setShowUnreadAgentsOnly(true); + expect(useUIStore.getState().showUnreadAgentsOnly).toBe(true); + }); + + it('toggles show unread agents only', () => { + expect(useUIStore.getState().showUnreadAgentsOnly).toBe(false); + useUIStore.getState().toggleShowUnreadAgentsOnly(); + expect(useUIStore.getState().showUnreadAgentsOnly).toBe(true); + useUIStore.getState().toggleShowUnreadAgentsOnly(); + expect(useUIStore.getState().showUnreadAgentsOnly).toBe(false); + }); + it('sets pre-filter active tab id', () => { useUIStore.getState().setPreFilterActiveTabId('tab-123'); expect(useUIStore.getState().preFilterActiveTabId).toBe('tab-123'); diff --git a/src/__tests__/renderer/utils/fileExplorer.test.ts b/src/__tests__/renderer/utils/fileExplorer.test.ts index 17d54db1e..e64acf8c1 100644 --- a/src/__tests__/renderer/utils/fileExplorer.test.ts +++ b/src/__tests__/renderer/utils/fileExplorer.test.ts @@ -271,6 +271,26 @@ describe('fileExplorer utils', () => { expect(result[0].name).toBe('main.py'); }); + it('always shows .maestro folder even when it matches ignore patterns', async () => { + vi.mocked(window.maestro.fs.readDir) + .mockResolvedValueOnce([ + { name: '.maestro', isFile: false, isDirectory: true }, + { name: 'node_modules', isFile: false, isDirectory: true }, + { name: 'src', isFile: false, isDirectory: true }, + ]) + .mockResolvedValue([]); // Empty for folder recursion + + // Use ignore patterns that would match .maestro (e.g., dotfile glob) + const result = await loadFileTree('/project', 10, 0, undefined, undefined, { + ignorePatterns: ['node_modules', '.*'], + }); + + // .maestro should be present, node_modules should be filtered + expect(result.find((n) => n.name === '.maestro')).toBeDefined(); + expect(result.find((n) => n.name === 'node_modules')).toBeUndefined(); + expect(result.find((n) => n.name === 'src')).toBeDefined(); + }); + it('sorts folders before files', async () => { vi.mocked(window.maestro.fs.readDir) .mockResolvedValueOnce([ diff --git a/src/__tests__/renderer/utils/markdownConfig.test.ts b/src/__tests__/renderer/utils/markdownConfig.test.ts index 54a68ec98..6950ada4b 100644 --- a/src/__tests__/renderer/utils/markdownConfig.test.ts +++ b/src/__tests__/renderer/utils/markdownConfig.test.ts @@ -6,6 +6,7 @@ vi.mock('react-syntax-highlighter', () => ({ })); vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, + vs: {}, })); import { diff --git a/src/__tests__/renderer/utils/sessionValidation.test.ts b/src/__tests__/renderer/utils/sessionValidation.test.ts index c2aafa2f4..383a42e6b 100644 --- a/src/__tests__/renderer/utils/sessionValidation.test.ts +++ b/src/__tests__/renderer/utils/sessionValidation.test.ts @@ -400,6 +400,172 @@ describe('sessionValidation', () => { }); }); + describe('SSH host awareness', () => { + it('does not warn when same directory but different SSH hosts', () => { + const existingSessions = [ + createMockSession({ + name: 'SSH Agent', + projectRoot: '/home/user/project', + toolType: 'claude-code', + sshRemoteId: 'ssh-remote-1', + }), + ]; + const result = validateNewSession( + 'Local Agent', + '/home/user/project', + 'claude-code', + existingSessions, + 'ssh-remote-2' + ); + expect(result.valid).toBe(true); + expect(result.warning).toBeUndefined(); + }); + + it('does not warn when same directory but one is local and one is SSH', () => { + const existingSessions = [ + createMockSession({ + name: 'SSH Agent', + projectRoot: '/Users/test/project', + toolType: 'claude-code', + sshRemoteId: 'ssh-remote-1', + }), + ]; + // New agent is local (no sshRemoteId) + const result = validateNewSession( + 'Local Agent', + '/Users/test/project', + 'claude-code', + existingSessions + ); + expect(result.valid).toBe(true); + expect(result.warning).toBeUndefined(); + }); + + it('does not warn when local agent exists and new agent is SSH', () => { + const existingSessions = [ + createMockSession({ + name: 'Local Agent', + projectRoot: '/Users/test/project', + toolType: 'claude-code', + }), + ]; + const result = validateNewSession( + 'SSH Agent', + '/Users/test/project', + 'claude-code', + existingSessions, + 'ssh-remote-1' + ); + expect(result.valid).toBe(true); + expect(result.warning).toBeUndefined(); + }); + + it('warns when same directory and same SSH host', () => { + const existingSessions = [ + createMockSession({ + name: 'SSH Agent 1', + projectRoot: '/home/user/project', + toolType: 'claude-code', + sshRemoteId: 'ssh-remote-1', + }), + ]; + const result = validateNewSession( + 'SSH Agent 2', + '/home/user/project', + 'claude-code', + existingSessions, + 'ssh-remote-1' + ); + expect(result.valid).toBe(true); + expect(result.warning).toBeDefined(); + expect(result.conflictingAgents).toEqual(['SSH Agent 1']); + }); + + it('warns when same directory and both are local', () => { + const existingSessions = [ + createMockSession({ + name: 'Local Agent 1', + projectRoot: '/Users/test/project', + toolType: 'claude-code', + }), + ]; + const result = validateNewSession( + 'Local Agent 2', + '/Users/test/project', + 'claude-code', + existingSessions + ); + expect(result.valid).toBe(true); + expect(result.warning).toBeDefined(); + expect(result.conflictingAgents).toEqual(['Local Agent 1']); + }); + + it('resolves SSH remote from sessionSshRemoteConfig (canonical source)', () => { + const existingSessions = [ + createMockSession({ + name: 'SSH Agent', + projectRoot: '/home/user/project', + toolType: 'claude-code', + sessionSshRemoteConfig: { enabled: true, remoteId: 'ssh-remote-1' }, + }), + ]; + // Same SSH remote should warn + const sameResult = validateNewSession( + 'SSH Agent 2', + '/home/user/project', + 'claude-code', + existingSessions, + 'ssh-remote-1' + ); + expect(sameResult.valid).toBe(true); + expect(sameResult.warning).toBeDefined(); + expect(sameResult.conflictingAgents).toEqual(['SSH Agent']); + + // Different SSH remote should not warn + const diffResult = validateNewSession( + 'SSH Agent 3', + '/home/user/project', + 'claude-code', + existingSessions, + 'ssh-remote-2' + ); + expect(diffResult.valid).toBe(true); + expect(diffResult.warning).toBeUndefined(); + + // Local agent should not warn + const localResult = validateNewSession( + 'Local Agent', + '/home/user/project', + 'claude-code', + existingSessions + ); + expect(localResult.valid).toBe(true); + expect(localResult.warning).toBeUndefined(); + }); + + it('uses sshRemote.id as fallback when sshRemoteId is not set', () => { + const existingSessions = [ + createMockSession({ + name: 'SSH Agent', + projectRoot: '/home/user/project', + toolType: 'claude-code', + sshRemote: { id: 'ssh-remote-1', name: 'My Server', host: 'server.example.com' }, + }), + ]; + // Same SSH remote should warn + const result = validateNewSession( + 'SSH Agent 2', + '/home/user/project', + 'claude-code', + existingSessions, + 'ssh-remote-1' + ); + expect(result.valid).toBe(true); + expect(result.warning).toBeDefined(); + expect(result.conflictingAgents).toEqual(['SSH Agent']); + }); + }); + describe('edge cases', () => { it('handles empty session list', () => { const result = validateNewSession('Any Name', '/any/path', 'claude-code', []); diff --git a/src/__tests__/web/mobile/ResponseViewer.test.tsx b/src/__tests__/web/mobile/ResponseViewer.test.tsx index 76df6902b..8e11efd96 100644 --- a/src/__tests__/web/mobile/ResponseViewer.test.tsx +++ b/src/__tests__/web/mobile/ResponseViewer.test.tsx @@ -70,6 +70,7 @@ vi.mock('react-syntax-highlighter', () => ({ vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, vs: {}, + vs: {}, })); import ResponseViewer, { diff --git a/src/cli/commands/send.ts b/src/cli/commands/send.ts index 0180538dd..5a8b2c12b 100644 --- a/src/cli/commands/send.ts +++ b/src/cli/commands/send.ts @@ -8,6 +8,7 @@ import type { ToolType } from '../../shared/types'; interface SendOptions { session?: string; + readOnly?: boolean; } interface SendResponse { @@ -119,7 +120,9 @@ export async function send( } // Spawn agent — spawnAgent handles --resume vs --session-id internally - const result = await spawnAgent(agent.toolType, agent.cwd, message, options.session); + const result = await spawnAgent(agent.toolType, agent.cwd, message, options.session, { + readOnlyMode: options.readOnly, + }); const response = buildResponse(agentId, agent.name, result, agent.toolType); console.log(JSON.stringify(response, null, 2)); diff --git a/src/cli/index.ts b/src/cli/index.ts index 95c99bdce..685687b74 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -107,6 +107,7 @@ program .command('send ') .description('Send a message to an agent and get a JSON response') .option('-s, --session ', 'Resume an existing agent session (for multi-turn conversations)') + .option('-r, --read-only', 'Run in read-only/plan mode (agent cannot modify files)') .action(send); program.parse(); diff --git a/src/cli/services/agent-spawner.ts b/src/cli/services/agent-spawner.ts index 893f6a239..19654bb21 100644 --- a/src/cli/services/agent-spawner.ts +++ b/src/cli/services/agent-spawner.ts @@ -9,6 +9,7 @@ import { aggregateModelUsage } from '../../main/parsers/usage-aggregator'; import { getAgentCustomPath } from './storage'; import { generateUUID } from '../../shared/uuid'; import { buildExpandedPath, buildExpandedEnv } from '../../shared/pathUtils'; +import { getAgentDefinition } from '../../main/agents/definitions'; // Claude Code default command and arguments (same as Electron app) const CLAUDE_DEFAULT_COMMAND = 'claude'; @@ -223,7 +224,8 @@ export function getCodexCommand(): string { async function spawnClaudeAgent( cwd: string, prompt: string, - agentSessionId?: string + agentSessionId?: string, + readOnlyMode?: boolean ): Promise { return new Promise((resolve) => { // Note: CLI agent spawner doesn't have access to settingsStore with global shell env vars. @@ -231,9 +233,20 @@ async function spawnClaudeAgent( // Global shell env vars are primarily used by the desktop app's process manager. const env = buildExpandedEnv(); - // Build args: base args + session handling + prompt + // Build args: base args + session handling + read-only + prompt const args = [...CLAUDE_ARGS]; + // Apply read-only mode args from centralized agent definitions + if (readOnlyMode) { + const def = getAgentDefinition('claude-code'); + if (def?.readOnlyArgs) { + args.push(...def.readOnlyArgs); + } + if (def?.readOnlyEnvOverrides) { + Object.assign(env, def.readOnlyEnvOverrides); + } + } + if (agentSessionId) { // Resume an existing session (e.g., for synopsis generation) args.push('--resume', agentSessionId); @@ -376,7 +389,8 @@ function mergeUsageStats( async function spawnCodexAgent( cwd: string, prompt: string, - agentSessionId?: string + agentSessionId?: string, + readOnlyMode?: boolean ): Promise { return new Promise((resolve) => { // Note: CLI agent spawner doesn't have access to settingsStore with global shell env vars. @@ -385,6 +399,17 @@ async function spawnCodexAgent( const env = buildExpandedEnv(); const args = [...CODEX_ARGS]; + + // Apply read-only mode args from centralized agent definitions + if (readOnlyMode) { + const def = getAgentDefinition('codex'); + if (def?.readOnlyArgs) { + args.push(...def.readOnlyArgs); + } + if (def?.readOnlyEnvOverrides) { + Object.assign(env, def.readOnlyEnvOverrides); + } + } args.push('-C', cwd); if (agentSessionId) { @@ -472,6 +497,16 @@ async function spawnCodexAgent( }); } +/** + * Options for spawning an agent via CLI + */ +export interface SpawnAgentOptions { + /** Resume an existing agent session */ + agentSessionId?: string; + /** Run in read-only/plan mode (uses centralized agent definitions for provider-specific flags) */ + readOnlyMode?: boolean; +} + /** * Spawn an agent with a prompt and return the result */ @@ -479,14 +514,17 @@ export async function spawnAgent( toolType: ToolType, cwd: string, prompt: string, - agentSessionId?: string + agentSessionId?: string, + options?: SpawnAgentOptions ): Promise { + const readOnly = options?.readOnlyMode; + if (toolType === 'codex') { - return spawnCodexAgent(cwd, prompt, agentSessionId); + return spawnCodexAgent(cwd, prompt, agentSessionId, readOnly); } if (toolType === 'claude-code') { - return spawnClaudeAgent(cwd, prompt, agentSessionId); + return spawnClaudeAgent(cwd, prompt, agentSessionId, readOnly); } return { diff --git a/src/main/ipc/handlers/director-notes.ts b/src/main/ipc/handlers/director-notes.ts index 269cb2d2c..350d7aa05 100644 --- a/src/main/ipc/handlers/director-notes.ts +++ b/src/main/ipc/handlers/director-notes.ts @@ -151,6 +151,7 @@ export function registerDirectorNotesHandlers(deps: DirectorNotesHandlerDependen // Collect all entries within time range (unfiltered by type for stats) const allEntries: UnifiedHistoryEntry[] = []; + const agentsWithEntries = new Set(); // track agents that have qualifying entries const uniqueAgentSessions = new Set(); // track unique provider sessions let autoCount = 0; let userCount = 0; @@ -163,6 +164,7 @@ export function registerDirectorNotesHandlers(deps: DirectorNotesHandlerDependen if (cutoffTime > 0 && entry.timestamp < cutoffTime) continue; // Track stats from all entries (before type filter) + agentsWithEntries.add(sessionId); if (entry.type === 'AUTO') autoCount++; else if (entry.type === 'USER') userCount++; if (entry.agentSessionId) uniqueAgentSessions.add(entry.agentSessionId); @@ -186,7 +188,7 @@ export function registerDirectorNotesHandlers(deps: DirectorNotesHandlerDependen // Build stats from unfiltered data const stats: UnifiedHistoryStats = { - agentCount: sessionIds.length, + agentCount: agentsWithEntries.size, sessionCount: uniqueAgentSessions.size, autoCount, userCount, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 667edde5c..55808f610 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2012,6 +2012,9 @@ function MaestroConsoleInner() { // Auto-scroll AI mode toggle autoScrollAiMode, setAutoScrollAiMode, + + // Unread agents filter toggle + toggleShowUnreadAgentsOnly: useUIStore.getState().toggleShowUnreadAgentsOnly, }; // NOTE: File explorer effects (flat file list, pending jump path, scroll, keyboard nav) are @@ -2586,6 +2589,7 @@ function MaestroConsoleInner() { hasActiveSessionCapability={hasActiveSessionCapability} onOpenMergeSession={handleQuickActionsOpenMergeSession} onOpenSendToAgent={handleQuickActionsOpenSendToAgent} + onQuickCreateWorktree={handleQuickCreateWorktree} onOpenCreatePR={handleQuickActionsOpenCreatePR} onSummarizeAndContinue={handleQuickActionsSummarizeAndContinue} canSummarizeActiveTab={ diff --git a/src/renderer/components/AppModals.tsx b/src/renderer/components/AppModals.tsx index 1d3240bd2..608d27f65 100644 --- a/src/renderer/components/AppModals.tsx +++ b/src/renderer/components/AppModals.tsx @@ -839,6 +839,7 @@ export interface AppUtilityModalsProps { ) => boolean; onOpenMergeSession: () => void; onOpenSendToAgent: () => void; + onQuickCreateWorktree: (session: Session) => void; onOpenCreatePR: (session: Session) => void; onSummarizeAndContinue: () => void; canSummarizeActiveTab: boolean; @@ -1045,6 +1046,7 @@ export const AppUtilityModals = memo(function AppUtilityModals({ hasActiveSessionCapability, onOpenMergeSession, onOpenSendToAgent, + onQuickCreateWorktree, onOpenCreatePR, onSummarizeAndContinue, canSummarizeActiveTab, @@ -1206,6 +1208,7 @@ export const AppUtilityModals = memo(function AppUtilityModals({ hasActiveSessionCapability={hasActiveSessionCapability} onOpenMergeSession={onOpenMergeSession} onOpenSendToAgent={onOpenSendToAgent} + onQuickCreateWorktree={onQuickCreateWorktree} onOpenCreatePR={onOpenCreatePR} onSummarizeAndContinue={onSummarizeAndContinue} canSummarizeActiveTab={canSummarizeActiveTab} @@ -1953,6 +1956,7 @@ export interface AppModalsProps { ) => boolean; onOpenMergeSession: () => void; onOpenSendToAgent: () => void; + onQuickCreateWorktree: (session: Session) => void; onOpenCreatePR: (session: Session) => void; onSummarizeAndContinue: () => void; canSummarizeActiveTab: boolean; @@ -2325,6 +2329,7 @@ export const AppModals = memo(function AppModals(props: AppModalsProps) { hasActiveSessionCapability, onOpenMergeSession, onOpenSendToAgent, + onQuickCreateWorktree, onOpenCreatePR, onSummarizeAndContinue, canSummarizeActiveTab, @@ -2632,6 +2637,7 @@ export const AppModals = memo(function AppModals(props: AppModalsProps) { hasActiveSessionCapability={hasActiveSessionCapability} onOpenMergeSession={onOpenMergeSession} onOpenSendToAgent={onOpenSendToAgent} + onQuickCreateWorktree={onQuickCreateWorktree} onOpenCreatePR={onOpenCreatePR} onSummarizeAndContinue={onSummarizeAndContinue} canSummarizeActiveTab={canSummarizeActiveTab} diff --git a/src/renderer/components/ContextWarningSash.tsx b/src/renderer/components/ContextWarningSash.tsx index 3c713e601..45c0e5e53 100644 --- a/src/renderer/components/ContextWarningSash.tsx +++ b/src/renderer/components/ContextWarningSash.tsx @@ -24,7 +24,7 @@ export interface ContextWarningSashProps { * - Dismiss button that hides the warning until usage increases 10%+ or crosses threshold */ export const ContextWarningSash = memo(function ContextWarningSash({ - theme: _theme, + theme, contextUsage, yellowThreshold, redThreshold, @@ -32,6 +32,7 @@ export const ContextWarningSash = memo(function ContextWarningSash({ onSummarizeClick, tabId, }: ContextWarningSashProps) { + const isLight = theme.mode === 'light'; const tabKey = tabId ?? '__default__'; const [dismissedByTab, setDismissedByTab] = useState< Record @@ -77,18 +78,26 @@ export const ContextWarningSash = memo(function ContextWarningSash({ const isRed = warningLevel === 'red'; - // Color values from spec + // Color values — light mode needs darker text/icon colors for contrast const backgroundColor = isRed - ? 'rgba(239, 68, 68, 0.15)' // red-500 with low opacity - : 'rgba(234, 179, 8, 0.15)'; // yellow-500 with low opacity + ? isLight + ? 'rgba(239, 68, 68, 0.12)' + : 'rgba(239, 68, 68, 0.15)' + : isLight + ? 'rgba(234, 179, 8, 0.12)' + : 'rgba(234, 179, 8, 0.15)'; const borderColor = isRed ? 'rgba(239, 68, 68, 0.5)' : 'rgba(234, 179, 8, 0.5)'; const textColor = isRed - ? '#fca5a5' // red-300 - : '#fde047'; // yellow-300 - - const iconColor = isRed ? '#ef4444' : '#eab308'; + ? isLight + ? '#991b1b' // red-800 + : '#fca5a5' // red-300 + : isLight + ? '#854d0e' // yellow-800 + : '#fde047'; // yellow-300 + + const iconColor = isRed ? (isLight ? '#dc2626' : '#ef4444') : isLight ? '#ca8a04' : '#eab308'; const buttonBgColor = isRed ? '#ef4444' : '#eab308'; return ( diff --git a/src/renderer/components/FilePreview.tsx b/src/renderer/components/FilePreview.tsx index b74f8fb96..9d78cbd5b 100644 --- a/src/renderer/components/FilePreview.tsx +++ b/src/renderer/components/FilePreview.tsx @@ -13,7 +13,7 @@ import rehypeRaw from 'rehype-raw'; import rehypeSlug from 'rehype-slug'; import GithubSlugger from 'github-slugger'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { getSyntaxStyle } from '../utils/syntaxTheme'; import { FileCode, Eye, @@ -933,7 +933,7 @@ export const FilePreview = React.memo( return (
{/* Session name - hidden at narrow widths via CSS container query */} - - {activeSession.name} - + {activeSession.name}
void; // Remote control onToggleRemoteControl?: () => void; + // Worktree creation (from command palette) + onQuickCreateWorktree?: (session: Session) => void; // Worktree PR creation onOpenCreatePR?: (session: Session) => void; // Summarize and continue @@ -187,6 +189,7 @@ export const QuickActionsModal = memo(function QuickActionsModal(props: QuickAct hasActiveSessionCapability, onOpenMergeSession, onOpenSendToAgent, + onQuickCreateWorktree, onOpenCreatePR, onSummarizeAndContinue, canSummarizeActiveTab, @@ -834,6 +837,26 @@ export const QuickActionsModal = memo(function QuickActionsModal(props: QuickAct }, ] : []), + // Create Worktree - for git repos (resolves parent if already in a worktree) + ...(activeSession && activeSession.isGitRepo && onQuickCreateWorktree + ? [ + { + id: 'createWorktree', + label: 'Create Worktree', + subtext: activeSession.parentSessionId + ? `New worktree under ${sessions.find((s) => s.id === activeSession.parentSessionId)?.name || 'parent'}` + : 'Create a new git worktree branch', + action: () => { + // If in a worktree child, resolve to parent session + const targetSession = activeSession.parentSessionId + ? sessions.find((s) => s.id === activeSession.parentSessionId) || activeSession + : activeSession; + onQuickCreateWorktree(targetSession); + setQuickActionOpen(false); + }, + }, + ] + : []), // Create PR - only for worktree child sessions ...(activeSession && activeSession.parentSessionId && diff --git a/src/renderer/components/RightPanel.tsx b/src/renderer/components/RightPanel.tsx index 5ac2d9d4d..d7326a8ce 100644 --- a/src/renderer/components/RightPanel.tsx +++ b/src/renderer/components/RightPanel.tsx @@ -132,7 +132,7 @@ export const RightPanel = memo( const fileTreeFilter = useFileExplorerStore((s) => s.fileTreeFilter); const fileTreeFilterOpen = useFileExplorerStore((s) => s.fileTreeFilterOpen); - const filteredFileTree = useFileExplorerStore((s) => s.flatFileList); + const filteredFileTree = useFileExplorerStore((s) => s.filteredFileTree); const selectedFileIndex = useFileExplorerStore((s) => s.selectedFileIndex); const lastGraphFocusFile = useFileExplorerStore((s) => s.lastGraphFocusFilePath); const setFileTreeFilter = useFileExplorerStore((s) => s.setFileTreeFilter); @@ -715,8 +715,8 @@ export const RightPanel = memo( {currentSessionBatchState.maxLoops ?? 'āˆž'} )} - {/* View history link - only shown on auto-run tab */} - {activeRightTab === 'autorun' && ( + {/* View history link - shown on all tabs except history */} + {activeRightTab !== 'history' && ( )} {/* Global LIVE Toggle */} -
+
{/* Hamburger Menu */} -
+
-
+ {!showUnreadAgentsOnly && ( +
+ +
+ )} ) : groups.length > 0 && ungroupedSessions.length > 0 ? ( /* UNGROUPED FOLDER - Groups exist and there are ungrouped agents */ @@ -1047,22 +1073,24 @@ function SessionListInner(props: SessionListProps) { Ungrouped Agents
- + {!showUnreadAgentsOnly && ( + + )}
{!ungroupedCollapsed ? ( @@ -1098,7 +1126,7 @@ function SessionListInner(props: SessionListProps) {
)} - ) : groups.length > 0 ? ( + ) : groups.length > 0 && !showUnreadAgentsOnly ? ( /* NO UNGROUPED AGENTS - Show drop zone for ungrouping + New Group button */
{/* Drop zone indicator when dragging */} @@ -1181,9 +1209,12 @@ function SessionListInner(props: SessionListProps) { leftSidebarOpen={leftSidebarOpen} hasNoSessions={sessions.length === 0} shortcuts={shortcuts} + showUnreadAgentsOnly={showUnreadAgentsOnly} + hasUnreadAgents={hasUnreadAgents} addNewSession={addNewSession} openWizard={openWizard} setLeftSidebarOpen={setLeftSidebarOpen} + toggleShowUnreadAgentsOnly={toggleShowUnreadAgentsOnly} /> {/* Session Context Menu */} diff --git a/src/renderer/components/SessionList/SidebarActions.tsx b/src/renderer/components/SessionList/SidebarActions.tsx index ccb53c11a..dd3167c5c 100644 --- a/src/renderer/components/SessionList/SidebarActions.tsx +++ b/src/renderer/components/SessionList/SidebarActions.tsx @@ -8,9 +8,12 @@ interface SidebarActionsProps { leftSidebarOpen: boolean; hasNoSessions: boolean; shortcuts: Record; + showUnreadAgentsOnly: boolean; + hasUnreadAgents: boolean; addNewSession: () => void; openWizard?: () => void; setLeftSidebarOpen: (open: boolean) => void; + toggleShowUnreadAgentsOnly: () => void; } export const SidebarActions = memo(function SidebarActions({ @@ -18,9 +21,12 @@ export const SidebarActions = memo(function SidebarActions({ leftSidebarOpen, hasNoSessions, shortcuts, + showUnreadAgentsOnly, + hasUnreadAgents, addNewSession, openWizard, setLeftSidebarOpen, + toggleShowUnreadAgentsOnly, }: SidebarActionsProps) { return (
Wizard )} + + {/* Unread agents filter toggle */} +
); }); diff --git a/src/renderer/components/Wizard/tour/tourSteps.tsx b/src/renderer/components/Wizard/tour/tourSteps.tsx index bedb277a3..8b8c31cb1 100644 --- a/src/renderer/components/Wizard/tour/tourSteps.tsx +++ b/src/renderer/components/Wizard/tour/tourSteps.tsx @@ -148,9 +148,9 @@ export const tourSteps: TourStepConfig[] = [ id: 'remote-control', title: 'Remote Control', description: - 'The LIVE/OFFLINE indicator controls a built-in web interface for remote access. Toggle it on to generate a local URL and QR code—scan it with your phone to control Maestro from the couch, the kitchen, or anywhere on your network.\n\nIf you have Cloudflare Tunnel (cloudflared) installed, one click opens a secure tunnel—no API keys, no login, no configuration. Access Maestro from anywhere, even outside your home network.', + 'The LIVE/OFFLINE indicator controls a built-in web interface for remote control. Toggle it on to generate a local URL and QR code—scan it with your phone to control Maestro from the couch, the kitchen, or anywhere on your network.\n\nIf you have Cloudflare Tunnel (cloudflared) installed, one click opens a secure tunnel—no API keys, no login, no configuration. Access Maestro from anywhere, even outside your home network.', descriptionGeneric: - 'The LIVE/OFFLINE indicator controls a built-in web interface for remote access. Toggle it on to generate a local URL and QR code—scan it with your phone to control Maestro from anywhere on your network.\n\nIf you have Cloudflare Tunnel (cloudflared) installed, one click opens a secure tunnel—no API keys, no login, no configuration. Access Maestro from anywhere, even outside your home network.', + 'The LIVE/OFFLINE indicator controls a built-in web interface for remote control. Toggle it on to generate a local URL and QR code—scan it with your phone to control Maestro from anywhere on your network.\n\nIf you have Cloudflare Tunnel (cloudflared) installed, one click opens a secure tunnel—no API keys, no login, no configuration. Access Maestro from anywhere, even outside your home network.', wide: true, selector: '[data-tour="remote-control"]', position: 'right', diff --git a/src/renderer/constants/shortcuts.ts b/src/renderer/constants/shortcuts.ts index 37f530386..2b035f053 100644 --- a/src/renderer/constants/shortcuts.ts +++ b/src/renderer/constants/shortcuts.ts @@ -78,6 +78,11 @@ export const DEFAULT_SHORTCUTS: Record = { label: "Director's Notes", keys: ['Meta', 'Shift', 'o'], }, + filterUnreadAgents: { + id: 'filterUnreadAgents', + label: 'Filter Unread Agents', + keys: ['Meta', 'Shift', 'u'], + }, }; // Non-editable shortcuts (displayed in help but not configurable) @@ -119,6 +124,21 @@ export const FIXED_SHORTCUTS: Record = { label: 'File Preview: Go Forward', keys: ['Meta', 'ArrowRight'], }, + fontSizeIncrease: { + id: 'fontSizeIncrease', + label: 'Increase Font Size', + keys: ['Meta', '='], + }, + fontSizeDecrease: { + id: 'fontSizeDecrease', + label: 'Decrease Font Size', + keys: ['Meta', '-'], + }, + fontSizeReset: { + id: 'fontSizeReset', + label: 'Reset Font Size', + keys: ['Meta', '0'], + }, }; // Tab navigation shortcuts (AI mode only) @@ -163,7 +183,7 @@ export const TAB_SHORTCUTS: Record = { toggleTabUnread: { id: 'toggleTabUnread', label: 'Toggle Tab Unread', - keys: ['Meta', 'Shift', 'u'], + keys: ['Alt', 'Shift', 'u'], }, goToTab1: { id: 'goToTab1', label: 'Go to Tab 1', keys: ['Meta', '1'] }, goToTab2: { id: 'goToTab2', label: 'Go to Tab 2', keys: ['Meta', '2'] }, diff --git a/src/renderer/hooks/git/useFileExplorerEffects.ts b/src/renderer/hooks/git/useFileExplorerEffects.ts index b4b58e80a..d175cc87a 100644 --- a/src/renderer/hooks/git/useFileExplorerEffects.ts +++ b/src/renderer/hooks/git/useFileExplorerEffects.ts @@ -107,6 +107,10 @@ export function useFileExplorerEffects( () => useFileExplorerStore.getState().setSelectedFileIndex, [] ); + const setFilteredFileTree = useMemo( + () => useFileExplorerStore.getState().setFilteredFileTree, + [] + ); const setFlatFileList = useMemo(() => useFileExplorerStore.getState().setFlatFileList, []); const { hasOpenModal } = useLayerStack(); @@ -199,6 +203,7 @@ export function useFileExplorerEffects( useEffect(() => { if (!activeSession || !activeSession.fileExplorerExpanded) { + setFilteredFileTree([]); setFlatFileList([]); return; } @@ -230,6 +235,7 @@ export function useFileExplorerEffects( } } + setFilteredFileTree(filteredFileTree); setFlatFileList(newFlatList); }, [activeSession?.fileExplorerExpanded, filteredFileTree, showHiddenFiles]); diff --git a/src/renderer/hooks/input/useInputHandlers.ts b/src/renderer/hooks/input/useInputHandlers.ts index f6e04e8b9..b2a63c8ea 100644 --- a/src/renderer/hooks/input/useInputHandlers.ts +++ b/src/renderer/hooks/input/useInputHandlers.ts @@ -459,12 +459,26 @@ export function useInputHandlers(deps: UseInputHandlersDeps): UseInputHandlersRe const handleReplayMessage = useCallback( (text: string, images?: string[]) => { + // Preserve draft input so replay doesn't clobber what the user was typing + const draftInput = aiInputValueLocalRef.current; + const draftImages = activeTab?.stagedImages ? [...activeTab.stagedImages] : []; + if (images && images.length > 0) { setStagedImages(images); } - setTimeout(() => processInputRef.current(text), 0); + setTimeout(() => { + processInputRef.current(text); + // Restore draft input after processInput clears it + if (draftInput) { + setInputValue(draftInput); + syncAiInputToSession(draftInput); + } + if (draftImages.length > 0) { + setStagedImages(draftImages); + } + }, 0); }, - [setStagedImages] + [setStagedImages, setInputValue, syncAiInputToSession, activeTab?.stagedImages] ); const handlePaste = useCallback( diff --git a/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts b/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts index b60ed62a9..3b4f73f63 100644 --- a/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts +++ b/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts @@ -2,6 +2,13 @@ import { useEffect, useRef, useState } from 'react'; import type { Session, AITab, ThinkingMode } from '../../types'; import { getInitialRenameValue } from '../../utils/tabHelpers'; import { useModalStore } from '../../stores/modalStore'; +import { useSettingsStore } from '../../stores/settingsStore'; + +// Font size keyboard shortcut constants +const FONT_SIZE_STEP = 2; +const FONT_SIZE_MIN = 10; +const FONT_SIZE_MAX = 24; +const FONT_SIZE_DEFAULT = 14; /** * Context object passed to the main keyboard handler via ref. @@ -140,22 +147,29 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn { e.altKey && (e.metaKey || e.ctrlKey) && !e.shiftKey && codeKeyLower === 't'; // Allow toggleMode (Cmd+J) to switch to terminal view from file preview const isToggleModeShortcut = ctx.isShortcut(e, 'toggleMode'); + // Allow font size shortcuts (Cmd+=/+, Cmd+-, Cmd+0) even when modals/overlays are open + const isFontSizeShortcut = + (e.metaKey || e.ctrlKey) && + !e.altKey && + !e.shiftKey && + (e.key === '=' || e.key === '+' || e.key === '-' || e.key === '0'); if (ctx.hasOpenModal()) { // TRUE MODAL is open - block most shortcuts from App.tsx // The modal's own handler will handle Cmd+Shift+[] if it supports it // BUT allow layout shortcuts (sidebar toggles), system utility shortcuts, session jump, - // jumpToBottom, and markdown toggle to work (these are benign viewing preferences) + // jumpToBottom, markdown toggle, and font size to work (these are benign viewing preferences) if ( !isLayoutShortcut && !isSystemUtilShortcut && !isSessionJumpShortcut && !isJumpToBottomShortcut && - !isMarkdownToggleShortcut + !isMarkdownToggleShortcut && + !isFontSizeShortcut ) { return; } - // Fall through to handle layout/system utility/session jump/jumpToBottom/markdown toggle shortcuts below + // Fall through to handle layout/system utility/session jump/jumpToBottom/markdown toggle/font size shortcuts below } else { // Only OVERLAYS are open (file tabs, LogViewer, etc.) // Allow Cmd+Shift+[] to fall through to App.tsx handler @@ -172,7 +186,8 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn { !isMarkdownToggleShortcut && !isTabManagementShortcut && !isTabSwitcherShortcut && - !isToggleModeShortcut + !isToggleModeShortcut && + !isFontSizeShortcut ) { return; } @@ -426,6 +441,10 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn { e.preventDefault(); ctx.setDirectorNotesOpen?.(true); trackShortcut('directorNotes'); + } else if (ctx.isShortcut(e, 'filterUnreadAgents')) { + e.preventDefault(); + ctx.toggleShowUnreadAgentsOnly(); + trackShortcut('filterUnreadAgents'); } else if (ctx.isShortcut(e, 'jumpToBottom')) { e.preventDefault(); // Jump to the bottom of the current main panel output (AI logs or terminal output) @@ -482,6 +501,34 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn { } } + // Font size shortcuts: Cmd+= (zoom in), Cmd+- (zoom out), Cmd+0 (reset) + // These take priority over tab shortcuts (Cmd+0 was previously goToLastTab) + if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey) { + if (e.key === '=' || e.key === '+') { + e.preventDefault(); + const { fontSize, setFontSize } = useSettingsStore.getState(); + const newSize = Math.min(fontSize + FONT_SIZE_STEP, FONT_SIZE_MAX); + if (newSize !== fontSize) setFontSize(newSize); + trackShortcut('fontSizeIncrease'); + return; + } + if (e.key === '-') { + e.preventDefault(); + const { fontSize, setFontSize } = useSettingsStore.getState(); + const newSize = Math.max(fontSize - FONT_SIZE_STEP, FONT_SIZE_MIN); + if (newSize !== fontSize) setFontSize(newSize); + trackShortcut('fontSizeDecrease'); + return; + } + if (e.key === '0') { + e.preventDefault(); + const { fontSize, setFontSize } = useSettingsStore.getState(); + if (fontSize !== FONT_SIZE_DEFAULT) setFontSize(FONT_SIZE_DEFAULT); + trackShortcut('fontSizeReset'); + return; + } + } + // Tab shortcuts (AI mode only, requires an explicitly selected session, disabled in group chat view) if ( ctx.activeSessionId && diff --git a/src/renderer/hooks/session/useSessionCategories.ts b/src/renderer/hooks/session/useSessionCategories.ts index 17e98e5b9..0abbf7746 100644 --- a/src/renderer/hooks/session/useSessionCategories.ts +++ b/src/renderer/hooks/session/useSessionCategories.ts @@ -22,7 +22,8 @@ export interface SessionCategories { export function useSessionCategories( sessionFilter: string, - sortedSessions: Session[] + sortedSessions: Session[], + showUnreadAgentsOnly = false ): SessionCategories { const sessions = useSessionStore((s) => s.sessions); const groups = useSessionStore((s) => s.groups); @@ -67,7 +68,7 @@ export function useSessionCategories( // Consolidated session categorization and sorting - computed in a single pass const sessionCategories = useMemo(() => { - // Step 1: Filter sessions based on search query + // Step 1: Filter sessions based on search query and unread filter const query = sessionFilter?.toLowerCase() ?? ''; const filtered: Session[] = []; @@ -75,6 +76,12 @@ export function useSessionCategories( // Exclude worktree children from main list (they appear under parent) if (s.parentSessionId) continue; + // Apply unread agents filter (also keep busy/working agents visible) + if (showUnreadAgentsOnly) { + const hasUnread = s.aiTabs?.some((tab) => tab.hasUnread); + if (!hasUnread && s.state !== 'busy') continue; + } + if (!query) { filtered.push(s); } else { @@ -150,7 +157,7 @@ export function useSessionCategories( sortedUngroupedParent, sortedGrouped, }; - }, [sessionFilter, sessions, worktreeChildrenByParentId]); + }, [sessionFilter, showUnreadAgentsOnly, sessions, worktreeChildrenByParentId]); const sortedGroups = useMemo( () => [...groups].sort((a, b) => compareSessionNames(a.name, b.name)), diff --git a/src/renderer/hooks/session/useSessionCrud.ts b/src/renderer/hooks/session/useSessionCrud.ts index f318b3f9a..e1b3db85f 100644 --- a/src/renderer/hooks/session/useSessionCrud.ts +++ b/src/renderer/hooks/session/useSessionCrud.ts @@ -153,7 +153,8 @@ export function useSessionCrud(deps: UseSessionCrudDeps): UseSessionCrudReturn { name, workingDir, agentId as ToolType, - currentSessions + currentSessions, + sessionSshRemoteConfig?.enabled ? sessionSshRemoteConfig.remoteId : null ); if (!validation.valid) { console.error(`Session validation failed: ${validation.error}`); diff --git a/src/renderer/hooks/worktree/useWorktreeHandlers.ts b/src/renderer/hooks/worktree/useWorktreeHandlers.ts index f94c52d9a..1f3f403e5 100644 --- a/src/renderer/hooks/worktree/useWorktreeHandlers.ts +++ b/src/renderer/hooks/worktree/useWorktreeHandlers.ts @@ -353,6 +353,9 @@ export function useWorktreeHandlers(): WorktreeHandlersReturn { worktreeSession, ]); + // Auto-focus the new worktree session + useSessionStore.getState().setActiveSessionId(worktreeSession.id); + notifyToast({ type: 'success', title: 'Worktree Created', @@ -442,6 +445,9 @@ export function useWorktreeHandlers(): WorktreeHandlersReturn { worktreeSession, ]); + // Auto-focus the new worktree session + useSessionStore.getState().setActiveSessionId(worktreeSession.id); + notifyToast({ type: 'success', title: 'Worktree Created', diff --git a/src/renderer/stores/fileExplorerStore.ts b/src/renderer/stores/fileExplorerStore.ts index dc2cd022a..039542e05 100644 --- a/src/renderer/stores/fileExplorerStore.ts +++ b/src/renderer/stores/fileExplorerStore.ts @@ -13,6 +13,7 @@ import { create } from 'zustand'; import type { FlatTreeNode } from '../utils/fileExplorer'; +import type { FileNode } from '../types/fileTree'; // ============================================================================ // Types @@ -32,6 +33,9 @@ export interface FileExplorerStoreState { // File preview loading indicator (migrated from App.tsx) filePreviewLoading: FilePreviewLoading | null; + // Filtered file tree (tree-structured, for FileExplorerPanel rendering) + filteredFileTree: FileNode[]; + // Flattened file list for keyboard navigation (migrated from App.tsx) flatFileList: FlatTreeNode[]; @@ -50,7 +54,8 @@ export interface FileExplorerStoreActions { // File preview loading setFilePreviewLoading: (loading: FilePreviewLoading | null) => void; - // Flat file list + // File tree data + setFilteredFileTree: (tree: FileNode[]) => void; setFlatFileList: (list: FlatTreeNode[]) => void; // Document Graph @@ -87,6 +92,7 @@ export const useFileExplorerStore = create()((set, get) => ({ fileTreeFilter: '', fileTreeFilterOpen: false, filePreviewLoading: null, + filteredFileTree: [], flatFileList: [], isGraphViewOpen: false, graphFocusFilePath: undefined, @@ -100,6 +106,7 @@ export const useFileExplorerStore = create()((set, get) => ({ setFilePreviewLoading: (loading) => set({ filePreviewLoading: loading }), + setFilteredFileTree: (tree) => set({ filteredFileTree: tree }), setFlatFileList: (list) => set({ flatFileList: list }), focusFileInGraph: (relativePath) => @@ -150,6 +157,7 @@ export function getFileExplorerActions() { setFileTreeFilter: state.setFileTreeFilter, setFileTreeFilterOpen: state.setFileTreeFilterOpen, setFilePreviewLoading: state.setFilePreviewLoading, + setFilteredFileTree: state.setFilteredFileTree, setFlatFileList: state.setFlatFileList, focusFileInGraph: state.focusFileInGraph, openLastDocumentGraph: state.openLastDocumentGraph, diff --git a/src/renderer/stores/uiStore.ts b/src/renderer/stores/uiStore.ts index 40e7aaf9e..e21e52260 100644 --- a/src/renderer/stores/uiStore.ts +++ b/src/renderer/stores/uiStore.ts @@ -28,6 +28,7 @@ export interface UIStoreState { // Session list filter showUnreadOnly: boolean; + showUnreadAgentsOnly: boolean; preFilterActiveTabId: string | null; preTerminalFileTabId: string | null; @@ -79,6 +80,8 @@ export interface UIStoreActions { // Session list filter setShowUnreadOnly: (show: boolean | ((prev: boolean) => boolean)) => void; toggleShowUnreadOnly: () => void; + setShowUnreadAgentsOnly: (show: boolean | ((prev: boolean) => boolean)) => void; + toggleShowUnreadAgentsOnly: () => void; setPreFilterActiveTabId: (id: string | null) => void; setPreTerminalFileTabId: (id: string | null) => void; @@ -130,6 +133,7 @@ export const useUIStore = create()((set) => ({ bookmarksCollapsed: false, groupChatsExpanded: true, showUnreadOnly: false, + showUnreadAgentsOnly: false, preFilterActiveTabId: null, preTerminalFileTabId: null, selectedSidebarIndex: 0, @@ -162,6 +166,9 @@ export const useUIStore = create()((set) => ({ setShowUnreadOnly: (v) => set((s) => ({ showUnreadOnly: resolve(v, s.showUnreadOnly) })), toggleShowUnreadOnly: () => set((s) => ({ showUnreadOnly: !s.showUnreadOnly })), + setShowUnreadAgentsOnly: (v) => + set((s) => ({ showUnreadAgentsOnly: resolve(v, s.showUnreadAgentsOnly) })), + toggleShowUnreadAgentsOnly: () => set((s) => ({ showUnreadAgentsOnly: !s.showUnreadAgentsOnly })), setPreFilterActiveTabId: (id) => set({ preFilterActiveTabId: id }), setPreTerminalFileTabId: (id) => set({ preTerminalFileTabId: id }), diff --git a/src/renderer/utils/fileExplorer.ts b/src/renderer/utils/fileExplorer.ts index e18924d7a..1419ad898 100644 --- a/src/renderer/utils/fileExplorer.ts +++ b/src/renderer/utils/fileExplorer.ts @@ -350,8 +350,8 @@ async function loadFileTreeRecursive( } seen.add(entry.name); - // Skip entries that match ignore patterns - if (shouldIgnore(entry.name, state.ignorePatterns)) { + // Skip entries that match ignore patterns (but always show .maestro) + if (entry.name !== '.maestro' && shouldIgnore(entry.name, state.ignorePatterns)) { continue; } diff --git a/src/renderer/utils/markdownConfig.ts b/src/renderer/utils/markdownConfig.ts index 8823a109e..d4a488a29 100644 --- a/src/renderer/utils/markdownConfig.ts +++ b/src/renderer/utils/markdownConfig.ts @@ -18,7 +18,7 @@ import type { Components } from 'react-markdown'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { getSyntaxStyle } from './syntaxTheme'; import React from 'react'; import type { Theme } from '../types'; @@ -377,7 +377,7 @@ export function createMarkdownComponents(options: MarkdownComponentsOptions): Pa // Standard syntax-highlighted code block return React.createElement(SyntaxHighlighter, { language, - style: vscDarkPlus, + style: getSyntaxStyle(theme.mode), customStyle: { margin: '0.5em 0', padding: '1em', diff --git a/src/renderer/utils/sessionValidation.ts b/src/renderer/utils/sessionValidation.ts index d89858f13..c66263b4b 100644 --- a/src/renderer/utils/sessionValidation.ts +++ b/src/renderer/utils/sessionValidation.ts @@ -16,18 +16,21 @@ export interface SessionValidationResult { * * Rules: * 1. Session names must be unique across all sessions (hard error) - * 2. Home directories (projectRoot) shared with any existing agent produce a warning + * 2. Home directories (projectRoot) shared with any existing agent on the same host produce a warning * - Users can acknowledge the risk and proceed * - Multiple agents in the same directory may clobber each other's work + * - Agents on different hosts (local vs SSH, or different SSH remotes) are not considered conflicting */ export function validateNewSession( name: string, directory: string, _toolType: ToolType, - existingSessions: Session[] + existingSessions: Session[], + sshRemoteId?: string | null ): SessionValidationResult { const trimmedName = name.trim(); const normalizedDir = normalizeDirectory(directory); + const newRemoteId = sshRemoteId || null; // Check for duplicate name (hard error - cannot proceed) const duplicateName = existingSessions.find( @@ -41,10 +44,13 @@ export function validateNewSession( }; } - // Check for duplicate directory with ANY existing agent (warning - user can acknowledge) + // Check for duplicate directory with existing agents on the SAME host (warning - user can acknowledge) + // Agents on different hosts (local vs SSH, or different SSH remotes) are not considered conflicting const conflictingAgents = existingSessions.filter((session) => { const sessionDir = normalizeDirectory(session.projectRoot || session.cwd); - return sessionDir === normalizedDir; + if (sessionDir !== normalizedDir) return false; + const existingRemoteId = getSessionSshRemoteId(session); + return existingRemoteId === newRemoteId; }); if (conflictingAgents.length > 0) { @@ -91,6 +97,19 @@ export function validateEditSession( return { valid: true }; } +/** + * Resolve the SSH remote ID from a session, checking all possible locations. + * Returns null for local sessions. + */ +function getSessionSshRemoteId(session: Session): string | null { + // sessionSshRemoteConfig is the canonical per-session SSH config + if (session.sessionSshRemoteConfig?.enabled && session.sessionSshRemoteConfig.remoteId) { + return session.sessionSshRemoteConfig.remoteId; + } + // Fallback to flattened fields set during session lifecycle + return session.sshRemoteId || session.sshRemote?.id || null; +} + /** * Normalize directory path for comparison. * Removes trailing slashes and resolves common variations. diff --git a/src/renderer/utils/syntaxTheme.ts b/src/renderer/utils/syntaxTheme.ts new file mode 100644 index 000000000..36171c049 --- /dev/null +++ b/src/renderer/utils/syntaxTheme.ts @@ -0,0 +1,16 @@ +/** + * Syntax highlighting theme selection based on app theme mode. + * + * Light themes need a light syntax style (vs), dark/vibe themes use vscDarkPlus. + * This matches the pattern already used in the mobile code. + */ + +import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import type { ThemeMode } from '../../shared/theme-types'; + +/** + * Returns the appropriate syntax highlighter style for the given theme mode. + */ +export function getSyntaxStyle(mode: ThemeMode) { + return mode === 'light' ? vs : vscDarkPlus; +} diff --git a/src/shared/themes.ts b/src/shared/themes.ts index 8bd1455a0..97ee3676d 100644 --- a/src/shared/themes.ts +++ b/src/shared/themes.ts @@ -164,15 +164,15 @@ export const THEMES: Record = { bgSidebar: '#eee8d5', bgActivity: '#e6dfc8', border: '#d3cbb7', - textMain: '#657b83', - textDim: '#93a1a1', - accent: '#2aa198', - accentDim: 'rgba(42, 161, 152, 0.1)', - accentText: '#2aa198', + textMain: '#5f737b', + textDim: '#606969', + accent: '#207c76', + accentDim: 'rgba(32, 124, 118, 0.1)', + accentText: '#207c76', accentForeground: '#fdf6e3', - success: '#859900', - warning: '#b58900', - error: '#dc322f', + success: '#687700', + warning: '#8d6a00', + error: '#d3302d', }, }, 'one-light': { @@ -185,14 +185,14 @@ export const THEMES: Record = { bgActivity: '#dbdbdc', border: '#c8c8c9', textMain: '#383a42', - textDim: '#696c77', + textDim: '#666873', accent: '#a626a4', accentDim: 'rgba(166, 38, 164, 0.1)', - accentText: '#0184bc', + accentText: '#0079ad', accentForeground: '#ffffff', - success: '#50a14f', - warning: '#c18401', - error: '#e45649', + success: '#3f803f', + warning: '#996800', + error: '#c4493e', }, }, 'gruvbox-light': { @@ -205,13 +205,13 @@ export const THEMES: Record = { bgActivity: '#d5c4a1', border: '#bdae93', textMain: '#3c3836', - textDim: '#7c6f64', - accent: '#458588', - accentDim: 'rgba(69, 133, 136, 0.1)', + textDim: '#695d55', + accent: '#3d7578', + accentDim: 'rgba(61, 117, 120, 0.1)', accentText: '#076678', accentForeground: '#fbf1c7', - success: '#98971a', - warning: '#d79921', + success: '#707013', + warning: '#8e6515', error: '#cc241d', }, }, @@ -225,13 +225,13 @@ export const THEMES: Record = { bgActivity: '#dce0e8', border: '#acb0be', textMain: '#4c4f69', - textDim: '#6c6f85', + textDim: '#65667c', accent: '#8839ef', accentDim: 'rgba(136, 57, 239, 0.12)', - accentText: '#ea76cb', + accentText: '#a0508b', accentForeground: '#ffffff', - success: '#40a02b', - warning: '#fe640b', + success: '#317c21', + warning: '#b94908', error: '#d20f39', }, }, @@ -245,14 +245,14 @@ export const THEMES: Record = { bgActivity: '#e7e8e9', border: '#d9d9d9', textMain: '#5c6166', - textDim: '#828c99', - accent: '#55b4d4', - accentDim: 'rgba(85, 180, 212, 0.1)', - accentText: '#399ee6', + textDim: '#686f79', + accent: '#3a7a90', + accentDim: 'rgba(58, 122, 144, 0.1)', + accentText: '#2b77ae', accentForeground: '#1a1a1a', - success: '#86b300', - warning: '#f2ae49', - error: '#f07171', + success: '#5d7c00', + warning: '#946a2c', + error: '#b45555', }, }, // Vibe themes diff --git a/symphony-registry.json b/symphony-registry.json index a42e78528..b70f53c59 100644 --- a/symphony-registry.json +++ b/symphony-registry.json @@ -1,154 +1,111 @@ { - "schemaVersion": "1.0", - "lastUpdated": "2026-02-28T00:00:00Z", - "repositories": [ - { - "slug": "RunMaestro/Maestro", - "name": "Maestro", - "description": "Desktop app for managing multiple AI agents with a keyboard-first interface.", - "url": "https://github.com/RunMaestro/Maestro", - "category": "ai-ml", - "tags": [ - "electron", - "ai", - "productivity", - "typescript" - ], - "maintainer": { - "name": "Pedram Amini", - "url": "https://github.com/pedramamini" - }, - "isActive": true, - "featured": true, - "addedAt": "2025-01-01" - }, - { - "slug": "pedramamini/Podsidian", - "name": "Podsidian", - "description": "An MCP-capable intelligent Apple podcast transcription and summarization to markdown tool.", - "url": "https://github.com/pedramamini/Podsidian", - "category": "productivity", - "tags": [ - "mcp", - "podcasts", - "obsidian", - "markdown", - "transcription", - "python" - ], - "maintainer": { - "name": "Pedram Amini", - "url": "https://github.com/pedramamini" - }, - "isActive": true, - "featured": false, - "addedAt": "2026-02-14" - }, - { - "slug": "pedramamini/RSSidian", - "name": "RSSidian", - "description": "An MCP-capable intelligent RSS feed ingestion and summarization to markdown tool.", - "url": "https://github.com/pedramamini/RSSidian", - "category": "productivity", - "tags": [ - "mcp", - "rss", - "obsidian", - "markdown", - "summarization", - "python" - ], - "maintainer": { - "name": "Pedram Amini", - "url": "https://github.com/pedramamini" - }, - "isActive": true, - "featured": false, - "addedAt": "2026-02-14" - }, - { - "slug": "thedotmack/claude-mem", - "name": "claude-mem", - "description": "A Claude Code plugin that automatically captures everything Claude does during your coding sessions, compresses it with AI, and injects relevant context back into future sessions.", - "url": "https://github.com/thedotmack/claude-mem", - "category": "ai-ml", - "tags": [ - "claude-code", - "memory", - "ai-agents", - "plugin", - "typescript" - ], - "maintainer": { - "name": "thedotmack", - "url": "https://github.com/thedotmack" - }, - "isActive": true, - "featured": false, - "addedAt": "2026-02-14" - }, - { - "slug": "danielmiessler/Personal_AI_Infrastructure", - "name": "Personal AI Infrastructure", - "description": "A guide and framework for building your own personal AI infrastructure, including patterns for AI agents, workflows, and automation.", - "url": "https://github.com/danielmiessler/Personal_AI_Infrastructure", - "category": "ai-ml", - "tags": [ - "ai", - "infrastructure", - "agents", - "automation", - "guide" - ], - "maintainer": { - "name": "Daniel Miessler", - "url": "https://github.com/danielmiessler" - }, - "isActive": true, - "featured": false, - "addedAt": "2026-02-25" - }, - { - "slug": "danielmiessler/fabric", - "name": "fabric", - "description": "An open-source framework for augmenting humans using AI, providing a modular system for solving specific problems with crowdsourced AI prompts.", - "url": "https://github.com/danielmiessler/fabric", - "category": "productivity", - "tags": [ - "ai", - "prompts", - "automation", - "cli", - "go" - ], - "maintainer": { - "name": "Daniel Miessler", - "url": "https://github.com/danielmiessler" - }, - "isActive": true, - "featured": false, - "addedAt": "2026-02-25" - }, - { - "slug": "volvoxllc/volvox-bot", - "name": "volvox-bot", - "description": "An AI-powered Discord bot used for managing servers that includes interaction, question answering, full memory of users, interaction, games, and much more.", - "url": "https://github.com/VolvoxLLC/volvox-bot", - "category": "ai-ml", - "tags": [ - "discord-bot", - "claude-sdk", - "moderation", - "discord.js", - "typescript" - ], - "maintainer": { - "name": "BillChirico", - "url": "https://github.com/BillChirico" - }, - "isActive": true, - "featured": false, - "addedAt": "2026-02-28" - } - ] + "schemaVersion": "1.0", + "lastUpdated": "2026-02-28T00:00:00Z", + "repositories": [ + { + "slug": "RunMaestro/Maestro", + "name": "Maestro", + "description": "Desktop app for managing multiple AI agents with a keyboard-first interface.", + "url": "https://github.com/RunMaestro/Maestro", + "category": "ai-ml", + "tags": ["electron", "ai", "productivity", "typescript"], + "maintainer": { + "name": "Pedram Amini", + "url": "https://github.com/pedramamini" + }, + "isActive": true, + "featured": true, + "addedAt": "2025-01-01" + }, + { + "slug": "pedramamini/Podsidian", + "name": "Podsidian", + "description": "An MCP-capable intelligent Apple podcast transcription and summarization to markdown tool.", + "url": "https://github.com/pedramamini/Podsidian", + "category": "productivity", + "tags": ["mcp", "podcasts", "obsidian", "markdown", "transcription", "python"], + "maintainer": { + "name": "Pedram Amini", + "url": "https://github.com/pedramamini" + }, + "isActive": true, + "featured": false, + "addedAt": "2026-02-14" + }, + { + "slug": "pedramamini/RSSidian", + "name": "RSSidian", + "description": "An MCP-capable intelligent RSS feed ingestion and summarization to markdown tool.", + "url": "https://github.com/pedramamini/RSSidian", + "category": "productivity", + "tags": ["mcp", "rss", "obsidian", "markdown", "summarization", "python"], + "maintainer": { + "name": "Pedram Amini", + "url": "https://github.com/pedramamini" + }, + "isActive": true, + "featured": false, + "addedAt": "2026-02-14" + }, + { + "slug": "thedotmack/claude-mem", + "name": "claude-mem", + "description": "A Claude Code plugin that automatically captures everything Claude does during your coding sessions, compresses it with AI, and injects relevant context back into future sessions.", + "url": "https://github.com/thedotmack/claude-mem", + "category": "ai-ml", + "tags": ["claude-code", "memory", "ai-agents", "plugin", "typescript"], + "maintainer": { + "name": "thedotmack", + "url": "https://github.com/thedotmack" + }, + "isActive": true, + "featured": false, + "addedAt": "2026-02-14" + }, + { + "slug": "danielmiessler/Personal_AI_Infrastructure", + "name": "Personal AI Infrastructure", + "description": "A guide and framework for building your own personal AI infrastructure, including patterns for AI agents, workflows, and automation.", + "url": "https://github.com/danielmiessler/Personal_AI_Infrastructure", + "category": "ai-ml", + "tags": ["ai", "infrastructure", "agents", "automation", "guide"], + "maintainer": { + "name": "Daniel Miessler", + "url": "https://github.com/danielmiessler" + }, + "isActive": true, + "featured": false, + "addedAt": "2026-02-25" + }, + { + "slug": "danielmiessler/fabric", + "name": "fabric", + "description": "An open-source framework for augmenting humans using AI, providing a modular system for solving specific problems with crowdsourced AI prompts.", + "url": "https://github.com/danielmiessler/fabric", + "category": "productivity", + "tags": ["ai", "prompts", "automation", "cli", "go"], + "maintainer": { + "name": "Daniel Miessler", + "url": "https://github.com/danielmiessler" + }, + "isActive": true, + "featured": false, + "addedAt": "2026-02-25" + }, + { + "slug": "volvoxllc/volvox-bot", + "name": "volvox-bot", + "description": "An AI-powered Discord bot used for managing servers that includes interaction, question answering, full memory of users, interaction, games, and much more.", + "url": "https://github.com/VolvoxLLC/volvox-bot", + "category": "ai-ml", + "tags": ["discord-bot", "claude-sdk", "moderation", "discord.js", "typescript"], + "maintainer": { + "name": "BillChirico", + "url": "https://github.com/BillChirico" + }, + "isActive": true, + "featured": false, + "addedAt": "2026-02-28" + } + ] }