diff --git a/AGENTS.md b/AGENTS.md index 723e8fb..da8a6db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,10 +17,10 @@ Prefer `typecheck` + `lint` for quick verification. Do not start the dev server ## Layout -- `components/motion/` — the library. One file per component; multi-file widgets get a folder (`swap/`, `button/`). -- `components/previews/` — demo per component, registered in `components/previews/index.tsx`. Previews ship through the registry too. +- `components/motion/` and `components/agents/` — the library. One file per component; multi-file widgets get a folder (`swap/`, `button/`, `loading-states/`). +- `components/previews/` — demo per component, registered in `components/previews/index.tsx`. Previews ship through the registry too. When an interactive preview uses internal demo helpers, set the registry entry's `usageFile` to a separate public composition so the Usage tab stays copyable. - `components/app/` — site chrome (header, hero, dock, code blocks). Not part of the library. -- `lib/registry.ts` — component catalog (slugs, files, examples). Two categories: `motion` (display name "Components", primitives) and `blocks` (composed widgets: swap, dynamic island, command palette, expandable action bar). Blocks emit `registry:block` shadcn items. Preview files live under `components/previews//`. `lib/registry-server.ts` builds registry items by following each file's `@/` and relative imports and bundling everything it finds. Internal imports are therefore safe and encouraged; a component that imports `@/lib/ease` ships `lib/ease.ts` with it. +- `lib/registry.ts` — component catalog (slugs, files, examples). Three categories: `motion` (display name "Components", primitives), `agents` (AI and agent interface primitives), and `blocks` (composed widgets: swap, dynamic island, command palette, expandable action bar). Blocks emit `registry:block` shadcn items. Preview files live under `components/previews//`. `lib/registry-server.ts` builds registry items by following each file's `@/` and relative imports and bundling everything it finds. Internal imports are therefore safe and encouraged; a component that imports `@/lib/ease` ships `lib/ease.ts` with it. - `app/r/*` — registry endpoints (shadcn items, raw source, index). beUI is listed in shadcn's official registry directory as the `@beui` namespace with URL template `https://beui.dev/r/{name}.json` — that path shape and existing install slugs are public contract; never break or rename them. - `lib/ease.ts` — all motion tokens. - `scripts/check-registry.ts` — validates the catalog. @@ -46,7 +46,7 @@ Before building a new component, check this list. If it exists, import it. If it | `shared-layout-bg` | `components/motion/shared-layout-bg.tsx` | Pill that glides between hovered items via shared layout | | `bounce-sidebar` | `components/motion/bounce-sidebar.tsx` | Vertical navigation whose active dot jumps between rows on a curved spring path; controlled/uncontrolled, links or buttons, reduced-motion safe | | `animated-sidebar` | `components/motion/animated-sidebar.tsx` | Shadcn-style application sidebar with provider-managed desktop/mobile state, icon or off-canvas collapse, morphing nested-menu primitives, animated content inset, toggle rail, keyboard shortcut and a focus-managed mobile sheet | -| `preview-rail` | `components/motion/preview-rail.tsx` | Vertical or horizontal navigation ticks that form a pyramid around the hovered item with a gliding preview card | +| `preview-rail` | `components/motion/preview-rail.tsx` | Vertical or horizontal link/button navigation ticks that form a pyramid around the hovered item with an optionally positioned gliding preview card | | `dock` | `components/motion/dock.tsx` | macOS-style dock with grouped actions and gliding active pill | | `tooltip` | `components/motion/tooltip.tsx` | Hover/focus tooltip with blur enter/exit and spring spawn | | `context-menu` | `components/motion/context-menu.tsx` | Composable right-click/long-press menu with a pointer-origin clip morph, a spring-gliding active row, checkbox/radio choices, keyboard navigation and typeahead | @@ -70,6 +70,28 @@ Before building a new component, check this list. If it exists, import it. If it | `magnetic` | `components/motion/magnetic.tsx` | Cursor-attracted magnetic pull wrapper | | `scroll-animation` | `components/motion/smooth-scroll.tsx`, `scroll-progress.tsx`, `parallax.tsx`, `scroll-to.tsx`, `scroll-reveal.tsx` | Scroll-driven motion group (variants install as `@beui/smooth-scroll`, `@beui/scroll-progress`, `@beui/parallax`, `@beui/scroll-to`, `@beui/scroll-reveal`). **Smooth Scroll**: Lenis provider (`root` page / `root={false}` contained) + `useSmoothScroll` hook (offset/progress/velocity, `scrollTo`), reduced-motion native. **Scroll Progress**: bar or ring reading `useSmoothScroll().progress`. **Parallax**: drifts children at a speed factor across the viewport, either axis. **Scroll To**: button that smooth-scrolls to a target via the provider. **Scroll Reveal**: spring slide + blur reveal on viewport enter | +### AI Agents (`agents` category — agent interface primitives) + +| slug | file | what it does | +|---|---|---| +| `chat-app` | `components/agents/chat-app.tsx` | Composable agent workspace shell (`ChatApp`) backed by the animated sidebar provider; its complete usage example combines navigation, messages, streaming, planning, approvals, tool results, code, diffs, generated media, sources, and prompt input | +| `ai-sidebar` | `components/agents/ai-sidebar.tsx` | Collapsible AI workspace sidebar (`AISidebar`) for folders, projects, files, and bookmarks with full-row drag and keyboard moves, optimistic rollback, inline rename, persistent menu hover, open-folder icons, and overflow-only marquee labels | +| `message-bubble` | `components/agents/message-bubble.tsx` | Focused conversational surface (`MessageBubble`) with six visual treatments, independent alignment, an opt-in mount-only surface pop, compact grouping, polymorphic links/buttons, and `MessageBubbleCollapsible` for long responses; streaming geometry updates immediately | +| `message` | `components/agents/message.tsx` | Composable conversation message (`Message`) with bubble, avatar, header, footer, group, marker, and typing primitives plus an opt-in mount-only trailing-edge pop-up for newly sent rows; re-exports the independently installable `MessageScroller` for compatibility | +| `message-scroller` | `components/agents/message-scroller.tsx` | Reader-aware conversation viewport (`MessageScroller`) that follows streamed growth only at the live edge, releases on deliberate navigation, and can add a compact message rail that previews, tracks, and scrolls to rendered messages | +| `prompt-input` | `components/agents/prompt-input.tsx` | Auto-growing agent composer (`PromptInput`) with controlled/uncontrolled prompt and model state, a configurable animated actions menu, model selection, Enter-to-send, multiline input, and send/stop actions | +| `todo-list` | `components/agents/todo-list.tsx` | Collapsible agent task plan (`TodoList`) with pending/in-progress/completed/cancelled states, morphing progress-to-check marks, a completion count, stable streaming rows, bounded smooth following, and compact per-task metadata | +| `code-block` | `components/agents/code-block.tsx` | Syntax-highlighted agent code surface (`CodeBlock`) with stable streamed updates, optional line numbers and focused lines, bounded smooth following, filename/language metadata, and copy feedback | +| `approval-card` | `components/agents/approval-card/` | Human-in-the-loop decision surface (`ApprovalCard`) for approval/rejection/revision actions and single-choice, multiple-choice, freeform, or multi-step questions; single choices advance by default, with controlled/uncontrolled answers and step state, reduced-motion safe transitions, and collapsed submitted outcomes | +| `file-diff` | `components/agents/file-diff.tsx` | Progressive syntax-highlighted file change disclosure (`FileDiff`) with stable line rows, live addition/deletion counts, bounded smooth following, copy support, and completion collapse | +| `tool-result` | `components/agents/tool-result.tsx` | Lightweight execution disclosure (`ToolResult`) for syntax-highlighted terminal output and request responses; bounded auto-following output, success/error/cancelled states, completion collapse, copy/retry actions, and the reusable `ToolResultOutput` renderer | +| `loading-states` | `components/agents/loading-states/` | Three agent loading states: `ThinkingShimmer`, compact live-timed `AgentProgress`, and `ReasoningText` with an ASCII loader, shimmering phrases, and cascade/swap/scramble styles; each variant is independently installable and reduced-motion safe | +| `agent-activity` | `components/agents/agent-activity/` | Adaptive agent activity disclosure (`AgentActivity`): renders compact streaming text, reasoning steps, search results, tool calls, structured execution traces, or a chronological mix; derives a matching active/completed summary, smoothly follows entries in a capped viewport, then collapses into a reopenable disclosure | +| `streaming-response` | `components/agents/streaming-response.tsx` | Renderer-agnostic streamed answer surface (`StreamingResponse`) with completion-only copy, retry, feedback, an optional expandable source summary, and controls for actions and nested live-region announcements | +| `image-generation` | `components/agents/image-generation.tsx` | Stable generated-media canvas (`ImageGeneration`) with queued, generating, refining, complete, and recoverable error states; progressively resolves supplied media without remounting or shifting the surrounding conversation | +| `tool-approval` | `components/agents/tool-approval.tsx` | Human-in-the-loop permission card (`ToolApproval`) with controlled execution states, expandable parameters, allow-once/always-allow/deny actions, optional Shiki-powered `ToolApprovalCode` values, and reduced-motion behavior | +| `citations` | `components/agents/citations.tsx` | Collapsible progressive citation collection (`Citations`) paired with inline `Citation` links; derives real favicons from each URL via `lib/favicon.ts` and renders compact numbered title/domain rows with controlled disclosure and reduced motion | + ### Blocks (`blocks` category — composed product widgets) | slug | file | what it does | diff --git a/app/components/[category]/[slug]/page.tsx b/app/components/[category]/[slug]/page.tsx index 8e2599f..8450fe5 100644 --- a/app/components/[category]/[slug]/page.tsx +++ b/app/components/[category]/[slug]/page.tsx @@ -21,6 +21,7 @@ import { } from "@/components/motion/tabs"; import { NewBadge } from "@/components/app/docs/new-badge"; import { ComponentCard } from "@/components/app/docs/component-card"; +import { ComponentGuide } from "@/components/app/docs/component-guide"; import { CopyPage } from "@/components/app/docs/copy-page"; import { JsonLd } from "@/components/app/analytics/json-ld"; import { getPreview, previews } from "@/components/previews"; @@ -65,7 +66,7 @@ export async function generateMetadata({ ? `/${installSlugs[0]}.json` : `/${comp.slug}.json`; - const title = `${comp.name} · React motion component`; + const title = comp.guide?.seo.title ?? `${comp.name} · React motion component`; const ogTitle = `${title} · beUI`; const pageUrl = `/components/${cat.slug}/${comp.slug}`; const imageUrl = `/api/og?component=${comp.slug}`; @@ -137,11 +138,16 @@ export default async function ComponentPage({ const hasMultipleVariants = (comp.examples?.length ?? 0) > 1; const hasVariantInstallCommands = comp.examples?.some((example) => example.installSlug) ?? false; + const examplesShareSource = + (comp.examples?.length ?? 0) > 1 && + new Set(comp.examples?.map((example) => example.file)).size === 1; + const shouldShowExampleApi = (index: number) => + !examplesShareSource || index === (comp.examples?.length ?? 0) - 1; const dates = componentDates(cat.slug, comp.slug); const related = relatedComponents(cat.slug, comp.slug, 3); const propsDocs = comp.examples?.length ? [] : getComponentProps(comp.file); const variantNavItems: PageNavItem[] = - comp.examples?.map((example) => ({ + comp.examples?.map((example, index) => ({ id: example.slug, label: example.name, children: [ @@ -149,7 +155,7 @@ export default async function ComponentPage({ ...(example.installSlug ? [{ id: `${example.slug}-install`, label: "Install" }] : []), - ...(getComponentProps(example.file).length + ...(shouldShowExampleApi(index) && getComponentProps(example.file).length ? [ { id: `${example.slug}-api-reference`, @@ -161,7 +167,18 @@ export default async function ComponentPage({ })) ?? []; const pageNavItems: PageNavItem[] = [ ...(variantNavItems.length - ? variantNavItems + ? [ + ...variantNavItems, + ...(!hasVariantInstallCommands + ? [{ id: "install", label: "Install" }] + : []), + ...(comp.guide + ? [ + { id: "composition", label: "Composition" }, + { id: "behavior", label: "How it works" }, + ] + : []), + ] : [ { id: "overview", @@ -174,6 +191,12 @@ export default async function ComponentPage({ ...(propsDocs.length ? [{ id: "api-reference", label: "API Reference" }] : []), + ...(comp.guide + ? [ + { id: "composition", label: "Composition" }, + { id: "behavior", label: "How it works" }, + ] + : []), ], }, ]), @@ -211,7 +234,7 @@ export default async function ComponentPage({
-

