diff --git a/apps/web/src/components/features/project/ProjectCard.tsx b/apps/web/src/components/features/project/ProjectCard.tsx
new file mode 100644
index 0000000..01ac6fa
--- /dev/null
+++ b/apps/web/src/components/features/project/ProjectCard.tsx
@@ -0,0 +1,120 @@
+import { Icon } from "../../icon";
+import { WorkspaceCreateForm } from "../workspace/workspace-create-form";
+import type { ProjectWithRepos, WorkspaceSummary } from "../../../types";
+
+interface Props {
+ project: ProjectWithRepos;
+ expanded: boolean;
+ deleteConfirm: boolean;
+ workspaceLoading: string | null;
+ showCreateForm: string | null;
+ onToggle: () => void;
+ onEdit: () => void;
+ onDelete: () => void;
+ onConfirmDelete: () => void;
+ onCancelDelete: () => void;
+ onCreateWorkspace: (repoId: string) => void;
+ onWorkspaceAction: (repoId: string, workspace: WorkspaceSummary, action: "sync" | "delete") => void;
+ onCancelCreate: () => void;
+ onCreated: () => void;
+}
+
+export const ProjectCard = ({
+ project,
+ expanded,
+ deleteConfirm,
+ workspaceLoading,
+ showCreateForm,
+ onToggle,
+ onEdit,
+ onDelete,
+ onConfirmDelete,
+ onCancelDelete,
+ onCreateWorkspace,
+ onWorkspaceAction,
+ onCancelCreate,
+ onCreated,
+}: Props) => {
+ return (
+
+
+
+
+
+ {deleteConfirm ? (
+
+ Are you sure?
+
+
+
+ ) : (
+
+ )}
+
+
+
+ {expanded && (
+
+ {project.repositories.length === 0 ? (
+
No repositories yet.
+ ) : (
+
+ {project.repositories.map((repo) => (
+
+
+
{repo.name}
+
+
+ {showCreateForm === repo.id && (
+
+ )}
+ {repo.workspaces.length === 0 ? (
+
No workspaces.
+ ) : (
+
+ {repo.workspaces.map((ws) => (
+
+
{ws.name}
+
{ws.branch}
+ {ws.instance_count > 0 && (
+
{ws.instance_count} tool{ws.instance_count > 1 ? "s" : ""}
+ )}
+
+
+
+
+
+ ))}
+
+ )}
+
+ ))}
+
+ )}
+
+ )}
+
+ );
+};
diff --git a/apps/web/src/components/features/project/ProjectDialog.tsx b/apps/web/src/components/features/project/ProjectDialog.tsx
new file mode 100644
index 0000000..5468dab
--- /dev/null
+++ b/apps/web/src/components/features/project/ProjectDialog.tsx
@@ -0,0 +1,71 @@
+import { Icon } from "../../icon";
+
+interface Props {
+ mode: "create" | "edit";
+ name: string;
+ description: string;
+ error: string | null;
+ onNameChange: (name: string) => void;
+ onDescriptionChange: (desc: string) => void;
+ onSubmit: (e: React.FormEvent) => void;
+ onCancel: () => void;
+}
+
+export const ProjectDialog = ({
+ mode,
+ name,
+ description,
+ error,
+ onNameChange,
+ onDescriptionChange,
+ onSubmit,
+ onCancel,
+}: Props) => {
+ return (
+
+
+
{mode === "create" ? "Create Project" : "Edit Project"}
+
+
+
+ );
+};
diff --git a/apps/web/src/hooks/use-projects.ts b/apps/web/src/hooks/use-projects.ts
new file mode 100644
index 0000000..e33b557
--- /dev/null
+++ b/apps/web/src/hooks/use-projects.ts
@@ -0,0 +1,155 @@
+import { useState } from "react";
+import {
+ createProject,
+ deleteProject,
+ listProjects,
+ updateProject,
+ type ProjectCreateInput,
+ type ProjectUpdateInput,
+} from "../api/projects";
+import { deleteWorkspace, syncWorkspace } from "../api/workspaces";
+import { useAsyncData } from "./use-async-data";
+import type { ProjectWithRepos, WorkspaceSummary } from "../types";
+
+type DialogMode = "none" | "create" | "edit";
+
+export const useProjects = () => {
+ const {
+ data: projects,
+ status,
+ reload,
+ } = useAsyncData(listProjects, []);
+ const [dialogMode, setDialogMode] = useState("none");
+ const [editingProject, setEditingProject] = useState(
+ null,
+ );
+ const [formName, setFormName] = useState("");
+ const [formDescription, setFormDescription] = useState("");
+ const [formError, setFormError] = useState(null);
+ const [deleteConfirmId, setDeleteConfirmId] = useState(null);
+ const [expandedProject, setExpandedProject] = useState(null);
+ const [creatingWorkspace, setCreatingWorkspace] = useState<{
+ projectId: string;
+ repoId: string;
+ } | null>(null);
+ const [workspaceLoading, setWorkspaceLoading] = useState(null);
+
+ const safeProjects = projects ?? [];
+
+ const openCreate = () => {
+ setFormName("");
+ setFormDescription("");
+ setFormError(null);
+ setEditingProject(null);
+ setDialogMode("create");
+ };
+
+ const openEdit = (project: ProjectWithRepos) => {
+ setFormName(project.name);
+ setFormDescription(project.description ?? "");
+ setFormError(null);
+ setEditingProject(project);
+ setDialogMode("edit");
+ };
+
+ const closeDialog = () => {
+ setDialogMode("none");
+ setEditingProject(null);
+ setFormError(null);
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setFormError(null);
+
+ if (!formName.trim()) {
+ setFormError("Project name is required");
+ return;
+ }
+
+ try {
+ if (dialogMode === "create") {
+ const input: ProjectCreateInput = {
+ name: formName.trim(),
+ description: formDescription.trim() || null,
+ };
+ await createProject(input);
+ } else if (dialogMode === "edit" && editingProject) {
+ const input: ProjectUpdateInput = {
+ name: formName.trim(),
+ description: formDescription.trim() || null,
+ };
+ await updateProject(editingProject.id, input);
+ }
+ closeDialog();
+ reload();
+ } catch {
+ setFormError("Failed to save project");
+ }
+ };
+
+ const handleDelete = async (projectId: string) => {
+ try {
+ await deleteProject(projectId);
+ setDeleteConfirmId(null);
+ reload();
+ } catch {
+ setDeleteConfirmId(null);
+ }
+ };
+
+ const handleSyncWorkspace = async (
+ projectId: string,
+ repoId: string,
+ workspace: WorkspaceSummary,
+ ) => {
+ setWorkspaceLoading(workspace.id);
+ try {
+ await syncWorkspace(projectId, repoId, workspace.id);
+ reload();
+ } catch (err) {
+ alert(err instanceof Error ? err.message : "Failed to sync workspace");
+ } finally {
+ setWorkspaceLoading(null);
+ }
+ };
+
+ const handleDeleteWorkspace = async (workspace: WorkspaceSummary) => {
+ if (!confirm(`Delete workspace "${workspace.name}"?`)) return;
+ setWorkspaceLoading(workspace.id);
+ try {
+ await deleteWorkspace(workspace.id);
+ reload();
+ } catch (err) {
+ alert(err instanceof Error ? err.message : "Failed to delete workspace");
+ } finally {
+ setWorkspaceLoading(null);
+ }
+ };
+
+ return {
+ projects: safeProjects,
+ status,
+ reload,
+ dialogMode,
+ formName,
+ setFormName,
+ formDescription,
+ setFormDescription,
+ formError,
+ deleteConfirmId,
+ setDeleteConfirmId,
+ expandedProject,
+ setExpandedProject,
+ creatingWorkspace,
+ setCreatingWorkspace,
+ workspaceLoading,
+ openCreate,
+ openEdit,
+ closeDialog,
+ handleSubmit,
+ handleDelete,
+ handleSyncWorkspace,
+ handleDeleteWorkspace,
+ };
+};
diff --git a/apps/web/src/pages/ProjectsPage.tsx b/apps/web/src/pages/ProjectsPage.tsx
index b5fca25..efcdb90 100644
--- a/apps/web/src/pages/ProjectsPage.tsx
+++ b/apps/web/src/pages/ProjectsPage.tsx
@@ -1,143 +1,37 @@
-/** Projects page with inline repositories and workspaces. */
-
-import { useState } from "react";
-
-import {
- createProject,
- deleteProject,
- listProjects,
- updateProject,
- type ProjectCreateInput,
- type ProjectUpdateInput,
-} from "../api/projects";
-import { deleteWorkspace, syncWorkspace } from "../api/workspaces";
-import {
- EmptyState,
- ErrorState,
- LoadingState,
-} from "../components/data-states";
+import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
-import { WorkspaceCreateForm } from "../components/features/workspace/workspace-create-form";
-import { useAsyncData } from "../hooks/use-async-data";
-import type { ProjectWithRepos, WorkspaceSummary } from "../types";
-
-type DialogMode = "none" | "create" | "edit";
+import { ProjectCard } from "../components/features/project/ProjectCard";
+import { ProjectDialog } from "../components/features/project/ProjectDialog";
+import { useProjects } from "../hooks/use-projects";
export const ProjectsPage = () => {
const {
- data: projects,
+ projects,
status,
reload,
- } = useAsyncData(listProjects, []);
- const [dialogMode, setDialogMode] = useState("none");
- const [editingProject, setEditingProject] = useState(
- null,
- );
- const [formName, setFormName] = useState("");
- const [formDescription, setFormDescription] = useState("");
- const [formError, setFormError] = useState(null);
- const [deleteConfirmId, setDeleteConfirmId] = useState(null);
- const [expandedProject, setExpandedProject] = useState(null);
- const [creatingWorkspace, setCreatingWorkspace] = useState<{
- projectId: string;
- repoId: string;
- } | null>(null);
- const [workspaceLoading, setWorkspaceLoading] = useState(null);
+ dialogMode,
+ formName,
+ setFormName,
+ formDescription,
+ setFormDescription,
+ formError,
+ deleteConfirmId,
+ setDeleteConfirmId,
+ expandedProject,
+ setExpandedProject,
+ creatingWorkspace,
+ setCreatingWorkspace,
+ workspaceLoading,
+ openCreate,
+ openEdit,
+ closeDialog,
+ handleSubmit,
+ handleDelete,
+ handleSyncWorkspace,
+ handleDeleteWorkspace,
+ } = useProjects();
- const safeProjects = projects ?? [];
-
- const openCreate = () => {
- setFormName("");
- setFormDescription("");
- setFormError(null);
- setEditingProject(null);
- setDialogMode("create");
- };
-
- const openEdit = (project: ProjectWithRepos) => {
- setFormName(project.name);
- setFormDescription(project.description ?? "");
- setFormError(null);
- setEditingProject(project);
- setDialogMode("edit");
- };
-
- const closeDialog = () => {
- setDialogMode("none");
- setEditingProject(null);
- setFormError(null);
- };
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- setFormError(null);
-
- if (!formName.trim()) {
- setFormError("Project name is required");
- return;
- }
-
- try {
- if (dialogMode === "create") {
- const input: ProjectCreateInput = {
- name: formName.trim(),
- description: formDescription.trim() || null,
- };
- await createProject(input);
- } else if (dialogMode === "edit" && editingProject) {
- const input: ProjectUpdateInput = {
- name: formName.trim(),
- description: formDescription.trim() || null,
- };
- await updateProject(editingProject.id, input);
- }
- closeDialog();
- reload();
- } catch {
- setFormError("Failed to save project");
- }
- };
-
- const handleDelete = async (projectId: string) => {
- try {
- await deleteProject(projectId);
- setDeleteConfirmId(null);
- reload();
- } catch {
- setDeleteConfirmId(null);
- }
- };
-
- const handleSyncWorkspace = async (
- projectId: string,
- repoId: string,
- workspace: WorkspaceSummary,
- ) => {
- setWorkspaceLoading(workspace.id);
- try {
- await syncWorkspace(projectId, repoId, workspace.id);
- reload();
- } catch (err) {
- alert(err instanceof Error ? err.message : "Failed to sync workspace");
- } finally {
- setWorkspaceLoading(null);
- }
- };
-
- const handleDeleteWorkspace = async (workspace: WorkspaceSummary) => {
- if (!confirm(`Delete workspace "${workspace.name}"?`)) return;
- setWorkspaceLoading(workspace.id);
- try {
- await deleteWorkspace(workspace.id);
- reload();
- } catch (err) {
- alert(err instanceof Error ? err.message : "Failed to delete workspace");
- } finally {
- setWorkspaceLoading(null);
- }
- };
-
- const isEmpty = status === "ready" && safeProjects.length === 0;
+ const isEmpty = status === "ready" && projects.length === 0;
return (
@@ -159,13 +53,20 @@ export const ProjectsPage = () => {
)}
- {status === "ready" && safeProjects.length > 0 && (
+ {status === "ready" && projects.length > 0 && (
- {safeProjects.map((project) => (
+ {projects.map((project) => (
setExpandedProject(
expandedProject === project.id ? null : project.id,
@@ -173,7 +74,6 @@ export const ProjectsPage = () => {
}
onEdit={() => openEdit(project)}
onDelete={() => setDeleteConfirmId(project.id)}
- deleteConfirm={deleteConfirmId === project.id}
onConfirmDelete={() => void handleDelete(project.id)}
onCancelDelete={() => setDeleteConfirmId(null)}
onCreateWorkspace={(repoId) =>
@@ -186,12 +86,6 @@ export const ProjectsPage = () => {
void handleDeleteWorkspace(workspace);
}
}}
- workspaceLoading={workspaceLoading}
- showCreateForm={
- creatingWorkspace?.projectId === project.id
- ? creatingWorkspace.repoId
- : null
- }
onCancelCreate={() => setCreatingWorkspace(null)}
onCreated={() => {
setCreatingWorkspace(null);
@@ -203,231 +97,17 @@ export const ProjectsPage = () => {
)}
{dialogMode !== "none" && (
-
-
-
- {dialogMode === "create" ? "Create Project" : "Edit Project"}
-
-
-
-
+
)}
);
};
-
-/* ─── Project Card ─── */
-
-function ProjectCard({
- project,
- expanded,
- onToggle,
- onEdit,
- onDelete,
- deleteConfirm,
- onConfirmDelete,
- onCancelDelete,
- onCreateWorkspace,
- onWorkspaceAction,
- workspaceLoading,
- showCreateForm,
- onCancelCreate,
- onCreated,
-}: {
- project: ProjectWithRepos;
- expanded: boolean;
- onToggle: () => void;
- onEdit: () => void;
- onDelete: () => void;
- deleteConfirm: boolean;
- onConfirmDelete: () => void;
- onCancelDelete: () => void;
- onCreateWorkspace: (repoId: string) => void;
- onWorkspaceAction: (
- repoId: string,
- workspace: WorkspaceSummary,
- action: "sync" | "delete",
- ) => void;
- workspaceLoading: string | null;
- onCancelCreate: () => void;
- showCreateForm: string | null;
- onCreated: () => void;
-}) {
- return (
-
-
-
-
-
- {deleteConfirm ? (
-
- Are you sure?
-
-
-
- ) : (
-
- )}
-
-
-
- {expanded && (
-
- {project.repositories.length === 0 ? (
-
No repositories yet.
- ) : (
-
- {project.repositories.map((repo) => (
-
-
-
{repo.name}
-
-
- {showCreateForm === repo.id && (
-
- )}
- {repo.workspaces.length === 0 ? (
-
No workspaces.
- ) : (
-
- {repo.workspaces.map((ws) => (
-
-
{ws.name}
-
- {ws.branch}
-
- {ws.instance_count > 0 && (
-
- {ws.instance_count} tool
- {ws.instance_count > 1 ? "s" : ""}
-
- )}
-
-
-
-
-
- ))}
-
- )}
-
- ))}
-
- )}
-
- )}
-
- );
-}