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).
This commit is contained in:
Developer
2026-06-13 10:10:28 +00:00
parent 8a8a9bc9e4
commit f8b162ec5f
6 changed files with 84 additions and 0 deletions
@@ -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 (
<div className={`create-session-form-wrapper ${className}`}>
<LoadingOverlay visible={isSubmitting} label="Creating session..." />
<form onSubmit={handleSubmit} className="stack create-session-form">
{/* Project */}
<div className="form-field">
@@ -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 (
<article className="card session-card">
<LoadingOverlay visible={isBusy} label={busyLabel} />
<div className="session-card-content">
<div className="session-card-header">
<div className="session-card-title">
@@ -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<string | null>(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 = ({
<div className="instance-grid">
{instances.map((instance) => (
<div key={instance.id} className="instance-card">
<LoadingOverlay
visible={busyInstanceId === instance.id}
label={busyLabel}
/>
<div className="instance-info">
<div className="instance-name">{instance.display_name}</div>
<div className="instance-meta">
@@ -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 (
<div className="tool-starter">
<LoadingOverlay visible={starting} label="Starting tool..." />
{/* Context header — read-only workspace info */}
<div className="tool-starter-context">
<div className="context-row">
@@ -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 (
<div className="loading-overlay" aria-live="polite">
<div className="loading-overlay-content">
<Icon name="loading" size="lg" className="icon-spin" ariaLabel={label} />
{label && (
<span className="loading-overlay-label">{label}</span>
)}
</div>
</div>
);
}