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:
Pedram Amini
2025-12-29 16:26:53 -06:00
parent 0e8c6cd2df
commit 89a72ffa3f
4 changed files with 867 additions and 0 deletions

View 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';

View 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,
};
}

View 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,
};
}

View 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,
};
}