diff --git a/apps/web/src/api/projects.ts b/apps/web/src/api/projects.ts index 1252dce..19ee39e 100644 --- a/apps/web/src/api/projects.ts +++ b/apps/web/src/api/projects.ts @@ -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 => { - const response = await apiClient.get("/projects"); - return response.data; + const response = await apiClient.get("/projects"); + return response.data; }; export const createProject = async ( - input: ProjectCreateInput + input: ProjectCreateInput, ): Promise => { - const response = await apiClient.post("/projects", input); - return response.data; + const response = await apiClient.post("/projects", input); + return response.data; }; export const updateProject = async ( - projectId: string, - input: ProjectUpdateInput + projectId: string, + input: ProjectUpdateInput, ): Promise => { - const response = await apiClient.patch(`/projects/${projectId}`, input); - return response.data; + const response = await apiClient.patch( + `/projects/${projectId}`, + input, + ); + return response.data; }; export const deleteProject = async (projectId: string): Promise => { - await apiClient.delete(`/projects/${projectId}`); + await apiClient.delete(`/projects/${projectId}`); }; export const setDefaultSSHKey = async ( - projectId: string, - input: SetDefaultSSHKeyInput + projectId: string, + input: SetDefaultSSHKeyInput, ): Promise => { - const response = await apiClient.patch( - `/projects/${projectId}/default-ssh-key`, - input - ); - return response.data; + const response = await apiClient.patch( + `/projects/${projectId}/default-ssh-key`, + input, + ); + return response.data; }; diff --git a/apps/web/src/api/workspaces.ts b/apps/web/src/api/workspaces.ts index b452132..9636b7c 100644 --- a/apps/web/src/api/workspaces.ts +++ b/apps/web/src/api/workspaces.ts @@ -39,6 +39,13 @@ export async function createWorkspace( return response.data; } +export async function createWorkspaceTopLevel( + data: CreateWorkspaceRequest & { repo_id: string }, +): Promise { + const response = await apiClient.post("/workspaces/", data); + return response.data; +} + export async function getWorkspace( projectId: string, repoId: string, diff --git a/apps/web/src/components/workspace-card.tsx b/apps/web/src/components/workspace-card.tsx index 4e92079..47ebed4 100644 --- a/apps/web/src/components/workspace-card.tsx +++ b/apps/web/src/components/workspace-card.tsx @@ -28,7 +28,10 @@ export function WorkspaceCard({ return (
- +

{workspace.name}

diff --git a/apps/web/src/pages/config-profiles.tsx b/apps/web/src/pages/config-profiles.tsx index ac7e33f..09e0ca6 100644 --- a/apps/web/src/pages/config-profiles.tsx +++ b/apps/web/src/pages/config-profiles.tsx @@ -1539,7 +1539,6 @@ export const ConfigProfilesPage = () => { onChange={(git_mounts) => updateFormField("git_mounts", git_mounts) } - />
diff --git a/apps/web/src/pages/dashboard.tsx b/apps/web/src/pages/dashboard.tsx index 59e2ea0..b511002 100644 --- a/apps/web/src/pages/dashboard.tsx +++ b/apps/web/src/pages/dashboard.tsx @@ -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("loading"); - const [summary, setSummary] = useState(null); - const [sessions, setSessions] = useState([]); - const [projects, setProjects] = useState([]); - const [repositories, setRepositories] = useState([]); - const [toolTypes, setToolTypes] = useState([]); - const [selectedProject, setSelectedProject] = useState(""); - const [tunnelHealth, setTunnelHealth] = useState>({}); - const safeSessions = Array.isArray(sessions) ? sessions : []; + const navigate = useNavigate(); + const [status, setStatus] = useState("loading"); + const [summary, setSummary] = useState(null); + const [sessions, setSessions] = useState([]); + const [projects, setProjects] = useState([]); + const [repositories, setRepositories] = useState([]); + const [toolTypes, setToolTypes] = useState([]); + const [selectedProject, setSelectedProject] = useState(""); + const [tunnelHealth, setTunnelHealth] = useState< + Record + >({}); + 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 ( -
-
-
-

Workspace overview

-

Home

-

Open sessions, available projects, and the fastest path back into work.

-
-
- - -
-
+ return ( +
+
+
+

Workspace overview

+

Home

+

+ Open sessions, available projects, and the fastest path back into + work. +

+
+
+ + +
+
- {status === "loading" && } + {status === "loading" && } - {status === "error" && void loadHome()} />} + {status === "error" && ( + void loadHome()} + /> + )} - {status === "ready" && summary && ( - <> -
- {summaryCards.map((card) => ( -
-

{card.label}

-

- {card.key === "openSessions" - ? activeSessions.length - : card.key === "projects" - ? summary.projects - : summary.repositories} -

-
- ))} -
+ {status === "ready" && summary && ( + <> +
+ {summaryCards.map((card) => ( +
+

{card.label}

+

+ {card.key === "openSessions" + ? activeSessions.length + : card.key === "projects" + ? summary.projects + : summary.repositories} +

+
+ ))} +
-
-
-
-

Open sessions

-

{safeSessions.filter((s) => ["running", "building", "pending", "starting", "probing", "unhealthy"].includes(s.status)).length}

-
-
- -
+
+
+
+

Open sessions

+

+ { + safeSessions.filter((s) => + [ + "running", + "building", + "pending", + "starting", + "probing", + "unhealthy", + ].includes(s.status), + ).length + } +

+
+
+ +
-
-
-
-

Available projects

-

{projects.length}

-
- -
- {projects.length === 0 ? ( - - ) : ( -
- {projects.map((project) => ( -
-
-

{project.name}

- {project.description &&

{project.description}

} -
- -
- ))} -
- )} -
+
+
+
+

Available projects

+

{projects.length}

+
+ +
+ {projects.length === 0 ? ( + + ) : ( +
+ {projects.map((project) => ( +
+
+

{project.name}

+ {project.description && ( +

{project.description}

+ )} +
+ +
+ ))} +
+ )} +
-
-
-
-

Quick create

-

Start a session

-
-
- setSelectedProject(projectId)} - onSuccess={handleCreateSuccess} - /> -
+
+
+
+

Quick create

+

Start a session

+
+
+ setSelectedProject(projectId)} + onSuccess={handleCreateSuccess} + /> +
- {safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length > 0 && ( -
-
-
-

Recent sessions

-

{safeSessions.filter((s) => ["stopped", "error"].includes(s.status)).length}

-
-
- -
- )} - - )} -
- ); + {safeSessions.filter((s) => ["stopped", "error"].includes(s.status)) + .length > 0 && ( +
+
+
+

Recent sessions

+

+ { + safeSessions.filter((s) => + ["stopped", "error"].includes(s.status), + ).length + } +

+
+
+ +
+ )} + + )} +
+ ); }; export { HomePage as DashboardPage }; diff --git a/apps/web/src/pages/projects.tsx b/apps/web/src/pages/projects.tsx index 92df8f0..622eb35 100644 --- a/apps/web/src/pages/projects.tsx +++ b/apps/web/src/pages/projects.tsx @@ -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( - listProjects, - [], - ); + const { + data: projects, + status, + reload, + } = useAsyncData(listProjects, []); const [dialogMode, setDialogMode] = useState("none"); const [editingProject, setEditingProject] = useState( 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; + onSubmitCreate: ( + repoId: string, + data: { name: string; branch: string }, + ) => Promise; }) { return (
@@ -328,10 +332,7 @@ function ProjectCard({ type="button" aria-expanded={expanded} > - +

{project.name}

{project.repositories.length > 0 && ( @@ -400,9 +401,7 @@ function ProjectCard({ - onSubmitCreate(repo.id, data) - } + onSubmit={(data) => onSubmitCreate(repo.id, data)} onCancel={onCancelCreate} /> )} @@ -428,15 +427,9 @@ function ProjectCard({
- -
- - - )} - - )} - - ); + {/* Dirty Delete Confirmation Modal */} + {dirtyDeleteSession && ( +
+
e.stopPropagation()} + > +

Uncommitted Changes

+

+ The repository{" "} + {dirtyDeleteSession.repository_name} has + uncommitted changes. Deleting this session will permanently + lose these changes. +

+
+

Changed files:

+
    + {dirtyDeleteFiles.map((file, idx) => ( +
  • {file}
  • + ))} +
+
+
+ + +
+
+
+ )} + + )} + + ); }; diff --git a/apps/web/src/pages/workspace-detail.tsx b/apps/web/src/pages/workspace-detail.tsx index f3ce712..a40a95f 100644 --- a/apps/web/src/pages/workspace-detail.tsx +++ b/apps/web/src/pages/workspace-detail.tsx @@ -100,7 +100,10 @@ function TabBar({ role="tab" aria-selected={active === tab.id} > - + {tab.label} ))} @@ -132,7 +135,9 @@ function MobileTabBar({ role="tab" aria-selected={active === tab.id} > - + {tab.label} ))} diff --git a/apps/web/src/pages/workspaces.tsx b/apps/web/src/pages/workspaces.tsx index 7a9f8cc..df2e261 100644 --- a/apps/web/src/pages/workspaces.tsx +++ b/apps/web/src/pages/workspaces.tsx @@ -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(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() { @@ -112,15 +93,13 @@ export function WorkspacesPage() { {error &&
{error}
} - {showCreate && createTarget && ( - { + {showCreate && ( + { setShowCreate(false); - setCreateTarget(null); + refresh(); }} + onCancel={() => setShowCreate(false)} /> )} @@ -129,7 +108,12 @@ export function WorkspacesPage() { ) : workspaces.length === 0 ? (

No workspaces yet.

-

Navigate to a project to create your first workspace.

+
) : (
@@ -156,3 +140,190 @@ export function WorkspacesPage() {
); } + +/* ─── Inline Workspace Creation Form ─── */ + +function WorkspaceCreateInline({ + onCreated, + onCancel, +}: { + onCreated: () => void; + onCancel: () => void; +}) { + const [projects, setProjects] = useState([]); + const [repos, setRepos] = useState([]); + 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(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 ( +
+

Loading projects...

+
+ ); + } + + return ( +
+

+ Create Workspace +

+
+
+ + +
+ +
+ + +
+ +
+ + setName(e.target.value)} + placeholder="e.g., feature-branch" + required + /> +
+ +
+ + setBranch(e.target.value)} + placeholder="main" + /> +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index dd393fa..dde0bfa 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -58,7 +58,10 @@ export const AppRouter = () => { } /> } /> - } /> + } + /> } />