diff --git a/apps/api/src/api/workspaces.py b/apps/api/src/api/workspaces.py index 5bdda47..0614475 100644 --- a/apps/api/src/api/workspaces.py +++ b/apps/api/src/api/workspaces.py @@ -38,6 +38,7 @@ async def list_all_workspaces( Workspace, GitRepository.name.label("repo_name"), GitRepository.project_id, + GitRepository.ssh_key_id.label("repo_ssh_key_id"), instance_count.label("instance_count"), ) .join(GitRepository, Workspace.repo_id == GitRepository.id) @@ -52,6 +53,7 @@ async def list_all_workspaces( "name": ws.name, "repo_id": str(ws.repo_id), "repo_name": repo_name or "", + "repo_ssh_key_id": str(ssh_key_id) if ssh_key_id else None, "project_id": str(project_id) if project_id else "", "project_name": "", "user_id": str(ws.user_id), @@ -63,7 +65,7 @@ async def list_all_workspaces( "updated_at": ws.updated_at.isoformat() if ws.updated_at else None, "instance_count": count or 0, } - for ws, repo_name, project_id, count in rows + for ws, repo_name, project_id, ssh_key_id, count in rows ] @@ -190,6 +192,7 @@ async def list_workspaces( "name": ws.name, "repo_id": str(ws.repo_id), "repo_name": repo.name, + "repo_ssh_key_id": str(repo.ssh_key_id) if repo.ssh_key_id else None, "project_id": str(repo.project_id) if repo.project_id else "", "project_name": repo.project.name if repo.project else "", "user_id": str(ws.user_id), diff --git a/apps/web/src/components/tool-starter.tsx b/apps/web/src/components/tool-starter.tsx new file mode 100644 index 0000000..9821790 --- /dev/null +++ b/apps/web/src/components/tool-starter.tsx @@ -0,0 +1,268 @@ +/** Unified tool starter — workspace-first, fetches real tool types and config profiles. */ + +import { useState, useEffect, useCallback } from "react"; +import { Icon } from "./icon"; +import { listToolTypes, type ToolType } from "../api/tool_types"; +import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles"; +import { listSSHKeys, type SSHKey } from "../api/ssh_keys"; +import type { Workspace } from "../types/workspace"; +import type { ToolInstance } from "../api/sessions"; + +export interface ToolStarterProps { + workspace: Workspace; + onStarted: (instance: ToolInstance) => void; + onCancel?: () => void; +} + +export function ToolStarter({ workspace, onStarted, onCancel }: ToolStarterProps) { + const [toolTypes, setToolTypes] = useState([]); + const [toolTypesLoading, setToolTypesLoading] = useState(true); + const [toolTypesError, setToolTypesError] = useState(null); + + const [selectedToolTypeId, setSelectedToolTypeId] = useState(""); + + const [profiles, setProfiles] = useState([]); + const [profilesLoading, setProfilesLoading] = useState(false); + const [selectedProfileId, setSelectedProfileId] = useState(""); + + const [sshKeys, setSshKeys] = useState([]); + const [sshKeysLoading, setSshKeysLoading] = useState(true); + + const [starting, setStarting] = useState(false); + const [error, setError] = useState(null); + + // Fetch tool types on mount + useEffect(() => { + const load = async () => { + try { + const data = await listToolTypes(); + setToolTypes(data); + } catch (err) { + setToolTypesError( + err instanceof Error ? err.message : "Failed to load tool types", + ); + } finally { + setToolTypesLoading(false); + } + }; + void load(); + }, []); + + // Fetch config profiles when tool type changes + useEffect(() => { + if (!selectedToolTypeId) { + setProfiles([]); + setSelectedProfileId(""); + return; + } + const load = async () => { + setProfilesLoading(true); + try { + const data = await listConfigProfiles( + workspace.project_id, + selectedToolTypeId, + ); + setProfiles(data); + // Auto-select default profile if available + const defaultProfile = data.find((p) => p.is_default); + if (defaultProfile) { + setSelectedProfileId(defaultProfile.id); + } else { + setSelectedProfileId(""); + } + } catch { + setProfiles([]); + } finally { + setProfilesLoading(false); + } + }; + void load(); + }, [selectedToolTypeId, workspace.project_id]); + + // Fetch SSH keys on mount + useEffect(() => { + const load = async () => { + try { + const data = await listSSHKeys(); + setSshKeys(data); + } catch { + // ignore + } finally { + setSshKeysLoading(false); + } + }; + void load(); + }, []); + + const repoHasSshKey = !!workspace.repo_ssh_key_id; + const repoSshKey = sshKeys.find((k) => k.id === workspace.repo_ssh_key_id); + + const handleStart = useCallback(async () => { + if (!selectedToolTypeId) { + setError("Please select a tool type"); + return; + } + setStarting(true); + setError(null); + try { + const { createInstance, startInstance } = await import( + "../api/sessions" + ); + const instance = await createInstance( + workspace.project_id, + workspace.repo_id, + selectedToolTypeId, + workspace.name, + undefined, + undefined, + undefined, + selectedProfileId || undefined, + [], + workspace.id, + ); + await startInstance( + workspace.project_id, + workspace.repo_id, + instance.id, + selectedProfileId || undefined, + ); + onStarted(instance); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to start tool"); + } finally { + setStarting(false); + } + }, [ + selectedToolTypeId, + selectedProfileId, + workspace, + onStarted, + ]); + + return ( +
+ {/* Context header — read-only workspace info */} +
+
+ Project + {workspace.project_name} +
+
+ Repository + {workspace.repo_name} +
+
+ Workspace + {workspace.name} + + {workspace.branch} + +
+
+ + {/* Tool Type */} +
+ + + {toolTypesLoading && ( + Loading tools... + )} + {toolTypesError && ( + {toolTypesError} + )} +
+ + {/* Config Profile */} + {selectedToolTypeId && ( +
+ + + {profilesLoading && ( + Loading profiles... + )} + {profiles.length === 0 && !profilesLoading && ( + No custom profiles for this tool. + )} +
+ )} + + {/* SSH Key Status */} +
+ + {sshKeysLoading ? ( + Checking... + ) : repoHasSshKey ? ( + + {" "} + {repoSshKey?.name || "SSH key assigned"} + + ) : ( + + {" "} + No SSH key assigned to repository + + )} +
+ + {error &&

{error}

} + +
+ {onCancel && ( + + )} + +
+
+ ); +} diff --git a/apps/web/src/pages/workspace-detail.tsx b/apps/web/src/pages/workspace-detail.tsx index 43ed24b..3287307 100644 --- a/apps/web/src/pages/workspace-detail.tsx +++ b/apps/web/src/pages/workspace-detail.tsx @@ -7,9 +7,8 @@ import { useWorkspaces } from "../hooks/use-workspaces"; import { useWorkspaceFiles } from "../hooks/use-workspace-files"; import { useWorkspaceGit } from "../hooks/use-workspace-git"; import { useWorkspaceInstances } from "../hooks/use-workspace-instances"; -import { useStartTool } from "../hooks/use-start-tool"; import { useMobileViewport } from "../hooks/use-mobile-viewport"; -import { StartToolModal } from "../components/start-tool-modal"; +import { ToolStarter } from "../components/tool-starter"; import type { FileEntry } from "../api/workspace-files"; import type { Workspace } from "../types/workspace"; @@ -314,7 +313,6 @@ function GitTab({ workspaceId }: { workspaceId: string }) { function ToolsTab({ workspace }: { workspace: Workspace }) { const { instances, loading, refresh } = useWorkspaceInstances(workspace.id); - const { startTool, starting } = useStartTool(); const [showModal, setShowModal] = useState(false); return ( @@ -328,9 +326,8 @@ function ToolsTab({ workspace }: { workspace: Workspace }) { ) : ( @@ -358,29 +355,25 @@ function ToolsTab({ workspace }: { workspace: Workspace }) { )} {showModal && ( - setShowModal(false)} - onStart={async (toolTypeId, configProfileId) => { - const instance = await startTool( - workspace, - toolTypeId, - undefined, - configProfileId, - ); - if (instance) { - setShowModal(false); - await refresh(); - } - }} - /> +
setShowModal(false)}> +
e.stopPropagation()}> +

Start Tool

+ { + setShowModal(false); + void refresh(); + }} + onCancel={() => setShowModal(false)} + /> +
+
)} ); diff --git a/apps/web/src/pages/workspaces.tsx b/apps/web/src/pages/workspaces.tsx index e86833e..6a60769 100644 --- a/apps/web/src/pages/workspaces.tsx +++ b/apps/web/src/pages/workspaces.tsx @@ -4,10 +4,9 @@ import { useState } from "react"; import { Icon } from "../components/icon"; import { useWorkspaces } from "../hooks/use-workspaces"; import { useWorkspaceActions } from "../hooks/use-workspace-actions"; -import { useStartTool } from "../hooks/use-start-tool"; import { WorkspaceCard } from "../components/workspace-card"; import { WorkspaceCreateForm } from "../components/workspace-create-form"; -import { StartToolModal } from "../components/start-tool-modal"; +import { ToolStarter } from "../components/tool-starter"; import type { Workspace } from "../types/workspace"; export function WorkspacesPage() { @@ -16,8 +15,6 @@ export function WorkspacesPage() { const { workspaces, loading, error, refresh } = useWorkspaces(); const actions = useWorkspaceActions(); - const { startTool } = useStartTool(); - const handleDelete = async (workspace: Workspace) => { await actions.delete(workspace, refresh); }; @@ -31,23 +28,6 @@ export function WorkspacesPage() { ); }; - const handleStartTool = async ( - toolTypeId: string, - configProfileId?: string, - ) => { - if (!startWorkspace) return; - const instance = await startTool( - startWorkspace, - toolTypeId, - undefined, - configProfileId, - ); - if (instance) { - setStartWorkspace(null); - await refresh(); - } - }; - return (
@@ -109,11 +89,19 @@ export function WorkspacesPage() { )} {startWorkspace && ( - setStartWorkspace(null)} - onStart={handleStartTool} - /> +
setStartWorkspace(null)}> +
e.stopPropagation()}> +

Start Tool

+ { + setStartWorkspace(null); + void refresh(); + }} + onCancel={() => setStartWorkspace(null)} + /> +
+
)}
); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 59af8bd..a123ee2 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -5503,3 +5503,51 @@ a:active, justify-content: flex-end; margin-top: var(--space-2); } + +/* ─── Tool Starter ─── */ +.tool-starter { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.tool-starter-context { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 10px; + padding: var(--space-3); + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.context-row { + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; +} + +.context-label { + font-size: var(--font-size-sm); + color: var(--muted); + min-width: 80px; +} + +.context-value { + font-weight: 500; +} + +.ssh-key-status { + padding: var(--space-2); + background: var(--bg); + border-radius: 8px; +} + +.ssh-key-status .success-text, +.ssh-key-status .warning-text { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--font-size-sm); +} diff --git a/apps/web/src/types/workspace.ts b/apps/web/src/types/workspace.ts index 8051129..65b6b02 100644 --- a/apps/web/src/types/workspace.ts +++ b/apps/web/src/types/workspace.ts @@ -5,6 +5,7 @@ export interface Workspace { name: string; repo_id: string; repo_name: string; + repo_ssh_key_id: string | null; project_id: string; project_name: string; user_id: string;