diff --git a/web/components.json b/web/components.json index 5c23ec49..76f1171c 100644 --- a/web/components.json +++ b/web/components.json @@ -21,5 +21,7 @@ }, "menuColor": "default", "menuAccent": "subtle", - "registries": {} + "registries": { + "@notra": "https://ui.usenotra.com/r/{name}.json" + } } diff --git a/web/package-lock.json b/web/package-lock.json index 7e0e0599..d915ed54 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@ai-sdk/react": "^4.0.50", + "@base-ui/react": "^1.8.0", "@fontsource-variable/geist-mono": "^5.2.8", "@fontsource-variable/inter": "^5.2.8", "@posthog/react": "^1.10.3", @@ -19,6 +20,7 @@ "ai": "^7.0.47", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cn": "^0.4.0", "lucide-react": "^1.21.0", "posthog-js": "^1.395.0", "radix-ui": "^1.6.0", @@ -560,6 +562,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -608,6 +619,66 @@ "node": ">=6.9.0" } }, + "node_modules/@base-ui/react": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.8.0.tgz", + "integrity": "sha512-P0/1sxo6SBVZOklKMIedvTWqw2s2IQzi9x5bIVsXu980cuSOD4NeuRSs+/L7LZQfDkZP/uRZyGPyfFl/B1oH+Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "@base-ui/utils": "0.4.0", + "@floating-ui/react-dom": "^2.1.9", + "@floating-ui/utils": "^0.2.12", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@date-fns/tz": "^1.2.0", + "@types/react": "^17 || ^18 || ^19", + "date-fns": "^4.0.0", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@date-fns/tz": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "date-fns": { + "optional": true + } + } + }, + "node_modules/@base-ui/utils": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.4.0.tgz", + "integrity": "sha512-bO9fz25kKtPf+aZVyfQrC0PDmJdmVni31W2hCS5/Owb+inwdIL3XU26pCPRPlt4LSxZrBgLwubXQXQlKaFEZzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "@floating-ui/utils": "^0.2.12", + "reselect": "^5.2.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.75.1", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.75.1.tgz", @@ -1081,31 +1152,31 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.11" + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", - "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.6" + "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", @@ -1113,9 +1184,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, "node_modules/@fontsource-variable/geist-mono": { @@ -4836,6 +4907,18 @@ "node": ">=6" } }, + "node_modules/cn": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cn/-/cn-0.4.0.tgz", + "integrity": "sha512-qOAeUhPwPzBxx6jaeQdo646XIKliKHc2j1ZF7Ngb1o0pJZKH5gq8FkA0aVdUdPFUDXNXyUCXpCtoJISo6dhizg==", + "license": "MIT", + "bin": { + "cn": "bin/cn.mjs" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/code-block-writer": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", @@ -10633,6 +10716,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz", + "integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", diff --git a/web/package.json b/web/package.json index 339b88cb..d80a3581 100644 --- a/web/package.json +++ b/web/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@ai-sdk/react": "^4.0.50", + "@base-ui/react": "^1.8.0", "@fontsource-variable/geist-mono": "^5.2.8", "@fontsource-variable/inter": "^5.2.8", "@posthog/react": "^1.10.3", @@ -29,6 +30,7 @@ "ai": "^7.0.47", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cn": "^0.4.0", "lucide-react": "^1.21.0", "posthog-js": "^1.395.0", "radix-ui": "^1.6.0", diff --git a/web/src/App.tsx b/web/src/App.tsx index f6a284ae..1055de18 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -28,6 +28,7 @@ const Billing = lazy(() => import('./pages/Billing')) const SandboxDetail = lazy(() => import('./pages/SandboxDetail')) const SandboxWebhooks = lazy(() => import('./pages/SandboxWebhooks')) const DeferredAction = lazy(() => import('./pages/DeferredAction')) +const UiDemo = lazy(() => import('./pages/UiDemo')) const ManagedAgentsHome = lazy(() => import('./managed-agents/Home')) const ManagedAgentDetail = lazy(() => import('./managed-agents/Detail')) const ManagedProjectDetail = lazy(() => import('./managed-agents/Project')) @@ -57,6 +58,9 @@ export default function App() { {/* Deferred-action executor — outside ProtectedRoute so the anonymous branch can fire analytics + capture returnTo before login. */} } /> + {/* Component playground — public so the design system can be previewed + without a backend session. */} + } /> {/* Template links are shareable: anonymous visitors see the template and its deploy form, and sign up when they actually deploy. */} }> diff --git a/web/src/components/chat-minimap/components/chat-minimap.tsx b/web/src/components/chat-minimap/components/chat-minimap.tsx new file mode 100644 index 00000000..8d9c95f5 --- /dev/null +++ b/web/src/components/chat-minimap/components/chat-minimap.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { createContext, use } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +import { isChatMinimapPayload } from "../lib/chat-minimap"; +import type { + ChatMinimapItemProps, + ChatMinimapNavButtonProps, + ChatMinimapPayload, + ChatMinimapProps, + ChatMinimapSide, +} from "../types/chat-minimap"; + +const HOVER_OPEN_DELAY_MS = 80; +const HOVER_CLOSE_DELAY_MS = 100; +const ITEM_FALLOFF_CLASS_NAME = + "[&:is(:hover,:focus-visible)_svg]:w-6 [&:has(+[data-slot=chat-minimap-item]:is(:hover,:focus-visible))_svg]:w-5 [[data-slot=chat-minimap-item]:is(:hover,:focus-visible)+&_svg]:w-5 [&:has(+*+[data-slot=chat-minimap-item]:is(:hover,:focus-visible))_svg]:w-4 [[data-slot=chat-minimap-item]:is(:hover,:focus-visible)+*+&_svg]:w-4 focus-visible:text-foreground [&:has(+[data-slot=chat-minimap-item]:is(:hover,:focus-visible))]:text-foreground/75 [[data-slot=chat-minimap-item]:is(:hover,:focus-visible)+&]:text-foreground/75 [&:has(+*+[data-slot=chat-minimap-item]:is(:hover,:focus-visible))]:text-foreground/55 [[data-slot=chat-minimap-item]:is(:hover,:focus-visible)+*+&]:text-foreground/55"; + +const ChatMinimapContext = createContext("right"); + +export const ChatMinimap = ({ + children, + className, + side = "right", + ...props +}: ChatMinimapProps) => ( + + + {({ payload }) => ( + <> + + + {isChatMinimapPayload(payload) ? ( + + + + {payload.title} + + {payload.description ? ( + + {payload.description} + + ) : null} + + + ) : null} + + + )} + + +); + +export const ChatMinimapItem = ({ + active = false, + className, + description, + title, + ...props +}: ChatMinimapItemProps) => { + const side = use(ChatMinimapContext); + const payload: ChatMinimapPayload = { description, title }; + + return ( + + } + > + + {title} + + ); +}; + +export const ChatMinimapNavButton = ({ + className, + direction, + label = direction === "previous" ? "Previous turn" : "Next turn", + ...props +}: ChatMinimapNavButtonProps) => { + const side = use(ChatMinimapContext); + + return ( + + + } + > + + + + {label} + + + ); +}; diff --git a/web/src/components/chat-minimap/hooks/use-chat-minimap-scroll-spy.ts b/web/src/components/chat-minimap/hooks/use-chat-minimap-scroll-spy.ts new file mode 100644 index 00000000..39c27af1 --- /dev/null +++ b/web/src/components/chat-minimap/hooks/use-chat-minimap-scroll-spy.ts @@ -0,0 +1,78 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +type TurnRef = (element: HTMLElement | null) => void; + +export const useChatMinimapScrollSpy = (turnCount: number) => { + const [root, setRoot] = useState(null); + const [visible, setVisible] = useState>(new Set()); + const elements = useRef(new Map()); + const turnRefs = useRef(new Map()); + + useEffect(() => { + if (!root) { + return; + } + const indexes = new Map(); + const inView = new Set(); + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const index = indexes.get(entry.target); + if (index === undefined) { + continue; + } + if (entry.isIntersecting) { + inView.add(index); + } else { + inView.delete(index); + } + } + setVisible(new Set(inView)); + }, + { root } + ); + for (const [index, element] of elements.current) { + if (index < turnCount) { + indexes.set(element, index); + observer.observe(element); + } + } + return () => observer.disconnect(); + }, [root, turnCount]); + + const turnRef = useCallback((index: number) => { + const cached = turnRefs.current.get(index); + if (cached) { + return cached; + } + const ref: TurnRef = (element) => { + if (element) { + elements.current.set(index, element); + } else { + elements.current.delete(index); + } + }; + turnRefs.current.set(index, ref); + return ref; + }, []); + + const scrollToTurn = useCallback( + (index: number) => { + const element = elements.current.get(index); + if (!(root && element)) { + return; + } + root.scrollBy({ + behavior: "smooth", + top: + element.getBoundingClientRect().top - + root.getBoundingClientRect().top, + }); + }, + [root] + ); + + return { rootRef: setRoot, scrollToTurn, turnRef, visible }; +}; diff --git a/web/src/components/chat-minimap/lib/chat-minimap.ts b/web/src/components/chat-minimap/lib/chat-minimap.ts new file mode 100644 index 00000000..6b564339 --- /dev/null +++ b/web/src/components/chat-minimap/lib/chat-minimap.ts @@ -0,0 +1,6 @@ +import type { ChatMinimapPayload } from "../types/chat-minimap"; + +export const isChatMinimapPayload = ( + value: unknown +): value is ChatMinimapPayload => + typeof value === "object" && value !== null && "title" in value; diff --git a/web/src/components/chat-minimap/types/chat-minimap.ts b/web/src/components/chat-minimap/types/chat-minimap.ts new file mode 100644 index 00000000..ee14cd76 --- /dev/null +++ b/web/src/components/chat-minimap/types/chat-minimap.ts @@ -0,0 +1,43 @@ +import type { ComponentProps, ReactNode } from "react"; + +import type { Button } from "@/components/ui/button"; + +export type ChatMinimapSide = "left" | "right"; + +export interface ChatMinimapPayload { + description?: ReactNode; + title: ReactNode; +} + +export interface ChatMinimapProps extends ComponentProps<"nav"> { + /** Which side of the rail the preview card opens on. */ + side?: ChatMinimapSide; +} + +export interface ChatMinimapItemProps extends Omit< + ComponentProps, + "title" +> { + active?: boolean; + description?: ReactNode; + title: ReactNode; +} + +export interface ChatMinimapNavButtonProps extends ComponentProps< + typeof Button +> { + direction: "previous" | "next"; + label?: string; +} + +export interface ChatMinimapDemoTurn { + description: string; + id: string; + title: string; +} + +export interface ChatMinimapExampleProps { + initialStart?: number; + side?: ChatMinimapSide; + turns?: readonly ChatMinimapDemoTurn[]; +} diff --git a/web/src/components/icon-tabs/components/icon-tabs.tsx b/web/src/components/icon-tabs/components/icon-tabs.tsx new file mode 100644 index 00000000..03aac005 --- /dev/null +++ b/web/src/components/icon-tabs/components/icon-tabs.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"; +import { cn } from "@/lib/utils"; +import { useLayoutEffect, useRef } from "react"; + +import { ICON_TABS_INDICATOR_MS } from "../constants/icon-tabs"; +import { + applyIndicatorBox, + easeOutQuint, + measureActiveTab, +} from "../lib/icon-tabs"; +import type { + IconTabsIndicatorBox, + IconTabsIndicatorProps, + IconTabsListProps, + IconTabsSlideInIconProps, + IconTabsTriggerProps, +} from "../types/icon-tabs"; + +function IconTabs({ + className, + ...props +}: Omit) { + return ( + + ); +} + +/** + * Icon that only shows on the active tab: it widens and fades in beside the + * label, so inactive tabs stay text-only. `pinned` keeps it visible anyway, + * e.g. for a live scan spinner. + */ +function SlideInIcon({ children, pinned = false }: IconTabsSlideInIconProps) { + return ( + + ); +} + +/** + * Indicator for triggers whose width animates when they turn active. A CSS + * transition measures its target once, so it lags and overshoots while the + * tabs resize. This one re-measures the active tab every frame and + * interpolates from where it started, so it lands with the resize in one + * motion and never leaves the space between the two tabs. + */ +function IconTabsIndicator({ value }: IconTabsIndicatorProps) { + const ref = useRef(null); + const boxRef = useRef(null); + + useLayoutEffect(() => { + const element = ref.current; + const list = element?.parentElement; + if (!(element && list)) { + return; + } + + const from = boxRef.current; + const animate = + from !== null && + !window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const start = performance.now(); + let frame = 0; + + const tick = (now: number) => { + frame = 0; + const target = measureActiveTab(list); + if (!target) { + // The active tab can be marked a frame after this effect runs. + if (now - start < ICON_TABS_INDICATOR_MS) { + frame = requestAnimationFrame(tick); + } + return; + } + // rAF timestamps mark the frame start and can predate `start`. + const elapsed = Math.max(0, now - start); + const progress = animate + ? Math.min(1, elapsed / ICON_TABS_INDICATOR_MS) + : 1; + const eased = easeOutQuint(progress); + const box = + from && progress < 1 + ? { + left: from.left + (target.left - from.left) * eased, + top: target.top, + width: from.width + (target.width - from.width) * eased, + height: target.height, + } + : target; + boxRef.current = box; + applyIndicatorBox(element, box); + if (progress < 1) { + frame = requestAnimationFrame(tick); + } + }; + tick(start); + + // Keep following the tab once settled, e.g. when a count changes. + const observer = new ResizeObserver(() => { + if (frame !== 0) { + return; + } + const target = measureActiveTab(list); + if (target) { + boxRef.current = target; + applyIndicatorBox(element, target); + } + }); + observer.observe(list); + + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + }; + }, [value]); + + return ( + ) } diff --git a/web/src/components/shimmer/components/shimmer.tsx b/web/src/components/shimmer/components/shimmer.tsx new file mode 100644 index 00000000..38f50007 --- /dev/null +++ b/web/src/components/shimmer/components/shimmer.tsx @@ -0,0 +1,34 @@ +import { cn } from "@/lib/utils"; +import type { CSSProperties } from "react"; + +import type { ShimmerProps } from "../types/shimmer"; + +const SHIMMER_GRADIENT = + "linear-gradient(90deg, transparent calc(50% - var(--shimmer-spread)), var(--shimmer-highlight, var(--color-foreground)) 50%, transparent calc(50% + var(--shimmer-spread))), linear-gradient(currentColor, currentColor)"; + +export const Shimmer = ({ + className, + duration = 1.7, + paused = false, + spread = 4, + style, + ...props +}: ShimmerProps) => ( + +); diff --git a/web/src/components/shimmer/types/shimmer.ts b/web/src/components/shimmer/types/shimmer.ts new file mode 100644 index 00000000..35fac11b --- /dev/null +++ b/web/src/components/shimmer/types/shimmer.ts @@ -0,0 +1,8 @@ +import type { ComponentProps } from "react"; + +export interface ShimmerProps extends ComponentProps<"span"> { + duration?: number; + /** Stops the sweep and shows plain text. */ + paused?: boolean; + spread?: number; +} diff --git a/web/src/components/ui/button-content.tsx b/web/src/components/ui/button-content.tsx new file mode 100644 index 00000000..bf3f82a7 --- /dev/null +++ b/web/src/components/ui/button-content.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { Progress as ProgressPrimitive } from "@base-ui/react/progress"; +import { cn } from "@/lib/utils"; +import { useState } from "react"; + +// Each dot rises in a beat after the label starts leaving, then bounces in turn. +const LOADING_DOTS = [ + { bounce: "[animation-delay:0ms]", enter: "[animation-delay:70ms]" }, + { bounce: "[animation-delay:140ms]", enter: "[animation-delay:110ms]" }, + { bounce: "[animation-delay:280ms]", enter: "[animation-delay:150ms]" }, +] as const; + +/* True from the first `loading` change until the swap back has finished, so + buttons don't animate on mount and stop clipping once they're idle again. */ +function useLoadingSwap(loading: boolean) { + const [previousLoading, setPreviousLoading] = useState(loading); + const [isSwapping, setIsSwapping] = useState(false); + if (loading !== previousLoading) { + setPreviousLoading(loading); + setIsSwapping(true); + } + return { finishSwap: () => setIsSwapping(false), isSwapping }; +} + +/* Clearing `progress` keeps the bar mounted at 100% until it has faded out. */ +function useProgressCompletion(hasProgress: boolean) { + const [previousHasProgress, setPreviousHasProgress] = useState(hasProgress); + const [isCompleting, setIsCompleting] = useState(false); + if (hasProgress !== previousHasProgress) { + setPreviousHasProgress(hasProgress); + setIsCompleting(!hasProgress); + } + return { finishCompletion: () => setIsCompleting(false), isCompleting }; +} + +function ButtonProgress({ + completing, + onFaded, + value, +}: { + completing: boolean; + onFaded: () => void; + value: number; +}) { + return ( + // Decorative: a button's content only feeds its accessible name, so the + // progressbar role (and Base UI's hidden "x") would just garble it. + // `Button` describes the percentage through `aria-describedby` instead. + { + if (event.target === event.currentTarget) { + onFaded(); + } + }} + render={} + value={value} + > + } + > + } + /> + + + ); +} + +function ButtonLoader({ + entering, + loading, + onSwappedOut, +}: { + entering: boolean; + loading: boolean; + onSwappedOut: () => void; +}) { + return ( + { + if (!loading && event.target === event.currentTarget) { + onSwappedOut(); + } + }} + > + {LOADING_DOTS.map(({ bounce, enter }) => ( + + + + ))} + + ); +} + +interface ButtonContentProps { + children?: React.ReactNode; + loading: boolean; + progress: number | undefined; + progressTextId: string; +} + +/* The animated inside of `Button`: the label, the loading dots and the + progress fill. Kept in its own client module so `button.tsx` stays importable + from server components that only call `buttonVariants`. The label is only + wrapped while a state is showing, so idle buttons keep their children as + direct children and `[&>svg]`-style selectors keep working. */ +function ButtonContent({ + children, + loading, + progress, + progressTextId, +}: ButtonContentProps) { + const hasProgress = progress !== undefined; + const { finishSwap, isSwapping } = useLoadingSwap(loading); + const { finishCompletion, isCompleting } = useProgressCompletion(hasProgress); + const showLoader = loading || isSwapping; + const showProgress = hasProgress || isCompleting; + + return ( + <> + {showProgress && ( + + )} + {hasProgress && ( + + )} + {showLoader || showProgress ? ( + + {children} + + ) : ( + children + )} + {showLoader && ( + + )} + + ); +} + +export { ButtonContent }; diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx index d73666a3..7ff3e2a6 100644 --- a/web/src/components/ui/button.tsx +++ b/web/src/components/ui/button.tsx @@ -1,67 +1,124 @@ -import * as React from 'react' -import { cva, type VariantProps } from 'class-variance-authority' -import { Slot } from 'radix-ui' +import { Button as ButtonPrimitive } from "@base-ui/react/button"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; +import { + Children, + isValidElement, + useId, + type ReactElement, + type ReactNode, +} from "react"; -import { cn } from '@/lib/utils' +import { ButtonContent } from "@/components/ui/button-content"; const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/button focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 relative inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:ring-3 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: - 'bg-primary text-primary-foreground hover:bg-primary/80 disabled:border-border disabled:bg-muted disabled:text-muted-foreground disabled:opacity-100', + "border-primary/60 text-primary-foreground bg-linear-to-b from-[color-mix(in_oklab,var(--color-primary),black_8%)] to-[color-mix(in_oklab,var(--color-primary),black_20%)] shadow-[inset_0_1px_0_rgba(255,255,255,0.28),0_1px_2px_rgba(0,0,0,0.18)] [corner-shape:squircle] hover:brightness-110 supports-[corner-shape:squircle]:rounded-[0.75rem]", outline: - 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', + "border-border from-background to-muted text-foreground aria-expanded:from-secondary aria-expanded:to-secondary dark:from-input dark:to-muted bg-linear-to-b shadow-[inset_0_1px_0_rgba(255,255,255,0.7),0_1px_2px_rgba(0,0,0,0.06)] [corner-shape:squircle] hover:brightness-[0.97] supports-[corner-shape:squircle]:rounded-[0.75rem] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.06),0_1px_2px_rgba(0,0,0,0.4)] dark:hover:brightness-125", secondary: - 'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground', + "border-foreground/60 from-foreground/85 to-foreground text-background hover:from-foreground/70 hover:to-foreground/85 bg-linear-to-b shadow-[inset_0_1px_0_rgba(255,255,255,0.28),0_1px_2px_rgba(0,0,0,0.18)] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[0.75rem]", ghost: - 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50', + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 bg-clip-padding", destructive: - 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40', - link: 'text-primary underline-offset-4 hover:underline', + "border-destructive/60 from-destructive focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 bg-linear-to-b to-[color-mix(in_oklab,var(--color-destructive),black_12%)] text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.28),0_1px_2px_rgba(0,0,0,0.18)] [corner-shape:squircle] hover:brightness-110 supports-[corner-shape:squircle]:rounded-[0.75rem] dark:from-[color-mix(in_oklab,var(--color-destructive),black_20%)] dark:to-[color-mix(in_oklab,var(--color-destructive),black_30%)]", + link: "text-foreground underline-offset-4 hover:underline", }, size: { default: - 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', - xs: "h-6 gap-1 rounded-sm px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-7 gap-1 rounded-sm px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', - icon: 'size-8', - 'icon-xs': - "size-6 rounded-sm in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", - 'icon-sm': 'size-7 rounded-sm in-data-[slot=button-group]:rounded-lg', - 'icon-lg': 'size-9', + "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-8", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-9", }, }, defaultVariants: { - variant: 'default', - size: 'default', + variant: "default", + size: "default", }, - }, -) + } +); + +type ButtonProps = ButtonPrimitive.Props & + VariantProps & { + /** Swaps the label for bouncing dots while an action runs. */ + loading?: boolean; + /** + * Fills the button from the left, from 0 to 100, e.g. while a file + * uploads. Set it back to `undefined` when the work is done: the bar fills + * up and fades out. For work you can't measure, use `loading`. + */ + progress?: number; + /** Render the single child element (e.g. a router Link) with button + styles. Maps to Base UI's `render` prop. */ + asChild?: boolean; + }; function Button({ className, - variant = 'default', - size = 'default', + variant = "default", + size = "default", + loading = false, + progress, + disabled, + focusableWhenDisabled, + "aria-describedby": ariaDescribedBy, + children, asChild = false, ...props -}: React.ComponentProps<'button'> & - VariantProps & { - asChild?: boolean - }) { - const Comp = asChild ? Slot.Root : 'button' +}: ButtonProps) { + const hasProgress = progress !== undefined; + const busy = loading || hasProgress; + const progressTextId = useId(); + + // asChild compat: the child element becomes the rendered host (Base UI + // merges our props into it) and the child's own children stay the label. + const slotChild = + asChild && isValidElement(children) + ? (Children.only(children) as ReactElement<{ children?: ReactNode }>) + : null; return ( - - ) + > + + {slotChild ? slotChild.props.children : children} + + + ); } -export { Button, buttonVariants } +export { Button, buttonVariants }; +export type { ButtonProps }; diff --git a/web/src/components/ui/card.tsx b/web/src/components/ui/card.tsx new file mode 100644 index 00000000..0ed575f7 --- /dev/null +++ b/web/src/components/ui/card.tsx @@ -0,0 +1,102 @@ +import { cn } from "@/lib/utils"; +import * as React from "react"; + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className + )} + {...props} + /> + ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +}; diff --git a/web/src/components/ui/checkbox.tsx b/web/src/components/ui/checkbox.tsx index 44cc6ab9..c5c642ed 100644 --- a/web/src/components/ui/checkbox.tsx +++ b/web/src/components/ui/checkbox.tsx @@ -1,8 +1,7 @@ -import * as React from 'react' -import { Checkbox as CheckboxPrimitive } from 'radix-ui' - -import { cn } from '@/lib/utils' -import { CheckIcon } from 'lucide-react' +import * as React from "react" +import { cn } from "@/lib/utils" +import { Checkbox as CheckboxPrimitive } from "radix-ui" +import { CheckIcon } from "lucide-react" function Checkbox({ className, @@ -12,8 +11,8 @@ function Checkbox({ @@ -21,7 +20,8 @@ function Checkbox({ data-slot="checkbox-indicator" className="grid place-content-center text-current transition-none [&>svg]:size-3.5" > - + ) diff --git a/web/src/components/ui/hover-card.tsx b/web/src/components/ui/hover-card.tsx new file mode 100644 index 00000000..de7fc275 --- /dev/null +++ b/web/src/components/ui/hover-card.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"; +import { cn } from "@/lib/utils"; + +function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) { + return ; +} + +function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) { + return ( + + ); +} + +function HoverCardContent({ + className, + side = "bottom", + sideOffset = 4, + align = "center", + alignOffset = 4, + ...props +}: PreviewCardPrimitive.Popup.Props & + Pick< + PreviewCardPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + + + ); +} + +export { HoverCard, HoverCardTrigger, HoverCardContent }; diff --git a/web/src/components/ui/skeleton.tsx b/web/src/components/ui/skeleton.tsx index 6756d1d2..0118624f 100644 --- a/web/src/components/ui/skeleton.tsx +++ b/web/src/components/ui/skeleton.tsx @@ -1,10 +1,10 @@ -import { cn } from '@/lib/utils' +import { cn } from "@/lib/utils" -function Skeleton({ className, ...props }: React.ComponentProps<'div'>) { +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { return (
) diff --git a/web/src/components/ui/sonner.tsx b/web/src/components/ui/sonner.tsx index 2dc68aa5..505b68bc 100644 --- a/web/src/components/ui/sonner.tsx +++ b/web/src/components/ui/sonner.tsx @@ -1,42 +1,79 @@ -import { Toaster as Sonner, type ToasterProps } from 'sonner' +"use client"; + +import { cn } from "@/lib/utils"; import { CircleCheckIcon, InfoIcon, - TriangleAlertIcon, - OctagonXIcon, Loader2Icon, -} from 'lucide-react' -import { useTheme } from '@/hooks/useTheme' + OctagonXIcon, + TriangleAlertIcon, + XIcon, +} from "lucide-react"; +import { useTheme } from "@/hooks/useTheme"; +import { Toaster as Sonner, type ToasterProps } from "sonner"; + +import { buttonVariants } from "@/components/ui/button"; + +// Status colours fall back to Notra's values when the project has no +// --success, --warning or --info tokens of its own. +const statusTokens = cn( + "[--toast-info:var(--info,oklch(0.55_0.13_250))] [--toast-success:var(--success,oklch(0.55_0.109_155))] [--toast-warning:var(--warning,oklch(0.57_0.113_55))]", + "dark:[--toast-info:var(--info,oklch(0.7_0.12_250))] dark:[--toast-success:var(--success,oklch(0.7_0.125_155))] dark:[--toast-warning:var(--warning,oklch(0.7_0.125_55))]" +); + +// The Depth surface of the outline button: squircle corners, a soft vertical +// gradient, an inset top highlight and a layered drop shadow. +const toastClassName = cn( + "group/toast border-border bg-background from-background to-muted/60 text-popover-foreground flex w-(--width) items-center gap-2.5 rounded-[0.875rem] border bg-linear-to-b p-3 text-sm [corner-shape:squircle]", + "shadow-[inset_0_1px_0_rgba(255,255,255,0.7),0_1px_2px_rgba(0,0,0,0.06),0_8px_24px_-8px_rgba(0,0,0,0.14)]", + "dark:border-input dark:bg-muted dark:from-input dark:to-muted dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.06),0_1px_2px_rgba(0,0,0,0.4),0_8px_24px_-8px_rgba(0,0,0,0.6)]", + // Sonner only hides the content of stacked toasts when it styles them itself. + "*:transition-opacity data-[expanded=false]:data-[front=false]:*:opacity-0" +); + +const iconClassName = cn( + "bg-muted text-muted-foreground relative flex size-6 shrink-0 items-center justify-center rounded-lg [corner-shape:squircle] [&_svg]:size-3.5", + "group-data-[type=success]/toast:bg-(--toast-success)/12 group-data-[type=success]/toast:text-(--toast-success)", + "group-data-[type=error]/toast:bg-destructive/12 group-data-[type=error]/toast:text-destructive", + "group-data-[type=warning]/toast:bg-(--toast-warning)/12 group-data-[type=warning]/toast:text-(--toast-warning)", + "group-data-[type=info]/toast:bg-(--toast-info)/12 group-data-[type=info]/toast:text-(--toast-info)" +); + +const Toaster = ({ className, ...props }: ToasterProps) => { + const { dark } = useTheme(); -const Toaster = ({ theme, ...props }: ToasterProps) => { - const { dark } = useTheme() return ( , - info: , - warning: , - error: , - loading: , + success: , + info: , + warning: , + error: , + loading: , + close: , }} - style={ - { - '--normal-bg': 'var(--popover)', - '--normal-text': 'var(--popover-foreground)', - '--normal-border': 'var(--border)', - '--border-radius': 'var(--radius)', - } as React.CSSProperties - } + theme={dark ? "dark" : "light"} toastOptions={{ + unstyled: true, classNames: { - toast: 'cn-toast', + toast: toastClassName, + icon: iconClassName, + content: "flex min-w-0 flex-1 flex-col gap-0.5", + title: "font-medium leading-snug", + description: "text-[0.8rem] text-muted-foreground leading-snug", + actionButton: buttonVariants({ size: "xs" }), + cancelButton: buttonVariants({ size: "xs", variant: "ghost" }), + closeButton: cn( + buttonVariants({ size: "icon-xs", variant: "outline" }), + "absolute -top-2 -left-2 size-5 rounded-full opacity-0 transition-opacity group-focus-within/toast:opacity-100 group-hover/toast:opacity-100 [&_svg]:size-2.5" + ), }, }} {...props} /> - ) -} + ); +}; -export { Toaster } +export { Toaster }; diff --git a/web/src/components/ui/table.tsx b/web/src/components/ui/table.tsx index 96b63245..738b6656 100644 --- a/web/src/components/ui/table.tsx +++ b/web/src/components/ui/table.tsx @@ -1,105 +1,110 @@ -import * as React from 'react' +"use client"; -import { cn } from '@/lib/utils' +import { cn } from "@/lib/utils"; +import type * as React from "react"; -function Table({ className, ...props }: React.ComponentProps<'table'>) { +/** + * Dualtone chrome for a single ``: the header sits on the grey shell, + * the body cells form the lifted white card. `border-separate` is required + * because row borders and radii only render on cells in that model. + */ +const TABLE_CARD_CELLS_CLASS = [ + "[&>tbody>tr>td]:bg-background [&>tbody>tr>td]:border-border/60 [&>tbody>tr>td]:border-b [&>tbody>tr>td]:transition-colors", + "[&>tbody>tr:hover>td]:bg-[color-mix(in_oklab,var(--muted)_50%,var(--background))]", + "[&>tbody>tr:first-child>td]:border-t-border [&>tbody>tr:first-child>td]:border-t", + "[&>tbody>tr:last-child>td]:border-b-border", + "[&>tbody>tr>td:first-child]:border-l-border [&>tbody>tr>td:first-child]:border-l", + "[&>tbody>tr>td:last-child]:border-r-border [&>tbody>tr>td:last-child]:border-r", + "[&>tbody>tr:first-child>td:first-child]:rounded-tl-[14px] [&>tbody>tr:first-child>td:last-child]:rounded-tr-[14px]", + "[&>tbody>tr:last-child>td:first-child]:rounded-bl-[14px] [&>tbody>tr:last-child>td:last-child]:rounded-br-[14px]", +].join(" "); + +function Table({ className, ...props }: React.ComponentProps<"table">) { return (
-
+
+
+ - ) + ); } -function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) { - return ( - - ) +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ; } -function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) { - return ( - - ) +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ; } -function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) { +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { return ( tr]:last:border-b-0', - className, - )} {...props} /> - ) + ); } -function TableRow({ className, ...props }: React.ComponentProps<'tr'>) { +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { return ( - ) + ); } -function TableHead({ className, ...props }: React.ComponentProps<'th'>) { +function TableHead({ className, ...props }: React.ComponentProps<"th">) { return (
- ) + ); } -function TableCell({ className, ...props }: React.ComponentProps<'td'>) { +function TableCell({ className, ...props }: React.ComponentProps<"td">) { return ( - ) + ); } function TableCaption({ className, ...props -}: React.ComponentProps<'caption'>) { +}: React.ComponentProps<"caption">) { return (
- ) + ); } export { @@ -111,4 +116,4 @@ export { TableRow, TableCell, TableCaption, -} +}; diff --git a/web/src/components/ui/tooltip.tsx b/web/src/components/ui/tooltip.tsx index c6f5cefc..d63868cf 100644 --- a/web/src/components/ui/tooltip.tsx +++ b/web/src/components/ui/tooltip.tsx @@ -1,57 +1,285 @@ -'use client' +"use client"; -import * as React from 'react' -import { Tooltip as TooltipPrimitive } from 'radix-ui' +import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; +import { cn } from "@/lib/utils"; +import { + createContext, + type Dispatch, + type ReactNode, + type SetStateAction, + use, + useLayoutEffect, + useMemo, + useState, +} from "react"; -import { cn } from '@/lib/utils' +type TooltipPlacement = Pick< + TooltipPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" +>; + +type TooltipContentProps = TooltipPrimitive.Popup.Props & TooltipPlacement; + +type SharedPopupProps = Omit< + TooltipPrimitive.Popup.Props, + "children" | "className" +>; + +interface SharedTooltipPayload extends TooltipPlacement { + children: ReactNode; + className?: string; + popupProps?: SharedPopupProps; +} + +interface SharedTooltipItem { + payload: SharedTooltipPayload | undefined; + setPayload: Dispatch>; +} + +const tooltipContentClassName = + "border-foreground/60 to-foreground text-background z-50 block w-fit max-w-xs origin-(--transform-origin) rounded-[0.625rem] border bg-linear-to-b from-[color-mix(in_oklab,var(--color-foreground),var(--color-background)_10%)] px-2.5 py-1 text-xs shadow-[inset_0_1px_0_rgba(255,255,255,0.22),0_1px_2px_rgba(0,0,0,0.16),0_6px_16px_-4px_rgba(0,0,0,0.24)] transition-[opacity,scale,translate] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] [corner-shape:squircle] has-[[data-slot=kbd]]:pr-1 data-ending-style:scale-[0.97] data-ending-style:opacity-0 data-ending-style:duration-100 data-starting-style:scale-[0.97] data-starting-style:opacity-0 data-[side=bottom]:data-starting-style:-translate-y-0.5 data-[side=left]:data-starting-style:translate-x-0.5 data-[side=right]:data-starting-style:-translate-x-0.5 data-[side=top]:data-starting-style:translate-y-0.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm **:data-[slot=kbd]:align-middle **:data-[slot=kbd-group]:ms-1.5 **:data-[slot=kbd-group]:align-middle *:data-[slot=kbd]:ms-1.5 [--tooltip-arrow:var(--color-foreground)] **:[--color-border:color-mix(in_oklab,var(--color-background)_18%,transparent)] **:[--color-foreground:var(--color-background)] **:[--color-muted-foreground:color-mix(in_oklab,var(--color-background)_65%,transparent)] motion-reduce:transition-opacity"; + +const TOOLTIP_ARROW_CLASS_NAME = + "bg-(--tooltip-arrow) fill-(--tooltip-arrow) -z-10 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5"; + +// Inside a TooltipProvider every tooltip shares one popup, so moving between +// neighbours glides it over and resizes it instead of closing and reopening. +const SHARED_POSITIONER_CLASS_NAME = + "h-(--positioner-height) w-(--positioner-width) transition-[top,left,right,bottom] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none"; +const SHARED_POPUP_CLASS_NAME = + "h-(--popup-height) w-(--popup-width) max-w-none p-0 has-[[data-slot=kbd]]:pr-0 transition-[opacity,scale,translate,width,height] motion-reduce:transition-opacity"; +const SHARED_VIEWPORT_CLASS_NAME = + "relative m-0! overflow-hidden rounded-[inherit] [&>[data-previous]]:hidden"; +// The label keeps its own natural width, so it never reflows while the popup +// resizes around it. +const SHARED_CONTENT_CLASS_NAME = + "w-max max-w-xs px-2.5 py-1 has-[[data-slot=kbd]]:pr-1"; +const SHARED_ARROW_CLASS_NAME = + "transition-[left,top] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none"; + +const SharedTooltipContext = + createContext | null>(null); +const SharedTooltipItemContext = createContext(null); + +function TooltipFrame({ + align = "center", + alignOffset = 0, + children, + className, + contentClassName, + shared = false, + side = "top", + sideOffset = 4, + ...props +}: TooltipContentProps & { contentClassName?: string; shared?: boolean }) { + return ( + + + + {shared ? ( + +
+ {children} +
+
+ ) : ( + children + )} + +
+
+
+ ); +} function TooltipProvider({ - delayDuration = 0, + children, + delay = 0, ...props -}: React.ComponentProps) { +}: TooltipPrimitive.Provider.Props) { + // The shared popup only carries labels, so it closes as soon as the pointer + // leaves the trigger (disableHoverablePopup on the shared Root). + const [handle] = useState(() => + TooltipPrimitive.createHandle() + ); + return ( - ) + > + + {children} + + {({ payload }) => { + const { + children: content, + className: contentClassName, + popupProps, + ...frameProps + } = payload ?? {}; + return ( + + {content} + + ); + }} + + + + ); } -function Tooltip({ - ...props -}: React.ComponentProps) { - return +function Tooltip({ ...props }: TooltipPrimitive.Root.Props) { + const handle = use(SharedTooltipContext); + const [payload, setPayload] = useState(); + const item = useMemo(() => ({ payload, setPayload }), [payload]); + + if (handle) { + return ( + + {props.children as ReactNode} + + ); + } + + return ; } -function TooltipTrigger({ - ...props -}: React.ComponentProps) { - return +function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) { + const handle = use(SharedTooltipContext); + const item = use(SharedTooltipItemContext); + + if (handle && item) { + return ( + + ); + } + + return ; +} + +const isSamePopupProps = ( + a: SharedPopupProps | undefined, + b: SharedPopupProps | undefined +) => { + const aKeys = Object.keys(a ?? {}) as (keyof SharedPopupProps)[]; + const bKeys = Object.keys(b ?? {}); + return ( + aKeys.length === bKeys.length && aKeys.every((key) => a?.[key] === b?.[key]) + ); +}; + +const isSamePayload = ( + a: SharedTooltipPayload | undefined, + b: SharedTooltipPayload +) => + a !== undefined && + a.align === b.align && + a.alignOffset === b.alignOffset && + a.children === b.children && + a.className === b.className && + a.side === b.side && + a.sideOffset === b.sideOffset && + isSamePopupProps(a.popupProps, b.popupProps); + +function SharedTooltipContent({ + hidden, + setPayload, + ...payload +}: SharedTooltipPayload & { + hidden?: boolean; + setPayload: SharedTooltipItem["setPayload"]; +}) { + useLayoutEffect(() => { + if (hidden) { + setPayload(undefined); + return; + } + // Only store a new payload when something changed, so re-renders of the + // item don't loop back into another state update. + setPayload((current) => + isSamePayload(current, payload) ? current : payload + ); + }); + + useLayoutEffect(() => () => setPayload(undefined), [setPayload]); + + return null; } function TooltipContent({ - className, - sideOffset = 0, children, + className, ...props -}: React.ComponentProps) { - return ( - - - - ) + + ); + } + + return ( + + {children} + + ); } -export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } +export { + Tooltip, + TooltipTrigger, + TooltipContent, + TooltipProvider, + tooltipContentClassName, +}; diff --git a/web/src/index.css b/web/src/index.css index ef2e3792..a372b424 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -114,6 +114,61 @@ --radius-md: var(--radius); --radius-lg: calc(var(--radius) + 2px); --radius-xl: calc(var(--radius) + 4px); + --animate-button-swap-in: button-swap-in 280ms cubic-bezier(0.23, 1, 0.32, 1) both; + --animate-button-swap-out: button-swap-out 360ms cubic-bezier(0.45, 0, 0.25, 1) both; + --animate-button-dot: button-dot 1s ease-in-out infinite; + --animate-button-progress-out: button-progress-out 600ms ease-out both; + --animate-shimmer: shimmer var(--shimmer-duration, 1.7s) linear infinite; + @keyframes button-swap-in { + from { + opacity: 0; + transform: translateY(0.45em); + filter: blur(4px); + } + to { + opacity: 1; + transform: translateY(0); + filter: blur(0); + } + } + @keyframes button-swap-out { + from { + opacity: 1; + transform: translateY(0); + filter: blur(0); + } + to { + opacity: 0; + transform: translateY(-0.45em); + filter: blur(4px); + } + } + @keyframes button-dot { + 0%, 60%, 100% { + opacity: 0.45; + transform: translateY(0); + } + 30% { + opacity: 1; + transform: translateY(-0.3em); + } + } + @keyframes button-progress-out { + 0%, 50% { + opacity: 1; + } + 100% { + opacity: 0; + } + } + @keyframes shimmer { + from { + background-position: 100% 0; + } + to { + background-position: 0% 0; + } + } } /* Non-inline so the overlay shadow resolves through var(--shadow-overlay) at @@ -313,4 +368,4 @@ body { @apply bg-background text-foreground font-sans antialiased; } -} +} \ No newline at end of file diff --git a/web/src/managed-agents/GitHub.tsx b/web/src/managed-agents/GitHub.tsx index e403c0ba..0ad17dca 100644 --- a/web/src/managed-agents/GitHub.tsx +++ b/web/src/managed-agents/GitHub.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { GitBranch, Loader2, Unplug } from 'lucide-react' +import { AlertTriangle, GitBranch, Loader2, Unplug } from 'lucide-react' import { EmptyState } from '@/components/empty-state' import { GithubMark } from '@/components/github-mark' import { @@ -139,6 +139,40 @@ export function ManagedProjectGitHub({ Disconnect + ) : availableConnections.length === 0 ? ( + { + setAddingConnection(true) + void launchAuthorizationWindow(() => + connectManagedGitHub({ + projectId, + environments: [environment], + }).then((connection) => connection.installUrl), + ) + .catch((error: unknown) => + notifyError( + "Couldn't start the GitHub installation.", + error, + ), + ) + .finally(() => setAddingConnection(false)) + }} + > + {addingConnection ? ( + + ) : ( + + )} + Add GitHub connection + + } + /> ) : (

@@ -208,7 +242,8 @@ export function ManagedProjectGitHub({

)} {!status.data?.app ? ( -

+

+ The managed GitHub App is not configured in this environment.

) : null} diff --git a/web/src/managed-agents/Session.tsx b/web/src/managed-agents/Session.tsx index de4a8162..37274ccb 100644 --- a/web/src/managed-agents/Session.tsx +++ b/web/src/managed-agents/Session.tsx @@ -1,11 +1,13 @@ -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { ArrowLeft, Bot, Clock3, FolderDown, + ListTree, Loader2, + MessagesSquare, TerminalSquare, } from 'lucide-react' import { Link, useParams, useSearchParams } from 'react-router-dom' @@ -20,6 +22,16 @@ import { PanelTitle, } from '@/components/panel' import { StatusBadge } from '@/components/status-badge' +import { + ChatMinimap, + ChatMinimapItem, +} from '@/components/chat-minimap/components/chat-minimap' +import { + IconTabs, + IconTabsList, + IconTabsTrigger, +} from '@/components/icon-tabs/components/icon-tabs' +import { Shimmer } from '@/components/shimmer/components/shimmer' import { Button } from '@/components/ui/button' import { getManagedAgentSession, @@ -75,6 +87,36 @@ export default function ManagedSessionDetail() { refetchInterval: 1_000, }) + // Scroll-spy for the conversation minimap: the page scrolls, so the + // observer uses the viewport and the topmost on-screen turn wins. The + // effect depends on activeTab so it re-observes the remounted turn + // elements after leaving and returning to the Conversation tab. + const turnEls = useRef<(HTMLElement | null)[]>([]) + const visibleTurns = useRef>(new Set()) + const [activeTurn, setActiveTurn] = useState(0) + const turnCount = session.data?.turns.length ?? 0 + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const index = turnEls.current.indexOf(entry.target as HTMLElement) + if (index < 0) continue + if (entry.isIntersecting) visibleTurns.current.add(index) + else visibleTurns.current.delete(index) + } + if (visibleTurns.current.size > 0) { + setActiveTurn(Math.min(...visibleTurns.current)) + } + }, + { rootMargin: '-15% 0px -65% 0px' }, + ) + visibleTurns.current.clear() + for (const el of turnEls.current) { + if (el) observer.observe(el) + } + return () => observer.disconnect() + }, [turnCount, activeTab]) + if (project.isLoading || session.isLoading) { return (
@@ -199,42 +241,31 @@ export default function ManagedSessionDetail() { ) : null} -
+ setActiveTab(value as 'conversation' | 'events' | 'files') + } + className="w-fit" > - - - -
+ + } + value="conversation" + > + Conversation + + } value="events"> + Events + + {events.data?.length ?? 0} + + + } value="files"> + Files + + + {activeTab === 'conversation' ? ( @@ -246,11 +277,13 @@ export default function ManagedSessionDetail() {
- + {session.data.turns.length === 0 ? (

No turns yet.

) : ( - session.data.turns.map((turn) => { +
+
+ {session.data.turns.map((turn, turnIndex) => { const responseText = turnAssistantText( events.data ?? [], turn.id, @@ -262,7 +295,13 @@ export default function ManagedSessionDetail() { const payload = turnPayload(events.data ?? [], turn.id) const running = !['completed', 'failed'].includes(turn.status) return ( -
+
{ + turnEls.current[turnIndex] = el + }} + className="scroll-mt-24 space-y-6" + >

You @@ -294,7 +333,7 @@ export default function ManagedSessionDetail() { className="size-3 animate-spin" aria-hidden /> - Streaming response… + Streaming response…

) : null}
@@ -313,7 +352,29 @@ export default function ManagedSessionDetail() {
) - }) + })} +
+ + {session.data.turns.map((turn, turnIndex) => ( + { + setActiveTurn(turnIndex) + turnEls.current[turnIndex]?.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }) + }} + /> + ))} + +
)}
diff --git a/web/src/managed-agents/Settings.tsx b/web/src/managed-agents/Settings.tsx index 34ac6b12..6de35243 100644 --- a/web/src/managed-agents/Settings.tsx +++ b/web/src/managed-agents/Settings.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link } from 'react-router-dom' import { + AlertTriangle, ExternalLink, GitBranch, GitPullRequest, @@ -342,40 +343,41 @@ export function ManagedProjectSettings({ projectId }: { projectId: string }) { ) : activeConnections.length === 0 ? ( -
-

- Install the OpenComputer GitHub App on the repository you want - to deploy from. -

- -
+ .then(() => + queryClient.invalidateQueries({ + queryKey: ['managed-github-connections'], + }), + ) + .catch((error: unknown) => + notifyError( + "Couldn't start the GitHub installation.", + error, + ), + ) + .finally(() => setAddingConnection(false)) + }} + > + {addingConnection ? ( + + ) : ( + + )} + Add GitHub connection + + } + /> ) : (
)} {connections.data && !connections.data.app ? ( -

+

+ The managed GitHub App is not configured in this environment.

) : null} diff --git a/web/src/pages/UiDemo.tsx b/web/src/pages/UiDemo.tsx new file mode 100644 index 00000000..ebf5676d --- /dev/null +++ b/web/src/pages/UiDemo.tsx @@ -0,0 +1,306 @@ +import { useEffect, useRef, useState } from 'react' +import { + FolderDown, + ListTree, + Loader2, + MessagesSquare, +} from 'lucide-react' +import { toast } from 'sonner' +import { + ChatMinimap, + ChatMinimapItem, +} from '@/components/chat-minimap/components/chat-minimap' +import { + IconTabs, + IconTabsList, + IconTabsTrigger, +} from '@/components/icon-tabs/components/icon-tabs' +import { Shimmer } from '@/components/shimmer/components/shimmer' +import { ThemeToggle } from '@/components/theme-toggle' +import { Button } from '@/components/ui/button' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' + +function Section({ + title, + children, +}: { + title: string + children: React.ReactNode +}) { + return ( +
+

