diff --git a/apps/server/src/lib/validation-storage.ts b/apps/server/src/lib/validation-storage.ts index 9fa7d151..f2304c4c 100644 --- a/apps/server/src/lib/validation-storage.ts +++ b/apps/server/src/lib/validation-storage.ts @@ -145,3 +145,35 @@ export async function getValidationWithFreshness( isStale: isValidationStale(validation), }; } + +/** + * Mark a validation as viewed by the user + * + * @param projectPath - Absolute path to project directory + * @param issueNumber - GitHub issue number + * @returns true if validation was marked as viewed, false if not found + */ +export async function markValidationViewed( + projectPath: string, + issueNumber: number +): Promise { + const validation = await readValidation(projectPath, issueNumber); + if (!validation) { + return false; + } + + validation.viewedAt = new Date().toISOString(); + await writeValidation(projectPath, issueNumber, validation); + return true; +} + +/** + * Get count of unviewed, non-stale validations for a project + * + * @param projectPath - Absolute path to project directory + * @returns Number of unviewed validations + */ +export async function getUnviewedValidationsCount(projectPath: string): Promise { + const validations = await getAllValidations(projectPath); + return validations.filter((v) => !v.viewedAt && !isValidationStale(v)).length; +} diff --git a/apps/server/src/routes/github/index.ts b/apps/server/src/routes/github/index.ts index 4ebd52f7..798deae5 100644 --- a/apps/server/src/routes/github/index.ts +++ b/apps/server/src/routes/github/index.ts @@ -14,6 +14,7 @@ import { createValidationStopHandler, createGetValidationsHandler, createDeleteValidationHandler, + createMarkViewedHandler, } from './routes/validation-endpoints.js'; export function createGitHubRoutes(events: EventEmitter): Router { @@ -41,6 +42,11 @@ export function createGitHubRoutes(events: EventEmitter): Router { validatePathParams('projectPath'), createDeleteValidationHandler() ); + router.post( + '/validation-mark-viewed', + validatePathParams('projectPath'), + createMarkViewedHandler() + ); return router; } diff --git a/apps/server/src/routes/github/routes/validation-endpoints.ts b/apps/server/src/routes/github/routes/validation-endpoints.ts index da9c7b95..652aa3d2 100644 --- a/apps/server/src/routes/github/routes/validation-endpoints.ts +++ b/apps/server/src/routes/github/routes/validation-endpoints.ts @@ -17,6 +17,7 @@ import { getAllValidations, getValidationWithFreshness, deleteValidation, + markValidationViewed, } from '../../../lib/validation-storage.js'; /** @@ -188,3 +189,36 @@ export function createDeleteValidationHandler() { } }; } + +/** + * POST /validation-mark-viewed - Mark a validation as viewed by the user + */ +export function createMarkViewedHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, issueNumber } = req.body as { + projectPath: string; + issueNumber: number; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!issueNumber || typeof issueNumber !== 'number') { + res + .status(400) + .json({ success: false, error: 'issueNumber is required and must be a number' }); + return; + } + + const success = await markValidationViewed(projectPath, issueNumber); + + res.json({ success }); + } catch (error) { + logError(error, 'Mark validation viewed failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index 0e87d03b..12a20113 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -30,6 +30,7 @@ import { useSetupDialog, useTrashDialog, useProjectTheme, + useUnviewedValidations, } from './sidebar/hooks'; export function Sidebar() { @@ -127,6 +128,9 @@ export function Sidebar() { // Running agents count const { runningAgentsCount } = useRunningAgents(); + // Unviewed validations count + const { count: unviewedValidationsCount } = useUnviewedValidations(currentProject); + // Trash dialog and operations const { showTrashDialog, @@ -235,6 +239,7 @@ export function Sidebar() { setIsProjectPickerOpen, cyclePrevProject, cycleNextProject, + unviewedValidationsCount, }); // Register keyboard shortcuts diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx index b22dd8c1..002530f5 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-navigation.tsx @@ -78,14 +78,29 @@ export function SidebarNavigation({ title={!sidebarOpen ? item.label : undefined} data-testid={`nav-${item.id}`} > - + + {/* Count badge for collapsed state */} + {!sidebarOpen && item.count !== undefined && item.count > 0 && ( + + {item.count > 99 ? '99' : item.count} + )} - /> + {item.label} - {item.shortcut && sidebarOpen && ( + {/* Count badge */} + {item.count !== undefined && item.count > 0 && sidebarOpen && ( + + {item.count > 99 ? '99+' : item.count} + + )} + {item.shortcut && sidebarOpen && !item.count && ( boolean)) => void; cyclePrevProject: () => void; cycleNextProject: () => void; + /** Count of unviewed validations to show on GitHub Issues nav item */ + unviewedValidationsCount?: number; } export function useNavigation({ @@ -61,6 +63,7 @@ export function useNavigation({ setIsProjectPickerOpen, cyclePrevProject, cycleNextProject, + unviewedValidationsCount, }: UseNavigationProps) { // Track if current project has a GitHub remote const [hasGitHubRemote, setHasGitHubRemote] = useState(false); @@ -169,6 +172,7 @@ export function useNavigation({ id: 'github-issues', label: 'Issues', icon: CircleDot, + count: unviewedValidationsCount, }, { id: 'github-prs', @@ -180,7 +184,15 @@ export function useNavigation({ } return sections; - }, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles, hasGitHubRemote]); + }, [ + shortcuts, + hideSpecEditor, + hideContext, + hideTerminal, + hideAiProfiles, + hasGitHubRemote, + unviewedValidationsCount, + ]); // Build keyboard shortcuts for navigation const navigationShortcuts: KeyboardShortcut[] = useMemo(() => { diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-unviewed-validations.ts b/apps/ui/src/components/layout/sidebar/hooks/use-unviewed-validations.ts new file mode 100644 index 00000000..44470e3c --- /dev/null +++ b/apps/ui/src/components/layout/sidebar/hooks/use-unviewed-validations.ts @@ -0,0 +1,84 @@ +import { useState, useEffect, useCallback } from 'react'; +import { getElectronAPI } from '@/lib/electron'; +import type { Project, StoredValidation } from '@/lib/electron'; + +/** + * Hook to track the count of unviewed (fresh) issue validations for a project. + * Also provides a function to decrement the count when a validation is viewed. + */ +export function useUnviewedValidations(currentProject: Project | null) { + const [count, setCount] = useState(0); + + // Load initial count + useEffect(() => { + if (!currentProject?.path) { + setCount(0); + return; + } + + const loadCount = async () => { + try { + const api = getElectronAPI(); + if (api.github?.getValidations) { + const result = await api.github.getValidations(currentProject.path); + if (result.success && result.validations) { + const unviewed = result.validations.filter((v: StoredValidation) => { + if (v.viewedAt) return false; + // Check if not stale (< 24 hours) + const hoursSince = + (Date.now() - new Date(v.validatedAt).getTime()) / (1000 * 60 * 60); + return hoursSince <= 24; + }); + setCount(unviewed.length); + } + } + } catch (err) { + console.error('[useUnviewedValidations] Failed to load count:', err); + } + }; + + loadCount(); + + // Subscribe to validation events to update count + const api = getElectronAPI(); + if (api.github?.onValidationEvent) { + const unsubscribe = api.github.onValidationEvent((event) => { + if (event.projectPath === currentProject.path) { + if (event.type === 'issue_validation_complete') { + setCount((prev) => prev + 1); + } + } + }); + return () => unsubscribe(); + } + }, [currentProject?.path]); + + // Function to decrement count when a validation is viewed + const decrementCount = useCallback(() => { + setCount((prev) => Math.max(0, prev - 1)); + }, []); + + // Function to refresh count (e.g., after marking as viewed) + const refreshCount = useCallback(async () => { + if (!currentProject?.path) return; + + try { + const api = getElectronAPI(); + if (api.github?.getValidations) { + const result = await api.github.getValidations(currentProject.path); + if (result.success && result.validations) { + const unviewed = result.validations.filter((v: StoredValidation) => { + if (v.viewedAt) return false; + const hoursSince = (Date.now() - new Date(v.validatedAt).getTime()) / (1000 * 60 * 60); + return hoursSince <= 24; + }); + setCount(unviewed.length); + } + } + } catch (err) { + console.error('[useUnviewedValidations] Failed to refresh count:', err); + } + }, [currentProject?.path]); + + return { count, decrementCount, refreshCount }; +} diff --git a/apps/ui/src/components/layout/sidebar/types.ts b/apps/ui/src/components/layout/sidebar/types.ts index 4d9ecc35..25192d04 100644 --- a/apps/ui/src/components/layout/sidebar/types.ts +++ b/apps/ui/src/components/layout/sidebar/types.ts @@ -11,6 +11,8 @@ export interface NavItem { label: string; icon: React.ComponentType<{ className?: string }>; shortcut?: string; + /** Optional count badge to display next to the nav item */ + count?: number; } export interface SortableProjectItemProps { diff --git a/apps/ui/src/components/views/github-issues-view.tsx b/apps/ui/src/components/views/github-issues-view.tsx index 0fb8194c..9e2104f5 100644 --- a/apps/ui/src/components/views/github-issues-view.tsx +++ b/apps/ui/src/components/views/github-issues-view.tsx @@ -12,6 +12,7 @@ import { User, CheckCircle, Clock, + Sparkles, } from 'lucide-react'; import { getElectronAPI, @@ -119,6 +120,27 @@ export function GitHubIssuesView() { loadCachedValidations(); }, [currentProject?.path]); + // Load running validations on mount (restore validatingIssues state) + useEffect(() => { + const loadRunningValidations = async () => { + if (!currentProject?.path) return; + + try { + const api = getElectronAPI(); + if (api.github?.getValidationStatus) { + const result = await api.github.getValidationStatus(currentProject.path); + if (result.success && result.runningIssues) { + setValidatingIssues(new Set(result.runningIssues)); + } + } + } catch (err) { + console.error('[GitHubIssuesView] Failed to load running validations:', err); + } + }; + + loadRunningValidations(); + }, [currentProject?.path]); + // Subscribe to validation events useEffect(() => { const api = getElectronAPI(); @@ -293,14 +315,38 @@ export function GitHubIssuesView() { // View cached validation result const handleViewCachedValidation = useCallback( - (issue: GitHubIssue) => { + async (issue: GitHubIssue) => { const cached = cachedValidations.get(issue.number); if (cached) { setValidationResult(cached.result); setShowValidationDialog(true); + + // Mark as viewed if not already viewed + if (!cached.viewedAt && currentProject?.path) { + try { + const api = getElectronAPI(); + if (api.github?.markValidationViewed) { + await api.github.markValidationViewed(currentProject.path, issue.number); + // Update local state + setCachedValidations((prev) => { + const next = new Map(prev); + const updated = prev.get(issue.number); + if (updated) { + next.set(issue.number, { + ...updated, + viewedAt: new Date().toISOString(), + }); + } + return next; + }); + } + } catch (err) { + console.error('[GitHubIssuesView] Failed to mark validation as viewed:', err); + } + } } }, - [cachedValidations] + [cachedValidations, currentProject?.path] ); const handleConvertToTask = useCallback( @@ -446,6 +492,7 @@ export function GitHubIssuesView() { onClick={() => setSelectedIssue(issue)} onOpenExternal={() => handleOpenInGitHub(issue.url)} formatDate={formatDate} + cachedValidation={cachedValidations.get(issue.number)} /> ))} @@ -463,6 +510,7 @@ export function GitHubIssuesView() { onClick={() => setSelectedIssue(issue)} onOpenExternal={() => handleOpenInGitHub(issue.url)} formatDate={formatDate} + cachedValidation={cachedValidations.get(issue.number)} /> ))} @@ -726,9 +774,27 @@ interface IssueRowProps { onClick: () => void; onOpenExternal: () => void; formatDate: (date: string) => string; + /** Cached validation for this issue (if any) */ + cachedValidation?: StoredValidation | null; } -function IssueRow({ issue, isSelected, onClick, onOpenExternal, formatDate }: IssueRowProps) { +function IssueRow({ + issue, + isSelected, + onClick, + onOpenExternal, + formatDate, + cachedValidation, +}: IssueRowProps) { + // Check if validation is unviewed (exists, not stale, not viewed) + const hasUnviewedValidation = + cachedValidation && + !cachedValidation.viewedAt && + (() => { + const hoursSince = + (Date.now() - new Date(cachedValidation.validatedAt).getTime()) / (1000 * 60 * 60); + return hoursSince <= 24; + })(); return (
a.login).join(', ')} )} + + {/* Unviewed validation indicator */} + {hasUnviewedValidation && ( + + + Analysis Ready + + )}
diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 6d5ec9c9..b1da31e6 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -227,6 +227,11 @@ export interface GitHubAPI { isStale?: boolean; error?: string; }>; + /** Mark a validation as viewed by the user */ + markValidationViewed: ( + projectPath: string, + issueNumber: number + ) => Promise<{ success: boolean; error?: string }>; /** Subscribe to validation events */ onValidationEvent: (callback: (event: IssueValidationEvent) => void) => () => void; } diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index f9d40360..24bbe104 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -762,6 +762,8 @@ export class HttpApiClient implements ElectronAPI { this.post('/api/github/validation-stop', { projectPath, issueNumber }), getValidations: (projectPath: string, issueNumber?: number) => this.post('/api/github/validations', { projectPath, issueNumber }), + markValidationViewed: (projectPath: string, issueNumber: number) => + this.post('/api/github/validation-mark-viewed', { projectPath, issueNumber }), onValidationEvent: (callback: (event: IssueValidationEvent) => void) => this.subscribeToEvent('issue-validation:event', callback as EventCallback), }; diff --git a/libs/types/src/issue-validation.ts b/libs/types/src/issue-validation.ts index cbc4c948..30ea1f53 100644 --- a/libs/types/src/issue-validation.ts +++ b/libs/types/src/issue-validation.ts @@ -123,4 +123,6 @@ export interface StoredValidation { model: AgentModel; /** The validation result */ result: IssueValidationResult; + /** ISO timestamp when user viewed this validation (undefined = not yet viewed) */ + viewedAt?: string; }