- Introduced a summaryOnly option in the API for fetching task changes, allowing for lightweight responses that skip detailed snippets and timelines. - Enhanced ChangeExtractorService to utilize the summaryOnly option, improving performance by conditionally caching task change data. - Updated related components and services to support the new summaryOnly feature, ensuring consistent behavior across the application. - Improved state management in TaskDetailDialog for task changes, including loading and error handling enhancements.
766 lines
28 KiB
TypeScript
766 lines
28 KiB
TypeScript
/**
|
|
* 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<CommandSearchProps>): 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 (
|
|
<div className="relative w-full">
|
|
{/* Search container with glow effect on focus */}
|
|
<div
|
|
className={`relative flex items-center gap-3 rounded-sm border bg-surface-raised px-4 py-3 transition-all duration-200 ${
|
|
isFocused
|
|
? 'border-zinc-500 shadow-[0_0_20px_rgba(255,255,255,0.04)] ring-1 ring-zinc-600/30'
|
|
: 'border-border hover:border-zinc-600'
|
|
} `}
|
|
>
|
|
<Search className="size-4 shrink-0 text-text-muted" />
|
|
<input
|
|
type="text"
|
|
value={value}
|
|
onChange={(e) => 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 */}
|
|
<button
|
|
onClick={() => openCommandPalette()}
|
|
className="flex shrink-0 items-center gap-1 transition-opacity hover:opacity-80"
|
|
title={
|
|
selectedProjectId
|
|
? `Search in sessions (${formatShortcut('K')})`
|
|
: `Search projects (${formatShortcut('K')})`
|
|
}
|
|
>
|
|
<kbd className="flex h-5 items-center justify-center rounded border border-border bg-surface-overlay px-1.5 text-[10px] font-medium text-text-muted">
|
|
<Command className="size-2.5" />
|
|
</kbd>
|
|
<kbd className="flex size-5 items-center justify-center rounded border border-border bg-surface-overlay text-[10px] font-medium text-text-muted">
|
|
K
|
|
</kbd>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// =============================================================================
|
|
// Repository Card
|
|
// =============================================================================
|
|
|
|
interface RepositoryCardProps {
|
|
repo: RepositoryGroup;
|
|
onClick: () => void;
|
|
isHighlighted?: boolean;
|
|
taskCounts?: TaskStatusCounts;
|
|
tasksLoading?: boolean;
|
|
}
|
|
|
|
const RepositoryCard = ({
|
|
repo,
|
|
onClick,
|
|
isHighlighted,
|
|
taskCounts,
|
|
tasksLoading,
|
|
}: Readonly<RepositoryCardProps>): 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<string, string> = {
|
|
'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<HTMLButtonElement>(null);
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
|
|
const handleOpenPath = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
if (projectPath) {
|
|
void api.openPath(projectPath, projectPath);
|
|
}
|
|
},
|
|
[projectPath]
|
|
);
|
|
|
|
return (
|
|
<button
|
|
ref={cardRef}
|
|
onClick={onClick}
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
onMouseLeave={() => setIsHovered(false)}
|
|
className={`group relative flex min-h-[120px] flex-col overflow-hidden rounded-lg border border-l-[3px] p-4 text-left transition-all duration-300 ${
|
|
isHighlighted
|
|
? 'border-border-emphasis bg-surface-raised'
|
|
: 'bg-surface/50 border-border hover:border-border-emphasis hover:bg-surface-raised'
|
|
} `}
|
|
style={{
|
|
borderLeftColor: color.border,
|
|
boxShadow: isHovered ? `inset 3px 0 12px -4px ${color.glow}` : undefined,
|
|
}}
|
|
>
|
|
{/* Icon + Project name */}
|
|
<div className="mb-1 flex items-center gap-2.5">
|
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-surface-overlay transition-colors duration-300 group-hover:border-border-emphasis">
|
|
{isWorktreeProject ? (
|
|
<GitFork
|
|
className="size-4 transition-colors group-hover:text-text"
|
|
style={{ color: color.icon }}
|
|
/>
|
|
) : (
|
|
<FolderGit2
|
|
className="size-4 transition-colors group-hover:text-text"
|
|
style={{ color: color.icon }}
|
|
/>
|
|
)}
|
|
</div>
|
|
<h3 className="min-w-0 truncate text-sm font-medium text-text transition-colors duration-200 group-hover:text-text">
|
|
{repo.name}
|
|
</h3>
|
|
{isWorktreeProject && (
|
|
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-purple-500/15 px-1.5 py-0.5 text-[9px] font-medium text-purple-400">
|
|
{worktreeSourceLabel ?? 'Worktree'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Project path - monospace, muted; folder icon opens in file manager */}
|
|
<div className="flex w-full min-w-0 items-center gap-1 font-mono text-[10px] text-text-muted">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={handleOpenPath}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ')
|
|
handleOpenPath(e as unknown as React.MouseEvent);
|
|
}}
|
|
className="shrink-0 cursor-pointer rounded p-0.5 transition-colors hover:bg-white/5 hover:text-text-secondary"
|
|
>
|
|
<FolderOpen className="size-3" />
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">Open</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="truncate">{formattedPath}</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" align="start">
|
|
<p className="font-mono text-[11px]">{projectPath}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
{/* Git branch / worktree info */}
|
|
{mainBranch ? (
|
|
<div className="mb-auto mt-1 flex items-center gap-1.5 truncate">
|
|
<GitBranch className="size-3 shrink-0 text-text-muted" />
|
|
<span className="truncate text-[10px] text-text-secondary">{mainBranch}</span>
|
|
{hasMultipleWorktrees && (
|
|
<span className="shrink-0 rounded bg-surface-raised px-1 py-px text-[9px] text-text-muted">
|
|
+{worktreeCount - 1}
|
|
</span>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="mb-auto" />
|
|
)}
|
|
|
|
{/* Meta row: worktrees, sessions, time */}
|
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
{hasMultipleWorktrees && (
|
|
<span className="inline-flex items-center gap-1 text-[10px] text-text-secondary">
|
|
<GitBranch className="size-3" />
|
|
{worktreeCount} worktrees
|
|
</span>
|
|
)}
|
|
<span className="text-[10px] text-text-secondary">{repo.totalSessions} sessions</span>
|
|
{taskCounts &&
|
|
(taskCounts.pending > 0 || taskCounts.inProgress > 0 || taskCounts.completed > 0) && (
|
|
<>
|
|
<span className="text-text-muted">·</span>
|
|
{taskCounts.inProgress > 0 && (
|
|
<span className="inline-flex items-center rounded-full bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
|
|
{taskCounts.inProgress} active
|
|
</span>
|
|
)}
|
|
{taskCounts.pending > 0 && (
|
|
<span className="inline-flex items-center rounded-full bg-yellow-500/15 px-1.5 py-0.5 text-[10px] font-medium text-yellow-400">
|
|
{taskCounts.pending} pending
|
|
</span>
|
|
)}
|
|
{taskCounts.completed > 0 && (
|
|
<span className="inline-flex items-center rounded-full bg-green-500/15 px-1.5 py-0.5 text-[10px] font-medium text-green-400">
|
|
{taskCounts.completed} done
|
|
</span>
|
|
)}
|
|
</>
|
|
)}
|
|
<span className="text-text-muted">·</span>
|
|
<span className="text-[10px] text-text-muted">{lastActivity}</span>
|
|
</div>
|
|
|
|
{/* Tasks progress bar */}
|
|
{tasksLoading ? (
|
|
<div className="mt-2 w-full">
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-1.5 flex-1 animate-pulse overflow-hidden rounded-full bg-[var(--color-surface-raised)]" />
|
|
<div className="h-2.5 w-6 animate-pulse rounded bg-[var(--color-surface-raised)]" />
|
|
</div>
|
|
</div>
|
|
) : (
|
|
taskCounts &&
|
|
(() => {
|
|
const pending = taskCounts.pending ?? 0;
|
|
const inProgress = taskCounts.inProgress ?? 0;
|
|
const completed = taskCounts.completed ?? 0;
|
|
const totalTasks = pending + inProgress + completed;
|
|
if (totalTasks === 0) return null;
|
|
const completedRatio = completed / totalTasks;
|
|
const progressPercent = Math.round(completedRatio * 100);
|
|
return (
|
|
<div className="mt-2 w-full space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--color-surface-raised)]"
|
|
role="progressbar"
|
|
aria-valuenow={completed}
|
|
aria-valuemin={0}
|
|
aria-valuemax={totalTasks}
|
|
aria-label={`Tasks ${completed}/${totalTasks} completed`}
|
|
>
|
|
<div
|
|
className="h-full rounded-full bg-emerald-500 transition-all duration-200"
|
|
style={{ width: `${progressPercent}%` }}
|
|
/>
|
|
</div>
|
|
<span className="shrink-0 text-[10px] font-medium tracking-tight text-[var(--color-text-muted)]">
|
|
{completed}/{totalTasks}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()
|
|
)}
|
|
</button>
|
|
);
|
|
};
|
|
|
|
// =============================================================================
|
|
// 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<void> => {
|
|
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 (
|
|
<button
|
|
className="hover:bg-surface/30 group relative flex min-h-[120px] flex-col items-center justify-center rounded-lg border border-dashed border-border bg-transparent p-4 transition-all duration-300 hover:border-border-emphasis"
|
|
onClick={handleClick}
|
|
title="Select a project folder"
|
|
>
|
|
<div className="mb-2 flex size-8 items-center justify-center rounded-md border border-dashed border-border transition-colors duration-300 group-hover:border-border-emphasis">
|
|
<FolderOpen className="size-4 text-text-muted transition-colors group-hover:text-text-secondary" />
|
|
</div>
|
|
<span className="text-xs text-text-muted transition-colors group-hover:text-text-secondary">
|
|
Select Folder
|
|
</span>
|
|
</button>
|
|
);
|
|
};
|
|
|
|
// =============================================================================
|
|
// 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<ProjectsGridProps>): 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 (
|
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-4">
|
|
{Array.from({ length: 8 }).map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="skeleton-card flex min-h-[120px] flex-col rounded-sm border border-border p-4"
|
|
style={{
|
|
animationDelay: `${i * 80}ms`,
|
|
backgroundColor: 'var(--skeleton-base)',
|
|
}}
|
|
>
|
|
{/* Icon placeholder */}
|
|
<div
|
|
className="mb-3 size-8 rounded-sm"
|
|
style={{ backgroundColor: 'var(--skeleton-base-light)' }}
|
|
/>
|
|
{/* Title placeholder */}
|
|
<div
|
|
className="mb-2 h-3.5 rounded-sm"
|
|
style={{
|
|
width: `${titleWidths[i]}%`,
|
|
backgroundColor: 'var(--skeleton-base-light)',
|
|
}}
|
|
/>
|
|
{/* Path placeholder */}
|
|
<div
|
|
className="mb-auto h-2.5 rounded-sm"
|
|
style={{
|
|
width: `${pathWidths[i]}%`,
|
|
backgroundColor: 'var(--skeleton-base-dim)',
|
|
}}
|
|
/>
|
|
{/* Meta row placeholder */}
|
|
<div className="mt-3 flex gap-2">
|
|
<div
|
|
className="h-2.5 w-16 rounded-sm"
|
|
style={{ backgroundColor: 'var(--skeleton-base-dim)' }}
|
|
/>
|
|
<div
|
|
className="h-2.5 w-12 rounded-sm"
|
|
style={{ backgroundColor: 'var(--skeleton-base-dim)' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (repositoryGroupsError && repositoryGroups.length === 0) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center gap-3 rounded-sm border border-dashed border-border px-8 py-16">
|
|
<div className="mb-1 flex size-12 items-center justify-center rounded-sm border border-border bg-surface-raised">
|
|
<FolderGit2 className="size-6 text-text-muted" />
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="mb-1 text-sm text-text-secondary">Failed to load projects</p>
|
|
<p className="max-w-xl text-xs text-text-muted">{repositoryGroupsError}</p>
|
|
</div>
|
|
<button
|
|
onClick={() => void fetchRepositoryGroups()}
|
|
className="rounded-sm border border-border bg-surface-raised px-3 py-1.5 text-xs text-text-secondary transition-colors hover:border-border-emphasis hover:text-text"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (filteredRepos.length === 0 && searchQuery.trim()) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center rounded-sm border border-dashed border-border px-8 py-16">
|
|
<div className="mb-4 flex size-12 items-center justify-center rounded-sm border border-border bg-surface-raised">
|
|
<Search className="size-6 text-text-muted" />
|
|
</div>
|
|
<p className="mb-1 text-sm text-text-secondary">No projects found</p>
|
|
<p className="text-xs text-text-muted">No matches for "{searchQuery}"</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (repositoryGroups.length === 0) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center rounded-sm border border-dashed border-border px-8 py-16">
|
|
<div className="mb-4 flex size-12 items-center justify-center rounded-sm border border-border bg-surface-raised">
|
|
<FolderGit2 className="size-6 text-text-muted" />
|
|
</div>
|
|
<p className="mb-1 text-sm text-text-secondary">No projects found</p>
|
|
<p className="font-mono text-xs text-text-muted">~/.claude/projects/</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-4">
|
|
{!searchQuery.trim() && <NewProjectCard />}
|
|
{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 (
|
|
<RepositoryCard
|
|
key={repo.id}
|
|
repo={repo}
|
|
onClick={() => {
|
|
selectRepository(repo.id);
|
|
openTeamsTab();
|
|
}}
|
|
isHighlighted={!!searchQuery.trim()}
|
|
taskCounts={globalTasksLoading ? undefined : counts}
|
|
tasksLoading={globalTasksLoading}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{canLoadMore && (
|
|
<div className="flex justify-center">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setVisibleProjects((prev) => prev + LOAD_MORE_STEP)}
|
|
>
|
|
Load more
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// =============================================================================
|
|
// Dashboard View
|
|
// =============================================================================
|
|
|
|
export const DashboardView = (): React.JSX.Element => {
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const openTeamsTab = useStore((s) => s.openTeamsTab);
|
|
|
|
return (
|
|
<div className="relative flex-1 overflow-auto bg-surface">
|
|
{/* Spotlight gradient background */}
|
|
<div
|
|
className="pointer-events-none absolute inset-x-0 top-0 h-[600px] bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(99,102,241,0.08),transparent)]"
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
{/* Content */}
|
|
<div className="relative mx-auto max-w-5xl px-8 py-12">
|
|
{/* CLI Status Banner */}
|
|
<CliStatusBanner />
|
|
|
|
{/* Team select + Search */}
|
|
<div className="mb-12 flex items-center justify-center gap-3">
|
|
<button
|
|
onClick={openTeamsTab}
|
|
className="flex shrink-0 items-center gap-2 rounded-sm border border-border bg-surface-raised px-4 py-3 text-sm text-text-secondary transition-all duration-200 hover:border-zinc-500 hover:text-text"
|
|
>
|
|
<Users className="size-4" />
|
|
Select Team
|
|
</button>
|
|
<span className="shrink-0 text-xs text-text-muted">or</span>
|
|
<div className="flex-1">
|
|
<CommandSearch value={searchQuery} onChange={setSearchQuery} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Section header */}
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h2 className="text-xs font-medium uppercase tracking-wider text-text-muted">
|
|
{searchQuery.trim() ? 'Search Results' : 'Recent Projects'}
|
|
</h2>
|
|
{searchQuery.trim() && (
|
|
<button
|
|
onClick={() => setSearchQuery('')}
|
|
className="text-xs text-text-muted transition-colors hover:text-text-secondary"
|
|
>
|
|
Clear search
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Projects Grid */}
|
|
<ProjectsGrid searchQuery={searchQuery} />
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|