From cf0afd0b5be0b70c5059338c7be47427ecf91508 Mon Sep 17 00:00:00 2001 From: tulsi Date: Thu, 5 Feb 2026 12:21:11 -0500 Subject: [PATCH] feat(ui): add inline rename for chat sessions in sidebar Add double-click inline editing for chat session names in the sidebar, improving UX by allowing quick renames without opening a modal. - Create reusable InlineEditText component with validation and keyboard shortcuts - Integrate inline editing into sidebar session list with double-click activation - Use pointer cursor to indicate clickability, switching to text cursor when editing - Protect recipe sessions from renaming (read-only for sessions with recipe titles) - Sync updates across all components via SESSION_RENAMED event - Support Enter to save, Escape to cancel, and blur to save - Handle errors with toast notifications and automatic revert - Validate max 200 characters and trim whitespace - Maintain compatibility with existing modal-based rename in session history page Co-authored-by: Cursor --- .../components/GooseSidebar/AppSidebar.tsx | 33 +++- .../src/components/common/InlineEditText.tsx | 186 ++++++++++++++++++ 2 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 ui/desktop/src/components/common/InlineEditText.tsx diff --git a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx index baefdc5c684a..65a58cea299c 100644 --- a/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx +++ b/ui/desktop/src/components/GooseSidebar/AppSidebar.tsx @@ -25,13 +25,14 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/colla import { Gear } from '../icons'; import { View, ViewOptions } from '../../utils/navigationUtils'; import { DEFAULT_CHAT_TITLE, useChatContext } from '../../contexts/ChatContext'; -import { listSessions, Session } from '../../api'; +import { listSessions, Session, updateSessionName } from '../../api'; import { resumeSession, startNewSession, shouldShowNewChatTitle } from '../../sessions'; import { useNavigation } from '../../hooks/useNavigation'; import { SessionIndicators } from '../SessionIndicators'; import { useSidebarSessionStatus } from '../../hooks/useSidebarSessionStatus'; import { getInitialWorkingDir } from '../../utils/workingDir'; import { useConfig } from '../ConfigContext'; +import { InlineEditText } from '../common/InlineEditText'; interface SidebarProps { onSelectSession: (sessionId: string) => void; @@ -124,6 +125,21 @@ const SessionList = React.memo<{ }); }, [sessions]); + const handleRenameSession = async (sessionId: string, newName: string) => { + await updateSessionName({ + path: { session_id: sessionId }, + body: { name: newName }, + throwOnError: true, + }); + + // Dispatch event to update all components + window.dispatchEvent( + new CustomEvent(AppEvents.SESSION_RENAMED, { + detail: { sessionId, newName }, + }) + ); + }; + return (
{sortedSessions.map((session, index) => { @@ -133,6 +149,7 @@ const SessionList = React.memo<{ const hasUnread = status?.hasUnreadActivity ?? false; const displayName = getSessionDisplayName(session); const isLast = index === sortedSessions.length - 1; + const canRename = !session.recipe?.title; return (
@@ -154,7 +171,19 @@ const SessionList = React.memo<{ title={displayName} > {session.recipe && } - {displayName} +
+ {canRename ? ( + handleRenameSession(session.id, newName)} + className="text-sm -mx-2 -my-1" + editClassName="text-sm" + singleClickEdit={false} + /> + ) : ( + {displayName} + )} +
Promise; + maxLength?: number; + placeholder?: string; + disabled?: boolean; + className?: string; + editClassName?: string; + onEditStart?: () => void; + onEditEnd?: () => void; + allowEmpty?: boolean; + singleClickEdit?: boolean; +} + +export const InlineEditText: React.FC = ({ + value, + onSave, + maxLength = 200, + placeholder = 'Enter text', + disabled = false, + className = '', + editClassName = '', + onEditStart, + onEditEnd, + allowEmpty = false, + singleClickEdit = true, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(value); + const [isSaving, setIsSaving] = useState(false); + const inputRef = useRef(null); + const originalValue = useRef(value); + + useEffect(() => { + if (!isEditing) { + setEditValue(value); + originalValue.current = value; + } + }, [value, isEditing]); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + const handleStartEdit = useCallback(() => { + if (disabled || isSaving) return; + setIsEditing(true); + setEditValue(value); + onEditStart?.(); + }, [disabled, isSaving, value, onEditStart]); + + const handleCancel = useCallback(() => { + setIsEditing(false); + setEditValue(originalValue.current); + onEditEnd?.(); + }, [onEditEnd]); + + const handleSave = useCallback(async () => { + if (isSaving) return; + + const trimmedValue = editValue.trim(); + + // Check if value unchanged + if (trimmedValue === originalValue.current) { + handleCancel(); + return; + } + + // Check if empty when not allowed + if (!allowEmpty && !trimmedValue) { + handleCancel(); + return; + } + + setIsSaving(true); + try { + await onSave(trimmedValue); + originalValue.current = trimmedValue; + setIsEditing(false); + onEditEnd?.(); + } catch (error) { + const errMsg = errorMessage(error, 'Failed to save'); + console.error('InlineEditText save error:', errMsg); + toast.error(errMsg); + setEditValue(originalValue.current); + handleCancel(); + } finally { + setIsSaving(false); + } + }, [editValue, isSaving, allowEmpty, onSave, handleCancel, onEditEnd]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !isSaving) { + e.preventDefault(); + handleSave(); + } else if (e.key === 'Escape' && !isSaving) { + e.preventDefault(); + handleCancel(); + } + }, + [handleSave, handleCancel, isSaving] + ); + + const handleBlur = useCallback(() => { + if (!isSaving) { + handleSave(); + } + }, [handleSave, isSaving]); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + setEditValue(e.target.value); + }, + [] + ); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (singleClickEdit) { + e.stopPropagation(); + handleStartEdit(); + } + }, + [singleClickEdit, handleStartEdit] + ); + + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + if (!singleClickEdit) { + e.stopPropagation(); + handleStartEdit(); + } + }, + [singleClickEdit, handleStartEdit] + ); + + if (isEditing) { + return ( + e.stopPropagation()} + /> + ); + } + + return ( +
+ {value || {placeholder}} +
+ ); +};