Merge branch 'remove/session-operations-center' into dev

This commit is contained in:
Developer
2026-06-13 09:50:14 +00:00
8 changed files with 28 additions and 561 deletions
+6 -13
View File
@@ -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 = () => {
<EventProvider>
<ToastProvider>
<NotificationProvider>
<SessionOperationsProvider>
<EventToastBridge />
<div className="shell mobile-terminal-shell">
<Outlet />
</div>
</SessionOperationsProvider>
<EventToastBridge />
<div className="shell mobile-terminal-shell">
<Outlet />
</div>
</NotificationProvider>
</ToastProvider>
</EventProvider>
@@ -103,10 +99,8 @@ export const AppShell = () => {
<EventProvider>
<ToastProvider>
<NotificationProvider>
<SessionOperationsProvider>
<EventToastBridge />
<SessionProgressPanel />
<div className="shell">
<EventToastBridge />
<div className="shell">
<header className="shell-header">
<Link className="brand" to="/">
Headquarter
@@ -180,7 +174,6 @@ export const AppShell = () => {
)}
<StartToolFAB />
</div>
</SessionOperationsProvider>
</NotificationProvider>
</ToastProvider>
</EventProvider>
@@ -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 (
<div
className={`session-operation-item ${operation.status}`}
role="status"
aria-live="polite"
>
<div className="session-operation-header">
<div className="session-operation-title">
{isError ? (
<Icon name="error" size="sm" />
) : isDone ? (
<Icon name="success" size="sm" />
) : (
<Icon name="loading" size="sm" />
)}
<span className="session-operation-name">{operation.displayName}</span>
</div>
{isDone && (
<button
type="button"
className="session-operation-dismiss"
onClick={onDismiss}
aria-label="Dismiss"
>
<Icon name="close" size="sm" />
</button>
)}
</div>
<p className="session-operation-message">{operation.message}</p>
<div className="session-operation-steps">
{steps.map((step) => {
const active = operation.step >= step.index;
const current = operation.step === step.index && !isDone;
return (
<span
key={step.label}
className={`session-operation-step ${active ? "active" : ""} ${current ? "current" : ""}`}
>
{step.label}
</span>
);
})}
</div>
</div>
);
}
export function SessionProgressPanel() {
const { operations, updateOperationFromEvent, dismissOperation } =
useSessionOperations();
const { events } = useEventContext();
useEffect(() => {
if (events.length === 0) return;
const latestEvent = events[events.length - 1];
updateOperationFromEvent(latestEvent);
}, [events, updateOperationFromEvent]);
const visibleOperations = operations.filter(
(op) =>
op.status === "pending" ||
op.status === "active" ||
(op.status === "success" && Date.now() - op.createdAt < 5000) ||
op.status === "error",
);
if (visibleOperations.length === 0) return null;
return (
<div className="session-progress-panel" role="region" aria-label="Session operations">
<div className="session-progress-panel-header">
<span className="session-progress-panel-title">Operations</span>
</div>
<div className="session-progress-panel-list">
{visibleOperations.map((operation) => (
<OperationItem
key={operation.id}
operation={operation}
onDismiss={() => dismissOperation(operation.id)}
/>
))}
</div>
</div>
);
}
@@ -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<ToolType[]>([]);
const [toolTypesLoading, setToolTypesLoading] = useState(true);
const [toolTypesError, setToolTypesError] = useState<string | null>(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 (
+17 -9
View File
@@ -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);
});
+5 -33
View File
@@ -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<void>;
@@ -33,7 +32,6 @@ export function useInstanceActions(
): UseInstanceActionsReturn {
const { onRefresh } = options;
const { removeSession } = useSessions();
const { startOperation, completeOperation } = useSessionOperations();
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(
null,
@@ -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(
@@ -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(
<MemoryRouter>
<SessionsProvider>
<SessionOperationsProvider>
<HomePage />
</SessionOperationsProvider>
</SessionsProvider>
</MemoryRouter>,
);
@@ -81,9 +78,7 @@ describe("HomePage", () => {
render(
<MemoryRouter>
<SessionsProvider>
<SessionOperationsProvider>
<HomePage />
</SessionOperationsProvider>
</SessionsProvider>
</MemoryRouter>,
);
-259
View File
@@ -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<SessionOperationsContextType | undefined>(undefined);
let operationIdCounter = 0;
function actionLabel(type: OperationType): string {
switch (type) {
case "create":
return "Creating";
case "start":
return "Starting";
case "stop":
return "Stopping";
case "restart":
return "Restarting";
case "delete":
return "Deleting";
case "recreate-tunnel":
return "Recreating tunnel";
default:
return "Working";
}
}
function messageForEvent(
type: OperationType,
event: InstanceEventPayload,
): string {
if (event.message) return event.message;
switch (event.event) {
case "instance.created":
return "Created";
case "instance.started":
return "Starting container";
case "instance.restarted":
return "Restarting container";
case "instance.stopped":
return "Stopped";
case "instance.deleted":
return "Deleted";
case "instance.health_changed":
if (event.status === "running") return "Running";
if (event.status === "unhealthy") return "Unhealthy";
return `Status: ${event.status ?? event.event}`;
case "instance.error":
return event.message ?? "Error";
default:
return event.message ?? actionLabel(type);
}
}
function stepForEvent(event: InstanceEventPayload): number {
switch (event.event) {
case "instance.created":
return 1;
case "instance.started":
case "instance.restarted":
return 2;
case "instance.health_changed":
if (event.status === "running") return 4;
if (event.status === "unhealthy") return 4;
return 3;
case "instance.error":
return 4;
case "instance.stopped":
return 4;
case "instance.deleted":
return 4;
default:
return 0;
}
}
export const SessionOperationsProvider = ({
children,
}: {
children: ReactNode;
}) => {
const [operations, setOperations] = useState<Operation[]>([]);
const startOperation = useCallback(
(type: OperationType, instanceId: string, displayName: string): string => {
const id = `op-${++operationIdCounter}`;
const operation: Operation = {
id,
type,
instanceId,
displayName,
status: "pending",
message: actionLabel(type),
step: 0,
createdAt: Date.now(),
};
setOperations((prev) => [operation, ...prev].slice(0, 20));
return id;
},
[],
);
const updateOperationFromEvent = useCallback(
(event: InstanceEventPayload) => {
setOperations((prev) => {
const matches = prev.filter(
(op) => op.instanceId === event.instance_id && op.status !== "success",
);
if (matches.length === 0) return prev;
const updated = new Map<string, Operation>();
for (const op of prev) updated.set(op.id, op);
for (const op of matches) {
const nextStep = stepForEvent(event);
const message = messageForEvent(op.type, event);
let nextStatus: OperationStatus = op.status;
if (event.event === "instance.error") {
nextStatus = "error";
} else if (
event.event === "instance.health_changed" &&
event.status === "running"
) {
nextStatus = "success";
} else if (event.event === "instance.deleted") {
nextStatus = "success";
} else if (event.event === "instance.stopped") {
nextStatus = "success";
} else if (nextStatus === "pending") {
nextStatus = "active";
}
updated.set(op.id, {
...op,
status: nextStatus,
message,
step: Math.max(op.step, nextStep),
});
}
return Array.from(updated.values());
});
},
[],
);
const completeOperation = useCallback(
(
instanceId: string,
type: OperationType,
outcome: "success" | "error",
message?: string,
) => {
setOperations((prev) => {
const match = prev.find(
(op) => op.instanceId === instanceId && op.type === type,
);
if (!match) return prev;
return prev.map((op) =>
op.id === match.id
? {
...op,
status: outcome,
message:
message ?? (outcome === "success" ? "Done" : "Failed"),
step: 4,
}
: op,
);
});
},
[],
);
const dismissOperation = useCallback((id: string) => {
setOperations((prev) => prev.filter((op) => op.id !== id));
}, []);
const value = useMemo(
() => ({
operations,
startOperation,
updateOperationFromEvent,
completeOperation,
dismissOperation,
}),
[
operations,
startOperation,
updateOperationFromEvent,
completeOperation,
dismissOperation,
],
);
return (
<SessionOperationsContext.Provider value={value}>
{children}
</SessionOperationsContext.Provider>
);
};
export const useSessionOperations = (): SessionOperationsContextType => {
const context = useContext(SessionOperationsContext);
if (context === undefined) {
throw new Error(
"useSessionOperations must be used within a SessionOperationsProvider",
);
}
return context;
};
-123
View File
@@ -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
============================================ */