authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-24 17:49:27-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-24 17:49:27-07:00
logfd465100581055383126aab922a8da2c5d7952fd
tree3b5ba949265f8764bbb719afa567d023778c6d7e
parent59abcd67ac2c54bd63a422e92fa2e31b2c667b6d
signature Signed by PGP key B5690EEEBB952194

fix(web): keep sidebar terminal pulses in sync (#12962)

Pin each terminal status pulse to the document timeline when it starts, so running-terminal icons in both sidebars and the command palette share one cycle. Terminal pulses now respect prefers-reduced-motion. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

4 files changed, 39 insertions(+), 4 deletions(-)

apps/web/src/components/LegacySidebar.tsx+3-1
......@@ -17,6 +17,7 @@ import {
1717 prStatusIndicator,
1818 PrStatusTooltipContent,
1919 terminalStatusFromRunningIds,
20 synchronizeTerminalPulse,
2021 ThreadStatusLabel,
2122 ThreadWorktreeIndicator,
2223 useLinkedThreadPullRequest,
......@@ -832,7 +833,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
832833 }
833834 >
834835 <TerminalIcon
835 className={`size-3 ${terminalStatus.pulse ? "animate-status-pulse" : ""}`}
836 className={`size-3 ${terminalStatus.pulse ? "motion-safe:animate-status-pulse" : ""}`}
837 onAnimationStart={synchronizeTerminalPulse}
836838 />
837839 </TooltipTrigger>
838840 <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup>
apps/web/src/components/Sidebar.tsx+5-1
......@@ -205,6 +205,7 @@ import {
205205 prStatusIndicator,
206206 resolveThreadPullRequestBadge,
207207 terminalStatusFromRunningIds,
208 synchronizeTerminalPulse,
208209 type TerminalStatusIndicator,
209210 useLinkedThreadPullRequest,
210211} from "./ThreadStatusIndicators";
......@@ -1518,7 +1519,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
15181519 data-testid={`sidebar-terminal-status-${thread.id}`}
15191520 className={cn("inline-flex shrink-0 items-center justify-center", terminalStatus.colorClass)}
15201521 >
1521 <TerminalIcon className={cn("size-3.5", terminalStatus.pulse && "animate-status-pulse")} />
1522 <TerminalIcon
1523 className={cn("size-3.5", terminalStatus.pulse && "motion-safe:animate-status-pulse")}
1524 onAnimationStart={synchronizeTerminalPulse}
1525 />
15221526 </span>
15231527 ) : null;
15241528 // Same pen the new-thread draft rows lead with, so both kinds of unsent
apps/web/src/components/ThreadStatusIndicators.test.ts+17
......@@ -1,14 +1,31 @@
11import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts";
22import { describe, expect, it } from "@effect/vitest";
3import type { AnimationEvent } from "react";
34
45import {
56 ChangeRequestStatusIcon,
67 prStatusIndicator,
78 resolveThreadPullRequestBadgePresentation,
9 synchronizeTerminalPulse,
810} from "./ThreadStatusIndicators";
911import { newestPullRequestSummary } from "../state/pullRequests";
1012import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
1113
14describe("synchronizeTerminalPulse", () => {
15 it("pins only the status pulse to the document clock", () => {
16 const pulse = { animationName: "status-pulse", startTime: 975 } as CSSAnimation;
17 const otherCss = { animationName: "other-animation", startTime: 125 } as CSSAnimation;
18 const otherAnimation = { startTime: 250 } as Animation;
19
20 synchronizeTerminalPulse({
21 animationName: "status-pulse",
22 currentTarget: { getAnimations: () => [pulse, otherCss, otherAnimation] },
23 } as AnimationEvent<SVGSVGElement>);
24
25 expect([pulse.startTime, otherCss.startTime, otherAnimation.startTime]).toEqual([0, 125, 250]);
26 });
27});
28
1229describe("ChangeRequestStatusIcon", () => {
1330 it.each([
1431 ["open", "open", false, PullRequestGlyph.pullRequest],
apps/web/src/components/ThreadStatusIndicators.tsx+14-2
......@@ -16,7 +16,7 @@ import {
1616} from "@t3tools/shared/threadPullRequests";
1717import { FolderGit2Icon, TerminalIcon } from "lucide-react";
1818import { useRender } from "@base-ui/react/use-render";
19import { useMemo, type MouseEvent, type ReactElement } from "react";
19import { useMemo, type AnimationEvent, type MouseEvent, type ReactElement } from "react";
2020import { cn } from "../lib/utils";
2121import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
2222import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
......@@ -394,6 +394,17 @@ export function terminalStatusFromRunningIds(
394394 };
395395}
396396
397/** Align newly started pulses with the document clock without a timer or frame loop. */
398export function synchronizeTerminalPulse(event: AnimationEvent<SVGSVGElement>) {
399 if (event.animationName !== "status-pulse") return;
400
401 for (const animation of event.currentTarget.getAnimations()) {
402 if ("animationName" in animation && animation.animationName === "status-pulse") {
403 animation.startTime = 0;
404 }
405 }
406}
407
397408export function ThreadWorktreeIndicator({
398409 thread,
399410}: {
......@@ -582,7 +593,8 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma
582593 }
583594 >
584595 <TerminalIcon
585 className={`size-3 ${terminalStatus.pulse ? "animate-status-pulse" : ""}`}
596 className={`size-3 ${terminalStatus.pulse ? "motion-safe:animate-status-pulse" : ""}`}
597 onAnimationStart={synchronizeTerminalPulse}
586598 />
587599 </TooltipTrigger>
588600 <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup>