mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,7 @@ function SessionsPage() {
|
||||
onRefresh={handleRefresh}
|
||||
isLoading={isLoading}
|
||||
renderHeader={false}
|
||||
api={api}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user