Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions ui/desktop/src/components/GooseSidebar/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand All @@ -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">
Expand All @@ -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}
Expand Down
186 changes: 186 additions & 0 deletions ui/desktop/src/components/common/InlineEditText.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};