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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user