Studio: Add rename to project chat rows (#7005)

This commit is contained in:
oobabooga 2026-07-13 20:52:30 -03:00 committed by GitHub
commit a5eb10abc9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -14,6 +14,12 @@ import {
} from "@/components/assistant-ui/model-selector/remembered-load-settings";
import { ProjectComposer, Thread } from "@/components/assistant-ui/thread";
import { CopyableErrorChip } from "@/components/ui/copyable-error-chip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
ResizableHandle,
ResizablePanel,
@ -41,8 +47,10 @@ import { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";
import {
BubbleChatTemporaryIcon,
Edit03Icon,
Folder02Icon,
LayoutAlignRightIcon,
MoreVerticalIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useNavigate } from "@tanstack/react-router";
@ -82,6 +90,7 @@ import type { SelectedModelInput } from "./hooks/use-chat-model-runtime";
import { useChatProjects } from "./hooks/use-chat-projects";
import {
type SidebarItem,
renameChatItem,
useChatSidebarItems,
} from "./hooks/use-chat-sidebar-items";
import { useStagedModelPreparation } from "./hooks/use-staged-model-preparation";
@ -900,6 +909,19 @@ function ProjectLanding({
const [previews, setPreviews] = useState<
Record<string, { snippet: string; date: string }>
>({});
// Inline rename, mirroring the sidebar recent-row UX: edit the title in place,
// commit on Enter/blur, cancel on Escape. Reuses the projectId-agnostic
// renameChatItem so behavior matches the sidebar.
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameDraft, setRenameDraft] = useState("");
// Skips the input's blur-commit when Enter/Escape already handled it.
const skipRenameBlurRef = useRef(false);
// Optimistic title shown until the debounced sidebar refresh (fired by the
// rename) catches up, so the old name does not flash back in.
const [pendingRename, setPendingRename] = useState<{
id: string;
title: string;
} | null>(null);
useEffect(() => {
initialActiveThreadRef.current =
@ -908,8 +930,40 @@ function ProjectLanding({
useChatRuntimeStore.getState().setContextUsage(null);
setPendingNewThreadId(null);
setNewThreadNonce(crypto.randomUUID());
setRenamingId(null);
setPendingRename(null);
}, [projectId]);
useEffect(() => {
if (!pendingRename) return;
const match = items.find((item) => item.id === pendingRename.id);
if (match && match.title === pendingRename.title) setPendingRename(null);
}, [items, pendingRename]);
const openRename = useCallback((item: SidebarItem) => {
skipRenameBlurRef.current = false;
setRenameDraft(item.title);
setRenamingId(item.id);
}, []);
const commitRename = useCallback(
async (item: SidebarItem) => {
const trimmed = renameDraft.trim();
setRenamingId(null);
if (!trimmed || trimmed === item.title) return;
setPendingRename({ id: item.id, title: trimmed });
try {
await renameChatItem(item, trimmed);
} catch (err) {
setPendingRename(null);
toast.error("Failed to rename chat", {
description: err instanceof Error ? err.message : undefined,
});
}
},
[renameDraft],
);
useEffect(() => {
if (!activeThreadId) {
setPendingNewThreadId(null);
@ -1039,35 +1093,122 @@ function ProjectLanding({
<div className="mt-8 flex flex-col gap-1">
{items.map((item) => {
const preview = previews[item.id];
return (
<button
key={`${item.type}:${item.id}`}
type="button"
onClick={() => {
navigate({
to: "/chat",
search:
item.type === "single"
? { thread: item.id, project: projectId }
: { compare: item.id, project: projectId },
});
}}
className="group flex min-h-[58px] w-full items-center gap-4 rounded-full px-4 py-2 text-left transition-colors hover:bg-nav-surface-hover"
>
<div className="min-w-0 flex-1">
<div className="truncate text-[15px] font-semibold leading-5 text-foreground">
{item.title}
const displayTitle =
pendingRename?.id === item.id
? pendingRename.title
: item.title;
if (renamingId === item.id) {
return (
<div
key={`${item.type}:${item.id}`}
className="flex min-h-[58px] w-full items-center rounded-full px-4 py-2"
>
<div className="min-w-0 flex-1">
<input
autoFocus
value={renameDraft}
onChange={(event) =>
setRenameDraft(event.target.value)
}
onKeyDown={(event) => {
if (event.key === "Enter") {
if (event.nativeEvent.isComposing || event.keyCode === 229)
return;
event.preventDefault();
skipRenameBlurRef.current = true;
void commitRename(item);
} else if (event.key === "Escape") {
event.preventDefault();
skipRenameBlurRef.current = true;
setRenamingId(null);
}
}}
onBlur={() => {
if (skipRenameBlurRef.current) {
skipRenameBlurRef.current = false;
return;
}
void commitRename(item);
}}
onFocus={(event) => event.currentTarget.select()}
maxLength={120}
aria-label="Rename chat"
className="w-full border-0 bg-transparent text-[15px] font-semibold leading-5 text-foreground outline-none"
/>
{preview?.snippet ? (
<div className="mt-0.5 truncate text-[14px] leading-5 text-muted-foreground">
{preview.snippet}
</div>
) : null}
</div>
{preview?.snippet ? (
<div className="mt-0.5 truncate text-[14px] leading-5 text-muted-foreground">
{preview.snippet}
</div>
) : null}
</div>
<span className="shrink-0 text-[14px] text-muted-foreground">
{preview?.date ?? formatProjectChatDate(item.createdAt)}
</span>
</button>
);
}
return (
<div
key={`${item.type}:${item.id}`}
className="group relative flex min-h-[58px] w-full items-center rounded-full transition-colors hover:bg-nav-surface-hover has-[[data-state=open]]:bg-nav-surface-hover"
>
<button
type="button"
onClick={() => {
navigate({
to: "/chat",
search:
item.type === "single"
? { thread: item.id, project: projectId }
: { compare: item.id, project: projectId },
});
}}
className="flex min-h-[58px] min-w-0 flex-1 items-center gap-4 rounded-full px-4 py-2 text-left"
>
<div className="min-w-0 flex-1">
<div className="truncate text-[15px] font-semibold leading-5 text-foreground">
{displayTitle}
</div>
{preview?.snippet ? (
<div className="mt-0.5 truncate text-[14px] leading-5 text-muted-foreground">
{preview.snippet}
</div>
) : null}
</div>
<span className="shrink-0 text-[14px] text-muted-foreground transition-opacity max-md:opacity-0 pointer-coarse:opacity-0 group-hover:opacity-0 group-has-[[data-state=open]]:opacity-0">
{preview?.date ??
formatProjectChatDate(item.createdAt)}
</span>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(event) => event.stopPropagation()}
aria-label="Chat options"
className="absolute right-3 top-1/2 inline-flex size-8 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full text-muted-foreground outline-none transition-opacity hover:bg-foreground/10 md:pointer-fine:opacity-0 md:pointer-fine:pointer-events-none focus-visible:opacity-100 focus-visible:pointer-events-auto group-hover:opacity-100 group-hover:pointer-events-auto data-[state=open]:opacity-100 data-[state=open]:pointer-events-auto"
>
<HugeiconsIcon
icon={MoreVerticalIcon}
strokeWidth={1.75}
className="size-icon"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
sideOffset={4}
className="unsloth-plus-menu w-56"
>
<DropdownMenuItem onSelect={() => openRename(item)}>
<HugeiconsIcon
icon={Edit03Icon}
strokeWidth={1.75}
className="size-icon"
/>
<span>Rename</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>