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