Files
headquarter/apps/web/src/components/features/session/create-session-form.tsx
T
Developer 6b947b7593 feat: complete working-copies workspace-first cleanup
- 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.
2026-06-12 15:47:26 +00:00

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>
);
};