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:
@@ -1,7 +1,5 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
|
||||
|
||||
import { getUserSessions } from "../api/sessions";
|
||||
import type { Session } from "../api/sessions";
|
||||
import { useTheme } from "../hooks/use-theme";
|
||||
import { useAuth } from "../state/auth";
|
||||
@@ -10,8 +8,10 @@ import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
import { EventProvider } from "../state/events";
|
||||
import { ToastProvider } from "../state/toast";
|
||||
import { NotificationProvider } from "../state/notifications";
|
||||
import { SessionOperationsProvider } from "../state/session-operations";
|
||||
import { EventToastBridge } from "./features/notification/event-toast-bridge";
|
||||
import { NotificationCenter } from "./features/notification/notification-center";
|
||||
import { SessionProgressPanel } from "./features/session/session-progress-panel";
|
||||
import { Icon } from "./icon";
|
||||
import { MobileNav } from "./features/mobile/mobile-nav";
|
||||
import { StartToolFAB } from "./features/tool/start-tool-fab";
|
||||
@@ -74,7 +74,7 @@ const SessionItem = ({ session }: { session: Session }) => {
|
||||
export const AppShell = () => {
|
||||
useTheme();
|
||||
const { user, logout } = useAuth();
|
||||
const { sessions, setAllSessions } = useSessions();
|
||||
const { sessions } = useSessions();
|
||||
const location = useLocation();
|
||||
const isMobile = useMobileViewport();
|
||||
const isMobileTerminal =
|
||||
@@ -82,33 +82,17 @@ export const AppShell = () => {
|
||||
location.pathname.includes("/instances/") &&
|
||||
location.pathname.includes("/terminal");
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
const data = await getUserSessions();
|
||||
setAllSessions(data);
|
||||
} catch {
|
||||
// Silently fail - sessions are optional
|
||||
}
|
||||
}, [setAllSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSessions();
|
||||
// Poll every 30 seconds (reduced from 10s to avoid ERR_NETWORK_CHANGED from Docker network changes)
|
||||
const interval = setInterval(() => {
|
||||
void loadSessions();
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadSessions]);
|
||||
|
||||
if (isMobileTerminal) {
|
||||
return (
|
||||
<EventProvider>
|
||||
<ToastProvider>
|
||||
<NotificationProvider>
|
||||
<EventToastBridge />
|
||||
<div className="shell mobile-terminal-shell">
|
||||
<Outlet />
|
||||
</div>
|
||||
<SessionOperationsProvider>
|
||||
<EventToastBridge />
|
||||
<div className="shell mobile-terminal-shell">
|
||||
<Outlet />
|
||||
</div>
|
||||
</SessionOperationsProvider>
|
||||
</NotificationProvider>
|
||||
</ToastProvider>
|
||||
</EventProvider>
|
||||
@@ -119,81 +103,84 @@ export const AppShell = () => {
|
||||
<EventProvider>
|
||||
<ToastProvider>
|
||||
<NotificationProvider>
|
||||
<EventToastBridge />
|
||||
<div className="shell">
|
||||
<header className="shell-header">
|
||||
<Link className="brand" to="/">
|
||||
Headquarter
|
||||
</Link>
|
||||
<div className="header-actions">
|
||||
<NotificationCenter isMobileTerminal={isMobileTerminal} />
|
||||
<Link className="user-chip" to="/profile">
|
||||
{user?.name ?? "User"}
|
||||
<SessionOperationsProvider>
|
||||
<EventToastBridge />
|
||||
<SessionProgressPanel />
|
||||
<div className="shell">
|
||||
<header className="shell-header">
|
||||
<Link className="brand" to="/">
|
||||
Headquarter
|
||||
</Link>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => {
|
||||
void logout();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="logout" size="sm" />
|
||||
Logout
|
||||
</button>
|
||||
<div className="header-actions">
|
||||
<NotificationCenter isMobileTerminal={isMobileTerminal} />
|
||||
<Link className="user-chip" to="/profile">
|
||||
{user?.name ?? "User"}
|
||||
</Link>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => {
|
||||
void logout();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="logout" size="sm" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="shell-body">
|
||||
{!isMobile && (
|
||||
<aside className="shell-nav" aria-label="Primary navigation">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const activeCount = sessions.filter(
|
||||
(s) => s.status === "running",
|
||||
).length;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
isActive ? "nav-item nav-item-active" : "nav-item"
|
||||
}
|
||||
end={item.to === "/"}
|
||||
>
|
||||
<Icon name={item.icon} size="sm" />
|
||||
{item.label}
|
||||
{item.badge === "sessions" && activeCount > 0 && (
|
||||
<span className="nav-badge">{activeCount}</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
{sessions.length > 0 && (
|
||||
<>
|
||||
<div className="nav-divider" />
|
||||
<div className="nav-section-title">Live sessions</div>
|
||||
{sessions.map((session) => (
|
||||
<SessionItem key={session.id} session={session} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<main className={`shell-content ${isMobile ? "mobile" : ""}`}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="shell-body">
|
||||
{!isMobile && (
|
||||
<aside className="shell-nav" aria-label="Primary navigation">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const activeCount = sessions.filter(
|
||||
(s) => s.status === "running",
|
||||
).length;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
isActive ? "nav-item nav-item-active" : "nav-item"
|
||||
}
|
||||
end={item.to === "/"}
|
||||
>
|
||||
<Icon name={item.icon} size="sm" />
|
||||
{item.label}
|
||||
{item.badge === "sessions" && activeCount > 0 && (
|
||||
<span className="nav-badge">{activeCount}</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
{sessions.length > 0 && (
|
||||
<>
|
||||
<div className="nav-divider" />
|
||||
<div className="nav-section-title">Live sessions</div>
|
||||
{sessions.map((session) => (
|
||||
<SessionItem key={session.id} session={session} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
{isMobile && (
|
||||
<MobileNav
|
||||
sessionCount={
|
||||
sessions.filter((s) => s.status === "running").length
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<main className={`shell-content ${isMobile ? "mobile" : ""}`}>
|
||||
<Outlet />
|
||||
</main>
|
||||
<StartToolFAB />
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
<MobileNav
|
||||
sessionCount={
|
||||
sessions.filter((s) => s.status === "running").length
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<StartToolFAB />
|
||||
</div>
|
||||
</SessionOperationsProvider>
|
||||
</NotificationProvider>
|
||||
</ToastProvider>
|
||||
</EventProvider>
|
||||
|
||||
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 (
|
||||
|
||||
Reference in New Issue
Block a user