From b4d08b0232d461fa95b1f48ac20fb602abaa7a77 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 11 Jun 2026 15:32:03 +0000 Subject: [PATCH] feat: combine session actions into options dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../features/session/session-card.tsx | 260 +++++++++--------- apps/web/src/components/icon.tsx | 3 + apps/web/src/styles/pages/sessions.css | 64 +++++ 3 files changed, 203 insertions(+), 124 deletions(-) diff --git a/apps/web/src/components/features/session/session-card.tsx b/apps/web/src/components/features/session/session-card.tsx index de59f10..58608f8 100644 --- a/apps/web/src/components/features/session/session-card.tsx +++ b/apps/web/src/components/features/session/session-card.tsx @@ -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(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 (
{isBusy && ( @@ -225,87 +232,40 @@ export function SessionCard({ )} {!isActive && onStart && ( - - )} - {!isActive && ( - + <> + + + )} ) : (
- {isActive && ( - <> - - - {!isTerminalOnly && onRecreateTunnel && ( - - )} - - {showStopConfirm ? ( -
- Stop? - - -
- ) : ( - - )} - + {isActive && onOpen && ( + )} - {!isActive && onStart && ( - -
- ) : ( +
- )} + + {optionsOpen && ( +
+ {isActive && onStop && ( + + )} + {!isActive && onStart && ( + + )} + {isActive && !isTerminalOnly && onRecreateTunnel && ( + + )} + {onDelete && ( + + )} +
+ )} +
)} @@ -376,6 +378,16 @@ export function SessionCard({ }, ] : []), + ...(!isActive && onStart + ? [ + { + id: "start", + label: "Start", + icon: "play" as IconName, + onClick: () => onStart(session), + }, + ] + : []), ...(onDelete ? [ { diff --git a/apps/web/src/components/icon.tsx b/apps/web/src/components/icon.tsx index 7222b30..09828ea 100644 --- a/apps/web/src/components/icon.tsx +++ b/apps/web/src/components/icon.tsx @@ -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, diff --git a/apps/web/src/styles/pages/sessions.css b/apps/web/src/styles/pages/sessions.css index 1d360ee..35f54ba 100644 --- a/apps/web/src/styles/pages/sessions.css +++ b/apps/web/src/styles/pages/sessions.css @@ -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);