Files
hapi/web/src/components/SessionHeader.tsx
T
weishu 8ed94193d8 fix: position session action menu at touch/click point
- Updated useLongPress.ts to pass click coordinates to onLongPress callback
- Modified SessionList.tsx to use menuAnchorPoint state instead of menuAnchorRef
- Updated SessionActionMenu.tsx to position menu based on anchorPoint coordinates
- Enhanced SessionHeader.tsx to calculate anchorPoint from button position and added stopPropagation to fix toggle issue

The menu now appears at the exact touch/click position instead of being centered or aligned to the button edge.
2026-01-12 19:08:01 +08:00

222 lines
8.5 KiB
TypeScript

import { useId, useMemo, useRef, useState } from 'react'
import type { Session } from '@/types/api'
import type { ApiClient } from '@/api/client'
import { isTelegramApp } from '@/hooks/useTelegram'
import { useSessionActions } from '@/hooks/mutations/useSessionActions'
import { SessionActionMenu } from '@/components/SessionActionMenu'
import { RenameSessionDialog } from '@/components/RenameSessionDialog'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { useTranslation } from '@/lib/use-translation'
function getSessionTitle(session: Session): string {
if (session.metadata?.name) {
return session.metadata.name
}
if (session.metadata?.summary?.text) {
return session.metadata.summary.text
}
if (session.metadata?.path) {
const parts = session.metadata.path.split('/').filter(Boolean)
return parts.length > 0 ? parts[parts.length - 1] : session.id.slice(0, 8)
}
return session.id.slice(0, 8)
}
function FilesIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
</svg>
)
}
function MoreVerticalIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="currentColor"
className={props.className}
>
<circle cx="12" cy="5" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="12" cy="19" r="2" />
</svg>
)
}
export function SessionHeader(props: {
session: Session
onBack: () => void
onViewFiles?: () => void
api: ApiClient | null
onSessionDeleted?: () => void
}) {
const { t } = useTranslation()
const { session, api, onSessionDeleted } = props
const title = useMemo(() => getSessionTitle(session), [session])
const worktreeBranch = session.metadata?.worktree?.branch
const [menuOpen, setMenuOpen] = useState(false)
const [menuAnchorPoint, setMenuAnchorPoint] = useState<{ x: number; y: number }>({ x: 0, y: 0 })
const menuId = useId()
const menuAnchorRef = useRef<HTMLButtonElement | null>(null)
const [renameOpen, setRenameOpen] = useState(false)
const [archiveOpen, setArchiveOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const { archiveSession, renameSession, deleteSession, isPending } = useSessionActions(
api,
session.id,
session.metadata?.flavor ?? null
)
const handleDelete = async () => {
await deleteSession()
onSessionDeleted?.()
}
const handleMenuToggle = () => {
if (!menuOpen && menuAnchorRef.current) {
const rect = menuAnchorRef.current.getBoundingClientRect()
setMenuAnchorPoint({ x: rect.right, y: rect.bottom })
}
setMenuOpen((open) => !open)
}
// In Telegram, don't render header (Telegram provides its own)
if (isTelegramApp()) {
return null
}
return (
<>
<div className="bg-[var(--app-bg)] pt-[env(safe-area-inset-top)]">
<div className="mx-auto w-full max-w-content flex items-center gap-2 p-3">
{/* Back button */}
<button
type="button"
onClick={props.onBack}
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)]"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
{/* Session info - two lines: title and path */}
<div className="min-w-0 flex-1">
<div className="truncate font-semibold">
{title}
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-[var(--app-hint)]">
<span className="inline-flex items-center gap-1">
<span aria-hidden="true"></span>
{session.metadata?.flavor?.trim() || 'unknown'}
</span>
<span>
{t('session.item.modelMode')}: {session.modelMode || 'default'}
</span>
{worktreeBranch ? (
<span>{t('session.item.worktree')}: {worktreeBranch}</span>
) : null}
</div>
</div>
{props.onViewFiles ? (
<button
type="button"
onClick={props.onViewFiles}
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={t('session.title')}
>
<FilesIcon />
</button>
) : null}
<button
type="button"
onClick={handleMenuToggle}
onPointerDown={(e) => e.stopPropagation()}
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={t('session.more')}
>
<MoreVerticalIcon />
</button>
</div>
</div>
<SessionActionMenu
isOpen={menuOpen}
onClose={() => setMenuOpen(false)}
sessionActive={session.active}
onRename={() => setRenameOpen(true)}
onArchive={() => setArchiveOpen(true)}
onDelete={() => setDeleteOpen(true)}
anchorPoint={menuAnchorPoint}
menuId={menuId}
/>
<RenameSessionDialog
isOpen={renameOpen}
onClose={() => setRenameOpen(false)}
currentName={title}
onRename={renameSession}
isPending={isPending}
/>
<ConfirmDialog
isOpen={archiveOpen}
onClose={() => setArchiveOpen(false)}
title={t('dialog.archive.title')}
description={t('dialog.archive.description', { name: title })}
confirmLabel={t('dialog.archive.confirm')}
confirmingLabel={t('dialog.archive.confirming')}
onConfirm={archiveSession}
isPending={isPending}
destructive
/>
<ConfirmDialog
isOpen={deleteOpen}
onClose={() => setDeleteOpen(false)}
title={t('dialog.delete.title')}
description={t('dialog.delete.description', { name: title })}
confirmLabel={t('dialog.delete.confirm')}
confirmingLabel={t('dialog.delete.confirming')}
onConfirm={handleDelete}
isPending={isPending}
destructive
/>
</>
)
}