Files
headquarter/apps/web/src/state/session-operations.tsx
T
Developer 7440720b7b 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).
2026-06-12 13:19:58 +00:00

260 lines
6.3 KiB
TypeScript

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;
};