6b947b7593
- Remove clone_mode/branch/new_branch from frontend create session flow. - Add workspace picker to CreateSessionForm; auto-create default workspace when repo selected. - Fix tool-starter.tsx and use-start-tool.ts createInstance signatures after API change. - Remove clone mode badge from SessionCard. - Delete stale backend unit tests referencing removed clone_mode schema fields. - Update OpenSpec working-copies tasks and mark change completed. - Regenerate project maps. Quality gates: npm run typecheck, npm run lint, npm test -- --run (82 passed), python3 -m py_compile on changed backend files.
353 lines
9.8 KiB
TypeScript
353 lines
9.8 KiB
TypeScript
/** 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 { useSessions } from "../../../state/sessions";
|
|
import { useSessionOperations } from "../../../state/session-operations";
|
|
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 { addOrUpdateSession } = useSessions();
|
|
const { startOperation } = useSessionOperations();
|
|
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 [selectedSshKeyIds, setSelectedSshKeyIds] = useState<string[]>([]);
|
|
|
|
const [displayName, setDisplayName] = useState(workspace.name);
|
|
const [nameEdited, setNameEdited] = useState(false);
|
|
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);
|
|
// Auto-select the repository's SSH key if available
|
|
if (workspace.repo_ssh_key_id) {
|
|
setSelectedSshKeyIds([workspace.repo_ssh_key_id]);
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to load SSH keys:", err);
|
|
} finally {
|
|
setSshKeysLoading(false);
|
|
}
|
|
};
|
|
void load();
|
|
}, [workspace.repo_ssh_key_id]);
|
|
|
|
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,
|
|
displayName.trim() || undefined,
|
|
workspace.id,
|
|
selectedProfileId || undefined,
|
|
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined,
|
|
);
|
|
await startInstance(
|
|
workspace.project_id,
|
|
workspace.repo_id,
|
|
instance.id,
|
|
selectedProfileId || undefined,
|
|
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined,
|
|
);
|
|
addOrUpdateSession({
|
|
id: instance.id,
|
|
display_name: instance.display_name,
|
|
tool_type_name: instance.tool_type_name,
|
|
tool_icon: "code",
|
|
tool_type_interfaces: instance.tool_type_interfaces || [],
|
|
repository_name: workspace.repo_name,
|
|
repository_id: workspace.repo_id,
|
|
project_name: workspace.project_name,
|
|
project_id: workspace.project_id,
|
|
workspace_name: workspace.name,
|
|
status: instance.status || "pending",
|
|
url: instance.url || null,
|
|
});
|
|
startOperation("create", instance.id, instance.display_name);
|
|
onStarted(instance);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to start tool");
|
|
} finally {
|
|
setStarting(false);
|
|
}
|
|
}, [
|
|
selectedToolTypeId,
|
|
selectedProfileId,
|
|
displayName,
|
|
workspace,
|
|
onStarted,
|
|
toolTypes,
|
|
addOrUpdateSession,
|
|
startOperation,
|
|
]);
|
|
|
|
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) => {
|
|
const toolId = e.target.value;
|
|
setSelectedToolTypeId(toolId);
|
|
setError(null);
|
|
const tt = toolTypes.find((t) => t.id === toolId);
|
|
if (tt && !nameEdited) {
|
|
setDisplayName(`${workspace.name} ${tt.display_name}`);
|
|
}
|
|
}}
|
|
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>
|
|
|
|
{/* Session Name */}
|
|
<div className="form-group">
|
|
<label htmlFor="session-name">Session Name</label>
|
|
<input
|
|
id="session-name"
|
|
type="text"
|
|
value={displayName}
|
|
onChange={(e) => {
|
|
setDisplayName(e.target.value);
|
|
setNameEdited(true);
|
|
}}
|
|
placeholder="My dev environment"
|
|
disabled={starting}
|
|
/>
|
|
</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 Selection */}
|
|
<div className="form-group ssh-key-selection">
|
|
<label>SSH Keys</label>
|
|
{sshKeysLoading ? (
|
|
<span className="muted">Loading SSH keys...</span>
|
|
) : sshKeys.length === 0 ? (
|
|
<span className="muted">No SSH keys configured.</span>
|
|
) : (
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
|
|
{sshKeys.map((key) => (
|
|
<label
|
|
key={key.id}
|
|
className="checkbox-label"
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.25rem",
|
|
padding: "0.375rem 0.75rem",
|
|
background: "var(--panel)",
|
|
borderRadius: "0.375rem",
|
|
border: "1px solid var(--border)",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedSshKeyIds.includes(key.id)}
|
|
onChange={(e) => {
|
|
if (e.target.checked) {
|
|
setSelectedSshKeyIds((prev) => [...prev, key.id]);
|
|
} else {
|
|
setSelectedSshKeyIds((prev) =>
|
|
prev.filter((id) => id !== key.id),
|
|
);
|
|
}
|
|
}}
|
|
disabled={starting}
|
|
/>
|
|
{key.name}
|
|
</label>
|
|
))}
|
|
</div>
|
|
)}
|
|
{!sshKeysLoading && repoHasSshKey && repoSshKey && (
|
|
<div className="hint" style={{ marginTop: "0.5rem" }}>
|
|
Repository key <strong>{repoSshKey.name}</strong> is pre-selected.
|
|
</div>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|