+
+
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
============================================ */