feat: implement tool-session progress panel and live list updates

- Add SessionOperationsContext + SessionProgressPanel for global,
  non-blocking lifecycle progress (create/start/stop/restart/delete/
  recreate-tunnel) driven by SSE events.
- Promote SessionsContext to authoritative shared session state with
  refresh, addOrUpdateSession, and removeSession helpers.
- Wire AppShell, DashboardPage, SessionsPage, useInstanceActions,
  ToolStarter, and InstanceList into shared state so lists update
  immediately after create/delete without manual refresh.
- Remove legacy blocking overlays from CreateSessionForm, SessionCard,
  and InstanceList; keep disabled states and inline spinners only.
- Update DashboardPage tests to wrap with SessionsProvider and
  SessionOperationsProvider.
- Add .cache/ to .gitignore.

Quality gates: npm run typecheck, npm run lint, npm test -- --run
(82 passed).
This commit is contained in:
Developer
2026-06-12 13:19:58 +00:00
parent 110844e597
commit 7440720b7b
30 changed files with 1664 additions and 845 deletions
+82 -95
View File
@@ -1,7 +1,5 @@
import { useCallback, useEffect } from "react";
import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
import { getUserSessions } from "../api/sessions";
import type { Session } from "../api/sessions";
import { useTheme } from "../hooks/use-theme";
import { useAuth } from "../state/auth";
@@ -10,8 +8,10 @@ import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { EventProvider } from "../state/events";
import { ToastProvider } from "../state/toast";
import { NotificationProvider } from "../state/notifications";
import { SessionOperationsProvider } from "../state/session-operations";
import { EventToastBridge } from "./features/notification/event-toast-bridge";
import { NotificationCenter } from "./features/notification/notification-center";
import { SessionProgressPanel } from "./features/session/session-progress-panel";
import { Icon } from "./icon";
import { MobileNav } from "./features/mobile/mobile-nav";
import { StartToolFAB } from "./features/tool/start-tool-fab";
@@ -74,7 +74,7 @@ const SessionItem = ({ session }: { session: Session }) => {
export const AppShell = () => {
useTheme();
const { user, logout } = useAuth();
const { sessions, setAllSessions } = useSessions();
const { sessions } = useSessions();
const location = useLocation();
const isMobile = useMobileViewport();
const isMobileTerminal =
@@ -82,33 +82,17 @@ export const AppShell = () => {
location.pathname.includes("/instances/") &&
location.pathname.includes("/terminal");
const loadSessions = useCallback(async () => {
try {
const data = await getUserSessions();
setAllSessions(data);
} catch {
// Silently fail - sessions are optional
}
}, [setAllSessions]);
useEffect(() => {
void loadSessions();
// Poll every 30 seconds (reduced from 10s to avoid ERR_NETWORK_CHANGED from Docker network changes)
const interval = setInterval(() => {
void loadSessions();
}, 30000);
return () => clearInterval(interval);
}, [loadSessions]);
if (isMobileTerminal) {
return (
<EventProvider>
<ToastProvider>
<NotificationProvider>
<EventToastBridge />
<div className="shell mobile-terminal-shell">
<Outlet />
</div>
<SessionOperationsProvider>
<EventToastBridge />
<div className="shell mobile-terminal-shell">
<Outlet />
</div>
</SessionOperationsProvider>
</NotificationProvider>
</ToastProvider>
</EventProvider>
@@ -119,81 +103,84 @@ export const AppShell = () => {
<EventProvider>
<ToastProvider>
<NotificationProvider>
<EventToastBridge />
<div className="shell">
<header className="shell-header">
<Link className="brand" to="/">
Headquarter
</Link>
<div className="header-actions">
<NotificationCenter isMobileTerminal={isMobileTerminal} />
<Link className="user-chip" to="/profile">
{user?.name ?? "User"}
<SessionOperationsProvider>
<EventToastBridge />
<SessionProgressPanel />
<div className="shell">
<header className="shell-header">
<Link className="brand" to="/">
Headquarter
</Link>
<button
className="ghost-button"
onClick={() => {
void logout();
}}
type="button"
>
<Icon name="logout" size="sm" />
Logout
</button>
<div className="header-actions">
<NotificationCenter isMobileTerminal={isMobileTerminal} />
<Link className="user-chip" to="/profile">
{user?.name ?? "User"}
</Link>
<button
className="ghost-button"
onClick={() => {
void logout();
}}
type="button"
>
<Icon name="logout" size="sm" />
Logout
</button>
</div>
</header>
<div className="shell-body">
{!isMobile && (
<aside className="shell-nav" aria-label="Primary navigation">
{NAV_ITEMS.map((item) => {
const activeCount = sessions.filter(
(s) => s.status === "running",
).length;
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
isActive ? "nav-item nav-item-active" : "nav-item"
}
end={item.to === "/"}
>
<Icon name={item.icon} size="sm" />
{item.label}
{item.badge === "sessions" && activeCount > 0 && (
<span className="nav-badge">{activeCount}</span>
)}
</NavLink>
);
})}
{sessions.length > 0 && (
<>
<div className="nav-divider" />
<div className="nav-section-title">Live sessions</div>
{sessions.map((session) => (
<SessionItem key={session.id} session={session} />
))}
</>
)}
</aside>
)}
<main className={`shell-content ${isMobile ? "mobile" : ""}`}>
<Outlet />
</main>
</div>
</header>
<div className="shell-body">
{!isMobile && (
<aside className="shell-nav" aria-label="Primary navigation">
{NAV_ITEMS.map((item) => {
const activeCount = sessions.filter(
(s) => s.status === "running",
).length;
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
isActive ? "nav-item nav-item-active" : "nav-item"
}
end={item.to === "/"}
>
<Icon name={item.icon} size="sm" />
{item.label}
{item.badge === "sessions" && activeCount > 0 && (
<span className="nav-badge">{activeCount}</span>
)}
</NavLink>
);
})}
{sessions.length > 0 && (
<>
<div className="nav-divider" />
<div className="nav-section-title">Live sessions</div>
{sessions.map((session) => (
<SessionItem key={session.id} session={session} />
))}
</>
)}
</aside>
{isMobile && (
<MobileNav
sessionCount={
sessions.filter((s) => s.status === "running").length
}
/>
)}
<main className={`shell-content ${isMobile ? "mobile" : ""}`}>
<Outlet />
</main>
<StartToolFAB />
</div>
{isMobile && (
<MobileNav
sessionCount={
sessions.filter((s) => s.status === "running").length
}
/>
)}
<StartToolFAB />
</div>
</SessionOperationsProvider>
</NotificationProvider>
</ToastProvider>
</EventProvider>
File diff suppressed because it is too large Load Diff
@@ -105,12 +105,7 @@ export function SessionCard({
};
return (
<article className={`card session-card ${isBusy ? "busy" : ""}`}>
{isBusy && (
<div className="session-busy-overlay">
<Icon name="loading" size="md" />
</div>
)}
<article className="card session-card">
<div className="session-card-content">
<div className="session-card-header">
<div className="session-card-title">
@@ -0,0 +1,115 @@
import { useEffect } from "react";
import {
useSessionOperations,
type Operation,
} from "../../../state/session-operations";
import { useEventContext } from "../../../state/events";
import { Icon } from "../../icon";
interface StepConfig {
label: string;
index: number;
}
const steps: StepConfig[] = [
{ label: "Created", index: 1 },
{ label: "Building", index: 2 },
{ label: "Starting", index: 3 },
{ label: "Ready", index: 4 },
];
function OperationItem({
operation,
onDismiss,
}: {
operation: Operation;
onDismiss: () => void;
}) {
const isDone = operation.status === "success" || operation.status === "error";
const isError = operation.status === "error";
return (
<div
className={`session-operation-item ${operation.status}`}
role="status"
aria-live="polite"
>
<div className="session-operation-header">
<div className="session-operation-title">
{isError ? (
<Icon name="error" size="sm" />
) : isDone ? (
<Icon name="success" size="sm" />
) : (
<Icon name="loading" size="sm" />
)}
<span className="session-operation-name">{operation.displayName}</span>
</div>
{isDone && (
<button
type="button"
className="session-operation-dismiss"
onClick={onDismiss}
aria-label="Dismiss"
>
<Icon name="close" size="sm" />
</button>
)}
</div>
<p className="session-operation-message">{operation.message}</p>
<div className="session-operation-steps">
{steps.map((step) => {
const active = operation.step >= step.index;
const current = operation.step === step.index && !isDone;
return (
<span
key={step.label}
className={`session-operation-step ${active ? "active" : ""} ${current ? "current" : ""}`}
>
{step.label}
</span>
);
})}
</div>
</div>
);
}
export function SessionProgressPanel() {
const { operations, updateOperationFromEvent, dismissOperation } =
useSessionOperations();
const { events } = useEventContext();
useEffect(() => {
if (events.length === 0) return;
const latestEvent = events[events.length - 1];
updateOperationFromEvent(latestEvent);
}, [events, updateOperationFromEvent]);
const visibleOperations = operations.filter(
(op) =>
op.status === "pending" ||
op.status === "active" ||
(op.status === "success" && Date.now() - op.createdAt < 5000) ||
op.status === "error",
);
if (visibleOperations.length === 0) return null;
return (
<div className="session-progress-panel" role="region" aria-label="Session operations">
<div className="session-progress-panel-header">
<span className="session-progress-panel-title">Operations</span>
</div>
<div className="session-progress-panel-list">
{visibleOperations.map((operation) => (
<OperationItem
key={operation.id}
operation={operation}
onDismiss={() => dismissOperation(operation.id)}
/>
))}
</div>
</div>
);
}
@@ -17,6 +17,7 @@ import {
} from "../../../api/config-profiles";
import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
import { useEventContext } from "../../../state/events";
import { useSessions } from "../../../state/sessions";
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
@@ -37,6 +38,7 @@ export const InstanceList = ({
toolTypes,
}: InstanceListProps) => {
const navigate = useNavigate();
const { refreshSessions } = useSessions();
const [instances, setInstances] = useState<ToolInstance[]>([]);
const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false);
@@ -108,6 +110,7 @@ export const InstanceList = ({
const handleCreateSuccess = async () => {
setShowCreate(false);
await loadInstances();
await refreshSessions();
};
const loadConfigProfiles = useCallback(
@@ -196,6 +199,7 @@ export const InstanceList = ({
await deleteInstance(projectId, repoId, instanceId);
// Update state immediately instead of reloading
setInstances((prev) => prev.filter((i) => i.id !== instanceId));
await refreshSessions();
} catch {
setError("Failed to delete instance");
} finally {
@@ -245,15 +249,7 @@ export const InstanceList = ({
) : (
<div className="instance-grid">
{instances.map((instance) => (
<div
key={instance.id}
className={`instance-card ${busyInstanceId === instance.id ? "busy" : ""}`}
>
{busyInstanceId === instance.id && (
<div className="instance-busy-overlay">
<Icon name="loading" size="md" />
</div>
)}
<div key={instance.id} className="instance-card">
<div className="instance-info">
<div className="instance-name">{instance.display_name}</div>
<div className="instance-meta">
@@ -8,6 +8,8 @@ import {
type ConfigProfile,
} from "../../../api/config-profiles";
import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
import { useSessions } from "../../../state/sessions";
import { useSessionOperations } from "../../../state/session-operations";
import type { Workspace } from "../../../types/workspace";
import type { ToolInstance } from "../../../api/sessions";
@@ -22,6 +24,8 @@ export function ToolStarter({
onStarted,
onCancel,
}: ToolStarterProps) {
const { addOrUpdateSession } = useSessions();
const { startOperation } = useSessionOperations();
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [toolTypesLoading, setToolTypesLoading] = useState(true);
const [toolTypesError, setToolTypesError] = useState<string | null>(null);
@@ -141,6 +145,21 @@ export function ToolStarter({
selectedProfileId || undefined,
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined,
);
addOrUpdateSession({
id: instance.id,
display_name: instance.display_name,
tool_type_name: instance.tool_type_name,
tool_icon: "code",
tool_type_interfaces: instance.tool_type_interfaces || [],
repository_name: workspace.repo_name,
repository_id: workspace.repo_id,
project_name: workspace.project_name,
project_id: workspace.project_id,
workspace_name: workspace.name,
status: instance.status || "pending",
url: instance.url || null,
});
startOperation("create", instance.id, instance.display_name);
onStarted(instance);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to start tool");
@@ -153,6 +172,9 @@ export function ToolStarter({
displayName,
workspace,
onStarted,
toolTypes,
addOrUpdateSession,
startOperation,
]);
return (
+34 -8
View File
@@ -7,6 +7,8 @@ import {
renameInstance,
} from "../api/sessions";
import type { Session } from "../api/sessions";
import { useSessions } from "../state/sessions";
import { useSessionOperations } from "../state/session-operations";
interface UseInstanceActionsOptions {
onRefresh: () => Promise<void>;
@@ -30,6 +32,8 @@ export function useInstanceActions(
options: UseInstanceActionsOptions,
): UseInstanceActionsReturn {
const { onRefresh } = options;
const { removeSession } = useSessions();
const { startOperation, completeOperation } = useSessionOperations();
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(
null,
@@ -62,6 +66,7 @@ export function useInstanceActions(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
startOperation("start", session.id, session.display_name);
try {
await startInstance(
session.project_id,
@@ -70,18 +75,19 @@ export function useInstanceActions(
);
await onRefresh();
} catch {
// ignore
completeOperation(session.id, "start", "error");
} finally {
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh],
[loadingSessionId, onRefresh, startOperation, completeOperation],
);
const handleStop = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
startOperation("stop", session.id, session.display_name);
try {
await stopInstance(
session.project_id,
@@ -90,18 +96,19 @@ export function useInstanceActions(
);
await onRefresh();
} catch {
// ignore
completeOperation(session.id, "stop", "error");
} finally {
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh],
[loadingSessionId, onRefresh, startOperation, completeOperation],
);
const handleDelete = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
startOperation("delete", session.id, session.display_name);
try {
await deleteInstance(
session.project_id,
@@ -110,8 +117,10 @@ export function useInstanceActions(
);
setDirtyDeleteSession(null);
setDirtyDeleteFiles([]);
removeSession(session.id);
await onRefresh();
} catch (error) {
completeOperation(session.id, "delete", "error");
const axiosError = error as {
response?: {
status?: number;
@@ -130,13 +139,20 @@ export function useInstanceActions(
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh],
[
loadingSessionId,
onRefresh,
removeSession,
startOperation,
completeOperation,
],
);
const handleForceDelete = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
startOperation("delete", session.id, session.display_name);
try {
await deleteInstance(
session.project_id,
@@ -146,20 +162,28 @@ export function useInstanceActions(
);
setDirtyDeleteSession(null);
setDirtyDeleteFiles([]);
removeSession(session.id);
await onRefresh();
} catch {
// ignore
completeOperation(session.id, "delete", "error");
} finally {
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh],
[
loadingSessionId,
onRefresh,
removeSession,
startOperation,
completeOperation,
],
);
const handleRecreateTunnel = useCallback(
async (session: Session) => {
if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id);
startOperation("recreate-tunnel", session.id, session.display_name);
try {
await recreateInstanceTunnel(
session.project_id,
@@ -167,16 +191,18 @@ export function useInstanceActions(
session.id,
);
await onRefresh();
completeOperation(session.id, "recreate-tunnel", "success");
} catch (err) {
const message =
(err as { response?: { data?: { detail?: string } } })?.response?.data
?.detail || "Failed to recreate tunnel";
completeOperation(session.id, "recreate-tunnel", "error", message);
alert(message);
} finally {
setLoadingSessionId(null);
}
},
[loadingSessionId, onRefresh],
[loadingSessionId, onRefresh, startOperation, completeOperation],
);
const handleRename = useCallback(
+13 -2
View File
@@ -1,8 +1,11 @@
import "@testing-library/jest-dom/vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { HomePage } from "./DashboardPage";
import { SessionsProvider } from "../state/sessions";
import { SessionOperationsProvider } from "../state/session-operations";
const mockDashboard = vi.fn();
const mockSessions = vi.fn();
@@ -55,7 +58,11 @@ describe("HomePage", () => {
render(
<MemoryRouter>
<HomePage />
<SessionsProvider>
<SessionOperationsProvider>
<HomePage />
</SessionOperationsProvider>
</SessionsProvider>
</MemoryRouter>,
);
@@ -73,7 +80,11 @@ describe("HomePage", () => {
render(
<MemoryRouter>
<HomePage />
<SessionsProvider>
<SessionOperationsProvider>
<HomePage />
</SessionOperationsProvider>
</SessionsProvider>
</MemoryRouter>,
);
+7 -14
View File
@@ -2,15 +2,11 @@ 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 { checkInstanceHealth, type InstanceHealth } from "../api/sessions";
import { ErrorState, LoadingState } from "../components/data-states";
import { SessionList } from "../components/features/session/session-list";
import { useInstanceActions } from "../hooks/use-instance-actions";
import { useSessions } from "../state/sessions";
type HomeStatus = "loading" | "ready" | "error";
@@ -20,13 +16,11 @@ const summaryCards = [
{ label: "Repositories", key: "repositories" },
] as const;
type SessionView = SessionApi;
export const HomePage = () => {
const navigate = useNavigate();
const { sessions, refreshSessions } = useSessions();
const [status, setStatus] = useState<HomeStatus>("loading");
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [sessions, setSessions] = useState<SessionView[]>([]);
const [tunnelHealth, setTunnelHealth] = useState<
Record<string, InstanceHealth>
>({});
@@ -35,17 +29,16 @@ export const HomePage = () => {
const loadHome = useCallback(async () => {
setStatus("loading");
try {
const [dashboard, sessionData] = await Promise.all([
const [dashboard] = await Promise.all([
getDashboardSummary(),
getUserSessions(),
refreshSessions(),
]);
setSummary(dashboard);
setSessions(sessionData as SessionView[]);
setStatus("ready");
} catch {
setStatus("error");
}
}, []);
}, [refreshSessions]);
useEffect(() => {
void loadHome();
@@ -58,7 +51,7 @@ export const HomePage = () => {
handleStop,
handleDelete,
handleRecreateTunnel,
} = useInstanceActions({ onRefresh: loadHome });
} = useInstanceActions({ onRefresh: refreshSessions });
// Poll tunnel health every 30 seconds for running instances
useEffect(() => {
+19 -22
View File
@@ -1,46 +1,39 @@
import { useCallback, useEffect, useState } from "react";
import {
getUserSessions,
type Session,
checkInstanceHealth,
} from "../api/sessions";
import { checkInstanceHealth, type InstanceHealth } from "../api/sessions";
import { getUserConfig } from "../api/settings";
import { ErrorState, LoadingState } from "../components/data-states";
import { SessionList } from "../components/features/session/session-list";
import { SessionCard } from "../components/features/session/session-card";
import { useInstanceActions } from "../hooks/use-instance-actions";
import type { InstanceHealth } from "../api/sessions";
import { useSessions } from "../state/sessions";
type SessionsStatus = "loading" | "ready" | "error";
export const SessionsPage = () => {
const { sessions, isLoading, error, refreshSessions } = useSessions();
const [status, setStatus] = useState<SessionsStatus>("loading");
const [sessions, setSessions] = useState<Session[]>([]);
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
const [tunnelHealth, setTunnelHealth] = useState<
Record<string, InstanceHealth>
>({});
const loadSessions = useCallback(async () => {
const loadPageData = useCallback(async () => {
setStatus("loading");
try {
const [sessionsData, config] = await Promise.all([
getUserSessions(),
getUserConfig(),
]);
setSessions(sessionsData);
await refreshSessions();
const config = await getUserConfig();
setLastSessionId(config.last_session_id ?? null);
setStatus("ready");
} catch {
setStatus("error");
}
}, []);
}, [refreshSessions]);
useEffect(() => {
void loadSessions();
}, [loadSessions]);
void loadPageData();
}, [loadPageData]);
const {
loadingSessionId,
@@ -54,7 +47,7 @@ export const SessionsPage = () => {
handleRecreateTunnel,
handleRename,
clearDirtyDelete,
} = useInstanceActions({ onRefresh: loadSessions });
} = useInstanceActions({ onRefresh: refreshSessions });
// Poll health every 30 seconds for active web-enabled instances
useEffect(() => {
@@ -100,22 +93,26 @@ export const SessionsPage = () => {
const lastSession = sessions.find((s) => s.id === lastSessionId) ?? null;
const isPageLoading = status === "loading" || (status === "ready" && isLoading && sessions.length === 0);
return (
<section className="stack sessions-page">
<div className="page-header">
<h1>Sessions</h1>
</div>
{status === "loading" && <LoadingState message="Loading sessions..." />}
{status === "error" && (
<ErrorState
message="Failed to load sessions"
onRetry={() => void loadSessions()}
message={error?.message ?? "Failed to load sessions"}
onRetry={() => void loadPageData()}
/>
)}
{status === "ready" && (
{(status === "loading" || isPageLoading) && (
<LoadingState message="Loading sessions..." />
)}
{status === "ready" && !isPageLoading && (
<>
{/* Last Session */}
{lastSession && (
-2
View File
@@ -84,7 +84,6 @@ export function NotificationProvider({
}
}
// Silently log other errors; next cycle proceeds
// eslint-disable-next-line no-console
console.error("Notification unread count poll failed", err);
}
}, []);
@@ -111,7 +110,6 @@ export function NotificationProvider({
listIntervalRef.current = null;
}
}
// eslint-disable-next-line no-console
console.error("Notification list poll failed", err);
} finally {
setIsLoading(false);
+259
View File
@@ -0,0 +1,259 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import type { InstanceEventPayload } from "../types/events";
export type OperationType =
| "create"
| "start"
| "stop"
| "restart"
| "delete"
| "recreate-tunnel";
export type OperationStatus = "pending" | "active" | "success" | "error";
export interface Operation {
id: string;
type: OperationType;
instanceId: string;
displayName: string;
status: OperationStatus;
message: string;
step: number;
createdAt: number;
}
export interface SessionOperationsContextType {
operations: Operation[];
startOperation: (
type: OperationType,
instanceId: string,
displayName: string,
) => string;
updateOperationFromEvent: (event: InstanceEventPayload) => void;
completeOperation: (
instanceId: string,
type: OperationType,
outcome: "success" | "error",
message?: string,
) => void;
dismissOperation: (id: string) => void;
}
const SessionOperationsContext =
createContext<SessionOperationsContextType | undefined>(undefined);
let operationIdCounter = 0;
function actionLabel(type: OperationType): string {
switch (type) {
case "create":
return "Creating";
case "start":
return "Starting";
case "stop":
return "Stopping";
case "restart":
return "Restarting";
case "delete":
return "Deleting";
case "recreate-tunnel":
return "Recreating tunnel";
default:
return "Working";
}
}
function messageForEvent(
type: OperationType,
event: InstanceEventPayload,
): string {
if (event.message) return event.message;
switch (event.event) {
case "instance.created":
return "Created";
case "instance.started":
return "Starting container";
case "instance.restarted":
return "Restarting container";
case "instance.stopped":
return "Stopped";
case "instance.deleted":
return "Deleted";
case "instance.health_changed":
if (event.status === "running") return "Running";
if (event.status === "unhealthy") return "Unhealthy";
return `Status: ${event.status ?? event.event}`;
case "instance.error":
return event.message ?? "Error";
default:
return event.message ?? actionLabel(type);
}
}
function stepForEvent(event: InstanceEventPayload): number {
switch (event.event) {
case "instance.created":
return 1;
case "instance.started":
case "instance.restarted":
return 2;
case "instance.health_changed":
if (event.status === "running") return 4;
if (event.status === "unhealthy") return 4;
return 3;
case "instance.error":
return 4;
case "instance.stopped":
return 4;
case "instance.deleted":
return 4;
default:
return 0;
}
}
export const SessionOperationsProvider = ({
children,
}: {
children: ReactNode;
}) => {
const [operations, setOperations] = useState<Operation[]>([]);
const startOperation = useCallback(
(type: OperationType, instanceId: string, displayName: string): string => {
const id = `op-${++operationIdCounter}`;
const operation: Operation = {
id,
type,
instanceId,
displayName,
status: "pending",
message: actionLabel(type),
step: 0,
createdAt: Date.now(),
};
setOperations((prev) => [operation, ...prev].slice(0, 20));
return id;
},
[],
);
const updateOperationFromEvent = useCallback(
(event: InstanceEventPayload) => {
setOperations((prev) => {
const matches = prev.filter(
(op) => op.instanceId === event.instance_id && op.status !== "success",
);
if (matches.length === 0) return prev;
const updated = new Map<string, Operation>();
for (const op of prev) updated.set(op.id, op);
for (const op of matches) {
const nextStep = stepForEvent(event);
const message = messageForEvent(op.type, event);
let nextStatus: OperationStatus = op.status;
if (event.event === "instance.error") {
nextStatus = "error";
} else if (
event.event === "instance.health_changed" &&
event.status === "running"
) {
nextStatus = "success";
} else if (event.event === "instance.deleted") {
nextStatus = "success";
} else if (event.event === "instance.stopped") {
nextStatus = "success";
} else if (nextStatus === "pending") {
nextStatus = "active";
}
updated.set(op.id, {
...op,
status: nextStatus,
message,
step: Math.max(op.step, nextStep),
});
}
return Array.from(updated.values());
});
},
[],
);
const completeOperation = useCallback(
(
instanceId: string,
type: OperationType,
outcome: "success" | "error",
message?: string,
) => {
setOperations((prev) => {
const match = prev.find(
(op) => op.instanceId === instanceId && op.type === type,
);
if (!match) return prev;
return prev.map((op) =>
op.id === match.id
? {
...op,
status: outcome,
message:
message ?? (outcome === "success" ? "Done" : "Failed"),
step: 4,
}
: op,
);
});
},
[],
);
const dismissOperation = useCallback((id: string) => {
setOperations((prev) => prev.filter((op) => op.id !== id));
}, []);
const value = useMemo(
() => ({
operations,
startOperation,
updateOperationFromEvent,
completeOperation,
dismissOperation,
}),
[
operations,
startOperation,
updateOperationFromEvent,
completeOperation,
dismissOperation,
],
);
return (
<SessionOperationsContext.Provider value={value}>
{children}
</SessionOperationsContext.Provider>
);
};
export const useSessionOperations = (): SessionOperationsContextType => {
const context = useContext(SessionOperationsContext);
if (context === undefined) {
throw new Error(
"useSessionOperations must be used within a SessionOperationsProvider",
);
}
return context;
};
+66 -19
View File
@@ -1,35 +1,82 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
export interface Session {
id: string;
display_name: string;
tool_type_name: string;
tool_icon: string;
tool_type_interfaces: string[];
repository_name: string;
repository_id: string;
project_name: string;
project_id: string;
status: string;
url: string | null;
}
import { getUserSessions } from "../api/sessions";
import type { Session } from "../api/sessions";
interface SessionsContextType {
export interface SessionsContextType {
sessions: Session[];
setAllSessions: (sessions: Session[]) => void;
isLoading: boolean;
error: Error | null;
refreshSessions: () => Promise<void>;
addOrUpdateSession: (session: Session) => void;
removeSession: (id: string) => void;
}
const SessionsContext = createContext<SessionsContextType | undefined>(undefined);
export const SessionsProvider = ({ children }: { children: ReactNode }) => {
const [sessions, setSessions] = useState<Session[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const setAllSessions = useCallback((newSessions: Session[]) => {
setSessions(newSessions);
const refreshSessions = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await getUserSessions();
setSessions(data);
} catch (err) {
setError(err instanceof Error ? err : new Error("Failed to load sessions"));
} finally {
setIsLoading(false);
}
}, []);
const addOrUpdateSession = useCallback((session: Session) => {
setSessions((prev) => {
const existing = prev.find((s) => s.id === session.id);
if (existing) {
return prev.map((s) => (s.id === session.id ? { ...s, ...session } : s));
}
return [session, ...prev];
});
}, []);
const removeSession = useCallback((id: string) => {
setSessions((prev) => prev.filter((s) => s.id !== id));
}, []);
useEffect(() => {
void refreshSessions();
// Poll every 30 seconds to reconcile shared state with the server.
const interval = setInterval(() => {
void refreshSessions();
}, 30000);
return () => clearInterval(interval);
}, [refreshSessions]);
const value = useMemo(
() => ({
sessions,
isLoading,
error,
refreshSessions,
addOrUpdateSession,
removeSession,
}),
[sessions, isLoading, error, refreshSessions, addOrUpdateSession, removeSession],
);
return (
<SessionsContext.Provider value={{ sessions, setAllSessions }}>
<SessionsContext.Provider value={value}>
{children}
</SessionsContext.Provider>
);
-5
View File
@@ -141,11 +141,6 @@
padding: var(--space-2);
}
@media (max-width: 767px) {
.create-session-form .form-row {
grid-template-columns: 1fr;
gap: var(--space-3);
}
.create-session-form input,
.create-session-form select,
.create-session-form textarea,
+105 -128
View File
@@ -383,41 +383,127 @@ a.nav-item,
align-items: center;
}
.instance-card.busy {
opacity: 0.7;
/* ============================================
Session Operations Progress Panel
============================================ */
.session-progress-panel {
position: fixed;
bottom: var(--space-4);
right: var(--space-4);
width: min(360px, calc(100vw - 2rem));
max-height: min(480px, 60vh);
overflow-y: auto;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--space-2);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
z-index: 100;
display: flex;
flex-direction: column;
}
.instance-busy-overlay {
position: absolute;
inset: 0;
.session-progress-panel-header {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
font-weight: 600;
font-size: var(--text-sm);
}
.session-progress-panel-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3);
}
.session-operation-item {
padding: var(--space-3);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--space-2);
}
.session-operation-item.error {
border-color: var(--danger);
}
.session-operation-header {
display: flex;
align-items: center;
justify-content: center;
justify-content: space-between;
gap: var(--space-2);
background: rgba(var(--bg-rgb, 255, 255, 255), 0.8);
border-radius: 10px;
z-index: 1;
}
.session-operation-title {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.session-operation-title .icon {
flex-shrink: 0;
}
.session-operation-name {
font-weight: 600;
font-size: var(--text-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-operation-message {
margin: var(--space-1) 0 0;
font-size: var(--text-xs);
color: var(--muted);
}
.session-card {
position: relative;
.session-operation-steps {
display: flex;
gap: var(--space-2);
margin-top: var(--space-2);
}
.session-card.busy {
opacity: 0.7;
.session-operation-step {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--muted);
padding: 2px 6px;
border-radius: 4px;
background: var(--bg-muted);
}
.session-busy-overlay {
position: absolute;
inset: 0;
.session-operation-step.active {
background: var(--primary);
color: var(--primary-fg);
}
.session-operation-step.current {
box-shadow: 0 0 0 1px var(--primary);
}
.session-operation-dismiss {
background: transparent;
border: none;
color: var(--muted);
cursor: pointer;
padding: var(--space-1);
display: flex;
align-items: center;
justify-content: center;
background: rgba(var(--bg-rgb, 255, 255, 255), 0.8);
border-radius: var(--space-2);
z-index: 1;
}
@media (max-width: 767px) {
.session-progress-panel {
left: var(--space-2);
right: var(--space-2);
bottom: calc(var(--space-2) + 64px);
width: auto;
max-height: 35vh;
}
}
/* ============================================
@@ -1222,56 +1308,6 @@ a.nav-item,
pointer-events: none;
}
.create-session-form-wrapper {
position: relative;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
background: rgba(255, 254, 249, 0.7);
border-radius: var(--space-2);
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
padding: var(--space-6);
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--space-2);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.loading-content .icon {
animation: spin 1s linear infinite;
color: var(--brand);
}
.loading-content p {
margin: 0;
font-size: var(--font-size-base);
color: var(--muted);
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.recent-sessions-list {
display: flex;
flex-direction: column;
@@ -1337,65 +1373,6 @@ a.nav-item,
gap: var(--space-4);
}
/* Workflow Step Styles */
.workflow-form {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.workflow-step {
opacity: 0.4;
pointer-events: none;
transition: opacity 0.2s ease;
}
.workflow-step.active {
opacity: 1;
pointer-events: auto;
}
.workflow-step.complete {
opacity: 0.7;
pointer-events: auto;
}
.workflow-step-header {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-3);
font-weight: 600;
color: var(--text-muted);
}
.workflow-step.active .workflow-step-header {
color: var(--text-primary);
}
.workflow-step-number {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--bg-muted);
color: var(--text-muted);
font-size: 14px;
font-weight: 600;
}
.workflow-step.active .workflow-step-number {
background: var(--primary);
color: white;
}
.workflow-step.complete .workflow-step-number {
background: var(--success);
color: white;
}
.status-badge {
display: inline-flex;
align-items: center;