fix: allow workspace creation from workspaces page
- Replace awkward first-workspace-guessing logic with inline project/repo selector - New WorkspaceCreateInline component with cascading dropdowns: - Select project → loads repositories for that project - Select repository → enter workspace name + branch - Submit creates workspace via top-level POST /workspaces/ - Add createWorkspaceTopLevel() API client for flat endpoint - Works even with zero existing workspaces (shows create button in empty state) - Add CSS grid layout for inline create form - TypeScript + eslint clean
This commit is contained in:
+227
-215
@@ -4,9 +4,9 @@ import { listProjects } from "../api/projects";
|
||||
import type { ProjectWithRepos } from "../types";
|
||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||
import {
|
||||
getUserSessions,
|
||||
type Session,
|
||||
checkInstanceHealth,
|
||||
getUserSessions,
|
||||
type Session,
|
||||
checkInstanceHealth,
|
||||
} from "../api/sessions";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { getUserConfig, updateUserConfig } from "../api/settings";
|
||||
@@ -20,235 +20,247 @@ import type { InstanceHealth } from "../api/sessions";
|
||||
type SessionsStatus = "loading" | "ready" | "error";
|
||||
|
||||
export const SessionsPage = () => {
|
||||
const [status, setStatus] = useState<SessionsStatus>("loading");
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<SessionsStatus>("loading");
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
|
||||
|
||||
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState<string>("");
|
||||
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState<string>("");
|
||||
|
||||
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
||||
const [tunnelHealth, setTunnelHealth] = useState<
|
||||
Record<string, InstanceHealth>
|
||||
>({});
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const [sessionsData, config] = await Promise.all([
|
||||
getUserSessions(),
|
||||
getUserConfig(),
|
||||
]);
|
||||
setSessions(sessionsData);
|
||||
setLastSessionId(config.last_session_id ?? null);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
const loadSessions = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const [sessionsData, config] = await Promise.all([
|
||||
getUserSessions(),
|
||||
getUserConfig(),
|
||||
]);
|
||||
setSessions(sessionsData);
|
||||
setLastSessionId(config.last_session_id ?? null);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSessions();
|
||||
}, [loadSessions]);
|
||||
useEffect(() => {
|
||||
void loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const data = await listProjects();
|
||||
setProjects(data);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void loadProjects();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const data = await listProjects();
|
||||
setProjects(data);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void loadProjects();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadToolTypes = async () => {
|
||||
try {
|
||||
const data = await listToolTypes();
|
||||
setToolTypes(data);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void loadToolTypes();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const loadToolTypes = async () => {
|
||||
try {
|
||||
const data = await listToolTypes();
|
||||
setToolTypes(data);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void loadToolTypes();
|
||||
}, []);
|
||||
|
||||
const {
|
||||
loadingSessionId,
|
||||
dirtyDeleteSession,
|
||||
dirtyDeleteFiles,
|
||||
handleOpen,
|
||||
handleStart,
|
||||
handleStop,
|
||||
handleDelete,
|
||||
handleForceDelete,
|
||||
handleRecreateTunnel,
|
||||
clearDirtyDelete,
|
||||
} = useInstanceActions({ onRefresh: loadSessions });
|
||||
const {
|
||||
loadingSessionId,
|
||||
dirtyDeleteSession,
|
||||
dirtyDeleteFiles,
|
||||
handleOpen,
|
||||
handleStart,
|
||||
handleStop,
|
||||
handleDelete,
|
||||
handleForceDelete,
|
||||
handleRecreateTunnel,
|
||||
clearDirtyDelete,
|
||||
} = useInstanceActions({ onRefresh: loadSessions });
|
||||
|
||||
// Poll health every 30 seconds for active web-enabled instances
|
||||
useEffect(() => {
|
||||
const checkHealth = async () => {
|
||||
const activeSessions = sessions.filter(
|
||||
(s) => ["running", "starting", "unhealthy", "probing"].includes(s.status)
|
||||
&& s.tool_type_interfaces?.includes("web")
|
||||
);
|
||||
for (const session of activeSessions) {
|
||||
try {
|
||||
const health = await checkInstanceHealth(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id
|
||||
);
|
||||
setTunnelHealth((prev) => ({
|
||||
...prev,
|
||||
[session.id]: health,
|
||||
}));
|
||||
} catch {
|
||||
setTunnelHealth((prev) => ({
|
||||
...prev,
|
||||
[session.id]: {
|
||||
healthy: false,
|
||||
container_status: "unknown",
|
||||
container_health: null,
|
||||
tunnel_status: "unreachable",
|
||||
tunnel_status_code: null,
|
||||
probe_status: "unknown",
|
||||
last_probe_output: null,
|
||||
error: "check failed",
|
||||
} as InstanceHealth,
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
// Poll health every 30 seconds for active web-enabled instances
|
||||
useEffect(() => {
|
||||
const checkHealth = async () => {
|
||||
const activeSessions = sessions.filter(
|
||||
(s) =>
|
||||
["running", "starting", "unhealthy", "probing"].includes(s.status) &&
|
||||
s.tool_type_interfaces?.includes("web"),
|
||||
);
|
||||
for (const session of activeSessions) {
|
||||
try {
|
||||
const health = await checkInstanceHealth(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id,
|
||||
);
|
||||
setTunnelHealth((prev) => ({
|
||||
...prev,
|
||||
[session.id]: health,
|
||||
}));
|
||||
} catch {
|
||||
setTunnelHealth((prev) => ({
|
||||
...prev,
|
||||
[session.id]: {
|
||||
healthy: false,
|
||||
container_status: "unknown",
|
||||
container_health: null,
|
||||
tunnel_status: "unreachable",
|
||||
tunnel_status_code: null,
|
||||
probe_status: "unknown",
|
||||
last_probe_output: null,
|
||||
error: "check failed",
|
||||
} as InstanceHealth,
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Check immediately and then every 30 seconds
|
||||
void checkHealth();
|
||||
const interval = setInterval(() => void checkHealth(), 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [sessions]);
|
||||
// Check immediately and then every 30 seconds
|
||||
void checkHealth();
|
||||
const interval = setInterval(() => void checkHealth(), 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
setRepositories([]);
|
||||
return;
|
||||
}
|
||||
const loadRepos = async () => {
|
||||
try {
|
||||
const data = await listRepositories(selectedProject);
|
||||
setRepositories(data);
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
}
|
||||
};
|
||||
void loadRepos();
|
||||
}, [selectedProject]);
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
setRepositories([]);
|
||||
return;
|
||||
}
|
||||
const loadRepos = async () => {
|
||||
try {
|
||||
const data = await listRepositories(selectedProject);
|
||||
setRepositories(data);
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
}
|
||||
};
|
||||
void loadRepos();
|
||||
}, [selectedProject]);
|
||||
|
||||
const lastSession = useMemo(
|
||||
() => sessions.find((s) => s.id === lastSessionId) ?? null,
|
||||
[sessions, lastSessionId]
|
||||
);
|
||||
const lastSession = useMemo(
|
||||
() => sessions.find((s) => s.id === lastSessionId) ?? null,
|
||||
[sessions, lastSessionId],
|
||||
);
|
||||
|
||||
const handleCreateSuccess = async (instance: { id: string }) => {
|
||||
await updateUserConfig({ last_session_id: instance.id });
|
||||
setSelectedProject("");
|
||||
await loadSessions();
|
||||
};
|
||||
const handleCreateSuccess = async (instance: { id: string }) => {
|
||||
await updateUserConfig({ last_session_id: instance.id });
|
||||
setSelectedProject("");
|
||||
await loadSessions();
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="stack sessions-page">
|
||||
<div className="page-header">
|
||||
<h1>Sessions</h1>
|
||||
</div>
|
||||
return (
|
||||
<section className="stack sessions-page">
|
||||
<div className="page-header">
|
||||
<h1>Sessions</h1>
|
||||
</div>
|
||||
|
||||
{status === "loading" && <LoadingState message="Loading sessions..." />}
|
||||
{status === "loading" && <LoadingState message="Loading sessions..." />}
|
||||
|
||||
{status === "error" && <ErrorState message="Failed to load sessions" onRetry={() => void loadSessions()} />}
|
||||
{status === "error" && (
|
||||
<ErrorState
|
||||
message="Failed to load sessions"
|
||||
onRetry={() => void loadSessions()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === "ready" && (
|
||||
<>
|
||||
{/* Last Session */}
|
||||
{lastSession && (
|
||||
<div className="last-session-section">
|
||||
<h2>Last Session</h2>
|
||||
<SessionCard
|
||||
session={lastSession}
|
||||
onOpen={handleOpen}
|
||||
onDelete={handleDelete}
|
||||
isBusy={loadingSessionId === lastSession.id}
|
||||
tunnelHealth={tunnelHealth[lastSession.id] || null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{status === "ready" && (
|
||||
<>
|
||||
{/* Last Session */}
|
||||
{lastSession && (
|
||||
<div className="last-session-section">
|
||||
<h2>Last Session</h2>
|
||||
<SessionCard
|
||||
session={lastSession}
|
||||
onOpen={handleOpen}
|
||||
onDelete={handleDelete}
|
||||
isBusy={loadingSessionId === lastSession.id}
|
||||
tunnelHealth={tunnelHealth[lastSession.id] || null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session List */}
|
||||
<div className="sessions-list-wrapper">
|
||||
<SessionList
|
||||
sessions={sessions}
|
||||
onOpen={handleOpen}
|
||||
onStart={handleStart}
|
||||
onStop={handleStop}
|
||||
onDelete={handleDelete}
|
||||
onRecreateTunnel={handleRecreateTunnel}
|
||||
actionBusyId={loadingSessionId}
|
||||
tunnelHealth={tunnelHealth}
|
||||
/>
|
||||
</div>
|
||||
{/* Session List */}
|
||||
<div className="sessions-list-wrapper">
|
||||
<SessionList
|
||||
sessions={sessions}
|
||||
onOpen={handleOpen}
|
||||
onStart={handleStart}
|
||||
onStop={handleStop}
|
||||
onDelete={handleDelete}
|
||||
onRecreateTunnel={handleRecreateTunnel}
|
||||
actionBusyId={loadingSessionId}
|
||||
tunnelHealth={tunnelHealth}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Create Session */}
|
||||
<div className="create-session-section">
|
||||
<h2>Create New Session</h2>
|
||||
<CreateSessionForm
|
||||
projects={projects}
|
||||
repositories={repositories}
|
||||
toolTypes={toolTypes}
|
||||
onProjectChange={(projectId) => {
|
||||
setSelectedProject(projectId);
|
||||
}}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
</div>
|
||||
{/* Create Session */}
|
||||
<div className="create-session-section">
|
||||
<h2>Create New Session</h2>
|
||||
<CreateSessionForm
|
||||
projects={projects}
|
||||
repositories={repositories}
|
||||
toolTypes={toolTypes}
|
||||
onProjectChange={(projectId) => {
|
||||
setSelectedProject(projectId);
|
||||
}}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dirty Delete Confirmation Modal */}
|
||||
{dirtyDeleteSession && (
|
||||
<div className="modal-overlay" onClick={clearDirtyDelete}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Uncommitted Changes</h3>
|
||||
<p>
|
||||
The repository <strong>{dirtyDeleteSession.repository_name}</strong> has
|
||||
uncommitted changes. Deleting this session will permanently lose these
|
||||
changes.
|
||||
</p>
|
||||
<div className="changed-files-list">
|
||||
<h4>Changed files:</h4>
|
||||
<ul>
|
||||
{dirtyDeleteFiles.map((file, idx) => (
|
||||
<li key={idx}>{file}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={clearDirtyDelete}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="danger-button"
|
||||
onClick={() => void handleForceDelete(dirtyDeleteSession)}
|
||||
type="button"
|
||||
>
|
||||
Force Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
{/* Dirty Delete Confirmation Modal */}
|
||||
{dirtyDeleteSession && (
|
||||
<div className="modal-overlay" onClick={clearDirtyDelete}>
|
||||
<div
|
||||
className="modal-content"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3>Uncommitted Changes</h3>
|
||||
<p>
|
||||
The repository{" "}
|
||||
<strong>{dirtyDeleteSession.repository_name}</strong> has
|
||||
uncommitted changes. Deleting this session will permanently
|
||||
lose these changes.
|
||||
</p>
|
||||
<div className="changed-files-list">
|
||||
<h4>Changed files:</h4>
|
||||
<ul>
|
||||
{dirtyDeleteFiles.map((file, idx) => (
|
||||
<li key={idx}>{file}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={clearDirtyDelete}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="danger-button"
|
||||
onClick={() => void handleForceDelete(dirtyDeleteSession)}
|
||||
type="button"
|
||||
>
|
||||
Force Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user