diff --git a/src/main/ipc/handlers/symphony.ts b/src/main/ipc/handlers/symphony.ts
index 8f4d2e64..5b5ab3e9 100644
--- a/src/main/ipc/handlers/symphony.ts
+++ b/src/main/ipc/handlers/symphony.ts
@@ -1354,5 +1354,47 @@ This PR will be updated automatically when the Auto Run completes.`;
)
);
+ // Handler for fetching document content (from main process to avoid CORS)
+ ipcMain.handle(
+ 'symphony:fetchDocumentContent',
+ createIpcHandler(
+ handlerOpts('fetchDocumentContent'),
+ async (params: { url: string }): Promise<{ success: boolean; content?: string; error?: string }> => {
+ const { url } = params;
+
+ // Validate URL - only allow GitHub URLs
+ try {
+ const parsed = new URL(url);
+ if (!['github.com', 'raw.githubusercontent.com', 'objects.githubusercontent.com'].some(
+ host => parsed.hostname === host || parsed.hostname.endsWith('.' + host)
+ )) {
+ return { success: false, error: 'Only GitHub URLs are allowed' };
+ }
+ if (parsed.protocol !== 'https:') {
+ return { success: false, error: 'Only HTTPS URLs are allowed' };
+ }
+ } catch {
+ return { success: false, error: 'Invalid URL' };
+ }
+
+ try {
+ logger.info('Fetching document content', LOG_CONTEXT, { url });
+ const response = await fetch(url);
+ if (!response.ok) {
+ return { success: false, error: `HTTP ${response.status}: ${response.statusText}` };
+ }
+ const content = await response.text();
+ return { success: true, content };
+ } catch (error) {
+ logger.error('Failed to fetch document content', LOG_CONTEXT, { url, error });
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to fetch document',
+ };
+ }
+ }
+ )
+ );
+
logger.info('Symphony handlers registered', LOG_CONTEXT);
}
diff --git a/src/renderer/components/SymphonyModal.tsx b/src/renderer/components/SymphonyModal.tsx
index 29932899..909fdf5e 100644
--- a/src/renderer/components/SymphonyModal.tsx
+++ b/src/renderer/components/SymphonyModal.tsx
@@ -27,7 +27,6 @@ import {
GitMerge,
Clock,
Zap,
- Star,
Play,
Pause,
AlertCircle,
@@ -36,6 +35,7 @@ import {
Flame,
FileText,
Hash,
+ ChevronDown,
} from 'lucide-react';
import type { Theme } from '../types';
import type {
@@ -53,6 +53,7 @@ import { MODAL_PRIORITIES } from '../constants/modalPriorities';
import { useSymphony } from '../hooks/symphony';
import { useContributorStats, type Achievement } from '../hooks/symphony/useContributorStats';
import { AgentCreationDialog, type AgentCreationConfig } from './AgentCreationDialog';
+import { generateProseStyles, createMarkdownComponents } from '../utils/markdownConfig';
// ============================================================================
// Types
@@ -207,7 +208,6 @@ function RepositoryTile({
{categoryInfo.emoji}
{categoryInfo.label}
- {repo.featured && }
@@ -255,7 +255,7 @@ function IssueCard({
!isAvailable ? 'opacity-60 cursor-not-allowed' : 'hover:bg-white/5'
} ${isSelected ? 'ring-2' : ''}`}
style={{
- backgroundColor: isSelected ? theme.colors.bgActivity : 'transparent',
+ backgroundColor: isSelected ? theme.colors.bgActivity : theme.colors.bgMain,
borderColor: isSelected ? theme.colors.accent : theme.colors.border,
...(isSelected && { boxShadow: `0 0 0 2px ${theme.colors.accent}` }),
}}
@@ -331,15 +331,92 @@ function RepositoryDetailView({
onBack: () => void;
onSelectIssue: (issue: SymphonyIssue) => void;
onStartContribution: () => void;
- onPreviewDocument: (path: string) => void;
+ onPreviewDocument: (path: string, isExternal: boolean) => void;
}) {
const categoryInfo = SYMPHONY_CATEGORIES[repo.category] ?? { label: repo.category, emoji: '📦' };
const availableIssues = issues.filter(i => i.status === 'available');
- const [selectedDocPath, setSelectedDocPath] = useState(null);
+ const [selectedDocIndex, setSelectedDocIndex] = useState(0);
+ const [showDocDropdown, setShowDocDropdown] = useState(false);
+ const dropdownRef = useRef(null);
- const handleSelectDoc = (path: string) => {
- setSelectedDocPath(path);
- onPreviewDocument(path);
+ // Generate prose styles scoped to symphony preview panel
+ const proseStyles = useMemo(
+ () =>
+ generateProseStyles({
+ theme,
+ coloredHeadings: true,
+ compactSpacing: false,
+ includeCheckboxStyles: true,
+ scopeSelector: '.symphony-preview',
+ }),
+ [theme]
+ );
+
+ // Create markdown components with link handling
+ const markdownComponents = useMemo(
+ () =>
+ createMarkdownComponents({
+ theme,
+ onExternalLinkClick: (href) => window.maestro.shell?.openExternal?.(href),
+ }),
+ [theme]
+ );
+
+ // Close dropdown when clicking outside
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
+ setShowDocDropdown(false);
+ }
+ };
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ // Auto-load first document when issue is selected
+ useEffect(() => {
+ if (selectedIssue && selectedIssue.documentPaths.length > 0) {
+ const firstDoc = selectedIssue.documentPaths[0];
+ setSelectedDocIndex(0);
+ onPreviewDocument(firstDoc.path, firstDoc.isExternal);
+ }
+ }, [selectedIssue, onPreviewDocument]);
+
+ // Keyboard shortcuts for document navigation: Cmd+Shift+[ and Cmd+Shift+]
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (!selectedIssue || selectedIssue.documentPaths.length === 0) return;
+
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === '[' || e.key === ']')) {
+ e.preventDefault();
+
+ const docCount = selectedIssue.documentPaths.length;
+ let newIndex: number;
+
+ if (e.key === '[') {
+ // Go backwards, wrap around
+ newIndex = selectedDocIndex <= 0 ? docCount - 1 : selectedDocIndex - 1;
+ } else {
+ // Go forwards, wrap around
+ newIndex = selectedDocIndex >= docCount - 1 ? 0 : selectedDocIndex + 1;
+ }
+
+ const doc = selectedIssue.documentPaths[newIndex];
+ setSelectedDocIndex(newIndex);
+ onPreviewDocument(doc.path, doc.isExternal);
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [selectedIssue, selectedDocIndex, onPreviewDocument]);
+
+ const handleSelectDoc = (index: number) => {
+ if (!selectedIssue) return;
+ const doc = selectedIssue.documentPaths[index];
+ setSelectedDocIndex(index);
+ setShowDocDropdown(false);
+ onPreviewDocument(doc.path, doc.isExternal);
};
const handleOpenExternal = useCallback((url: string) => {
@@ -362,7 +439,6 @@ function RepositoryDetailView({
{categoryInfo.emoji}
{categoryInfo.label}
- {repo.featured && }
{repo.name}
@@ -475,10 +551,10 @@ function RepositoryDetailView({
{/* Right: Issue preview */}
-
+
{selectedIssue ? (
<>
-
+
#{selectedIssue.number}
{selectedIssue.title}
@@ -489,46 +565,66 @@ function RepositoryDetailView({
- {/* Document tabs */}
-
- {selectedIssue.documentPaths.map((doc) => (
+ {/* Document selector dropdown */}
+
+
- ))}
+
+ {showDocDropdown && (
+
+ {selectedIssue.documentPaths.map((doc, index) => (
+
+ ))}
+
+ )}
+
-
+ {/* Document preview - Markdown preview scrollable container with prose styles */}
+
+
{isLoadingDocument ? (
) : documentPreview ? (
-
-
{documentPreview}
+
+
+ {documentPreview}
+
- ) : selectedDocPath ? (
-
- Document preview unavailable
-
) : (
@@ -538,7 +634,10 @@ function RepositoryDetailView({
>
) : (
-
+
Select an issue to see details
@@ -963,14 +1062,31 @@ export function SymphonyModal({
setDocumentPreview(null);
}, []);
- // Preview document (stub - not yet implemented in IPC)
- const handlePreviewDocument = useCallback(async (path: string) => {
+ // Preview document - fetches content from external URLs (GitHub attachments)
+ const handlePreviewDocument = useCallback(async (path: string, isExternal: boolean) => {
if (!selectedRepo) return;
setIsLoadingDocument(true);
- // TODO: Implement document preview via IPC
- // const content = await window.maestro.symphony.previewDocument(selectedRepo.slug, path);
- setDocumentPreview(`# Document Preview\n\nPreview for \`${path}\` is not yet available.\n\nThis document will be processed when you start the Symphony contribution.`);
- setIsLoadingDocument(false);
+ setDocumentPreview(null);
+
+ try {
+ if (isExternal && path.startsWith('http')) {
+ // Fetch content from external URL via main process (to avoid CORS)
+ const result = await window.maestro.symphony.fetchDocumentContent(path);
+ if (result.success && result.content) {
+ setDocumentPreview(result.content);
+ } else {
+ setDocumentPreview(`*Failed to load document: ${result.error || 'Unknown error'}*`);
+ }
+ } else {
+ // For repo-relative paths, we can't preview until contribution starts
+ setDocumentPreview(`*This document is located at \`${path}\` in the repository and will be available when you start the contribution.*`);
+ }
+ } catch (error) {
+ console.error('Failed to fetch document:', error);
+ setDocumentPreview(`*Failed to load document: ${error instanceof Error ? error.message : 'Unknown error'}*`);
+ } finally {
+ setIsLoadingDocument(false);
+ }
}, [selectedRepo]);
// Start contribution - opens agent creation dialog
@@ -1154,7 +1270,10 @@ export function SymphonyModal({
{activeTab === 'projects' && (
<>
{/* Search + Category tabs */}
-
+
@@ -1164,8 +1283,12 @@ export function SymphonyModal({
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search repositories..."
- className="w-full pl-9 pr-3 py-2 rounded border bg-transparent outline-none text-sm focus:ring-1"
- style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
+ className="w-full pl-9 pr-3 py-2 rounded border outline-none text-sm focus:ring-1"
+ style={{
+ borderColor: theme.colors.border,
+ color: theme.colors.textMain,
+ backgroundColor: theme.colors.bgActivity,
+ }}
/>
@@ -1174,8 +1297,9 @@ export function SymphonyModal({
onClick={() => setSelectedCategory('all')}
className={`px-3 py-1.5 rounded text-sm transition-colors ${selectedCategory === 'all' ? 'font-semibold' : ''}`}
style={{
- backgroundColor: selectedCategory === 'all' ? theme.colors.accent + '20' : 'transparent',
+ backgroundColor: selectedCategory === 'all' ? theme.colors.bgActivity : 'transparent',
color: selectedCategory === 'all' ? theme.colors.accent : theme.colors.textDim,
+ border: selectedCategory === 'all' ? `1px solid ${theme.colors.accent}` : '1px solid transparent',
}}
>
All
@@ -1190,8 +1314,9 @@ export function SymphonyModal({
selectedCategory === cat ? 'font-semibold' : ''
}`}
style={{
- backgroundColor: selectedCategory === cat ? theme.colors.accent + '20' : 'transparent',
+ backgroundColor: selectedCategory === cat ? theme.colors.bgActivity : 'transparent',
color: selectedCategory === cat ? theme.colors.accent : theme.colors.textDim,
+ border: selectedCategory === cat ? `1px solid ${theme.colors.accent}` : '1px solid transparent',
}}
>
{info?.emoji}
@@ -1204,7 +1329,7 @@ export function SymphonyModal({
{/* Repository grid */}
-
+
{isLoading ? (
{[1, 2, 3, 4, 5, 6].map((i) => )}
diff --git a/symphony-registry.json b/symphony-registry.json
index 67258208..1ee94e7d 100644
--- a/symphony-registry.json
+++ b/symphony-registry.json
@@ -5,9 +5,9 @@
{
"slug": "pedramamini/Maestro",
"name": "Maestro",
- "description": "Desktop app for managing multiple AI coding assistants with a keyboard-first interface.",
+ "description": "Desktop app for managing multiple AI agents with a keyboard-first interface.",
"url": "https://github.com/pedramamini/Maestro",
- "category": "developer-tools",
+ "category": "ai-ml",
"tags": ["electron", "ai", "productivity", "typescript"],
"maintainer": {
"name": "Pedram Amini",