+ {title} +

+ {children} +
+ ) +} + +const DEMO_TURNS = [ + { + input: 'Summarize the last five deployments for the web agent.', + reply: + 'All five deployments succeeded. The last three shipped the new retry policy; deploy 41 and 42 only changed environment variables. Median build time was 48s.', + status: 'completed', + }, + { + input: 'Draft a changelog entry for the retry policy rollout.', + reply: + 'Here is a draft under `changelog/2026-10-retry-policy.md`: transient tool-sandbox launches now retry with backoff instead of failing the session, and the worker long-polls sandbox status instead of ticking once a second.', + status: 'completed', + }, + { + input: 'Post it to the docs site.', + reply: + 'Opened a pull request against the docs repo with the new entry and a link from the changelog index. CI is green.', + status: 'completed', + }, + { + input: 'While that merges, watch the error rate on deploys.', + reply: + 'Watching the deploy error rate now. Baseline over the last hour is 0.4% — I will flag anything above 1%.', + status: 'completed', + }, + { + input: 'One more: cancel the scheduled Sunday cleanup job.', + reply: + 'Checking the schedule configuration and removing the Sunday cleanup cron.', + status: 'running', + }, +] + +// Mirrors the Session detail page wiring: a scroll-spy over the viewport marks +// the topmost visible turn, and clicking a rail line scrolls to it. +function ConversationDemo() { + const turnEls = useRef<(HTMLElement | null)[]>([]) + const [activeTurn, setActiveTurn] = useState(0) + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue + const index = turnEls.current.indexOf(entry.target as HTMLElement) + if (index >= 0) setActiveTurn(index) + } + }, + { rootMargin: '-15% 0px -65% 0px' }, + ) + for (const el of turnEls.current) { + if (el) observer.observe(el) + } + return () => observer.disconnect() + }, []) + + return ( +
+
+ {DEMO_TURNS.map((turn, index) => ( +
{ + turnEls.current[index] = el + }} + className="scroll-mt-24 space-y-6" + > +
+

+ You +

+

+ {turn.input} +

+
+
+

+ Agent +

+

+ {turn.reply} +

+ {turn.status === 'running' ? ( +

+ + Streaming response… +

+ ) : null} +
+
+ ))} +
+ + {DEMO_TURNS.map((turn, index) => ( + { + setActiveTurn(index) + turnEls.current[index]?.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }) + }} + /> + ))} + +
+ ) +} + +export default function UiDemo() { + const [tab, setTab] = useState('conversation') + const [saving, setSaving] = useState(false) + const [progress, setProgress] = useState(undefined) + + return ( + +
+
+
+

Notra UI components

+

+ Live swap-in on the OpenComputer dashboard. +

+
+ +
+ +
+
+ + + + + + + + +
+
+ +
+ + + } + value="conversation" + > + Conversation + + } value="events"> + Events12 + + } value="files"> + Files + + + +

Active tab: {tab}

+
+ +
+
+ + Hover me} + /> + Depth surface tooltip + + + And me} + /> + + The popup glides between triggers + + +
+
+ +
+

+ Working +

+
+ +
+
+ + + + +
+
+ +
+ +
+
+
+ ) +}