mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
MAESTRO: Add Symphony React hooks for Phase 3 implementation
- useSymphony: Primary hook for registry, issues, and contribution management - useContribution: Single contribution state and action management - useContributorStats: Stats and achievements tracking with formatted display - index.ts: Central exports for all Symphony hooks Hooks follow established patterns from useMarketplace and Usage Dashboard, including debounced real-time updates, proper undefined handling for IPC responses, and type-safe integration with the symphony IPC handlers.
This commit is contained in:
13
src/renderer/hooks/symphony/index.ts
Normal file
13
src/renderer/hooks/symphony/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Maestro Symphony Hooks
|
||||
*
|
||||
* Central exports for all Symphony-related React hooks.
|
||||
*/
|
||||
|
||||
export { useSymphony } from './useSymphony';
|
||||
export { useContribution } from './useContribution';
|
||||
export { useContributorStats } from './useContributorStats';
|
||||
|
||||
export type { UseSymphonyReturn } from './useSymphony';
|
||||
export type { UseContributionReturn } from './useContribution';
|
||||
export type { UseContributorStatsReturn, Achievement } from './useContributorStats';
|
||||
225
src/renderer/hooks/symphony/useContribution.ts
Normal file
225
src/renderer/hooks/symphony/useContribution.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* useContribution Hook
|
||||
*
|
||||
* Manages the state and actions for a single active contribution.
|
||||
* Used by the contribution runner component.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import type {
|
||||
ActiveContribution,
|
||||
ContributionStatus,
|
||||
} from '../../../shared/symphony-types';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface UseContributionReturn {
|
||||
// Contribution data
|
||||
contribution: ActiveContribution | null;
|
||||
|
||||
// Status
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Progress tracking
|
||||
currentDocumentIndex: number;
|
||||
totalDocuments: number;
|
||||
currentDocument: string | null;
|
||||
elapsedTime: number;
|
||||
|
||||
// Actions
|
||||
updateProgress: (progress: Partial<ActiveContribution['progress']>) => Promise<void>;
|
||||
updateTokenUsage: (usage: Partial<ActiveContribution['tokenUsage']>) => Promise<void>;
|
||||
setStatus: (status: ContributionStatus) => Promise<void>;
|
||||
pause: () => Promise<void>;
|
||||
resume: () => Promise<void>;
|
||||
cancel: (cleanup?: boolean) => Promise<{ success: boolean }>;
|
||||
finalize: () => Promise<{ success: boolean; prUrl?: string; error?: string }>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook Implementation
|
||||
// ============================================================================
|
||||
|
||||
export function useContribution(contributionId: string | null): UseContributionReturn {
|
||||
const [contribution, setContribution] = useState<ActiveContribution | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
|
||||
// Track if component is mounted
|
||||
const isMountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => { isMountedRef.current = false; };
|
||||
}, []);
|
||||
|
||||
// Fetch contribution data
|
||||
const fetchContribution = useCallback(async () => {
|
||||
if (!contributionId) {
|
||||
setContribution(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await window.maestro.symphony.getActive();
|
||||
const contributions = response.contributions ?? [];
|
||||
const found = contributions.find(c => c.id === contributionId);
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (!found) {
|
||||
setError('Contribution not found');
|
||||
setContribution(null);
|
||||
} else {
|
||||
setContribution(found as ActiveContribution);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMountedRef.current) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch contribution');
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [contributionId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchContribution();
|
||||
}, [fetchContribution]);
|
||||
|
||||
// Poll for updates while contribution is active
|
||||
useEffect(() => {
|
||||
if (!contributionId || !contribution) return;
|
||||
if (['ready_for_review', 'failed', 'cancelled'].includes(contribution.status)) return;
|
||||
|
||||
const interval = setInterval(fetchContribution, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [contributionId, contribution?.status, fetchContribution]);
|
||||
|
||||
// Track elapsed time
|
||||
useEffect(() => {
|
||||
if (!contribution || contribution.status !== 'running') {
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = new Date(contribution.startedAt).getTime();
|
||||
const updateElapsed = () => {
|
||||
setElapsedTime(Date.now() - startTime);
|
||||
};
|
||||
|
||||
updateElapsed();
|
||||
const interval = setInterval(updateElapsed, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [contribution?.startedAt, contribution?.status]);
|
||||
|
||||
// Computed values
|
||||
const currentDocumentIndex = contribution?.progress.completedDocuments ?? 0;
|
||||
const totalDocuments = contribution?.progress.totalDocuments ?? 0;
|
||||
const currentDocument = contribution?.progress.currentDocument ?? null;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Actions
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const updateProgress = useCallback(async (progress: Partial<ActiveContribution['progress']>) => {
|
||||
if (!contributionId) return;
|
||||
|
||||
await window.maestro.symphony.updateStatus({
|
||||
contributionId,
|
||||
progress: {
|
||||
totalDocuments: progress.totalDocuments ?? contribution?.progress.totalDocuments ?? 0,
|
||||
completedDocuments: progress.completedDocuments ?? contribution?.progress.completedDocuments ?? 0,
|
||||
totalTasks: progress.totalTasks ?? contribution?.progress.totalTasks ?? 0,
|
||||
completedTasks: progress.completedTasks ?? contribution?.progress.completedTasks ?? 0,
|
||||
currentDocument: progress.currentDocument,
|
||||
},
|
||||
});
|
||||
|
||||
await fetchContribution();
|
||||
}, [contributionId, contribution, fetchContribution]);
|
||||
|
||||
const updateTokenUsage = useCallback(async (usage: Partial<ActiveContribution['tokenUsage']>) => {
|
||||
if (!contributionId) return;
|
||||
|
||||
await window.maestro.symphony.updateStatus({
|
||||
contributionId,
|
||||
tokenUsage: {
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
estimatedCost: usage.estimatedCost,
|
||||
},
|
||||
});
|
||||
|
||||
await fetchContribution();
|
||||
}, [contributionId, fetchContribution]);
|
||||
|
||||
const setStatus = useCallback(async (status: ContributionStatus) => {
|
||||
if (!contributionId) return;
|
||||
|
||||
await window.maestro.symphony.updateStatus({
|
||||
contributionId,
|
||||
status,
|
||||
});
|
||||
|
||||
await fetchContribution();
|
||||
}, [contributionId, fetchContribution]);
|
||||
|
||||
const pause = useCallback(async () => {
|
||||
await setStatus('paused');
|
||||
}, [setStatus]);
|
||||
|
||||
const resume = useCallback(async () => {
|
||||
await setStatus('running');
|
||||
}, [setStatus]);
|
||||
|
||||
const cancel = useCallback(async (cleanup: boolean = true) => {
|
||||
if (!contributionId) return { success: false };
|
||||
const result = await window.maestro.symphony.cancel(contributionId, cleanup);
|
||||
return { success: result.cancelled ?? false };
|
||||
}, [contributionId]);
|
||||
|
||||
const finalize = useCallback(async (): Promise<{ success: boolean; prUrl?: string; error?: string }> => {
|
||||
if (!contributionId || !contribution) {
|
||||
return { success: false, error: 'No active contribution' };
|
||||
}
|
||||
|
||||
const result = await window.maestro.symphony.complete({
|
||||
contributionId,
|
||||
});
|
||||
|
||||
if (result.prUrl) {
|
||||
return { success: true, prUrl: result.prUrl };
|
||||
}
|
||||
|
||||
return { success: false, error: result.error ?? 'Unknown error' };
|
||||
}, [contributionId, contribution]);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Return
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
return {
|
||||
contribution,
|
||||
isLoading,
|
||||
error,
|
||||
currentDocumentIndex,
|
||||
totalDocuments,
|
||||
currentDocument,
|
||||
elapsedTime,
|
||||
updateProgress,
|
||||
updateTokenUsage,
|
||||
setStatus,
|
||||
pause,
|
||||
resume,
|
||||
cancel,
|
||||
finalize,
|
||||
};
|
||||
}
|
||||
245
src/renderer/hooks/symphony/useContributorStats.ts
Normal file
245
src/renderer/hooks/symphony/useContributorStats.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* useContributorStats Hook
|
||||
*
|
||||
* Provides contributor statistics for achievements and the Stats tab.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import type {
|
||||
ContributorStats,
|
||||
CompletedContribution,
|
||||
} from '../../../shared/symphony-types';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface Achievement {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
earned: boolean;
|
||||
earnedAt?: string;
|
||||
progress?: number; // 0-100
|
||||
}
|
||||
|
||||
export interface UseContributorStatsReturn {
|
||||
stats: ContributorStats | null;
|
||||
recentContributions: CompletedContribution[];
|
||||
achievements: Achievement[];
|
||||
isLoading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
|
||||
// Formatted stats for display
|
||||
formattedTotalCost: string;
|
||||
formattedTotalTokens: string;
|
||||
formattedTotalTime: string;
|
||||
uniqueRepos: number;
|
||||
currentStreakDays: number;
|
||||
longestStreakDays: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Achievement Definitions
|
||||
// ============================================================================
|
||||
|
||||
interface AchievementDefinition {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
check: (stats: ContributorStats) => boolean;
|
||||
progress: (stats: ContributorStats) => number;
|
||||
}
|
||||
|
||||
const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
|
||||
{
|
||||
id: 'first-contribution',
|
||||
title: 'First Steps',
|
||||
description: 'Complete your first Symphony contribution',
|
||||
icon: '🎵',
|
||||
check: (stats: ContributorStats) => stats.totalContributions >= 1,
|
||||
progress: (stats: ContributorStats) => Math.min(100, stats.totalContributions * 100),
|
||||
},
|
||||
{
|
||||
id: 'ten-contributions',
|
||||
title: 'Harmony Seeker',
|
||||
description: 'Complete 10 contributions',
|
||||
icon: '🎶',
|
||||
check: (stats: ContributorStats) => stats.totalContributions >= 10,
|
||||
progress: (stats: ContributorStats) => Math.min(100, (stats.totalContributions / 10) * 100),
|
||||
},
|
||||
{
|
||||
id: 'first-merge',
|
||||
title: 'Merged Melody',
|
||||
description: 'Have a contribution merged',
|
||||
icon: '🎼',
|
||||
check: (stats: ContributorStats) => stats.totalMerged >= 1,
|
||||
progress: (stats: ContributorStats) => Math.min(100, stats.totalMerged * 100),
|
||||
},
|
||||
{
|
||||
id: 'multi-repo',
|
||||
title: 'Ensemble Player',
|
||||
description: 'Contribute to 5 different repositories',
|
||||
icon: '🎻',
|
||||
check: (stats: ContributorStats) => stats.repositoriesContributed.length >= 5,
|
||||
progress: (stats: ContributorStats) => Math.min(100, (stats.repositoriesContributed.length / 5) * 100),
|
||||
},
|
||||
{
|
||||
id: 'streak-week',
|
||||
title: 'Weekly Rhythm',
|
||||
description: 'Maintain a 7-day contribution streak',
|
||||
icon: '🔥',
|
||||
check: (stats: ContributorStats) => stats.longestStreak >= 7,
|
||||
progress: (stats: ContributorStats) => Math.min(100, (stats.longestStreak / 7) * 100),
|
||||
},
|
||||
{
|
||||
id: 'token-millionaire',
|
||||
title: 'Token Millionaire',
|
||||
description: 'Donate over 1 million tokens',
|
||||
icon: '💎',
|
||||
check: (stats: ContributorStats) => stats.totalTokensUsed >= 1_000_000,
|
||||
progress: (stats: ContributorStats) => Math.min(100, (stats.totalTokensUsed / 1_000_000) * 100),
|
||||
},
|
||||
{
|
||||
id: 'hundred-tasks',
|
||||
title: 'Virtuoso',
|
||||
description: 'Complete 100 tasks across all contributions',
|
||||
icon: '🏆',
|
||||
check: (stats: ContributorStats) => stats.totalTasksCompleted >= 100,
|
||||
progress: (stats: ContributorStats) => Math.min(100, stats.totalTasksCompleted),
|
||||
},
|
||||
{
|
||||
id: 'early-adopter',
|
||||
title: 'Early Adopter',
|
||||
description: 'Join Symphony in its first month',
|
||||
icon: '⭐',
|
||||
check: (stats: ContributorStats) => {
|
||||
if (!stats.firstContributionAt) return false;
|
||||
const firstDate = new Date(stats.firstContributionAt);
|
||||
const symphonyLaunch = new Date('2025-01-01'); // Placeholder
|
||||
const oneMonthLater = new Date(symphonyLaunch);
|
||||
oneMonthLater.setMonth(oneMonthLater.getMonth() + 1);
|
||||
return firstDate <= oneMonthLater;
|
||||
},
|
||||
progress: () => 100, // Either earned or not
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
function formatTokenCount(count: number): string {
|
||||
if (count >= 1_000_000) {
|
||||
return `${(count / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
if (count >= 1_000) {
|
||||
return `${(count / 1_000).toFixed(1)}K`;
|
||||
}
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
function formatCost(cost: number): string {
|
||||
return `$${cost.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const hours = Math.floor(ms / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook Implementation
|
||||
// ============================================================================
|
||||
|
||||
export function useContributorStats(): UseContributorStatsReturn {
|
||||
const [stats, setStats] = useState<ContributorStats | null>(null);
|
||||
const [recentContributions, setRecentContributions] = useState<CompletedContribution[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [statsResponse, completedResponse] = await Promise.all([
|
||||
window.maestro.symphony.getStats(),
|
||||
window.maestro.symphony.getCompleted(10), // Last 10 contributions
|
||||
]);
|
||||
|
||||
if (statsResponse.stats) {
|
||||
setStats(statsResponse.stats as ContributorStats);
|
||||
}
|
||||
if (completedResponse.contributions) {
|
||||
setRecentContributions(completedResponse.contributions as CompletedContribution[]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch contributor stats:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, [fetchStats]);
|
||||
|
||||
// Compute achievements
|
||||
const achievements = useMemo((): Achievement[] => {
|
||||
if (!stats) return ACHIEVEMENT_DEFINITIONS.map(def => ({
|
||||
id: def.id,
|
||||
title: def.title,
|
||||
description: def.description,
|
||||
icon: def.icon,
|
||||
earned: false,
|
||||
progress: 0,
|
||||
}));
|
||||
|
||||
return ACHIEVEMENT_DEFINITIONS.map(def => ({
|
||||
id: def.id,
|
||||
title: def.title,
|
||||
description: def.description,
|
||||
icon: def.icon,
|
||||
earned: def.check(stats),
|
||||
progress: def.progress(stats),
|
||||
}));
|
||||
}, [stats]);
|
||||
|
||||
// Formatted values
|
||||
const formattedTotalCost = useMemo(() => {
|
||||
return formatCost(stats?.estimatedCostDonated ?? 0);
|
||||
}, [stats]);
|
||||
|
||||
const formattedTotalTokens = useMemo(() => {
|
||||
return formatTokenCount(stats?.totalTokensUsed ?? 0);
|
||||
}, [stats]);
|
||||
|
||||
const formattedTotalTime = useMemo(() => {
|
||||
return formatDuration(stats?.totalTimeSpent ?? 0);
|
||||
}, [stats]);
|
||||
|
||||
const uniqueRepos = useMemo(() => {
|
||||
return stats?.repositoriesContributed.length ?? 0;
|
||||
}, [stats]);
|
||||
|
||||
const currentStreakDays = stats?.currentStreak ?? 0;
|
||||
const longestStreakDays = stats?.longestStreak ?? 0;
|
||||
|
||||
return {
|
||||
stats,
|
||||
recentContributions,
|
||||
achievements,
|
||||
isLoading,
|
||||
refresh: fetchStats,
|
||||
formattedTotalCost,
|
||||
formattedTotalTokens,
|
||||
formattedTotalTime,
|
||||
uniqueRepos,
|
||||
currentStreakDays,
|
||||
longestStreakDays,
|
||||
};
|
||||
}
|
||||
384
src/renderer/hooks/symphony/useSymphony.ts
Normal file
384
src/renderer/hooks/symphony/useSymphony.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* useSymphony Hook
|
||||
*
|
||||
* Primary hook for managing the Maestro Symphony feature.
|
||||
* Handles registry fetching, GitHub Issues browsing, and contribution state.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import type {
|
||||
SymphonyRegistry,
|
||||
RegisteredRepository,
|
||||
SymphonyIssue,
|
||||
SymphonyState,
|
||||
ActiveContribution,
|
||||
CompletedContribution,
|
||||
ContributorStats,
|
||||
SymphonyCategory,
|
||||
} from '../../../shared/symphony-types';
|
||||
import { SYMPHONY_CATEGORIES } from '../../../shared/symphony-constants';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface UseSymphonyReturn {
|
||||
// Registry data
|
||||
registry: SymphonyRegistry | null;
|
||||
repositories: RegisteredRepository[];
|
||||
categories: SymphonyCategory[];
|
||||
isLoading: boolean;
|
||||
isRefreshing: boolean;
|
||||
error: string | null;
|
||||
fromCache: boolean;
|
||||
cacheAge: number | null;
|
||||
|
||||
// Filtering
|
||||
selectedCategory: SymphonyCategory | 'all';
|
||||
setSelectedCategory: (category: SymphonyCategory | 'all') => void;
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
filteredRepositories: RegisteredRepository[];
|
||||
|
||||
// Selected repository
|
||||
selectedRepo: RegisteredRepository | null;
|
||||
repoIssues: SymphonyIssue[];
|
||||
isLoadingIssues: boolean;
|
||||
selectRepository: (repo: RegisteredRepository | null) => Promise<void>;
|
||||
|
||||
// Symphony state
|
||||
symphonyState: SymphonyState | null;
|
||||
activeContributions: ActiveContribution[];
|
||||
completedContributions: CompletedContribution[];
|
||||
stats: ContributorStats | null;
|
||||
|
||||
// Actions
|
||||
refresh: (force?: boolean) => Promise<void>;
|
||||
startContribution: (repo: RegisteredRepository, issue: SymphonyIssue, agentType: string, sessionId: string) => Promise<{
|
||||
success: boolean;
|
||||
contributionId?: string;
|
||||
draftPrUrl?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
cancelContribution: (contributionId: string, cleanup?: boolean) => Promise<{ success: boolean }>;
|
||||
finalizeContribution: (contributionId: string) => Promise<{
|
||||
success: boolean;
|
||||
prUrl?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook Implementation
|
||||
// ============================================================================
|
||||
|
||||
export function useSymphony(): UseSymphonyReturn {
|
||||
// Registry state
|
||||
const [registry, setRegistry] = useState<SymphonyRegistry | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fromCache, setFromCache] = useState(false);
|
||||
const [cacheAge, setCacheAge] = useState<number | null>(null);
|
||||
|
||||
// Filtering state
|
||||
const [selectedCategory, setSelectedCategory] = useState<SymphonyCategory | 'all'>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Selected repository state
|
||||
const [selectedRepo, setSelectedRepo] = useState<RegisteredRepository | null>(null);
|
||||
const [repoIssues, setRepoIssues] = useState<SymphonyIssue[]>([]);
|
||||
const [isLoadingIssues, setIsLoadingIssues] = useState(false);
|
||||
|
||||
// Symphony state
|
||||
const [symphonyState, setSymphonyState] = useState<SymphonyState | null>(null);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Computed Values
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const repositories = useMemo(() => {
|
||||
return registry?.repositories.filter(r => r.isActive) ?? [];
|
||||
}, [registry]);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const cats = new Set<SymphonyCategory>();
|
||||
repositories.forEach(r => cats.add(r.category));
|
||||
return Array.from(cats).sort((a, b) => {
|
||||
const labelA = SYMPHONY_CATEGORIES[a]?.label ?? a;
|
||||
const labelB = SYMPHONY_CATEGORIES[b]?.label ?? b;
|
||||
return labelA.localeCompare(labelB);
|
||||
});
|
||||
}, [repositories]);
|
||||
|
||||
const filteredRepositories = useMemo(() => {
|
||||
let filtered = repositories;
|
||||
|
||||
// Filter by category
|
||||
if (selectedCategory !== 'all') {
|
||||
filtered = filtered.filter(r => r.category === selectedCategory);
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(r =>
|
||||
r.name.toLowerCase().includes(query) ||
|
||||
r.description.toLowerCase().includes(query) ||
|
||||
r.slug.toLowerCase().includes(query) ||
|
||||
r.tags?.some(t => t.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
// Sort: featured first, then by name
|
||||
return filtered.sort((a, b) => {
|
||||
if (a.featured && !b.featured) return -1;
|
||||
if (!a.featured && b.featured) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [repositories, selectedCategory, searchQuery]);
|
||||
|
||||
const activeContributions = useMemo(() => symphonyState?.active ?? [], [symphonyState]);
|
||||
const completedContributions = useMemo(() => symphonyState?.history ?? [], [symphonyState]);
|
||||
const stats = useMemo(() => symphonyState?.stats ?? null, [symphonyState]);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Registry Fetching
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const fetchRegistry = useCallback(async (force: boolean = false) => {
|
||||
try {
|
||||
if (force) {
|
||||
setIsRefreshing(true);
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
const response = await window.maestro.symphony.getRegistry(force);
|
||||
if (response.registry) {
|
||||
setRegistry(response.registry as SymphonyRegistry);
|
||||
}
|
||||
setFromCache(response.fromCache ?? false);
|
||||
setCacheAge(response.cacheAge ?? null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch registry');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchSymphonyState = useCallback(async () => {
|
||||
try {
|
||||
const response = await window.maestro.symphony.getState();
|
||||
if (response.state) {
|
||||
setSymphonyState(response.state as SymphonyState);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch symphony state:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchRegistry();
|
||||
fetchSymphonyState();
|
||||
}, [fetchRegistry, fetchSymphonyState]);
|
||||
|
||||
// Real-time updates (matches Usage Dashboard pattern)
|
||||
useEffect(() => {
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const unsubscribe = window.maestro.symphony.onUpdated(() => {
|
||||
// Debounce to prevent excessive updates
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
fetchSymphonyState();
|
||||
}, 500);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
};
|
||||
}, [fetchSymphonyState]);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Repository Selection & GitHub Issues
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const selectRepository = useCallback(async (repo: RegisteredRepository | null) => {
|
||||
setSelectedRepo(repo);
|
||||
setRepoIssues([]);
|
||||
|
||||
if (!repo) return;
|
||||
|
||||
setIsLoadingIssues(true);
|
||||
try {
|
||||
// Fetch issues with runmaestro.ai label from GitHub API
|
||||
const response = await window.maestro.symphony.getIssues(repo.slug);
|
||||
if (response.issues) {
|
||||
setRepoIssues(response.issues as SymphonyIssue[]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch issues:', err);
|
||||
} finally {
|
||||
setIsLoadingIssues(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Contribution Actions
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const refresh = useCallback(async (force: boolean = true) => {
|
||||
await Promise.all([
|
||||
fetchRegistry(force),
|
||||
fetchSymphonyState(),
|
||||
]);
|
||||
}, [fetchRegistry, fetchSymphonyState]);
|
||||
|
||||
const startContribution = useCallback(async (
|
||||
repo: RegisteredRepository,
|
||||
issue: SymphonyIssue,
|
||||
agentType: string,
|
||||
sessionId: string
|
||||
): Promise<{ success: boolean; contributionId?: string; draftPrUrl?: string; error?: string }> => {
|
||||
try {
|
||||
// This single action will:
|
||||
// 1. Clone the repository
|
||||
// 2. Create a branch (symphony/issue-{number}-{timestamp})
|
||||
// 3. Create an empty commit
|
||||
// 4. Push the branch
|
||||
// 5. Open a draft PR (claims the issue)
|
||||
// 6. Set up Auto Run with the document paths from the issue
|
||||
const result = await window.maestro.symphony.start({
|
||||
repoSlug: repo.slug,
|
||||
repoUrl: repo.url,
|
||||
repoName: repo.name,
|
||||
issueNumber: issue.number,
|
||||
issueTitle: issue.title,
|
||||
documentPaths: issue.documentPaths,
|
||||
agentType,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
if (result.contributionId) {
|
||||
await fetchSymphonyState();
|
||||
return {
|
||||
success: true,
|
||||
contributionId: result.contributionId,
|
||||
draftPrUrl: result.draftPrUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: result.error ?? 'Unknown error',
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to start contribution',
|
||||
};
|
||||
}
|
||||
}, [fetchSymphonyState]);
|
||||
|
||||
const cancelContribution = useCallback(async (
|
||||
contributionId: string,
|
||||
cleanup: boolean = true
|
||||
): Promise<{ success: boolean }> => {
|
||||
try {
|
||||
// This will:
|
||||
// 1. Close the draft PR
|
||||
// 2. Delete the local branch
|
||||
// 3. Clean up local files
|
||||
const result = await window.maestro.symphony.cancel(contributionId, cleanup);
|
||||
if (result.cancelled) {
|
||||
await fetchSymphonyState();
|
||||
}
|
||||
return { success: result.cancelled ?? false };
|
||||
} catch {
|
||||
return { success: false };
|
||||
}
|
||||
}, [fetchSymphonyState]);
|
||||
|
||||
const finalizeContribution = useCallback(async (
|
||||
contributionId: string
|
||||
): Promise<{ success: boolean; prUrl?: string; error?: string }> => {
|
||||
const contribution = activeContributions.find(c => c.id === contributionId);
|
||||
if (!contribution) {
|
||||
return { success: false, error: 'Contribution not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
// This will:
|
||||
// 1. Commit all changes
|
||||
// 2. Push to the branch
|
||||
// 3. Convert draft PR to ready for review
|
||||
const result = await window.maestro.symphony.complete({
|
||||
contributionId,
|
||||
});
|
||||
|
||||
if (result.prUrl) {
|
||||
await fetchSymphonyState();
|
||||
return {
|
||||
success: true,
|
||||
prUrl: result.prUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: result.error ?? 'Unknown error',
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to finalize contribution',
|
||||
};
|
||||
}
|
||||
}, [activeContributions, fetchSymphonyState]);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Return
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
return {
|
||||
// Registry data
|
||||
registry,
|
||||
repositories,
|
||||
categories,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
error,
|
||||
fromCache,
|
||||
cacheAge,
|
||||
|
||||
// Filtering
|
||||
selectedCategory,
|
||||
setSelectedCategory,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filteredRepositories,
|
||||
|
||||
// Selected repository
|
||||
selectedRepo,
|
||||
repoIssues,
|
||||
isLoadingIssues,
|
||||
selectRepository,
|
||||
|
||||
// Symphony state
|
||||
symphonyState,
|
||||
activeContributions,
|
||||
completedContributions,
|
||||
stats,
|
||||
|
||||
// Actions
|
||||
refresh,
|
||||
startContribution,
|
||||
cancelContribution,
|
||||
finalizeContribution,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user