From 7c86b329b8705b4f36342806a4a14a13d3d699c1 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 20 Jul 2026 16:38:45 -0700 Subject: [PATCH] Animate project name completion in new threads --- .../src/components/chat/DraftHeroHeadline.tsx | 78 ++++++++++++++++++- .../chat/incrementalTextCompletion.test.ts | 20 +++++ .../chat/incrementalTextCompletion.ts | 21 +++++ 3 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/chat/incrementalTextCompletion.test.ts create mode 100644 apps/web/src/components/chat/incrementalTextCompletion.ts diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 6a88fb8da19..e5a992885b5 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -1,12 +1,17 @@ import type { ScopedProjectRef } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { FolderPlusIcon } from "lucide-react"; -import { useMemo } from "react"; +import { useLayoutEffect, useMemo, useRef, useState } from "react"; import { useOpenAddProjectCommandPalette } from "~/commandPaletteContext"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { useProjects, useThreadShells } from "~/state/entities"; import { sortScopedProjectsForSidebar } from "../Sidebar.logic"; +import { + getIncrementalTextCompletionStart, + INCREMENTAL_TEXT_COMPLETION_INTERVAL_MS, + splitTextForIncrementalCompletion, +} from "./incrementalTextCompletion"; import { Menu, MenuItem, @@ -22,6 +27,65 @@ interface DraftHeroHeadlineProps { readonly activeProjectTitle: string | null; } +function useIncrementalProjectTitle( + projectKey: string, + projectTitle: string | null, +): string | null { + const [completedTitle, setCompletedTitle] = useState(projectTitle); + const completedTitleRef = useRef(projectTitle); + const previousProjectKeyRef = useRef(projectKey); + + useLayoutEffect(() => { + const projectChanged = previousProjectKeyRef.current !== projectKey; + previousProjectKeyRef.current = projectKey; + + if (!projectChanged || projectTitle === null) { + completedTitleRef.current = projectTitle; + setCompletedTitle(projectTitle); + return; + } + + const prefersReducedMotion = + typeof window !== "undefined" && + (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false); + if (prefersReducedMotion) { + completedTitleRef.current = projectTitle; + setCompletedTitle(projectTitle); + return; + } + + const characters = splitTextForIncrementalCompletion(projectTitle); + let completedCharacterCount = getIncrementalTextCompletionStart( + completedTitleRef.current ?? "", + projectTitle, + ); + const updateCompletedTitle = () => { + const nextTitle = characters.slice(0, completedCharacterCount).join(""); + completedTitleRef.current = nextTitle; + setCompletedTitle(nextTitle); + }; + + updateCompletedTitle(); + if (completedCharacterCount >= characters.length) { + return; + } + + const intervalId = window.setInterval(() => { + completedCharacterCount += 1; + updateCompletedTitle(); + if (completedCharacterCount >= characters.length) { + window.clearInterval(intervalId); + } + }, INCREMENTAL_TEXT_COMPLETION_INTERVAL_MS); + + return () => { + window.clearInterval(intervalId); + }; + }, [projectKey, projectTitle]); + + return completedTitle; +} + export function DraftHeroHeadline({ activeProjectRef, activeProjectTitle, @@ -49,6 +113,7 @@ export function DraftHeroHeadline({ [orderedProjects], ); const activeProjectKey = activeProjectRef === null ? "" : scopedProjectKey(activeProjectRef); + const completedProjectTitle = useIncrementalProjectTitle(activeProjectKey, activeProjectTitle); const hasResolvedProject = activeProjectTitle !== null; const canChooseProject = orderedProjects.length > 0; const shouldShowProjectMenu = canChooseProject; @@ -59,7 +124,16 @@ export function DraftHeroHeadline({ aria-label={hasResolvedProject ? "Change project" : "Choose a project"} className="pointer-events-auto inline cursor-pointer border-current border-b border-dotted text-foreground underline-offset-8 transition-opacity hover:opacity-75 focus-visible:rounded-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring" > - {activeProjectTitle ?? "Choose a project"} + {activeProjectTitle ? ( + + + {activeProjectTitle} + + {completedProjectTitle} + + ) : ( + "Choose a project" + )} { + it("keeps the shared prefix when the selected project changes", () => { + expect(getIncrementalTextCompletionStart("t3code-web", "t3code-mobile")).toBe(7); + }); + + it("starts over when project names do not share a prefix", () => { + expect(getIncrementalTextCompletionStart("frontend", "backend")).toBe(0); + }); + + it("advances through unicode text by code point instead of UTF-16 code unit", () => { + expect(splitTextForIncrementalCompletion("T3 🚀")).toEqual(["T", "3", " ", "🚀"]); + }); +}); diff --git a/apps/web/src/components/chat/incrementalTextCompletion.ts b/apps/web/src/components/chat/incrementalTextCompletion.ts new file mode 100644 index 00000000000..2c194ab7bd2 --- /dev/null +++ b/apps/web/src/components/chat/incrementalTextCompletion.ts @@ -0,0 +1,21 @@ +export const INCREMENTAL_TEXT_COMPLETION_INTERVAL_MS = 24; + +export function splitTextForIncrementalCompletion(text: string): string[] { + return Array.from(text); +} + +export function getIncrementalTextCompletionStart(currentText: string, nextText: string): number { + const currentCharacters = splitTextForIncrementalCompletion(currentText); + const nextCharacters = splitTextForIncrementalCompletion(nextText); + const maxSharedLength = Math.min(currentCharacters.length, nextCharacters.length); + + let sharedLength = 0; + while ( + sharedLength < maxSharedLength && + currentCharacters[sharedLength] === nextCharacters[sharedLength] + ) { + sharedLength += 1; + } + + return sharedLength; +}