add inline rename for sidebar chats (#28)

* add inline chat rename in sidebar

* format sidebar rename test for biome
This commit is contained in:
tulsi
2026-03-31 15:44:02 -07:00
committed by GitHub
parent a2133170b5
commit 430d5ba78c
5 changed files with 303 additions and 46 deletions
+8
View File
@@ -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}
@@ -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}
/>
</nav>
@@ -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<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<div className={cn("flex items-center group", className)}>
<div className="flex items-center flex-1 min-w-0 py-1.5 rounded-md text-[13px] px-2.5">
<input
ref={inputRef}
type="text"
value={draftTitle}
onChange={(e) => 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"
/>
</div>
</div>
);
}
return (
<div className={cn("flex items-center group", className)}>
<button
type="button"
onClick={() => onSelect?.(id)}
onDoubleClick={(event) => {
event.preventDefault();
event.stopPropagation();
startRename();
}}
title="Double-click to rename"
className={cn(
"flex items-center gap-2 flex-1 min-w-0 py-1.5 rounded-md text-[13px]",
"transition-colors duration-150 px-2.5 text-left",
isActive
? "bg-background-tertiary/70 text-foreground font-medium"
: isOpen
? "text-foreground hover:bg-background-tertiary/50"
: "text-foreground-secondary/70 hover:text-foreground hover:bg-background-tertiary/50",
)}
>
<span className="flex-1 min-w-0 truncate">{title}</span>
</button>
<div ref={menuRef} className="relative shrink-0">
<button
type="button"
aria-label={`Options for ${title}`}
aria-haspopup="true"
aria-expanded={menuOpen}
onClick={(e) => {
e.stopPropagation();
setMenuOpen((prev) => !prev);
}}
className={cn(
"flex items-center justify-center w-6 h-6 rounded-md",
"text-foreground-secondary/40 hover:text-foreground hover:bg-background-tertiary/50",
menuOpen
? "visible opacity-100"
: "invisible group-hover:visible opacity-0 group-hover:opacity-100",
)}
>
<MoreHorizontal className="w-3.5 h-3.5" />
</button>
{menuOpen && (
<div
role="menu"
className="absolute right-0 top-full z-10 mt-1 w-32 rounded-lg border border-border bg-background py-1 shadow-lg"
>
<button
type="button"
role="menuitem"
onClick={startRename}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs hover:bg-background-secondary transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
Rename
</button>
<button
type="button"
role="menuitem"
onClick={() => {
setMenuOpen(false);
onArchive?.(id);
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs hover:bg-background-secondary transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
Archive
</button>
</div>
)}
</div>
</div>
);
}
@@ -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 (
<div key={tab.id} className="flex items-center group">
<button
type="button"
onClick={() => onSelectTab?.(tab.id)}
className={cn(
"flex items-center gap-2 flex-1 min-w-0 py-1.5 pl-8 pr-1 rounded-md text-[13px]",
"transition-colors duration-150",
isActive
? "bg-background-tertiary/70 text-foreground font-medium"
: isOpen
? "text-foreground hover:bg-background-tertiary/50"
: "text-foreground-secondary/70 hover:text-foreground hover:bg-background-tertiary/50",
)}
>
<span className="flex-1 min-w-0 truncate text-left">
{tab.title}
</span>
</button>
<ItemMenu
label={tab.title}
onArchive={() => onArchiveChat?.(tab.id)}
/>
</div>
<SidebarChatRow
key={tab.id}
id={tab.id}
title={tab.title}
isActive={isActive}
isOpen={isOpen}
className="pl-5"
onSelect={onSelectTab}
onRename={onRenameChat}
onArchive={onArchiveChat}
/>
);
})}
{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}
/>
))}
</div>
@@ -433,29 +427,16 @@ export function SidebarProjectsSection({
const isActive = activeTabId === tab.id;
const isOpen = tab.isOpenTab ?? false;
return (
<div key={tab.id} className="flex items-center group">
<button
type="button"
onClick={() => onSelectTab?.(tab.id)}
className={cn(
"flex items-center gap-2 flex-1 min-w-0 py-1.5 rounded-md text-[13px]",
"transition-colors duration-150 px-2.5",
isActive
? "bg-background-tertiary/70 text-foreground font-medium"
: isOpen
? "text-foreground hover:bg-background-tertiary/50"
: "text-foreground-secondary/70 hover:text-foreground hover:bg-background-tertiary/50",
)}
>
<span className="flex-1 min-w-0 truncate text-left">
{tab.title}
</span>
</button>
<ItemMenu
label={tab.title}
onArchive={() => onArchiveChat?.(tab.id)}
/>
</div>
<SidebarChatRow
key={tab.id}
id={tab.id}
title={tab.title}
isActive={isActive}
isOpen={isOpen}
onSelect={onSelectTab}
onRename={onRenameChat}
onArchive={onArchiveChat}
/>
);
})}
{hasMoreRecents && (
@@ -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(
<SidebarChatRow
id="session-1"
title="Original Title"
isActive={false}
isOpen={false}
onRename={onRename}
/>,
);
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(
<SidebarChatRow
id="session-1"
title="Original Title"
isActive={false}
isOpen={false}
onRename={onRename}
/>,
);
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(
<SidebarChatRow
id="session-1"
title="Same Title"
isActive={false}
isOpen={false}
onRename={onRename}
/>,
);
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();
});
});