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.
This commit is contained in:
webdevcody
2026-01-10 12:24:28 -05:00
parent a2ccf200a9
commit 6c19bb60d1
4 changed files with 314 additions and 50 deletions

View File

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

View File

@@ -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<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 || '';
@@ -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,11 +278,13 @@ export function GitHubPanel() {
issues.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-4">No open issues</p>
) : (
issues.map((issue) => (
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 cursor-pointer group"
onClick={() => handleOpenInGitHub(issue.url)}
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">
@@ -193,9 +293,67 @@ export function GitHubPanel() {
#{issue.number} opened by {issue.author?.login}
</p>
</div>
<ExternalLink className="h-3 w-3 opacity-0 group-hover:opacity-100 text-muted-foreground" />
<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>
@@ -219,6 +377,18 @@ export function GitHubPanel() {
)}
</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>
);
}

View File

@@ -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() {
</div>
{/* Content */}
<div className="flex-1 p-2 overflow-hidden">
<Textarea
<div className="flex-1 overflow-hidden bg-muted/30 rounded-md m-2">
<XmlSyntaxEditor
value={specContent}
onChange={(e) => setSpecContent(e.target.value)}
className={cn(
'h-full w-full resize-none font-mono text-xs',
'bg-muted/30 border-0 focus-visible:ring-1'
)}
onChange={(value) => setSpecContent(value)}
placeholder="Enter your app specification..."
className="h-full"
/>
</div>

View File

@@ -8,6 +8,7 @@ import { Archive, Settings2, CheckSquare, GripVertical, Plus } from 'lucide-reac
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
import { getColumnsWithPipeline, type ColumnId } from './constants';
import type { PipelineConfig } from '@automaker/types';
import { useDockState } from '@/components/layout/bottom-dock/bottom-dock';
interface KanbanBoardProps {
sensors: any;
@@ -95,8 +96,33 @@ export function KanbanBoard({
// containerStyle handles centering and ensures columns fit without horizontal scroll in Electron
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
// Get dock state to add padding when dock is expanded on the side
const {
position: dockPosition,
isExpanded: dockExpanded,
isMaximized: dockMaximized,
} = useDockState();
// Calculate padding based on dock state
// Dock widths: collapsed=w-10 (2.5rem), expanded=w-96 (24rem), maximized=w-[50vw]
const getSideDockPadding = () => {
if (!dockExpanded) return undefined;
if (dockMaximized) return '50vw';
return '25rem'; // 24rem dock width + 1rem breathing room
};
const sideDockPadding = getSideDockPadding();
return (
<div className="flex-1 overflow-x-auto px-5 pb-4 relative" style={backgroundImageStyle}>
<div
className="flex-1 overflow-x-auto px-5 pb-4 relative transition-[padding] duration-300"
style={{
...backgroundImageStyle,
// Add padding when dock is expanded on the side so content can scroll past the overlay
paddingRight: dockPosition === 'right' ? sideDockPadding : undefined,
paddingLeft: dockPosition === 'left' ? sideDockPadding : undefined,
}}
>
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}