feat: add session management (rename, archive, delete)

Implements comprehensive session lifecycle management with user-friendly interactions:

Rename: Update session metadata.name via PATCH endpoint with conflict detection
Archive: Abort active sessions via DELETE endpoint with validation
Delete: Permanently remove inactive sessions with cascade cleanup

Backend:
- Store.deleteSession() removes session and cascade-deletes messages
- SyncEngine.renameSession() with concurrency error handling
- SyncEngine.deleteSession() with active session validation
- PATCH /sessions/:id for rename, DELETE /sessions/:id for delete

Frontend Components:
- RenameSessionDialog: Text input with auto-focus and error display
- SessionActionMenu: Modal with rename, archive, delete buttons
- ConfirmDialog: Reusable confirmation with error feedback
- SessionHeader: Menu button (⋮) triggering action menu
- SessionList: Long-press detection triggering item actions

Interactions:
- Long-press on session list items (500ms threshold) opens action menu
- Menu button in session header (non-Telegram environments only)
- Confirmation dialogs with descriptive warnings for destructive actions
- Real-time error display in dialogs on operation failure
- Haptic feedback on long-press via usePlatform hook

Accessibility:
- Keyboard support (Enter/Space) for long-press handler
- Focus management in RenameSessionDialog
- Proper ARIA labels and semantic HTML
This commit is contained in:
weishu
2026-01-02 19:15:01 +08:00
parent c2d1f8c507
commit 41fd1abd53
13 changed files with 881 additions and 98 deletions
+13
View File
@@ -307,4 +307,17 @@ export class ApiClient {
`/api/sessions/${encodeURIComponent(sessionId)}/slash-commands`
)
}
async renameSession(sessionId: string, name: string): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}`, {
method: 'PATCH',
body: JSON.stringify({ name })
})
}
async deleteSession(sessionId: string): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}`, {
method: 'DELETE'
})
}
}
+103
View File
@@ -0,0 +1,103 @@
import { useState, useEffect, useRef } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
type RenameSessionDialogProps = {
isOpen: boolean
onClose: () => void
currentName: string
onRename: (newName: string) => Promise<void>
isPending: boolean
}
export function RenameSessionDialog(props: RenameSessionDialogProps) {
const { isOpen, onClose, currentName, onRename, isPending } = props
const [name, setName] = useState(currentName)
const [error, setError] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (isOpen) {
setName(currentName)
setError(null)
setTimeout(() => {
inputRef.current?.focus()
inputRef.current?.select()
}, 100)
}
}, [isOpen, currentName])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const trimmed = name.trim()
if (!trimmed || trimmed === currentName) {
onClose()
return
}
setError(null)
try {
await onRename(trimmed)
onClose()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to rename. Please try again.'
setError(message)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
}
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Rename Session</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="mt-4 flex flex-col gap-4">
<input
ref={inputRef}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Session name"
className="w-full px-3 py-2.5 rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] text-[var(--app-fg)] placeholder:text-[var(--app-hint)] focus:outline-none focus:ring-2 focus:ring-[var(--app-button)] focus:border-transparent"
disabled={isPending}
maxLength={255}
/>
{error ? (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
) : null}
<div className="flex gap-2 justify-end">
<Button
type="button"
variant="secondary"
onClick={onClose}
disabled={isPending}
>
Cancel
</Button>
<Button
type="submit"
disabled={isPending || !name.trim()}
>
{isPending ? 'Saving...' : 'Save'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}
+139
View File
@@ -0,0 +1,139 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
type SessionActionMenuProps = {
isOpen: boolean
onClose: () => void
sessionActive: boolean
onRename: () => void
onArchive: () => void
onDelete: () => void
}
function EditIcon(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="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
<path d="m15 5 4 4" />
</svg>
)
}
function ArchiveIcon(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}
>
<rect width="20" height="5" x="2" y="3" rx="1" />
<path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" />
<path d="M10 12h4" />
</svg>
)
}
function TrashIcon(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="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
<line x1="10" x2="10" y1="11" y2="17" />
<line x1="14" x2="14" y1="11" y2="17" />
</svg>
)
}
export function SessionActionMenu(props: SessionActionMenuProps) {
const { isOpen, onClose, sessionActive, onRename, onArchive, onDelete } = props
const handleRename = () => {
onClose()
onRename()
}
const handleArchive = () => {
onClose()
onArchive()
}
const handleDelete = () => {
onClose()
onDelete()
}
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}
>
<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>
)
}
+2
View File
@@ -143,6 +143,8 @@ export function SessionChat(props: {
session={props.session}
onBack={props.onBack}
onViewFiles={props.session.metadata?.path ? handleViewFiles : undefined}
api={props.api}
onSessionDeleted={props.onBack}
/>
{controlsDisabled ? (
+132 -43
View File
@@ -1,6 +1,11 @@
import { useMemo } from 'react'
import { useMemo, 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'
function getSessionTitle(session: Session): string {
if (session.metadata?.name) {
@@ -36,13 +41,45 @@ function FilesIcon(props: { className?: string }) {
)
}
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 title = useMemo(() => getSessionTitle(props.session), [props.session])
const worktreeBranch = props.session.metadata?.worktree?.branch
const { session, api, onSessionDeleted } = props
const title = useMemo(() => getSessionTitle(session), [session])
const worktreeBranch = session.metadata?.worktree?.branch
const [menuOpen, setMenuOpen] = useState(false)
const [renameOpen, setRenameOpen] = useState(false)
const [archiveOpen, setArchiveOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const { abortSession, renameSession, deleteSession, isPending } = useSessionActions(api, session.id)
const handleDelete = async () => {
await deleteSession()
onSessionDeleted?.()
}
// In Telegram, don't render header (Telegram provides its own)
if (isTelegramApp()) {
@@ -50,51 +87,103 @@ export function SessionHeader(props: {
}
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="text-xs text-[var(--app-hint)] truncate">
{props.session.metadata?.path ?? props.session.id}
{worktreeBranch ? ` • worktree: ${worktreeBranch}` : ''}
</div>
</div>
{props.onViewFiles ? (
<>
<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.onViewFiles}
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)]"
title="Files"
>
<FilesIcon />
<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>
) : null}
{/* Session info - two lines: title and path */}
<div className="min-w-0 flex-1">
<div className="truncate font-semibold">
{title}
</div>
<div className="text-xs text-[var(--app-hint)] truncate">
{session.metadata?.path ?? session.id}
{worktreeBranch ? ` • worktree: ${worktreeBranch}` : ''}
</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="Files"
>
<FilesIcon />
</button>
) : null}
<button
type="button"
onClick={() => setMenuOpen(true)}
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"
>
<MoreVerticalIcon />
</button>
</div>
</div>
</div>
<SessionActionMenu
isOpen={menuOpen}
onClose={() => setMenuOpen(false)}
sessionActive={session.active}
onRename={() => setRenameOpen(true)}
onArchive={() => setArchiveOpen(true)}
onDelete={() => setDeleteOpen(true)}
/>
<RenameSessionDialog
isOpen={renameOpen}
onClose={() => setRenameOpen(false)}
currentName={title}
onRename={renameSession}
isPending={isPending}
/>
<ConfirmDialog
isOpen={archiveOpen}
onClose={() => setArchiveOpen(false)}
title="Archive Session"
description={`Are you sure you want to archive "${title}"? This will disconnect the active session.`}
confirmLabel="Archive"
confirmingLabel="Archiving..."
onConfirm={abortSession}
isPending={isPending}
destructive
/>
<ConfirmDialog
isOpen={deleteOpen}
onClose={() => setDeleteOpen(false)}
title="Delete Session"
description={`Are you sure you want to delete "${title}"? This action cannot be undone.`}
confirmLabel="Delete"
confirmingLabel="Deleting..."
onConfirm={handleDelete}
isPending={isPending}
destructive
/>
</>
)
}
+127 -54
View File
@@ -1,5 +1,12 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { SessionSummary } from '@/types/api'
import type { ApiClient } from '@/api/client'
import { useLongPress } from '@/hooks/useLongPress'
import { usePlatform } from '@/hooks/usePlatform'
import { useSessionActions } from '@/hooks/mutations/useSessionActions'
import { SessionActionMenu } from '@/components/SessionActionMenu'
import { RenameSessionDialog } from '@/components/RenameSessionDialog'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
type SessionGroup = {
directory: string
@@ -161,64 +168,128 @@ function SessionItem(props: {
session: SessionSummary
onSelect: (sessionId: string) => void
showPath?: boolean
api: ApiClient | null
}) {
const { session: s, onSelect, showPath = true } = props
const { session: s, onSelect, showPath = true, api } = props
const { haptic } = usePlatform()
const [menuOpen, setMenuOpen] = useState(false)
const [renameOpen, setRenameOpen] = useState(false)
const [archiveOpen, setArchiveOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const { abortSession, renameSession, deleteSession, isPending } = useSessionActions(api, s.id)
const longPressHandlers = useLongPress({
onLongPress: () => {
haptic.impact('medium')
setMenuOpen(true)
},
onClick: () => onSelect(s.id),
threshold: 500
})
const sessionName = getSessionTitle(s)
return (
<button
type="button"
onClick={() => onSelect(s.id)}
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)]"
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<span className="flex h-4 w-4 items-center justify-center" aria-hidden="true">
<span
className={`h-2 w-2 rounded-full ${s.active ? 'bg-[var(--app-badge-success-text)]' : 'bg-[var(--app-hint)]'}`}
/>
</span>
<div className="truncate text-sm font-medium">
{getSessionTitle(s)}
<>
<button
type="button"
{...longPressHandlers}
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' }}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<span className="flex h-4 w-4 items-center justify-center" aria-hidden="true">
<span
className={`h-2 w-2 rounded-full ${s.active ? 'bg-[var(--app-badge-success-text)]' : 'bg-[var(--app-hint)]'}`}
/>
</span>
<div className="truncate text-sm font-medium">
{sessionName}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 text-xs">
{(() => {
const progress = getTodoProgress(s)
if (!progress) return null
return (
<span className="flex items-center gap-1 text-[var(--app-hint)]">
<BulbIcon className="h-3 w-3" />
{progress.completed}/{progress.total}
</span>
)
})()}
{s.pendingRequestsCount > 0 ? (
<span className="text-[var(--app-badge-warning-text)]">
pending {s.pendingRequestsCount}
</span>
) : null}
<span className="text-[var(--app-hint)]">
{formatRelativeTime(s.updatedAt)}
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0 text-xs">
{(() => {
const progress = getTodoProgress(s)
if (!progress) return null
return (
<span className="flex items-center gap-1 text-[var(--app-hint)]">
<BulbIcon className="h-3 w-3" />
{progress.completed}/{progress.total}
</span>
)
})()}
{s.pendingRequestsCount > 0 ? (
<span className="text-[var(--app-badge-warning-text)]">
pending {s.pendingRequestsCount}
</span>
) : null}
<span className="text-[var(--app-hint)]">
{formatRelativeTime(s.updatedAt)}
</span>
</div>
</div>
{showPath ? (
<div className="truncate text-xs text-[var(--app-hint)]">
{s.metadata?.path ?? s.id}
</div>
) : null}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--app-hint)]">
<span className="inline-flex items-center gap-2">
<span className="flex h-4 w-4 items-center justify-center" aria-hidden="true">
</span>
{getAgentLabel(s)}
</span>
<span>model: {getModelLabel(s)}</span>
{s.metadata?.worktree?.branch ? (
<span>worktree: {s.metadata.worktree.branch}</span>
{showPath ? (
<div className="truncate text-xs text-[var(--app-hint)]">
{s.metadata?.path ?? s.id}
</div>
) : null}
</div>
</button>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--app-hint)]">
<span className="inline-flex items-center gap-2">
<span className="flex h-4 w-4 items-center justify-center" aria-hidden="true">
</span>
{getAgentLabel(s)}
</span>
<span>model: {getModelLabel(s)}</span>
{s.metadata?.worktree?.branch ? (
<span>worktree: {s.metadata.worktree.branch}</span>
) : null}
</div>
</button>
<SessionActionMenu
isOpen={menuOpen}
onClose={() => setMenuOpen(false)}
sessionActive={s.active}
onRename={() => setRenameOpen(true)}
onArchive={() => setArchiveOpen(true)}
onDelete={() => setDeleteOpen(true)}
/>
<RenameSessionDialog
isOpen={renameOpen}
onClose={() => setRenameOpen(false)}
currentName={sessionName}
onRename={renameSession}
isPending={isPending}
/>
<ConfirmDialog
isOpen={archiveOpen}
onClose={() => setArchiveOpen(false)}
title="Archive Session"
description={`Are you sure you want to archive "${sessionName}"? This will disconnect the active session.`}
confirmLabel="Archive"
confirmingLabel="Archiving..."
onConfirm={abortSession}
isPending={isPending}
destructive
/>
<ConfirmDialog
isOpen={deleteOpen}
onClose={() => setDeleteOpen(false)}
title="Delete Session"
description={`Are you sure you want to delete "${sessionName}"? This action cannot be undone.`}
confirmLabel="Delete"
confirmingLabel="Deleting..."
onConfirm={deleteSession}
isPending={isPending}
destructive
/>
</>
)
}
@@ -229,8 +300,9 @@ export function SessionList(props: {
onRefresh: () => void
isLoading: boolean
renderHeader?: boolean
api: ApiClient | null
}) {
const { renderHeader = true } = props
const { renderHeader = true, api } = props
const groups = useMemo(
() => groupSessionsByDirectory(props.sessions),
[props.sessions]
@@ -317,6 +389,7 @@ export function SessionList(props: {
session={s}
onSelect={props.onSelect}
showPath={false}
api={api}
/>
))}
</div>
+94
View File
@@ -0,0 +1,94 @@
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
type ConfirmDialogProps = {
isOpen: boolean
onClose: () => void
title: string
description: string
confirmLabel: string
confirmingLabel: string
onConfirm: () => Promise<void>
isPending: boolean
destructive?: boolean
}
export function ConfirmDialog(props: ConfirmDialogProps) {
const {
isOpen,
onClose,
title,
description,
confirmLabel,
confirmingLabel,
onConfirm,
isPending,
destructive = false
} = props
const [error, setError] = useState<string | null>(null)
// Clear error when dialog opens/closes
useEffect(() => {
if (isOpen) {
setError(null)
}
}, [isOpen])
const handleConfirm = async () => {
setError(null)
try {
await onConfirm()
onClose()
} catch (err) {
const message = err instanceof Error ? err.message : 'Operation failed. Please try again.'
setError(message)
}
}
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription className="mt-2">
{description}
</DialogDescription>
</DialogHeader>
{error ? (
<div className="mt-3 rounded-md bg-red-50 p-3 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
) : null}
<div className="mt-4 flex gap-2 justify-end">
<Button
type="button"
variant="secondary"
onClick={onClose}
disabled={isPending}
>
Cancel
</Button>
<Button
type="button"
variant="secondary"
className={destructive ? 'text-red-500' : ''}
onClick={handleConfirm}
disabled={isPending}
>
{isPending ? confirmingLabel : confirmLabel}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
+30 -1
View File
@@ -25,6 +25,8 @@ export function useSessionActions(api: ApiClient | null, sessionId: string | nul
switchSession: () => Promise<void>
setPermissionMode: (mode: PermissionMode) => Promise<void>
setModelMode: (mode: ModelMode) => Promise<void>
renameSession: (name: string) => Promise<void>
deleteSession: () => Promise<void>
isPending: boolean
} {
const queryClient = useQueryClient()
@@ -75,11 +77,38 @@ export function useSessionActions(api: ApiClient | null, sessionId: string | nul
onSuccess: () => void invalidateSession(),
})
const renameMutation = useMutation({
mutationFn: async (name: string) => {
if (!api || !sessionId) {
throw new Error('Session unavailable')
}
await api.renameSession(sessionId, name)
},
onSuccess: () => void invalidateSession(),
})
const deleteMutation = useMutation({
mutationFn: async () => {
if (!api || !sessionId) {
throw new Error('Session unavailable')
}
await api.deleteSession(sessionId)
},
onSuccess: async () => {
if (!sessionId) return
queryClient.removeQueries({ queryKey: queryKeys.session(sessionId) })
queryClient.removeQueries({ queryKey: queryKeys.messages(sessionId) })
await queryClient.invalidateQueries({ queryKey: queryKeys.sessions })
},
})
return {
abortSession: abortMutation.mutateAsync,
switchSession: switchMutation.mutateAsync,
setPermissionMode: permissionMutation.mutateAsync,
setModelMode: modelMutation.mutateAsync,
isPending: abortMutation.isPending || switchMutation.isPending || permissionMutation.isPending || modelMutation.isPending,
renameSession: renameMutation.mutateAsync,
deleteSession: deleteMutation.mutateAsync,
isPending: abortMutation.isPending || switchMutation.isPending || permissionMutation.isPending || modelMutation.isPending || renameMutation.isPending || deleteMutation.isPending,
}
}
+116
View File
@@ -0,0 +1,116 @@
import type React from 'react'
import { useCallback, useRef } from 'react'
type UseLongPressOptions = {
onLongPress: () => void
onClick?: () => void
threshold?: number
disabled?: boolean
}
type UseLongPressHandlers = {
onMouseDown: React.MouseEventHandler
onMouseUp: React.MouseEventHandler
onMouseLeave: React.MouseEventHandler
onTouchStart: React.TouchEventHandler
onTouchEnd: React.TouchEventHandler
onTouchMove: React.TouchEventHandler
onContextMenu: React.MouseEventHandler
onKeyDown: React.KeyboardEventHandler
}
export function useLongPress(options: UseLongPressOptions): UseLongPressHandlers {
const { onLongPress, onClick, threshold = 500, disabled = false } = options
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isLongPressRef = useRef(false)
const touchMoved = useRef(false)
const clearTimer = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}, [])
const startTimer = useCallback(() => {
if (disabled) return
clearTimer()
isLongPressRef.current = false
touchMoved.current = false
timerRef.current = setTimeout(() => {
isLongPressRef.current = true
onLongPress()
}, threshold)
}, [disabled, clearTimer, onLongPress, threshold])
const handleEnd = useCallback((shouldTriggerClick: boolean) => {
clearTimer()
if (shouldTriggerClick && !isLongPressRef.current && !touchMoved.current && onClick) {
onClick()
}
isLongPressRef.current = false
touchMoved.current = false
}, [clearTimer, onClick])
const onMouseDown = useCallback<React.MouseEventHandler>((e) => {
if (e.button !== 0) return
startTimer()
}, [startTimer])
const onMouseUp = useCallback<React.MouseEventHandler>(() => {
handleEnd(true)
}, [handleEnd])
const onMouseLeave = useCallback<React.MouseEventHandler>(() => {
handleEnd(false)
}, [handleEnd])
const onTouchStart = useCallback<React.TouchEventHandler>(() => {
startTimer()
}, [startTimer])
const onTouchEnd = useCallback<React.TouchEventHandler>((e) => {
if (isLongPressRef.current) {
e.preventDefault()
}
handleEnd(true)
}, [handleEnd])
const onTouchMove = useCallback<React.TouchEventHandler>(() => {
touchMoved.current = true
clearTimer()
}, [clearTimer])
const onContextMenu = useCallback<React.MouseEventHandler>((e) => {
if (!disabled) {
e.preventDefault()
clearTimer()
isLongPressRef.current = true
onLongPress()
}
}, [disabled, clearTimer, onLongPress])
const onKeyDown = useCallback<React.KeyboardEventHandler>((e) => {
if (disabled) return
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onClick?.()
}
}, [disabled, onClick])
return {
onMouseDown,
onMouseUp,
onMouseLeave,
onTouchStart,
onTouchEnd,
onTouchMove,
onContextMenu,
onKeyDown
}
}
+1
View File
@@ -110,6 +110,7 @@ function SessionsPage() {
onRefresh={handleRefresh}
isLoading={isLoading}
renderHeader={false}
api={api}
/>
</div>
</div>