diff --git a/apps/ui/src/components/layout/bottom-dock/bottom-dock.tsx b/apps/ui/src/components/layout/bottom-dock/bottom-dock.tsx index f6b3130c..6daeb0cb 100644 --- a/apps/ui/src/components/layout/bottom-dock/bottom-dock.tsx +++ b/apps/ui/src/components/layout/bottom-dock/bottom-dock.tsx @@ -1,6 +1,7 @@ import { useState, useCallback, useSyncExternalStore, useRef, useEffect } from 'react'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/store/app-store'; +import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; import { Button } from '@/components/ui/button'; import { Terminal, @@ -35,53 +36,76 @@ export type DockPosition = 'bottom' | 'right' | 'left'; const DOCK_POSITION_STORAGE_KEY = 'automaker:dock-position'; -// Event emitter for dock position changes -const positionListeners = new Set<() => void>(); +// Event emitter for dock state changes +const stateListeners = new Set<() => void>(); -function emitPositionChange() { - positionListeners.forEach((listener) => listener()); +function emitStateChange() { + stateListeners.forEach((listener) => listener()); } -// Cached position to avoid creating new objects on every read -let cachedPosition: DockPosition = 'bottom'; +// Cached dock state +interface DockState { + position: DockPosition; + isExpanded: boolean; + isMaximized: boolean; +} -// Initialize from localStorage +let cachedState: DockState = { + position: 'bottom', + isExpanded: false, + isMaximized: false, +}; + +// Initialize position from localStorage try { const stored = localStorage.getItem(DOCK_POSITION_STORAGE_KEY) as DockPosition | null; if (stored && ['bottom', 'right', 'left'].includes(stored)) { - cachedPosition = stored; + cachedState.position = stored; } } catch { // Ignore localStorage errors } -function getPosition(): DockPosition { - return cachedPosition; +function getDockState(): DockState { + return cachedState; } function updatePosition(position: DockPosition) { - if (cachedPosition !== position) { - cachedPosition = position; + if (cachedState.position !== position) { + cachedState = { ...cachedState, position }; try { localStorage.setItem(DOCK_POSITION_STORAGE_KEY, position); } catch { // Ignore localStorage errors } - emitPositionChange(); + emitStateChange(); } } -// Hook for external components to read dock position -export function useDockState(): { position: DockPosition } { - const position = useSyncExternalStore( +function updateExpanded(isExpanded: boolean) { + if (cachedState.isExpanded !== isExpanded) { + cachedState = { ...cachedState, isExpanded }; + emitStateChange(); + } +} + +function updateMaximized(isMaximized: boolean) { + if (cachedState.isMaximized !== isMaximized) { + cachedState = { ...cachedState, isMaximized }; + emitStateChange(); + } +} + +// Hook for external components to read dock state +export function useDockState(): DockState { + return useSyncExternalStore( (callback) => { - positionListeners.add(callback); - return () => positionListeners.delete(callback); + stateListeners.add(callback); + return () => stateListeners.delete(callback); }, - getPosition, - getPosition + getDockState, + getDockState ); - return { position }; } interface BottomDockProps { @@ -98,13 +122,22 @@ export function BottomDock({ className }: BottomDockProps) { // Use external store for position - single source of truth const position = useSyncExternalStore( (callback) => { - positionListeners.add(callback); - return () => positionListeners.delete(callback); + stateListeners.add(callback); + return () => stateListeners.delete(callback); }, - getPosition, - getPosition + () => getDockState().position, + () => getDockState().position ); + // Sync local expanded/maximized state to external store for other components + useEffect(() => { + updateExpanded(isExpanded); + }, [isExpanded]); + + useEffect(() => { + updateMaximized(isMaximized); + }, [isMaximized]); + const autoModeState = currentProject ? getAutoModeState(currentProject.id) : null; const runningAgentsCount = autoModeState?.runningTasks?.length ?? 0; @@ -139,6 +172,43 @@ export function BottomDock({ className }: BottomDockProps) { [activeTab, isExpanded] ); + // Get keyboard shortcuts from config + const shortcuts = useKeyboardShortcutsConfig(); + + // Register keyboard shortcuts for dock tabs + useKeyboardShortcuts([ + { + key: shortcuts.terminal, + action: () => handleTabClick('terminal'), + description: 'Toggle Terminal panel', + }, + { + key: shortcuts.ideation, + action: () => handleTabClick('ideation'), + description: 'Toggle Ideation panel', + }, + { + key: shortcuts.spec, + action: () => handleTabClick('spec'), + description: 'Toggle Spec panel', + }, + { + key: shortcuts.context, + action: () => handleTabClick('context'), + description: 'Toggle Context panel', + }, + { + key: shortcuts.githubIssues, + action: () => handleTabClick('github'), + description: 'Toggle GitHub panel', + }, + { + key: shortcuts.agent, + action: () => handleTabClick('agents'), + description: 'Toggle Agents panel', + }, + ]); + const handleDoubleClick = useCallback(() => { if (isExpanded) { setIsMaximized(!isMaximized); diff --git a/apps/ui/src/components/layout/bottom-dock/panels/github-panel.tsx b/apps/ui/src/components/layout/bottom-dock/panels/github-panel.tsx index 50999878..41e9e6c5 100644 --- a/apps/ui/src/components/layout/bottom-dock/panels/github-panel.tsx +++ b/apps/ui/src/components/layout/bottom-dock/panels/github-panel.tsx @@ -1,18 +1,49 @@ import { useState, useCallback, useEffect, useRef } from 'react'; -import { CircleDot, GitPullRequest, RefreshCw, ExternalLink, Loader2 } from 'lucide-react'; -import { getElectronAPI, GitHubIssue, GitHubPR } from '@/lib/electron'; +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('issues'); + const [selectedIssue, setSelectedIssue] = useState(null); + const [validationResult, setValidationResult] = useState(null); + const [showValidationDialog, setShowValidationDialog] = useState(false); const fetchingRef = useRef(false); const projectPath = currentProject?.path || ''; @@ -24,6 +55,18 @@ export function GitHubPanel() { 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; @@ -123,6 +166,61 @@ export function GitHubPanel() { 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 ( @@ -180,22 +278,82 @@ export function GitHubPanel() { issues.length === 0 ? (

