diff --git a/.claude/hooks/auto-lint.sh b/.claude/hooks/auto-lint.sh deleted file mode 100755 index 0946c58ef6..0000000000 --- a/.claude/hooks/auto-lint.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash -# Auto-lint TypeScript files after edits -# Runs eslint --fix on the edited file for fast, single-file linting - -set -euo pipefail - -input_data=$(cat) -file_path=$(echo "$input_data" | jq -r '.tool_input.file_path // empty') - -# Skip if no file path or not a TypeScript file -if [[ -z "$file_path" ]] || [[ ! "$file_path" =~ \.(ts|tsx)$ ]]; then - exit 0 -fi - -# Only lint if file exists (skip for failed writes) -if [[ ! -f "$file_path" ]]; then - exit 0 -fi - -# Run eslint fix on the single file (suppress errors to avoid blocking) -cd "$CLAUDE_PROJECT_DIR" && pnpm eslint --fix "$file_path" 2>/dev/null || true - -exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index 9e271c0d8b..06cb38d861 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -112,17 +112,6 @@ } ] } - ], - "PostToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auto-lint.sh" - } - ] - } ] }, "enabledPlugins": { diff --git a/packages/shared/src/components/icons/Arena/filled.svg b/packages/shared/src/components/icons/Arena/filled.svg new file mode 100644 index 0000000000..c6ecfb5be1 --- /dev/null +++ b/packages/shared/src/components/icons/Arena/filled.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/shared/src/components/icons/Arena/index.tsx b/packages/shared/src/components/icons/Arena/index.tsx new file mode 100644 index 0000000000..a249711840 --- /dev/null +++ b/packages/shared/src/components/icons/Arena/index.tsx @@ -0,0 +1,10 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { IconProps } from '../../Icon'; +import Icon from '../../Icon'; +import OutlinedIcon from './outlined.svg'; +import FilledIcon from './filled.svg'; + +export const ArenaIcon = (props: IconProps): ReactElement => ( + +); diff --git a/packages/shared/src/components/icons/Arena/outlined.svg b/packages/shared/src/components/icons/Arena/outlined.svg new file mode 100644 index 0000000000..c614ab6c86 --- /dev/null +++ b/packages/shared/src/components/icons/Arena/outlined.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/shared/src/features/agents/arena/ArenaAnimatedCounter.tsx b/packages/shared/src/features/agents/arena/ArenaAnimatedCounter.tsx new file mode 100644 index 0000000000..680343f003 --- /dev/null +++ b/packages/shared/src/features/agents/arena/ArenaAnimatedCounter.tsx @@ -0,0 +1,54 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; + +interface ArenaAnimatedCounterProps { + value: number; + format?: (n: number) => string; + className?: string; + duration?: number; +} + +export const ArenaAnimatedCounter = ({ + value, + format, + className, + duration = 800, +}: ArenaAnimatedCounterProps): ReactElement => { + const [display, setDisplay] = useState(value); + const prevRef = useRef(value); + const frameRef = useRef(0); + + useEffect(() => { + const from = prevRef.current; + const to = value; + prevRef.current = value; + + if (from === to) { + return undefined; + } + + const startTime = performance.now(); + const animate = (now: number) => { + const elapsed = now - startTime; + const progress = Math.min(elapsed / duration, 1); + // ease-out cubic + const eased = 1 - (1 - progress) * (1 - progress) * (1 - progress); + const current = Math.round(from + (to - from) * eased); + setDisplay(current); + + if (progress < 1) { + frameRef.current = requestAnimationFrame(animate); + } + }; + + frameRef.current = requestAnimationFrame(animate); + + return () => { + cancelAnimationFrame(frameRef.current); + }; + }, [value, duration]); + + const formatted = format ? format(display) : display.toLocaleString(); + + return {formatted}; +}; diff --git a/packages/shared/src/features/agents/arena/ArenaCrownAnimations.ts b/packages/shared/src/features/agents/arena/ArenaCrownAnimations.ts new file mode 100644 index 0000000000..bd1bd6145d --- /dev/null +++ b/packages/shared/src/features/agents/arena/ArenaCrownAnimations.ts @@ -0,0 +1,462 @@ +import type { CrownType } from './types'; + +export const CROWN_SPARK_COUNT = 7; + +export const crownHoverAnimations: Partial> = { + 'developers-choice': 'crown-hover-developers-choice', + 'most-loved': 'crown-hover-most-loved', + 'fastest-rising': 'crown-hover-fastest-rising', + 'most-discussed': 'crown-hover-most-discussed', + 'most-controversial': 'crown-hover-most-controversial', +}; + +const crownIconAnimation: Partial> = { + 'most-loved': 'crown-icon-bloom-pulse 1.2s ease-in-out', +}; + +const DEFAULT_ICON_ANIMATION = 'crown-icon-pop 1.2s ease-out'; + +const forceReflow = (element: Element): void => { + element.getBoundingClientRect(); +}; + +const restartAnimation = ( + element: HTMLElement | SVGElement, + animation: string, +): void => { + const { style } = element; + style.animation = 'none'; + forceReflow(element); + style.animation = animation; +}; + +const SPARK_CONFIG = { + count: CROWN_SPARK_COUNT, + radius: 30, + burstRatio: 0.65, + angleMin: -50, + angleMax: 50, + duration: 1.2, + staggerMax: 0.08, +}; + +const randomizeSparks = (card: HTMLElement): void => { + const { + count, + radius, + burstRatio, + angleMin, + angleMax, + duration, + staggerMax, + } = SPARK_CONFIG; + const sparks = card.querySelectorAll('.crown-spark'); + const angleRange = angleMax - angleMin; + + sparks.forEach((el, i) => { + const { style } = el; + const isFullCircle = angleRange >= 360; + const slots = isFullCircle ? count : Math.max(count - 1, 1); + const slotWidth = angleRange / slots; + const baseAngle = angleMin + (i / slots) * angleRange; + const jitter = (Math.random() - 0.5) * slotWidth * 0.4; + const angle = baseAngle + jitter; + const rad = (angle * Math.PI) / 180; + + const r = radius * (0.95 + Math.random() * 0.05); + const burstR = r * burstRatio; + + const bx = Math.round(Math.sin(rad) * burstR); + const by = Math.round(-Math.cos(rad) * burstR); + const fx = Math.round(Math.sin(rad) * r); + const fy = Math.round(-Math.cos(rad) * r); + + style.setProperty('--spark-bx', `${bx}px`); + style.setProperty('--spark-by', `${by}px`); + style.setProperty('--spark-fx', `${fx}px`); + style.setProperty('--spark-fy', `${fy}px`); + style.setProperty( + '--spark-peak', + `${(0.7 + Math.random() * 0.3).toFixed(2)}`, + ); + style.setProperty( + '--spark-delay', + `${(Math.random() * staggerMax).toFixed(2)}s`, + ); + + restartAnimation( + el, + `crown-spark ${duration}s ease-out var(--spark-delay) forwards`, + ); + }); +}; + +const restartIconAnimation = ( + card: HTMLElement, + crownType: CrownType, +): void => { + const iconWrapper = card.querySelector('.crown-icon-wrapper'); + if (!iconWrapper) { + return; + } + + const anim = crownIconAnimation[crownType] ?? DEFAULT_ICON_ANIMATION; + restartAnimation(iconWrapper, anim); +}; + +const restartBloom = (card: HTMLElement): void => { + const bloom = card.querySelector('.crown-bloom'); + if (!bloom) { + return; + } + + restartAnimation(bloom, 'crown-bloom 1.4s ease-in-out forwards'); +}; + +const GHOST_CONFIG = { + count: 5, + spacing: 6, + blur: [0.5, 1, 1.8, 2.8, 4], + opacities: [0.5, 0.38, 0.26, 0.15, 0.07], + stretch: [1.08, 1.14, 1.2, 1.28, 1.36], + lungeDist: [7, 11], + lungeAngle: [38, 52], + duration: 0.9, +}; + +const restartRocket = (card: HTMLElement, glowColor: string): void => { + const iconWrapper = card.querySelector('.crown-icon-wrapper'); + if (!iconWrapper) { + return; + } + + iconWrapper.querySelectorAll('.crown-ghost').forEach((el) => el.remove()); + + const svg = iconWrapper.querySelector(':scope > svg'); + if (!svg) { + return; + } + + svg.style.animation = 'none'; + forceReflow(svg); + + const { + count, + spacing, + blur, + opacities, + stretch, + lungeDist, + lungeAngle, + duration, + } = GHOST_CONFIG; + + const angleDeg = + lungeAngle[0] + Math.random() * (lungeAngle[1] - lungeAngle[0]); + const angleRad = (angleDeg * Math.PI) / 180; + const dist = lungeDist[0] + Math.random() * (lungeDist[1] - lungeDist[0]); + + const trailDx = -Math.cos(angleRad); + const trailDy = Math.sin(angleRad); + const trailAngleDeg = angleDeg + 180; + + for (let i = 0; i < count; i += 1) { + const ghost = svg.cloneNode(true) as SVGElement; + ghost.classList.add('crown-ghost'); + const { style } = ghost; + style.position = 'absolute'; + style.inset = '0'; + style.pointerEvents = 'none'; + style.opacity = '0'; + style.color = glowColor; + + const ghostBlur = blur[i] ?? blur[blur.length - 1]; + const ghostStretch = stretch[i] ?? stretch[stretch.length - 1]; + style.filter = `blur(${ghostBlur}px)`; + style.transformOrigin = '50% 50%'; + + const offset = spacing * (i + 1); + style.setProperty('--ghost-tx', `${(trailDx * offset).toFixed(1)}px`); + style.setProperty('--ghost-ty', `${(trailDy * offset).toFixed(1)}px`); + style.setProperty('--ghost-peak', `${opacities[i] ?? 0.05}`); + style.setProperty('--ghost-stretch', `${ghostStretch}`); + style.setProperty('--ghost-angle', `${trailAngleDeg.toFixed(0)}deg`); + style.setProperty('--ghost-delay', `${(i * 0.025).toFixed(3)}s`); + + iconWrapper.appendChild(ghost); + + restartAnimation( + ghost, + `crown-ghost ${duration}s ease-out var(--ghost-delay) forwards`, + ); + } + + iconWrapper.style.setProperty( + '--rocket-dx', + `${(Math.cos(angleRad) * dist).toFixed(1)}px`, + ); + iconWrapper.style.setProperty( + '--rocket-dy', + `${(-Math.sin(angleRad) * dist).toFixed(1)}px`, + ); + + restartAnimation( + iconWrapper, + `crown-icon-rocket ${duration}s cubic-bezier(0.22, 1, 0.36, 1)`, + ); + + svg.style.transformOrigin = '15% 75%'; + svg.style.animation = `crown-arrow-extend ${duration}s cubic-bezier(0.22, 1, 0.36, 1)`; + + setTimeout(() => { + iconWrapper.querySelectorAll('.crown-ghost').forEach((el) => el.remove()); + }, duration * 1000 + 50); +}; + +const restartSoundPulse = (card: HTMLElement): void => { + const iconWrapper = card.querySelector('.crown-icon-wrapper'); + if (!iconWrapper) { + return; + } + + const svg = iconWrapper.querySelector(':scope > svg'); + if (!svg) { + return; + } + + svg.style.overflow = 'visible'; + iconWrapper.style.animation = 'none'; + + const nestedArcPaths = Array.from( + svg.querySelectorAll('g > g > path'), + ); + + if (nestedArcPaths.length && !svg.dataset.megaphonePrepared) { + const topLevelPaths = Array.from( + svg.querySelectorAll(':scope > g > path'), + ); + + const duplicateArcPath = topLevelPaths[0]; + if (duplicateArcPath) { + duplicateArcPath.style.opacity = '0'; + } + + const mergedBodyPath = topLevelPaths.find((path) => { + const d = path.getAttribute('d') ?? ''; + return /[zZ]\s*M/.test(d); + }); + + if (mergedBodyPath) { + const d = mergedBodyPath.getAttribute('d') ?? ''; + const splitIndex = d.search(/[zZ]\s*M/); + if (splitIndex >= 0) { + const nextMoveIndex = d.indexOf('M', splitIndex); + if (nextMoveIndex >= 0) { + mergedBodyPath.setAttribute('d', d.slice(nextMoveIndex)); + } + } + } + + svg.dataset.megaphonePrepared = 'true'; + } + + const arcGroupPaths = nestedArcPaths.length + ? nestedArcPaths + : Array.from(svg.querySelectorAll('path')); + + arcGroupPaths.forEach((el) => { + el.style.removeProperty('animation'); + }); + forceReflow(svg); + + iconWrapper.style.animation = 'crown-icon-megaphone 0.8s ease-out'; + if (!arcGroupPaths.length) { + return; + } + + const orderedArcPaths = [...arcGroupPaths].sort((a, b) => { + const aLength = a.getAttribute('d')?.length ?? 0; + const bLength = b.getAttribute('d')?.length ?? 0; + return bLength - aLength; + }); + + const earlyCount = Math.ceil(orderedArcPaths.length / 2); + + orderedArcPaths.forEach((el, i) => { + const delay = i < earlyCount ? 0 : 0.1; + const { style } = el; + style.transformBox = 'fill-box'; + style.transformOrigin = '0% 50%'; + style.animation = `crown-sound-ripple 0.8s ease-out ${delay.toFixed(2)}s`; + }); + + const soundParticles = iconWrapper.querySelectorAll( + '.crown-sound-particle', + ); + + soundParticles.forEach((el, i) => { + const { style } = el; + const side = i % 2 === 0 ? -1 : 1; + const startX = 9 + Math.random() * 4; + const arcBandY = -4.5; + const sideOffset = 1.8 + Math.random() * 1.8; + const startY = arcBandY + side * sideOffset; + const endX = startX + 3 + Math.random() * 4; + const endY = startY + side * (0.4 + Math.random() * 1.4); + const delay = 0.04 + i * 0.02 + Math.random() * 0.03; + const peak = 0.24 + Math.random() * 0.12; + + style.setProperty('--sound-particle-sx', `${startX.toFixed(1)}px`); + style.setProperty('--sound-particle-sy', `${startY.toFixed(1)}px`); + style.setProperty('--sound-particle-ex', `${endX.toFixed(1)}px`); + style.setProperty('--sound-particle-ey', `${endY.toFixed(1)}px`); + style.setProperty('--sound-particle-peak', peak.toFixed(2)); + + restartAnimation( + el, + `crown-sound-particle 0.65s ease-out ${delay.toFixed(2)}s`, + ); + }); +}; + +const restartFlame = (card: HTMLElement): void => { + const iconWrapper = card.querySelector('.crown-icon-wrapper'); + if (!iconWrapper) { + return; + } + + restartAnimation( + iconWrapper, + 'crown-icon-flame 1.1s cubic-bezier(0.32, 0.02, 0.22, 1)', + ); + + const flameGlow = iconWrapper.querySelector('.crown-flame-glow'); + if (flameGlow) { + restartAnimation(flameGlow, 'crown-flame-glow 1.1s ease-out'); + } + + const embers = + iconWrapper.querySelectorAll('.crown-flame-ember'); + embers.forEach((el, i) => { + const { style } = el; + const side = i % 2 === 0 ? -1 : 1; + const startX = side * (1.5 + Math.random() * 4.5); + const startY = -0.5 + Math.random() * 3.5; + const endX = startX + side * (1 + Math.random() * 3.5); + const endY = -11 - Math.random() * 7; + const peak = 0.42 + Math.random() * 0.22; + const delay = 0.02 + i * 0.02 + Math.random() * 0.03; + + style.setProperty('--flame-ember-sx', `${startX.toFixed(1)}px`); + style.setProperty('--flame-ember-sy', `${startY.toFixed(1)}px`); + style.setProperty('--flame-ember-ex', `${endX.toFixed(1)}px`); + style.setProperty('--flame-ember-ey', `${endY.toFixed(1)}px`); + style.setProperty('--flame-ember-peak', peak.toFixed(2)); + + restartAnimation( + el, + `crown-flame-ember 1.45s ease-out ${delay.toFixed(2)}s`, + ); + }); +}; + +const restartMedal = (card: HTMLElement): void => { + const iconWrapper = card.querySelector('.crown-icon-wrapper'); + if (!iconWrapper) { + return; + } + + restartAnimation( + iconWrapper, + 'crown-icon-medal 0.95s cubic-bezier(0.22, 1, 0.36, 1)', + ); + + const sheen = iconWrapper.querySelector('.crown-medal-sheen'); + if (sheen) { + restartAnimation(sheen, 'crown-medal-sheen 0.7s ease-out'); + } + + const glints = + iconWrapper.querySelectorAll('.crown-medal-glint'); + glints.forEach((el, i) => { + const { style } = el; + const delay = 0.06 + i * 0.08; + const x = i === 0 ? 7.5 : -7; + const y = i === 0 ? -6.5 : 4.5; + + style.setProperty('--medal-glint-x', `${x}px`); + style.setProperty('--medal-glint-y', `${y}px`); + restartAnimation( + el, + `crown-medal-glint 0.46s ease-out ${delay.toFixed(2)}s`, + ); + }); + + const particles = iconWrapper.querySelectorAll( + '.crown-medal-particle', + ); + + const medalCenterX = -2; + const medalCenterY = -5; + particles.forEach((el, i) => { + const { style } = el; + const slot = 360 / Math.max(particles.length, 1); + const jitter = (Math.random() - 0.5) * slot * 0.45; + const angleDeg = i * slot + jitter; + const angleRad = (angleDeg * Math.PI) / 180; + const startR = 2 + Math.random() * 2; + const endR = 18 + Math.random() * 10; + const sx = Math.cos(angleRad) * startR + medalCenterX; + const sy = Math.sin(angleRad) * startR + medalCenterY; + const ex = Math.cos(angleRad) * endR + medalCenterX; + const ey = Math.sin(angleRad) * endR + medalCenterY; + const delay = 0.01 + i * 0.01 + Math.random() * 0.015; + const peak = 0.62 + Math.random() * 0.24; + + style.setProperty('--medal-particle-sx', `${sx.toFixed(1)}px`); + style.setProperty('--medal-particle-sy', `${sy.toFixed(1)}px`); + style.setProperty('--medal-particle-ex', `${ex.toFixed(1)}px`); + style.setProperty('--medal-particle-ey', `${ey.toFixed(1)}px`); + style.setProperty('--medal-particle-peak', peak.toFixed(2)); + + restartAnimation( + el, + `crown-medal-particle 1.05s ease-out ${delay.toFixed(2)}s`, + ); + }); +}; + +export const runCrownMouseEnterAnimation = ( + card: HTMLElement, + crownType: CrownType, + glowColor: string, +): void => { + if (crownType === 'fastest-rising') { + restartRocket(card, glowColor); + return; + } + + if (crownType === 'most-discussed') { + restartSoundPulse(card); + return; + } + + if (crownType === 'most-controversial') { + restartFlame(card); + return; + } + + if (crownType === 'developers-choice') { + restartMedal(card); + return; + } + + restartIconAnimation(card, crownType); + + if (crownType === 'most-loved') { + restartBloom(card); + return; + } + + randomizeSparks(card); +}; diff --git a/packages/shared/src/features/agents/arena/ArenaCrownCards.tsx b/packages/shared/src/features/agents/arena/ArenaCrownCards.tsx new file mode 100644 index 0000000000..726cbd2c9c --- /dev/null +++ b/packages/shared/src/features/agents/arena/ArenaCrownCards.tsx @@ -0,0 +1,241 @@ +import type { ReactElement } from 'react'; +import React, { useCallback } from 'react'; +import classNames from 'classnames'; +import { IconSize } from '../../../components/Icon'; +import type { CrownData } from './types'; +import { + CROWN_SPARK_COUNT, + crownHoverAnimations, + runCrownMouseEnterAnimation, +} from './ArenaCrownAnimations'; + +interface ArenaCrownCardsProps { + crowns: CrownData[]; + loading?: boolean; +} + +const Placeholder = ({ className }: { className?: string }): ReactElement => ( +
+); + +const CrownCard = ({ + crown, + loading, +}: { + crown: CrownData; + loading?: boolean; +}): ReactElement => { + const hasEntity = !loading && !!crown.entity; + const hoverClass = hasEntity ? crownHoverAnimations[crown.type] : undefined; + + const handleMouseEnter = useCallback( + (e: React.MouseEvent) => { + if (!hasEntity) { + return; + } + + runCrownMouseEnterAnimation(e.currentTarget, crown.type, crown.glowColor); + }, + [hasEntity, crown.glowColor, crown.type], + ); + + return ( +
+ {hasEntity && ( +
+ )} + +
+
+ + + {hasEntity && + crown.type !== 'most-loved' && + crown.type !== 'fastest-rising' && + crown.type !== 'most-discussed' && + crown.type !== 'most-controversial' && + crown.type !== 'developers-choice' && ( +
+ {Array.from({ length: CROWN_SPARK_COUNT }, (_, i) => ( +
+ ))} +
+ )} + + {hasEntity && crown.type === 'most-controversial' && ( + <> +
+
+ {Array.from({ length: 10 }, (_, i) => ( +
+ ))} +
+ + )} + + {hasEntity && crown.type === 'developers-choice' && ( + <> +
+
+
+
+ {Array.from({ length: 2 }, (_, i) => ( +
+ ))} +
+
+ {Array.from({ length: 16 }, (_, i) => ( +
+ ))} +
+ + )} + + {hasEntity && crown.type === 'most-discussed' && ( +
+ {Array.from({ length: 6 }, (_, i) => ( +
+ ))} +
+ )} + + {hasEntity && crown.type === 'most-loved' && ( +
+ )} +
+ + + {crown.label} + +
+ +
+ {loading ? ( + <> + + + + ) : ( + <> + {crown.entity?.name} + + {crown.entity?.name} + + + )} +
+ +
+ {loading ? ( + + ) : ( + {crown.stat} + )} +
+
+ ); +}; + +export const ArenaCrownCards = ({ + crowns, + loading, +}: ArenaCrownCardsProps): ReactElement => { + return ( +
+ {crowns.map((crown) => ( + + ))} +
+ ); +}; diff --git a/packages/shared/src/features/agents/arena/ArenaHighlightUtils.ts b/packages/shared/src/features/agents/arena/ArenaHighlightUtils.ts new file mode 100644 index 0000000000..039daf8d2e --- /dev/null +++ b/packages/shared/src/features/agents/arena/ArenaHighlightUtils.ts @@ -0,0 +1,2 @@ +export const stripTcoLinks = (text: string): string => + text.replace(/https?:\/\/t\.co\/\S+/g, '').trim(); diff --git a/packages/shared/src/features/agents/arena/ArenaHighlightsFeed.tsx b/packages/shared/src/features/agents/arena/ArenaHighlightsFeed.tsx new file mode 100644 index 0000000000..3fbda369ed --- /dev/null +++ b/packages/shared/src/features/agents/arena/ArenaHighlightsFeed.tsx @@ -0,0 +1,268 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { useViewSize, ViewSize } from '../../../hooks'; +import { getLastActivityDateFormat } from '../../../lib/dateFormat'; +import type { SentimentAnnotation, SentimentHighlightItem } from './types'; +import { stripTcoLinks } from './ArenaHighlightUtils'; + +const decodeHtmlEntities = (text: string): string => { + const textarea = + typeof document !== 'undefined' && document.createElement('textarea'); + if (!textarea) { + return text + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); + } + + textarea.innerHTML = text; + return textarea.value; +}; + +const getSentimentColor = (score: number): string => { + if (score > 0.2) { + return 'bg-accent-avocado-default'; + } + if (score < -0.2) { + return 'bg-accent-ketchup-default'; + } + return 'bg-text-quaternary'; +}; + +const Placeholder = ({ className }: { className?: string }): ReactElement => ( +
+); + +const SentimentPill = ({ + sentiment, +}: { + sentiment: SentimentAnnotation; +}): ReactElement => { + return ( + + + {sentiment.entity.replace(/_/g, ' ')} + + ); +}; + +const AuthorAvatar = ({ + author, +}: { + author?: SentimentHighlightItem['author']; +}): ReactElement => { + if (author?.avatarUrl) { + return ( + {author.name + ); + } + + const initials = (author?.name ?? author?.handle ?? '?') + .split(' ') + .map((w) => w[0]) + .join('') + .slice(0, 2) + .toUpperCase(); + + return ( +
+ {initials} +
+ ); +}; + +const HighlightCard = ({ + item, +}: { + item: SentimentHighlightItem; +}): ReactElement => { + const cleanText = decodeHtmlEntities(stripTcoLinks(item.text)); + + return ( +
+ +
+
+ {item.author?.name && ( + + {item.author.name} + + )} + {item.author?.handle && ( + + @{item.author.handle} + + )} + + · {getLastActivityDateFormat(item.createdAt)} + +
+ +

+ {cleanText} +

+ + {item.sentiments.length > 0 && ( +
+ {item.sentiments.map((sentiment) => ( + + ))} +
+ )} +
+
+ ); +}; + +const PlaceholderCard = (): ReactElement => ( +
+ +
+
+ + +
+ +
+ + +
+
+
+); + +const MOBILE_FEED_LIMIT = 5; +const MAX_HIGHLIGHTS = 100; + +interface ArenaHighlightsFeedProps { + items: SentimentHighlightItem[]; + loading?: boolean; +} + +export const ArenaHighlightsFeed = ({ + items, + loading, +}: ArenaHighlightsFeedProps): ReactElement => { + const [visibleItems, setVisibleItems] = useState( + [], + ); + const [pendingItems, setPendingItems] = useState( + [], + ); + const [mobileExpanded, setMobileExpanded] = useState(false); + const isLaptop = useViewSize(ViewSize.LaptopL); + const initializedRef = useRef(false); + const visibleIdsRef = useRef>(new Set()); + const scrollRef = useRef(null); + + useEffect(() => { + visibleIdsRef.current = new Set( + visibleItems.map((item) => item.externalItemId), + ); + }, [visibleItems]); + + useEffect(() => { + if (items.length === 0) { + initializedRef.current = false; + setVisibleItems([]); + setPendingItems([]); + return; + } + + if (!initializedRef.current) { + initializedRef.current = true; + setVisibleItems(items.slice(0, MAX_HIGHLIGHTS)); + setPendingItems([]); + return; + } + + const newItems = items.filter( + (item) => !visibleIdsRef.current.has(item.externalItemId), + ); + + if (newItems.length > 0) { + setPendingItems((prev) => + [...newItems, ...prev].slice(0, MAX_HIGHLIGHTS), + ); + } + }, [items]); + + const showPending = (): void => { + setVisibleItems((prev) => + [...pendingItems, ...prev].slice(0, MAX_HIGHLIGHTS), + ); + setPendingItems([]); + scrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + const displayItems = + isLaptop || mobileExpanded + ? visibleItems + : visibleItems.slice(0, MOBILE_FEED_LIMIT); + const hasMoreOnMobile = + !isLaptop && !mobileExpanded && visibleItems.length > MOBILE_FEED_LIMIT; + + return ( +
+
+ + Live Highlights + +
+ + {!loading && pendingItems.length > 0 && ( +
+ +
+ )} + +
+ {loading + ? Array.from({ length: 4 }).map((_, i) => ( + // eslint-disable-next-line react/no-array-index-key + + )) + : displayItems.map((item) => ( + + ))} +
+ + {!loading && hasMoreOnMobile && ( + + )} +
+ ); +}; diff --git a/packages/shared/src/features/agents/arena/ArenaPage.tsx b/packages/shared/src/features/agents/arena/ArenaPage.tsx new file mode 100644 index 0000000000..9129c4d72c --- /dev/null +++ b/packages/shared/src/features/agents/arena/ArenaPage.tsx @@ -0,0 +1,116 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import classNames from 'classnames'; +import { useQuery } from '@tanstack/react-query'; +import { ArenaIcon } from '../../../components/icons/Arena'; +import { IconSize } from '../../../components/Icon'; +import type { ArenaTab } from './types'; +import { ARENA_TABS } from './config'; +import { computeRankings, computeCrowns } from './arenaMetrics'; +import { ArenaCrownCards } from './ArenaCrownCards'; +import { ArenaRankings } from './ArenaRankings'; +import { ArenaHighlightsFeed } from './ArenaHighlightsFeed'; +import { arenaOptions } from './queries'; + +interface ArenaPageProps { + activeTab: ArenaTab; + onTabChange?: (tab: ArenaTab) => void; +} + +const LiveIndicator = (): ReactElement => ( +
+ + Live +
+); + +export const ArenaPage = ({ + activeTab, + onTabChange, +}: ArenaPageProps): ReactElement => { + const { data, isFetching } = useQuery(arenaOptions({ groupId: activeTab })); + + const rankings = useMemo( + () => + data?.sentimentTimeSeries && data.sentimentGroup + ? computeRankings( + data.sentimentTimeSeries.entities.nodes, + data.sentimentGroup.entities, + data.sentimentTimeSeries.resolutionSeconds, + ) + : [], + [data?.sentimentTimeSeries, data?.sentimentGroup], + ); + + const crowns = useMemo(() => computeCrowns(rankings), [rankings]); + const loading = isFetching && !data; + + return ( +
+
+
+
+
+
+ +
+
+ +
+

+ The Arena +

+

+ Where AI tools fight for developer love +

+
+
+
+ + + +
+
+ +
+ +
+
+ +
+ +
+
+
+ ); +}; diff --git a/packages/shared/src/features/agents/arena/ArenaRankings.tsx b/packages/shared/src/features/agents/arena/ArenaRankings.tsx new file mode 100644 index 0000000000..dfe514a174 --- /dev/null +++ b/packages/shared/src/features/agents/arena/ArenaRankings.tsx @@ -0,0 +1,432 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import type { RankedTool } from './types'; +import { ArenaSparkline } from './ArenaSparkline'; +import { ArenaSentimentBar } from './ArenaSentimentBar'; +import { ArenaAnimatedCounter } from './ArenaAnimatedCounter'; +import { formatDIndex, formatVolume } from './arenaMetrics'; +import { Tooltip } from '../../../components/tooltip/Tooltip'; +import { InfoIcon } from '../../../components/icons/Info'; +import { MedalBadgeIcon } from '../../../components/icons/MedalBadge'; +import { IconSize } from '../../../components/Icon'; + +interface ArenaRankingsProps { + tools: RankedTool[]; + loading?: boolean; +} + +const RANK_MEDAL_COLORS = [ + 'text-accent-cheese-default', // gold + 'text-text-tertiary', // silver + 'text-accent-bacon-default', // bronze +]; + +const Placeholder = ({ className }: { className?: string }): ReactElement => ( +
+); + +const getMomentumDisplay = ( + momentum: number, +): { text: string; color: string; cssColor: string } => { + if (momentum > 0) { + return { + text: `+${momentum}%`, + color: 'text-accent-avocado-default', + cssColor: 'var(--theme-accent-avocado-default)', + }; + } + if (momentum < 0) { + return { + text: `${momentum}%`, + color: 'text-accent-ketchup-default', + cssColor: 'var(--theme-accent-ketchup-default)', + }; + } + return { + text: '0%', + color: 'text-text-quaternary', + cssColor: 'var(--theme-text-quaternary)', + }; +}; + +const ChevronIcon = ({ expanded }: { expanded: boolean }): ReactElement => ( + + + +); + +const RankingRow = ({ + tool, + rank, + loading, + expanded, + onToggle, +}: { + tool: RankedTool; + rank: number; + loading?: boolean; + expanded: boolean; + onToggle: () => void; +}): ReactElement => { + const momentum = getMomentumDisplay(tool.momentum); + const medalColor = RANK_MEDAL_COLORS[rank - 1]; + + return ( +
+ {/* Main row — clickable on mobile to expand */} + + + {/* Expanded detail panel — mobile/tablet only */} + {expanded && !loading && ( +
+
+ {/* Sentiment — shown on mobile (hidden on tablet since it's inline) */} +
+ + Sentiment + + +
+
+ + 24h Vol + + + {formatVolume(tool.volume24h)} + +
+
+ + Momentum + + + {momentum.text} + +
+
+
+ + 7d Trend + + +
+
+ )} +
+ ); +}; + +const EmergingRow = ({ + tool, + loading, +}: { + tool: RankedTool; + loading?: boolean; +}): ReactElement => ( +
+ {loading ? ( + <> + + + + ) : ( + <> + {tool.entity.name} + + {tool.entity.name} + + + )} + {!loading && ( + + Not enough data yet + + )} +
+); + +const HeaderWithTooltip = ({ + label, + tooltip, +}: { + label: string; + tooltip: string; +}): ReactElement => ( + + + {label} + + + +); + +const PLACEHOLDER_ROW_COUNT = 6; + +const PlaceholderRow = ({ rank }: { rank: number }): ReactElement => ( +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+); + +export const ArenaRankings = ({ + tools, + loading, +}: ArenaRankingsProps): ReactElement => { + const [expandedEntity, setExpandedEntity] = useState(null); + const established = tools.filter((t) => !t.isEmerging); + const emerging = tools.filter((t) => t.isEmerging); + const showPlaceholderRows = loading && established.length === 0; + + return ( +
+ {/* Header */} +
+ +   + + + Tool + + + + + + + + + + + + + + + 7d Trend + + {/* Spacer for chevron column on mobile */} + +
+ + {/* Ranked rows */} +
+ {showPlaceholderRows + ? Array.from({ length: PLACEHOLDER_ROW_COUNT }).map((_, i) => ( + // eslint-disable-next-line react/no-array-index-key + + )) + : established.map((tool, idx) => ( + + setExpandedEntity((prev) => + prev === tool.entity.entity ? null : tool.entity.entity, + ) + } + /> + ))} +
+ + {/* Emerging section */} + {emerging.length > 0 && ( + <> +
+ + Emerging + +
+ {emerging.map((tool) => ( + + ))} + + )} +
+ ); +}; diff --git a/packages/shared/src/features/agents/arena/ArenaSentimentBar.tsx b/packages/shared/src/features/agents/arena/ArenaSentimentBar.tsx new file mode 100644 index 0000000000..af3f0f3d5b --- /dev/null +++ b/packages/shared/src/features/agents/arena/ArenaSentimentBar.tsx @@ -0,0 +1,51 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; + +interface ArenaSentimentBarProps { + value: number; +} + +const getSentimentColor = (value: number): string => { + if (value > 60) { + return 'bg-accent-avocado-default'; + } + if (value >= 40) { + return 'bg-accent-cheese-default'; + } + return 'bg-accent-ketchup-default'; +}; + +const getSentimentTextColor = (value: number): string => { + if (value > 60) { + return 'text-accent-avocado-default'; + } + if (value >= 40) { + return 'text-accent-cheese-default'; + } + return 'text-accent-ketchup-default'; +}; + +export const ArenaSentimentBar = ({ + value, +}: ArenaSentimentBarProps): ReactElement => ( +
+
+
+
+ + {value} + +
+); diff --git a/packages/shared/src/features/agents/arena/ArenaSparkline.tsx b/packages/shared/src/features/agents/arena/ArenaSparkline.tsx new file mode 100644 index 0000000000..647e241414 --- /dev/null +++ b/packages/shared/src/features/agents/arena/ArenaSparkline.tsx @@ -0,0 +1,80 @@ +import type { ReactElement } from 'react'; +import React, { useId } from 'react'; + +interface ArenaSparklineProps { + data: number[]; + width?: number; + height?: number; + className?: string; + color?: string; +} + +export const ArenaSparkline = ({ + data, + width = 80, + height = 24, + className, + color, +}: ArenaSparklineProps): ReactElement => { + const max = Math.max(...data, 1); + const padding = 2; + const chartWidth = width - padding * 2; + const chartHeight = height - padding * 2; + const step = chartWidth / Math.max(data.length - 1, 1); + const strokeColor = color || 'var(--theme-text-tertiary)'; + const gradientId = `sparkGrad-${useId().replace(/:/g, '')}`; + + const points = data.map((value, i) => ({ + x: padding + i * step, + y: padding + chartHeight - (value / max) * chartHeight, + })); + + const pathD = points.reduce( + (acc, point, i) => `${acc}${i === 0 ? 'M' : 'L'}${point.x},${point.y}`, + '', + ); + + const lastPoint = points[points.length - 1]; + const areaD = `${pathD}L${lastPoint.x},${padding + chartHeight}L${padding},${ + padding + chartHeight + }Z`; + + return ( + + + + + + + + + + + + + + + ); +}; diff --git a/packages/shared/src/features/agents/arena/arenaMetrics.ts b/packages/shared/src/features/agents/arena/arenaMetrics.ts new file mode 100644 index 0000000000..9ba390d86c --- /dev/null +++ b/packages/shared/src/features/agents/arena/arenaMetrics.ts @@ -0,0 +1,408 @@ +import type { + SentimentTimeSeriesNode, + ArenaEntity, + CrownData, + RankedTool, +} from './types'; +import { EMERGING_THRESHOLD } from './config'; +import { MedalBadgeIcon } from '../../../components/icons/MedalBadge'; +import { StarIcon } from '../../../components/icons/Star'; +import { TrendingIcon } from '../../../components/icons/Trending'; +import { MegaphoneIcon } from '../../../components/icons/Megaphone'; +import { HotIcon } from '../../../components/icons/Hot'; + +const SECONDS_PER_DAY = 86400; + +interface WindowData { + volume: number; + sentimentScore: number; +} + +/** + * Sum volume and compute weighted sentiment for points in a timestamp range. + * Timestamps are offsets (in seconds) from the series start. + */ +const sumWindowByTime = ( + node: SentimentTimeSeriesNode, + fromOffset: number, + toOffset: number, +): WindowData => { + let totalVolume = 0; + let weightedScore = 0; + + for (let i = 0; i < node.timestamps.length; i += 1) { + const ts = node.timestamps[i]; + if (ts >= fromOffset && ts < toOffset) { + totalVolume += node.volume[i]; + weightedScore += node.scores[i] * node.volume[i]; + } + } + + if (totalVolume === 0) { + return { volume: 0, sentimentScore: 0 }; + } + + return { volume: totalVolume, sentimentScore: weightedScore / totalVolume }; +}; + +/** + * Returns the total window span in seconds based on the sparse timestamps. + * For a 7d lookback the max offset should be ~604800. + */ +const getWindowSpan = (node: SentimentTimeSeriesNode): number => { + if (node.timestamps.length === 0) { + return 0; + } + return node.timestamps[node.timestamps.length - 1]; +}; + +const getLatest24hWindow = (node: SentimentTimeSeriesNode): WindowData => { + const span = getWindowSpan(node); + if (span === 0) { + return { volume: 0, sentimentScore: 0 }; + } + + const cutoff = Math.max(0, span - SECONDS_PER_DAY); + return sumWindowByTime(node, cutoff, span + 1); +}; + +const assertSeriesShape = (node: SentimentTimeSeriesNode): void => { + const expectedLength = node.timestamps.length; + const series: Array<[string, number[]]> = [ + ['scores', node.scores], + ['volume', node.volume], + ['scoreVariance', node.scoreVariance], + ['dIndex', node.dIndex], + ]; + + const invalidSeries = series.find( + ([, values]) => values.length !== expectedLength, + ); + if (invalidSeries) { + const [name, values] = invalidSeries; + throw new Error( + `Arena sentiment series "${node.entity}" has invalid ${name} length: expected ${expectedLength}, got ${values.length}`, + ); + } +}; + +const getWeightedAverageDIndexByTime = ( + node: SentimentTimeSeriesNode, + fromOffset: number, + toOffset: number, + multiplier: number, +): number => { + let totalWeight = 0; + let weightedDIndex = 0; + + for (let i = 0; i < node.timestamps.length; i += 1) { + const ts = node.timestamps[i]; + if (ts >= fromOffset && ts < toOffset) { + const volume = node.volume[i]; + if (volume > 0) { + weightedDIndex += node.dIndex[i] * volume; + totalWeight += volume; + } + } + } + + if (totalWeight === 0) { + return 0; + } + + return (weightedDIndex / totalWeight) * multiplier; +}; + +const getLatest24hDIndex = ( + node: SentimentTimeSeriesNode, + multiplier: number, +): number => { + const span = getWindowSpan(node); + if (span === 0) { + return 0; + } + + const cutoff = Math.max(0, span - SECONDS_PER_DAY); + return getWeightedAverageDIndexByTime(node, cutoff, span + 1, multiplier); +}; + +const getPrevious24hDIndex = ( + node: SentimentTimeSeriesNode, + multiplier: number, +): number => { + const span = getWindowSpan(node); + if (span <= SECONDS_PER_DAY) { + return 0; + } + + const cutoff = Math.max(0, span - SECONDS_PER_DAY); + const prevStart = Math.max(0, cutoff - SECONDS_PER_DAY); + return getWeightedAverageDIndexByTime(node, prevStart, cutoff, multiplier); +}; + +const computeSentimentDisplay = (sentimentScore: number): number => + Math.round(((sentimentScore + 1) / 2) * 100); + +const computeMomentum = (currentDIndex: number, prevDIndex: number): number => { + if (prevDIndex === 0) { + return currentDIndex > 0 ? 100 : 0; + } + return ((currentDIndex - prevDIndex) / prevDIndex) * 100; +}; + +interface ControversyData { + /** Total controversy: sum of volume × scoreVariance. Used for ranking. */ + score: number; + /** Volume-weighted average scoreVariance. */ + heat: number; +} + +/** + * Compute controversy metrics from the API-provided scoreVariance. + */ +const computeControversy = ( + node: SentimentTimeSeriesNode, + fromOffset: number, + toOffset: number, +): ControversyData => { + let totalWeighted = 0; + let totalVolume = 0; + + for (let i = 0; i < node.timestamps.length; i += 1) { + const ts = node.timestamps[i]; + if (ts >= fromOffset && ts < toOffset && node.volume[i] > 0) { + totalWeighted += node.volume[i] * node.scoreVariance[i]; + totalVolume += node.volume[i]; + } + } + + const avgVariance = totalVolume > 0 ? totalWeighted / totalVolume : 0; + return { + score: totalWeighted, + heat: Math.round(avgVariance * 100), + }; +}; + +/** + * Build a 7-point sparkline across the available time window. + * Points 0-5 are fixed day-aligned buckets (complete days). + * Point 6 is a rolling 24h window ending at `span` so the in-progress day + * is compared against a full day of data instead of a partial one. + */ +const getSparklineData = ( + node: SentimentTimeSeriesNode, + multiplier: number, +): number[] => { + const span = getWindowSpan(node); + if (span === 0) { + return [0, 0, 0, 0, 0, 0, 0]; + } + + return Array.from({ length: 7 }, (_, idx) => { + // Last point: rolling 24h window ending at the latest data point. + if (idx === 6) { + const from = Math.max(0, span - SECONDS_PER_DAY); + return getWeightedAverageDIndexByTime(node, from, span + 1, multiplier); + } + + // Points 0-5: fixed day-aligned buckets. + const from = idx * SECONDS_PER_DAY; + const to = (idx + 1) * SECONDS_PER_DAY; + return getWeightedAverageDIndexByTime(node, from, to, multiplier); + }); +}; + +export const computeRankings = ( + nodes: SentimentTimeSeriesNode[], + entityMetadata: ArenaEntity[], + resolutionSeconds = 3600, +): RankedTool[] => { + if (!entityMetadata.length) { + throw new Error('Arena entity metadata is empty'); + } + + const entityMap = new Map( + entityMetadata.map((entity) => [entity.entity, entity]), + ); + + const multiplier = + resolutionSeconds > 0 ? SECONDS_PER_DAY / resolutionSeconds : 1; + + const tools: RankedTool[] = nodes.map((node) => { + const entityMeta = entityMap.get(node.entity); + if (!entityMeta) { + throw new Error( + `Arena sentiment series contains unknown entity "${node.entity}"`, + ); + } + assertSeriesShape(node); + + const current = getLatest24hWindow(node); + const dIndex = getLatest24hDIndex(node, multiplier); + const prevDIndex = getPrevious24hDIndex(node, multiplier); + + const span = getWindowSpan(node); + const cutoff24h = Math.max(0, span - SECONDS_PER_DAY); + const controversy = computeControversy(node, cutoff24h, span + 1); + + return { + entity: entityMeta, + dIndex: Math.round(dIndex), + sentimentDisplay: computeSentimentDisplay(current.sentimentScore), + momentum: Math.round(computeMomentum(dIndex, prevDIndex)), + volume24h: current.volume, + controversyScore: controversy.score, + heat: controversy.heat, + sparkline: getSparklineData(node, multiplier), + isEmerging: current.volume < EMERGING_THRESHOLD, + }; + }); + // Include entities with no data as emerging + const presentEntities = new Set(tools.map((t) => t.entity.entity)); + entityMetadata.forEach((entity) => { + if (!presentEntities.has(entity.entity)) { + tools.push({ + entity, + dIndex: 0, + sentimentDisplay: 50, + momentum: 0, + volume24h: 0, + controversyScore: 0, + heat: 0, + sparkline: [0, 0, 0, 0, 0, 0, 0], + isEmerging: true, + }); + } + }); + + return tools.sort((a, b) => b.dIndex - a.dIndex); +}; + +export const formatDIndex = (value: number): string => { + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(1)}M`; + } + if (value >= 1_000) { + return `${(value / 1_000).toFixed(1)}k`; + } + return value.toLocaleString(); +}; + +export const formatVolume = (value: number): string => { + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(1)}M`; + } + if (value >= 1_000) { + return `${(value / 1_000).toFixed(1)}k`; + } + return value.toString(); +}; + +interface CrownThresholds { + minVolume: number; +} + +const CROWN_CONFIG: Record< + string, + { + icon: CrownData['icon']; + iconColor: string; + glowColor: string; + label: string; + thresholds: CrownThresholds; + getValue: (t: RankedTool) => number; + formatStat: (t: RankedTool) => string; + } +> = { + 'developers-choice': { + icon: MedalBadgeIcon, + iconColor: 'text-accent-cheese-default', + glowColor: 'var(--theme-accent-cheese-default)', + label: "Developer's choice", + thresholds: { minVolume: 10 }, + getValue: (t) => t.dIndex, + formatStat: (t) => `${formatDIndex(t.dIndex)} D-Index`, + }, + 'most-loved': { + icon: StarIcon, + iconColor: 'text-accent-cabbage-default', + glowColor: 'var(--theme-accent-cabbage-default)', + label: 'Most loved', + thresholds: { minVolume: 10 }, + getValue: (t) => t.sentimentDisplay, + formatStat: (t) => `${t.sentimentDisplay} / 100`, + }, + 'fastest-rising': { + icon: TrendingIcon, + iconColor: 'text-accent-avocado-default', + glowColor: 'var(--theme-accent-avocado-default)', + label: 'Fastest rising', + thresholds: { minVolume: 5 }, + getValue: (t) => t.momentum, + formatStat: (t) => + `${t.momentum > 0 ? '+' : ''}${t.momentum}% vs prior 24h`, + }, + 'most-discussed': { + icon: MegaphoneIcon, + iconColor: 'text-accent-blueCheese-default', + glowColor: 'var(--theme-accent-blueCheese-default)', + label: 'Most discussed', + thresholds: { minVolume: 0 }, + getValue: (t) => t.volume24h, + formatStat: (t) => `${formatVolume(t.volume24h)} mentions`, + }, + 'most-controversial': { + icon: HotIcon, + iconColor: 'text-accent-ketchup-default', + glowColor: 'var(--theme-accent-ketchup-default)', + label: 'Most controversial', + thresholds: { minVolume: 10 }, + getValue: (t) => t.controversyScore, + formatStat: (t) => `Heat ${t.heat}`, + }, +}; + +export const computeCrowns = (tools: RankedTool[]): CrownData[] => { + const established = tools.filter((t) => !t.isEmerging); + + return ( + [ + 'developers-choice', + 'most-loved', + 'fastest-rising', + 'most-discussed', + 'most-controversial', + ] as const + ).map((type) => { + const config = CROWN_CONFIG[type]; + const eligible = established.filter( + (t) => t.volume24h >= config.thresholds.minVolume, + ); + + if (eligible.length === 0) { + return { + type, + icon: config.icon, + iconColor: config.iconColor, + glowColor: config.glowColor, + label: config.label, + entity: null, + stat: '', + }; + } + + const winner = eligible.reduce((best, current) => + config.getValue(current) > config.getValue(best) ? current : best, + ); + + return { + type, + icon: config.icon, + iconColor: config.iconColor, + glowColor: config.glowColor, + label: config.label, + entity: winner.entity, + stat: config.formatStat(winner), + }; + }); +}; diff --git a/packages/shared/src/features/agents/arena/config.ts b/packages/shared/src/features/agents/arena/config.ts new file mode 100644 index 0000000000..8bf48430f9 --- /dev/null +++ b/packages/shared/src/features/agents/arena/config.ts @@ -0,0 +1,13 @@ +import type { ArenaGroupId, ArenaTab } from './types'; + +export const ARENA_TABS: { label: string; value: ArenaTab }[] = [ + { label: 'Coding Agents', value: 'coding-agents' }, + { label: 'LLMs', value: 'llms' }, +]; + +export const ARENA_GROUP_IDS: Record = { + 'coding-agents': '385404b4-f0f4-4e81-a338-bdca851eca31', + llms: '970ab2c9-f845-4822-82f0-02169713b814', +}; + +export const EMERGING_THRESHOLD = 50; diff --git a/packages/shared/src/features/agents/arena/graphql.ts b/packages/shared/src/features/agents/arena/graphql.ts new file mode 100644 index 0000000000..3fefad3905 --- /dev/null +++ b/packages/shared/src/features/agents/arena/graphql.ts @@ -0,0 +1,73 @@ +import { gql } from 'graphql-request'; + +export const ARENA_QUERY = gql` + query ArenaData( + $groupId: ID! + $lookback: String! + $resolution: SentimentResolution! + $highlightsFirst: Int + $highlightsOrderBy: SentimentHighlightsOrderBy + ) { + sentimentGroup(id: $groupId) { + id + name + entities { + entity + name + logo + } + } + sentimentTimeSeries( + resolution: $resolution + groupId: $groupId + lookback: $lookback + ) { + start + resolutionSeconds + entities { + nodes { + entity + timestamps + scores + volume + scoreVariance + dIndex + } + } + } + sentimentHighlights( + groupId: $groupId + first: $highlightsFirst + orderBy: $highlightsOrderBy + ) { + items { + provider + externalItemId + url + text + author { + ... on SentimentAuthorX { + id + name + handle + avatarUrl + } + } + metrics { + ... on SentimentMetricsX { + likeCount + replyCount + retweetCount + } + } + createdAt + sentiments { + entity + score + highlightScore + } + } + cursor + } + } +`; diff --git a/packages/shared/src/features/agents/arena/queries.ts b/packages/shared/src/features/agents/arena/queries.ts new file mode 100644 index 0000000000..0956ecf7df --- /dev/null +++ b/packages/shared/src/features/agents/arena/queries.ts @@ -0,0 +1,29 @@ +import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query'; +import { gqlClient } from '../../../graphql/common'; +import type { ArenaGroupId, ArenaQueryResponse } from './types'; +import { ARENA_QUERY } from './graphql'; +import { ARENA_GROUP_IDS } from './config'; + +const ARENA_REFETCH_INTERVAL = 60_000; + +export const arenaOptions = ({ groupId }: { groupId: ArenaGroupId }) => ({ + queryKey: generateQueryKey(RequestKey.Arena, undefined, groupId), + queryFn: async () => { + const res = await gqlClient.request(ARENA_QUERY, { + groupId: ARENA_GROUP_IDS[groupId], + lookback: '7d', + resolution: 'HOUR', + highlightsFirst: 50, + highlightsOrderBy: 'RECENCY', + }); + + if (!res.sentimentGroup) { + throw new Error(`Arena sentiment group not found for tab "${groupId}"`); + } + + return res; + }, + staleTime: StaleTime.Base, + refetchInterval: ARENA_REFETCH_INTERVAL, + refetchIntervalInBackground: false, +}); diff --git a/packages/shared/src/features/agents/arena/types.ts b/packages/shared/src/features/agents/arena/types.ts new file mode 100644 index 0000000000..cd514a5160 --- /dev/null +++ b/packages/shared/src/features/agents/arena/types.ts @@ -0,0 +1,108 @@ +import type { ComponentType } from 'react'; +import type { IconProps } from '../../../components/Icon'; + +export type ArenaGroupId = 'coding-agents' | 'llms'; + +export type ArenaTab = 'coding-agents' | 'llms'; + +export interface SentimentTimeSeriesNode { + entity: string; + timestamps: number[]; + scores: number[]; + volume: number[]; + scoreVariance: number[]; + dIndex: number[]; +} + +export interface SentimentTimeSeries { + start: string; + resolutionSeconds: number; + entities: { + nodes: SentimentTimeSeriesNode[]; + }; +} + +export interface ArenaQueryResponse { + sentimentTimeSeries: SentimentTimeSeries; + sentimentHighlights: SentimentHighlightsConnection; + sentimentGroup: SentimentGroup | null; +} + +export interface ArenaEntity { + entity: string; + name: string; + logo: string; +} + +export interface SentimentGroup { + id: string; + name: string; + entities: ArenaEntity[]; +} + +export type CrownType = + | 'developers-choice' + | 'most-loved' + | 'fastest-rising' + | 'most-discussed' + | 'most-controversial'; + +export interface CrownData { + type: CrownType; + icon: ComponentType; + iconColor: string; + glowColor: string; + label: string; + entity: ArenaEntity | null; + stat: string; +} + +export interface RankedTool { + entity: ArenaEntity; + dIndex: number; + sentimentDisplay: number; + momentum: number; + volume24h: number; + controversyScore: number; + heat: number; + sparkline: number[]; + isEmerging: boolean; +} + +export interface SentimentHighlightAuthor { + id?: string; + name?: string; + handle?: string; + avatarUrl?: string; +} + +export interface SentimentHighlightMetrics { + likeCount?: number; + replyCount?: number; + retweetCount?: number; + quoteCount?: number; + bookmarkCount?: number; + impressionCount?: number; +} + +export interface SentimentAnnotation { + entity: string; + score: number; + highlightScore: number; +} + +export interface SentimentHighlightItem { + provider: string; + externalItemId: string; + url: string; + text: string; + author?: SentimentHighlightAuthor; + metrics?: SentimentHighlightMetrics; + createdAt: string; + sentiments: SentimentAnnotation[]; +} + +export interface SentimentHighlightsConnection { + items: SentimentHighlightItem[]; + cursor?: string; +} diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index d7d6a32a1e..45c02cecbc 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -249,6 +249,7 @@ export enum RequestKey { UserAchievements = 'user_achievements', TrackedAchievement = 'tracked_achievement', AchievementSyncStatus = 'achievement_sync_status', + Arena = 'arena', ShowcaseAchievements = 'showcase_achievements', } diff --git a/packages/shared/src/styles/utilities.css b/packages/shared/src/styles/utilities.css index e6dc7be5e2..c3ba393f92 100644 --- a/packages/shared/src/styles/utilities.css +++ b/packages/shared/src/styles/utilities.css @@ -25,6 +25,24 @@ } } +.slim-scrollbar { + scrollbar-width: thin; + scrollbar-color: var(--theme-text-tertiary) transparent; + + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-thumb { + border-radius: 2px; + background: var(--theme-text-tertiary); + } + + &::-webkit-scrollbar-thumb:hover { + background: var(--theme-text-secondary); + } +} + .break-words { word-break: break-word; } @@ -48,3 +66,493 @@ margin-left: 20px; } } + +@keyframes float-slow { + 0%, + 100% { + transform: translate(0, 0) scale(1); + } + + 33% { + transform: translate(15px, -10px) scale(1.05); + } + + 66% { + transform: translate(-10px, 8px) scale(0.95); + } +} + +.animate-float-slow { + animation: float-slow 20s ease-in-out infinite; +} + +.animate-float-slow-reverse { + animation: float-slow 25s ease-in-out infinite reverse; +} + +.animate-float-slow-delayed { + animation: float-slow 18s ease-in-out infinite 5s; +} + +@keyframes slide-down { + 0% { + opacity: 0; + transform: translateY(-100%); + } + + 100% { + opacity: 1; + transform: translateY(0); + } +} + +.animate-slide-down { + animation: slide-down 0.3s ease-out; +} + +@keyframes crown-icon-pop { + 0% { + transform: scale(1) rotate(0deg); + } + + 15% { + transform: scale(1.18) rotate(-8deg); + } + + 30% { + transform: scale(1.05) rotate(2deg); + } + + 45% { + transform: scale(1.1) rotate(-3deg); + } + + 60%, + 100% { + transform: scale(1) rotate(0deg); + } +} + +@keyframes crown-spark { + 0% { + transform: translate(-50%, -50%) scale(1); + opacity: 0; + } + + 20% { + opacity: var(--spark-peak, 0.9); + transform: translate( + calc(-50% + var(--spark-bx, 0px)), + calc(-50% + var(--spark-by, -16px)) + ) + scale(0.7); + } + + 100% { + transform: translate( + calc(-50% + var(--spark-fx, 0px)), + calc(-50% + var(--spark-fy, -26px)) + ) + scale(0); + opacity: 0; + } +} + +.crown-spark { + opacity: 0; +} + +@keyframes crown-icon-bloom-pulse { + 0%, + 60%, + 100% { + transform: scale(1); + } + + 30% { + transform: scale(1.1); + } +} + +@keyframes crown-bloom { + 0% { + transform: translate(-50%, -50%) scale(0.2); + opacity: 0; + } + + 20% { + opacity: 1; + } + + 45% { + transform: translate(-50%, -50%) scale(0.9); + opacity: 0.85; + } + + 100% { + transform: translate(-50%, -50%) scale(1.15); + opacity: 0; + } +} + +.crown-bloom { + opacity: 0; +} + +.crown-hover-most-loved:hover { + box-shadow: + 0 0 40px color-mix(in srgb, var(--theme-accent-cabbage-default) 22%, transparent), + inset + 0 + 1px + 0 + color-mix(in srgb, var(--theme-accent-cabbage-default) 30%, transparent) !important; +} + +@keyframes crown-icon-rocket { + 0% { + transform: translate(0, 0); + } + + 18%, + 45% { + transform: translate(var(--rocket-dx, 3px), var(--rocket-dy, -3px)); + } + + 75%, + 100% { + transform: translate(0, 0); + } +} + +@keyframes crown-arrow-extend { + 0% { + transform: rotate(-45deg) scaleY(1) scaleX(1) rotate(45deg); + } + + 15%, + 50% { + transform: rotate(-45deg) scaleY(1.25) scaleX(0.95) rotate(45deg); + } + + 80%, + 100% { + transform: rotate(-45deg) scaleY(1) scaleX(1) rotate(45deg); + } +} + +@keyframes crown-ghost { + 0% { + transform: translate(0, 0) rotate(var(--ghost-angle, 225deg)) scaleY(1) + rotate(calc(var(--ghost-angle, 225deg) * -1)); + opacity: 0; + } + + 12%, + 55% { + transform: translate(var(--ghost-tx, -3px), var(--ghost-ty, 3px)) + rotate(var(--ghost-angle, 225deg)) scaleY(var(--ghost-stretch, 1.2)) + rotate(calc(var(--ghost-angle, 225deg) * -1)); + opacity: var(--ghost-peak, 0.3); + } + + 100% { + transform: translate(var(--ghost-tx, -3px), var(--ghost-ty, 3px)) + rotate(var(--ghost-angle, 225deg)) scaleY(var(--ghost-stretch, 1.2)) + rotate(calc(var(--ghost-angle, 225deg) * -1)); + opacity: 0; + } +} + +@keyframes crown-icon-megaphone { + 0%, + 60%, + 100% { + transform: rotate(0deg) translateX(0); + } + + 18% { + transform: rotate(-5deg) translateX(-2px); + } + + 40% { + transform: rotate(2deg) translateX(1px); + } +} + +@keyframes crown-sound-ripple { + 0% { + transform: rotate(-53deg) translateX(0) scale(1) rotate(53deg); + opacity: 1; + } + + 65%, + 99.9% { + transform: rotate(-53deg) translateX(6px) scale(1.08) rotate(53deg); + opacity: 0; + } + + 100% { + transform: rotate(-53deg) translateX(0) scale(1) rotate(53deg); + opacity: 1; + } +} + +@keyframes crown-sound-particle { + 0% { + transform: translate( + var(--sound-particle-sx, 10px), + var(--sound-particle-sy, 0px) + ) + scale(0.8); + opacity: 0; + } + + 28% { + transform: translate( + var(--sound-particle-sx, 10px), + var(--sound-particle-sy, 0px) + ) + scale(1); + opacity: var(--sound-particle-peak, 0.3); + } + + 100% { + transform: translate(var(--sound-particle-ex, 14px), var(--sound-particle-ey, 0px)) + scale(0.65); + opacity: 0; + } +} + +@keyframes crown-icon-flame { + 0% { + transform: translate(0, 0) rotate(0deg) scaleX(1) scaleY(1); + } + + 16% { + transform: translate(-0.5px, -1.2px) rotate(-1.8deg) scaleX(0.96) + scaleY(1.1); + } + + 33% { + transform: translate(0.6px, -1.8px) rotate(1.3deg) scaleX(1.04) + scaleY(1.13); + } + + 50% { + transform: translate(-0.4px, -0.9px) rotate(-0.9deg) scaleX(0.98) + scaleY(1.07); + } + + 72% { + transform: translate(0.5px, -1.5px) rotate(1.1deg) scaleX(1.03) + scaleY(1.1); + } + + 100% { + transform: translate(0, 0) rotate(0deg) scaleX(1) scaleY(1); + } +} + +@keyframes crown-flame-glow { + 0% { + transform: translate(-50%, -50%) scale(0.85); + opacity: 0; + } + + 30% { + transform: translate(-50%, -50%) scale(1.04); + opacity: 0.38; + } + + 65% { + transform: translate(-50%, -50%) scale(1.14); + opacity: 0.24; + } + + 100% { + transform: translate(-50%, -50%) scale(1.14); + opacity: 0; + } +} + +@keyframes crown-flame-ember { + 0% { + transform: translate(var(--flame-ember-sx, 0px), var(--flame-ember-sy, 0px)) + scale(0.7); + opacity: 0; + } + + 25% { + transform: translate(var(--flame-ember-sx, 0px), var(--flame-ember-sy, 0px)) + scale(1); + opacity: var(--flame-ember-peak, 0.5); + } + + 55% { + transform: translate( + calc( + var(--flame-ember-sx, 0px) + + (var(--flame-ember-ex, 2px) - var(--flame-ember-sx, 0px)) * 0.45 + ), + calc( + var(--flame-ember-sy, 0px) + + (var(--flame-ember-ey, -10px) - var(--flame-ember-sy, 0px)) * 0.45 + ) + ) + scale(0.78); + opacity: calc(var(--flame-ember-peak, 0.5) * 0.7); + } + + 100% { + transform: translate(var(--flame-ember-ex, 2px), var(--flame-ember-ey, -10px)) + scale(0.4); + opacity: 0; + } +} + +@keyframes crown-icon-medal { + 0% { + transform: rotate(0deg) scale(1); + } + + 20% { + transform: rotate(-6deg) scale(1.06); + } + + 44% { + transform: rotate(2.2deg) scale(1.02); + } + + 64% { + transform: rotate(-1deg) scale(1.01); + } + + 100% { + transform: rotate(0deg) scale(1); + } +} + +@keyframes crown-medal-sheen { + 0% { + transform: translate(-170%, -50%) rotate(18deg); + opacity: 0; + } + + 20% { + opacity: 0.5; + } + + 70% { + transform: translate(20%, -50%) rotate(18deg); + opacity: 0.35; + } + + 100% { + transform: translate(65%, -50%) rotate(18deg); + opacity: 0; + } +} + +@keyframes crown-medal-glint { + 0% { + transform: translate(var(--medal-glint-x, 0px), var(--medal-glint-y, 0px)) + scale(0.5); + opacity: 0; + } + + 45% { + transform: translate(var(--medal-glint-x, 0px), var(--medal-glint-y, 0px)) + scale(1.2); + opacity: 0.75; + } + + 100% { + transform: translate(var(--medal-glint-x, 0px), var(--medal-glint-y, 0px)) + scale(0.7); + opacity: 0; + } +} + +@keyframes crown-medal-particle { + 0% { + transform: translate( + var(--medal-particle-sx, 0px), + var(--medal-particle-sy, 0px) + ) + scale(0.5); + opacity: 0; + } + + 35% { + transform: translate( + var(--medal-particle-sx, 0px), + var(--medal-particle-sy, 0px) + ) + scale(1); + opacity: var(--medal-particle-peak, 0.78); + } + + 58% { + transform: translate( + calc( + var(--medal-particle-sx, 0px) + + (var(--medal-particle-ex, 0px) - var(--medal-particle-sx, 0px)) * + 0.5 + ), + calc( + var(--medal-particle-sy, 0px) + + (var(--medal-particle-ey, 0px) - var(--medal-particle-sy, 0px)) * + 0.5 + ) + ) + scale(0.82); + opacity: calc(var(--medal-particle-peak, 0.78) * 0.6); + } + + 100% { + transform: translate(var(--medal-particle-ex, 0px), var(--medal-particle-ey, 0px)) + scale(0.38); + opacity: 0; + } +} + +.crown-hover-most-discussed:hover { + box-shadow: + 0 0 40px color-mix(in srgb, var(--theme-accent-blueCheese-default) 22%, transparent), + inset + 0 + 1px + 0 + color-mix(in srgb, var(--theme-accent-blueCheese-default) 30%, transparent) !important; +} + +.crown-hover-developers-choice:hover { + box-shadow: + 0 0 40px color-mix(in srgb, var(--theme-accent-cheese-default) 22%, transparent), + inset + 0 + 1px + 0 + color-mix(in srgb, var(--theme-accent-cheese-default) 30%, transparent) !important; +} + +.crown-hover-most-controversial:hover { + box-shadow: + 0 0 40px color-mix(in srgb, var(--theme-accent-ketchup-default) 22%, transparent), + inset + 0 + 1px + 0 + color-mix(in srgb, var(--theme-accent-ketchup-default) 30%, transparent) !important; +} + +.crown-hover-fastest-rising:hover { + box-shadow: + 0 0 40px color-mix(in srgb, var(--theme-accent-avocado-default) 22%, transparent), + inset + 0 + 1px + 0 + color-mix(in srgb, var(--theme-accent-avocado-default) 30%, transparent) !important; +} diff --git a/packages/webapp/pages/agents/arena.tsx b/packages/webapp/pages/agents/arena.tsx new file mode 100644 index 0000000000..0fdff74883 --- /dev/null +++ b/packages/webapp/pages/agents/arena.tsx @@ -0,0 +1,76 @@ +import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next'; +import type { ReactElement } from 'react'; +import React from 'react'; +import { useRouter } from 'next/router'; +import type { DehydratedState } from '@tanstack/react-query'; +import { dehydrate, QueryClient } from '@tanstack/react-query'; +import { ArenaPage } from '@dailydotdev/shared/src/features/agents/arena/ArenaPage'; +import type { ArenaTab } from '@dailydotdev/shared/src/features/agents/arena/types'; +import { arenaOptions } from '@dailydotdev/shared/src/features/agents/arena/queries'; +import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; +import { getLayout } from '../../components/layouts/MainLayout'; + +interface ArenaPageRouteProps { + initialTab: ArenaTab; + dehydratedState: DehydratedState; +} + +const ArenaPageRoute = ({ initialTab }: ArenaPageRouteProps): ReactElement => { + const router = useRouter(); + const queryTab = router.query.tab; + let activeTab: ArenaTab = initialTab; + if (queryTab === 'llms') { + activeTab = 'llms'; + } + if (queryTab === 'coding-agents') { + activeTab = 'coding-agents'; + } + + const handleTabChange = (tab: ArenaTab): void => { + const query = tab === 'coding-agents' ? {} : { tab }; + router.replace({ pathname: router.pathname, query }, undefined, { + shallow: true, + }); + }; + + return ; +}; + +export async function getServerSideProps({ + query, + res, +}: GetServerSidePropsContext): Promise< + GetServerSidePropsResult +> { + const initialTab: ArenaTab = query.tab === 'llms' ? 'llms' : 'coding-agents'; + + res.setHeader( + 'Cache-Control', + 'public, s-maxage=60, stale-while-revalidate=120', + ); + + const queryClient = new QueryClient(); + await queryClient.prefetchQuery(arenaOptions({ groupId: initialTab })); + + return { + props: { + initialTab, + dehydratedState: dehydrate(queryClient), + }, + }; +} + +const getArenaLayout: typeof getLayout = (...props) => + getFooterNavBarLayout(getLayout(...props)); + +ArenaPageRoute.getLayout = getArenaLayout; +ArenaPageRoute.layoutProps = { + screenCentered: false, + seo: { + title: 'The Arena - Agents & LLM Leaderboard | daily.dev', + description: + "No benchmarks. No hype. Just developers voting on which AI coding agents and LLMs actually deliver. See who's on top right now.", + }, +}; + +export default ArenaPageRoute;