diff --git a/src/__tests__/main/web-server/web-server-factory.test.ts b/src/__tests__/main/web-server/web-server-factory.test.ts
index 60ffc9ad01..541d0e1174 100644
--- a/src/__tests__/main/web-server/web-server-factory.test.ts
+++ b/src/__tests__/main/web-server/web-server-factory.test.ts
@@ -41,6 +41,35 @@ vi.mock('../../../main/web-server/WebServer', () => {
setRefreshFileTreeCallback = vi.fn();
setRefreshAutoRunDocsCallback = vi.fn();
setConfigureAutoRunCallback = vi.fn();
+ setGetAutoRunDocsCallback = vi.fn();
+ setGetAutoRunDocContentCallback = vi.fn();
+ setSaveAutoRunDocCallback = vi.fn();
+ setStopAutoRunCallback = vi.fn();
+ setGetSettingsCallback = vi.fn();
+ setSetSettingCallback = vi.fn();
+ setGetGroupsCallback = vi.fn();
+ setCreateGroupCallback = vi.fn();
+ setRenameGroupCallback = vi.fn();
+ setDeleteGroupCallback = vi.fn();
+ setMoveSessionToGroupCallback = vi.fn();
+ setCreateSessionCallback = vi.fn();
+ setDeleteSessionCallback = vi.fn();
+ setRenameSessionCallback = vi.fn();
+ setGetGitStatusCallback = vi.fn();
+ setGetGitDiffCallback = vi.fn();
+ setGetGroupChatsCallback = vi.fn();
+ setStartGroupChatCallback = vi.fn();
+ setGetGroupChatStateCallback = vi.fn();
+ setStopGroupChatCallback = vi.fn();
+ setSendGroupChatMessageCallback = vi.fn();
+ setMergeContextCallback = vi.fn();
+ setTransferContextCallback = vi.fn();
+ setSummarizeContextCallback = vi.fn();
+ setGetCueSubscriptionsCallback = vi.fn();
+ setToggleCueSubscriptionCallback = vi.fn();
+ setGetCueActivityCallback = vi.fn();
+ setGetUsageDashboardCallback = vi.fn();
+ setGetAchievementsCallback = vi.fn();
constructor(port: number, securityToken?: string) {
this.port = port;
diff --git a/src/__tests__/renderer/hooks/useRemoteIntegration.test.ts b/src/__tests__/renderer/hooks/useRemoteIntegration.test.ts
index 377e514030..69b5e43094 100644
--- a/src/__tests__/renderer/hooks/useRemoteIntegration.test.ts
+++ b/src/__tests__/renderer/hooks/useRemoteIntegration.test.ts
@@ -135,6 +135,59 @@ describe('useRemoteIntegration', () => {
}),
sendRemoteNewTabResponse: vi.fn(),
sendRemoteConfigureAutoRunResponse: vi.fn(),
+ onRemoteGetAutoRunDocs: vi.fn().mockImplementation(() => {
+ return () => {};
+ }),
+ onRemoteGetAutoRunDocContent: vi.fn().mockImplementation(() => {
+ return () => {};
+ }),
+ onRemoteSaveAutoRunDoc: vi.fn().mockImplementation(() => {
+ return () => {};
+ }),
+ sendRemoteSaveAutoRunDocResponse: vi.fn(),
+ sendRemoteGetAutoRunDocsResponse: vi.fn(),
+ sendRemoteGetAutoRunDocContentResponse: vi.fn(),
+ onRemoteStopAutoRun: vi.fn().mockImplementation(() => {
+ return () => {};
+ }),
+ onRemoteSetSetting: vi.fn().mockImplementation(() => {
+ return () => {};
+ }),
+ sendRemoteSetSettingResponse: vi.fn(),
+ onRemoteCreateSession: vi.fn().mockImplementation(() => {
+ return () => {};
+ }),
+ sendRemoteCreateSessionResponse: vi.fn(),
+ onRemoteDeleteSession: vi.fn().mockImplementation(() => {
+ return () => {};
+ }),
+ onRemoteRenameSession: vi.fn().mockImplementation(() => {
+ return () => {};
+ }),
+ sendRemoteRenameSessionResponse: vi.fn(),
+ onRemoteCreateGroup: vi.fn().mockImplementation(() => {
+ return () => {};
+ }),
+ sendRemoteCreateGroupResponse: vi.fn(),
+ onRemoteRenameGroup: vi.fn().mockImplementation(() => {
+ return () => {};
+ }),
+ sendRemoteRenameGroupResponse: vi.fn(),
+ onRemoteDeleteGroup: vi.fn().mockImplementation(() => {
+ return () => {};
+ }),
+ onRemoteMoveSessionToGroup: vi.fn().mockImplementation(() => {
+ return () => {};
+ }),
+ sendRemoteMoveSessionToGroupResponse: vi.fn(),
+ onRemoteGetGitStatus: vi.fn().mockImplementation(() => {
+ return () => {};
+ }),
+ sendRemoteGetGitStatusResponse: vi.fn(),
+ onRemoteGetGitDiff: vi.fn().mockImplementation(() => {
+ return () => {};
+ }),
+ sendRemoteGetGitDiffResponse: vi.fn(),
};
const mockLive = {
diff --git a/src/__tests__/web/hooks/useLongPressMenu.test.ts b/src/__tests__/web/hooks/useLongPressMenu.test.ts
index 12c40aaa86..214387905c 100644
--- a/src/__tests__/web/hooks/useLongPressMenu.test.ts
+++ b/src/__tests__/web/hooks/useLongPressMenu.test.ts
@@ -31,24 +31,15 @@ describe('useLongPressMenu', () => {
vi.restoreAllMocks();
});
- it('opens the menu after long press', () => {
+ it('triggers onOpenCommandPalette after long press', () => {
+ const onOpenCommandPalette = vi.fn();
const button = document.createElement('button');
- button.getBoundingClientRect = vi.fn(() => ({
- left: 10,
- top: 20,
- width: 30,
- height: 40,
- right: 40,
- bottom: 60,
- x: 10,
- y: 20,
- toJSON: () => {},
- })) as unknown as () => DOMRect;
const { result } = renderHook(() =>
useLongPressMenu({
inputMode: 'ai',
value: 'hello',
+ onOpenCommandPalette,
})
);
@@ -61,28 +52,18 @@ describe('useLongPressMenu', () => {
vi.advanceTimersByTime(500);
});
- expect(result.current.isMenuOpen).toBe(true);
- expect(result.current.menuAnchor).toEqual({ x: 25, y: 20 });
+ expect(onOpenCommandPalette).toHaveBeenCalledTimes(1);
});
it('cancels long press on touch move', () => {
+ const onOpenCommandPalette = vi.fn();
const button = document.createElement('button');
- button.getBoundingClientRect = vi.fn(() => ({
- left: 0,
- top: 0,
- width: 10,
- height: 10,
- right: 10,
- bottom: 10,
- x: 0,
- y: 0,
- toJSON: () => {},
- })) as unknown as () => DOMRect;
const { result } = renderHook(() =>
useLongPressMenu({
inputMode: 'ai',
value: 'hello',
+ onOpenCommandPalette,
})
);
@@ -96,44 +77,18 @@ describe('useLongPressMenu', () => {
vi.advanceTimersByTime(500);
});
- expect(result.current.isMenuOpen).toBe(false);
+ expect(onOpenCommandPalette).not.toHaveBeenCalled();
});
- it('handles quick action selection', () => {
- const onModeToggle = vi.fn();
- const { result } = renderHook(() =>
- useLongPressMenu({
- inputMode: 'ai',
- onModeToggle,
- value: 'hello',
- })
- );
-
- act(() => {
- result.current.handleQuickAction('switch_mode');
- });
-
- expect(onModeToggle).toHaveBeenCalledWith('terminal');
- });
-
- it('closes the menu when requested', () => {
+ it('does not trigger onOpenCommandPalette when touch ends before duration', () => {
+ const onOpenCommandPalette = vi.fn();
const button = document.createElement('button');
- button.getBoundingClientRect = vi.fn(() => ({
- left: 0,
- top: 0,
- width: 10,
- height: 10,
- right: 10,
- bottom: 10,
- x: 0,
- y: 0,
- toJSON: () => {},
- })) as unknown as () => DOMRect;
const { result } = renderHook(() =>
useLongPressMenu({
inputMode: 'ai',
value: 'hello',
+ onOpenCommandPalette,
})
);
@@ -143,15 +98,24 @@ describe('useLongPressMenu', () => {
act(() => {
result.current.handleTouchStart(createTouchEvent(button));
+ result.current.handleTouchEnd(createTouchEvent(button));
vi.advanceTimersByTime(500);
});
- expect(result.current.isMenuOpen).toBe(true);
+ expect(onOpenCommandPalette).not.toHaveBeenCalled();
+ });
- act(() => {
- result.current.closeMenu();
- });
+ it('returns expected handler functions', () => {
+ const { result } = renderHook(() =>
+ useLongPressMenu({
+ inputMode: 'ai',
+ value: 'hello',
+ })
+ );
- expect(result.current.isMenuOpen).toBe(false);
+ expect(typeof result.current.handleTouchStart).toBe('function');
+ expect(typeof result.current.handleTouchEnd).toBe('function');
+ expect(typeof result.current.handleTouchMove).toBe('function');
+ expect(result.current.sendButtonRef).toBeDefined();
});
});
diff --git a/src/__tests__/web/mobile/AllSessionsView.test.tsx b/src/__tests__/web/mobile/AllSessionsView.test.tsx
index dd048820a0..e0f77b95fd 100644
--- a/src/__tests__/web/mobile/AllSessionsView.test.tsx
+++ b/src/__tests__/web/mobile/AllSessionsView.test.tsx
@@ -299,7 +299,7 @@ describe('AllSessionsView', () => {
});
describe('session card interaction', () => {
- it('calls onSelectSession and onClose when card is clicked', async () => {
+ it('calls onSelectSession and onClose when card is tapped', async () => {
const onSelectSession = vi.fn();
const onClose = vi.fn();
const sessions = [createMockSession({ id: 'session-1', name: 'Click Me' })];
@@ -307,7 +307,10 @@ describe('AllSessionsView', () => {
render();
const sessionCard = screen.getByRole('button', { name: /Click Me/i });
- fireEvent.click(sessionCard);
+ // JSDOM has ontouchstart in window, so the click handler is bypassed.
+ // Use touch events to simulate a tap (as the source uses handleTouchEnd for selection).
+ fireEvent.touchStart(sessionCard, { touches: [{ clientX: 0, clientY: 0 }] });
+ fireEvent.touchEnd(sessionCard);
expect(mockTriggerHaptic).toHaveBeenCalledWith([10]); // HAPTIC_PATTERNS.tap
expect(onSelectSession).toHaveBeenCalledWith('session-1');
@@ -957,8 +960,11 @@ describe('AllSessionsView', () => {
});
// 3. Select Backend
+ // JSDOM has ontouchstart in window, so the click handler is bypassed.
+ // Use touch events to simulate a tap (as the source uses handleTouchEnd for selection).
const backendCard = screen.getByRole('button', { name: /Backend session/i });
- fireEvent.click(backendCard);
+ fireEvent.touchStart(backendCard, { touches: [{ clientX: 0, clientY: 0 }] });
+ fireEvent.touchEnd(backendCard);
expect(onSelectSession).toHaveBeenCalledWith('s2');
expect(onClose).toHaveBeenCalled();
diff --git a/src/__tests__/web/mobile/App.test.tsx b/src/__tests__/web/mobile/App.test.tsx
index d098da8405..8cc6860a08 100644
--- a/src/__tests__/web/mobile/App.test.tsx
+++ b/src/__tests__/web/mobile/App.test.tsx
@@ -51,6 +51,7 @@ vi.mock('../../../web/main', () => ({
// Mock useWebSocket hook
const mockConnect = vi.fn();
const mockSend = vi.fn(() => true);
+const mockSendRequest = vi.fn(() => Promise.resolve({}));
const mockDisconnect = vi.fn();
let mockWebSocketState = 'connected';
let mockWebSocketError: string | null = null;
@@ -69,6 +70,7 @@ vi.mock('../../../web/hooks/useWebSocket', () => ({
state: mockWebSocketState,
connect: mockConnect,
send: mockSend,
+ sendRequest: mockSendRequest,
disconnect: mockDisconnect,
error: mockWebSocketError,
reconnectAttempts: mockReconnectAttempts,
@@ -152,6 +154,12 @@ vi.mock('../../../web/mobile/constants', () => ({
success: [30],
error: [50],
},
+ GESTURE_THRESHOLDS: {
+ swipeDistance: 50,
+ swipeTime: 300,
+ pullToRefresh: 80,
+ longPress: 500,
+ },
}));
// Mock webLogger
@@ -504,6 +512,23 @@ vi.mock('../../../web/mobile/SlashCommandAutocomplete', () => ({
],
}));
+vi.mock('../../../web/mobile/RightDrawer', () => ({
+ RightDrawer: ({
+ activeTab,
+ onClose,
+ }: {
+ sessionId: string;
+ activeTab?: string;
+ onClose: () => void;
+ }) => (
+
+
+
+ ),
+}));
+
// Now import the component
import MobileApp from '../../../web/mobile/App';
import type { Session } from '../../../web/hooks/useSessions';
@@ -579,6 +604,7 @@ describe('MobileApp', () => {
(window as any).__MAESTRO_CONFIG__ = {};
// Reset mock function return values
+ mockSendRequest.mockResolvedValue({});
mockIsOffline.mockReturnValue(false);
mockIsDashboard.mockReturnValue(true);
mockIsSession.mockReturnValue(false);
@@ -1442,7 +1468,7 @@ describe('MobileApp', () => {
});
describe('history panel', () => {
- it('opens history panel', async () => {
+ it('opens history panel via right drawer', async () => {
render();
await act(async () => {
@@ -1453,10 +1479,13 @@ describe('MobileApp', () => {
fireEvent.click(screen.getByTestId('open-history'));
- expect(screen.getByTestId('mobile-history-panel')).toBeInTheDocument();
+ // History is now inside the RightDrawer with activeTab='history'
+ const drawer = screen.getByTestId('right-drawer');
+ expect(drawer).toBeInTheDocument();
+ expect(drawer).toHaveAttribute('data-active-tab', 'history');
});
- it('closes history panel', async () => {
+ it('closes history panel via right drawer', async () => {
render();
await act(async () => {
@@ -1466,13 +1495,13 @@ describe('MobileApp', () => {
});
fireEvent.click(screen.getByTestId('open-history'));
- expect(screen.getByTestId('mobile-history-panel')).toBeInTheDocument();
+ expect(screen.getByTestId('right-drawer')).toBeInTheDocument();
- fireEvent.click(screen.getByTestId('close-history'));
- expect(screen.queryByTestId('mobile-history-panel')).not.toBeInTheDocument();
+ fireEvent.click(screen.getByTestId('close-right-drawer'));
+ expect(screen.queryByTestId('right-drawer')).not.toBeInTheDocument();
});
- it('handles onSearchChange callback to update search state', async () => {
+ it('opens history panel and can close it again', async () => {
render();
await act(async () => {
@@ -1483,21 +1512,20 @@ describe('MobileApp', () => {
// Open history panel
fireEvent.click(screen.getByTestId('open-history'));
- expect(screen.getByTestId('mobile-history-panel')).toBeInTheDocument();
+ const drawer = screen.getByTestId('right-drawer');
+ expect(drawer).toBeInTheDocument();
+ expect(drawer).toHaveAttribute('data-active-tab', 'history');
- // Trigger onSearchChange callback
- fireEvent.click(screen.getByTestId('trigger-search-change'));
+ // Close drawer
+ fireEvent.click(screen.getByTestId('close-right-drawer'));
+ expect(screen.queryByTestId('right-drawer')).not.toBeInTheDocument();
- // Close and reopen to verify state persistence
- fireEvent.click(screen.getByTestId('close-history'));
+ // Reopen
fireEvent.click(screen.getByTestId('open-history'));
-
- // Verify the search query and open state were persisted
- expect(screen.getByTestId('history-initial-search-query')).toHaveTextContent('test query');
- expect(screen.getByTestId('history-initial-search-open')).toHaveTextContent('true');
+ expect(screen.getByTestId('right-drawer')).toBeInTheDocument();
});
- it('handles onFilterChange callback to update filter state', async () => {
+ it('opens history panel with history tab active', async () => {
render();
await act(async () => {
@@ -1508,17 +1536,15 @@ describe('MobileApp', () => {
// Open history panel
fireEvent.click(screen.getByTestId('open-history'));
- expect(screen.getByTestId('mobile-history-panel')).toBeInTheDocument();
-
- // Trigger onFilterChange callback
- fireEvent.click(screen.getByTestId('trigger-filter-change'));
+ const drawer = screen.getByTestId('right-drawer');
+ expect(drawer).toBeInTheDocument();
+ expect(drawer).toHaveAttribute('data-active-tab', 'history');
- // Close and reopen to verify state persistence
- fireEvent.click(screen.getByTestId('close-history'));
+ // Close and reopen to verify drawer opens with history tab
+ fireEvent.click(screen.getByTestId('close-right-drawer'));
+ expect(screen.queryByTestId('right-drawer')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('open-history'));
-
- // Verify the filter was persisted
- expect(screen.getByTestId('history-initial-filter')).toHaveTextContent('AUTO');
+ expect(screen.getByTestId('right-drawer')).toHaveAttribute('data-active-tab', 'history');
});
});
diff --git a/src/__tests__/web/mobile/CommandInputBar.test.tsx b/src/__tests__/web/mobile/CommandInputBar.test.tsx
index 2bf93f2474..5543504609 100644
--- a/src/__tests__/web/mobile/CommandInputBar.test.tsx
+++ b/src/__tests__/web/mobile/CommandInputBar.test.tsx
@@ -91,23 +91,9 @@ vi.mock('../../../web/mobile/SlashCommandAutocomplete', () => ({
],
}));
-vi.mock('../../../web/mobile/QuickActionsMenu', () => ({
- QuickActionsMenu: vi.fn(
- ({ isOpen, onClose, onSelectAction, inputMode, anchorPosition, hasActiveSession }) =>
- isOpen ? (
-
- {inputMode}
- {String(hasActiveSession)}
-
-
-
- ) : null
- ),
-}));
+// Note: QuickActionsMenu is no longer rendered inside CommandInputBar.
+// Long-press on the send button now calls the onOpenCommandPalette prop directly,
+// and the App-level component is responsible for showing QuickActionsMenu.
vi.mock('../../../web/utils/logger', () => ({
webLogger: {
@@ -699,8 +685,9 @@ describe('CommandInputBar', () => {
expect(screen.queryByTestId('quick-actions-menu')).not.toBeInTheDocument();
});
- it('shows quick actions menu on long-press of send button', async () => {
- renderComponent({ value: 'test' });
+ it('calls onOpenCommandPalette on long-press of send button', async () => {
+ const onOpenCommandPalette = vi.fn();
+ renderComponent({ value: 'test', onOpenCommandPalette });
const sendButton = screen.getByRole('button', { name: /send/i });
@@ -714,7 +701,7 @@ describe('CommandInputBar', () => {
await vi.advanceTimersByTimeAsync(600);
});
- expect(screen.getByTestId('quick-actions-menu')).toBeInTheDocument();
+ expect(onOpenCommandPalette).toHaveBeenCalledTimes(1);
});
it('cancels long-press if touch ends before duration', () => {
@@ -763,9 +750,9 @@ describe('CommandInputBar', () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
- it('handles switch_mode action from quick actions', async () => {
- const onModeToggle = vi.fn();
- renderComponent({ value: 'test', inputMode: 'ai', onModeToggle });
+ it('does not call onOpenCommandPalette if touch ends before long-press duration', async () => {
+ const onOpenCommandPalette = vi.fn();
+ renderComponent({ value: 'test', inputMode: 'ai', onOpenCommandPalette });
const sendButton = screen.getByRole('button', { name: /send/i });
@@ -773,18 +760,19 @@ describe('CommandInputBar', () => {
touches: [{ clientX: 100, clientY: 100 }],
});
+ // End touch before 500ms
+ fireEvent.touchEnd(sendButton);
+
await act(async () => {
await vi.advanceTimersByTimeAsync(600);
});
- const switchModeButton = screen.getByTestId('qa-switch-mode');
- fireEvent.click(switchModeButton);
-
- expect(onModeToggle).toHaveBeenCalledWith('terminal');
+ expect(onOpenCommandPalette).not.toHaveBeenCalled();
});
- it('closes quick actions menu on close button', async () => {
- renderComponent({ value: 'test' });
+ it('does not call onOpenCommandPalette if touch moves before long-press duration', async () => {
+ const onOpenCommandPalette = vi.fn();
+ renderComponent({ value: 'test', onOpenCommandPalette });
const sendButton = screen.getByRole('button', { name: /send/i });
@@ -792,14 +780,16 @@ describe('CommandInputBar', () => {
touches: [{ clientX: 100, clientY: 100 }],
});
+ // Move touch - should cancel the timer
+ fireEvent.touchMove(sendButton, {
+ touches: [{ clientX: 150, clientY: 150 }],
+ });
+
await act(async () => {
await vi.advanceTimersByTimeAsync(600);
});
- const closeButton = screen.getByTestId('qa-close');
- fireEvent.click(closeButton);
-
- expect(screen.queryByTestId('quick-actions-menu')).not.toBeInTheDocument();
+ expect(onOpenCommandPalette).not.toHaveBeenCalled();
});
});
diff --git a/src/__tests__/web/mobile/QuickActionsMenu.test.tsx b/src/__tests__/web/mobile/QuickActionsMenu.test.tsx
index 00e0bede95..d88e55fabf 100644
--- a/src/__tests__/web/mobile/QuickActionsMenu.test.tsx
+++ b/src/__tests__/web/mobile/QuickActionsMenu.test.tsx
@@ -1,8 +1,8 @@
/**
* Tests for QuickActionsMenu component
*
- * QuickActionsMenu is a popup menu shown on long-press of send button
- * providing quick actions like mode switching.
+ * QuickActionsMenu is a full-screen command palette providing quick access
+ * to all app actions with search, keyboard navigation, and recent actions.
*/
import React from 'react';
@@ -13,6 +13,7 @@ import { render, screen, fireEvent, cleanup } from '@testing-library/react';
vi.mock('../../../web/components/ThemeProvider', () => ({
useThemeColors: () => ({
bgSidebar: '#1e1e2e',
+ bgMain: '#181825',
border: '#45475a',
textMain: '#cdd6f4',
textDim: '#a6adc8',
@@ -20,27 +21,59 @@ vi.mock('../../../web/components/ThemeProvider', () => ({
}),
}));
+// Mock localStorage for the test environment
+const localStorageMock = (() => {
+ let store: Record = {};
+ return {
+ getItem: vi.fn((key: string) => store[key] ?? null),
+ setItem: vi.fn((key: string, value: string) => {
+ store[key] = value;
+ }),
+ removeItem: vi.fn((key: string) => {
+ delete store[key];
+ }),
+ clear: vi.fn(() => {
+ store = {};
+ }),
+ length: 0,
+ key: vi.fn(),
+ };
+})();
+Object.defineProperty(window, 'localStorage', { value: localStorageMock, writable: true });
+
import {
QuickActionsMenu,
QuickActionsMenuProps,
- QuickAction,
+ CommandPaletteAction,
} from '../../../web/mobile/QuickActionsMenu';
+/** Helper to build a minimal CommandPaletteAction */
+function makeAction(overrides: Partial = {}): CommandPaletteAction {
+ return {
+ id: 'test-action',
+ label: 'Test Action',
+ category: 'Navigation',
+ icon: icon,
+ action: vi.fn(),
+ ...overrides,
+ };
+}
+
describe('QuickActionsMenu', () => {
+ const defaultActions: CommandPaletteAction[] = [
+ makeAction({ id: 'action-1', label: 'Go to Home', category: 'Navigation' }),
+ makeAction({ id: 'action-2', label: 'Start Agent', category: 'Agent' }),
+ ];
+
const defaultProps: QuickActionsMenuProps = {
isOpen: true,
onClose: vi.fn(),
- onSelectAction: vi.fn(),
- inputMode: 'ai',
- anchorPosition: { x: 200, y: 500 },
- hasActiveSession: true,
+ actions: defaultActions,
};
beforeEach(() => {
vi.clearAllMocks();
- // Mock window dimensions
- Object.defineProperty(window, 'innerWidth', { value: 400, writable: true });
- Object.defineProperty(window, 'innerHeight', { value: 800, writable: true });
+ localStorageMock.clear();
});
afterEach(() => {
@@ -53,230 +86,170 @@ describe('QuickActionsMenu', () => {
expect(container.firstChild).toBeNull();
});
- it('returns null when anchorPosition is null', () => {
- const { container } = render();
- expect(container.firstChild).toBeNull();
- });
-
- it('renders when isOpen is true and anchorPosition is provided', () => {
+ it('renders when isOpen is true', () => {
render();
- expect(screen.getByRole('menu')).toBeInTheDocument();
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('renders backdrop overlay', () => {
render();
- // Backdrop has aria-hidden="true"
const backdrop = document.querySelector('[aria-hidden="true"]');
expect(backdrop).toBeInTheDocument();
expect(backdrop).toHaveStyle({ position: 'fixed' });
});
- });
- describe('Menu positioning', () => {
- it('centers menu horizontally on anchor position', () => {
- render();
- const menu = screen.getByRole('menu');
- // menuWidth is 200, so centered would be 200 - 100 = 100
- // But clamped by Math.max(16, Math.min(100, 400-200-16)) = Math.max(16, 100) = 100
- expect(menu).toHaveStyle({ width: '200px' });
+ it('renders with empty actions array', () => {
+ render();
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
});
+ });
- it('clamps menu to left edge with minimum 16px margin', () => {
- render();
- const menu = screen.getByRole('menu');
- // x=50, centered would be 50 - 100 = -50, clamped to 16
- expect(menu.style.left).toBe('16px');
+ describe('Search input', () => {
+ it('renders search input with placeholder', () => {
+ render();
+ expect(screen.getByPlaceholderText('Search actions...')).toBeInTheDocument();
});
- it('clamps menu to right edge with minimum 16px margin', () => {
- render();
- const menu = screen.getByRole('menu');
- // x=380, centered would be 380 - 100 = 280
- // maxRight = 400 - 200 - 16 = 184
- // Math.min(280, 184) = 184
- expect(menu.style.left).toBe('184px');
+ it('has aria-label on search input', () => {
+ render();
+ expect(screen.getByLabelText('Search actions')).toBeInTheDocument();
});
- it('positions menu above anchor point', () => {
- render();
- const menu = screen.getByRole('menu');
- // bottom: calc(100vh - 500px + 12px)
- expect(menu.style.bottom).toContain('500px');
+ it('filters actions by search query', () => {
+ render();
+ const input = screen.getByPlaceholderText('Search actions...');
+
+ fireEvent.change(input, { target: { value: 'Home' } });
+
+ expect(screen.getByText('Go to Home')).toBeInTheDocument();
+ expect(screen.queryByText('Start Agent')).not.toBeInTheDocument();
});
- });
- describe('Menu styling', () => {
- it('applies correct z-index', () => {
+ it('filters actions by category', () => {
render();
- const menu = screen.getByRole('menu');
- expect(menu).toHaveStyle({ zIndex: '200' });
+ const input = screen.getByPlaceholderText('Search actions...');
+
+ fireEvent.change(input, { target: { value: 'Agent' } });
+
+ expect(screen.getByText('Start Agent')).toBeInTheDocument();
+ expect(screen.queryByText('Go to Home')).not.toBeInTheDocument();
});
- it('has animation class', () => {
+ it('shows "No matching actions" when nothing matches', () => {
render();
- const menu = screen.getByRole('menu');
- expect(menu.style.animation).toContain('quickActionsPopIn');
+ const input = screen.getByPlaceholderText('Search actions...');
+
+ fireEvent.change(input, { target: { value: 'zzznomatch' } });
+
+ expect(screen.getByText('No matching actions')).toBeInTheDocument();
});
- it('has proper border radius', () => {
+ it('shows clear button when search has text', () => {
render();
- const menu = screen.getByRole('menu');
- expect(menu).toHaveStyle({ borderRadius: '12px' });
- });
- });
+ const input = screen.getByPlaceholderText('Search actions...');
- describe('Menu items - AI mode', () => {
- it('renders "Switch to Terminal" when in AI mode', () => {
- render();
- expect(screen.getByText('Switch to Terminal')).toBeInTheDocument();
- });
+ fireEvent.change(input, { target: { value: 'test' } });
- it('renders terminal icon when in AI mode', () => {
- render();
- const menuItem = screen.getByRole('menuitem');
- // Terminal icon has polyline with points="4 17 10 11 4 5"
- const svg = menuItem.querySelector('svg');
- expect(svg).toBeInTheDocument();
- const polyline = svg?.querySelector('polyline');
- expect(polyline).toBeInTheDocument();
- expect(polyline?.getAttribute('points')).toBe('4 17 10 11 4 5');
+ expect(screen.getByLabelText('Clear search')).toBeInTheDocument();
});
- });
- describe('Menu items - Terminal mode', () => {
- it('renders "Switch to AI" when in terminal mode', () => {
- render();
- expect(screen.getByText('Switch to AI')).toBeInTheDocument();
- });
+ it('clears search when clear button is clicked', () => {
+ render();
+ const input = screen.getByPlaceholderText('Search actions...');
+
+ fireEvent.change(input, { target: { value: 'test' } });
+ fireEvent.click(screen.getByLabelText('Clear search'));
- it('renders AI sparkle icon when in terminal mode', () => {
- render();
- const menuItem = screen.getByRole('menuitem');
- // AI icon has circle with cx="12" cy="12" r="4"
- const svg = menuItem.querySelector('svg');
- expect(svg).toBeInTheDocument();
- const circle = svg?.querySelector('circle');
- expect(circle).toBeInTheDocument();
- expect(circle?.getAttribute('cx')).toBe('12');
+ expect((input as HTMLInputElement).value).toBe('');
});
});
- describe('Disabled state', () => {
- it('disables menu item when hasActiveSession is false', () => {
- render();
- const menuItem = screen.getByRole('menuitem');
- expect(menuItem).toBeDisabled();
- expect(menuItem).toHaveAttribute('aria-disabled', 'true');
+ describe('Action list rendering', () => {
+ it('renders all available actions', () => {
+ render();
+ expect(screen.getByText('Go to Home')).toBeInTheDocument();
+ expect(screen.getByText('Start Agent')).toBeInTheDocument();
});
- it('applies reduced opacity when disabled', () => {
- render();
- const menuItem = screen.getByRole('menuitem');
- expect(menuItem).toHaveStyle({ opacity: '0.5' });
+ it('renders category headers', () => {
+ render();
+ expect(screen.getByText('Navigation')).toBeInTheDocument();
+ expect(screen.getByText('Agent')).toBeInTheDocument();
});
- it('does not trigger onSelectAction when clicking disabled item', () => {
- const onSelectAction = vi.fn();
- render(
-
- );
- fireEvent.click(screen.getByRole('menuitem'));
- expect(onSelectAction).not.toHaveBeenCalled();
+ it('renders action buttons with role="option"', () => {
+ render();
+ const options = screen.getAllByRole('option');
+ expect(options).toHaveLength(2);
});
- it('enables menu item when hasActiveSession is true', () => {
- render();
- const menuItem = screen.getByRole('menuitem');
- expect(menuItem).not.toBeDisabled();
+ it('renders listbox with role="listbox"', () => {
+ render();
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
});
- });
- describe('Action selection', () => {
- it('calls onSelectAction with switch_mode when item is clicked', () => {
- const onSelectAction = vi.fn();
- render();
- fireEvent.click(screen.getByRole('menuitem'));
- expect(onSelectAction).toHaveBeenCalledWith('switch_mode');
+ it('skips actions where available() returns false', () => {
+ const actions: CommandPaletteAction[] = [
+ makeAction({ id: 'visible', label: 'Visible Action', available: () => true }),
+ makeAction({ id: 'hidden', label: 'Hidden Action', available: () => false }),
+ ];
+ render();
+ expect(screen.getByText('Visible Action')).toBeInTheDocument();
+ expect(screen.queryByText('Hidden Action')).not.toBeInTheDocument();
});
- it('calls onClose after action is selected', () => {
- const onClose = vi.fn();
- render();
- fireEvent.click(screen.getByRole('menuitem'));
- expect(onClose).toHaveBeenCalled();
+ it('shows action when available() is undefined (defaults to visible)', () => {
+ const actions: CommandPaletteAction[] = [
+ makeAction({ id: 'no-guard', label: 'No Guard Action', available: undefined }),
+ ];
+ render();
+ expect(screen.getByText('No Guard Action')).toBeInTheDocument();
});
- it('calls onSelectAction before onClose', () => {
- const callOrder: string[] = [];
- const onSelectAction = vi.fn(() => callOrder.push('select'));
- const onClose = vi.fn(() => callOrder.push('close'));
- render(
-
- );
- fireEvent.click(screen.getByRole('menuitem'));
- expect(callOrder).toEqual(['select', 'close']);
+ it('renders action shortcuts when provided', () => {
+ const actions: CommandPaletteAction[] = [
+ makeAction({ id: 'with-shortcut', label: 'Shortcut Action', shortcut: 'Cmd+K' }),
+ ];
+ render();
+ expect(screen.getByText('Cmd+K')).toBeInTheDocument();
});
});
- describe('Outside click handling', () => {
- it('closes menu when backdrop is clicked', () => {
- const onClose = vi.fn();
- render();
- const backdrop = document.querySelector('[aria-hidden="true"]') as HTMLElement;
- fireEvent.click(backdrop);
- expect(onClose).toHaveBeenCalled();
- });
+ describe('Action execution', () => {
+ it('calls action.action() when an item is clicked', () => {
+ const actionFn = vi.fn();
+ const actions = [makeAction({ id: 'clickable', label: 'Clickable', action: actionFn })];
+ render();
- it('closes menu on mousedown outside', () => {
- const onClose = vi.fn();
- render(
-
- );
- fireEvent.mouseDown(screen.getByTestId('outside'));
- expect(onClose).toHaveBeenCalled();
+ fireEvent.click(screen.getByText('Clickable'));
+ expect(actionFn).toHaveBeenCalled();
});
- it('closes menu on touchstart outside', () => {
+ it('calls onClose when an item is clicked', () => {
const onClose = vi.fn();
- render(
-
- );
- fireEvent.touchStart(screen.getByTestId('outside'));
- expect(onClose).toHaveBeenCalled();
- });
+ const actions = [makeAction({ id: 'clickable', label: 'Clickable' })];
+ render();
- it('does not close menu when clicking inside menu', () => {
- const onClose = vi.fn();
- render();
- const menu = screen.getByRole('menu');
- fireEvent.mouseDown(menu);
- // onClose should not be called for inside click
- // (it will only be called from handleItemClick)
- expect(onClose).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByText('Clickable'));
+ expect(onClose).toHaveBeenCalled();
});
+ });
- it('removes event listeners when menu closes', () => {
- const onClose = vi.fn();
- const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
-
- const { rerender } = render();
+ describe('Recent actions', () => {
+ it('shows Recent section header after an action is used', () => {
+ const actionFn = vi.fn();
+ const actions = [makeAction({ id: 'recent-test', label: 'Recent Test', action: actionFn })];
- rerender();
+ // Simulate a prior usage by pre-populating localStorage
+ localStorage.setItem('maestro-command-palette-recent', JSON.stringify(['recent-test']));
- expect(removeEventListenerSpy).toHaveBeenCalledWith('mousedown', expect.any(Function));
- expect(removeEventListenerSpy).toHaveBeenCalledWith('touchstart', expect.any(Function));
+ render();
+ expect(screen.getByText('Recent')).toBeInTheDocument();
+ });
- removeEventListenerSpy.mockRestore();
+ it('does not show Recent section when localStorage is empty', () => {
+ render();
+ expect(screen.queryByText('Recent')).not.toBeInTheDocument();
});
});
@@ -291,9 +264,8 @@ describe('QuickActionsMenu', () => {
it('does not close menu on other keys', () => {
const onClose = vi.fn();
render();
- fireEvent.keyDown(document, { key: 'Enter' });
fireEvent.keyDown(document, { key: 'Tab' });
- fireEvent.keyDown(document, { key: 'ArrowDown' });
+ fireEvent.keyDown(document, { key: 'a' });
expect(onClose).not.toHaveBeenCalled();
});
@@ -302,212 +274,207 @@ describe('QuickActionsMenu', () => {
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
const { rerender } = render();
-
rerender();
expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function));
-
removeEventListenerSpy.mockRestore();
});
});
- describe('Touch feedback', () => {
- it('applies highlight color on touch start (enabled)', () => {
- render();
- const menuItem = screen.getByRole('menuitem');
-
- fireEvent.touchStart(menuItem);
- // Should apply accent color with alpha (browser converts to rgba)
- // #89b4fa20 becomes rgba(137, 180, 250, 0.125)
- expect(menuItem.style.backgroundColor).toContain('rgba(137, 180, 250');
- });
+ describe('Keyboard navigation', () => {
+ it('moves selection down with ArrowDown', () => {
+ render();
+ const options = screen.getAllByRole('option');
- it('resets background on touch end', () => {
- render();
- const menuItem = screen.getByRole('menuitem');
+ // First item should be selected initially
+ expect(options[0]).toHaveAttribute('aria-selected', 'true');
- fireEvent.touchStart(menuItem);
- fireEvent.touchEnd(menuItem);
- expect(menuItem.style.backgroundColor).toBe('transparent');
+ fireEvent.keyDown(document, { key: 'ArrowDown' });
+ expect(options[1]).toHaveAttribute('aria-selected', 'true');
});
- it('does not apply highlight on touch start when disabled', () => {
- render();
- const menuItem = screen.getByRole('menuitem');
- const initialBg = menuItem.style.backgroundColor;
+ it('moves selection up with ArrowUp', () => {
+ render();
+ const options = screen.getAllByRole('option');
- fireEvent.touchStart(menuItem);
- // Should not change when disabled
- expect(menuItem.style.backgroundColor).toBe(initialBg);
+ // Move down first
+ fireEvent.keyDown(document, { key: 'ArrowDown' });
+ expect(options[1]).toHaveAttribute('aria-selected', 'true');
+
+ // Then up
+ fireEvent.keyDown(document, { key: 'ArrowUp' });
+ expect(options[0]).toHaveAttribute('aria-selected', 'true');
});
- });
- describe('Mouse hover feedback', () => {
- it('applies highlight color on mouse enter (enabled)', () => {
- render();
- const menuItem = screen.getByRole('menuitem');
+ it('executes selected action with Enter key', () => {
+ const actionFn = vi.fn();
+ const actions = [makeAction({ id: 'enter-test', label: 'Enter Test', action: actionFn })];
+ render();
- fireEvent.mouseEnter(menuItem);
- // Browser converts #89b4fa20 to rgba(137, 180, 250, 0.125)
- expect(menuItem.style.backgroundColor).toContain('rgba(137, 180, 250');
+ fireEvent.keyDown(document, { key: 'Enter' });
+ expect(actionFn).toHaveBeenCalled();
});
- it('resets background on mouse leave', () => {
- render();
- const menuItem = screen.getByRole('menuitem');
+ it('resets selection to 0 when search query changes', () => {
+ render();
+ const options = screen.getAllByRole('option');
- fireEvent.mouseEnter(menuItem);
- fireEvent.mouseLeave(menuItem);
- expect(menuItem.style.backgroundColor).toBe('transparent');
- });
+ // Move selection down
+ fireEvent.keyDown(document, { key: 'ArrowDown' });
+ expect(options[1]).toHaveAttribute('aria-selected', 'true');
- it('does not apply highlight on mouse enter when disabled', () => {
- render();
- const menuItem = screen.getByRole('menuitem');
- const initialBg = menuItem.style.backgroundColor;
+ // Change search — selection should reset
+ const input = screen.getByPlaceholderText('Search actions...');
+ fireEvent.change(input, { target: { value: 'Go' } });
- fireEvent.mouseEnter(menuItem);
- expect(menuItem.style.backgroundColor).toBe(initialBg);
+ // Only one option now, and it should be selected
+ const newOptions = screen.getAllByRole('option');
+ expect(newOptions[0]).toHaveAttribute('aria-selected', 'true');
});
});
- describe('Menu item styling', () => {
- it('has minimum touch target height', () => {
- render();
- const menuItem = screen.getByRole('menuitem');
- expect(menuItem).toHaveStyle({ minHeight: '44px' });
+ describe('Backdrop interaction', () => {
+ it('closes menu when backdrop is clicked', () => {
+ const onClose = vi.fn();
+ render();
+ const backdrop = document.querySelector('[aria-hidden="true"]') as HTMLElement;
+ fireEvent.click(backdrop);
+ expect(onClose).toHaveBeenCalled();
});
- it('has correct font styling', () => {
+ it('backdrop covers full viewport', () => {
render();
- const menuItem = screen.getByRole('menuitem');
- expect(menuItem).toHaveStyle({
- fontSize: '15px',
- fontWeight: '500',
+ const backdrop = document.querySelector('[aria-hidden="true"]') as HTMLElement;
+ expect(backdrop).toHaveStyle({
+ position: 'fixed',
+ top: '0px',
+ left: '0px',
+ right: '0px',
+ bottom: '0px',
});
});
- it('has cursor pointer when enabled', () => {
- render();
- const menuItem = screen.getByRole('menuitem');
- expect(menuItem).toHaveStyle({ cursor: 'pointer' });
- });
-
- it('has cursor default when disabled', () => {
- render();
- const menuItem = screen.getByRole('menuitem');
- expect(menuItem).toHaveStyle({ cursor: 'default' });
+ it('backdrop has semi-transparent background', () => {
+ render();
+ const backdrop = document.querySelector('[aria-hidden="true"]') as HTMLElement;
+ expect(backdrop.style.backgroundColor).toContain('rgba(0, 0, 0');
});
});
- describe('CSS keyframes injection', () => {
- it('injects quickActionsPopIn keyframes', () => {
+ describe('Accessibility', () => {
+ it('has role="dialog" on container', () => {
render();
- const styleElement = document.querySelector('style');
- expect(styleElement).toBeInTheDocument();
- expect(styleElement?.textContent).toContain('quickActionsPopIn');
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
});
- it('injects quickActionsFadeIn keyframes', () => {
+ it('has aria-label on dialog container', () => {
render();
- const styleElement = document.querySelector('style');
- expect(styleElement?.textContent).toContain('quickActionsFadeIn');
+ const dialog = screen.getByRole('dialog');
+ expect(dialog).toHaveAttribute('aria-label', 'Command palette');
});
- it('keyframes include transform: scale animation', () => {
+ it('has aria-modal on dialog container', () => {
render();
- const styleElement = document.querySelector('style');
- expect(styleElement?.textContent).toContain('scale(0.9)');
- expect(styleElement?.textContent).toContain('scale(1)');
+ const dialog = screen.getByRole('dialog');
+ expect(dialog).toHaveAttribute('aria-modal', 'true');
});
- });
- describe('Accessibility', () => {
- it('has role="menu" on container', () => {
+ it('has role="option" on action buttons', () => {
render();
- expect(screen.getByRole('menu')).toBeInTheDocument();
+ const options = screen.getAllByRole('option');
+ expect(options.length).toBeGreaterThan(0);
});
- it('has aria-label on menu container', () => {
+ it('backdrop has aria-hidden', () => {
render();
- const menu = screen.getByRole('menu');
- expect(menu).toHaveAttribute('aria-label', 'Quick actions');
+ const backdrop = document.querySelector('[aria-hidden="true"]');
+ expect(backdrop).toBeInTheDocument();
});
+ });
- it('has role="menuitem" on action buttons', () => {
+ describe('Menu styling', () => {
+ it('applies correct z-index', () => {
render();
- expect(screen.getByRole('menuitem')).toBeInTheDocument();
+ const dialog = screen.getByRole('dialog');
+ expect(dialog).toHaveStyle({ zIndex: '300' });
});
- it('has aria-disabled on disabled items', () => {
- render();
- const menuItem = screen.getByRole('menuitem');
- expect(menuItem).toHaveAttribute('aria-disabled', 'true');
+ it('has animation', () => {
+ render();
+ const dialog = screen.getByRole('dialog');
+ expect(dialog.style.animation).toContain('quickActionsPopIn');
});
- it('backdrop has aria-hidden', () => {
+ it('has proper border radius', () => {
render();
- const backdrop = document.querySelector('[aria-hidden="true"]');
- expect(backdrop).toBeInTheDocument();
+ const dialog = screen.getByRole('dialog');
+ expect(dialog).toHaveStyle({ borderRadius: '16px' });
});
});
- describe('Backdrop styling', () => {
- it('backdrop covers full viewport', () => {
+ describe('CSS keyframes injection', () => {
+ it('injects quickActionsPopIn keyframes', () => {
render();
- const backdrop = document.querySelector('[aria-hidden="true"]') as HTMLElement;
- expect(backdrop).toHaveStyle({
- position: 'fixed',
- top: '0px',
- left: '0px',
- right: '0px',
- bottom: '0px',
- });
+ const styleElement = document.querySelector('style');
+ expect(styleElement).toBeInTheDocument();
+ expect(styleElement?.textContent).toContain('quickActionsPopIn');
});
- it('backdrop has lower z-index than menu', () => {
+ it('injects quickActionsFadeIn keyframes', () => {
render();
- const backdrop = document.querySelector('[aria-hidden="true"]') as HTMLElement;
- const menu = screen.getByRole('menu');
- expect(backdrop).toHaveStyle({ zIndex: '199' });
- expect(menu).toHaveStyle({ zIndex: '200' });
+ const styleElement = document.querySelector('style');
+ expect(styleElement?.textContent).toContain('quickActionsFadeIn');
});
- it('backdrop has semi-transparent background', () => {
+ it('keyframes include transform: scale animation', () => {
render();
- const backdrop = document.querySelector('[aria-hidden="true"]') as HTMLElement;
- expect(backdrop.style.backgroundColor).toContain('rgba(0, 0, 0');
+ const styleElement = document.querySelector('style');
+ expect(styleElement?.textContent).toContain('scale(0.9');
+ expect(styleElement?.textContent).toContain('scale(1)');
});
+ });
- it('backdrop has fade animation', () => {
+ describe('Footer hint', () => {
+ it('renders keyboard navigation hints', () => {
render();
- const backdrop = document.querySelector('[aria-hidden="true"]') as HTMLElement;
- expect(backdrop.style.animation).toContain('quickActionsFadeIn');
+ expect(screen.getByText('navigate')).toBeInTheDocument();
+ expect(screen.getByText('select')).toBeInTheDocument();
+ expect(screen.getByText('close')).toBeInTheDocument();
});
});
- describe('Type exports', () => {
- it('QuickAction type is properly defined', () => {
- const action: QuickAction = 'switch_mode';
- expect(action).toBe('switch_mode');
+ describe('Touch feedback', () => {
+ it('applies highlight color on touch start', () => {
+ const actions = [makeAction({ id: 'touch-test', label: 'Touch Test' })];
+ render();
+ const option = screen.getByRole('option');
+
+ fireEvent.touchStart(option);
+ expect(option.style.backgroundColor).toContain('rgba(137, 180, 250');
});
- });
- describe('Edge cases', () => {
- it('handles anchor position at screen edge (0, 0)', () => {
- render();
- const menu = screen.getByRole('menu');
- // Should clamp to left edge minimum
- expect(menu.style.left).toBe('16px');
+ it('resets background on touch end', () => {
+ const actions = [makeAction({ id: 'touch-test', label: 'Touch Test' })];
+ render();
+ const option = screen.getByRole('option');
+
+ fireEvent.touchStart(option);
+ fireEvent.touchEnd(option);
+ // After touch end, reverts to either selected highlight or transparent
+ expect(option.style.backgroundColor).toBeTruthy();
});
+ });
- it('handles anchor position at bottom-right corner', () => {
- render();
- const menu = screen.getByRole('menu');
- // Should clamp to right edge
- expect(parseInt(menu.style.left)).toBeLessThanOrEqual(184);
+ describe('Type exports', () => {
+ it('CommandPaletteAction type is properly defined', () => {
+ const action: CommandPaletteAction = makeAction();
+ expect(action.id).toBeDefined();
+ expect(action.label).toBeDefined();
+ expect(action.category).toBeDefined();
});
+ });
+ describe('Edge cases', () => {
it('handles rapid open/close transitions', () => {
const { rerender } = render();
@@ -516,24 +483,28 @@ describe('QuickActionsMenu', () => {
rerender();
}
- expect(screen.getByRole('menu')).toBeInTheDocument();
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
});
- it('handles mode switching while open', () => {
- const { rerender } = render();
- expect(screen.getByText('Switch to Terminal')).toBeInTheDocument();
+ it('handles actions list updating while open', () => {
+ const { rerender } = render();
+ expect(screen.getByText('Go to Home')).toBeInTheDocument();
- rerender();
- expect(screen.getByText('Switch to AI')).toBeInTheDocument();
+ const newActions = [
+ makeAction({ id: 'new-action', label: 'New Action', category: 'Navigation' }),
+ ];
+ rerender();
+ expect(screen.getByText('New Action')).toBeInTheDocument();
+ expect(screen.queryByText('Go to Home')).not.toBeInTheDocument();
});
- it('handles hasActiveSession toggle while open', () => {
- const { rerender } = render();
- const menuItem = screen.getByRole('menuitem');
- expect(menuItem).not.toBeDisabled();
-
- rerender();
- expect(screen.getByRole('menuitem')).toBeDisabled();
+ it('handles a large number of actions', () => {
+ const manyActions: CommandPaletteAction[] = Array.from({ length: 50 }, (_, i) =>
+ makeAction({ id: `action-${i}`, label: `Action ${i}`, category: 'Navigation' })
+ );
+ render();
+ const options = screen.getAllByRole('option');
+ expect(options).toHaveLength(50);
});
});
diff --git a/src/main/preload/process.ts b/src/main/preload/process.ts
index 6fb18ec876..94bde82334 100644
--- a/src/main/preload/process.ts
+++ b/src/main/preload/process.ts
@@ -516,6 +516,569 @@ export function createProcessApi() {
ipcRenderer.send(responseChannel, result);
},
+ /**
+ * Subscribe to remote get auto-run docs from web interface (request-response)
+ */
+ onRemoteGetAutoRunDocs: (
+ callback: (sessionId: string, responseChannel: string) => void
+ ): (() => void) => {
+ const handler = (_: unknown, sessionId: string, responseChannel: string) => {
+ try {
+ Promise.resolve(callback(sessionId, responseChannel)).catch(() => {
+ ipcRenderer.send(responseChannel, []);
+ });
+ } catch {
+ ipcRenderer.send(responseChannel, []);
+ }
+ };
+ ipcRenderer.on('remote:getAutoRunDocs', handler);
+ return () => ipcRenderer.removeListener('remote:getAutoRunDocs', handler);
+ },
+
+ /**
+ * Send response for remote get auto-run docs
+ */
+ sendRemoteGetAutoRunDocsResponse: (responseChannel: string, documents: any[]): void => {
+ ipcRenderer.send(responseChannel, documents);
+ },
+
+ /**
+ * Subscribe to remote get auto-run doc content from web interface (request-response)
+ */
+ onRemoteGetAutoRunDocContent: (
+ callback: (sessionId: string, filename: string, responseChannel: string) => void
+ ): (() => void) => {
+ const handler = (
+ _: unknown,
+ sessionId: string,
+ filename: string,
+ responseChannel: string
+ ) => {
+ try {
+ Promise.resolve(callback(sessionId, filename, responseChannel)).catch(() => {
+ ipcRenderer.send(responseChannel, '');
+ });
+ } catch {
+ ipcRenderer.send(responseChannel, '');
+ }
+ };
+ ipcRenderer.on('remote:getAutoRunDocContent', handler);
+ return () => ipcRenderer.removeListener('remote:getAutoRunDocContent', handler);
+ },
+
+ /**
+ * Send response for remote get auto-run doc content
+ */
+ sendRemoteGetAutoRunDocContentResponse: (responseChannel: string, content: string): void => {
+ ipcRenderer.send(responseChannel, content);
+ },
+
+ /**
+ * Subscribe to remote save auto-run doc from web interface (request-response)
+ */
+ onRemoteSaveAutoRunDoc: (
+ callback: (
+ sessionId: string,
+ filename: string,
+ content: string,
+ responseChannel: string
+ ) => void
+ ): (() => void) => {
+ const handler = (
+ _: unknown,
+ sessionId: string,
+ filename: string,
+ content: string,
+ responseChannel: string
+ ) => {
+ try {
+ Promise.resolve(callback(sessionId, filename, content, responseChannel)).catch(() => {
+ ipcRenderer.send(responseChannel, false);
+ });
+ } catch {
+ ipcRenderer.send(responseChannel, false);
+ }
+ };
+ ipcRenderer.on('remote:saveAutoRunDoc', handler);
+ return () => ipcRenderer.removeListener('remote:saveAutoRunDoc', handler);
+ },
+
+ /**
+ * Send response for remote save auto-run doc
+ */
+ sendRemoteSaveAutoRunDocResponse: (responseChannel: string, success: boolean): void => {
+ ipcRenderer.send(responseChannel, success);
+ },
+
+ /**
+ * Subscribe to remote stop auto-run from web interface (fire-and-forget)
+ */
+ onRemoteStopAutoRun: (callback: (sessionId: string) => void): (() => void) => {
+ const handler = (_: unknown, sessionId: string) => callback(sessionId);
+ ipcRenderer.on('remote:stopAutoRun', handler);
+ return () => ipcRenderer.removeListener('remote:stopAutoRun', handler);
+ },
+
+ /**
+ * Subscribe to remote set setting from web interface
+ * Uses request-response pattern with a unique responseChannel
+ */
+ onRemoteSetSetting: (
+ callback: (key: string, value: unknown, responseChannel: string) => void
+ ): (() => void) => {
+ const handler = (_: unknown, key: string, value: unknown, responseChannel: string) =>
+ callback(key, value, responseChannel);
+ ipcRenderer.on('remote:setSetting', handler);
+ return () => ipcRenderer.removeListener('remote:setSetting', handler);
+ },
+
+ /**
+ * Send response for remote set setting
+ */
+ sendRemoteSetSettingResponse: (responseChannel: string, success: boolean): void => {
+ ipcRenderer.send(responseChannel, success);
+ },
+
+ /**
+ * Subscribe to remote create session from web interface
+ * Uses request-response pattern with a unique responseChannel
+ */
+ onRemoteCreateSession: (
+ callback: (
+ name: string,
+ toolType: string,
+ cwd: string,
+ groupId: string | undefined,
+ responseChannel: string
+ ) => void
+ ): (() => void) => {
+ const handler = (
+ _: unknown,
+ name: string,
+ toolType: string,
+ cwd: string,
+ groupId: string | undefined,
+ responseChannel: string
+ ) => callback(name, toolType, cwd, groupId, responseChannel);
+ ipcRenderer.on('remote:createSession', handler);
+ return () => ipcRenderer.removeListener('remote:createSession', handler);
+ },
+
+ /**
+ * Send response for remote create session
+ */
+ sendRemoteCreateSessionResponse: (
+ responseChannel: string,
+ result: { sessionId: string } | null
+ ): void => {
+ ipcRenderer.send(responseChannel, result);
+ },
+
+ /**
+ * Subscribe to remote delete session from web interface (fire-and-forget)
+ */
+ onRemoteDeleteSession: (callback: (sessionId: string) => void): (() => void) => {
+ const handler = (_: unknown, sessionId: string) => callback(sessionId);
+ ipcRenderer.on('remote:deleteSession', handler);
+ return () => ipcRenderer.removeListener('remote:deleteSession', handler);
+ },
+
+ /**
+ * Subscribe to remote rename session from web interface
+ * Uses request-response pattern with a unique responseChannel
+ */
+ onRemoteRenameSession: (
+ callback: (sessionId: string, newName: string, responseChannel: string) => void
+ ): (() => void) => {
+ const handler = (_: unknown, sessionId: string, newName: string, responseChannel: string) =>
+ callback(sessionId, newName, responseChannel);
+ ipcRenderer.on('remote:renameSession', handler);
+ return () => ipcRenderer.removeListener('remote:renameSession', handler);
+ },
+
+ /**
+ * Send response for remote rename session
+ */
+ sendRemoteRenameSessionResponse: (responseChannel: string, success: boolean): void => {
+ ipcRenderer.send(responseChannel, success);
+ },
+
+ /**
+ * Subscribe to remote create group from web interface
+ * Uses request-response pattern with a unique responseChannel
+ */
+ onRemoteCreateGroup: (
+ callback: (name: string, emoji: string | undefined, responseChannel: string) => void
+ ): (() => void) => {
+ const handler = (
+ _: unknown,
+ name: string,
+ emoji: string | undefined,
+ responseChannel: string
+ ) => callback(name, emoji, responseChannel);
+ ipcRenderer.on('remote:createGroup', handler);
+ return () => ipcRenderer.removeListener('remote:createGroup', handler);
+ },
+
+ /**
+ * Send response for remote create group
+ */
+ sendRemoteCreateGroupResponse: (
+ responseChannel: string,
+ result: { id: string } | null
+ ): void => {
+ ipcRenderer.send(responseChannel, result);
+ },
+
+ /**
+ * Subscribe to remote rename group from web interface
+ * Uses request-response pattern with a unique responseChannel
+ */
+ onRemoteRenameGroup: (
+ callback: (groupId: string, name: string, responseChannel: string) => void
+ ): (() => void) => {
+ const handler = (_: unknown, groupId: string, name: string, responseChannel: string) =>
+ callback(groupId, name, responseChannel);
+ ipcRenderer.on('remote:renameGroup', handler);
+ return () => ipcRenderer.removeListener('remote:renameGroup', handler);
+ },
+
+ /**
+ * Send response for remote rename group
+ */
+ sendRemoteRenameGroupResponse: (responseChannel: string, success: boolean): void => {
+ ipcRenderer.send(responseChannel, success);
+ },
+
+ /**
+ * Subscribe to remote delete group from web interface (fire-and-forget)
+ */
+ onRemoteDeleteGroup: (callback: (groupId: string) => void): (() => void) => {
+ const handler = (_: unknown, groupId: string) => callback(groupId);
+ ipcRenderer.on('remote:deleteGroup', handler);
+ return () => ipcRenderer.removeListener('remote:deleteGroup', handler);
+ },
+
+ /**
+ * Subscribe to remote move session to group from web interface
+ * Uses request-response pattern with a unique responseChannel
+ */
+ onRemoteMoveSessionToGroup: (
+ callback: (sessionId: string, groupId: string | null, responseChannel: string) => void
+ ): (() => void) => {
+ const handler = (
+ _: unknown,
+ sessionId: string,
+ groupId: string | null,
+ responseChannel: string
+ ) => callback(sessionId, groupId, responseChannel);
+ ipcRenderer.on('remote:moveSessionToGroup', handler);
+ return () => ipcRenderer.removeListener('remote:moveSessionToGroup', handler);
+ },
+
+ /**
+ * Send response for remote move session to group
+ */
+ sendRemoteMoveSessionToGroupResponse: (responseChannel: string, success: boolean): void => {
+ ipcRenderer.send(responseChannel, success);
+ },
+
+ /**
+ * Subscribe to remote get git status from web interface
+ * Uses request-response pattern with a unique responseChannel
+ */
+ onRemoteGetGitStatus: (
+ callback: (sessionId: string, responseChannel: string) => void
+ ): (() => void) => {
+ const handler = (_: unknown, sessionId: string, responseChannel: string) => {
+ try {
+ Promise.resolve(callback(sessionId, responseChannel)).catch(() => {
+ ipcRenderer.send(responseChannel, { branch: '', files: [], ahead: 0, behind: 0 });
+ });
+ } catch {
+ ipcRenderer.send(responseChannel, { branch: '', files: [], ahead: 0, behind: 0 });
+ }
+ };
+ ipcRenderer.on('remote:getGitStatus', handler);
+ return () => ipcRenderer.removeListener('remote:getGitStatus', handler);
+ },
+
+ /**
+ * Send response for remote get git status
+ */
+ sendRemoteGetGitStatusResponse: (responseChannel: string, result: any): void => {
+ ipcRenderer.send(responseChannel, result);
+ },
+
+ /**
+ * Subscribe to remote get git diff from web interface
+ * Uses request-response pattern with a unique responseChannel
+ */
+ onRemoteGetGitDiff: (
+ callback: (sessionId: string, filePath: string | undefined, responseChannel: string) => void
+ ): (() => void) => {
+ const handler = (
+ _: unknown,
+ sessionId: string,
+ filePath: string | undefined,
+ responseChannel: string
+ ) => {
+ try {
+ Promise.resolve(callback(sessionId, filePath, responseChannel)).catch(() => {
+ ipcRenderer.send(responseChannel, { diff: '', files: [] });
+ });
+ } catch {
+ ipcRenderer.send(responseChannel, { diff: '', files: [] });
+ }
+ };
+ ipcRenderer.on('remote:getGitDiff', handler);
+ return () => ipcRenderer.removeListener('remote:getGitDiff', handler);
+ },
+
+ /**
+ * Send response for remote get git diff
+ */
+ sendRemoteGetGitDiffResponse: (responseChannel: string, result: any): void => {
+ ipcRenderer.send(responseChannel, result);
+ },
+
+ /**
+ * Subscribe to remote get group chats from web interface
+ * Uses request-response pattern with a unique responseChannel
+ */
+ onRemoteGetGroupChats: (callback: (responseChannel: string) => void): (() => void) => {
+ const handler = (_: unknown, responseChannel: string) => callback(responseChannel);
+ ipcRenderer.on('remote:getGroupChats', handler);
+ return () => ipcRenderer.removeListener('remote:getGroupChats', handler);
+ },
+
+ /**
+ * Send response for remote get group chats
+ */
+ sendRemoteGetGroupChatsResponse: (responseChannel: string, result: any): void => {
+ ipcRenderer.send(responseChannel, result);
+ },
+
+ /**
+ * Subscribe to remote start group chat from web interface
+ * Uses request-response pattern with a unique responseChannel
+ */
+ onRemoteStartGroupChat: (
+ callback: (topic: string, participantIds: string[], responseChannel: string) => void
+ ): (() => void) => {
+ const handler = (
+ _: unknown,
+ topic: string,
+ participantIds: string[],
+ responseChannel: string
+ ) => callback(topic, participantIds, responseChannel);
+ ipcRenderer.on('remote:startGroupChat', handler);
+ return () => ipcRenderer.removeListener('remote:startGroupChat', handler);
+ },
+
+ /**
+ * Send response for remote start group chat
+ */
+ sendRemoteStartGroupChatResponse: (
+ responseChannel: string,
+ result: { chatId: string } | null
+ ): void => {
+ ipcRenderer.send(responseChannel, result);
+ },
+
+ /**
+ * Subscribe to remote get group chat state from web interface
+ * Uses request-response pattern with a unique responseChannel
+ */
+ onRemoteGetGroupChatState: (
+ callback: (chatId: string, responseChannel: string) => void
+ ): (() => void) => {
+ const handler = (_: unknown, chatId: string, responseChannel: string) =>
+ callback(chatId, responseChannel);
+ ipcRenderer.on('remote:getGroupChatState', handler);
+ return () => ipcRenderer.removeListener('remote:getGroupChatState', handler);
+ },
+
+ /**
+ * Send response for remote get group chat state
+ */
+ sendRemoteGetGroupChatStateResponse: (responseChannel: string, result: any): void => {
+ ipcRenderer.send(responseChannel, result);
+ },
+
+ /**
+ * Subscribe to remote stop group chat from web interface
+ * Uses request-response pattern with a unique responseChannel
+ */
+ onRemoteStopGroupChat: (
+ callback: (chatId: string, responseChannel: string) => void
+ ): (() => void) => {
+ const handler = (_: unknown, chatId: string, responseChannel: string) =>
+ callback(chatId, responseChannel);
+ ipcRenderer.on('remote:stopGroupChat', handler);
+ return () => ipcRenderer.removeListener('remote:stopGroupChat', handler);
+ },
+
+ /**
+ * Send response for remote stop group chat
+ */
+ sendRemoteStopGroupChatResponse: (responseChannel: string, success: boolean): void => {
+ ipcRenderer.send(responseChannel, success);
+ },
+
+ /**
+ * Subscribe to remote send group chat message from web interface
+ * Uses request-response pattern with a unique responseChannel
+ */
+ onRemoteSendGroupChatMessage: (
+ callback: (chatId: string, message: string, responseChannel: string) => void
+ ): (() => void) => {
+ const handler = (_: unknown, chatId: string, message: string, responseChannel: string) =>
+ callback(chatId, message, responseChannel);
+ ipcRenderer.on('remote:sendGroupChatMessage', handler);
+ return () => ipcRenderer.removeListener('remote:sendGroupChatMessage', handler);
+ },
+
+ /**
+ * Send response for remote send group chat message
+ */
+ sendRemoteSendGroupChatMessageResponse: (responseChannel: string, success: boolean): void => {
+ ipcRenderer.send(responseChannel, success);
+ },
+
+ /**
+ * Subscribe to remote merge context from web interface
+ * Uses request-response pattern with a unique responseChannel
+ */
+ onRemoteMergeContext: (
+ callback: (sourceSessionId: string, targetSessionId: string, responseChannel: string) => void
+ ): (() => void) => {
+ const handler = (
+ _: unknown,
+ sourceSessionId: string,
+ targetSessionId: string,
+ responseChannel: string
+ ) => callback(sourceSessionId, targetSessionId, responseChannel);
+ ipcRenderer.on('remote:mergeContext', handler);
+ return () => ipcRenderer.removeListener('remote:mergeContext', handler);
+ },
+
+ /**
+ * Send response for remote merge context
+ */
+ sendRemoteMergeContextResponse: (responseChannel: string, success: boolean): void => {
+ ipcRenderer.send(responseChannel, success);
+ },
+
+ /**
+ * Subscribe to remote transfer context from web interface
+ * Uses request-response pattern with a unique responseChannel
+ */
+ onRemoteTransferContext: (
+ callback: (sourceSessionId: string, targetSessionId: string, responseChannel: string) => void
+ ): (() => void) => {
+ const handler = (
+ _: unknown,
+ sourceSessionId: string,
+ targetSessionId: string,
+ responseChannel: string
+ ) => callback(sourceSessionId, targetSessionId, responseChannel);
+ ipcRenderer.on('remote:transferContext', handler);
+ return () => ipcRenderer.removeListener('remote:transferContext', handler);
+ },
+
+ /**
+ * Send response for remote transfer context
+ */
+ sendRemoteTransferContextResponse: (responseChannel: string, success: boolean): void => {
+ ipcRenderer.send(responseChannel, success);
+ },
+
+ /**
+ * Subscribe to remote summarize context from web interface
+ * Uses request-response pattern with a unique responseChannel
+ */
+ onRemoteSummarizeContext: (
+ callback: (sessionId: string, responseChannel: string) => void
+ ): (() => void) => {
+ const handler = (_: unknown, sessionId: string, responseChannel: string) =>
+ callback(sessionId, responseChannel);
+ ipcRenderer.on('remote:summarizeContext', handler);
+ return () => ipcRenderer.removeListener('remote:summarizeContext', handler);
+ },
+
+ /**
+ * Send response for remote summarize context
+ */
+ sendRemoteSummarizeContextResponse: (responseChannel: string, success: boolean): void => {
+ ipcRenderer.send(responseChannel, success);
+ },
+
+ /**
+ * Subscribe to remote get Cue subscriptions from web interface
+ */
+ onRemoteGetCueSubscriptions: (
+ callback: (sessionId: string | undefined, responseChannel: string) => void
+ ): (() => void) => {
+ const handler = (_: unknown, sessionId: string | undefined, responseChannel: string) =>
+ callback(sessionId, responseChannel);
+ ipcRenderer.on('remote:getCueSubscriptions', handler);
+ return () => ipcRenderer.removeListener('remote:getCueSubscriptions', handler);
+ },
+
+ /**
+ * Send response for remote get Cue subscriptions
+ */
+ sendRemoteGetCueSubscriptionsResponse: (responseChannel: string, result: unknown): void => {
+ ipcRenderer.send(responseChannel, result);
+ },
+
+ /**
+ * Subscribe to remote toggle Cue subscription from web interface
+ */
+ onRemoteToggleCueSubscription: (
+ callback: (subscriptionId: string, enabled: boolean, responseChannel: string) => void
+ ): (() => void) => {
+ const handler = (
+ _: unknown,
+ subscriptionId: string,
+ enabled: boolean,
+ responseChannel: string
+ ) => callback(subscriptionId, enabled, responseChannel);
+ ipcRenderer.on('remote:toggleCueSubscription', handler);
+ return () => ipcRenderer.removeListener('remote:toggleCueSubscription', handler);
+ },
+
+ /**
+ * Send response for remote toggle Cue subscription
+ */
+ sendRemoteToggleCueSubscriptionResponse: (responseChannel: string, success: boolean): void => {
+ ipcRenderer.send(responseChannel, success);
+ },
+
+ /**
+ * Subscribe to remote get Cue activity from web interface
+ */
+ onRemoteGetCueActivity: (
+ callback: (sessionId: string | undefined, limit: number, responseChannel: string) => void
+ ): (() => void) => {
+ const handler = (
+ _: unknown,
+ sessionId: string | undefined,
+ limit: number,
+ responseChannel: string
+ ) => callback(sessionId, limit, responseChannel);
+ ipcRenderer.on('remote:getCueActivity', handler);
+ return () => ipcRenderer.removeListener('remote:getCueActivity', handler);
+ },
+
+ /**
+ * Send response for remote get Cue activity
+ */
+ sendRemoteGetCueActivityResponse: (responseChannel: string, result: unknown): void => {
+ ipcRenderer.send(responseChannel, result);
+ },
+
/**
* Subscribe to stderr from runCommand (separate stream)
*/
diff --git a/src/main/web-server/WebServer.ts b/src/main/web-server/WebServer.ts
index 064c1a99fc..36fcb34ea5 100644
--- a/src/main/web-server/WebServer.ts
+++ b/src/main/web-server/WebServer.ts
@@ -44,10 +44,13 @@ import type {
AITabData,
CustomAICommand,
AutoRunState,
+ AutoRunDocument,
CliActivity,
+ NotificationEvent,
SessionBroadcastData,
WebClient,
WebClientMessage,
+ WebSettings,
GetSessionsCallback,
GetSessionDetailCallback,
WriteToSessionCallback,
@@ -69,6 +72,40 @@ import type {
GetThemeCallback,
GetCustomCommandsCallback,
GetHistoryCallback,
+ GetAutoRunDocsCallback,
+ GetAutoRunDocContentCallback,
+ SaveAutoRunDocCallback,
+ StopAutoRunCallback,
+ GetSettingsCallback,
+ SetSettingCallback,
+ GetGroupsCallback,
+ CreateGroupCallback,
+ RenameGroupCallback,
+ DeleteGroupCallback,
+ MoveSessionToGroupCallback,
+ CreateSessionCallback,
+ DeleteSessionCallback,
+ RenameSessionCallback,
+ GetGitStatusCallback,
+ GetGitDiffCallback,
+ GroupData,
+ GetGroupChatsCallback,
+ StartGroupChatCallback,
+ GetGroupChatStateCallback,
+ StopGroupChatCallback,
+ SendGroupChatMessageCallback,
+ GroupChatMessage,
+ GroupChatState,
+ MergeContextCallback,
+ TransferContextCallback,
+ SummarizeContextCallback,
+ GetCueSubscriptionsCallback,
+ ToggleCueSubscriptionCallback,
+ GetCueActivityCallback,
+ CueActivityEntry,
+ CueSubscriptionInfo,
+ GetUsageDashboardCallback,
+ GetAchievementsCallback,
} from './types';
// Logger context for all web server logs
@@ -330,6 +367,126 @@ export class WebServer {
this.callbackRegistry.setGetHistoryCallback(callback);
}
+ setGetAutoRunDocsCallback(callback: GetAutoRunDocsCallback): void {
+ this.callbackRegistry.setGetAutoRunDocsCallback(callback);
+ }
+
+ setGetAutoRunDocContentCallback(callback: GetAutoRunDocContentCallback): void {
+ this.callbackRegistry.setGetAutoRunDocContentCallback(callback);
+ }
+
+ setSaveAutoRunDocCallback(callback: SaveAutoRunDocCallback): void {
+ this.callbackRegistry.setSaveAutoRunDocCallback(callback);
+ }
+
+ setStopAutoRunCallback(callback: StopAutoRunCallback): void {
+ this.callbackRegistry.setStopAutoRunCallback(callback);
+ }
+
+ setGetSettingsCallback(callback: GetSettingsCallback): void {
+ this.callbackRegistry.setGetSettingsCallback(callback);
+ }
+
+ setSetSettingCallback(callback: SetSettingCallback): void {
+ this.callbackRegistry.setSetSettingCallback(callback);
+ }
+
+ setGetGroupsCallback(callback: GetGroupsCallback): void {
+ this.callbackRegistry.setGetGroupsCallback(callback);
+ }
+
+ setCreateGroupCallback(callback: CreateGroupCallback): void {
+ this.callbackRegistry.setCreateGroupCallback(callback);
+ }
+
+ setRenameGroupCallback(callback: RenameGroupCallback): void {
+ this.callbackRegistry.setRenameGroupCallback(callback);
+ }
+
+ setDeleteGroupCallback(callback: DeleteGroupCallback): void {
+ this.callbackRegistry.setDeleteGroupCallback(callback);
+ }
+
+ setMoveSessionToGroupCallback(callback: MoveSessionToGroupCallback): void {
+ this.callbackRegistry.setMoveSessionToGroupCallback(callback);
+ }
+
+ setCreateSessionCallback(callback: CreateSessionCallback): void {
+ this.callbackRegistry.setCreateSessionCallback(callback);
+ }
+
+ setDeleteSessionCallback(callback: DeleteSessionCallback): void {
+ this.callbackRegistry.setDeleteSessionCallback(callback);
+ }
+
+ setRenameSessionCallback(callback: RenameSessionCallback): void {
+ this.callbackRegistry.setRenameSessionCallback(callback);
+ }
+
+ setGetGitStatusCallback(callback: GetGitStatusCallback): void {
+ this.callbackRegistry.setGetGitStatusCallback(callback);
+ }
+
+ setGetGitDiffCallback(callback: GetGitDiffCallback): void {
+ this.callbackRegistry.setGetGitDiffCallback(callback);
+ }
+
+ setGetGroupChatsCallback(callback: GetGroupChatsCallback): void {
+ this.callbackRegistry.setGetGroupChatsCallback(callback);
+ }
+
+ setStartGroupChatCallback(callback: StartGroupChatCallback): void {
+ this.callbackRegistry.setStartGroupChatCallback(callback);
+ }
+
+ setGetGroupChatStateCallback(callback: GetGroupChatStateCallback): void {
+ this.callbackRegistry.setGetGroupChatStateCallback(callback);
+ }
+
+ setStopGroupChatCallback(callback: StopGroupChatCallback): void {
+ this.callbackRegistry.setStopGroupChatCallback(callback);
+ }
+
+ setSendGroupChatMessageCallback(callback: SendGroupChatMessageCallback): void {
+ this.callbackRegistry.setSendGroupChatMessageCallback(callback);
+ }
+
+ setMergeContextCallback(callback: MergeContextCallback): void {
+ this.callbackRegistry.setMergeContextCallback(callback);
+ }
+
+ setTransferContextCallback(callback: TransferContextCallback): void {
+ this.callbackRegistry.setTransferContextCallback(callback);
+ }
+
+ setSummarizeContextCallback(callback: SummarizeContextCallback): void {
+ this.callbackRegistry.setSummarizeContextCallback(callback);
+ }
+
+ setGetCueSubscriptionsCallback(callback: GetCueSubscriptionsCallback): void {
+ this.callbackRegistry.setGetCueSubscriptionsCallback(callback);
+ }
+
+ setToggleCueSubscriptionCallback(callback: ToggleCueSubscriptionCallback): void {
+ this.callbackRegistry.setToggleCueSubscriptionCallback(callback);
+ }
+
+ setGetCueActivityCallback(callback: GetCueActivityCallback): void {
+ this.callbackRegistry.setGetCueActivityCallback(callback);
+ }
+
+ setGetUsageDashboardCallback(callback: GetUsageDashboardCallback): void {
+ this.callbackRegistry.setGetUsageDashboardCallback(callback);
+ }
+
+ setGetAchievementsCallback(callback: GetAchievementsCallback): void {
+ this.callbackRegistry.setGetAchievementsCallback(callback);
+ }
+
+ broadcastGroupsChanged(groups: GroupData[]): void {
+ this.broadcastService.broadcastGroupsChanged(groups);
+ }
+
// ============ Rate Limiting ============
setRateLimitConfig(config: Partial): void {
@@ -482,12 +639,60 @@ export class WebServer {
this.callbackRegistry.refreshFileTree(sessionId),
refreshAutoRunDocs: async (sessionId: string) =>
this.callbackRegistry.refreshAutoRunDocs(sessionId),
- configureAutoRun: async (sessionId: string, config: any) =>
- this.callbackRegistry.configureAutoRun(sessionId, config),
+ configureAutoRun: async (
+ sessionId: string,
+ config: Parameters[1]
+ ) => this.callbackRegistry.configureAutoRun(sessionId, config),
getSessions: () => this.callbackRegistry.getSessions(),
getLiveSessionInfo: (sessionId: string) =>
this.liveSessionManager.getLiveSessionInfo(sessionId),
isSessionLive: (sessionId: string) => this.liveSessionManager.isSessionLive(sessionId),
+ getAutoRunDocs: async (sessionId: string) => this.callbackRegistry.getAutoRunDocs(sessionId),
+ getAutoRunDocContent: async (sessionId: string, filename: string) =>
+ this.callbackRegistry.getAutoRunDocContent(sessionId, filename),
+ saveAutoRunDoc: async (sessionId: string, filename: string, content: string) =>
+ this.callbackRegistry.saveAutoRunDoc(sessionId, filename, content),
+ stopAutoRun: async (sessionId: string) => this.callbackRegistry.stopAutoRun(sessionId),
+ getSettings: () => this.callbackRegistry.getSettings(),
+ setSetting: async (key: string, value: any) => this.callbackRegistry.setSetting(key, value),
+ getGroups: () => this.callbackRegistry.getGroups(),
+ createGroup: async (name: string, emoji?: string) =>
+ this.callbackRegistry.createGroup(name, emoji),
+ renameGroup: async (groupId: string, name: string) =>
+ this.callbackRegistry.renameGroup(groupId, name),
+ deleteGroup: async (groupId: string) => this.callbackRegistry.deleteGroup(groupId),
+ moveSessionToGroup: async (sessionId: string, groupId: string | null) =>
+ this.callbackRegistry.moveSessionToGroup(sessionId, groupId),
+ createSession: async (name: string, toolType: string, cwd: string, groupId?: string) =>
+ this.callbackRegistry.createSession(name, toolType, cwd, groupId),
+ deleteSession: async (sessionId: string) => this.callbackRegistry.deleteSession(sessionId),
+ renameSession: async (sessionId: string, newName: string) =>
+ this.callbackRegistry.renameSession(sessionId, newName),
+ getGitStatus: async (sessionId: string) => this.callbackRegistry.getGitStatus(sessionId),
+ getGitDiff: async (sessionId: string, filePath?: string) =>
+ this.callbackRegistry.getGitDiff(sessionId, filePath),
+ getGroupChats: async () => this.callbackRegistry.getGroupChats(),
+ startGroupChat: async (topic: string, participantIds: string[]) =>
+ this.callbackRegistry.startGroupChat(topic, participantIds),
+ getGroupChatState: async (chatId: string) => this.callbackRegistry.getGroupChatState(chatId),
+ stopGroupChat: async (chatId: string) => this.callbackRegistry.stopGroupChat(chatId),
+ sendGroupChatMessage: async (chatId: string, message: string) =>
+ this.callbackRegistry.sendGroupChatMessage(chatId, message),
+ mergeContext: async (sourceSessionId: string, targetSessionId: string) =>
+ this.callbackRegistry.mergeContext(sourceSessionId, targetSessionId),
+ transferContext: async (sourceSessionId: string, targetSessionId: string) =>
+ this.callbackRegistry.transferContext(sourceSessionId, targetSessionId),
+ summarizeContext: async (sessionId: string) =>
+ this.callbackRegistry.summarizeContext(sessionId),
+ getCueSubscriptions: async (sessionId?: string) =>
+ this.callbackRegistry.getCueSubscriptions(sessionId),
+ toggleCueSubscription: async (subscriptionId: string, enabled: boolean) =>
+ this.callbackRegistry.toggleCueSubscription(subscriptionId, enabled),
+ getCueActivity: async (sessionId?: string, limit?: number) =>
+ this.callbackRegistry.getCueActivity(sessionId, limit),
+ getUsageDashboard: async (timeRange: 'day' | 'week' | 'month' | 'all') =>
+ this.callbackRegistry.getUsageDashboard(timeRange),
+ getAchievements: async () => this.callbackRegistry.getAchievements(),
});
}
@@ -497,6 +702,10 @@ export class WebServer {
this.broadcastService.broadcastToAll(message);
}
+ broadcastNotificationEvent(event: NotificationEvent): void {
+ this.broadcastService.broadcastNotificationEvent(event);
+ }
+
broadcastToSessionClients(sessionId: string, message: object): void {
this.broadcastService.broadcastToSession(sessionId, message);
}
@@ -543,14 +752,46 @@ export class WebServer {
this.broadcastService.broadcastCustomCommands(commands);
}
+ broadcastSettingsChanged(settings: WebSettings): void {
+ this.broadcastService.broadcastSettingsChanged(settings);
+ }
+
broadcastAutoRunState(sessionId: string, state: AutoRunState | null): void {
this.liveSessionManager.setAutoRunState(sessionId, state);
}
+ broadcastAutoRunDocsChanged(sessionId: string, documents: AutoRunDocument[]): void {
+ this.broadcastService.broadcastAutoRunDocsChanged(sessionId, documents);
+ }
+
broadcastUserInput(sessionId: string, command: string, inputMode: 'ai' | 'terminal'): void {
this.broadcastService.broadcastUserInput(sessionId, command, inputMode);
}
+ broadcastGroupChatMessage(chatId: string, message: GroupChatMessage): void {
+ this.broadcastService.broadcastGroupChatMessage(chatId, message);
+ }
+
+ broadcastGroupChatStateChange(chatId: string, state: Partial): void {
+ this.broadcastService.broadcastGroupChatStateChange(chatId, state);
+ }
+
+ broadcastContextOperationProgress(sessionId: string, operation: string, progress: number): void {
+ this.broadcastService.broadcastContextOperationProgress(sessionId, operation, progress);
+ }
+
+ broadcastContextOperationComplete(sessionId: string, operation: string, success: boolean): void {
+ this.broadcastService.broadcastContextOperationComplete(sessionId, operation, success);
+ }
+
+ broadcastCueActivity(entry: CueActivityEntry): void {
+ this.broadcastService.broadcastCueActivity(entry);
+ }
+
+ broadcastCueSubscriptionsChanged(subscriptions: CueSubscriptionInfo[]): void {
+ this.broadcastService.broadcastCueSubscriptionsChanged(subscriptions);
+ }
+
// ============ Server Lifecycle ============
getWebClientCount(): number {
diff --git a/src/main/web-server/handlers/messageHandlers.ts b/src/main/web-server/handlers/messageHandlers.ts
index 2799ea59b6..3c5c52770d 100644
--- a/src/main/web-server/handlers/messageHandlers.ts
+++ b/src/main/web-server/handlers/messageHandlers.ts
@@ -20,11 +20,32 @@
* - refresh_file_tree: Refresh the file tree for a session
* - refresh_auto_run_docs: Refresh auto-run documents for a session
* - configure_auto_run: Configure and optionally launch an auto-run session
+ * - get_auto_run_docs: List auto-run documents for a session
+ * - get_auto_run_state: Get current auto-run state for a session
+ * - get_auto_run_document: Read content of a specific auto-run document
+ * - save_auto_run_document: Write content to a specific auto-run document
+ * - stop_auto_run: Stop an active auto-run for a session
+ * - get_settings: Fetch current web settings
+ * - set_setting: Modify a single setting (allowlisted keys only)
*/
import path from 'path';
import { WebSocket } from 'ws';
import { logger } from '../../utils/logger';
+import type {
+ AutoRunDocument,
+ AutoRunState,
+ WebSettings,
+ SettingValue,
+ GroupData,
+ GitStatusResult,
+ GitDiffResult,
+ GroupChatState,
+ CueSubscriptionInfo,
+ CueActivityEntry,
+ UsageDashboardData,
+ AchievementData,
+} from '../types';
// Logger context for all message handler logs
const LOG_CONTEXT = 'WebServer';
@@ -118,6 +139,40 @@ export interface MessageHandlerCallbacks {
}>;
getLiveSessionInfo: (sessionId: string) => LiveSessionInfo | undefined;
isSessionLive: (sessionId: string) => boolean;
+ getAutoRunDocs: (sessionId: string) => Promise;
+ getAutoRunDocContent: (sessionId: string, filename: string) => Promise;
+ saveAutoRunDoc: (sessionId: string, filename: string, content: string) => Promise;
+ stopAutoRun: (sessionId: string) => Promise;
+ getSettings: () => WebSettings;
+ setSetting: (key: string, value: SettingValue) => Promise;
+ getGroups: () => GroupData[];
+ createGroup: (name: string, emoji?: string) => Promise<{ id: string } | null>;
+ renameGroup: (groupId: string, name: string) => Promise;
+ deleteGroup: (groupId: string) => Promise;
+ moveSessionToGroup: (sessionId: string, groupId: string | null) => Promise;
+ createSession: (
+ name: string,
+ toolType: string,
+ cwd: string,
+ groupId?: string
+ ) => Promise<{ sessionId: string } | null>;
+ deleteSession: (sessionId: string) => Promise;
+ renameSession: (sessionId: string, newName: string) => Promise;
+ getGitStatus: (sessionId: string) => Promise;
+ getGitDiff: (sessionId: string, filePath?: string) => Promise;
+ getGroupChats: () => Promise;
+ startGroupChat: (topic: string, participantIds: string[]) => Promise<{ chatId: string } | null>;
+ getGroupChatState: (chatId: string) => Promise;
+ stopGroupChat: (chatId: string) => Promise;
+ sendGroupChatMessage: (chatId: string, message: string) => Promise;
+ mergeContext: (sourceSessionId: string, targetSessionId: string) => Promise;
+ transferContext: (sourceSessionId: string, targetSessionId: string) => Promise;
+ summarizeContext: (sessionId: string) => Promise;
+ getCueSubscriptions: (sessionId?: string) => Promise;
+ toggleCueSubscription: (subscriptionId: string, enabled: boolean) => Promise;
+ getCueActivity: (sessionId?: string, limit?: number) => Promise;
+ getUsageDashboard: (timeRange: 'day' | 'week' | 'month' | 'all') => Promise;
+ getAchievements: () => Promise;
}
/**
@@ -232,6 +287,126 @@ export class WebSocketMessageHandler {
this.handleConfigureAutoRun(client, message);
break;
+ case 'get_auto_run_docs':
+ this.handleGetAutoRunDocs(client, message);
+ break;
+
+ case 'get_auto_run_state':
+ this.handleGetAutoRunState(client, message);
+ break;
+
+ case 'get_auto_run_document':
+ this.handleGetAutoRunDocument(client, message);
+ break;
+
+ case 'save_auto_run_document':
+ this.handleSaveAutoRunDocument(client, message);
+ break;
+
+ case 'stop_auto_run':
+ this.handleStopAutoRun(client, message);
+ break;
+
+ case 'get_settings':
+ this.handleGetSettings(client, message);
+ break;
+
+ case 'set_setting':
+ this.handleSetSetting(client, message);
+ break;
+
+ case 'create_session':
+ this.handleCreateSession(client, message);
+ break;
+
+ case 'delete_session':
+ this.handleDeleteSession(client, message);
+ break;
+
+ case 'rename_session':
+ this.handleRenameSession(client, message);
+ break;
+
+ case 'get_groups':
+ this.handleGetGroups(client, message);
+ break;
+
+ case 'create_group':
+ this.handleCreateGroup(client, message);
+ break;
+
+ case 'rename_group':
+ this.handleRenameGroup(client, message);
+ break;
+
+ case 'delete_group':
+ this.handleDeleteGroup(client, message);
+ break;
+
+ case 'move_session_to_group':
+ this.handleMoveSessionToGroup(client, message);
+ break;
+
+ case 'get_git_status':
+ this.handleGetGitStatus(client, message);
+ break;
+
+ case 'get_git_diff':
+ this.handleGetGitDiff(client, message);
+ break;
+
+ case 'get_group_chats':
+ this.handleGetGroupChats(client, message);
+ break;
+
+ case 'start_group_chat':
+ this.handleStartGroupChat(client, message);
+ break;
+
+ case 'get_group_chat_state':
+ this.handleGetGroupChatState(client, message);
+ break;
+
+ case 'send_group_chat_message':
+ this.handleSendGroupChatMessage(client, message);
+ break;
+
+ case 'stop_group_chat':
+ this.handleStopGroupChat(client, message);
+ break;
+
+ case 'merge_context':
+ this.handleMergeContext(client, message);
+ break;
+
+ case 'transfer_context':
+ this.handleTransferContext(client, message);
+ break;
+
+ case 'summarize_context':
+ this.handleSummarizeContext(client, message);
+ break;
+
+ case 'get_cue_subscriptions':
+ this.handleGetCueSubscriptions(client, message);
+ break;
+
+ case 'toggle_cue_subscription':
+ this.handleToggleCueSubscription(client, message);
+ break;
+
+ case 'get_cue_activity':
+ this.handleGetCueActivity(client, message);
+ break;
+
+ case 'get_usage_dashboard':
+ this.handleGetUsageDashboard(client, message);
+ break;
+
+ case 'get_achievements':
+ this.handleGetAchievements(client, message);
+ break;
+
default:
this.handleUnknown(client, message);
}
@@ -941,6 +1116,1047 @@ export class WebSocketMessageHandler {
});
}
+ /**
+ * Validate that a filename does not contain path traversal sequences.
+ * Returns true if the filename is safe, false otherwise.
+ */
+ private isValidFilename(filename: string): boolean {
+ return (
+ typeof filename === 'string' &&
+ filename.length > 0 &&
+ !filename.includes('..') &&
+ !filename.includes('/') &&
+ !filename.includes('\\')
+ );
+ }
+
+ /**
+ * Handle get_auto_run_docs message - list Auto Run documents for a session
+ */
+ private handleGetAutoRunDocs(client: WebClient, message: WebClientMessage): void {
+ const sessionId = message.sessionId as string;
+ logger.info(`[Web] Received get_auto_run_docs message: session=${sessionId}`, LOG_CONTEXT);
+
+ if (!sessionId) {
+ this.sendError(client, 'Missing sessionId');
+ return;
+ }
+
+ if (!this.callbacks.getAutoRunDocs) {
+ this.sendError(client, 'Auto-run docs listing not configured');
+ return;
+ }
+
+ this.callbacks
+ .getAutoRunDocs(sessionId)
+ .then((documents) => {
+ this.send(client, {
+ type: 'auto_run_docs',
+ sessionId,
+ documents,
+ requestId: message.requestId,
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to get auto-run docs: ${error.message}`);
+ });
+ }
+
+ /**
+ * Handle get_auto_run_state message - get current Auto Run state for a session
+ */
+ private handleGetAutoRunState(client: WebClient, message: WebClientMessage): void {
+ const sessionId = message.sessionId as string;
+ logger.info(`[Web] Received get_auto_run_state message: session=${sessionId}`, LOG_CONTEXT);
+
+ if (!sessionId) {
+ this.sendError(client, 'Missing sessionId');
+ return;
+ }
+
+ if (!this.callbacks.getSessionDetail) {
+ this.sendError(client, 'Session detail not configured');
+ return;
+ }
+
+ const detail = this.callbacks.getSessionDetail(sessionId);
+ const state: AutoRunState | null =
+ ((detail as any)?.autoRunState as AutoRunState | null) ?? null;
+
+ this.send(client, {
+ type: 'auto_run_state',
+ sessionId,
+ state,
+ requestId: message.requestId,
+ });
+ }
+
+ /**
+ * Handle get_auto_run_document message - read content of a specific Auto Run document
+ */
+ private handleGetAutoRunDocument(client: WebClient, message: WebClientMessage): void {
+ const sessionId = message.sessionId as string;
+ const filename = message.filename as string;
+ logger.info(
+ `[Web] Received get_auto_run_document message: session=${sessionId}, filename=${filename}`,
+ LOG_CONTEXT
+ );
+
+ if (!sessionId || !filename) {
+ this.sendError(client, 'Missing sessionId or filename');
+ return;
+ }
+
+ if (!this.isValidFilename(filename)) {
+ this.sendError(
+ client,
+ 'Invalid filename: must not contain path separators or traversal sequences'
+ );
+ return;
+ }
+
+ if (!this.callbacks.getAutoRunDocContent) {
+ this.sendError(client, 'Auto-run document reading not configured');
+ return;
+ }
+
+ this.callbacks
+ .getAutoRunDocContent(sessionId, filename)
+ .then((content) => {
+ this.send(client, {
+ type: 'auto_run_document_content',
+ sessionId,
+ filename,
+ content,
+ requestId: message.requestId,
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to read auto-run document: ${error.message}`);
+ });
+ }
+
+ /**
+ * Handle save_auto_run_document message - write content to a specific Auto Run document
+ */
+ private handleSaveAutoRunDocument(client: WebClient, message: WebClientMessage): void {
+ const sessionId = message.sessionId as string;
+ const filename = message.filename as string;
+ const content = message.content as string;
+ logger.info(
+ `[Web] Received save_auto_run_document message: session=${sessionId}, filename=${filename}`,
+ LOG_CONTEXT
+ );
+
+ if (!sessionId || !filename) {
+ this.sendError(client, 'Missing sessionId or filename');
+ return;
+ }
+
+ if (typeof content !== 'string') {
+ this.sendError(client, 'Missing or invalid content');
+ return;
+ }
+
+ if (!this.isValidFilename(filename)) {
+ this.sendError(
+ client,
+ 'Invalid filename: must not contain path separators or traversal sequences'
+ );
+ return;
+ }
+
+ if (!this.callbacks.saveAutoRunDoc) {
+ this.sendError(client, 'Auto-run document saving not configured');
+ return;
+ }
+
+ this.callbacks
+ .saveAutoRunDoc(sessionId, filename, content)
+ .then((success) => {
+ this.send(client, {
+ type: 'save_auto_run_document_result',
+ success,
+ sessionId,
+ filename,
+ requestId: message.requestId,
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to save auto-run document: ${error.message}`);
+ });
+ }
+
+ /**
+ * Handle stop_auto_run message - stop an active Auto Run for a session
+ */
+ private handleStopAutoRun(client: WebClient, message: WebClientMessage): void {
+ const sessionId = message.sessionId as string;
+ logger.info(`[Web] Received stop_auto_run message: session=${sessionId}`, LOG_CONTEXT);
+
+ if (!sessionId) {
+ this.sendError(client, 'Missing sessionId');
+ return;
+ }
+
+ if (!this.callbacks.stopAutoRun) {
+ this.sendError(client, 'Auto-run stopping not configured');
+ return;
+ }
+
+ this.callbacks
+ .stopAutoRun(sessionId)
+ .then((success) => {
+ this.send(client, {
+ type: 'stop_auto_run_result',
+ success,
+ sessionId,
+ requestId: message.requestId,
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to stop auto-run: ${error.message}`);
+ });
+ }
+
+ /**
+ * Allowlist of setting keys modifiable from the web interface.
+ */
+ private static readonly ALLOWED_SETTING_KEYS = new Set([
+ 'activeThemeId',
+ 'fontSize',
+ 'enterToSendAI',
+ 'enterToSendTerminal',
+ 'defaultSaveToHistory',
+ 'defaultShowThinking',
+ 'autoScroll',
+ 'notificationsEnabled',
+ 'audioFeedbackEnabled',
+ 'colorBlindMode',
+ 'conductorProfile',
+ ]);
+
+ /**
+ * Handle get_settings message - return current settings
+ */
+ private handleGetSettings(client: WebClient, message: WebClientMessage): void {
+ if (!this.callbacks.getSettings) {
+ this.sendError(client, 'Settings not configured');
+ return;
+ }
+
+ const settings = this.callbacks.getSettings();
+ this.send(client, {
+ type: 'settings',
+ settings,
+ requestId: message.requestId,
+ });
+ }
+
+ /**
+ * Handle set_setting message - modify a single setting
+ */
+ private handleSetSetting(client: WebClient, message: WebClientMessage): void {
+ const key = message.key as string;
+ const value = message.value as SettingValue;
+
+ if (!key || typeof key !== 'string') {
+ this.sendError(client, 'Missing or invalid setting key');
+ return;
+ }
+
+ if (!WebSocketMessageHandler.ALLOWED_SETTING_KEYS.has(key)) {
+ this.sendError(client, `Setting key '${key}' is not modifiable from the web interface`);
+ return;
+ }
+
+ if (value === undefined) {
+ this.sendError(client, 'Missing setting value');
+ return;
+ }
+
+ if (!this.callbacks.setSetting) {
+ this.sendError(client, 'Setting modification not configured');
+ return;
+ }
+
+ this.callbacks
+ .setSetting(key, value)
+ .then((success) => {
+ this.send(client, {
+ type: 'set_setting_result',
+ success,
+ key,
+ requestId: message.requestId,
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to set setting: ${error.message}`);
+ });
+ }
+
+ /**
+ * Known agent types for validation
+ */
+ private static readonly VALID_AGENT_TYPES = new Set([
+ 'claude-code',
+ 'codex',
+ 'opencode',
+ 'factory-droid',
+ ]);
+
+ /**
+ * Handle create_session message - create a new agent session
+ */
+ private handleCreateSession(client: WebClient, message: WebClientMessage): void {
+ const name = message.name as string;
+ const toolType = message.toolType as string;
+ const cwd = message.cwd as string;
+ const groupId = message.groupId as string | undefined;
+
+ if (!name || typeof name !== 'string') {
+ this.sendError(client, 'Missing or invalid name');
+ return;
+ }
+
+ if (!toolType || !WebSocketMessageHandler.VALID_AGENT_TYPES.has(toolType)) {
+ this.sendError(
+ client,
+ `Invalid toolType. Must be one of: ${[...WebSocketMessageHandler.VALID_AGENT_TYPES].join(', ')}`
+ );
+ return;
+ }
+
+ if (!cwd || typeof cwd !== 'string') {
+ this.sendError(client, 'Missing or invalid cwd');
+ return;
+ }
+
+ if (!this.callbacks.createSession) {
+ this.sendError(client, 'Session creation not configured');
+ return;
+ }
+
+ this.callbacks
+ .createSession(name, toolType, cwd, groupId)
+ .then((result) => {
+ this.send(client, {
+ type: 'create_session_result',
+ success: !!result,
+ sessionId: result?.sessionId,
+ requestId: message.requestId,
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to create session: ${error.message}`);
+ });
+ }
+
+ /**
+ * Handle delete_session message - delete an agent session
+ */
+ private handleDeleteSession(client: WebClient, message: WebClientMessage): void {
+ const sessionId = message.sessionId as string;
+
+ if (!sessionId) {
+ this.sendError(client, 'Missing sessionId');
+ return;
+ }
+
+ if (!this.callbacks.deleteSession) {
+ this.sendError(client, 'Session deletion not configured');
+ return;
+ }
+
+ this.callbacks
+ .deleteSession(sessionId)
+ .then((success) => {
+ this.send(client, {
+ type: 'delete_session_result',
+ success,
+ sessionId,
+ requestId: message.requestId,
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to delete session: ${error.message}`);
+ });
+ }
+
+ /**
+ * Handle rename_session message - rename an agent session
+ */
+ private handleRenameSession(client: WebClient, message: WebClientMessage): void {
+ const sessionId = message.sessionId as string;
+ const newName = message.newName as string;
+
+ if (!sessionId) {
+ this.sendError(client, 'Missing sessionId');
+ return;
+ }
+
+ if (!newName || typeof newName !== 'string' || newName.length === 0) {
+ this.sendError(client, 'Missing or empty newName');
+ return;
+ }
+
+ if (newName.length > 100) {
+ this.sendError(client, 'newName must be 100 characters or less');
+ return;
+ }
+
+ if (!this.callbacks.renameSession) {
+ this.sendError(client, 'Session renaming not configured');
+ return;
+ }
+
+ this.callbacks
+ .renameSession(sessionId, newName)
+ .then((success) => {
+ this.send(client, {
+ type: 'rename_session_result',
+ success,
+ sessionId,
+ newName,
+ requestId: message.requestId,
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to rename session: ${error.message}`);
+ });
+ }
+
+ /**
+ * Handle get_groups message - return list of groups
+ */
+ private handleGetGroups(client: WebClient, message: WebClientMessage): void {
+ if (!this.callbacks.getGroups) {
+ this.sendError(client, 'Groups not configured');
+ return;
+ }
+
+ const groups = this.callbacks.getGroups();
+ this.send(client, {
+ type: 'groups_list',
+ groups,
+ requestId: message.requestId,
+ });
+ }
+
+ /**
+ * Handle create_group message - create a new group
+ */
+ private handleCreateGroup(client: WebClient, message: WebClientMessage): void {
+ const name = message.name as string;
+ const emoji = message.emoji as string | undefined;
+
+ if (!name || typeof name !== 'string') {
+ this.sendError(client, 'Missing or invalid group name');
+ return;
+ }
+
+ if (!this.callbacks.createGroup) {
+ this.sendError(client, 'Group creation not configured');
+ return;
+ }
+
+ this.callbacks
+ .createGroup(name, emoji)
+ .then((result) => {
+ this.send(client, {
+ type: 'create_group_result',
+ success: !!result,
+ groupId: result?.id,
+ requestId: message.requestId,
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to create group: ${error.message}`);
+ });
+ }
+
+ /**
+ * Handle rename_group message - rename a group
+ */
+ private handleRenameGroup(client: WebClient, message: WebClientMessage): void {
+ const groupId = message.groupId as string;
+ const name = message.name as string;
+
+ if (!groupId) {
+ this.sendError(client, 'Missing groupId');
+ return;
+ }
+
+ if (!name || typeof name !== 'string') {
+ this.sendError(client, 'Missing or invalid group name');
+ return;
+ }
+
+ if (!this.callbacks.renameGroup) {
+ this.sendError(client, 'Group renaming not configured');
+ return;
+ }
+
+ this.callbacks
+ .renameGroup(groupId, name)
+ .then((success) => {
+ this.send(client, {
+ type: 'rename_group_result',
+ success,
+ groupId,
+ requestId: message.requestId,
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to rename group: ${error.message}`);
+ });
+ }
+
+ /**
+ * Handle delete_group message - delete a group
+ */
+ private handleDeleteGroup(client: WebClient, message: WebClientMessage): void {
+ const groupId = message.groupId as string;
+
+ if (!groupId) {
+ this.sendError(client, 'Missing groupId');
+ return;
+ }
+
+ if (!this.callbacks.deleteGroup) {
+ this.sendError(client, 'Group deletion not configured');
+ return;
+ }
+
+ this.callbacks
+ .deleteGroup(groupId)
+ .then((success) => {
+ this.send(client, {
+ type: 'delete_group_result',
+ success,
+ groupId,
+ requestId: message.requestId,
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to delete group: ${error.message}`);
+ });
+ }
+
+ /**
+ * Handle move_session_to_group message - move a session to a group (or ungrouped)
+ */
+ private handleMoveSessionToGroup(client: WebClient, message: WebClientMessage): void {
+ const sessionId = message.sessionId as string;
+ const groupId = message.groupId as string | null;
+
+ if (!sessionId) {
+ this.sendError(client, 'Missing sessionId');
+ return;
+ }
+
+ // groupId can be null (for ungrouped), but must be present in message
+ if (!('groupId' in message)) {
+ this.sendError(client, 'Missing groupId (use null for ungrouped)');
+ return;
+ }
+
+ if (!this.callbacks.moveSessionToGroup) {
+ this.sendError(client, 'Move to group not configured');
+ return;
+ }
+
+ this.callbacks
+ .moveSessionToGroup(sessionId, groupId)
+ .then((success) => {
+ this.send(client, {
+ type: 'move_session_to_group_result',
+ success,
+ sessionId,
+ groupId,
+ requestId: message.requestId,
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to move session to group: ${error.message}`);
+ });
+ }
+
+ /**
+ * Handle get_git_status message - fetch git status for a session
+ */
+ private handleGetGitStatus(client: WebClient, message: WebClientMessage): void {
+ const sessionId = message.sessionId as string;
+
+ if (!sessionId) {
+ this.sendError(client, 'Missing sessionId');
+ return;
+ }
+
+ if (!this.callbacks.getGitStatus) {
+ this.sendError(client, 'Git status not configured');
+ return;
+ }
+
+ this.callbacks
+ .getGitStatus(sessionId)
+ .then((status) => {
+ this.send(client, {
+ type: 'git_status',
+ sessionId,
+ status,
+ requestId: message.requestId,
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to get git status: ${error.message}`);
+ });
+ }
+
+ /**
+ * Handle get_git_diff message - fetch git diff for a session
+ */
+ private handleGetGitDiff(client: WebClient, message: WebClientMessage): void {
+ const sessionId = message.sessionId as string;
+ const filePath = message.filePath as string | undefined;
+
+ if (!sessionId) {
+ this.sendError(client, 'Missing sessionId');
+ return;
+ }
+
+ if (!this.callbacks.getGitDiff) {
+ this.sendError(client, 'Git diff not configured');
+ return;
+ }
+
+ this.callbacks
+ .getGitDiff(sessionId, filePath)
+ .then((diff) => {
+ this.send(client, {
+ type: 'git_diff',
+ sessionId,
+ diff,
+ requestId: message.requestId,
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to get git diff: ${error.message}`);
+ });
+ }
+
+ /**
+ * Handle get_group_chats message - return list of all group chats
+ */
+ private handleGetGroupChats(client: WebClient, message: WebClientMessage): void {
+ if (!this.callbacks.getGroupChats) {
+ this.sendError(client, 'Group chats not configured');
+ return;
+ }
+
+ this.callbacks
+ .getGroupChats()
+ .then((chats) => {
+ this.send(client, {
+ type: 'group_chats_list',
+ chats,
+ requestId: message.requestId,
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to get group chats: ${error.message}`);
+ });
+ }
+
+ /**
+ * Handle start_group_chat message - start a new group chat
+ */
+ private handleStartGroupChat(client: WebClient, message: WebClientMessage): void {
+ const topic = message.topic as string;
+ const participantIds = message.participantIds as string[];
+
+ if (!topic || typeof topic !== 'string') {
+ this.sendError(client, 'Missing or invalid topic');
+ return;
+ }
+
+ if (!participantIds || !Array.isArray(participantIds) || participantIds.length < 2) {
+ this.sendError(client, 'At least 2 participants are required');
+ return;
+ }
+
+ if (!this.callbacks.startGroupChat) {
+ this.sendError(client, 'Group chat not configured');
+ return;
+ }
+
+ this.callbacks
+ .startGroupChat(topic, participantIds)
+ .then((result) => {
+ this.send(client, {
+ type: 'start_group_chat_result',
+ success: !!result,
+ chatId: result?.chatId,
+ requestId: message.requestId,
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to start group chat: ${error.message}`);
+ });
+ }
+
+ /**
+ * Handle get_group_chat_state message - get state of a specific group chat
+ */
+ private handleGetGroupChatState(client: WebClient, message: WebClientMessage): void {
+ const chatId = message.chatId as string;
+
+ if (!chatId) {
+ this.sendError(client, 'Missing chatId');
+ return;
+ }
+
+ if (!this.callbacks.getGroupChatState) {
+ this.sendError(client, 'Group chat not configured');
+ return;
+ }
+
+ this.callbacks
+ .getGroupChatState(chatId)
+ .then((state) => {
+ this.send(client, {
+ type: 'group_chat_state',
+ chatId,
+ state,
+ requestId: message.requestId,
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to get group chat state: ${error.message}`);
+ });
+ }
+
+ /**
+ * Handle send_group_chat_message message - send a message to a group chat
+ */
+ private handleSendGroupChatMessage(client: WebClient, message: WebClientMessage): void {
+ const chatId = message.chatId as string;
+ const chatMessage = message.message as string;
+
+ if (!chatId) {
+ this.sendError(client, 'Missing chatId');
+ return;
+ }
+
+ if (!chatMessage || typeof chatMessage !== 'string') {
+ this.sendError(client, 'Missing or invalid message');
+ return;
+ }
+
+ if (!this.callbacks.sendGroupChatMessage) {
+ this.sendError(client, 'Group chat not configured');
+ return;
+ }
+
+ this.callbacks
+ .sendGroupChatMessage(chatId, chatMessage)
+ .then((success) => {
+ this.send(client, {
+ type: 'send_group_chat_message_result',
+ success,
+ chatId,
+ requestId: message.requestId,
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to send group chat message: ${error.message}`);
+ });
+ }
+
+ /**
+ * Handle stop_group_chat message - stop an active group chat
+ */
+ private handleStopGroupChat(client: WebClient, message: WebClientMessage): void {
+ const chatId = message.chatId as string;
+
+ if (!chatId) {
+ this.sendError(client, 'Missing chatId');
+ return;
+ }
+
+ if (!this.callbacks.stopGroupChat) {
+ this.sendError(client, 'Group chat not configured');
+ return;
+ }
+
+ this.callbacks
+ .stopGroupChat(chatId)
+ .then((success) => {
+ this.send(client, {
+ type: 'stop_group_chat_result',
+ success,
+ chatId,
+ requestId: message.requestId,
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to stop group chat: ${error.message}`);
+ });
+ }
+
+ /**
+ * Handle merge_context message - merge context from source to target session
+ */
+ private handleMergeContext(client: WebClient, message: WebClientMessage): void {
+ const sourceSessionId = message.sourceSessionId as string;
+ const targetSessionId = message.targetSessionId as string;
+
+ if (!sourceSessionId || !targetSessionId) {
+ this.sendError(client, 'Missing sourceSessionId or targetSessionId');
+ return;
+ }
+
+ if (sourceSessionId === targetSessionId) {
+ this.sendError(client, 'Source and target sessions must be different');
+ return;
+ }
+
+ if (!this.callbacks.mergeContext) {
+ this.sendError(client, 'Context merge not configured');
+ return;
+ }
+
+ this.callbacks
+ .mergeContext(sourceSessionId, targetSessionId)
+ .then((success) => {
+ this.send(client, {
+ type: 'merge_context_result',
+ success,
+ requestId: message.requestId,
+ timestamp: Date.now(),
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to merge context: ${error.message}`);
+ });
+ }
+
+ /**
+ * Handle transfer_context message - transfer context from source to target session
+ */
+ private handleTransferContext(client: WebClient, message: WebClientMessage): void {
+ const sourceSessionId = message.sourceSessionId as string;
+ const targetSessionId = message.targetSessionId as string;
+
+ if (!sourceSessionId || !targetSessionId) {
+ this.sendError(client, 'Missing sourceSessionId or targetSessionId');
+ return;
+ }
+
+ if (sourceSessionId === targetSessionId) {
+ this.sendError(client, 'Source and target sessions must be different');
+ return;
+ }
+
+ if (!this.callbacks.transferContext) {
+ this.sendError(client, 'Context transfer not configured');
+ return;
+ }
+
+ this.callbacks
+ .transferContext(sourceSessionId, targetSessionId)
+ .then((success) => {
+ this.send(client, {
+ type: 'transfer_context_result',
+ success,
+ requestId: message.requestId,
+ timestamp: Date.now(),
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to transfer context: ${error.message}`);
+ });
+ }
+
+ /**
+ * Handle summarize_context message - summarize context for a session
+ */
+ private handleSummarizeContext(client: WebClient, message: WebClientMessage): void {
+ const sessionId = message.sessionId as string;
+
+ if (!sessionId) {
+ this.sendError(client, 'Missing sessionId');
+ return;
+ }
+
+ if (!this.callbacks.summarizeContext) {
+ this.sendError(client, 'Context summarize not configured');
+ return;
+ }
+
+ this.callbacks
+ .summarizeContext(sessionId)
+ .then((success) => {
+ this.send(client, {
+ type: 'summarize_context_result',
+ success,
+ requestId: message.requestId,
+ timestamp: Date.now(),
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to summarize context: ${error.message}`);
+ });
+ }
+
+ /**
+ * Handle get_cue_subscriptions message - fetch Cue subscriptions
+ */
+ private handleGetCueSubscriptions(client: WebClient, message: WebClientMessage): void {
+ const sessionId = message.sessionId as string | undefined;
+
+ if (!this.callbacks.getCueSubscriptions) {
+ this.sendError(client, 'Cue subscriptions not available');
+ return;
+ }
+
+ this.callbacks
+ .getCueSubscriptions(sessionId)
+ .then((subscriptions) => {
+ this.send(client, {
+ type: 'cue_subscriptions',
+ subscriptions,
+ requestId: message.requestId,
+ timestamp: Date.now(),
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to get Cue subscriptions: ${error.message}`);
+ });
+ }
+
+ /**
+ * Handle toggle_cue_subscription message - enable/disable a subscription
+ */
+ private handleToggleCueSubscription(client: WebClient, message: WebClientMessage): void {
+ const subscriptionId = message.subscriptionId as string;
+ const enabled = message.enabled as boolean;
+
+ if (!subscriptionId) {
+ this.sendError(client, 'Missing subscriptionId');
+ return;
+ }
+
+ if (typeof enabled !== 'boolean') {
+ this.sendError(client, 'Missing or invalid enabled flag');
+ return;
+ }
+
+ if (!this.callbacks.toggleCueSubscription) {
+ this.sendError(client, 'Cue toggle not available');
+ return;
+ }
+
+ this.callbacks
+ .toggleCueSubscription(subscriptionId, enabled)
+ .then((success) => {
+ this.send(client, {
+ type: 'toggle_cue_subscription_result',
+ success,
+ subscriptionId,
+ enabled,
+ requestId: message.requestId,
+ timestamp: Date.now(),
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to toggle Cue subscription: ${error.message}`);
+ });
+ }
+
+ /**
+ * Handle get_cue_activity message - fetch Cue activity log
+ */
+ private handleGetCueActivity(client: WebClient, message: WebClientMessage): void {
+ const sessionId = message.sessionId as string | undefined;
+ const limit = (message.limit as number) ?? 50;
+
+ if (!this.callbacks.getCueActivity) {
+ this.sendError(client, 'Cue activity not available');
+ return;
+ }
+
+ this.callbacks
+ .getCueActivity(sessionId, limit)
+ .then((entries) => {
+ this.send(client, {
+ type: 'cue_activity',
+ entries,
+ requestId: message.requestId,
+ timestamp: Date.now(),
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to get Cue activity: ${error.message}`);
+ });
+ }
+
+ /**
+ * Handle get_usage_dashboard message - fetch usage analytics data
+ */
+ private handleGetUsageDashboard(client: WebClient, message: WebClientMessage): void {
+ const timeRange = (message.timeRange as string) || 'week';
+ const validRanges = new Set(['day', 'week', 'month', 'all']);
+
+ if (!validRanges.has(timeRange)) {
+ this.sendError(client, 'Invalid timeRange. Must be one of: day, week, month, all');
+ return;
+ }
+
+ if (!this.callbacks.getUsageDashboard) {
+ this.sendError(client, 'Usage dashboard not available');
+ return;
+ }
+
+ this.callbacks
+ .getUsageDashboard(timeRange as 'day' | 'week' | 'month' | 'all')
+ .then((data) => {
+ this.send(client, {
+ type: 'usage_dashboard',
+ data,
+ requestId: message.requestId,
+ timestamp: Date.now(),
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to get usage dashboard: ${error.message}`);
+ });
+ }
+
+ /**
+ * Handle get_achievements message - fetch achievement data
+ */
+ private handleGetAchievements(client: WebClient, message: WebClientMessage): void {
+ if (!this.callbacks.getAchievements) {
+ this.sendError(client, 'Achievements not available');
+ return;
+ }
+
+ this.callbacks
+ .getAchievements()
+ .then((achievements) => {
+ this.send(client, {
+ type: 'achievements',
+ achievements,
+ requestId: message.requestId,
+ timestamp: Date.now(),
+ });
+ })
+ .catch((error) => {
+ this.sendError(client, `Failed to get achievements: ${error.message}`);
+ });
+ }
+
/**
* Handle unknown message types - echo back for debugging
*/
diff --git a/src/main/web-server/managers/CallbackRegistry.ts b/src/main/web-server/managers/CallbackRegistry.ts
index 3e10acfd84..4a7895c620 100644
--- a/src/main/web-server/managers/CallbackRegistry.ts
+++ b/src/main/web-server/managers/CallbackRegistry.ts
@@ -28,6 +28,45 @@ import type {
GetThemeCallback,
GetCustomCommandsCallback,
GetHistoryCallback,
+ GetAutoRunDocsCallback,
+ GetAutoRunDocContentCallback,
+ SaveAutoRunDocCallback,
+ StopAutoRunCallback,
+ GetSettingsCallback,
+ SetSettingCallback,
+ GetGroupsCallback,
+ CreateGroupCallback,
+ RenameGroupCallback,
+ DeleteGroupCallback,
+ MoveSessionToGroupCallback,
+ CreateSessionCallback,
+ DeleteSessionCallback,
+ RenameSessionCallback,
+ WebSettings,
+ SettingValue,
+ GroupData,
+ GetGitStatusCallback,
+ GetGitDiffCallback,
+ GitStatusResult,
+ GitDiffResult,
+ GetGroupChatsCallback,
+ StartGroupChatCallback,
+ GetGroupChatStateCallback,
+ StopGroupChatCallback,
+ SendGroupChatMessageCallback,
+ GroupChatState,
+ MergeContextCallback,
+ TransferContextCallback,
+ SummarizeContextCallback,
+ GetCueSubscriptionsCallback,
+ ToggleCueSubscriptionCallback,
+ GetCueActivityCallback,
+ CueSubscriptionInfo,
+ CueActivityEntry,
+ GetUsageDashboardCallback,
+ GetAchievementsCallback,
+ UsageDashboardData,
+ AchievementData,
} from '../types';
const LOG_CONTEXT = 'CallbackRegistry';
@@ -57,6 +96,35 @@ export interface WebServerCallbacks {
refreshAutoRunDocs: RefreshAutoRunDocsCallback | null;
configureAutoRun: ConfigureAutoRunCallback | null;
getHistory: GetHistoryCallback | null;
+ getAutoRunDocs: GetAutoRunDocsCallback | null;
+ getAutoRunDocContent: GetAutoRunDocContentCallback | null;
+ saveAutoRunDoc: SaveAutoRunDocCallback | null;
+ stopAutoRun: StopAutoRunCallback | null;
+ getSettings: GetSettingsCallback | null;
+ setSetting: SetSettingCallback | null;
+ getGroups: GetGroupsCallback | null;
+ createGroup: CreateGroupCallback | null;
+ renameGroup: RenameGroupCallback | null;
+ deleteGroup: DeleteGroupCallback | null;
+ moveSessionToGroup: MoveSessionToGroupCallback | null;
+ createSession: CreateSessionCallback | null;
+ deleteSession: DeleteSessionCallback | null;
+ renameSession: RenameSessionCallback | null;
+ getGitStatus: GetGitStatusCallback | null;
+ getGitDiff: GetGitDiffCallback | null;
+ getGroupChats: GetGroupChatsCallback | null;
+ startGroupChat: StartGroupChatCallback | null;
+ getGroupChatState: GetGroupChatStateCallback | null;
+ stopGroupChat: StopGroupChatCallback | null;
+ sendGroupChatMessage: SendGroupChatMessageCallback | null;
+ mergeContext: MergeContextCallback | null;
+ transferContext: TransferContextCallback | null;
+ summarizeContext: SummarizeContextCallback | null;
+ getCueSubscriptions: GetCueSubscriptionsCallback | null;
+ toggleCueSubscription: ToggleCueSubscriptionCallback | null;
+ getCueActivity: GetCueActivityCallback | null;
+ getUsageDashboard: GetUsageDashboardCallback | null;
+ getAchievements: GetAchievementsCallback | null;
}
export class CallbackRegistry {
@@ -82,6 +150,35 @@ export class CallbackRegistry {
refreshAutoRunDocs: null,
configureAutoRun: null,
getHistory: null,
+ getAutoRunDocs: null,
+ getAutoRunDocContent: null,
+ saveAutoRunDoc: null,
+ stopAutoRun: null,
+ getSettings: null,
+ setSetting: null,
+ getGroups: null,
+ createGroup: null,
+ renameGroup: null,
+ deleteGroup: null,
+ moveSessionToGroup: null,
+ createSession: null,
+ deleteSession: null,
+ renameSession: null,
+ getGitStatus: null,
+ getGitDiff: null,
+ getGroupChats: null,
+ startGroupChat: null,
+ getGroupChatState: null,
+ stopGroupChat: null,
+ sendGroupChatMessage: null,
+ mergeContext: null,
+ transferContext: null,
+ summarizeContext: null,
+ getCueSubscriptions: null,
+ toggleCueSubscription: null,
+ getCueActivity: null,
+ getUsageDashboard: null,
+ getAchievements: null,
};
// ============ Getter Methods ============
@@ -198,6 +295,182 @@ export class CallbackRegistry {
return this.callbacks.getHistory?.(projectPath, sessionId) ?? [];
}
+ async getAutoRunDocs(sessionId: string): Promise {
+ if (!this.callbacks.getAutoRunDocs) return [];
+ return this.callbacks.getAutoRunDocs(sessionId);
+ }
+
+ async getAutoRunDocContent(sessionId: string, filename: string): Promise {
+ if (!this.callbacks.getAutoRunDocContent) return '';
+ return this.callbacks.getAutoRunDocContent(sessionId, filename);
+ }
+
+ async saveAutoRunDoc(sessionId: string, filename: string, content: string): Promise {
+ if (!this.callbacks.saveAutoRunDoc) return false;
+ return this.callbacks.saveAutoRunDoc(sessionId, filename, content);
+ }
+
+ async stopAutoRun(sessionId: string): Promise {
+ if (!this.callbacks.stopAutoRun) return false;
+ return this.callbacks.stopAutoRun(sessionId);
+ }
+
+ getSettings(): WebSettings {
+ if (this.callbacks.getSettings) {
+ return this.callbacks.getSettings();
+ }
+ return {
+ theme: 'dracula',
+ fontSize: 14,
+ enterToSendAI: false,
+ enterToSendTerminal: true,
+ defaultSaveToHistory: true,
+ defaultShowThinking: 'off',
+ autoScroll: false,
+ notificationsEnabled: true,
+ audioFeedbackEnabled: false,
+ colorBlindMode: 'false',
+ conductorProfile: '',
+ };
+ }
+
+ async setSetting(key: string, value: SettingValue): Promise {
+ if (!this.callbacks.setSetting) return false;
+ return this.callbacks.setSetting(key, value);
+ }
+
+ getGroups(): GroupData[] {
+ return this.callbacks.getGroups?.() ?? [];
+ }
+
+ async createGroup(name: string, emoji?: string): Promise<{ id: string } | null> {
+ if (!this.callbacks.createGroup) return null;
+ return this.callbacks.createGroup(name, emoji);
+ }
+
+ async renameGroup(groupId: string, name: string): Promise {
+ if (!this.callbacks.renameGroup) return false;
+ return this.callbacks.renameGroup(groupId, name);
+ }
+
+ async deleteGroup(groupId: string): Promise {
+ if (!this.callbacks.deleteGroup) return false;
+ return this.callbacks.deleteGroup(groupId);
+ }
+
+ async moveSessionToGroup(sessionId: string, groupId: string | null): Promise {
+ if (!this.callbacks.moveSessionToGroup) return false;
+ return this.callbacks.moveSessionToGroup(sessionId, groupId);
+ }
+
+ async createSession(
+ name: string,
+ toolType: string,
+ cwd: string,
+ groupId?: string
+ ): Promise<{ sessionId: string } | null> {
+ if (!this.callbacks.createSession) return null;
+ return this.callbacks.createSession(name, toolType, cwd, groupId);
+ }
+
+ async deleteSession(sessionId: string): Promise {
+ if (!this.callbacks.deleteSession) return false;
+ return this.callbacks.deleteSession(sessionId);
+ }
+
+ async renameSession(sessionId: string, newName: string): Promise {
+ if (!this.callbacks.renameSession) return false;
+ return this.callbacks.renameSession(sessionId, newName);
+ }
+
+ async getGitStatus(sessionId: string): Promise {
+ if (!this.callbacks.getGitStatus) return { branch: '', files: [], ahead: 0, behind: 0 };
+ return this.callbacks.getGitStatus(sessionId);
+ }
+
+ async getGitDiff(sessionId: string, filePath?: string): Promise {
+ if (!this.callbacks.getGitDiff) return { diff: '', files: [] };
+ return this.callbacks.getGitDiff(sessionId, filePath);
+ }
+
+ async getGroupChats(): Promise {
+ if (!this.callbacks.getGroupChats) return [];
+ return this.callbacks.getGroupChats();
+ }
+
+ async startGroupChat(
+ topic: string,
+ participantIds: string[]
+ ): Promise<{ chatId: string } | null> {
+ if (!this.callbacks.startGroupChat) return null;
+ return this.callbacks.startGroupChat(topic, participantIds);
+ }
+
+ async getGroupChatState(chatId: string): Promise {
+ if (!this.callbacks.getGroupChatState) return null;
+ return this.callbacks.getGroupChatState(chatId);
+ }
+
+ async stopGroupChat(chatId: string): Promise {
+ if (!this.callbacks.stopGroupChat) return false;
+ return this.callbacks.stopGroupChat(chatId);
+ }
+
+ async sendGroupChatMessage(chatId: string, message: string): Promise {
+ if (!this.callbacks.sendGroupChatMessage) return false;
+ return this.callbacks.sendGroupChatMessage(chatId, message);
+ }
+
+ async mergeContext(sourceSessionId: string, targetSessionId: string): Promise {
+ if (!this.callbacks.mergeContext) return false;
+ return this.callbacks.mergeContext(sourceSessionId, targetSessionId);
+ }
+
+ async transferContext(sourceSessionId: string, targetSessionId: string): Promise {
+ if (!this.callbacks.transferContext) return false;
+ return this.callbacks.transferContext(sourceSessionId, targetSessionId);
+ }
+
+ async summarizeContext(sessionId: string): Promise {
+ if (!this.callbacks.summarizeContext) return false;
+ return this.callbacks.summarizeContext(sessionId);
+ }
+
+ async getCueSubscriptions(sessionId?: string): Promise {
+ if (!this.callbacks.getCueSubscriptions) return [];
+ return this.callbacks.getCueSubscriptions(sessionId);
+ }
+
+ async toggleCueSubscription(subscriptionId: string, enabled: boolean): Promise {
+ if (!this.callbacks.toggleCueSubscription) return false;
+ return this.callbacks.toggleCueSubscription(subscriptionId, enabled);
+ }
+
+ async getCueActivity(sessionId?: string, limit?: number): Promise {
+ if (!this.callbacks.getCueActivity) return [];
+ return this.callbacks.getCueActivity(sessionId, limit);
+ }
+
+ async getUsageDashboard(
+ timeRange: 'day' | 'week' | 'month' | 'all'
+ ): Promise {
+ if (!this.callbacks.getUsageDashboard) {
+ return {
+ totalTokensIn: 0,
+ totalTokensOut: 0,
+ totalCost: 0,
+ sessionBreakdown: [],
+ dailyUsage: [],
+ };
+ }
+ return this.callbacks.getUsageDashboard(timeRange);
+ }
+
+ async getAchievements(): Promise {
+ if (!this.callbacks.getAchievements) return [];
+ return this.callbacks.getAchievements();
+ }
+
// ============ Setter Methods ============
setGetSessionsCallback(callback: GetSessionsCallback): void {
@@ -290,6 +563,122 @@ export class CallbackRegistry {
this.callbacks.getHistory = callback;
}
+ setGetAutoRunDocsCallback(callback: GetAutoRunDocsCallback): void {
+ this.callbacks.getAutoRunDocs = callback;
+ }
+
+ setGetAutoRunDocContentCallback(callback: GetAutoRunDocContentCallback): void {
+ this.callbacks.getAutoRunDocContent = callback;
+ }
+
+ setSaveAutoRunDocCallback(callback: SaveAutoRunDocCallback): void {
+ this.callbacks.saveAutoRunDoc = callback;
+ }
+
+ setStopAutoRunCallback(callback: StopAutoRunCallback): void {
+ this.callbacks.stopAutoRun = callback;
+ }
+
+ setGetSettingsCallback(callback: GetSettingsCallback): void {
+ this.callbacks.getSettings = callback;
+ }
+
+ setSetSettingCallback(callback: SetSettingCallback): void {
+ this.callbacks.setSetting = callback;
+ }
+
+ setGetGroupsCallback(callback: GetGroupsCallback): void {
+ this.callbacks.getGroups = callback;
+ }
+
+ setCreateGroupCallback(callback: CreateGroupCallback): void {
+ this.callbacks.createGroup = callback;
+ }
+
+ setRenameGroupCallback(callback: RenameGroupCallback): void {
+ this.callbacks.renameGroup = callback;
+ }
+
+ setDeleteGroupCallback(callback: DeleteGroupCallback): void {
+ this.callbacks.deleteGroup = callback;
+ }
+
+ setMoveSessionToGroupCallback(callback: MoveSessionToGroupCallback): void {
+ this.callbacks.moveSessionToGroup = callback;
+ }
+
+ setCreateSessionCallback(callback: CreateSessionCallback): void {
+ this.callbacks.createSession = callback;
+ }
+
+ setDeleteSessionCallback(callback: DeleteSessionCallback): void {
+ this.callbacks.deleteSession = callback;
+ }
+
+ setRenameSessionCallback(callback: RenameSessionCallback): void {
+ this.callbacks.renameSession = callback;
+ }
+
+ setGetGitStatusCallback(callback: GetGitStatusCallback): void {
+ this.callbacks.getGitStatus = callback;
+ }
+
+ setGetGitDiffCallback(callback: GetGitDiffCallback): void {
+ this.callbacks.getGitDiff = callback;
+ }
+
+ setGetGroupChatsCallback(callback: GetGroupChatsCallback): void {
+ this.callbacks.getGroupChats = callback;
+ }
+
+ setStartGroupChatCallback(callback: StartGroupChatCallback): void {
+ this.callbacks.startGroupChat = callback;
+ }
+
+ setGetGroupChatStateCallback(callback: GetGroupChatStateCallback): void {
+ this.callbacks.getGroupChatState = callback;
+ }
+
+ setStopGroupChatCallback(callback: StopGroupChatCallback): void {
+ this.callbacks.stopGroupChat = callback;
+ }
+
+ setSendGroupChatMessageCallback(callback: SendGroupChatMessageCallback): void {
+ this.callbacks.sendGroupChatMessage = callback;
+ }
+
+ setMergeContextCallback(callback: MergeContextCallback): void {
+ this.callbacks.mergeContext = callback;
+ }
+
+ setTransferContextCallback(callback: TransferContextCallback): void {
+ this.callbacks.transferContext = callback;
+ }
+
+ setSummarizeContextCallback(callback: SummarizeContextCallback): void {
+ this.callbacks.summarizeContext = callback;
+ }
+
+ setGetCueSubscriptionsCallback(callback: GetCueSubscriptionsCallback): void {
+ this.callbacks.getCueSubscriptions = callback;
+ }
+
+ setToggleCueSubscriptionCallback(callback: ToggleCueSubscriptionCallback): void {
+ this.callbacks.toggleCueSubscription = callback;
+ }
+
+ setGetCueActivityCallback(callback: GetCueActivityCallback): void {
+ this.callbacks.getCueActivity = callback;
+ }
+
+ setGetUsageDashboardCallback(callback: GetUsageDashboardCallback): void {
+ this.callbacks.getUsageDashboard = callback;
+ }
+
+ setGetAchievementsCallback(callback: GetAchievementsCallback): void {
+ this.callbacks.getAchievements = callback;
+ }
+
// ============ Check Methods ============
hasCallback(name: keyof WebServerCallbacks): boolean {
diff --git a/src/main/web-server/services/broadcastService.ts b/src/main/web-server/services/broadcastService.ts
index d66c9eaae6..dfaebb6033 100644
--- a/src/main/web-server/services/broadcastService.ts
+++ b/src/main/web-server/services/broadcastService.ts
@@ -15,6 +15,7 @@
* - theme: Theme updates
* - custom_commands: Custom AI commands updates
* - autorun_state: Auto Run batch processing state
+ * - autorun_docs_changed: Auto Run document list changes
* - user_input: User input from desktop (for web client sync)
* - session_output: Session output data
*/
@@ -28,7 +29,15 @@ import type {
AITabData,
SessionBroadcastData,
AutoRunState,
+ AutoRunDocument,
CliActivity,
+ NotificationEvent,
+ WebSettings,
+ GroupData,
+ GroupChatMessage,
+ GroupChatState,
+ CueActivityEntry,
+ CueSubscriptionInfo,
} from '../types';
// Re-export types for backwards compatibility
@@ -61,6 +70,8 @@ export type GetWebClientsCallback = () => Map;
*/
export class BroadcastService {
private getWebClients: GetWebClientsCallback | null = null;
+ private previousAutoRunStates: Map =
+ new Map();
/**
* Set the callback for getting web clients
@@ -100,6 +111,17 @@ export class BroadcastService {
}
}
+ /**
+ * Broadcast a notification event to all connected web clients
+ */
+ broadcastNotificationEvent(event: NotificationEvent): void {
+ this.broadcastToAll({
+ type: 'notification_event',
+ ...event,
+ timestamp: Date.now(),
+ });
+ }
+
/**
* Broadcast a session state change to all connected web clients
* Called when any session's state changes (idle, busy, error, connecting)
@@ -122,6 +144,25 @@ export class BroadcastService {
...additionalData,
timestamp: Date.now(),
});
+
+ // Trigger notification events on state transitions
+ if (state === 'idle') {
+ this.broadcastNotificationEvent({
+ eventType: 'agent_complete',
+ sessionId,
+ sessionName: additionalData?.name ?? sessionId,
+ message: 'Agent finished processing',
+ severity: 'info',
+ });
+ } else if (state === 'error') {
+ this.broadcastNotificationEvent({
+ eventType: 'agent_error',
+ sessionId,
+ sessionName: additionalData?.name ?? sessionId,
+ message: 'Agent encountered an error',
+ severity: 'error',
+ });
+ }
}
/**
@@ -208,6 +249,30 @@ export class BroadcastService {
});
}
+ /**
+ * Broadcast settings change to all connected web clients
+ * Called when a setting is modified (from web or desktop)
+ */
+ broadcastSettingsChanged(settings: WebSettings): void {
+ this.broadcastToAll({
+ type: 'settings_changed',
+ settings,
+ timestamp: Date.now(),
+ });
+ }
+
+ /**
+ * Broadcast groups change to all connected web clients
+ * Called when groups are created, renamed, deleted, or sessions are moved
+ */
+ broadcastGroupsChanged(groups: GroupData[]): void {
+ this.broadcastToAll({
+ type: 'groups_changed',
+ groups,
+ timestamp: Date.now(),
+ });
+ }
+
/**
* Broadcast AutoRun state to all connected web clients
* Called when batch processing starts, progresses, or stops
@@ -223,6 +288,54 @@ export class BroadcastService {
state,
timestamp: Date.now(),
});
+
+ // Detect transitions for notification events
+ const previous = this.previousAutoRunStates.get(sessionId);
+ if (state) {
+ // Detect autorun_complete: running → not running
+ if (previous?.running && !state.isRunning) {
+ this.broadcastNotificationEvent({
+ eventType: 'autorun_complete',
+ sessionId,
+ sessionName: sessionId,
+ message: `Auto Run finished (${state.completedTasks}/${state.totalTasks} tasks)`,
+ severity: 'info',
+ });
+ }
+
+ // Detect autorun_task_complete: completedTasks increased
+ if (previous && state.completedTasks > previous.completedTasks) {
+ this.broadcastNotificationEvent({
+ eventType: 'autorun_task_complete',
+ sessionId,
+ sessionName: sessionId,
+ message: `Task ${state.completedTasks}/${state.totalTasks} completed`,
+ severity: 'info',
+ });
+ }
+
+ // Update previous state
+ this.previousAutoRunStates.set(sessionId, {
+ running: state.isRunning,
+ completedTasks: state.completedTasks,
+ });
+ } else {
+ // State cleared — remove tracking
+ this.previousAutoRunStates.delete(sessionId);
+ }
+ }
+
+ /**
+ * Broadcast Auto Run documents changed to all connected web clients
+ * Called when Auto Run documents are added, removed, or modified
+ */
+ broadcastAutoRunDocsChanged(sessionId: string, documents: AutoRunDocument[]): void {
+ this.broadcastToAll({
+ type: 'autorun_docs_changed',
+ sessionId,
+ documents,
+ timestamp: Date.now(),
+ });
}
/**
@@ -263,4 +376,76 @@ export class BroadcastService {
timestamp: Date.now(),
});
}
+
+ /**
+ * Broadcast a group chat message to all connected web clients
+ */
+ broadcastGroupChatMessage(chatId: string, message: GroupChatMessage): void {
+ this.broadcastToAll({
+ type: 'group_chat_message',
+ chatId,
+ message,
+ timestamp: Date.now(),
+ });
+ }
+
+ /**
+ * Broadcast a group chat state change to all connected web clients
+ */
+ broadcastGroupChatStateChange(chatId: string, state: Partial): void {
+ this.broadcastToAll({
+ type: 'group_chat_state_change',
+ chatId,
+ ...state,
+ timestamp: Date.now(),
+ });
+ }
+
+ /**
+ * Broadcast context operation progress to all connected web clients
+ */
+ broadcastContextOperationProgress(sessionId: string, operation: string, progress: number): void {
+ this.broadcastToAll({
+ type: 'context_operation_progress',
+ sessionId,
+ operation,
+ progress,
+ timestamp: Date.now(),
+ });
+ }
+
+ /**
+ * Broadcast context operation completion to all connected web clients
+ */
+ broadcastContextOperationComplete(sessionId: string, operation: string, success: boolean): void {
+ this.broadcastToAll({
+ type: 'context_operation_complete',
+ sessionId,
+ operation,
+ success,
+ timestamp: Date.now(),
+ });
+ }
+
+ /**
+ * Broadcast a Cue activity event to all connected web clients
+ */
+ broadcastCueActivity(entry: CueActivityEntry): void {
+ this.broadcastToAll({
+ type: 'cue_activity_event',
+ entry,
+ timestamp: Date.now(),
+ });
+ }
+
+ /**
+ * Broadcast Cue subscriptions changed to all connected web clients
+ */
+ broadcastCueSubscriptionsChanged(subscriptions: CueSubscriptionInfo[]): void {
+ this.broadcastToAll({
+ type: 'cue_subscriptions_changed',
+ subscriptions,
+ timestamp: Date.now(),
+ });
+ }
}
diff --git a/src/main/web-server/types.ts b/src/main/web-server/types.ts
index d03df966b1..26c120f5fe 100644
--- a/src/main/web-server/types.ts
+++ b/src/main/web-server/types.ts
@@ -329,3 +329,307 @@ export type GetHistoryCallback = (
* Callback to get all connected web clients.
*/
export type GetWebClientsCallback = () => Map;
+
+// =============================================================================
+// Web UX Parity Types
+// =============================================================================
+
+/**
+ * Union type for setting values exposed to web.
+ */
+export type SettingValue = string | number | boolean | null;
+
+/**
+ * Curated subset of settings exposed to the web interface.
+ */
+export interface WebSettings {
+ theme: string;
+ fontSize: number;
+ enterToSendAI: boolean;
+ enterToSendTerminal: boolean;
+ defaultSaveToHistory: boolean;
+ defaultShowThinking: string;
+ autoScroll: boolean;
+ notificationsEnabled: boolean;
+ audioFeedbackEnabled: boolean;
+ colorBlindMode: string;
+ conductorProfile: string;
+}
+
+/**
+ * Group info for web.
+ */
+export interface GroupData {
+ id: string;
+ name: string;
+ emoji: string | null;
+ sessionIds: string[];
+}
+
+/**
+ * Auto Run document metadata.
+ */
+export interface AutoRunDocument {
+ filename: string;
+ path: string;
+ taskCount: number;
+ completedCount: number;
+}
+
+/**
+ * File tree entry.
+ */
+export interface FileTreeNode {
+ name: string;
+ path: string;
+ isDirectory: boolean;
+ children?: FileTreeNode[];
+ size?: number;
+}
+
+/**
+ * File content response.
+ */
+export interface FileContentResult {
+ content: string;
+ language: string;
+ size: number;
+ truncated: boolean;
+}
+
+/**
+ * Git status entry.
+ */
+export interface GitStatusFile {
+ path: string;
+ status: string;
+ staged: boolean;
+}
+
+/**
+ * Git status response.
+ */
+export interface GitStatusResult {
+ branch: string;
+ files: GitStatusFile[];
+ ahead: number;
+ behind: number;
+}
+
+/**
+ * Git diff response.
+ */
+export interface GitDiffResult {
+ diff: string;
+ files: string[];
+}
+
+/**
+ * Notification preferences configuration.
+ */
+export interface NotificationPreferences {
+ agentComplete: boolean;
+ agentError: boolean;
+ autoRunComplete: boolean;
+ autoRunTaskComplete: boolean;
+ contextWarning: boolean;
+ soundEnabled: boolean;
+}
+
+/**
+ * Notification broadcast payload.
+ */
+export interface NotificationEvent {
+ eventType:
+ | 'agent_complete'
+ | 'agent_error'
+ | 'autorun_complete'
+ | 'autorun_task_complete'
+ | 'context_warning';
+ sessionId: string;
+ sessionName: string;
+ message: string;
+ severity: 'info' | 'warning' | 'error';
+}
+
+// =============================================================================
+// Web UX Parity Callback Types
+// =============================================================================
+
+export type GetSettingsCallback = () => WebSettings;
+export type SetSettingCallback = (key: string, value: SettingValue) => Promise;
+export type GetGroupsCallback = () => GroupData[];
+export type CreateGroupCallback = (name: string, emoji?: string) => Promise<{ id: string } | null>;
+export type RenameGroupCallback = (groupId: string, name: string) => Promise;
+export type DeleteGroupCallback = (groupId: string) => Promise;
+export type MoveSessionToGroupCallback = (
+ sessionId: string,
+ groupId: string | null
+) => Promise;
+export type CreateSessionCallback = (
+ name: string,
+ toolType: string,
+ cwd: string,
+ groupId?: string
+) => Promise<{ sessionId: string } | null>;
+export type DeleteSessionCallback = (sessionId: string) => Promise;
+export type RenameSessionCallback = (sessionId: string, newName: string) => Promise;
+export type GetAutoRunDocsCallback = (sessionId: string) => Promise;
+export type GetAutoRunDocContentCallback = (sessionId: string, filename: string) => Promise;
+export type SaveAutoRunDocCallback = (
+ sessionId: string,
+ filename: string,
+ content: string
+) => Promise;
+export type StopAutoRunCallback = (sessionId: string) => Promise;
+export type GetFileTreeCallback = (sessionId: string, subPath?: string) => Promise;
+export type GetFileContentCallback = (
+ sessionId: string,
+ filePath: string
+) => Promise;
+export type GetGitStatusCallback = (sessionId: string) => Promise;
+export type GetGitDiffCallback = (sessionId: string, filePath?: string) => Promise;
+
+// =============================================================================
+// Group Chat Types
+// =============================================================================
+
+/**
+ * Group chat message for web interface.
+ */
+export interface GroupChatMessage {
+ id: string;
+ participantId: string;
+ participantName: string;
+ content: string;
+ timestamp: number;
+ role: 'user' | 'assistant';
+}
+
+/**
+ * Group chat state for web interface.
+ */
+export interface GroupChatState {
+ id: string;
+ topic: string;
+ participants: Array<{ sessionId: string; name: string; toolType: string }>;
+ messages: GroupChatMessage[];
+ isActive: boolean;
+ currentTurn?: string;
+}
+
+// =============================================================================
+// Group Chat Callback Types
+// =============================================================================
+
+export type StartGroupChatCallback = (
+ topic: string,
+ participantIds: string[]
+) => Promise<{ chatId: string } | null>;
+export type GetGroupChatStateCallback = (chatId: string) => Promise;
+export type StopGroupChatCallback = (chatId: string) => Promise;
+export type SendGroupChatMessageCallback = (chatId: string, message: string) => Promise;
+export type GetGroupChatsCallback = () => Promise;
+
+// =============================================================================
+// Context Management Callback Types
+// =============================================================================
+
+export type MergeContextCallback = (
+ sourceSessionId: string,
+ targetSessionId: string
+) => Promise;
+export type TransferContextCallback = (
+ sourceSessionId: string,
+ targetSessionId: string
+) => Promise;
+export type SummarizeContextCallback = (sessionId: string) => Promise;
+
+// =============================================================================
+// Cue Automation Types
+// =============================================================================
+
+/** Web-specific Cue subscription metadata (simplified from engine types) */
+export interface CueSubscriptionInfo {
+ id: string;
+ name: string;
+ eventType: string;
+ pattern?: string;
+ schedule?: string;
+ sessionId: string;
+ sessionName: string;
+ enabled: boolean;
+ lastTriggered?: number;
+ triggerCount: number;
+}
+
+/** Web-specific Cue activity log entry (simplified from engine types) */
+export interface CueActivityEntry {
+ id: string;
+ subscriptionId: string;
+ subscriptionName: string;
+ eventType: string;
+ sessionId: string;
+ timestamp: number;
+ status: 'triggered' | 'running' | 'completed' | 'failed';
+ result?: string;
+ duration?: number;
+}
+
+// =============================================================================
+// Cue Automation Callback Types
+// =============================================================================
+
+export type GetCueSubscriptionsCallback = (sessionId?: string) => Promise;
+export type ToggleCueSubscriptionCallback = (
+ subscriptionId: string,
+ enabled: boolean
+) => Promise;
+export type GetCueActivityCallback = (
+ sessionId?: string,
+ limit?: number
+) => Promise;
+
+// =============================================================================
+// Usage Dashboard Types
+// =============================================================================
+
+/** Usage dashboard aggregate data for web interface */
+export interface UsageDashboardData {
+ totalTokensIn: number;
+ totalTokensOut: number;
+ totalCost: number;
+ sessionBreakdown: Array<{
+ sessionId: string;
+ sessionName: string;
+ tokensIn: number;
+ tokensOut: number;
+ cost: number;
+ }>;
+ dailyUsage: Array<{
+ date: string;
+ tokensIn: number;
+ tokensOut: number;
+ cost: number;
+ }>;
+}
+
+/** Achievement data for web interface */
+export interface AchievementData {
+ id: string;
+ name: string;
+ description: string;
+ unlocked: boolean;
+ unlockedAt?: number;
+ progress?: number;
+ maxProgress?: number;
+}
+
+// =============================================================================
+// Usage Dashboard Callback Types
+// =============================================================================
+
+export type GetUsageDashboardCallback = (
+ timeRange: 'day' | 'week' | 'month' | 'all'
+) => Promise;
+export type GetAchievementsCallback = () => Promise;
diff --git a/src/main/web-server/web-server-factory.ts b/src/main/web-server/web-server-factory.ts
index 58b36e71e7..47c1d25b5e 100644
--- a/src/main/web-server/web-server-factory.ts
+++ b/src/main/web-server/web-server-factory.ts
@@ -603,6 +603,1138 @@ export function createWebServerFactory(deps: WebServerFactoryDependencies) {
});
});
+ // Set up callback for web server to fetch Auto Run documents list
+ // Uses IPC request-response pattern with timeout
+ server.setGetAutoRunDocsCallback(async (sessionId: string) => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for getAutoRunDocs', 'WebServer');
+ return [];
+ }
+
+ return new Promise((resolve) => {
+ const responseChannel = `remote:getAutoRunDocs:response:${randomUUID()}`;
+ let resolved = false;
+
+ const handleResponse = (_event: Electron.IpcMainEvent, result: any) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timeoutId);
+ resolve(result || []);
+ };
+
+ ipcMain.once(responseChannel, handleResponse);
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for getAutoRunDocs', 'WebServer');
+ ipcMain.removeListener(responseChannel, handleResponse);
+ resolve([]);
+ return;
+ }
+ mainWindow.webContents.send('remote:getAutoRunDocs', sessionId, responseChannel);
+
+ const timeoutId = setTimeout(() => {
+ if (resolved) return;
+ resolved = true;
+ ipcMain.removeListener(responseChannel, handleResponse);
+ logger.warn(`getAutoRunDocs callback timed out for session ${sessionId}`, 'WebServer');
+ resolve([]);
+ }, 10000);
+ });
+ });
+
+ // Set up callback for web server to fetch Auto Run document content
+ // Uses IPC request-response pattern with timeout
+ server.setGetAutoRunDocContentCallback(async (sessionId: string, filename: string) => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for getAutoRunDocContent', 'WebServer');
+ return '';
+ }
+
+ return new Promise((resolve) => {
+ const responseChannel = `remote:getAutoRunDocContent:response:${randomUUID()}`;
+ let resolved = false;
+
+ const handleResponse = (_event: Electron.IpcMainEvent, result: any) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timeoutId);
+ resolve(result ?? '');
+ };
+
+ ipcMain.once(responseChannel, handleResponse);
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for getAutoRunDocContent', 'WebServer');
+ ipcMain.removeListener(responseChannel, handleResponse);
+ resolve('');
+ return;
+ }
+ mainWindow.webContents.send(
+ 'remote:getAutoRunDocContent',
+ sessionId,
+ filename,
+ responseChannel
+ );
+
+ const timeoutId = setTimeout(() => {
+ if (resolved) return;
+ resolved = true;
+ ipcMain.removeListener(responseChannel, handleResponse);
+ logger.warn(
+ `getAutoRunDocContent callback timed out for session ${sessionId}`,
+ 'WebServer'
+ );
+ resolve('');
+ }, 10000);
+ });
+ });
+
+ // Set up callback for web server to save Auto Run document content
+ // Uses IPC request-response pattern with timeout
+ server.setSaveAutoRunDocCallback(
+ async (sessionId: string, filename: string, content: string) => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for saveAutoRunDoc', 'WebServer');
+ return false;
+ }
+
+ return new Promise((resolve) => {
+ const responseChannel = `remote:saveAutoRunDoc:response:${randomUUID()}`;
+ let resolved = false;
+
+ const handleResponse = (_event: Electron.IpcMainEvent, result: any) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timeoutId);
+ resolve(result ?? false);
+ };
+
+ ipcMain.once(responseChannel, handleResponse);
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for saveAutoRunDoc', 'WebServer');
+ ipcMain.removeListener(responseChannel, handleResponse);
+ resolve(false);
+ return;
+ }
+ mainWindow.webContents.send(
+ 'remote:saveAutoRunDoc',
+ sessionId,
+ filename,
+ content,
+ responseChannel
+ );
+
+ const timeoutId = setTimeout(() => {
+ if (resolved) return;
+ resolved = true;
+ ipcMain.removeListener(responseChannel, handleResponse);
+ logger.warn(`saveAutoRunDoc callback timed out for session ${sessionId}`, 'WebServer');
+ resolve(false);
+ }, 10000);
+ });
+ }
+ );
+
+ // Set up callback for web server to read settings
+ // Reads directly from settingsStore — maps store keys to WebSettings shape
+ server.setGetSettingsCallback(() => {
+ return {
+ theme: settingsStore.get('activeThemeId', 'dracula') as string,
+ fontSize: settingsStore.get('fontSize', 14) as number,
+ enterToSendAI: settingsStore.get('enterToSendAI', false) as boolean,
+ enterToSendTerminal: settingsStore.get('enterToSendTerminal', true) as boolean,
+ defaultSaveToHistory: settingsStore.get('defaultSaveToHistory', true) as boolean,
+ defaultShowThinking: settingsStore.get('defaultShowThinking', 'off') as string,
+ autoScroll: settingsStore.get('autoScrollAiMode', false) as boolean,
+ notificationsEnabled: settingsStore.get('osNotificationsEnabled', true) as boolean,
+ audioFeedbackEnabled: settingsStore.get('audioFeedbackEnabled', false) as boolean,
+ colorBlindMode: settingsStore.get('colorBlindMode', 'false') as string,
+ conductorProfile: settingsStore.get('conductorProfile', '') as string,
+ };
+ });
+
+ // Set up callback for web server to modify settings
+ // Uses IPC request-response pattern — forwards to renderer which applies via existing settings infrastructure
+ // After a successful set, re-reads all settings and broadcasts the change to all web clients
+ server.setSetSettingCallback(async (key: string, value: unknown) => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for setSetting', 'WebServer');
+ return false;
+ }
+
+ return new Promise((resolve) => {
+ const responseChannel = `remote:setSetting:response:${randomUUID()}`;
+ let resolved = false;
+
+ const handleResponse = (_event: Electron.IpcMainEvent, result: any) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timeoutId);
+ const success = result ?? false;
+
+ // After successful setting change, broadcast updated settings to all web clients
+ if (success) {
+ const settings = {
+ theme: settingsStore.get('activeThemeId', 'dracula') as string,
+ fontSize: settingsStore.get('fontSize', 14) as number,
+ enterToSendAI: settingsStore.get('enterToSendAI', false) as boolean,
+ enterToSendTerminal: settingsStore.get('enterToSendTerminal', true) as boolean,
+ defaultSaveToHistory: settingsStore.get('defaultSaveToHistory', true) as boolean,
+ defaultShowThinking: settingsStore.get('defaultShowThinking', 'off') as string,
+ autoScroll: settingsStore.get('autoScrollAiMode', false) as boolean,
+ notificationsEnabled: settingsStore.get('osNotificationsEnabled', true) as boolean,
+ audioFeedbackEnabled: settingsStore.get('audioFeedbackEnabled', false) as boolean,
+ colorBlindMode: settingsStore.get('colorBlindMode', 'false') as string,
+ conductorProfile: settingsStore.get('conductorProfile', '') as string,
+ };
+ server.broadcastSettingsChanged(settings);
+ }
+
+ resolve(success);
+ };
+
+ ipcMain.once(responseChannel, handleResponse);
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for setSetting', 'WebServer');
+ ipcMain.removeListener(responseChannel, handleResponse);
+ resolve(false);
+ return;
+ }
+ mainWindow.webContents.send('remote:setSetting', key, value, responseChannel);
+
+ const timeoutId = setTimeout(() => {
+ if (resolved) return;
+ resolved = true;
+ ipcMain.removeListener(responseChannel, handleResponse);
+ logger.warn(`setSetting callback timed out for key ${key}`, 'WebServer');
+ resolve(false);
+ }, 5000);
+ });
+ });
+
+ // Set up callback for web server to read groups
+ // Direct read from groupsStore, derive sessionIds from sessions
+ server.setGetGroupsCallback(() => {
+ const groups = groupsStore.get('groups', []);
+ const sessions = sessionsStore.get('sessions', []);
+ return groups.map((g) => ({
+ id: g.id,
+ name: g.name,
+ emoji: g.emoji || null,
+ sessionIds: sessions.filter((s) => s.groupId === g.id).map((s) => s.id),
+ }));
+ });
+
+ // Set up callback for web server to create a session
+ // Uses IPC request-response pattern — renderer creates the session and responds with sessionId
+ server.setCreateSessionCallback(
+ async (name: string, toolType: string, cwd: string, groupId?: string) => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for createSession', 'WebServer');
+ return null;
+ }
+
+ return new Promise((resolve) => {
+ const responseChannel = `remote:createSession:response:${randomUUID()}`;
+ let resolved = false;
+
+ const handleResponse = (_event: Electron.IpcMainEvent, result: any) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timeoutId);
+ resolve(result || null);
+ };
+
+ ipcMain.once(responseChannel, handleResponse);
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for createSession', 'WebServer');
+ ipcMain.removeListener(responseChannel, handleResponse);
+ resolve(null);
+ return;
+ }
+ mainWindow.webContents.send(
+ 'remote:createSession',
+ name,
+ toolType,
+ cwd,
+ groupId,
+ responseChannel
+ );
+
+ const timeoutId = setTimeout(() => {
+ if (resolved) return;
+ resolved = true;
+ ipcMain.removeListener(responseChannel, handleResponse);
+ logger.warn(`createSession callback timed out`, 'WebServer');
+ resolve(null);
+ }, 10000);
+ });
+ }
+ );
+
+ // Set up callback for web server to delete a session
+ // Fire-and-forget pattern
+ server.setDeleteSessionCallback(async (sessionId: string) => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for deleteSession', 'WebServer');
+ return false;
+ }
+
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for deleteSession', 'WebServer');
+ return false;
+ }
+ mainWindow.webContents.send('remote:deleteSession', sessionId);
+ return true;
+ });
+
+ // Set up callback for web server to rename a session
+ // Uses IPC request-response pattern
+ server.setRenameSessionCallback(async (sessionId: string, newName: string) => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for renameSession', 'WebServer');
+ return false;
+ }
+
+ return new Promise((resolve) => {
+ const responseChannel = `remote:renameSession:response:${randomUUID()}`;
+ let resolved = false;
+
+ const handleResponse = (_event: Electron.IpcMainEvent, result: any) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timeoutId);
+ resolve(result ?? false);
+ };
+
+ ipcMain.once(responseChannel, handleResponse);
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for renameSession', 'WebServer');
+ ipcMain.removeListener(responseChannel, handleResponse);
+ resolve(false);
+ return;
+ }
+ mainWindow.webContents.send('remote:renameSession', sessionId, newName, responseChannel);
+
+ const timeoutId = setTimeout(() => {
+ if (resolved) return;
+ resolved = true;
+ ipcMain.removeListener(responseChannel, handleResponse);
+ logger.warn(`renameSession callback timed out for session ${sessionId}`, 'WebServer');
+ resolve(false);
+ }, 5000);
+ });
+ });
+
+ // Set up callback for web server to create a group
+ // Uses IPC request-response pattern
+ server.setCreateGroupCallback(async (name: string, emoji?: string) => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for createGroup', 'WebServer');
+ return null;
+ }
+
+ return new Promise((resolve) => {
+ const responseChannel = `remote:createGroup:response:${randomUUID()}`;
+ let resolved = false;
+
+ const handleResponse = (_event: Electron.IpcMainEvent, result: any) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timeoutId);
+ resolve(result || null);
+ };
+
+ ipcMain.once(responseChannel, handleResponse);
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for createGroup', 'WebServer');
+ ipcMain.removeListener(responseChannel, handleResponse);
+ resolve(null);
+ return;
+ }
+ mainWindow.webContents.send('remote:createGroup', name, emoji, responseChannel);
+
+ const timeoutId = setTimeout(() => {
+ if (resolved) return;
+ resolved = true;
+ ipcMain.removeListener(responseChannel, handleResponse);
+ logger.warn(`createGroup callback timed out`, 'WebServer');
+ resolve(null);
+ }, 5000);
+ });
+ });
+
+ // Set up callback for web server to rename a group
+ // Uses IPC request-response pattern
+ server.setRenameGroupCallback(async (groupId: string, name: string) => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for renameGroup', 'WebServer');
+ return false;
+ }
+
+ return new Promise((resolve) => {
+ const responseChannel = `remote:renameGroup:response:${randomUUID()}`;
+ let resolved = false;
+
+ const handleResponse = (_event: Electron.IpcMainEvent, result: any) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timeoutId);
+ resolve(result ?? false);
+ };
+
+ ipcMain.once(responseChannel, handleResponse);
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for renameGroup', 'WebServer');
+ ipcMain.removeListener(responseChannel, handleResponse);
+ resolve(false);
+ return;
+ }
+ mainWindow.webContents.send('remote:renameGroup', groupId, name, responseChannel);
+
+ const timeoutId = setTimeout(() => {
+ if (resolved) return;
+ resolved = true;
+ ipcMain.removeListener(responseChannel, handleResponse);
+ logger.warn(`renameGroup callback timed out for group ${groupId}`, 'WebServer');
+ resolve(false);
+ }, 5000);
+ });
+ });
+
+ // Set up callback for web server to delete a group
+ // Fire-and-forget pattern
+ server.setDeleteGroupCallback(async (groupId: string) => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for deleteGroup', 'WebServer');
+ return false;
+ }
+
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for deleteGroup', 'WebServer');
+ return false;
+ }
+ mainWindow.webContents.send('remote:deleteGroup', groupId);
+ return true;
+ });
+
+ // Set up callback for web server to move a session to a group
+ // Uses IPC request-response pattern
+ server.setMoveSessionToGroupCallback(async (sessionId: string, groupId: string | null) => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for moveSessionToGroup', 'WebServer');
+ return false;
+ }
+
+ return new Promise((resolve) => {
+ const responseChannel = `remote:moveSessionToGroup:response:${randomUUID()}`;
+ let resolved = false;
+
+ const handleResponse = (_event: Electron.IpcMainEvent, result: any) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timeoutId);
+ resolve(result ?? false);
+ };
+
+ ipcMain.once(responseChannel, handleResponse);
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for moveSessionToGroup', 'WebServer');
+ ipcMain.removeListener(responseChannel, handleResponse);
+ resolve(false);
+ return;
+ }
+ mainWindow.webContents.send(
+ 'remote:moveSessionToGroup',
+ sessionId,
+ groupId,
+ responseChannel
+ );
+
+ const timeoutId = setTimeout(() => {
+ if (resolved) return;
+ resolved = true;
+ ipcMain.removeListener(responseChannel, handleResponse);
+ logger.warn(
+ `moveSessionToGroup callback timed out for session ${sessionId}`,
+ 'WebServer'
+ );
+ resolve(false);
+ }, 5000);
+ });
+ });
+
+ // Set up callback for web server to get git status
+ // Uses IPC request-response pattern with timeout
+ server.setGetGitStatusCallback(async (sessionId: string) => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for getGitStatus', 'WebServer');
+ return { branch: '', files: [], ahead: 0, behind: 0 };
+ }
+
+ return new Promise((resolve) => {
+ const responseChannel = `remote:getGitStatus:response:${randomUUID()}`;
+ let resolved = false;
+
+ const handleResponse = (_event: Electron.IpcMainEvent, result: any) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timeoutId);
+ resolve(result || { branch: '', files: [], ahead: 0, behind: 0 });
+ };
+
+ ipcMain.once(responseChannel, handleResponse);
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for getGitStatus', 'WebServer');
+ ipcMain.removeListener(responseChannel, handleResponse);
+ resolve({ branch: '', files: [], ahead: 0, behind: 0 });
+ return;
+ }
+ mainWindow.webContents.send('remote:getGitStatus', sessionId, responseChannel);
+
+ const timeoutId = setTimeout(() => {
+ if (resolved) return;
+ resolved = true;
+ ipcMain.removeListener(responseChannel, handleResponse);
+ logger.warn(`getGitStatus callback timed out for session ${sessionId}`, 'WebServer');
+ resolve({ branch: '', files: [], ahead: 0, behind: 0 });
+ }, 10000);
+ });
+ });
+
+ // Set up callback for web server to get git diff
+ // Uses IPC request-response pattern with timeout
+ server.setGetGitDiffCallback(async (sessionId: string, filePath?: string) => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for getGitDiff', 'WebServer');
+ return { diff: '', files: [] };
+ }
+
+ return new Promise((resolve) => {
+ const responseChannel = `remote:getGitDiff:response:${randomUUID()}`;
+ let resolved = false;
+
+ const handleResponse = (_event: Electron.IpcMainEvent, result: any) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timeoutId);
+ resolve(result || { diff: '', files: [] });
+ };
+
+ ipcMain.once(responseChannel, handleResponse);
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for getGitDiff', 'WebServer');
+ ipcMain.removeListener(responseChannel, handleResponse);
+ resolve({ diff: '', files: [] });
+ return;
+ }
+ mainWindow.webContents.send('remote:getGitDiff', sessionId, filePath, responseChannel);
+
+ const timeoutId = setTimeout(() => {
+ if (resolved) return;
+ resolved = true;
+ ipcMain.removeListener(responseChannel, handleResponse);
+ logger.warn(`getGitDiff callback timed out for session ${sessionId}`, 'WebServer');
+ resolve({ diff: '', files: [] });
+ }, 10000);
+ });
+ });
+
+ // Set up callback for web server to stop Auto Run
+ // Fire-and-forget pattern (like interrupt)
+ server.setStopAutoRunCallback(async (sessionId: string) => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for stopAutoRun', 'WebServer');
+ return false;
+ }
+
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for stopAutoRun', 'WebServer');
+ return false;
+ }
+ mainWindow.webContents.send('remote:stopAutoRun', sessionId);
+ return true;
+ });
+
+ // ============ Group Chat Callbacks ============
+
+ // Get all group chats — uses IPC request-response pattern
+ server.setGetGroupChatsCallback(async () => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for getGroupChats', 'WebServer');
+ return [];
+ }
+
+ return new Promise((resolve) => {
+ const responseChannel = `remote:getGroupChats:response:${randomUUID()}`;
+ let resolved = false;
+
+ const handleResponse = (_event: Electron.IpcMainEvent, result: any) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timeoutId);
+ resolve(result || []);
+ };
+
+ ipcMain.once(responseChannel, handleResponse);
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for getGroupChats', 'WebServer');
+ ipcMain.removeListener(responseChannel, handleResponse);
+ resolve([]);
+ return;
+ }
+ mainWindow.webContents.send('remote:getGroupChats', responseChannel);
+
+ const timeoutId = setTimeout(() => {
+ if (resolved) return;
+ resolved = true;
+ ipcMain.removeListener(responseChannel, handleResponse);
+ logger.warn(`getGroupChats callback timed out`, 'WebServer');
+ resolve([]);
+ }, 10000);
+ });
+ });
+
+ // Start a group chat — uses IPC request-response pattern
+ server.setStartGroupChatCallback(async (topic: string, participantIds: string[]) => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for startGroupChat', 'WebServer');
+ return null;
+ }
+
+ return new Promise((resolve) => {
+ const responseChannel = `remote:startGroupChat:response:${randomUUID()}`;
+ let resolved = false;
+
+ const handleResponse = (_event: Electron.IpcMainEvent, result: any) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timeoutId);
+ resolve(result || null);
+ };
+
+ ipcMain.once(responseChannel, handleResponse);
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for startGroupChat', 'WebServer');
+ ipcMain.removeListener(responseChannel, handleResponse);
+ resolve(null);
+ return;
+ }
+ mainWindow.webContents.send(
+ 'remote:startGroupChat',
+ topic,
+ participantIds,
+ responseChannel
+ );
+
+ const timeoutId = setTimeout(() => {
+ if (resolved) return;
+ resolved = true;
+ ipcMain.removeListener(responseChannel, handleResponse);
+ logger.warn(`startGroupChat callback timed out`, 'WebServer');
+ resolve(null);
+ }, 15000);
+ });
+ });
+
+ // Get group chat state — uses IPC request-response pattern
+ server.setGetGroupChatStateCallback(async (chatId: string) => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for getGroupChatState', 'WebServer');
+ return null;
+ }
+
+ return new Promise((resolve) => {
+ const responseChannel = `remote:getGroupChatState:response:${randomUUID()}`;
+ let resolved = false;
+
+ const handleResponse = (_event: Electron.IpcMainEvent, result: any) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timeoutId);
+ resolve(result || null);
+ };
+
+ ipcMain.once(responseChannel, handleResponse);
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for getGroupChatState', 'WebServer');
+ ipcMain.removeListener(responseChannel, handleResponse);
+ resolve(null);
+ return;
+ }
+ mainWindow.webContents.send('remote:getGroupChatState', chatId, responseChannel);
+
+ const timeoutId = setTimeout(() => {
+ if (resolved) return;
+ resolved = true;
+ ipcMain.removeListener(responseChannel, handleResponse);
+ logger.warn(`getGroupChatState callback timed out for chat ${chatId}`, 'WebServer');
+ resolve(null);
+ }, 10000);
+ });
+ });
+
+ // Stop group chat — uses IPC request-response pattern
+ server.setStopGroupChatCallback(async (chatId: string) => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for stopGroupChat', 'WebServer');
+ return false;
+ }
+
+ return new Promise((resolve) => {
+ const responseChannel = `remote:stopGroupChat:response:${randomUUID()}`;
+ let resolved = false;
+
+ const handleResponse = (_event: Electron.IpcMainEvent, result: any) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timeoutId);
+ resolve(result ?? false);
+ };
+
+ ipcMain.once(responseChannel, handleResponse);
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for stopGroupChat', 'WebServer');
+ ipcMain.removeListener(responseChannel, handleResponse);
+ resolve(false);
+ return;
+ }
+ mainWindow.webContents.send('remote:stopGroupChat', chatId, responseChannel);
+
+ const timeoutId = setTimeout(() => {
+ if (resolved) return;
+ resolved = true;
+ ipcMain.removeListener(responseChannel, handleResponse);
+ logger.warn(`stopGroupChat callback timed out for chat ${chatId}`, 'WebServer');
+ resolve(false);
+ }, 10000);
+ });
+ });
+
+ // Send message to group chat — uses IPC request-response pattern
+ server.setSendGroupChatMessageCallback(async (chatId: string, message: string) => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for sendGroupChatMessage', 'WebServer');
+ return false;
+ }
+
+ return new Promise((resolve) => {
+ const responseChannel = `remote:sendGroupChatMessage:response:${randomUUID()}`;
+ let resolved = false;
+
+ const handleResponse = (_event: Electron.IpcMainEvent, result: any) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timeoutId);
+ resolve(result ?? false);
+ };
+
+ ipcMain.once(responseChannel, handleResponse);
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for sendGroupChatMessage', 'WebServer');
+ ipcMain.removeListener(responseChannel, handleResponse);
+ resolve(false);
+ return;
+ }
+ mainWindow.webContents.send(
+ 'remote:sendGroupChatMessage',
+ chatId,
+ message,
+ responseChannel
+ );
+
+ const timeoutId = setTimeout(() => {
+ if (resolved) return;
+ resolved = true;
+ ipcMain.removeListener(responseChannel, handleResponse);
+ logger.warn(`sendGroupChatMessage callback timed out for chat ${chatId}`, 'WebServer');
+ resolve(false);
+ }, 10000);
+ });
+ });
+
+ // ============ Context Management Callbacks ============
+
+ // Merge context — uses IPC request-response pattern
+ server.setMergeContextCallback(async (sourceSessionId: string, targetSessionId: string) => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for mergeContext', 'WebServer');
+ return false;
+ }
+
+ return new Promise((resolve) => {
+ const responseChannel = `remote:mergeContext:response:${randomUUID()}`;
+ let resolved = false;
+
+ const handleResponse = (_event: Electron.IpcMainEvent, result: any) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timeoutId);
+ resolve(result ?? false);
+ };
+
+ ipcMain.once(responseChannel, handleResponse);
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for mergeContext', 'WebServer');
+ ipcMain.removeListener(responseChannel, handleResponse);
+ resolve(false);
+ return;
+ }
+ mainWindow.webContents.send(
+ 'remote:mergeContext',
+ sourceSessionId,
+ targetSessionId,
+ responseChannel
+ );
+
+ const timeoutId = setTimeout(() => {
+ if (resolved) return;
+ resolved = true;
+ ipcMain.removeListener(responseChannel, handleResponse);
+ logger.warn(
+ `mergeContext callback timed out for sessions ${sourceSessionId} → ${targetSessionId}`,
+ 'WebServer'
+ );
+ resolve(false);
+ }, 30000);
+ });
+ });
+
+ // Transfer context — uses IPC request-response pattern
+ server.setTransferContextCallback(async (sourceSessionId: string, targetSessionId: string) => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for transferContext', 'WebServer');
+ return false;
+ }
+
+ return new Promise((resolve) => {
+ const responseChannel = `remote:transferContext:response:${randomUUID()}`;
+ let resolved = false;
+
+ const handleResponse = (_event: Electron.IpcMainEvent, result: any) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timeoutId);
+ resolve(result ?? false);
+ };
+
+ ipcMain.once(responseChannel, handleResponse);
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for transferContext', 'WebServer');
+ ipcMain.removeListener(responseChannel, handleResponse);
+ resolve(false);
+ return;
+ }
+ mainWindow.webContents.send(
+ 'remote:transferContext',
+ sourceSessionId,
+ targetSessionId,
+ responseChannel
+ );
+
+ const timeoutId = setTimeout(() => {
+ if (resolved) return;
+ resolved = true;
+ ipcMain.removeListener(responseChannel, handleResponse);
+ logger.warn(
+ `transferContext callback timed out for sessions ${sourceSessionId} → ${targetSessionId}`,
+ 'WebServer'
+ );
+ resolve(false);
+ }, 30000);
+ });
+ });
+
+ // Summarize context — uses IPC request-response pattern
+ server.setSummarizeContextCallback(async (sessionId: string) => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for summarizeContext', 'WebServer');
+ return false;
+ }
+
+ return new Promise((resolve) => {
+ const responseChannel = `remote:summarizeContext:response:${randomUUID()}`;
+ let resolved = false;
+
+ const handleResponse = (_event: Electron.IpcMainEvent, result: any) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timeoutId);
+ resolve(result ?? false);
+ };
+
+ ipcMain.once(responseChannel, handleResponse);
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for summarizeContext', 'WebServer');
+ ipcMain.removeListener(responseChannel, handleResponse);
+ resolve(false);
+ return;
+ }
+ mainWindow.webContents.send('remote:summarizeContext', sessionId, responseChannel);
+
+ const timeoutId = setTimeout(() => {
+ if (resolved) return;
+ resolved = true;
+ ipcMain.removeListener(responseChannel, handleResponse);
+ logger.warn(`summarizeContext callback timed out for session ${sessionId}`, 'WebServer');
+ resolve(false);
+ }, 60000);
+ });
+ });
+
+ // ============ Cue Automation Callbacks ============
+
+ // Get Cue subscriptions — uses IPC request-response pattern
+ server.setGetCueSubscriptionsCallback(async (sessionId?: string) => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for getCueSubscriptions', 'WebServer');
+ return [];
+ }
+
+ return new Promise((resolve) => {
+ const responseChannel = `remote:getCueSubscriptions:response:${randomUUID()}`;
+ let resolved = false;
+
+ const handleResponse = (_event: Electron.IpcMainEvent, result: any) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timeoutId);
+ resolve(result ?? []);
+ };
+
+ ipcMain.once(responseChannel, handleResponse);
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for getCueSubscriptions', 'WebServer');
+ ipcMain.removeListener(responseChannel, handleResponse);
+ resolve([]);
+ return;
+ }
+ mainWindow.webContents.send('remote:getCueSubscriptions', sessionId, responseChannel);
+
+ const timeoutId = setTimeout(() => {
+ if (resolved) return;
+ resolved = true;
+ ipcMain.removeListener(responseChannel, handleResponse);
+ logger.warn('getCueSubscriptions callback timed out', 'WebServer');
+ resolve([]);
+ }, 30000);
+ });
+ });
+
+ // Toggle Cue subscription — uses IPC request-response pattern
+ server.setToggleCueSubscriptionCallback(async (subscriptionId: string, enabled: boolean) => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for toggleCueSubscription', 'WebServer');
+ return false;
+ }
+
+ return new Promise((resolve) => {
+ const responseChannel = `remote:toggleCueSubscription:response:${randomUUID()}`;
+ let resolved = false;
+
+ const handleResponse = (_event: Electron.IpcMainEvent, result: any) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timeoutId);
+ resolve(result ?? false);
+ };
+
+ ipcMain.once(responseChannel, handleResponse);
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for toggleCueSubscription', 'WebServer');
+ ipcMain.removeListener(responseChannel, handleResponse);
+ resolve(false);
+ return;
+ }
+ mainWindow.webContents.send(
+ 'remote:toggleCueSubscription',
+ subscriptionId,
+ enabled,
+ responseChannel
+ );
+
+ const timeoutId = setTimeout(() => {
+ if (resolved) return;
+ resolved = true;
+ ipcMain.removeListener(responseChannel, handleResponse);
+ logger.warn(
+ `toggleCueSubscription callback timed out for ${subscriptionId}`,
+ 'WebServer'
+ );
+ resolve(false);
+ }, 10000);
+ });
+ });
+
+ // Get Cue activity log — uses IPC request-response pattern
+ server.setGetCueActivityCallback(async (sessionId?: string, limit?: number) => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for getCueActivity', 'WebServer');
+ return [];
+ }
+
+ return new Promise((resolve) => {
+ const responseChannel = `remote:getCueActivity:response:${randomUUID()}`;
+ let resolved = false;
+
+ const handleResponse = (_event: Electron.IpcMainEvent, result: any) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timeoutId);
+ resolve(result ?? []);
+ };
+
+ ipcMain.once(responseChannel, handleResponse);
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for getCueActivity', 'WebServer');
+ ipcMain.removeListener(responseChannel, handleResponse);
+ resolve([]);
+ return;
+ }
+ mainWindow.webContents.send(
+ 'remote:getCueActivity',
+ sessionId,
+ limit ?? 50,
+ responseChannel
+ );
+
+ const timeoutId = setTimeout(() => {
+ if (resolved) return;
+ resolved = true;
+ ipcMain.removeListener(responseChannel, handleResponse);
+ logger.warn('getCueActivity callback timed out', 'WebServer');
+ resolve([]);
+ }, 30000);
+ });
+ });
+
+ // ============ Usage Dashboard & Achievements Callbacks ============
+
+ // Get usage dashboard data — aggregates from session usage stats via IPC
+ server.setGetUsageDashboardCallback(async (timeRange: 'day' | 'week' | 'month' | 'all') => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for getUsageDashboard', 'WebServer');
+ return {
+ totalTokensIn: 0,
+ totalTokensOut: 0,
+ totalCost: 0,
+ sessionBreakdown: [],
+ dailyUsage: [],
+ };
+ }
+
+ return new Promise((resolve) => {
+ const responseChannel = `remote:getUsageDashboard:response:${randomUUID()}`;
+ let resolved = false;
+
+ const handleResponse = (_event: Electron.IpcMainEvent, result: any) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timeoutId);
+ resolve(
+ result ?? {
+ totalTokensIn: 0,
+ totalTokensOut: 0,
+ totalCost: 0,
+ sessionBreakdown: [],
+ dailyUsage: [],
+ }
+ );
+ };
+
+ ipcMain.once(responseChannel, handleResponse);
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for getUsageDashboard', 'WebServer');
+ ipcMain.removeListener(responseChannel, handleResponse);
+ resolve({
+ totalTokensIn: 0,
+ totalTokensOut: 0,
+ totalCost: 0,
+ sessionBreakdown: [],
+ dailyUsage: [],
+ });
+ return;
+ }
+ mainWindow.webContents.send('remote:getUsageDashboard', timeRange, responseChannel);
+
+ const timeoutId = setTimeout(() => {
+ if (resolved) return;
+ resolved = true;
+ ipcMain.removeListener(responseChannel, handleResponse);
+ logger.warn('getUsageDashboard callback timed out', 'WebServer');
+ resolve({
+ totalTokensIn: 0,
+ totalTokensOut: 0,
+ totalCost: 0,
+ sessionBreakdown: [],
+ dailyUsage: [],
+ });
+ }, 15000);
+ });
+ });
+
+ // Get achievements data — aggregates from settings store via IPC
+ server.setGetAchievementsCallback(async () => {
+ const mainWindow = getMainWindow();
+ if (!mainWindow) {
+ logger.warn('mainWindow is null for getAchievements', 'WebServer');
+ return [];
+ }
+
+ return new Promise((resolve) => {
+ const responseChannel = `remote:getAchievements:response:${randomUUID()}`;
+ let resolved = false;
+
+ const handleResponse = (_event: Electron.IpcMainEvent, result: any) => {
+ if (resolved) return;
+ resolved = true;
+ clearTimeout(timeoutId);
+ resolve(result ?? []);
+ };
+
+ ipcMain.once(responseChannel, handleResponse);
+ if (!isWebContentsAvailable(mainWindow)) {
+ logger.warn('webContents is not available for getAchievements', 'WebServer');
+ ipcMain.removeListener(responseChannel, handleResponse);
+ resolve([]);
+ return;
+ }
+ mainWindow.webContents.send('remote:getAchievements', responseChannel);
+
+ const timeoutId = setTimeout(() => {
+ if (resolved) return;
+ resolved = true;
+ ipcMain.removeListener(responseChannel, handleResponse);
+ logger.warn('getAchievements callback timed out', 'WebServer');
+ resolve([]);
+ }, 10000);
+ });
+ });
+
return server;
};
}
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 68c2285797..5aab92bec7 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -20,6 +20,7 @@ import {
MaestroWizard,
useWizard,
WizardResumeModal,
+ AUTO_RUN_FOLDER_NAME,
type SerializableWizardState,
type WizardStep,
} from './components/Wizard';
@@ -169,7 +170,15 @@ import { ToastContainer } from './components/Toast';
// Import types and constants
// Note: GroupChat, GroupChatState are imported from types (re-exported from shared)
-import type { RightPanelTab, Session, QueuedItem, CustomAICommand, ThinkingItem } from './types';
+import type {
+ RightPanelTab,
+ Session,
+ QueuedItem,
+ CustomAICommand,
+ ThinkingItem,
+ AITab,
+ ToolType,
+} from './types';
import { THEMES } from './constants/themes';
import { generateId } from './utils/ids';
import { getContextColor } from './utils/theme';
@@ -193,6 +202,7 @@ import {
// validateNewSession moved to useSymphonyContribution, useSessionCrud hooks
// formatLogsForClipboard moved to useTabExportHandlers hook
// getSlashCommandDescription moved to useWizardHandlers
+import { useSettingsStore } from './stores/settingsStore';
import { useUIStore } from './stores/uiStore';
import { useTabStore } from './stores/tabStore';
import { useFileExplorerStore } from './stores/fileExplorerStore';
@@ -1309,6 +1319,7 @@ function MaestroConsoleInner() {
// --- BATCH HANDLERS (Auto Run processing, quit confirmation, error handling) ---
const {
startBatchRun,
+ stopBatchRun,
getBatchState,
handleStopBatchRun,
handleKillBatchRun,
@@ -1991,6 +2002,350 @@ function MaestroConsoleInner() {
return () => window.removeEventListener('maestro:configureAutoRun', handler);
}, [sessionsRef, startBatchRun]);
+ // Handle remote get auto-run docs from web interface
+ useEffect(() => {
+ const handler = async (e: Event) => {
+ const { sessionId, responseChannel } = (e as CustomEvent).detail;
+ try {
+ const session = sessionsRef.current.find((s) => s.id === sessionId);
+ if (!session?.autoRunFolderPath) {
+ window.maestro.process.sendRemoteGetAutoRunDocsResponse(responseChannel, []);
+ return;
+ }
+ const sshRemoteId =
+ session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId || undefined;
+ const listResult = await window.maestro.autorun.listDocs(
+ session.autoRunFolderPath,
+ sshRemoteId
+ );
+ const files = listResult.success ? listResult.files || [] : [];
+ window.maestro.process.sendRemoteGetAutoRunDocsResponse(responseChannel, files);
+ } catch (error) {
+ console.error('[Remote] Failed to get auto-run docs:', error);
+ window.maestro.process.sendRemoteGetAutoRunDocsResponse(responseChannel, []);
+ }
+ };
+ window.addEventListener('maestro:getAutoRunDocs', handler);
+ return () => window.removeEventListener('maestro:getAutoRunDocs', handler);
+ }, [sessionsRef]);
+
+ // Handle remote get auto-run doc content from web interface
+ useEffect(() => {
+ const handler = async (e: Event) => {
+ const { sessionId, filename, responseChannel } = (e as CustomEvent).detail;
+ try {
+ const session = sessionsRef.current.find((s) => s.id === sessionId);
+ if (!session?.autoRunFolderPath) {
+ window.maestro.process.sendRemoteGetAutoRunDocContentResponse(responseChannel, '');
+ return;
+ }
+ const sshRemoteId =
+ session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId || undefined;
+ const contentResult = await window.maestro.autorun.readDoc(
+ session.autoRunFolderPath,
+ filename,
+ sshRemoteId
+ );
+ const content = contentResult.success ? contentResult.content || '' : '';
+ window.maestro.process.sendRemoteGetAutoRunDocContentResponse(responseChannel, content);
+ } catch (error) {
+ console.error('[Remote] Failed to get auto-run doc content:', error);
+ window.maestro.process.sendRemoteGetAutoRunDocContentResponse(responseChannel, '');
+ }
+ };
+ window.addEventListener('maestro:getAutoRunDocContent', handler);
+ return () => window.removeEventListener('maestro:getAutoRunDocContent', handler);
+ }, [sessionsRef]);
+
+ // Handle remote save auto-run doc from web interface
+ useEffect(() => {
+ const handler = async (e: Event) => {
+ const { sessionId, filename, content, responseChannel } = (e as CustomEvent).detail;
+ try {
+ const session = sessionsRef.current.find((s) => s.id === sessionId);
+ if (!session?.autoRunFolderPath) {
+ window.maestro.process.sendRemoteSaveAutoRunDocResponse(responseChannel, false);
+ return;
+ }
+ const sshRemoteId =
+ session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId || undefined;
+ const writeResult = await window.maestro.autorun.writeDoc(
+ session.autoRunFolderPath,
+ filename,
+ content,
+ sshRemoteId
+ );
+ window.maestro.process.sendRemoteSaveAutoRunDocResponse(
+ responseChannel,
+ writeResult.success ?? false
+ );
+ } catch (error) {
+ console.error('[Remote] Failed to save auto-run doc:', error);
+ window.maestro.process.sendRemoteSaveAutoRunDocResponse(responseChannel, false);
+ }
+ };
+ window.addEventListener('maestro:saveAutoRunDoc', handler);
+ return () => window.removeEventListener('maestro:saveAutoRunDoc', handler);
+ }, [sessionsRef]);
+
+ // Handle remote stop auto-run from web interface (fire-and-forget, no confirmation dialog)
+ useEffect(() => {
+ const handler = (e: Event) => {
+ const { sessionId } = (e as CustomEvent).detail;
+ stopBatchRun(sessionId);
+ };
+ window.addEventListener('maestro:stopAutoRun', handler);
+ return () => window.removeEventListener('maestro:stopAutoRun', handler);
+ }, [stopBatchRun]);
+
+ // Handle remote create session from web interface
+ useEffect(() => {
+ const handler = async (e: Event) => {
+ const { name, toolType, cwd, groupId, responseChannel } = (e as CustomEvent).detail;
+ try {
+ // Get agent definition to validate
+ const agent = await (window as any).maestro.agents.get(toolType);
+ if (!agent) {
+ window.maestro.process.sendRemoteCreateSessionResponse(responseChannel, null);
+ return;
+ }
+
+ const currentDefaults = useSettingsStore.getState();
+ const newId = generateId();
+ const initialTabId = generateId();
+ const initialTab: AITab = {
+ id: initialTabId,
+ agentSessionId: null,
+ name: null,
+ starred: false,
+ logs: [],
+ inputValue: '',
+ stagedImages: [],
+ createdAt: Date.now(),
+ state: 'idle',
+ saveToHistory: currentDefaults.defaultSaveToHistory,
+ showThinking: currentDefaults.defaultShowThinking,
+ };
+
+ const newSession: Session = {
+ id: newId,
+ name,
+ toolType: toolType as ToolType,
+ state: 'idle',
+ cwd,
+ fullPath: cwd,
+ projectRoot: cwd,
+ isGitRepo: false,
+ aiLogs: [],
+ shellLogs: [
+ {
+ id: generateId(),
+ timestamp: Date.now(),
+ source: 'system',
+ text: 'Shell Session Ready.',
+ },
+ ],
+ workLog: [],
+ contextUsage: 0,
+ inputMode: toolType === 'terminal' ? 'terminal' : 'ai',
+ aiPid: 0,
+ terminalPid: 0,
+ port: 3000 + Math.floor(Math.random() * 100),
+ isLive: false,
+ changedFiles: [],
+ fileTree: [],
+ fileExplorerExpanded: [],
+ fileExplorerScrollPos: 0,
+ fileTreeAutoRefreshInterval: 180,
+ shellCwd: cwd,
+ aiCommandHistory: [],
+ shellCommandHistory: [],
+ executionQueue: [],
+ activeTimeMs: 0,
+ aiTabs: [initialTab],
+ activeTabId: initialTabId,
+ closedTabHistory: [],
+ filePreviewTabs: [],
+ activeFileTabId: null,
+ terminalTabs: [],
+ activeTerminalTabId: null,
+ unifiedTabOrder: [{ type: 'ai' as const, id: initialTabId }],
+ unifiedClosedTabHistory: [],
+ groupId: groupId || undefined,
+ autoRunFolderPath: `${cwd}/${AUTO_RUN_FOLDER_NAME}`,
+ };
+
+ setSessions((prev) => [...prev, newSession]);
+ setActiveSessionId(newId);
+ (window as any).maestro.stats.recordSessionCreated({
+ sessionId: newId,
+ agentType: toolType,
+ projectPath: cwd,
+ createdAt: Date.now(),
+ isRemote: false,
+ });
+
+ window.maestro.process.sendRemoteCreateSessionResponse(responseChannel, {
+ sessionId: newId,
+ });
+ } catch (error) {
+ console.error('[Remote] Failed to create session:', error);
+ window.maestro.process.sendRemoteCreateSessionResponse(responseChannel, null);
+ }
+ };
+ window.addEventListener('maestro:remoteCreateSession', handler);
+ return () => window.removeEventListener('maestro:remoteCreateSession', handler);
+ }, [setSessions, setActiveSessionId]);
+
+ // Handle remote delete session from web interface (skip confirmation dialog)
+ useEffect(() => {
+ const handler = async (e: Event) => {
+ const { sessionId } = (e as CustomEvent).detail;
+ const session = sessionsRef.current.find((s) => s.id === sessionId);
+ if (!session) return;
+
+ // Kill processes
+ try {
+ await window.maestro.process.kill(`${sessionId}-ai`);
+ } catch {
+ /* ignore */
+ }
+ try {
+ await window.maestro.process.kill(`${sessionId}-terminal`);
+ } catch {
+ /* ignore */
+ }
+ for (const tab of session.terminalTabs || []) {
+ try {
+ await window.maestro.process.kill(`${sessionId}-terminal-${tab.id}`);
+ } catch {
+ /* ignore */
+ }
+ }
+
+ // Remove session
+ setSessions((prev) => {
+ const filtered = prev.filter((s) => s.id !== sessionId);
+ if (filtered.length > 0 && useSessionStore.getState().activeSessionId === sessionId) {
+ setActiveSessionId(filtered[0].id);
+ }
+ return filtered;
+ });
+ };
+ window.addEventListener('maestro:remoteDeleteSession', handler);
+ return () => window.removeEventListener('maestro:remoteDeleteSession', handler);
+ }, [sessionsRef, setSessions, setActiveSessionId]);
+
+ // Handle remote rename session from web interface
+ useEffect(() => {
+ const handler = (e: Event) => {
+ const { sessionId, newName, responseChannel } = (e as CustomEvent).detail;
+ const session = sessionsRef.current.find((s) => s.id === sessionId);
+ if (!session) {
+ window.maestro.process.sendRemoteRenameSessionResponse(responseChannel, false);
+ return;
+ }
+
+ setSessions((prev) => {
+ const updated = prev.map((s) => (s.id === sessionId ? { ...s, name: newName } : s));
+ const sess = updated.find((s) => s.id === sessionId);
+ // Persist name to agent storage
+ const providerSessionId =
+ sess?.agentSessionId ||
+ sess?.aiTabs?.find((t) => t.id === sess.activeTabId)?.agentSessionId ||
+ sess?.aiTabs?.[0]?.agentSessionId;
+ if (providerSessionId && sess?.projectRoot) {
+ const agentId = sess.toolType || 'claude-code';
+ if (agentId === 'claude-code') {
+ (window as any).maestro.claude
+ .updateSessionName(sess.projectRoot, providerSessionId, newName)
+ .catch(() => {});
+ } else {
+ (window as any).maestro.agentSessions
+ .setSessionName(agentId, sess.projectRoot, providerSessionId, newName)
+ .catch(() => {});
+ }
+ }
+ return updated;
+ });
+
+ window.maestro.process.sendRemoteRenameSessionResponse(responseChannel, true);
+ };
+ window.addEventListener('maestro:remoteRenameSession', handler);
+ return () => window.removeEventListener('maestro:remoteRenameSession', handler);
+ }, [sessionsRef, setSessions]);
+
+ // Handle remote create group from web interface
+ useEffect(() => {
+ const handler = (e: Event) => {
+ const { name, emoji, responseChannel } = (e as CustomEvent).detail;
+ const trimmed = name.trim();
+ if (!trimmed) {
+ window.maestro.process.sendRemoteCreateGroupResponse(responseChannel, null);
+ return;
+ }
+ const newGroupId = `group-${generateId()}`;
+ setGroups((prev) => [
+ ...prev,
+ { id: newGroupId, name: trimmed.toUpperCase(), emoji: emoji || '📂', collapsed: false },
+ ]);
+ window.maestro.process.sendRemoteCreateGroupResponse(responseChannel, { id: newGroupId });
+ };
+ window.addEventListener('maestro:remoteCreateGroup', handler);
+ return () => window.removeEventListener('maestro:remoteCreateGroup', handler);
+ }, [setGroups]);
+
+ // Handle remote rename group from web interface
+ useEffect(() => {
+ const handler = (e: Event) => {
+ const { groupId, name, responseChannel } = (e as CustomEvent).detail;
+ const trimmed = name.trim();
+ if (!trimmed) {
+ window.maestro.process.sendRemoteRenameGroupResponse(responseChannel, false);
+ return;
+ }
+ setGroups((prev) =>
+ prev.map((g) => (g.id === groupId ? { ...g, name: trimmed.toUpperCase() } : g))
+ );
+ window.maestro.process.sendRemoteRenameGroupResponse(responseChannel, true);
+ };
+ window.addEventListener('maestro:remoteRenameGroup', handler);
+ return () => window.removeEventListener('maestro:remoteRenameGroup', handler);
+ }, [setGroups]);
+
+ // Handle remote delete group from web interface (fire-and-forget)
+ useEffect(() => {
+ const handler = (e: Event) => {
+ const { groupId } = (e as CustomEvent).detail;
+ // Ungroup sessions in this group
+ setSessions((prev) =>
+ prev.map((s) => (s.groupId === groupId ? { ...s, groupId: undefined } : s))
+ );
+ // Remove the group
+ setGroups((prev) => prev.filter((g) => g.id !== groupId));
+ };
+ window.addEventListener('maestro:remoteDeleteGroup', handler);
+ return () => window.removeEventListener('maestro:remoteDeleteGroup', handler);
+ }, [setSessions, setGroups]);
+
+ // Handle remote move session to group from web interface
+ useEffect(() => {
+ const handler = (e: Event) => {
+ const { sessionId, groupId, responseChannel } = (e as CustomEvent).detail;
+ const session = sessionsRef.current.find((s) => s.id === sessionId);
+ if (!session) {
+ window.maestro.process.sendRemoteMoveSessionToGroupResponse(responseChannel, false);
+ return;
+ }
+ setSessions((prev) =>
+ prev.map((s) => (s.id === sessionId ? { ...s, groupId: groupId || undefined } : s))
+ );
+ window.maestro.process.sendRemoteMoveSessionToGroupResponse(responseChannel, true);
+ };
+ window.addEventListener('maestro:remoteMoveSessionToGroup', handler);
+ return () => window.removeEventListener('maestro:remoteMoveSessionToGroup', handler);
+ }, [sessionsRef, setSessions]);
+
// --- GROUP MANAGEMENT ---
// Extracted hook for group CRUD operations (toggle, rename, create, drag-drop)
const {
diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts
index cc15504af5..306a0591eb 100644
--- a/src/renderer/global.d.ts
+++ b/src/renderer/global.d.ts
@@ -367,6 +367,67 @@ interface MaestroAPI {
responseChannel: string,
result: { success: boolean; playbookId?: string; error?: string }
) => void;
+ onRemoteGetAutoRunDocs: (
+ callback: (sessionId: string, responseChannel: string) => void
+ ) => () => void;
+ sendRemoteGetAutoRunDocsResponse: (responseChannel: string, documents: any[]) => void;
+ onRemoteGetAutoRunDocContent: (
+ callback: (sessionId: string, filename: string, responseChannel: string) => void
+ ) => () => void;
+ sendRemoteGetAutoRunDocContentResponse: (responseChannel: string, content: string) => void;
+ onRemoteSaveAutoRunDoc: (
+ callback: (
+ sessionId: string,
+ filename: string,
+ content: string,
+ responseChannel: string
+ ) => void
+ ) => () => void;
+ sendRemoteSaveAutoRunDocResponse: (responseChannel: string, success: boolean) => void;
+ onRemoteStopAutoRun: (callback: (sessionId: string) => void) => () => void;
+ onRemoteSetSetting: (
+ callback: (key: string, value: unknown, responseChannel: string) => void
+ ) => () => void;
+ sendRemoteSetSettingResponse: (responseChannel: string, success: boolean) => void;
+ onRemoteCreateSession: (
+ callback: (
+ name: string,
+ toolType: string,
+ cwd: string,
+ groupId: string | undefined,
+ responseChannel: string
+ ) => void
+ ) => () => void;
+ sendRemoteCreateSessionResponse: (
+ responseChannel: string,
+ result: { sessionId: string } | null
+ ) => void;
+ onRemoteDeleteSession: (callback: (sessionId: string) => void) => () => void;
+ onRemoteRenameSession: (
+ callback: (sessionId: string, newName: string, responseChannel: string) => void
+ ) => () => void;
+ sendRemoteRenameSessionResponse: (responseChannel: string, success: boolean) => void;
+ onRemoteCreateGroup: (
+ callback: (name: string, emoji: string | undefined, responseChannel: string) => void
+ ) => () => void;
+ sendRemoteCreateGroupResponse: (responseChannel: string, result: { id: string } | null) => void;
+ onRemoteRenameGroup: (
+ callback: (groupId: string, name: string, responseChannel: string) => void
+ ) => () => void;
+ sendRemoteRenameGroupResponse: (responseChannel: string, success: boolean) => void;
+ onRemoteDeleteGroup: (callback: (groupId: string) => void) => () => void;
+ onRemoteMoveSessionToGroup: (
+ callback: (sessionId: string, groupId: string | null, responseChannel: string) => void
+ ) => () => void;
+ sendRemoteMoveSessionToGroupResponse: (responseChannel: string, success: boolean) => void;
+ onRemoteGetGitStatus: (
+ callback: (sessionId: string, responseChannel: string) => void
+ ) => () => void;
+ sendRemoteGetGitStatusResponse: (responseChannel: string, result: any) => void;
+ onRemoteGetGitDiff: (
+ callback: (sessionId: string, filePath: string | undefined, responseChannel: string) => void
+ ) => () => void;
+ sendRemoteGetGitDiffResponse: (responseChannel: string, result: any) => void;
onStderr: (callback: (sessionId: string, data: string) => void) => () => void;
onCommandExit: (callback: (sessionId: string, code: number) => void) => () => void;
onUsage: (callback: (sessionId: string, usageStats: UsageStats) => void) => () => void;
diff --git a/src/renderer/hooks/batch/useBatchHandlers.ts b/src/renderer/hooks/batch/useBatchHandlers.ts
index befc5bf7e4..80b4bb83db 100644
--- a/src/renderer/hooks/batch/useBatchHandlers.ts
+++ b/src/renderer/hooks/batch/useBatchHandlers.ts
@@ -61,6 +61,8 @@ export interface UseBatchHandlersDeps {
export interface UseBatchHandlersReturn {
/** Start a batch run for a session */
startBatchRun: (sessionId: string, config: BatchRunConfig, folderPath: string) => Promise;
+ /** Stop a batch run directly (no confirmation dialog, used by web remote) */
+ stopBatchRun: (sessionId: string) => void;
/** Get batch state for a specific session */
getBatchState: (sessionId: string) => BatchRunState;
/** Stop batch run with confirmation dialog */
@@ -703,6 +705,7 @@ export function useBatchHandlers(deps: UseBatchHandlersDeps): UseBatchHandlersRe
return {
startBatchRun,
+ stopBatchRun,
getBatchState,
handleStopBatchRun,
handleKillBatchRun,
diff --git a/src/renderer/hooks/remote/useRemoteIntegration.ts b/src/renderer/hooks/remote/useRemoteIntegration.ts
index eaf5e757ab..13c3bf0109 100644
--- a/src/renderer/hooks/remote/useRemoteIntegration.ts
+++ b/src/renderer/hooks/remote/useRemoteIntegration.ts
@@ -496,6 +496,296 @@ export function useRemoteIntegration(deps: UseRemoteIntegrationDeps): UseRemoteI
};
}, []);
+ // Handle remote get auto-run docs from web interface
+ useEffect(() => {
+ const unsubscribe = window.maestro.process.onRemoteGetAutoRunDocs(
+ (sessionId: string, responseChannel: string) => {
+ window.dispatchEvent(
+ new CustomEvent('maestro:getAutoRunDocs', {
+ detail: { sessionId, responseChannel },
+ })
+ );
+ }
+ );
+ return () => {
+ unsubscribe();
+ };
+ }, []);
+
+ // Handle remote get auto-run doc content from web interface
+ useEffect(() => {
+ const unsubscribe = window.maestro.process.onRemoteGetAutoRunDocContent(
+ (sessionId: string, filename: string, responseChannel: string) => {
+ window.dispatchEvent(
+ new CustomEvent('maestro:getAutoRunDocContent', {
+ detail: { sessionId, filename, responseChannel },
+ })
+ );
+ }
+ );
+ return () => {
+ unsubscribe();
+ };
+ }, []);
+
+ // Handle remote save auto-run doc from web interface
+ useEffect(() => {
+ const unsubscribe = window.maestro.process.onRemoteSaveAutoRunDoc(
+ (sessionId: string, filename: string, content: string, responseChannel: string) => {
+ window.dispatchEvent(
+ new CustomEvent('maestro:saveAutoRunDoc', {
+ detail: { sessionId, filename, content, responseChannel },
+ })
+ );
+ }
+ );
+ return () => {
+ unsubscribe();
+ };
+ }, []);
+
+ // Handle remote stop auto-run from web interface
+ useEffect(() => {
+ const unsubscribe = window.maestro.process.onRemoteStopAutoRun((sessionId: string) => {
+ window.dispatchEvent(
+ new CustomEvent('maestro:stopAutoRun', {
+ detail: { sessionId },
+ })
+ );
+ });
+ return () => {
+ unsubscribe();
+ };
+ }, []);
+
+ // Handle remote set setting from web interface
+ // Uses the existing settings infrastructure via window.maestro.settings.set()
+ useEffect(() => {
+ const unsubscribe = window.maestro.process.onRemoteSetSetting(
+ async (key: string, value: unknown, responseChannel: string) => {
+ try {
+ await window.maestro.settings.set(key, value);
+ window.maestro.process.sendRemoteSetSettingResponse(responseChannel, true);
+ } catch {
+ window.maestro.process.sendRemoteSetSettingResponse(responseChannel, false);
+ }
+ }
+ );
+ return () => {
+ unsubscribe();
+ };
+ }, []);
+
+ // Handle remote get git status from web interface
+ // Uses existing git IPC infrastructure (window.maestro.git.status + window.maestro.git.branch)
+ useEffect(() => {
+ const unsubscribe = window.maestro.process.onRemoteGetGitStatus(
+ async (sessionId: string, responseChannel: string) => {
+ try {
+ // Look up the session's cwd
+ const session = sessionsRef.current.find((s) => s.id === sessionId);
+ if (!session) {
+ window.maestro.process.sendRemoteGetGitStatusResponse(responseChannel, {
+ branch: '',
+ files: [],
+ ahead: 0,
+ behind: 0,
+ });
+ return;
+ }
+
+ const cwd = session.cwd;
+
+ // Run git status --porcelain and git branch in parallel
+ const [statusResult, branchResult] = await Promise.all([
+ window.maestro.git.status(cwd),
+ window.maestro.git.branch(cwd),
+ ]);
+
+ // Parse status output
+ const statusLines = (statusResult.stdout || '')
+ .replace(/\s+$/, '')
+ .split('\n')
+ .filter((line: string) => line.length > 0);
+
+ const files = statusLines.map((line: string) => {
+ const status = line.substring(0, 2);
+ const pathField = line.substring(3);
+ const renameParts = pathField.split(' -> ');
+ const filePath = renameParts[renameParts.length - 1] || pathField;
+ // Staged if index column (first char) is not space or ?
+ const staged = status[0] !== ' ' && status[0] !== '?';
+ return { path: filePath, status: status.trim(), staged };
+ });
+
+ const branch = (branchResult.stdout || '').trim();
+
+ // Get ahead/behind info
+ let ahead = 0;
+ let behind = 0;
+ try {
+ const infoResult = await window.maestro.git.info(cwd);
+ ahead = infoResult.ahead || 0;
+ behind = infoResult.behind || 0;
+ } catch {
+ // ahead/behind not available, that's fine
+ }
+
+ window.maestro.process.sendRemoteGetGitStatusResponse(responseChannel, {
+ branch,
+ files,
+ ahead,
+ behind,
+ });
+ } catch {
+ window.maestro.process.sendRemoteGetGitStatusResponse(responseChannel, {
+ branch: '',
+ files: [],
+ ahead: 0,
+ behind: 0,
+ });
+ }
+ }
+ );
+ return () => {
+ unsubscribe();
+ };
+ }, []);
+
+ // Handle remote get git diff from web interface
+ // Uses existing git IPC infrastructure (window.maestro.git.diff)
+ useEffect(() => {
+ const unsubscribe = window.maestro.process.onRemoteGetGitDiff(
+ async (sessionId: string, filePath: string | undefined, responseChannel: string) => {
+ try {
+ // Look up the session's cwd
+ const session = sessionsRef.current.find((s) => s.id === sessionId);
+ if (!session) {
+ window.maestro.process.sendRemoteGetGitDiffResponse(responseChannel, {
+ diff: '',
+ files: [],
+ });
+ return;
+ }
+
+ const cwd = session.cwd;
+ const diffResult = await window.maestro.git.diff(cwd, filePath);
+ const diff = diffResult.stdout || '';
+
+ // Extract changed file paths from diff output
+ const fileMatches = diff.match(/^diff --git a\/.+ b\/(.+)$/gm) || [];
+ const files = fileMatches
+ .map((line: string) => {
+ const match = line.match(/^diff --git a\/.+ b\/(.+)$/);
+ return match ? match[1] : '';
+ })
+ .filter(Boolean);
+
+ window.maestro.process.sendRemoteGetGitDiffResponse(responseChannel, {
+ diff,
+ files,
+ });
+ } catch {
+ window.maestro.process.sendRemoteGetGitDiffResponse(responseChannel, {
+ diff: '',
+ files: [],
+ });
+ }
+ }
+ );
+ return () => {
+ unsubscribe();
+ };
+ }, []);
+
+ // Handle remote session/group management from web interface
+ // These dispatch CustomEvents for App.tsx to handle via existing session/group management hooks
+ useEffect(() => {
+ const unsubscribeCreateSession = window.maestro.process.onRemoteCreateSession(
+ (
+ name: string,
+ toolType: string,
+ cwd: string,
+ groupId: string | undefined,
+ responseChannel: string
+ ) => {
+ window.dispatchEvent(
+ new CustomEvent('maestro:remoteCreateSession', {
+ detail: { name, toolType, cwd, groupId, responseChannel },
+ })
+ );
+ }
+ );
+
+ const unsubscribeDeleteSession = window.maestro.process.onRemoteDeleteSession(
+ (sessionId: string) => {
+ window.dispatchEvent(
+ new CustomEvent('maestro:remoteDeleteSession', {
+ detail: { sessionId },
+ })
+ );
+ }
+ );
+
+ const unsubscribeRenameSession = window.maestro.process.onRemoteRenameSession(
+ (sessionId: string, newName: string, responseChannel: string) => {
+ window.dispatchEvent(
+ new CustomEvent('maestro:remoteRenameSession', {
+ detail: { sessionId, newName, responseChannel },
+ })
+ );
+ }
+ );
+
+ const unsubscribeCreateGroup = window.maestro.process.onRemoteCreateGroup(
+ (name: string, emoji: string | undefined, responseChannel: string) => {
+ window.dispatchEvent(
+ new CustomEvent('maestro:remoteCreateGroup', {
+ detail: { name, emoji, responseChannel },
+ })
+ );
+ }
+ );
+
+ const unsubscribeRenameGroup = window.maestro.process.onRemoteRenameGroup(
+ (groupId: string, name: string, responseChannel: string) => {
+ window.dispatchEvent(
+ new CustomEvent('maestro:remoteRenameGroup', {
+ detail: { groupId, name, responseChannel },
+ })
+ );
+ }
+ );
+
+ const unsubscribeDeleteGroup = window.maestro.process.onRemoteDeleteGroup((groupId: string) => {
+ window.dispatchEvent(
+ new CustomEvent('maestro:remoteDeleteGroup', {
+ detail: { groupId },
+ })
+ );
+ });
+
+ const unsubscribeMoveSessionToGroup = window.maestro.process.onRemoteMoveSessionToGroup(
+ (sessionId: string, groupId: string | null, responseChannel: string) => {
+ window.dispatchEvent(
+ new CustomEvent('maestro:remoteMoveSessionToGroup', {
+ detail: { sessionId, groupId, responseChannel },
+ })
+ );
+ }
+ );
+
+ return () => {
+ unsubscribeCreateSession();
+ unsubscribeDeleteSession();
+ unsubscribeRenameSession();
+ unsubscribeCreateGroup();
+ unsubscribeRenameGroup();
+ unsubscribeDeleteGroup();
+ unsubscribeMoveSessionToGroup();
+ };
+ }, []);
+
// Broadcast tab changes to web clients when tabs, activeTabId, or tab properties change
// PERFORMANCE FIX: This effect was previously missing its dependency array, causing it to
// run on EVERY render (including every keystroke). Now it only runs when isLiveMode changes,
diff --git a/src/renderer/stores/agentStore.ts b/src/renderer/stores/agentStore.ts
index 33d83c26a6..b651724264 100644
--- a/src/renderer/stores/agentStore.ts
+++ b/src/renderer/stores/agentStore.ts
@@ -366,9 +366,6 @@ export const useAgentStore = create()((set, get) => ({
} else if (item.type === 'command' && item.command) {
// Process a slash command - find matching command
// Check user-defined commands first, then agent-discovered commands with prompts
- const agentCmd = session.agentCommands?.find(
- (cmd) => cmd.command === item.command && cmd.prompt
- );
const matchingCommand =
deps.customAICommands.find((cmd) => cmd.command === item.command) ||
deps.speckitCommands.find((cmd) => cmd.command === item.command) ||
diff --git a/src/web/hooks/index.ts b/src/web/hooks/index.ts
index 8fe2b5c424..5bd05cb003 100644
--- a/src/web/hooks/index.ts
+++ b/src/web/hooks/index.ts
@@ -22,6 +22,8 @@ export type {
SessionRemovedMessage,
ThemeMessage,
ErrorMessage,
+ GroupData,
+ GroupsChangedMessage,
TypedServerMessage,
WebSocketEventHandlers,
UseWebSocketOptions,
@@ -182,3 +184,27 @@ export type {
UseMobileAutoReconnectDeps,
UseMobileAutoReconnectReturn,
} from './useMobileAutoReconnect';
+
+export { useAgentManagement, default as useAgentManagementDefault } from './useAgentManagement';
+
+export type { UseAgentManagementReturn } from './useAgentManagement';
+
+export { useGitStatus, default as useGitStatusDefault } from './useGitStatus';
+
+export type {
+ GitStatusFile,
+ GitStatusResult,
+ GitDiffResult,
+ UseGitStatusReturn,
+} from './useGitStatus';
+
+export { useGroupChat, default as useGroupChatDefault } from './useGroupChat';
+
+export type { UseGroupChatReturn } from './useGroupChat';
+
+export type {
+ GroupChatMessage,
+ GroupChatState,
+ GroupChatMessageBroadcast,
+ GroupChatStateChangeBroadcast,
+} from './useWebSocket';
diff --git a/src/web/hooks/useAgentManagement.ts b/src/web/hooks/useAgentManagement.ts
new file mode 100644
index 0000000000..10e3ee0e09
--- /dev/null
+++ b/src/web/hooks/useAgentManagement.ts
@@ -0,0 +1,255 @@
+/**
+ * useAgentManagement hook for agent and group CRUD operations from the web client.
+ *
+ * Provides functions for creating, deleting, and renaming agents,
+ * as well as managing groups (create, rename, delete, move agents).
+ * Maintains groups state, auto-loaded on mount and refreshed via broadcasts.
+ */
+
+import { useState, useCallback, useEffect, useRef } from 'react';
+import type { UseWebSocketReturn, GroupData } from './useWebSocket';
+
+/**
+ * Return value from useAgentManagement hook.
+ */
+export interface UseAgentManagementReturn {
+ /** Current list of groups */
+ groups: GroupData[];
+ /** Whether groups are currently loading */
+ isLoading: boolean;
+
+ /** Create a new agent. Returns { sessionId } on success, null on failure. */
+ createAgent: (
+ name: string,
+ toolType: string,
+ cwd: string,
+ groupId?: string
+ ) => Promise<{ sessionId: string } | null>;
+ /** Delete an agent by session ID. */
+ deleteAgent: (sessionId: string) => Promise;
+ /** Rename an agent. */
+ renameAgent: (sessionId: string, newName: string) => Promise;
+
+ /** Fetch the latest groups list. */
+ getGroups: () => Promise;
+ /** Create a new group. Returns { id } on success, null on failure. */
+ createGroup: (name: string, emoji?: string) => Promise<{ id: string } | null>;
+ /** Rename a group. */
+ renameGroup: (groupId: string, name: string) => Promise;
+ /** Delete a group. */
+ deleteGroup: (groupId: string) => Promise;
+ /** Move an agent to a group (or null for ungrouped). */
+ moveToGroup: (sessionId: string, groupId: string | null) => Promise;
+
+ /** Handler for groups_changed broadcasts — wire to onGroupsChanged in WebSocket handlers */
+ handleGroupsChanged: (groups: GroupData[]) => void;
+}
+
+/**
+ * Hook for managing agents and groups via WebSocket.
+ *
+ * @param sendRequest - WebSocket sendRequest function for request-response operations
+ * @param isConnected - Whether the WebSocket is currently connected
+ */
+export function useAgentManagement(
+ sendRequest: UseWebSocketReturn['sendRequest'],
+ isConnected: boolean
+): UseAgentManagementReturn {
+ const [groups, setGroups] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const hasFetchedRef = useRef(false);
+
+ // Fetch groups on mount and when connection is established
+ useEffect(() => {
+ if (!isConnected) {
+ hasFetchedRef.current = false;
+ return;
+ }
+ if (hasFetchedRef.current) return;
+
+ hasFetchedRef.current = true;
+ setIsLoading(true);
+
+ sendRequest<{ groups?: GroupData[] }>('get_groups')
+ .then((response) => {
+ if (response.groups) {
+ setGroups(response.groups);
+ }
+ })
+ .catch(() => {
+ // Groups fetch failed — will retry on reconnect
+ })
+ .finally(() => {
+ setIsLoading(false);
+ });
+ }, [isConnected, sendRequest]);
+
+ /**
+ * Update groups from a broadcast message.
+ * Intended to be wired to onGroupsChanged in the WebSocket handlers.
+ */
+ const handleGroupsChanged = useCallback((newGroups: GroupData[]) => {
+ setGroups(newGroups);
+ }, []);
+
+ /**
+ * Fetch the latest groups list on demand.
+ */
+ const getGroups = useCallback(async (): Promise => {
+ try {
+ const response = await sendRequest<{ groups?: GroupData[] }>('get_groups');
+ const fetched = response.groups ?? [];
+ setGroups(fetched);
+ return fetched;
+ } catch {
+ return groups;
+ }
+ }, [sendRequest, groups]);
+
+ /**
+ * Create a new agent.
+ */
+ const createAgent = useCallback(
+ async (
+ name: string,
+ toolType: string,
+ cwd: string,
+ groupId?: string
+ ): Promise<{ sessionId: string } | null> => {
+ try {
+ const response = await sendRequest<{
+ success?: boolean;
+ sessionId?: string;
+ }>('create_session', { name, toolType, cwd, groupId });
+ if (response.success && response.sessionId) {
+ return { sessionId: response.sessionId };
+ }
+ return null;
+ } catch {
+ return null;
+ }
+ },
+ [sendRequest]
+ );
+
+ /**
+ * Delete an agent by session ID.
+ */
+ const deleteAgent = useCallback(
+ async (sessionId: string): Promise => {
+ try {
+ const response = await sendRequest<{ success?: boolean }>('delete_session', { sessionId });
+ return response.success ?? false;
+ } catch {
+ return false;
+ }
+ },
+ [sendRequest]
+ );
+
+ /**
+ * Rename an agent.
+ */
+ const renameAgent = useCallback(
+ async (sessionId: string, newName: string): Promise => {
+ try {
+ const response = await sendRequest<{ success?: boolean }>('rename_session', {
+ sessionId,
+ newName,
+ });
+ return response.success ?? false;
+ } catch {
+ return false;
+ }
+ },
+ [sendRequest]
+ );
+
+ /**
+ * Create a new group.
+ */
+ const createGroup = useCallback(
+ async (name: string, emoji?: string): Promise<{ id: string } | null> => {
+ try {
+ const response = await sendRequest<{
+ success?: boolean;
+ groupId?: string;
+ }>('create_group', { name, emoji });
+ if (response.success && response.groupId) {
+ return { id: response.groupId };
+ }
+ return null;
+ } catch {
+ return null;
+ }
+ },
+ [sendRequest]
+ );
+
+ /**
+ * Rename a group.
+ */
+ const renameGroup = useCallback(
+ async (groupId: string, name: string): Promise => {
+ try {
+ const response = await sendRequest<{ success?: boolean }>('rename_group', {
+ groupId,
+ name,
+ });
+ return response.success ?? false;
+ } catch {
+ return false;
+ }
+ },
+ [sendRequest]
+ );
+
+ /**
+ * Delete a group.
+ */
+ const deleteGroup = useCallback(
+ async (groupId: string): Promise => {
+ try {
+ const response = await sendRequest<{ success?: boolean }>('delete_group', { groupId });
+ return response.success ?? false;
+ } catch {
+ return false;
+ }
+ },
+ [sendRequest]
+ );
+
+ /**
+ * Move an agent to a group (or null to ungroup).
+ */
+ const moveToGroup = useCallback(
+ async (sessionId: string, groupId: string | null): Promise => {
+ try {
+ const response = await sendRequest<{ success?: boolean }>('move_session_to_group', {
+ sessionId,
+ groupId,
+ });
+ return response.success ?? false;
+ } catch {
+ return false;
+ }
+ },
+ [sendRequest]
+ );
+
+ return {
+ groups,
+ isLoading,
+ createAgent,
+ deleteAgent,
+ renameAgent,
+ getGroups,
+ createGroup,
+ renameGroup,
+ deleteGroup,
+ moveToGroup,
+ handleGroupsChanged,
+ };
+}
+
+export default useAgentManagement;
diff --git a/src/web/hooks/useAutoRun.ts b/src/web/hooks/useAutoRun.ts
new file mode 100644
index 0000000000..7da23e18ee
--- /dev/null
+++ b/src/web/hooks/useAutoRun.ts
@@ -0,0 +1,161 @@
+/**
+ * useAutoRun hook for Auto Run state management in the web interface.
+ *
+ * Provides document listing, content loading/saving, launch/stop controls,
+ * and real-time document change tracking via WebSocket broadcasts.
+ */
+
+import { useState, useCallback } from 'react';
+import type { UseWebSocketReturn, AutoRunState } from './useWebSocket';
+
+/**
+ * Auto Run document metadata (mirrors server-side AutoRunDocument).
+ */
+export interface AutoRunDocument {
+ filename: string;
+ path: string;
+ taskCount: number;
+ completedCount: number;
+}
+
+/**
+ * Currently selected document with content.
+ */
+export interface SelectedDocument {
+ filename: string;
+ content: string;
+}
+
+/**
+ * Launch configuration for Auto Run.
+ */
+export interface LaunchConfig {
+ documents: Array<{ filename: string }>;
+ prompt?: string;
+ loopEnabled?: boolean;
+ maxLoops?: number;
+}
+
+/**
+ * Return value from useAutoRun hook.
+ */
+export interface UseAutoRunReturn {
+ documents: AutoRunDocument[];
+ autoRunState: AutoRunState | null;
+ isLoadingDocs: boolean;
+ selectedDoc: SelectedDocument | null;
+ loadDocuments: (sessionId: string) => Promise;
+ loadDocumentContent: (sessionId: string, filename: string) => Promise;
+ saveDocumentContent: (sessionId: string, filename: string, content: string) => Promise;
+ launchAutoRun: (sessionId: string, config: LaunchConfig) => boolean;
+ stopAutoRun: (sessionId: string) => Promise;
+}
+
+/**
+ * Hook for managing Auto Run state and operations.
+ *
+ * @param sendRequest - WebSocket sendRequest function for request-response operations
+ * @param send - WebSocket send function for fire-and-forget messages
+ * @param onMessage - Optional message handler registration callback
+ */
+export function useAutoRun(
+ sendRequest: UseWebSocketReturn['sendRequest'],
+ send: UseWebSocketReturn['send'],
+ autoRunState: AutoRunState | null = null
+): UseAutoRunReturn {
+ const [documents, setDocuments] = useState([]);
+ const [isLoadingDocs, setIsLoadingDocs] = useState(false);
+ const [selectedDoc, setSelectedDoc] = useState(null);
+
+ const loadDocuments = useCallback(
+ async (sessionId: string) => {
+ setIsLoadingDocs(true);
+ try {
+ const response = await sendRequest<{ documents?: AutoRunDocument[] }>('get_auto_run_docs', {
+ sessionId,
+ });
+ setDocuments(response.documents ?? []);
+ } catch {
+ setDocuments([]);
+ } finally {
+ setIsLoadingDocs(false);
+ }
+ },
+ [sendRequest]
+ );
+
+ const loadDocumentContent = useCallback(
+ async (sessionId: string, filename: string) => {
+ try {
+ const response = await sendRequest<{ content?: string }>('get_auto_run_document', {
+ sessionId,
+ filename,
+ });
+ setSelectedDoc({
+ filename,
+ content: response.content ?? '',
+ });
+ } catch {
+ setSelectedDoc({ filename, content: '' });
+ }
+ },
+ [sendRequest]
+ );
+
+ const saveDocumentContent = useCallback(
+ async (sessionId: string, filename: string, content: string): Promise => {
+ try {
+ const response = await sendRequest<{ success?: boolean }>('save_auto_run_document', {
+ sessionId,
+ filename,
+ content,
+ });
+ return response.success ?? false;
+ } catch {
+ return false;
+ }
+ },
+ [sendRequest]
+ );
+
+ const launchAutoRun = useCallback(
+ (sessionId: string, config: LaunchConfig): boolean => {
+ return send({
+ type: 'configure_auto_run',
+ sessionId,
+ documents: config.documents,
+ prompt: config.prompt,
+ loopEnabled: config.loopEnabled,
+ maxLoops: config.maxLoops,
+ launch: true,
+ });
+ },
+ [send]
+ );
+
+ const stopAutoRun = useCallback(
+ async (sessionId: string): Promise => {
+ try {
+ const response = await sendRequest<{ success?: boolean }>('stop_auto_run', { sessionId });
+ return response.success ?? false;
+ } catch {
+ return false;
+ }
+ },
+ [sendRequest]
+ );
+
+ return {
+ documents,
+ autoRunState,
+ isLoadingDocs,
+ selectedDoc,
+ loadDocuments,
+ loadDocumentContent,
+ saveDocumentContent,
+ launchAutoRun,
+ stopAutoRun,
+ };
+}
+
+export default useAutoRun;
diff --git a/src/web/hooks/useCue.ts b/src/web/hooks/useCue.ts
new file mode 100644
index 0000000000..885d897e42
--- /dev/null
+++ b/src/web/hooks/useCue.ts
@@ -0,0 +1,162 @@
+/**
+ * useCue hook for Cue automation management in the web interface.
+ *
+ * Provides Cue subscription listing, toggling, and activity viewing
+ * with real-time updates via WebSocket broadcasts.
+ */
+
+import { useState, useCallback, useEffect } from 'react';
+import type { UseWebSocketReturn } from './useWebSocket';
+
+/** Web-specific Cue subscription metadata (simplified from engine types) */
+export interface CueSubscriptionInfo {
+ id: string;
+ name: string;
+ eventType: string;
+ pattern?: string;
+ schedule?: string;
+ sessionId: string;
+ sessionName: string;
+ enabled: boolean;
+ lastTriggered?: number;
+ triggerCount: number;
+}
+
+/** Web-specific Cue activity log entry (simplified from engine types) */
+export interface CueActivityEntry {
+ id: string;
+ subscriptionId: string;
+ subscriptionName: string;
+ eventType: string;
+ sessionId: string;
+ timestamp: number;
+ status: 'triggered' | 'running' | 'completed' | 'failed';
+ result?: string;
+ duration?: number;
+}
+
+/**
+ * Return value from useCue hook.
+ */
+export interface UseCueReturn {
+ /** All known Cue subscriptions */
+ subscriptions: CueSubscriptionInfo[];
+ /** Recent Cue activity entries (most recent first) */
+ activity: CueActivityEntry[];
+ /** Whether data is being loaded */
+ isLoading: boolean;
+ /** Load subscriptions from the server */
+ loadSubscriptions: (sessionId?: string) => Promise;
+ /** Toggle a subscription's enabled state */
+ toggleSubscription: (subscriptionId: string, enabled: boolean) => Promise;
+ /** Load activity entries from the server */
+ loadActivity: (sessionId?: string, limit?: number) => Promise;
+ /** Handle incoming Cue activity broadcast */
+ handleCueActivityEvent: (entry: CueActivityEntry) => void;
+ /** Handle incoming Cue subscriptions changed broadcast */
+ handleCueSubscriptionsChanged: (subscriptions: CueSubscriptionInfo[]) => void;
+}
+
+const MAX_ACTIVITY_ENTRIES = 100;
+
+/**
+ * Hook for managing Cue automation state and operations.
+ *
+ * @param sendRequest - WebSocket sendRequest function for request-response operations
+ * @param send - WebSocket send function for fire-and-forget messages
+ * @param isConnected - Whether the WebSocket is connected
+ */
+export function useCue(
+ sendRequest: UseWebSocketReturn['sendRequest'],
+ _send: UseWebSocketReturn['send'],
+ isConnected: boolean
+): UseCueReturn {
+ const [subscriptions, setSubscriptions] = useState([]);
+ const [activity, setActivity] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const loadSubscriptions = useCallback(
+ async (sessionId?: string) => {
+ setIsLoading(true);
+ try {
+ const response = await sendRequest<{ subscriptions?: CueSubscriptionInfo[] }>(
+ 'get_cue_subscriptions',
+ sessionId ? { sessionId } : undefined
+ );
+ setSubscriptions(response.subscriptions ?? []);
+ } catch {
+ setSubscriptions([]);
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [sendRequest]
+ );
+
+ const toggleSubscription = useCallback(
+ async (subscriptionId: string, enabled: boolean): Promise => {
+ try {
+ const response = await sendRequest<{ success?: boolean }>('toggle_cue_subscription', {
+ subscriptionId,
+ enabled,
+ });
+ return response.success ?? false;
+ } catch {
+ return false;
+ }
+ },
+ [sendRequest]
+ );
+
+ const loadActivity = useCallback(
+ async (sessionId?: string, limit?: number) => {
+ setIsLoading(true);
+ try {
+ const response = await sendRequest<{ entries?: CueActivityEntry[] }>('get_cue_activity', {
+ ...(sessionId ? { sessionId } : {}),
+ ...(limit ? { limit } : {}),
+ });
+ setActivity(response.entries ?? []);
+ } catch {
+ setActivity([]);
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [sendRequest]
+ );
+
+ const handleCueActivityEvent = useCallback((entry: CueActivityEntry) => {
+ setActivity((prev) => {
+ const updated = [entry, ...prev];
+ return updated.length > MAX_ACTIVITY_ENTRIES
+ ? updated.slice(0, MAX_ACTIVITY_ENTRIES)
+ : updated;
+ });
+ }, []);
+
+ const handleCueSubscriptionsChanged = useCallback((subs: CueSubscriptionInfo[]) => {
+ setSubscriptions(subs);
+ }, []);
+
+ // Auto-load on mount when connected
+ useEffect(() => {
+ if (isConnected) {
+ loadSubscriptions();
+ loadActivity();
+ }
+ }, [isConnected, loadSubscriptions, loadActivity]);
+
+ return {
+ subscriptions,
+ activity,
+ isLoading,
+ loadSubscriptions,
+ toggleSubscription,
+ loadActivity,
+ handleCueActivityEvent,
+ handleCueSubscriptionsChanged,
+ };
+}
+
+export default useCue;
diff --git a/src/web/hooks/useGitStatus.ts b/src/web/hooks/useGitStatus.ts
new file mode 100644
index 0000000000..9c6b3bda8a
--- /dev/null
+++ b/src/web/hooks/useGitStatus.ts
@@ -0,0 +1,169 @@
+/**
+ * useGitStatus hook for git status and diff information from the web client.
+ *
+ * Provides git status and diff loading via WebSocket request-response,
+ * with auto-load on sessionId changes.
+ */
+
+import { useState, useCallback, useEffect, useRef } from 'react';
+import type { UseWebSocketReturn } from './useWebSocket';
+
+/**
+ * Git file status entry.
+ */
+export interface GitStatusFile {
+ path: string;
+ status: string;
+ staged: boolean;
+}
+
+/**
+ * Git status result from the server.
+ */
+export interface GitStatusResult {
+ branch: string;
+ files: GitStatusFile[];
+ ahead: number;
+ behind: number;
+}
+
+/**
+ * Git diff result from the server.
+ */
+export interface GitDiffResult {
+ diff: string;
+ files: string[];
+}
+
+/**
+ * Return value from useGitStatus hook.
+ */
+export interface UseGitStatusReturn {
+ status: GitStatusResult | null;
+ diff: GitDiffResult | null;
+ isLoading: boolean;
+ loadStatus: (sessionId: string) => Promise;
+ loadDiff: (sessionId: string, filePath?: string) => Promise;
+ refresh: (sessionId: string) => Promise;
+}
+
+/**
+ * Hook for managing git status and diff state via WebSocket.
+ *
+ * @param sendRequest - WebSocket sendRequest function for request-response operations
+ * @param isConnected - Whether the WebSocket is currently connected
+ * @param sessionId - Optional session ID to auto-load status when it changes
+ */
+export function useGitStatus(
+ sendRequest: UseWebSocketReturn['sendRequest'],
+ isConnected: boolean,
+ sessionId?: string
+): UseGitStatusReturn {
+ const [status, setStatus] = useState(null);
+ const [diff, setDiff] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const lastSessionIdRef = useRef(undefined);
+
+ /**
+ * Load git status for a session.
+ */
+ const loadStatus = useCallback(
+ async (sid: string): Promise => {
+ if (!isConnected) return;
+
+ setIsLoading(true);
+ try {
+ const response = await sendRequest<{ status?: GitStatusResult }>('get_git_status', {
+ sessionId: sid,
+ });
+ if (response.status) {
+ setStatus(response.status);
+ }
+ } catch {
+ // Status fetch failed — will retry on next call
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [sendRequest, isConnected]
+ );
+
+ /**
+ * Load git diff for a session, optionally for a specific file.
+ */
+ const loadDiff = useCallback(
+ async (sid: string, filePath?: string): Promise => {
+ if (!isConnected) return;
+
+ setIsLoading(true);
+ try {
+ const response = await sendRequest<{ diff?: GitDiffResult }>('get_git_diff', {
+ sessionId: sid,
+ filePath,
+ });
+ if (response.diff) {
+ setDiff(response.diff);
+ }
+ } catch {
+ // Diff fetch failed — will retry on next call
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [sendRequest, isConnected]
+ );
+
+ /**
+ * Refresh both status and diff for a session.
+ */
+ const refresh = useCallback(
+ async (sid: string): Promise => {
+ if (!isConnected) return;
+
+ setIsLoading(true);
+ try {
+ const [statusResponse, diffResponse] = await Promise.all([
+ sendRequest<{ status?: GitStatusResult }>('get_git_status', { sessionId: sid }),
+ sendRequest<{ diff?: GitDiffResult }>('get_git_diff', { sessionId: sid }),
+ ]);
+ if (statusResponse.status) {
+ setStatus(statusResponse.status);
+ }
+ if (diffResponse.diff) {
+ setDiff(diffResponse.diff);
+ }
+ } catch {
+ // Refresh failed — will retry on next call
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [sendRequest, isConnected]
+ );
+
+ // Auto-load status when sessionId changes
+ useEffect(() => {
+ if (!isConnected || !sessionId) {
+ return;
+ }
+ if (lastSessionIdRef.current === sessionId) {
+ return;
+ }
+
+ lastSessionIdRef.current = sessionId;
+ setStatus(null);
+ setDiff(null);
+ loadStatus(sessionId);
+ }, [isConnected, sessionId, loadStatus]);
+
+ return {
+ status,
+ diff,
+ isLoading,
+ loadStatus,
+ loadDiff,
+ refresh,
+ };
+}
+
+export default useGitStatus;
diff --git a/src/web/hooks/useGroupChat.ts b/src/web/hooks/useGroupChat.ts
new file mode 100644
index 0000000000..5dd769ec74
--- /dev/null
+++ b/src/web/hooks/useGroupChat.ts
@@ -0,0 +1,210 @@
+/**
+ * useGroupChat hook for group chat management in the web interface.
+ *
+ * Provides group chat listing, creation, messaging, and real-time
+ * state updates via WebSocket broadcasts.
+ */
+
+import { useState, useCallback, useEffect } from 'react';
+import type { UseWebSocketReturn, GroupChatState, GroupChatMessage } from './useWebSocket';
+
+/**
+ * Return value from useGroupChat hook.
+ */
+export interface UseGroupChatReturn {
+ /** All known group chats */
+ chats: GroupChatState[];
+ /** Currently active/viewed chat */
+ activeChat: GroupChatState | null;
+ /** Whether chats are being loaded */
+ isLoading: boolean;
+ /** Load all group chats from the server */
+ loadChats: () => Promise;
+ /** Start a new group chat */
+ startChat: (topic: string, participantIds: string[]) => Promise;
+ /** Load full state for a specific chat */
+ loadChatState: (chatId: string) => Promise;
+ /** Send a message to a group chat */
+ sendMessage: (chatId: string, message: string) => Promise;
+ /** Stop a group chat */
+ stopChat: (chatId: string) => Promise;
+ /** Set the active chat by ID (or null to deselect) */
+ setActiveChatId: (chatId: string | null) => void;
+ /** Handle incoming group chat message broadcast */
+ handleGroupChatMessage: (chatId: string, message: GroupChatMessage) => void;
+ /** Handle incoming group chat state change broadcast */
+ handleGroupChatStateChange: (chatId: string, state: Partial) => void;
+}
+
+/**
+ * Hook for managing group chat state and operations.
+ *
+ * @param sendRequest - WebSocket sendRequest function for request-response operations
+ * @param send - WebSocket send function for fire-and-forget messages
+ * @param isConnected - Whether the WebSocket is connected
+ */
+export function useGroupChat(
+ sendRequest: UseWebSocketReturn['sendRequest'],
+ _send: UseWebSocketReturn['send'],
+ isConnected: boolean
+): UseGroupChatReturn {
+ const [chats, setChats] = useState([]);
+ const [activeChat, setActiveChat] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const loadChats = useCallback(async () => {
+ setIsLoading(true);
+ try {
+ const response = await sendRequest<{ chats?: GroupChatState[] }>('get_group_chats');
+ setChats(response.chats ?? []);
+ } catch {
+ setChats([]);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [sendRequest]);
+
+ const startChat = useCallback(
+ async (topic: string, participantIds: string[]): Promise => {
+ try {
+ const response = await sendRequest<{ success?: boolean; chatId?: string }>(
+ 'start_group_chat',
+ { topic, participantIds }
+ );
+ if (response.success && response.chatId) {
+ // Reload chats to get the new one
+ await loadChats();
+ return response.chatId;
+ }
+ return null;
+ } catch {
+ return null;
+ }
+ },
+ [sendRequest, loadChats]
+ );
+
+ const loadChatState = useCallback(
+ async (chatId: string) => {
+ try {
+ const response = await sendRequest<{ state?: GroupChatState | null }>(
+ 'get_group_chat_state',
+ { chatId }
+ );
+ if (response.state) {
+ setActiveChat(response.state);
+ // Also update in the chats list
+ setChats((prev) => prev.map((c) => (c.id === chatId ? response.state! : c)));
+ }
+ } catch {
+ // Keep current state on error
+ }
+ },
+ [sendRequest]
+ );
+
+ const sendMessage = useCallback(
+ async (chatId: string, message: string): Promise => {
+ try {
+ const response = await sendRequest<{ success?: boolean }>('send_group_chat_message', {
+ chatId,
+ message,
+ });
+ return response.success ?? false;
+ } catch {
+ return false;
+ }
+ },
+ [sendRequest]
+ );
+
+ const stopChat = useCallback(
+ async (chatId: string): Promise => {
+ try {
+ const response = await sendRequest<{ success?: boolean }>('stop_group_chat', { chatId });
+ return response.success ?? false;
+ } catch {
+ return false;
+ }
+ },
+ [sendRequest]
+ );
+
+ const setActiveChatId = useCallback(
+ (chatId: string | null) => {
+ if (chatId === null) {
+ setActiveChat(null);
+ } else {
+ const chat = chats.find((c) => c.id === chatId);
+ setActiveChat(chat ?? null);
+ }
+ },
+ [chats]
+ );
+
+ const handleGroupChatMessage = useCallback((chatId: string, message: GroupChatMessage) => {
+ // Update activeChat if it matches
+ setActiveChat((prev) => {
+ if (prev && prev.id === chatId) {
+ return { ...prev, messages: [...prev.messages, message] };
+ }
+ return prev;
+ });
+
+ // Update in chats list
+ setChats((prev) =>
+ prev.map((c) => {
+ if (c.id === chatId) {
+ return { ...c, messages: [...c.messages, message] };
+ }
+ return c;
+ })
+ );
+ }, []);
+
+ const handleGroupChatStateChange = useCallback(
+ (chatId: string, state: Partial) => {
+ // Update activeChat if it matches
+ setActiveChat((prev) => {
+ if (prev && prev.id === chatId) {
+ return { ...prev, ...state };
+ }
+ return prev;
+ });
+
+ // Update in chats list
+ setChats((prev) =>
+ prev.map((c) => {
+ if (c.id === chatId) {
+ return { ...c, ...state };
+ }
+ return c;
+ })
+ );
+ },
+ []
+ );
+
+ // Auto-load chats on mount when connected
+ useEffect(() => {
+ if (isConnected) {
+ loadChats();
+ }
+ }, [isConnected, loadChats]);
+
+ return {
+ chats,
+ activeChat,
+ isLoading,
+ loadChats,
+ startChat,
+ loadChatState,
+ sendMessage,
+ stopChat,
+ setActiveChatId,
+ handleGroupChatMessage,
+ handleGroupChatStateChange,
+ };
+}
+
+export default useGroupChat;
diff --git a/src/web/hooks/useLongPressMenu.ts b/src/web/hooks/useLongPressMenu.ts
index a87a48f694..ab8d4c7df5 100644
--- a/src/web/hooks/useLongPressMenu.ts
+++ b/src/web/hooks/useLongPressMenu.ts
@@ -1,35 +1,25 @@
/**
- * useLongPressMenu - Long-press menu hook for mobile touch interactions
+ * useLongPressMenu - Long-press gesture hook for mobile touch interactions
*
- * Provides long-press gesture detection to show a quick actions menu.
- * Used for send button long-press to open mode switching menu.
+ * Detects long-press on the send button and triggers the command palette.
*
* Features:
* - Configurable long-press duration (default 500ms)
* - Touch event handlers (start, end, move)
* - Automatic timer cleanup on touch move/end
- * - Menu anchor position calculation
* - Haptic feedback integration
* - Proper cleanup on unmount
*
* @module useLongPressMenu
*/
-import { useRef, useState, useCallback, useEffect } from 'react';
-import type { QuickAction } from '../mobile/QuickActionsMenu';
+import { useRef, useCallback, useEffect } from 'react';
/** Default duration in ms to trigger long-press for quick actions menu */
const DEFAULT_LONG_PRESS_DURATION = 500;
/**
* Trigger haptic feedback using the Vibration API
- * Uses short vibrations for tactile confirmation on mobile devices
- *
- * @param pattern - Vibration pattern in milliseconds or single duration
- * - 'light' (10ms) - subtle tap for button presses
- * - 'medium' (25ms) - standard confirmation feedback
- * - 'strong' (50ms) - important action confirmation
- * - number - custom duration in milliseconds
*/
function triggerHapticFeedback(pattern: 'light' | 'medium' | 'strong' | number = 'medium'): void {
if (typeof navigator !== 'undefined' && 'vibrate' in navigator) {
@@ -39,7 +29,7 @@ function triggerHapticFeedback(pattern: 'light' | 'medium' | 'strong' | number =
try {
navigator.vibrate(duration);
} catch {
- // Silently fail if vibration is not allowed (e.g., permissions, battery saver)
+ // Silently fail if vibration is not allowed
}
}
}
@@ -56,14 +46,12 @@ export interface UseLongPressMenuOptions {
disabled?: boolean;
/** Current input value (to check if send should be disabled) */
value?: string;
+ /** Callback to open the command palette */
+ onOpenCommandPalette?: () => void;
}
/** Return value from useLongPressMenu hook */
export interface UseLongPressMenuReturn {
- /** Whether the quick actions menu is open */
- isMenuOpen: boolean;
- /** Anchor position for the menu (relative to viewport) */
- menuAnchor: { x: number; y: number } | null;
/** Ref for the send button element */
sendButtonRef: React.RefObject;
/** Handler for touch start event */
@@ -72,70 +60,22 @@ export interface UseLongPressMenuReturn {
handleTouchEnd: (e: React.TouchEvent) => void;
/** Handler for touch move event */
handleTouchMove: () => void;
- /** Handler for quick action selection */
- handleQuickAction: (action: QuickAction) => void;
- /** Close the quick actions menu */
- closeMenu: () => void;
}
/**
- * Hook for long-press menu on send button
- *
- * @param options - Configuration options
- * @returns Long-press menu state and handlers
+ * Hook for long-press gesture on send button
*
- * @example
- * ```tsx
- * const {
- * isMenuOpen,
- * menuAnchor,
- * sendButtonRef,
- * handleTouchStart,
- * handleTouchEnd,
- * handleTouchMove,
- * handleQuickAction,
- * closeMenu,
- * } = useLongPressMenu({
- * inputMode,
- * onModeToggle,
- * });
- *
- * return (
- * <>
- *
- *
- * >
- * );
- * ```
+ * On long-press, opens the command palette via the onOpenCommandPalette callback.
*/
export function useLongPressMenu({
- inputMode,
- onModeToggle,
longPressDuration = DEFAULT_LONG_PRESS_DURATION,
disabled = false,
value = '',
+ onOpenCommandPalette,
}: UseLongPressMenuOptions): UseLongPressMenuReturn {
- // Quick actions menu state
- const [isMenuOpen, setIsMenuOpen] = useState(false);
- const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number } | null>(null);
const longPressTimerRef = useRef | null>(null);
const sendButtonRef = useRef(null);
- /**
- * Clear long-press timer (used when touch ends or moves)
- */
const clearLongPressTimer = useCallback(() => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
@@ -143,99 +83,39 @@ export function useLongPressMenu({
}
}, []);
- /**
- * Handle long-press start on send button
- * Starts a timer that will show the quick actions menu
- */
const handleTouchStart = useCallback(
(e: React.TouchEvent) => {
- // Clear any existing timer
clearLongPressTimer();
- // Get the button position for menu anchor
- const button = sendButtonRef.current;
- if (button) {
- const rect = button.getBoundingClientRect();
- const anchor = {
- x: rect.left + rect.width / 2,
- y: rect.top,
- };
-
- // Start long-press timer
- longPressTimerRef.current = setTimeout(() => {
- // Trigger haptic feedback for long-press activation
- triggerHapticFeedback('medium');
-
- // Show quick actions menu
- setMenuAnchor(anchor);
- setIsMenuOpen(true);
-
- // Prevent the normal touch behavior
- longPressTimerRef.current = null;
- }, longPressDuration);
- }
+ longPressTimerRef.current = setTimeout(() => {
+ triggerHapticFeedback('medium');
+ onOpenCommandPalette?.();
+ longPressTimerRef.current = null;
+ }, longPressDuration);
// Scale down slightly on touch for tactile feedback
if (!disabled && value.trim()) {
e.currentTarget.style.transform = 'scale(0.95)';
}
},
- [clearLongPressTimer, disabled, value, longPressDuration]
+ [clearLongPressTimer, disabled, value, longPressDuration, onOpenCommandPalette]
);
- /**
- * Handle touch end on send button
- * Clears the long-press timer and handles normal tap
- */
const handleTouchEnd = useCallback(
(e: React.TouchEvent) => {
e.currentTarget.style.transform = 'scale(1)';
-
- // If quick actions menu is not open and timer was running, this was a normal tap
- // The form onSubmit will handle the actual submission
clearLongPressTimer();
},
[clearLongPressTimer]
);
- /**
- * Handle touch move on send button
- * Cancels long-press if user moves finger
- */
const handleTouchMove = useCallback(() => {
clearLongPressTimer();
}, [clearLongPressTimer]);
- /**
- * Handle quick action selection from menu
- */
- const handleQuickAction = useCallback(
- (action: QuickAction) => {
- // Trigger haptic feedback
- triggerHapticFeedback('medium');
-
- if (action === 'switch_mode') {
- // Toggle to the opposite mode
- const newMode = inputMode === 'ai' ? 'terminal' : 'ai';
- onModeToggle?.(newMode);
- }
- },
- [inputMode, onModeToggle]
- );
-
- /**
- * Close quick actions menu
- */
- const closeMenu = useCallback(() => {
- setIsMenuOpen(false);
- }, []);
-
- /**
- * Cleanup timers on unmount
- */
+ // Cleanup timers on unmount
useEffect(() => {
return () => {
- // Clean up long-press timer
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
}
@@ -243,14 +123,10 @@ export function useLongPressMenu({
}, []);
return {
- isMenuOpen,
- menuAnchor,
sendButtonRef,
handleTouchStart,
handleTouchEnd,
handleTouchMove,
- handleQuickAction,
- closeMenu,
};
}
diff --git a/src/web/hooks/useMobileKeyboardHandler.ts b/src/web/hooks/useMobileKeyboardHandler.ts
index 0cf7c92714..883802cb8d 100644
--- a/src/web/hooks/useMobileKeyboardHandler.ts
+++ b/src/web/hooks/useMobileKeyboardHandler.ts
@@ -2,6 +2,8 @@
* useMobileKeyboardHandler - Mobile keyboard shortcuts handler hook
*
* Handles keyboard shortcuts for the mobile web interface:
+ * - Cmd+K / Ctrl+K: Toggle command palette
+ * - Escape: Close command palette
* - Cmd+J / Ctrl+J: Toggle between AI and Terminal mode
* - Cmd+[ / Ctrl+[: Switch to previous tab
* - Cmd+] / Ctrl+]: Switch to next tab
@@ -53,6 +55,12 @@ export interface UseMobileKeyboardHandlerDeps {
handleModeToggle: (mode: MobileInputMode) => void;
/** Handler to select a tab */
handleSelectTab: (tabId: string) => void;
+ /** Handler to open the command palette (Cmd+K / Ctrl+K) */
+ onOpenCommandPalette?: () => void;
+ /** Handler to close the command palette (Escape) */
+ onCloseCommandPalette?: () => void;
+ /** Whether the command palette is currently open */
+ isCommandPaletteOpen?: boolean;
}
/**
@@ -64,10 +72,37 @@ export interface UseMobileKeyboardHandlerDeps {
* @param deps - Dependencies including session state and handlers
*/
export function useMobileKeyboardHandler(deps: UseMobileKeyboardHandlerDeps): void {
- const { activeSessionId, activeSession, handleModeToggle, handleSelectTab } = deps;
+ const {
+ activeSessionId,
+ activeSession,
+ handleModeToggle,
+ handleSelectTab,
+ onOpenCommandPalette,
+ onCloseCommandPalette,
+ isCommandPaletteOpen,
+ } = deps;
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
+ // Cmd+K / Ctrl+K: Open command palette
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
+ if (isCommandPaletteOpen && onCloseCommandPalette) {
+ e.preventDefault();
+ onCloseCommandPalette();
+ } else if (onOpenCommandPalette) {
+ e.preventDefault();
+ onOpenCommandPalette();
+ }
+ return;
+ }
+
+ // Escape: Close command palette if open
+ if (e.key === 'Escape' && isCommandPaletteOpen && onCloseCommandPalette) {
+ e.preventDefault();
+ onCloseCommandPalette();
+ return;
+ }
+
// Check for Cmd+J (Mac) or Ctrl+J (Windows/Linux) to toggle AI/CLI mode
if ((e.metaKey || e.ctrlKey) && e.key === 'j') {
e.preventDefault();
@@ -118,7 +153,15 @@ export function useMobileKeyboardHandler(deps: UseMobileKeyboardHandlerDeps): vo
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
- }, [activeSessionId, activeSession, handleModeToggle, handleSelectTab]);
+ }, [
+ activeSessionId,
+ activeSession,
+ handleModeToggle,
+ handleSelectTab,
+ onOpenCommandPalette,
+ onCloseCommandPalette,
+ isCommandPaletteOpen,
+ ]);
}
export default useMobileKeyboardHandler;
diff --git a/src/web/hooks/useNotifications.ts b/src/web/hooks/useNotifications.ts
index 79271d97c0..6b6b644cd3 100644
--- a/src/web/hooks/useNotifications.ts
+++ b/src/web/hooks/useNotifications.ts
@@ -10,9 +10,62 @@
* - Persist permission request state to avoid repeated prompts
*/
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, useRef } from 'react';
import { webLogger } from '../utils/logger';
+/**
+ * Notification event types from the server
+ */
+export interface NotificationEvent {
+ eventType:
+ | 'agent_complete'
+ | 'agent_error'
+ | 'autorun_complete'
+ | 'autorun_task_complete'
+ | 'context_warning';
+ sessionId: string;
+ sessionName: string;
+ message: string;
+ severity: 'info' | 'warning' | 'error';
+}
+
+/**
+ * Notification preferences configuration
+ */
+export interface NotificationPreferences {
+ agentComplete: boolean;
+ agentError: boolean;
+ autoRunComplete: boolean;
+ autoRunTaskComplete: boolean;
+ contextWarning: boolean;
+ soundEnabled: boolean;
+}
+
+/**
+ * Map from event type to preference key
+ */
+const EVENT_TYPE_TO_PREF: Record<
+ NotificationEvent['eventType'],
+ keyof Omit
+> = {
+ agent_complete: 'agentComplete',
+ agent_error: 'agentError',
+ autorun_complete: 'autoRunComplete',
+ autorun_task_complete: 'autoRunTaskComplete',
+ context_warning: 'contextWarning',
+};
+
+const DEFAULT_PREFERENCES: NotificationPreferences = {
+ agentComplete: true,
+ agentError: true,
+ autoRunComplete: true,
+ autoRunTaskComplete: true,
+ contextWarning: true,
+ soundEnabled: false,
+};
+
+const NOTIFICATION_PREFS_KEY = 'maestro-notification-prefs';
+
/**
* Notification permission states
*/
@@ -64,6 +117,12 @@ export interface UseNotificationsReturn {
resetPromptState: () => void;
/** Show a notification (if permission granted) */
showNotification: (title: string, options?: NotificationOptions) => Notification | null;
+ /** Current notification preferences */
+ preferences: NotificationPreferences;
+ /** Update notification preferences (partial merge) */
+ setPreferences: (prefs: Partial) => void;
+ /** Handle an incoming notification event from the server */
+ handleNotificationEvent: (event: NotificationEvent) => void;
}
/**
@@ -220,6 +279,105 @@ export function useNotifications(options: UseNotificationsOptions = {}): UseNoti
requestPermission,
]);
+ // Notification preferences state — persisted to localStorage
+ const [preferences, setPreferencesState] = useState(() => {
+ if (typeof localStorage === 'undefined') return DEFAULT_PREFERENCES;
+ try {
+ const stored = localStorage.getItem(NOTIFICATION_PREFS_KEY);
+ if (stored) {
+ return { ...DEFAULT_PREFERENCES, ...JSON.parse(stored) };
+ }
+ } catch {
+ // Ignore parse errors
+ }
+ return DEFAULT_PREFERENCES;
+ });
+
+ const setPreferences = useCallback((prefs: Partial) => {
+ setPreferencesState((prev) => {
+ const merged = { ...prev, ...prefs };
+ try {
+ localStorage.setItem(NOTIFICATION_PREFS_KEY, JSON.stringify(merged));
+ } catch {
+ // Ignore storage errors
+ }
+ return merged;
+ });
+ }, []);
+
+ // Keep a ref to preferences so handleNotificationEvent always has current values
+ const preferencesRef = useRef(preferences);
+ preferencesRef.current = preferences;
+
+ // Keep a ref to showNotification so handleNotificationEvent doesn't need it as a dep
+ const showNotificationRef = useRef(showNotification);
+ showNotificationRef.current = showNotification;
+
+ /**
+ * Play a short notification beep using Web Audio API
+ */
+ const playNotificationSound = useCallback(() => {
+ try {
+ const ctx = new AudioContext();
+ const oscillator = ctx.createOscillator();
+ const gainNode = ctx.createGain();
+
+ oscillator.connect(gainNode);
+ gainNode.connect(ctx.destination);
+
+ oscillator.type = 'sine';
+ oscillator.frequency.setValueAtTime(800, ctx.currentTime);
+ gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.2);
+
+ oscillator.start(ctx.currentTime);
+ oscillator.stop(ctx.currentTime + 0.2);
+
+ // Clean up after sound completes
+ oscillator.onended = () => ctx.close();
+ } catch {
+ // Audio not available
+ }
+ }, []);
+
+ /**
+ * Handle an incoming notification event from the server
+ */
+ const handleNotificationEvent = useCallback(
+ (event: NotificationEvent) => {
+ const prefs = preferencesRef.current;
+ const prefKey = EVENT_TYPE_TO_PREF[event.eventType];
+
+ // Check if this event type is enabled
+ if (!prefKey || !prefs[prefKey]) return;
+
+ // Check if we have permission
+ if (getNotificationPermission() !== 'granted') return;
+
+ const notification = showNotificationRef.current(event.sessionName, {
+ body: event.message,
+ tag: `maestro-${event.eventType}-${event.sessionId}`,
+ icon: '/icon-192.png',
+ });
+
+ if (notification) {
+ notification.onclick = () => {
+ window.focus();
+ window.dispatchEvent(
+ new CustomEvent('maestro-notification-click', {
+ detail: { sessionId: event.sessionId },
+ })
+ );
+ };
+ }
+
+ if (prefs.soundEnabled) {
+ playNotificationSound();
+ }
+ },
+ [playNotificationSound]
+ );
+
// Listen for permission changes (e.g., user changes in browser settings)
useEffect(() => {
if (!isSupported) return;
@@ -253,6 +411,9 @@ export function useNotifications(options: UseNotificationsOptions = {}): UseNoti
declineNotifications,
resetPromptState,
showNotification,
+ preferences,
+ setPreferences,
+ handleNotificationEvent,
};
}
diff --git a/src/web/hooks/useSettings.ts b/src/web/hooks/useSettings.ts
new file mode 100644
index 0000000000..d346a8608c
--- /dev/null
+++ b/src/web/hooks/useSettings.ts
@@ -0,0 +1,210 @@
+/**
+ * useSettings hook for reading and writing settings from the web client.
+ *
+ * Fetches settings on mount/reconnect, listens for broadcast changes,
+ * and provides typed setters for each configurable setting.
+ */
+
+import { useState, useCallback, useEffect, useRef } from 'react';
+import type { UseWebSocketReturn, SettingsChangedMessage } from './useWebSocket';
+
+/**
+ * Web-facing settings shape (mirrors server-side WebSettings).
+ */
+export type WebSettings = SettingsChangedMessage['settings'];
+
+/**
+ * Setting value type for setSetting.
+ */
+export type SettingValue = string | number | boolean | null;
+
+/**
+ * Return value from useSettings hook.
+ */
+export interface UseSettingsReturn {
+ settings: WebSettings | null;
+ isLoading: boolean;
+ setSetting: (key: string, value: SettingValue) => Promise;
+ setTheme: (themeId: string) => Promise;
+ setFontSize: (size: number) => Promise;
+ setEnterToSendAI: (value: boolean) => Promise;
+ setEnterToSendTerminal: (value: boolean) => Promise;
+ setAutoScroll: (value: boolean) => Promise;
+ setDefaultSaveToHistory: (value: boolean) => Promise;
+ setDefaultShowThinking: (value: string) => Promise;
+ setNotificationsEnabled: (value: boolean) => Promise;
+ setAudioFeedbackEnabled: (value: boolean) => Promise;
+ setColorBlindMode: (value: string) => Promise;
+ setConductorProfile: (value: string) => Promise;
+ /** Handler for settings_changed broadcasts — wire to onSettingsChanged in WebSocket handlers */
+ handleSettingsChanged: (settings: WebSettings) => void;
+}
+
+/**
+ * Map from allowlisted setting keys to their WebSettings field names.
+ */
+const SETTING_KEY_TO_FIELD: Record = {
+ activeThemeId: 'theme',
+ fontSize: 'fontSize',
+ enterToSendAI: 'enterToSendAI',
+ enterToSendTerminal: 'enterToSendTerminal',
+ defaultSaveToHistory: 'defaultSaveToHistory',
+ defaultShowThinking: 'defaultShowThinking',
+ autoScroll: 'autoScroll',
+ notificationsEnabled: 'notificationsEnabled',
+ audioFeedbackEnabled: 'audioFeedbackEnabled',
+ colorBlindMode: 'colorBlindMode',
+ conductorProfile: 'conductorProfile',
+};
+
+/**
+ * Hook for managing settings state and operations via WebSocket.
+ *
+ * @param sendRequest - WebSocket sendRequest function for request-response operations
+ * @param isConnected - Whether the WebSocket is currently connected
+ */
+export function useSettings(
+ sendRequest: UseWebSocketReturn['sendRequest'],
+ isConnected: boolean
+): UseSettingsReturn {
+ const [settings, setSettings] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const hasFetchedRef = useRef(false);
+
+ // Fetch settings on mount and when connection is established
+ useEffect(() => {
+ if (!isConnected) {
+ hasFetchedRef.current = false;
+ return;
+ }
+ if (hasFetchedRef.current) return;
+
+ hasFetchedRef.current = true;
+ setIsLoading(true);
+
+ sendRequest<{ settings?: WebSettings }>('get_settings')
+ .then((response) => {
+ if (response.settings) {
+ setSettings(response.settings);
+ }
+ })
+ .catch(() => {
+ // Settings fetch failed — will retry on reconnect
+ })
+ .finally(() => {
+ setIsLoading(false);
+ });
+ }, [isConnected, sendRequest]);
+
+ /**
+ * Update settings from a broadcast message.
+ * Intended to be wired to onSettingsChanged in the WebSocket handlers.
+ */
+ const handleSettingsChanged = useCallback((newSettings: WebSettings) => {
+ setSettings(newSettings);
+ }, []);
+
+ /**
+ * Set a single setting by its allowlisted key.
+ * Optimistically updates local state, rolls back on failure.
+ */
+ const setSetting = useCallback(
+ async (key: string, value: SettingValue): Promise => {
+ const field = SETTING_KEY_TO_FIELD[key];
+ if (!field) return false;
+
+ // Optimistic update
+ const prev = settings;
+ if (settings) {
+ setSettings({ ...settings, [field]: value } as WebSettings);
+ }
+
+ try {
+ const response = await sendRequest<{ success?: boolean }>('set_setting', { key, value });
+ if (!response.success) {
+ // Rollback on explicit server rejection
+ if (prev) setSettings(prev);
+ }
+ return response.success ?? false;
+ } catch {
+ // Rollback on failure
+ if (prev) setSettings(prev);
+ return false;
+ }
+ },
+ [sendRequest, settings]
+ );
+
+ // Typed convenience setters
+ const setTheme = useCallback(
+ (themeId: string) => setSetting('activeThemeId', themeId),
+ [setSetting]
+ );
+
+ const setFontSize = useCallback((size: number) => setSetting('fontSize', size), [setSetting]);
+
+ const setEnterToSendAI = useCallback(
+ (value: boolean) => setSetting('enterToSendAI', value),
+ [setSetting]
+ );
+
+ const setEnterToSendTerminal = useCallback(
+ (value: boolean) => setSetting('enterToSendTerminal', value),
+ [setSetting]
+ );
+
+ const setAutoScroll = useCallback(
+ (value: boolean) => setSetting('autoScroll', value),
+ [setSetting]
+ );
+
+ const setDefaultSaveToHistory = useCallback(
+ (value: boolean) => setSetting('defaultSaveToHistory', value),
+ [setSetting]
+ );
+
+ const setDefaultShowThinking = useCallback(
+ (value: string) => setSetting('defaultShowThinking', value),
+ [setSetting]
+ );
+
+ const setNotificationsEnabled = useCallback(
+ (value: boolean) => setSetting('notificationsEnabled', value),
+ [setSetting]
+ );
+
+ const setAudioFeedbackEnabled = useCallback(
+ (value: boolean) => setSetting('audioFeedbackEnabled', value),
+ [setSetting]
+ );
+
+ const setColorBlindMode = useCallback(
+ (value: string) => setSetting('colorBlindMode', value),
+ [setSetting]
+ );
+
+ const setConductorProfile = useCallback(
+ (value: string) => setSetting('conductorProfile', value),
+ [setSetting]
+ );
+
+ return {
+ settings,
+ isLoading,
+ setSetting,
+ setTheme,
+ setFontSize,
+ setEnterToSendAI,
+ setEnterToSendTerminal,
+ setAutoScroll,
+ setDefaultSaveToHistory,
+ setDefaultShowThinking,
+ setNotificationsEnabled,
+ setAudioFeedbackEnabled,
+ setColorBlindMode,
+ setConductorProfile,
+ handleSettingsChanged,
+ };
+}
+
+export default useSettings;
diff --git a/src/web/hooks/useWebSocket.ts b/src/web/hooks/useWebSocket.ts
index dd3ecc5b0e..7ba338a08a 100644
--- a/src/web/hooks/useWebSocket.ts
+++ b/src/web/hooks/useWebSocket.ts
@@ -118,11 +118,23 @@ export type ServerMessageType =
| 'active_session_changed'
| 'session_output'
| 'session_exit'
+ | 'session_live'
+ | 'session_offline'
| 'user_input'
| 'theme'
| 'custom_commands'
| 'autorun_state'
+ | 'autorun_docs_changed'
+ | 'notification_event'
+ | 'settings_changed'
+ | 'groups_changed'
| 'tabs_changed'
+ | 'group_chat_message'
+ | 'group_chat_state_change'
+ | 'context_operation_progress'
+ | 'context_operation_complete'
+ | 'cue_activity_event'
+ | 'cue_subscriptions_changed'
| 'pong'
| 'subscribed'
| 'echo'
@@ -286,6 +298,79 @@ export interface AutoRunStateMessage extends ServerMessage {
state: AutoRunState | null;
}
+/**
+ * AutoRun documents changed message from server
+ * Sent when Auto Run document list changes on the desktop
+ */
+export interface AutoRunDocsChangedMessage extends ServerMessage {
+ type: 'autorun_docs_changed';
+ sessionId: string;
+ documents: Array<{
+ filename: string;
+ path: string;
+ taskCount: number;
+ completedCount: number;
+ }>;
+}
+
+/**
+ * Notification event message from server
+ * Sent when a notification-worthy event occurs (agent complete, error, autorun, etc.)
+ */
+export interface NotificationEventMessage extends ServerMessage {
+ type: 'notification_event';
+ eventType:
+ | 'agent_complete'
+ | 'agent_error'
+ | 'autorun_complete'
+ | 'autorun_task_complete'
+ | 'context_warning';
+ sessionId: string;
+ sessionName: string;
+ message: string;
+ severity: 'info' | 'warning' | 'error';
+}
+
+/**
+ * Settings changed message from server
+ * Sent when settings are modified (from web or desktop)
+ */
+export interface SettingsChangedMessage extends ServerMessage {
+ type: 'settings_changed';
+ settings: {
+ theme: string;
+ fontSize: number;
+ enterToSendAI: boolean;
+ enterToSendTerminal: boolean;
+ defaultSaveToHistory: boolean;
+ defaultShowThinking: string;
+ autoScroll: boolean;
+ notificationsEnabled: boolean;
+ audioFeedbackEnabled: boolean;
+ colorBlindMode: string;
+ conductorProfile: string;
+ };
+}
+
+/**
+ * Group data for web clients
+ */
+export interface GroupData {
+ id: string;
+ name: string;
+ emoji: string | null;
+ sessionIds: string[];
+}
+
+/**
+ * Groups changed message from server
+ * Sent when groups are created, renamed, deleted, or membership changes
+ */
+export interface GroupsChangedMessage extends ServerMessage {
+ type: 'groups_changed';
+ groups: GroupData[];
+}
+
/**
* Tabs changed message from server
* Sent when tabs are added, removed, or active tab changes in a session
@@ -297,6 +382,48 @@ export interface TabsChangedMessage extends ServerMessage {
activeTabId: string;
}
+/**
+ * Group chat message data
+ */
+export interface GroupChatMessage {
+ id: string;
+ participantId: string;
+ participantName: string;
+ content: string;
+ timestamp: number;
+ role: 'user' | 'assistant';
+}
+
+/**
+ * Group chat state data
+ */
+export interface GroupChatState {
+ id: string;
+ topic: string;
+ participants: Array<{ sessionId: string; name: string; toolType: string }>;
+ messages: GroupChatMessage[];
+ isActive: boolean;
+ currentTurn?: string;
+}
+
+/**
+ * Group chat message broadcast from server
+ */
+export interface GroupChatMessageBroadcast extends ServerMessage {
+ type: 'group_chat_message';
+ chatId: string;
+ message: GroupChatMessage;
+}
+
+/**
+ * Group chat state change broadcast from server
+ */
+export interface GroupChatStateChangeBroadcast extends ServerMessage {
+ type: 'group_chat_state_change';
+ chatId: string;
+ [key: string]: unknown;
+}
+
/**
* Error message from server
*/
@@ -324,7 +451,13 @@ export type TypedServerMessage =
| ThemeMessage
| CustomCommandsMessage
| AutoRunStateMessage
+ | AutoRunDocsChangedMessage
+ | NotificationEventMessage
+ | SettingsChangedMessage
+ | GroupsChangedMessage
| TabsChangedMessage
+ | GroupChatMessageBroadcast
+ | GroupChatStateChangeBroadcast
| ErrorMessage
| ServerMessage;
@@ -363,8 +496,23 @@ export interface WebSocketEventHandlers {
onCustomCommands?: (commands: CustomCommand[]) => void;
/** Called when AutoRun state changes (batch processing on desktop) */
onAutoRunStateChange?: (sessionId: string, state: AutoRunState | null) => void;
+ /** Called when AutoRun document list changes */
+ onAutoRunDocsChanged?: (
+ sessionId: string,
+ documents: AutoRunDocsChangedMessage['documents']
+ ) => void;
+ /** Called when a notification event is received */
+ onNotificationEvent?: (event: NotificationEventMessage) => void;
+ /** Called when settings are changed (from web or desktop) */
+ onSettingsChanged?: (settings: SettingsChangedMessage['settings']) => void;
+ /** Called when groups are changed (created, renamed, deleted, membership) */
+ onGroupsChanged?: (groups: GroupData[]) => void;
/** Called when tabs change in a session */
onTabsChanged?: (sessionId: string, aiTabs: AITabData[], activeTabId: string) => void;
+ /** Called when a group chat message is broadcast */
+ onGroupChatMessage?: (chatId: string, message: GroupChatMessage) => void;
+ /** Called when group chat state changes */
+ onGroupChatStateChange?: (chatId: string, state: Partial) => void;
/** Called when connection state changes */
onConnectionChange?: (state: WebSocketState) => void;
/** Called when an error occurs */
@@ -419,6 +567,12 @@ export interface UseWebSocketReturn {
ping: () => void;
/** Send a raw message to the server */
send: (message: object) => boolean;
+ /** Send a request and wait for a correlated response */
+ sendRequest: (
+ type: string,
+ payload?: Record,
+ timeoutMs?: number
+ ) => Promise;
}
/**
@@ -501,6 +655,17 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
const seenMsgIdsRef = useRef>(new Set());
// Ref for handleMessage to avoid stale closure issues
const handleMessageRef = useRef<((event: MessageEvent) => void) | null>(null);
+ // Pending request-response map for sendRequest correlation
+ const pendingRequestsRef = useRef<
+ Map<
+ string,
+ {
+ resolve: (data: any) => void;
+ reject: (err: Error) => void;
+ timer: ReturnType;
+ }
+ >
+ >(new Map());
// Keep handlers ref up to date SYNCHRONOUSLY to avoid race conditions
// This must happen before any WebSocket messages are processed
@@ -541,11 +706,22 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
try {
const message = JSON.parse(event.data) as TypedServerMessage;
- // Debug: Log all incoming messages (not just session_output)
- console.log(
- `[WebSocket] Message received: type=${message.type}`,
- message.type === 'session_output' ? message : ''
- );
+ // Check for request-response correlation before dispatching
+ const requestId = (message as any).requestId as string | undefined;
+ if (requestId && pendingRequestsRef.current.has(requestId)) {
+ const pending = pendingRequestsRef.current.get(requestId)!;
+ clearTimeout(pending.timer);
+ pendingRequestsRef.current.delete(requestId);
+ if ((message as any).type === 'error') {
+ pending.reject(new Error((message as any).message ?? 'Server error'));
+ } else {
+ pending.resolve(message);
+ }
+ return;
+ }
+
+ // Log all incoming messages for debugging
+ webLogger.debug(`Message received: type=${message.type}`, 'WebSocket');
// Call the generic message handler
handlersRef.current?.onMessage?.(message);
@@ -635,8 +811,9 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
// Dedupe using message ID if available
if (outputMsg.msgId) {
if (seenMsgIdsRef.current.has(outputMsg.msgId)) {
- console.log(
- `[WebSocket] DEDUPE: Skipping duplicate session_output msgId=${outputMsg.msgId}`
+ webLogger.debug(
+ `DEDUPE: Skipping duplicate session_output msgId=${outputMsg.msgId}`,
+ 'WebSocket'
);
break;
}
@@ -647,8 +824,9 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
seenMsgIdsRef.current = new Set(idsArray.slice(-500));
}
}
- console.log(
- `[WebSocket] Received session_output: msgId=${outputMsg.msgId || 'none'}, session=${outputMsg.sessionId}, tabId=${outputMsg.tabId || 'none'}, source=${outputMsg.source}, dataLen=${outputMsg.data?.length || 0}, hasHandler=${!!handlersRef.current?.onSessionOutput}`
+ webLogger.debug(
+ `Received session_output: msgId=${outputMsg.msgId || 'none'}, session=${outputMsg.sessionId}, tabId=${outputMsg.tabId || 'none'}, source=${outputMsg.source}, dataLen=${outputMsg.data?.length || 0}`,
+ 'WebSocket'
);
handlersRef.current?.onSessionOutput?.(
outputMsg.sessionId,
@@ -697,6 +875,30 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
break;
}
+ case 'autorun_docs_changed': {
+ const docsMsg = message as AutoRunDocsChangedMessage;
+ handlersRef.current?.onAutoRunDocsChanged?.(docsMsg.sessionId, docsMsg.documents);
+ break;
+ }
+
+ case 'notification_event': {
+ const notifMsg = message as NotificationEventMessage;
+ handlersRef.current?.onNotificationEvent?.(notifMsg);
+ break;
+ }
+
+ case 'settings_changed': {
+ const settingsMsg = message as SettingsChangedMessage;
+ handlersRef.current?.onSettingsChanged?.(settingsMsg.settings);
+ break;
+ }
+
+ case 'groups_changed': {
+ const groupsMsg = message as GroupsChangedMessage;
+ handlersRef.current?.onGroupsChanged?.(groupsMsg.groups);
+ break;
+ }
+
case 'tabs_changed': {
const tabsMsg = message as TabsChangedMessage;
handlersRef.current?.onTabsChanged?.(
@@ -707,6 +909,22 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
break;
}
+ case 'group_chat_message': {
+ const gcMsg = message as GroupChatMessageBroadcast;
+ handlersRef.current?.onGroupChatMessage?.(gcMsg.chatId, gcMsg.message);
+ break;
+ }
+
+ case 'group_chat_state_change': {
+ const gcStateMsg = message as GroupChatStateChangeBroadcast;
+ const { chatId: gcChatId, type: _gcType, timestamp: _gcTs, ...gcState } = gcStateMsg;
+ handlersRef.current?.onGroupChatStateChange?.(
+ gcChatId,
+ gcState as Partial
+ );
+ break;
+ }
+
case 'error': {
const errorMsg = message as ErrorMessage;
setError(errorMsg.message);
@@ -798,6 +1016,12 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
webLogger.error('WebSocket connection error', 'WebSocket', event);
setError('WebSocket connection error');
handlersRef.current?.onError?.('WebSocket connection error');
+ // Reject all pending requests
+ for (const [, pending] of pendingRequestsRef.current) {
+ clearTimeout(pending.timer);
+ pending.reject(new Error('Connection lost'));
+ }
+ pendingRequestsRef.current.clear();
};
ws.onclose = (event) => {
@@ -807,6 +1031,12 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
wsRef.current = null;
setState('disconnected');
handlersRef.current?.onConnectionChange?.('disconnected');
+ // Reject all pending requests
+ for (const [, pending] of pendingRequestsRef.current) {
+ clearTimeout(pending.timer);
+ pending.reject(new Error('Connection lost'));
+ }
+ pendingRequestsRef.current.clear();
// Attempt to reconnect if not a clean close
if (event.code !== 1000 && shouldReconnectRef.current) {
@@ -887,6 +1117,40 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
return false;
}, []);
+ /**
+ * Send a request and wait for a correlated response.
+ * The server must echo back the requestId in its response.
+ */
+ const sendRequest = useCallback(
+ (
+ type: string,
+ payload?: Record,
+ timeoutMs: number = 10000
+ ): Promise => {
+ return new Promise((resolve, reject) => {
+ const requestId =
+ typeof crypto !== 'undefined' && crypto.randomUUID
+ ? crypto.randomUUID()
+ : Date.now().toString(36) + Math.random().toString(36);
+
+ const timer = setTimeout(() => {
+ pendingRequestsRef.current.delete(requestId);
+ reject(new Error('Request timed out'));
+ }, timeoutMs);
+
+ pendingRequestsRef.current.set(requestId, { resolve, reject, timer });
+
+ const sent = send({ type, ...payload, requestId });
+ if (!sent) {
+ clearTimeout(timer);
+ pendingRequestsRef.current.delete(requestId);
+ reject(new Error('WebSocket not connected'));
+ }
+ });
+ },
+ [send]
+ );
+
// Cleanup on unmount - track mount ID to handle StrictMode double-mount
useEffect(() => {
const thisMountId = ++mountIdRef.current;
@@ -928,6 +1192,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
authenticate,
ping,
send,
+ sendRequest,
};
}
diff --git a/src/web/mobile/AchievementsPanel.tsx b/src/web/mobile/AchievementsPanel.tsx
new file mode 100644
index 0000000000..315854a754
--- /dev/null
+++ b/src/web/mobile/AchievementsPanel.tsx
@@ -0,0 +1,364 @@
+/**
+ * AchievementsPanel component for Maestro mobile web interface
+ *
+ * Read-only viewer for achievements with progress tracking,
+ * sorted with unlocked first then locked by progress.
+ */
+
+import { useState, useCallback, useEffect, useMemo } from 'react';
+import { X } from 'lucide-react';
+import { useThemeColors } from '../components/ThemeProvider';
+
+interface AchievementData {
+ id: string;
+ name: string;
+ description: string;
+ unlocked: boolean;
+ unlockedAt?: number;
+ progress?: number;
+ maxProgress?: number;
+}
+
+export interface AchievementsPanelProps {
+ onClose: () => void;
+ sendRequest: (
+ type: string,
+ payload?: Record,
+ timeoutMs?: number
+ ) => Promise;
+}
+
+function formatRelativeTime(timestamp: number): string {
+ const now = Date.now();
+ const diff = now - timestamp;
+ const seconds = Math.floor(diff / 1000);
+ const minutes = Math.floor(seconds / 60);
+ const hours = Math.floor(minutes / 60);
+ const days = Math.floor(hours / 24);
+
+ if (days > 30) {
+ const months = Math.floor(days / 30);
+ return `${months}mo ago`;
+ }
+ if (days > 0) return `${days}d ago`;
+ if (hours > 0) return `${hours}h ago`;
+ if (minutes > 0) return `${minutes}m ago`;
+ return 'just now';
+}
+
+export function AchievementsPanel({ onClose, sendRequest }: AchievementsPanelProps) {
+ const colors = useThemeColors();
+ const [achievements, setAchievements] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const fetchAchievements = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+ try {
+ const result = await sendRequest<{ achievements: AchievementData[] }>(
+ 'get_achievements',
+ {},
+ 15000
+ );
+ setAchievements(result.achievements);
+ } catch {
+ setError('Failed to load achievements');
+ } finally {
+ setIsLoading(false);
+ }
+ }, [sendRequest]);
+
+ useEffect(() => {
+ fetchAchievements();
+ }, [fetchAchievements]);
+
+ const unlockedCount = useMemo(
+ () => achievements.filter((a) => a.unlocked).length,
+ [achievements]
+ );
+
+ const sortedAchievements = useMemo(() => {
+ return [...achievements].sort((a, b) => {
+ // Unlocked first
+ if (a.unlocked && !b.unlocked) return -1;
+ if (!a.unlocked && b.unlocked) return 1;
+ // Among unlocked: most recent first
+ if (a.unlocked && b.unlocked) {
+ return (b.unlockedAt ?? 0) - (a.unlockedAt ?? 0);
+ }
+ // Among locked: closest to completion first
+ const aProgress = a.maxProgress ? (a.progress ?? 0) / a.maxProgress : 0;
+ const bProgress = b.maxProgress ? (b.progress ?? 0) / b.maxProgress : 0;
+ return bProgress - aProgress;
+ });
+ }, [achievements]);
+
+ const overallProgress = achievements.length > 0 ? (unlockedCount / achievements.length) * 100 : 0;
+
+ return (
+
+ {/* Header */}
+
+
+ Achievements
+
+
+
+
+ {/* Content */}
+
+ {isLoading ? (
+
+ Loading achievements...
+
+ ) : error ? (
+
+ {error}
+
+ ) : achievements.length === 0 ? (
+
+ No achievements available
+
+ ) : (
+ <>
+ {/* Stats Bar */}
+
+
+
+ {unlockedCount} / {achievements.length} unlocked
+
+
+ {Math.round(overallProgress)}%
+
+
+
+
+
+ {/* Achievement Grid */}
+
+ {sortedAchievements.map((achievement) => (
+
+ ))}
+
+ >
+ )}
+
+
+ );
+}
+
+function AchievementCard({
+ achievement,
+ colors,
+}: {
+ achievement: AchievementData;
+ colors: ReturnType;
+}) {
+ const hasProgress =
+ achievement.progress != null && achievement.maxProgress != null && achievement.maxProgress > 0;
+ const progressPercent = hasProgress
+ ? ((achievement.progress ?? 0) / (achievement.maxProgress ?? 1)) * 100
+ : 0;
+
+ return (
+
+
+ {achievement.unlocked ? '\u{1F3C6}' : '\u{1F512}'}
+
+
+ {achievement.name}
+
+
+ {achievement.description}
+
+
+ {hasProgress && !achievement.unlocked && (
+
+
+
+ {achievement.progress} / {achievement.maxProgress}
+
+
+ )}
+
+ {achievement.unlocked && achievement.unlockedAt && (
+
+ {formatRelativeTime(achievement.unlockedAt)}
+
+ )}
+
+ );
+}
+
+export default AchievementsPanel;
diff --git a/src/web/mobile/AgentCreationSheet.tsx b/src/web/mobile/AgentCreationSheet.tsx
new file mode 100644
index 0000000000..330bd75dff
--- /dev/null
+++ b/src/web/mobile/AgentCreationSheet.tsx
@@ -0,0 +1,518 @@
+/**
+ * AgentCreationSheet component for Maestro mobile web interface
+ *
+ * Bottom sheet modal for creating a new agent.
+ * Allows selecting agent type, name, working directory, and optional group.
+ */
+
+import { useState, useCallback, useEffect, useRef } from 'react';
+import { useThemeColors } from '../components/ThemeProvider';
+import { triggerHaptic, HAPTIC_PATTERNS } from './constants';
+import type { GroupData } from '../hooks/useWebSocket';
+
+/** Agent types available for creation from the web interface */
+const CREATABLE_AGENT_TYPES = [
+ { id: 'claude-code', name: 'Claude Code', emoji: '🤖' },
+ { id: 'codex', name: 'Codex', emoji: '📦' },
+ { id: 'opencode', name: 'OpenCode', emoji: '🔓' },
+ { id: 'factory-droid', name: 'Factory Droid', emoji: '🏭' },
+] as const;
+
+export interface AgentCreationSheetProps {
+ groups: GroupData[];
+ defaultCwd: string;
+ createAgent: (
+ name: string,
+ toolType: string,
+ cwd: string,
+ groupId?: string
+ ) => Promise<{ sessionId: string } | null>;
+ onCreated: (sessionId: string) => void;
+ onClose: () => void;
+}
+
+export function AgentCreationSheet({
+ groups,
+ defaultCwd,
+ createAgent,
+ onCreated,
+ onClose,
+}: AgentCreationSheetProps) {
+ const colors = useThemeColors();
+ const [selectedType, setSelectedType] = useState('claude-code');
+ const [name, setName] = useState('');
+ const [cwd, setCwd] = useState(defaultCwd);
+ const [groupId, setGroupId] = useState(null);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [isVisible, setIsVisible] = useState(false);
+ const nameInputRef = useRef(null);
+
+ const handleClose = useCallback(() => {
+ triggerHaptic(HAPTIC_PATTERNS.tap);
+ setIsVisible(false);
+ setTimeout(() => onClose(), 300);
+ }, [onClose]);
+
+ // Animate in on mount
+ useEffect(() => {
+ requestAnimationFrame(() => setIsVisible(true));
+ }, []);
+
+ // Close on escape key
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ handleClose();
+ }
+ };
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [handleClose]);
+
+ const handleBackdropTap = useCallback(
+ (e: React.MouseEvent) => {
+ if (e.target === e.currentTarget) {
+ handleClose();
+ }
+ },
+ [handleClose]
+ );
+
+ const handleSelectType = useCallback((typeId: string) => {
+ triggerHaptic(HAPTIC_PATTERNS.tap);
+ setSelectedType(typeId);
+ // Update default name when type changes
+ const agentType = CREATABLE_AGENT_TYPES.find((t) => t.id === typeId);
+ if (agentType) {
+ setName('');
+ }
+ }, []);
+
+ const getDefaultName = useCallback(() => {
+ const agentType = CREATABLE_AGENT_TYPES.find((t) => t.id === selectedType);
+ return agentType ? agentType.name : 'New Agent';
+ }, [selectedType]);
+
+ const handleCreate = useCallback(async () => {
+ if (isSubmitting) return;
+ const agentName = name.trim() || getDefaultName();
+ if (!cwd.trim()) return;
+
+ setIsSubmitting(true);
+ triggerHaptic(HAPTIC_PATTERNS.send);
+
+ try {
+ const result = await createAgent(agentName, selectedType, cwd.trim(), groupId || undefined);
+ if (result) {
+ triggerHaptic(HAPTIC_PATTERNS.success);
+ onCreated(result.sessionId);
+ handleClose();
+ } else {
+ triggerHaptic(HAPTIC_PATTERNS.error);
+ setIsSubmitting(false);
+ }
+ } catch {
+ triggerHaptic(HAPTIC_PATTERNS.error);
+ setIsSubmitting(false);
+ }
+ }, [
+ isSubmitting,
+ name,
+ getDefaultName,
+ cwd,
+ selectedType,
+ groupId,
+ createAgent,
+ onCreated,
+ handleClose,
+ ]);
+
+ return (
+
+ {/* Sheet */}
+
+ {/* Drag handle */}
+
+
+ {/* Header */}
+
+
+ Create Agent
+
+
+
+
+ {/* Scrollable content */}
+
+ {/* Agent type selector */}
+
+
+ Agent Type
+
+
+ {CREATABLE_AGENT_TYPES.map((agentType) => {
+ const isSelected = selectedType === agentType.id;
+ return (
+
+ );
+ })}
+
+
+
+ {/* Name input */}
+
+
+ setName(e.target.value)}
+ placeholder={getDefaultName()}
+ style={{
+ width: '100%',
+ padding: '12px 14px',
+ borderRadius: '10px',
+ border: `1px solid ${colors.border}`,
+ backgroundColor: colors.bgSidebar,
+ color: colors.textMain,
+ fontSize: '14px',
+ outline: 'none',
+ WebkitAppearance: 'none',
+ boxSizing: 'border-box',
+ minHeight: '44px',
+ }}
+ onFocus={(e) => {
+ (e.target as HTMLInputElement).style.borderColor = colors.accent;
+ }}
+ onBlur={(e) => {
+ (e.target as HTMLInputElement).style.borderColor = colors.border;
+ }}
+ />
+
+
+ {/* Working directory */}
+
+
+ setCwd(e.target.value)}
+ placeholder="/path/to/project"
+ style={{
+ width: '100%',
+ padding: '12px 14px',
+ borderRadius: '10px',
+ border: `1px solid ${colors.border}`,
+ backgroundColor: colors.bgSidebar,
+ color: colors.textMain,
+ fontSize: '14px',
+ outline: 'none',
+ WebkitAppearance: 'none',
+ boxSizing: 'border-box',
+ fontFamily: 'monospace',
+ minHeight: '44px',
+ }}
+ onFocus={(e) => {
+ (e.target as HTMLInputElement).style.borderColor = colors.accent;
+ }}
+ onBlur={(e) => {
+ (e.target as HTMLInputElement).style.borderColor = colors.border;
+ }}
+ />
+
+
+ {/* Group selector */}
+
+
+
+ {/* No group option */}
+
+ {groups.map((group) => {
+ const isSelected = groupId === group.id;
+ return (
+
+ );
+ })}
+
+
+
+
+ {/* Create button */}
+
+
+
+
+
+ );
+}
+
+export default AgentCreationSheet;
diff --git a/src/web/mobile/AllSessionsView.tsx b/src/web/mobile/AllSessionsView.tsx
index 07fe602b82..f2348e90bb 100644
--- a/src/web/mobile/AllSessionsView.tsx
+++ b/src/web/mobile/AllSessionsView.tsx
@@ -12,6 +12,8 @@
* - Status indicator, mode badge, and working directory visible
* - Swipe down to dismiss / back button at top
* - Search/filter sessions
+ * - Long-press context menu for rename, move, delete
+ * - Floating "+" button to create new agents
*/
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
@@ -21,6 +23,21 @@ import type { Session, GroupInfo } from '../hooks/useSessions';
import { triggerHaptic, HAPTIC_PATTERNS } from './constants';
import { truncatePath } from '../../shared/formatters';
import { getAgentDisplayName } from '../../shared/agentMetadata';
+import type { GroupData } from '../hooks/useWebSocket';
+
+/** Duration in ms to trigger long-press */
+const LONG_PRESS_DURATION = 500;
+
+/**
+ * Context menu action types for session management
+ */
+type ContextMenuAction = 'rename' | 'move' | 'delete';
+
+interface ContextMenuState {
+ session: Session;
+ x: number;
+ y: number;
+}
/**
* Session card component for the All Sessions view
@@ -35,6 +52,18 @@ interface SessionCardProps {
interface MobileSessionCardPropsInternal extends SessionCardProps {
/** Display name (may include parent prefix for worktree children) */
displayName: string;
+ /** Whether this card is currently being renamed inline */
+ isRenaming: boolean;
+ /** Current rename value */
+ renameValue: string;
+ /** Callback for rename value changes */
+ onRenameChange: (value: string) => void;
+ /** Callback to confirm rename */
+ onRenameConfirm: () => void;
+ /** Callback to cancel rename */
+ onRenameCancel: () => void;
+ /** Long-press handler */
+ onLongPress: (session: Session, x: number, y: number) => void;
}
function MobileSessionCard({
@@ -42,8 +71,25 @@ function MobileSessionCard({
isActive,
onSelect,
displayName,
+ isRenaming,
+ renameValue,
+ onRenameChange,
+ onRenameConfirm,
+ onRenameCancel,
+ onLongPress,
}: MobileSessionCardPropsInternal) {
const colors = useThemeColors();
+ const longPressTimerRef = useRef | null>(null);
+ const isLongPressTriggeredRef = useRef(false);
+ const renameInputRef = useRef(null);
+
+ // Focus rename input when entering rename mode
+ useEffect(() => {
+ if (isRenaming && renameInputRef.current) {
+ renameInputRef.current.focus();
+ renameInputRef.current.select();
+ }
+ }, [isRenaming]);
// Map session state to status for StatusDot
const getStatus = (): SessionStatus => {
@@ -68,14 +114,93 @@ function MobileSessionCard({
return getAgentDisplayName(session.toolType);
};
+ const clearLongPressTimer = useCallback(() => {
+ if (longPressTimerRef.current) {
+ clearTimeout(longPressTimerRef.current);
+ longPressTimerRef.current = null;
+ }
+ }, []);
+
+ const handleTouchStart = useCallback(
+ (e: React.TouchEvent) => {
+ isLongPressTriggeredRef.current = false;
+ const touch = e.touches[0];
+ const x = touch.clientX;
+ const y = touch.clientY;
+ longPressTimerRef.current = setTimeout(() => {
+ isLongPressTriggeredRef.current = true;
+ triggerHaptic(HAPTIC_PATTERNS.success);
+ onLongPress(session, x, y);
+ }, LONG_PRESS_DURATION);
+ },
+ [session, onLongPress]
+ );
+
+ const handleTouchEnd = useCallback(() => {
+ clearLongPressTimer();
+ if (!isLongPressTriggeredRef.current) {
+ triggerHaptic(HAPTIC_PATTERNS.tap);
+ onSelect(session.id);
+ }
+ isLongPressTriggeredRef.current = false;
+ }, [clearLongPressTimer, onSelect, session.id]);
+
+ const handleTouchMove = useCallback(() => {
+ clearLongPressTimer();
+ }, [clearLongPressTimer]);
+
+ const handleTouchCancel = useCallback(() => {
+ clearLongPressTimer();
+ isLongPressTriggeredRef.current = false;
+ }, [clearLongPressTimer]);
+
+ const handleContextMenu = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ triggerHaptic(HAPTIC_PATTERNS.success);
+ onLongPress(session, e.clientX, e.clientY);
+ },
+ [session, onLongPress]
+ );
+
const handleClick = useCallback(() => {
- triggerHaptic(HAPTIC_PATTERNS.tap);
- onSelect(session.id);
+ // For non-touch devices
+ if (!('ontouchstart' in window)) {
+ triggerHaptic(HAPTIC_PATTERNS.tap);
+ onSelect(session.id);
+ }
}, [session.id, onSelect]);
+ // Cleanup timer on unmount
+ useEffect(() => {
+ return () => clearLongPressTimer();
+ }, [clearLongPressTimer]);
+
+ const handleRenameKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.stopPropagation();
+ e.preventDefault();
+ onRenameConfirm();
+ } else if (e.key === 'Escape') {
+ e.stopPropagation();
+ e.preventDefault();
+ onRenameCancel();
+ }
+ },
+ [onRenameConfirm, onRenameCancel]
+ );
+
+ const CardContainer = isRenaming ? 'div' : 'button';
+
return (
-
+
+ );
+}
+
+/**
+ * Context menu component for session management actions
+ */
+function SessionContextMenu({
+ session,
+ x,
+ y,
+ onAction,
+ onClose,
+}: {
+ session: Session;
+ x: number;
+ y: number;
+ onAction: (action: ContextMenuAction, session: Session) => void;
+ onClose: () => void;
+}) {
+ const colors = useThemeColors();
+ const menuRef = useRef(null);
+
+ // Position the menu within viewport bounds
+ const calculatePosition = (): React.CSSProperties => {
+ const menuWidth = 180;
+ const menuHeight = 150;
+ const padding = 12;
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+
+ let left = x;
+ let top = y;
+
+ if (left + menuWidth > viewportWidth - padding) {
+ left = viewportWidth - menuWidth - padding;
+ }
+ if (left < padding) {
+ left = padding;
+ }
+ if (top + menuHeight > viewportHeight - padding) {
+ top = viewportHeight - menuHeight - padding;
+ }
+
+ return {
+ position: 'fixed',
+ left: `${left}px`,
+ top: `${top}px`,
+ width: `${menuWidth}px`,
+ zIndex: 310,
+ };
+ };
+
+ // Close on outside click
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent | TouchEvent) => {
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
+ onClose();
+ }
+ };
+ const timer = setTimeout(() => {
+ document.addEventListener('mousedown', handleClickOutside);
+ document.addEventListener('touchstart', handleClickOutside);
+ }, 50);
+ return () => {
+ clearTimeout(timer);
+ document.removeEventListener('mousedown', handleClickOutside);
+ document.removeEventListener('touchstart', handleClickOutside);
+ };
+ }, [onClose]);
+
+ const menuItemStyle: React.CSSProperties = {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '10px',
+ width: '100%',
+ padding: '12px 14px',
+ border: 'none',
+ backgroundColor: 'transparent',
+ color: colors.textMain,
+ fontSize: '14px',
+ fontWeight: 500,
+ cursor: 'pointer',
+ textAlign: 'left',
+ touchAction: 'manipulation',
+ WebkitTapHighlightColor: 'transparent',
+ };
+
+ return (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Menu */}
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+/**
+ * Delete confirmation dialog
+ */
+function DeleteConfirmDialog({
+ sessionName,
+ onConfirm,
+ onCancel,
+}: {
+ sessionName: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+}) {
+ const colors = useThemeColors();
+
+ return (
+ <>
+ {/* Backdrop */}
+
+ {/* Dialog */}
+
e.stopPropagation()}
+ style={{
+ backgroundColor: colors.bgSidebar,
+ borderRadius: '14px',
+ border: `1px solid ${colors.border}`,
+ boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
+ width: 'min(320px, calc(100vw - 48px))',
+ padding: '24px 20px',
+ animation: 'dialogFadeIn 0.15s ease-out',
+ }}
+ role="alertdialog"
+ aria-label={`Delete agent ${sessionName}?`}
+ >
+
+ Delete Agent
+
+
+ Delete agent "{sessionName}"? This action cannot be undone.
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+/**
+ * Move to Group bottom sheet
+ */
+function MoveToGroupSheet({
+ session,
+ groups,
+ onMove,
+ onClose,
+}: {
+ session: Session;
+ groups: GroupData[];
+ onMove: (sessionId: string, groupId: string | null) => void;
+ onClose: () => void;
+}) {
+ const colors = useThemeColors();
+ const [isVisible, setIsVisible] = useState(false);
+
+ useEffect(() => {
+ requestAnimationFrame(() => setIsVisible(true));
+ }, []);
+
+ const handleClose = useCallback(() => {
+ setIsVisible(false);
+ setTimeout(() => onClose(), 300);
+ }, [onClose]);
+
+ const handleMove = useCallback(
+ (groupId: string | null) => {
+ triggerHaptic(HAPTIC_PATTERNS.tap);
+ onMove(session.id, groupId);
+ handleClose();
+ },
+ [session.id, onMove, handleClose]
+ );
+
+ return (
+ {
+ if (e.target === e.currentTarget) handleClose();
+ }}
+ style={{
+ position: 'fixed',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: `rgba(0, 0, 0, ${isVisible ? 0.5 : 0})`,
+ zIndex: 320,
+ display: 'flex',
+ alignItems: 'flex-end',
+ transition: 'background-color 0.3s ease-out',
+ }}
+ >
+
+ {/* Drag handle */}
+
+
+ {/* Header */}
+
+
+ Move "{session.name}" to Group
+
+
+
+ {/* Group list */}
+
+ {/* No group / Ungrouped */}
+
+ {groups.map((group) => {
+ const isCurrentGroup = session.groupId === group.id;
+ return (
+
+ );
+ })}
+
+
+
);
}
@@ -205,11 +821,7 @@ function MobileSessionCard({
function findParentSession(session: Session, sessions: Session[]): Session | null {
// If parentSessionId is set, use it directly
if (session.parentSessionId) {
- const parent = sessions.find((s) => s.id === session.parentSessionId) || null;
- console.log(
- `[findParentSession] ${session.name}: parentSessionId=${session.parentSessionId}, found=${parent?.name || 'null'}`
- );
- return parent;
+ return sessions.find((s) => s.id === session.parentSessionId) || null;
}
// Try to infer parent from path patterns
@@ -217,35 +829,21 @@ function findParentSession(session: Session, sessions: Session[]): Session | nul
// Check for worktree path patterns: ProjectName-WorkTrees/branch or ProjectNameWorkTrees/branch
const worktreeMatch = cwd.match(/^(.+?)[-]?WorkTrees[\/\\]([^\/\\]+)/i);
- console.log(
- `[findParentSession] ${session.name}: cwd=${cwd}, worktreeMatch=${JSON.stringify(worktreeMatch)}`
- );
if (worktreeMatch) {
const basePath = worktreeMatch[1];
- console.log(`[findParentSession] ${session.name}: basePath=${basePath}`);
-
- // Log all potential parents
- const potentialParents = sessions.filter((s) => s.id !== session.id && !s.parentSessionId);
- console.log(
- `[findParentSession] ${session.name}: potential parents:`,
- potentialParents.map((s) => ({ name: s.name, cwd: s.cwd }))
- );
// Find a session whose cwd matches the base path
- const parent = sessions.find(
- (s) =>
- s.id !== session.id &&
- !s.parentSessionId && // Not itself a worktree child
- (s.cwd === basePath ||
- s.cwd.startsWith(basePath + '/') ||
- s.cwd.startsWith(basePath + '\\'))
+ return (
+ sessions.find(
+ (s) =>
+ s.id !== session.id &&
+ !s.parentSessionId && // Not itself a worktree child
+ (s.cwd === basePath ||
+ s.cwd.startsWith(basePath + '/') ||
+ s.cwd.startsWith(basePath + '\\'))
+ ) || null
);
- if (parent) {
- console.log(`[findParentSession] ${session.name}: FOUND parent=${parent.name}`);
- return parent;
- }
- console.log(`[findParentSession] ${session.name}: NO parent found for basePath=${basePath}`);
}
return null;
@@ -303,6 +901,13 @@ interface GroupSectionProps {
onToggleCollapse: (groupId: string) => void;
/** All sessions for parent lookup */
allSessions: Session[];
+ /** Currently renaming session id */
+ renamingSessionId: string | null;
+ renameValue: string;
+ onRenameChange: (value: string) => void;
+ onRenameConfirm: () => void;
+ onRenameCancel: () => void;
+ onLongPress: (session: Session, x: number, y: number) => void;
}
function GroupSection({
@@ -315,6 +920,12 @@ function GroupSection({
isCollapsed,
onToggleCollapse,
allSessions,
+ renamingSessionId,
+ renameValue,
+ onRenameChange,
+ onRenameConfirm,
+ onRenameCancel,
+ onLongPress,
}: GroupSectionProps) {
const colors = useThemeColors();
@@ -402,6 +1013,12 @@ function GroupSection({
isActive={session.id === activeSessionId}
onSelect={onSelectSession}
displayName={getSessionDisplayName(session, allSessions)}
+ isRenaming={renamingSessionId === session.id}
+ renameValue={renameValue}
+ onRenameChange={onRenameChange}
+ onRenameConfirm={onRenameConfirm}
+ onRenameCancel={onRenameCancel}
+ onLongPress={onLongPress}
/>
))}
@@ -424,6 +1041,16 @@ export interface AllSessionsViewProps {
onClose: () => void;
/** Optional filter/search query */
searchQuery?: string;
+ /** Callback to rename an agent */
+ onRenameAgent?: (sessionId: string, newName: string) => Promise;
+ /** Callback to delete an agent */
+ onDeleteAgent?: (sessionId: string) => Promise;
+ /** Callback to move an agent to a group */
+ onMoveToGroup?: (sessionId: string, groupId: string | null) => Promise;
+ /** Available groups for move-to-group */
+ groups?: GroupData[];
+ /** Callback to open agent creation sheet */
+ onOpenCreateAgent?: () => void;
}
/**
@@ -438,12 +1065,30 @@ export function AllSessionsView({
onSelectSession,
onClose,
searchQuery = '',
+ onRenameAgent,
+ onDeleteAgent,
+ onMoveToGroup,
+ groups = [],
+ onOpenCreateAgent,
}: AllSessionsViewProps) {
const colors = useThemeColors();
const [collapsedGroups, setCollapsedGroups] = useState | null>(null);
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
const containerRef = useRef(null);
+ // Context menu state
+ const [contextMenu, setContextMenu] = useState(null);
+
+ // Inline rename state
+ const [renamingSessionId, setRenamingSessionId] = useState(null);
+ const [renameValue, setRenameValue] = useState('');
+
+ // Delete confirmation state
+ const [deleteSession, setDeleteSession] = useState(null);
+
+ // Move to group sheet state
+ const [moveSession, setMoveSession] = useState(null);
+
// Filter sessions by search query (including worktree display names)
const filteredSessions = useMemo(() => {
if (!localSearchQuery.trim()) return sessions;
@@ -465,12 +1110,12 @@ export function AllSessionsView({
// Organize sessions by group, including a special "bookmarks" group
// Worktree children inherit their parent's group
const sessionsByGroup = useMemo((): Record => {
- const groups: Record = {};
+ const groupMap: Record = {};
// Add bookmarked sessions to a special "bookmarks" group
const bookmarkedSessions = filteredSessions.filter((s) => s.bookmarked);
if (bookmarkedSessions.length > 0) {
- groups['bookmarks'] = {
+ groupMap['bookmarks'] = {
id: 'bookmarks',
name: 'Bookmarks',
emoji: '★',
@@ -484,18 +1129,18 @@ export function AllSessionsView({
const effectiveGroup = getSessionEffectiveGroup(session, sessions);
const groupKey = effectiveGroup.groupId || 'ungrouped';
- if (!groups[groupKey]) {
- groups[groupKey] = {
+ if (!groupMap[groupKey]) {
+ groupMap[groupKey] = {
id: effectiveGroup.groupId,
name: effectiveGroup.groupName || 'Ungrouped',
emoji: effectiveGroup.groupEmoji,
sessions: [],
};
}
- groups[groupKey].sessions.push(session);
+ groupMap[groupKey].sessions.push(session);
}
- return groups;
+ return groupMap;
}, [filteredSessions, sessions]);
// Get sorted group keys (bookmarks first, ungrouped last)
@@ -581,6 +1226,59 @@ export function AllSessionsView({
setLocalSearchQuery('');
}, []);
+ // Long-press handler
+ const handleLongPress = useCallback((session: Session, x: number, y: number) => {
+ setContextMenu({ session, x, y });
+ }, []);
+
+ // Context menu action handler
+ const handleContextMenuAction = useCallback((action: ContextMenuAction, session: Session) => {
+ setContextMenu(null);
+ switch (action) {
+ case 'rename':
+ setRenamingSessionId(session.id);
+ setRenameValue(session.name);
+ break;
+ case 'move':
+ setMoveSession(session);
+ break;
+ case 'delete':
+ setDeleteSession(session);
+ break;
+ }
+ }, []);
+
+ // Rename confirm handler
+ const handleRenameConfirm = useCallback(async () => {
+ if (!renamingSessionId || !renameValue.trim() || !onRenameAgent) {
+ setRenamingSessionId(null);
+ return;
+ }
+ await onRenameAgent(renamingSessionId, renameValue.trim());
+ setRenamingSessionId(null);
+ }, [renamingSessionId, renameValue, onRenameAgent]);
+
+ // Rename cancel handler
+ const handleRenameCancel = useCallback(() => {
+ setRenamingSessionId(null);
+ }, []);
+
+ // Delete confirm handler
+ const handleDeleteConfirm = useCallback(async () => {
+ if (!deleteSession || !onDeleteAgent) return;
+ await onDeleteAgent(deleteSession.id);
+ setDeleteSession(null);
+ }, [deleteSession, onDeleteAgent]);
+
+ // Move to group handler
+ const handleMoveToGroup = useCallback(
+ async (sessionId: string, groupId: string | null) => {
+ if (!onMoveToGroup) return;
+ await onMoveToGroup(sessionId, groupId);
+ },
+ [onMoveToGroup]
+ );
+
// Close on escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -716,7 +1414,7 @@ export function AllSessionsView({
overflowY: 'auto',
overflowX: 'hidden',
padding: '16px',
- paddingBottom: 'max(16px, env(safe-area-inset-bottom))',
+ paddingBottom: 'max(80px, env(safe-area-inset-bottom))',
}}
>
{filteredSessions.length === 0 ? (
@@ -755,6 +1453,12 @@ export function AllSessionsView({
isActive={session.id === activeSessionId}
onSelect={handleSelectSession}
displayName={getSessionDisplayName(session, sessions)}
+ isRenaming={renamingSessionId === session.id}
+ renameValue={renameValue}
+ onRenameChange={setRenameValue}
+ onRenameConfirm={handleRenameConfirm}
+ onRenameCancel={handleRenameCancel}
+ onLongPress={handleLongPress}
/>
))}
@@ -774,12 +1478,83 @@ export function AllSessionsView({
isCollapsed={collapsedGroups?.has(groupKey) ?? true}
onToggleCollapse={handleToggleCollapse}
allSessions={sessions}
+ renamingSessionId={renamingSessionId}
+ renameValue={renameValue}
+ onRenameChange={setRenameValue}
+ onRenameConfirm={handleRenameConfirm}
+ onRenameCancel={handleRenameCancel}
+ onLongPress={handleLongPress}
/>
);
})
)}
+ {/* Floating "+" button for creating new agents */}
+ {onOpenCreateAgent && (
+
+ )}
+
+ {/* Context menu */}
+ {contextMenu && (
+ setContextMenu(null)}
+ />
+ )}
+
+ {/* Delete confirmation dialog */}
+ {deleteSession && (
+ setDeleteSession(null)}
+ />
+ )}
+
+ {/* Move to group sheet */}
+ {moveSession && (
+ setMoveSession(null)}
+ />
+ )}
+
{/* Animation keyframes */}
+
+ );
+}
+
+export default AutoRunDocumentViewer;
diff --git a/src/web/mobile/AutoRunIndicator.tsx b/src/web/mobile/AutoRunIndicator.tsx
index b37049e9a4..7327541980 100644
--- a/src/web/mobile/AutoRunIndicator.tsx
+++ b/src/web/mobile/AutoRunIndicator.tsx
@@ -14,6 +14,8 @@ interface AutoRunIndicatorProps {
state: AutoRunState | null;
/** Name of the session running AutoRun */
sessionName?: string;
+ /** Handler when the indicator is tapped - opens the full Auto Run panel */
+ onTap?: () => void;
}
/**
@@ -21,7 +23,7 @@ interface AutoRunIndicatorProps {
* Shows task progress when batch processing is active
* PROMINENT: Uses bold colors and large text for visibility
*/
-export function AutoRunIndicator({ state, sessionName }: AutoRunIndicatorProps) {
+export function AutoRunIndicator({ state, sessionName, onTap }: AutoRunIndicatorProps) {
const colors = useThemeColors();
// Don't render if no state or not running
@@ -35,6 +37,9 @@ export function AutoRunIndicator({ state, sessionName }: AutoRunIndicatorProps)
return (
{/* Animated indicator icon - white circle with icon */}
diff --git a/src/web/mobile/AutoRunPanel.tsx b/src/web/mobile/AutoRunPanel.tsx
new file mode 100644
index 0000000000..5059b43eb0
--- /dev/null
+++ b/src/web/mobile/AutoRunPanel.tsx
@@ -0,0 +1,560 @@
+/**
+ * AutoRunPanel component for Maestro mobile web interface
+ *
+ * Full-screen management panel for Auto Run documents.
+ * Provides document listing, launch/stop controls, and navigation
+ * to document viewer and setup sheet.
+ */
+
+import { useState, useCallback, useEffect } from 'react';
+import { useThemeColors } from '../components/ThemeProvider';
+import { useAutoRun, type AutoRunDocument } from '../hooks/useAutoRun';
+import type { AutoRunState, UseWebSocketReturn } from '../hooks/useWebSocket';
+import { triggerHaptic, HAPTIC_PATTERNS } from './constants';
+
+/**
+ * Document card component for the Auto Run panel
+ */
+interface DocumentCardProps {
+ document: AutoRunDocument;
+ onTap: (filename: string) => void;
+}
+
+function DocumentCard({ document, onTap }: DocumentCardProps) {
+ const colors = useThemeColors();
+ const progress =
+ document.taskCount > 0 ? Math.round((document.completedCount / document.taskCount) * 100) : 0;
+
+ const handleTap = useCallback(() => {
+ triggerHaptic(HAPTIC_PATTERNS.tap);
+ onTap(document.filename);
+ }, [document.filename, onTap]);
+
+ return (
+
+ );
+}
+
+/**
+ * Props for AutoRunPanel component
+ */
+export interface AutoRunPanelProps {
+ sessionId: string;
+ autoRunState: AutoRunState | null;
+ onClose: () => void;
+ onOpenDocument?: (filename: string) => void;
+ onOpenSetup?: () => void;
+ sendRequest: UseWebSocketReturn['sendRequest'];
+ send: UseWebSocketReturn['send'];
+}
+
+/**
+ * AutoRunPanel component
+ *
+ * Full-screen panel for managing Auto Run documents, launching/stopping runs,
+ * and navigating to document viewer and setup sheet.
+ */
+export function AutoRunPanel({
+ sessionId,
+ autoRunState,
+ onClose,
+ onOpenDocument,
+ onOpenSetup,
+ sendRequest,
+ send,
+}: AutoRunPanelProps) {
+ const colors = useThemeColors();
+ const [isStopping, setIsStopping] = useState(false);
+
+ const { documents, isLoadingDocs, loadDocuments, stopAutoRun } = useAutoRun(
+ sendRequest,
+ send,
+ autoRunState
+ );
+
+ // Load documents on mount and when sessionId changes
+ useEffect(() => {
+ loadDocuments(sessionId);
+ }, [sessionId, loadDocuments]);
+
+ // Reset stopping state when autoRun stops
+ useEffect(() => {
+ if (!autoRunState?.isRunning) {
+ setIsStopping(false);
+ }
+ }, [autoRunState?.isRunning]);
+
+ const handleClose = useCallback(() => {
+ triggerHaptic(HAPTIC_PATTERNS.tap);
+ onClose();
+ }, [onClose]);
+
+ const handleRefresh = useCallback(() => {
+ triggerHaptic(HAPTIC_PATTERNS.tap);
+ loadDocuments(sessionId);
+ }, [sessionId, loadDocuments]);
+
+ const handleStop = useCallback(async () => {
+ triggerHaptic(HAPTIC_PATTERNS.interrupt);
+ setIsStopping(true);
+ const success = await stopAutoRun(sessionId);
+ if (!success) {
+ setIsStopping(false);
+ }
+ }, [sessionId, stopAutoRun]);
+
+ const handleConfigure = useCallback(() => {
+ triggerHaptic(HAPTIC_PATTERNS.tap);
+ onOpenSetup?.();
+ }, [onOpenSetup]);
+
+ const handleDocumentTap = useCallback(
+ (filename: string) => {
+ onOpenDocument?.(filename);
+ },
+ [onOpenDocument]
+ );
+
+ // Close on escape key
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ onClose();
+ }
+ };
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [onClose]);
+
+ const isRunning = autoRunState?.isRunning ?? false;
+ const totalTasks = autoRunState?.totalTasks;
+ const completedTasks = autoRunState?.completedTasks ?? 0;
+ const currentTaskIndex = autoRunState?.currentTaskIndex ?? 0;
+ const progress =
+ totalTasks != null && totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
+ const totalDocs = autoRunState?.totalDocuments;
+ const currentDocIndex = autoRunState?.currentDocumentIndex;
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Status bar (when running) */}
+ {isRunning && (
+
+ {/* Progress badge */}
+
+ {progress}%
+
+
+ {/* Status text */}
+
+
+ {totalTasks != null && totalTasks > 0 && (
+
+ Task {currentTaskIndex + 1}/{totalTasks}
+
+ )}
+ {totalDocs != null && currentDocIndex != null && totalDocs > 1 && (
+
+ Doc {currentDocIndex + 1}/{totalDocs}
+
+ )}
+
+
+ {/* Progress bar */}
+
+
+
+ )}
+
+ {/* Controls bar */}
+
+ {/* Configure & Launch button */}
+
+
+ {/* Stop button (visible only when running) */}
+ {isRunning && (
+
+ )}
+
+
+ {/* Document list */}
+
+ {isLoadingDocs ? (
+
+ Loading documents...
+
+ ) : documents.length === 0 ? (
+ /* Empty state */
+
+
+ No Auto Run documents found
+
+
+ Create documents in the{' '}
+
+ .maestro/auto-run/
+ {' '}
+ directory to get started
+
+
+ ) : (
+
+ {documents.map((doc) => (
+
+ ))}
+
+ )}
+
+
+ {/* Animation keyframes */}
+
+
+ );
+}
+
+export default AutoRunPanel;
diff --git a/src/web/mobile/AutoRunSetupSheet.tsx b/src/web/mobile/AutoRunSetupSheet.tsx
new file mode 100644
index 0000000000..13b3a17934
--- /dev/null
+++ b/src/web/mobile/AutoRunSetupSheet.tsx
@@ -0,0 +1,574 @@
+/**
+ * AutoRunSetupSheet component for Maestro mobile web interface
+ *
+ * Bottom sheet modal for configuring Auto Run before launch.
+ * Allows document selection, custom prompt, and loop settings.
+ */
+
+import { useState, useCallback, useEffect, useRef } from 'react';
+import { useThemeColors } from '../components/ThemeProvider';
+import { triggerHaptic, HAPTIC_PATTERNS } from './constants';
+import type { AutoRunDocument, LaunchConfig } from '../hooks/useAutoRun';
+
+/**
+ * Props for AutoRunSetupSheet component
+ */
+export interface AutoRunSetupSheetProps {
+ sessionId: string;
+ documents: AutoRunDocument[];
+ onLaunch: (config: LaunchConfig) => void;
+ onClose: () => void;
+}
+
+/**
+ * AutoRunSetupSheet component
+ *
+ * Bottom sheet modal that slides up from the bottom of the screen.
+ * Provides document selection, optional prompt, and loop configuration.
+ */
+export function AutoRunSetupSheet({
+ sessionId: _sessionId,
+ documents,
+ onLaunch,
+ onClose,
+}: AutoRunSetupSheetProps) {
+ const colors = useThemeColors();
+ const [selectedFiles, setSelectedFiles] = useState
>(
+ () => new Set(documents.map((d) => d.filename))
+ );
+ const [prompt, setPrompt] = useState('');
+ const [loopEnabled, setLoopEnabled] = useState(false);
+ const [maxLoops, setMaxLoops] = useState(3);
+ const [isVisible, setIsVisible] = useState(false);
+ const sheetRef = useRef(null);
+
+ const handleClose = useCallback(() => {
+ triggerHaptic(HAPTIC_PATTERNS.tap);
+ setIsVisible(false);
+ setTimeout(() => onClose(), 300);
+ }, [onClose]);
+
+ // Reinitialize draft when sessionId or documents change
+ useEffect(() => {
+ setSelectedFiles(new Set(documents.map((d) => d.filename)));
+ setPrompt('');
+ setLoopEnabled(false);
+ setMaxLoops(3);
+ }, [_sessionId, documents]);
+
+ // Animate in on mount
+ useEffect(() => {
+ requestAnimationFrame(() => setIsVisible(true));
+ }, []);
+
+ // Close on escape key
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ handleClose();
+ }
+ };
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [handleClose]);
+
+ const handleBackdropTap = useCallback(
+ (e: React.MouseEvent) => {
+ if (e.target === e.currentTarget) {
+ handleClose();
+ }
+ },
+ [handleClose]
+ );
+
+ const handleToggleFile = useCallback((filename: string) => {
+ triggerHaptic(HAPTIC_PATTERNS.tap);
+ setSelectedFiles((prev) => {
+ const next = new Set(prev);
+ if (next.has(filename)) {
+ next.delete(filename);
+ } else {
+ next.add(filename);
+ }
+ return next;
+ });
+ }, []);
+
+ const handleToggleAll = useCallback(() => {
+ triggerHaptic(HAPTIC_PATTERNS.tap);
+ if (selectedFiles.size === documents.length) {
+ setSelectedFiles(new Set());
+ } else {
+ setSelectedFiles(new Set(documents.map((d) => d.filename)));
+ }
+ }, [selectedFiles.size, documents]);
+
+ const handleLoopToggle = useCallback(() => {
+ triggerHaptic(HAPTIC_PATTERNS.tap);
+ setLoopEnabled((prev) => !prev);
+ }, []);
+
+ const handleMaxLoopsChange = useCallback((e: React.ChangeEvent) => {
+ const value = parseInt(e.target.value, 10);
+ if (!isNaN(value)) {
+ setMaxLoops(Math.max(1, Math.min(100, value)));
+ }
+ }, []);
+
+ const handleLaunch = useCallback(() => {
+ if (selectedFiles.size === 0) return;
+ triggerHaptic(HAPTIC_PATTERNS.success);
+ const config: LaunchConfig = {
+ documents: Array.from(selectedFiles).map((filename) => ({ filename })),
+ prompt: prompt.trim() || undefined,
+ loopEnabled: loopEnabled || undefined,
+ maxLoops: loopEnabled ? maxLoops : undefined,
+ };
+ onLaunch(config);
+ }, [selectedFiles, prompt, loopEnabled, maxLoops, onLaunch]);
+
+ const allSelected = selectedFiles.size === documents.length && documents.length > 0;
+
+ return (
+
+ {/* Sheet */}
+
+ {/* Drag handle */}
+
+
+ {/* Header */}
+
+
+ Configure Auto Run
+
+
+
+
+ {/* Scrollable content */}
+
+ {/* Document selector section */}
+
+ {/* Section label + Select All toggle */}
+
+
+ Documents
+
+
+
+
+ {/* Document checkbox list */}
+
+ {documents.map((doc) => {
+ const isSelected = selectedFiles.has(doc.filename);
+ return (
+
+ );
+ })}
+
+
+
+ {/* Prompt input section */}
+
+
+
+
+ {/* Loop settings section */}
+
+
+
+ {/* Loop toggle */}
+
+
+ {/* Max loops input (visible when loop enabled) */}
+ {loopEnabled && (
+
+
+ Max loops
+
+
+
+ )}
+
+
+
+ {/* Launch button */}
+
+
+
+
+
+ );
+}
+
+export default AutoRunSetupSheet;
diff --git a/src/web/mobile/CommandInputBar.tsx b/src/web/mobile/CommandInputBar.tsx
index 511e2a5285..1adcf634df 100644
--- a/src/web/mobile/CommandInputBar.tsx
+++ b/src/web/mobile/CommandInputBar.tsx
@@ -36,7 +36,6 @@ import {
type SlashCommand,
DEFAULT_SLASH_COMMANDS,
} from './SlashCommandAutocomplete';
-import { QuickActionsMenu } from './QuickActionsMenu';
import { triggerHaptic } from './constants';
import {
InputModeToggleButton,
@@ -126,8 +125,6 @@ export interface CommandInputBarProps {
onSelectRecentCommand?: (command: string) => void;
/** Available slash commands (uses defaults if not provided) */
slashCommands?: SlashCommand[];
- /** Whether a session is currently active (for quick actions menu) */
- hasActiveSession?: boolean;
/** Current working directory (shown in terminal mode) */
cwd?: string;
/** Callback when input receives focus */
@@ -136,6 +133,8 @@ export interface CommandInputBarProps {
onInputBlur?: () => void;
/** Whether to show recent command chips (defaults to true) */
showRecentCommands?: boolean;
+ /** Callback when command palette should open (long-press of send button) */
+ onOpenCommandPalette?: () => void;
}
/**
@@ -160,11 +159,11 @@ export function CommandInputBar({
recentCommands,
onSelectRecentCommand,
slashCommands = DEFAULT_SLASH_COMMANDS,
- hasActiveSession = false,
cwd,
onInputFocus,
onInputBlur,
showRecentCommands = true,
+ onOpenCommandPalette,
}: CommandInputBarProps) {
const colors = useThemeColors();
const textareaRef = useRef(null);
@@ -242,21 +241,18 @@ export function CommandInputBar({
focusRef: textareaRef as React.RefObject,
});
- // Long-press menu hook - handles quick actions menu state and touch handlers
+ // Long-press menu hook - opens the command palette on long-press of send button
const {
- isMenuOpen: quickActionsOpen,
- menuAnchor: quickActionsAnchor,
sendButtonRef,
handleTouchStart: handleSendButtonTouchStart,
handleTouchEnd: handleSendButtonTouchEnd,
handleTouchMove: handleSendButtonTouchMove,
- handleQuickAction,
- closeMenu: handleCloseQuickActions,
} = useLongPressMenu({
inputMode,
onModeToggle,
disabled: isDisabled,
value,
+ onOpenCommandPalette,
});
// Separate flag for whether send is blocked (AI thinking)
@@ -918,16 +914,6 @@ export function CommandInputBar({
}
`}
-
- {/* Quick actions menu - shown on long-press of send button */}
-
);
}
diff --git a/src/web/mobile/ContextManagementSheet.tsx b/src/web/mobile/ContextManagementSheet.tsx
new file mode 100644
index 0000000000..70910c70a1
--- /dev/null
+++ b/src/web/mobile/ContextManagementSheet.tsx
@@ -0,0 +1,753 @@
+/**
+ * ContextManagementSheet component for Maestro mobile web interface
+ *
+ * Bottom sheet modal for context management operations:
+ * merge, transfer, and summarize agent contexts.
+ */
+
+import { useState, useCallback, useEffect, useRef } from 'react';
+import { useThemeColors } from '../components/ThemeProvider';
+import { triggerHaptic, HAPTIC_PATTERNS } from './constants';
+import type { Session } from '../hooks/useSessions';
+
+type ContextOperation = 'merge' | 'transfer' | 'summarize' | null;
+
+interface OperationDef {
+ id: ContextOperation & string;
+ icon: string;
+ label: string;
+ description: string;
+}
+
+const OPERATIONS: OperationDef[] = [
+ {
+ id: 'merge',
+ icon: '\u{1F500}',
+ label: 'Merge',
+ description: 'Combine context from two agents',
+ },
+ {
+ id: 'transfer',
+ icon: '\u{1F4E4}',
+ label: 'Transfer',
+ description: 'Send context to another agent',
+ },
+ {
+ id: 'summarize',
+ icon: '\u{1F4DD}',
+ label: 'Summarize',
+ description: "Compress current agent's context",
+ },
+];
+
+type ExecutionState = 'idle' | 'executing' | 'success' | 'failure';
+
+export interface ContextManagementSheetProps {
+ sessions: Session[];
+ currentSessionId: string;
+ onClose: () => void;
+ sendRequest: (
+ type: string,
+ payload?: Record,
+ timeoutMs?: number
+ ) => Promise;
+}
+
+export function ContextManagementSheet({
+ sessions,
+ currentSessionId,
+ onClose,
+ sendRequest,
+}: ContextManagementSheetProps) {
+ const colors = useThemeColors();
+ const [isVisible, setIsVisible] = useState(false);
+ const [selectedOp, setSelectedOp] = useState(null);
+ const [sourceId, setSourceId] = useState(currentSessionId);
+ const [targetId, setTargetId] = useState('');
+ const [executionState, setExecutionState] = useState('idle');
+ const [progress, setProgress] = useState(0);
+ const [resultMessage, setResultMessage] = useState('');
+ const autoCloseTimerRef = useRef>();
+
+ const handleClose = useCallback(() => {
+ triggerHaptic(HAPTIC_PATTERNS.tap);
+ setIsVisible(false);
+ setTimeout(() => onClose(), 300);
+ }, [onClose]);
+
+ // Animate in on mount
+ useEffect(() => {
+ requestAnimationFrame(() => setIsVisible(true));
+ }, []);
+
+ // Close on escape key (but not during execution)
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape' && executionState !== 'executing') {
+ handleClose();
+ }
+ };
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [handleClose, executionState]);
+
+ // Cleanup timers on unmount
+ useEffect(() => {
+ return () => {
+ if (autoCloseTimerRef.current) {
+ clearTimeout(autoCloseTimerRef.current);
+ }
+ };
+ }, []);
+
+ const handleBackdropTap = useCallback(
+ (e: React.MouseEvent) => {
+ if (e.target === e.currentTarget && executionState !== 'executing') {
+ handleClose();
+ }
+ },
+ [handleClose, executionState]
+ );
+
+ const handleSelectOperation = useCallback((op: ContextOperation) => {
+ triggerHaptic(HAPTIC_PATTERNS.tap);
+ setSelectedOp(op);
+ // Reset selections when switching
+ setTargetId('');
+ setExecutionState('idle');
+ setResultMessage('');
+ }, []);
+
+ // Pre-select source as current session for transfer
+ useEffect(() => {
+ if (selectedOp === 'transfer') {
+ setSourceId(currentSessionId);
+ }
+ }, [selectedOp, currentSessionId]);
+
+ const otherSessions = sessions.filter((s) => s.id !== sourceId);
+
+ const canExecute = (() => {
+ if (executionState === 'executing') return false;
+ if (!selectedOp) return false;
+ if (selectedOp === 'summarize') return true;
+ if (!targetId) return false;
+ if (sourceId === targetId) return false;
+ return true;
+ })();
+
+ const handleExecute = useCallback(async () => {
+ if (!canExecute || !selectedOp) return;
+ setExecutionState('executing');
+ setProgress(0);
+ triggerHaptic(HAPTIC_PATTERNS.send);
+
+ // Simulate progress while waiting
+ const progressInterval = setInterval(() => {
+ setProgress((prev) => Math.min(prev + 5, 90));
+ }, 500);
+
+ try {
+ let result: { success: boolean };
+ const timeout = selectedOp === 'summarize' ? 60000 : 30000;
+
+ if (selectedOp === 'merge') {
+ result = await sendRequest<{ success: boolean }>(
+ 'merge_context',
+ {
+ sourceSessionId: sourceId,
+ targetSessionId: targetId,
+ },
+ timeout
+ );
+ } else if (selectedOp === 'transfer') {
+ result = await sendRequest<{ success: boolean }>(
+ 'transfer_context',
+ {
+ sourceSessionId: sourceId,
+ targetSessionId: targetId,
+ },
+ timeout
+ );
+ } else {
+ result = await sendRequest<{ success: boolean }>(
+ 'summarize_context',
+ {
+ sessionId: currentSessionId,
+ },
+ timeout
+ );
+ }
+
+ clearInterval(progressInterval);
+ setProgress(100);
+
+ if (result.success) {
+ setExecutionState('success');
+ setResultMessage(
+ `${selectedOp.charAt(0).toUpperCase() + selectedOp.slice(1)} completed successfully`
+ );
+ triggerHaptic(HAPTIC_PATTERNS.success);
+ autoCloseTimerRef.current = setTimeout(() => handleClose(), 2000);
+ } else {
+ setExecutionState('failure');
+ setResultMessage(`${selectedOp.charAt(0).toUpperCase() + selectedOp.slice(1)} failed`);
+ triggerHaptic(HAPTIC_PATTERNS.error);
+ }
+ } catch {
+ clearInterval(progressInterval);
+ setExecutionState('failure');
+ setProgress(0);
+ setResultMessage('Operation failed — check connection');
+ triggerHaptic(HAPTIC_PATTERNS.error);
+ }
+ }, [canExecute, selectedOp, sourceId, targetId, currentSessionId, sendRequest, handleClose]);
+
+ const isExecuting = executionState === 'executing';
+
+ const getSessionLabel = (session: Session) => session.name || session.id.slice(0, 8);
+
+ const getStatusColor = (state: string) => {
+ switch (state) {
+ case 'idle':
+ return colors.success;
+ case 'busy':
+ return colors.warning;
+ case 'error':
+ return colors.error;
+ default:
+ return colors.warning;
+ }
+ };
+
+ return (
+
+ {/* Sheet */}
+
+ {/* Drag handle */}
+
+
+ {/* Header */}
+
+
+ Context Management
+
+
+
+
+ {/* Scrollable content */}
+
+ {/* Operation selector */}
+
+
+ Operation
+
+
+ {OPERATIONS.map((op) => {
+ const isSelected = selectedOp === op.id;
+ return (
+
+ );
+ })}
+
+
+
+ {/* Agent selector (for merge and transfer) */}
+ {selectedOp && selectedOp !== 'summarize' && (
+
+ {/* Source selector */}
+
+
+
+ {sessions.map((session) => {
+ const isSelected = sourceId === session.id;
+ return (
+
+ );
+ })}
+
+
+
+ {/* Target selector */}
+
+
+
+ {otherSessions.length === 0 && (
+
+ No other agents available
+
+ )}
+ {otherSessions.map((session) => {
+ const isSelected = targetId === session.id;
+ return (
+
+ );
+ })}
+
+
+
+ )}
+
+ {/* Summarize info */}
+ {selectedOp === 'summarize' && (
+
+
+ This will compress the context of the current agent
+
+ {' '}
+ {getSessionLabel(sessions.find((s) => s.id === currentSessionId) || sessions[0])}
+ {' '}
+ to reduce token usage while preserving key information.
+
+
+ )}
+
+ {/* Progress indicator */}
+ {isExecuting && (
+
+
+ {selectedOp && selectedOp.charAt(0).toUpperCase() + selectedOp.slice(1)}ing...
+
+
+
+ )}
+
+ {/* Result message */}
+ {resultMessage && !isExecuting && (
+
+ )}
+
+
+ {/* Execute button */}
+
+
+
+
+
+ );
+}
+
+export default ContextManagementSheet;
diff --git a/src/web/mobile/CuePanel.tsx b/src/web/mobile/CuePanel.tsx
new file mode 100644
index 0000000000..afc6168bcb
--- /dev/null
+++ b/src/web/mobile/CuePanel.tsx
@@ -0,0 +1,592 @@
+/**
+ * CuePanel component for Maestro mobile web interface
+ *
+ * Displays a Cue automation dashboard with subscription management
+ * and activity monitoring in a tab-based layout.
+ */
+
+import { useState, useCallback, useMemo } from 'react';
+import { X, RefreshCw, ChevronDown, ChevronRight } from 'lucide-react';
+import { useThemeColors } from '../components/ThemeProvider';
+import type { CueSubscriptionInfo, CueActivityEntry } from '../hooks/useCue';
+
+export interface CuePanelProps {
+ /** Close the panel */
+ onClose: () => void;
+ /** Cue subscriptions */
+ subscriptions: CueSubscriptionInfo[];
+ /** Cue activity entries */
+ activity: CueActivityEntry[];
+ /** Whether data is loading */
+ isLoading: boolean;
+ /** Toggle a subscription */
+ onToggleSubscription: (subscriptionId: string, enabled: boolean) => void;
+ /** Refresh data */
+ onRefresh: () => void;
+}
+
+type Tab = 'subscriptions' | 'activity';
+
+const EVENT_TYPE_COLORS: Record = {
+ file: '#3b82f6',
+ schedule: '#8b5cf6',
+ pr: '#f59e0b',
+ issue: '#ef4444',
+ task: '#10b981',
+ agent_complete: '#6366f1',
+};
+
+function getEventTypeColor(eventType: string): string {
+ return EVENT_TYPE_COLORS[eventType] ?? '#6b7280';
+}
+
+function formatRelativeTime(timestamp: number): string {
+ const now = Date.now();
+ const diff = now - timestamp;
+ const seconds = Math.floor(diff / 1000);
+ if (seconds < 60) return `${seconds}s ago`;
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ return `${days}d ago`;
+}
+
+function formatDuration(ms: number): string {
+ if (ms < 1000) return `${ms}ms`;
+ const seconds = Math.floor(ms / 1000);
+ if (seconds < 60) return `${seconds}s`;
+ const minutes = Math.floor(seconds / 60);
+ const remainingSeconds = seconds % 60;
+ return `${minutes}m ${remainingSeconds}s`;
+}
+
+const STATUS_COLORS: Record = {
+ triggered: { bg: '#fbbf2420', text: '#fbbf24' },
+ running: { bg: '#3b82f620', text: '#3b82f6' },
+ completed: { bg: '#10b98120', text: '#10b981' },
+ failed: { bg: '#ef444420', text: '#ef4444' },
+};
+
+/**
+ * CuePanel component
+ */
+export function CuePanel({
+ onClose,
+ subscriptions,
+ activity,
+ isLoading,
+ onToggleSubscription,
+ onRefresh,
+}: CuePanelProps) {
+ const colors = useThemeColors();
+ const [activeTab, setActiveTab] = useState('subscriptions');
+ const [collapsedGroups, setCollapsedGroups] = useState>(new Set());
+ const [expandedActivities, setExpandedActivities] = useState>(new Set());
+
+ const groupedSubscriptions = useMemo(() => {
+ const groups = new Map();
+ for (const sub of subscriptions) {
+ const key = sub.sessionId;
+ if (!groups.has(key)) {
+ groups.set(key, { sessionName: sub.sessionName, items: [] });
+ }
+ groups.get(key)!.items.push(sub);
+ }
+ return groups;
+ }, [subscriptions]);
+
+ const toggleGroup = useCallback((sessionId: string) => {
+ setCollapsedGroups((prev) => {
+ const next = new Set(prev);
+ if (next.has(sessionId)) {
+ next.delete(sessionId);
+ } else {
+ next.add(sessionId);
+ }
+ return next;
+ });
+ }, []);
+
+ const toggleActivityExpanded = useCallback((activityId: string) => {
+ setExpandedActivities((prev) => {
+ const next = new Set(prev);
+ if (next.has(activityId)) {
+ next.delete(activityId);
+ } else {
+ next.add(activityId);
+ }
+ return next;
+ });
+ }, []);
+
+ const handleTouchRefresh = useCallback(() => {
+ onRefresh();
+ }, [onRefresh]);
+
+ return (
+
+ {/* Header */}
+
+
+ Maestro Cue
+
+
+
+
+
+
+
+ {/* Tab Bar */}
+
+ {(['subscriptions', 'activity'] as Tab[]).map((tab) => (
+
+ ))}
+
+
+ {/* Content */}
+
+ {activeTab === 'subscriptions' ? (
+ subscriptions.length === 0 ? (
+
+ No Cue subscriptions configured
+
+ ) : (
+
+ {Array.from(groupedSubscriptions.entries()).map(([sessionId, group]) => {
+ const isCollapsed = collapsedGroups.has(sessionId);
+ return (
+
+ {/* Session group header */}
+
+
+ {/* Subscription cards */}
+ {!isCollapsed && (
+
+ {group.items.map((sub) => (
+
+ {/* Left content */}
+
+
+
+ {sub.name}
+
+
+ {sub.eventType}
+
+
+
+
+ {sub.lastTriggered
+ ? formatRelativeTime(sub.lastTriggered)
+ : 'Never'}
+
+ {sub.triggerCount > 0 && (
+
+ {sub.triggerCount}x
+
+ )}
+
+
+
+ {/* Toggle switch */}
+
+
+ ))}
+
+ )}
+
+ );
+ })}
+
+ )
+ ) : activity.length === 0 ? (
+
+ No recent Cue activity
+
+ ) : (
+
+ {/* Pull-to-refresh hint */}
+ {isLoading && (
+
+ Refreshing...
+
+ )}
+ {activity.map((entry) => {
+ const statusColor = STATUS_COLORS[entry.status] ?? STATUS_COLORS.triggered;
+ const isExpanded = expandedActivities.has(entry.id);
+ return (
+
+ );
+ })}
+
+ )}
+
+
+ {/* CSS animations */}
+
+
+ );
+}
+
+export default CuePanel;
diff --git a/src/web/mobile/GitDiffViewer.tsx b/src/web/mobile/GitDiffViewer.tsx
new file mode 100644
index 0000000000..5b757d98bb
--- /dev/null
+++ b/src/web/mobile/GitDiffViewer.tsx
@@ -0,0 +1,269 @@
+/**
+ * GitDiffViewer component for Maestro mobile web interface
+ *
+ * Displays a unified diff with line-by-line coloring, line numbers parsed
+ * from @@ hunks, and horizontal scroll for long lines.
+ */
+
+import { useMemo } from 'react';
+import { useThemeColors } from '../components/ThemeProvider';
+import { triggerHaptic, HAPTIC_PATTERNS } from './constants';
+
+export interface GitDiffViewerProps {
+ diff: string;
+ filePath: string;
+ onBack: () => void;
+}
+
+interface DiffLine {
+ content: string;
+ type: 'add' | 'remove' | 'hunk' | 'context';
+ oldNum: string;
+ newNum: string;
+}
+
+/**
+ * Parse a unified diff string into typed lines with line numbers.
+ */
+function parseDiffLines(diff: string): DiffLine[] {
+ const rawLines = diff.split('\n');
+ const result: DiffLine[] = [];
+
+ let oldLine = 0;
+ let newLine = 0;
+
+ for (const line of rawLines) {
+ if (line.startsWith('@@')) {
+ // Parse hunk header: @@ -oldStart,oldCount +newStart,newCount @@
+ const match = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
+ if (match) {
+ oldLine = parseInt(match[1], 10);
+ newLine = parseInt(match[2], 10);
+ }
+ result.push({ content: line, type: 'hunk', oldNum: '', newNum: '' });
+ } else if (line.startsWith('+')) {
+ result.push({ content: line, type: 'add', oldNum: '', newNum: String(newLine) });
+ newLine++;
+ } else if (line.startsWith('-')) {
+ result.push({ content: line, type: 'remove', oldNum: String(oldLine), newNum: '' });
+ oldLine++;
+ } else {
+ // Context line (or diff header lines before first hunk)
+ const isBeforeFirstHunk = oldLine === 0 && newLine === 0;
+ if (isBeforeFirstHunk) {
+ result.push({ content: line, type: 'context', oldNum: '', newNum: '' });
+ } else {
+ result.push({
+ content: line,
+ type: 'context',
+ oldNum: String(oldLine),
+ newNum: String(newLine),
+ });
+ oldLine++;
+ newLine++;
+ }
+ }
+ }
+
+ return result;
+}
+
+export function GitDiffViewer({ diff, filePath, onBack }: GitDiffViewerProps) {
+ const colors = useThemeColors();
+ const lines = useMemo(() => parseDiffLines(diff), [diff]);
+
+ // Determine max line number width for gutter sizing
+ const maxNumWidth = useMemo(() => {
+ let max = 0;
+ for (const line of lines) {
+ const oldLen = line.oldNum.length;
+ const newLen = line.newNum.length;
+ if (oldLen > max) max = oldLen;
+ if (newLen > max) max = newLen;
+ }
+ return Math.max(max, 1);
+ }, [lines]);
+
+ const gutterWidth = `${maxNumWidth}ch`;
+
+ function lineBackground(type: DiffLine['type']): string {
+ switch (type) {
+ case 'add':
+ return `${colors.success}26`;
+ case 'remove':
+ return `${colors.error}26`;
+ case 'hunk':
+ return `${colors.accent}1a`;
+ default:
+ return 'transparent';
+ }
+ }
+
+ function lineColor(type: DiffLine['type']): string {
+ switch (type) {
+ case 'hunk':
+ return colors.accent;
+ default:
+ return colors.textMain;
+ }
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ {filePath}
+
+
+
+ {/* Diff content */}
+
+ {diff.trim() === '' ? (
+
+ No diff available
+
+ ) : (
+
+ {lines.map((line, i) => (
+
+ {/* Line number gutter */}
+
+ {line.oldNum}
+
+
+ {line.newNum}
+
+
+ {/* Line content */}
+ {line.content}
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+export default GitDiffViewer;
diff --git a/src/web/mobile/GitStatusPanel.tsx b/src/web/mobile/GitStatusPanel.tsx
new file mode 100644
index 0000000000..1b889ccf45
--- /dev/null
+++ b/src/web/mobile/GitStatusPanel.tsx
@@ -0,0 +1,454 @@
+/**
+ * GitStatusPanel component for Maestro mobile web interface
+ *
+ * Displays git status for the active session including branch info,
+ * ahead/behind counts, and categorized file lists (staged, modified, untracked).
+ * Tapping a file triggers a diff view.
+ */
+
+import { useState, useCallback, useEffect } from 'react';
+import { useThemeColors } from '../components/ThemeProvider';
+import { triggerHaptic, HAPTIC_PATTERNS } from './constants';
+import type { GitStatusFile, UseGitStatusReturn } from '../hooks/useGitStatus';
+
+/**
+ * Props for GitStatusPanel component
+ */
+export interface GitStatusPanelProps {
+ sessionId: string;
+ gitStatus: UseGitStatusReturn;
+ onViewDiff?: (filePath: string) => void;
+}
+
+/**
+ * Status icon character for git file status codes
+ */
+function statusIcon(status: string): string {
+ switch (status.trim().charAt(0)) {
+ case 'M':
+ return 'M';
+ case 'A':
+ return 'A';
+ case 'D':
+ return 'D';
+ case 'R':
+ return 'R';
+ case 'C':
+ return 'C';
+ case '?':
+ return '?';
+ default:
+ return status.trim().charAt(0) || '?';
+ }
+}
+
+/**
+ * Color for a git file status icon
+ */
+function statusColor(status: string, colors: ReturnType): string {
+ const code = status.trim().charAt(0);
+ switch (code) {
+ case 'M':
+ return colors.warning;
+ case 'A':
+ return colors.success;
+ case 'D':
+ return colors.error;
+ case 'R':
+ return colors.accent;
+ case '?':
+ return colors.textDim;
+ default:
+ return colors.textMain;
+ }
+}
+
+/**
+ * Collapsible file section
+ */
+function FileSection({
+ title,
+ files,
+ accentColor,
+ colors,
+ onFileSelect,
+}: {
+ title: string;
+ files: GitStatusFile[];
+ accentColor: string;
+ colors: ReturnType;
+ onFileSelect: (path: string) => void;
+}) {
+ const [collapsed, setCollapsed] = useState(false);
+
+ if (files.length === 0) return null;
+
+ return (
+
+
+
+ {!collapsed && (
+
+ {files.map((file) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+/**
+ * GitStatusPanel component
+ *
+ * Displays git branch info, ahead/behind badges, and categorized file lists.
+ */
+export function GitStatusPanel({ sessionId, gitStatus, onViewDiff }: GitStatusPanelProps) {
+ const colors = useThemeColors();
+ const { status, isLoading, refresh } = gitStatus;
+
+ // Load status on mount
+ useEffect(() => {
+ if (sessionId) {
+ refresh(sessionId);
+ }
+ }, [sessionId, refresh]);
+
+ const handleRefresh = useCallback(() => {
+ triggerHaptic(HAPTIC_PATTERNS.tap);
+ refresh(sessionId);
+ }, [sessionId, refresh]);
+
+ const handleFileSelect = useCallback(
+ (filePath: string) => {
+ if (onViewDiff) {
+ onViewDiff(filePath);
+ }
+ },
+ [onViewDiff]
+ );
+
+ // Categorize files
+ const staged = status?.files.filter((f) => f.staged) ?? [];
+ const modified =
+ status?.files.filter((f) => !f.staged && f.status.trim().charAt(0) !== '?') ?? [];
+ const untracked =
+ status?.files.filter((f) => !f.staged && f.status.trim().charAt(0) === '?') ?? [];
+
+ const isClean = status !== null && status.files.length === 0;
+
+ return (
+
+ {/* Header: branch info + refresh */}
+
+
+ {/* Branch icon */}
+
+
+
+ {status?.branch || '...'}
+
+
+ {/* Ahead/behind badges */}
+ {status && status.ahead > 0 && (
+
+ ↑{status.ahead}
+
+ )}
+ {status && status.behind > 0 && (
+
+ ↓{status.behind}
+
+ )}
+
+
+ {/* Refresh button */}
+
+
+
+ {/* File list */}
+
+ {isLoading && !status && (
+
+ Loading git status...
+
+ )}
+
+ {isClean && (
+
+
+
+ Working tree clean
+
+
+ )}
+
+ {!isClean && status && (
+ <>
+
+
+
+ >
+ )}
+
+
+ {/* Spin animation for refresh button */}
+
+
+ );
+}
+
+export default GitStatusPanel;
diff --git a/src/web/mobile/GroupChatPanel.tsx b/src/web/mobile/GroupChatPanel.tsx
new file mode 100644
index 0000000000..c21076b03d
--- /dev/null
+++ b/src/web/mobile/GroupChatPanel.tsx
@@ -0,0 +1,464 @@
+/**
+ * GroupChatPanel component for Maestro mobile web interface
+ *
+ * Displays a group chat conversation with participant bar, message bubbles,
+ * and input area for multi-agent group chat sessions.
+ */
+
+import { useState, useRef, useEffect, useCallback } from 'react';
+import { ArrowLeft, Square, Send } from 'lucide-react';
+import { useThemeColors } from '../components/ThemeProvider';
+import { MobileMarkdownRenderer } from './MobileMarkdownRenderer';
+import type { GroupChatState, GroupChatMessage } from '../hooks/useWebSocket';
+
+export interface GroupChatPanelProps {
+ /** Current group chat state */
+ chatState: GroupChatState;
+ /** Send a message to the group */
+ onSendMessage: (message: string) => void;
+ /** Stop the group chat */
+ onStop: () => void;
+ /** Navigate back */
+ onBack: () => void;
+}
+
+/**
+ * Format timestamp for display
+ */
+function formatTime(timestamp: number): string {
+ const date = new Date(timestamp);
+ const now = new Date();
+ const isToday = date.toDateString() === now.toDateString();
+
+ if (isToday) {
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ }
+ return (
+ date.toLocaleDateString([], { month: 'short', day: 'numeric' }) +
+ ' ' +
+ date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+ );
+}
+
+/**
+ * Generate a consistent color for a participant based on their ID
+ */
+function getParticipantColor(id: string): string {
+ const hues = [210, 150, 330, 30, 270, 60, 180, 300, 0, 120];
+ let hash = 0;
+ for (let i = 0; i < id.length; i++) {
+ hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0;
+ }
+ const hue = hues[Math.abs(hash) % hues.length];
+ return `hsl(${hue}, 60%, 50%)`;
+}
+
+/**
+ * GroupChatPanel component
+ */
+export function GroupChatPanel({ chatState, onSendMessage, onStop, onBack }: GroupChatPanelProps) {
+ const colors = useThemeColors();
+ const [inputValue, setInputValue] = useState('');
+ const messagesEndRef = useRef(null);
+ const inputRef = useRef(null);
+
+ // Auto-scroll to bottom when new messages arrive
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [chatState.messages.length]);
+
+ const handleSend = useCallback(() => {
+ const trimmed = inputValue.trim();
+ if (!trimmed || !chatState.isActive) return;
+ onSendMessage(trimmed);
+ setInputValue('');
+ inputRef.current?.focus();
+ }, [inputValue, chatState.isActive, onSendMessage]);
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSend();
+ }
+ },
+ [handleSend]
+ );
+
+ const currentTurnParticipant = chatState.currentTurn
+ ? chatState.participants.find((p) => p.sessionId === chatState.currentTurn)
+ : null;
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+ {chatState.topic}
+
+
+ {chatState.participants.length} participants
+
+
+
+ {chatState.isActive && (
+
+ )}
+
+
+ {/* Participant bar */}
+
+ {chatState.participants.map((participant) => {
+ const isCurrentTurn = chatState.currentTurn === participant.sessionId;
+ const participantColor = getParticipantColor(participant.sessionId);
+
+ return (
+
+
+
+ {participant.name}
+
+
+ );
+ })}
+
+
+ {/* Chat ended banner */}
+ {!chatState.isActive && (
+
+ Chat ended
+
+ )}
+
+ {/* Messages area */}
+
+ {chatState.messages.length === 0 && (
+
+ No messages yet. Start the conversation!
+
+ )}
+
+ {chatState.messages.map((message) => (
+
+ ))}
+
+ {/* Thinking indicator */}
+ {chatState.isActive && currentTurnParticipant && (
+
+
+ {currentTurnParticipant.name.charAt(0).toUpperCase()}
+
+
+ {currentTurnParticipant.name} is thinking...
+
+
+ )}
+
+
+
+
+ {/* Input area */}
+
+ setInputValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder="Send a message to the group..."
+ disabled={!chatState.isActive}
+ style={{
+ flex: 1,
+ padding: '10px 14px',
+ borderRadius: 8,
+ border: `1px solid ${colors.border}`,
+ backgroundColor: chatState.isActive ? colors.bgSidebar : `${colors.textDim}10`,
+ color: colors.textMain,
+ fontSize: 14,
+ outline: 'none',
+ opacity: chatState.isActive ? 1 : 0.5,
+ }}
+ />
+
+
+
+ {/* Pulse animation for current turn indicator */}
+
+
+ );
+}
+
+/**
+ * Individual message bubble component
+ */
+function MessageBubble({
+ message,
+ colors,
+}: {
+ message: GroupChatMessage;
+ colors: ReturnType;
+}) {
+ const isUser = message.role === 'user';
+ const participantColor = getParticipantColor(message.participantId);
+
+ return (
+
+ {/* Participant header */}
+
+
+ {message.participantName.charAt(0).toUpperCase()}
+
+
+ {message.participantName}
+
+
{formatTime(message.timestamp)}
+
+
+ {/* Message content */}
+
+
+
+
+ );
+}
+
+export default GroupChatPanel;
diff --git a/src/web/mobile/GroupChatSetupSheet.tsx b/src/web/mobile/GroupChatSetupSheet.tsx
new file mode 100644
index 0000000000..0cd713e3ed
--- /dev/null
+++ b/src/web/mobile/GroupChatSetupSheet.tsx
@@ -0,0 +1,410 @@
+/**
+ * GroupChatSetupSheet component for Maestro mobile web interface
+ *
+ * Bottom sheet modal for starting a new group chat.
+ * Allows selecting a topic and choosing participants from available agents.
+ */
+
+import { useState, useCallback, useEffect, useRef } from 'react';
+import { useThemeColors } from '../components/ThemeProvider';
+import { triggerHaptic, HAPTIC_PATTERNS } from './constants';
+import type { Session } from '../hooks/useSessions';
+
+export interface GroupChatSetupSheetProps {
+ sessions: Session[];
+ onStart: (topic: string, participantIds: string[]) => void;
+ onClose: () => void;
+}
+
+export function GroupChatSetupSheet({ sessions, onStart, onClose }: GroupChatSetupSheetProps) {
+ const colors = useThemeColors();
+ const [topic, setTopic] = useState('');
+ const [selectedIds, setSelectedIds] = useState>(new Set());
+ const [isVisible, setIsVisible] = useState(false);
+ const topicInputRef = useRef(null);
+
+ const handleClose = useCallback(() => {
+ triggerHaptic(HAPTIC_PATTERNS.tap);
+ setIsVisible(false);
+ setTimeout(() => onClose(), 300);
+ }, [onClose]);
+
+ // Animate in on mount
+ useEffect(() => {
+ requestAnimationFrame(() => setIsVisible(true));
+ }, []);
+
+ // Close on escape key
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ handleClose();
+ }
+ };
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [handleClose]);
+
+ const handleBackdropTap = useCallback(
+ (e: React.MouseEvent) => {
+ if (e.target === e.currentTarget) {
+ handleClose();
+ }
+ },
+ [handleClose]
+ );
+
+ const toggleParticipant = useCallback((sessionId: string) => {
+ triggerHaptic(HAPTIC_PATTERNS.tap);
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(sessionId)) {
+ next.delete(sessionId);
+ } else {
+ next.add(sessionId);
+ }
+ return next;
+ });
+ }, []);
+
+ const canStart = topic.trim().length > 0 && selectedIds.size >= 2;
+
+ const handleStart = useCallback(() => {
+ if (!canStart) return;
+ triggerHaptic(HAPTIC_PATTERNS.send);
+ onStart(topic.trim(), Array.from(selectedIds));
+ handleClose();
+ }, [canStart, topic, selectedIds, onStart, handleClose]);
+
+ return (
+
+ {/* Sheet */}
+
+ {/* Drag handle */}
+
+
+ {/* Header */}
+
+
+ Start Group Chat
+
+
+
+
+ {/* Scrollable content */}
+
+ {/* Topic input */}
+
+
+ setTopic(e.target.value)}
+ placeholder="What should the agents discuss?"
+ style={{
+ width: '100%',
+ padding: '12px 14px',
+ borderRadius: '10px',
+ border: `1px solid ${colors.border}`,
+ backgroundColor: colors.bgSidebar,
+ color: colors.textMain,
+ fontSize: '14px',
+ outline: 'none',
+ WebkitAppearance: 'none',
+ boxSizing: 'border-box',
+ minHeight: '44px',
+ }}
+ onFocus={(e) => {
+ (e.target as HTMLInputElement).style.borderColor = colors.accent;
+ }}
+ onBlur={(e) => {
+ (e.target as HTMLInputElement).style.borderColor = colors.border;
+ }}
+ />
+
+
+ {/* Participant selector */}
+
+
+
+ {selectedIds.size} agent{selectedIds.size !== 1 ? 's' : ''} selected
+ {selectedIds.size < 2 && ' — select at least 2'}
+
+
+ {sessions.map((session) => {
+ const isSelected = selectedIds.has(session.id);
+ return (
+
+ );
+ })}
+
+ {sessions.length === 0 && (
+
+ No agents available
+
+ )}
+
+
+
+
+ {/* Start button */}
+
+
+
+
+
+ );
+}
+
+export default GroupChatSetupSheet;
diff --git a/src/web/mobile/NotificationSettingsSheet.tsx b/src/web/mobile/NotificationSettingsSheet.tsx
new file mode 100644
index 0000000000..e4cc522e9c
--- /dev/null
+++ b/src/web/mobile/NotificationSettingsSheet.tsx
@@ -0,0 +1,493 @@
+/**
+ * NotificationSettingsSheet component for Maestro mobile web interface
+ *
+ * Bottom sheet modal for configuring push notification preferences.
+ * Allows toggling individual notification event types and sound.
+ */
+
+import { useState, useCallback, useEffect, useRef } from 'react';
+import { useThemeColors } from '../components/ThemeProvider';
+import { triggerHaptic, HAPTIC_PATTERNS } from './constants';
+import type { NotificationPreferences, NotificationPermission } from '../hooks/useNotifications';
+
+/**
+ * Props for NotificationSettingsSheet component
+ */
+export interface NotificationSettingsSheetProps {
+ preferences: NotificationPreferences;
+ onPreferencesChange: (prefs: Partial) => void;
+ permission: NotificationPermission;
+ onClose: () => void;
+}
+
+/**
+ * Toggle item configuration
+ */
+interface ToggleItem {
+ key: keyof NotificationPreferences;
+ label: string;
+ description: string;
+}
+
+const EVENT_TOGGLES: ToggleItem[] = [
+ { key: 'agentComplete', label: 'Agent Complete', description: 'When an agent finishes thinking' },
+ { key: 'agentError', label: 'Agent Error', description: 'When an agent encounters an error' },
+ {
+ key: 'autoRunComplete',
+ label: 'Auto Run Complete',
+ description: 'When Auto Run finishes all documents',
+ },
+ {
+ key: 'autoRunTaskComplete',
+ label: 'Auto Run Task Complete',
+ description: 'When each individual task completes',
+ },
+ {
+ key: 'contextWarning',
+ label: 'Context Warning',
+ description: 'When context window is running low',
+ },
+];
+
+/**
+ * Permission status badge colors
+ */
+function getPermissionBadge(permission: NotificationPermission): {
+ label: string;
+ color: string;
+ bgColor: string;
+} {
+ switch (permission) {
+ case 'granted':
+ return { label: 'Enabled', color: '#22c55e', bgColor: 'rgba(34, 197, 94, 0.15)' };
+ case 'denied':
+ return { label: 'Blocked', color: '#ef4444', bgColor: 'rgba(239, 68, 68, 0.15)' };
+ default:
+ return { label: 'Not Set', color: '#f59e0b', bgColor: 'rgba(245, 158, 11, 0.15)' };
+ }
+}
+
+/**
+ * NotificationSettingsSheet component
+ *
+ * Bottom sheet modal that slides up from the bottom of the screen.
+ * Provides notification permission management and per-event-type toggles.
+ */
+export function NotificationSettingsSheet({
+ preferences,
+ onPreferencesChange,
+ permission,
+ onClose,
+}: NotificationSettingsSheetProps) {
+ const colors = useThemeColors();
+ const [isVisible, setIsVisible] = useState(false);
+ const sheetRef = useRef(null);
+
+ const handleClose = useCallback(() => {
+ triggerHaptic(HAPTIC_PATTERNS.tap);
+ setIsVisible(false);
+ setTimeout(() => onClose(), 300);
+ }, [onClose]);
+
+ // Animate in on mount
+ useEffect(() => {
+ requestAnimationFrame(() => setIsVisible(true));
+ }, []);
+
+ // Close on escape key
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ handleClose();
+ }
+ };
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [handleClose]);
+
+ const handleBackdropTap = useCallback(
+ (e: React.MouseEvent) => {
+ if (e.target === e.currentTarget) {
+ handleClose();
+ }
+ },
+ [handleClose]
+ );
+
+ const handleToggle = useCallback(
+ (key: keyof NotificationPreferences) => {
+ triggerHaptic(HAPTIC_PATTERNS.tap);
+ onPreferencesChange({ [key]: !preferences[key] });
+ },
+ [preferences, onPreferencesChange]
+ );
+
+ const handleRequestPermission = useCallback(async () => {
+ triggerHaptic(HAPTIC_PATTERNS.tap);
+ if ('Notification' in window) {
+ await Notification.requestPermission();
+ }
+ }, []);
+
+ const badge = getPermissionBadge(permission);
+
+ return (
+
+ {/* Sheet */}
+
+ {/* Drag handle */}
+
+
+ {/* Header */}
+
+
+ Notification Settings
+
+
+
+
+ {/* Scrollable content */}
+
+ {/* Permission status section */}
+
+
+ Permission
+
+
+
+ {/* Bell icon */}
+
+
+ Browser Notifications
+
+
+ {/* Status badge */}
+
+ {badge.label}
+
+
+
+ {permission !== 'granted' && (
+
+ )}
+
+
+ {/* Event toggles section */}
+
+
+ Events
+
+
+ {EVENT_TOGGLES.map((toggle) => (
+
+ ))}
+
+
+
+ {/* Sound toggle section */}
+
+
+ Sound
+
+
+
+
+
+
+ );
+}
+
+export default NotificationSettingsSheet;
diff --git a/src/web/mobile/QuickActionsMenu.tsx b/src/web/mobile/QuickActionsMenu.tsx
index e4f3aa02e5..0ccb369201 100644
--- a/src/web/mobile/QuickActionsMenu.tsx
+++ b/src/web/mobile/QuickActionsMenu.tsx
@@ -1,170 +1,236 @@
/**
- * QuickActionsMenu - Popup menu shown on long-press of send button
- *
- * Displays quick action for mode switching:
- * - Switch to terminal/AI mode
+ * QuickActionsMenu - Full command palette for the mobile web interface
*
* Features:
- * - Appears above the send button on long-press
+ * - Search input with real-time filtering
+ * - Actions organized by category with section headers
+ * - Keyboard navigation (Arrow keys, Enter, Escape)
+ * - Recent actions tracked in localStorage
* - Touch-friendly hit targets (minimum 44pt)
* - Animated appearance with scale/opacity
- * - Haptic feedback on selection
- * - Dismisses on outside tap or action selection
* - Accessible with proper ARIA roles
*/
-import React, { useEffect, useRef } from 'react';
+import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useThemeColors } from '../components/ThemeProvider';
import { MIN_TOUCH_TARGET } from './constants';
-export type QuickAction = 'switch_mode';
+/** Represents a single action in the command palette */
+export interface CommandPaletteAction {
+ id: string;
+ label: string;
+ category: string;
+ icon: React.ReactNode;
+ shortcut?: string;
+ action: () => void;
+ available?: () => boolean;
+}
+
+/** Category display order */
+const CATEGORY_ORDER = ['Navigation', 'Agent', 'Auto Run', 'Group Chat', 'Cue', 'Settings', 'View'];
+
+/** localStorage key for recent actions */
+const RECENT_ACTIONS_KEY = 'maestro-command-palette-recent';
+const MAX_RECENT_ACTIONS = 5;
export interface QuickActionsMenuProps {
/** Whether the menu is visible */
isOpen: boolean;
/** Callback when the menu should close */
onClose: () => void;
- /** Callback when an action is selected */
- onSelectAction: (action: QuickAction) => void;
- /** Current input mode (to display correct switch text) */
- inputMode: 'ai' | 'terminal';
- /** Position coordinates for the menu (relative to viewport) */
- anchorPosition: { x: number; y: number } | null;
- /** Whether a session is selected (disable actions if not) */
- hasActiveSession: boolean;
+ /** Available actions to display */
+ actions: CommandPaletteAction[];
+}
+
+/** Load recent action IDs from localStorage */
+function loadRecentActions(): string[] {
+ try {
+ const stored = localStorage.getItem(RECENT_ACTIONS_KEY);
+ if (stored) {
+ const parsed = JSON.parse(stored);
+ if (Array.isArray(parsed)) return parsed.slice(0, MAX_RECENT_ACTIONS);
+ }
+ } catch {
+ // Ignore parse errors
+ }
+ return [];
+}
+
+/** Save a used action to recent actions */
+function saveRecentAction(actionId: string): void {
+ try {
+ const recent = loadRecentActions().filter((id) => id !== actionId);
+ recent.unshift(actionId);
+ localStorage.setItem(RECENT_ACTIONS_KEY, JSON.stringify(recent.slice(0, MAX_RECENT_ACTIONS)));
+ } catch {
+ // Ignore storage errors
+ }
}
/**
* QuickActionsMenu component
*
- * A floating menu that appears on long-press of the send button,
- * providing quick access to common session actions.
+ * A full-screen command palette providing quick access to all app actions.
*/
-export function QuickActionsMenu({
- isOpen,
- onClose,
- onSelectAction,
- inputMode,
- anchorPosition,
- hasActiveSession,
-}: QuickActionsMenuProps) {
+export function QuickActionsMenu({ isOpen, onClose, actions }: QuickActionsMenuProps) {
const colors = useThemeColors();
const menuRef = useRef(null);
+ const searchRef = useRef(null);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [selectedIndex, setSelectedIndex] = useState(0);
+ const itemRefs = useRef