Merge branch 'feat/session-branch-selection' into dev

Resolved conflicts:
- Moved branch selection UI from inline sessions.tsx to CreateSessionForm component
- Integrated branch dropdown and new branch creation into CreateSessionForm
- Removed duplicate branch state management from sessions.tsx

All branch selection tests pass (7/7).
This commit is contained in:
2026-05-24 10:06:14 +00:00
37 changed files with 2542 additions and 765 deletions
+45 -313
View File
@@ -3,24 +3,21 @@ import { useNavigate } from "react-router-dom";
import { listProjects } from "../api/projects";
import type { Project } from "../types";
import { listRepositories, listRepositoryBranches, type GitRepository, type Branch } from "../api/git_repositories";
import { listRepositories, type GitRepository } from "../api/git_repositories";
import {
getUserSessions,
type Session,
deleteInstance,
stopInstance,
startInstance,
checkInstanceHealth,
recreateInstanceTunnel,
} from "../api/sessions";
import { listToolTypes, type ToolType } from "../api/tool_types";
import { createInstance } from "../api/sessions";
import { getUserConfig, updateUserConfig } from "../api/settings";
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
import { Icon } from "../components/icon";
import { CreateSessionForm } from "../components/create-session-form";
type SessionsStatus = "loading" | "ready" | "error";
type CreateStatus = "idle" | "creating" | "error";
export const SessionsPage = () => {
const navigate = useNavigate();
@@ -31,23 +28,7 @@ export const SessionsPage = () => {
const [projects, setProjects] = useState<Project[]>([]);
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProject, setSelectedProject] = useState<string>("");
const [selectedRepo, setSelectedRepo] = useState<string>("");
const [selectedToolType, setSelectedToolType] = useState<string>("");
const [displayName, setDisplayName] = useState("");
const [createStatus, setCreateStatus] = useState<CreateStatus>("idle");
const [createError, setCreateError] = useState<string | null>(null);
const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount");
const [branch, setBranch] = useState("main");
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
const [branches, setBranches] = useState<Branch[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [isCreatingNewBranch, setIsCreatingNewBranch] = useState(false);
const [newBranchName, setNewBranchName] = useState("");
const [baseBranch, setBaseBranch] = useState("");
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
@@ -66,6 +47,8 @@ export const SessionsPage = () => {
}>>({});
const [recreatingId, setRecreatingId] = useState<string | null>(null);
const [expandedProbeId, setExpandedProbeId] = useState<string | null>(null);
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const [loadingAction, setLoadingAction] = useState<string>("");
const loadSessions = useCallback(async () => {
setStatus("loading");
@@ -110,44 +93,7 @@ export const SessionsPage = () => {
void loadToolTypes();
}, []);
useEffect(() => {
const loadSshKeys = async () => {
try {
const data = await listSSHKeys();
setSshKeys(data);
} catch {
// ignore
}
};
void loadSshKeys();
}, []);
useEffect(() => {
const loadBranches = async () => {
if (!selectedRepo || !selectedProject || cloneMode !== "clone") {
setBranches([]);
setIsCreatingNewBranch(false);
setNewBranchName("");
setBaseBranch("");
return;
}
setIsLoadingBranches(true);
try {
const data = await listRepositoryBranches(selectedProject, selectedRepo);
setBranches(data.branches);
const defaultBranch = data.default_branch;
setBaseBranch(defaultBranch);
if (!branch || !data.branches.find((b) => b.name === branch)) {
setBranch(defaultBranch);
}
} catch {
setBranches([]);
} finally {
setIsLoadingBranches(false);
}
};
void loadBranches();
}, [selectedRepo, selectedProject, cloneMode]);
// Poll health every 30 seconds for active instances
useEffect(() => {
@@ -221,72 +167,30 @@ export const SessionsPage = () => {
[sessions, lastSessionId]
);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setCreateError(null);
if (!selectedProject || !selectedRepo || !selectedToolType) {
setCreateError("Project, repository, and tool type are required");
return;
}
if (cloneMode === "clone") {
const repo = repositories.find((r) => r.id === selectedRepo);
if (!repo?.ssh_key_id) {
setCreateError("Repository must have an SSH key assigned for clone mode");
return;
}
}
setCreateStatus("creating");
try {
const instance = await createInstance(
selectedProject,
selectedRepo,
selectedToolType,
displayName || undefined,
cloneMode,
isCreatingNewBranch ? baseBranch : branch,
isCreatingNewBranch ? newBranchName : undefined
);
// Auto-start the instance
await startInstance(selectedProject, selectedRepo, instance.id);
await updateUserConfig({ last_session_id: instance.id });
setCreateStatus("idle");
setSelectedProject("");
setSelectedRepo("");
setSelectedToolType("");
setDisplayName("");
setCloneMode("mount");
setBranch("main");
setIsCreatingNewBranch(false);
setNewBranchName("");
setBaseBranch("");
setBranches([]);
await loadSessions();
} catch (error) {
setCreateStatus("error");
const axiosError = error as { response?: { data?: { detail?: string } } };
const message = axiosError.response?.data?.detail;
setCreateError(
typeof message === "string" ? message : "Failed to create session"
);
}
const handleCreateSuccess = async (instance: { id: string }) => {
await updateUserConfig({ last_session_id: instance.id });
setSelectedProject("");
await loadSessions();
};
const handleStop = async (sessionId: string, projectId: string, repoId: string) => {
setLoadingSessionId(sessionId);
setLoadingAction("Stopping...");
try {
await stopInstance(projectId, repoId, sessionId);
setStopConfirmId(null);
await loadSessions();
} catch {
setStopConfirmId(null);
} finally {
setLoadingSessionId(null);
setLoadingAction("");
}
};
const handleDelete = async (sessionId: string, projectId: string, repoId: string, force = false) => {
setLoadingSessionId(sessionId);
setLoadingAction("Deleting...");
try {
await deleteInstance(projectId, repoId, sessionId, force);
setDeleteConfirmId(null);
@@ -306,11 +210,15 @@ export const SessionsPage = () => {
}
}
setDeleteConfirmId(null);
} finally {
setLoadingSessionId(null);
setLoadingAction("");
}
};
const handleRecreateTunnel = async (session: Session) => {
setRecreatingId(session.id);
setLoadingSessionId(session.id);
setLoadingAction("Recreating tunnel...");
try {
await recreateInstanceTunnel(
session.project_id,
@@ -322,7 +230,8 @@ export const SessionsPage = () => {
} catch {
// ignore
} finally {
setRecreatingId(null);
setLoadingSessionId(null);
setLoadingAction("");
}
};
@@ -407,7 +316,15 @@ export const SessionsPage = () => {
)}
{/* Active Sessions */}
<div className="active-sessions-section">
<div className={`active-sessions-section ${loadingSessionId ? "dimmed" : ""}`}>
{loadingSessionId && (
<div className="loading-overlay">
<div className="loading-content">
<Icon name="loading" size="lg" />
<p>{loadingAction}</p>
</div>
</div>
)}
<h2>
Active Sessions
{activeSessions.length > 0 && (
@@ -445,17 +362,18 @@ export const SessionsPage = () => {
{tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "error_response" && (
<span className="status-badge warning">app error ({tunnelHealth[session.id].tunnel_status_code})</span>
)}
{tunnelHealth[session.id]?.last_probe_output && (
{tunnelHealth[session.id]?.probe_status && tunnelHealth[session.id]?.probe_status !== "not_applicable" && (
<div className="probe-output-section">
<button
className="probe-toggle"
className={`probe-toggle probe-${tunnelHealth[session.id].probe_status}`}
onClick={() => setExpandedProbeId(expandedProbeId === session.id ? null : session.id)}
type="button"
>
<Icon name="info" size="sm" />
{expandedProbeId === session.id ? "Hide probe output" : "Show probe output"}
Probe: {tunnelHealth[session.id].probe_status}
{expandedProbeId === session.id ? " (hide)" : " (show)"}
</button>
{expandedProbeId === session.id && (
{expandedProbeId === session.id && tunnelHealth[session.id]?.last_probe_output && (
<pre className="probe-output">
{tunnelHealth[session.id].last_probe_output}
</pre>
@@ -642,201 +560,15 @@ export const SessionsPage = () => {
{/* Create Session */}
<div className="create-session-section">
<h2>Create New Session</h2>
<form onSubmit={handleCreate} className="card stack create-session-form">
<div className="form-row">
<label className="form-field">
Project
<select
value={selectedProject}
onChange={(e) => {
setSelectedProject(e.target.value);
setSelectedRepo("");
}}
>
<option value="">Select project...</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
<label className="form-field">
Repository
<select
value={selectedRepo}
onChange={(e) => setSelectedRepo(e.target.value)}
disabled={!selectedProject}
>
<option value="">Select repository...</option>
{repositories.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</label>
<label className="form-field">
Tool Type
<select
value={selectedToolType}
onChange={(e) => setSelectedToolType(e.target.value)}
>
<option value="">Select tool...</option>
{toolTypes.map((t) => (
<option key={t.id} value={t.id}>
{t.display_name}
</option>
))}
</select>
</label>
</div>
<div className="form-row">
<label className="form-field">
Repository Access
<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")}
/>
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")}
/>
Clone fresh copy
</label>
</div>
</label>
{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);
}
}}
>
{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
/>
</label>
<label className="form-field">
Base Branch
<select
value={baseBranch}
onChange={(e) => setBaseBranch(e.target.value)}
>
{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>
<label className="form-field">
Display Name (optional)
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="My Development Environment"
/>
</label>
{createError && <p className="error-text">{createError}</p>}
<div className="form-actions">
<button
className="primary-button"
type="submit"
disabled={createStatus === "creating"}
>
{createStatus === "creating" ? (
<>
<Icon name="loading" size="sm" />
Creating...
</>
) : (
<>
<Icon name="add" size="sm" />
Create Session
</>
)}
</button>
</div>
</form>
<CreateSessionForm
projects={projects}
repositories={repositories}
toolTypes={toolTypes}
onProjectChange={(projectId) => {
setSelectedProject(projectId);
}}
onSuccess={handleCreateSuccess}
/>
</div>
{/* Dirty Delete Confirmation Modal */}