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:
@@ -2,50 +2,53 @@ import { apiClient } from "./client";
|
||||
import type { Project, ProjectWithRepos } from "../types";
|
||||
|
||||
export type ProjectCreateInput = {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type ProjectUpdateInput = {
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type SetDefaultSSHKeyInput = {
|
||||
ssh_key_id: string;
|
||||
ssh_key_id: string;
|
||||
};
|
||||
|
||||
export const listProjects = async (): Promise<ProjectWithRepos[]> => {
|
||||
const response = await apiClient.get<ProjectWithRepos[]>("/projects");
|
||||
return response.data;
|
||||
const response = await apiClient.get<ProjectWithRepos[]>("/projects");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createProject = async (
|
||||
input: ProjectCreateInput
|
||||
input: ProjectCreateInput,
|
||||
): Promise<Project> => {
|
||||
const response = await apiClient.post<Project>("/projects", input);
|
||||
return response.data;
|
||||
const response = await apiClient.post<Project>("/projects", input);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateProject = async (
|
||||
projectId: string,
|
||||
input: ProjectUpdateInput
|
||||
projectId: string,
|
||||
input: ProjectUpdateInput,
|
||||
): Promise<Project> => {
|
||||
const response = await apiClient.patch<Project>(`/projects/${projectId}`, input);
|
||||
return response.data;
|
||||
const response = await apiClient.patch<Project>(
|
||||
`/projects/${projectId}`,
|
||||
input,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteProject = async (projectId: string): Promise<void> => {
|
||||
await apiClient.delete(`/projects/${projectId}`);
|
||||
await apiClient.delete(`/projects/${projectId}`);
|
||||
};
|
||||
|
||||
export const setDefaultSSHKey = async (
|
||||
projectId: string,
|
||||
input: SetDefaultSSHKeyInput
|
||||
projectId: string,
|
||||
input: SetDefaultSSHKeyInput,
|
||||
): Promise<Project> => {
|
||||
const response = await apiClient.patch<Project>(
|
||||
`/projects/${projectId}/default-ssh-key`,
|
||||
input
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.patch<Project>(
|
||||
`/projects/${projectId}/default-ssh-key`,
|
||||
input,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -39,6 +39,13 @@ export async function createWorkspace(
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createWorkspaceTopLevel(
|
||||
data: CreateWorkspaceRequest & { repo_id: string },
|
||||
): Promise<Workspace> {
|
||||
const response = await apiClient.post<Workspace>("/workspaces/", data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getWorkspace(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
|
||||
@@ -28,7 +28,10 @@ export function WorkspaceCard({
|
||||
|
||||
return (
|
||||
<article className={`card workspace-card ${loading ? "loading" : ""}`}>
|
||||
<Link to={`/workspaces/${workspace.id}`} className="workspace-header-link">
|
||||
<Link
|
||||
to={`/workspaces/${workspace.id}`}
|
||||
className="workspace-header-link"
|
||||
>
|
||||
<div className="workspace-header">
|
||||
<h4>{workspace.name}</h4>
|
||||
<span className={`status-badge ${statusClass}`}>
|
||||
|
||||
@@ -1539,7 +1539,6 @@ export const ConfigProfilesPage = () => {
|
||||
onChange={(git_mounts) =>
|
||||
updateFormField("git_mounts", git_mounts)
|
||||
}
|
||||
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
+294
-224
@@ -2,13 +2,22 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
||||
import { getUserSessions, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions";
|
||||
import {
|
||||
getUserSessions,
|
||||
checkInstanceHealth,
|
||||
type Session as SessionApi,
|
||||
type InstanceHealth,
|
||||
} from "../api/sessions";
|
||||
import { listProjects } from "../api/projects";
|
||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { updateUserConfig } from "../api/settings";
|
||||
import type { ProjectWithRepos } from "../types";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import {
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
} from "../components/data-states";
|
||||
import { CreateSessionForm } from "../components/create-session-form";
|
||||
import { SessionList } from "../components/session-list";
|
||||
import { useInstanceActions } from "../hooks/use-instance-actions";
|
||||
@@ -16,251 +25,312 @@ import { useInstanceActions } from "../hooks/use-instance-actions";
|
||||
type HomeStatus = "loading" | "ready" | "error";
|
||||
|
||||
const summaryCards = [
|
||||
{ label: "Open sessions", key: "openSessions" },
|
||||
{ label: "Projects", key: "projects" },
|
||||
{ label: "Repositories", key: "repositories" },
|
||||
{ label: "Open sessions", key: "openSessions" },
|
||||
{ label: "Projects", key: "projects" },
|
||||
{ label: "Repositories", key: "repositories" },
|
||||
] as const;
|
||||
|
||||
type SessionView = SessionApi;
|
||||
|
||||
export const HomePage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<HomeStatus>("loading");
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [sessions, setSessions] = useState<SessionView[]>([]);
|
||||
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState("");
|
||||
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({});
|
||||
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<HomeStatus>("loading");
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [sessions, setSessions] = useState<SessionView[]>([]);
|
||||
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState("");
|
||||
const [tunnelHealth, setTunnelHealth] = useState<
|
||||
Record<string, InstanceHealth>
|
||||
>({});
|
||||
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
||||
|
||||
const loadHome = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const [dashboard, sessionData, projectData, toolTypeData] = await Promise.all([
|
||||
getDashboardSummary(),
|
||||
getUserSessions(),
|
||||
listProjects(),
|
||||
listToolTypes(),
|
||||
]);
|
||||
setSummary(dashboard);
|
||||
setSessions(sessionData as SessionView[]);
|
||||
setProjects(projectData);
|
||||
setToolTypes(toolTypeData);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
const loadHome = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const [dashboard, sessionData, projectData, toolTypeData] =
|
||||
await Promise.all([
|
||||
getDashboardSummary(),
|
||||
getUserSessions(),
|
||||
listProjects(),
|
||||
listToolTypes(),
|
||||
]);
|
||||
setSummary(dashboard);
|
||||
setSessions(sessionData as SessionView[]);
|
||||
setProjects(projectData);
|
||||
setToolTypes(toolTypeData);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHome();
|
||||
}, [loadHome]);
|
||||
useEffect(() => {
|
||||
void loadHome();
|
||||
}, [loadHome]);
|
||||
|
||||
const {
|
||||
loadingSessionId: actionBusy,
|
||||
handleOpen,
|
||||
handleStart,
|
||||
handleStop,
|
||||
handleDelete,
|
||||
handleRecreateTunnel,
|
||||
} = useInstanceActions({ onRefresh: loadHome });
|
||||
const {
|
||||
loadingSessionId: actionBusy,
|
||||
handleOpen,
|
||||
handleStart,
|
||||
handleStop,
|
||||
handleDelete,
|
||||
handleRecreateTunnel,
|
||||
} = useInstanceActions({ onRefresh: loadHome });
|
||||
|
||||
// Poll tunnel health every 30 seconds for running instances
|
||||
useEffect(() => {
|
||||
const checkHealth = async () => {
|
||||
const runningSessions = safeSessions.filter(
|
||||
(s) => s.status === "running" && s.url
|
||||
);
|
||||
for (const session of runningSessions) {
|
||||
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,
|
||||
container_exit_code: null,
|
||||
tunnel_status: "error",
|
||||
tunnel_status_code: null,
|
||||
probe_status: "error",
|
||||
last_probe_output: null,
|
||||
error: "check failed",
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
// Poll tunnel health every 30 seconds for running instances
|
||||
useEffect(() => {
|
||||
const checkHealth = async () => {
|
||||
const runningSessions = safeSessions.filter(
|
||||
(s) => s.status === "running" && s.url,
|
||||
);
|
||||
for (const session of runningSessions) {
|
||||
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,
|
||||
container_exit_code: null,
|
||||
tunnel_status: "error",
|
||||
tunnel_status_code: null,
|
||||
probe_status: "error",
|
||||
last_probe_output: null,
|
||||
error: "check failed",
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void checkHealth();
|
||||
const interval = setInterval(() => {
|
||||
void checkHealth();
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [safeSessions]);
|
||||
void checkHealth();
|
||||
const interval = setInterval(() => {
|
||||
void checkHealth();
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [safeSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
setRepositories([]);
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
setRepositories([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadRepos = async () => {
|
||||
try {
|
||||
const data = await listRepositories(selectedProject);
|
||||
setRepositories(data);
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
}
|
||||
};
|
||||
const loadRepos = async () => {
|
||||
try {
|
||||
const data = await listRepositories(selectedProject);
|
||||
setRepositories(data);
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
}
|
||||
};
|
||||
|
||||
void loadRepos();
|
||||
}, [selectedProject]);
|
||||
void loadRepos();
|
||||
}, [selectedProject]);
|
||||
|
||||
const activeSessions = useMemo(
|
||||
() => safeSessions.filter((session) => ["running", "building", "pending"].includes(session.status)),
|
||||
[safeSessions]
|
||||
);
|
||||
const activeSessions = useMemo(
|
||||
() =>
|
||||
safeSessions.filter((session) =>
|
||||
["running", "building", "pending"].includes(session.status),
|
||||
),
|
||||
[safeSessions],
|
||||
);
|
||||
|
||||
const handleCreateSuccess = async (instance: { id: string }) => {
|
||||
await updateUserConfig({ last_session_id: instance.id });
|
||||
setSelectedProject("");
|
||||
await loadHome();
|
||||
};
|
||||
const handleCreateSuccess = async (instance: { id: string }) => {
|
||||
await updateUserConfig({ last_session_id: instance.id });
|
||||
setSelectedProject("");
|
||||
await loadHome();
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="stack home-page">
|
||||
<header className="home-hero card">
|
||||
<div className="stack-sm">
|
||||
<p className="eyebrow">Workspace overview</p>
|
||||
<h1>Home</h1>
|
||||
<p className="muted">Open sessions, available projects, and the fastest path back into work.</p>
|
||||
</div>
|
||||
<div className="home-hero-actions">
|
||||
<button className="primary-button" type="button" onClick={() => navigate("/projects")}>New Project</button>
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Settings</button>
|
||||
</div>
|
||||
</header>
|
||||
return (
|
||||
<section className="stack home-page">
|
||||
<header className="home-hero card">
|
||||
<div className="stack-sm">
|
||||
<p className="eyebrow">Workspace overview</p>
|
||||
<h1>Home</h1>
|
||||
<p className="muted">
|
||||
Open sessions, available projects, and the fastest path back into
|
||||
work.
|
||||
</p>
|
||||
</div>
|
||||
<div className="home-hero-actions">
|
||||
<button
|
||||
className="primary-button"
|
||||
type="button"
|
||||
onClick={() => navigate("/projects")}
|
||||
>
|
||||
New Project
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={() => navigate("/settings")}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{status === "loading" && <LoadingState message="Loading overview..." />}
|
||||
{status === "loading" && <LoadingState message="Loading overview..." />}
|
||||
|
||||
{status === "error" && <ErrorState message="Unable to load your workspace overview." onRetry={() => void loadHome()} />}
|
||||
{status === "error" && (
|
||||
<ErrorState
|
||||
message="Unable to load your workspace overview."
|
||||
onRetry={() => void loadHome()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === "ready" && summary && (
|
||||
<>
|
||||
<div className="home-summary-grid">
|
||||
{summaryCards.map((card) => (
|
||||
<article className="card home-summary-card" key={card.label}>
|
||||
<p className="card-label">{card.label}</p>
|
||||
<p className="card-value">
|
||||
{card.key === "openSessions"
|
||||
? activeSessions.length
|
||||
: card.key === "projects"
|
||||
? summary.projects
|
||||
: summary.repositories}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{status === "ready" && summary && (
|
||||
<>
|
||||
<div className="home-summary-grid">
|
||||
{summaryCards.map((card) => (
|
||||
<article className="card home-summary-card" key={card.label}>
|
||||
<p className="card-label">{card.label}</p>
|
||||
<p className="card-value">
|
||||
{card.key === "openSessions"
|
||||
? activeSessions.length
|
||||
: card.key === "projects"
|
||||
? summary.projects
|
||||
: summary.repositories}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Open sessions</p>
|
||||
<h2>{safeSessions.filter((s) => ["running", "building", "pending", "starting", "probing", "unhealthy"].includes(s.status)).length}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<SessionList
|
||||
sessions={safeSessions}
|
||||
onOpen={handleOpen}
|
||||
onStop={handleStop}
|
||||
onDelete={handleDelete}
|
||||
onRecreateTunnel={handleRecreateTunnel}
|
||||
actionBusyId={actionBusy}
|
||||
tunnelHealth={tunnelHealth}
|
||||
showGrouping={false}
|
||||
emptyMessage="No active sessions right now."
|
||||
/>
|
||||
</section>
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Open sessions</p>
|
||||
<h2>
|
||||
{
|
||||
safeSessions.filter((s) =>
|
||||
[
|
||||
"running",
|
||||
"building",
|
||||
"pending",
|
||||
"starting",
|
||||
"probing",
|
||||
"unhealthy",
|
||||
].includes(s.status),
|
||||
).length
|
||||
}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<SessionList
|
||||
sessions={safeSessions}
|
||||
onOpen={handleOpen}
|
||||
onStop={handleStop}
|
||||
onDelete={handleDelete}
|
||||
onRecreateTunnel={handleRecreateTunnel}
|
||||
actionBusyId={actionBusy}
|
||||
tunnelHealth={tunnelHealth}
|
||||
showGrouping={false}
|
||||
emptyMessage="No active sessions right now."
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Available projects</p>
|
||||
<h2>{projects.length}</h2>
|
||||
</div>
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button>
|
||||
</div>
|
||||
{projects.length === 0 ? (
|
||||
<EmptyState message="No projects yet." />
|
||||
) : (
|
||||
<div className="home-project-grid">
|
||||
{projects.map((project) => (
|
||||
<article className="card project-card home-project-card" key={project.id}>
|
||||
<div className="stack-sm">
|
||||
<h3>{project.name}</h3>
|
||||
{project.description && <p className="muted">{project.description}</p>}
|
||||
</div>
|
||||
<button className="ghost-button small" type="button" onClick={() => navigate(`/projects/${project.id}`)}>
|
||||
Open Workspace
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Available projects</p>
|
||||
<h2>{projects.length}</h2>
|
||||
</div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={() => navigate("/projects")}
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
</div>
|
||||
{projects.length === 0 ? (
|
||||
<EmptyState message="No projects yet." />
|
||||
) : (
|
||||
<div className="home-project-grid">
|
||||
{projects.map((project) => (
|
||||
<article
|
||||
className="card project-card home-project-card"
|
||||
key={project.id}
|
||||
>
|
||||
<div className="stack-sm">
|
||||
<h3>{project.name}</h3>
|
||||
{project.description && (
|
||||
<p className="muted">{project.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
type="button"
|
||||
onClick={() => navigate(`/projects/${project.id}`)}
|
||||
>
|
||||
Open Workspace
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Quick create</p>
|
||||
<h2>Start a session</h2>
|
||||
</div>
|
||||
</div>
|
||||
<CreateSessionForm
|
||||
projects={projects}
|
||||
repositories={repositories}
|
||||
toolTypes={toolTypes}
|
||||
onProjectChange={(projectId) => setSelectedProject(projectId)}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
</section>
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Quick create</p>
|
||||
<h2>Start a session</h2>
|
||||
</div>
|
||||
</div>
|
||||
<CreateSessionForm
|
||||
projects={projects}
|
||||
repositories={repositories}
|
||||
toolTypes={toolTypes}
|
||||
onProjectChange={(projectId) => setSelectedProject(projectId)}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length > 0 && (
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Recent sessions</p>
|
||||
<h2>{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<SessionList
|
||||
sessions={safeSessions}
|
||||
onOpen={handleOpen}
|
||||
onStart={handleStart}
|
||||
onDelete={handleDelete}
|
||||
actionBusyId={actionBusy}
|
||||
showGrouping={false}
|
||||
maxRecent={5}
|
||||
emptyMessage="No recent sessions."
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
{safeSessions.filter((s) => ["stopped", "error"].includes(s.status))
|
||||
.length > 0 && (
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Recent sessions</p>
|
||||
<h2>
|
||||
{
|
||||
safeSessions.filter((s) =>
|
||||
["stopped", "error"].includes(s.status),
|
||||
).length
|
||||
}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<SessionList
|
||||
sessions={safeSessions}
|
||||
onOpen={handleOpen}
|
||||
onStart={handleStart}
|
||||
onDelete={handleDelete}
|
||||
actionBusyId={actionBusy}
|
||||
showGrouping={false}
|
||||
maxRecent={5}
|
||||
emptyMessage="No recent sessions."
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { HomePage as DashboardPage };
|
||||
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
deleteWorkspace,
|
||||
syncWorkspace,
|
||||
} from "../api/workspaces";
|
||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||
import {
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
} from "../components/data-states";
|
||||
import { Icon } from "../components/icon";
|
||||
import { WorkspaceCreateForm } from "../components/workspace-create-form";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
@@ -24,10 +28,11 @@ import type { ProjectWithRepos, WorkspaceSummary } from "../types";
|
||||
type DialogMode = "none" | "create" | "edit";
|
||||
|
||||
export const ProjectsPage = () => {
|
||||
const { data: projects, status, reload } = useAsyncData<ProjectWithRepos[]>(
|
||||
listProjects,
|
||||
[],
|
||||
);
|
||||
const {
|
||||
data: projects,
|
||||
status,
|
||||
reload,
|
||||
} = useAsyncData<ProjectWithRepos[]>(listProjects, []);
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
||||
const [editingProject, setEditingProject] = useState<ProjectWithRepos | null>(
|
||||
null,
|
||||
@@ -203,11 +208,7 @@ export const ProjectsPage = () => {
|
||||
if (action === "sync") {
|
||||
void handleSyncWorkspace(project.id, repoId, workspace);
|
||||
} else if (action === "delete") {
|
||||
void handleDeleteWorkspace(
|
||||
project.id,
|
||||
repoId,
|
||||
workspace,
|
||||
);
|
||||
void handleDeleteWorkspace(project.id, repoId, workspace);
|
||||
}
|
||||
}}
|
||||
workspaceLoading={workspaceLoading}
|
||||
@@ -317,7 +318,10 @@ function ProjectCard({
|
||||
workspaceLoading: string | null;
|
||||
onCancelCreate: () => void;
|
||||
showCreateForm: string | null;
|
||||
onSubmitCreate: (repoId: string, data: { name: string; branch: string }) => Promise<void>;
|
||||
onSubmitCreate: (
|
||||
repoId: string,
|
||||
data: { name: string; branch: string },
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<article className="card project-card">
|
||||
@@ -328,10 +332,7 @@ function ProjectCard({
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<Icon
|
||||
name={expanded ? "chevron-down" : "chevron-right"}
|
||||
size="sm"
|
||||
/>
|
||||
<Icon name={expanded ? "chevron-down" : "chevron-right"} size="sm" />
|
||||
<h3>{project.name}</h3>
|
||||
{project.repositories.length > 0 && (
|
||||
<span className="repo-count">
|
||||
@@ -400,9 +401,7 @@ function ProjectCard({
|
||||
<WorkspaceCreateForm
|
||||
projectId={project.id}
|
||||
repoId={repo.id}
|
||||
onSubmit={(data) =>
|
||||
onSubmitCreate(repo.id, data)
|
||||
}
|
||||
onSubmit={(data) => onSubmitCreate(repo.id, data)}
|
||||
onCancel={onCancelCreate}
|
||||
/>
|
||||
)}
|
||||
@@ -428,15 +427,9 @@ function ProjectCard({
|
||||
<div className="ws-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
workspaceLoading === ws.id
|
||||
}
|
||||
disabled={workspaceLoading === ws.id}
|
||||
onClick={() =>
|
||||
onWorkspaceAction(
|
||||
repo.id,
|
||||
ws,
|
||||
"sync",
|
||||
)
|
||||
onWorkspaceAction(repo.id, ws, "sync")
|
||||
}
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
@@ -444,15 +437,9 @@ function ProjectCard({
|
||||
<button
|
||||
type="button"
|
||||
className="danger-text"
|
||||
disabled={
|
||||
workspaceLoading === ws.id
|
||||
}
|
||||
disabled={workspaceLoading === ws.id}
|
||||
onClick={() =>
|
||||
onWorkspaceAction(
|
||||
repo.id,
|
||||
ws,
|
||||
"delete",
|
||||
)
|
||||
onWorkspaceAction(repo.id, ws, "delete")
|
||||
}
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
|
||||
+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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -100,7 +100,10 @@ function TabBar({
|
||||
role="tab"
|
||||
aria-selected={active === tab.id}
|
||||
>
|
||||
<Icon name={tab.icon as "folder" | "branch" | "terminal" | "settings"} size="sm" />
|
||||
<Icon
|
||||
name={tab.icon as "folder" | "branch" | "terminal" | "settings"}
|
||||
size="sm"
|
||||
/>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
@@ -132,7 +135,9 @@ function MobileTabBar({
|
||||
role="tab"
|
||||
aria-selected={active === tab.id}
|
||||
>
|
||||
<Icon name={tab.icon as "folder" | "branch" | "terminal" | "settings"} />
|
||||
<Icon
|
||||
name={tab.icon as "folder" | "branch" | "terminal" | "settings"}
|
||||
/>
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -1,34 +1,26 @@
|
||||
/** Workspaces list page. */
|
||||
/** Workspaces list page with direct creation. */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Icon } from "../components/icon";
|
||||
import { useWorkspaces } from "../hooks/use-workspaces";
|
||||
import { useWorkspaceActions } from "../hooks/use-workspace-actions";
|
||||
import { WorkspaceCard } from "../components/workspace-card";
|
||||
import { WorkspaceCreateForm } from "../components/workspace-create-form";
|
||||
import { StartToolModal } from "../components/start-tool-modal";
|
||||
import { createInstance, startInstance } from "../api/sessions";
|
||||
import { listProjects } from "../api/projects";
|
||||
import { listRepositories } from "../api/git_repositories";
|
||||
import { createWorkspaceTopLevel } from "../api/workspaces";
|
||||
import type { Workspace } from "../types/workspace";
|
||||
import type { ProjectWithRepos } from "../types";
|
||||
import type { GitRepository } from "../api/git_repositories";
|
||||
|
||||
export function WorkspacesPage() {
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [startWorkspace, setStartWorkspace] = useState<Workspace | null>(null);
|
||||
const [createTarget, setCreateTarget] = useState<{
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
} | null>(null);
|
||||
|
||||
const { workspaces, loading, error, refresh } = useWorkspaces();
|
||||
const actions = useWorkspaceActions();
|
||||
|
||||
const handleCreate = async (data: { name: string; branch: string }) => {
|
||||
if (!createTarget) return;
|
||||
await actions.create(createTarget.projectId, createTarget.repoId, data);
|
||||
setShowCreate(false);
|
||||
setCreateTarget(null);
|
||||
await refresh();
|
||||
};
|
||||
|
||||
const handleDelete = async (workspace: Workspace) => {
|
||||
await actions.delete(
|
||||
workspace.project_id,
|
||||
@@ -92,18 +84,7 @@ export function WorkspacesPage() {
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
if (workspaces.length > 0) {
|
||||
const first = workspaces[0];
|
||||
setCreateTarget({
|
||||
projectId: first.project_id,
|
||||
repoId: first.repo_id,
|
||||
});
|
||||
setShowCreate(true);
|
||||
} else {
|
||||
alert("Navigate to a project to create your first workspace.");
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
<Icon name="add" size="sm" /> New Workspace
|
||||
</button>
|
||||
@@ -112,15 +93,13 @@ export function WorkspacesPage() {
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
{showCreate && createTarget && (
|
||||
<WorkspaceCreateForm
|
||||
projectId={createTarget.projectId}
|
||||
repoId={createTarget.repoId}
|
||||
onSubmit={handleCreate}
|
||||
onCancel={() => {
|
||||
{showCreate && (
|
||||
<WorkspaceCreateInline
|
||||
onCreated={() => {
|
||||
setShowCreate(false);
|
||||
setCreateTarget(null);
|
||||
refresh();
|
||||
}}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -129,7 +108,12 @@ export function WorkspacesPage() {
|
||||
) : workspaces.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No workspaces yet.</p>
|
||||
<p>Navigate to a project to create your first workspace.</p>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
<Icon name="add" size="sm" /> Create your first workspace
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="workspaces-grid">
|
||||
@@ -156,3 +140,190 @@ export function WorkspacesPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Inline Workspace Creation Form ─── */
|
||||
|
||||
function WorkspaceCreateInline({
|
||||
onCreated,
|
||||
onCancel,
|
||||
}: {
|
||||
onCreated: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
|
||||
const [repos, setRepos] = useState<GitRepository[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState("");
|
||||
const [selectedRepo, setSelectedRepo] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [branch, setBranch] = useState("main");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetching, setFetching] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadProjects = useCallback(async () => {
|
||||
try {
|
||||
const data = await listProjects();
|
||||
setProjects(data);
|
||||
if (data.length === 1) {
|
||||
setSelectedProject(data[0].id);
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load projects");
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProjects();
|
||||
}, [loadProjects]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
setRepos([]);
|
||||
setSelectedRepo("");
|
||||
return;
|
||||
}
|
||||
const loadRepos = async () => {
|
||||
try {
|
||||
const data = await listRepositories(selectedProject);
|
||||
setRepos(data);
|
||||
if (data.length === 1) {
|
||||
setSelectedRepo(data[0].id);
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load repositories");
|
||||
}
|
||||
};
|
||||
void loadRepos();
|
||||
}, [selectedProject]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedRepo) {
|
||||
setError("Please select a repository");
|
||||
return;
|
||||
}
|
||||
if (!name.trim()) {
|
||||
setError("Workspace name is required");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await createWorkspaceTopLevel({
|
||||
repo_id: selectedRepo,
|
||||
name: name.trim(),
|
||||
branch: branch.trim() || "main",
|
||||
});
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create workspace");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (fetching) {
|
||||
return (
|
||||
<div className="card workspace-create-inline">
|
||||
<p className="muted">Loading projects...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card workspace-create-inline">
|
||||
<h3>
|
||||
<Icon name="add" size="sm" /> Create Workspace
|
||||
</h3>
|
||||
<form onSubmit={handleSubmit} className="workspace-create-form-grid">
|
||||
<div className="form-group">
|
||||
<label>Project</label>
|
||||
<select
|
||||
value={selectedProject}
|
||||
onChange={(e) => setSelectedProject(e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">Select project...</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Repository</label>
|
||||
<select
|
||||
value={selectedRepo}
|
||||
onChange={(e) => setSelectedRepo(e.target.value)}
|
||||
required
|
||||
disabled={!selectedProject || repos.length === 0}
|
||||
>
|
||||
<option value="">
|
||||
{!selectedProject
|
||||
? "Select a project first"
|
||||
: repos.length === 0
|
||||
? "No repositories"
|
||||
: "Select repository..."}
|
||||
</option>
|
||||
{repos.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., feature-branch"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
<Icon name="branch" size="sm" /> Branch
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
placeholder="main"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="form-error" style={{ gridColumn: "1 / -1" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-actions" style={{ gridColumn: "1 / -1" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={loading || !selectedRepo}
|
||||
>
|
||||
{loading ? "Creating..." : "Create Workspace"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,10 @@ export const AppRouter = () => {
|
||||
</Route>
|
||||
<Route path="sessions" element={<SessionsPage />} />
|
||||
<Route path="workspaces" element={<WorkspacesPage />} />
|
||||
<Route path="workspaces/:workspaceId" element={<WorkspaceDetailPage />} />
|
||||
<Route
|
||||
path="workspaces/:workspaceId"
|
||||
element={<WorkspaceDetailPage />}
|
||||
/>
|
||||
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
|
||||
<Route
|
||||
path="instances/:instanceId/terminal"
|
||||
|
||||
@@ -5454,3 +5454,52 @@ a:active,
|
||||
.workspace-chip .ws-actions button.danger-text:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ─── Workspace Create Inline ─── */
|
||||
|
||||
.workspace-create-inline {
|
||||
padding: var(--space-5);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.workspace-create-inline h3 {
|
||||
margin: 0 0 var(--space-4) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.workspace-create-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--space-4);
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.workspace-create-form-grid .form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.workspace-create-form-grid label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.workspace-create-form-grid input,
|
||||
.workspace-create-form-grid select {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.workspace-create-form-grid .form-actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
+26
-26
@@ -1,43 +1,43 @@
|
||||
export type SessionUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
};
|
||||
|
||||
export type SessionPayload = {
|
||||
user: SessionUser;
|
||||
user: SessionUser;
|
||||
};
|
||||
|
||||
export type Project = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
owner_id: string;
|
||||
default_ssh_key_id: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
owner_id: string;
|
||||
default_ssh_key_id: string | null;
|
||||
};
|
||||
|
||||
export type WorkspaceSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
branch: string;
|
||||
status: string;
|
||||
instance_count: number;
|
||||
id: string;
|
||||
name: string;
|
||||
branch: string;
|
||||
status: string;
|
||||
instance_count: number;
|
||||
};
|
||||
|
||||
export type RepositorySummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
remote_url: string;
|
||||
workspaces: WorkspaceSummary[];
|
||||
id: string;
|
||||
name: string;
|
||||
remote_url: string;
|
||||
workspaces: WorkspaceSummary[];
|
||||
};
|
||||
|
||||
export type ProjectWithRepos = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
owner_id: string;
|
||||
default_ssh_key_id: string | null;
|
||||
repositories: RepositorySummary[];
|
||||
created_at: string;
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
owner_id: string;
|
||||
default_ssh_key_id: string | null;
|
||||
repositories: RepositorySummary[];
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user