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:
Developer
2026-06-11 15:32:03 +00:00
parent f1180f6053
commit b4d08b0232
3 changed files with 203 additions and 124 deletions
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useRef, useEffect } from "react";
import type { Session } from "../../../api/sessions";
import { Icon } from "../../icon";
import { useMobileViewport } from "../../../hooks/use-mobile-viewport";
@@ -48,11 +48,11 @@ export function SessionCard({
isBusy = false,
tunnelHealth = null,
}: SessionCardProps) {
const [showStopConfirm, setShowStopConfirm] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
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] || {
@@ -67,27 +67,6 @@ export function SessionCard({
const hasAppError =
!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 = [
"running",
"building",
@@ -97,6 +76,34 @@ export function SessionCard({
"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 && (
@@ -225,87 +232,40 @@ export function SessionCard({
</>
)}
{!isActive && onStart && (
<button
className="secondary-button mobile-primary"
onClick={() => onStart(session)}
type="button"
disabled={isBusy}
>
<Icon name="play" size="sm" />
Start
</button>
)}
{!isActive && (
<button
className="ghost-button mobile-more"
onClick={() => setShowActionSheet(true)}
type="button"
disabled={isBusy}
>
<Icon name="menu" size="sm" />
</button>
<>
<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 && (
<>
<button
className="secondary-button small"
onClick={() => onOpen?.(session)}
type="button"
disabled={isBusy}
>
<Icon name="external" size="sm" />
<span className="action-label">Open</span>
</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 && 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"
@@ -318,35 +278,77 @@ export function SessionCard({
</button>
)}
{showDeleteConfirm ? (
<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>
) : (
<div className="session-options" ref={optionsRef}>
<button
className="ghost-button small danger-text"
onClick={handleDelete}
className="ghost-button small"
onClick={() => setOptionsOpen((prev) => !prev)}
type="button"
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>
)}
{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>
)}
@@ -376,6 +378,16 @@ export function SessionCard({
},
]
: []),
...(!isActive && onStart
? [
{
id: "start",
label: "Start",
icon: "play" as IconName,
onClick: () => onStart(session),
},
]
: []),
...(onDelete
? [
{
+3
View File
@@ -35,6 +35,7 @@ import {
Terminal,
ArrowLeft,
DotsSixVertical,
DotsThreeVertical,
Bell,
CaretDown,
CaretRight,
@@ -81,6 +82,7 @@ export type IconName =
| "terminal"
| "arrow-left"
| "drag"
| "more"
| "bell"
| "chevron-down"
| "chevron-right";
@@ -132,6 +134,7 @@ const iconMap: Record<
terminal: Terminal,
"arrow-left": ArrowLeft,
drag: DotsSixVertical,
more: DotsThreeVertical,
bell: Bell,
"chevron-down": CaretDown,
"chevron-right": CaretRight,
+64
View File
@@ -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 {
display: flex;
gap: var(--space-2);