diff --git a/.gitignore b/.gitignore index 2809674..0dc154a 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ Thumbs.db .sisyphus/ .pi-lens/ minerv3/ +.cache/ diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx index 5e07e69..73ec6b0 100644 --- a/apps/web/src/components/app-shell.tsx +++ b/apps/web/src/components/app-shell.tsx @@ -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 ( - -
- -
+ + +
+ +
+
@@ -119,81 +103,84 @@ export const AppShell = () => { - -
-
- - Headquarter - -
- - - {user?.name ?? "User"} + + + +
+
+ + Headquarter - +
+ + + {user?.name ?? "User"} + + +
+
+ +
+ {!isMobile && ( + + )} + +
+ +
-
-
- {!isMobile && ( - + {isMobile && ( + s.status === "running").length + } + /> )} - -
- -
+
- - {isMobile && ( - s.status === "running").length - } - /> - )} - -
+
diff --git a/apps/web/src/components/features/session/create-session-form.tsx b/apps/web/src/components/features/session/create-session-form.tsx index 89e4188..9fb1e78 100644 --- a/apps/web/src/components/features/session/create-session-form.tsx +++ b/apps/web/src/components/features/session/create-session-form.tsx @@ -1,575 +1,564 @@ import { useState, useEffect } from "react"; import { Icon } from "../../icon"; -import { createInstance, startInstance, type ToolInstance } from "../../../api/sessions"; +import { + createInstance, + startInstance, + type ToolInstance, +} from "../../../api/sessions"; import type { Project } from "../../../types"; -import { listRepositoryBranches, type GitRepository, type Branch } from "../../../api/git-repositories"; +import { + listRepositoryBranches, + type GitRepository, + type Branch, +} from "../../../api/git-repositories"; import type { ToolType } from "../../../api/tool-types"; import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys"; -import { listConfigProfiles, type ConfigProfile } from "../../../api/config-profiles"; +import { + listConfigProfiles, + type ConfigProfile, +} from "../../../api/config-profiles"; interface CreateSessionFormProps { - projects: Project[]; - repositories: GitRepository[]; - toolTypes: ToolType[]; - fixedProjectId?: string; - fixedRepoId?: string; - projectName?: string; - repoName?: string; - showCloneMode?: boolean; - showFixedFields?: boolean; - onProjectChange?: (projectId: string) => void; - onSuccess?: (instance: ToolInstance) => void; - onCancel?: () => void; - submitLabel?: string; - className?: string; + projects: Project[]; + repositories: GitRepository[]; + toolTypes: ToolType[]; + fixedProjectId?: string; + fixedRepoId?: string; + projectName?: string; + repoName?: string; + showCloneMode?: boolean; + showFixedFields?: boolean; + onProjectChange?: (projectId: string) => void; + onSuccess?: (instance: ToolInstance) => void; + onCancel?: () => void; + submitLabel?: string; + className?: string; } export const CreateSessionForm = ({ - projects, - repositories, - toolTypes, - fixedProjectId, - fixedRepoId, - projectName, - repoName, - showCloneMode = true, - showFixedFields = true, - onProjectChange, - onSuccess, - onCancel, - submitLabel = "Create Session", - className = "", + projects, + repositories, + toolTypes, + fixedProjectId, + fixedRepoId, + projectName, + repoName, + showCloneMode = true, + showFixedFields = true, + onProjectChange, + onSuccess, + onCancel, + submitLabel = "Create Session", + className = "", }: CreateSessionFormProps) => { - const [selectedProject, setSelectedProject] = useState(fixedProjectId || ""); - const [selectedRepo, setSelectedRepo] = useState(fixedRepoId || ""); - const [selectedToolType, setSelectedToolType] = useState(""); - const [displayName, setDisplayName] = useState(""); - const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount"); - const [branch, setBranch] = useState("main"); - const [sshKeys, setSshKeys] = useState([]); - const [configProfiles, setConfigProfiles] = useState([]); - const [selectedConfigProfile, setSelectedConfigProfile] = useState(""); - const [selectedSshKeyIds, setSelectedSshKeyIds] = useState([]); - - const [branches, setBranches] = useState([]); - const [isLoadingBranches, setIsLoadingBranches] = useState(false); - const [isCreatingNewBranch, setIsCreatingNewBranch] = useState(false); - const [newBranchName, setNewBranchName] = useState(""); - const [baseBranch, setBaseBranch] = useState(""); - - const [status, setStatus] = useState<"idle" | "creating" | "error">("idle"); - const [progress, setProgress] = useState(""); - const [error, setError] = useState(null); + const [selectedProject, setSelectedProject] = useState(fixedProjectId || ""); + const [selectedRepo, setSelectedRepo] = useState(fixedRepoId || ""); + const [selectedToolType, setSelectedToolType] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount"); + const [branch, setBranch] = useState("main"); + const [sshKeys, setSshKeys] = useState([]); + const [configProfiles, setConfigProfiles] = useState([]); + const [selectedConfigProfile, setSelectedConfigProfile] = useState(""); + const [selectedSshKeyIds, setSelectedSshKeyIds] = useState([]); - // Load SSH keys - useEffect(() => { - const loadKeys = async () => { - try { - const keys = await listSSHKeys(); - setSshKeys(keys); - } catch { - // ignore - } - }; - void loadKeys(); - }, []); + const [branches, setBranches] = useState([]); + const [isLoadingBranches, setIsLoadingBranches] = useState(false); + const [isCreatingNewBranch, setIsCreatingNewBranch] = useState(false); + const [newBranchName, setNewBranchName] = useState(""); + const [baseBranch, setBaseBranch] = useState(""); - // Load config profiles when tool type is selected - useEffect(() => { - const projectId = fixedProjectId || selectedProject; - if (!selectedToolType || !projectId) { - setConfigProfiles([]); - setSelectedConfigProfile(""); - return; - } - const loadProfiles = async () => { - try { - const profiles = await listConfigProfiles(projectId, selectedToolType); - setConfigProfiles(profiles); - // Auto-select default if available - const defaultProfile = profiles.find((p) => p.is_default); - if (defaultProfile) { - setSelectedConfigProfile(defaultProfile.id); - } - } catch { - // ignore - } - }; - void loadProfiles(); - }, [selectedToolType, selectedProject, fixedProjectId]); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); - // Load branches when selected repo changes - useEffect(() => { - const projectId = fixedProjectId || selectedProject; - if (!selectedRepo || !projectId || !showCloneMode) { - setBranches([]); - return; - } - const loadBranches = async () => { - setIsLoadingBranches(true); - try { - const response = await listRepositoryBranches(projectId, selectedRepo); - setBranches(response.branches); - if (response.default_branch) { - setBranch(response.default_branch); - setBaseBranch(response.default_branch); - } - } catch { - // ignore - } finally { - setIsLoadingBranches(false); - } - }; - void loadBranches(); - }, [selectedRepo, selectedProject, fixedProjectId, showCloneMode]); + // Load SSH keys + useEffect(() => { + const loadKeys = async () => { + try { + const keys = await listSSHKeys(); + setSshKeys(keys); + } catch { + // ignore + } + }; + void loadKeys(); + }, []); - // Filter repositories by selected project - const availableRepos = selectedProject - ? repositories.filter((r) => r.project_id === selectedProject) - : []; + // Load config profiles when tool type is selected + useEffect(() => { + const projectId = fixedProjectId || selectedProject; + if (!selectedToolType || !projectId) { + setConfigProfiles([]); + setSelectedConfigProfile(""); + return; + } + const loadProfiles = async () => { + try { + const profiles = await listConfigProfiles(projectId, selectedToolType); + setConfigProfiles(profiles); + // Auto-select default if available + const defaultProfile = profiles.find((p) => p.is_default); + if (defaultProfile) { + setSelectedConfigProfile(defaultProfile.id); + } + } catch { + // ignore + } + }; + void loadProfiles(); + }, [selectedToolType, selectedProject, fixedProjectId]); - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - setError(null); + // Load branches when selected repo changes + useEffect(() => { + const projectId = fixedProjectId || selectedProject; + if (!selectedRepo || !projectId || !showCloneMode) { + setBranches([]); + return; + } + const loadBranches = async () => { + setIsLoadingBranches(true); + try { + const response = await listRepositoryBranches(projectId, selectedRepo); + setBranches(response.branches); + if (response.default_branch) { + setBranch(response.default_branch); + setBaseBranch(response.default_branch); + } + } catch { + // ignore + } finally { + setIsLoadingBranches(false); + } + }; + void loadBranches(); + }, [selectedRepo, selectedProject, fixedProjectId, showCloneMode]); - const projectId = fixedProjectId || selectedProject; - const repoId = fixedRepoId || selectedRepo; + // Filter repositories by selected project + const availableRepos = selectedProject + ? repositories.filter((r) => r.project_id === selectedProject) + : []; - if (!projectId || !repoId || !selectedToolType) { - setError("Project, repository, and tool type are required"); - return; - } + const resetForm = () => { + if (!fixedProjectId) setSelectedProject(""); + if (!fixedRepoId) setSelectedRepo(""); + setSelectedToolType(""); + setDisplayName(""); + setCloneMode("mount"); + setBranch("main"); + setIsCreatingNewBranch(false); + setNewBranchName(""); + setBaseBranch(""); + setBranches([]); + setSelectedSshKeyIds([]); + setSelectedConfigProfile(""); + }; - if (showCloneMode && cloneMode === "clone") { - const repo = repositories.find((r) => r.id === repoId); - if (!repo?.ssh_key_id) { - setError("Repository must have an SSH key assigned for clone mode"); - return; - } - } + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); - setStatus("creating"); - setProgress("Creating instance..."); + const projectId = fixedProjectId || selectedProject; + const repoId = fixedRepoId || selectedRepo; - try { - const instance = await createInstance( - projectId, - repoId, - selectedToolType, - displayName || undefined, - showCloneMode ? cloneMode : undefined, - showCloneMode && cloneMode === "clone" - ? isCreatingNewBranch - ? baseBranch - : branch - : undefined, - showCloneMode && cloneMode === "clone" && isCreatingNewBranch - ? newBranchName - : undefined, - selectedConfigProfile || undefined, - selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined - ); + if (!projectId || !repoId || !selectedToolType) { + setError("Project, repository, and tool type are required"); + return; + } - setProgress("Starting container..."); - await startInstance( - projectId, - repoId, - instance.id, - selectedConfigProfile || undefined, - selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined - ); + if (showCloneMode && cloneMode === "clone") { + const repo = repositories.find((r) => r.id === repoId); + if (!repo?.ssh_key_id) { + setError("Repository must have an SSH key assigned for clone mode"); + return; + } + } - // Reset form - if (!fixedProjectId) setSelectedProject(""); - if (!fixedRepoId) setSelectedRepo(""); - setSelectedToolType(""); - setDisplayName(""); - setCloneMode("mount"); - setBranch("main"); - setIsCreatingNewBranch(false); - setNewBranchName(""); - setBaseBranch(""); - setBranches([]); - setSelectedSshKeyIds([]); - setStatus("idle"); - - onSuccess?.(instance); - } catch { - setStatus("error"); - setError("Failed to create session"); - setProgress(""); - } - }; + setIsSubmitting(true); - const isSubmitting = status === "creating"; + try { + const instance = await createInstance( + projectId, + repoId, + selectedToolType, + displayName || undefined, + showCloneMode ? cloneMode : undefined, + showCloneMode && cloneMode === "clone" + ? isCreatingNewBranch + ? baseBranch + : branch + : undefined, + showCloneMode && cloneMode === "clone" && isCreatingNewBranch + ? newBranchName + : undefined, + selectedConfigProfile || undefined, + selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined, + ); - // Determine which steps are active/unlocked - const hasProject = !!(fixedProjectId || selectedProject); - const hasRepo = !!(fixedRepoId || selectedRepo); - const hasToolType = !!selectedToolType; + await startInstance( + projectId, + repoId, + instance.id, + selectedConfigProfile || undefined, + selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined, + ); - const renderStep = ( - label: string, - number: number, - isActive: boolean, - isComplete: boolean, - children: React.ReactNode - ) => { - const stepClass = `workflow-step ${isActive ? "active" : ""} ${isComplete ? "complete" : ""}`; - return ( -
-
- {number} - {label} -
-
- {children} -
-
- ); - }; + resetForm(); + onSuccess?.(instance); + } catch { + setError("Failed to create session"); + } finally { + setIsSubmitting(false); + } + }; - return ( -
- {isSubmitting && ( -
-
- -

{progress || "Creating session..."}

-
-
- )} + const hasProject = !!(fixedProjectId || selectedProject); + const hasRepo = !!(fixedRepoId || selectedRepo); + const hasToolType = !!selectedToolType; -
- {/* Step 1: Project */} - {renderStep("Select Project", 1, true, hasProject, - fixedProjectId && showFixedFields ? ( - - ) : ( - - ) - )} + return ( +
+ + {/* Project */} +
+ + {fixedProjectId && showFixedFields ? ( + p.id === fixedProjectId)?.name || + "" + } + disabled + readOnly + /> + ) : ( + + )} +
- {/* Step 2: Repository */} - {hasProject && renderStep("Select Repository", 2, true, hasRepo, - fixedRepoId && showFixedFields ? ( - - ) : ( - - ) - )} + {/* Repository */} + {hasProject && ( +
+ + {fixedRepoId && showFixedFields ? ( + r.id === fixedRepoId)?.name || + "" + } + disabled + readOnly + /> + ) : ( + + )} +
+ )} - {/* Step 3: Tool Type */} - {hasRepo && renderStep("Select Tool", 3, true, hasToolType, - - )} + {/* Tool Type */} + {hasRepo && ( +
+ + +
+ )} - {/* Step 4: Config Profile */} - {hasToolType && renderStep("Config Profile (optional)", 4, true, false, - - )} + {/* Config Profile */} + {hasToolType && ( +
+ + +
+ )} - {/* Step 5: SSH Keys */} - {hasToolType && renderStep("SSH Keys (optional)", 5, true, false, -
-
- {sshKeys.length === 0 && ( - No SSH keys configured. - )} - {sshKeys.map((key) => ( - - ))} -
-
- Selected keys will be mounted into the container at ~/.ssh -
-
- )} + {/* SSH Keys */} + {hasToolType && ( +
+ +
+ {sshKeys.length === 0 && ( + No SSH keys configured. + )} + {sshKeys.map((key) => ( + + ))} +
+
+ Selected keys will be mounted into the container at ~/.ssh +
+
+ )} - {/* Step 6: Clone Mode & Branch */} - {showCloneMode && hasToolType && renderStep("Repository Access", 6, true, false, -
- + {/* Clone Mode & Branch */} + {showCloneMode && hasToolType && ( +
+ +
+ + +
- {cloneMode === "clone" && ( - <> - + {cloneMode === "clone" && ( + <> + - {isCreatingNewBranch && ( - <> - - - - )} + {isCreatingNewBranch && ( + <> + + + + )} - {selectedRepo && ( -
- {(() => { - const repo = repositories.find((r) => r.id === selectedRepo); - if (!repo) return null; - if (repo.ssh_key_id) { - const key = sshKeys.find((k) => k.id === repo.ssh_key_id); - return ( - - SSH key: {key?.name || "Assigned"} - - ); - } - return ( - - No SSH key assigned to this repository. Clone mode requires an SSH key. - - ); - })()} -
- )} - - )} -
- )} + {selectedRepo && ( +
+ {(() => { + const repo = repositories.find( + (r) => r.id === selectedRepo, + ); + if (!repo) return null; + if (repo.ssh_key_id) { + const key = sshKeys.find( + (k) => k.id === repo.ssh_key_id, + ); + return ( + + SSH key: {key?.name || "Assigned"} + + ); + } + return ( + + No SSH key assigned to this repository. Clone mode + requires an SSH key. + + ); + })()} +
+ )} + + )} +
+ )} - {/* Step 7: Display Name */} - {hasToolType && renderStep("Display Name (optional)", 7, true, !!displayName, - - )} + {/* Display Name */} + {hasToolType && ( +
+ + setDisplayName(e.target.value)} + placeholder="My Development Environment" + disabled={isSubmitting} + /> +
+ )} - {/* Error & Submit */} - {error &&

{error}

} + {/* Error & Submit */} + {error &&

{error}

} - {hasToolType && ( -
- {onCancel && ( - - )} - -
- )} - -
- ); + {hasToolType && ( +
+ {onCancel && ( + + )} + +
+ )} + +
+ ); }; diff --git a/apps/web/src/components/features/session/session-card.tsx b/apps/web/src/components/features/session/session-card.tsx index 58608f8..53d330e 100644 --- a/apps/web/src/components/features/session/session-card.tsx +++ b/apps/web/src/components/features/session/session-card.tsx @@ -105,12 +105,7 @@ export function SessionCard({ }; return ( -
- {isBusy && ( -
- -
- )} +
diff --git a/apps/web/src/components/features/session/session-progress-panel.tsx b/apps/web/src/components/features/session/session-progress-panel.tsx new file mode 100644 index 0000000..142da1e --- /dev/null +++ b/apps/web/src/components/features/session/session-progress-panel.tsx @@ -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 ( +
+
+
+ {isError ? ( + + ) : isDone ? ( + + ) : ( + + )} + {operation.displayName} +
+ {isDone && ( + + )} +
+

{operation.message}

+
+ {steps.map((step) => { + const active = operation.step >= step.index; + const current = operation.step === step.index && !isDone; + return ( + + {step.label} + + ); + })} +
+
+ ); +} + +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 ( +
+
+ Operations +
+
+ {visibleOperations.map((operation) => ( + dismissOperation(operation.id)} + /> + ))} +
+
+ ); +} diff --git a/apps/web/src/components/features/tool/instance-list.tsx b/apps/web/src/components/features/tool/instance-list.tsx index ee09f47..7496d37 100644 --- a/apps/web/src/components/features/tool/instance-list.tsx +++ b/apps/web/src/components/features/tool/instance-list.tsx @@ -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([]); 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 = ({ ) : (
{instances.map((instance) => ( -
- {busyInstanceId === instance.id && ( -
- -
- )} +
{instance.display_name}
diff --git a/apps/web/src/components/features/tool/tool-starter.tsx b/apps/web/src/components/features/tool/tool-starter.tsx index e5680f9..95dd48b 100644 --- a/apps/web/src/components/features/tool/tool-starter.tsx +++ b/apps/web/src/components/features/tool/tool-starter.tsx @@ -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([]); const [toolTypesLoading, setToolTypesLoading] = useState(true); const [toolTypesError, setToolTypesError] = useState(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 ( diff --git a/apps/web/src/hooks/use-instance-actions.ts b/apps/web/src/hooks/use-instance-actions.ts index 2c66bd0..6194912 100644 --- a/apps/web/src/hooks/use-instance-actions.ts +++ b/apps/web/src/hooks/use-instance-actions.ts @@ -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; @@ -30,6 +32,8 @@ export function useInstanceActions( options: UseInstanceActionsOptions, ): UseInstanceActionsReturn { const { onRefresh } = options; + const { removeSession } = useSessions(); + const { startOperation, completeOperation } = useSessionOperations(); const [loadingSessionId, setLoadingSessionId] = useState(null); const [dirtyDeleteSession, setDirtyDeleteSession] = useState( 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( diff --git a/apps/web/src/pages/DashboardPage.test.tsx b/apps/web/src/pages/DashboardPage.test.tsx index 33dcda6..585445f 100644 --- a/apps/web/src/pages/DashboardPage.test.tsx +++ b/apps/web/src/pages/DashboardPage.test.tsx @@ -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( - + + + + + , ); @@ -73,7 +80,11 @@ describe("HomePage", () => { render( - + + + + + , ); diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx index 515cc89..80fcec0 100644 --- a/apps/web/src/pages/DashboardPage.tsx +++ b/apps/web/src/pages/DashboardPage.tsx @@ -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("loading"); const [summary, setSummary] = useState(null); - const [sessions, setSessions] = useState([]); const [tunnelHealth, setTunnelHealth] = useState< Record >({}); @@ -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(() => { diff --git a/apps/web/src/pages/SessionsPage.tsx b/apps/web/src/pages/SessionsPage.tsx index c6ac630..c37ad44 100644 --- a/apps/web/src/pages/SessionsPage.tsx +++ b/apps/web/src/pages/SessionsPage.tsx @@ -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("loading"); - const [sessions, setSessions] = useState([]); const [lastSessionId, setLastSessionId] = useState(null); const [tunnelHealth, setTunnelHealth] = useState< Record >({}); - 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 (

Sessions

- {status === "loading" && } - {status === "error" && ( void loadSessions()} + message={error?.message ?? "Failed to load sessions"} + onRetry={() => void loadPageData()} /> )} - {status === "ready" && ( + {(status === "loading" || isPageLoading) && ( + + )} + + {status === "ready" && !isPageLoading && ( <> {/* Last Session */} {lastSession && ( diff --git a/apps/web/src/state/notifications.tsx b/apps/web/src/state/notifications.tsx index 1bb17cb..7a4c4d7 100644 --- a/apps/web/src/state/notifications.tsx +++ b/apps/web/src/state/notifications.tsx @@ -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); diff --git a/apps/web/src/state/session-operations.tsx b/apps/web/src/state/session-operations.tsx new file mode 100644 index 0000000..63c64db --- /dev/null +++ b/apps/web/src/state/session-operations.tsx @@ -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(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([]); + + 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(); + 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 ( + + {children} + + ); +}; + +export const useSessionOperations = (): SessionOperationsContextType => { + const context = useContext(SessionOperationsContext); + if (context === undefined) { + throw new Error( + "useSessionOperations must be used within a SessionOperationsProvider", + ); + } + return context; +}; diff --git a/apps/web/src/state/sessions.tsx b/apps/web/src/state/sessions.tsx index 5b0353c..5053269 100644 --- a/apps/web/src/state/sessions.tsx +++ b/apps/web/src/state/sessions.tsx @@ -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; + addOrUpdateSession: (session: Session) => void; + removeSession: (id: string) => void; } const SessionsContext = createContext(undefined); export const SessionsProvider = ({ children }: { children: ReactNode }) => { const [sessions, setSessions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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 ( - + {children} ); diff --git a/apps/web/src/styles/pages/sessions.css b/apps/web/src/styles/pages/sessions.css index b1ce221..ad6711f 100644 --- a/apps/web/src/styles/pages/sessions.css +++ b/apps/web/src/styles/pages/sessions.css @@ -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, diff --git a/apps/web/src/styles/utilities.css b/apps/web/src/styles/utilities.css index a84f962..5945f22 100644 --- a/apps/web/src/styles/utilities.css +++ b/apps/web/src/styles/utilities.css @@ -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; diff --git a/openspec/changes/tool-session-progress-and-updates/.openspec.yaml b/openspec/changes/tool-session-progress-and-updates/.openspec.yaml new file mode 100644 index 0000000..8fe2055 --- /dev/null +++ b/openspec/changes/tool-session-progress-and-updates/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-12 diff --git a/openspec/changes/tool-session-progress-and-updates/.pi-map.index.md b/openspec/changes/tool-session-progress-and-updates/.pi-map.index.md new file mode 100644 index 0000000..f8e7e2e --- /dev/null +++ b/openspec/changes/tool-session-progress-and-updates/.pi-map.index.md @@ -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 +- diff --git a/openspec/changes/tool-session-progress-and-updates/.pi-map.md b/openspec/changes/tool-session-progress-and-updates/.pi-map.md new file mode 100644 index 0000000..87677b0 --- /dev/null +++ b/openspec/changes/tool-session-progress-and-updates/.pi-map.md @@ -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 +- diff --git a/openspec/changes/tool-session-progress-and-updates/design.md b/openspec/changes/tool-session-progress-and-updates/design.md new file mode 100644 index 0000000..1b9ab24 --- /dev/null +++ b/openspec/changes/tool-session-progress-and-updates/design.md @@ -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. diff --git a/openspec/changes/tool-session-progress-and-updates/proposal.md b/openspec/changes/tool-session-progress-and-updates/proposal.md new file mode 100644 index 0000000..a4abde1 --- /dev/null +++ b/openspec/changes/tool-session-progress-and-updates/proposal.md @@ -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. diff --git a/openspec/changes/tool-session-progress-and-updates/specs/.pi-map.index.md b/openspec/changes/tool-session-progress-and-updates/specs/.pi-map.index.md new file mode 100644 index 0000000..b88dd99 --- /dev/null +++ b/openspec/changes/tool-session-progress-and-updates/specs/.pi-map.index.md @@ -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 +- diff --git a/openspec/changes/tool-session-progress-and-updates/specs/.pi-map.md b/openspec/changes/tool-session-progress-and-updates/specs/.pi-map.md new file mode 100644 index 0000000..d9f51e1 --- /dev/null +++ b/openspec/changes/tool-session-progress-and-updates/specs/.pi-map.md @@ -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 +- diff --git a/openspec/changes/tool-session-progress-and-updates/specs/session-lifecycle-ux/.pi-map.index.md b/openspec/changes/tool-session-progress-and-updates/specs/session-lifecycle-ux/.pi-map.index.md new file mode 100644 index 0000000..ad26a2f --- /dev/null +++ b/openspec/changes/tool-session-progress-and-updates/specs/session-lifecycle-ux/.pi-map.index.md @@ -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 +- diff --git a/openspec/changes/tool-session-progress-and-updates/specs/session-lifecycle-ux/.pi-map.md b/openspec/changes/tool-session-progress-and-updates/specs/session-lifecycle-ux/.pi-map.md new file mode 100644 index 0000000..2785c22 --- /dev/null +++ b/openspec/changes/tool-session-progress-and-updates/specs/session-lifecycle-ux/.pi-map.md @@ -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 +- diff --git a/openspec/changes/tool-session-progress-and-updates/specs/session-lifecycle-ux/spec.md b/openspec/changes/tool-session-progress-and-updates/specs/session-lifecycle-ux/spec.md new file mode 100644 index 0000000..091cab5 --- /dev/null +++ b/openspec/changes/tool-session-progress-and-updates/specs/session-lifecycle-ux/spec.md @@ -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 diff --git a/openspec/changes/tool-session-progress-and-updates/specs/sessions-hub/.pi-map.index.md b/openspec/changes/tool-session-progress-and-updates/specs/sessions-hub/.pi-map.index.md new file mode 100644 index 0000000..ddb9240 --- /dev/null +++ b/openspec/changes/tool-session-progress-and-updates/specs/sessions-hub/.pi-map.index.md @@ -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 +- diff --git a/openspec/changes/tool-session-progress-and-updates/specs/sessions-hub/.pi-map.md b/openspec/changes/tool-session-progress-and-updates/specs/sessions-hub/.pi-map.md new file mode 100644 index 0000000..526f2e4 --- /dev/null +++ b/openspec/changes/tool-session-progress-and-updates/specs/sessions-hub/.pi-map.md @@ -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 +- diff --git a/openspec/changes/tool-session-progress-and-updates/specs/sessions-hub/spec.md b/openspec/changes/tool-session-progress-and-updates/specs/sessions-hub/spec.md new file mode 100644 index 0000000..b5334d9 --- /dev/null +++ b/openspec/changes/tool-session-progress-and-updates/specs/sessions-hub/spec.md @@ -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`. diff --git a/openspec/changes/tool-session-progress-and-updates/tasks.md b/openspec/changes/tool-session-progress-and-updates/tasks.md new file mode 100644 index 0000000..57970bf --- /dev/null +++ b/openspec/changes/tool-session-progress-and-updates/tasks.md @@ -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.