Split app/styles.css from ~40k lines down to ~4.5k. Created 56 co-located
component CSS files in app/components/, each imported by its owning .tsx.
The remainder of styles.css holds genuinely global rules (design tokens,
.btn/.card/.modal/.form-input primitives, cross-component @media overrides).
- Lazy-load 13 heavy views (AgentsView, RoadmapsView, NodesView, etc.) via
React.lazy + Suspense; prefetch all chunks on idle so first navigation is
instant. Initial JS bundle: 1.58 MB → 1.16 MB (-26%). Initial CSS bundle:
635 kB → 471 kB (-26%); the rest splits into 13 per-view chunks.
- Add app/test/cssFixture.ts exposing loadAllAppCss() + loadAllAppCssBaseOnly()
so CSS regression tests load the full per-component bundle (mirroring Vite
source order). Migrate 30+ tests off direct readFileSync('../styles.css').
- Enable test.css: { include: [/.+/] } in vitest.config.ts so component CSS
imports actually inject styles in jsdom (fixes getComputedStyle assertions).
- Add ESLint rule (no-restricted-syntax) banning direct styles.css reads in
dashboard test files; points at loadAllAppCss() instead.
- Restore lost utility classes (.text-muted, .text-secondary, .text-dim,
.form-input) and rescue dropped chat tool-call rules into QuickChatFAB.css.
- Mobile fixes along the way: scroll containment for view containers
(min-height:0 + -webkit-overflow-scrolling), QuickChatFAB full-screen on
mobile (with safe-area-inset for iOS home bar), AgentsView single-row
header layout, ActivityLogModal close button on right, model-combobox
z-index above the mobile quick-chat panel.
- Bug fix: SkillsView toggle was display:none which hid the input from the
accessibility tree; replaced with the visually-hidden pattern so screen
readers + getByRole still find the checkbox.
- Bug fix: standalone Delete button in TaskDetailModal for triage-column
tasks (Actions dropdown is hidden in triage state, so previously no way
to delete a freshly-created task without status change first).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
261 lines
8.9 KiB
TypeScript
261 lines
8.9 KiB
TypeScript
import { memo, useCallback, useState } from "react";
|
|
import { Play, Pause, AlertCircle, Loader2, Trash2, Folder, ArrowRight } from "lucide-react";
|
|
import "./ProjectCard.css";
|
|
import type { RegisteredProject, ProjectHealth, ProjectStatus } from "@fusion/core";
|
|
import type { NodeInfo } from "../api";
|
|
|
|
export interface ProjectCardProps {
|
|
project: RegisteredProject;
|
|
health: ProjectHealth | null;
|
|
onSelect: (project: RegisteredProject) => void;
|
|
onPause: (project: RegisteredProject) => void;
|
|
onResume: (project: RegisteredProject) => void;
|
|
onRemove: (project: RegisteredProject) => void;
|
|
node?: NodeInfo;
|
|
/** Fallback node name when the node object is not available (e.g., for remote projects) */
|
|
nodeNameFallback?: string;
|
|
isLoading?: boolean;
|
|
}
|
|
|
|
const STATUS_CONFIG: Record<ProjectStatus, { label: string; color: string; icon: typeof Play }> = {
|
|
active: { label: "Active", color: "var(--success)", icon: Play },
|
|
paused: { label: "Paused", color: "var(--warning)", icon: Pause },
|
|
errored: { label: "Error", color: "var(--color-error)", icon: AlertCircle },
|
|
initializing: { label: "Initializing", color: "var(--info)", icon: Loader2 },
|
|
};
|
|
|
|
function formatRelativeTime(timestamp: string | undefined): string {
|
|
if (!timestamp) return "Never";
|
|
|
|
const date = new Date(timestamp);
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - date.getTime();
|
|
const diffMins = Math.floor(diffMs / 60000);
|
|
const diffHours = Math.floor(diffMs / 3600000);
|
|
const diffDays = Math.floor(diffMs / 86400000);
|
|
|
|
if (diffMins < 1) return "Just now";
|
|
if (diffMins < 60) return `${diffMins}m ago`;
|
|
if (diffHours < 24) return `${diffHours}h ago`;
|
|
if (diffDays < 7) return `${diffDays}d ago`;
|
|
return date.toLocaleDateString();
|
|
}
|
|
|
|
function truncatePath(path: string, maxLength: number = 40): string {
|
|
if (path.length <= maxLength) return path;
|
|
const start = path.slice(0, Math.floor(maxLength / 2) - 2);
|
|
const end = path.slice(-Math.floor(maxLength / 2) + 2);
|
|
return `${start}...${end}`;
|
|
}
|
|
|
|
function areProjectCardPropsEqual(previous: ProjectCardProps, next: ProjectCardProps): boolean {
|
|
if (previous.project.id !== next.project.id) return false;
|
|
if (previous.project.status !== next.project.status) return false;
|
|
if (previous.project.name !== next.project.name) return false;
|
|
if (previous.project.path !== next.project.path) return false;
|
|
if (previous.project.lastActivityAt !== next.project.lastActivityAt) return false;
|
|
if (previous.isLoading !== next.isLoading) return false;
|
|
|
|
// Compare health
|
|
const prevHealth = previous.health;
|
|
const nextHealth = next.health;
|
|
if (!prevHealth && !nextHealth) return true;
|
|
if (!prevHealth || !nextHealth) return false;
|
|
|
|
if (
|
|
prevHealth.activeTaskCount !== nextHealth.activeTaskCount ||
|
|
prevHealth.inFlightAgentCount !== nextHealth.inFlightAgentCount ||
|
|
prevHealth.totalTasksCompleted !== nextHealth.totalTasksCompleted ||
|
|
prevHealth.totalTasksFailed !== nextHealth.totalTasksFailed ||
|
|
prevHealth.status !== nextHealth.status
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const prevNode = previous.node;
|
|
const nextNode = next.node;
|
|
if (!prevNode && !nextNode) {
|
|
// Compare fallback names
|
|
if (previous.nodeNameFallback !== next.nodeNameFallback) return false;
|
|
return true;
|
|
}
|
|
if (!prevNode || !nextNode) return false;
|
|
|
|
return (
|
|
prevNode.id === nextNode.id
|
|
&& prevNode.name === nextNode.name
|
|
&& prevNode.status === nextNode.status
|
|
&& prevNode.type === nextNode.type
|
|
);
|
|
}
|
|
|
|
function ProjectCardInner({
|
|
project,
|
|
health,
|
|
onSelect,
|
|
onPause,
|
|
onResume,
|
|
onRemove,
|
|
node,
|
|
nodeNameFallback,
|
|
isLoading = false,
|
|
}: ProjectCardProps) {
|
|
const [removeArmed, setRemoveArmed] = useState(false);
|
|
const statusConfig = STATUS_CONFIG[project.status];
|
|
const StatusIcon = statusConfig.icon;
|
|
|
|
const handleSelect = useCallback(() => {
|
|
onSelect(project);
|
|
}, [onSelect, project]);
|
|
|
|
const handlePause = useCallback((e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onPause(project);
|
|
}, [onPause, project]);
|
|
|
|
const handleResume = useCallback((e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onResume(project);
|
|
}, [onResume, project]);
|
|
|
|
const handleRemove = useCallback((e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
if (!removeArmed) {
|
|
setRemoveArmed(true);
|
|
return;
|
|
}
|
|
|
|
onRemove(project);
|
|
setRemoveArmed(false);
|
|
}, [removeArmed, onRemove, project]);
|
|
|
|
const isPaused = project.status === "paused";
|
|
const isErrored = project.status === "errored";
|
|
const isInitializing = project.status === "initializing";
|
|
|
|
return (
|
|
<div
|
|
className={`project-card ${isLoading ? "project-card-loading" : ""} ${isErrored ? "project-card-errored" : ""}`}
|
|
onClick={handleSelect}
|
|
data-project-id={project.id}
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
handleSelect();
|
|
}
|
|
}}
|
|
>
|
|
<div className="project-card-header">
|
|
<div className="project-card-icon">
|
|
<Folder size={20} />
|
|
</div>
|
|
<div className="project-card-title-section">
|
|
<h3 className="project-card-name" title={project.name}>
|
|
{project.name}
|
|
</h3>
|
|
{(node || nodeNameFallback) && (
|
|
<span className="node-badge" title={`Assigned node: ${node?.name ?? nodeNameFallback}`}>
|
|
on: {node?.name ?? nodeNameFallback}
|
|
</span>
|
|
)}
|
|
<span className="project-card-path" title={project.path}>
|
|
{truncatePath(project.path)}
|
|
</span>
|
|
</div>
|
|
<div
|
|
className="project-card-status-badge"
|
|
style={{ color: statusConfig.color, borderColor: statusConfig.color }}
|
|
>
|
|
<StatusIcon size={12} className={isInitializing ? "animate-spin" : ""} />
|
|
<span>{statusConfig.label}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="project-card-health">
|
|
{health && (
|
|
<>
|
|
<div className="project-card-metric">
|
|
<span className="project-card-metric-value">{health.activeTaskCount}</span>
|
|
<span className="project-card-metric-label">Active Tasks</span>
|
|
</div>
|
|
<div className="project-card-metric">
|
|
<span className="project-card-metric-value">{health.inFlightAgentCount}</span>
|
|
<span className="project-card-metric-label">Agents</span>
|
|
</div>
|
|
<div className="project-card-metric">
|
|
<span className="project-card-metric-value">{health.totalTasksCompleted}</span>
|
|
<span className="project-card-metric-label">Completed</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
{!health && (
|
|
<div className="project-card-metric project-card-metric-empty">
|
|
<span className="project-card-metric-label">No health data available</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="project-card-footer">
|
|
<div className="project-card-activity">
|
|
<span className="project-card-activity-label">Last activity:</span>
|
|
<span className="project-card-activity-time">
|
|
{formatRelativeTime(project.lastActivityAt || health?.lastActivityAt)}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="project-card-actions">
|
|
{isPaused ? (
|
|
<button
|
|
className="project-card-action project-card-action-resume"
|
|
onClick={handleResume}
|
|
disabled={isLoading}
|
|
title="Resume project"
|
|
aria-label="Resume project"
|
|
>
|
|
<Play size={14} />
|
|
<span>Resume</span>
|
|
</button>
|
|
) : (
|
|
<button
|
|
className="project-card-action project-card-action-pause"
|
|
onClick={handlePause}
|
|
disabled={isLoading || isInitializing}
|
|
title={isInitializing ? "Cannot pause while initializing" : "Pause project"}
|
|
aria-label="Pause project"
|
|
>
|
|
<Pause size={14} />
|
|
<span>Pause</span>
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
className="project-card-action project-card-action-open"
|
|
onClick={handleSelect}
|
|
disabled={isLoading}
|
|
title="Open project"
|
|
aria-label="Open project"
|
|
>
|
|
<ArrowRight size={14} />
|
|
<span>Open</span>
|
|
</button>
|
|
|
|
<button
|
|
className={`project-card-action project-card-action-remove ${removeArmed ? "is-armed" : ""}`}
|
|
onClick={handleRemove}
|
|
disabled={isLoading}
|
|
title={removeArmed ? "Confirm remove" : "Remove project"}
|
|
aria-label={removeArmed ? "Confirm remove project" : "Remove project"}
|
|
>
|
|
<Trash2 size={14} />
|
|
<span>{removeArmed ? "Confirm" : ""}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const ProjectCard = memo(ProjectCardInner, areProjectCardPropsEqual);
|