From a26d17d8351f3b17ee5d89d2de06ca156a7be540 Mon Sep 17 00:00:00 2001 From: Derek Alvarado Date: Wed, 22 Jul 2026 12:47:58 -0600 Subject: [PATCH] Add working wave animation to thread statuses - Animate the Working status copy in web and mobile thread lists - Reuse shared status text rendering in the sidebar - Update tests and keyframes for the new wave motion --- .../features/threads/thread-list-v2-items.tsx | 104 +++++++++++++++++- apps/web/src/components/SidebarV2.tsx | 13 ++- .../ThreadStatusIndicators.test.tsx | 51 ++++++++- .../src/components/ThreadStatusIndicators.tsx | 28 ++++- apps/web/src/index.css | 27 +++-- 5 files changed, 200 insertions(+), 23 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 9dd41f3cd60..1daec2d4e62 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -6,6 +6,16 @@ import type { MenuAction } from "@react-native-menu/menu"; import { memo, useCallback, useEffect, useMemo, type ComponentProps } from "react"; import { Platform, Pressable, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; +import Animated, { + cancelAnimation, + Easing, + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, + type SharedValue, +} from "react-native-reanimated"; import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; @@ -38,6 +48,93 @@ const STATUS_LABEL_BY_STATUS: Partial< failed: { label: "Failed", className: "text-red-700 dark:text-red-300" }, }; +const WORKING_WAVE_CYCLE_MS = 4000; +const WORKING_WAVE_STAGGER_MS = 60; +const WORKING_WAVE_OFFSET = 1.1; +const WORKING_WAVE_EASING = Easing.bezierFn(0.42, 0, 0.58, 1); + +function WorkingStatusLetter(props: { + readonly letter: string; + readonly index: number; + readonly progress: SharedValue; + readonly className: string; +}) { + const style = useAnimatedStyle(() => { + const phase = + (props.progress.value - (props.index * WORKING_WAVE_STAGGER_MS) / WORKING_WAVE_CYCLE_MS + 1) % + 1; + let translateY = 0; + + if (phase < 0.04) { + translateY = -WORKING_WAVE_OFFSET * WORKING_WAVE_EASING(phase / 0.04); + } else if (phase < 0.08) { + translateY = + -WORKING_WAVE_OFFSET + WORKING_WAVE_OFFSET * 2 * WORKING_WAVE_EASING((phase - 0.04) / 0.04); + } else if (phase < 0.12) { + translateY = WORKING_WAVE_OFFSET * (1 - WORKING_WAVE_EASING((phase - 0.08) / 0.04)); + } + + return { transform: [{ translateY }] }; + }); + + return ( + + {props.letter} + + ); +} + +function WorkingStatusText({ className }: { readonly className: string }) { + const progress = useSharedValue(0); + + useEffect(() => { + progress.value = 0; + progress.value = withRepeat( + withTiming(1, { + duration: WORKING_WAVE_CYCLE_MS, + easing: Easing.linear, + reduceMotion: ReduceMotion.System, + }), + -1, + false, + undefined, + ReduceMotion.System, + ); + + return () => { + cancelAnimation(progress); + }; + }, [progress]); + + return ( + + {Array.from("Working", (letter, index) => ( + + ))} + + ); +} + +function ThreadListV2StatusText({ + label, + className, +}: { + readonly label: string; + readonly className: string; +}) { + if (label === "Working") { + return ; + } + + return {label}; +} + function threadTimeLabel(thread: EnvironmentThreadShell): string { return relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt); } @@ -212,14 +309,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { > {props.project?.title ?? ""} - - {statusLabel?.label ?? timeLabel} - + /> {thread.title} diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 352a2fc471e..1cd76bfa1b7 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -85,7 +85,7 @@ import { resolveSidebarV2Status, sortThreadsForSidebarV2, } from "./Sidebar.logic"; -import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; +import { prStatusIndicator, resolveThreadPr, ThreadStatusText } from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; @@ -185,8 +185,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ? { label: "Working", icon: "working" as const, - className: - "animate-sidebar-working-text font-semibold text-blue-600 motion-reduce:animate-none dark:text-blue-400", + className: "font-semibold text-blue-600 dark:text-blue-400", } : status === "approval" ? { @@ -518,17 +517,21 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : topStatus ? ( {topStatus.icon === "working" ? ( - + ) : topStatus.icon === "done" ? ( ) : null} - {topStatus.label} + ) : ( threadTimeLabel(thread) diff --git a/apps/web/src/components/ThreadStatusIndicators.test.tsx b/apps/web/src/components/ThreadStatusIndicators.test.tsx index 868bd2cd99c..98172f23e80 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.test.tsx @@ -2,7 +2,56 @@ import { ThreadId } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; -import { ThreadWorktreeIndicator } from "./ThreadStatusIndicators"; +import { + ThreadStatusLabel, + ThreadStatusText, + ThreadWorktreeIndicator, +} from "./ThreadStatusIndicators"; + +describe("ThreadStatusLabel", () => { + it("renders the Working label as an accessible staggered wave", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('aria-label="Working"'); + expect(markup).toContain('aria-hidden="true"'); + expect(markup.match(/animate-working-wave/g)).toHaveLength("Working".length); + expect(markup).toContain("animation-delay:360ms"); + }); + + it("leaves non-working status text static", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Connecting"); + expect(markup).not.toContain("animate-working-wave"); + }); + + it("renders sidebar-specific status copy without animation", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Failed"); + expect(markup).not.toContain("animate-working-wave"); + }); +}); describe("ThreadWorktreeIndicator", () => { it("renders the worktree folder and branch in an accessible label", () => { diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 55f9fbfdc04..6071a997a01 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -173,13 +173,39 @@ export function ThreadStatusLabel({ status.pulse ? "animate-status-pulse" : "" }`} /> - {status.label} + {status.label} ); } +export function ThreadStatusText({ + label, + className = "hidden md:inline whitespace-pre", +}: { + label: string; + className?: string; +}) { + if (label !== "Working") { + return {label}; + } + + return ( + + ); +} + /** * Non-interactive leading status icons for a thread row in compact contexts * like the command palette. Shows the change request state icon (if present) and the diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 72caf539e8b..cdf6e76d06b 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -55,7 +55,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-new(t3-mobil } } -html[data-mobile-composer-route-transition="true"]::view-transition-group(t3-mobile-draft-headline) { +html[data-mobile-composer-route-transition="true"]::view-transition-group( + t3-mobile-draft-headline + ) { animation-duration: 130ms; } @@ -112,7 +114,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil compositor updates discrete frames instead of every vsync. */ --animate-status-pulse: status-pulse 2s infinite; --animate-status-ping: status-ping 2s infinite; - --animate-sidebar-working-text: sidebar-working-text 3.4s infinite; + --animate-working-wave: working-wave 4s ease-in-out infinite; --font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; @@ -185,19 +187,20 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil scale: 2; } } - @keyframes sidebar-working-text { + @keyframes working-wave { 0%, - 36% { - opacity: 1; - animation-timing-function: steps(10); + 15%, + 100% { + transform: translateY(0); } - 50%, - 86% { - opacity: 0.75; - animation-timing-function: steps(10); + 4% { + transform: translateY(-0.1em); } - 100% { - opacity: 1; + 8% { + transform: translateY(0.1em); + } + 12% { + transform: translateY(0); } } }