Studio: Add rename to project chat rows (#7005)
This commit is contained in:
parent
014d08c763
commit
a5eb10abc9
1 changed files with 166 additions and 25 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue