Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 185 additions & 43 deletions web/src/components/SessionActionMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type RefObject
} from 'react'

type SessionActionMenuProps = {
isOpen: boolean
Expand All @@ -13,6 +16,9 @@ type SessionActionMenuProps = {
onRename: () => void
onArchive: () => void
onDelete: () => void
anchorRef?: RefObject<HTMLElement | null>
align?: 'start' | 'end'
menuId?: string
}

function EditIcon(props: { className?: string }) {
Expand Down Expand Up @@ -79,8 +85,29 @@ function TrashIcon(props: { className?: string }) {
)
}

type MenuPosition = {
top: number
left: number
transformOrigin: string
}

export function SessionActionMenu(props: SessionActionMenuProps) {
const { isOpen, onClose, sessionActive, onRename, onArchive, onDelete } = props
const {
isOpen,
onClose,
sessionActive,
onRename,
onArchive,
onDelete,
anchorRef,
align = 'end',
menuId
} = props
const menuRef = useRef<HTMLDivElement | null>(null)
const [menuPosition, setMenuPosition] = useState<MenuPosition | null>(null)
const internalId = useId()
const resolvedMenuId = menuId ?? `session-action-menu-${internalId}`
const headingId = `${resolvedMenuId}-heading`

const handleRename = () => {
onClose()
Expand All @@ -97,43 +124,158 @@ export function SessionActionMenu(props: SessionActionMenuProps) {
onDelete()
}

const updatePosition = useCallback(() => {
const menuEl = menuRef.current
if (!menuEl) return

const menuRect = menuEl.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const padding = 8
const gap = 8

let top = (viewportHeight - menuRect.height) / 2
let left = (viewportWidth - menuRect.width) / 2
let transformOrigin = 'top center'

const anchorEl = anchorRef?.current
if (anchorEl) {
const anchorRect = anchorEl.getBoundingClientRect()
const spaceBelow = viewportHeight - anchorRect.bottom
const spaceAbove = anchorRect.top
const openAbove = spaceBelow < menuRect.height + gap && spaceAbove > spaceBelow

top = openAbove ? anchorRect.top - menuRect.height - gap : anchorRect.bottom + gap
if (align === 'start') {
left = anchorRect.left
transformOrigin = openAbove ? 'bottom left' : 'top left'
} else {
left = anchorRect.right - menuRect.width
transformOrigin = openAbove ? 'bottom right' : 'top right'
}
}

top = Math.min(Math.max(top, padding), viewportHeight - menuRect.height - padding)
left = Math.min(Math.max(left, padding), viewportWidth - menuRect.width - padding)

setMenuPosition({ top, left, transformOrigin })
}, [align, anchorRef])

useLayoutEffect(() => {
if (!isOpen) return
updatePosition()
}, [isOpen, updatePosition])

useEffect(() => {
if (!isOpen) {
setMenuPosition(null)
return
}

const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node
if (menuRef.current?.contains(target)) return
if (anchorRef?.current?.contains(target)) return
onClose()
}

const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
}

const handleReflow = () => {
updatePosition()
}

document.addEventListener('pointerdown', handlePointerDown)
document.addEventListener('keydown', handleKeyDown)
window.addEventListener('resize', handleReflow)
window.addEventListener('scroll', handleReflow, true)

return () => {
document.removeEventListener('pointerdown', handlePointerDown)
document.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('resize', handleReflow)
window.removeEventListener('scroll', handleReflow, true)
}
}, [anchorRef, isOpen, onClose, updatePosition])

useEffect(() => {
if (!isOpen) return

const frame = window.requestAnimationFrame(() => {
const firstItem = menuRef.current?.querySelector<HTMLElement>('[role="menuitem"]')
firstItem?.focus()
})

return () => window.cancelAnimationFrame(frame)
}, [isOpen])

if (!isOpen) return null

const menuStyle: CSSProperties | undefined = menuPosition
? {
top: menuPosition.top,
left: menuPosition.left,
transformOrigin: menuPosition.transformOrigin
}
: undefined

const baseItemClassName =
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]'

