From a799356bc35eeef37e373766affbf382a8a1141e Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 1 May 2026 20:44:19 +0700 Subject: [PATCH] feat(site/src/pages/AgentsPage/components/Sidebar): animate generated rename title (#24860) Animates the generated rename title returned by `onPropose` into the rename input character-by-character, replacing the previous instant set. Uses a local `requestAnimationFrame` typing loop (~80 chars/sec) with `Intl.Segmenter` for grapheme-safe splitting, gated by the existing `sessionRef` race protection so stale generate responses and stale animation frames cannot overwrite the active dialog. The animation is canceled on dialog close, cancel, submit, chat change, generate retry, manual input edits, and unmount. Save is disabled while the generated title is still typing so partial values cannot be saved. Storybook coverage updated to assert an intermediate animated prefix, disabled-while-typing button states, and that errors do not start an animation. --- .../Sidebar/AgentsSidebar.stories.tsx | 64 ++++++- .../components/Sidebar/RenameChatDialog.tsx | 169 ++++++++++++++++-- 2 files changed, 211 insertions(+), 22 deletions(-) diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx index 524d4a832b..ed9aa34eb0 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx @@ -589,6 +589,9 @@ export const CancellingRenameDialogKeepsTitle: Story = { }, }; +const animatedGeneratedTitle = + "AI suggested title for a complex workspace migration with focused follow up tasks"; + export const RenameChatGenerateFillsInput: Story = { args: { chats: [ @@ -598,7 +601,7 @@ export const RenameChatGenerateFillsInput: Story = { updated_at: recentTimestamp, }), ], - onProposeTitle: fn(async () => "AI suggested title"), + onProposeTitle: fn(async () => animatedGeneratedTitle), onRenameTitle: fn(() => Promise.resolve()), }, parameters: { @@ -625,9 +628,25 @@ export const RenameChatGenerateFillsInput: Story = { }); await userEvent.click(body.getByRole("button", { name: "Generate" })); - await waitFor(() => { - expect(input).toHaveValue("AI suggested title"); - }); + await waitFor( + () => { + const value = input.value; + expect(value.length).toBeGreaterThan(0); + expect(animatedGeneratedTitle.startsWith(value)).toBe(true); + expect(value).not.toBe(animatedGeneratedTitle); + expect(body.getByRole("button", { name: "Generate" })).toBeDisabled(); + expect(body.getByRole("button", { name: "Save" })).toBeDisabled(); + }, + { timeout: 2_000 }, + ); + await waitFor( + () => { + expect(input).toHaveValue(animatedGeneratedTitle); + }, + { timeout: 4_000 }, + ); + expect(body.getByRole("button", { name: "Generate" })).toBeEnabled(); + expect(body.getByRole("button", { name: "Save" })).toBeEnabled(); expect(args.onProposeTitle).toHaveBeenCalledWith("rename-generate"); expect(args.onRenameTitle).not.toHaveBeenCalled(); }, @@ -680,6 +699,7 @@ export const RenameChatGenerateErrorSurfacesAlert: Story = { expect(input).toHaveAttribute("aria-invalid", "true"); }); expect(input).toHaveValue("Original title"); + expect(body.getByRole("button", { name: "Generate" })).toBeEnabled(); }, }; @@ -692,7 +712,7 @@ export const RenameChatCancelAfterGenerateRestoresTitle: Story = { updated_at: recentTimestamp, }), ], - onProposeTitle: fn(async () => "Server suggestion"), + onProposeTitle: fn(async () => animatedGeneratedTitle), onRenameTitle: fn(() => Promise.resolve()), }, parameters: { @@ -718,13 +738,41 @@ export const RenameChatCancelAfterGenerateRestoresTitle: Story = { name: "Chat title", }); await userEvent.click(body.getByRole("button", { name: "Generate" })); - await waitFor(() => { - expect(input).toHaveValue("Server suggestion"); - }); + await waitFor( + () => { + const value = input.value; + expect(value.length).toBeGreaterThan(0); + expect(animatedGeneratedTitle.startsWith(value)).toBe(true); + expect(value).not.toBe(animatedGeneratedTitle); + expect(body.getByRole("button", { name: "Generate" })).toBeDisabled(); + expect(body.getByRole("button", { name: "Save" })).toBeDisabled(); + }, + { timeout: 2_000 }, + ); await userEvent.click(body.getByRole("button", { name: "Cancel" })); + await waitFor(() => { + expect( + body.queryByRole("heading", { name: "Rename chat" }), + ).not.toBeInTheDocument(); + }); expect(args.onRenameTitle).not.toHaveBeenCalled(); expect(canvas.getByText("Keep this one")).toBeInTheDocument(); + + await userEvent.click( + canvas.getByRole("button", { + name: "Open actions for Keep this one", + }), + ); + await userEvent.click( + await body.findByRole("menuitem", { name: "Rename chat" }), + ); + const reopenedInput = await body.findByRole("textbox", { + name: "Chat title", + }); + expect(reopenedInput).toHaveValue("Keep this one"); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(reopenedInput).toHaveValue("Keep this one"); }, }; diff --git a/site/src/pages/AgentsPage/components/Sidebar/RenameChatDialog.tsx b/site/src/pages/AgentsPage/components/Sidebar/RenameChatDialog.tsx index 8fde93642e..cc7843e0dd 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/RenameChatDialog.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/RenameChatDialog.tsx @@ -1,5 +1,12 @@ import { SparklesIcon } from "lucide-react"; -import { type FC, useEffect, useId, useRef, useState } from "react"; +import { + type FC, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from "react"; import { getErrorMessage } from "#/api/errors"; import type { Chat } from "#/api/typesGenerated"; import { Button } from "#/components/Button/Button"; @@ -20,6 +27,22 @@ type RenameChatDialogProps = { readonly onOpenChange: (open: boolean) => void; }; +// Generated titles should feel typed without making short titles feel slow. +const GENERATED_TITLE_TYPING_CHARACTERS_PER_SECOND = 80; +const GENERATED_TITLE_TYPING_MS_PER_CHARACTER = + 1000 / GENERATED_TITLE_TYPING_CHARACTERS_PER_SECOND; + +const splitGeneratedTitleGraphemes = (title: string): string[] => { + if (typeof Intl !== "undefined" && typeof Intl.Segmenter === "function") { + const segmenter = new Intl.Segmenter(undefined, { + granularity: "grapheme", + }); + return Array.from(segmenter.segment(title), ({ segment }) => segment); + } + + return Array.from(title); +}; + export const RenameChatDialog: FC = ({ chat, onRename, @@ -29,14 +52,97 @@ export const RenameChatDialog: FC = ({ const [renameTitle, setRenameTitle] = useState(""); const [isRenamingChat, setIsRenamingChat] = useState(false); const [isGeneratingTitle, setIsGeneratingTitle] = useState(false); + const [isTypingGeneratedTitle, setIsTypingGeneratedTitle] = useState(false); const [generateTitleError, setGenerateTitleError] = useState( null, ); const inputRef = useRef(null); + const generatedTitleTypingFrameRef = useRef(null); + const synchronizedChatIdRef = useRef(undefined); const sessionRef = useRef(0); const inputId = useId(); const errorId = `${inputId}-error`; + const cancelGeneratedTitleTyping = () => { + if (generatedTitleTypingFrameRef.current !== null) { + cancelAnimationFrame(generatedTitleTypingFrameRef.current); + generatedTitleTypingFrameRef.current = null; + } + setIsTypingGeneratedTitle(false); + }; + + const finishGeneratedTitleTyping = ( + title: string, + requestedSession: number, + ) => { + generatedTitleTypingFrameRef.current = null; + if (sessionRef.current !== requestedSession) return; + + setRenameTitle(title); + setIsTypingGeneratedTitle(false); + generatedTitleTypingFrameRef.current = requestAnimationFrame(() => { + generatedTitleTypingFrameRef.current = null; + if (sessionRef.current !== requestedSession) return; + inputRef.current?.focus(); + inputRef.current?.select(); + }); + }; + + const startGeneratedTitleTyping = ( + title: string, + requestedSession: number, + ) => { + const graphemes = splitGeneratedTitleGraphemes(title); + const startedAt = performance.now(); + + setRenameTitle(""); + setIsTypingGeneratedTitle(true); + inputRef.current?.focus(); + inputRef.current?.setSelectionRange(0, 0); + + if (graphemes.length === 0) { + finishGeneratedTitleTyping(title, requestedSession); + return; + } + + const typeNextFrame = (timestamp: number) => { + if (sessionRef.current !== requestedSession) { + generatedTitleTypingFrameRef.current = null; + setIsTypingGeneratedTitle(false); + return; + } + + const nextLength = Math.min( + graphemes.length, + Math.max( + 1, + Math.floor( + (timestamp - startedAt) / GENERATED_TITLE_TYPING_MS_PER_CHARACTER, + ), + ), + ); + const nextTitle = graphemes.slice(0, nextLength).join(""); + setRenameTitle(nextTitle); + + if (nextLength === graphemes.length) { + finishGeneratedTitleTyping(title, requestedSession); + return; + } + + generatedTitleTypingFrameRef.current = + requestAnimationFrame(typeNextFrame); + }; + + generatedTitleTypingFrameRef.current = requestAnimationFrame(typeNextFrame); + }; + + const closeDialog = () => { + sessionRef.current += 1; + cancelGeneratedTitleTyping(); + setIsGeneratingTitle(false); + onOpenChange(false); + }; + const currentChatId = chat?.id ?? null; const [prevChatId, setPrevChatId] = useState(null); if (currentChatId !== prevChatId) { @@ -48,25 +154,46 @@ export const RenameChatDialog: FC = ({ } } - useEffect(() => { - if (prevChatId === null) return; + useLayoutEffect(() => { + if (synchronizedChatIdRef.current === prevChatId) return; + synchronizedChatIdRef.current = prevChatId; sessionRef.current += 1; - }, [prevChatId]); + if (generatedTitleTypingFrameRef.current !== null) { + cancelAnimationFrame(generatedTitleTypingFrameRef.current); + generatedTitleTypingFrameRef.current = null; + } + setIsTypingGeneratedTitle(false); + setIsGeneratingTitle(false); + }); + + useEffect(() => { + return () => { + if (generatedTitleTypingFrameRef.current !== null) { + cancelAnimationFrame(generatedTitleTypingFrameRef.current); + } + }; + }, []); + + useEffect(() => { + if (!isTypingGeneratedTitle) return; + const input = inputRef.current; + if (!input) return; + input.focus(); + const end = renameTitle.length; + input.setSelectionRange(end, end); + }, [isTypingGeneratedTitle, renameTitle]); const handleGenerate = async () => { if (!chat || !onPropose) return; const requestedSession = sessionRef.current; + cancelGeneratedTitleTyping(); setIsGeneratingTitle(true); setGenerateTitleError(null); try { const newTitle = await onPropose(chat.id); if (sessionRef.current !== requestedSession) return; - setRenameTitle(newTitle); - requestAnimationFrame(() => { - inputRef.current?.focus(); - inputRef.current?.select(); - }); setIsGeneratingTitle(false); + startGeneratedTitleTyping(newTitle, requestedSession); } catch (error) { if (sessionRef.current !== requestedSession) return; setGenerateTitleError( @@ -78,15 +205,19 @@ export const RenameChatDialog: FC = ({ const handleSubmit = async () => { if (!chat) return; + if (isTypingGeneratedTitle) { + cancelGeneratedTitleTyping(); + return; + } const trimmedTitle = renameTitle.trim(); if (!trimmedTitle) { - onOpenChange(false); + closeDialog(); return; } setIsRenamingChat(true); await onRename(chat.id, trimmedTitle) .then(() => { - onOpenChange(false); + closeDialog(); }) .catch(() => {}); setIsRenamingChat(false); @@ -99,6 +230,10 @@ export const RenameChatDialog: FC = ({ // Block closes (escape / outside click) while a rename is in // flight; the submit handler will close on success. if (!open && isRenamingChat) return; + if (!open) { + closeDialog(); + return; + } onOpenChange(open); }} > @@ -124,7 +259,9 @@ export const RenameChatDialog: FC = ({ onClick={() => { void handleGenerate(); }} - disabled={isRenamingChat || isGeneratingTitle} + disabled={ + isRenamingChat || isGeneratingTitle || isTypingGeneratedTitle + } > {isGeneratingTitle ? ( @@ -148,6 +285,9 @@ export const RenameChatDialog: FC = ({ ref={inputRef} value={renameTitle} onChange={(event) => { + if (isTypingGeneratedTitle) { + cancelGeneratedTitleTyping(); + } setRenameTitle(event.target.value); if (generateTitleError) { setGenerateTitleError(null); @@ -173,7 +313,7 @@ export const RenameChatDialog: FC = ({