feat: workspace-first UI refresh - PR-3 projects page + routing cleanup
- Rewrite ProjectsPage with inline repository and workspace display - Expandable project cards showing repos + workspace chips - Inline workspace creation from project page (New Workspace button per repo) - Workspace chips link to workspace detail page - Sync/delete actions on workspace chips - Update Project type: add ProjectWithRepos, RepositorySummary, WorkspaceSummary - Update listProjects API to return ProjectWithRepos[] - Update dashboard, sessions, config-profiles to use ProjectWithRepos - Remove old /projects/:projectId route (RepoWorkspace) - Add chevron icons to Icon component - Projects page CSS: project-toggle, repo-block, workspace-grid, workspace-chip - TypeScript + eslint clean Quality gates: tsc --noEmit clean, eslint clean
This commit is contained in:
+446
-189
@@ -1,216 +1,473 @@
|
||||
/** Projects page with inline repositories and workspaces. */
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import {
|
||||
createProject,
|
||||
deleteProject,
|
||||
listProjects,
|
||||
updateProject,
|
||||
type ProjectCreateInput,
|
||||
type ProjectUpdateInput,
|
||||
createProject,
|
||||
deleteProject,
|
||||
listProjects,
|
||||
updateProject,
|
||||
type ProjectCreateInput,
|
||||
type ProjectUpdateInput,
|
||||
} from "../api/projects";
|
||||
import {
|
||||
createWorkspace,
|
||||
deleteWorkspace,
|
||||
syncWorkspace,
|
||||
} from "../api/workspaces";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { WorkspaceCreateForm } from "../components/workspace-create-form";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
import type { Project } from "../types";
|
||||
import type { ProjectWithRepos, WorkspaceSummary } from "../types";
|
||||
|
||||
type DialogMode = "none" | "create" | "edit";
|
||||
|
||||
export const ProjectsPage = () => {
|
||||
const { data: projects, status, reload } = useAsyncData<Project[]>(listProjects, []);
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
||||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||
const [formName, setFormName] = useState("");
|
||||
const [formDescription, setFormDescription] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const { data: projects, status, reload } = useAsyncData<ProjectWithRepos[]>(
|
||||
listProjects,
|
||||
[],
|
||||
);
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
||||
const [editingProject, setEditingProject] = useState<ProjectWithRepos | null>(
|
||||
null,
|
||||
);
|
||||
const [formName, setFormName] = useState("");
|
||||
const [formDescription, setFormDescription] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [expandedProject, setExpandedProject] = useState<string | null>(null);
|
||||
const [creatingWorkspace, setCreatingWorkspace] = useState<{
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
} | null>(null);
|
||||
const [workspaceLoading, setWorkspaceLoading] = useState<string | null>(null);
|
||||
|
||||
const safeProjects = projects ?? [];
|
||||
const safeProjects = projects ?? [];
|
||||
|
||||
const openCreate = () => {
|
||||
setFormName("");
|
||||
setFormDescription("");
|
||||
setFormError(null);
|
||||
setEditingProject(null);
|
||||
setDialogMode("create");
|
||||
};
|
||||
const openCreate = () => {
|
||||
setFormName("");
|
||||
setFormDescription("");
|
||||
setFormError(null);
|
||||
setEditingProject(null);
|
||||
setDialogMode("create");
|
||||
};
|
||||
|
||||
const openEdit = (project: Project) => {
|
||||
setFormName(project.name);
|
||||
setFormDescription(project.description ?? "");
|
||||
setFormError(null);
|
||||
setEditingProject(project);
|
||||
setDialogMode("edit");
|
||||
};
|
||||
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 closeDialog = () => {
|
||||
setDialogMode("none");
|
||||
setEditingProject(null);
|
||||
setFormError(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
if (!formName.trim()) {
|
||||
setFormError("Project name is required");
|
||||
return;
|
||||
}
|
||||
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");
|
||||
}
|
||||
};
|
||||
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 handleDelete = async (projectId: string) => {
|
||||
try {
|
||||
await deleteProject(projectId);
|
||||
setDeleteConfirmId(null);
|
||||
reload();
|
||||
} catch {
|
||||
setDeleteConfirmId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isEmpty = status === "ready" && safeProjects.length === 0;
|
||||
const handleCreateWorkspace = async (
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
data: { name: string; branch: string },
|
||||
) => {
|
||||
setWorkspaceLoading(repoId);
|
||||
try {
|
||||
await createWorkspace(projectId, repoId, data);
|
||||
setCreatingWorkspace(null);
|
||||
reload();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Failed to create workspace");
|
||||
} finally {
|
||||
setWorkspaceLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<h1>Projects</h1>
|
||||
<button className="primary-button" onClick={openCreate} type="button">
|
||||
<Icon name="add" size="sm" />
|
||||
New Project
|
||||
</button>
|
||||
</div>
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
{status === "loading" && <LoadingState message="Loading projects..." />}
|
||||
const handleDeleteWorkspace = async (
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
workspace: WorkspaceSummary,
|
||||
) => {
|
||||
if (!confirm(`Delete workspace "${workspace.name}"?`)) return;
|
||||
setWorkspaceLoading(workspace.id);
|
||||
try {
|
||||
await deleteWorkspace(projectId, repoId, workspace.id);
|
||||
reload();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Failed to delete workspace");
|
||||
} finally {
|
||||
setWorkspaceLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
{status === "error" && <ErrorState message="Failed to load projects" onRetry={reload} />}
|
||||
const isEmpty = status === "ready" && safeProjects.length === 0;
|
||||
|
||||
{isEmpty && <EmptyState message="No projects yet. Create your first project above." />}
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<h1>Projects</h1>
|
||||
<button className="primary-button" onClick={openCreate} type="button">
|
||||
<Icon name="add" size="sm" />
|
||||
New Project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status === "ready" && safeProjects.length > 0 && (
|
||||
<div className="project-list">
|
||||
{safeProjects.map((project) => (
|
||||
<article className="card project-card" key={project.id}>
|
||||
<div className="project-info">
|
||||
<h3>{project.name}</h3>
|
||||
{project.description && <p className="muted">{project.description}</p>}
|
||||
</div>
|
||||
<div className="project-actions">
|
||||
<Link className="ghost-button" to={`/projects/${project.id}`}>
|
||||
Open Workspace
|
||||
</Link>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => openEdit(project)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
Edit
|
||||
</button>
|
||||
{deleteConfirmId === project.id ? (
|
||||
<div className="delete-confirm">
|
||||
<span>Are you sure?</span>
|
||||
<button
|
||||
className="danger-button"
|
||||
onClick={() => void handleDelete(project.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button danger-text"
|
||||
onClick={() => setDeleteConfirmId(project.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{status === "loading" && <LoadingState message="Loading projects..." />}
|
||||
|
||||
{dialogMode !== "none" && (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h2>{dialogMode === "create" ? "Create Project" : "Edit Project"}</h2>
|
||||
<form onSubmit={handleSubmit} className="stack">
|
||||
<label className="form-field">
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
placeholder="Project name"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Description
|
||||
<textarea
|
||||
value={formDescription}
|
||||
onChange={(e) => setFormDescription(e.target.value)}
|
||||
placeholder="Optional description"
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
{formError && <p className="error-text">{formError}</p>}
|
||||
<div className="dialog-actions">
|
||||
<button className="secondary-button" onClick={closeDialog} type="button">
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
<button className="primary-button" type="submit">
|
||||
{dialogMode === "create" ? (
|
||||
<>
|
||||
<Icon name="add" size="sm" />
|
||||
Create
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="save" size="sm" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
{status === "error" && (
|
||||
<ErrorState message="Failed to load projects" onRetry={reload} />
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<EmptyState message="No projects yet. Create your first project above." />
|
||||
)}
|
||||
|
||||
{status === "ready" && safeProjects.length > 0 && (
|
||||
<div className="project-list">
|
||||
{safeProjects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
expanded={expandedProject === project.id}
|
||||
onToggle={() =>
|
||||
setExpandedProject(
|
||||
expandedProject === project.id ? null : project.id,
|
||||
)
|
||||
}
|
||||
onEdit={() => openEdit(project)}
|
||||
onDelete={() => setDeleteConfirmId(project.id)}
|
||||
deleteConfirm={deleteConfirmId === project.id}
|
||||
onConfirmDelete={() => void handleDelete(project.id)}
|
||||
onCancelDelete={() => setDeleteConfirmId(null)}
|
||||
onCreateWorkspace={(repoId) =>
|
||||
setCreatingWorkspace({ projectId: project.id, repoId })
|
||||
}
|
||||
onWorkspaceAction={(repoId, workspace, action) => {
|
||||
if (action === "sync") {
|
||||
void handleSyncWorkspace(project.id, repoId, workspace);
|
||||
} else if (action === "delete") {
|
||||
void handleDeleteWorkspace(
|
||||
project.id,
|
||||
repoId,
|
||||
workspace,
|
||||
);
|
||||
}
|
||||
}}
|
||||
workspaceLoading={workspaceLoading}
|
||||
showCreateForm={
|
||||
creatingWorkspace?.projectId === project.id
|
||||
? creatingWorkspace.repoId
|
||||
: null
|
||||
}
|
||||
onCancelCreate={() => setCreatingWorkspace(null)}
|
||||
onSubmitCreate={async (repoId, data) =>
|
||||
await handleCreateWorkspace(project.id, repoId, data)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialogMode !== "none" && (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h2>
|
||||
{dialogMode === "create" ? "Create Project" : "Edit Project"}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="stack">
|
||||
<label className="form-field">
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
placeholder="Project name"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Description
|
||||
<textarea
|
||||
value={formDescription}
|
||||
onChange={(e) => setFormDescription(e.target.value)}
|
||||
placeholder="Optional description"
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
{formError && <p className="error-text">{formError}</p>}
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={closeDialog}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
<button className="primary-button" type="submit">
|
||||
{dialogMode === "create" ? (
|
||||
<>
|
||||
<Icon name="add" size="sm" />
|
||||
Create
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="save" size="sm" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ─── Project Card ─── */
|
||||
|
||||
function ProjectCard({
|
||||
project,
|
||||
expanded,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
deleteConfirm,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
onCreateWorkspace,
|
||||
onWorkspaceAction,
|
||||
workspaceLoading,
|
||||
showCreateForm,
|
||||
onCancelCreate,
|
||||
onSubmitCreate,
|
||||
}: {
|
||||
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;
|
||||
onSubmitCreate: (repoId: string, data: { name: string; branch: string }) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<article className="card project-card">
|
||||
<div className="project-info-row">
|
||||
<button
|
||||
className="project-toggle"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<Icon
|
||||
name={expanded ? "chevron-down" : "chevron-right"}
|
||||
size="sm"
|
||||
/>
|
||||
<h3>{project.name}</h3>
|
||||
{project.repositories.length > 0 && (
|
||||
<span className="repo-count">
|
||||
{project.repositories.length} repo
|
||||
{project.repositories.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="project-actions">
|
||||
<button className="ghost-button" onClick={onEdit} type="button">
|
||||
<Icon name="edit" size="sm" />
|
||||
Edit
|
||||
</button>
|
||||
{deleteConfirm ? (
|
||||
<div className="delete-confirm">
|
||||
<span>Are you sure?</span>
|
||||
<button
|
||||
className="danger-button"
|
||||
onClick={onConfirmDelete}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={onCancelDelete}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button danger-text"
|
||||
onClick={onDelete}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="project-detail">
|
||||
{project.repositories.length === 0 ? (
|
||||
<p className="muted">No repositories yet.</p>
|
||||
) : (
|
||||
<div className="repo-list">
|
||||
{project.repositories.map((repo) => (
|
||||
<div key={repo.id} className="repo-block">
|
||||
<div className="repo-header">
|
||||
<h4>{repo.name}</h4>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => onCreateWorkspace(repo.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="sm" /> New Workspace
|
||||
</button>
|
||||
</div>
|
||||
{showCreateForm === repo.id && (
|
||||
<WorkspaceCreateForm
|
||||
projectId={project.id}
|
||||
repoId={repo.id}
|
||||
onSubmit={(data) =>
|
||||
onSubmitCreate(repo.id, data)
|
||||
}
|
||||
onCancel={onCancelCreate}
|
||||
/>
|
||||
)}
|
||||
{repo.workspaces.length === 0 ? (
|
||||
<p className="muted">No workspaces.</p>
|
||||
) : (
|
||||
<div className="workspace-grid">
|
||||
{repo.workspaces.map((ws) => (
|
||||
<div
|
||||
key={ws.id}
|
||||
className={`workspace-chip ${ws.status}`}
|
||||
>
|
||||
<a href={`/workspaces/${ws.id}`}>{ws.name}</a>
|
||||
<span className="ws-branch">
|
||||
<Icon name="branch" size="sm" /> {ws.branch}
|
||||
</span>
|
||||
{ws.instance_count > 0 && (
|
||||
<span className="ws-instances">
|
||||
{ws.instance_count} tool
|
||||
{ws.instance_count > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
<div className="ws-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
workspaceLoading === ws.id
|
||||
}
|
||||
onClick={() =>
|
||||
onWorkspaceAction(
|
||||
repo.id,
|
||||
ws,
|
||||
"sync",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger-text"
|
||||
disabled={
|
||||
workspaceLoading === ws.id
|
||||
}
|
||||
onClick={() =>
|
||||
onWorkspaceAction(
|
||||
repo.id,
|
||||
ws,
|
||||
"delete",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user