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:
@@ -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>,
|
||||
);
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user