Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 141 additions & 3 deletions src/__tests__/renderer/components/SettingsModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ vi.mock('../../../renderer/hooks/settings/useSettings', () => ({
setWakatimeEnabled: vi.fn(),
wakatimeApiKey: '',
setWakatimeApiKey: vi.fn(),
// Symphony registry URLs
symphonyRegistryUrls: [],
setSymphonyRegistryUrls: vi.fn(),
...mockUseSettingsOverrides,
}),
}));
Expand Down Expand Up @@ -258,7 +261,7 @@ const createDefaultProps = (overrides = {}) => ({
setCrashReportingEnabled: vi.fn(),
customAICommands: [],
setCustomAICommands: vi.fn(),
encoreFeatures: { directorNotes: false },
encoreFeatures: { directorNotes: false, usageStats: true, symphony: true },
setEncoreFeatures: mockSetEncoreFeatures,
...overrides,
});
Expand Down Expand Up @@ -2237,13 +2240,15 @@ describe('SettingsModal', () => {

expect(mockSetEncoreFeatures).toHaveBeenCalledWith({
directorNotes: true,
usageStats: true,
symphony: true,
});
});

it('should call setEncoreFeatures with false when toggling DN off', async () => {
mockSetEncoreFeatures.mockClear();

render(<SettingsModal {...createDefaultProps({ encoreFeatures: { directorNotes: true } })} />);
render(<SettingsModal {...createDefaultProps({ encoreFeatures: { directorNotes: true, usageStats: true, symphony: true } })} />);

await act(async () => {
await vi.advanceTimersByTimeAsync(50);
Expand All @@ -2261,11 +2266,144 @@ describe('SettingsModal', () => {

expect(mockSetEncoreFeatures).toHaveBeenCalledWith({
directorNotes: false,
usageStats: true,
symphony: true,
});
});

it('should show Usage & Stats feature toggle defaulting to on', async () => {
render(<SettingsModal {...createDefaultProps()} />);

await act(async () => {
await vi.advanceTimersByTimeAsync(50);
});

fireEvent.click(screen.getByTitle('Encore Features'));

await act(async () => {
await vi.advanceTimersByTimeAsync(50);
});

expect(screen.getByText('Usage & Stats')).toBeInTheDocument();
// Settings should be visible when enabled (default on)
expect(screen.getByText('Enable stats collection')).toBeInTheDocument();
});

it('should call setEncoreFeatures when Usage & Stats toggle is clicked off', async () => {
mockSetEncoreFeatures.mockClear();

render(<SettingsModal {...createDefaultProps()} />);

await act(async () => {
await vi.advanceTimersByTimeAsync(50);
});

fireEvent.click(screen.getByTitle('Encore Features'));

await act(async () => {
await vi.advanceTimersByTimeAsync(50);
});

const usSection = screen.getByText('Usage & Stats').closest('button');
expect(usSection).toBeInTheDocument();
fireEvent.click(usSection!);

expect(mockSetEncoreFeatures).toHaveBeenCalledWith({
directorNotes: false,
usageStats: false,
symphony: true,
});
});

it('should show Maestro Symphony feature toggle defaulting to on', async () => {
render(<SettingsModal {...createDefaultProps()} />);

await act(async () => {
await vi.advanceTimersByTimeAsync(50);
});

fireEvent.click(screen.getByTitle('Encore Features'));

await act(async () => {
await vi.advanceTimersByTimeAsync(50);
});

expect(screen.getByText('Maestro Symphony')).toBeInTheDocument();
// Settings should be visible when enabled (default on)
expect(screen.getByText('Registry Sources')).toBeInTheDocument();
});

it('should call setEncoreFeatures when Symphony toggle is clicked off', async () => {
mockSetEncoreFeatures.mockClear();

render(<SettingsModal {...createDefaultProps()} />);

await act(async () => {
await vi.advanceTimersByTimeAsync(50);
});

fireEvent.click(screen.getByTitle('Encore Features'));

await act(async () => {
await vi.advanceTimersByTimeAsync(50);
});

const symphonySection = screen.getByText('Maestro Symphony').closest('button');
expect(symphonySection).toBeInTheDocument();
fireEvent.click(symphonySection!);

expect(mockSetEncoreFeatures).toHaveBeenCalledWith({
directorNotes: false,
usageStats: true,
symphony: false,
});
});

it('should call setEncoreFeatures when Symphony toggle is clicked on', async () => {
mockSetEncoreFeatures.mockClear();

render(<SettingsModal {...createDefaultProps({ encoreFeatures: { directorNotes: false, usageStats: true, symphony: false } })} />);

await act(async () => {
await vi.advanceTimersByTimeAsync(50);
});

fireEvent.click(screen.getByTitle('Encore Features'));

await act(async () => {
await vi.advanceTimersByTimeAsync(50);
});

const symphonySection = screen.getByText('Maestro Symphony').closest('button');
expect(symphonySection).toBeInTheDocument();
fireEvent.click(symphonySection!);

expect(mockSetEncoreFeatures).toHaveBeenCalledWith({
directorNotes: false,
usageStats: true,
symphony: true,
});
});

it('should hide Symphony registry settings when symphony is disabled', async () => {
render(<SettingsModal {...createDefaultProps({ encoreFeatures: { directorNotes: false, usageStats: true, symphony: false } })} />);

await act(async () => {
await vi.advanceTimersByTimeAsync(50);
});

fireEvent.click(screen.getByTitle('Encore Features'));

await act(async () => {
await vi.advanceTimersByTimeAsync(50);
});

expect(screen.getByText('Maestro Symphony')).toBeInTheDocument();
expect(screen.queryByText('Registry Sources')).not.toBeInTheDocument();
});

describe('with Director\'s Notes enabled', () => {
const dnEnabledProps = { encoreFeatures: { directorNotes: true } };
const dnEnabledProps = { encoreFeatures: { directorNotes: true, usageStats: true, symphony: true } };

it('should render provider dropdown with detected available agents', async () => {
render(<SettingsModal {...createDefaultProps(dnEnabledProps)} />);
Expand Down
1 change: 1 addition & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,7 @@ function setupIpcHandlers() {
app,
getMainWindow: () => mainWindow,
sessionsStore,
settingsStore: store,
});

// Register tab naming handlers for automatic tab naming
Expand Down
1 change: 1 addition & 0 deletions src/main/ipc/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ export function registerAllHandlers(deps: HandlerDependencies): void {
app: deps.app,
getMainWindow: deps.getMainWindow,
sessionsStore: deps.sessionsStore,
settingsStore: deps.settingsStore,
});
// Register agent error handlers (error state management)
registerAgentErrorHandlers();
Expand Down
77 changes: 54 additions & 23 deletions src/main/ipc/handlers/symphony.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ export interface SymphonyHandlerDependencies {
app: App;
getMainWindow: () => BrowserWindow | null;
sessionsStore: Store<SessionsData>;
settingsStore: Store;
}

// ============================================================================
Expand Down Expand Up @@ -379,39 +380,67 @@ function parseDocumentPaths(body: string): DocumentReference[] {
// ============================================================================

/**
* Fetch the symphony registry from GitHub.
* Fetch a single symphony registry from a URL.
* Returns null on failure instead of throwing (isolated error handling per URL).
*/
async function fetchRegistry(): Promise<SymphonyRegistry> {
logger.info('Fetching Symphony registry', LOG_CONTEXT);

async function fetchSingleRegistry(url: string): Promise<SymphonyRegistry | null> {
try {
const response = await fetch(SYMPHONY_REGISTRY_URL);

const response = await fetch(url);
if (!response.ok) {
throw new SymphonyError(
`Failed to fetch registry: ${response.status} ${response.statusText}`,
'network'
);
logger.warn(`Failed to fetch registry from ${url}: ${response.status}`, LOG_CONTEXT);
return null;
}

const data = (await response.json()) as SymphonyRegistry;

if (!data.repositories || !Array.isArray(data.repositories)) {
throw new SymphonyError('Invalid registry structure', 'parse');
logger.warn(`Invalid registry structure from ${url}`, LOG_CONTEXT);
return null;
}

logger.info(`Fetched registry with ${data.repositories.length} repos`, LOG_CONTEXT);
logger.info(`Fetched ${data.repositories.length} repos from ${url}`, LOG_CONTEXT);
return data;
} catch (error) {
if (error instanceof SymphonyError) throw error;
throw new SymphonyError(
`Network error: ${error instanceof Error ? error.message : String(error)}`,
'network',
error
);
logger.warn(`Network error fetching registry from ${url}: ${error instanceof Error ? error.message : String(error)}`, LOG_CONTEXT);
return null;
}
}

/**
* Fetch and merge symphony registries from all configured URLs.
* Default URL always fetched first (wins on slug conflicts).
* Custom URL failures are isolated — other registries still load.
*/
async function fetchRegistries(customUrls: string[]): Promise<SymphonyRegistry> {
logger.info(`Fetching Symphony registries (1 default + ${customUrls.length} custom)`, LOG_CONTEXT);

const allUrls = [SYMPHONY_REGISTRY_URL, ...customUrls];
const results = await Promise.allSettled(allUrls.map(fetchSingleRegistry));

const seenSlugs = new Set<string>();
const mergedRepos: SymphonyRegistry['repositories'] = [];

for (const result of results) {
if (result.status === 'fulfilled' && result.value) {
for (const repo of result.value.repositories) {
if (!seenSlugs.has(repo.slug)) {
seenSlugs.add(repo.slug);
mergedRepos.push(repo);
}
}
}
}

if (mergedRepos.length === 0) {
throw new SymphonyError('Failed to fetch registry from all configured URLs', 'network');
}

logger.info(`Merged registry: ${mergedRepos.length} repos from ${allUrls.length} sources`, LOG_CONTEXT);

return {
schemaVersion: '1.0',
lastUpdated: new Date().toISOString(),
repositories: mergedRepos,
};
}

/**
* Fetch GitHub star counts for multiple repositories.
* Uses concurrent requests with a concurrency limit to stay within rate limits.
Expand Down Expand Up @@ -957,6 +986,7 @@ export function registerSymphonyHandlers({
app,
getMainWindow,
sessionsStore,
settingsStore,
}: SymphonyHandlerDependencies): void {
// ─────────────────────────────────────────────────────────────────────────
// Registry Operations
Expand Down Expand Up @@ -1045,9 +1075,10 @@ export function registerSymphonyHandlers({
};
}

// Fetch fresh data
// Fetch fresh data from all configured registries
try {
const registry = await fetchRegistry();
const customUrls = (settingsStore.get('symphonyRegistryUrls') as string[] | undefined) ?? [];
const registry = await fetchRegistries(customUrls);
const enriched = await enrichWithStars(registry, cache, !!forceRefresh);

// Update cache (enriched registry includes stars on repo objects,
Expand Down
10 changes: 5 additions & 5 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11494,7 +11494,7 @@ You are taking over this conversation. Based on the context above, provide a bri
setLogViewerOpen,
setProcessMonitorOpen,
setUsageDashboardOpen,
setSymphonyModalOpen,
setSymphonyModalOpen: encoreFeatures.symphony ? setSymphonyModalOpen : undefined,
setDirectorNotesOpen: encoreFeatures.directorNotes ? setDirectorNotesOpen : undefined,
setGroups,
setSessions,
Expand Down Expand Up @@ -11777,7 +11777,7 @@ You are taking over this conversation. Based on the context above, provide a bri
onCloseProcessMonitor={handleCloseProcessMonitor}
onNavigateToSession={handleProcessMonitorNavigateToSession}
onNavigateToGroupChat={handleProcessMonitorNavigateToGroupChat}
usageDashboardOpen={usageDashboardOpen}
usageDashboardOpen={encoreFeatures.usageStats && usageDashboardOpen}
onCloseUsageDashboard={() => setUsageDashboardOpen(false)}
defaultStatsTimeRange={defaultStatsTimeRange}
colorBlindMode={colorBlindMode}
Expand Down Expand Up @@ -11866,7 +11866,7 @@ You are taking over this conversation. Based on the context above, provide a bri
setAboutModalOpen={setAboutModalOpen}
setLogViewerOpen={setLogViewerOpen}
setProcessMonitorOpen={setProcessMonitorOpen}
setUsageDashboardOpen={setUsageDashboardOpen}
setUsageDashboardOpen={encoreFeatures.usageStats ? setUsageDashboardOpen : undefined}
setActiveRightTab={setActiveRightTab}
setAgentSessionsOpen={setAgentSessionsOpen}
setActiveAgentSessionId={setActiveAgentSessionId}
Expand Down Expand Up @@ -11945,7 +11945,7 @@ You are taking over this conversation. Based on the context above, provide a bri
getDocumentTaskCount={getDocumentTaskCount}
onAutoRunRefresh={handleAutoRunRefresh}
onOpenMarketplace={handleOpenMarketplace}
onOpenSymphony={() => setSymphonyModalOpen(true)}
onOpenSymphony={encoreFeatures.symphony ? () => setSymphonyModalOpen(true) : undefined}
onOpenDirectorNotes={encoreFeatures.directorNotes ? () => setDirectorNotesOpen(true) : undefined}
tabSwitcherOpen={tabSwitcherOpen}
onCloseTabSwitcher={handleCloseTabSwitcher}
Expand Down Expand Up @@ -12118,7 +12118,7 @@ You are taking over this conversation. Based on the context above, provide a bri
)}

{/* --- SYMPHONY MODAL (lazy-loaded) --- */}
{symphonyModalOpen && (
{encoreFeatures.symphony && symphonyModalOpen && (
<Suspense fallback={null}>
<SymphonyModal
theme={theme}
Expand Down
4 changes: 2 additions & 2 deletions src/renderer/components/AppModals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -782,7 +782,7 @@ export interface AppUtilityModalsProps {
setAboutModalOpen: (open: boolean) => void;
setLogViewerOpen: (open: boolean) => void;
setProcessMonitorOpen: (open: boolean) => void;
setUsageDashboardOpen: (open: boolean) => void;
setUsageDashboardOpen?: (open: boolean) => void;
setActiveRightTab: (tab: RightPanelTab) => void;
setAgentSessionsOpen: (open: boolean) => void;
setActiveAgentSessionId: (id: string | null) => void;
Expand Down Expand Up @@ -1894,7 +1894,7 @@ export interface AppModalsProps {
setAboutModalOpen: (open: boolean) => void;
setLogViewerOpen: (open: boolean) => void;
setProcessMonitorOpen: (open: boolean) => void;
setUsageDashboardOpen: (open: boolean) => void;
setUsageDashboardOpen?: (open: boolean) => void;
setActiveRightTab: (tab: RightPanelTab) => void;
setAgentSessionsOpen: (open: boolean) => void;
setActiveAgentSessionId: (id: string | null) => void;
Expand Down
Loading