No open issues

) : ( - issues.map((issue) => ( -
handleOpenInGitHub(issue.url)} - > - -
-

{issue.title}

-

- #{issue.number} opened by {issue.author?.login} -

+ issues.map((issue) => { + const { isValidating, cached, isStale } = getValidationStatus(issue.number); + + return ( +
+ +
+

{issue.title}

+

+ #{issue.number} opened by {issue.author?.login} +

+
+
+ {/* Validation status/action */} + {isValidating ? ( + + ) : cached && !isStale ? ( + + ) : cached && isStale ? ( + + ) : ( + + )} + {/* Open in GitHub */} + +
- -
- )) + ); + }) ) ) : prs.length === 0 ? (

No open pull requests

@@ -219,6 +377,18 @@ export function GitHubPanel() { )}
+ + {/* Validation Dialog */} + { + // Task conversion not supported in dock panel - need to go to full view + toast.info('Open GitHub Issues view for task conversion'); + }} + /> ); } diff --git a/apps/ui/src/components/layout/bottom-dock/panels/spec-panel.tsx b/apps/ui/src/components/layout/bottom-dock/panels/spec-panel.tsx index 60710886..460f7b96 100644 --- a/apps/ui/src/components/layout/bottom-dock/panels/spec-panel.tsx +++ b/apps/ui/src/components/layout/bottom-dock/panels/spec-panel.tsx @@ -13,6 +13,7 @@ import { getElectronAPI } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; +import { XmlSyntaxEditor } from '@/components/ui/xml-syntax-editor'; import { Checkbox } from '@/components/ui/checkbox'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; @@ -473,15 +474,12 @@ export function SpecPanel() { {/* Content */} -
-