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 { useCallback, useEffect, useRef, useState } from 'react'
import { useTimer } from './useTimer'
export interface UseInPlaceEditOptions { export interface UseInPlaceEditOptions {
onSave: (value: string) => void onSave: ((value: string) => void) | ((value: string) => Promise<void>)
onCancel?: () => void onCancel?: () => void
autoSelectOnStart?: boolean autoSelectOnStart?: boolean
trimOnSave?: boolean trimOnSave?: boolean
@ -9,6 +11,7 @@ export interface UseInPlaceEditOptions {
export interface UseInPlaceEditReturn { export interface UseInPlaceEditReturn {
isEditing: boolean isEditing: boolean
isSaving: boolean
editValue: string editValue: string
inputRef: React.RefObject<HTMLInputElement | null> inputRef: React.RefObject<HTMLInputElement | null>
startEdit: (initialValue: string) => void startEdit: (initialValue: string) => void
@ -16,23 +19,27 @@ export interface UseInPlaceEditReturn {
cancelEdit: () => void cancelEdit: () => void
handleKeyDown: (e: React.KeyboardEvent) => void handleKeyDown: (e: React.KeyboardEvent) => void
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => 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 { export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditReturn {
const { onSave, onCancel, autoSelectOnStart = true, trimOnSave = true } = options const { onSave, onCancel, autoSelectOnStart = true, trimOnSave = true } = options
const [isSaving, setIsSaving] = useState(false)
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [editValue, setEditValue] = useState('') const [editValue, setEditValue] = useState('')
const [originalValue, setOriginalValue] = useState('') const [originalValue, setOriginalValue] = useState('')
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const { setTimeoutTimer } = useTimer()
const editTimerRef = useRef<NodeJS.Timeout>(undefined)
useEffect(() => {
return () => {
clearTimeout(editTimerRef.current)
}
}, [])
const startEdit = useCallback( const startEdit = useCallback(
(initialValue: string) => { (initialValue: string) => {
@ -40,26 +47,37 @@ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditRe
setEditValue(initialValue) setEditValue(initialValue)
setOriginalValue(initialValue) setOriginalValue(initialValue)
clearTimeout(editTimerRef.current) setTimeoutTimer(
editTimerRef.current = setTimeout(() => { 'startEdit',
inputRef.current?.focus() () => {
if (autoSelectOnStart) { inputRef.current?.focus()
inputRef.current?.select() if (autoSelectOnStart) {
} inputRef.current?.select()
}, 0) }
},
0
)
}, },
[autoSelectOnStart] [autoSelectOnStart, setTimeoutTimer]
) )
const saveEdit = useCallback(() => { const saveEdit = useCallback(async () => {
const finalValue = trimOnSave ? editValue.trim() : editValue if (isSaving) return
if (finalValue !== originalValue) {
onSave(finalValue) setIsSaving(true)
try {
const finalValue = trimOnSave ? editValue.trim() : editValue
if (finalValue !== originalValue) {
await onSave(finalValue)
}
setIsEditing(false)
setEditValue('')
setOriginalValue('')
} finally {
setIsSaving(false)
} }
setIsEditing(false) }, [isSaving, trimOnSave, editValue, originalValue, onSave])
setEditValue('')
setOriginalValue('')
}, [editValue, originalValue, onSave, trimOnSave])
const cancelEdit = useCallback(() => { const cancelEdit = useCallback(() => {
setIsEditing(false) setIsEditing(false)
@ -86,6 +104,10 @@ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditRe
setEditValue(e.target.value) setEditValue(e.target.value)
}, []) }, [])
const handleValueChange = useCallback((value: string) => {
setEditValue(value)
}, [])
// Handle clicks outside the input to save // Handle clicks outside the input to save
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
@ -105,12 +127,14 @@ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditRe
return { return {
isEditing, isEditing,
isSaving,
editValue, editValue,
inputRef, inputRef,
startEdit, startEdit,
saveEdit, saveEdit,
cancelEdit, cancelEdit,
handleKeyDown, 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 { DeleteIcon, EditIcon } from '@renderer/components/Icons'
import { SessionModal } from '@renderer/components/Popups/agent/SessionModal' 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 { useRuntime } from '@renderer/hooks/useRuntime'
import { AgentSessionEntity } from '@renderer/types' import { AgentSessionEntity } from '@renderer/types'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@renderer/ui/context-menu' 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 { t } = useTranslation()
const { isOpen, onOpen, onClose } = useDisclosure() const { isOpen, onOpen, onClose } = useDisclosure()
const { chat } = useRuntime() const { chat } = useRuntime()
const updateSession = useUpdateSession(agentId)
const activeSessionId = chat.activeSessionId[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 isActive = activeSessionId === session.id
const SessionLabel = useCallback(() => { const SessionLabel = useCallback(() => {
const displayName = session.name ?? session.id const displayName = session.name ?? session.id
return ( return (
<> <>
<span className="text-sm">{displayName}</span> <span className="px-2 text-sm">{displayName}</span>
</> </>
) )
}, [session.id, session.name]) }, [session.id, session.name])
@ -44,9 +55,26 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, isDisabled, isLoa
isDisabled={isDisabled} isDisabled={isDisabled}
isLoading={isLoading} isLoading={isLoading}
onPress={onPress} onPress={onPress}
className={isActive ? 'active' : ''}> className={isActive ? 'active' : ''}
<SessionLabelContainer className="name" title={session.name ?? session.id}> onDoubleClick={() => startEdit(session.name ?? '')}>
<SessionLabel /> <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> </SessionLabelContainer>
</ButtonContainer> </ButtonContainer>
</ContextMenuTrigger> </ContextMenuTrigger>
@ -79,7 +107,7 @@ const ButtonContainer: React.FC<React.ComponentProps<typeof Button>> = ({ classN
<Button <Button
{...props} {...props}
className={cn( 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)]', 'rounded-[var(--list-item-border-radius)]',
'border-[0.5px] border-transparent', 'border-[0.5px] border-transparent',
'w-[calc(var(--assistants-width)_-_20px)]', 'w-[calc(var(--assistants-width)_-_20px)]',