feat: workspace frontend core (PR-3)

- Workspace types, API client, hooks (useWorkspaces, useWorkspaceActions)
- WorkspaceCard, WorkspaceCreateForm, StartToolModal components
- WorkspacesPage with list, create, sync, delete, start-tool flow
- Sidebar navigation: new 'Workspaces' entry
- Router: /workspaces route
- TypeScript + eslint clean
This commit is contained in:
2026-05-31 23:27:10 +02:00
parent 47b1af8e92
commit 986091ac56
10 changed files with 623 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
/** Workspace API client. */
import { apiClient } from "./client";
import type { Workspace, CreateWorkspaceRequest, SyncResult } from "../types/workspace";
function workspaceUrl(projectId: string, repoId: string, workspaceId?: string) {
const base = `/projects/${projectId}/repositories/${repoId}/workspaces`;
return workspaceId ? `${base}/${workspaceId}` : base;
}
export async function listWorkspaces(projectId: string, repoId: string): Promise<Workspace[]> {
const response = await apiClient.get<Workspace[]>(workspaceUrl(projectId, repoId));
return response.data;
}
export async function createWorkspace(
projectId: string,
repoId: string,
data: CreateWorkspaceRequest,
): Promise<Workspace> {
const response = await apiClient.post<Workspace>(workspaceUrl(projectId, repoId), data);
return response.data;
}
export async function getWorkspace(
projectId: string,
repoId: string,
workspaceId: string,
): Promise<Workspace> {
const response = await apiClient.get<Workspace>(workspaceUrl(projectId, repoId, workspaceId));
return response.data;
}
export async function updateWorkspace(
projectId: string,
repoId: string,
workspaceId: string,
data: Partial<CreateWorkspaceRequest>,
): Promise<Workspace> {
const response = await apiClient.patch<Workspace>(workspaceUrl(projectId, repoId, workspaceId), data);
return response.data;
}
export async function deleteWorkspace(
projectId: string,
repoId: string,
workspaceId: string,
force = false,
): Promise<{ status: string }> {
const response = await apiClient.delete<{ status: string }>(
`${workspaceUrl(projectId, repoId, workspaceId)}?force=${force}`,
);
return response.data;
}
export async function syncWorkspace(
projectId: string,
repoId: string,
workspaceId: string,
): Promise<SyncResult> {
const response = await apiClient.post<SyncResult>(
`${workspaceUrl(projectId, repoId, workspaceId)}/sync`,
);
return response.data;
}
+1
View File
@@ -24,6 +24,7 @@ const NAV_ITEMS: {
}[] = [
{ to: "/", label: "Home", icon: "dashboard" },
{ to: "/sessions", label: "Sessions", icon: "terminal", badge: "sessions" },
{ to: "/workspaces", label: "Workspaces", icon: "folder" },
{ to: "/projects", label: "Projects", icon: "projects" },
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
{ to: "/config-profiles", label: "Config Profiles", icon: "folder" },
@@ -0,0 +1,87 @@
/** Modal for starting a tool on a workspace. */
import { useState } from "react";
import { Icon } from "./icon";
import type { Workspace } from "../types/workspace";
export interface StartToolModalProps {
workspace: Workspace;
onClose: () => void;
onStart: (toolTypeId: string, configProfileId?: string) => Promise<void>;
}
export function StartToolModal({ workspace, onClose, onStart }: StartToolModalProps) {
const [toolTypeId, setToolTypeId] = useState("");
const [configProfileId, setConfigProfileId] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!toolTypeId) {
setError("Please select a tool type");
return;
}
setSubmitting(true);
setError(null);
try {
await onStart(toolTypeId, configProfileId || undefined);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to start tool");
} finally {
setSubmitting(false);
}
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>
<Icon name="play" size="sm" /> Start Tool on {workspace.name}
</h3>
<button className="btn btn-icon" onClick={onClose}>
<Icon name="cancel" size="sm" />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="tool-type">Tool Type</label>
<select
id="tool-type"
value={toolTypeId}
onChange={(e) => setToolTypeId(e.target.value)}
disabled={submitting}
>
<option value="">Select a tool...</option>
<option value="code-server">Code Server</option>
<option value="jupyter-notebook">Jupyter Notebook</option>
<option value="terminal">Terminal</option>
</select>
</div>
<div className="form-group">
<label htmlFor="config-profile">Config Profile (optional)</label>
<input
id="config-profile"
type="text"
value={configProfileId}
onChange={(e) => setConfigProfileId(e.target.value)}
placeholder="Profile ID"
disabled={submitting}
/>
</div>
{error && <p className="form-error">{error}</p>}
<div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={submitting}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? "Starting..." : "Start Tool"}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,73 @@
/** Card component for displaying a workspace. */
import { Icon } from "./icon";
import type { Workspace } from "../types/workspace";
export interface WorkspaceCardProps {
workspace: Workspace;
loading?: boolean;
onStartTool: (workspace: Workspace) => void;
onSync: (workspace: Workspace) => void;
onDelete: (workspace: Workspace) => void;
}
export function WorkspaceCard({
workspace,
loading = false,
onStartTool,
onSync,
onDelete,
}: WorkspaceCardProps) {
const statusClass =
workspace.status === "ready"
? "status-ready"
: workspace.status === "syncing"
? "status-syncing"
: "status-error";
return (
<article className={`card workspace-card ${loading ? "loading" : ""}`}>
<div className="workspace-header">
<h4>{workspace.name}</h4>
<span className={`status-badge ${statusClass}`}>{workspace.status}</span>
</div>
<div className="workspace-meta">
<p className="workspace-project">
{workspace.project_name} / {workspace.repo_name}
</p>
<p className="workspace-branch">
<Icon name="branch" size="sm" /> {workspace.branch}
</p>
{workspace.instance_count > 0 && (
<p className="workspace-instances">
{workspace.instance_count} active tool
{workspace.instance_count > 1 ? "s" : ""}
</p>
)}
</div>
<div className="workspace-actions">
<button
className="btn btn-primary"
onClick={() => onStartTool(workspace)}
disabled={loading}
>
<Icon name="play" size="sm" /> Start Tool
</button>
<button
className="btn btn-secondary"
onClick={() => onSync(workspace)}
disabled={loading}
>
<Icon name="refresh" size="sm" /> Sync
</button>
<button
className="btn btn-danger"
onClick={() => onDelete(workspace)}
disabled={loading}
>
<Icon name="delete" size="sm" /> Delete
</button>
</div>
</article>
);
}
@@ -0,0 +1,82 @@
/** Form for creating a new workspace. */
import { useState } from "react";
import { Icon } from "./icon";
import type { CreateWorkspaceRequest } from "../types/workspace";
export interface WorkspaceCreateFormProps {
projectId: string;
repoId: string;
defaultBranch?: string;
onSubmit: (data: CreateWorkspaceRequest) => Promise<void>;
onCancel: () => void;
}
export function WorkspaceCreateForm({
defaultBranch = "main",
onSubmit,
onCancel,
}: WorkspaceCreateFormProps) {
const [name, setName] = useState("");
const [branch, setBranch] = useState(defaultBranch);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
setError("Workspace name is required");
return;
}
setSubmitting(true);
setError(null);
try {
await onSubmit({ name: name.trim(), branch: branch.trim() });
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create workspace");
} finally {
setSubmitting(false);
}
};
return (
<form className="workspace-create-form card" onSubmit={handleSubmit}>
<h3>
<Icon name="add" size="sm" /> Create Workspace
</h3>
<div className="form-group">
<label htmlFor="ws-name">Name</label>
<input
id="ws-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., feature-branch"
disabled={submitting}
/>
</div>
<div className="form-group">
<label htmlFor="ws-branch">
<Icon name="branch" size="sm" /> Branch
</label>
<input
id="ws-branch"
type="text"
value={branch}
onChange={(e) => setBranch(e.target.value)}
placeholder="main"
disabled={submitting}
/>
</div>
{error && <p className="form-error">{error}</p>}
<div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={onCancel} disabled={submitting}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? "Creating..." : "Create"}
</button>
</div>
</form>
);
}
+146
View File
@@ -0,0 +1,146 @@
/** Hook for workspace CRUD actions with confirmation handling. */
import { useState, useCallback } from "react";
import {
createWorkspace,
deleteWorkspace,
syncWorkspace,
updateWorkspace,
} from "../api/workspaces";
import type { Workspace, CreateWorkspaceRequest } from "../types/workspace";
export interface UseWorkspaceActionsResult {
loadingId: string | null;
create: (
projectId: string,
repoId: string,
data: CreateWorkspaceRequest,
) => Promise<Workspace>;
delete: (
projectId: string,
repoId: string,
workspace: Workspace,
onRefresh: () => Promise<void>,
) => Promise<void>;
sync: (
projectId: string,
repoId: string,
workspace: Workspace,
onRefresh: () => Promise<void>,
) => Promise<void>;
update: (
projectId: string,
repoId: string,
workspaceId: string,
data: Partial<CreateWorkspaceRequest>,
) => Promise<Workspace>;
}
interface ApiError {
response?: {
status?: number;
data?: {
detail?: {
message?: string;
instances?: Array<{ id: string; name: string }>;
branch_deleted?: boolean;
};
};
};
}
export function useWorkspaceActions(): UseWorkspaceActionsResult {
const [loadingId, setLoadingId] = useState<string | null>(null);
const create = useCallback(
async (projectId: string, repoId: string, data: CreateWorkspaceRequest) => {
return createWorkspace(projectId, repoId, data);
},
[],
);
const deleteAction = useCallback(
async (
projectId: string,
repoId: string,
workspace: Workspace,
onRefresh: () => Promise<void>,
) => {
setLoadingId(workspace.id);
try {
await deleteWorkspace(projectId, repoId, workspace.id);
await onRefresh();
} catch (err) {
const error = err as ApiError;
if (error.response?.status === 409) {
const detail = error.response.data?.detail;
const instances = detail?.instances || [];
const confirmed = window.confirm(
`This workspace has ${instances.length} running tool instance(s):\n` +
instances.map((i) => `- ${i.name}`).join("\n") +
`\n\nDelete workspace and all instances?`,
);
if (confirmed) {
await deleteWorkspace(projectId, repoId, workspace.id, true);
await onRefresh();
}
} else {
throw err;
}
} finally {
setLoadingId(null);
}
},
[],
);
const sync = useCallback(
async (
projectId: string,
repoId: string,
workspace: Workspace,
onRefresh: () => Promise<void>,
) => {
setLoadingId(workspace.id);
try {
await syncWorkspace(projectId, repoId, workspace.id);
await onRefresh();
} catch (err) {
const error = err as ApiError;
if (error.response?.status === 409 && error.response.data?.detail?.branch_deleted) {
const message = error.response.data.detail.message || "Branch was deleted from remote";
const confirmed = window.confirm(`${message}\n\nDelete this workspace?`);
if (confirmed) {
await deleteWorkspace(projectId, repoId, workspace.id, true);
await onRefresh();
}
} else {
throw err;
}
} finally {
setLoadingId(null);
}
},
[],
);
const update = useCallback(
async (
projectId: string,
repoId: string,
workspaceId: string,
data: Partial<CreateWorkspaceRequest>,
) => {
return updateWorkspace(projectId, repoId, workspaceId, data);
},
[],
);
return {
loadingId,
create,
delete: deleteAction,
sync,
update,
};
}
+37
View File
@@ -0,0 +1,37 @@
/** Hook for fetching workspaces. */
import { useCallback, useEffect, useState } from "react";
import { listWorkspaces } from "../api/workspaces";
import type { Workspace } from "../types/workspace";
export interface UseWorkspacesResult {
workspaces: Workspace[];
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
}
export function useWorkspaces(projectId: string, repoId: string): UseWorkspacesResult {
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await listWorkspaces(projectId, repoId);
setWorkspaces(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load workspaces");
} finally {
setLoading(false);
}
}, [projectId, repoId]);
useEffect(() => {
refresh();
}, [refresh]);
return { workspaces, loading, error, refresh };
}
+102
View File
@@ -0,0 +1,102 @@
/** Workspaces list page. */
import { useState } 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 type { Workspace } from "../types/workspace";
export function WorkspacesPage() {
const [showCreate, setShowCreate] = useState(false);
const [startWorkspace, setStartWorkspace] = useState<Workspace | null>(null);
// TODO: Get projectId and repoId from URL params or context
const projectId = "default-project";
const repoId = "default-repo";
const { workspaces, loading, error, refresh } = useWorkspaces(projectId, repoId);
const actions = useWorkspaceActions();
const handleCreate = async (data: { name: string; branch: string }) => {
await actions.create(projectId, repoId, data);
setShowCreate(false);
await refresh();
};
const handleDelete = async (workspace: Workspace) => {
await actions.delete(projectId, repoId, workspace, refresh);
};
const handleSync = async (workspace: Workspace) => {
await actions.sync(projectId, repoId, workspace, refresh);
};
const handleStartTool = async (toolTypeId: string, configProfileId?: string) => {
if (!startWorkspace) return;
// TODO: Call instance creation API with workspace_id
console.log("Start tool", { toolTypeId, configProfileId, workspace: startWorkspace.id });
setStartWorkspace(null);
};
return (
<div className="page workspaces-page">
<header className="page-header">
<h1>Workspaces</h1>
<div className="header-actions">
<button className="btn btn-secondary" onClick={refresh} disabled={loading}>
<Icon name="refresh" size="sm" />
</button>
<button className="btn btn-primary" onClick={() => setShowCreate(true)}>
<Icon name="add" size="sm" /> New Workspace
</button>
</div>
</header>
{error && <div className="alert alert-error">{error}</div>}
{showCreate && (
<WorkspaceCreateForm
projectId={projectId}
repoId={repoId}
onSubmit={handleCreate}
onCancel={() => setShowCreate(false)}
/>
)}
{loading && workspaces.length === 0 ? (
<div className="loading-state">Loading workspaces...</div>
) : workspaces.length === 0 ? (
<div className="empty-state">
<p>No workspaces yet.</p>
<button className="btn btn-primary" onClick={() => setShowCreate(true)}>
<Icon name="add" size="sm" /> Create your first workspace
</button>
</div>
) : (
<div className="workspaces-grid">
{workspaces.map((ws) => (
<WorkspaceCard
key={ws.id}
workspace={ws}
loading={actions.loadingId === ws.id}
onStartTool={setStartWorkspace}
onSync={handleSync}
onDelete={handleDelete}
/>
))}
</div>
)}
{startWorkspace && (
<StartToolModal
workspace={startWorkspace}
onClose={() => setStartWorkspace(null)}
onStart={handleStartTool}
/>
)}
</div>
);
}
+2
View File
@@ -16,6 +16,7 @@ import { ToolWorkshopPage } from "./pages/tool-workshop";
import { SSHKeysPage } from "./pages/ssh-keys";
import { ConfigProfilesPage } from "./pages/config-profiles";
import { SessionsPage } from "./pages/sessions";
import { WorkspacesPage } from "./pages/workspaces";
export const AppRouter = () => {
return (
@@ -45,6 +46,7 @@ export const AppRouter = () => {
<Route path="*" element={<Navigate to="general" replace />} />
</Route>
<Route path="sessions" element={<SessionsPage />} />
<Route path="workspaces" element={<WorkspacesPage />} />
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
</Route>
+28
View File
@@ -0,0 +1,28 @@
/** Types for the workspace feature. */
export interface Workspace {
id: string;
name: string;
repo_id: string;
repo_name: string;
project_name: string;
user_id: string;
branch: string;
path: string;
status: "ready" | "syncing" | "error";
last_sync_at: string | null;
created_at: string;
updated_at: string;
instance_count: number;
}
export interface CreateWorkspaceRequest {
name: string;
branch: string;
}
export interface SyncResult {
branch_deleted: boolean;
pulled: boolean;
last_sync_at: string | null;
}