feat: combine session actions into options dropdown
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
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import type { Session } from "../../../api/sessions";
|
import type { Session } from "../../../api/sessions";
|
||||||
import { Icon } from "../../icon";
|
import { Icon } from "../../icon";
|
||||||
import { useMobileViewport } from "../../../hooks/use-mobile-viewport";
|
import { useMobileViewport } from "../../../hooks/use-mobile-viewport";
|
||||||
@@ -48,11 +48,11 @@ export function SessionCard({
|
|||||||
isBusy = false,
|
isBusy = false,
|
||||||
tunnelHealth = null,
|
tunnelHealth = null,
|
||||||
}: SessionCardProps) {
|
}: SessionCardProps) {
|
||||||
const [showStopConfirm, setShowStopConfirm] = useState(false);
|
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
||||||
const [showActionSheet, setShowActionSheet] = useState(false);
|
const [showActionSheet, setShowActionSheet] = useState(false);
|
||||||
const [isEditingName, setIsEditingName] = useState(false);
|
const [isEditingName, setIsEditingName] = useState(false);
|
||||||
const [editName, setEditName] = useState(session.display_name);
|
const [editName, setEditName] = useState(session.display_name);
|
||||||
|
const [optionsOpen, setOptionsOpen] = useState(false);
|
||||||
|
const optionsRef = useRef<HTMLDivElement>(null);
|
||||||
const isMobile = useMobileViewport();
|
const isMobile = useMobileViewport();
|
||||||
|
|
||||||
const status = statusConfig[session.status] || {
|
const status = statusConfig[session.status] || {
|
||||||
@@ -67,27 +67,6 @@ export function SessionCard({
|
|||||||
const hasAppError =
|
const hasAppError =
|
||||||
!isTerminalOnly && tunnelHealth?.tunnel_status === "error_response";
|
!isTerminalOnly && tunnelHealth?.tunnel_status === "error_response";
|
||||||
|
|
||||||
const handleStop = () => {
|
|
||||||
if (showStopConfirm) {
|
|
||||||
setShowStopConfirm(false);
|
|
||||||
onStop?.(session);
|
|
||||||
} else {
|
|
||||||
setShowStopConfirm(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
if (showDeleteConfirm) {
|
|
||||||
setShowDeleteConfirm(false);
|
|
||||||
onDelete?.(session);
|
|
||||||
} else {
|
|
||||||
setShowDeleteConfirm(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelStop = () => setShowStopConfirm(false);
|
|
||||||
const handleCancelDelete = () => setShowDeleteConfirm(false);
|
|
||||||
|
|
||||||
const isActive = [
|
const isActive = [
|
||||||
"running",
|
"running",
|
||||||
"building",
|
"building",
|
||||||
@@ -97,6 +76,34 @@ export function SessionCard({
|
|||||||
"unhealthy",
|
"unhealthy",
|
||||||
].includes(session.status);
|
].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 (
|
return (
|
||||||
<article className={`card session-card ${isBusy ? "busy" : ""}`}>
|
<article className={`card session-card ${isBusy ? "busy" : ""}`}>
|
||||||
{isBusy && (
|
{isBusy && (
|
||||||
@@ -225,87 +232,40 @@ export function SessionCard({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!isActive && onStart && (
|
{!isActive && onStart && (
|
||||||
<button
|
<>
|
||||||
className="secondary-button mobile-primary"
|
<button
|
||||||
onClick={() => onStart(session)}
|
className="secondary-button mobile-primary"
|
||||||
type="button"
|
onClick={() => onStart(session)}
|
||||||
disabled={isBusy}
|
type="button"
|
||||||
>
|
disabled={isBusy}
|
||||||
<Icon name="play" size="sm" />
|
>
|
||||||
Start
|
<Icon name="play" size="sm" />
|
||||||
</button>
|
Start
|
||||||
)}
|
</button>
|
||||||
{!isActive && (
|
<button
|
||||||
<button
|
className="ghost-button mobile-more"
|
||||||
className="ghost-button mobile-more"
|
onClick={() => setShowActionSheet(true)}
|
||||||
onClick={() => setShowActionSheet(true)}
|
type="button"
|
||||||
type="button"
|
disabled={isBusy}
|
||||||
disabled={isBusy}
|
>
|
||||||
>
|
<Icon name="menu" size="sm" />
|
||||||
<Icon name="menu" size="sm" />
|
</button>
|
||||||
</button>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="session-card-actions">
|
<div className="session-card-actions">
|
||||||
{isActive && (
|
{isActive && onOpen && (
|
||||||
<>
|
<button
|
||||||
<button
|
className="secondary-button small"
|
||||||
className="secondary-button small"
|
onClick={() => onOpen(session)}
|
||||||
onClick={() => onOpen?.(session)}
|
type="button"
|
||||||
type="button"
|
disabled={isBusy}
|
||||||
disabled={isBusy}
|
>
|
||||||
>
|
<Icon name="external" size="sm" />
|
||||||
<Icon name="external" size="sm" />
|
<span className="action-label">Open</span>
|
||||||
<span className="action-label">Open</span>
|
</button>
|
||||||
</button>
|
|
||||||
|
|
||||||
{!isTerminalOnly && onRecreateTunnel && (
|
|
||||||
<button
|
|
||||||
className="ghost-button small"
|
|
||||||
onClick={() => onRecreateTunnel(session)}
|
|
||||||
type="button"
|
|
||||||
disabled={isBusy}
|
|
||||||
title="Recreate Cloudflare tunnel"
|
|
||||||
>
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
<span className="action-label">Tunnel</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showStopConfirm ? (
|
|
||||||
<div className="confirm-inline">
|
|
||||||
<span className="confirm-text">Stop?</span>
|
|
||||||
<button
|
|
||||||
className="danger-button small"
|
|
||||||
onClick={handleStop}
|
|
||||||
type="button"
|
|
||||||
disabled={isBusy}
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ghost-button small"
|
|
||||||
onClick={handleCancelStop}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="ghost-button small"
|
|
||||||
onClick={handleStop}
|
|
||||||
type="button"
|
|
||||||
disabled={isBusy}
|
|
||||||
>
|
|
||||||
<Icon name="stop" size="sm" />
|
|
||||||
<span className="action-label">Stop</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isActive && onStart && (
|
{!isActive && onStart && (
|
||||||
<button
|
<button
|
||||||
className="secondary-button small"
|
className="secondary-button small"
|
||||||
@@ -318,35 +278,77 @@ export function SessionCard({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showDeleteConfirm ? (
|
<div className="session-options" ref={optionsRef}>
|
||||||
<div className="confirm-inline">
|
|
||||||
<span className="confirm-text">Delete?</span>
|
|
||||||
<button
|
|
||||||
className="danger-button small"
|
|
||||||
onClick={handleDelete}
|
|
||||||
type="button"
|
|
||||||
disabled={isBusy}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ghost-button small"
|
|
||||||
onClick={handleCancelDelete}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
<button
|
||||||
className="ghost-button small danger-text"
|
className="ghost-button small"
|
||||||
onClick={handleDelete}
|
onClick={() => setOptionsOpen((prev) => !prev)}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={optionsOpen}
|
||||||
>
|
>
|
||||||
<Icon name="delete" size="sm" />
|
<Icon name="more" size="sm" />
|
||||||
|
<span className="action-label">Options</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -376,6 +378,16 @@ export function SessionCard({
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
...(!isActive && onStart
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "start",
|
||||||
|
label: "Start",
|
||||||
|
icon: "play" as IconName,
|
||||||
|
onClick: () => onStart(session),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
...(onDelete
|
...(onDelete
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
Terminal,
|
Terminal,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
DotsSixVertical,
|
DotsSixVertical,
|
||||||
|
DotsThreeVertical,
|
||||||
Bell,
|
Bell,
|
||||||
CaretDown,
|
CaretDown,
|
||||||
CaretRight,
|
CaretRight,
|
||||||
@@ -81,6 +82,7 @@ export type IconName =
|
|||||||
| "terminal"
|
| "terminal"
|
||||||
| "arrow-left"
|
| "arrow-left"
|
||||||
| "drag"
|
| "drag"
|
||||||
|
| "more"
|
||||||
| "bell"
|
| "bell"
|
||||||
| "chevron-down"
|
| "chevron-down"
|
||||||
| "chevron-right";
|
| "chevron-right";
|
||||||
@@ -132,6 +134,7 @@ const iconMap: Record<
|
|||||||
terminal: Terminal,
|
terminal: Terminal,
|
||||||
"arrow-left": ArrowLeft,
|
"arrow-left": ArrowLeft,
|
||||||
drag: DotsSixVertical,
|
drag: DotsSixVertical,
|
||||||
|
more: DotsThreeVertical,
|
||||||
bell: Bell,
|
bell: Bell,
|
||||||
"chevron-down": CaretDown,
|
"chevron-down": CaretDown,
|
||||||
"chevron-right": CaretRight,
|
"chevron-right": CaretRight,
|
||||||
|
|||||||
@@ -54,6 +54,70 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Session options dropdown */
|
||||||
|
.session-options {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-options-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 0.25rem);
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
min-width: 12rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: dropdown-in 0.12s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dropdown-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-option-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-option-item:hover {
|
||||||
|
background: var(--surface-hover, rgba(0, 0, 0, 0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-option-item.danger-text {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-option-item.danger-text:hover {
|
||||||
|
background: var(--danger-subtle, rgba(239, 68, 68, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
.session-card-actions.mobile {
|
.session-card-actions.mobile {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
|||||||
Reference in New Issue
Block a user