Skip to content

Commit 4e68f2d

Browse files
feat(ui): add inline rename for chat sessions in sidebar (aaif-goose#6995)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d565dc5 commit 4e68f2d

2 files changed

Lines changed: 217 additions & 2 deletions

File tree

ui/desktop/src/components/GooseSidebar/AppSidebar.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,14 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/colla
2525
import { Gear } from '../icons';
2626
import { View, ViewOptions } from '../../utils/navigationUtils';
2727
import { DEFAULT_CHAT_TITLE, useChatContext } from '../../contexts/ChatContext';
28-
import { listSessions, Session } from '../../api';
28+
import { listSessions, Session, updateSessionName } from '../../api';
2929
import { resumeSession, startNewSession, shouldShowNewChatTitle } from '../../sessions';
3030
import { useNavigation } from '../../hooks/useNavigation';
3131
import { SessionIndicators } from '../SessionIndicators';
3232
import { useSidebarSessionStatus } from '../../hooks/useSidebarSessionStatus';
3333
import { getInitialWorkingDir } from '../../utils/workingDir';
3434
import { useConfig } from '../ConfigContext';
35+
import { InlineEditText } from '../common/InlineEditText';
3536

3637
interface SidebarProps {
3738
onSelectSession: (sessionId: string) => void;
@@ -124,6 +125,21 @@ const SessionList = React.memo<{
124125
});
125126
}, [sessions]);
126127

