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 { EventProvider } from "../state/events";
import { ToastProvider } from "../state/toast"; import { ToastProvider } from "../state/toast";
import { NotificationProvider } from "../state/notifications"; import { NotificationProvider } from "../state/notifications";
import { SessionOperationsProvider } from "../state/session-operations";
import { EventToastBridge } from "./features/notification/event-toast-bridge"; import { EventToastBridge } from "./features/notification/event-toast-bridge";
import { NotificationCenter } from "./features/notification/notification-center"; import { NotificationCenter } from "./features/notification/notification-center";
import { SessionProgressPanel } from "./features/session/session-progress-panel";
import { Icon } from "./icon"; import { Icon } from "./icon";
import { MobileNav } from "./features/mobile/mobile-nav"; import { MobileNav } from "./features/mobile/mobile-nav";
import { StartToolFAB } from "./features/tool/start-tool-fab"; import { StartToolFAB } from "./features/tool/start-tool-fab";
@@ -87,12 +85,10 @@ export const AppShell = () => {
<EventProvider> <EventProvider>
<ToastProvider> <ToastProvider>
<NotificationProvider> <NotificationProvider>
<SessionOperationsProvider> <EventToastBridge />
<EventToastBridge /> <div className="shell mobile-terminal-shell">
<div className="shell mobile-terminal-shell"> <Outlet />
<Outlet /> </div>
</div>
</SessionOperationsProvider>
</NotificationProvider> </NotificationProvider>
</ToastProvider> </ToastProvider>
</EventProvider> </EventProvider>
@@ -103,10 +99,8 @@ export const AppShell = () => {
<EventProvider> <EventProvider>
<ToastProvider> <ToastProvider>
<NotificationProvider> <NotificationProvider>
<SessionOperationsProvider> <EventToastBridge />
<EventToastBridge /> <div className="shell">
<SessionProgressPanel />
<div className="shell">
<header className="shell-header"> <header className="shell-header">
<Link className="brand" to="/"> <Link className="brand" to="/">
Headquarter Headquarter
@@ -180,7 +174,6 @@ export const AppShell = () => {
)} )}
<StartToolFAB /> <StartToolFAB />
</div> </div>
</SessionOperationsProvider>
</NotificationProvider> </NotificationProvider>
</ToastProvider> </ToastProvider>
</EventProvider> </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"; } from "../../../api/config-profiles";
import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys"; import { listSSHKeys, type SSHKey } from "../../../api/ssh-keys";
import { useSessions } from "../../../state/sessions"; import { useSessions } from "../../../state/sessions";
import { useSessionOperations } from "../../../state/session-operations";
import type { Workspace } from "../../../types/workspace"; import type { Workspace } from "../../../types/workspace";
import type { ToolInstance } from "../../../api/sessions"; import type { ToolInstance } from "../../../api/sessions";
@@ -25,7 +24,6 @@ export function ToolStarter({
onCancel, onCancel,
}: ToolStarterProps) { }: ToolStarterProps) {
const { addOrUpdateSession } = useSessions(); const { addOrUpdateSession } = useSessions();
const { startOperation } = useSessionOperations();
const [toolTypes, setToolTypes] = useState<ToolType[]>([]); const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [toolTypesLoading, setToolTypesLoading] = useState(true); const [toolTypesLoading, setToolTypesLoading] = useState(true);
const [toolTypesError, setToolTypesError] = useState<string | null>(null); const [toolTypesError, setToolTypesError] = useState<string | null>(null);
@@ -156,7 +154,6 @@ export function ToolStarter({
status: instance.status || "pending", status: instance.status || "pending",
url: instance.url || null, url: instance.url || null,
}); });
startOperation("create", instance.id, instance.display_name);
onStarted(instance); onStarted(instance);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to start tool"); setError(err instanceof Error ? err.message : "Failed to start tool");
@@ -171,7 +168,6 @@ export function ToolStarter({
onStarted, onStarted,
toolTypes, toolTypes,
addOrUpdateSession, addOrUpdateSession,
startOperation,
]); ]);
return ( return (
+17 -9
View File
@@ -30,17 +30,25 @@ describe("useEvents", () => {
return EventSource.OPEN; return EventSource.OPEN;
}, },
url: "http://localhost:8000/events/stream", url: "http://localhost:8000/events/stream",
addEventListener: vi.fn((type: string, handler: (e: MessageEvent) => void) => { addEventListener: vi.fn(
if (!listeners.has(type)) listeners.set(type, new Set()); (type: string, handler: (e: MessageEvent) => void) => {
listeners.get(type)!.add(handler); 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); ),
}), removeEventListener: vi.fn(
(type: string, handler: (e: MessageEvent) => void) => {
listeners.get(type)?.delete(handler);
},
),
emit: (type: string, data: string) => { 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); mockedCreateEventSource.mockReturnValue(mockEs);
mockedProbeEventStreamStatus.mockResolvedValue(null); mockedProbeEventStreamStatus.mockResolvedValue(null);
}); });
+5 -33
View File
@@ -8,7 +8,6 @@ import {
} from "../api/sessions"; } from "../api/sessions";
import type { Session } from "../api/sessions"; import type { Session } from "../api/sessions";
import { useSessions } from "../state/sessions"; import { useSessions } from "../state/sessions";
import { useSessionOperations } from "../state/session-operations";
interface UseInstanceActionsOptions { interface UseInstanceActionsOptions {
onRefresh: () => Promise<void>; onRefresh: () => Promise<void>;
@@ -33,7 +32,6 @@ export function useInstanceActions(
): UseInstanceActionsReturn { ): UseInstanceActionsReturn {
const { onRefresh } = options; const { onRefresh } = options;
const { removeSession } = useSessions(); const { removeSession } = useSessions();
const { startOperation, completeOperation } = useSessionOperations();
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null); const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>( const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(
null, null,
@@ -66,7 +64,6 @@ export function useInstanceActions(
async (session: Session) => { async (session: Session) => {
if (loadingSessionId === session.id) return; if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id); setLoadingSessionId(session.id);
startOperation("start", session.id, session.display_name);
try { try {
await startInstance( await startInstance(
session.project_id, session.project_id,
@@ -74,20 +71,17 @@ export function useInstanceActions(
session.id, session.id,
); );
await onRefresh(); await onRefresh();
} catch {
completeOperation(session.id, "start", "error");
} finally { } finally {
setLoadingSessionId(null); setLoadingSessionId(null);
} }
}, },
[loadingSessionId, onRefresh, startOperation, completeOperation], [loadingSessionId, onRefresh],
); );
const handleStop = useCallback( const handleStop = useCallback(
async (session: Session) => { async (session: Session) => {
if (loadingSessionId === session.id) return; if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id); setLoadingSessionId(session.id);
startOperation("stop", session.id, session.display_name);
try { try {
await stopInstance( await stopInstance(
session.project_id, session.project_id,
@@ -95,20 +89,17 @@ export function useInstanceActions(
session.id, session.id,
); );
await onRefresh(); await onRefresh();
} catch {
completeOperation(session.id, "stop", "error");
} finally { } finally {
setLoadingSessionId(null); setLoadingSessionId(null);
} }
}, },
[loadingSessionId, onRefresh, startOperation, completeOperation], [loadingSessionId, onRefresh],
); );
const handleDelete = useCallback( const handleDelete = useCallback(
async (session: Session) => { async (session: Session) => {
if (loadingSessionId === session.id) return; if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id); setLoadingSessionId(session.id);
startOperation("delete", session.id, session.display_name);
try { try {
await deleteInstance( await deleteInstance(
session.project_id, session.project_id,
@@ -120,7 +111,6 @@ export function useInstanceActions(
removeSession(session.id); removeSession(session.id);
await onRefresh(); await onRefresh();
} catch (error) { } catch (error) {
completeOperation(session.id, "delete", "error");
const axiosError = error as { const axiosError = error as {
response?: { response?: {
status?: number; status?: number;
@@ -139,20 +129,13 @@ export function useInstanceActions(
setLoadingSessionId(null); setLoadingSessionId(null);
} }
}, },
[ [loadingSessionId, onRefresh, removeSession],
loadingSessionId,
onRefresh,
removeSession,
startOperation,
completeOperation,
],
); );
const handleForceDelete = useCallback( const handleForceDelete = useCallback(
async (session: Session) => { async (session: Session) => {
if (loadingSessionId === session.id) return; if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id); setLoadingSessionId(session.id);
startOperation("delete", session.id, session.display_name);
try { try {
await deleteInstance( await deleteInstance(
session.project_id, session.project_id,
@@ -164,26 +147,17 @@ export function useInstanceActions(
setDirtyDeleteFiles([]); setDirtyDeleteFiles([]);
removeSession(session.id); removeSession(session.id);
await onRefresh(); await onRefresh();
} catch {
completeOperation(session.id, "delete", "error");
} finally { } finally {
setLoadingSessionId(null); setLoadingSessionId(null);
} }
}, },
[ [loadingSessionId, onRefresh, removeSession],
loadingSessionId,
onRefresh,
removeSession,
startOperation,
completeOperation,
],
); );
const handleRecreateTunnel = useCallback( const handleRecreateTunnel = useCallback(
async (session: Session) => { async (session: Session) => {
if (loadingSessionId === session.id) return; if (loadingSessionId === session.id) return;
setLoadingSessionId(session.id); setLoadingSessionId(session.id);
startOperation("recreate-tunnel", session.id, session.display_name);
try { try {
await recreateInstanceTunnel( await recreateInstanceTunnel(
session.project_id, session.project_id,
@@ -191,18 +165,16 @@ export function useInstanceActions(
session.id, session.id,
); );
await onRefresh(); await onRefresh();
completeOperation(session.id, "recreate-tunnel", "success");
} catch (err) { } catch (err) {
const message = const message =
(err as { response?: { data?: { detail?: string } } })?.response?.data (err as { response?: { data?: { detail?: string } } })?.response?.data
?.detail || "Failed to recreate tunnel"; ?.detail || "Failed to recreate tunnel";
completeOperation(session.id, "recreate-tunnel", "error", message);
alert(message); alert(message);
} finally { } finally {
setLoadingSessionId(null); setLoadingSessionId(null);
} }
}, },
[loadingSessionId, onRefresh, startOperation, completeOperation], [loadingSessionId, onRefresh],
); );
const handleRename = useCallback( const handleRename = useCallback(
@@ -5,7 +5,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { HomePage } from "./DashboardPage"; import { HomePage } from "./DashboardPage";
import { SessionsProvider } from "../state/sessions"; import { SessionsProvider } from "../state/sessions";
import { SessionOperationsProvider } from "../state/session-operations";
const mockDashboard = vi.fn(); const mockDashboard = vi.fn();
const mockSessions = vi.fn(); const mockSessions = vi.fn();
@@ -59,9 +58,7 @@ describe("HomePage", () => {
render( render(
<MemoryRouter> <MemoryRouter>
<SessionsProvider> <SessionsProvider>
<SessionOperationsProvider>
<HomePage /> <HomePage />
</SessionOperationsProvider>
</SessionsProvider> </SessionsProvider>
</MemoryRouter>, </MemoryRouter>,
); );
@@ -81,9 +78,7 @@ describe("HomePage", () => {
render( render(
<MemoryRouter> <MemoryRouter>
<SessionsProvider> <SessionsProvider>
<SessionOperationsProvider>
<HomePage /> <HomePage />
</SessionOperationsProvider>
</SessionsProvider> </SessionsProvider>
</MemoryRouter>, </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; 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 Terminal Styles
============================================ */ ============================================ */