From a5d64d1859d4b947f0e44bd2d57f467c08a8d953 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Sun, 31 May 2026 23:41:45 +0200 Subject: [PATCH] fix: add from __future__ import annotations to workspace_manager.py Fixes NameError: ToolInstance not defined at runtime because type annotations are evaluated at class definition time. Deferring annotation evaluation with __future__ annotations keeps TYPE_CHECKING imports from causing runtime crashes. Also includes ruff formatting cleanup on workspace-related files. --- apps/api/src/api/tool_instances.py | 1 + apps/api/src/api/workspaces.py | 8 +- apps/api/src/services/workspace_manager.py | 6 +- .../tests/integration/test_workspaces_api.py | 59 +++- apps/api/tests/unit/test_git_service.py | 4 +- apps/web/src/api/sessions.ts | 294 +++++++++--------- apps/web/src/api/workspaces.ts | 91 +++--- apps/web/src/components/start-tool-modal.tsx | 163 +++++----- apps/web/src/components/workspace-card.tsx | 124 ++++---- .../src/components/workspace-create-form.tsx | 143 +++++---- apps/web/src/hooks/use-workspace-actions.ts | 259 +++++++-------- apps/web/src/hooks/use-workspaces.ts | 53 ++-- apps/web/src/pages/workspaces.tsx | 204 ++++++------ apps/web/src/router.tsx | 85 ++--- apps/web/src/types/workspace.ts | 36 +-- 15 files changed, 831 insertions(+), 699 deletions(-) diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 7aa4908..b86dca4 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -1684,6 +1684,7 @@ async def start_instance( repo_path = "" if instance.workspace_id: from src.models.workspace import Workspace as WorkspaceModel + workspace = await session.get(WorkspaceModel, instance.workspace_id) if workspace: repo_path = workspace.path diff --git a/apps/api/src/api/workspaces.py b/apps/api/src/api/workspaces.py index 70d5278..fa80dec 100644 --- a/apps/api/src/api/workspaces.py +++ b/apps/api/src/api/workspaces.py @@ -108,7 +108,9 @@ async def create_workspace( "branch": workspace.branch, "path": workspace.path, "status": workspace.status, - "created_at": workspace.created_at.isoformat() if workspace.created_at else None, + "created_at": workspace.created_at.isoformat() + if workspace.created_at + else None, } @@ -216,9 +218,7 @@ async def delete_workspace( status_code=409, detail={ "message": "Workspace has running tool instances", - "instances": [ - {"id": str(i.id), "name": i.name} for i in exc.instances - ], + "instances": [{"id": str(i.id), "name": i.name} for i in exc.instances], }, ) from exc except Exception as exc: diff --git a/apps/api/src/services/workspace_manager.py b/apps/api/src/services/workspace_manager.py index fccf957..cc7d886 100644 --- a/apps/api/src/services/workspace_manager.py +++ b/apps/api/src/services/workspace_manager.py @@ -1,5 +1,7 @@ """Workspace lifecycle management service.""" +from __future__ import annotations + import logging import os import shutil @@ -170,6 +172,4 @@ class WorkspaceManager: TODO(PR-2): Wire up to actual instance stop/delete logic. For now, this is a placeholder. """ - logger.warning( - "Placeholder: stopping and deleting instance %s", instance.id - ) + logger.warning("Placeholder: stopping and deleting instance %s", instance.id) diff --git a/apps/api/tests/integration/test_workspaces_api.py b/apps/api/tests/integration/test_workspaces_api.py index 6c211a9..cb7f4d5 100644 --- a/apps/api/tests/integration/test_workspaces_api.py +++ b/apps/api/tests/integration/test_workspaces_api.py @@ -60,7 +60,9 @@ def test_repo(db_session: AsyncSession, authenticated_client: TestClient): class TestListWorkspaces: """Tests for GET /projects/{pid}/repositories/{rid}/workspaces.""" - def test_list_empty(self, authenticated_client: TestClient, test_repo: GitRepository): + def test_list_empty( + self, authenticated_client: TestClient, test_repo: GitRepository + ): """Returns empty list when no workspaces exist.""" response = authenticated_client.get( f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces" @@ -69,7 +71,10 @@ class TestListWorkspaces: assert response.json() == [] def test_list_with_workspaces( - self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository + self, + authenticated_client: TestClient, + db_session: AsyncSession, + test_repo: GitRepository, ): """Returns workspaces with instance counts.""" ws = Workspace( @@ -99,7 +104,9 @@ class TestListWorkspaces: class TestCreateWorkspace: """Tests for POST /projects/{pid}/repositories/{rid}/workspaces.""" - def test_create_success(self, authenticated_client: TestClient, test_repo: GitRepository): + def test_create_success( + self, authenticated_client: TestClient, test_repo: GitRepository + ): """Creates a workspace and clones the repo.""" mock_ws = Workspace( id=uuid.uuid4(), @@ -110,7 +117,9 @@ class TestCreateWorkspace: path="/data/working-copies/test/feature-branch", ) - with patch.object(WorkspaceManager, "create", return_value=mock_ws) as mock_create: + with patch.object( + WorkspaceManager, "create", return_value=mock_ws + ) as mock_create: response = authenticated_client.post( f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces", json={"name": "feature-branch", "branch": "feature"}, @@ -121,7 +130,9 @@ class TestCreateWorkspace: assert data["branch"] == "feature" mock_create.assert_called_once() - def test_create_missing_name(self, authenticated_client: TestClient, test_repo: GitRepository): + def test_create_missing_name( + self, authenticated_client: TestClient, test_repo: GitRepository + ): """Returns 400 when name is missing.""" response = authenticated_client.post( f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces", @@ -131,7 +142,10 @@ class TestCreateWorkspace: assert "name" in response.json()["detail"] def test_create_duplicate_name( - self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository + self, + authenticated_client: TestClient, + db_session: AsyncSession, + test_repo: GitRepository, ): """Returns 409 when workspace name already exists.""" ws = Workspace( @@ -148,7 +162,9 @@ class TestCreateWorkspace: asyncio.run(_commit()) - with patch.object(WorkspaceManager, "create", side_effect=Exception("duplicate")): + with patch.object( + WorkspaceManager, "create", side_effect=Exception("duplicate") + ): response = authenticated_client.post( f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces", json={"name": "dev", "branch": "main"}, @@ -160,7 +176,10 @@ class TestDeleteWorkspace: """Tests for DELETE /projects/{pid}/repositories/{rid}/workspaces/{wid}.""" def test_delete_without_instances( - self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository + self, + authenticated_client: TestClient, + db_session: AsyncSession, + test_repo: GitRepository, ): """Deletes workspace when no instances exist.""" ws = Workspace( @@ -185,9 +204,14 @@ class TestDeleteWorkspace: assert response.status_code == 200 assert response.json()["status"] == "deleted" - @pytest.mark.skip(reason="Async fixture interaction with sync tests — endpoint logic verified manually") + @pytest.mark.skip( + reason="Async fixture interaction with sync tests — endpoint logic verified manually" + ) def test_delete_with_instances_no_force( - self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository + self, + authenticated_client: TestClient, + db_session: AsyncSession, + test_repo: GitRepository, ): """Returns 409 when workspace has instances and force=False.""" ws = Workspace( @@ -239,7 +263,10 @@ class TestDeleteWorkspace: assert len(detail["instances"]) == 1 def test_delete_with_instances_force( - self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository + self, + authenticated_client: TestClient, + db_session: AsyncSession, + test_repo: GitRepository, ): """Deletes workspace when force=True even with instances.""" ws = Workspace( @@ -268,7 +295,10 @@ class TestSyncWorkspace: """Tests for POST /projects/{pid}/repositories/{rid}/workspaces/{wid}/sync.""" def test_sync_success( - self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository + self, + authenticated_client: TestClient, + db_session: AsyncSession, + test_repo: GitRepository, ): """Sync succeeds and updates last_sync_at.""" ws = Workspace( @@ -298,7 +328,10 @@ class TestSyncWorkspace: assert data["pulled"] is True def test_sync_branch_deleted( - self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository + self, + authenticated_client: TestClient, + db_session: AsyncSession, + test_repo: GitRepository, ): """Returns 409 when branch was deleted from remote.""" ws = Workspace( diff --git a/apps/api/tests/unit/test_git_service.py b/apps/api/tests/unit/test_git_service.py index 444bc76..35fd263 100644 --- a/apps/api/tests/unit/test_git_service.py +++ b/apps/api/tests/unit/test_git_service.py @@ -21,7 +21,9 @@ class TestGitServiceClone: with patch( "asyncio.create_subprocess_exec", return_value=mock_proc ) as mock_exec: - await GitService.clone("https://github.com/test/repo.git", "main", "/tmp/ws") + await GitService.clone( + "https://github.com/test/repo.git", "main", "/tmp/ws" + ) mock_exec.assert_called_once_with( "git", diff --git a/apps/web/src/api/sessions.ts b/apps/web/src/api/sessions.ts index 0bc13e8..751bf6e 100644 --- a/apps/web/src/api/sessions.ts +++ b/apps/web/src/api/sessions.ts @@ -2,185 +2,199 @@ import { AxiosError } from "axios"; import { apiClient } from "./client"; export interface ToolInstance { - id: string; - name: string; - display_name: string; - tool_type_id: string; - tool_type_name: string; - tool_type_interfaces: string[]; - status: string; - url: string | null; - port: number | null; - selected_config_profile_id: string | null; - ssh_key_ids: string[]; - created_at: string; + id: string; + name: string; + display_name: string; + tool_type_id: string; + tool_type_name: string; + tool_type_interfaces: string[]; + status: string; + url: string | null; + port: number | null; + selected_config_profile_id: string | null; + ssh_key_ids: string[]; + created_at: string; } export interface Session { - id: string; - display_name: string; - tool_type_name: string; - tool_icon: string; - tool_type_interfaces: string[]; - repository_name: string; - repository_id: string; - project_name: string; - project_id: string; - status: string; - url: string | null; - container_status?: string; - probe_status?: string; - clone_mode?: string; - branch?: string | null; - created_at?: string; + id: string; + display_name: string; + tool_type_name: string; + tool_icon: string; + tool_type_interfaces: string[]; + repository_name: string; + repository_id: string; + project_name: string; + project_id: string; + status: string; + url: string | null; + container_status?: string; + probe_status?: string; + clone_mode?: string; + branch?: string | null; + created_at?: string; } export async function listInstances( - projectId: string, - repoId: string + projectId: string, + repoId: string, ): Promise { - const response = await apiClient.get( - `/projects/${projectId}/repositories/${repoId}/instances` - ); - return response.data.instances; + const response = await apiClient.get( + `/projects/${projectId}/repositories/${repoId}/instances`, + ); + return response.data.instances; } export async function createInstance( - projectId: string, - repoId: string, - toolTypeId: string, - displayName?: string, - cloneMode?: string, - branch?: string, - newBranch?: string, - configProfileId?: string, - sshKeyIds?: string[], - workspaceId?: string + projectId: string, + repoId: string, + toolTypeId: string, + displayName?: string, + cloneMode?: string, + branch?: string, + newBranch?: string, + configProfileId?: string, + sshKeyIds?: string[], + workspaceId?: string, ): Promise { - const response = await apiClient.post( - `/projects/${projectId}/repositories/${repoId}/instances`, - { - tool_type_id: toolTypeId, - display_name: displayName, - workspace_id: workspaceId || undefined, - clone_mode: cloneMode || "mount", - branch: branch || undefined, - new_branch: newBranch || undefined, - config_profile_id: configProfileId, - ssh_key_ids: sshKeyIds || [], - } - ); - return response.data; + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/instances`, + { + tool_type_id: toolTypeId, + display_name: displayName, + workspace_id: workspaceId || undefined, + clone_mode: cloneMode || "mount", + branch: branch || undefined, + new_branch: newBranch || undefined, + config_profile_id: configProfileId, + ssh_key_ids: sshKeyIds || [], + }, + ); + return response.data; } export async function startInstance( - projectId: string, - repoId: string, - instanceId: string, - configProfileId?: string, - sshKeyIds?: string[], - retries = 2 + projectId: string, + repoId: string, + instanceId: string, + configProfileId?: string, + sshKeyIds?: string[], + retries = 2, ): Promise<{ status: string; url?: string }> { - try { - const response = await apiClient.post( - `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`, - { config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] } - ); - return response.data; - } catch (error) { - // Retry on network errors (e.g. Docker creating network interfaces) - const axiosError = error as AxiosError; - if (retries > 0 && !axiosError.response) { - await new Promise((r) => setTimeout(r, 1500)); - return startInstance(projectId, repoId, instanceId, configProfileId, sshKeyIds, retries - 1); - } - throw error; - } + try { + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`, + { config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }, + ); + return response.data; + } catch (error) { + // Retry on network errors (e.g. Docker creating network interfaces) + const axiosError = error as AxiosError; + if (retries > 0 && !axiosError.response) { + await new Promise((r) => setTimeout(r, 1500)); + return startInstance( + projectId, + repoId, + instanceId, + configProfileId, + sshKeyIds, + retries - 1, + ); + } + throw error; + } } export async function stopInstance( - projectId: string, - repoId: string, - instanceId: string + projectId: string, + repoId: string, + instanceId: string, ): Promise<{ status: string }> { - const response = await apiClient.post( - `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop` - ); - return response.data; + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop`, + ); + return response.data; } export async function restartInstance( - projectId: string, - repoId: string, - instanceId: string, - configProfileId?: string, - sshKeyIds?: string[], - retries = 2 + projectId: string, + repoId: string, + instanceId: string, + configProfileId?: string, + sshKeyIds?: string[], + retries = 2, ): Promise<{ status: string; url?: string }> { - try { - const response = await apiClient.post( - `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`, - { config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] } - ); - return response.data; - } catch (error) { - // Retry on network errors (e.g. Docker creating network interfaces) - const axiosError = error as AxiosError; - if (retries > 0 && !axiosError.response) { - await new Promise((r) => setTimeout(r, 1500)); - return restartInstance(projectId, repoId, instanceId, configProfileId, sshKeyIds, retries - 1); - } - throw error; - } + try { + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`, + { config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }, + ); + return response.data; + } catch (error) { + // Retry on network errors (e.g. Docker creating network interfaces) + const axiosError = error as AxiosError; + if (retries > 0 && !axiosError.response) { + await new Promise((r) => setTimeout(r, 1500)); + return restartInstance( + projectId, + repoId, + instanceId, + configProfileId, + sshKeyIds, + retries - 1, + ); + } + throw error; + } } export async function deleteInstance( - projectId: string, - repoId: string, - instanceId: string, - force?: boolean + projectId: string, + repoId: string, + instanceId: string, + force?: boolean, ): Promise { - await apiClient.delete( - `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`, - { params: { force } } - ); + await apiClient.delete( + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`, + { params: { force } }, + ); } export async function getUserSessions(): Promise { - const response = await apiClient.get("/users/me/sessions"); - return response.data.sessions; + const response = await apiClient.get("/users/me/sessions"); + return response.data.sessions; } export interface InstanceHealth { - healthy: boolean; - container_status: string; - container_health: string | null; - container_exit_code: number | null; - tunnel_status: string; - tunnel_status_code: number | null; - probe_status: string; - last_probe_output: string | null; - error: string | null; + healthy: boolean; + container_status: string; + container_health: string | null; + container_exit_code: number | null; + tunnel_status: string; + tunnel_status_code: number | null; + probe_status: string; + last_probe_output: string | null; + error: string | null; } export async function checkInstanceHealth( - projectId: string, - repoId: string, - instanceId: string + projectId: string, + repoId: string, + instanceId: string, ): Promise { - const response = await apiClient.get( - `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health` - ); - return response.data; + const response = await apiClient.get( + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`, + ); + return response.data; } export async function recreateInstanceTunnel( - projectId: string, - repoId: string, - instanceId: string + projectId: string, + repoId: string, + instanceId: string, ): Promise<{ status: string; url?: string }> { - const response = await apiClient.post( - `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel` - ); - return response.data; + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`, + ); + return response.data; } diff --git a/apps/web/src/api/workspaces.ts b/apps/web/src/api/workspaces.ts index cba8972..1632770 100644 --- a/apps/web/src/api/workspaces.ts +++ b/apps/web/src/api/workspaces.ts @@ -1,65 +1,82 @@ /** Workspace API client. */ import { apiClient } from "./client"; -import type { Workspace, CreateWorkspaceRequest, SyncResult } from "../types/workspace"; +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; + 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 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, + projectId: string, + repoId: string, + data: CreateWorkspaceRequest, ): Promise { - const response = await apiClient.post(workspaceUrl(projectId, repoId), data); - return response.data; + const response = await apiClient.post( + workspaceUrl(projectId, repoId), + data, + ); + return response.data; } export async function getWorkspace( - projectId: string, - repoId: string, - workspaceId: string, + projectId: string, + repoId: string, + workspaceId: string, ): Promise { - const response = await apiClient.get(workspaceUrl(projectId, repoId, workspaceId)); - return response.data; + const response = await apiClient.get( + workspaceUrl(projectId, repoId, workspaceId), + ); + return response.data; } export async function updateWorkspace( - projectId: string, - repoId: string, - workspaceId: string, - data: Partial, + projectId: string, + repoId: string, + workspaceId: string, + data: Partial, ): Promise { - const response = await apiClient.patch(workspaceUrl(projectId, repoId, workspaceId), data); - return response.data; + 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, + 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; + 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, + projectId: string, + repoId: string, + workspaceId: string, ): Promise { - const response = await apiClient.post( - `${workspaceUrl(projectId, repoId, workspaceId)}/sync`, - ); - return response.data; + const response = await apiClient.post( + `${workspaceUrl(projectId, repoId, workspaceId)}/sync`, + ); + return response.data; } diff --git a/apps/web/src/components/start-tool-modal.tsx b/apps/web/src/components/start-tool-modal.tsx index 02a7f0b..67bcdd0 100644 --- a/apps/web/src/components/start-tool-modal.tsx +++ b/apps/web/src/components/start-tool-modal.tsx @@ -5,83 +5,96 @@ import { Icon } from "./icon"; import type { Workspace } from "../types/workspace"; export interface StartToolModalProps { - workspace: Workspace; - onClose: () => void; - onStart: (toolTypeId: string, configProfileId?: string) => Promise; + 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); +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); - } - }; + 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}

} -
- - -
-
-
-
- ); + 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 index dd0ad4c..d6e4e06 100644 --- a/apps/web/src/components/workspace-card.tsx +++ b/apps/web/src/components/workspace-card.tsx @@ -4,70 +4,72 @@ 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; + 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, + workspace, + loading = false, + onStartTool, + onSync, + onDelete, }: WorkspaceCardProps) { - const statusClass = - workspace.status === "ready" - ? "status-ready" - : workspace.status === "syncing" - ? "status-syncing" - : "status-error"; + 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" : ""} -

- )} -
-
- - - -
-
- ); + 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 index f2c9631..c907f3f 100644 --- a/apps/web/src/components/workspace-create-form.tsx +++ b/apps/web/src/components/workspace-create-form.tsx @@ -5,78 +5,85 @@ 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; + projectId: string; + repoId: string; + defaultBranch?: string; + onSubmit: (data: CreateWorkspaceRequest) => Promise; + onCancel: () => void; } export function WorkspaceCreateForm({ - defaultBranch = "main", - onSubmit, - onCancel, + 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 [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); - } - }; + 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}

} -
- - -
-
- ); + 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 index 594b7a2..52b5d99 100644 --- a/apps/web/src/hooks/use-workspace-actions.ts +++ b/apps/web/src/hooks/use-workspace-actions.ts @@ -2,145 +2,152 @@ import { useState, useCallback } from "react"; import { - createWorkspace, - deleteWorkspace, - syncWorkspace, - updateWorkspace, + 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; + 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; - }; - }; - }; + 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 [loadingId, setLoadingId] = useState(null); - const create = useCallback( - async (projectId: string, repoId: string, data: CreateWorkspaceRequest) => { - return createWorkspace(projectId, repoId, data); - }, - [], - ); + 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 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 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); - }, - [], - ); + 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, - }; + 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 index 65691c6..f27969f 100644 --- a/apps/web/src/hooks/use-workspaces.ts +++ b/apps/web/src/hooks/use-workspaces.ts @@ -5,33 +5,38 @@ import { listWorkspaces } from "../api/workspaces"; import type { Workspace } from "../types/workspace"; export interface UseWorkspacesResult { - workspaces: Workspace[]; - loading: boolean; - error: string | null; - refresh: () => Promise; + 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); +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]); + 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]); + useEffect(() => { + refresh(); + }, [refresh]); - return { workspaces, loading, error, refresh }; + return { workspaces, loading, error, refresh }; } diff --git a/apps/web/src/pages/workspaces.tsx b/apps/web/src/pages/workspaces.tsx index 92f36a0..9a1f681 100644 --- a/apps/web/src/pages/workspaces.tsx +++ b/apps/web/src/pages/workspaces.tsx @@ -11,109 +11,125 @@ import { createInstance, startInstance } from "../api/sessions"; import type { Workspace } from "../types/workspace"; export function WorkspacesPage() { - const [showCreate, setShowCreate] = useState(false); - const [startWorkspace, setStartWorkspace] = useState(null); + 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"; + // 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 { 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 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 handleDelete = async (workspace: Workspace) => { + await actions.delete(projectId, repoId, workspace, refresh); + }; - const handleSync = async (workspace: Workspace) => { - await actions.sync(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; - try { - const instance = await createInstance( - projectId, - repoId, - toolTypeId, - `${startWorkspace.name} - ${toolTypeId}`, - undefined, - undefined, - undefined, - configProfileId, - [], - startWorkspace.id - ); - await startInstance(projectId, repoId, instance.id, configProfileId); - setStartWorkspace(null); - await refresh(); - } catch (err) { - alert(err instanceof Error ? err.message : "Failed to start tool"); - } - }; + const handleStartTool = async ( + toolTypeId: string, + configProfileId?: string, + ) => { + if (!startWorkspace) return; + try { + const instance = await createInstance( + projectId, + repoId, + toolTypeId, + `${startWorkspace.name} - ${toolTypeId}`, + undefined, + undefined, + undefined, + configProfileId, + [], + startWorkspace.id, + ); + await startInstance(projectId, repoId, instance.id, configProfileId); + setStartWorkspace(null); + await refresh(); + } catch (err) { + alert(err instanceof Error ? err.message : "Failed to start tool"); + } + }; - return ( -
-
-

Workspaces

-
- - -
-
+ return ( +
+
+

Workspaces

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

No workspaces yet.

- -
- ) : ( -
- {workspaces.map((ws) => ( - - ))} -
- )} + {loading && workspaces.length === 0 ? ( +
Loading workspaces...
+ ) : workspaces.length === 0 ? ( +
+

No workspaces yet.

+ +
+ ) : ( +
+ {workspaces.map((ws) => ( + + ))} +
+ )} - {startWorkspace && ( - setStartWorkspace(null)} - onStart={handleStartTool} - /> - )} -
- ); + {startWorkspace && ( + setStartWorkspace(null)} + onStart={handleStartTool} + /> + )} +
+ ); } diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 2b292b1..2e60f54 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -19,39 +19,54 @@ import { SessionsPage } from "./pages/sessions"; import { WorkspacesPage } from "./pages/workspaces"; export const AppRouter = () => { - return ( - - } /> - } /> - - - - } - > - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - }> - } /> - } /> - } /> - } /> - - } /> - } /> - } /> - } /> - - } /> - } /> - - ); + return ( + + } /> + } + /> + + + + } + > + } /> + } /> + } /> + } + /> + } + /> + } + /> + } /> + } /> + }> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } + /> + + } /> + } /> + + ); }; diff --git a/apps/web/src/types/workspace.ts b/apps/web/src/types/workspace.ts index 1aaf5f1..801d0bb 100644 --- a/apps/web/src/types/workspace.ts +++ b/apps/web/src/types/workspace.ts @@ -1,28 +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; + 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; + name: string; + branch: string; } export interface SyncResult { - branch_deleted: boolean; - pulled: boolean; - last_sync_at: string | null; + branch_deleted: boolean; + pulled: boolean; + last_sync_at: string | null; }