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:
File diff suppressed because it is too large
Load Diff
@@ -105,12 +105,7 @@ export function SessionCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<article className={`card session-card ${isBusy ? "busy" : ""}`}>
|
||||
{isBusy && (
|
||||
<div className="session-busy-overlay">
|
||||
<Icon name="loading" size="md" />
|
||||
</div>
|
||||
)}
|
||||
<article className="card session-card">
|
||||
<div className="session-card-content">
|
||||
<div className="session-card-header">
|
||||
<div className="session-card-title">
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
useSessionOperations,
|
||||
type Operation,
|
||||
} from "../../../state/session-operations";
|
||||
import { useEventContext } from "../../../state/events";
|
||||
import { Icon } from "../../icon";
|
||||
|
||||
interface StepConfig {
|
||||
label: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const steps: StepConfig[] = [
|
||||
{ label: "Created", index: 1 },
|
||||
{ label: "Building", index: 2 },
|
||||
{ label: "Starting", index: 3 },
|
||||
{ label: "Ready", index: 4 },
|
||||
];
|
||||
|
||||
function OperationItem({
|
||||
operation,
|
||||
onDismiss,
|
||||
}: {
|
||||
operation: Operation;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const isDone = operation.status === "success" || operation.status === "error";
|
||||
const isError = operation.status === "error";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`session-operation-item ${operation.status}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="session-operation-header">
|
||||
<div className="session-operation-title">
|
||||
{isError ? (
|
||||
<Icon name="error" size="sm" />
|
||||
) : isDone ? (
|
||||
<Icon name="success" size="sm" />
|
||||
) : (
|
||||
<Icon name="loading" size="sm" />
|
||||
)}
|
||||
<span className="session-operation-name">{operation.displayName}</span>
|
||||
</div>
|
||||
{isDone && (
|
||||
<button
|
||||
type="button"
|
||||
className="session-operation-dismiss"
|
||||
onClick={onDismiss}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<Icon name="close" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="session-operation-message">{operation.message}</p>
|
||||
<div className="session-operation-steps">
|
||||
{steps.map((step) => {
|
||||
const active = operation.step >= step.index;
|
||||
const current = operation.step === step.index && !isDone;
|
||||
return (
|
||||
<span
|
||||
key={step.label}
|
||||
className={`session-operation-step ${active ? "active" : ""} ${current ? "current" : ""}`}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionProgressPanel() {
|
||||
const { operations, updateOperationFromEvent, dismissOperation } =
|
||||
useSessionOperations();
|
||||
const { events } = useEventContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (events.length === 0) return;
|
||||
const latestEvent = events[events.length - 1];
|
||||
updateOperationFromEvent(latestEvent);
|
||||
}, [events, updateOperationFromEvent]);
|
||||
|
||||
const visibleOperations = operations.filter(
|
||||
(op) =>
|
||||
op.status === "pending" ||
|
||||
op.status === "active" ||
|
||||
(op.status === "success" && Date.now() - op.createdAt < 5000) ||
|
||||
op.status === "error",
|
||||
);
|
||||
|
||||
if (visibleOperations.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="session-progress-panel" role="region" aria-label="Session operations">
|
||||
<div className="session-progress-panel-header">
|
||||
<span className="session-progress-panel-title">Operations</span>
|
||||
</div>
|
||||
<div className="session-progress-panel-list">
|
||||
{visibleOperations.map((operation) => (
|
||||
<OperationItem
|
||||
key={operation.id}
|
||||
operation={operation}
|
||||
onDismiss={() => dismissOperation(operation.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user