6f35eb77ae
- Add HomePage with open sessions grid, projects overview, and session composer - Add SettingsPage with tabs for General, SSH Keys, Tool Types, Tool Configs - Update navigation to Home, Projects, Settings - Redirect legacy routes (/sessions, /ssh-keys, /tool-types, /tool-configs) - Apply Inter font and warm editorial styling - Update tests for new dashboard and projects pages Quality gates: typecheck pass, lint pass, 15/15 tests pass Refs: openspec/changes/ui-redesign-home-settings
339 lines
13 KiB
TypeScript
339 lines
13 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
|
|
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
|
import { createInstance, getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, type Session as SessionApi } from "../api/sessions";
|
|
import { listProjects } from "../api/projects";
|
|
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
|
import { updateUserConfig } from "../api/settings";
|
|
import type { Project } from "../types";
|
|
import { Icon } from "../components/icon";
|
|
|
|
type HomeStatus = "loading" | "ready" | "error";
|
|
|
|
const summaryCards = [
|
|
{ label: "Open sessions", key: "openSessions" },
|
|
{ label: "Projects", key: "projects" },
|
|
{ label: "Repositories", key: "repositories" },
|
|
] as const;
|
|
|
|
type SessionView = SessionApi;
|
|
|
|
export const HomePage = () => {
|
|
const navigate = useNavigate();
|
|
const [status, setStatus] = useState<HomeStatus>("loading");
|
|
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
|
const [sessions, setSessions] = useState<SessionView[]>([]);
|
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
|
const [selectedProject, setSelectedProject] = useState("");
|
|
const [selectedRepo, setSelectedRepo] = useState("");
|
|
const [selectedToolType, setSelectedToolType] = useState("");
|
|
const [displayName, setDisplayName] = useState("");
|
|
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle");
|
|
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
|
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
|
|
|
const loadHome = useCallback(async () => {
|
|
setStatus("loading");
|
|
try {
|
|
const [dashboard, sessionData, projectData, toolTypeData] = await Promise.all([
|
|
getDashboardSummary(),
|
|
getUserSessions(),
|
|
listProjects(),
|
|
listToolTypes(),
|
|
]);
|
|
setSummary(dashboard);
|
|
setSessions(sessionData as SessionView[]);
|
|
setProjects(projectData);
|
|
setToolTypes(toolTypeData);
|
|
setStatus("ready");
|
|
} catch {
|
|
setStatus("error");
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadHome();
|
|
}, [loadHome]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedProject) {
|
|
setRepositories([]);
|
|
return;
|
|
}
|
|
|
|
const loadRepos = async () => {
|
|
try {
|
|
const data = await listRepositories(selectedProject);
|
|
setRepositories(data);
|
|
} catch {
|
|
setRepositories([]);
|
|
}
|
|
};
|
|
|
|
void loadRepos();
|
|
}, [selectedProject]);
|
|
|
|
const activeSessions = useMemo(
|
|
() => safeSessions.filter((session) => ["running", "building", "pending"].includes(session.status)),
|
|
[safeSessions]
|
|
);
|
|
|
|
const recentSessions = useMemo(
|
|
() => safeSessions.filter((session) => ["stopped", "error"].includes(session.status)).slice(0, 5),
|
|
[safeSessions]
|
|
);
|
|
|
|
const handleCreate = async (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
if (!selectedProject || !selectedRepo || !selectedToolType) return;
|
|
|
|
setSaveState("saving");
|
|
try {
|
|
const instance = await createInstance(selectedProject, selectedRepo, selectedToolType, displayName || undefined);
|
|
await startInstance(selectedProject, selectedRepo, instance.id);
|
|
await updateUserConfig({ last_session_id: instance.id });
|
|
setDisplayName("");
|
|
setSelectedProject("");
|
|
setSelectedRepo("");
|
|
setSelectedToolType("");
|
|
setSaveState("idle");
|
|
await loadHome();
|
|
} catch {
|
|
setSaveState("error");
|
|
}
|
|
};
|
|
|
|
const handleOpen = (session: SessionView) => {
|
|
if (session.url) {
|
|
window.open(session.url, "_blank", "noopener,noreferrer");
|
|
return;
|
|
}
|
|
if (session.tool_type_interfaces.includes("terminal")) {
|
|
navigate(`/instances/${session.id}/terminal`);
|
|
return;
|
|
}
|
|
navigate(`/projects/${session.project_id}`);
|
|
};
|
|
|
|
const handleStop = async (session: SessionView) => {
|
|
setActionBusy(session.id);
|
|
try {
|
|
await stopInstance(session.project_id, session.repository_id, session.id);
|
|
await loadHome();
|
|
} finally {
|
|
setActionBusy(null);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (session: SessionView) => {
|
|
setActionBusy(session.id);
|
|
try {
|
|
await deleteInstance(session.project_id, session.repository_id, session.id);
|
|
await loadHome();
|
|
} finally {
|
|
setActionBusy(null);
|
|
}
|
|
};
|
|
|
|
const handleRecreateTunnel = async (session: SessionView) => {
|
|
setActionBusy(session.id);
|
|
try {
|
|
await recreateInstanceTunnel(session.project_id, session.repository_id, session.id);
|
|
await loadHome();
|
|
} finally {
|
|
setActionBusy(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<section className="stack home-page">
|
|
<header className="home-hero card">
|
|
<div className="stack-sm">
|
|
<p className="eyebrow">Workspace overview</p>
|
|
<h1>Home</h1>
|
|
<p className="muted">Open sessions, available projects, and the fastest path back into work.</p>
|
|
</div>
|
|
<div className="home-hero-actions">
|
|
<button className="primary-button" type="button" onClick={() => navigate("/projects")}>New Project</button>
|
|
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Settings</button>
|
|
</div>
|
|
</header>
|
|
|
|
{status === "loading" && <p className="muted">Loading overview...</p>}
|
|
|
|
{status === "error" && (
|
|
<div className="card stack">
|
|
<p>Unable to load your workspace overview.</p>
|
|
<button className="secondary-button" type="button" onClick={() => void loadHome()}>
|
|
<Icon name="refresh" size="sm" />
|
|
Retry
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{status === "ready" && summary && (
|
|
<>
|
|
<div className="home-summary-grid">
|
|
{summaryCards.map((card) => (
|
|
<article className="card home-summary-card" key={card.label}>
|
|
<p className="card-label">{card.label}</p>
|
|
<p className="card-value">
|
|
{card.key === "openSessions"
|
|
? activeSessions.length
|
|
: card.key === "projects"
|
|
? summary.projects
|
|
: summary.repositories}
|
|
</p>
|
|
</article>
|
|
))}
|
|
</div>
|
|
|
|
<section className="card stack home-section">
|
|
<div className="page-header">
|
|
<div>
|
|
<p className="eyebrow">Open sessions</p>
|
|
<h2>{activeSessions.length}</h2>
|
|
</div>
|
|
</div>
|
|
{activeSessions.length === 0 ? (
|
|
<p className="muted">No active sessions right now.</p>
|
|
) : (
|
|
<div className="home-session-grid">
|
|
{activeSessions.map((session) => (
|
|
<article className="card session-card" key={session.id}>
|
|
<div className="stack-sm">
|
|
<div className="row row-tight">
|
|
<h3>{session.display_name}</h3>
|
|
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
|
</div>
|
|
<p className="muted">{session.project_name} · {session.repository_name}</p>
|
|
<p className="muted">{session.tool_type_name}</p>
|
|
</div>
|
|
<div className="session-actions">
|
|
<button className="secondary-button small" type="button" onClick={() => handleOpen(session)}>
|
|
<Icon name="external" size="sm" />
|
|
Open
|
|
</button>
|
|
<button className="ghost-button small" type="button" onClick={() => void handleRecreateTunnel(session)} disabled={actionBusy === session.id}>
|
|
<Icon name="refresh" size="sm" />
|
|
Tunnel
|
|
</button>
|
|
<button className="ghost-button small" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}>
|
|
<Icon name="stop" size="sm" />
|
|
Stop
|
|
</button>
|
|
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}>
|
|
<Icon name="delete" size="sm" />
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="card stack home-section">
|
|
<div className="page-header">
|
|
<div>
|
|
<p className="eyebrow">Available projects</p>
|
|
<h2>{projects.length}</h2>
|
|
</div>
|
|
<button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button>
|
|
</div>
|
|
{projects.length === 0 ? (
|
|
<p className="muted">No projects yet.</p>
|
|
) : (
|
|
<div className="home-project-grid">
|
|
{projects.map((project) => (
|
|
<article className="card project-card home-project-card" key={project.id}>
|
|
<div className="stack-sm">
|
|
<h3>{project.name}</h3>
|
|
{project.description && <p className="muted">{project.description}</p>}
|
|
</div>
|
|
<button className="ghost-button small" type="button" onClick={() => navigate(`/projects/${project.id}`)}>
|
|
Open Workspace
|
|
</button>
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="card stack home-section">
|
|
<div className="page-header">
|
|
<div>
|
|
<p className="eyebrow">Quick create</p>
|
|
<h2>Start a session</h2>
|
|
</div>
|
|
</div>
|
|
<form className="stack create-session-form" onSubmit={handleCreate}>
|
|
<div className="form-row">
|
|
<label className="form-field">
|
|
Project
|
|
<select value={selectedProject} onChange={(event) => { setSelectedProject(event.target.value); setSelectedRepo(""); }}>
|
|
<option value="">Select project...</option>
|
|
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
|
|
</select>
|
|
</label>
|
|
<label className="form-field">
|
|
Repository
|
|
<select value={selectedRepo} onChange={(event) => setSelectedRepo(event.target.value)} disabled={!selectedProject}>
|
|
<option value="">Select repository...</option>
|
|
{repositories.map((repo) => <option key={repo.id} value={repo.id}>{repo.name}</option>)}
|
|
</select>
|
|
</label>
|
|
<label className="form-field">
|
|
Tool type
|
|
<select value={selectedToolType} onChange={(event) => setSelectedToolType(event.target.value)}>
|
|
<option value="">Select tool...</option>
|
|
{toolTypes.map((tool) => <option key={tool.id} value={tool.id}>{tool.display_name}</option>)}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<label className="form-field">
|
|
Display name
|
|
<input type="text" value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="My Development Environment" />
|
|
</label>
|
|
<div className="form-actions">
|
|
<button className="primary-button" type="submit" disabled={saveState === "saving"}>
|
|
{saveState === "saving" ? <><Icon name="loading" size="sm" /> Creating...</> : <><Icon name="add" size="sm" /> Create Session</>}
|
|
</button>
|
|
{saveState === "error" && <span className="error-text">Failed to create session</span>}
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
{recentSessions.length > 0 && (
|
|
<section className="card stack home-section">
|
|
<div className="page-header">
|
|
<div>
|
|
<p className="eyebrow">Recent sessions</p>
|
|
<h2>{recentSessions.length}</h2>
|
|
</div>
|
|
</div>
|
|
<div className="recent-sessions-list">
|
|
{recentSessions.map((session) => (
|
|
<article className="recent-session-item" key={session.id}>
|
|
<div className="recent-session-info">
|
|
<span className="recent-session-name">{session.display_name}</span>
|
|
<span className="muted">{session.project_name} · {session.tool_type_name}</span>
|
|
</div>
|
|
<button className="ghost-button small" type="button" onClick={() => handleOpen(session)}>Open</button>
|
|
</article>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
</>
|
|
)}
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export { HomePage as DashboardPage };
|