From f8b162ec5f656f0683da8d960c6bf93491383728 Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 13 Jun 2026 10:10:28 +0000 Subject: [PATCH] feat: busy container overlays for lifecycle actions Add a reusable LoadingOverlay component that dims and disables the container owning an in-flight action, with a spinning indicator and label. Apply it to: - SessionCard (when actionBusyId matches) - InstanceList cards (per busyInstanceId with action-specific labels) - CreateSessionForm (while submitting) - ToolStarter (while starting) Also add .icon-spin animation and position:relative to the relevant containers. Quality gates: npm run typecheck, npm run lint, npm test -- --run (87 passed). --- .../features/session/create-session-form.tsx | 2 + .../features/session/session-card.tsx | 4 ++ .../features/tool/instance-list.tsx | 10 +++++ .../components/features/tool/tool-starter.tsx | 2 + apps/web/src/components/loading-overlay.tsx | 21 +++++++++ apps/web/src/styles/utilities.css | 45 +++++++++++++++++++ 6 files changed, 84 insertions(+) create mode 100644 apps/web/src/components/loading-overlay.tsx diff --git a/apps/web/src/components/features/session/create-session-form.tsx b/apps/web/src/components/features/session/create-session-form.tsx index d32b1c0..98fac07 100644 --- a/apps/web/src/components/features/session/create-session-form.tsx +++ b/apps/web/src/components/features/session/create-session-form.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; import { Icon } from "../../icon"; +import { LoadingOverlay } from "../../loading-overlay"; import { createInstance, startInstance, @@ -215,6 +216,7 @@ export const CreateSessionForm = ({ return (
+
{/* Project */}
diff --git a/apps/web/src/components/features/session/session-card.tsx b/apps/web/src/components/features/session/session-card.tsx index 81856b1..274ad78 100644 --- a/apps/web/src/components/features/session/session-card.tsx +++ b/apps/web/src/components/features/session/session-card.tsx @@ -3,6 +3,7 @@ import type { Session } from "../../../api/sessions"; import { Icon } from "../../icon"; import { useMobileViewport } from "../../../hooks/use-mobile-viewport"; import { MobileActionSheet } from "../mobile/mobile-action-sheet"; +import { LoadingOverlay } from "../../loading-overlay"; import type { IconName } from "../../icon"; export interface SessionCardProps { @@ -14,6 +15,7 @@ export interface SessionCardProps { onRecreateTunnel?: (session: Session) => void; onRename?: (session: Session, newName: string) => void; isBusy?: boolean; + busyLabel?: string; tunnelHealth?: { healthy: boolean; container_status: string; @@ -46,6 +48,7 @@ export function SessionCard({ onRecreateTunnel, onRename, isBusy = false, + busyLabel = "Working...", tunnelHealth = null, }: SessionCardProps) { const [showActionSheet, setShowActionSheet] = useState(false); @@ -106,6 +109,7 @@ export function SessionCard({ return (
+
diff --git a/apps/web/src/components/features/tool/instance-list.tsx b/apps/web/src/components/features/tool/instance-list.tsx index 7496d37..de5b878 100644 --- a/apps/web/src/components/features/tool/instance-list.tsx +++ b/apps/web/src/components/features/tool/instance-list.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Icon } from "../../icon"; +import { LoadingOverlay } from "../../loading-overlay"; import type { ToolInstance } from "../../../api/sessions"; import { deleteInstance, @@ -60,6 +61,7 @@ export const InstanceList = ({ // Per-instance busy state for actions const [busyInstanceId, setBusyInstanceId] = useState(null); + const [busyLabel, setBusyLabel] = useState("Working..."); const loadInstances = useCallback(async () => { setLoading(true); @@ -135,6 +137,7 @@ export const InstanceList = ({ sshKeyIds?: string[], ) => { setBusyInstanceId(instanceId); + setBusyLabel("Starting..."); try { await startInstance( projectId, @@ -156,6 +159,7 @@ export const InstanceList = ({ const handleStop = async (instanceId: string) => { setBusyInstanceId(instanceId); + setBusyLabel("Stopping..."); try { await stopInstance(projectId, repoId, instanceId); setStopConfirmId(null); @@ -173,6 +177,7 @@ export const InstanceList = ({ sshKeyIds?: string[], ) => { setBusyInstanceId(instanceId); + setBusyLabel("Restarting..."); try { await restartInstance( projectId, @@ -195,6 +200,7 @@ export const InstanceList = ({ const handleDelete = async (instanceId: string) => { if (!confirm("Are you sure you want to delete this instance?")) return; setBusyInstanceId(instanceId); + setBusyLabel("Deleting..."); try { await deleteInstance(projectId, repoId, instanceId); // Update state immediately instead of reloading @@ -250,6 +256,10 @@ export const InstanceList = ({
{instances.map((instance) => (
+
{instance.display_name}
diff --git a/apps/web/src/components/features/tool/tool-starter.tsx b/apps/web/src/components/features/tool/tool-starter.tsx index 5a20337..7cef793 100644 --- a/apps/web/src/components/features/tool/tool-starter.tsx +++ b/apps/web/src/components/features/tool/tool-starter.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from "react"; import { Icon } from "../../icon"; +import { LoadingOverlay } from "../../loading-overlay"; import { listToolTypes, type ToolType } from "../../../api/tool-types"; import { listConfigProfiles, @@ -172,6 +173,7 @@ export function ToolStarter({ return (
+ {/* Context header — read-only workspace info */}
diff --git a/apps/web/src/components/loading-overlay.tsx b/apps/web/src/components/loading-overlay.tsx new file mode 100644 index 0000000..64edff3 --- /dev/null +++ b/apps/web/src/components/loading-overlay.tsx @@ -0,0 +1,21 @@ +import { Icon } from "./icon"; + +interface LoadingOverlayProps { + visible: boolean; + label?: string; +} + +export function LoadingOverlay({ visible, label }: LoadingOverlayProps) { + if (!visible) return null; + + return ( +
+
+ + {label && ( + {label} + )} +
+
+ ); +} diff --git a/apps/web/src/styles/utilities.css b/apps/web/src/styles/utilities.css index a6a6922..18ed2e0 100644 --- a/apps/web/src/styles/utilities.css +++ b/apps/web/src/styles/utilities.css @@ -383,6 +383,51 @@ a.nav-item, align-items: center; } +/* ============================================ + Loading Overlay + ============================================ */ + +.loading-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.12); + backdrop-filter: blur(1px); + border-radius: inherit; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.loading-overlay-content { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); + color: var(--text, currentColor); +} + +.loading-overlay-label { + font-size: var(--text-sm); + font-weight: 500; +} + +.icon-spin { + animation: icon-spin 1s linear infinite; +} + +@keyframes icon-spin { + to { + transform: rotate(360deg); + } +} + +.session-card, +.tool-starter, +.create-session-form-wrapper { + position: relative; +} + /* ============================================ Terminal Styles ============================================ */