From 430d5ba78c8e0cf60a700b9013d49fa5ea539f01 Mon Sep 17 00:00:00 2001 From: tulsi Date: Tue, 31 Mar 2026 15:44:02 -0700 Subject: [PATCH] add inline rename for sidebar chats (#28) * add inline chat rename in sidebar * format sidebar rename test for biome --- ui/goose2/src/app/AppShell.tsx | 8 + ui/goose2/src/features/sidebar/ui/Sidebar.tsx | 3 + .../features/sidebar/ui/SidebarChatRow.tsx | 179 ++++++++++++++++++ .../sidebar/ui/SidebarProjectsSection.tsx | 73 +++---- .../ui/__tests__/SidebarChatRow.test.tsx | 86 +++++++++ 5 files changed, 303 insertions(+), 46 deletions(-) create mode 100644 ui/goose2/src/features/sidebar/ui/SidebarChatRow.tsx create mode 100644 ui/goose2/src/features/sidebar/ui/__tests__/SidebarChatRow.test.tsx diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index a52cae508e..9c9ce891a5 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -256,6 +256,13 @@ export function AppShell({ children }: { children?: React.ReactNode }) { [projectStore.projects], ); + const handleRenameChat = useCallback( + (tabId: string, nextTitle: string) => { + sessionStore.updateSession(tabId, { title: nextTitle }); + }, + [sessionStore], + ); + const handleNewTab = useCallback(() => { setHomeSelectedProvider(undefined); createNewTab(); @@ -392,6 +399,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) { onEditProject={handleEditProject} onArchiveProject={handleArchiveProject} onArchiveChat={handleArchiveChat} + onRenameChat={handleRenameChat} onSelectTab={handleTabSelect} activeView={activeView} activeTabId={activeTabId} diff --git a/ui/goose2/src/features/sidebar/ui/Sidebar.tsx b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx index 67baf223f2..71f96b4a20 100644 --- a/ui/goose2/src/features/sidebar/ui/Sidebar.tsx +++ b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx @@ -27,6 +27,7 @@ interface SidebarProps { onEditProject?: (projectId: string) => void; onArchiveProject?: (projectId: string) => void; onArchiveChat?: (tabId: string) => void; + onRenameChat?: (tabId: string, nextTitle: string) => void; onNavigate?: (view: AppView) => void; onSelectTab?: (tabId: string) => void; activeView?: AppView; @@ -53,6 +54,7 @@ export function Sidebar({ onEditProject, onArchiveProject, onArchiveChat, + onRenameChat, onNavigate, onSelectTab, activeView, @@ -346,6 +348,7 @@ export function Sidebar({ onEditProject={onEditProject} onArchiveProject={onArchiveProject} onArchiveChat={onArchiveChat} + onRenameChat={onRenameChat} /> diff --git a/ui/goose2/src/features/sidebar/ui/SidebarChatRow.tsx b/ui/goose2/src/features/sidebar/ui/SidebarChatRow.tsx new file mode 100644 index 0000000000..d092c8b687 --- /dev/null +++ b/ui/goose2/src/features/sidebar/ui/SidebarChatRow.tsx @@ -0,0 +1,179 @@ +import { useEffect, useRef, useState } from "react"; +import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"; +import { cn } from "@/shared/lib/cn"; + +interface SidebarChatRowProps { + id: string; + title: string; + isActive: boolean; + isOpen: boolean; + className?: string; + onSelect?: (id: string) => void; + onRename?: (id: string, nextTitle: string) => void; + onArchive?: (id: string) => void; +} + +export function SidebarChatRow({ + id, + title, + isActive, + isOpen, + className, + onSelect, + onRename, + onArchive, +}: SidebarChatRowProps) { + const [menuOpen, setMenuOpen] = useState(false); + const [editing, setEditing] = useState(false); + const [draftTitle, setDraftTitle] = useState(title); + const menuRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + if (!menuOpen) return; + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setMenuOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [menuOpen]); + + useEffect(() => { + setDraftTitle(title); + }, [title]); + + useEffect(() => { + if (!editing) return; + inputRef.current?.focus(); + inputRef.current?.select(); + }, [editing]); + + const startRename = () => { + setDraftTitle(title); + setMenuOpen(false); + setEditing(true); + }; + + const cancelRename = () => { + setDraftTitle(title); + setEditing(false); + }; + + const commitRename = () => { + const nextTitle = draftTitle.trim(); + setEditing(false); + if (!nextTitle || nextTitle === title) return; + onRename?.(id, nextTitle); + }; + + if (editing) { + return ( +
+
+ setDraftTitle(e.target.value)} + onBlur={commitRename} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitRename(); + } + if (e.key === "Escape") { + e.preventDefault(); + cancelRename(); + } + }} + className="w-full min-w-0 h-6 px-1.5 rounded border border-border bg-background text-[13px] text-foreground focus:outline-none focus:ring-1 focus:ring-ring" + /> +
+
+ ); + } + + return ( +
+ + +
+ + + {menuOpen && ( +
+ + +
+ )} +
+
+ ); +} diff --git a/ui/goose2/src/features/sidebar/ui/SidebarProjectsSection.tsx b/ui/goose2/src/features/sidebar/ui/SidebarProjectsSection.tsx index 711a260878..18a57323fa 100644 --- a/ui/goose2/src/features/sidebar/ui/SidebarProjectsSection.tsx +++ b/ui/goose2/src/features/sidebar/ui/SidebarProjectsSection.tsx @@ -9,6 +9,7 @@ import { import { cn } from "@/shared/lib/cn"; import type { AppView } from "@/app/AppShell"; import type { ProjectInfo } from "@/features/projects/api/projects"; +import { SidebarChatRow } from "./SidebarChatRow"; const MAX_VISIBLE_CHATS = 3; @@ -40,6 +41,7 @@ interface SidebarProjectsSectionProps { onEditProject?: (projectId: string) => void; onArchiveProject?: (projectId: string) => void; onArchiveChat?: (tabId: string) => void; + onRenameChat?: (tabId: string, nextTitle: string) => void; } function ItemMenu({ @@ -135,6 +137,7 @@ function ProjectSection({ onEditProject, onArchiveProject, onArchiveChat, + onRenameChat, }: { project: ProjectInfo; projectChats: TabInfo[]; @@ -147,6 +150,7 @@ function ProjectSection({ onEditProject?: (projectId: string) => void; onArchiveProject?: (projectId: string) => void; onArchiveChat?: (tabId: string) => void; + onRenameChat?: (tabId: string, nextTitle: string) => void; }) { const [showAll, setShowAll] = useState(false); const visibleChats = showAll @@ -209,29 +213,17 @@ function ProjectSection({ const isActive = activeTabId === tab.id; const isOpen = tab.isOpenTab ?? false; return ( -
- - onArchiveChat?.(tab.id)} - /> -
+ ); })} {hasMore && ( @@ -291,6 +283,7 @@ export function SidebarProjectsSection({ onEditProject, onArchiveProject, onArchiveChat, + onRenameChat, }: SidebarProjectsSectionProps) { const [showAllRecents, setShowAllRecents] = useState(false); const visibleRecents = showAllRecents @@ -380,6 +373,7 @@ export function SidebarProjectsSection({ onEditProject={onEditProject} onArchiveProject={onArchiveProject} onArchiveChat={onArchiveChat} + onRenameChat={onRenameChat} /> ))} @@ -433,29 +427,16 @@ export function SidebarProjectsSection({ const isActive = activeTabId === tab.id; const isOpen = tab.isOpenTab ?? false; return ( -
- - onArchiveChat?.(tab.id)} - /> -
+ ); })} {hasMoreRecents && ( diff --git a/ui/goose2/src/features/sidebar/ui/__tests__/SidebarChatRow.test.tsx b/ui/goose2/src/features/sidebar/ui/__tests__/SidebarChatRow.test.tsx new file mode 100644 index 0000000000..de68a9f2b6 --- /dev/null +++ b/ui/goose2/src/features/sidebar/ui/__tests__/SidebarChatRow.test.tsx @@ -0,0 +1,86 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { SidebarChatRow } from "../SidebarChatRow"; + +describe("SidebarChatRow", () => { + it("starts inline rename on double-click and commits on Enter", async () => { + const user = userEvent.setup(); + const onRename = vi.fn(); + + render( + , + ); + + await user.dblClick(screen.getByTitle("Double-click to rename")); + + const input = screen.getByRole("textbox"); + await user.clear(input); + await user.type(input, "Renamed Chat{Enter}"); + + expect(onRename).toHaveBeenCalledWith("session-1", "Renamed Chat"); + }); + + it("opens rename from menu and cancels on Escape", async () => { + const user = userEvent.setup(); + const onRename = vi.fn(); + + render( + , + ); + + await user.click( + screen.getByRole("button", { name: /options for original title/i }), + ); + await user.click(screen.getByRole("menuitem", { name: /rename/i })); + + const input = screen.getByRole("textbox"); + await user.clear(input); + await user.type(input, "Should Not Save{Escape}"); + + expect(onRename).not.toHaveBeenCalled(); + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + }); + + it("trims input and does not rename when empty or unchanged", async () => { + const user = userEvent.setup(); + const onRename = vi.fn(); + + render( + , + ); + + await user.dblClick(screen.getByTitle("Double-click to rename")); + const input = screen.getByRole("textbox"); + + await user.clear(input); + await user.type(input, " {Enter}"); + + expect(onRename).not.toHaveBeenCalled(); + + await user.dblClick(screen.getByTitle("Double-click to rename")); + const input2 = screen.getByRole("textbox"); + await user.clear(input2); + await user.type(input2, " Same Title {Enter}"); + + expect(onRename).not.toHaveBeenCalled(); + }); +});