diff --git a/.agents/skills/react-doctor/SKILL.md b/.agents/skills/react-doctor/SKILL.md new file mode 100644 index 0000000000..22e73a50a5 --- /dev/null +++ b/.agents/skills/react-doctor/SKILL.md @@ -0,0 +1,32 @@ +--- +name: react-doctor +description: Use when finishing a feature, fixing a bug, before committing React code, or when the user wants to improve code quality or clean up a codebase. Checks for score regression. Covers lint, dead code, accessibility, bundle size, architecture diagnostics. +version: '1.0.0' +--- + +# React Doctor + +Scans React codebases for security, performance, correctness, and architecture issues. Outputs a 0–100 health score. + +## After making React code changes: + +Run `npx -y react-doctor@latest . --verbose --diff` and check the score did not regress. + +If the score dropped, fix the regressions before committing. + +## For general cleanup or code improvement: + +Run `npx -y react-doctor@latest . --verbose` (without `--diff`) to scan the full codebase. Fix issues by severity — errors first, then warnings. + +## Command + +```bash +npx -y react-doctor@latest . --verbose --diff +``` + +| Flag | Purpose | +| ----------- | --------------------------------------------- | +| `.` | Scan current directory | +| `--verbose` | Show affected files and line numbers per rule | +| `--diff` | Only scan changed files vs base branch | +| `--score` | Output only the numeric score | diff --git a/apps/web/src/app/(app)/claw/components/ChannelPairingStep.tsx b/apps/web/src/app/(app)/claw/components/ChannelPairingStep.tsx index a78aea4033..c07e0bb34f 100644 --- a/apps/web/src/app/(app)/claw/components/ChannelPairingStep.tsx +++ b/apps/web/src/app/(app)/claw/components/ChannelPairingStep.tsx @@ -46,16 +46,27 @@ export function ChannelPairingStep({ useEffect(() => { let cancelled = false; + let timeoutId: ReturnType | undefined; + + function waitForNextPoll() { + return new Promise(resolve => { + timeoutId = setTimeout(resolve, 1_000); + }); + } + async function poll() { while (!cancelled) { await refreshRef.current().catch(() => {}); if (cancelled) break; - await new Promise(r => setTimeout(r, 1_000)); + await waitForNextPoll(); } } void poll(); return () => { cancelled = true; + if (timeoutId) { + clearTimeout(timeoutId); + } }; }, []); @@ -143,7 +154,7 @@ export function ChannelPairingStepView({ >
- + Pairing request received @@ -175,9 +186,9 @@ export function ChannelPairingStepView({ disabled={isApproving} > {isApproving ? ( - + ) : ( - + )} Authorize this request @@ -187,7 +198,7 @@ export function ChannelPairingStepView({ className="text-muted-foreground/50 hover:text-muted-foreground mx-auto flex cursor-pointer items-center gap-1.5 text-sm transition-colors" onClick={onSkip} > - + Decline @@ -203,7 +214,7 @@ export function ChannelPairingStepView({ contentClassName="gap-8" >
-
+

- Waiting for you to message the bot... + Waiting for you to message the bot…

This page will update automatically.

@@ -247,7 +258,7 @@ export function ChannelPairingStepView({ className="text-muted-foreground/50 cursor-pointer hover:text-muted-foreground text-sm transition-colors my-6" onClick={onSkip} > - Skip — I'll pair later from Settings + Skip. I'll pair later from Settings
diff --git a/apps/web/src/app/(app)/claw/components/KiloClawScheduledActionBanner.tsx b/apps/web/src/app/(app)/claw/components/KiloClawScheduledActionBanner.tsx index cfd5c77b9e..f36c9b5306 100644 --- a/apps/web/src/app/(app)/claw/components/KiloClawScheduledActionBanner.tsx +++ b/apps/web/src/app/(app)/claw/components/KiloClawScheduledActionBanner.tsx @@ -17,6 +17,8 @@ import { CalendarClock } from 'lucide-react'; import { Alert, AlertDescription } from '@/components/ui/alert'; import type { KiloClawScheduledActionStatusBlock } from '@/lib/kiloclaw/types'; +const timezoneNameFormatter = new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' }); + type Props = { scheduledAction: KiloClawScheduledActionStatusBlock | null; /** @@ -47,7 +49,7 @@ function formatScheduledAt(iso: string): string { const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; const dateStr = d.toLocaleString(); - const tzPart = new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' }) + const tzPart = timezoneNameFormatter .formatToParts(d) .find(p => p.type === 'timeZoneName')?.value; return tzPart ? `${dateStr} ${tzPart}` : dateStr; @@ -80,7 +82,7 @@ export function KiloClawScheduledActionBanner({ scheduledAction, instanceName }: return ( - + {isVersionChange ? ( <> diff --git a/apps/web/src/app/(app)/install/page.tsx b/apps/web/src/app/(app)/install/page.tsx index 881e6dbffb..967b10aba9 100644 --- a/apps/web/src/app/(app)/install/page.tsx +++ b/apps/web/src/app/(app)/install/page.tsx @@ -44,10 +44,10 @@ export default function InstallPage() {
-
+
-
- +
+
IDE Extension @@ -57,7 +57,7 @@ export default function InstallPage() { {data?.updated_by_email && ( @@ -143,7 +143,7 @@ function StatsTab() { {stats.totalDomains} {stats.totalDomains === 1 ? 'domain' : 'domains'} - + {stats.totalBlockedUsers.toLocaleString()} blocked users
@@ -152,7 +152,7 @@ function StatsTab() {
{isLoading ? ( -
Loading stats...
+
Loading stats…
) : !stats || stats.domains.length === 0 ? (
No blacklisted domains configured @@ -219,7 +219,7 @@ function SuspiciousTab() { {domains.length} {domains.length === 1 ? 'domain' : 'domains'} - + {blacklistedCount} already blacklisted
@@ -254,12 +254,12 @@ function SuspiciousTab() { {domain.isBlacklisted ? ( - + Blacklisted ) : ( - + Not blacklisted )} diff --git a/apps/web/src/app/admin/components/KiloclawInstances/KiloclawOrphansTab.tsx b/apps/web/src/app/admin/components/KiloclawInstances/KiloclawOrphansTab.tsx index b7de2f41bf..b86a9a87dd 100644 --- a/apps/web/src/app/admin/components/KiloclawInstances/KiloclawOrphansTab.tsx +++ b/apps/web/src/app/admin/components/KiloclawInstances/KiloclawOrphansTab.tsx @@ -84,14 +84,14 @@ function TroubleshootingEventsDialog({
{isLoading && (
- - Loading events... + + Loading events…
)} {error && ( - + {error instanceof Error ? error.message : 'Failed to load Analytics Engine events'} @@ -148,10 +148,12 @@ export function KiloclawOrphansTab() { const trpc = useTRPC(); const queryClient = useQueryClient(); - const [createdAfterInput, setCreatedAfterInput] = useState( + const [createdAfterInput, setCreatedAfterInput] = useState(() => toDatetimeLocalInput(subDays(new Date(), 1)) ); - const [createdBeforeInput, setCreatedBeforeInput] = useState(toDatetimeLocalInput(new Date())); + const [createdBeforeInput, setCreatedBeforeInput] = useState(() => + toDatetimeLocalInput(new Date()) + ); const [scanResult, setScanResult] = useState<{ orphans: OrphanRow[]; scanned: number; @@ -187,11 +189,15 @@ export function KiloclawOrphansTab() { void queryClient.invalidateQueries({ queryKey: trpc.admin.kiloclawInstances.stats.queryKey(), }); - if (scanResult && destroyTarget) { - setScanResult({ - ...scanResult, - orphans: scanResult.orphans.filter(orphan => orphan.id !== destroyTarget.id), - }); + if (destroyTarget) { + setScanResult(prevScanResult => + prevScanResult + ? { + ...prevScanResult, + orphans: prevScanResult.orphans.filter(orphan => orphan.id !== destroyTarget.id), + } + : prevScanResult + ); } }, onError: err => { @@ -237,16 +243,22 @@ export function KiloclawOrphansTab() {
- + setCreatedAfterInput(e.target.value)} />
- + setCreatedBeforeInput(e.target.value)} @@ -255,12 +267,12 @@ export function KiloclawOrphansTab() {
@@ -440,8 +452,8 @@ export function KiloclawOrphansTab() { > {destroyOrphan.isPending ? ( <> - - Destroying... + + Destroying… ) : ( 'Destroy orphan' diff --git a/apps/web/src/app/auth/verify-magic-link/page.tsx b/apps/web/src/app/auth/verify-magic-link/page.tsx index aec7bd17cf..e902d673e6 100644 --- a/apps/web/src/app/auth/verify-magic-link/page.tsx +++ b/apps/web/src/app/auth/verify-magic-link/page.tsx @@ -30,7 +30,7 @@ function VerifyMagicLinkContent() { if (error) { return ( -
+
{error}
@@ -42,10 +42,10 @@ function VerifyMagicLinkContent() { } return ( -
+
-
-

Signing you in...

+
+

Signing you in…

); @@ -55,10 +55,10 @@ export default function VerifyMagicLinkPage() { return ( +
-
-

Loading...

+
+

Loading…

} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 5ab1625faa..a8e28c3617 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -214,6 +214,41 @@ animation: pulse-glow 0.6s ease-out; } + @keyframes typing-dot-pulse { + 0%, + 80%, + 100% { + opacity: 0.35; + transform: translateY(0); + } + 40% { + opacity: 1; + transform: translateY(-2px); + } + } + + @keyframes typing-dot-opacity { + 0%, + 80%, + 100% { + opacity: 0.35; + } + 40% { + opacity: 1; + } + } + + .animate-typing-dot { + animation: typing-dot-pulse 1.2s var(--ease-out-strong) infinite; + } + + @media (prefers-reduced-motion: reduce) { + .animate-typing-dot { + animation-name: typing-dot-opacity; + transform: none; + } + } + @keyframes pulse-opacity { 0%, 100% { diff --git a/apps/web/src/app/vscode-marketplace/page.tsx b/apps/web/src/app/vscode-marketplace/page.tsx index 3b32258fc7..9d795febde 100644 --- a/apps/web/src/app/vscode-marketplace/page.tsx +++ b/apps/web/src/app/vscode-marketplace/page.tsx @@ -14,17 +14,22 @@ export default function VSCodeMarketplaceRedirectPage() { }; // Posthog Bug workaround: posthog.capture() is not working without a timeout - setTimeout(() => { + const captureTimeout = setTimeout(() => { posthog?.capture('vscode_marketplace_redirect', { source: 'vscode-marketplace-page', }); }, 0); // Capture event immediately - setTimeout(() => { + const redirectTimeout = setTimeout(() => { performRedirect(); }, 500); + + return () => { + clearTimeout(captureTimeout); + clearTimeout(redirectTimeout); + }; }, [posthog]); // Optionally, render a fallback/loading state - return
Redirecting...
; + return
Redirecting…
; } diff --git a/apps/web/src/components/AnimatedLogo.tsx b/apps/web/src/components/AnimatedLogo.tsx index 74b66259bb..50266f2ad3 100644 --- a/apps/web/src/components/AnimatedLogo.tsx +++ b/apps/web/src/components/AnimatedLogo.tsx @@ -1,25 +1,23 @@ 'use client'; -import { useState, useRef, useEffect, useMemo } from 'react'; +import { useRef, useMemo } from 'react'; import Image from 'next/image'; import Link from 'next/link'; import { useSession } from 'next-auth/react'; export function AnimatedLogo() { - const [isHovered, setIsHovered] = useState(false); const videoRef = useRef(null); const { status } = useSession(); - useEffect(() => { - if (videoRef.current) { - if (isHovered) { - void videoRef.current.play(); - } else { - videoRef.current.pause(); - videoRef.current.currentTime = 0; // Reset to first frame - } - } - }, [isHovered]); + const playLogoAnimation = () => { + void videoRef.current?.play(); + }; + + const resetLogoAnimation = () => { + if (!videoRef.current) return; + videoRef.current.pause(); + videoRef.current.currentTime = 0; + }; const href = useMemo(() => { if (status === 'authenticated') { @@ -32,8 +30,8 @@ export function AnimatedLogo() { setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} + onMouseEnter={playLogoAnimation} + onMouseLeave={resetLogoAnimation} >