feat: new ToolStarter component — unified workspace-first tool starting

- New ToolStarter component: workspace context, fetches real tool types,
  auto-fetches config profiles per tool type, shows SSH key status
- Backend: add repo_ssh_key_id to workspace list responses
- WorkspacesPage: uses ToolStarter in modal instead of StartToolModal
- WorkspaceDetailPage ToolsTab: uses ToolStarter in modal
- Removed old inline StartToolModal from workspace-detail.tsx
- Styles: .tool-starter-context, .context-row, .ssh-key-status

Quality gates: ruff clean, tsc --noEmit clean, pytest workspaces API (9 passed, 1 skipped)
This commit is contained in:
2026-06-01 21:27:21 +02:00
parent ff8aa2a4f5
commit c1445976d7
6 changed files with 351 additions and 50 deletions
+4 -1
View File
@@ -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),
+268
View File
@@ -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<ToolType[]>([]);
const [toolTypesLoading, setToolTypesLoading] = useState(true);
const [toolTypesError, setToolTypesError] = useState<string | null>(null);
const [selectedToolTypeId, setSelectedToolTypeId] = useState("");
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
const [profilesLoading, setProfilesLoading] = useState(false);
const [selectedProfileId, setSelectedProfileId] = useState("");
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
const [sshKeysLoading, setSshKeysLoading] = useState(true);
const [starting, setStarting] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="tool-starter">
{/* Context header — read-only workspace info */}
<div className="tool-starter-context">
<div className="context-row">
<span className="context-label">Project</span>
<span className="context-value">{workspace.project_name}</span>
</div>
<div className="context-row">
<span className="context-label">Repository</span>
<span className="context-value">{workspace.repo_name}</span>
</div>
<div className="context-row">
<span className="context-label">Workspace</span>
<span className="context-value">{workspace.name}</span>
<span className="branch-badge">
<Icon name="branch" size="sm" /> {workspace.branch}
</span>
</div>
</div>
{/* Tool Type */}
<div className="form-group">
<label htmlFor="tool-type">Tool Type</label>
<select
id="tool-type"
value={selectedToolTypeId}
onChange={(e) => {
setSelectedToolTypeId(e.target.value);
setError(null);
}}
disabled={toolTypesLoading || starting}
>
<option value="">Select a tool...</option>
{toolTypes.map((tt) => (
<option key={tt.id} value={tt.id}>
{tt.display_name}
{tt.category && ` (${tt.category})`}
</option>
))}
</select>
{toolTypesLoading && (
<span className="muted">Loading tools...</span>
)}
{toolTypesError && (
<span className="error-text">{toolTypesError}</span>
)}
</div>
{/* Config Profile */}
{selectedToolTypeId && (
<div className="form-group">
<label htmlFor="config-profile">Config Profile</label>
<select
id="config-profile"
value={selectedProfileId}
onChange={(e) => setSelectedProfileId(e.target.value)}
disabled={profilesLoading || starting}
>
<option value="">Default (no profile)</option>
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
{p.is_default && " (default)"}
</option>
))}
</select>
{profilesLoading && (
<span className="muted">Loading profiles...</span>
)}
{profiles.length === 0 && !profilesLoading && (
<span className="muted">No custom profiles for this tool.</span>
)}
</div>
)}
{/* SSH Key Status */}
<div className="form-group ssh-key-status">
<label>SSH Key</label>
{sshKeysLoading ? (
<span className="muted">Checking...</span>
) : repoHasSshKey ? (
<span className="success-text">
<Icon name="success" size="sm" />{" "}
{repoSshKey?.name || "SSH key assigned"}
</span>
) : (
<span className="warning-text">
<Icon name="warning" size="sm" />{" "}
No SSH key assigned to repository
</span>
)}
</div>
{error && <p className="form-error">{error}</p>}
<div className="form-actions">
{onCancel && (
<button
type="button"
className="btn btn-secondary"
onClick={onCancel}
disabled={starting}
>
Cancel
</button>
)}
<button
type="button"
className="btn btn-primary"
onClick={handleStart}
disabled={!selectedToolTypeId || toolTypesLoading || starting}
>
{starting ? (
<>
<Icon name="loading" size="sm" /> Starting...
</>
) : (
<>
<Icon name="play" size="sm" /> Start Tool
</>
)}
</button>
</div>
</div>
);
}
+16 -23
View File
@@ -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 }) {
<button
className="btn btn-primary"
onClick={() => setShowModal(true)}
disabled={starting}
>
{starting ? "Starting..." : "Start Tool"}
Start Tool
</button>
</div>
) : (
@@ -358,29 +355,25 @@ function ToolsTab({ workspace }: { workspace: Workspace }) {
<button
className="btn btn-primary"
onClick={() => setShowModal(true)}
disabled={starting}
>
{starting ? "Starting..." : "Start Another Tool"}
Start Another Tool
</button>
</>
)}
{showModal && (
<StartToolModal
workspace={workspace}
onClose={() => setShowModal(false)}
onStart={async (toolTypeId, configProfileId) => {
const instance = await startTool(
workspace,
toolTypeId,
undefined,
configProfileId,
);
if (instance) {
setShowModal(false);
await refresh();
}
}}
/>
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h3>Start Tool</h3>
<ToolStarter
workspace={workspace}
onStarted={() => {
setShowModal(false);
void refresh();
}}
onCancel={() => setShowModal(false)}
/>
</div>
</div>
)}
</div>
);
+14 -26
View File
@@ -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 (
<div className="page workspaces-page">
<header className="page-header">
@@ -109,11 +89,19 @@ export function WorkspacesPage() {
)}
{startWorkspace && (
<StartToolModal
workspace={startWorkspace}
onClose={() => setStartWorkspace(null)}
onStart={handleStartTool}
/>
<div className="modal-overlay" onClick={() => setStartWorkspace(null)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h3>Start Tool</h3>
<ToolStarter
workspace={startWorkspace}
onStarted={() => {
setStartWorkspace(null);
void refresh();
}}
onCancel={() => setStartWorkspace(null)}
/>
</div>
</div>
)}
</div>
);
+48
View File
@@ -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);
}
+1
View File
@@ -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;