mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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.
This commit is contained in:
@@ -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<HTMLInputElement>("textbox", {
|
||||
name: "Chat title",
|
||||
});
|
||||
expect(reopenedInput).toHaveValue("Keep this one");
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(reopenedInput).toHaveValue("Keep this one");
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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<RenameChatDialogProps> = ({
|
||||
chat,
|
||||
onRename,
|
||||
@@ -29,14 +52,97 @@ export const RenameChatDialog: FC<RenameChatDialogProps> = ({
|
||||
const [renameTitle, setRenameTitle] = useState("");
|
||||
const [isRenamingChat, setIsRenamingChat] = useState(false);
|
||||
const [isGeneratingTitle, setIsGeneratingTitle] = useState(false);
|
||||
const [isTypingGeneratedTitle, setIsTypingGeneratedTitle] = useState(false);
|
||||
const [generateTitleError, setGenerateTitleError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const generatedTitleTypingFrameRef = useRef<number | null>(null);
|
||||
const synchronizedChatIdRef = useRef<string | null | undefined>(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<string | null>(null);
|
||||
if (currentChatId !== prevChatId) {
|
||||
@@ -48,25 +154,46 @@ export const RenameChatDialog: FC<RenameChatDialogProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
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<RenameChatDialogProps> = ({
|
||||
|
||||
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<RenameChatDialogProps> = ({
|
||||
// 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<RenameChatDialogProps> = ({
|
||||
onClick={() => {
|
||||
void handleGenerate();
|
||||
}}
|
||||
disabled={isRenamingChat || isGeneratingTitle}
|
||||
disabled={
|
||||
isRenamingChat || isGeneratingTitle || isTypingGeneratedTitle
|
||||
}
|
||||
>
|
||||
{isGeneratingTitle ? (
|
||||
<Spinner className="h-[18px] w-[18px]" loading />
|
||||
@@ -148,6 +285,9 @@ export const RenameChatDialog: FC<RenameChatDialogProps> = ({
|
||||
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<RenameChatDialogProps> = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
onClick={closeDialog}
|
||||
disabled={isRenamingChat}
|
||||
>
|
||||
Cancel
|
||||
@@ -185,7 +325,8 @@ export const RenameChatDialog: FC<RenameChatDialogProps> = ({
|
||||
!renameTitle.trim() ||
|
||||
renameTitle.trim() === chat?.title ||
|
||||
isRenamingChat ||
|
||||
isGeneratingTitle
|
||||
isGeneratingTitle ||
|
||||
isTypingGeneratedTitle
|
||||
}
|
||||
>
|
||||
{isRenamingChat && <Spinner className="h-4 w-4" loading />}
|
||||
|
||||
Reference in New Issue
Block a user