feat: Add validation viewing functionality and UI updates

- Implemented a new function to mark validations as viewed by the user, updating the validation state accordingly.
- Added a new API endpoint for marking validations as viewed, integrated with the existing GitHub routes.
- Enhanced the sidebar to display the count of unviewed validations, providing real-time updates.
- Updated the GitHub issues view to mark validations as viewed when issues are accessed, improving user interaction.
- Introduced a visual indicator for unviewed validations in the issue list, enhancing user awareness of pending validations.
This commit is contained in:
Kacper
2025-12-23 22:11:26 +01:00
parent 6acb751eb3
commit 0c9f05ee38
13 changed files with 300 additions and 12 deletions

View File

@@ -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

View File

@@ -78,14 +78,29 @@ export function SidebarNavigation({
title={!sidebarOpen ? item.label : undefined}
data-testid={`nav-${item.id}`}
>
<Icon
className={cn(
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
isActive
? 'text-brand-500 drop-shadow-sm'
: 'group-hover:text-brand-400 group-hover:scale-110'
<div className="relative">
<Icon
className={cn(
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
isActive
? 'text-brand-500 drop-shadow-sm'
: 'group-hover:text-brand-400 group-hover:scale-110'
)}
/>
{/* Count badge for collapsed state */}
{!sidebarOpen && item.count !== undefined && item.count > 0 && (
<span
className={cn(
'absolute -top-1.5 -right-1.5 flex items-center justify-center',
'min-w-4 h-4 px-1 text-[9px] font-bold rounded-full',
'bg-primary text-primary-foreground shadow-sm',
'animate-in fade-in zoom-in duration-200'
)}
>
{item.count > 99 ? '99' : item.count}
</span>
)}
/>
</div>
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
@@ -94,7 +109,21 @@ export function SidebarNavigation({
>
{item.label}
</span>
{item.shortcut && sidebarOpen && (
{/* Count badge */}
{item.count !== undefined && item.count > 0 && sidebarOpen && (
<span
className={cn(
'hidden lg:flex items-center justify-center',
'min-w-5 h-5 px-1.5 text-[10px] font-bold rounded-full',
'bg-primary text-primary-foreground shadow-sm',
'animate-in fade-in zoom-in duration-200'
)}
data-testid={`count-${item.id}`}
>
{item.count > 99 ? '99+' : item.count}
</span>
)}
{item.shortcut && sidebarOpen && !item.count && (
<span
className={cn(
'hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',

View File

@@ -10,3 +10,4 @@ export { useProjectCreation } from './use-project-creation';
export { useSetupDialog } from './use-setup-dialog';
export { useTrashDialog } from './use-trash-dialog';
export { useProjectTheme } from './use-project-theme';
export { useUnviewedValidations } from './use-unviewed-validations';

View File

@@ -44,6 +44,8 @@ interface UseNavigationProps {
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => 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(() => {

View File

@@ -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 };
}

View File

@@ -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 {

View File

@@ -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 (
<div
className={cn(
@@ -785,6 +851,14 @@ function IssueRow({ issue, isSelected, onClick, onOpenExternal, formatDate }: Is
{issue.assignees.map((a) => a.login).join(', ')}
</span>
)}
{/* Unviewed validation indicator */}
{hasUnviewedValidation && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-amber-500/10 text-amber-500 border border-amber-500/20 animate-in fade-in duration-200">
<Sparkles className="h-3 w-3" />
Analysis Ready
</span>
)}
</div>
</div>