refactor: consolidate loading/error states and extract instance actions hook

Frontend:
- Create reusable DataStates components (LoadingState, ErrorState, EmptyState)
- Refactor 12 pages to use shared state components instead of inline JSX
- Extract useInstanceActions hook to eliminate session action duplication
- Update dashboard and sessions pages to use shared hook

OpenSpec:
- Archive completed mobile-app-usability change (44/44 tasks)
- Archive completed add-config-profiles change (15/15 tasks)

Quality: TypeScript check passes, production build succeeds
This commit is contained in:
Alex Blank
2026-05-25 22:50:18 +02:00
parent 4c216dd1ca
commit ab79080f0b
31 changed files with 258 additions and 266 deletions
+15 -71
View File
@@ -2,15 +2,17 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
import { getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions";
import { getUserSessions, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } 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 { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { CreateSessionForm } from "../components/create-session-form";
import { SessionList } from "../components/session-list";
import { useInstanceActions } from "../hooks/use-instance-actions";
type HomeStatus = "loading" | "ready" | "error";
@@ -31,7 +33,6 @@ export const HomePage = () => {
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProject, setSelectedProject] = useState("");
const [actionBusy, setActionBusy] = useState<string | null>(null);
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
const safeSessions = Array.isArray(sessions) ? sessions : [];
@@ -58,6 +59,15 @@ export const HomePage = () => {
void loadHome();
}, [loadHome]);
const {
loadingSessionId: actionBusy,
handleOpen,
handleStart,
handleStop,
handleDelete,
handleRecreateTunnel,
} = useInstanceActions({ onRefresh: loadHome });
// Poll tunnel health every 30 seconds for running instances
useEffect(() => {
const checkHealth = async () => {
@@ -130,64 +140,6 @@ export const HomePage = () => {
await loadHome();
};
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) => {
if (actionBusy === session.id) return;
setActionBusy(session.id);
try {
await stopInstance(session.project_id, session.repository_id, session.id);
await loadHome();
} finally {
setActionBusy(null);
}
};
const handleDelete = async (session: SessionView) => {
if (actionBusy === session.id) return;
setActionBusy(session.id);
try {
await deleteInstance(session.project_id, session.repository_id, session.id);
setSessions((prev) => prev.filter((s) => s.id !== session.id));
} catch {
// error - session remains in state
} finally {
setActionBusy(null);
}
};
const handleRecreateTunnel = async (session: SessionView) => {
if (actionBusy === session.id) return;
setActionBusy(session.id);
try {
await recreateInstanceTunnel(session.project_id, session.repository_id, session.id);
await loadHome();
} finally {
setActionBusy(null);
}
};
const handleStart = async (session: SessionView) => {
if (actionBusy === session.id) return;
setActionBusy(session.id);
try {
await startInstance(session.project_id, session.repository_id, session.id);
await loadHome();
} finally {
setActionBusy(null);
}
};
return (
<section className="stack home-page">
<header className="home-hero card">
@@ -202,17 +154,9 @@ export const HomePage = () => {
</div>
</header>
{status === "loading" && <p className="muted">Loading overview...</p>}
{status === "loading" && <LoadingState message="Loading overview..." />}
{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 === "error" && <ErrorState message="Unable to load your workspace overview." onRetry={() => void loadHome()} />}
{status === "ready" && summary && (
<>
@@ -260,7 +204,7 @@ export const HomePage = () => {
<button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button>
</div>
{projects.length === 0 ? (
<p className="muted">No projects yet.</p>
<EmptyState message="No projects yet." />
) : (
<div className="home-project-grid">
{projects.map((project) => (