| author | |
| committer | |
| log | fd465100581055383126aab922a8da2c5d7952fd |
| tree | 3b5ba949265f8764bbb719afa567d023778c6d7e |
| parent | 59abcd67ac2c54bd63a422e92fa2e31b2c667b6d |
| signature | Signed by PGP key B5690EEEBB952194 |
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 { | ... | @@ -17,6 +17,7 @@ import { |
| 17 | prStatusIndicator, | 17 | prStatusIndicator, |
| 18 | PrStatusTooltipContent, | 18 | PrStatusTooltipContent, |
| 19 | terminalStatusFromRunningIds, | 19 | terminalStatusFromRunningIds, |
| 20 | synchronizeTerminalPulse, | ||
| 20 | ThreadStatusLabel, | 21 | ThreadStatusLabel, |
| 21 | ThreadWorktreeIndicator, | 22 | ThreadWorktreeIndicator, |
| 22 | useLinkedThreadPullRequest, | 23 | useLinkedThreadPullRequest, |
| ... | @@ -832,7 +833,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP | ... | @@ -832,7 +833,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 832 | } | 833 | } |
| 833 | > | 834 | > |
| 834 | <TerminalIcon | 835 | <TerminalIcon |
| 835 | className={`size-3 ${terminalStatus.pulse ? "animate-status-pulse" : ""}`} | 836 | className={`size-3 ${terminalStatus.pulse ? "motion-safe:animate-status-pulse" : ""}`} |
| 837 | onAnimationStart={synchronizeTerminalPulse} | ||
| 836 | /> | 838 | /> |
| 837 | </TooltipTrigger> | 839 | </TooltipTrigger> |
| 838 | <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup> | 840 | <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup> |
apps/web/src/components/Sidebar.tsx+5-1| ... | @@ -205,6 +205,7 @@ import { | ... | @@ -205,6 +205,7 @@ import { |
| 205 | prStatusIndicator, | 205 | prStatusIndicator, |
| 206 | resolveThreadPullRequestBadge, | 206 | resolveThreadPullRequestBadge, |
| 207 | terminalStatusFromRunningIds, | 207 | terminalStatusFromRunningIds, |
| 208 | synchronizeTerminalPulse, | ||
| 208 | type TerminalStatusIndicator, | 209 | type TerminalStatusIndicator, |
| 209 | useLinkedThreadPullRequest, | 210 | useLinkedThreadPullRequest, |
| 210 | } from "./ThreadStatusIndicators"; | 211 | } from "./ThreadStatusIndicators"; |
| ... | @@ -1518,7 +1519,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1518,7 +1519,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1518 | data-testid={`sidebar-terminal-status-${thread.id}`} | 1519 | data-testid={`sidebar-terminal-status-${thread.id}`} |
| 1519 | className={cn("inline-flex shrink-0 items-center justify-center", terminalStatus.colorClass)} | 1520 | className={cn("inline-flex shrink-0 items-center justify-center", terminalStatus.colorClass)} |
| 1520 | > | 1521 | > |
| 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 | /> | ||
| 1522 | </span> | 1526 | </span> |
| 1523 | ) : null; | 1527 | ) : null; |
| 1524 | // Same pen the new-thread draft rows lead with, so both kinds of unsent | 1528 | // 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 @@ | ... | @@ -1,14 +1,31 @@ |
| 1 | import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts"; | 1 | import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "@effect/vitest"; | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | import type { AnimationEvent } from "react"; | ||
| 3 | 4 | ||
| 4 | import { | 5 | import { |
| 5 | ChangeRequestStatusIcon, | 6 | ChangeRequestStatusIcon, |
| 6 | prStatusIndicator, | 7 | prStatusIndicator, |
| 7 | resolveThreadPullRequestBadgePresentation, | 8 | resolveThreadPullRequestBadgePresentation, |
| 9 | synchronizeTerminalPulse, | ||
| 8 | } from "./ThreadStatusIndicators"; | 10 | } from "./ThreadStatusIndicators"; |
| 9 | import { newestPullRequestSummary } from "../state/pullRequests"; | 11 | import { newestPullRequestSummary } from "../state/pullRequests"; |
| 10 | import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; | 12 | import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; |
| 11 | 13 | ||
| 14 | describe("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 | |||
| 12 | describe("ChangeRequestStatusIcon", () => { | 29 | describe("ChangeRequestStatusIcon", () => { |
| 13 | it.each([ | 30 | it.each([ |
| 14 | ["open", "open", false, PullRequestGlyph.pullRequest], | 31 | ["open", "open", false, PullRequestGlyph.pullRequest], |
apps/web/src/components/ThreadStatusIndicators.tsx+14-2| ... | @@ -16,7 +16,7 @@ import { | ... | @@ -16,7 +16,7 @@ import { |
| 16 | } from "@t3tools/shared/threadPullRequests"; | 16 | } from "@t3tools/shared/threadPullRequests"; |
| 17 | import { FolderGit2Icon, TerminalIcon } from "lucide-react"; | 17 | import { FolderGit2Icon, TerminalIcon } from "lucide-react"; |
| 18 | import { useRender } from "@base-ui/react/use-render"; | 18 | import { useRender } from "@base-ui/react/use-render"; |
| 19 | import { useMemo, type MouseEvent, type ReactElement } from "react"; | 19 | import { useMemo, type AnimationEvent, type MouseEvent, type ReactElement } from "react"; |
| 20 | import { cn } from "../lib/utils"; | 20 | import { cn } from "../lib/utils"; |
| 21 | import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; | 21 | import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; |
| 22 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; | 22 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| ... | @@ -394,6 +394,17 @@ export function terminalStatusFromRunningIds( | ... | @@ -394,6 +394,17 @@ export function terminalStatusFromRunningIds( |
| 394 | }; | 394 | }; |
| 395 | } | 395 | } |
| 396 | 396 | ||
| 397 | /** Align newly started pulses with the document clock without a timer or frame loop. */ | ||
| 398 | export 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 | |||
| 397 | export function ThreadWorktreeIndicator({ | 408 | export function ThreadWorktreeIndicator({ |
| 398 | thread, | 409 | thread, |
| 399 | }: { | 410 | }: { |
| ... | @@ -582,7 +593,8 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma | ... | @@ -582,7 +593,8 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma |
| 582 | } | 593 | } |
| 583 | > | 594 | > |
| 584 | <TerminalIcon | 595 | <TerminalIcon |
| 585 | className={`size-3 ${terminalStatus.pulse ? "animate-status-pulse" : ""}`} | 596 | className={`size-3 ${terminalStatus.pulse ? "motion-safe:animate-status-pulse" : ""}`} |
| 597 | onAnimationStart={synchronizeTerminalPulse} | ||
| 586 | /> | 598 | /> |
| 587 | </TooltipTrigger> | 599 | </TooltipTrigger> |
| 588 | <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup> | 600 | <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup> |