Files
headquarter/apps/web/src/components/features/session/create-session-form.tsx
T
Developer 7440720b7b feat: implement tool-session progress panel and live list updates
- Add SessionOperationsContext + SessionProgressPanel for global,
  non-blocking lifecycle progress (create/start/stop/restart/delete/
  recreate-tunnel) driven by SSE events.
- Promote SessionsContext to authoritative shared session state with
  refresh, addOrUpdateSession, and removeSession helpers.
- Wire AppShell, DashboardPage, SessionsPage, useInstanceActions,
  ToolStarter, and InstanceList into shared state so lists update
  immediately after create/delete without manual refresh.
- Remove legacy blocking overlays from CreateSessionForm, SessionCard,
  and InstanceList; keep disabled states and inline spinners only.
- Update DashboardPage tests to wrap with SessionsProvider and
  SessionOperationsProvider.
- Add .cache/ to .gitignore.

Quality gates: npm run typecheck, npm run lint, npm test -- --run
(82 passed).
2026-06-12 13:19:58 +00:00

565 lines
15 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 {
listRepositoryBranches,
type GitRepository,
type Branch,
} 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";
interface CreateSessionFormProps {
projects: Project[];
repositories: GitRepository[];
toolTypes: ToolType[];
fixedProjectId?: string;
fixedRepoId?: string;
projectName?: string;
repoName?: string;
showCloneMode?: boolean;
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,
showCloneMode = true,
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 [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount");
const [branch, setBranch] = useState("main");
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
const [selectedConfigProfile, setSelectedConfigProfile] = useState("");
const [selectedSshKeyIds, setSelectedSshKeyIds] = useState<string[]>([]);
const [branches, setBranches] = useState<Branch[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [isCreatingNewBranch, setIsCreatingNewBranch] = useState(false);
const [newBranchName, setNewBranchName] = useState("");
const [baseBranch, setBaseBranch] = useState("");
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 branches when selected repo changes
useEffect(() => {
const projectId = fixedProjectId || selectedProject;
if (!selectedRepo || !projectId || !showCloneMode) {
setBranches([]);
return;
}
const loadBranches = async () => {
setIsLoadingBranches(true);
try {
const response = await listRepositoryBranches(projectId, selectedRepo);
setBranches(response.branches);
if (response.default_branch) {
setBranch(response.default_branch);
setBaseBranch(response.default_branch);
}
} catch {
// ignore
} finally {
setIsLoadingBranches(false);
}
};
void loadBranches();
}, [selectedRepo, selectedProject, fixedProjectId, showCloneMode]);
// 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("");
setCloneMode("mount");
setBranch("main");
setIsCreatingNewBranch(false);
setNewBranchName("");
setBaseBranch("");
setBranches([]);
setSelectedSshKeyIds([]);
setSelectedConfigProfile("");
};
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;
}
if (showCloneMode && cloneMode === "clone") {
const repo = repositories.find((r) => r.id === repoId);
if (!repo?.ssh_key_id) {
setError("Repository must have an SSH key assigned for clone mode");
return;
}
}
setIsSubmitting(true);
try {
const instance = await createInstance(
projectId,
repoId,
selectedToolType,
displayName || undefined,
showCloneMode ? cloneMode : undefined,
showCloneMode && cloneMode === "clone"
? isCreatingNewBranch
? baseBranch
: branch
: undefined,
showCloneMode && cloneMode === "clone" && isCreatingNewBranch
? newBranchName
: undefined,
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("");
setCloneMode("mount");
setIsCreatingNewBranch(false);
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("");
setCloneMode("mount");
setIsCreatingNewBranch(false);
}}
disabled={!hasProject || isSubmitting}
>
<option value="">Select repository...</option>
{availableRepos.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
)}
</div>
)}
{/* Tool Type */}
{hasRepo && (
<div className="form-field">
<label>Tool</label>
<select
value={selectedToolType}
onChange={(e) => {
setSelectedToolType(e.target.value);
setCloneMode("mount");
setIsCreatingNewBranch(false);
}}
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>
)}
{/* Clone Mode & Branch */}
{showCloneMode && hasToolType && (
<div className="form-field">
<label>Repository Access</label>
<div className="radio-group">
<label className="radio-label">
<input
type="radio"
name="cloneMode"
value="mount"
checked={cloneMode === "mount"}
onChange={(e) => {
setCloneMode(e.target.value as "mount" | "clone");
setIsCreatingNewBranch(false);
}}
disabled={isSubmitting}
/>
Mount (live sync)
</label>
<label className="radio-label">
<input
type="radio"
name="cloneMode"
value="clone"
checked={cloneMode === "clone"}
onChange={(e) => {
setCloneMode(e.target.value as "mount" | "clone");
setIsCreatingNewBranch(false);
}}
disabled={isSubmitting}
/>
Clone fresh copy
</label>
</div>
{cloneMode === "clone" && (
<>
<label className="form-field">
Branch
{isLoadingBranches ? (
<span className="muted">Loading branches...</span>
) : (
<select
value={isCreatingNewBranch ? "__new__" : branch}
onChange={(e) => {
const value = e.target.value;
if (value === "__new__") {
setIsCreatingNewBranch(true);
setNewBranchName("");
} else {
setIsCreatingNewBranch(false);
setBranch(value);
setBaseBranch(value);
}
}}
disabled={isSubmitting}
>
{branches.map((b) => (
<option key={b.name} value={b.name}>
{b.name} {b.is_default ? "(default)" : ""}
</option>
))}
<option value="__new__">Create new branch...</option>
</select>
)}
</label>
{isCreatingNewBranch && (
<>
<label className="form-field">
New Branch Name
<input
type="text"
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
placeholder="feature/my-new-branch"
required
disabled={isSubmitting}
/>
</label>
<label className="form-field">
Base Branch
<select
value={baseBranch}
onChange={(e) => setBaseBranch(e.target.value)}
disabled={isSubmitting}
>
{branches.map((b) => (
<option key={b.name} value={b.name}>
{b.name} {b.is_default ? "(default)" : ""}
</option>
))}
</select>
</label>
</>
)}
{selectedRepo && (
<div className="form-field ssh-key-info">
{(() => {
const repo = repositories.find(
(r) => r.id === selectedRepo,
);
if (!repo) return null;
if (repo.ssh_key_id) {
const key = sshKeys.find(
(k) => k.id === repo.ssh_key_id,
);
return (
<span className="success-text">
SSH key: {key?.name || "Assigned"}
</span>
);
}
return (
<span className="warning-text">
No SSH key assigned to this repository. Clone mode
requires an SSH key.
</span>
);
})()}
</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>
);
};