From 244f2e41981236e704c503b6e51479e9c3a57479 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Wed, 4 Mar 2026 09:35:30 -0800 Subject: [PATCH 01/22] fix: restore Cmd+/- font size shortcuts lost when custom menu was added The custom Electron menu (added to fix Cmd+Shift+{/} tab cycling) removed the View menu role, which provided native Cmd+=/- zoom and Cmd+0 reset. This adds font size shortcuts directly in the renderer keyboard handler using the existing fontSize setting (2px steps, range 10-24). Cmd+0 now resets font size instead of goToLastTab since zoom reset is standard behavior. --- .../hooks/useMainKeyboardHandler.test.ts | 239 ++++++++++++++---- .../hooks/keyboard/useMainKeyboardHandler.ts | 51 +++- 2 files changed, 239 insertions(+), 51 deletions(-) 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/renderer/hooks/keyboard/useMainKeyboardHandler.ts b/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts index b60ed62a9..4a4926f56 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; } @@ -482,6 +497,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 && From 5272ae1ab9c3fc4c665f60fab6a9e74539916faf Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Wed, 4 Mar 2026 10:37:48 -0800 Subject: [PATCH 02/22] feat: add Create Worktree command to command palette with auto-focus - Add "Create Worktree" action to QuickActionsModal (Cmd+K) for git repo sessions - Resolve to parent session when invoked from a worktree child - Auto-focus newly created worktree sessions after creation --- .../components/QuickActionsModal.test.tsx | 75 +++++++++++++++++++ .../hooks/useWorktreeHandlers.test.ts | 33 ++++++++ src/renderer/App.tsx | 1 + src/renderer/components/AppModals.tsx | 6 ++ src/renderer/components/QuickActionsModal.tsx | 23 ++++++ .../hooks/worktree/useWorktreeHandlers.ts | 6 ++ 6 files changed, 144 insertions(+) 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/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/renderer/App.tsx b/src/renderer/App.tsx index 667edde5c..1ae322eae 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2586,6 +2586,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/QuickActionsModal.tsx b/src/renderer/components/QuickActionsModal.tsx index d80133411..ab73c0e40 100644 --- a/src/renderer/components/QuickActionsModal.tsx +++ b/src/renderer/components/QuickActionsModal.tsx @@ -91,6 +91,8 @@ interface QuickActionsModalProps { onOpenSendToAgent?: () => 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/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', From f6e9ebb51d7160487d426c8b3eebfd586ebf8af3 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Wed, 4 Mar 2026 14:31:14 -0800 Subject: [PATCH 03/22] fix: format symphony-registry.json and register font size shortcuts Format symphony-registry.json to fix CI prettier check failure. Add fontSizeIncrease/Decrease/Reset to FIXED_SHORTCUTS registry per CodeRabbit review feedback. --- src/renderer/constants/shortcuts.ts | 15 ++ symphony-registry.json | 261 ++++++++++++---------------- 2 files changed, 124 insertions(+), 152 deletions(-) diff --git a/src/renderer/constants/shortcuts.ts b/src/renderer/constants/shortcuts.ts index 37f530386..cc1e63349 100644 --- a/src/renderer/constants/shortcuts.ts +++ b/src/renderer/constants/shortcuts.ts @@ -119,6 +119,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) 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" + } + ] } From 05a4bfb8f7c62bd09ef4a68a0864000721bde7d0 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 5 Mar 2026 08:36:19 -0800 Subject: [PATCH 04/22] fix: resolve duplicate entries in file tree by using tree-structured data RightPanel was consuming flatFileList (FlatTreeNode[]) where it needed tree-structured FileNode[], causing double-flattening and duplicate entries. Added filteredFileTree to the store as the proper bridge between useFileTreeManagement and RightPanel. --- .../renderer/hooks/useFileExplorerEffects.test.ts | 2 ++ src/renderer/components/RightPanel.tsx | 2 +- src/renderer/hooks/git/useFileExplorerEffects.ts | 6 ++++++ src/renderer/stores/fileExplorerStore.ts | 10 +++++++++- 4 files changed, 18 insertions(+), 2 deletions(-) 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/renderer/components/RightPanel.tsx b/src/renderer/components/RightPanel.tsx index 5ac2d9d4d..e5edce1bd 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); 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/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, From 38a741c2f205dab0009036a00c25db0ef5423b7f Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 5 Mar 2026 08:36:29 -0800 Subject: [PATCH 05/22] feat: add --read-only flag to maestro-cli send command Applies provider-specific read-only args from centralized agent definitions (e.g. --permission-mode plan for Claude Code, --sandbox read-only for Codex) without duplicating logic. --- src/__tests__/cli/commands/send.test.ts | 34 +++++++++++-- .../cli/services/agent-spawner.test.ts | 36 +++++++++++++ src/cli/commands/send.ts | 5 +- src/cli/index.ts | 1 + src/cli/services/agent-spawner.ts | 50 ++++++++++++++++--- 5 files changed, 115 insertions(+), 11 deletions(-) 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/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 { From 05f3cf88f96bbda28a863645e78a387e5e1d2f95 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 5 Mar 2026 10:56:03 -0800 Subject: [PATCH 06/22] fix: allow session name pill to shrink so date doesn't collide with type pill The session name pill had flex-shrink-0 which prevented it from shrinking when the right panel was narrow, causing the date to overlap the USER/AUTO pill. Now uses flex-shrink so it truncates gracefully and expands when space is available. --- .../History/HistoryEntryItem.test.tsx | 19 +++++++++++++++++++ .../components/History/HistoryEntryItem.tsx | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) 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/renderer/components/History/HistoryEntryItem.tsx b/src/renderer/components/History/HistoryEntryItem.tsx index 2e8b28e11..2de4932d2 100644 --- a/src/renderer/components/History/HistoryEntryItem.tsx +++ b/src/renderer/components/History/HistoryEntryItem.tsx @@ -119,7 +119,7 @@ export const HistoryEntryItem = memo(function HistoryEntryItem({ e.stopPropagation(); onOpenSessionAsTab?.(entry.agentSessionId!); }} - className={`flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold transition-colors hover:opacity-80 min-w-0 max-w-[200px] flex-shrink-0 ${entry.sessionName ? '' : 'font-mono uppercase'}`} + className={`flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold transition-colors hover:opacity-80 min-w-0 flex-shrink ${entry.sessionName ? '' : 'font-mono uppercase'}`} style={{ backgroundColor: theme.colors.accent + '20', color: theme.colors.accent, From 8eaed7eb15d800f673b079475169a43e1bcc6786 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 5 Mar 2026 11:17:59 -0800 Subject: [PATCH 07/22] feat: add unread agents filter toggle with Cmd+Shift+U shortcut Adds a robot face icon button to the right side of the left sidebar bottom panel that toggles "Unread Agent Only" mode, hiding all agents without unread indicators. Reassigns toggleTabUnread to Alt+Shift+U. --- .../SessionList/SidebarActions.test.tsx | 22 +++++++++ .../hooks/useSessionCategories.test.ts | 49 +++++++++++++++++++ src/__tests__/renderer/stores/uiStore.test.ts | 14 ++++++ src/renderer/App.tsx | 3 ++ .../components/SessionList/SessionList.tsx | 12 ++++- .../components/SessionList/SidebarActions.tsx | 30 ++++++++++++ src/renderer/constants/shortcuts.ts | 7 ++- .../hooks/keyboard/useMainKeyboardHandler.ts | 4 ++ .../hooks/session/useSessionCategories.ts | 13 +++-- src/renderer/stores/uiStore.ts | 7 +++ 10 files changed, 156 insertions(+), 5 deletions(-) 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/hooks/useSessionCategories.test.ts b/src/__tests__/renderer/hooks/useSessionCategories.test.ts index a6f9162cf..f5fc22691 100644 --- a/src/__tests__/renderer/hooks/useSessionCategories.test.ts +++ b/src/__tests__/renderer/hooks/useSessionCategories.test.ts @@ -241,6 +241,55 @@ 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('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/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/renderer/App.tsx b/src/renderer/App.tsx index 1ae322eae..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 diff --git a/src/renderer/components/SessionList/SessionList.tsx b/src/renderer/components/SessionList/SessionList.tsx index 379e11df9..fe3421e5b 100644 --- a/src/renderer/components/SessionList/SessionList.tsx +++ b/src/renderer/components/SessionList/SessionList.tsx @@ -223,6 +223,8 @@ function SessionListInner(props: SessionListProps) { }); const sessionFilterOpen = useUIStore((s) => s.sessionFilterOpen); const setSessionFilterOpen = useUIStore((s) => s.setSessionFilterOpen); + const showUnreadAgentsOnly = useUIStore((s) => s.showUnreadAgentsOnly); + const toggleShowUnreadAgentsOnly = useUIStore((s) => s.toggleShowUnreadAgentsOnly); const [menuOpen, setMenuOpen] = useState(false); // Live overlay state (extracted hook) @@ -377,7 +379,12 @@ function SessionListInner(props: SessionListProps) { sortedUngroupedParentSessions, sortedFilteredSessions, sortedGroups, - } = useSessionCategories(sessionFilter, sortedSessions); + } = useSessionCategories(sessionFilter, sortedSessions, showUnreadAgentsOnly); + + const hasUnreadAgents = useMemo( + () => sessions.some((s) => !s.parentSessionId && s.aiTabs?.some((tab) => tab.hasUnread)), + [sessions] + ); // PERF: Cached callback maps to prevent SessionItem re-renders // These Maps store stable function references keyed by session/editing ID @@ -1181,9 +1188,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..1227c497d 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/constants/shortcuts.ts b/src/renderer/constants/shortcuts.ts index cc1e63349..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) @@ -178,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/keyboard/useMainKeyboardHandler.ts b/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts index 4a4926f56..3b4f73f63 100644 --- a/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts +++ b/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts @@ -441,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) diff --git a/src/renderer/hooks/session/useSessionCategories.ts b/src/renderer/hooks/session/useSessionCategories.ts index 17e98e5b9..033d6d765 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 + if (showUnreadAgentsOnly) { + const hasUnread = s.aiTabs?.some((tab) => tab.hasUnread); + if (!hasUnread) 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/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 }), From ce5cf67af6aef070d82e0136feb277e3083c2ce0 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 5 Mar 2026 21:16:30 -0600 Subject: [PATCH 08/22] fix: use textMain for session names to prevent visual dimming Session names used textDim for inactive sessions, which caused agents with expanded worktree drawers to appear visually dimmer due to contrast with the tinted worktree background. Active state is already indicated by accent border and background highlight. --- src/renderer/components/SessionItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/components/SessionItem.tsx b/src/renderer/components/SessionItem.tsx index 2554071e4..ee45ab235 100644 --- a/src/renderer/components/SessionItem.tsx +++ b/src/renderer/components/SessionItem.tsx @@ -152,7 +152,7 @@ export const SessionItem = memo(function SessionItem({ )} {session.name} From 8d2b00357133a040c9690ebedf3a58202fad1af0 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 5 Mar 2026 22:06:42 -0600 Subject: [PATCH 09/22] fix: count only agents with entries in lookback window for Director's Notes stats agentCount was using sessionIds.length which counts all history files on disk, including deleted agents and those with no entries in the selected time range. Now tracks which agents actually contribute entries using a Set, matching how the synopsis handler already does it. --- .../main/ipc/handlers/director-notes.test.ts | 31 +++++++++++++++++++ src/main/ipc/handlers/director-notes.ts | 4 ++- 2 files changed, 34 insertions(+), 1 deletion(-) 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/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, From 926f978175152a71fecc2ea698b201f26dbd8657 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 5 Mar 2026 22:09:17 -0600 Subject: [PATCH 10/22] fix: preserve draft input when replaying a previous message handleReplayMessage now captures the current draft text and staged images before calling processInput, then restores them after the send clears the input box. --- .../renderer/hooks/useInputHandlers.test.ts | 32 +++++++++++++++++++ src/renderer/hooks/input/useInputHandlers.ts | 18 +++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) 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/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( From 7409805765770c771c98175054177d7a9322d636 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 5 Mar 2026 22:12:05 -0600 Subject: [PATCH 11/22] fix: match unread agent indicator dot position to tab unread pattern Use -top-0.5 -right-0.5 positioning to match the Mail icon's notification dot in TabBar, placing it slightly outside button bounds. --- src/renderer/components/SessionList/SidebarActions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/components/SessionList/SidebarActions.tsx b/src/renderer/components/SessionList/SidebarActions.tsx index 1227c497d..dd3167c5c 100644 --- a/src/renderer/components/SessionList/SidebarActions.tsx +++ b/src/renderer/components/SessionList/SidebarActions.tsx @@ -92,7 +92,7 @@ export const SidebarActions = memo(function SidebarActions({ {hasUnreadAgents && (
)} From bca4bed7b582257468e06f6f5658eae3ea5f09ed Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 5 Mar 2026 22:21:12 -0600 Subject: [PATCH 12/22] feat: show View history link on files tab during batch run Previously the "View history" link in the auto-run status bar was only visible on the autorun tab. Now it appears on all tabs except history. --- .../renderer/components/RightPanel.test.tsx | 26 +++++++++++++++++++ src/renderer/components/RightPanel.tsx | 4 +-- 2 files changed, 28 insertions(+), 2 deletions(-) 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/renderer/components/RightPanel.tsx b/src/renderer/components/RightPanel.tsx index e5edce1bd..d7326a8ce 100644 --- a/src/renderer/components/RightPanel.tsx +++ b/src/renderer/components/RightPanel.tsx @@ -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' && (
{/* Hamburger Menu */} -
+
-
+ {!showUnreadAgentsOnly && ( +
+ +
+ )} ) : groups.length > 0 && ungroupedSessions.length > 0 ? ( /* UNGROUPED FOLDER - Groups exist and there are ungrouped agents */ @@ -1057,22 +1061,24 @@ function SessionListInner(props: SessionListProps) { Ungrouped Agents
- + {!showUnreadAgentsOnly && ( + + )} {!ungroupedCollapsed ? ( @@ -1108,7 +1114,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 */} From c6734e586634abd8640d19bbaea82bd5ccaac6bc Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Fri, 6 Mar 2026 23:45:50 -0600 Subject: [PATCH 22/22] feat: add empty state for unread agents filter with centered Bot icon Shows a vertically centered Bot icon and "No unread or working agents" message when the filter is active but no agents match. --- src/renderer/components/SessionList/SessionList.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/renderer/components/SessionList/SessionList.tsx b/src/renderer/components/SessionList/SessionList.tsx index cbc59e4c2..4632d2001 100644 --- a/src/renderer/components/SessionList/SessionList.tsx +++ b/src/renderer/components/SessionList/SessionList.tsx @@ -13,6 +13,7 @@ import { Bookmark, Trophy, Trash2, + Bot, } from 'lucide-react'; import type { Session, Group, Theme } from '../../types'; import { getBadgeForTime } from '../../constants/conductorBadges'; @@ -817,6 +818,17 @@ function SessionListInner(props: SessionListProps) {
)} + {/* Empty state for unread agents filter */} + {showUnreadAgentsOnly && sortedFilteredSessions.length === 0 && ( +
+ + No unread or working agents +
+ )} + {/* BOOKMARKS SECTION - only show if there are bookmarked sessions */} {bookmarkedSessions.length > 0 && (