mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-20 23:22:05 +08:00
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:
parent
0e35224787
commit
6950b6f1e7
@ -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()
|
inputRef.current?.focus()
|
||||||
if (autoSelectOnStart) {
|
if (autoSelectOnStart) {
|
||||||
inputRef.current?.select()
|
inputRef.current?.select()
|
||||||
}
|
}
|
||||||
}, 0)
|
|
||||||
},
|
},
|
||||||
[autoSelectOnStart]
|
0
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[autoSelectOnStart, setTimeoutTimer]
|
||||||
)
|
)
|
||||||
|
|
||||||
const saveEdit = useCallback(() => {
|
const saveEdit = useCallback(async () => {
|
||||||
|
if (isSaving) return
|
||||||
|
|
||||||
|
setIsSaving(true)
|
||||||
|
|
||||||
|
try {
|
||||||
const finalValue = trimOnSave ? editValue.trim() : editValue
|
const finalValue = trimOnSave ? editValue.trim() : editValue
|
||||||
if (finalValue !== originalValue) {
|
if (finalValue !== originalValue) {
|
||||||
onSave(finalValue)
|
await onSave(finalValue)
|
||||||
}
|
}
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
setEditValue('')
|
setEditValue('')
|
||||||
setOriginalValue('')
|
setOriginalValue('')
|
||||||
}, [editValue, originalValue, onSave, trimOnSave])
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}, [isSaving, trimOnSave, editValue, originalValue, onSave])
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)]',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user