feat(ui): add inline rename for chat sessions in sidebar (#6995)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
tulsi
2026-02-06 10:09:43 -05:00
committed by GitHub
parent 969ea53302
commit b1feead405
2 changed files with 217 additions and 2 deletions
@@ -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 (
<div className="relative ml-3">
{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 (
<div key={session.id} className="relative flex items-center">
@@ -154,7 +171,19 @@ const SessionList = React.memo<{
title={displayName}
>
{session.recipe && <ChefHat className="w-3.5 h-3.5 flex-shrink-0" />}
<span className="flex-1 truncate min-w-0 block">{displayName}</span>
<div className="flex-1 min-w-0">
{canRename ? (
<InlineEditText
value={displayName}
onSave={(newName) => handleRenameSession(session.id, newName)}
className="text-sm -mx-2 -my-1"
editClassName="text-sm"
singleClickEdit={false}
/>
) : (
<span className="truncate block">{displayName}</span>
)}
</div>
<SessionIndicators
isStreaming={isStreaming}
hasUnread={hasUnread}
@@ -0,0 +1,186 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { toast } from 'react-toastify';
import { errorMessage } from '../../utils/conversionUtils';
interface InlineEditTextProps {
value: string;
onSave: (newValue: string) => Promise<void>;
maxLength?: number;
placeholder?: string;
disabled?: boolean;
className?: string;
editClassName?: string;
onEditStart?: () => void;
onEditEnd?: () => void;
allowEmpty?: boolean;
singleClickEdit?: boolean;
}
export const InlineEditText: React.FC<InlineEditTextProps> = ({
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<HTMLInputElement>(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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<input
ref={inputRef}
type="text"
value={editValue}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
maxLength={maxLength}
placeholder={placeholder}
disabled={isSaving}
className={`
w-full px-2 py-1 border rounded
bg-background-default text-text-standard
border-blue-500 ring-2 ring-blue-500/20
focus:outline-none focus:ring-2 focus:ring-blue-500/40
disabled:opacity-50 disabled:cursor-not-allowed
${editClassName}
`}
onClick={(e) => e.stopPropagation()}
/>
);
}
return (
<div
className={`
cursor-pointer px-2 py-1 rounded
hover:bg-background-hover
transition-colors
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
${className}
`}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
title={disabled ? '' : singleClickEdit ? 'Click to edit' : 'Double-click to edit'}
>
{value || <span className="text-text-subtle italic">{placeholder}</span>}
</div>
);
};