diff --git a/apps/web/src/api/workspaces.ts b/apps/web/src/api/workspaces.ts new file mode 100644 index 0000000..cba8972 --- /dev/null +++ b/apps/web/src/api/workspaces.ts @@ -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 { + const response = await apiClient.get(workspaceUrl(projectId, repoId)); + return response.data; +} + +export async function createWorkspace( + projectId: string, + repoId: string, + data: CreateWorkspaceRequest, +): Promise { + const response = await apiClient.post(workspaceUrl(projectId, repoId), data); + return response.data; +} + +export async function getWorkspace( + projectId: string, + repoId: string, + workspaceId: string, +): Promise { + const response = await apiClient.get(workspaceUrl(projectId, repoId, workspaceId)); + return response.data; +} + +export async function updateWorkspace( + projectId: string, + repoId: string, + workspaceId: string, + data: Partial, +): Promise { + const response = await apiClient.patch(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 { + const response = await apiClient.post( + `${workspaceUrl(projectId, repoId, workspaceId)}/sync`, + ); + return response.data; +} diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx index a76cb0a..3d924b6 100644 --- a/apps/web/src/components/app-shell.tsx +++ b/apps/web/src/components/app-shell.tsx @@ -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" }, diff --git a/apps/web/src/components/start-tool-modal.tsx b/apps/web/src/components/start-tool-modal.tsx new file mode 100644 index 0000000..02a7f0b --- /dev/null +++ b/apps/web/src/components/start-tool-modal.tsx @@ -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; +} + +export function StartToolModal({ workspace, onClose, onStart }: StartToolModalProps) { + const [toolTypeId, setToolTypeId] = useState(""); + const [configProfileId, setConfigProfileId] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( +
+
e.stopPropagation()}> +
+

+ Start Tool on {workspace.name} +

+ +
+
+
+ + +
+
+ + setConfigProfileId(e.target.value)} + placeholder="Profile ID" + disabled={submitting} + /> +
+ {error &&

{error}

} +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/workspace-card.tsx b/apps/web/src/components/workspace-card.tsx new file mode 100644 index 0000000..dd0ad4c --- /dev/null +++ b/apps/web/src/components/workspace-card.tsx @@ -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 ( +
+
+

{workspace.name}

+ {workspace.status} +
+
+

+ {workspace.project_name} / {workspace.repo_name} +

+

+ {workspace.branch} +

+ {workspace.instance_count > 0 && ( +

+ {workspace.instance_count} active tool + {workspace.instance_count > 1 ? "s" : ""} +

+ )} +
+
+ + + +
+
+ ); +} diff --git a/apps/web/src/components/workspace-create-form.tsx b/apps/web/src/components/workspace-create-form.tsx new file mode 100644 index 0000000..f2c9631 --- /dev/null +++ b/apps/web/src/components/workspace-create-form.tsx @@ -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; + 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(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 ( +
+

+ Create Workspace +

+
+ + setName(e.target.value)} + placeholder="e.g., feature-branch" + disabled={submitting} + /> +
+
+ + setBranch(e.target.value)} + placeholder="main" + disabled={submitting} + /> +
+ {error &&

{error}

} +
+ + +
+
+ ); +} diff --git a/apps/web/src/hooks/use-workspace-actions.ts b/apps/web/src/hooks/use-workspace-actions.ts new file mode 100644 index 0000000..594b7a2 --- /dev/null +++ b/apps/web/src/hooks/use-workspace-actions.ts @@ -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; + delete: ( + projectId: string, + repoId: string, + workspace: Workspace, + onRefresh: () => Promise, + ) => Promise; + sync: ( + projectId: string, + repoId: string, + workspace: Workspace, + onRefresh: () => Promise, + ) => Promise; + update: ( + projectId: string, + repoId: string, + workspaceId: string, + data: Partial, + ) => Promise; +} + +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(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, + ) => { + 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, + ) => { + 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, + ) => { + return updateWorkspace(projectId, repoId, workspaceId, data); + }, + [], + ); + + return { + loadingId, + create, + delete: deleteAction, + sync, + update, + }; +} diff --git a/apps/web/src/hooks/use-workspaces.ts b/apps/web/src/hooks/use-workspaces.ts new file mode 100644 index 0000000..65691c6 --- /dev/null +++ b/apps/web/src/hooks/use-workspaces.ts @@ -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; +} + +export function useWorkspaces(projectId: string, repoId: string): UseWorkspacesResult { + const [workspaces, setWorkspaces] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 }; +} diff --git a/apps/web/src/pages/workspaces.tsx b/apps/web/src/pages/workspaces.tsx new file mode 100644 index 0000000..d055323 --- /dev/null +++ b/apps/web/src/pages/workspaces.tsx @@ -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(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 ( +
+
+

Workspaces

+
+ + +
+
+ + {error &&
{error}
} + + {showCreate && ( + setShowCreate(false)} + /> + )} + + {loading && workspaces.length === 0 ? ( +
Loading workspaces...
+ ) : workspaces.length === 0 ? ( +
+

No workspaces yet.

+ +
+ ) : ( +
+ {workspaces.map((ws) => ( + + ))} +
+ )} + + {startWorkspace && ( + setStartWorkspace(null)} + onStart={handleStartTool} + /> + )} +
+ ); +} diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 2debf3a..2b292b1 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -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 = () => { } /> } /> + } /> } /> } /> diff --git a/apps/web/src/types/workspace.ts b/apps/web/src/types/workspace.ts new file mode 100644 index 0000000..1aaf5f1 --- /dev/null +++ b/apps/web/src/types/workspace.ts @@ -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; +}