From 88a973dc6892ffdbe40333de3a51346b6f9bdecf Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Mon, 1 Jun 2026 17:18:51 +0200 Subject: [PATCH] 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 --- apps/web/src/api/projects.ts | 6 +- apps/web/src/components/icon.tsx | 8 +- apps/web/src/pages/config-profiles.tsx | 4 +- apps/web/src/pages/dashboard.tsx | 4 +- apps/web/src/pages/projects.tsx | 635 +++++++++++++++++-------- apps/web/src/pages/sessions.tsx | 4 +- apps/web/src/router.tsx | 2 - apps/web/src/styles.css | 132 +++++ apps/web/src/types.ts | 25 + apps/web/src/utils/icons.ts | 181 +------ 10 files changed, 620 insertions(+), 381 deletions(-) diff --git a/apps/web/src/api/projects.ts b/apps/web/src/api/projects.ts index beaed85..1252dce 100644 --- a/apps/web/src/api/projects.ts +++ b/apps/web/src/api/projects.ts @@ -1,5 +1,5 @@ import { apiClient } from "./client"; -import type { Project } from "../types"; +import type { Project, ProjectWithRepos } from "../types"; export type ProjectCreateInput = { name: string; @@ -15,8 +15,8 @@ export type SetDefaultSSHKeyInput = { ssh_key_id: string; }; -export const listProjects = async (): Promise => { - const response = await apiClient.get("/projects"); +export const listProjects = async (): Promise => { + const response = await apiClient.get("/projects"); return response.data; }; diff --git a/apps/web/src/components/icon.tsx b/apps/web/src/components/icon.tsx index cc2cc79..7222b30 100644 --- a/apps/web/src/components/icon.tsx +++ b/apps/web/src/components/icon.tsx @@ -36,6 +36,8 @@ import { ArrowLeft, DotsSixVertical, Bell, + CaretDown, + CaretRight, } from "@phosphor-icons/react"; export type IconName = @@ -79,7 +81,9 @@ export type IconName = | "terminal" | "arrow-left" | "drag" - | "bell"; + | "bell" + | "chevron-down" + | "chevron-right"; const iconMap: Record< IconName, @@ -129,6 +133,8 @@ const iconMap: Record< "arrow-left": ArrowLeft, drag: DotsSixVertical, bell: Bell, + "chevron-down": CaretDown, + "chevron-right": CaretRight, }; export interface IconProps { diff --git a/apps/web/src/pages/config-profiles.tsx b/apps/web/src/pages/config-profiles.tsx index 4e630cf..ac7e33f 100644 --- a/apps/web/src/pages/config-profiles.tsx +++ b/apps/web/src/pages/config-profiles.tsx @@ -19,7 +19,7 @@ import { type ResolvedProfile, } from "../api/config_profiles"; import { listProjects } from "../api/projects"; -import type { Project } from "../types"; +import type { ProjectWithRepos } from "../types"; import { listToolTypes, type ToolType } from "../api/tool_types"; import { GitMountEditor } from "../components/git-mount-editor"; @@ -31,7 +31,7 @@ export const ConfigProfilesPage = () => { const [mobileView, setMobileView] = useState("list"); const [status, setStatus] = useState("loading"); const [profiles, setProfiles] = useState([]); - const [projects, setProjects] = useState([]); + const [projects, setProjects] = useState([]); const [toolTypes, setToolTypes] = useState([]); const [selectedProfileId, setSelectedProfileId] = useState( diff --git a/apps/web/src/pages/dashboard.tsx b/apps/web/src/pages/dashboard.tsx index 00ee820..59e2ea0 100644 --- a/apps/web/src/pages/dashboard.tsx +++ b/apps/web/src/pages/dashboard.tsx @@ -7,7 +7,7 @@ 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 type { ProjectWithRepos } from "../types"; import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; import { CreateSessionForm } from "../components/create-session-form"; import { SessionList } from "../components/session-list"; @@ -28,7 +28,7 @@ export const HomePage = () => { const [status, setStatus] = useState("loading"); const [summary, setSummary] = useState(null); const [sessions, setSessions] = useState([]); - const [projects, setProjects] = useState([]); + const [projects, setProjects] = useState([]); const [repositories, setRepositories] = useState([]); const [toolTypes, setToolTypes] = useState([]); const [selectedProject, setSelectedProject] = useState(""); diff --git a/apps/web/src/pages/projects.tsx b/apps/web/src/pages/projects.tsx index c1c0d71..92df8f0 100644 --- a/apps/web/src/pages/projects.tsx +++ b/apps/web/src/pages/projects.tsx @@ -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(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 { 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 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 ( -
-
-

Projects

- -
+ 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" && } + 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" && } + const isEmpty = status === "ready" && safeProjects.length === 0; - {isEmpty && } + return ( +
+
+

Projects

+ +
- {status === "ready" && safeProjects.length > 0 && ( -
- {safeProjects.map((project) => ( -
-
-

{project.name}

- {project.description &&

{project.description}

} -
-
- - Open Workspace - - - {deleteConfirmId === project.id ? ( -
- Are you sure? - - -
- ) : ( - - )} -
-
- ))} -
- )} + {status === "loading" && } - {dialogMode !== "none" && ( -
-
-

{dialogMode === "create" ? "Create Project" : "Edit Project"}

-
- -