From 0c60fd6222746e396b5001af7abb32d87673e381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Bert=C3=B3?= <463349+robertoberto@users.noreply.github.com> Date: Sat, 23 May 2026 15:15:15 -0300 Subject: [PATCH] feat: rename sessions inline via double-click (#1320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: rename sessions inline via double-click Double-clicking a session name in the sidebar or the mobile session status bar now switches it into an inline editable input. Enter saves, Esc cancels, and clicking elsewhere blurs the input to save. This mirrors the VSCode/Finder rename pattern and removes the need to open the session menu for what is a very common action. * fix(rename): close sidebar input on empty title; drop duplicate mobile editor Two issues from PR review: 1. handleSaveEdit (sidebar) only closed the input when editTitle.trim() was non-empty. Clearing the title and pressing Enter or blurring left the input open with no exit path other than Escape. The save handler now always closes the editor; an empty title is treated as a silent cancel (no update call). 2. ExpandedView (mobile) renders the current session twice — once in the sticky header, once in the session list — so a single editingSessionId produced two simultaneous inputs for the current session. The list row for the current session now suppresses its rename input; the header remains the single editor in that case. * fix: refine inline session rename editing --------- Co-authored-by: Bohdan Triapitsyn --- .../chat/MobileSessionStatusBar.tsx | 310 +++++++++++++++--- .../session/sidebar/SessionNodeItem.tsx | 15 +- .../sidebar/hooks/useSessionActions.ts | 15 +- 3 files changed, 293 insertions(+), 47 deletions(-) diff --git a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx index a5dea54b..27709da4 100644 --- a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx +++ b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx @@ -302,27 +302,60 @@ function SessionItem({ getSessionTitle, onClick, onDoubleClick, - needsAttention + needsAttention, + isEditing = false, + editingTitle = '', + onEditingTitleChange, + onEditSave, + onEditCancel, }: { session: SessionWithStatus; isCurrent: boolean; getSessionAgentName: (s: Session) => string; getSessionTitle: (s: Session) => string; onClick: () => void; - onDoubleClick?: () => void; + onDoubleClick?: (sessionId: string, sessionTitle: string) => void; needsAttention: (sessionId: string) => boolean; + isEditing?: boolean; + editingTitle?: string; + onEditingTitleChange?: (value: string) => void; + onEditSave?: () => void; + onEditCancel?: () => void; }) { const agentName = getSessionAgentName(session); const agentColor = getAgentColor(agentName); const extraCount = (session._runningChildrenCount || 0) + (session._statusType !== 'idle' ? 1 : 0) - 1 - (session._childIndicators?.length || 0); + const sessionTitle = getSessionTitle(session); + const editInputRef = React.useRef(null); + const editCancelledRef = React.useRef(false); + + React.useEffect(() => { + if (isEditing) { + editCancelledRef.current = false; + const node = editInputRef.current; + if (node) { + node.focus(); + node.select(); + } + } + }, [isEditing]); return ( - + ); } @@ -396,6 +465,7 @@ function TokenUsageIndicator({ contextUsage }: { contextUsage: SessionContextUsa } interface SessionStatusHeaderProps { + currentSessionId?: string | null; currentSessionTitle: string; currentProjectLabel?: string; currentProjectIcon?: string | null; @@ -405,9 +475,16 @@ interface SessionStatusHeaderProps { onToggle: () => void; isExpanded?: boolean; childIndicators?: Array<{ session: Session; isRunning: boolean }>; + isEditing?: boolean; + editingTitle?: string; + onTitleDoubleClick?: (sessionId: string, sessionTitle: string) => void; + onEditingTitleChange?: (value: string) => void; + onEditSave?: () => void; + onEditCancel?: () => void; } function SessionStatusHeader({ + currentSessionId, currentSessionTitle, currentProjectLabel, currentProjectIcon, @@ -416,22 +493,49 @@ function SessionStatusHeader({ currentProjectColor, onToggle, isExpanded = false, - childIndicators = [] + childIndicators = [], + isEditing = false, + editingTitle = '', + onTitleDoubleClick, + onEditingTitleChange, + onEditSave, + onEditCancel, }: SessionStatusHeaderProps) { const [imageFailed, setImageFailed] = React.useState(false); const projectIconName = currentProjectIcon ? PROJECT_ICON_MAP[currentProjectIcon] : null; const imageUrl = !imageFailed ? currentProjectIconImageUrl : null; const projectColorVar = currentProjectColor ? (PROJECT_COLOR_MAP[currentProjectColor] ?? null) : null; const extraCount = childIndicators.length > 3 ? childIndicators.length - 3 : 0; + const editInputRef = React.useRef(null); + const editCancelledRef = React.useRef(false); React.useEffect(() => { setImageFailed(false); }, [currentProjectIconImageUrl]); + React.useEffect(() => { + if (isEditing) { + editCancelledRef.current = false; + const node = editInputRef.current; + if (node) { + node.focus(); + node.select(); + } + } + }, [isEditing]); + return ( - + ); } @@ -1142,6 +1290,7 @@ function ProjectBar({ function CollapsedView({ runningCount, unreadCount, + currentSessionId, currentSessionTitle, currentProjectLabel, currentProjectIcon, @@ -1152,9 +1301,16 @@ function CollapsedView({ onNewSession, contextUsage, childIndicators = [], + editingSessionId = null, + editingTitle = '', + onTitleDoubleClick, + onEditingTitleChange, + onEditSave, + onEditCancel, }: { runningCount: number; unreadCount: number; + currentSessionId?: string | null; currentSessionTitle: string; currentProjectLabel?: string; currentProjectIcon?: string | null; @@ -1165,6 +1321,12 @@ function CollapsedView({ onNewSession: () => void; contextUsage: SessionContextUsage | null; childIndicators?: Array<{ session: Session; isRunning: boolean }>; + editingSessionId?: string | null; + editingTitle?: string; + onTitleDoubleClick?: (sessionId: string, sessionTitle: string) => void; + onEditingTitleChange?: (value: string) => void; + onEditSave?: () => void; + onEditCancel?: () => void; }) { const { t } = useI18n(); const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe(); @@ -1182,6 +1344,7 @@ function CollapsedView({ >
@@ -1245,6 +1414,11 @@ function ExpandedView({ getProjectStatus, homeDirectory, childIndicators = [], + editingSessionId = null, + editingTitle = '', + onEditingTitleChange, + onEditSave, + onEditCancel, }: { sessions: SessionWithStatus[]; currentSessionId: string; @@ -1260,7 +1434,7 @@ function ExpandedView({ onToggleCollapse: () => void; onNewSession: () => void; onSessionClick: (id: string) => void; - onSessionDoubleClick?: () => void; + onSessionDoubleClick?: (sessionId: string, sessionTitle: string) => void; onProjectSwitch: (projectId: string) => void; onAddProject: () => void; onRemoveProject?: (projectId: string) => void; @@ -1273,6 +1447,11 @@ function ExpandedView({ getProjectStatus: (path: string) => { hasRunning: boolean; hasUnread: boolean }; homeDirectory: string | null; childIndicators?: Array<{ session: Session; isRunning: boolean }>; + editingSessionId?: string | null; + editingTitle?: string; + onEditingTitleChange?: (value: string) => void; + onEditSave?: () => void; + onEditCancel?: () => void; }) { const { t } = useI18n(); const containerRef = React.useRef(null); @@ -1334,6 +1513,7 @@ function ExpandedView({
{t('chat.mobileStatus.noSessionsInProject')}
) : ( - displaySessions.map((session) => ( - onSessionClick(session.id)} - onDoubleClick={onSessionDoubleClick} - needsAttention={needsAttention} - /> - )) + displaySessions.map((session) => { + // When the current session is being edited, the sticky header + // already renders the rename input; suppress the duplicate + // input on this row to avoid two simultaneous editors. + const isCurrent = session.id === currentSessionId; + const isEditingHere = editingSessionId === session.id && !isCurrent; + return ( + onSessionClick(session.id)} + onDoubleClick={onSessionDoubleClick} + needsAttention={needsAttention} + isEditing={isEditingHere} + editingTitle={editingTitle} + onEditingTitleChange={onEditingTitleChange} + onEditSave={onEditSave} + onEditCancel={onEditCancel} + /> + ); + }) )}
@@ -1424,13 +1622,13 @@ export const MobileSessionStatusBar: React.FC = ({ const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); const getContextUsage = useSessionUIStore((state) => state.getContextUsage); + const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle); const agents = useConfigStore((state) => state.agents); const getCurrentModel = useConfigStore((state) => state.getCurrentModel); const isMobile = useUIStore((state) => state.isMobile); const showMobileSessionStatusBar = useUIStore((state) => state.showMobileSessionStatusBar); const isMobileSessionStatusBarCollapsed = useUIStore((state) => state.isMobileSessionStatusBarCollapsed); const setIsMobileSessionStatusBarCollapsed = useUIStore((state) => state.setIsMobileSessionStatusBarCollapsed); - const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); // Project store const projects = useProjectsStore((state) => state.projects); @@ -1478,20 +1676,44 @@ export const MobileSessionStatusBar: React.FC = ({ const contextUsage = getContextUsage(contextLimit, outputLimit); const [isExpanded, setIsExpanded] = React.useState(false); + const [editingSessionId, setEditingSessionId] = React.useState(null); + const [editingTitle, setEditingTitle] = React.useState(''); if (!isMobile || !showMobileSessionStatusBar || totalCount === 0) { return null; } const handleSessionClick = (sessionId: string) => { + if (editingSessionId) return; setCurrentSession(sessionId); onSessionSwitch?.(sessionId); setIsExpanded(false); }; - const handleSessionDoubleClick = () => { - // On double-tap, switch to the Chat tab - setActiveMainTab('chat'); + const handleSessionDoubleClick = (sessionId: string, sessionTitle: string) => { + setEditingSessionId(sessionId); + setEditingTitle(sessionTitle); + }; + + const handleEditCancel = () => { + setEditingSessionId(null); + setEditingTitle(''); + }; + + const handleEditSave = () => { + if (!editingSessionId) return; + const trimmed = editingTitle.trim(); + const target = sessions.find((s) => s.id === editingSessionId); + const originalTitle = target ? getSessionTitle(target) : ''; + if (trimmed && trimmed !== originalTitle) { + void updateSessionTitle(editingSessionId, trimmed); + } + setEditingSessionId(null); + setEditingTitle(''); + }; + + const handleEditingTitleChange = (value: string) => { + setEditingTitle(value); }; const handleCreateSession = () => { @@ -1533,6 +1755,7 @@ export const MobileSessionStatusBar: React.FC = ({ = ({ onNewSession={handleCreateSession} contextUsage={contextUsage} childIndicators={currentSessionChildIndicators} + editingSessionId={editingSessionId} + editingTitle={editingTitle} + onTitleDoubleClick={handleSessionDoubleClick} + onEditingTitleChange={handleEditingTitleChange} + onEditSave={handleEditSave} + onEditCancel={handleEditCancel} /> ); } @@ -1579,6 +1808,11 @@ export const MobileSessionStatusBar: React.FC = ({ getProjectStatus={getProjectStatus} homeDirectory={homeDirectory} childIndicators={currentSessionChildIndicators} + editingSessionId={editingSessionId} + editingTitle={editingTitle} + onEditingTitleChange={handleEditingTitleChange} + onEditSave={handleEditSave} + onEditCancel={handleEditCancel} /> ); }; diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index df4de3f5..06431912 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -62,7 +62,7 @@ type Props = { handleCancelEdit: () => void; toggleParent: (expansionKey: string) => void; handleSessionSelect: (sessionId: string, sessionDirectory: string | null, isMissingDirectory: boolean, projectId?: string | null) => void; - handleSessionDoubleClick: () => void; + handleSessionDoubleClick: (sessionId: string, sessionTitle: string) => void; togglePinnedSession: (sessionId: string) => void; handleShareSession: (session: Session) => void; copiedSessionId: string | null; @@ -284,6 +284,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { : (showQuickArchiveAction ? 'group-hover:pr-12 group-focus-within:pr-12' : 'group-hover:pr-5 group-focus-within:pr-5')); const alwaysActionPaddingClass = showQuickArchiveAction ? 'pr-13' : 'pr-7'; const suppressNextSelectRef = React.useRef(false); + const editCancelledRef = React.useRef(false); const [isTouchPressed, setIsTouchPressed] = React.useState(false); const session = node.session; @@ -470,6 +471,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { onKeyDown={(event) => { if (event.key === 'Escape') { event.stopPropagation(); + editCancelledRef.current = true; handleCancelEdit(); return; } @@ -477,6 +479,13 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { event.stopPropagation(); } }} + onBlur={() => { + if (editCancelledRef.current) { + editCancelledRef.current = false; + return; + } + handleSaveEdit(); + }} />