b4d08b0232
SessionCard desktop view: - Retain primary action button: Open (active) or Start (inactive) - Replace individual Stop/Tunnel/Delete buttons with a single Options dropdown triggered by a ⋯ button - Dropdown contains applicable actions: - Active: Stop, Recreate Tunnel (web), Delete - Inactive: Start, Delete - Add window.confirm before Delete as a safety net - Dropdown closes on outside click or Escape key - Add 'more' icon (DotsThreeVertical) to icon component - Add session-options-dropdown CSS with subtle animation Mobile view unchanged (already uses MobileActionSheet). Quality gates: tsc --noEmit pass, npm run build pass, 82/82 tests pass
407 lines
11 KiB
TypeScript
407 lines
11 KiB
TypeScript
import { useState, useRef, useEffect } from "react";
|
|
import type { Session } from "../../../api/sessions";
|
|
import { Icon } from "../../icon";
|
|
import { useMobileViewport } from "../../../hooks/use-mobile-viewport";
|
|
import { MobileActionSheet } from "../mobile/mobile-action-sheet";
|
|
import type { IconName } from "../../icon";
|
|
|
|
export interface SessionCardProps {
|
|
session: Session;
|
|
onOpen?: (session: Session) => void;
|
|
onStart?: (session: Session) => void;
|
|
onStop?: (session: Session) => void;
|
|
onDelete?: (session: Session) => void;
|
|
onRecreateTunnel?: (session: Session) => void;
|
|
onRename?: (session: Session, newName: string) => void;
|
|
isBusy?: boolean;
|
|
tunnelHealth?: {
|
|
healthy: boolean;
|
|
container_status: string;
|
|
container_health: string | null;
|
|
tunnel_status: string;
|
|
tunnel_status_code: number | null;
|
|
probe_status: string;
|
|
last_probe_output: string | null;
|
|
error: string | null;
|
|
} | null;
|
|
}
|
|
|
|
const statusConfig: Record<string, { color: string; label: string }> = {
|
|
running: { color: "running", label: "Running" },
|
|
building: { color: "pending", label: "Building" },
|
|
starting: { color: "starting", label: "Starting" },
|
|
probing: { color: "probing", label: "Probing" },
|
|
pending: { color: "pending", label: "Pending" },
|
|
stopped: { color: "stopped", label: "Stopped" },
|
|
error: { color: "error", label: "Error" },
|
|
unhealthy: { color: "unhealthy", label: "Unhealthy" },
|
|
};
|
|
|
|
export function SessionCard({
|
|
session,
|
|
onOpen,
|
|
onStart,
|
|
onStop,
|
|
onDelete,
|
|
onRecreateTunnel,
|
|
onRename,
|
|
isBusy = false,
|
|
tunnelHealth = null,
|
|
}: SessionCardProps) {
|
|
const [showActionSheet, setShowActionSheet] = useState(false);
|
|
const [isEditingName, setIsEditingName] = useState(false);
|
|
const [editName, setEditName] = useState(session.display_name);
|
|
const [optionsOpen, setOptionsOpen] = useState(false);
|
|
const optionsRef = useRef<HTMLDivElement>(null);
|
|
const isMobile = useMobileViewport();
|
|
|
|
const status = statusConfig[session.status] || {
|
|
color: "gray",
|
|
label: session.status,
|
|
};
|
|
const isTerminalOnly =
|
|
session.tool_type_interfaces?.includes("terminal") &&
|
|
!session.tool_type_interfaces?.includes("web");
|
|
const hasTunnelError =
|
|
!isTerminalOnly && tunnelHealth?.tunnel_status === "unreachable";
|
|
const hasAppError =
|
|
!isTerminalOnly && tunnelHealth?.tunnel_status === "error_response";
|
|
|
|
const isActive = [
|
|
"running",
|
|
"building",
|
|
"starting",
|
|
"probing",
|
|
"pending",
|
|
"unhealthy",
|
|
].includes(session.status);
|
|
|
|
useEffect(() => {
|
|
if (!optionsOpen) return;
|
|
const handleClick = (e: MouseEvent) => {
|
|
if (
|
|
optionsRef.current &&
|
|
!optionsRef.current.contains(e.target as Node)
|
|
) {
|
|
setOptionsOpen(false);
|
|
}
|
|
};
|
|
const handleKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") setOptionsOpen(false);
|
|
};
|
|
document.addEventListener("mousedown", handleClick);
|
|
document.addEventListener("keydown", handleKey);
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleClick);
|
|
document.removeEventListener("keydown", handleKey);
|
|
};
|
|
}, [optionsOpen]);
|
|
|
|
const handleDeleteClick = () => {
|
|
setOptionsOpen(false);
|
|
if (window.confirm("Are you sure you want to delete this session?")) {
|
|
onDelete?.(session);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<article className={`card session-card ${isBusy ? "busy" : ""}`}>
|
|
{isBusy && (
|
|
<div className="session-busy-overlay">
|
|
<Icon name="loading" size="md" />
|
|
</div>
|
|
)}
|
|
<div className="session-card-content">
|
|
<div className="session-card-header">
|
|
<div className="session-card-title">
|
|
{isEditingName ? (
|
|
<div className="session-card-rename">
|
|
<input
|
|
type="text"
|
|
value={editName}
|
|
onChange={(e) => setEditName(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
onRename?.(session, editName);
|
|
setIsEditingName(false);
|
|
}
|
|
if (e.key === "Escape") {
|
|
setEditName(session.display_name);
|
|
setIsEditingName(false);
|
|
}
|
|
}}
|
|
onBlur={() => {
|
|
if (editName.trim() && editName !== session.display_name) {
|
|
onRename?.(session, editName);
|
|
}
|
|
setIsEditingName(false);
|
|
}}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
) : (
|
|
<h4
|
|
onClick={() => {
|
|
setEditName(session.display_name);
|
|
setIsEditingName(true);
|
|
}}
|
|
title="Click to rename"
|
|
style={{ cursor: "pointer" }}
|
|
>
|
|
{session.display_name}
|
|
</h4>
|
|
)}
|
|
<div className="session-card-status-badges">
|
|
<span className={`status-badge ${status.color}`}>
|
|
{status.label}
|
|
</span>
|
|
{hasTunnelError && (
|
|
<span className="status-badge error">Tunnel Error</span>
|
|
)}
|
|
{hasAppError && (
|
|
<span className="status-badge warning">
|
|
App Error {tunnelHealth?.tunnel_status_code}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<p className="muted session-card-meta session-card-context">
|
|
<strong>{session.project_name}</strong>
|
|
{session.workspace_name && (
|
|
<>
|
|
{" "}
|
|
/ <span>{session.workspace_name}</span>
|
|
</>
|
|
)}
|
|
{!session.workspace_name && session.repository_name && (
|
|
<>
|
|
{" "}
|
|
/ <span>{session.repository_name}</span>
|
|
</>
|
|
)}{" "}
|
|
· {session.tool_type_name}
|
|
</p>
|
|
{session.clone_mode && (
|
|
<p className="muted session-card-meta">
|
|
<Icon name="branch" size="sm" />
|
|
{session.clone_mode === "clone"
|
|
? `Clone${session.branch ? ` (${session.branch})` : ""}`
|
|
: "Mount"}
|
|
</p>
|
|
)}
|
|
{session.url && (
|
|
<p className="session-card-url">
|
|
<button
|
|
type="button"
|
|
className="link-button"
|
|
onClick={() => onOpen?.(session)}
|
|
>
|
|
{session.url}
|
|
</button>
|
|
</p>
|
|
)}
|
|
{session.created_at && (
|
|
<p className="muted session-card-meta">
|
|
Created: {new Date(session.created_at).toLocaleString()}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{isMobile ? (
|
|
<div className="session-card-actions mobile">
|
|
{isActive && (
|
|
<>
|
|
<button
|
|
className="secondary-button mobile-primary"
|
|
onClick={() => onOpen?.(session)}
|
|
type="button"
|
|
disabled={isBusy}
|
|
>
|
|
<Icon name="external" size="sm" />
|
|
Open
|
|
</button>
|
|
<button
|
|
className="ghost-button mobile-more"
|
|
onClick={() => setShowActionSheet(true)}
|
|
type="button"
|
|
disabled={isBusy}
|
|
>
|
|
<Icon name="menu" size="sm" />
|
|
</button>
|
|
</>
|
|
)}
|
|
{!isActive && onStart && (
|
|
<>
|
|
<button
|
|
className="secondary-button mobile-primary"
|
|
onClick={() => onStart(session)}
|
|
type="button"
|
|
disabled={isBusy}
|
|
>
|
|
<Icon name="play" size="sm" />
|
|
Start
|
|
</button>
|
|
<button
|
|
className="ghost-button mobile-more"
|
|
onClick={() => setShowActionSheet(true)}
|
|
type="button"
|
|
disabled={isBusy}
|
|
>
|
|
<Icon name="menu" size="sm" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="session-card-actions">
|
|
{isActive && onOpen && (
|
|
<button
|
|
className="secondary-button small"
|
|
onClick={() => onOpen(session)}
|
|
type="button"
|
|
disabled={isBusy}
|
|
>
|
|
<Icon name="external" size="sm" />
|
|
<span className="action-label">Open</span>
|
|
</button>
|
|
)}
|
|
{!isActive && onStart && (
|
|
<button
|
|
className="secondary-button small"
|
|
onClick={() => onStart(session)}
|
|
type="button"
|
|
disabled={isBusy}
|
|
>
|
|
<Icon name="play" size="sm" />
|
|
<span className="action-label">Start</span>
|
|
</button>
|
|
)}
|
|
|
|
<div className="session-options" ref={optionsRef}>
|
|
<button
|
|
className="ghost-button small"
|
|
onClick={() => setOptionsOpen((prev) => !prev)}
|
|
type="button"
|
|
disabled={isBusy}
|
|
aria-haspopup="menu"
|
|
aria-expanded={optionsOpen}
|
|
>
|
|
<Icon name="more" size="sm" />
|
|
<span className="action-label">Options</span>
|
|
</button>
|
|
|
|
{optionsOpen && (
|
|
<div className="session-options-dropdown" role="menu">
|
|
{isActive && onStop && (
|
|
<button
|
|
className="session-option-item"
|
|
onClick={() => {
|
|
setOptionsOpen(false);
|
|
onStop(session);
|
|
}}
|
|
type="button"
|
|
role="menuitem"
|
|
>
|
|
<Icon name="stop" size="sm" />
|
|
Stop
|
|
</button>
|
|
)}
|
|
{!isActive && onStart && (
|
|
<button
|
|
className="session-option-item"
|
|
onClick={() => {
|
|
setOptionsOpen(false);
|
|
onStart(session);
|
|
}}
|
|
type="button"
|
|
role="menuitem"
|
|
>
|
|
<Icon name="play" size="sm" />
|
|
Start
|
|
</button>
|
|
)}
|
|
{isActive && !isTerminalOnly && onRecreateTunnel && (
|
|
<button
|
|
className="session-option-item"
|
|
onClick={() => {
|
|
setOptionsOpen(false);
|
|
onRecreateTunnel(session);
|
|
}}
|
|
type="button"
|
|
role="menuitem"
|
|
>
|
|
<Icon name="refresh" size="sm" />
|
|
Recreate Tunnel
|
|
</button>
|
|
)}
|
|
{onDelete && (
|
|
<button
|
|
className="session-option-item danger-text"
|
|
onClick={handleDeleteClick}
|
|
type="button"
|
|
role="menuitem"
|
|
>
|
|
<Icon name="delete" size="sm" />
|
|
Delete
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<MobileActionSheet
|
|
isOpen={showActionSheet}
|
|
onClose={() => setShowActionSheet(false)}
|
|
title={session.display_name}
|
|
actions={[
|
|
...(isActive && !isTerminalOnly && onRecreateTunnel
|
|
? [
|
|
{
|
|
id: "tunnel",
|
|
label: "Recreate Tunnel",
|
|
icon: "refresh" as IconName,
|
|
onClick: () => onRecreateTunnel(session),
|
|
},
|
|
]
|
|
: []),
|
|
...(isActive && onStop
|
|
? [
|
|
{
|
|
id: "stop",
|
|
label: "Stop",
|
|
icon: "stop" as IconName,
|
|
variant: "danger" as const,
|
|
onClick: () => onStop(session),
|
|
},
|
|
]
|
|
: []),
|
|
...(!isActive && onStart
|
|
? [
|
|
{
|
|
id: "start",
|
|
label: "Start",
|
|
icon: "play" as IconName,
|
|
onClick: () => onStart(session),
|
|
},
|
|
]
|
|
: []),
|
|
...(onDelete
|
|
? [
|
|
{
|
|
id: "delete",
|
|
label: "Delete",
|
|
icon: "delete" as IconName,
|
|
variant: "danger" as const,
|
|
onClick: () => onDelete(session),
|
|
},
|
|
]
|
|
: []),
|
|
]}
|
|
/>
|
|
</article>
|
|
);
|
|
}
|