diff --git a/apps/web/src/components/data-states.tsx b/apps/web/src/components/data-states.tsx new file mode 100644 index 0000000..14bfad3 --- /dev/null +++ b/apps/web/src/components/data-states.tsx @@ -0,0 +1,34 @@ +import { Icon } from "./icon"; + +interface LoadingStateProps { + message?: string; +} + +export const LoadingState = ({ message = "Loading..." }: LoadingStateProps) => ( +

{message}

+); + +interface ErrorStateProps { + message?: string; + onRetry?: () => void; +} + +export const ErrorState = ({ message = "Failed to load", onRetry }: ErrorStateProps) => ( +
+

{message}

+ {onRetry && ( + + )} +
+); + +interface EmptyStateProps { + message: string; +} + +export const EmptyState = ({ message }: EmptyStateProps) => ( +

{message}

+); diff --git a/apps/web/src/hooks/use-instance-actions.ts b/apps/web/src/hooks/use-instance-actions.ts new file mode 100644 index 0000000..066c5a0 --- /dev/null +++ b/apps/web/src/hooks/use-instance-actions.ts @@ -0,0 +1,158 @@ +import { useState, useCallback } from "react"; +import { + stopInstance, + deleteInstance, + startInstance, + recreateInstanceTunnel, +} from "../api/sessions"; +import type { Session } from "../api/sessions"; + +interface UseInstanceActionsOptions { + onRefresh: () => Promise; +} + +interface UseInstanceActionsReturn { + loadingSessionId: string | null; + dirtyDeleteSession: Session | null; + dirtyDeleteFiles: string[]; + handleOpen: (session: Session) => void; + handleStart: (session: Session) => Promise; + handleStop: (session: Session) => Promise; + handleDelete: (session: Session) => Promise; + handleForceDelete: (session: Session) => Promise; + handleRecreateTunnel: (session: Session) => Promise; + clearDirtyDelete: () => void; +} + +export function useInstanceActions( + options: UseInstanceActionsOptions +): UseInstanceActionsReturn { + const { onRefresh } = options; + const [loadingSessionId, setLoadingSessionId] = useState(null); + const [dirtyDeleteSession, setDirtyDeleteSession] = useState(null); + const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState([]); + + const handleOpen = useCallback((session: Session) => { + if (session.url) { + window.open(session.url, "_blank", "noopener,noreferrer"); + return; + } + if (session.tool_type_interfaces?.includes("terminal")) { + window.location.href = `/instances/${session.id}/terminal`; + return; + } + window.location.href = `/projects/${session.project_id}`; + }, []); + + const handleStart = useCallback( + async (session: Session) => { + if (loadingSessionId === session.id) return; + setLoadingSessionId(session.id); + try { + await startInstance(session.project_id, session.repository_id, session.id); + await onRefresh(); + } catch { + // ignore + } finally { + setLoadingSessionId(null); + } + }, + [loadingSessionId, onRefresh] + ); + + const handleStop = useCallback( + async (session: Session) => { + if (loadingSessionId === session.id) return; + setLoadingSessionId(session.id); + try { + await stopInstance(session.project_id, session.repository_id, session.id); + await onRefresh(); + } catch { + // ignore + } finally { + setLoadingSessionId(null); + } + }, + [loadingSessionId, onRefresh] + ); + + const handleDelete = useCallback( + async (session: Session) => { + if (loadingSessionId === session.id) return; + setLoadingSessionId(session.id); + try { + await deleteInstance(session.project_id, session.repository_id, session.id); + setDirtyDeleteSession(null); + setDirtyDeleteFiles([]); + await onRefresh(); + } catch (error) { + const axiosError = error as { + response?: { status?: number; data?: { detail?: { changed_files?: string[] } } }; + }; + if (axiosError.response?.status === 409) { + const detail = axiosError.response.data?.detail; + if (detail?.changed_files) { + setDirtyDeleteSession(session); + setDirtyDeleteFiles(detail.changed_files); + return; + } + } + } finally { + setLoadingSessionId(null); + } + }, + [loadingSessionId, onRefresh] + ); + + const handleForceDelete = useCallback( + async (session: Session) => { + if (loadingSessionId === session.id) return; + setLoadingSessionId(session.id); + try { + await deleteInstance(session.project_id, session.repository_id, session.id, true); + setDirtyDeleteSession(null); + setDirtyDeleteFiles([]); + await onRefresh(); + } catch { + // ignore + } finally { + setLoadingSessionId(null); + } + }, + [loadingSessionId, onRefresh] + ); + + const handleRecreateTunnel = useCallback( + async (session: Session) => { + if (loadingSessionId === session.id) return; + setLoadingSessionId(session.id); + try { + await recreateInstanceTunnel(session.project_id, session.repository_id, session.id); + await onRefresh(); + } catch { + // ignore + } finally { + setLoadingSessionId(null); + } + }, + [loadingSessionId, onRefresh] + ); + + const clearDirtyDelete = useCallback(() => { + setDirtyDeleteSession(null); + setDirtyDeleteFiles([]); + }, []); + + return { + loadingSessionId, + dirtyDeleteSession, + dirtyDeleteFiles, + handleOpen, + handleStart, + handleStop, + handleDelete, + handleForceDelete, + handleRecreateTunnel, + clearDirtyDelete, + }; +} diff --git a/apps/web/src/pages/config-profiles.tsx b/apps/web/src/pages/config-profiles.tsx index 119801d..0e06106 100644 --- a/apps/web/src/pages/config-profiles.tsx +++ b/apps/web/src/pages/config-profiles.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from "react"; +import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; import { Icon } from "../components/icon"; import { useMobileViewport } from "../hooks/use-mobile-viewport"; import { extractErrorMessage } from "../utils/errors"; @@ -408,7 +409,7 @@ export const ConfigProfilesPage = () => { if (status === "loading") { return (
-

Loading Config Profiles...

+
); } @@ -416,10 +417,7 @@ export const ConfigProfilesPage = () => { if (status === "error") { return (
-

Failed to load Config Profiles.

- +
); } diff --git a/apps/web/src/pages/dashboard.tsx b/apps/web/src/pages/dashboard.tsx index 1b6d99e..5103f86 100644 --- a/apps/web/src/pages/dashboard.tsx +++ b/apps/web/src/pages/dashboard.tsx @@ -2,15 +2,17 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { getDashboardSummary, type DashboardSummary } from "../api/dashboard"; -import { getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, 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 { Project } from "../types"; +import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; import { Icon } from "../components/icon"; import { CreateSessionForm } from "../components/create-session-form"; import { SessionList } from "../components/session-list"; +import { useInstanceActions } from "../hooks/use-instance-actions"; type HomeStatus = "loading" | "ready" | "error"; @@ -31,7 +33,6 @@ export const HomePage = () => { const [repositories, setRepositories] = useState([]); const [toolTypes, setToolTypes] = useState([]); const [selectedProject, setSelectedProject] = useState(""); - const [actionBusy, setActionBusy] = useState(null); const [tunnelHealth, setTunnelHealth] = useState>({}); const safeSessions = Array.isArray(sessions) ? sessions : []; @@ -58,6 +59,15 @@ export const HomePage = () => { void loadHome(); }, [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 () => { @@ -130,64 +140,6 @@ export const HomePage = () => { await loadHome(); }; - const handleOpen = (session: SessionView) => { - if (session.url) { - window.open(session.url, "_blank", "noopener,noreferrer"); - return; - } - if (session.tool_type_interfaces.includes("terminal")) { - navigate(`/instances/${session.id}/terminal`); - return; - } - navigate(`/projects/${session.project_id}`); - }; - - const handleStop = async (session: SessionView) => { - if (actionBusy === session.id) return; - setActionBusy(session.id); - try { - await stopInstance(session.project_id, session.repository_id, session.id); - await loadHome(); - } finally { - setActionBusy(null); - } - }; - - const handleDelete = async (session: SessionView) => { - if (actionBusy === session.id) return; - setActionBusy(session.id); - try { - await deleteInstance(session.project_id, session.repository_id, session.id); - setSessions((prev) => prev.filter((s) => s.id !== session.id)); - } catch { - // error - session remains in state - } finally { - setActionBusy(null); - } - }; - - const handleRecreateTunnel = async (session: SessionView) => { - if (actionBusy === session.id) return; - setActionBusy(session.id); - try { - await recreateInstanceTunnel(session.project_id, session.repository_id, session.id); - await loadHome(); - } finally { - setActionBusy(null); - } - }; - - const handleStart = async (session: SessionView) => { - if (actionBusy === session.id) return; - setActionBusy(session.id); - try { - await startInstance(session.project_id, session.repository_id, session.id); - await loadHome(); - } finally { - setActionBusy(null); - } - }; - return (
@@ -202,17 +154,9 @@ export const HomePage = () => {
- {status === "loading" &&

Loading overview...

} + {status === "loading" && } - {status === "error" && ( -
-

Unable to load your workspace overview.

- -
- )} + {status === "error" && void loadHome()} />} {status === "ready" && summary && ( <> @@ -260,7 +204,7 @@ export const HomePage = () => { {projects.length === 0 ? ( -

No projects yet.

+ ) : (
{projects.map((project) => ( diff --git a/apps/web/src/pages/git-history.tsx b/apps/web/src/pages/git-history.tsx index beb27ec..33113a3 100644 --- a/apps/web/src/pages/git-history.tsx +++ b/apps/web/src/pages/git-history.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryEntry, type CommitHistoryResponse } from "../api/git_repositories"; +import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; import { Icon } from "../components/icon"; import { useAsyncData } from "../hooks/use-async-data"; @@ -54,7 +55,7 @@ export const GitHistoryPage = () => { if (status === "loading") { return (
-

Loading commit history...

+
); } @@ -62,11 +63,7 @@ export const GitHistoryPage = () => { if (status === "error") { return (
-

Failed to load commit history

- +
); } diff --git a/apps/web/src/pages/git-repositories.tsx b/apps/web/src/pages/git-repositories.tsx index 5f2c195..7be70d0 100644 --- a/apps/web/src/pages/git-repositories.tsx +++ b/apps/web/src/pages/git-repositories.tsx @@ -6,6 +6,7 @@ import { listRepositories, } from "../api/git_repositories"; import type { GitRepository } from "../api/git_repositories"; +import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; import { Icon } from "../components/icon"; import { RepositoryCreateDialog } from "../components/repository-create-dialog"; import { useAsyncData } from "../hooks/use-async-data"; @@ -48,19 +49,11 @@ export const GitRepositoriesPage = () => {
- {status === "loading" &&

Loading repositories...

} + {status === "loading" && } - {status === "error" && ( -
-

Failed to load repositories

- -
- )} + {status === "error" && } - {isEmpty &&

No repositories yet. Create your first repository above.

} + {isEmpty && } {status === "ready" && safeRepositories.length > 0 && (
diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index 3f48ccd..a32bd9b 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { getProfile, updateProfile, uploadAvatar } from "../api/profile"; +import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; import { Icon } from "../components/icon"; import { useAuth } from "../state/auth"; import { useAsyncData } from "../hooks/use-async-data"; @@ -91,17 +92,9 @@ export const ProfilePage = () => {

Profile

- {displayStatus === "loading" &&

Loading profile...

} + {displayStatus === "loading" && } - {displayStatus === "error" && ( -
-

Failed to load profile

- -
- )} + {displayStatus === "error" && } {(displayStatus === "ready" || displayStatus === "saving") && profile && (
diff --git a/apps/web/src/pages/projects.tsx b/apps/web/src/pages/projects.tsx index 1420ae3..c1c0d71 100644 --- a/apps/web/src/pages/projects.tsx +++ b/apps/web/src/pages/projects.tsx @@ -10,6 +10,7 @@ import { type ProjectCreateInput, type ProjectUpdateInput, } from "../api/projects"; +import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; import { Icon } from "../components/icon"; import { useAsyncData } from "../hooks/use-async-data"; import type { Project } from "../types"; @@ -101,19 +102,11 @@ export const ProjectsPage = () => {
- {status === "loading" &&

Loading projects...

} + {status === "loading" && } - {status === "error" && ( -
-

Failed to load projects

- -
- )} + {status === "error" && } - {isEmpty &&

No projects yet. Create your first project above.

} + {isEmpty && } {status === "ready" && safeProjects.length > 0 && (
diff --git a/apps/web/src/pages/repo-workspace.tsx b/apps/web/src/pages/repo-workspace.tsx index 68fe8ef..a1027e4 100644 --- a/apps/web/src/pages/repo-workspace.tsx +++ b/apps/web/src/pages/repo-workspace.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from "react"; +import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; import { Icon } from "../components/icon"; import { useMobileViewport } from "../hooks/use-mobile-viewport"; @@ -164,26 +165,16 @@ export const RepoWorkspace = () => { )} {status === "loading" && ( -

Loading repositories...

+ )} {status === "error" && ( -
-

Failed to load repositories

- -
+ void loadRepositories()} /> )} {status === "empty" && (
-

No repositories in this project yet.

+ )} {entries.length === 0 && ( -

No files in this repository yet.

+ )} {entries.map((entry) => { const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null; diff --git a/apps/web/src/pages/sessions.tsx b/apps/web/src/pages/sessions.tsx index 22ac603..e7c0529 100644 --- a/apps/web/src/pages/sessions.tsx +++ b/apps/web/src/pages/sessions.tsx @@ -7,18 +7,15 @@ import { listRepositories, type GitRepository } from "../api/git_repositories"; import { getUserSessions, type Session, - deleteInstance, - stopInstance, - startInstance, checkInstanceHealth, - recreateInstanceTunnel, } from "../api/sessions"; import { listToolTypes, type ToolType } from "../api/tool_types"; import { getUserConfig, updateUserConfig } from "../api/settings"; -import { Icon } from "../components/icon"; +import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; import { CreateSessionForm } from "../components/create-session-form"; import { SessionList } from "../components/session-list"; import { SessionCard } from "../components/session-card"; +import { useInstanceActions } from "../hooks/use-instance-actions"; import type { InstanceHealth } from "../api/sessions"; type SessionsStatus = "loading" | "ready" | "error"; @@ -34,11 +31,7 @@ export const SessionsPage = () => { const [toolTypes, setToolTypes] = useState([]); const [selectedProject, setSelectedProject] = useState(""); - const [dirtyDeleteSession, setDirtyDeleteSession] = useState(null); - const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState([]); - const [tunnelHealth, setTunnelHealth] = useState>({}); - const [loadingSessionId, setLoadingSessionId] = useState(null); const loadSessions = useCallback(async () => { setStatus("loading"); @@ -83,7 +76,18 @@ export const SessionsPage = () => { void loadToolTypes(); }, []); - + 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(() => { @@ -154,116 +158,15 @@ export const SessionsPage = () => { await loadSessions(); }; - const handleStop = async (session: Session) => { - if (loadingSessionId === session.id) return; - setLoadingSessionId(session.id); - try { - await stopInstance(session.project_id, session.repository_id, session.id); - await loadSessions(); - } catch { - // ignore - } finally { - setLoadingSessionId(null); - } - }; - - const handleDelete = async (session: Session) => { - if (loadingSessionId === session.id) return; - setLoadingSessionId(session.id); - try { - await deleteInstance(session.project_id, session.repository_id, session.id); - setDirtyDeleteSession(null); - setDirtyDeleteFiles([]); - // Remove from local state immediately - setSessions((prev) => prev.filter((s) => s.id !== session.id)); - } catch (error) { - const axiosError = error as { response?: { status?: number; data?: { detail?: { changed_files?: string[] } } } }; - if (axiosError.response?.status === 409) { - const detail = axiosError.response.data?.detail; - if (detail?.changed_files) { - setDirtyDeleteSession(session); - setDirtyDeleteFiles(detail.changed_files); - return; - } - } - } finally { - setLoadingSessionId(null); - } - }; - - const handleForceDelete = async (session: Session) => { - if (loadingSessionId === session.id) return; - setLoadingSessionId(session.id); - try { - await deleteInstance(session.project_id, session.repository_id, session.id, true); - setDirtyDeleteSession(null); - setDirtyDeleteFiles([]); - setSessions((prev) => prev.filter((s) => s.id !== session.id)); - } catch { - // ignore - } finally { - setLoadingSessionId(null); - } - }; - - const handleRecreateTunnel = async (session: Session) => { - if (loadingSessionId === session.id) return; - setLoadingSessionId(session.id); - try { - await recreateInstanceTunnel( - session.project_id, - session.repository_id, - session.id - ); - // Refresh sessions to get new URL - await loadSessions(); - } catch { - // ignore - } finally { - setLoadingSessionId(null); - } - }; - - const handleStart = async (session: Session) => { - if (loadingSessionId === session.id) return; - setLoadingSessionId(session.id); - try { - await startInstance(session.project_id, session.repository_id, session.id); - await loadSessions(); - } catch { - // ignore - } finally { - setLoadingSessionId(null); - } - }; - - const handleOpen = (session: Session) => { - if (session.url) { - window.open(session.url, '_blank', 'noopener,noreferrer'); - } else if (session.tool_type_interfaces?.includes("terminal")) { - navigate(`/instances/${session.id}/terminal`); - } else { - navigate(`/projects/${session.project_id}`); - } - }; - return (

Sessions

- {status === "loading" &&

Loading sessions...

} + {status === "loading" && } - {status === "error" && ( -
-

Failed to load sessions

- -
- )} + {status === "error" && void loadSessions()} />} {status === "ready" && ( <> @@ -311,7 +214,7 @@ export const SessionsPage = () => { {/* Dirty Delete Confirmation Modal */} {dirtyDeleteSession && ( -
setDirtyDeleteSession(null)}> +
e.stopPropagation()}>

Uncommitted Changes

@@ -330,7 +233,7 @@ export const SessionsPage = () => {

+
); } diff --git a/apps/web/src/pages/ssh-keys.tsx b/apps/web/src/pages/ssh-keys.tsx index e1a5d0f..5f12e46 100644 --- a/apps/web/src/pages/ssh-keys.tsx +++ b/apps/web/src/pages/ssh-keys.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh_keys"; +import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; import { Icon } from "../components/icon"; import { useAsyncData } from "../hooks/use-async-data"; @@ -87,7 +88,7 @@ export const SSHKeysPage = () => { } } - if (status === "loading") return
Loading...
; + if (status === "loading") return ; return (
@@ -130,19 +131,11 @@ export const SSHKeysPage = () => { - {status === "error" && ( -
-

Failed to load SSH keys

- -
- )} + {status === "error" && }
{safeKeys.length === 0 ? ( -

No SSH keys yet. Generate one above.

+ ) : ( safeKeys.map((key) => (
diff --git a/apps/web/src/pages/tool-workshop.tsx b/apps/web/src/pages/tool-workshop.tsx index 814003b..8cc295c 100644 --- a/apps/web/src/pages/tool-workshop.tsx +++ b/apps/web/src/pages/tool-workshop.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from "react"; +import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; import { Icon } from "../components/icon"; import { useMobileViewport } from "../hooks/use-mobile-viewport"; import { extractErrorMessage } from "../utils/errors"; @@ -491,7 +492,7 @@ export const ToolWorkshopPage = () => { if (status === "loading") { return (
-

Loading Tool Workshop...

+
); } @@ -499,10 +500,7 @@ export const ToolWorkshopPage = () => { if (status === "error") { return (
-

Failed to load Tool Workshop.

- +
); } @@ -1305,7 +1303,7 @@ export const ToolWorkshopPage = () => {
{toolConfigs.length === 0 ? ( -

No configurations for this tool type yet.

+ ) : ( toolConfigs.map((config) => (