7440720b7b
- 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).
248 lines
6.3 KiB
TypeScript
248 lines
6.3 KiB
TypeScript
import { useState, useCallback, useRef } from "react";
|
|
import {
|
|
stopInstance,
|
|
deleteInstance,
|
|
startInstance,
|
|
recreateInstanceTunnel,
|
|
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>;
|
|
}
|
|
|
|
interface UseInstanceActionsReturn {
|
|
loadingSessionId: string | null;
|
|
dirtyDeleteSession: Session | null;
|
|
dirtyDeleteFiles: string[];
|
|
handleOpen: (session: Session) => void;
|
|
handleStart: (session: Session) => Promise<void>;
|
|
handleStop: (session: Session) => Promise<void>;
|
|
handleDelete: (session: Session) => Promise<void>;
|
|
handleForceDelete: (session: Session) => Promise<void>;
|
|
handleRecreateTunnel: (session: Session) => Promise<void>;
|
|
handleRename: (session: Session, newName: string) => Promise<void>;
|
|
clearDirtyDelete: () => void;
|
|
}
|
|
|
|
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,
|
|
);
|
|
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
|
const tabRefs = useRef<Map<string, Window | null>>(new Map());
|
|
|
|
const handleOpen = useCallback((session: Session) => {
|
|
const key = session.id;
|
|
const existing = tabRefs.current.get(key);
|
|
if (existing && !existing.closed) {
|
|
existing.focus();
|
|
return;
|
|
}
|
|
|
|
let url: string;
|
|
if (session.url) {
|
|
url = session.url;
|
|
} else if (session.tool_type_interfaces?.includes("terminal")) {
|
|
url = `/instances/${session.id}/terminal`;
|
|
} else {
|
|
url = `/projects/${session.project_id}`;
|
|
}
|
|
|
|
const w = window.open(url, `session-${session.id}`);
|
|
tabRefs.current.set(key, w);
|
|
}, []);
|
|
|
|
const handleStart = useCallback(
|
|
async (session: Session) => {
|
|
if (loadingSessionId === session.id) return;
|
|
setLoadingSessionId(session.id);
|
|
startOperation("start", session.id, session.display_name);
|
|
try {
|
|
await startInstance(
|
|
session.project_id,
|
|
session.repository_id,
|
|
session.id,
|
|
);
|
|
await onRefresh();
|
|
} catch {
|
|
completeOperation(session.id, "start", "error");
|
|
} finally {
|
|
setLoadingSessionId(null);
|
|
}
|
|
},
|
|
[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,
|
|
session.repository_id,
|
|
session.id,
|
|
);
|
|
await onRefresh();
|
|
} catch {
|
|
completeOperation(session.id, "stop", "error");
|
|
} finally {
|
|
setLoadingSessionId(null);
|
|
}
|
|
},
|
|
[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,
|
|
session.repository_id,
|
|
session.id,
|
|
);
|
|
setDirtyDeleteSession(null);
|
|
setDirtyDeleteFiles([]);
|
|
removeSession(session.id);
|
|
await onRefresh();
|
|
} catch (error) {
|
|
completeOperation(session.id, "delete", "error");
|
|
const axiosError = error as {
|
|
response?: {
|
|
status?: number;
|
|
data?: { detail?: { changed_files?: string[] } };
|
|
};
|
|
};
|
|
if (axiosError.response?.status === 409) {
|
|
const detail = axiosError.response.data?.detail;
|
|
if (detail?.changed_files) {
|
|
setDirtyDeleteSession(session);
|
|
setDirtyDeleteFiles(detail.changed_files);
|
|
return;
|
|
}
|
|
}
|
|
} finally {
|
|
setLoadingSessionId(null);
|
|
}
|
|
},
|
|
[
|
|
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,
|
|
session.repository_id,
|
|
session.id,
|
|
true,
|
|
);
|
|
setDirtyDeleteSession(null);
|
|
setDirtyDeleteFiles([]);
|
|
removeSession(session.id);
|
|
await onRefresh();
|
|
} catch {
|
|
completeOperation(session.id, "delete", "error");
|
|
} finally {
|
|
setLoadingSessionId(null);
|
|
}
|
|
},
|
|
[
|
|
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,
|
|
session.repository_id,
|
|
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, startOperation, completeOperation],
|
|
);
|
|
|
|
const handleRename = useCallback(
|
|
async (session: Session, newName: string) => {
|
|
if (!newName.trim()) return;
|
|
setLoadingSessionId(session.id);
|
|
try {
|
|
await renameInstance(
|
|
session.project_id,
|
|
session.repository_id,
|
|
session.id,
|
|
newName.trim(),
|
|
);
|
|
await onRefresh();
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
setLoadingSessionId(null);
|
|
}
|
|
},
|
|
[onRefresh],
|
|
);
|
|
|
|
const clearDirtyDelete = useCallback(() => {
|
|
setDirtyDeleteSession(null);
|
|
setDirtyDeleteFiles([]);
|
|
}, []);
|
|
|
|
return {
|
|
loadingSessionId,
|
|
dirtyDeleteSession,
|
|
dirtyDeleteFiles,
|
|
handleOpen,
|
|
handleStart,
|
|
handleStop,
|
|
handleDelete,
|
|
handleForceDelete,
|
|
handleRecreateTunnel,
|
|
handleRename,
|
|
clearDirtyDelete,
|
|
};
|
|
}
|