128+
const handleRenameSession = async (sessionId: string, newName: string) => {
129+
await updateSessionName({
130+
path: { session_id: sessionId },
131+
body: { name: newName },
132+
throwOnError: true,
133+
});
134+
135+
// Dispatch event to update all components
136+
window.dispatchEvent(
137+
new CustomEvent(AppEvents.SESSION_RENAMED, {
138+
detail: { sessionId, newName },
139+
})
140+
);
141+
};
142+
127143
return (
128144
<div className="relative ml-3">
129145
{sortedSessions.map((session, index) => {
@@ -133,6 +149,7 @@ const SessionList = React.memo<{
133149
const hasUnread = status?.hasUnreadActivity ?? false;
134150
const displayName = getSessionDisplayName(session);
135151
const isLast = index === sortedSessions.length - 1;
152+
const canRename = !session.recipe?.title;
136153

137154
return (
138155
<div key={session.id} className="relative flex items-center">
@@ -154,7 +171,19 @@ const SessionList = React.memo<{
154171
title={displayName}
155172
>
156173
{session.recipe && <ChefHat className="w-3.5 h-3.5 flex-shrink-0" />}
157-
<span className="flex-1 truncate min-w-0 block">{displayName}</span>
174+
<div className="flex-1 min-w-0">
175+
{canRename ? (
176+
<InlineEditText
177+
value={displayName}
178+
onSave={(newName) => handleRenameSession(session.id, newName)}
179+
className="text-sm -mx-2 -my-1"
180+
editClassName="text-sm"
181+
singleClickEdit={false}
182+
/>
183+
) : (
184+
<span className="truncate block">{displayName}</span>
185+
)}
186+
</div>
158187
<SessionIndicators
159188
isStreaming={isStreaming}
160189
hasUnread={hasUnread}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import React, { useState, useRef, useEffect, useCallback } from 'react';
2+
import { toast } from 'react-toastify';
3+
import { errorMessage } from '../../utils/conversionUtils';
4+
5+
interface InlineEditTextProps {
6+
value: string;
7+
onSave: (newValue: string) => Promise<void>;
8+
maxLength?: number;
9+
placeholder?: string;
10+
disabled?: boolean;
11+
className?: string;
12+
editClassName?: string;
13+
onEditStart?: () => void;
14+
onEditEnd?: () => void;
15+
allowEmpty?: boolean;
16+
singleClickEdit?: boolean;
17+
}
18+
19+
export const InlineEditText: React.FC<InlineEditTextProps> = ({
20+
value,
21+
onSave,
22+
maxLength = 200,
23+
placeholder = 'Enter text',
24+
disabled = false,
25+
className = '',
26+
editClassName = '',
27+
onEditStart,
28+
onEditEnd,
29+
allowEmpty = false,
30+
singleClickEdit = true,
31+
}) => {
32+
const [isEditing, setIsEditing] = useState(false);
33+
const [editValue, setEditValue] = useState(value);
34+
const [isSaving, setIsSaving] = useState(false);
35+
const inputRef = useRef<HTMLInputElement>(null);
36+
const originalValue = useRef(value);
37+
38+
useEffect(() => {
39+
if (!isEditing) {
40+
setEditValue(value);
41+
originalValue.current = value;
42+
}
43+
}, [value, isEditing]);
44+
45+
useEffect(() => {
46+
if (isEditing && inputRef.current) {
47+
inputRef.current.focus();
48+
inputRef.current.select();
49+
}
50+
}, [isEditing]);
51+
52+
const handleStartEdit = useCallback(() => {
53+
if (disabled || isSaving) return;
54+
setIsEditing(true);
55+
setEditValue(value);
56+
onEditStart?.();
57+
}, [disabled, isSaving, value, onEditStart]);
58+
59+
const handleCancel = useCallback(() => {
60+
setIsEditing(false);
61+
setEditValue(originalValue.current);
62+
onEditEnd?.();
63+
}, [onEditEnd]);
64+
65+
const handleSave = useCallback(async () => {
66+
if (isSaving) return;
67+
68+
const trimmedValue = editValue.trim();
69+
70+
// Check if value unchanged
71+
if (trimmedValue === originalValue.current) {
72+
handleCancel();
73+
return;
74+
}
75+
76+
// Check if empty when not allowed
77+
if (!allowEmpty && !trimmedValue) {
78+
handleCancel();
79+
return;
80+
}
81+
82+
setIsSaving(true);
83+
try {
84+
await onSave(trimmedValue);
85+
originalValue.current = trimmedValue;
86+
setIsEditing(false);
87+
onEditEnd?.();
88+
} catch (error) {
89+
const errMsg = errorMessage(error, 'Failed to save');
90+
console.error('InlineEditText save error:', errMsg);
91+
toast.error(errMsg);
92+
setEditValue(originalValue.current);
93+
handleCancel();
94+
} finally {
95+
setIsSaving(false);
96+
}
97+
}, [editValue, isSaving, allowEmpty, onSave, handleCancel, onEditEnd]);
98+
99+
const handleKeyDown = useCallback(
100+
(e: React.KeyboardEvent<HTMLInputElement>) => {
101+
if (e.key === 'Enter' && !isSaving) {
102+
e.preventDefault();
103+
handleSave();
104+
} else if (e.key === 'Escape' && !isSaving) {
105+
e.preventDefault();
106+
handleCancel();
107+
}
108+
},
109+
[handleSave, handleCancel, isSaving]
110+
);
111+
112+
const handleBlur = useCallback(() => {
113+
if (!isSaving) {
114+
handleSave();
115+
}
116+
}, [handleSave, isSaving]);
117+
118+
const handleChange = useCallback(
119+
(e: React.ChangeEvent<HTMLInputElement>) => {
120+
setEditValue(e.target.value);
121+
},
122+
[]
123+
);
124+
125+
const handleClick = useCallback(
126+
(e: React.MouseEvent) => {
127+
if (singleClickEdit) {
128+
e.stopPropagation();
129+
handleStartEdit();
130+
}
131+
},
132+
[singleClickEdit, handleStartEdit]
133+
);
134+
135+
const handleDoubleClick = useCallback(
136+
(e: React.MouseEvent) => {
137+
if (!singleClickEdit) {
138+
e.stopPropagation();
139+
handleStartEdit();
140+
}
141+
},
142+
[singleClickEdit, handleStartEdit]
143+
);
144+
145+
if (isEditing) {
146+
return (
147+
<input
148+
ref={inputRef}
149+
type="text"
150+
value={editValue}
151+
onChange={handleChange}
152+
onKeyDown={handleKeyDown}
153+
onBlur={handleBlur}
154+
maxLength={maxLength}
155+
placeholder={placeholder}
156+
disabled={isSaving}
157+
className={`
158+
w-full px-2 py-1 border rounded
159+
bg-background-default text-text-standard
160+
border-blue-500 ring-2 ring-blue-500/20
161+
focus:outline-none focus:ring-2 focus:ring-blue-500/40
162+
disabled:opacity-50 disabled:cursor-not-allowed
163+
${editClassName}
164+
`}
165+
onClick={(e) => e.stopPropagation()}
166+
/>
167+
);
168+
}
169+
170+
return (
171+
<div
172+
className={`
173+
cursor-pointer px-2 py-1 rounded
174+
hover:bg-background-hover
175+
transition-colors
176+
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
177+
${className}
178+
`}
179+
onClick={handleClick}
180+
onDoubleClick={handleDoubleClick}
181+
title={disabled ? '' : singleClickEdit ? 'Click to edit' : 'Double-click to edit'}
182+
>
183+
{value || <span className="text-text-subtle italic">{placeholder}</span>}
184+
</div>
185+
);
186+
};

0 commit comments

Comments
 (0)