Files
automaker/apps/ui/src/components/layout/bottom-dock/panels/github-panel.tsx
webdevcody 6c19bb60d1 feat: enhance BottomDock and GitHubPanel with new state management and validation features
- Integrated keyboard shortcuts into BottomDock for improved accessibility and navigation.
- Refactored dock state management to include expanded and maximized states, allowing for more dynamic UI behavior.
- Added issue validation functionality in GitHubPanel, enabling users to validate GitHub issues with real-time feedback.
- Implemented a validation dialog for displaying results and managing issue validation status.

These enhancements significantly improve user interaction and functionality within the application, particularly in managing dock states and GitHub issues.
2026-01-10 12:24:28 -05:00

395 lines
14 KiB
TypeScript

import { useState, useCallback, useEffect, useRef } from 'react';
import {
CircleDot,
GitPullRequest,
RefreshCw,
ExternalLink,
Loader2,
Wand2,
CheckCircle,
Clock,
X,
} from 'lucide-react';
import {
getElectronAPI,
GitHubIssue,
GitHubPR,
IssueValidationResult,
StoredValidation,
} from '@/lib/electron';
import { useAppStore, GitHubCacheIssue, GitHubCachePR } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useIssueValidation } from '@/components/views/github-issues-view/hooks';
import { ValidationDialog } from '@/components/views/github-issues-view/dialogs';
import { useModelOverride } from '@/components/shared';
import { toast } from 'sonner';
type GitHubTab = 'issues' | 'prs';
// Cache duration: 5 minutes
const CACHE_DURATION_MS = 5 * 60 * 1000;
// Check if validation is stale (> 24 hours)
function isValidationStale(validatedAt: string): boolean {
const VALIDATION_CACHE_TTL_HOURS = 24;
const validatedTime = new Date(validatedAt).getTime();
const hoursSinceValidation = (Date.now() - validatedTime) / (1000 * 60 * 60);
return hoursSinceValidation > VALIDATION_CACHE_TTL_HOURS;
}
export function GitHubPanel() {
const { currentProject, getGitHubCache, setGitHubCache, setGitHubCacheFetching } = useAppStore();
const [activeTab, setActiveTab] = useState<GitHubTab>('issues');
const [selectedIssue, setSelectedIssue] = useState<GitHubIssue | null>(null);
const [validationResult, setValidationResult] = useState<IssueValidationResult | null>(null);
const [showValidationDialog, setShowValidationDialog] = useState(false);
const fetchingRef = useRef(false);
const projectPath = currentProject?.path || '';
const cache = getGitHubCache(projectPath);
const issues = cache?.issues || [];
const prs = cache?.prs || [];
const isFetching = cache?.isFetching || false;
const lastFetched = cache?.lastFetched || null;
const hasCache = issues.length > 0 || prs.length > 0 || lastFetched !== null;
// Model override for validation
const validationModelOverride = useModelOverride({ phase: 'validationModel' });
// Use the issue validation hook
const { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation } =
useIssueValidation({
selectedIssue,
showValidationDialog,
onValidationResultChange: setValidationResult,
onShowValidationDialogChange: setShowValidationDialog,
});
const fetchData = useCallback(
async (isBackgroundRefresh = false) => {
if (!projectPath || fetchingRef.current) return;
fetchingRef.current = true;
if (!isBackgroundRefresh) {
setGitHubCacheFetching(projectPath, true);
}
try {
const api = getElectronAPI();
const fetchedIssues: GitHubCacheIssue[] = [];
const fetchedPrs: GitHubCachePR[] = [];
// Fetch issues
if (api.github?.listIssues) {
const issuesResult = await api.github.listIssues(projectPath);
if (issuesResult.success && issuesResult.openIssues) {
// Map to cache format
fetchedIssues.push(
...issuesResult.openIssues.slice(0, 20).map((issue: GitHubIssue) => ({
number: issue.number,
title: issue.title,
url: issue.url,
author: issue.author,
}))
);
}
}
// Fetch PRs
if (api.github?.listPRs) {
const prsResult = await api.github.listPRs(projectPath);
if (prsResult.success && prsResult.openPRs) {
// Map to cache format
fetchedPrs.push(
...prsResult.openPRs.slice(0, 20).map((pr: GitHubPR) => ({
number: pr.number,
title: pr.title,
url: pr.url,
author: pr.author,
}))
);
}
}
setGitHubCache(projectPath, { issues: fetchedIssues, prs: fetchedPrs });
} catch (error) {
console.error('Error fetching GitHub data:', error);
// On error, just mark as not fetching but keep existing cache
setGitHubCacheFetching(projectPath, false);
} finally {
fetchingRef.current = false;
}
},
[projectPath, setGitHubCache, setGitHubCacheFetching]
);
// Initial fetch or refresh if cache is stale
useEffect(() => {
if (!projectPath) return;
const isCacheStale = !lastFetched || Date.now() - lastFetched > CACHE_DURATION_MS;
if (!hasCache) {
// No cache, do initial fetch (show spinner)
fetchData(false);
} else if (isCacheStale && !isFetching) {
// Cache is stale, refresh in background (no spinner, show cached data)
fetchData(true);
}
}, [projectPath, hasCache, lastFetched, isFetching, fetchData]);
// Auto-refresh interval
useEffect(() => {
if (!projectPath) return;
const interval = setInterval(() => {
const currentCache = getGitHubCache(projectPath);
const isStale =
!currentCache?.lastFetched || Date.now() - currentCache.lastFetched > CACHE_DURATION_MS;
if (isStale && !fetchingRef.current) {
fetchData(true);
}
}, CACHE_DURATION_MS);
return () => clearInterval(interval);
}, [projectPath, getGitHubCache, fetchData]);
const handleRefresh = useCallback(() => {
fetchData(false);
}, [fetchData]);
const handleOpenInGitHub = useCallback((url: string) => {
const api = getElectronAPI();
api.openExternalLink(url);
}, []);
// Handle validation for an issue (converts cache issue to GitHubIssue format)
const handleValidate = useCallback(
(cacheIssue: GitHubCacheIssue) => {
// Convert cache issue to GitHubIssue format for validation
const issue: GitHubIssue = {
number: cacheIssue.number,
title: cacheIssue.title,
url: cacheIssue.url,
author: cacheIssue.author || { login: 'unknown' },
state: 'OPEN',
body: '',
createdAt: new Date().toISOString(),
labels: [],
comments: { totalCount: 0 },
};
setSelectedIssue(issue);
handleValidateIssue(issue, {
modelEntry: validationModelOverride.effectiveModelEntry,
});
},
[handleValidateIssue, validationModelOverride.effectiveModelEntry]
);
// Handle viewing cached validation
const handleViewValidation = useCallback(
(cacheIssue: GitHubCacheIssue) => {
// Convert cache issue to GitHubIssue format
const issue: GitHubIssue = {
number: cacheIssue.number,
title: cacheIssue.title,
url: cacheIssue.url,
author: cacheIssue.author || { login: 'unknown' },
state: 'OPEN',
body: '',
createdAt: new Date().toISOString(),
labels: [],
comments: { totalCount: 0 },
};
setSelectedIssue(issue);
handleViewCachedValidation(issue);
},
[handleViewCachedValidation]
);
// Get validation status for an issue
const getValidationStatus = useCallback(
(issueNumber: number) => {
const isValidating = validatingIssues.has(issueNumber);
const cached = cachedValidations.get(issueNumber);
const isStale = cached ? isValidationStale(cached.validatedAt) : false;
return { isValidating, cached, isStale };
},
[validatingIssues, cachedValidations]
);
// Only show loading spinner if no cached data AND fetching
if (!hasCache && isFetching) {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="h-full flex flex-col">
{/* Header with tabs */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
<div className="flex items-center gap-1">
<button
onClick={() => setActiveTab('issues')}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors',
activeTab === 'issues'
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
<CircleDot className="h-3 w-3" />
Issues ({issues.length})
</button>
<button
onClick={() => setActiveTab('prs')}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors',
activeTab === 'prs'
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
<GitPullRequest className="h-3 w-3" />
PRs ({prs.length})
</button>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={handleRefresh}
disabled={isFetching}
>
<RefreshCw className={cn('h-3 w-3', isFetching && 'animate-spin')} />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-auto">
<div className="p-2 space-y-1">
{activeTab === 'issues' ? (
issues.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-4">No open issues</p>
) : (
issues.map((issue) => {
const { isValidating, cached, isStale } = getValidationStatus(issue.number);
return (
<div
key={issue.number}
className="flex items-start gap-2 p-2 rounded-md hover:bg-accent/50 group"
>
<CircleDot className="h-3.5 w-3.5 mt-0.5 text-green-500 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{issue.title}</p>
<p className="text-[10px] text-muted-foreground">
#{issue.number} opened by {issue.author?.login}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{/* Validation status/action */}
{isValidating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
) : cached && !isStale ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5"
onClick={(e) => {
e.stopPropagation();
handleViewValidation(issue);
}}
title="View validation result"
>
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
</Button>
) : cached && isStale ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5"
onClick={(e) => {
e.stopPropagation();
handleValidate(issue);
}}
title="Re-validate (stale)"
>
<Clock className="h-3.5 w-3.5 text-yellow-500" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
handleValidate(issue);
}}
title="Validate with AI"
>
<Wand2 className="h-3.5 w-3.5" />
</Button>
)}
{/* Open in GitHub */}
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
handleOpenInGitHub(issue.url);
}}
title="Open in GitHub"
>
<ExternalLink className="h-3 w-3" />
</Button>
</div>
</div>
);
})
)
) : prs.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-4">No open pull requests</p>
) : (
prs.map((pr) => (
<div
key={pr.number}
className="flex items-start gap-2 p-2 rounded-md hover:bg-accent/50 cursor-pointer group"
onClick={() => handleOpenInGitHub(pr.url)}
>
<GitPullRequest className="h-3.5 w-3.5 mt-0.5 text-purple-500 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{pr.title}</p>
<p className="text-[10px] text-muted-foreground">
#{pr.number} by {pr.author?.login}
</p>
</div>
<ExternalLink className="h-3 w-3 opacity-0 group-hover:opacity-100 text-muted-foreground" />
</div>
))
)}
</div>
</div>
{/* Validation Dialog */}
<ValidationDialog
open={showValidationDialog}
onOpenChange={setShowValidationDialog}
issue={selectedIssue}
validationResult={validationResult}
onConvertToTask={() => {
// Task conversion not supported in dock panel - need to go to full view
toast.info('Open GitHub Issues view for task conversion');
}}
/>
</div>
);
}