/** * DashboardView - Main dashboard with "Productivity Luxury" aesthetic. * Inspired by Linear, Vercel, and Raycast design patterns. * Features: * - Subtle spotlight gradient * - Centralized command search with inline project filtering * - Border-first project cards with minimal backgrounds */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { Button } from '@renderer/components/ui/button'; import { useStore } from '@renderer/store'; import { getWorktreeNavigationState } from '@renderer/store/utils/stateResetHelpers'; import { formatProjectPath } from '@renderer/utils/pathDisplay'; import { buildTaskCountsByProject, normalizePath, type TaskStatusCounts, } from '@renderer/utils/pathNormalize'; import { projectColor } from '@renderer/utils/projectColor'; import { formatShortcut } from '@renderer/utils/stringUtils'; import { createLogger } from '@shared/utils/logger'; import { useShallow } from 'zustand/react/shallow'; const logger = createLogger('Component:DashboardView'); import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { formatDistanceToNow } from 'date-fns'; import { Command, FolderGit2, FolderOpen, GitBranch, GitFork, Search, Users } from 'lucide-react'; import { CliStatusBanner } from './CliStatusBanner'; import type { RepositoryGroup } from '@renderer/types/data'; // ============================================================================= // Command Search Input // ============================================================================= interface CommandSearchProps { value: string; onChange: (value: string) => void; } const CommandSearch = ({ value, onChange }: Readonly): React.JSX.Element => { const [isFocused, setIsFocused] = useState(false); const { openCommandPalette, selectedProjectId } = useStore( useShallow((s) => ({ openCommandPalette: s.openCommandPalette, selectedProjectId: s.selectedProjectId, })) ); // Handle Cmd+K to open full command palette useEffect(() => { const handleKeyDown = (e: KeyboardEvent): void => { if ((e.metaKey || e.ctrlKey) && e.code === 'KeyK') { e.preventDefault(); openCommandPalette(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [openCommandPalette]); return (
{/* Search container with glow effect on focus */}
onChange(e.target.value)} placeholder="Search projects..." className="flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text-muted" onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} /> {/* Keyboard shortcut badge - opens full command palette */}
); }; // ============================================================================= // Repository Card // ============================================================================= interface RepositoryCardProps { repo: RepositoryGroup; onClick: () => void; isHighlighted?: boolean; taskCounts?: TaskStatusCounts; tasksLoading?: boolean; } const RepositoryCard = ({ repo, onClick, isHighlighted, taskCounts, tasksLoading, }: Readonly): React.JSX.Element => { const lastActivity = repo.mostRecentSession ? formatDistanceToNow(new Date(repo.mostRecentSession), { addSuffix: true }) : 'No recent activity'; const worktreeCount = repo.worktrees.length; const hasMultipleWorktrees = worktreeCount > 1; // Get the path from the first worktree const projectPath = repo.worktrees[0]?.path || ''; const formattedPath = formatProjectPath(projectPath); // Git branch info from worktrees const mainWorktree = repo.worktrees.find((w) => w.isMainWorktree) ?? repo.worktrees[0]; const mainBranch = mainWorktree?.gitBranch; // Detect if this is a worktree project: // 1. No main worktree in the group (isMainWorktree flag) // 2. OR the shown worktree has a tool-created source // 3. OR path-based fallback for .claude/worktrees/ directories const WORKTREE_PATH_MARKERS = [ '/.claude/worktrees/', '/.claude-worktrees/', '/.auto-claude/worktrees/', '/.21st/worktrees/', '/.ccswitch/worktrees/', '/.cursor/worktrees/', '/vibe-kanban/worktrees/', '/conductor/workspaces/', ]; const shownWorktree = repo.worktrees[0]; const isWorktreeBySource = shownWorktree?.source && !['git', 'unknown'].includes(shownWorktree.source); const isWorktreeByPath = shownWorktree && WORKTREE_PATH_MARKERS.some((m) => shownWorktree.path.includes(m)); const isWorktreeProject = !repo.worktrees.some((w) => w.isMainWorktree) || isWorktreeBySource || isWorktreeByPath; // Get the source label for worktree badge const SOURCE_LABELS: Record = { 'vibe-kanban': 'Vibe', conductor: 'Conductor', 'auto-claude': 'Auto', '21st': '21st', 'claude-desktop': 'Desktop', 'claude-code': 'Worktree', ccswitch: 'ccswitch', }; const worktreeSourceLabel = shownWorktree?.source && SOURCE_LABELS[shownWorktree.source]; const color = useMemo(() => projectColor(repo.name), [repo.name]); const cardRef = useRef(null); const [isHovered, setIsHovered] = useState(false); const handleOpenPath = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); if (projectPath) { void api.openPath(projectPath, projectPath); } }, [projectPath] ); return ( ); }; // ============================================================================= // Ghost Card (New Project) // ============================================================================= interface WorktreeMatch { repoId: string; worktreeId: string; } function findMatchingWorktree( groups: RepositoryGroup[], selectedPath: string ): WorktreeMatch | null { const norm = normalizePath(selectedPath); for (const repo of groups) { for (const worktree of repo.worktrees) { if (normalizePath(worktree.path) === norm) { return { repoId: repo.id, worktreeId: worktree.id }; } } } return null; } const NewProjectCard = (): React.JSX.Element => { const { repositoryGroups, fetchRepositoryGroups, openTeamsTab } = useStore( useShallow((s) => ({ repositoryGroups: s.repositoryGroups, fetchRepositoryGroups: s.fetchRepositoryGroups, openTeamsTab: s.openTeamsTab, })) ); const navigateToMatch = (match: WorktreeMatch): void => { useStore.setState(getWorktreeNavigationState(match.repoId, match.worktreeId)); void useStore.getState().fetchSessionsInitial(match.worktreeId); }; const handleClick = async (): Promise => { try { const selectedPaths = await api.config.selectFolders(); if (!selectedPaths || selectedPaths.length === 0) { return; // User cancelled } const selectedPath = selectedPaths[0]; // Match selected path against known repository worktrees (normalized comparison) const match = findMatchingWorktree(repositoryGroups, selectedPath); if (match) { navigateToMatch(match); openTeamsTab(); return; } // No match — refresh repository groups and retry await fetchRepositoryGroups(); const refreshedGroups = useStore.getState().repositoryGroups; const matchAfterRefresh = findMatchingWorktree(refreshedGroups, selectedPath); if (matchAfterRefresh) { navigateToMatch(matchAfterRefresh); openTeamsTab(); return; } // Still no match — create a synthetic group for this new folder and navigate to it. // This allows launching teams in projects that don't have Claude sessions yet. // Persist the path so it survives app restarts. await api.config.addCustomProjectPath(selectedPath); const encodedId = selectedPath.replace(/[/\\]/g, '-'); const folderName = selectedPath.split(/[/\\]/).filter(Boolean).pop() ?? selectedPath; const now = Date.now(); const syntheticGroup: RepositoryGroup = { id: encodedId, identity: null, worktrees: [ { id: encodedId, path: selectedPath, name: folderName, isMainWorktree: true, source: 'unknown', sessions: [], totalSessions: 0, createdAt: now, }, ], name: folderName, mostRecentSession: undefined, totalSessions: 0, }; useStore.setState((state) => ({ repositoryGroups: [syntheticGroup, ...state.repositoryGroups], })); navigateToMatch({ repoId: encodedId, worktreeId: encodedId }); openTeamsTab(); } catch (error) { logger.error('Error selecting folder:', error); } }; return ( ); }; // ============================================================================= // Projects Grid // ============================================================================= interface ProjectsGridProps { searchQuery: string; maxProjects?: number; } const INITIAL_RECENT_PROJECTS = 11; const LOAD_MORE_STEP = 8; const ProjectsGrid = ({ searchQuery, maxProjects = INITIAL_RECENT_PROJECTS, }: Readonly): React.JSX.Element => { const { repositoryGroups, repositoryGroupsLoading, repositoryGroupsError, fetchRepositoryGroups, selectRepository, globalTasks, globalTasksLoading, fetchAllTasks, openTeamsTab, } = useStore( useShallow((s) => ({ repositoryGroups: s.repositoryGroups, repositoryGroupsLoading: s.repositoryGroupsLoading, repositoryGroupsError: s.repositoryGroupsError, fetchRepositoryGroups: s.fetchRepositoryGroups, selectRepository: s.selectRepository, globalTasks: s.globalTasks, globalTasksLoading: s.globalTasksLoading, fetchAllTasks: s.fetchAllTasks, openTeamsTab: s.openTeamsTab, })) ); const hasFetchedTasksRef = React.useRef(false); const [visibleProjects, setVisibleProjects] = useState(maxProjects); useEffect(() => { if (repositoryGroups.length === 0 && !repositoryGroupsLoading) { void fetchRepositoryGroups(); } }, [repositoryGroups.length, repositoryGroupsLoading, fetchRepositoryGroups]); useEffect(() => { if (repositoryGroups.length > 0 && !hasFetchedTasksRef.current && !repositoryGroupsLoading) { hasFetchedTasksRef.current = true; void fetchAllTasks(); } }, [repositoryGroups.length, repositoryGroupsLoading, fetchAllTasks]); useEffect(() => { if (!searchQuery.trim()) { setVisibleProjects(maxProjects); } }, [searchQuery, maxProjects]); const taskCountsMap = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]); // Filter projects based on search query const filteredRepos = useMemo(() => { const query = searchQuery.toLowerCase().trim(); return repositoryGroups.filter((repo) => { if (!query) return true; // Match by name if (repo.name.toLowerCase().includes(query)) return true; // Match by path const path = repo.worktrees[0]?.path || ''; if (path.toLowerCase().includes(query)) return true; return false; }); }, [repositoryGroups, searchQuery]); const displayedRepos = useMemo(() => { if (searchQuery.trim()) { return filteredRepos; } return filteredRepos.slice(0, visibleProjects); }, [filteredRepos, searchQuery, visibleProjects]); const canLoadMore = !searchQuery.trim() && filteredRepos.length > visibleProjects; if (repositoryGroupsLoading) { // Organic widths per card — no repeating stamp const titleWidths = [60, 66, 50, 55, 75, 45, 40, 65]; const pathWidths = [80, 75, 85, 66, 70, 80, 60, 72]; return (
{Array.from({ length: 8 }).map((_, i) => (
{/* Icon placeholder */}
{/* Title placeholder */}
{/* Path placeholder */}
{/* Meta row placeholder */}
))}
); } if (repositoryGroupsError && repositoryGroups.length === 0) { return (

Failed to load projects

{repositoryGroupsError}

); } if (filteredRepos.length === 0 && searchQuery.trim()) { return (

No projects found

No matches for "{searchQuery}"

); } if (repositoryGroups.length === 0) { return (

No projects found

~/.claude/projects/

); } return (
{!searchQuery.trim() && } {displayedRepos.map((repo) => { const counts = repo.worktrees.reduce( (acc, wt) => { const c = taskCountsMap.get(normalizePath(wt.path)); if (c) { acc.pending += c.pending; acc.inProgress += c.inProgress; acc.completed += c.completed; } return acc; }, { pending: 0, inProgress: 0, completed: 0 } ); return ( { selectRepository(repo.id); openTeamsTab(); }} isHighlighted={!!searchQuery.trim()} taskCounts={globalTasksLoading ? undefined : counts} tasksLoading={globalTasksLoading} /> ); })}
{canLoadMore && (
)}
); }; // ============================================================================= // Dashboard View // ============================================================================= export const DashboardView = (): React.JSX.Element => { const [searchQuery, setSearchQuery] = useState(''); const openTeamsTab = useStore((s) => s.openTeamsTab); return (
{/* Spotlight gradient background */} ); };