Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
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
147 changes: 144 additions & 3 deletions src/__tests__/renderer/components/SettingsModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@
customAICommands: [],
setCustomAICommands: mockSetCustomAICommands,
// Encore features
encoreFeatures: { directorNotes: false },
encoreFeatures: { directorNotes: false, usageStats: true, symphony: true },
setEncoreFeatures: mockSetEncoreFeatures,
// Conductor profile settings
conductorProfile: '',
Expand Down Expand Up @@ -273,6 +273,9 @@
setUseNativeTitleBar: vi.fn(),
autoHideMenuBar: false,
setAutoHideMenuBar: vi.fn(),
// Symphony registry URLs
symphonyRegistryUrls: [],
setSymphonyRegistryUrls: vi.fn(),
...mockUseSettingsOverrides,
}),
}));
Expand Down Expand Up @@ -2333,12 +2336,14 @@

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

it('should call setEncoreFeatures with false when toggling DN off', async () => {
mockSetEncoreFeatures.mockClear();
mockUseSettingsOverrides = { encoreFeatures: { directorNotes: true } };
mockUseSettingsOverrides = { encoreFeatures: { directorNotes: true, usageStats: true, symphony: true } };
render(<SettingsModal {...createDefaultProps()} />);

await act(async () => {
Expand All @@ -2357,12 +2362,148 @@

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();

Check failure on line 2385 in src/__tests__/renderer/components/SettingsModal.test.tsx

View workflow job for this annotation

GitHub Actions / test

src/__tests__/renderer/components/SettingsModal.test.tsx > SettingsModal > Encore Features settings tab > should show Usage & Stats feature toggle defaulting to on

TestingLibraryElementError: Unable to find an element with the text: Enable stats collection. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible. Ignored nodes: comments, script, style <body> <div> <div aria-label="Settings" aria-modal="true" class="fixed inset-0 modal-overlay flex items-center justify-center z-[9999]" role="dialog" > <div class="w-[780px] h-[720px] rounded-xl border shadow-2xl overflow-hidden flex flex-col" style="background-color: rgb(33, 34, 44); border-color: rgb(68, 71, 90);" > <div class="flex border-b" style="border-color: rgb(68, 71, 90);" > <button class="px-4 py-4 text-sm font-bold border-b-2 cursor-pointer border-transparent flex items-center gap-2" title="General" > <svg class="w-4 h-4" data-testid="settings-icon" /> </button> <button class="px-4 py-4 text-sm font-bold border-b-2 cursor-pointer border-transparent flex items-center gap-2" title="Display" > <svg class="w-4 h-4" data-testid="monitor-icon" /> </button> <button class="px-4 py-4 text-sm font-bold border-b-2 cursor-pointer border-transparent flex items-center gap-2" title="Shortcuts" > <svg class="w-4 h-4" data-testid="keyboard-icon" /> </button> <button class="px-4 py-4 text-sm font-bold border-b-2 cursor-pointer border-transparent flex items-center gap-2" title="Themes" > <svg class="w-4 h-4" data-testid="palette-icon" /> </button> <button class="px-4 py-4 text-sm font-bold border-b-2 cursor-pointer border-transparent flex items-center gap-2" title="Notifications" > <svg class="w-4 h-4" data-testid="bell-icon" /> </button> <button class="px-4 py-4 text-sm font-bold border-b-2 cursor-pointer border-transparent flex items-center gap-2" title="AI Commands" > <svg class="w-4 h-4" data-testid="cpu-icon" /> </button> <button class="px-4 py-4 text-sm font-bold border-b-2 cursor-pointer border-transparent flex items-center gap-2" title="SSH Hosts" > <svg class="w-4 h-4" data-testid="server-icon" /> </button> <button class="px-4 py-4 text-sm font-bold border-b-2 cursor-pointer border-indigo-500 flex items-center gap-2" style="color: rgb(248, 248, 242);" title="Encore Features" > <svg class="w-4 h-4" data-testid="flaskconical-icon" /> <span> Encore Features </span> </button> <div class="flex-1 flex justify-end items-center pr-4" > <button class="cursor-pointer" > <svg class="w-5 h-5 opacity-50 hover:opacity-100" data-testid="x-icon" /> </button> </div> </div> <div class="flex-1 p-6 overflow-y-auto scrollbar-thin" > <div class="space-y-6" > <div> <h3 class="text-sm font-bold mb-2" style="color: rgb(248, 248, 242);" > Encore Features </h3> <p class="text-xs" style="color: rgb(98, 114, 164);"
});

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();
mockUseSettingsOverrides = { encoreFeatures: { directorNotes: false, usageStats: true, symphony: false } };

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: true,
});
});

it('should hide Symphony registry settings when symphony is disabled', async () => {
mockUseSettingsOverrides = { encoreFeatures: { directorNotes: false, usageStats: true, symphony: false } };

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();
expect(screen.queryByText('Registry Sources')).not.toBeInTheDocument();
});

describe("with Director's Notes enabled", () => {
beforeEach(() => {
mockUseSettingsOverrides = { encoreFeatures: { directorNotes: true } };
mockUseSettingsOverrides = { encoreFeatures: { directorNotes: true, usageStats: true, symphony: true } };
});

it('should render provider dropdown with detected available agents', async () => {
Expand Down
1 change: 1 addition & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,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 @@ -200,6 +200,7 @@ export interface SymphonyHandlerDependencies {
app: App;
getMainWindow: () => BrowserWindow | null;
sessionsStore: Store<SessionsData>;
settingsStore: Store;
}

// ============================================================================
Expand Down Expand Up @@ -381,39 +382,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 @@ -1014,6 +1043,7 @@ export function registerSymphonyHandlers({
app,
getMainWindow,
sessionsStore,
settingsStore,
}: SymphonyHandlerDependencies): void {
// ─────────────────────────────────────────────────────────────────────────
// Registry Operations
Expand Down Expand Up @@ -1102,9 +1132,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
6 changes: 3 additions & 3 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2551,7 +2551,7 @@ function MaestroConsoleInner() {
setAboutModalOpen={setAboutModalOpen}
setLogViewerOpen={setLogViewerOpen}
setProcessMonitorOpen={setProcessMonitorOpen}
setUsageDashboardOpen={setUsageDashboardOpen}
setUsageDashboardOpen={encoreFeatures.usageStats ? setUsageDashboardOpen : undefined}
setActiveRightTab={setActiveRightTab}
setAgentSessionsOpen={setAgentSessionsOpen}
setActiveAgentSessionId={setActiveAgentSessionId}
Expand Down Expand Up @@ -2626,7 +2626,7 @@ function MaestroConsoleInner() {
getDocumentTaskCount={getDocumentTaskCount}
onAutoRunRefresh={handleAutoRunRefresh}
onOpenMarketplace={handleOpenMarketplace}
onOpenSymphony={() => setSymphonyModalOpen(true)}
onOpenSymphony={encoreFeatures.symphony ? () => setSymphonyModalOpen(true) : undefined}
onOpenDirectorNotes={
encoreFeatures.directorNotes ? () => setDirectorNotesOpen(true) : undefined
}
Expand Down Expand Up @@ -2790,7 +2790,7 @@ function MaestroConsoleInner() {
)}

{/* --- 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 @@ -795,7 +795,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 @@ -1911,7 +1911,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
Loading