+

{comp.name}

{comp.badge === "new" && !hasMultipleVariants ? ( @@ -231,17 +254,23 @@ export default async function ComponentPage({ {comp.examples?.length ? (
- {comp.examples.map((ex) => ( + {comp.examples.map((ex, index) => ( ))}
) : ( - + )} {!hasVariantInstallCommands ? ( @@ -275,6 +304,8 @@ export default async function ComponentPage({ ) : null} + {comp.guide ? : null} + {related.length ? ( - ) : ( + ) : cat.slug !== "agents" ? ( - )} + ) : null}

Updated{" "}

@@ -384,7 +418,7 @@ async function ExampleBlock({
- + - + ({ category: c.slug })); } @@ -115,6 +162,16 @@ export default async function CategoryPage({ const components = cat.components.filter( (comp) => !isComponentNew(comp, now), ); + const agentGroups = + cat.slug === "agents" + ? AGENT_CATEGORY_GROUPS.map((group) => ({ + ...group, + components: group.slugs.flatMap((slug) => { + const component = cat.components.find((item) => item.slug === slug); + return component ? [component] : []; + }), + })) + : []; return (
@@ -143,45 +200,94 @@ export default async function CategoryPage({ {content.supportingText}

- {newComponents.length ? ( -
-

- New -

-
- {newComponents.map((comp) => ( - + {agentGroups.length ? ( + <> + + +
+ {agentGroups.map((group) => ( +
+
+

+ {group.title} +

+

+ {group.description} +

+
+
+ {group.components.map((comp) => ( + + ))} +
+
))}
-
- ) : null} + + ) : ( + <> + {newComponents.length ? ( +
+

+ New +

+
+ {newComponents.map((comp) => ( + + ))} +
+
+ ) : null} -
-

- {content.allLabel} -

-
- {components.map((comp) => ( - - ))} -
-
+
+

+ {content.allLabel} +

+
+ {components.map((comp) => ( + + ))} +
+
+ + )}
); } diff --git a/components/agents/agent-activity/activity-row.tsx b/components/agents/agent-activity/activity-row.tsx new file mode 100644 index 0000000..a176a75 --- /dev/null +++ b/components/agents/agent-activity/activity-row.tsx @@ -0,0 +1,242 @@ +import { + Check, + Circle, + FileText, + Globe2, + ImageIcon, + MessageSquare, + PencilLine, + Search, + Sparkles, + SquareTerminal, + Wrench, +} from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease"; +import { cn } from "@/lib/utils"; +import type { + AgentActivityItem, + AgentActivitySearch, + AgentActivityStep, + AgentActivityText, + AgentActivityTool, + AgentActivityTrace, + AgentSearchResult, +} from "./types"; + +function StepRow({ item }: { item: AgentActivityStep }) { + const state = item.status ?? "complete"; + + return ( +
+
+ ); +} + +function TextRow({ item }: { item: AgentActivityText }) { + return ( +
+ {item.content} +
+ ); +} + +function SearchResultRow({ + result, +}: { + result: AgentSearchResult; +}) { + const content = ( + <> + + + {result.title} + + {result.domain ? ( + + {result.domain} + + ) : null} + + ); + const className = cn( + "flex min-h-7 items-center gap-2 rounded-md px-1.5 py-1 text-left outline-none transition-colors", + result.url && "focus-visible:ring-2 focus-visible:ring-ring", + ); + + return result.url ? ( + + {content} + + ) : ( +
{content}
+ ); +} + +function SearchRow({ item }: { item: AgentActivitySearch }) { + const reduce = useReducedMotion() ?? false; + const enter = reduce ? { opacity: 1 } : { opacity: 0, y: 6 }; + const visible = { opacity: 1, y: 0 }; + const exit = reduce ? { opacity: 0 } : { opacity: 0, y: -3 }; + const transition = reduce + ? { duration: 0 } + : { + opacity: { duration: 0.18, ease: EASE_OUT }, + y: SPRING_LAYOUT, + layout: SPRING_LAYOUT, + }; + + return ( +
+
+
+ {item.results?.length ? ( +
+ + {item.results.map((result) => ( + + + + ))} + +
+ ) : null} + + {item.moreCount ? ( + + +{item.moreCount} more + + ) : null} + +
+ ); +} + +function ActionIcon({ action }: { action: string }) { + if (action === "read") return ; + if (action === "edit" || action === "write") { + return ; + } + if (action === "run") return ; + return ; +} + +function ToolRow({ item }: { item: AgentActivityTool }) { + const action = item.action.charAt(0).toUpperCase() + item.action.slice(1); + + return ( +
+ + {action} + + {item.target} + + {typeof item.additions === "number" || typeof item.deletions === "number" ? ( + + {typeof item.additions === "number" ? ( + +{item.additions} + ) : null} + {typeof item.deletions === "number" ? ( + −{item.deletions} + ) : null} + + ) : null} +
+ ); +} + +function TraceIcon({ kind }: { kind: AgentActivityTrace["kind"] }) { + if (kind === "thinking") return ; + if (kind === "message") return ; + if (kind === "write") return ; + if (kind === "run") return ; + if (kind === "read") return ; + return ; +} + +function TraceRow({ item }: { item: AgentActivityTrace }) { + return ( +
+ + {item.label} + {item.detail ? ( + + {item.detail} + + ) : ( + + )} +
+ ); +} + +export function ActivityRow({ item }: { item: AgentActivityItem }) { + if (item.type === "text") return ; + if (item.type === "search") return ; + if (item.type === "tool") return ; + if (item.type === "trace") return ; + return ; +} diff --git a/components/agents/agent-activity/index.tsx b/components/agents/agent-activity/index.tsx new file mode 100644 index 0000000..14f9322 --- /dev/null +++ b/components/agents/agent-activity/index.tsx @@ -0,0 +1,279 @@ +"use client"; + +import { ChevronDown } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { + type ReactNode, + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { ThinkingShimmer } from "@/components/agents/loading-states/thinking-shimmer"; +import { AgentDisclosure } from "@/components/agents/agent-disclosure"; +import { + EASE_OUT, + SPRING_LAYOUT, + SPRING_SWAP, +} from "@/lib/ease"; +import { cn } from "@/lib/utils"; +import { ActivityRow } from "./activity-row"; +import type { + AgentActivityContentType, + AgentActivityItem, + AgentActivityProps, +} from "./types"; + +export type { + AgentActivityContentType, + AgentActivityItem, + AgentActivityProps, + AgentActivitySearch, + AgentActivityStatus, + AgentActivityStep, + AgentActivityText, + AgentActivityTool, + AgentActivityTrace, + AgentSearchResult, + AgentStepStatus, + AgentTraceKind, +} from "./types"; + +function formatDuration(duration: number) { + const seconds = Math.max(0, Math.round(duration)); + if (seconds < 60) return `${seconds}s`; + + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`; +} + +function useControllableOpen({ + open, + defaultOpen, + onOpenChange, +}: { + open?: boolean; + defaultOpen: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const [internalOpen, setInternalOpen] = useState(defaultOpen); + const controlled = open !== undefined; + const currentOpen = open ?? internalOpen; + + const setOpen = useCallback( + (next: boolean) => { + if (!controlled) setInternalOpen(next); + onOpenChange?.(next); + }, + [controlled, onOpenChange], + ); + + return [currentOpen, setOpen] as const; +} + +function getContentType(items: AgentActivityItem[]): AgentActivityContentType { + const first = items[0]?.type; + return first && items.every((item) => item.type === first) ? first : "mixed"; +} + +function getActiveLabel(type: AgentActivityContentType) { + if (type === "search") return "Searching the web…"; + if (type === "tool") return "Running tools…"; + if (type === "trace") return "Working through the run…"; + if (type === "mixed") return "Working through it…"; + return "Thinking…"; +} + +function getSummary( + type: AgentActivityContentType, + items: AgentActivityItem[], + duration: number, +): ReactNode { + if (type === "step" || type === "text") { + return ( + <> + Thought for {formatDuration(duration)} + + ); + } + if (type === "search") return "Searched the web"; + if (type === "tool") { + return `Ran ${items.length} ${items.length === 1 ? "tool" : "tools"}`; + } + if (type === "trace") { + const messages = items.filter( + (item) => + item.type === "trace" && + (item.kind === "thinking" || item.kind === "message"), + ).length; + const tools = items.length - messages; + return `${tools} ${tools === 1 ? "tool call" : "tool calls"}, ${messages} ${messages === 1 ? "message" : "messages"}`; + } + return `Completed ${items.length} ${items.length === 1 ? "step" : "steps"}`; +} + +export function AgentActivity({ + items, + contentType: initialContentType, + status = "working", + duration = 0, + open, + defaultOpen = false, + onOpenChange, + collapseOnComplete = true, + activeLabel, + summary, + maxHeight = 208, + className, + contentClassName, +}: AgentActivityProps) { + const reduce = useReducedMotion() ?? false; + const baseId = useId(); + const triggerId = `${baseId}-trigger`; + const contentId = `${baseId}-content`; + const contentRef = useRef(null); + const viewportRef = useRef(null); + const previousStatus = useRef(status); + const [contentHeight, setContentHeight] = useState(0); + const [currentOpen, setOpen] = useControllableOpen({ + open, + defaultOpen, + onOpenChange, + }); + const working = status === "working"; + const expanded = working || currentOpen; + const contentType = items.length + ? getContentType(items) + : (initialContentType ?? "mixed"); + const cappedHeight = Math.min(contentHeight, Math.max(0, maxHeight)); + const viewportHeight = working ? Math.max(0, maxHeight) : cappedHeight; + const capped = contentHeight > maxHeight; + const streamOffset = working + ? Math.min(0, viewportHeight - contentHeight) + : 0; + + useLayoutEffect(() => { + const node = contentRef.current; + if (!node) return; + + const measure = () => setContentHeight(node.offsetHeight); + measure(); + + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measure); + observer.observe(node); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (previousStatus.current === "working" && status === "complete") { + setOpen(!collapseOnComplete); + } + previousStatus.current = status; + }, [collapseOnComplete, setOpen, status]); + + const toggle = () => { + const next = !currentOpen; + setOpen(next); + if (next) requestAnimationFrame(() => viewportRef.current?.scrollTo({ top: 0 })); + }; + + const liveLabel = activeLabel ?? getActiveLabel(contentType); + const completedSummary = summary ?? getSummary(contentType, items, duration); + const maskImage = capped + ? working + ? "linear-gradient(to bottom, transparent, black 12px)" + : "linear-gradient(to bottom, transparent, black 12px, black calc(100% - 12px), transparent)" + : undefined; + + return ( +
+ {working ? ( +
+ {liveLabel} +
+ ) : ( + + )} + + +
+ + + {items.map((item) => ( + + + + ))} + + +
+
+
+ ); +} diff --git a/components/agents/agent-activity/types.ts b/components/agents/agent-activity/types.ts new file mode 100644 index 0000000..bedf9b6 --- /dev/null +++ b/components/agents/agent-activity/types.ts @@ -0,0 +1,96 @@ +import type { ReactNode } from "react"; + +export type AgentActivityStatus = "working" | "complete"; +export type AgentStepStatus = "pending" | "active" | "complete"; + +export interface AgentActivityStep { + id: string; + type: "step"; + label: ReactNode; + status?: AgentStepStatus; + meta?: ReactNode; +} + +export interface AgentActivityText { + id: string; + type: "text"; + content: ReactNode; +} + +export interface AgentSearchResult { + id: string; + title: ReactNode; + domain?: ReactNode; + url?: string; + icon?: ReactNode; +} + +export interface AgentActivitySearch { + id: string; + type: "search"; + query: ReactNode; + results?: AgentSearchResult[]; + moreCount?: number; +} + +export interface AgentActivityTool { + id: string; + type: "tool"; + action: "read" | "edit" | "run" | (string & {}); + target: ReactNode; + additions?: number; + deletions?: number; +} + +export type AgentTraceKind = + | "thinking" + | "message" + | "write" + | "run" + | "read" + | (string & {}); + +export interface AgentActivityTrace { + id: string; + type: "trace"; + kind: AgentTraceKind; + label: ReactNode; + detail?: ReactNode; + icon?: ReactNode; +} + +export type AgentActivityItem = + | AgentActivityStep + | AgentActivityText + | AgentActivitySearch + | AgentActivityTool + | AgentActivityTrace; + +export type AgentActivityContentType = AgentActivityItem["type"] | "mixed"; + +export interface AgentActivityProps { + /** Chronological activity entries. Append or update items as events stream. */ + items: AgentActivityItem[]; + /** Expected activity kind before the first streamed item arrives. */ + contentType?: AgentActivityContentType; + /** Current run phase. Active runs always stay expanded. */ + status?: AgentActivityStatus; + /** Elapsed run time, in seconds. Used by the step-only summary. */ + duration?: number; + /** Controlled expanded state used after the run completes. */ + open?: boolean; + /** Initial expanded state used after the run completes. */ + defaultOpen?: boolean; + /** Called when the completed activity disclosure changes state. */ + onOpenChange?: (open: boolean) => void; + /** Collapse the disclosure when status changes from working to complete. */ + collapseOnComplete?: boolean; + /** Optional label shown while the run is active. */ + activeLabel?: ReactNode; + /** Optional completed summary. Derived from the item types by default. */ + summary?: ReactNode; + /** Maximum visible activity height before the stream begins gliding. */ + maxHeight?: number; + className?: string; + contentClassName?: string; +} diff --git a/components/agents/agent-code.tsx b/components/agents/agent-code.tsx new file mode 100644 index 0000000..6e76865 --- /dev/null +++ b/components/agents/agent-code.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { + type CSSProperties, + Fragment, + useEffect, + useState, +} from "react"; +import { createHighlighter, type Highlighter } from "shiki"; +import { cn } from "@/lib/utils"; + +export type AgentCodeLanguage = + | "bash" + | "diff" + | "json" + | "text" + | "tsx" + | "typescript"; + +export interface AgentCodeToken { + content: string; + offset: number; + light?: string; + dark?: string; +} + +export type AgentCodeTokenLines = AgentCodeToken[][]; + +export interface AgentCodeProps { + code: string; + language?: AgentCodeLanguage; + className?: string; +} + +export interface AgentCodeLineProps { + code: string; + tokens?: AgentCodeToken[]; + className?: string; +} + +const LIGHT_THEME = "github-light-high-contrast"; +const DARK_THEME = "github-dark-high-contrast"; +let agentCodeHighlighter: Promise | null = null; +const tokenCache = new Map(); + +function getAgentCodeHighlighter() { + if (!agentCodeHighlighter) { + agentCodeHighlighter = createHighlighter({ + themes: [LIGHT_THEME, DARK_THEME], + langs: ["bash", "diff", "json", "tsx", "typescript"], + }); + } + return agentCodeHighlighter; +} + +function tokenCacheKey(code: string, language: AgentCodeLanguage) { + return `${language}\u0000${code}`; +} + +export function useAgentCodeTokens( + code: string, + language: AgentCodeLanguage, +) { + const key = tokenCacheKey(code, language); + const cached = tokenCache.get(key); + const [result, setResult] = useState<{ + key: string; + code: string; + language: AgentCodeLanguage; + lines: AgentCodeTokenLines; + } | null>(cached ? { key, code, language, lines: cached } : null); + + useEffect(() => { + const current = tokenCache.get(key); + if (current) { + setResult({ key, code, language, lines: current }); + return; + } + + let cancelled = false; + getAgentCodeHighlighter().then((highlighter) => { + if (cancelled) return; + const lines = highlighter + .codeToTokensWithThemes(code, { + lang: language, + themes: { + light: LIGHT_THEME, + dark: DARK_THEME, + }, + }) + .map((line) => + line.map((token) => ({ + content: token.content, + offset: token.offset, + light: token.variants.light?.color, + dark: token.variants.dark?.color, + })), + ); + tokenCache.set(key, lines); + setResult({ key, code, language, lines }); + }); + return () => { + cancelled = true; + }; + }, [code, key, language]); + + if (result?.key === key) return result.lines; + if (result?.language === language && code.startsWith(result.code)) { + return result.lines; + } + return null; +} + +export function AgentCodeLine({ + code, + tokens, + className, +}: AgentCodeLineProps) { + return ( + + {tokens + ? tokens.map((token) => ( + + {token.content} + + )) + : code} + + ); +} + +export function AgentCode({ + code, + language = "bash", + className, +}: AgentCodeProps) { + const tokens = useAgentCodeTokens(code, language); + let offset = 0; + const lines = code.split("\n").map((content) => { + const line = { content, offset }; + offset += content.length + 1; + return line; + }); + + return ( +
+      
+        {lines.map((line, index) => (
+          
+            
+            {index < lines.length - 1 ? "\n" : null}
+          
+        ))}
+      
+    
+ ); +} diff --git a/components/agents/agent-disclosure.tsx b/components/agents/agent-disclosure.tsx new file mode 100644 index 0000000..08c949b --- /dev/null +++ b/components/agents/agent-disclosure.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { motion, type HTMLMotionProps, useReducedMotion } from "motion/react"; +import type { CSSProperties } from "react"; +import { EASE_OUT } from "@/lib/ease"; +import { cn } from "@/lib/utils"; + +export interface AgentDisclosureProps + extends Omit, "animate" | "initial"> { + open: boolean; + openHeight?: CSSProperties["height"]; +} + +/** Shared transform-only reveal for collapsible agent content. */ +export function AgentDisclosure({ + open, + openHeight = "auto", + className, + style, + transition, + ...props +}: AgentDisclosureProps) { + const reduce = useReducedMotion() ?? false; + + return ( + + ); +} diff --git a/components/agents/ai-sidebar.tsx b/components/agents/ai-sidebar.tsx new file mode 100644 index 0000000..0c699f5 --- /dev/null +++ b/components/agents/ai-sidebar.tsx @@ -0,0 +1,843 @@ +"use client"; + +import { + Bookmark, + FileText, + Folder, + FolderOpen, + MoreHorizontal, + Pencil, +} from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { + type DragEvent, + type KeyboardEvent, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + MorphPopover, + MorphPopoverContent, + MorphPopoverTrigger, +} from "@/components/motion/popover-morph"; +import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease"; +import { cn } from "@/lib/utils"; + +export type SidebarResourceKind = + | "folder" + | "project" + | "file" + | "bookmark"; + +export interface SidebarResource { + id: string; + label: string; + kind: SidebarResourceKind; + children?: SidebarResource[]; + disabled?: boolean; +} + +export type SidebarResourceDropPosition = "before" | "inside" | "after"; + +export interface SidebarResourceMove { + itemId: string; + targetId: string | null; + position: SidebarResourceDropPosition; +} + +export interface SidebarResourceMenuControls { + close: () => void; + rename: () => void; +} + +export interface AISidebarProps { + items?: SidebarResource[]; + defaultItems?: SidebarResource[]; + onItemsChange?: (items: SidebarResource[]) => void; + /** Reject the promise to roll the optimistic move back. */ + onMove?: (move: SidebarResourceMove) => void | Promise; + onMoveError?: (error: unknown, move: SidebarResourceMove) => void; + onRename?: (item: SidebarResource, label: string) => void | Promise; + activeId?: string | null; + defaultActiveId?: string | null; + onActiveChange?: (id: string) => void; + defaultExpandedIds?: string[]; + renderIcon?: (item: SidebarResource) => ReactNode; + renderMenu?: ( + item: SidebarResource, + controls: SidebarResourceMenuControls, + ) => ReactNode; + ariaLabel?: string; + className?: string; +} + +interface FlatResource { + item: SidebarResource; + depth: number; + parentId: string | null; +} + +interface DropTarget { + id: string | null; + position: SidebarResourceDropPosition; +} + +const ROW_REVEAL = { + duration: 0.16, + ease: EASE_OUT, +} as const; + +function canContain(item: SidebarResource) { + return item.kind === "folder" || item.kind === "project"; +} + +function flattenResources( + items: SidebarResource[], + expanded: Set, + depth = 0, + parentId: string | null = null, +): FlatResource[] { + return items.flatMap((item) => { + const row = { item, depth, parentId }; + if (!item.children?.length || !expanded.has(item.id)) return [row]; + return [ + row, + ...flattenResources(item.children, expanded, depth + 1, item.id), + ]; + }); +} + +function findResource( + items: SidebarResource[], + id: string, +): SidebarResource | undefined { + for (const item of items) { + if (item.id === id) return item; + const child = item.children ? findResource(item.children, id) : undefined; + if (child) return child; + } +} + +function containsResource(item: SidebarResource, id: string): boolean { + return ( + item.id === id || + item.children?.some((child) => containsResource(child, id)) === true + ); +} + +function removeResource( + items: SidebarResource[], + id: string, +): { items: SidebarResource[]; removed?: SidebarResource } { + let removed: SidebarResource | undefined; + const next: SidebarResource[] = []; + + for (const item of items) { + if (item.id === id) { + removed = item; + continue; + } + + if (item.children?.length) { + const childResult = removeResource(item.children, id); + if (childResult.removed) { + removed = childResult.removed; + next.push({ ...item, children: childResult.items }); + continue; + } + } + + next.push(item); + } + + return { items: next, removed }; +} + +function insertResource( + items: SidebarResource[], + resource: SidebarResource, + targetId: string | null, + position: SidebarResourceDropPosition, +): SidebarResource[] { + if (targetId === null) return [...items, resource]; + + const next: SidebarResource[] = []; + for (const item of items) { + if (item.id === targetId) { + if (position === "before") next.push(resource, item); + else if (position === "after") next.push(item, resource); + else next.push({ ...item, children: [...(item.children ?? []), resource] }); + continue; + } + + if (item.children?.length) { + next.push({ + ...item, + children: insertResource(item.children, resource, targetId, position), + }); + } else { + next.push(item); + } + } + return next; +} + +function moveResource( + items: SidebarResource[], + move: SidebarResourceMove, +): SidebarResource[] | null { + const source = findResource(items, move.itemId); + if (!source || source.disabled) return null; + if (move.targetId && containsResource(source, move.targetId)) return null; + + const target = move.targetId ? findResource(items, move.targetId) : undefined; + if ( + move.position === "inside" && + (!target || target.disabled || !canContain(target)) + ) + return null; + + const removed = removeResource(items, move.itemId); + if (!removed.removed) return null; + return insertResource( + removed.items, + removed.removed, + move.targetId, + move.position, + ); +} + +function renameResource( + items: SidebarResource[], + id: string, + label: string, +): SidebarResource[] { + return items.map((item) => ({ + ...item, + label: item.id === id ? label : item.label, + children: item.children + ? renameResource(item.children, id, label) + : undefined, + })); +} + +function defaultIcon(item: SidebarResource, expanded: boolean) { + const Icon = + item.kind === "folder" || item.kind === "project" + ? expanded + ? FolderOpen + : Folder + : item.kind === "bookmark" + ? Bookmark + : FileText; + return ; +} + +function MarqueeLabel({ active, children }: { active: boolean; children: string }) { + const reduce = useReducedMotion() ?? false; + const viewportRef = useRef(null); + const labelRef = useRef(null); + const [distance, setDistance] = useState(0); + + useEffect(() => { + const measure = () => { + const viewport = viewportRef.current; + const label = labelRef.current; + if (!viewport || !label) return; + setDistance(label.scrollWidth > viewport.clientWidth ? label.scrollWidth + 24 : 0); + }; + measure(); + const observer = new ResizeObserver(measure); + if (viewportRef.current) observer.observe(viewportRef.current); + if (labelRef.current) observer.observe(labelRef.current); + return () => observer.disconnect(); + }, []); + + const running = active && distance > 0 && !reduce; + + return ( + + + {children} + {running ? : null} + + + ); +} + +interface ResourceRowProps { + row: FlatResource; + active: boolean; + expanded: boolean; + focused: boolean; + draggingId: string | null; + dropTarget: DropTarget | null; + menuOpen: boolean; + renaming: boolean; + onDragEnd: () => void; + onDragOver: (event: DragEvent, row: FlatResource) => void; + onDragStart: (event: DragEvent, id: string) => void; + onDrop: (event: DragEvent) => void; + onFocus: () => void; + onKeyDown: (event: KeyboardEvent) => void; + onMenuOpenChange: (open: boolean) => void; + onRenameCancel: () => void; + onRenameCommit: (label: string) => void; + onRenameStart: () => void; + onSelect: () => void; + onToggle: () => void; + renderIcon?: (item: SidebarResource) => ReactNode; + renderMenu?: AISidebarProps["renderMenu"]; + setRef: (node: HTMLDivElement | null) => void; +} + +function ResourceRow({ + row, + active, + expanded, + focused, + draggingId, + dropTarget, + menuOpen, + renaming, + onDragEnd, + onDragOver, + onDragStart, + onDrop, + onFocus, + onKeyDown, + onMenuOpenChange, + onRenameCancel, + onRenameCommit, + onRenameStart, + onSelect, + onToggle, + renderIcon, + renderMenu, + setRef, +}: ResourceRowProps) { + const reduce = useReducedMotion() ?? false; + const [hovered, setHovered] = useState(false); + const inputRef = useRef(null); + const skipRenameBlurRef = useRef(false); + const draggedRef = useRef(false); + const [draft, setDraft] = useState(row.item.label); + const acceptsChildren = canContain(row.item); + const isDragging = draggingId === row.item.id; + const dropPosition = dropTarget?.id === row.item.id ? dropTarget.position : null; + + useEffect(() => { + if (!renaming) return; + skipRenameBlurRef.current = false; + setDraft(row.item.label); + requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + }, [renaming, row.item.label]); + + const menu = renderMenu?.(row.item, { + close: () => onMenuOpenChange(false), + rename: () => { + onMenuOpenChange(false); + onRenameStart(); + }, + }) ?? ( + + ); + + return ( + { + if ( + event.defaultPrevented || + draggedRef.current || + renaming || + row.item.disabled + ) + return; + if (acceptsChildren) onToggle(); + else onSelect(); + }} + onDoubleClick={(event) => { + if (acceptsChildren || row.item.disabled) return; + event.preventDefault(); + onRenameStart(); + }} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + onDragStartCapture={(event) => { + draggedRef.current = true; + onDragStart(event, row.item.id); + }} + onDragEndCapture={() => { + onDragEnd(); + requestAnimationFrame(() => { + draggedRef.current = false; + }); + }} + onDragOver={(event) => onDragOver(event, row)} + onDrop={onDrop} + className={cn( + "group/resource relative flex min-h-9 min-w-0 cursor-pointer items-center gap-2.5 rounded-xl pr-3 text-sm outline-none", + "text-muted-foreground transition-colors hover:bg-muted hover:text-foreground", + "focus-visible:bg-muted/70 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset", + "data-[menu-open=true]:bg-muted data-[menu-open=true]:text-foreground", + "data-[dragging=true]:opacity-40", + "data-[drop=inside]:bg-primary/10 data-[drop=inside]:ring-1 data-[drop=inside]:ring-primary/45", + "data-[drop=before]:before:absolute data-[drop=before]:before:-top-0.5 data-[drop=before]:before:right-2 data-[drop=before]:before:left-2 data-[drop=before]:before:h-0.5 data-[drop=before]:before:rounded-full data-[drop=before]:before:bg-primary", + "data-[drop=after]:after:absolute data-[drop=after]:after:-bottom-0.5 data-[drop=after]:after:right-2 data-[drop=after]:after:left-2 data-[drop=after]:after:h-0.5 data-[drop=after]:after:rounded-full data-[drop=after]:after:bg-primary", + !acceptsChildren && active && "bg-muted text-foreground", + row.item.disabled && "cursor-not-allowed opacity-45", + )} + style={{ paddingLeft: `${12 + row.depth * 16}px` }} + > + + + {renaming ? ( + setDraft(event.target.value)} + draggable={false} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onBlur={() => { + if (!skipRenameBlurRef.current) onRenameCommit(draft); + }} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") { + skipRenameBlurRef.current = true; + onRenameCommit(draft); + } + if (event.key === "Escape") { + skipRenameBlurRef.current = true; + onRenameCancel(); + } + }} + className="mx-1 h-7 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + ) : ( + {row.item.label} + )} + + {!renaming && !row.item.disabled ? ( + + + + + +
{menu}
+
+
+ ) : null} +
+ ); +} + +export function AISidebar({ + items, + defaultItems = [], + onItemsChange, + onMove, + onMoveError, + onRename, + activeId, + defaultActiveId = null, + onActiveChange, + defaultExpandedIds = [], + renderIcon, + renderMenu, + ariaLabel = "Resources", + className, +}: AISidebarProps) { + const [internalItems, setInternalItems] = useState(items ?? defaultItems); + const [internalActiveId, setInternalActiveId] = useState(defaultActiveId); + const [expandedIds, setExpandedIds] = useState( + () => new Set(defaultExpandedIds), + ); + const [focusedId, setFocusedId] = useState( + activeId ?? defaultActiveId, + ); + const [draggingId, setDraggingId] = useState(null); + const [dropTarget, setDropTarget] = useState(null); + const [menuOpenId, setMenuOpenId] = useState(null); + const [renamingId, setRenamingId] = useState(null); + const [announcement, setAnnouncement] = useState(""); + const rowRefs = useRef(new Map()); + const movePendingRef = useRef(false); + const renderedItems = internalItems; + const selectedId = activeId ?? internalActiveId; + + useEffect(() => { + if (items) setInternalItems(items); + }, [items]); + + const flat = useMemo( + () => flattenResources(renderedItems, expandedIds), + [expandedIds, renderedItems], + ); + + useEffect(() => { + if (focusedId && flat.some((row) => row.item.id === focusedId)) return; + setFocusedId(flat[0]?.item.id ?? null); + }, [flat, focusedId]); + + useEffect(() => { + if (!menuOpenId) return; + const frame = requestAnimationFrame(() => { + const menus = Array.from( + document.querySelectorAll("[data-sidebar-resource-menu]"), + ); + menus + .find((menu) => menu.dataset.sidebarResourceMenu === menuOpenId) + ?.querySelector("button, a[href]") + ?.focus(); + }); + return () => cancelAnimationFrame(frame); + }, [menuOpenId]); + + const updateItems = useCallback( + (next: SidebarResource[]) => { + setInternalItems(next); + onItemsChange?.(next); + }, + [onItemsChange], + ); + + const performMove = useCallback( + async (move: SidebarResourceMove) => { + if (movePendingRef.current) { + setAnnouncement("Wait for the current move to finish."); + return; + } + const before = renderedItems; + const next = moveResource(before, move); + if (!next || next === before) return; + + movePendingRef.current = true; + updateItems(next); + setDropTarget(null); + setDraggingId(null); + const moved = findResource(before, move.itemId); + const target = move.targetId ? findResource(before, move.targetId) : null; + setAnnouncement( + target + ? `Moved ${moved?.label ?? "item"} ${move.position} ${target.label}.` + : `Moved ${moved?.label ?? "item"} to the top level.`, + ); + + try { + await onMove?.(move); + } catch (error) { + updateItems(before); + setAnnouncement(`Move failed. ${moved?.label ?? "Item"} was restored.`); + onMoveError?.(error, move); + } finally { + movePendingRef.current = false; + } + }, + [onMove, onMoveError, renderedItems, updateItems], + ); + + const focusRow = useCallback((id: string) => { + setFocusedId(id); + requestAnimationFrame(() => rowRefs.current.get(id)?.focus()); + }, []); + + const select = useCallback( + (id: string) => { + if (activeId === undefined) setInternalActiveId(id); + onActiveChange?.(id); + }, + [activeId, onActiveChange], + ); + + const toggle = useCallback((id: string) => { + setExpandedIds((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const handleKeyDown = useCallback( + (event: KeyboardEvent, row: FlatResource) => { + const index = flat.findIndex(({ item }) => item.id === row.item.id); + const previous = flat[index - 1]; + const next = flat[index + 1]; + const moveModifier = event.altKey && event.shiftKey; + + if (event.key === "ArrowDown" && !moveModifier && next) { + event.preventDefault(); + focusRow(next.item.id); + return; + } + if (event.key === "ArrowUp" && !moveModifier && previous) { + event.preventDefault(); + focusRow(previous.item.id); + return; + } + if (event.key === "Home" && flat[0]) { + event.preventDefault(); + focusRow(flat[0].item.id); + return; + } + if (event.key === "End" && flat.at(-1)) { + event.preventDefault(); + focusRow(flat.at(-1)?.item.id ?? row.item.id); + return; + } + + if (row.item.disabled) { + if (event.key === "ArrowLeft" && row.parentId) { + event.preventDefault(); + focusRow(row.parentId); + } else if ( + moveModifier || + ["ArrowRight", "Enter", " ", "F2", "ContextMenu"].includes( + event.key, + ) || + (event.shiftKey && event.key === "F10") + ) { + event.preventDefault(); + } + return; + } + + if (moveModifier && event.key === "ArrowUp" && previous) { + event.preventDefault(); + void performMove({ itemId: row.item.id, targetId: previous.item.id, position: "before" }); + return; + } + if (moveModifier && event.key === "ArrowDown" && next) { + event.preventDefault(); + void performMove({ itemId: row.item.id, targetId: next.item.id, position: "after" }); + return; + } + if (moveModifier && event.key === "ArrowRight" && previous && canContain(previous.item)) { + event.preventDefault(); + setExpandedIds((current) => new Set(current).add(previous.item.id)); + void performMove({ itemId: row.item.id, targetId: previous.item.id, position: "inside" }); + return; + } + if (moveModifier && event.key === "ArrowLeft" && row.parentId) { + event.preventDefault(); + void performMove({ itemId: row.item.id, targetId: row.parentId, position: "after" }); + return; + } + + if (event.key === "ArrowRight" && canContain(row.item)) { + event.preventDefault(); + if (!expandedIds.has(row.item.id)) toggle(row.item.id); + else if (next?.parentId === row.item.id) focusRow(next.item.id); + } else if (event.key === "ArrowLeft") { + event.preventDefault(); + if (expandedIds.has(row.item.id)) toggle(row.item.id); + else if (row.parentId) focusRow(row.parentId); + } else if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + if (canContain(row.item)) toggle(row.item.id); + else select(row.item.id); + } else if (event.key === "F2") { + event.preventDefault(); + setRenamingId(row.item.id); + } else if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) { + event.preventDefault(); + setMenuOpenId(row.item.id); + } + }, + [expandedIds, flat, focusRow, performMove, select, toggle], + ); + + return ( + <> +
{ + if (!draggingId || event.target !== event.currentTarget) return; + event.preventDefault(); + setDropTarget({ id: null, position: "after" }); + }} + onDrop={(event) => { + event.preventDefault(); + if (draggingId && dropTarget) { + void performMove({ + itemId: draggingId, + targetId: dropTarget.id, + position: dropTarget.position, + }); + } + }} + className={cn( + "relative flex min-w-0 flex-col gap-0.5 [overflow-anchor:none] group-data-[state=collapsed]/sidebar:hidden", + draggingId && "select-none pb-9", + className, + )} + > + + {flat.map((row) => ( + setFocusedId(row.item.id)} + onSelect={() => select(row.item.id)} + onToggle={() => toggle(row.item.id)} + onKeyDown={(event) => handleKeyDown(event, row)} + onRenameStart={() => setRenamingId(row.item.id)} + onRenameCancel={() => setRenamingId(null)} + onRenameCommit={(label) => { + const trimmed = label.trim(); + setRenamingId(null); + if (!trimmed || trimmed === row.item.label) return; + const before = renderedItems; + updateItems(renameResource(before, row.item.id, trimmed)); + void Promise.resolve(onRename?.(row.item, trimmed)).catch(() => { + updateItems(before); + setAnnouncement(`Rename failed. ${row.item.label} was restored.`); + }); + }} + onMenuOpenChange={(open) => { + setMenuOpenId(open ? row.item.id : null); + if (!open) focusRow(row.item.id); + }} + onDragStart={(event, id) => { + setDraggingId(id); + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", id); + }} + onDragEnd={() => { + setDraggingId(null); + setDropTarget(null); + }} + onDragOver={(event, targetRow) => { + if (!draggingId || draggingId === targetRow.item.id) return; + const source = findResource(renderedItems, draggingId); + if (source && containsResource(source, targetRow.item.id)) return; + event.preventDefault(); + event.stopPropagation(); + const rect = event.currentTarget.getBoundingClientRect(); + const ratio = (event.clientY - rect.top) / rect.height; + const position = + !targetRow.item.disabled && + canContain(targetRow.item) && + ratio >= 0.25 && + ratio <= 0.75 + ? "inside" + : ratio < 0.5 + ? "before" + : "after"; + setDropTarget({ id: targetRow.item.id, position }); + }} + onDrop={(event) => { + event.preventDefault(); + event.stopPropagation(); + if (draggingId && dropTarget) { + void performMove({ + itemId: draggingId, + targetId: dropTarget.id, + position: dropTarget.position, + }); + } + }} + renderIcon={renderIcon} + renderMenu={renderMenu} + setRef={(node) => { + if (node) rowRefs.current.set(row.item.id, node); + else rowRefs.current.delete(row.item.id); + }} + /> + ))} + + + {draggingId ? ( + + ) : null} + +
+ + {announcement} + + + ); +} diff --git a/components/agents/approval-card/index.tsx b/components/agents/approval-card/index.tsx new file mode 100644 index 0000000..f2146c1 --- /dev/null +++ b/components/agents/approval-card/index.tsx @@ -0,0 +1,467 @@ +"use client"; + +import { + ArrowLeft, + ArrowRight, + Check, + CircleHelp, + LoaderCircle, + MessageSquareText, + X, +} from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { AgentDisclosure } from "@/components/agents/agent-disclosure"; +import { ActionSwapRollText } from "@/components/motion/action-swap-roll"; +import { Button } from "@/components/motion/button"; +import { Checkbox } from "@/components/motion/checkbox"; +import { Input } from "@/components/motion/input"; +import { RadioGroup, RadioGroupItem } from "@/components/motion/radio"; +import { EASE_OUT, SPRING_SWAP } from "@/lib/ease"; +import { cn } from "@/lib/utils"; +import type { + ApprovalCardAnswer, + ApprovalCardAnswers, + ApprovalCardProps, + ApprovalCardQuestion, + ApprovalCardStatus, +} from "./types"; + +export type { + ApprovalCardAnswer, + ApprovalCardAnswers, + ApprovalCardOption, + ApprovalCardProps, + ApprovalCardQuestion, + ApprovalCardStatus, +} from "./types"; + +const EMPTY_ANSWER: ApprovalCardAnswer = { selected: [], custom: "" }; + +function getStatusLabel(status: ApprovalCardStatus) { + if (status === "submitting") return "Submitting"; + if (status === "approved") return "Approved"; + if (status === "rejected") return "Rejected"; + if (status === "changes-requested") return "Changes requested"; + if (status === "answered") return "Response submitted"; + return "Input required"; +} + +function getStatusClass(status: ApprovalCardStatus) { + if (status === "approved" || status === "answered") { + return "text-emerald-600 dark:text-emerald-400"; + } + if (status === "rejected") return "text-rose-600 dark:text-rose-400"; + if (status === "changes-requested") { + return "text-amber-600 dark:text-amber-400"; + } + return "text-muted-foreground"; +} + +function getStatusBadgeClass(status: ApprovalCardStatus) { + if (status === "pending" || status === "changes-requested") { + return "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400"; + } + if (status === "submitting") { + return "border-blue-500/30 bg-blue-500/10 text-blue-600 dark:text-blue-400"; + } + if (status === "approved" || status === "answered") { + return "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"; + } + return "border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-400"; +} + +function isAnswered(answer: ApprovalCardAnswer) { + return answer.selected.length > 0 || Boolean(answer.custom?.trim()); +} + +function QuestionOptions({ + question, + answer, + disabled, + onChange, + onSingleSelect, +}: { + question: ApprovalCardQuestion; + answer: ApprovalCardAnswer; + disabled: boolean; + onChange: (answer: ApprovalCardAnswer) => void; + onSingleSelect?: () => void; +}) { + const custom = answer.custom ?? ""; + + return ( +
+ {question.options?.length ? ( + question.multiple ? ( +
+ {question.options.map((option) => ( + + onChange({ + ...answer, + selected: checked + ? [...answer.selected, option.value] + : answer.selected.filter((value) => value !== option.value), + }) + } + className="min-h-9 rounded-lg px-1.5 py-1" + /> + ))} +
+ ) : ( + { + onChange({ selected: [value], custom: "" }); + onSingleSelect?.(); + }} + className="gap-0.5" + > + {question.options.map((option) => ( + + ))} + + ) + ) : null} + + {question.allowCustom ? ( + + onChange({ + selected: question.multiple ? answer.selected : [], + custom: value, + }) + } + className={cn("p-0.5", question.options?.length && "mt-1.5")} + classNames={{ + field: + "h-10 rounded-xl border-0 bg-background/70 focus-within:bg-background", + input: "px-3 text-sm", + }} + /> + ) : null} +
+ ); +} + +function ProgressDots({ current, ids }: { current: number; ids: string[] }) { + return ( + + + Question {current + 1} of {ids.length} + + {ids.map((id, index) => ( + + ); +} + +export function ApprovalCard({ + title = "Approval required", + description, + children, + questions = [], + status = "pending", + answers, + defaultAnswers = {}, + onAnswersChange, + step, + defaultStep = 0, + onStepChange, + onSubmit, + onApprove, + onReject, + onRequestChanges, + onDismiss, + approveLabel = "Approve", + submitLabel = "Submit response", + result, + className, +}: ApprovalCardProps) { + const reduce = useReducedMotion() ?? false; + const [internalAnswers, setInternalAnswers] = + useState(defaultAnswers); + const [internalStep, setInternalStep] = useState(defaultStep); + const autoAdvanceTimer = useRef(undefined); + const currentAnswers = answers ?? internalAnswers; + const currentStep = Math.min( + Math.max(0, step ?? internalStep), + Math.max(0, questions.length - 1), + ); + const question = questions[currentStep]; + const questionMode = questions.length > 0; + const pending = status === "pending"; + const busy = status === "submitting"; + const interactive = pending || busy; + const currentAnswer = question + ? (currentAnswers[question.id] ?? EMPTY_ANSWER) + : EMPTY_ANSWER; + const displayTitle = question?.title ?? title; + const titleKey = question?.id ?? String(status); + const statusLabel = getStatusLabel(status); + + const clearAutoAdvance = useCallback(() => { + if (autoAdvanceTimer.current === undefined) return; + window.clearTimeout(autoAdvanceTimer.current); + autoAdvanceTimer.current = undefined; + }, []); + + useEffect(() => clearAutoAdvance, [clearAutoAdvance]); + + const setAnswers = useCallback( + (next: ApprovalCardAnswers) => { + if (answers === undefined) setInternalAnswers(next); + onAnswersChange?.(next); + }, + [answers, onAnswersChange], + ); + + const setStep = (next: number) => { + clearAutoAdvance(); + if (step === undefined) setInternalStep(next); + onStepChange?.(next); + }; + + const updateCurrentAnswer = (next: ApprovalCardAnswer) => { + if (!question) return; + setAnswers({ ...currentAnswers, [question.id]: next }); + }; + + const continueQuestion = () => { + if (currentStep < questions.length - 1) { + setStep(currentStep + 1); + return; + } + onSubmit?.(currentAnswers); + }; + + const queueAutoAdvance = () => { + if ( + !question || + question.multiple || + question.autoAdvance === false || + currentStep >= questions.length - 1 || + busy + ) { + return; + } + + clearAutoAdvance(); + autoAdvanceTimer.current = window.setTimeout(() => { + setStep(currentStep + 1); + }, 240); + }; + + return ( +
+
+ + +
+
+

+ + {displayTitle} + +

+ {questionMode && interactive ? ( + + {currentStep + 1}/{questions.length} + + ) : ( + + {statusLabel} + + )} + {onDismiss ? ( + + ) : null} +
+ + + {questionMode && question ? ( + + + {question.description ? ( +

+ {question.description} +

+ ) : null} + +
+
+ ) : ( +
+ {description ? ( +

+ {description} +

+ ) : null} + {children ?
{children}
: null} +
+ )} + + {questionMode ? ( +
+ + item.id)} + /> + +
+ ) : ( +
+ + {onRequestChanges ? ( + + ) : null} + {onReject ? ( + + ) : null} +
+ )} +
+ + {!interactive ? ( +

+ {result ?? statusLabel} +

+ ) : null} +
+
+
+ ); +} diff --git a/components/agents/approval-card/types.ts b/components/agents/approval-card/types.ts new file mode 100644 index 0000000..fd34e6b --- /dev/null +++ b/components/agents/approval-card/types.ts @@ -0,0 +1,56 @@ +import type { ReactNode } from "react"; + +export type ApprovalCardStatus = + | "pending" + | "submitting" + | "approved" + | "rejected" + | "changes-requested" + | "answered"; + +export interface ApprovalCardOption { + value: string; + label: string; + disabled?: boolean; +} + +export interface ApprovalCardQuestion { + id: string; + title: ReactNode; + description?: ReactNode; + options?: ApprovalCardOption[]; + multiple?: boolean; + autoAdvance?: boolean; + allowCustom?: boolean; + customPlaceholder?: string; +} + +export interface ApprovalCardAnswer { + selected: string[]; + custom?: string; +} + +export type ApprovalCardAnswers = Record; + +export interface ApprovalCardProps { + title?: ReactNode; + description?: ReactNode; + children?: ReactNode; + questions?: ApprovalCardQuestion[]; + status?: ApprovalCardStatus; + answers?: ApprovalCardAnswers; + defaultAnswers?: ApprovalCardAnswers; + onAnswersChange?: (answers: ApprovalCardAnswers) => void; + step?: number; + defaultStep?: number; + onStepChange?: (step: number) => void; + onSubmit?: (answers: ApprovalCardAnswers) => void; + onApprove?: () => void; + onReject?: () => void; + onRequestChanges?: () => void; + onDismiss?: () => void; + approveLabel?: ReactNode; + submitLabel?: ReactNode; + result?: ReactNode; + className?: string; +} diff --git a/components/agents/chat-app.tsx b/components/agents/chat-app.tsx new file mode 100644 index 0000000..a7fb685 --- /dev/null +++ b/components/agents/chat-app.tsx @@ -0,0 +1,30 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { AnimatedSidebarProvider } from "@/components/motion/animated-sidebar"; +import { cn } from "@/lib/utils"; + +export type ChatAppProps = ComponentProps & { + sidebarWidth?: string; +}; + +export function ChatApp({ + children, + className, + sidebarWidth = "17rem", + style, + ...props +}: ChatAppProps) { + return ( + + {children} + + ); +} diff --git a/components/agents/citations.tsx b/components/agents/citations.tsx new file mode 100644 index 0000000..65ea3a0 --- /dev/null +++ b/components/agents/citations.tsx @@ -0,0 +1,289 @@ +"use client"; + +import { BookOpenText, ChevronDown, ExternalLink, Globe2 } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { + type ReactNode, + useCallback, + useId, + useState, +} from "react"; +import { AgentDisclosure } from "@/components/agents/agent-disclosure"; +import { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from "@/lib/ease"; +import { getFaviconUrl } from "@/lib/favicon"; +import { cn } from "@/lib/utils"; + +export interface CitationItem { + id: string; + title: ReactNode; + domain?: ReactNode; + url?: string; +} + +export interface CitationsProps { + citations: CitationItem[]; + title?: ReactNode; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + idPrefix?: string; + className?: string; +} + +export interface CitationProps { + citationId: string; + index: number; + /** Must match the related Citations idPrefix. */ + idPrefix: string; + className?: string; +} + +export interface CitationListProps { + citations: CitationItem[]; + idPrefix?: string; + className?: string; +} + +export interface CitationStackProps { + citations: CitationItem[]; + limit?: number; + className?: string; +} + +function citationTargetId(prefix: string, citationId: string) { + return `${prefix}-${citationId.replace(/[^a-zA-Z0-9_-]/g, "-")}`; +} + +export function Citation({ + citationId, + index, + idPrefix, + className, +}: CitationProps) { + return ( + + {index} + + ); +} + +export function CitationFavicon({ + url, + className, +}: { + url?: string; + className?: string; +}) { + const favicon = url ? getFaviconUrl(url) : null; + const [failedUrl, setFailedUrl] = useState(null); + + return ( + + ); +} + +export function CitationStack({ + citations, + limit = 3, + className, +}: CitationStackProps) { + return ( + + ); +} + +function CitationRow({ + citation, + index, + idPrefix, +}: { + citation: CitationItem; + index: number; + idPrefix: string; +}) { + const content = ( + <> + + + + {citation.title} + + {citation.domain ? ( + + {citation.domain} + + ) : null} + + + + {index} + + {citation.url ? ( + + ) : null} + + + ); + const className = + "group/citation flex items-center gap-2 rounded-md px-1.5 py-1 outline-none focus-visible:ring-2 focus-visible:ring-ring"; + const id = citationTargetId(idPrefix, citation.id); + + return citation.url ? ( + + {content} + + ) : ( +
+ {content} +
+ ); +} + +export function CitationList({ + citations, + idPrefix, + className, +}: CitationListProps) { + const reduce = useReducedMotion() ?? false; + const baseId = useId(); + const resolvedPrefix = + idPrefix ?? `citation-list-${baseId.replace(/:/g, "")}`; + + return ( +
+ + {citations.map((citation, index) => ( + + + + ))} + +
+ ); +} + +export function Citations({ + citations, + title = "Sources", + open, + defaultOpen = false, + onOpenChange, + idPrefix, + className, +}: CitationsProps) { + const reduce = useReducedMotion() ?? false; + const baseId = useId(); + const contentId = `${baseId}-content`; + const resolvedPrefix = + idPrefix ?? `citation-${baseId.replace(/:/g, "")}`; + const [internalOpen, setInternalOpen] = useState(defaultOpen); + const currentOpen = open ?? internalOpen; + const setOpen = useCallback( + (next: boolean) => { + if (open === undefined) setInternalOpen(next); + onOpenChange?.(next); + }, + [onOpenChange, open], + ); + + return ( +
+ + + + + +
+ ); +} diff --git a/components/agents/code-block.tsx b/components/agents/code-block.tsx new file mode 100644 index 0000000..978296d --- /dev/null +++ b/components/agents/code-block.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { Check, Copy, FileCode2, LoaderCircle } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; +import { + type ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + type AgentCodeLanguage, + AgentCodeLine, + useAgentCodeTokens, +} from "@/components/agents/agent-code"; +import { SPRING_PRESS } from "@/lib/ease"; +import { cn } from "@/lib/utils"; + +export type CodeBlockStatus = "streaming" | "complete"; + +export interface CodeBlockProps { + code: string; + language?: AgentCodeLanguage; + filename?: ReactNode; + status?: CodeBlockStatus; + showLineNumbers?: boolean; + highlightLines?: number[]; + maxHeight?: number; + wrap?: boolean; + copyable?: boolean; + onCopy?: () => void | Promise; + className?: string; +} + +export function CodeBlock({ + code, + language = "typescript", + filename, + status = "complete", + showLineNumbers = true, + highlightLines = [], + maxHeight = 280, + wrap = false, + copyable = true, + onCopy, + className, +}: CodeBlockProps) { + const reduce = useReducedMotion() ?? false; + const viewportRef = useRef(null); + const copyTimer = useRef(undefined); + const [copied, setCopied] = useState(false); + const streaming = status === "streaming"; + const tokens = useAgentCodeTokens(code, language); + const highlighted = useMemo( + () => new Set(highlightLines), + [highlightLines], + ); + let offset = 0; + const lines = code.split("\n").map((content) => { + const line = { content, offset }; + offset += content.length + 1; + return line; + }); + + useEffect( + () => () => { + if (copyTimer.current) window.clearTimeout(copyTimer.current); + }, + [], + ); + + useLayoutEffect(() => { + const viewport = viewportRef.current; + if (!viewport || !streaming) return; + + const frame = requestAnimationFrame(() => { + if (viewport.scrollHeight <= viewport.clientHeight) return; + if (typeof viewport.scrollTo === "function") { + viewport.scrollTo({ + top: viewport.scrollHeight, + behavior: reduce ? "auto" : "smooth", + }); + } else { + viewport.scrollTop = viewport.scrollHeight; + } + }); + return () => cancelAnimationFrame(frame); + }); + + const handleCopy = useCallback(async () => { + if (onCopy) await onCopy(); + else await navigator.clipboard?.writeText(code); + + setCopied(true); + if (copyTimer.current) window.clearTimeout(copyTimer.current); + copyTimer.current = window.setTimeout(() => setCopied(false), 1600); + }, [code, onCopy]); + + return ( +
+
+
+ +
+
+          
+            {lines.map((line, index) => {
+              const lineNumber = index + 1;
+              return (
+                
+                  {showLineNumbers ? (
+                    
+                      {lineNumber}
+                    
+                  ) : null}
+                  
+                
+              );
+            })}
+          
+        
+
+
+ ); +} diff --git a/components/agents/file-diff.tsx b/components/agents/file-diff.tsx new file mode 100644 index 0000000..254bc57 --- /dev/null +++ b/components/agents/file-diff.tsx @@ -0,0 +1,288 @@ +"use client"; + +import { + Check, + ChevronDown, + Copy, + FileCode2, + LoaderCircle, +} from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; +import { + type ReactNode, + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { + type AgentCodeLanguage, + AgentCodeLine, + useAgentCodeTokens, +} from "@/components/agents/agent-code"; +import { AgentDisclosure } from "@/components/agents/agent-disclosure"; +import { SPRING_PRESS, SPRING_SWAP } from "@/lib/ease"; +import { cn } from "@/lib/utils"; + +export type FileDiffStatus = "streaming" | "complete"; +export type FileDiffLineType = "added" | "removed" | "context"; + +export interface FileDiffLine { + id: string; + type?: FileDiffLineType; + oldLine?: number; + newLine?: number; + content: string; +} + +export interface FileDiffProps { + file: ReactNode; + lines: FileDiffLine[]; + status?: FileDiffStatus; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + collapseOnComplete?: boolean; + maxHeight?: number; + language?: AgentCodeLanguage; + copyText?: string; + onCopy?: () => void | Promise; + className?: string; +} + +function ChangeCount({ value, type }: { value: number; type: "added" | "removed" }) { + if (!value) return null; + return ( + + {type === "added" ? "+" : "−"} + {value} + + ); +} + +export function FileDiff({ + file, + lines, + status = "streaming", + open, + defaultOpen = true, + onOpenChange, + collapseOnComplete = true, + maxHeight = 220, + language = "typescript", + copyText, + onCopy, + className, +}: FileDiffProps) { + const reduce = useReducedMotion() ?? false; + const baseId = useId(); + const triggerId = `${baseId}-trigger`; + const contentId = `${baseId}-content`; + const viewportRef = useRef(null); + const previousStatus = useRef(status); + const copyTimer = useRef(undefined); + const [copied, setCopied] = useState(false); + const [internalOpen, setInternalOpen] = useState(defaultOpen); + const currentOpen = open ?? internalOpen; + const streaming = status === "streaming"; + const additions = lines.filter((line) => line.type === "added").length; + const deletions = lines.filter((line) => line.type === "removed").length; + const canCopy = Boolean(copyText || onCopy); + const code = lines.map((line) => line.content).join("\n"); + const tokens = useAgentCodeTokens(code, language); + + const setOpen = useCallback( + (next: boolean) => { + if (open === undefined) setInternalOpen(next); + onOpenChange?.(next); + }, + [onOpenChange, open], + ); + + useEffect(() => { + if (previousStatus.current !== "streaming" && status === "streaming") { + setOpen(true); + } + if ( + previousStatus.current === "streaming" && + status === "complete" && + collapseOnComplete + ) { + setOpen(false); + } + previousStatus.current = status; + }, [collapseOnComplete, setOpen, status]); + + useEffect( + () => () => { + if (copyTimer.current) window.clearTimeout(copyTimer.current); + }, + [], + ); + + useLayoutEffect(() => { + const viewport = viewportRef.current; + if (!viewport || !currentOpen || !streaming) return; + + const frame = requestAnimationFrame(() => { + if (viewport.scrollHeight <= viewport.clientHeight) return; + if (typeof viewport.scrollTo === "function") { + viewport.scrollTo({ + top: viewport.scrollHeight, + behavior: reduce ? "auto" : "smooth", + }); + } else { + viewport.scrollTop = viewport.scrollHeight; + } + }); + return () => cancelAnimationFrame(frame); + }); + + const handleCopy = useCallback(async () => { + if (onCopy) await onCopy(); + else if (copyText) await navigator.clipboard?.writeText(copyText); + + setCopied(true); + if (copyTimer.current) window.clearTimeout(copyTimer.current); + copyTimer.current = window.setTimeout(() => setCopied(false), 1600); + }, [copyText, onCopy]); + + return ( +
+ + + +
+
+
+
+ File changes + {lines.map((line, index) => { + const type = line.type ?? "context"; + return ( +
+ + {line.oldLine} + + + {line.newLine} + + + {type === "added" + ? "+" + : type === "removed" + ? "−" + : ""} + + +
+ ); + })} +
+
+ + {canCopy ? ( +
+ + {copied ? ( + + ) : ( + + )} + +
+ ) : null} +
+
+
+
+ ); +} diff --git a/components/agents/image-generation.tsx b/components/agents/image-generation.tsx new file mode 100644 index 0000000..d588a54 --- /dev/null +++ b/components/agents/image-generation.tsx @@ -0,0 +1,388 @@ +"use client"; + +import { Check, CircleAlert, RotateCcw } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import type { CSSProperties, ReactNode } from "react"; +import { useEffect, useRef } from "react"; +import { EASE_IN_OUT, EASE_OUT, SPRING_PRESS } from "@/lib/ease"; +import { useHoverCapable } from "@/lib/hooks/use-hover-capable"; +import { cn } from "@/lib/utils"; + +export type ImageGenerationStatus = + | "queued" + | "generating" + | "refining" + | "complete" + | "error"; + +export interface ImageGenerationProps { + /** The completed media. Pass an img, Next Image, canvas, video, or custom preview. */ + children?: ReactNode; + status?: ImageGenerationStatus; + /** Accessible description. Defaults to a description derived from prompt. */ + label?: string; + prompt?: string; + resolution?: string; + /** CSS aspect ratio reserved before generated media is available. */ + aspectRatio?: CSSProperties["aspectRatio"]; + size?: "compact" | "fluid"; + /** Lets the active dither cluster follow fine-pointer movement. */ + interactive?: boolean; + statusText?: string; + showStatus?: boolean; + onRetry?: () => void; + className?: string; + mediaClassName?: string; + statusClassName?: string; +} + +const STATUS_TEXT: Record = { + queued: "Waiting to generate", + generating: "Generating image", + refining: "Refining details", + complete: "Image ready", + error: "Generation failed", +}; + +const MEDIA_STATE: Record< + ImageGenerationStatus, + { filter: string; opacity: number; scale: number } +> = { + queued: { filter: "blur(4px) saturate(0.75)", opacity: 0, scale: 1.02 }, + generating: { filter: "blur(3px) saturate(0.85)", opacity: 0, scale: 1.015 }, + refining: { filter: "blur(1.5px) saturate(0.95)", opacity: 0.62, scale: 1.005 }, + complete: { filter: "blur(0px) saturate(1)", opacity: 1, scale: 1 }, + error: { filter: "blur(2px) saturate(0.5)", opacity: 0.28, scale: 1 }, +}; + +const OVERLAY_OPACITY: Record = { + queued: 1, + generating: 1, + refining: 0.48, + complete: 0, + error: 0, +}; + +const DOT_GAP = 10; +const TWO_PI = Math.PI * 2; + +function DitherMark({ + status, + reduce, +}: { + status: ImageGenerationStatus; + reduce: boolean; +}) { + if (status === "complete") { + return