mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
add inline rename for sidebar chats (#28)
* add inline chat rename in sidebar * format sidebar rename test for biome
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user