agent-ecosystem/src/renderer/components/dashboard/DashboardView.tsx
iliya 238a91f5f3 Merge remote-tracking branch 'origin/main' into improvements
# Conflicts:
#	docs/iterations/diff-view/phase-1-read-only-diff.md
#	docs/iterations/diff-view/phase-2-accept-reject.md
#	docs/iterations/diff-view/phase-3-per-task-scoping.md
#	docs/iterations/diff-view/phase-4-enhanced-features.md
#	src/main/ipc/configValidation.ts
#	src/main/services/infrastructure/ConfigManager.ts
#	src/main/services/team/TeamProvisioningService.ts
#	src/renderer/components/chat/items/TeammateMessageItem.tsx
#	src/renderer/components/dashboard/DashboardView.tsx
#	src/renderer/components/settings/hooks/useSettingsConfig.ts
#	src/renderer/components/settings/hooks/useSettingsHandlers.ts
#	src/renderer/components/sidebar/SidebarTaskItem.tsx
#	src/renderer/components/team/TeamDetailView.tsx
#	src/renderer/components/team/activity/ActivityItem.tsx
#	src/renderer/components/team/dialogs/TaskCommentsSection.tsx
#	src/renderer/components/team/members/MemberExecutionLog.tsx
#	src/renderer/utils/taskGrouping.ts
#	src/shared/types/notifications.ts
2026-02-24 21:26:41 +02:00

596 lines
21 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, { useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
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 { formatShortcut } from '@renderer/utils/stringUtils';
import { createLogger } from '@shared/utils/logger';
import { useShallow } from 'zustand/react/shallow';
const logger = createLogger('Component:DashboardView');
import { formatDistanceToNow } from 'date-fns';
import { Command, FolderGit2, FolderOpen, GitBranch, Search, Settings, Users } from 'lucide-react';
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.key === 'k') {
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);
return (
<button
onClick={onClick}
className={`group relative flex min-h-[120px] flex-col overflow-hidden rounded-lg border 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'
} `}
>
{/* 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">
<FolderGit2 className="size-4 text-text-secondary transition-colors group-hover:text-text" />
</div>
<h3 className="min-w-0 truncate text-sm font-medium text-text transition-colors duration-200 group-hover:text-text">
{repo.name}
</h3>
</div>
{/* Project path - monospace, muted */}
<p className="mb-auto truncate font-mono text-[10px] text-text-muted">{formattedPath}</p>
{/* 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-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 } = useStore(
useShallow((s) => ({
repositoryGroups: s.repositoryGroups,
fetchRepositoryGroups: s.fetchRepositoryGroups,
}))
);
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);
return;
}
// No match — refresh repository groups and retry
await fetchRepositoryGroups();
const refreshedGroups = useStore.getState().repositoryGroups;
const matchAfterRefresh = findMatchingWorktree(refreshedGroups, selectedPath);
if (matchAfterRefresh) {
navigateToMatch(matchAfterRefresh);
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.
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: [],
createdAt: now,
},
],
name: folderName,
mostRecentSession: undefined,
totalSessions: 0,
};
useStore.setState((state) => ({
repositoryGroups: [syntheticGroup, ...state.repositoryGroups],
}));
navigateToMatch({ repoId: encodedId, worktreeId: encodedId });
} 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 ProjectsGrid = ({
searchQuery,
maxProjects = 12,
}: Readonly<ProjectsGridProps>): React.JSX.Element => {
const {
repositoryGroups,
repositoryGroupsLoading,
fetchRepositoryGroups,
selectRepository,
globalTasks,
globalTasksLoading,
fetchAllTasks,
openTeamsTab,
} = useStore(
useShallow((s) => ({
repositoryGroups: s.repositoryGroups,
repositoryGroupsLoading: s.repositoryGroupsLoading,
fetchRepositoryGroups: s.fetchRepositoryGroups,
selectRepository: s.selectRepository,
globalTasks: s.globalTasks,
globalTasksLoading: s.globalTasksLoading,
fetchAllTasks: s.fetchAllTasks,
openTeamsTab: s.openTeamsTab,
}))
);
const hasFetchedTasksRef = React.useRef(false);
useEffect(() => {
if (repositoryGroups.length === 0) {
void fetchRepositoryGroups();
}
if (!hasFetchedTasksRef.current) {
hasFetchedTasksRef.current = true;
void fetchAllTasks();
}
}, [repositoryGroups.length, fetchRepositoryGroups, fetchAllTasks]);
const taskCountsMap = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]);
// Filter projects based on search query
const filteredRepos = useMemo(() => {
if (!searchQuery.trim()) {
return repositoryGroups.slice(0, maxProjects);
}
const query = searchQuery.toLowerCase().trim();
return repositoryGroups
.filter((repo) => {
// 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;
})
.slice(0, maxProjects);
}, [repositoryGroups, searchQuery, maxProjects]);
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 (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 &quot;{searchQuery}&quot;</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="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-4">
{filteredRepos.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}
/>
);
})}
{!searchQuery.trim() && <NewProjectCard />}
</div>
);
};
// =============================================================================
// Dashboard View
// =============================================================================
export const DashboardView = (): React.JSX.Element => {
const [searchQuery, setSearchQuery] = useState('');
const { openSettingsTab, openTeamsTab } = useStore(
useShallow((s) => ({
openSettingsTab: s.openSettingsTab,
openTeamsTab: 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">
{/* 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>
<div className="flex items-center gap-3">
{searchQuery.trim() && (
<button
onClick={() => setSearchQuery('')}
className="text-xs text-text-muted transition-colors hover:text-text-secondary"
>
Clear search
</button>
)}
<button
onClick={() => openSettingsTab('general')}
className="flex items-center gap-1.5 text-xs text-text-muted transition-colors hover:text-text-secondary"
title="Change Claude data folder"
>
<Settings className="size-3" />
Change default folder
</button>
</div>
</div>
{/* Projects Grid */}
<ProjectsGrid searchQuery={searchQuery} />
</div>
</div>
);
};