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.
This commit is contained in:
2026-05-31 23:41:45 +02:00
parent 5bba2bbd92
commit a5d64d1859
15 changed files with 831 additions and 699 deletions
+154 -140
View File
@@ -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<ToolInstance[]> {
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<ToolInstance> {
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<void> {
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<Session[]> {
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<InstanceHealth> {
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;
}
+54 -37
View File
@@ -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<Workspace[]> {
const response = await apiClient.get<Workspace[]>(workspaceUrl(projectId, repoId));
return response.data;
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,
projectId: string,
repoId: string,
data: CreateWorkspaceRequest,
): Promise<Workspace> {
const response = await apiClient.post<Workspace>(workspaceUrl(projectId, repoId), data);
return response.data;
const response = await apiClient.post<Workspace>(
workspaceUrl(projectId, repoId),
data,
);
return response.data;
}
export async function getWorkspace(
projectId: string,
repoId: string,
workspaceId: string,
projectId: string,
repoId: string,
workspaceId: string,
): Promise<Workspace> {
const response = await apiClient.get<Workspace>(workspaceUrl(projectId, repoId, workspaceId));
return response.data;
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>,
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;
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,
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<SyncResult> {
const response = await apiClient.post<SyncResult>(
`${workspaceUrl(projectId, repoId, workspaceId)}/sync`,
);
return response.data;
const response = await apiClient.post<SyncResult>(
`${workspaceUrl(projectId, repoId, workspaceId)}/sync`,
);
return response.data;
}
+88 -75
View File
@@ -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<void>;
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);
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);
}
};
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>
);
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>
);
}
+63 -61
View File
@@ -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 (
<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>
);
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>
);
}
@@ -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<void>;
onCancel: () => void;
projectId: string;
repoId: string;
defaultBranch?: string;
onSubmit: (data: CreateWorkspaceRequest) => Promise<void>;
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<string | null>(null);
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);
}
};
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>
);
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>
);
}
+133 -126
View File
@@ -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<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>;
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;
};
};
};
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 [loadingId, setLoadingId] = useState<string | null>(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<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 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 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);
},
[],
);
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,
};
return {
loadingId,
create,
delete: deleteAction,
sync,
update,
};
}
+29 -24
View File
@@ -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<void>;
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);
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]);
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 };
}
+110 -94
View File
@@ -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<Workspace | null>(null);
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";
// 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 (
<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>
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>}
{error && <div className="alert alert-error">{error}</div>}
{showCreate && (
<WorkspaceCreateForm
projectId={projectId}
repoId={repoId}
onSubmit={handleCreate}
onCancel={() => setShowCreate(false)}
/>
)}
{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>
)}
{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>
);
{startWorkspace && (
<StartToolModal
workspace={startWorkspace}
onClose={() => setStartWorkspace(null)}
onStart={handleStartTool}
/>
)}
</div>
);
}
+50 -35
View File
@@ -19,39 +19,54 @@ import { SessionsPage } from "./pages/sessions";
import { WorkspacesPage } from "./pages/workspaces";
export const AppRouter = () => {
return (
<Routes>
<Route path="/login" element={<LoginRedirectPage />} />
<Route path="/ssh-keys" element={<Navigate to="/settings/ssh-keys" replace />} />
<Route
path="/"
element={
<ProtectedRoute>
<AppShell />
</ProtectedRoute>
}
>
<Route index element={<HomePage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:projectId" element={<RepoWorkspace />} />
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} />
<Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} />
<Route path="projects/:projectId/settings/*" element={<ProjectSettingsPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="config-profiles" element={<ConfigProfilesPage />} />
<Route path="settings" element={<SettingsPage />}>
<Route index element={<Navigate to="general" replace />} />
<Route path="general" element={<GeneralSettingsTab />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
<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>
<Route path="/404" element={<NotFoundPage />} />
<Route path="*" element={<Navigate to="/404" replace />} />
</Routes>
);
return (
<Routes>
<Route path="/login" element={<LoginRedirectPage />} />
<Route
path="/ssh-keys"
element={<Navigate to="/settings/ssh-keys" replace />}
/>
<Route
path="/"
element={
<ProtectedRoute>
<AppShell />
</ProtectedRoute>
}
>
<Route index element={<HomePage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:projectId" element={<RepoWorkspace />} />
<Route
path="projects/:projectId/repositories"
element={<GitRepositoriesPage />}
/>
<Route
path="projects/:projectId/repositories/:repoId/history"
element={<GitHistoryPage />}
/>
<Route
path="projects/:projectId/settings/*"
element={<ProjectSettingsPage />}
/>
<Route path="profile" element={<ProfilePage />} />
<Route path="config-profiles" element={<ConfigProfilesPage />} />
<Route path="settings" element={<SettingsPage />}>
<Route index element={<Navigate to="general" replace />} />
<Route path="general" element={<GeneralSettingsTab />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
<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>
<Route path="/404" element={<NotFoundPage />} />
<Route path="*" element={<Navigate to="/404" replace />} />
</Routes>
);
};
+18 -18
View File
@@ -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;
}