feat: merge tool-session progress panel and live list updates
This commit is contained in:
@@ -55,3 +55,4 @@ Thumbs.db
|
||||
.sisyphus/
|
||||
.pi-lens/
|
||||
minerv3/
|
||||
.cache/
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
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>;
|
||||
@@ -30,6 +32,8 @@ 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,
|
||||
@@ -62,6 +66,7 @@ export function useInstanceActions(
|
||||
async (session: Session) => {
|
||||
if (loadingSessionId === session.id) return;
|
||||
setLoadingSessionId(session.id);
|
||||
startOperation("start", session.id, session.display_name);
|
||||
try {
|
||||
await startInstance(
|
||||
session.project_id,
|
||||
@@ -70,18 +75,19 @@ export function useInstanceActions(
|
||||
);
|
||||
await onRefresh();
|
||||
} catch {
|
||||
// ignore
|
||||
completeOperation(session.id, "start", "error");
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
},
|
||||
[loadingSessionId, onRefresh],
|
||||
[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,
|
||||
@@ -90,18 +96,19 @@ export function useInstanceActions(
|
||||
);
|
||||
await onRefresh();
|
||||
} catch {
|
||||
// ignore
|
||||
completeOperation(session.id, "stop", "error");
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
},
|
||||
[loadingSessionId, onRefresh],
|
||||
[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,
|
||||
@@ -110,8 +117,10 @@ export function useInstanceActions(
|
||||
);
|
||||
setDirtyDeleteSession(null);
|
||||
setDirtyDeleteFiles([]);
|
||||
removeSession(session.id);
|
||||
await onRefresh();
|
||||
} catch (error) {
|
||||
completeOperation(session.id, "delete", "error");
|
||||
const axiosError = error as {
|
||||
response?: {
|
||||
status?: number;
|
||||
@@ -130,13 +139,20 @@ export function useInstanceActions(
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
},
|
||||
[loadingSessionId, onRefresh],
|
||||
[
|
||||
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,
|
||||
@@ -146,20 +162,28 @@ export function useInstanceActions(
|
||||
);
|
||||
setDirtyDeleteSession(null);
|
||||
setDirtyDeleteFiles([]);
|
||||
removeSession(session.id);
|
||||
await onRefresh();
|
||||
} catch {
|
||||
// ignore
|
||||
completeOperation(session.id, "delete", "error");
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
},
|
||||
[loadingSessionId, onRefresh],
|
||||
[
|
||||
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,
|
||||
@@ -167,16 +191,18 @@ export function useInstanceActions(
|
||||
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],
|
||||
[loadingSessionId, onRefresh, startOperation, completeOperation],
|
||||
);
|
||||
|
||||
const handleRename = useCallback(
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { HomePage } from "./DashboardPage";
|
||||
import { SessionsProvider } from "../state/sessions";
|
||||
import { SessionOperationsProvider } from "../state/session-operations";
|
||||
|
||||
const mockDashboard = vi.fn();
|
||||
const mockSessions = vi.fn();
|
||||
@@ -55,7 +58,11 @@ describe("HomePage", () => {
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<HomePage />
|
||||
<SessionsProvider>
|
||||
<SessionOperationsProvider>
|
||||
<HomePage />
|
||||
</SessionOperationsProvider>
|
||||
</SessionsProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -73,7 +80,11 @@ describe("HomePage", () => {
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<HomePage />
|
||||
<SessionsProvider>
|
||||
<SessionOperationsProvider>
|
||||
<HomePage />
|
||||
</SessionOperationsProvider>
|
||||
</SessionsProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
|
||||
@@ -2,15 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
||||
import {
|
||||
getUserSessions,
|
||||
checkInstanceHealth,
|
||||
type Session as SessionApi,
|
||||
type InstanceHealth,
|
||||
} from "../api/sessions";
|
||||
import { checkInstanceHealth, type InstanceHealth } from "../api/sessions";
|
||||
import { ErrorState, LoadingState } from "../components/data-states";
|
||||
import { SessionList } from "../components/features/session/session-list";
|
||||
import { useInstanceActions } from "../hooks/use-instance-actions";
|
||||
import { useSessions } from "../state/sessions";
|
||||
|
||||
type HomeStatus = "loading" | "ready" | "error";
|
||||
|
||||
@@ -20,13 +16,11 @@ const summaryCards = [
|
||||
{ label: "Repositories", key: "repositories" },
|
||||
] as const;
|
||||
|
||||
type SessionView = SessionApi;
|
||||
|
||||
export const HomePage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { sessions, refreshSessions } = useSessions();
|
||||
const [status, setStatus] = useState<HomeStatus>("loading");
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [sessions, setSessions] = useState<SessionView[]>([]);
|
||||
const [tunnelHealth, setTunnelHealth] = useState<
|
||||
Record<string, InstanceHealth>
|
||||
>({});
|
||||
@@ -35,17 +29,16 @@ export const HomePage = () => {
|
||||
const loadHome = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const [dashboard, sessionData] = await Promise.all([
|
||||
const [dashboard] = await Promise.all([
|
||||
getDashboardSummary(),
|
||||
getUserSessions(),
|
||||
refreshSessions(),
|
||||
]);
|
||||
setSummary(dashboard);
|
||||
setSessions(sessionData as SessionView[]);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
}, [refreshSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHome();
|
||||
@@ -58,7 +51,7 @@ export const HomePage = () => {
|
||||
handleStop,
|
||||
handleDelete,
|
||||
handleRecreateTunnel,
|
||||
} = useInstanceActions({ onRefresh: loadHome });
|
||||
} = useInstanceActions({ onRefresh: refreshSessions });
|
||||
|
||||
// Poll tunnel health every 30 seconds for running instances
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,46 +1,39 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
getUserSessions,
|
||||
type Session,
|
||||
checkInstanceHealth,
|
||||
} from "../api/sessions";
|
||||
import { checkInstanceHealth, type InstanceHealth } from "../api/sessions";
|
||||
import { getUserConfig } from "../api/settings";
|
||||
import { ErrorState, LoadingState } from "../components/data-states";
|
||||
import { SessionList } from "../components/features/session/session-list";
|
||||
import { SessionCard } from "../components/features/session/session-card";
|
||||
import { useInstanceActions } from "../hooks/use-instance-actions";
|
||||
import type { InstanceHealth } from "../api/sessions";
|
||||
import { useSessions } from "../state/sessions";
|
||||
|
||||
type SessionsStatus = "loading" | "ready" | "error";
|
||||
|
||||
export const SessionsPage = () => {
|
||||
const { sessions, isLoading, error, refreshSessions } = useSessions();
|
||||
const [status, setStatus] = useState<SessionsStatus>("loading");
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
|
||||
|
||||
const [tunnelHealth, setTunnelHealth] = useState<
|
||||
Record<string, InstanceHealth>
|
||||
>({});
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
const loadPageData = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const [sessionsData, config] = await Promise.all([
|
||||
getUserSessions(),
|
||||
getUserConfig(),
|
||||
]);
|
||||
setSessions(sessionsData);
|
||||
await refreshSessions();
|
||||
const config = await getUserConfig();
|
||||
setLastSessionId(config.last_session_id ?? null);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
}, [refreshSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSessions();
|
||||
}, [loadSessions]);
|
||||
void loadPageData();
|
||||
}, [loadPageData]);
|
||||
|
||||
const {
|
||||
loadingSessionId,
|
||||
@@ -54,7 +47,7 @@ export const SessionsPage = () => {
|
||||
handleRecreateTunnel,
|
||||
handleRename,
|
||||
clearDirtyDelete,
|
||||
} = useInstanceActions({ onRefresh: loadSessions });
|
||||
} = useInstanceActions({ onRefresh: refreshSessions });
|
||||
|
||||
// Poll health every 30 seconds for active web-enabled instances
|
||||
useEffect(() => {
|
||||
@@ -100,22 +93,26 @@ export const SessionsPage = () => {
|
||||
|
||||
const lastSession = sessions.find((s) => s.id === lastSessionId) ?? null;
|
||||
|
||||
const isPageLoading = status === "loading" || (status === "ready" && isLoading && sessions.length === 0);
|
||||
|
||||
return (
|
||||
<section className="stack sessions-page">
|
||||
<div className="page-header">
|
||||
<h1>Sessions</h1>
|
||||
</div>
|
||||
|
||||
{status === "loading" && <LoadingState message="Loading sessions..." />}
|
||||
|
||||
{status === "error" && (
|
||||
<ErrorState
|
||||
message="Failed to load sessions"
|
||||
onRetry={() => void loadSessions()}
|
||||
message={error?.message ?? "Failed to load sessions"}
|
||||
onRetry={() => void loadPageData()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === "ready" && (
|
||||
{(status === "loading" || isPageLoading) && (
|
||||
<LoadingState message="Loading sessions..." />
|
||||
)}
|
||||
|
||||
{status === "ready" && !isPageLoading && (
|
||||
<>
|
||||
{/* Last Session */}
|
||||
{lastSession && (
|
||||
|
||||
@@ -84,7 +84,6 @@ export function NotificationProvider({
|
||||
}
|
||||
}
|
||||
// Silently log other errors; next cycle proceeds
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Notification unread count poll failed", err);
|
||||
}
|
||||
}, []);
|
||||
@@ -111,7 +110,6 @@ export function NotificationProvider({
|
||||
listIntervalRef.current = null;
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Notification list poll failed", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,35 +1,82 @@
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
display_name: string;
|
||||
tool_type_name: string;
|
||||
tool_icon: string;
|
||||
tool_type_interfaces: string[];
|
||||
repository_name: string;
|
||||
repository_id: string;
|
||||
project_name: string;
|
||||
project_id: string;
|
||||
status: string;
|
||||
url: string | null;
|
||||
}
|
||||
import { getUserSessions } from "../api/sessions";
|
||||
import type { Session } from "../api/sessions";
|
||||
|
||||
interface SessionsContextType {
|
||||
export interface SessionsContextType {
|
||||
sessions: Session[];
|
||||
setAllSessions: (sessions: Session[]) => void;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refreshSessions: () => Promise<void>;
|
||||
addOrUpdateSession: (session: Session) => void;
|
||||
removeSession: (id: string) => void;
|
||||
}
|
||||
|
||||
const SessionsContext = createContext<SessionsContextType | undefined>(undefined);
|
||||
|
||||
export const SessionsProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const setAllSessions = useCallback((newSessions: Session[]) => {
|
||||
setSessions(newSessions);
|
||||
const refreshSessions = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getUserSessions();
|
||||
setSessions(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error("Failed to load sessions"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const addOrUpdateSession = useCallback((session: Session) => {
|
||||
setSessions((prev) => {
|
||||
const existing = prev.find((s) => s.id === session.id);
|
||||
if (existing) {
|
||||
return prev.map((s) => (s.id === session.id ? { ...s, ...session } : s));
|
||||
}
|
||||
return [session, ...prev];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeSession = useCallback((id: string) => {
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshSessions();
|
||||
// Poll every 30 seconds to reconcile shared state with the server.
|
||||
const interval = setInterval(() => {
|
||||
void refreshSessions();
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshSessions]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
sessions,
|
||||
isLoading,
|
||||
error,
|
||||
refreshSessions,
|
||||
addOrUpdateSession,
|
||||
removeSession,
|
||||
}),
|
||||
[sessions, isLoading, error, refreshSessions, addOrUpdateSession, removeSession],
|
||||
);
|
||||
|
||||
return (
|
||||
<SessionsContext.Provider value={{ sessions, setAllSessions }}>
|
||||
<SessionsContext.Provider value={value}>
|
||||
{children}
|
||||
</SessionsContext.Provider>
|
||||
);
|
||||
|
||||
@@ -141,11 +141,6 @@
|
||||
padding: var(--space-2);
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.create-session-form .form-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.create-session-form input,
|
||||
.create-session-form select,
|
||||
.create-session-form textarea,
|
||||
|
||||
+105
-128
@@ -383,41 +383,127 @@ a.nav-item,
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.instance-card.busy {
|
||||
opacity: 0.7;
|
||||
/* ============================================
|
||||
Session Operations Progress Panel
|
||||
============================================ */
|
||||
|
||||
.session-progress-panel {
|
||||
position: fixed;
|
||||
bottom: var(--space-4);
|
||||
right: var(--space-4);
|
||||
width: min(360px, calc(100vw - 2rem));
|
||||
max-height: min(480px, 60vh);
|
||||
overflow-y: auto;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--space-2);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.instance-busy-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
.session-progress-panel-header {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.session-progress-panel-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.session-operation-item {
|
||||
padding: var(--space-3);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--space-2);
|
||||
}
|
||||
|
||||
.session-operation-item.error {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.session-operation-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
background: rgba(var(--bg-rgb, 255, 255, 255), 0.8);
|
||||
border-radius: 10px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.session-operation-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-operation-title .icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-operation-name {
|
||||
font-weight: 600;
|
||||
font-size: var(--text-sm);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-operation-message {
|
||||
margin: var(--space-1) 0 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.session-card {
|
||||
position: relative;
|
||||
.session-operation-steps {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.session-card.busy {
|
||||
opacity: 0.7;
|
||||
.session-operation-step {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--muted);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-muted);
|
||||
}
|
||||
|
||||
.session-busy-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
.session-operation-step.active {
|
||||
background: var(--primary);
|
||||
color: var(--primary-fg);
|
||||
}
|
||||
|
||||
.session-operation-step.current {
|
||||
box-shadow: 0 0 0 1px var(--primary);
|
||||
}
|
||||
|
||||
.session-operation-dismiss {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
padding: var(--space-1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(var(--bg-rgb, 255, 255, 255), 0.8);
|
||||
border-radius: var(--space-2);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.session-progress-panel {
|
||||
left: var(--space-2);
|
||||
right: var(--space-2);
|
||||
bottom: calc(var(--space-2) + 64px);
|
||||
width: auto;
|
||||
max-height: 35vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
@@ -1222,56 +1308,6 @@ a.nav-item,
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.create-session-form-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
background: rgba(255, 254, 249, 0.7);
|
||||
border-radius: var(--space-2);
|
||||
}
|
||||
|
||||
.loading-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-6);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--space-2);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.loading-content .icon {
|
||||
animation: spin 1s linear infinite;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.loading-content p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.recent-sessions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1337,65 +1373,6 @@ a.nav-item,
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
/* Workflow Step Styles */
|
||||
.workflow-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.workflow-step {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.workflow-step.active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.workflow-step.complete {
|
||||
opacity: 0.7;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.workflow-step-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.workflow-step.active .workflow-step-header {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.workflow-step-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-muted);
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.workflow-step.active .workflow-step-number {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.workflow-step.complete .workflow-step-number {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-06-12
|
||||
@@ -0,0 +1,37 @@
|
||||
# . (index)
|
||||
dir: .
|
||||
|
||||
## Project Map Protocol
|
||||
|
||||
1. Read this protocol and the root `.pi-map.index.md` first.
|
||||
2. Use `index:` / `map:` references to open relevant directory indexes and maps.
|
||||
3. Load indexes before rich maps during task-start navigation.
|
||||
4. Read the local rich map and actual source before editing.
|
||||
5. Treat non-empty `## dirty` sections in either artifact as stale.
|
||||
6. If source and generated artifacts disagree, trust source.
|
||||
7. If map and index disagree, trust neither blindly; verify from source and regenerate the pair.
|
||||
8. After editing source, run `project_map_patch` for each changed file.
|
||||
9. Before broad architectural claims or final handoff, run `project_map_validate` when freshness matters.
|
||||
|
||||
Trust boundary: index routes, map orients, source decides.
|
||||
|
||||
## role
|
||||
Frontend architecture specification for real-time session progress tracking using Server-Sent Events and shared React state management.
|
||||
## parent
|
||||
-
|
||||
## children
|
||||
- specs
|
||||
index: specs/.pi-map.index.md
|
||||
map: specs/.pi-map.md
|
||||
## files
|
||||
- .openspec.yaml
|
||||
- design.md
|
||||
- proposal.md
|
||||
- tasks.md
|
||||
## links
|
||||
index: ./.pi-map.index.md
|
||||
map: ./.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,36 @@
|
||||
# .
|
||||
dir: .
|
||||
|
||||
index: ./.pi-map.index.md
|
||||
|
||||
## Project Map Protocol
|
||||
|
||||
1. Read this protocol and the root `.pi-map.index.md` first.
|
||||
2. Use `index:` / `map:` references to open relevant directory indexes and maps.
|
||||
3. Load indexes before rich maps during task-start navigation.
|
||||
4. Read the local rich map and actual source before editing.
|
||||
5. Treat non-empty `## dirty` sections in either artifact as stale.
|
||||
6. If source and generated artifacts disagree, trust source.
|
||||
7. If map and index disagree, trust neither blindly; verify from source and regenerate the pair.
|
||||
8. After editing source, run `project_map_patch` for each changed file.
|
||||
9. Before broad architectural claims or final handoff, run `project_map_validate` when freshness matters.
|
||||
|
||||
Trust boundary: index routes, map orients, source decides.
|
||||
|
||||
## role
|
||||
Frontend architecture specification for real-time session progress tracking using Server-Sent Events and shared React state management.
|
||||
## files
|
||||
- .openspec.yaml | Defines an OpenAPI specification metadata file with schema type and creation date
|
||||
- design.md | Design document for frontend-only refactoring of session lifecycle progress tracking and live UI updates using SSE events and shared React context. | dep: React, SSE (useEvents), SessionsContext, SessionOperationsContext, AppShell, DashboardPage, SessionsPage, CreateSessionForm, InstanceList, useInstanceActions, ToolStarter, StartToolFAB, EventToastBridge
|
||||
- proposal.md | Proposes a frontend architecture change to add real-time session progress tracking via SSE and shared state across the application. | dep: React, SSE events, state management, AppShell, DashboardPage, SessionsPage, use-instance-actions, CreateSessionForm, SessionCard, InstanceCard
|
||||
- tasks.md | A task checklist for refactoring a web application's session management to use shared state, a global progress panel, and removing legacy progress overlays. | dep: React, state/sessions.tsx, state/session-operations.tsx, useEvents, use-instance-actions.ts, AppShell, SessionsPage, DashboardPage, tool-starter.tsx, start-tool-fab.tsx, instance-list.tsx, create-session-form.tsx, session-card.tsx
|
||||
## arch
|
||||
Documentation-driven design package using OpenAPI metadata, markdown specifications (design/proposal/tasks), and event-driven UI patterns with SSE, shared React context, and global progress panel replacing legacy overlay components.
|
||||
## tags
|
||||
tsx, session, progress, react, state, design, shared, appshell
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,71 @@
|
||||
# Design: Tool Session Progress and Live Updates
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Provide structured, real-time progress feedback for every tool lifecycle action.
|
||||
- Ensure all session lists (nav, dashboard, sessions page) update immediately after create/delete.
|
||||
- Remove blocking and card-dimming overlays that hide context and provide no step detail.
|
||||
- Keep the change frontend-only, reusing existing SSE and session APIs.
|
||||
|
||||
**Non-Goals:**
|
||||
- No new backend endpoints or event types.
|
||||
- No changes to the actual Docker/container orchestration logic.
|
||||
- No redesign of the session card layout beyond action/progress affordances.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision: Global progress panel in the corner
|
||||
A fixed panel (bottom-right desktop, bottom sheet style on mobile) lists in-flight operations. Each operation shows:
|
||||
- Action icon + session display name
|
||||
- Current step label derived from the latest SSE event
|
||||
- A compact stepper: Created → Building → Starting → Probing → Ready/Error
|
||||
- Dismiss button once settled
|
||||
|
||||
This is non-blocking, works across pages, and does not interfere with the modal create flow.
|
||||
|
||||
### Decision: SSE event-driven updates
|
||||
The panel subscribes to `useEvents`. When an operation is started we record `instanceId` + `action`. Incoming events that match a tracked instance update the operation's message, status, and step. Events handled:
|
||||
- `instance.created`, `instance.started`, `instance.restarted` → advance
|
||||
- `instance.health_changed` with `status=running` → complete success
|
||||
- `instance.error` → complete error
|
||||
- `instance.stopped` → complete for stop action
|
||||
- `instance.deleted` → complete for delete action
|
||||
|
||||
### Decision: Shared session state
|
||||
`SessionsContext` is promoted from a nav-only data holder to the authoritative session list:
|
||||
- Holds `sessions`, `isLoading`, `error`, `refreshSessions()`.
|
||||
- Provides `addOrUpdateSession`, `removeSession` for optimistic updates.
|
||||
- `AppShell`, `DashboardPage`, and `SessionsPage` read from this context instead of fetching independently.
|
||||
|
||||
### Decision: Optimistic create/delete updates
|
||||
- **Create**: after the API returns a pending instance, add it to shared state and start tracking. Subsequent SSE events update its status.
|
||||
- **Delete**: remove from shared state as soon as the API succeeds; the progress panel tracks the action until the `instance.deleted` event confirms it.
|
||||
- **Other actions**: keep the existing per-action busy flag on the card for button disabled states, but the panel provides the detailed progress.
|
||||
|
||||
### Decision: Remove legacy overlays
|
||||
- Delete `loading-overlay` and workflow step markup from `CreateSessionForm`.
|
||||
- Remove `session-busy-overlay` and `instance-busy-overlay` (the dimming overlays), but keep button disabled states and small inline spinners.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**Risk: Shared context causes extra re-renders**
|
||||
→ Mitigation: context value is memoized; lists use the same data they already fetched.
|
||||
|
||||
**Risk: SSE events arriving before operation is tracked**
|
||||
→ Mitigation: start tracking before calling the create/start API; for deletes the removal is optimistic and the panel reconciles on the event.
|
||||
|
||||
**Risk: Duplicate feedback between panel and toasts**
|
||||
→ Mitigation: panel shows in-flight steps; toasts remain for terminal success/error only. Existing `EventToastBridge` logic is left largely unchanged.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Extend `SessionsContext` with loading/error/refresh/update helpers.
|
||||
2. Create `SessionOperationsContext` + `SessionProgressPanel` and render it in `AppShell`.
|
||||
3. Update `useInstanceActions` to use shared state and start/stop tracking operations.
|
||||
4. Update `ToolStarter`/`StartToolFAB` to add pending sessions and start tracking.
|
||||
5. Update `CreateSessionForm` to remove overlay and report status to parent.
|
||||
6. Update `InstanceList` to refresh shared state after create/delete.
|
||||
7. Update `SessionsPage` and `DashboardPage` to consume shared context.
|
||||
8. Remove legacy overlay styles.
|
||||
9. Run typecheck, lint, and tests.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Tool Session Progress Indication and Live Updates
|
||||
|
||||
## Why
|
||||
|
||||
Creating or deleting a tool session currently leaves the UI out of sync. After starting a tool from the floating action button, the new session does not appear on `/sessions` or the dashboard until the user manually refreshes or the 30-second poll fires. The existing progress feedback is also poor: `CreateSessionForm` shows a blocking overlay with only two static messages ("Creating instance…", "Starting container…"), and `SessionCard`/`InstanceCard` dim the whole card with a generic spinner. Users cannot see real backend progress (building, starting, probing, running, error) and receive no confirmation when an action finishes.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Introduce a **global session progress panel** that tracks all lifecycle actions (create, start, stop, restart, delete, recreate tunnel) using the existing SSE event stream (`instance.created`, `instance.started`, `instance.health_changed`, `instance.error`, `instance.stopped`, `instance.deleted`, `instance.restarted`).
|
||||
- Make lists update **immediately** after create/delete by sharing session state across `AppShell`, `DashboardPage`, and `SessionsPage`.
|
||||
- Remove the blocking full-screen overlay in `CreateSessionForm` and the card-level busy overlays; replace them with minimal disabled/spinner states and the global panel.
|
||||
- Keep existing toast notifications for terminal states (success/error) while the panel handles in-flight progress.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `session-progress-panel`: Global, non-blocking progress UI for tool lifecycle actions driven by SSE events.
|
||||
- `shared-session-state`: Centralized session list used by navigation, dashboard, and sessions page.
|
||||
|
||||
### Modified Capabilities
|
||||
- `session-lifecycle-ux`: Delete and stop actions now update shared state immediately; create/start actions add a pending session and show progress.
|
||||
- `sessions-hub`: Dashboard and sessions lists reflect new/deleted sessions without manual refresh.
|
||||
|
||||
## Impact
|
||||
|
||||
- Frontend: new `state/session-operations.tsx`, new `components/features/session/session-progress-panel.tsx`, updates to `state/sessions.tsx`, `AppShell`, `SessionsPage`, `DashboardPage`, `hooks/use-instance-actions.ts`, `components/features/tool/instance-list.tsx`, `components/features/tool/tool-starter.tsx`, `components/features/session/create-session-form.tsx`, `components/features/session/session-card.tsx`, and styles.
|
||||
- No API changes: relies on existing lifecycle events and `/users/me/sessions`.
|
||||
|
||||
## Quality Gates
|
||||
|
||||
- `npm run typecheck`
|
||||
- `npm run lint`
|
||||
- Existing frontend tests still pass
|
||||
- Manual verification: create and delete sessions from the FAB and repository detail page; verify panel updates and lists refresh without manual reload.
|
||||
@@ -0,0 +1,23 @@
|
||||
# specs (index)
|
||||
dir: specs
|
||||
|
||||
## role
|
||||
Contains specification documents and design artifacts that define system requirements, APIs, and behavioral contracts for the project.
|
||||
## parent
|
||||
index: ./.pi-map.index.md
|
||||
map: ./.pi-map.md
|
||||
## children
|
||||
- specs/session-lifecycle-ux
|
||||
index: specs/session-lifecycle-ux/.pi-map.index.md
|
||||
map: specs/session-lifecycle-ux/.pi-map.md
|
||||
- specs/sessions-hub
|
||||
index: specs/sessions-hub/.pi-map.index.md
|
||||
map: specs/sessions-hub/.pi-map.md
|
||||
## files
|
||||
## links
|
||||
index: specs/.pi-map.index.md
|
||||
map: specs/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,18 @@
|
||||
# specs
|
||||
dir: specs
|
||||
|
||||
index: specs/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Contains specification documents and design artifacts that define system requirements, APIs, and behavioral contracts for the project.
|
||||
## files
|
||||
## arch
|
||||
Documentation-driven architecture using structured specifications (likely OpenAPI/Protobuf schemas, RFCs, or design docs) to establish interfaces before implementation, serving as the source of truth for cross-service contracts and client generation.
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# specs/session-lifecycle-ux (index)
|
||||
dir: specs/session-lifecycle-ux
|
||||
|
||||
## role
|
||||
Defines UI/UX requirements for real-time session lifecycle feedback using server-sent events with non-blocking progress indicators and optimistic updates.
|
||||
## parent
|
||||
index: specs/.pi-map.index.md
|
||||
map: specs/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- spec.md
|
||||
## links
|
||||
index: specs/session-lifecycle-ux/.pi-map.index.md
|
||||
map: specs/session-lifecycle-ux/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# specs/session-lifecycle-ux
|
||||
dir: specs/session-lifecycle-ux
|
||||
|
||||
index: specs/session-lifecycle-ux/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Defines UI/UX requirements for real-time session lifecycle feedback using server-sent events with non-blocking progress indicators and optimistic updates.
|
||||
## files
|
||||
- spec.md | Specifies UI/UX requirements for non-blocking progress indicators and optimistic updates for session lifecycle actions using SSE events. | dep: SSE, global progress indicator, shared state management, API
|
||||
## arch
|
||||
Event-driven reactive UX pattern with SSE streaming for asynchronous progress tracking and optimistic state management for immediate user feedback.
|
||||
## tags
|
||||
sse, spec, specifies, requirements, non, blocking, progress, indicators
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,44 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Lifecycle actions show structured progress
|
||||
The system SHALL display non-blocking, structured progress feedback for every tool lifecycle action (create, start, stop, restart, delete, recreate tunnel).
|
||||
|
||||
#### Scenario: Create a new session
|
||||
- **WHEN** the user creates a session
|
||||
- **THEN** a global progress indicator appears showing the current backend step
|
||||
- **AND** the indicator advances through Created, Building, Starting, Probing, and Running/Error based on SSE events
|
||||
|
||||
#### Scenario: Delete a session
|
||||
- **WHEN** the user deletes a session
|
||||
- **THEN** the session disappears from the current list immediately
|
||||
- **AND** the global progress indicator shows "Deleting…" until the backend confirms deletion via SSE or API response
|
||||
|
||||
#### Scenario: Stop or restart a session
|
||||
- **WHEN** the user stops or restarts a session
|
||||
- **THEN** the global progress indicator shows the action in progress
|
||||
- **AND** the indicator updates when the backend publishes the corresponding SSE event
|
||||
|
||||
### Requirement: Remove blocking progress overlays
|
||||
The system SHALL NOT dim the entire form or card with a generic spinner while an action is in progress.
|
||||
|
||||
#### Scenario: Create session form submission
|
||||
- **WHEN** the create session form is submitted
|
||||
- **THEN** form controls are disabled
|
||||
- **AND** no full-screen overlay blocks the rest of the application
|
||||
- **AND** progress is shown in the global progress panel
|
||||
|
||||
#### Scenario: Card action in progress
|
||||
- **WHEN** a session card action is triggered
|
||||
- **THEN** the relevant button is disabled or shows a small inline spinner
|
||||
- **AND** the card itself remains fully visible and interactive for other sessions
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Deleted sessions disappear from UI immediately
|
||||
**FROM:** The system SHALL update the frontend state immediately after a session is successfully deleted.
|
||||
**TO:** The system SHALL remove the session from all visible lists optimistically when the delete API call succeeds, and reconcile via shared state.
|
||||
|
||||
#### Scenario: Delete session from any page
|
||||
- **WHEN** the user deletes a session
|
||||
- **THEN** the session is removed from the nav sidebar, dashboard, and sessions page without a reload
|
||||
- **AND** a failure re-adds the session to the list and shows an error
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# specs/sessions-hub (index)
|
||||
dir: specs/sessions-hub
|
||||
|
||||
## role
|
||||
Defines requirements for a shared global session state system that enables real-time UI synchronization across dashboard, sessions page, and navigation components when sessions change.
|
||||
## parent
|
||||
index: specs/.pi-map.index.md
|
||||
map: specs/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- spec.md
|
||||
## links
|
||||
index: specs/sessions-hub/.pi-map.index.md
|
||||
map: specs/sessions-hub/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,19 @@
|
||||
# specs/sessions-hub
|
||||
dir: specs/sessions-hub
|
||||
|
||||
index: specs/sessions-hub/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Defines requirements for a shared global session state system that enables real-time UI synchronization across dashboard, sessions page, and navigation components when sessions change.
|
||||
## files
|
||||
- spec.md | Defines requirements for implementing a shared global session state across dashboard, sessions page, and navigation to enable immediate UI updates when sessions are created or modified. | dep: SessionsContext, React Context API, sessions API (`/users/me/sessions`)
|
||||
## arch
|
||||
Specification-driven architecture using a centralized hub pattern with reactive state propagation to decoupled consumers.
|
||||
## tags
|
||||
sessions, spec, defines, requirements, implementing, shared, global, session
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,33 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: New sessions appear in lists immediately
|
||||
The system SHALL add a newly created session to every session list as soon as the creation API returns.
|
||||
|
||||
#### Scenario: Create session from floating button
|
||||
- **WHEN** the user starts a tool from the global floating action button
|
||||
- **THEN** the modal closes
|
||||
- **AND** the new session appears on the dashboard and sessions page with status "pending" or "building"
|
||||
- **AND** the active session count in the navigation updates immediately
|
||||
|
||||
#### Scenario: Create session from repository detail
|
||||
- **WHEN** the user launches a tool inside a repository/workspace detail page
|
||||
- **THEN** the new session appears in the repository instance list
|
||||
- **AND** the global session lists update without requiring a manual refresh
|
||||
|
||||
### Requirement: Global session state is authoritative
|
||||
The system SHALL use a single shared session state for the navigation sidebar, dashboard, and sessions page.
|
||||
|
||||
#### Scenario: Shared state updated
|
||||
- **WHEN** a session is added, removed, or changed
|
||||
- **THEN** the navigation badge, dashboard, and sessions page all reflect the change
|
||||
- **AND** no page reload is required
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Sessions page loads sessions
|
||||
**FROM:** Sessions page fetches `/users/me/sessions` independently on mount.
|
||||
**TO:** Sessions page reads sessions from the shared `SessionsContext`; the context fetches on mount and exposes a refresh function.
|
||||
|
||||
### Requirement: Dashboard page loads sessions
|
||||
**FROM:** Dashboard page fetches `/users/me/sessions` independently on mount.
|
||||
**TO:** Dashboard page reads sessions from the shared `SessionsContext`.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Tasks: Tool Session Progress and Live Updates
|
||||
|
||||
## 1. Shared session state
|
||||
|
||||
- [x] 1.1 Extend `state/sessions.tsx` to hold `sessions`, `isLoading`, `error`, and `refreshSessions`, `addOrUpdateSession`, `removeSession` helpers.
|
||||
- [x] 1.2 Ensure `AppShell` uses the extended context and no longer needs a separate local load.
|
||||
- [x] 1.3 Update `SessionsPage` to read sessions from context and call `refreshSessions` after mutations.
|
||||
- [x] 1.4 Update `DashboardPage` to read sessions from context and call `refreshSessions` after mutations.
|
||||
|
||||
## 2. Global progress panel
|
||||
|
||||
- [x] 2.1 Create `state/session-operations.tsx` with an `Operation` model and `useSessionOperations` hook/API.
|
||||
- [x] 2.2 Create `components/features/session/session-progress-panel.tsx` that subscribes to `useEvents`, matches events to tracked operations, and renders a fixed panel.
|
||||
- [x] 2.3 Render `SessionProgressPanel` in `AppShell` so it is visible on every page.
|
||||
- [x] 2.4 Add minimal styles for the progress panel (desktop corner + mobile bottom bar).
|
||||
|
||||
## 3. Integrate actions with progress and shared state
|
||||
|
||||
- [x] 3.1 Update `hooks/use-instance-actions.ts` to remove deleted sessions optimistically from shared state, refresh on completion, and track start/stop/restart/delete/recreate-tunnel operations.
|
||||
- [x] 3.2 Update `components/features/tool/tool-starter.tsx` to add the new pending session to shared state and start tracking the create/start operation; close modal immediately.
|
||||
- [x] 3.3 Update `components/features/tool/start-tool-fab.tsx` to close modal and rely on the panel/toasts for feedback.
|
||||
- [x] 3.4 Update `components/features/tool/instance-list.tsx` to refresh shared sessions after create/delete.
|
||||
|
||||
## 4. Remove legacy progress overlays
|
||||
|
||||
- [x] 4.1 Remove `loading-overlay` and workflow step UI from `components/features/session/create-session-form.tsx`; keep form disabled during submit.
|
||||
- [x] 4.2 Remove `session-busy-overlay` from `components/features/session/session-card.tsx`; keep button disabled states and inline spinner.
|
||||
- [x] 4.3 Remove `instance-busy-overlay` from `components/features/tool/instance-list.tsx`; keep button disabled states and inline spinner.
|
||||
- [x] 4.4 Remove unused overlay CSS classes or repurpose them.
|
||||
|
||||
## 5. Verification
|
||||
|
||||
- [x] 5.1 Run `npm run typecheck` in `apps/web`.
|
||||
- [x] 5.2 Run `npm run lint` in `apps/web`.
|
||||
- [x] 5.3 Run frontend tests (`npm test -- --run` or equivalent).
|
||||
- [ ] 5.4 Manually verify: create from FAB, create from repository detail, delete from sessions page, stop/restart from dashboard.
|
||||
- [x] 5.5 Run `project_map_patch` for every edited source file.
|
||||
Reference in New Issue
Block a user