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 = ({