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.
454 lines
12 KiB
TypeScript
454 lines
12 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { Icon } from "../../icon";
|
|
import {
|
|
createInstance,
|
|
startInstance,
|
|
type ToolInstance,
|
|
} from "../../../api/sessions";
|
|
import type { Project } from "../../../types";
|
|
import type { GitRepository } from "../../../api/git-repositories";
|
|
import type { ToolType } from "../../../api/tool-types";
|
|
import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
|
|
import {
|
|
listConfigProfiles,
|
|
type ConfigProfile,
|
|
} from "../../../api/config-profiles";
|
|
import { listWorkspaces, createWorkspace } from "../../../api/workspaces";
|
|
import type { Workspace } from "../../../types/workspace";
|
|
|
|
interface CreateSessionFormProps {
|
|
projects: Project[];
|
|
repositories: GitRepository[];
|
|
toolTypes: ToolType[];
|
|
fixedProjectId?: string;
|
|
fixedRepoId?: string;
|
|
projectName?: string;
|
|
repoName?: string;
|
|
showFixedFields?: boolean;
|
|
onProjectChange?: (projectId: string) => void;
|
|
onSuccess?: (instance: ToolInstance) => void;
|
|
onCancel?: () => void;
|
|
submitLabel?: string;
|
|
className?: string;
|
|
}
|
|
|
|
export const CreateSessionForm = ({
|
|
projects,
|
|
repositories,
|
|
toolTypes,
|
|
fixedProjectId,
|
|
fixedRepoId,
|
|
projectName,
|
|
repoName,
|
|
showFixedFields = true,
|
|
onProjectChange,
|
|
onSuccess,
|
|
onCancel,
|
|
submitLabel = "Create Session",
|
|
className = "",
|
|
}: CreateSessionFormProps) => {
|
|
const [selectedProject, setSelectedProject] = useState(fixedProjectId || "");
|
|
const [selectedRepo, setSelectedRepo] = useState(fixedRepoId || "");
|
|
const [selectedToolType, setSelectedToolType] = useState("");
|
|
const [displayName, setDisplayName] = useState("");
|
|
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
|
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
|
|
const [selectedConfigProfile, setSelectedConfigProfile] = useState("");
|
|
const [selectedSshKeyIds, setSelectedSshKeyIds] = useState<string[]>([]);
|
|
|
|
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
|
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState("");
|
|
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false);
|
|
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Load SSH keys
|
|
useEffect(() => {
|
|
const loadKeys = async () => {
|
|
try {
|
|
const keys = await listSSHKeys();
|
|
setSshKeys(keys);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
void loadKeys();
|
|
}, []);
|
|
|
|
// Load config profiles when tool type is selected
|
|
useEffect(() => {
|
|
const projectId = fixedProjectId || selectedProject;
|
|
if (!selectedToolType || !projectId) {
|
|
setConfigProfiles([]);
|
|
setSelectedConfigProfile("");
|
|
return;
|
|
}
|
|
const loadProfiles = async () => {
|
|
try {
|
|
const profiles = await listConfigProfiles(projectId, selectedToolType);
|
|
setConfigProfiles(profiles);
|
|
// Auto-select default if available
|
|
const defaultProfile = profiles.find((p) => p.is_default);
|
|
if (defaultProfile) {
|
|
setSelectedConfigProfile(defaultProfile.id);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
void loadProfiles();
|
|
}, [selectedToolType, selectedProject, fixedProjectId]);
|
|
|
|
// Load workspaces when repository is selected
|
|
useEffect(() => {
|
|
const projectId = fixedProjectId || selectedProject;
|
|
const repoId = fixedRepoId || selectedRepo;
|
|
if (!projectId || !repoId) {
|
|
setWorkspaces([]);
|
|
setSelectedWorkspaceId("");
|
|
return;
|
|
}
|
|
const loadWorkspaces = async () => {
|
|
setIsLoadingWorkspaces(true);
|
|
try {
|
|
const data = await listWorkspaces(projectId, repoId);
|
|
setWorkspaces(data);
|
|
if (data.length > 0) {
|
|
setSelectedWorkspaceId(data[0].id);
|
|
} else {
|
|
// Auto-create a default workspace so the user can start a tool
|
|
const workspace = await createWorkspace(projectId, repoId, {
|
|
name: "default",
|
|
branch: "main",
|
|
});
|
|
setWorkspaces([workspace]);
|
|
setSelectedWorkspaceId(workspace.id);
|
|
}
|
|
} catch (err) {
|
|
setError(
|
|
err instanceof Error ? err.message : "Failed to load workspaces",
|
|
);
|
|
setWorkspaces([]);
|
|
setSelectedWorkspaceId("");
|
|
} finally {
|
|
setIsLoadingWorkspaces(false);
|
|
}
|
|
};
|
|
void loadWorkspaces();
|
|
}, [
|
|
fixedProjectId,
|
|
selectedProject,
|
|
fixedRepoId,
|
|
selectedRepo,
|
|
repositories,
|
|
]);
|
|
|
|
// Filter repositories by selected project
|
|
const availableRepos = selectedProject
|
|
? repositories.filter((r) => r.project_id === selectedProject)
|
|
: [];
|
|
|
|
const resetForm = () => {
|
|
if (!fixedProjectId) setSelectedProject("");
|
|
if (!fixedRepoId) setSelectedRepo("");
|
|
setSelectedToolType("");
|
|
setDisplayName("");
|
|
setSelectedSshKeyIds([]);
|
|
setSelectedConfigProfile("");
|
|
setWorkspaces([]);
|
|
setSelectedWorkspaceId("");
|
|
};
|
|
|
|
const handleSubmit = async (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
setError(null);
|
|
|
|
const projectId = fixedProjectId || selectedProject;
|
|
const repoId = fixedRepoId || selectedRepo;
|
|
|
|
if (!projectId || !repoId || !selectedToolType) {
|
|
setError("Project, repository, and tool type are required");
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
|
|
try {
|
|
const workspaceId = selectedWorkspaceId || undefined;
|
|
if (!workspaceId) {
|
|
setError("No workspace available for the selected repository");
|
|
setIsSubmitting(false);
|
|
return;
|
|
}
|
|
|
|
const instance = await createInstance(
|
|
projectId,
|
|
repoId,
|
|
selectedToolType,
|
|
displayName || undefined,
|
|
workspaceId,
|
|
selectedConfigProfile || undefined,
|
|
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined,
|
|
);
|
|
|
|
await startInstance(
|
|
projectId,
|
|
repoId,
|
|
instance.id,
|
|
selectedConfigProfile || undefined,
|
|
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined,
|
|
);
|
|
|
|
resetForm();
|
|
onSuccess?.(instance);
|
|
} catch {
|
|
setError("Failed to create session");
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const hasProject = !!(fixedProjectId || selectedProject);
|
|
const hasRepo = !!(fixedRepoId || selectedRepo);
|
|
const hasToolType = !!selectedToolType;
|
|
|
|
return (
|
|
<div className={`create-session-form-wrapper ${className}`}>
|
|
<form onSubmit={handleSubmit} className="stack create-session-form">
|
|
{/* Project */}
|
|
<div className="form-field">
|
|
<label>Project</label>
|
|
{fixedProjectId && showFixedFields ? (
|
|
<input
|
|
type="text"
|
|
value={
|
|
projectName ||
|
|
projects.find((p) => p.id === fixedProjectId)?.name ||
|
|
""
|
|
}
|
|
disabled
|
|
readOnly
|
|
/>
|
|
) : (
|
|
<select
|
|
value={selectedProject}
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
setSelectedProject(value);
|
|
setSelectedRepo("");
|
|
setSelectedToolType("");
|
|
onProjectChange?.(value);
|
|
}}
|
|
disabled={isSubmitting}
|
|
>
|
|
<option value="">Select project...</option>
|
|
{projects.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
|
|
{/* Repository */}
|
|
{hasProject && (
|
|
<div className="form-field">
|
|
<label>Repository</label>
|
|
{fixedRepoId && showFixedFields ? (
|
|
<input
|
|
type="text"
|
|
value={
|
|
repoName ||
|
|
repositories.find((r) => r.id === fixedRepoId)?.name ||
|
|
""
|
|
}
|
|
disabled
|
|
readOnly
|
|
/>
|
|
) : (
|
|
<select
|
|
value={selectedRepo}
|
|
onChange={(e) => {
|
|
setSelectedRepo(e.target.value);
|
|
setSelectedToolType("");
|
|
setSelectedWorkspaceId("");
|
|
}}
|
|
disabled={!hasProject || isSubmitting}
|
|
>
|
|
<option value="">Select repository...</option>
|
|
{availableRepos.map((r) => (
|
|
<option key={r.id} value={r.id}>
|
|
{r.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Workspace */}
|
|
{hasRepo && (
|
|
<div className="form-field">
|
|
<label>Workspace</label>
|
|
{isLoadingWorkspaces ? (
|
|
<span className="muted">Loading workspaces...</span>
|
|
) : workspaces.length === 0 ? (
|
|
<span className="muted">No workspace available</span>
|
|
) : (
|
|
<select
|
|
value={selectedWorkspaceId}
|
|
onChange={(e) => setSelectedWorkspaceId(e.target.value)}
|
|
disabled={!hasRepo || isSubmitting || isLoadingWorkspaces}
|
|
>
|
|
{workspaces.map((w) => (
|
|
<option key={w.id} value={w.id}>
|
|
{w.name} ({w.branch})
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Tool Type */}
|
|
{hasRepo && (
|
|
<div className="form-field">
|
|
<label>Tool</label>
|
|
<select
|
|
value={selectedToolType}
|
|
onChange={(e) => {
|
|
setSelectedToolType(e.target.value);
|
|
}}
|
|
disabled={!hasRepo || isSubmitting}
|
|
>
|
|
<option value="">Select tool...</option>
|
|
{toolTypes.map((t) => (
|
|
<option key={t.id} value={t.id}>
|
|
{t.display_name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
{/* Config Profile */}
|
|
{hasToolType && (
|
|
<div className="form-field">
|
|
<label>Config Profile (optional)</label>
|
|
<select
|
|
value={selectedConfigProfile}
|
|
onChange={(e) => setSelectedConfigProfile(e.target.value)}
|
|
disabled={!hasToolType || isSubmitting}
|
|
>
|
|
<option value="">No profile (use tool defaults)</option>
|
|
{configProfiles.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.name} {p.is_default ? "(default)" : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
{/* SSH Keys */}
|
|
{hasToolType && (
|
|
<div className="form-field">
|
|
<label>SSH Keys (optional)</label>
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
|
|
{sshKeys.length === 0 && (
|
|
<span className="muted">No SSH keys configured.</span>
|
|
)}
|
|
{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={isSubmitting}
|
|
/>
|
|
{key.name}
|
|
</label>
|
|
))}
|
|
</div>
|
|
<div className="hint" style={{ marginTop: "0.5rem" }}>
|
|
Selected keys will be mounted into the container at ~/.ssh
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Display Name */}
|
|
{hasToolType && (
|
|
<div className="form-field">
|
|
<label>Display Name (optional)</label>
|
|
<input
|
|
type="text"
|
|
value={displayName}
|
|
onChange={(e) => setDisplayName(e.target.value)}
|
|
placeholder="My Development Environment"
|
|
disabled={isSubmitting}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error & Submit */}
|
|
{error && <p className="error-text">{error}</p>}
|
|
|
|
{hasToolType && (
|
|
<div className="form-actions">
|
|
{onCancel && (
|
|
<button
|
|
className="secondary-button"
|
|
type="button"
|
|
onClick={onCancel}
|
|
disabled={isSubmitting}
|
|
>
|
|
Cancel
|
|
</button>
|
|
)}
|
|
<button
|
|
className="primary-button"
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<Icon name="loading" size="sm" />
|
|
Creating...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Icon name="add" size="sm" />
|
|
{submitLabel}
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</form>
|
|
</div>
|
|
);
|
|
};
|