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
+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 && (