diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx index 73ec6b0..2e6abf7 100644 --- a/apps/web/src/components/app-shell.tsx +++ b/apps/web/src/components/app-shell.tsx @@ -8,10 +8,8 @@ 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"; @@ -87,12 +85,10 @@ export const AppShell = () => { - - -
- -
-
+ +
+ +
@@ -103,10 +99,8 @@ export const AppShell = () => { - - - -
+ +
Headquarter @@ -180,7 +174,6 @@ export const AppShell = () => { )}
- diff --git a/apps/web/src/components/features/session/session-progress-panel.tsx b/apps/web/src/components/features/session/session-progress-panel.tsx deleted file mode 100644 index 142da1e..0000000 --- a/apps/web/src/components/features/session/session-progress-panel.tsx +++ /dev/null @@ -1,115 +0,0 @@ -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/tool-starter.tsx b/apps/web/src/components/features/tool/tool-starter.tsx index 20dca36..5a20337 100644 --- a/apps/web/src/components/features/tool/tool-starter.tsx +++ b/apps/web/src/components/features/tool/tool-starter.tsx @@ -9,7 +9,6 @@ import { } 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"; @@ -25,7 +24,6 @@ export function ToolStarter({ onCancel, }: ToolStarterProps) { const { addOrUpdateSession } = useSessions(); - const { startOperation } = useSessionOperations(); const [toolTypes, setToolTypes] = useState([]); const [toolTypesLoading, setToolTypesLoading] = useState(true); const [toolTypesError, setToolTypesError] = useState(null); @@ -156,7 +154,6 @@ export function ToolStarter({ 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"); @@ -171,7 +168,6 @@ export function ToolStarter({ onStarted, toolTypes, addOrUpdateSession, - startOperation, ]); return ( diff --git a/apps/web/src/hooks/use-events.test.ts b/apps/web/src/hooks/use-events.test.ts index 99081ef..582b45b 100644 --- a/apps/web/src/hooks/use-events.test.ts +++ b/apps/web/src/hooks/use-events.test.ts @@ -30,17 +30,25 @@ describe("useEvents", () => { return EventSource.OPEN; }, url: "http://localhost:8000/events/stream", - addEventListener: vi.fn((type: string, handler: (e: MessageEvent) => void) => { - if (!listeners.has(type)) listeners.set(type, new Set()); - listeners.get(type)!.add(handler); - }), - removeEventListener: vi.fn((type: string, handler: (e: MessageEvent) => void) => { - listeners.get(type)?.delete(handler); - }), + addEventListener: vi.fn( + (type: string, handler: (e: MessageEvent) => void) => { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)!.add(handler); + }, + ), + removeEventListener: vi.fn( + (type: string, handler: (e: MessageEvent) => void) => { + listeners.get(type)?.delete(handler); + }, + ), emit: (type: string, data: string) => { - listeners.get(type)?.forEach((handler) => handler({ data } as MessageEvent)); + listeners + .get(type) + ?.forEach((handler) => handler({ data } as MessageEvent)); }, - } as unknown as EventSource & { emit: (type: string, data: string) => void }; + } as unknown as EventSource & { + emit: (type: string, data: string) => void; + }; mockedCreateEventSource.mockReturnValue(mockEs); mockedProbeEventStreamStatus.mockResolvedValue(null); }); diff --git a/apps/web/src/hooks/use-instance-actions.ts b/apps/web/src/hooks/use-instance-actions.ts index 6194912..ce2dc61 100644 --- a/apps/web/src/hooks/use-instance-actions.ts +++ b/apps/web/src/hooks/use-instance-actions.ts @@ -8,7 +8,6 @@ import { } from "../api/sessions"; import type { Session } from "../api/sessions"; import { useSessions } from "../state/sessions"; -import { useSessionOperations } from "../state/session-operations"; interface UseInstanceActionsOptions { onRefresh: () => Promise; @@ -33,7 +32,6 @@ export function useInstanceActions( ): UseInstanceActionsReturn { const { onRefresh } = options; const { removeSession } = useSessions(); - const { startOperation, completeOperation } = useSessionOperations(); const [loadingSessionId, setLoadingSessionId] = useState(null); const [dirtyDeleteSession, setDirtyDeleteSession] = useState( null, @@ -66,7 +64,6 @@ 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, @@ -74,20 +71,17 @@ export function useInstanceActions( session.id, ); await onRefresh(); - } catch { - completeOperation(session.id, "start", "error"); } finally { setLoadingSessionId(null); } }, - [loadingSessionId, onRefresh, startOperation, completeOperation], + [loadingSessionId, onRefresh], ); 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, @@ -95,20 +89,17 @@ export function useInstanceActions( session.id, ); await onRefresh(); - } catch { - completeOperation(session.id, "stop", "error"); } finally { setLoadingSessionId(null); } }, - [loadingSessionId, onRefresh, startOperation, completeOperation], + [loadingSessionId, onRefresh], ); 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, @@ -120,7 +111,6 @@ export function useInstanceActions( removeSession(session.id); await onRefresh(); } catch (error) { - completeOperation(session.id, "delete", "error"); const axiosError = error as { response?: { status?: number; @@ -139,20 +129,13 @@ export function useInstanceActions( setLoadingSessionId(null); } }, - [ - loadingSessionId, - onRefresh, - removeSession, - startOperation, - completeOperation, - ], + [loadingSessionId, onRefresh, removeSession], ); 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, @@ -164,26 +147,17 @@ export function useInstanceActions( setDirtyDeleteFiles([]); removeSession(session.id); await onRefresh(); - } catch { - completeOperation(session.id, "delete", "error"); } finally { setLoadingSessionId(null); } }, - [ - loadingSessionId, - onRefresh, - removeSession, - startOperation, - completeOperation, - ], + [loadingSessionId, onRefresh, removeSession], ); 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, @@ -191,18 +165,16 @@ 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, startOperation, completeOperation], + [loadingSessionId, onRefresh], ); const handleRename = useCallback( diff --git a/apps/web/src/pages/DashboardPage.test.tsx b/apps/web/src/pages/DashboardPage.test.tsx index 585445f..bab6a91 100644 --- a/apps/web/src/pages/DashboardPage.test.tsx +++ b/apps/web/src/pages/DashboardPage.test.tsx @@ -5,7 +5,6 @@ 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(); @@ -59,9 +58,7 @@ describe("HomePage", () => { render( - - , ); @@ -81,9 +78,7 @@ describe("HomePage", () => { render( - - , ); diff --git a/apps/web/src/state/session-operations.tsx b/apps/web/src/state/session-operations.tsx deleted file mode 100644 index 63c64db..0000000 --- a/apps/web/src/state/session-operations.tsx +++ /dev/null @@ -1,259 +0,0 @@ -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/styles/utilities.css b/apps/web/src/styles/utilities.css index 5945f22..a6a6922 100644 --- a/apps/web/src/styles/utilities.css +++ b/apps/web/src/styles/utilities.css @@ -383,129 +383,6 @@ a.nav-item, align-items: center; } -/* ============================================ - 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; -} - -.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: space-between; - gap: var(--space-2); -} - -.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-operation-steps { - display: flex; - gap: var(--space-2); - margin-top: var(--space-2); -} - -.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-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; -} - -@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; - } -} - /* ============================================ Terminal Styles ============================================ */