feat(session-item): add in-place editing for session names

- Implement double-click to edit session names directly in the list
- Add loading state during save operation
- Update useInPlaceEdit hook to support async operations and saving state
- Adjust styling to accommodate new edit input field
This commit is contained in:
icarus 2025-09-24 03:21:00 +08:00
parent 0e35224787
commit 6950b6f1e7
2 changed files with 84 additions and 32 deletions

View File

@ -1,7 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTimer } from './useTimer'
export interface UseInPlaceEditOptions {
onSave: (value: string) => void
onSave: ((value: string) => void) | ((value: string) => Promise<void>)
onCancel?: () => void
autoSelectOnStart?: boolean
trimOnSave?: boolean
@ -9,6 +11,7 @@ export interface UseInPlaceEditOptions {
export interface UseInPlaceEditReturn {
isEditing: boolean
isSaving: boolean
editValue: string
inputRef: React.RefObject<HTMLInputElement | null>
startEdit: (initialValue: string) => void
@ -16,23 +19,27 @@ export interface UseInPlaceEditReturn {
cancelEdit: () => void
handleKeyDown: (e: React.KeyboardEvent) => void
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void
handleValueChange: (value: string) => void
}
/**
* A React hook that provides in-place editing functionality for text inputs
* @param options - Configuration options for the in-place edit behavior
* @param options.onSave - Callback function called when edits are saved
* @param options.onCancel - Optional callback function called when editing is cancelled
* @param options.autoSelectOnStart - Whether to automatically select text when editing starts (default: true)
* @param options.trimOnSave - Whether to trim whitespace when saving (default: true)
* @returns An object containing the editing state and handler functions
*/
export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditReturn {
const { onSave, onCancel, autoSelectOnStart = true, trimOnSave = true } = options
const [isSaving, setIsSaving] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [editValue, setEditValue] = useState('')
const [originalValue, setOriginalValue] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const editTimerRef = useRef<NodeJS.Timeout>(undefined)
useEffect(() => {
return () => {
clearTimeout(editTimerRef.current)
}
}, [])
const { setTimeoutTimer } = useTimer()
const startEdit = useCallback(
(initialValue: string) => {
@ -40,26 +47,37 @@ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditRe
setEditValue(initialValue)
setOriginalValue(initialValue)
clearTimeout(editTimerRef.current)
editTimerRef.current = setTimeout(() => {
inputRef.current?.focus()
if (autoSelectOnStart) {
inputRef.current?.select()
}
}, 0)
setTimeoutTimer(
'startEdit',
() => {
inputRef.current?.focus()
if (autoSelectOnStart) {
inputRef.current?.select()
}
},
0
)
},
[autoSelectOnStart]
[autoSelectOnStart, setTimeoutTimer]
)
const saveEdit = useCallback(() => {
const finalValue = trimOnSave ? editValue.trim() : editValue
if (finalValue !== originalValue) {
onSave(finalValue)
const saveEdit = useCallback(async () => {
if (isSaving) return
setIsSaving(true)
try {
const finalValue = trimOnSave ? editValue.trim() : editValue
if (finalValue !== originalValue) {
await onSave(finalValue)
}
setIsEditing(false)
setEditValue('')
setOriginalValue('')
} finally {
setIsSaving(false)
}
setIsEditing(false)
setEditValue('')
setOriginalValue('')
}, [editValue, originalValue, onSave, trimOnSave])
}, [isSaving, trimOnSave, editValue, originalValue, onSave])
const cancelEdit = useCallback(() => {
setIsEditing(false)
@ -86,6 +104,10 @@ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditRe
setEditValue(e.target.value)
}, [])
const handleValueChange = useCallback((value: string) => {
setEditValue(value)
}, [])
// Handle clicks outside the input to save
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@ -105,12 +127,14 @@ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditRe
return {
isEditing,
isSaving,
editValue,
inputRef,
startEdit,
saveEdit,
cancelEdit,
handleKeyDown,
handleInputChange
handleInputChange,
handleValueChange
}
}

View File

@ -1,6 +1,8 @@
import { Button, cn, useDisclosure } from '@heroui/react'
import { Button, cn, Input, useDisclosure } from '@heroui/react'
import { DeleteIcon, EditIcon } from '@renderer/components/Icons'
import { SessionModal } from '@renderer/components/Popups/agent/SessionModal'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import { useInPlaceEdit } from '@renderer/hooks/useInPlaceEdit'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { AgentSessionEntity } from '@renderer/types'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@renderer/ui/context-menu'
@ -23,15 +25,24 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, isDisabled, isLoa
const { t } = useTranslation()
const { isOpen, onOpen, onClose } = useDisclosure()
const { chat } = useRuntime()
const updateSession = useUpdateSession(agentId)
const activeSessionId = chat.activeSessionId[agentId]
const { isEditing, isSaving, editValue, inputRef, startEdit, handleKeyDown, handleValueChange } = useInPlaceEdit({
onSave: async (value) => {
if (value !== session.name) {
await updateSession({ id: session.id, name: value })
}
}
})
const isActive = activeSessionId === session.id
const SessionLabel = useCallback(() => {
const displayName = session.name ?? session.id
return (
<>
<span className="text-sm">{displayName}</span>
<span className="px-2 text-sm">{displayName}</span>
</>
)
}, [session.id, session.name])
@ -44,9 +55,26 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, isDisabled, isLoa
isDisabled={isDisabled}
isLoading={isLoading}
onPress={onPress}
className={isActive ? 'active' : ''}>
<SessionLabelContainer className="name" title={session.name ?? session.id}>
<SessionLabel />
className={isActive ? 'active' : ''}
onDoubleClick={() => startEdit(session.name ?? '')}>
<SessionLabelContainer className="name h-full w-full" title={session.name ?? session.id}>
{isEditing && (
<Input
ref={inputRef}
variant="bordered"
value={editValue}
onValueChange={handleValueChange}
onKeyDown={handleKeyDown}
onClick={(e) => e.stopPropagation()}
classNames={{
base: 'h-full',
mainWrapper: 'h-full',
inputWrapper: 'h-full min-h-0 px-1.5',
input: isSaving ? 'brightness-50' : undefined
}}
/>
)}
{!isEditing && <SessionLabel />}
</SessionLabelContainer>
</ButtonContainer>
</ContextMenuTrigger>
@ -79,7 +107,7 @@ const ButtonContainer: React.FC<React.ComponentProps<typeof Button>> = ({ classN
<Button
{...props}
className={cn(
'relative mb-2 flex h-[37px] flex-row justify-between p-2.5',
'relative mb-2 flex h-[37px] flex-row justify-between p-0',
'rounded-[var(--list-item-border-radius)]',
'border-[0.5px] border-transparent',
'w-[calc(var(--assistants-width)_-_20px)]',