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:
2026-06-01 17:32:35 +02:00
parent 88a973dc68
commit ab1843b1c3
12 changed files with 870 additions and 561 deletions
+25 -22
View File
@@ -2,50 +2,53 @@ import { apiClient } from "./client";
import type { Project, ProjectWithRepos } from "../types"; import type { Project, ProjectWithRepos } from "../types";
export type ProjectCreateInput = { export type ProjectCreateInput = {
name: string; name: string;
description?: string | null; description?: string | null;
}; };
export type ProjectUpdateInput = { export type ProjectUpdateInput = {
name?: string | null; name?: string | null;
description?: string | null; description?: string | null;
}; };
export type SetDefaultSSHKeyInput = { export type SetDefaultSSHKeyInput = {
ssh_key_id: string; ssh_key_id: string;
}; };
export const listProjects = async (): Promise<ProjectWithRepos[]> => { export const listProjects = async (): Promise<ProjectWithRepos[]> => {
const response = await apiClient.get<ProjectWithRepos[]>("/projects"); const response = await apiClient.get<ProjectWithRepos[]>("/projects");
return response.data; return response.data;
}; };
export const createProject = async ( export const createProject = async (
input: ProjectCreateInput input: ProjectCreateInput,
): Promise<Project> => { ): Promise<Project> => {
const response = await apiClient.post<Project>("/projects", input); const response = await apiClient.post<Project>("/projects", input);
return response.data; return response.data;
}; };
export const updateProject = async ( export const updateProject = async (
projectId: string, projectId: string,
input: ProjectUpdateInput input: ProjectUpdateInput,
): Promise<Project> => { ): Promise<Project> => {
const response = await apiClient.patch<Project>(`/projects/${projectId}`, input); const response = await apiClient.patch<Project>(
return response.data; `/projects/${projectId}`,
input,
);
return response.data;
}; };
export const deleteProject = async (projectId: string): Promise<void> => { export const deleteProject = async (projectId: string): Promise<void> => {
await apiClient.delete(`/projects/${projectId}`); await apiClient.delete(`/projects/${projectId}`);
}; };
export const setDefaultSSHKey = async ( export const setDefaultSSHKey = async (
projectId: string, projectId: string,
input: SetDefaultSSHKeyInput input: SetDefaultSSHKeyInput,
): Promise<Project> => { ): Promise<Project> => {
const response = await apiClient.patch<Project>( const response = await apiClient.patch<Project>(
`/projects/${projectId}/default-ssh-key`, `/projects/${projectId}/default-ssh-key`,
input input,
); );
return response.data; return response.data;
}; };
+7
View File
@@ -39,6 +39,13 @@ export async function createWorkspace(
return response.data; 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( export async function getWorkspace(
projectId: string, projectId: string,
repoId: string, repoId: string,
+4 -1
View File
@@ -28,7 +28,10 @@ export function WorkspaceCard({
return ( return (
<article className={`card workspace-card ${loading ? "loading" : ""}`}> <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"> <div className="workspace-header">
<h4>{workspace.name}</h4> <h4>{workspace.name}</h4>
<span className={`status-badge ${statusClass}`}> <span className={`status-badge ${statusClass}`}>
-1
View File
@@ -1539,7 +1539,6 @@ export const ConfigProfilesPage = () => {
onChange={(git_mounts) => onChange={(git_mounts) =>
updateFormField("git_mounts", git_mounts) updateFormField("git_mounts", git_mounts)
} }
/> />
</div> </div>
+294 -224
View File
@@ -2,13 +2,22 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard"; 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 { listProjects } from "../api/projects";
import { listRepositories, type GitRepository } from "../api/git_repositories"; import { listRepositories, type GitRepository } from "../api/git_repositories";
import { listToolTypes, type ToolType } from "../api/tool_types"; import { listToolTypes, type ToolType } from "../api/tool_types";
import { updateUserConfig } from "../api/settings"; import { updateUserConfig } from "../api/settings";
import type { ProjectWithRepos } from "../types"; 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 { CreateSessionForm } from "../components/create-session-form";
import { SessionList } from "../components/session-list"; import { SessionList } from "../components/session-list";
import { useInstanceActions } from "../hooks/use-instance-actions"; import { useInstanceActions } from "../hooks/use-instance-actions";
@@ -16,251 +25,312 @@ import { useInstanceActions } from "../hooks/use-instance-actions";
type HomeStatus = "loading" | "ready" | "error"; type HomeStatus = "loading" | "ready" | "error";
const summaryCards = [ const summaryCards = [
{ label: "Open sessions", key: "openSessions" }, { label: "Open sessions", key: "openSessions" },
{ label: "Projects", key: "projects" }, { label: "Projects", key: "projects" },
{ label: "Repositories", key: "repositories" }, { label: "Repositories", key: "repositories" },
] as const; ] as const;
type SessionView = SessionApi; type SessionView = SessionApi;
export const HomePage = () => { export const HomePage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [status, setStatus] = useState<HomeStatus>("loading"); const [status, setStatus] = useState<HomeStatus>("loading");
const [summary, setSummary] = useState<DashboardSummary | null>(null); const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [sessions, setSessions] = useState<SessionView[]>([]); const [sessions, setSessions] = useState<SessionView[]>([]);
const [projects, setProjects] = useState<ProjectWithRepos[]>([]); const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
const [repositories, setRepositories] = useState<GitRepository[]>([]); const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]); const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProject, setSelectedProject] = useState(""); const [selectedProject, setSelectedProject] = useState("");
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({}); const [tunnelHealth, setTunnelHealth] = useState<
const safeSessions = Array.isArray(sessions) ? sessions : []; Record<string, InstanceHealth>
>({});
const safeSessions = Array.isArray(sessions) ? sessions : [];
const loadHome = useCallback(async () => { const loadHome = useCallback(async () => {
setStatus("loading"); setStatus("loading");
try { try {
const [dashboard, sessionData, projectData, toolTypeData] = await Promise.all([ const [dashboard, sessionData, projectData, toolTypeData] =
getDashboardSummary(), await Promise.all([
getUserSessions(), getDashboardSummary(),
listProjects(), getUserSessions(),
listToolTypes(), listProjects(),
]); listToolTypes(),
setSummary(dashboard); ]);
setSessions(sessionData as SessionView[]); setSummary(dashboard);
setProjects(projectData); setSessions(sessionData as SessionView[]);
setToolTypes(toolTypeData); setProjects(projectData);
setStatus("ready"); setToolTypes(toolTypeData);
} catch { setStatus("ready");
setStatus("error"); } catch {
} setStatus("error");
}, []); }
}, []);
useEffect(() => { useEffect(() => {
void loadHome(); void loadHome();
}, [loadHome]); }, [loadHome]);
const { const {
loadingSessionId: actionBusy, loadingSessionId: actionBusy,
handleOpen, handleOpen,
handleStart, handleStart,
handleStop, handleStop,
handleDelete, handleDelete,
handleRecreateTunnel, handleRecreateTunnel,
} = useInstanceActions({ onRefresh: loadHome }); } = useInstanceActions({ onRefresh: loadHome });
// Poll tunnel health every 30 seconds for running instances // Poll tunnel health every 30 seconds for running instances
useEffect(() => { useEffect(() => {
const checkHealth = async () => { const checkHealth = async () => {
const runningSessions = safeSessions.filter( const runningSessions = safeSessions.filter(
(s) => s.status === "running" && s.url (s) => s.status === "running" && s.url,
); );
for (const session of runningSessions) { for (const session of runningSessions) {
try { try {
const health = await checkInstanceHealth( const health = await checkInstanceHealth(
session.project_id, session.project_id,
session.repository_id, session.repository_id,
session.id session.id,
); );
setTunnelHealth((prev) => ({ setTunnelHealth((prev) => ({
...prev, ...prev,
[session.id]: health, [session.id]: health,
})); }));
} catch { } catch {
setTunnelHealth((prev) => ({ setTunnelHealth((prev) => ({
...prev, ...prev,
[session.id]: { [session.id]: {
healthy: false, healthy: false,
container_status: "unknown", container_status: "unknown",
container_health: null, container_health: null,
container_exit_code: null, container_exit_code: null,
tunnel_status: "error", tunnel_status: "error",
tunnel_status_code: null, tunnel_status_code: null,
probe_status: "error", probe_status: "error",
last_probe_output: null, last_probe_output: null,
error: "check failed", error: "check failed",
}, },
})); }));
} }
} }
}; };
void checkHealth(); void checkHealth();
const interval = setInterval(() => { const interval = setInterval(() => {
void checkHealth(); void checkHealth();
}, 30000); }, 30000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [safeSessions]); }, [safeSessions]);
useEffect(() => { useEffect(() => {
if (!selectedProject) { if (!selectedProject) {
setRepositories([]); setRepositories([]);
return; return;
} }
const loadRepos = async () => { const loadRepos = async () => {
try { try {
const data = await listRepositories(selectedProject); const data = await listRepositories(selectedProject);
setRepositories(data); setRepositories(data);
} catch { } catch {
setRepositories([]); setRepositories([]);
} }
}; };
void loadRepos(); void loadRepos();
}, [selectedProject]); }, [selectedProject]);
const activeSessions = useMemo( const activeSessions = useMemo(
() => safeSessions.filter((session) => ["running", "building", "pending"].includes(session.status)), () =>
[safeSessions] safeSessions.filter((session) =>
); ["running", "building", "pending"].includes(session.status),
),
[safeSessions],
);
const handleCreateSuccess = async (instance: { id: string }) => { const handleCreateSuccess = async (instance: { id: string }) => {
await updateUserConfig({ last_session_id: instance.id }); await updateUserConfig({ last_session_id: instance.id });
setSelectedProject(""); setSelectedProject("");
await loadHome(); await loadHome();
}; };
return ( return (
<section className="stack home-page"> <section className="stack home-page">
<header className="home-hero card"> <header className="home-hero card">
<div className="stack-sm"> <div className="stack-sm">
<p className="eyebrow">Workspace overview</p> <p className="eyebrow">Workspace overview</p>
<h1>Home</h1> <h1>Home</h1>
<p className="muted">Open sessions, available projects, and the fastest path back into work.</p> <p className="muted">
</div> Open sessions, available projects, and the fastest path back into
<div className="home-hero-actions"> work.
<button className="primary-button" type="button" onClick={() => navigate("/projects")}>New Project</button> </p>
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Settings</button> </div>
</div> <div className="home-hero-actions">
</header> <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 && ( {status === "ready" && summary && (
<> <>
<div className="home-summary-grid"> <div className="home-summary-grid">
{summaryCards.map((card) => ( {summaryCards.map((card) => (
<article className="card home-summary-card" key={card.label}> <article className="card home-summary-card" key={card.label}>
<p className="card-label">{card.label}</p> <p className="card-label">{card.label}</p>
<p className="card-value"> <p className="card-value">
{card.key === "openSessions" {card.key === "openSessions"
? activeSessions.length ? activeSessions.length
: card.key === "projects" : card.key === "projects"
? summary.projects ? summary.projects
: summary.repositories} : summary.repositories}
</p> </p>
</article> </article>
))} ))}
</div> </div>
<section className="card stack home-section"> <section className="card stack home-section">
<div className="page-header"> <div className="page-header">
<div> <div>
<p className="eyebrow">Open sessions</p> <p className="eyebrow">Open sessions</p>
<h2>{safeSessions.filter((s) => ["running", "building", "pending", "starting", "probing", "unhealthy"].includes(s.status)).length}</h2> <h2>
</div> {
</div> safeSessions.filter((s) =>
<SessionList [
sessions={safeSessions} "running",
onOpen={handleOpen} "building",
onStop={handleStop} "pending",
onDelete={handleDelete} "starting",
onRecreateTunnel={handleRecreateTunnel} "probing",
actionBusyId={actionBusy} "unhealthy",
tunnelHealth={tunnelHealth} ].includes(s.status),
showGrouping={false} ).length
emptyMessage="No active sessions right now." }
/> </h2>
</section> </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"> <section className="card stack home-section">
<div className="page-header"> <div className="page-header">
<div> <div>
<p className="eyebrow">Available projects</p> <p className="eyebrow">Available projects</p>
<h2>{projects.length}</h2> <h2>{projects.length}</h2>
</div> </div>
<button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button> <button
</div> className="secondary-button"
{projects.length === 0 ? ( type="button"
<EmptyState message="No projects yet." /> onClick={() => navigate("/projects")}
) : ( >
<div className="home-project-grid"> View all
{projects.map((project) => ( </button>
<article className="card project-card home-project-card" key={project.id}> </div>
<div className="stack-sm"> {projects.length === 0 ? (
<h3>{project.name}</h3> <EmptyState message="No projects yet." />
{project.description && <p className="muted">{project.description}</p>} ) : (
</div> <div className="home-project-grid">
<button className="ghost-button small" type="button" onClick={() => navigate(`/projects/${project.id}`)}> {projects.map((project) => (
Open Workspace <article
</button> className="card project-card home-project-card"
</article> key={project.id}
))} >
</div> <div className="stack-sm">
)} <h3>{project.name}</h3>
</section> {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"> <section className="card stack home-section">
<div className="page-header"> <div className="page-header">
<div> <div>
<p className="eyebrow">Quick create</p> <p className="eyebrow">Quick create</p>
<h2>Start a session</h2> <h2>Start a session</h2>
</div> </div>
</div> </div>
<CreateSessionForm <CreateSessionForm
projects={projects} projects={projects}
repositories={repositories} repositories={repositories}
toolTypes={toolTypes} toolTypes={toolTypes}
onProjectChange={(projectId) => setSelectedProject(projectId)} onProjectChange={(projectId) => setSelectedProject(projectId)}
onSuccess={handleCreateSuccess} onSuccess={handleCreateSuccess}
/> />
</section> </section>
{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length > 0 && ( {safeSessions.filter((s) => ["stopped", "error"].includes(s.status))
<section className="card stack home-section"> .length > 0 && (
<div className="page-header"> <section className="card stack home-section">
<div> <div className="page-header">
<p className="eyebrow">Recent sessions</p> <div>
<h2>{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length}</h2> <p className="eyebrow">Recent sessions</p>
</div> <h2>
</div> {
<SessionList safeSessions.filter((s) =>
sessions={safeSessions} ["stopped", "error"].includes(s.status),
onOpen={handleOpen} ).length
onStart={handleStart} }
onDelete={handleDelete} </h2>
actionBusyId={actionBusy} </div>
showGrouping={false} </div>
maxRecent={5} <SessionList
emptyMessage="No recent sessions." sessions={safeSessions}
/> onOpen={handleOpen}
</section> onStart={handleStart}
)} onDelete={handleDelete}
</> actionBusyId={actionBusy}
)} showGrouping={false}
</section> maxRecent={5}
); emptyMessage="No recent sessions."
/>
</section>
)}
</>
)}
</section>
);
}; };
export { HomePage as DashboardPage }; export { HomePage as DashboardPage };
+21 -34
View File
@@ -15,7 +15,11 @@ import {
deleteWorkspace, deleteWorkspace,
syncWorkspace, syncWorkspace,
} from "../api/workspaces"; } 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 { Icon } from "../components/icon";
import { WorkspaceCreateForm } from "../components/workspace-create-form"; import { WorkspaceCreateForm } from "../components/workspace-create-form";
import { useAsyncData } from "../hooks/use-async-data"; import { useAsyncData } from "../hooks/use-async-data";
@@ -24,10 +28,11 @@ import type { ProjectWithRepos, WorkspaceSummary } from "../types";
type DialogMode = "none" | "create" | "edit"; type DialogMode = "none" | "create" | "edit";
export const ProjectsPage = () => { export const ProjectsPage = () => {
const { data: projects, status, reload } = useAsyncData<ProjectWithRepos[]>( const {
listProjects, data: projects,
[], status,
); reload,
} = useAsyncData<ProjectWithRepos[]>(listProjects, []);
const [dialogMode, setDialogMode] = useState<DialogMode>("none"); const [dialogMode, setDialogMode] = useState<DialogMode>("none");
const [editingProject, setEditingProject] = useState<ProjectWithRepos | null>( const [editingProject, setEditingProject] = useState<ProjectWithRepos | null>(
null, null,
@@ -203,11 +208,7 @@ export const ProjectsPage = () => {
if (action === "sync") { if (action === "sync") {
void handleSyncWorkspace(project.id, repoId, workspace); void handleSyncWorkspace(project.id, repoId, workspace);
} else if (action === "delete") { } else if (action === "delete") {
void handleDeleteWorkspace( void handleDeleteWorkspace(project.id, repoId, workspace);
project.id,
repoId,
workspace,
);
} }
}} }}
workspaceLoading={workspaceLoading} workspaceLoading={workspaceLoading}
@@ -317,7 +318,10 @@ function ProjectCard({
workspaceLoading: string | null; workspaceLoading: string | null;
onCancelCreate: () => void; onCancelCreate: () => void;
showCreateForm: string | null; showCreateForm: string | null;
onSubmitCreate: (repoId: string, data: { name: string; branch: string }) => Promise<void>; onSubmitCreate: (
repoId: string,
data: { name: string; branch: string },
) => Promise<void>;
}) { }) {
return ( return (
<article className="card project-card"> <article className="card project-card">
@@ -328,10 +332,7 @@ function ProjectCard({
type="button" type="button"
aria-expanded={expanded} aria-expanded={expanded}
> >
<Icon <Icon name={expanded ? "chevron-down" : "chevron-right"} size="sm" />
name={expanded ? "chevron-down" : "chevron-right"}
size="sm"
/>
<h3>{project.name}</h3> <h3>{project.name}</h3>
{project.repositories.length > 0 && ( {project.repositories.length > 0 && (
<span className="repo-count"> <span className="repo-count">
@@ -400,9 +401,7 @@ function ProjectCard({
<WorkspaceCreateForm <WorkspaceCreateForm
projectId={project.id} projectId={project.id}
repoId={repo.id} repoId={repo.id}
onSubmit={(data) => onSubmit={(data) => onSubmitCreate(repo.id, data)}
onSubmitCreate(repo.id, data)
}
onCancel={onCancelCreate} onCancel={onCancelCreate}
/> />
)} )}
@@ -428,15 +427,9 @@ function ProjectCard({
<div className="ws-actions"> <div className="ws-actions">
<button <button
type="button" type="button"
disabled={ disabled={workspaceLoading === ws.id}
workspaceLoading === ws.id
}
onClick={() => onClick={() =>
onWorkspaceAction( onWorkspaceAction(repo.id, ws, "sync")
repo.id,
ws,
"sync",
)
} }
> >
<Icon name="refresh" size="sm" /> <Icon name="refresh" size="sm" />
@@ -444,15 +437,9 @@ function ProjectCard({
<button <button
type="button" type="button"
className="danger-text" className="danger-text"
disabled={ disabled={workspaceLoading === ws.id}
workspaceLoading === ws.id
}
onClick={() => onClick={() =>
onWorkspaceAction( onWorkspaceAction(repo.id, ws, "delete")
repo.id,
ws,
"delete",
)
} }
> >
<Icon name="delete" size="sm" /> <Icon name="delete" size="sm" />
+227 -215
View File
@@ -4,9 +4,9 @@ import { listProjects } from "../api/projects";
import type { ProjectWithRepos } from "../types"; import type { ProjectWithRepos } from "../types";
import { listRepositories, type GitRepository } from "../api/git_repositories"; import { listRepositories, type GitRepository } from "../api/git_repositories";
import { import {
getUserSessions, getUserSessions,
type Session, type Session,
checkInstanceHealth, checkInstanceHealth,
} from "../api/sessions"; } from "../api/sessions";
import { listToolTypes, type ToolType } from "../api/tool_types"; import { listToolTypes, type ToolType } from "../api/tool_types";
import { getUserConfig, updateUserConfig } from "../api/settings"; import { getUserConfig, updateUserConfig } from "../api/settings";
@@ -20,235 +20,247 @@ import type { InstanceHealth } from "../api/sessions";
type SessionsStatus = "loading" | "ready" | "error"; type SessionsStatus = "loading" | "ready" | "error";
export const SessionsPage = () => { export const SessionsPage = () => {
const [status, setStatus] = useState<SessionsStatus>("loading"); const [status, setStatus] = useState<SessionsStatus>("loading");
const [sessions, setSessions] = useState<Session[]>([]); const [sessions, setSessions] = useState<Session[]>([]);
const [lastSessionId, setLastSessionId] = useState<string | null>(null); const [lastSessionId, setLastSessionId] = useState<string | null>(null);
const [projects, setProjects] = useState<ProjectWithRepos[]>([]); const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
const [repositories, setRepositories] = useState<GitRepository[]>([]); const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]); const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProject, setSelectedProject] = useState<string>(""); const [selectedProject, setSelectedProject] = useState<string>("");
const [tunnelHealth, setTunnelHealth] = useState<Record<string, InstanceHealth>>({}); const [tunnelHealth, setTunnelHealth] = useState<
Record<string, InstanceHealth>
>({});
const loadSessions = useCallback(async () => { const loadSessions = useCallback(async () => {
setStatus("loading"); setStatus("loading");
try { try {
const [sessionsData, config] = await Promise.all([ const [sessionsData, config] = await Promise.all([
getUserSessions(), getUserSessions(),
getUserConfig(), getUserConfig(),
]); ]);
setSessions(sessionsData); setSessions(sessionsData);
setLastSessionId(config.last_session_id ?? null); setLastSessionId(config.last_session_id ?? null);
setStatus("ready"); setStatus("ready");
} catch { } catch {
setStatus("error"); setStatus("error");
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
void loadSessions(); void loadSessions();
}, [loadSessions]); }, [loadSessions]);
useEffect(() => { useEffect(() => {
const loadProjects = async () => { const loadProjects = async () => {
try { try {
const data = await listProjects(); const data = await listProjects();
setProjects(data); setProjects(data);
} catch { } catch {
// ignore // ignore
} }
}; };
void loadProjects(); void loadProjects();
}, []); }, []);
useEffect(() => { useEffect(() => {
const loadToolTypes = async () => { const loadToolTypes = async () => {
try { try {
const data = await listToolTypes(); const data = await listToolTypes();
setToolTypes(data); setToolTypes(data);
} catch { } catch {
// ignore // ignore
} }
}; };
void loadToolTypes(); void loadToolTypes();
}, []); }, []);
const { const {
loadingSessionId, loadingSessionId,
dirtyDeleteSession, dirtyDeleteSession,
dirtyDeleteFiles, dirtyDeleteFiles,
handleOpen, handleOpen,
handleStart, handleStart,
handleStop, handleStop,
handleDelete, handleDelete,
handleForceDelete, handleForceDelete,
handleRecreateTunnel, handleRecreateTunnel,
clearDirtyDelete, clearDirtyDelete,
} = useInstanceActions({ onRefresh: loadSessions }); } = useInstanceActions({ onRefresh: loadSessions });
// Poll health every 30 seconds for active web-enabled instances // Poll health every 30 seconds for active web-enabled instances
useEffect(() => { useEffect(() => {
const checkHealth = async () => { const checkHealth = async () => {
const activeSessions = sessions.filter( const activeSessions = sessions.filter(
(s) => ["running", "starting", "unhealthy", "probing"].includes(s.status) (s) =>
&& s.tool_type_interfaces?.includes("web") ["running", "starting", "unhealthy", "probing"].includes(s.status) &&
); s.tool_type_interfaces?.includes("web"),
for (const session of activeSessions) { );
try { for (const session of activeSessions) {
const health = await checkInstanceHealth( try {
session.project_id, const health = await checkInstanceHealth(
session.repository_id, session.project_id,
session.id session.repository_id,
); session.id,
setTunnelHealth((prev) => ({ );
...prev, setTunnelHealth((prev) => ({
[session.id]: health, ...prev,
})); [session.id]: health,
} catch { }));
setTunnelHealth((prev) => ({ } catch {
...prev, setTunnelHealth((prev) => ({
[session.id]: { ...prev,
healthy: false, [session.id]: {
container_status: "unknown", healthy: false,
container_health: null, container_status: "unknown",
tunnel_status: "unreachable", container_health: null,
tunnel_status_code: null, tunnel_status: "unreachable",
probe_status: "unknown", tunnel_status_code: null,
last_probe_output: null, probe_status: "unknown",
error: "check failed", last_probe_output: null,
} as InstanceHealth, error: "check failed",
})); } as InstanceHealth,
} }));
} }
}; }
};
// Check immediately and then every 30 seconds // Check immediately and then every 30 seconds
void checkHealth(); void checkHealth();
const interval = setInterval(() => void checkHealth(), 30000); const interval = setInterval(() => void checkHealth(), 30000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [sessions]); }, [sessions]);
useEffect(() => { useEffect(() => {
if (!selectedProject) { if (!selectedProject) {
setRepositories([]); setRepositories([]);
return; return;
} }
const loadRepos = async () => { const loadRepos = async () => {
try { try {
const data = await listRepositories(selectedProject); const data = await listRepositories(selectedProject);
setRepositories(data); setRepositories(data);
} catch { } catch {
setRepositories([]); setRepositories([]);
} }
}; };
void loadRepos(); void loadRepos();
}, [selectedProject]); }, [selectedProject]);
const lastSession = useMemo( const lastSession = useMemo(
() => sessions.find((s) => s.id === lastSessionId) ?? null, () => sessions.find((s) => s.id === lastSessionId) ?? null,
[sessions, lastSessionId] [sessions, lastSessionId],
); );
const handleCreateSuccess = async (instance: { id: string }) => { const handleCreateSuccess = async (instance: { id: string }) => {
await updateUserConfig({ last_session_id: instance.id }); await updateUserConfig({ last_session_id: instance.id });
setSelectedProject(""); setSelectedProject("");
await loadSessions(); await loadSessions();
}; };
return ( return (
<section className="stack sessions-page"> <section className="stack sessions-page">
<div className="page-header"> <div className="page-header">
<h1>Sessions</h1> <h1>Sessions</h1>
</div> </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" && ( {status === "ready" && (
<> <>
{/* Last Session */} {/* Last Session */}
{lastSession && ( {lastSession && (
<div className="last-session-section"> <div className="last-session-section">
<h2>Last Session</h2> <h2>Last Session</h2>
<SessionCard <SessionCard
session={lastSession} session={lastSession}
onOpen={handleOpen} onOpen={handleOpen}
onDelete={handleDelete} onDelete={handleDelete}
isBusy={loadingSessionId === lastSession.id} isBusy={loadingSessionId === lastSession.id}
tunnelHealth={tunnelHealth[lastSession.id] || null} tunnelHealth={tunnelHealth[lastSession.id] || null}
/> />
</div> </div>
)} )}
{/* Session List */} {/* Session List */}
<div className="sessions-list-wrapper"> <div className="sessions-list-wrapper">
<SessionList <SessionList
sessions={sessions} sessions={sessions}
onOpen={handleOpen} onOpen={handleOpen}
onStart={handleStart} onStart={handleStart}
onStop={handleStop} onStop={handleStop}
onDelete={handleDelete} onDelete={handleDelete}
onRecreateTunnel={handleRecreateTunnel} onRecreateTunnel={handleRecreateTunnel}
actionBusyId={loadingSessionId} actionBusyId={loadingSessionId}
tunnelHealth={tunnelHealth} tunnelHealth={tunnelHealth}
/> />
</div> </div>
{/* Create Session */} {/* Create Session */}
<div className="create-session-section"> <div className="create-session-section">
<h2>Create New Session</h2> <h2>Create New Session</h2>
<CreateSessionForm <CreateSessionForm
projects={projects} projects={projects}
repositories={repositories} repositories={repositories}
toolTypes={toolTypes} toolTypes={toolTypes}
onProjectChange={(projectId) => { onProjectChange={(projectId) => {
setSelectedProject(projectId); setSelectedProject(projectId);
}} }}
onSuccess={handleCreateSuccess} onSuccess={handleCreateSuccess}
/> />
</div> </div>
{/* Dirty Delete Confirmation Modal */} {/* Dirty Delete Confirmation Modal */}
{dirtyDeleteSession && ( {dirtyDeleteSession && (
<div className="modal-overlay" onClick={clearDirtyDelete}> <div className="modal-overlay" onClick={clearDirtyDelete}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}> <div
<h3>Uncommitted Changes</h3> className="modal-content"
<p> onClick={(e) => e.stopPropagation()}
The repository <strong>{dirtyDeleteSession.repository_name}</strong> has >
uncommitted changes. Deleting this session will permanently lose these <h3>Uncommitted Changes</h3>
changes. <p>
</p> The repository{" "}
<div className="changed-files-list"> <strong>{dirtyDeleteSession.repository_name}</strong> has
<h4>Changed files:</h4> uncommitted changes. Deleting this session will permanently
<ul> lose these changes.
{dirtyDeleteFiles.map((file, idx) => ( </p>
<li key={idx}>{file}</li> <div className="changed-files-list">
))} <h4>Changed files:</h4>
</ul> <ul>
</div> {dirtyDeleteFiles.map((file, idx) => (
<div className="modal-actions"> <li key={idx}>{file}</li>
<button ))}
className="secondary-button" </ul>
onClick={clearDirtyDelete} </div>
type="button" <div className="modal-actions">
> <button
Cancel className="secondary-button"
</button> onClick={clearDirtyDelete}
<button type="button"
className="danger-button" >
onClick={() => void handleForceDelete(dirtyDeleteSession)} Cancel
type="button" </button>
> <button
Force Delete className="danger-button"
</button> onClick={() => void handleForceDelete(dirtyDeleteSession)}
</div> type="button"
</div> >
</div> Force Delete
)} </button>
</> </div>
)} </div>
</section> </div>
); )}
</>
)}
</section>
);
}; };
+7 -2
View File
@@ -100,7 +100,10 @@ function TabBar({
role="tab" role="tab"
aria-selected={active === tab.id} 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} {tab.label}
</button> </button>
))} ))}
@@ -132,7 +135,9 @@ function MobileTabBar({
role="tab" role="tab"
aria-selected={active === tab.id} 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> <span>{tab.label}</span>
</button> </button>
))} ))}
+206 -35
View File
@@ -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 { Icon } from "../components/icon";
import { useWorkspaces } from "../hooks/use-workspaces"; import { useWorkspaces } from "../hooks/use-workspaces";
import { useWorkspaceActions } from "../hooks/use-workspace-actions"; import { useWorkspaceActions } from "../hooks/use-workspace-actions";
import { WorkspaceCard } from "../components/workspace-card"; import { WorkspaceCard } from "../components/workspace-card";
import { WorkspaceCreateForm } from "../components/workspace-create-form";
import { StartToolModal } from "../components/start-tool-modal"; import { StartToolModal } from "../components/start-tool-modal";
import { createInstance, startInstance } from "../api/sessions"; 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 { Workspace } from "../types/workspace";
import type { ProjectWithRepos } from "../types";
import type { GitRepository } from "../api/git_repositories";
export function WorkspacesPage() { export function WorkspacesPage() {
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [startWorkspace, setStartWorkspace] = useState<Workspace | null>(null); const [startWorkspace, setStartWorkspace] = useState<Workspace | null>(null);
const [createTarget, setCreateTarget] = useState<{
projectId: string;
repoId: string;
} | null>(null);
const { workspaces, loading, error, refresh } = useWorkspaces(); const { workspaces, loading, error, refresh } = useWorkspaces();
const actions = useWorkspaceActions(); 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) => { const handleDelete = async (workspace: Workspace) => {
await actions.delete( await actions.delete(
workspace.project_id, workspace.project_id,
@@ -92,18 +84,7 @@ export function WorkspacesPage() {
</button> </button>
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={() => { onClick={() => setShowCreate(true)}
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.");
}
}}
> >
<Icon name="add" size="sm" /> New Workspace <Icon name="add" size="sm" /> New Workspace
</button> </button>
@@ -112,15 +93,13 @@ export function WorkspacesPage() {
{error && <div className="alert alert-error">{error}</div>} {error && <div className="alert alert-error">{error}</div>}
{showCreate && createTarget && ( {showCreate && (
<WorkspaceCreateForm <WorkspaceCreateInline
projectId={createTarget.projectId} onCreated={() => {
repoId={createTarget.repoId}
onSubmit={handleCreate}
onCancel={() => {
setShowCreate(false); setShowCreate(false);
setCreateTarget(null); refresh();
}} }}
onCancel={() => setShowCreate(false)}
/> />
)} )}
@@ -129,7 +108,12 @@ export function WorkspacesPage() {
) : workspaces.length === 0 ? ( ) : workspaces.length === 0 ? (
<div className="empty-state"> <div className="empty-state">
<p>No workspaces yet.</p> <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>
) : ( ) : (
<div className="workspaces-grid"> <div className="workspaces-grid">
@@ -156,3 +140,190 @@ export function WorkspacesPage() {
</div> </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>
);
}
+4 -1
View File
@@ -58,7 +58,10 @@ export const AppRouter = () => {
</Route> </Route>
<Route path="sessions" element={<SessionsPage />} /> <Route path="sessions" element={<SessionsPage />} />
<Route path="workspaces" element={<WorkspacesPage />} /> <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="tool-workshop" element={<ToolWorkshopPage />} />
<Route <Route
path="instances/:instanceId/terminal" path="instances/:instanceId/terminal"
+49
View File
@@ -5454,3 +5454,52 @@ a:active,
.workspace-chip .ws-actions button.danger-text:hover { .workspace-chip .ws-actions button.danger-text:hover {
color: var(--danger); 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
View File
@@ -1,43 +1,43 @@
export type SessionUser = { export type SessionUser = {
id: string; id: string;
email: string; email: string;
name: string; name: string;
avatar_url: string | null; avatar_url: string | null;
}; };
export type SessionPayload = { export type SessionPayload = {
user: SessionUser; user: SessionUser;
}; };
export type Project = { export type Project = {
id: string; id: string;
name: string; name: string;
description: string | null; description: string | null;
owner_id: string; owner_id: string;
default_ssh_key_id: string | null; default_ssh_key_id: string | null;
}; };
export type WorkspaceSummary = { export type WorkspaceSummary = {
id: string; id: string;
name: string; name: string;
branch: string; branch: string;
status: string; status: string;
instance_count: number; instance_count: number;
}; };
export type RepositorySummary = { export type RepositorySummary = {
id: string; id: string;
name: string; name: string;
remote_url: string; remote_url: string;
workspaces: WorkspaceSummary[]; workspaces: WorkspaceSummary[];
}; };
export type ProjectWithRepos = { export type ProjectWithRepos = {
id: string; id: string;
name: string; name: string;
description: string | null; description: string | null;
owner_id: string; owner_id: string;
default_ssh_key_id: string | null; default_ssh_key_id: string | null;
repositories: RepositorySummary[]; repositories: RepositorySummary[];
created_at: string; created_at: string;
}; };