gadget/gadget-code/frontend/src/pages/Home.tsx
Rob Colbert 802184a487 fix: home sidebar flex column layout with independent scrolling sections
- Convert sidebar <aside> to flex column container (flex flex-col overflow-hidden min-h-0)
- Remove sidebar-level scroll (overflow-y-auto → overflow-hidden)
- Add min-h-0 to parent flex containers in both layout paths for proper height containment
- Reorder sidebar sections: Clock → Projects → Drones → Recent Chats
- Add shrink-0 to Clock component to keep it at intrinsic content size
- Convert Projects, Drones, and Recent Chats sections to flex-1 min-h-0 flex-col with scrollable inner content
- Pin section headers with shrink-0; list content scrolls independently via overflow-y-auto
- Pin Recent Chats hint/footer with shrink-0 below its scrollable list
- No logic changes — purely CSS/flexbox layout correction
2026-05-18 09:04:25 -04:00

522 lines
19 KiB
TypeScript

import { useState, useEffect, useRef } from "react";
import { Link, useNavigate } from "react-router-dom";
import type { User, Project, DroneRegistration, ChatSession, SubProcessStat } from "../lib/api";
import { projectApi, droneApi, chatSessionApi } from "../lib/api";
import { socketClient } from "../lib/socket";
import Clock from "../components/Clock";
import GadgetGrid from "../components/GadgetGrid";
import LogRenderer from "../components/LogRenderer";
import type { LogRendererEntry } from "../components/LogRenderer";
function SystemReady() {
return (
<div className="flex flex-col items-center">
<div className="mt-8 border-2 border-border-default p-6 rounded bg-bg-secondary">
<div className="font-mono text-text-secondary text-sm">
<div className="mb-1 text-text-muted">// SYSTEM READY</div>
<div className="mb-3">Please sign in to continue.</div>
<div className="text-text-muted mb-4">
Accounts are administered. Contact your administrator for access.
</div>
<a
href="/sign-in"
className="inline-block px-4 py-2 border border-border-highlight text-text-primary hover:bg-bg-tertiary hover:text-text-primary rounded transition-colors"
>
Sign In
</a>
</div>
</div>
</div>
);
}
interface DashboardSidebarProps {
onNavigate: (view: string, data?: unknown) => void;
selectedDrone: DroneRegistration | null;
onSelectDrone: (drone: DroneRegistration | null) => void;
}
function DroneInspector({
drone,
onClose,
}: {
drone: DroneRegistration;
onClose: () => void;
}) {
const navigate = useNavigate();
const [processStats, setProcessStats] = useState<SubProcessStat[]>([]);
const [logEntries, setLogEntries] = useState<LogRendererEntry[]>([]);
const mountedRef = useRef(true);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
mountedRef.current = true;
const regId = drone._id;
if (drone.status === 'offline') return;
socketClient.subscribeDrone(regId);
const handleLog = (data: { timestamp: string; level: string; component: string; message: string }) => {
if (!mountedRef.current) return;
setLogEntries((prev) => {
const next = [...prev, {
id: `insp-log-${prev.length}`,
timestamp: new Date(data.timestamp),
level: data.level,
component: data.component,
message: data.message,
}];
return next.length > 100 ? next.slice(next.length - 100) : next;
});
};
socketClient.on('drone:log', handleLog);
const poll = async () => {
if (!mountedRef.current) return;
const result = await socketClient.requestProcessStats(regId);
if (result.success && result.processes && mountedRef.current) {
setProcessStats(result.processes as SubProcessStat[]);
}
};
poll();
pollRef.current = setInterval(poll, 1000);
return () => {
mountedRef.current = false;
if (pollRef.current) clearInterval(pollRef.current);
socketClient.off('drone:log', handleLog);
socketClient.unsubscribeDrone(regId);
};
}, [drone._id, drone.status]);
const runningCount = processStats.filter((p) => p.status === 'running').length;
return (
<div className="flex-1 flex items-start justify-center p-8 overflow-y-auto">
<div className="max-w-lg w-full">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold">Drone Inspector</h2>
<button
onClick={onClose}
className="text-text-secondary hover:text-text-primary text-sm"
>
Back to Dashboard
</button>
</div>
<div className="bg-bg-secondary border border-border-default rounded p-4 space-y-4">
<div>
<div className="text-sm text-text-muted">Hostname</div>
<div className="font-mono text-text-primary">{drone.hostname}</div>
</div>
<div>
<div className="text-sm text-text-muted">Workspace</div>
<div className="font-mono text-text-primary text-sm truncate">
{drone.workspaceDir}
</div>
</div>
<div>
<div className="text-sm text-text-muted">Status</div>
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full ${
drone.status === "available"
? "bg-green-500"
: drone.status === "busy"
? "bg-yellow-500"
: "bg-gray-600"
}`}
/>
<span className="text-text-primary capitalize">
{drone.status}
</span>
</div>
</div>
<div>
<div className="text-sm text-text-muted">SubProcesses</div>
<div className="text-text-primary text-sm">
{runningCount > 0
? `${runningCount} running`
: processStats.length > 0
? 'All stopped'
: 'No managed processes'}
</div>
</div>
<div>
<div className="text-sm text-text-muted">Registered</div>
<div className="text-text-primary text-sm">
{new Date(drone.createdAt).toLocaleString()}
</div>
</div>
</div>
{drone.status !== 'offline' && (
<div className="mt-4">
<button
onClick={() => navigate('/drones', { state: { droneId: drone._id } })}
className="w-full px-4 py-2 border border-border-default text-text-secondary hover:bg-bg-tertiary hover:text-text-primary rounded transition-colors text-sm"
>
Open in Drone Manager
</button>
</div>
)}
{drone.status !== 'offline' && logEntries.length > 0 && (
<div className="mt-4 border border-border-default rounded overflow-hidden max-h-48">
<div className="p-2 bg-bg-tertiary border-b border-border-subtle">
<h3 className="text-xs font-semibold text-text-secondary uppercase tracking-wider">
Live Log
</h3>
</div>
<div className="h-40 overflow-y-auto">
<LogRenderer logs={logEntries.slice(-50)} />
</div>
</div>
)}
</div>
</div>
);
}
function DashboardSidebar({
onNavigate: _onNavigate,
selectedDrone,
onSelectDrone,
}: DashboardSidebarProps) {
const navigate = useNavigate();
const [projects, setProjects] = useState<Project[]>([]);
const [drones, setDrones] = useState<DroneRegistration[]>([]);
const [recentChats, setRecentChats] = useState<ChatSession[]>([]);
const [loading, setLoading] = useState(true);
const [noDroneMessage, setNoDroneMessage] = useState<string | null>(null);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [projectsData, dronesData, recentChatsData] = await Promise.all([
projectApi.getAll(),
droneApi.getAll(),
chatSessionApi.getAll(undefined, 10),
]);
setProjects(projectsData);
setDrones(dronesData);
setRecentChats(recentChatsData);
} catch (err) {
console.error("Failed to load dashboard data", err);
} finally {
setLoading(false);
}
};
const getProjectName = (session: ChatSession): string => {
const projectRef = session.project;
if (typeof projectRef === 'object' && projectRef !== null && 'name' in projectRef) {
return projectRef.name;
}
return 'Unknown Project';
};
const getProjectId = (session: ChatSession): string => {
const projectRef = session.project;
if (typeof projectRef === 'object' && projectRef !== null && '_id' in projectRef) {
return projectRef._id;
}
return typeof projectRef === 'string' ? projectRef : '';
};
const handleOpenChat = (session: ChatSession) => {
if (!selectedDrone) {
setNoDroneMessage('Select a drone first to resume a chat');
setTimeout(() => setNoDroneMessage(null), 3000);
return;
}
const projectId = getProjectId(session);
navigate(`/projects/${projectId}/chat-session/${session._id}`, {
state: {
drone: selectedDrone,
project: typeof session.project === 'object' ? session.project : undefined,
},
});
};
const modeBadgeColors: Record<string, string> = {
plan: 'bg-blue-500/20 text-blue-400',
build: 'bg-brand/20 text-brand',
test: 'bg-green-500/20 text-green-400',
ship: 'bg-purple-500/20 text-purple-400',
dev: 'bg-orange-500/20 text-orange-400',
};
const formatRelativeTime = (dateStr: string): string => {
const now = Date.now();
const then = new Date(dateStr).getTime();
const diffMs = now - then;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
if (diffSec < 60) return 'just now';
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHr < 24) return `${diffHr}h ago`;
if (diffDay < 7) return `${diffDay}d ago`;
return new Date(dateStr).toLocaleDateString();
};
return (
<aside className="w-64 border-l border-border-subtle bg-bg-secondary flex flex-col overflow-hidden min-h-0">
<Clock />
{/* Projects */}
<div className="flex-1 min-h-0 flex flex-col border-t border-border-subtle">
<div className="shrink-0 px-3 pt-3 pb-2 flex items-center justify-between text-xs font-semibold text-text-muted uppercase tracking-wider">
<span>Projects</span>
<Link
to="/projects"
className="text-brand hover:text-red-400 transition-colors"
>
[+]
</Link>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-3 pb-3">
{loading ? (
<p className="text-sm text-text-muted px-2">Loading...</p>
) : projects.length === 0 ? (
<p className="text-sm text-text-muted px-2">No projects yet.</p>
) : (
<div className="space-y-1">
{projects.map((project) => (
<button
key={project._id}
onClick={() => navigate(`/projects/${project.slug}`)}
className="w-full text-left px-2 py-1.5 text-sm text-text-secondary hover:bg-bg-tertiary hover:text-text-primary rounded truncate transition-colors"
>
{project.name}
</button>
))}
</div>
)}
</div>
</div>
{/* Drones */}
<div className="flex-1 min-h-0 flex flex-col border-t border-border-subtle">
<div className="shrink-0 px-3 pt-3 pb-2 flex items-center justify-between text-xs font-semibold text-text-muted uppercase tracking-wider">
<span>Drones</span>
<Link
to="/drones"
className="text-text-secondary hover:text-text-primary transition-colors"
title="Drone Manager"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</Link>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-3 pb-3">
{loading ? (
<p className="text-sm text-text-muted px-2">Loading...</p>
) : drones.length === 0 ? (
<p className="text-sm text-text-muted px-2">No drones available.</p>
) : (
<div className="space-y-1">
{drones.map((drone) => (
<button
key={drone._id}
onClick={() => onSelectDrone(drone)}
className={`w-full text-left px-2 py-1 rounded transition-colors ${
selectedDrone?._id === drone._id
? "bg-brand text-white"
: "text-text-secondary hover:bg-bg-tertiary hover:text-text-primary"
}`}
>
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full shrink-0 ${
drone.status === "available"
? "bg-green-500"
: "bg-yellow-500"
}`}
/>
<div className="overflow-hidden">
<div className="text-sm truncate">{drone.hostname}</div>
{drone.workspaceDir && (
<div
className={`text-xs truncate ${
selectedDrone?._id === drone._id
? "text-white/70"
: "text-text-muted"
}`}
>
{drone.workspaceDir.length > 24
? "..." + drone.workspaceDir.slice(-21)
: drone.workspaceDir}
</div>
)}
</div>
</div>
</button>
))}
</div>
)}
</div>
</div>
{/* Recent Chats */}
<div className="flex-1 min-h-0 flex flex-col border-t border-border-subtle">
<div className="shrink-0 px-3 pt-3 pb-2 text-xs font-semibold text-text-muted uppercase tracking-wider">
Recent Chats
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-3 pb-3">
{loading ? (
<p className="text-sm text-text-muted px-2">Loading...</p>
) : recentChats.length === 0 ? (
<p className="text-sm text-text-muted px-2">No recent chats.</p>
) : (
<div className="space-y-1">
{recentChats.map((session) => (
<button
key={session._id}
onClick={() => handleOpenChat(session)}
className="w-full text-left px-2 py-1.5 text-sm text-text-secondary hover:bg-bg-tertiary hover:text-text-primary rounded transition-colors"
>
<div className="flex items-center justify-between gap-1">
<span className="truncate font-medium text-xs">{session.name || 'Untitled'}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium shrink-0 ${modeBadgeColors[session.mode] || 'bg-gray-500/20 text-gray-400'}`}>
{session.mode}
</span>
</div>
<div className="flex items-center justify-between gap-1 mt-0.5">
<span className="text-[11px] text-text-muted truncate">{getProjectName(session)}</span>
<span className="text-[10px] text-text-muted shrink-0">
{formatRelativeTime(session.lastMessageAt || session.createdAt)}
</span>
</div>
</button>
))}
</div>
)}
</div>
<div className="shrink-0 px-3 pb-3">
{noDroneMessage && (
<p className="text-xs text-yellow-400 mt-2 px-2">{noDroneMessage}</p>
)}
{selectedDrone && (
<p className="text-[10px] text-text-muted mt-2 px-2 italic">
Select a chat to resume with {selectedDrone.hostname}
</p>
)}
</div>
</div>
</aside>
);
}
interface HomeProps {
user: User | null;
}
export default function Home({ user }: HomeProps) {
const navigate = useNavigate();
const [selectedDrone, setSelectedDrone] = useState<DroneRegistration | null>(
null,
);
if (!user) {
return (
<div className="relative flex-1 flex bg-bg-primary overflow-hidden">
<div className="absolute inset-0">
<GadgetGrid />
</div>
<div className="relative z-10 flex items-center justify-center p-8">
<SystemReady />
</div>
</div>
);
}
const mainContent = selectedDrone ? (
<div className="relative z-10 flex-1 flex min-h-0">
<DroneInspector
drone={selectedDrone}
onClose={() => setSelectedDrone(null)}
/>
<DashboardSidebar
onNavigate={(view) => {
if (view === "project" && typeof navigate === "function") {
navigate("/projects");
}
}}
selectedDrone={selectedDrone}
onSelectDrone={setSelectedDrone}
/>
</div>
) : (
<div className="relative z-10 flex-1 flex flex-col min-h-0">
<div className="flex-1 flex min-h-0">
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center">
<h1 className="text-2xl font-semibold mb-4">
Welcome, {user.displayName}!
</h1>
<p className="text-text-secondary mb-4">
Your dashboard is under construction.
</p>
<p className="text-text-muted text-sm mb-6">
Select a project or chat session from the sidebar to get started.
</p>
<div className="flex gap-3 justify-center">
<Link
to="/projects"
className="px-4 py-2 border border-border-default text-text-secondary hover:bg-bg-tertiary hover:text-text-primary rounded transition-colors"
>
Open Project Manager
</Link>
</div>
</div>
</div>
<DashboardSidebar
onNavigate={(view) => {
if (view === "project" && typeof navigate === "function") {
navigate("/projects");
}
}}
selectedDrone={selectedDrone}
onSelectDrone={setSelectedDrone}
/>
</div>
</div>
);
return (
<div className="relative flex-1 flex bg-bg-primary overflow-hidden">
<div className="absolute inset-0">
<GadgetGrid />
</div>
{mainContent}
</div>
);
}