return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Session Actions</DialogTitle>
</DialogHeader>
<div className="mt-4 flex flex-col gap-2">
<Button
variant="secondary"
className="justify-start gap-3 h-12"
onClick={handleRename}
<div
ref={menuRef}
className="fixed z-50 min-w-[200px] rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] p-1 shadow-lg animate-menu-pop"
style={menuStyle}
>
<div
id={headingId}
className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-[var(--app-hint)]"
>
Session actions
</div>
<div
id={resolvedMenuId}
role="menu"
aria-labelledby={headingId}
className="flex flex-col gap-1"
>
<button
type="button"
role="menuitem"
className={`${baseItemClassName} hover:bg-[var(--app-subtle-bg)]`}
onClick={handleRename}
>
<EditIcon className="text-[var(--app-hint)]" />
Rename
</button>

{sessionActive ? (
<button
type="button"
role="menuitem"
className={`${baseItemClassName} text-red-500 hover:bg-red-500/10`}
onClick={handleArchive}
>
<ArchiveIcon className="text-red-500" />
Archive
</button>
) : (
<button
type="button"
role="menuitem"
className={`${baseItemClassName} text-red-500 hover:bg-red-500/10`}
onClick={handleDelete}
>
<EditIcon className="text-[var(--app-hint)]" />
Rename
</Button>

{sessionActive ? (
<Button
variant="secondary"
className="justify-start gap-3 h-12 text-red-500"
onClick={handleArchive}
>
<ArchiveIcon className="text-red-500" />
Archive
</Button>
) : (
<Button
variant="secondary"
className="justify-start gap-3 h-12 text-red-500"
onClick={handleDelete}
>
<TrashIcon className="text-red-500" />
Delete
</Button>
)}
</div>
</DialogContent>
</Dialog>
<TrashIcon className="text-red-500" />
Delete
</button>
)}
</div>
</div>
)
}
13 changes: 11 additions & 2 deletions web/src/components/SessionHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { useId, useMemo, useRef, useState } from 'react'
import type { Session } from '@/types/api'
import type { ApiClient } from '@/api/client'
import { isTelegramApp } from '@/hooks/useTelegram'
Expand Down Expand Up @@ -70,6 +70,8 @@ export function SessionHeader(props: {
const worktreeBranch = session.metadata?.worktree?.branch

const [menuOpen, setMenuOpen] = useState(false)
const menuId = useId()
const menuAnchorRef = useRef<HTMLButtonElement | null>(null)
const [renameOpen, setRenameOpen] = useState(false)
const [archiveOpen, setArchiveOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
Expand Down Expand Up @@ -139,7 +141,11 @@ export function SessionHeader(props: {

<button
type="button"
onClick={() => setMenuOpen(true)}
onClick={() => setMenuOpen((open) => !open)}
ref={menuAnchorRef}
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-controls={menuOpen ? menuId : undefined}
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]"
title="More actions"
>
Expand All @@ -155,6 +161,9 @@ export function SessionHeader(props: {
onRename={() => setRenameOpen(true)}
onArchive={() => setArchiveOpen(true)}
onDelete={() => setDeleteOpen(true)}
anchorRef={menuAnchorRef}
align="end"
menuId={menuId}
/>

<RenameSessionDialog
Expand Down
4 changes: 4 additions & 0 deletions web/src/components/SessionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ function SessionItem(props: {
const { session: s, onSelect, showPath = true, api } = props
const { haptic } = usePlatform()
const [menuOpen, setMenuOpen] = useState(false)
const menuAnchorRef = useRef<HTMLButtonElement | null>(null)
const [renameOpen, setRenameOpen] = useState(false)
const [archiveOpen, setArchiveOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
Expand Down Expand Up @@ -202,6 +203,7 @@ function SessionItem(props: {
<button
type="button"
{...longPressHandlers}
ref={menuAnchorRef}
className="session-list-item flex w-full flex-col gap-1.5 px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)] select-none"
style={{ WebkitTouchCallout: 'none' }}
>
Expand Down Expand Up @@ -263,6 +265,8 @@ function SessionItem(props: {
onRename={() => setRenameOpen(true)}
onArchive={() => setArchiveOpen(true)}
onDelete={() => setDeleteOpen(true)}
anchorRef={menuAnchorRef}
align="end"
/>

<RenameSessionDialog
Expand Down
15 changes: 15 additions & 0 deletions web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,21 @@ html[data-theme="dark"] .shiki span {
animation: slide-up 0.3s ease-out;
}

@keyframes menu-pop {
from {
opacity: 0;
transform: scale(0.98);
}
to {
opacity: 1;
transform: scale(1);
}
}

.animate-menu-pop {
animation: menu-pop 0.18s ease-out;
}

/* Syncing spinner animation */
@keyframes spin {
from {
Expand Down