mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
feat(ui): add inline rename for chat sessions in sidebar (#6995)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user