fix: allow workspace creation from workspaces page

- Replace awkward first-workspace-guessing logic with inline project/repo selector
- New WorkspaceCreateInline component with cascading dropdowns:
  - Select project → loads repositories for that project
  - Select repository → enter workspace name + branch
  - Submit creates workspace via top-level POST /workspaces/
- Add createWorkspaceTopLevel() API client for flat endpoint
- Works even with zero existing workspaces (shows create button in empty state)
- Add CSS grid layout for inline create form
- TypeScript + eslint clean
This commit is contained in:
2026-06-01 17:32:35 +02:00
parent 88a973dc68
commit ab1843b1c3
12 changed files with 870 additions and 561 deletions
+206 -35
View File
@@ -1,34 +1,26 @@
/** Workspaces list page. */
/** Workspaces list page with direct creation. */
import { useState } from "react";
import { useState, useEffect, useCallback } from "react";
import { Icon } from "../components/icon";
import { useWorkspaces } from "../hooks/use-workspaces";
import { useWorkspaceActions } from "../hooks/use-workspace-actions";
import { WorkspaceCard } from "../components/workspace-card";
import { WorkspaceCreateForm } from "../components/workspace-create-form";
import { StartToolModal } from "../components/start-tool-modal";
import { createInstance, startInstance } from "../api/sessions";
import { listProjects } from "../api/projects";
import { listRepositories } from "../api/git_repositories";
import { createWorkspaceTopLevel } from "../api/workspaces";
import type { Workspace } from "../types/workspace";
import type { ProjectWithRepos } from "../types";
import type { GitRepository } from "../api/git_repositories";
export function WorkspacesPage() {
const [showCreate, setShowCreate] = useState(false);
const [startWorkspace, setStartWorkspace] = useState<Workspace | null>(null);
const [createTarget, setCreateTarget] = useState<{
projectId: string;
repoId: string;
} | null>(null);
const { workspaces, loading, error, refresh } = useWorkspaces();
const actions = useWorkspaceActions();
const handleCreate = async (data: { name: string; branch: string }) => {
if (!createTarget) return;
await actions.create(createTarget.projectId, createTarget.repoId, data);
setShowCreate(false);
setCreateTarget(null);
await refresh();
};
const handleDelete = async (workspace: Workspace) => {
await actions.delete(
workspace.project_id,
@@ -92,18 +84,7 @@ export function WorkspacesPage() {
</button>
<button
className="btn btn-primary"
onClick={() => {
if (workspaces.length > 0) {
const first = workspaces[0];
setCreateTarget({
projectId: first.project_id,
repoId: first.repo_id,
});
setShowCreate(true);
} else {
alert("Navigate to a project to create your first workspace.");
}
}}
onClick={() => setShowCreate(true)}
>
<Icon name="add" size="sm" /> New Workspace
</button>
@@ -112,15 +93,13 @@ export function WorkspacesPage() {
{error && <div className="alert alert-error">{error}</div>}
{showCreate && createTarget && (
<WorkspaceCreateForm
projectId={createTarget.projectId}
repoId={createTarget.repoId}
onSubmit={handleCreate}
onCancel={() => {
{showCreate && (
<WorkspaceCreateInline
onCreated={() => {
setShowCreate(false);
setCreateTarget(null);
refresh();
}}
onCancel={() => setShowCreate(false)}
/>
)}
@@ -129,7 +108,12 @@ export function WorkspacesPage() {
) : workspaces.length === 0 ? (
<div className="empty-state">
<p>No workspaces yet.</p>
<p>Navigate to a project to create your first workspace.</p>
<button
className="btn btn-primary"
onClick={() => setShowCreate(true)}
>
<Icon name="add" size="sm" /> Create your first workspace
</button>
</div>
) : (
<div className="workspaces-grid">
@@ -156,3 +140,190 @@ export function WorkspacesPage() {
</div>
);
}
/* ─── Inline Workspace Creation Form ─── */
function WorkspaceCreateInline({
onCreated,
onCancel,
}: {
onCreated: () => void;
onCancel: () => void;
}) {
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
const [repos, setRepos] = useState<GitRepository[]>([]);
const [selectedProject, setSelectedProject] = useState("");
const [selectedRepo, setSelectedRepo] = useState("");
const [name, setName] = useState("");
const [branch, setBranch] = useState("main");
const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadProjects = useCallback(async () => {
try {
const data = await listProjects();
setProjects(data);
if (data.length === 1) {
setSelectedProject(data[0].id);
}
} catch {
setError("Failed to load projects");
} finally {
setFetching(false);
}
}, []);
useEffect(() => {
void loadProjects();
}, [loadProjects]);
useEffect(() => {
if (!selectedProject) {
setRepos([]);
setSelectedRepo("");
return;
}
const loadRepos = async () => {
try {
const data = await listRepositories(selectedProject);
setRepos(data);
if (data.length === 1) {
setSelectedRepo(data[0].id);
}
} catch {
setError("Failed to load repositories");
}
};
void loadRepos();
}, [selectedProject]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedRepo) {
setError("Please select a repository");
return;
}
if (!name.trim()) {
setError("Workspace name is required");
return;
}
setLoading(true);
setError(null);
try {
await createWorkspaceTopLevel({
repo_id: selectedRepo,
name: name.trim(),
branch: branch.trim() || "main",
});
onCreated();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create workspace");
} finally {
setLoading(false);
}
};
if (fetching) {
return (
<div className="card workspace-create-inline">
<p className="muted">Loading projects...</p>
</div>
);
}
return (
<div className="card workspace-create-inline">
<h3>
<Icon name="add" size="sm" /> Create Workspace
</h3>
<form onSubmit={handleSubmit} className="workspace-create-form-grid">
<div className="form-group">
<label>Project</label>
<select
value={selectedProject}
onChange={(e) => setSelectedProject(e.target.value)}
required
>
<option value="">Select project...</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div className="form-group">
<label>Repository</label>
<select
value={selectedRepo}
onChange={(e) => setSelectedRepo(e.target.value)}
required
disabled={!selectedProject || repos.length === 0}
>
<option value="">
{!selectedProject
? "Select a project first"
: repos.length === 0
? "No repositories"
: "Select repository..."}
</option>
{repos.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</div>
<div className="form-group">
<label>Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., feature-branch"
required
/>
</div>
<div className="form-group">
<label>
<Icon name="branch" size="sm" /> Branch
</label>
<input
type="text"
value={branch}
onChange={(e) => setBranch(e.target.value)}
placeholder="main"
/>
</div>
{error && (
<div className="form-error" style={{ gridColumn: "1 / -1" }}>
{error}
</div>
)}
<div className="form-actions" style={{ gridColumn: "1 / -1" }}>
<button
type="button"
className="btn btn-secondary"
onClick={onCancel}
disabled={loading}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
disabled={loading || !selectedRepo}
>
{loading ? "Creating..." : "Create Workspace"}
</button>
</div>
</form>
</div>
);
}