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
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>
);
}
@@ -17,6 +17,7 @@ import {
} from "../../../api/config-profiles";
import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
import { useEventContext } from "../../../state/events";
import { useSessions } from "../../../state/sessions";
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
@@ -37,6 +38,7 @@ export const InstanceList = ({
toolTypes,
}: InstanceListProps) => {
const navigate = useNavigate();
const { refreshSessions } = useSessions();
const [instances, setInstances] = useState<ToolInstance[]>([]);
const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false);
@@ -108,6 +110,7 @@ export const InstanceList = ({
const handleCreateSuccess = async () => {
setShowCreate(false);
await loadInstances();
await refreshSessions();
};
const loadConfigProfiles = useCallback(
@@ -196,6 +199,7 @@ export const InstanceList = ({
await deleteInstance(projectId, repoId, instanceId);
// Update state immediately instead of reloading
setInstances((prev) => prev.filter((i) => i.id !== instanceId));
await refreshSessions();
} catch {
setError("Failed to delete instance");
} finally {
@@ -245,15 +249,7 @@ export const InstanceList = ({
) : (
<div className="instance-grid">
{instances.map((instance) => (
<div
key={instance.id}
className={`instance-card ${busyInstanceId === instance.id ? "busy" : ""}`}
>
{busyInstanceId === instance.id && (
<div className="instance-busy-overlay">
<Icon name="loading" size="md" />
</div>
)}
<div key={instance.id} className="instance-card">
<div className="instance-info">
<div className="instance-name">{instance.display_name}</div>
<div className="instance-meta">
@@ -8,6 +8,8 @@ import {
type ConfigProfile,
} from "../../../api/config-profiles";
import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
import { useSessions } from "../../../state/sessions";
import { useSessionOperations } from "../../../state/session-operations";
import type { Workspace } from "../../../types/workspace";
import type { ToolInstance } from "../../../api/sessions";
@@ -22,6 +24,8 @@ export function ToolStarter({
onStarted,
onCancel,
}: ToolStarterProps) {
const { addOrUpdateSession } = useSessions();
const { startOperation } = useSessionOperations();
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [toolTypesLoading, setToolTypesLoading] = useState(true);
const [toolTypesError, setToolTypesError] = useState<string | null>(null);
@@ -141,6 +145,21 @@ export function ToolStarter({
selectedProfileId || undefined,
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined,
);
addOrUpdateSession({
id: instance.id,
display_name: instance.display_name,
tool_type_name: instance.tool_type_name,
tool_icon: "code",
tool_type_interfaces: instance.tool_type_interfaces || [],
repository_name: workspace.repo_name,
repository_id: workspace.repo_id,
project_name: workspace.project_name,
project_id: workspace.project_id,
workspace_name: workspace.name,
status: instance.status || "pending",
url: instance.url || null,
});
startOperation("create", instance.id, instance.display_name);
onStarted(instance);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to start tool");
@@ -153,6 +172,9 @@ export function ToolStarter({
displayName,
workspace,
onStarted,
toolTypes,
addOrUpdateSession,
startOperation,
]);
return (