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;
+}