diff --git a/apps/apollo-vertex/app/experiment/_meta.ts b/apps/apollo-vertex/app/experiment/_meta.ts new file mode 100644 index 000000000..39bcde8e4 --- /dev/null +++ b/apps/apollo-vertex/app/experiment/_meta.ts @@ -0,0 +1,4 @@ +export default { + dashboard: "Dashboard", + "product-demo": "Product Demo", +}; diff --git a/apps/apollo-vertex/app/experiment/dashboard/page.mdx b/apps/apollo-vertex/app/experiment/dashboard/page.mdx new file mode 100644 index 000000000..73bf6b217 --- /dev/null +++ b/apps/apollo-vertex/app/experiment/dashboard/page.mdx @@ -0,0 +1,149 @@ +import { DashboardTemplate } from '@/templates/dashboard/DashboardTemplateDynamic'; +import { PreviewFullScreen } from '@/app/_components/preview-full-screen'; + +# Dashboard + +A configurable dashboard template for building AI-assisted operational views. The dashboard is composed of named card regions with defined interaction patterns, designed to be data-driven and adaptable across verticals. + +## Previews + +### Sidebar Shell + + + + + +Open standalone preview → + +### Minimal Header Shell + + + + + +Open standalone preview → + +--- + +## Anatomy + +The dashboard is built from four semantic regions, each with a distinct role: + +| Region | Description | +|---|---| +| **Overview Card** | The primary narrative area. Displays a greeting, headline, and subhead that summarize the current state. Occupies the top-left of the layout. | +| **Prompt Bar** | The AI input surface. Supports text entry, recommendation chips, and an expand interaction that opens an inline chat view. Anchored below the overview card. | +| **Insight Cards** | A grid of data cards on the right side. Each card has a type, a chart visualization, and an interaction pattern. Cards are arranged in rows of two. | +| **Autopilot Panel** | A slide-in panel that provides AI-generated analysis for a specific insight card. Triggered from the card's autopilot icon. | + +## Card Types + +Every insight card has a `type` that determines what it displays: + +| Type | Renders | Example | +|---|---|---| +| `kpi` | A large number, badge, and description | "97.1%" with "+2.4%" badge | +| `chart` | A data visualization determined by `chartType` | Horizontal bars, stacked bars | + +## Chart Types + +Cards with `type: "chart"` use a `chartType` to select the visualization: + +| Chart Type | Description | +|---|---| +| `horizontal-bars` | Ranked list of items with proportional bars and percentages | +| `stacked-bar` | Grouped bars with color-coded segments and a legend | +| `donut` | Circular progress indicator with a center label | +| `sparkline` | Compact line chart for trend indication | +| `area` | Filled area chart for volume over time | + +## Card Sizes + +Each card has a `size` that controls its column weight in the grid: + +| Size | Grid Weight | Use Case | +|---|---|---| +| `sm` | `1fr` | KPI cards, compact metrics | +| `md` | `2fr` | Chart cards, detailed visualizations | +| `lg` | `1fr` | Full-width cards | + +## Interaction Patterns + +Cards support three interaction modes: + +### Static + +No interactive behavior. The card displays its content without any hover or click affordance. Used for simple KPIs that don't need drill-down. + +### Navigate + +On hover, an **arrow-up-right** icon appears in the card header. Clicking the card navigates to a detail page. Used for KPIs or summaries that link to a deeper view. + +### Expand + +On hover, a **maximize** icon appears in the card header. Clicking expands the card to fill the grid with a multi-phase animation: + +1. **Width phase** — Card expands horizontally, sibling card collapses +2. **Height phase** — Card expands vertically, other rows collapse +3. **Full phase** — Expanded content fades in (drilldown tabs, autopilot prompts) + +Clicking the **minimize** icon or another card collapses back to the grid. + +For `horizontal-bars` cards, the expanded state includes **drilldown tabs** below the title for switching between data views. + +## Prompt Bar + +The prompt bar supports three ways to expand into the inline chat view: + +| Trigger | Behavior | +|---|---| +| **Type + Enter / Submit** | Expands and passes the user's typed query | +| **Click a recommendation chip** | Expands and passes the chip text as the query | +| **Click the chat icon** | Expands and shows the current session conversation | + +When expanded, the overview card collapses and the prompt bar grows to fill the left column. A **minimize** icon in the header collapses back to the default layout. + +## Autopilot Panel + +Each expandable card includes an **autopilot** icon alongside the expand icon. Clicking it slides in a panel from the right that provides AI-generated context for that card. The dashboard content shifts left to make room. Clicking the autopilot icon again or the close button dismisses the panel. + +## Data Configuration + +The dashboard is driven by a `DashboardDataset` object that defines all text and card content: + +```ts +interface DashboardDataset { + name: string; // Dataset identifier + brandName: string; // Company name in header + brandLine: string; // Tagline in header + dashboardTitle: string; // Page title + badgeText: string; // Badge next to title + greeting: string; // Overview card greeting + headline: string; // Overview card headline + subhead: string; // Overview card description + promptPlaceholder: string; // Prompt bar placeholder text + promptSuggestions: string[]; // Recommendation chips + insightCards: [ // Exactly 4 insight cards + InsightCardData, + InsightCardData, + InsightCardData, + InsightCardData, + ]; +} +``` + +Each `InsightCardData` specifies its title, type, chart type, size, interaction, and the data for its visualization (KPI values, bar data, stacked segments, etc.). + +## Layout Modes + +The dashboard responds to container width: + +| Mode | Breakpoint | Behavior | +|---|---|---| +| **Desktop** | ≥ 1100px | Two-column layout, full card grid | +| **Compact** | 800–1099px | Two-column layout, condensed card content | +| **Stacked** | < 800px | Single-column, vertically stacked | + +## Theming + +Card backgrounds, glow effects, and gradients are configurable through `CardConfig` and `GlowConfig` objects. The dashboard supports light and dark modes with independent styling for each. Color tokens and theme values should be updated in `registry.json`, not directly in CSS files. diff --git a/apps/apollo-vertex/app/experiment/product-demo/page.tsx b/apps/apollo-vertex/app/experiment/product-demo/page.tsx new file mode 100644 index 000000000..0d7027fad --- /dev/null +++ b/apps/apollo-vertex/app/experiment/product-demo/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { DashboardTemplate } from "@/templates/dashboard/DashboardTemplateDynamic"; +import { invoiceProcessingDataset } from "@/templates/dashboard/dashboard-data"; + +export default function ProductDemoPage() { + return ( +
+ +
+ ); +} diff --git a/apps/apollo-vertex/app/globals.css b/apps/apollo-vertex/app/globals.css index fa5f28f21..f93de6793 100644 --- a/apps/apollo-vertex/app/globals.css +++ b/apps/apollo-vertex/app/globals.css @@ -1,4 +1,6 @@ @import 'tailwindcss'; +@source "../registry"; +@source "../templates"; /* Optional: import Nextra theme styles */ @import 'nextra-theme-docs/style.css'; diff --git a/apps/apollo-vertex/app/patterns/ai-chat/page.mdx b/apps/apollo-vertex/app/patterns/ai-chat/page.mdx index b28514cef..9aad19945 100644 --- a/apps/apollo-vertex/app/patterns/ai-chat/page.mdx +++ b/apps/apollo-vertex/app/patterns/ai-chat/page.mdx @@ -1,12 +1,15 @@ import { AiChatTemplate } from '@/templates/AiChatTemplate'; +import { PreviewFullScreen } from '@/app/_components/preview-full-screen'; # AI Chat A composable AI chat UI component for Apollo Vertex. Built with React, TypeScript, and Tailwind CSS. Designed to work with [TanStack AI](https://tanstack.com/ai) — you bring `useChat` and a connection adapter, the component handles the chrome (scroll, input, loading, suggestions, errors) while you control how messages and tool calls render. -
- -
+ + + + +> **All visual states and sub-components →** [Component Preview](/vertex-components/ai-chat/preview) ## Features diff --git a/apps/apollo-vertex/app/preview/_meta.ts b/apps/apollo-vertex/app/preview/_meta.ts new file mode 100644 index 000000000..e48eafb8a --- /dev/null +++ b/apps/apollo-vertex/app/preview/_meta.ts @@ -0,0 +1,3 @@ +export default { + "*": { display: "hidden" }, +}; diff --git a/apps/apollo-vertex/app/preview/dashboard-minimal/page.tsx b/apps/apollo-vertex/app/preview/dashboard-minimal/page.tsx new file mode 100644 index 000000000..deba5cb66 --- /dev/null +++ b/apps/apollo-vertex/app/preview/dashboard-minimal/page.tsx @@ -0,0 +1,19 @@ +"use client"; + +import dynamic from "next/dynamic"; + +const DashboardTemplate = dynamic( + () => + import("@/templates/dashboard/DashboardTemplate").then( + (mod) => mod.DashboardTemplate, + ), + { ssr: false }, +); + +export default function DashboardMinimalPreviewPage() { + return ( +
+ +
+ ); +} diff --git a/apps/apollo-vertex/app/preview/dashboard/page.tsx b/apps/apollo-vertex/app/preview/dashboard/page.tsx new file mode 100644 index 000000000..80046c1ad --- /dev/null +++ b/apps/apollo-vertex/app/preview/dashboard/page.tsx @@ -0,0 +1,20 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { invoiceProcessingDataset } from "@/templates/dashboard/dashboard-data"; + +const DashboardTemplate = dynamic( + () => + import("@/templates/dashboard/DashboardTemplate").then( + (mod) => mod.DashboardTemplate, + ), + { ssr: false }, +); + +export default function DashboardPreviewPage() { + return ( +
+ +
+ ); +} diff --git a/apps/apollo-vertex/app/vertex-components/ai-chat/preview/mock-data.ts b/apps/apollo-vertex/app/vertex-components/ai-chat/preview/mock-data.ts new file mode 100644 index 000000000..22630face --- /dev/null +++ b/apps/apollo-vertex/app/vertex-components/ai-chat/preview/mock-data.ts @@ -0,0 +1,218 @@ +import type { UIMessage } from "@tanstack/ai-client"; + +export const MOCK_MESSAGES_BASIC: UIMessage[] = [ + { + id: "1", + role: "user", + parts: [{ type: "text", content: "What is Apollo Design System?" }], + createdAt: new Date(Date.now() - 120000), + }, + { + id: "2", + role: "assistant", + parts: [ + { + type: "text", + content: + "Apollo is UiPath's open-source design system. It provides a unified component library with **design tokens**, **React components**, and **Web Components** for building consistent user interfaces.", + }, + ], + createdAt: new Date(Date.now() - 60000), + }, +]; + +export const MOCK_MESSAGES_MARKDOWN: UIMessage[] = [ + { + id: "1", + role: "user", + parts: [ + { + type: "text", + content: "Show me all the markdown formatting you support.", + }, + ], + createdAt: new Date(), + }, + { + id: "2", + role: "assistant", + parts: [ + { + type: "text", + content: `Here's a comprehensive demo of supported markdown: + +## Headings & Text + +This is a paragraph with **bold**, *italic*, and \`inline code\`. + +> This is a blockquote with some important information. + +--- + +## Lists + +**Unordered list:** +- First item +- Second item with **bold** +- Third item + +**Ordered list:** +1. Step one +2. Step two +3. Step three + +## Code Block + +\`\`\`typescript +interface AiChatProps { + messages: UIMessage[]; + isLoading: boolean; + onSendMessage: (content: string) => void; +} +\`\`\` + +## Table + +| Component | Status | Description | +|-----------|--------|-------------| +| AiChat | Ready | Main chat shell | +| AiChatMessage | Ready | Message renderer | +| AiChatInput | Ready | Text input | +| AiChatMarkdown | Ready | Markdown renderer | + +## Links + +Check [Apollo UI](https://github.com/UiPath/apollo-ui) for more info.`, + }, + ], + createdAt: new Date(), + }, +]; + +export const MOCK_MESSAGES_CONVERSATION: UIMessage[] = [ + { + id: "1", + role: "user", + parts: [{ type: "text", content: "Can you help me set up a new project?" }], + createdAt: new Date(Date.now() - 300000), + }, + { + id: "2", + role: "assistant", + parts: [ + { + type: "text", + content: + "Of course! I'd be happy to help. What kind of project are you looking to create?", + }, + ], + createdAt: new Date(Date.now() - 240000), + }, + { + id: "3", + role: "user", + parts: [ + { + type: "text", + content: "A React app with Tailwind CSS and TypeScript.", + }, + ], + createdAt: new Date(Date.now() - 180000), + }, + { + id: "4", + role: "assistant", + parts: [ + { + type: "text", + content: + "Great choice! Here's how to get started:\n\n1. Run `npx create-next-app@latest`\n2. Select **TypeScript** and **Tailwind CSS** during setup\n3. Install Apollo components:\n\n```bash\nnpx shadcn@latest add @uipath/button\n```\n\nWould you like me to walk through any of these steps in detail?", + }, + ], + createdAt: new Date(Date.now() - 120000), + }, + { + id: "5", + role: "user", + parts: [{ type: "text", content: "Yes, explain step 3 please." }], + createdAt: new Date(Date.now() - 60000), + }, + { + id: "6", + role: "assistant", + parts: [ + { + type: "text", + content: + "The `shadcn` CLI copies component source code directly into your project. When you run the command, it will:\n\n- Create `components/ui/button.tsx` in your project\n- Add any required dependencies to `package.json`\n- Set up the `cn()` utility if not present\n\nYou own the code — modify it however you need.", + }, + ], + createdAt: new Date(), + }, +]; + +export const MOCK_MESSAGES_WITH_CHOICES: UIMessage[] = [ + { + id: "1", + role: "user", + parts: [{ type: "text", content: "What presentation style should I use?" }], + createdAt: new Date(), + }, + { + id: "2", + role: "assistant", + parts: [ + { + type: "text", + content: "Here are some options based on your audience:", + }, + { + type: "tool-call", + id: "call-1", + name: "presentChoices", + arguments: "{}", + state: "input-complete" as const, + }, + { + type: "tool-result", + toolCallId: "call-1", + state: "complete" as const, + content: JSON.stringify({ + type: "choices", + prompt: "Pick a presentation style:", + options: [ + { id: "1", label: "Executive Summary", recommended: true }, + { id: "2", label: "Technical Deep Dive" }, + { id: "3", label: "Workshop Format" }, + ], + }), + }, + ], + createdAt: new Date(), + }, +]; + +export const MOCK_SOURCES_BASIC: Record< + string, + { label: string; url: string }[] +> = { + "2": [ + { label: "Apollo UI GitHub", url: "https://github.com/UiPath/apollo-ui" }, + { label: "Tailwind CSS", url: "https://tailwindcss.com" }, + { label: "shadcn/ui", url: "https://ui.shadcn.com" }, + ], +}; + +export const MOCK_ATTACHMENTS_BASIC: Record< + string, + { name: string; type: string; size: number }[] +> = { + "1": [{ name: "Q4_Report.pdf", type: "application/pdf", size: 245000 }], +}; + +export const MOCK_CHOICE_OPTIONS = [ + { id: "1", label: "Option A — Recommended", recommended: true }, + { id: "2", label: "Option B — Alternative" }, + { id: "3", label: "Option C — Experimental" }, + { id: "4", label: "Option D — Conservative" }, +]; diff --git a/apps/apollo-vertex/app/vertex-components/ai-chat/preview/page.tsx b/apps/apollo-vertex/app/vertex-components/ai-chat/preview/page.tsx new file mode 100644 index 000000000..c6de1cef7 --- /dev/null +++ b/apps/apollo-vertex/app/vertex-components/ai-chat/preview/page.tsx @@ -0,0 +1,523 @@ +// oxlint-disable eslint/max-lines -- preview page documents many visual states +"use client"; + +import { Maximize2, Minimize2 } from "lucide-react"; +import { type ReactNode, useState } from "react"; +import { AiChat } from "@/registry/ai-chat/components/ai-chat"; +import { AiChatCodeBlock } from "@/registry/ai-chat/components/ai-chat-code-block"; +import { AiChatEmptyState } from "@/registry/ai-chat/components/ai-chat-empty-state"; +import { AiChatFlow } from "@/registry/ai-chat/components/ai-chat-flow"; +import { AiChatInput } from "@/registry/ai-chat/components/ai-chat-input"; +import { AiChatMarkdown } from "@/registry/ai-chat/components/ai-chat-markdown"; +import { AiChatMessage } from "@/registry/ai-chat/components/ai-chat-message"; +import { AiChatMessageActions } from "@/registry/ai-chat/components/ai-chat-message-actions"; +import { AiChatSuggestions } from "@/registry/ai-chat/components/ai-chat-suggestions"; +import type { ToolResultFlow } from "@/registry/ai-chat/utils/ai-chat-utils"; +import { Button } from "@/registry/button/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogTitle, + DialogTrigger, +} from "@/registry/dialog/dialog"; +import { + MOCK_ATTACHMENTS_BASIC, + MOCK_CHOICE_OPTIONS, + MOCK_MESSAGES_BASIC, + MOCK_MESSAGES_CONVERSATION, + MOCK_MESSAGES_MARKDOWN, + MOCK_MESSAGES_WITH_CHOICES, + MOCK_SOURCES_BASIC, +} from "./mock-data"; +import { ThinkingDemo } from "./thinking-demo"; + +function noop(..._args: unknown[]) { + // no-op for preview +} + +const MOCK_FLOW: ToolResultFlow = { + type: "flow", + steps: [ + { + id: "step-1", + prompt: "What type of content are you creating?", + options: [ + { id: "blog", label: "Blog post" }, + { id: "email", label: "Email campaign" }, + { id: "social", label: "Social media posts" }, + ], + }, + { + id: "step-2", + prompt: "Who is your target audience?", + options: [ + { id: "developers", label: "Developers" }, + { id: "business", label: "Business leaders" }, + { id: "general", label: "General audience" }, + ], + canSkip: true, + }, + { + id: "step-3", + prompt: "What tone would you like?", + options: [ + { id: "professional", label: "Professional" }, + { id: "casual", label: "Casual & friendly" }, + { id: "technical", label: "Technical & precise" }, + ], + }, + ], +}; + +function FlowDemo() { + const [flowKey, setFlowKey] = useState(0); + const [result, setResult] = useState(null); + + if (result !== null) { + return ( +
+

+ {result} +

+ +
+ ); + } + + return ( + { + setResult( + answers.map((a, i) => `Step ${i + 1}: ${a.answer}`).join(" · "), + ); + }} + onDismiss={() => { + setResult("(dismissed)"); + }} + /> + ); +} + +function SectionHeader({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( +
+

{title}

+

{description}

+
+ ); +} + +function PreviewCard({ + children, + className, + title, +}: { + children: ReactNode; + className?: string; + title?: string; +}) { + const [isOpen, setIsOpen] = useState(false); + const baseClass = `border rounded-lg bg-background overflow-hidden ${className ?? ""}`; + + if (!title) { + return
{children}
; + } + + return ( + +
+ {!isOpen && children} + + + +
+ + {title} + + + +
{children}
+
+
+ ); +} + +function renderBasicMsg(msg: (typeof MOCK_MESSAGES_BASIC)[number]) { + return ( + + ); +} + +export default function AiChatPreviewPage() { + return ( +
+
+

+ {"AI Chat — Component Preview"} +

+

+ {"All visual states and sub-components rendered with mock data."} +

+
+ + {/* ── 1. Empty State (default) ────────────────────────── */} +
+ + + + +
+ + {/* ── 2. Empty State (with suggestions) ──────────────── */} +
+ + + + } + suggestions={["Create a React app", "Debug my code", "Write tests"]} + /> + +
+ + {/* ── 3. Basic Conversation with timestamps ──────────── */} +
+ + + + {MOCK_MESSAGES_BASIC.map((msg) => renderBasicMsg(msg))} + + +
+ + {/* ── 4. Multi-turn Conversation ─────────────────────── */} +
+ + + + {MOCK_MESSAGES_CONVERSATION.map((msg) => ( + + ))} + + +
+ + {/* ── 5. Markdown + Code Block ───────────────────────── */} +
+ + + + {MOCK_MESSAGES_MARKDOWN.map((msg) => ( + + ))} + + +
+ + {/* ── 6. Loading State ───────────────────────────────── */} +
+ +
+ + + {renderBasicMsg(MOCK_MESSAGES_BASIC[0])} + + + +

+ {"Thinking indicator (interactive)"} +

+ +
+
+
+ + {/* ── 7. Error + Retry ───────────────────────────────── */} +
+ + + + {MOCK_MESSAGES_BASIC.map((msg) => renderBasicMsg(msg))} + + +
+ + {/* ── 8. Suggestion Buttons ──────────────────────────── */} +
+ + + + {MOCK_MESSAGES_WITH_CHOICES.map((msg) => ( + + ))} + + +
+ + {/* ── 9. Message Actions ─────────────────────────────── */} +
+ +
+ +

+ {"Assistant actions"} +

+ +
+ +

+ {"User actions"} +

+ +
+
+
+ + {/* ── 10. Edit Mode ──────────────────────────────────── */} +
+ + + + {MOCK_MESSAGES_BASIC.map((msg) => renderBasicMsg(msg))} + + +
+ + {/* ── 11. Isolated Sub-Components ────────────────────── */} +
+ +
+ +

+ {"AiChatSuggestions (with recommended)"} +

+ +
+ + +

+ {"AiChatFlow (multi-step)"} +

+ +
+ + +

+ {"AiChatInput (with text + attachment)"} +

+ +
+ + +

+ {"AiChatCodeBlock"} +

+ + {`import { AiChat } from "@/components/ui/ai-chat"; + +function App() { + return ; +}`} + +
+ + +

+ {"AiChatMarkdown (standalone)"} +

+ + {`Here's a quick example with **bold**, *italic*, \`code\`, and a [link](https://example.com).\n\n| Feature | Supported |\n|---------|----------|\n| Tables | Yes |\n| Code | Yes |`} + +
+
+
+
+ ); +} diff --git a/apps/apollo-vertex/app/vertex-components/ai-chat/preview/thinking-demo.tsx b/apps/apollo-vertex/app/vertex-components/ai-chat/preview/thinking-demo.tsx new file mode 100644 index 000000000..12041faa1 --- /dev/null +++ b/apps/apollo-vertex/app/vertex-components/ai-chat/preview/thinking-demo.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { useState } from "react"; +import { AiChatThinking } from "@/registry/ai-chat/components/ai-chat-thinking"; +import { useThinkingLabel } from "@/registry/ai-chat/hooks/use-thinking-label"; +import { Button } from "@/registry/button/button"; + +const ENTRANCE_EASE = [0.22, 1, 0.36, 1] as const; +const LABEL_SLIDE = 6; + +const shimmerStyle = { + display: "inline-block", + whiteSpace: "nowrap", + lineHeight: 1.3, + fontSize: "14px", + fontWeight: 500, + backgroundImage: + "linear-gradient(90deg, var(--muted-foreground) 0%, var(--muted-foreground) 30%, #6C5AEF 42%, var(--foreground) 50%, #69C7DD 58%, var(--muted-foreground) 70%, var(--muted-foreground) 100%)", + backgroundSize: "200% 100%", + backgroundClip: "text", + WebkitBackgroundClip: "text", + color: "transparent", + animation: "ap-chat-shimmer-thinking 2.4s linear infinite", +} as const; + +export function ThinkingDemo() { + const [isThinking, setIsThinking] = useState(false); + const { label, key: labelKey } = useThinkingLabel(); + + return ( +
+ +
+ + + + + {isThinking ? label : "Thinking…"} + + + +
+ +
+ ); +} diff --git a/apps/apollo-vertex/lib/auth.ts b/apps/apollo-vertex/lib/auth.ts index 3f8cfcc4e..3dff14828 100644 --- a/apps/apollo-vertex/lib/auth.ts +++ b/apps/apollo-vertex/lib/auth.ts @@ -1,4 +1,4 @@ -import { useQueryClient } from "@tanstack/react-query"; +import type { useQueryClient } from "@tanstack/react-query"; import PKCEChallenge from "pkce-challenge"; import { toast } from "sonner"; import { z } from "zod"; diff --git a/apps/apollo-vertex/package.json b/apps/apollo-vertex/package.json index b46366cbf..2d75a2fc8 100644 --- a/apps/apollo-vertex/package.json +++ b/apps/apollo-vertex/package.json @@ -69,6 +69,7 @@ "embla-carousel-react": "^8.6.0", "eventsource-parser": "^3.0.6", "framer-motion": "^12.26.2", + "highlight.js": "^11.11.1", "i18next": "^25.8.1", "input-otp": "^1.4.2", "jwt-decode": "^4.0.0", diff --git a/apps/apollo-vertex/public/Autopilot_dark.svg b/apps/apollo-vertex/public/Autopilot_dark.svg new file mode 100644 index 000000000..5f35c08dc --- /dev/null +++ b/apps/apollo-vertex/public/Autopilot_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/apollo-vertex/public/Autopilot_light.svg b/apps/apollo-vertex/public/Autopilot_light.svg new file mode 100644 index 000000000..4f28c9a53 --- /dev/null +++ b/apps/apollo-vertex/public/Autopilot_light.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/apollo-vertex/registry.json b/apps/apollo-vertex/registry.json index 170d79712..b1bac5c2f 100644 --- a/apps/apollo-vertex/registry.json +++ b/apps/apollo-vertex/registry.json @@ -66,6 +66,30 @@ "color-sidebar-accent-foreground": "var(--sidebar-accent-foreground)", "color-sidebar-border": "var(--sidebar-border)", "color-sidebar-ring": "var(--sidebar-ring)", + "color-insight-50": "var(--insight-50)", + "color-insight-100": "var(--insight-100)", + "color-insight-200": "var(--insight-200)", + "color-insight-300": "var(--insight-300)", + "color-insight-400": "var(--insight-400)", + "color-insight-500": "var(--insight-500)", + "color-insight-600": "var(--insight-600)", + "color-insight-700": "var(--insight-700)", + "color-insight-800": "var(--insight-800)", + "color-insight-900": "var(--insight-900)", + "color-ai-chat": "var(--ai-chat)", + "color-ai-chat-foreground": "var(--ai-chat-foreground)", + "color-ai-chat-accent": "var(--ai-chat-accent)", + "color-ai-chat-accent-foreground": "var(--ai-chat-accent-foreground)", + "color-ai-chat-bubble-user": "var(--ai-chat-bubble-user)", + "color-ai-chat-bubble-user-foreground": "var(--ai-chat-bubble-user-foreground)", + "color-ai-chat-bubble-assistant": "var(--ai-chat-bubble-assistant)", + "color-ai-chat-bubble-assistant-foreground": "var(--ai-chat-bubble-assistant-foreground)", + "color-ai-chat-border": "var(--ai-chat-border)", + "color-ai-chat-input": "var(--ai-chat-input)", + "color-ai-chat-input-foreground": "var(--ai-chat-input-foreground)", + "color-ai-chat-ring": "var(--ai-chat-ring)", + "color-ai-chat-muted": "var(--ai-chat-muted)", + "color-ai-chat-muted-foreground": "var(--ai-chat-muted-foreground)", "font-sans": "var(--font-sans)" }, "light": { @@ -120,6 +144,33 @@ "sidebar-accent-foreground": "oklch(0.1660 0.0283 203.3380)", "sidebar-border": "oklch(0.9237 0.0133 262.3780)", "sidebar-ring": "oklch(0.64 0.115 208)", + "insight-50": "oklch(0.96 0.03 277)", + "insight-100": "oklch(0.92 0.05 277)", + "insight-200": "oklch(0.86 0.09 277)", + "insight-300": "oklch(0.78 0.14 277)", + "insight-400": "oklch(0.70 0.19 277)", + "insight-500": "oklch(0.62 0.22 277)", + "insight-600": "oklch(0.56 0.20 277)", + "insight-700": "oklch(0.48 0.17 277)", + "insight-800": "oklch(0.38 0.13 278)", + "insight-900": "oklch(0.30 0.10 278)", + "ai-chat": "oklch(0.985 0.002 260)", + "ai-chat-foreground": "oklch(0.2394 0.0455 252.45)", + "ai-chat-accent": "oklch(0.64 0.115 208)", + "ai-chat-accent-foreground": "oklch(1 0 0)", + "ai-chat-bubble-user": "oklch(0.955 0.008 252)", + "ai-chat-bubble-user-foreground": "oklch(0.2394 0.0455 252.45)", + "ai-chat-bubble-assistant": "oklch(0.96 0.020 275)", + "ai-chat-bubble-assistant-foreground": "oklch(0.2394 0.0455 252.45)", + "ai-chat-border": "oklch(0.92 0.008 260)", + "ai-chat-input": "oklch(0.975 0.004 260)", + "ai-chat-input-foreground": "oklch(0.2394 0.0455 252.45)", + "ai-chat-ring": "oklch(0.64 0.115 208)", + "ai-chat-muted": "oklch(0.955 0.006 260)", + "ai-chat-muted-foreground": "oklch(0.50 0.020 260)", + "font-sans": "Inter, ui-sans-serif, sans-serif, system-ui", + "font-serif": "IBM Plex Serif, ui-serif, serif", + "font-mono": "IBM Plex Mono, ui-monospace, monospace", "radius": "0.625rem", "shadow-x": "0", "shadow-y": "0px", @@ -152,7 +203,8 @@ "--info-fg": "oklch(0.49 0.12 210)", "--success-fg": "oklch(0.46 0.10 152)", "--destructive-fg": "oklch(0.50 0.14 18)", - "ai-gradient": "linear-gradient(100.64deg, rgb(238, 224, 255) 8.29%, rgb(207, 217, 255) 88.73%)" + "ai-gradient": "linear-gradient(100.64deg, rgb(238, 224, 255) 8.29%, rgb(207, 217, 255) 88.73%)", + "ai-gradient-strong": "linear-gradient(97.73deg, #6C5AEF 8.79%, #69C7DD 91.48%)" }, "dark": { "background": "oklch(0.2100 0.0300 258.5000)", @@ -206,6 +258,33 @@ "sidebar-accent-foreground": "oklch(0.9525 0.0110 225.9830)", "sidebar-border": "oklch(0.9525 0.0110 225.9830)", "sidebar-ring": "oklch(0.69 0.112 207)", + "insight-50": "oklch(0.96 0.03 277)", + "insight-100": "oklch(0.92 0.05 277)", + "insight-200": "oklch(0.86 0.09 277)", + "insight-300": "oklch(0.78 0.14 277)", + "insight-400": "oklch(0.70 0.19 277)", + "insight-500": "oklch(0.62 0.22 277)", + "insight-600": "oklch(0.56 0.20 277)", + "insight-700": "oklch(0.48 0.17 277)", + "insight-800": "oklch(0.38 0.13 278)", + "insight-900": "oklch(0.30 0.10 278)", + "ai-chat": "oklch(0.19 0.025 260)", + "ai-chat-foreground": "oklch(0.95 0.010 225)", + "ai-chat-accent": "oklch(0.69 0.112 207)", + "ai-chat-accent-foreground": "oklch(0.1660 0.0283 203.34)", + "ai-chat-bubble-user": "oklch(0.28 0.035 255)", + "ai-chat-bubble-user-foreground": "oklch(0.95 0.010 225)", + "ai-chat-bubble-assistant": "oklch(0.25 0.030 270)", + "ai-chat-bubble-assistant-foreground": "oklch(0.95 0.010 225)", + "ai-chat-border": "oklch(0.30 0.040 258)", + "ai-chat-input": "oklch(0.22 0.030 258)", + "ai-chat-input-foreground": "oklch(0.95 0.010 225)", + "ai-chat-ring": "oklch(0.69 0.112 207)", + "ai-chat-muted": "oklch(0.26 0.030 258)", + "ai-chat-muted-foreground": "oklch(0.65 0.020 258)", + "font-sans": "Inter, ui-sans-serif, sans-serif, system-ui", + "font-serif": "IBM Plex Serif, ui-serif, serif", + "font-mono": "IBM Plex Mono, ui-monospace, monospace", "radius": "0.625rem", "shadow-x": "0", "shadow-y": "0px", @@ -223,7 +302,8 @@ "shadow-2xl": "0 0px 12px 0px hsl(0 0% 0% / 0.25)", "tracking-normal": "0em", "spacing": "0.25rem", - "ai-gradient": "linear-gradient(98.69deg, rgb(108, 90, 239) 8.79%, rgb(105, 199, 221) 91.48%)" + "ai-gradient": "linear-gradient(98.69deg, rgb(108, 90, 239) 8.79%, rgb(105, 199, 221) 91.48%)", + "ai-gradient-strong": "linear-gradient(97.73deg, #6C5AEF 8.79%, #69C7DD 91.48%)" } } }, @@ -272,7 +352,16 @@ "react-markdown", "remark-gfm" ], - "registryDependencies": [], + "registryDependencies": [ + "avatar", + "button", + "tooltip", + "dropdown-menu", + "scroll-area", + "skeleton", + "badge", + "kbd" + ], "files": [ { "path": "registry/ai-chat/types.ts", @@ -339,6 +428,21 @@ "type": "registry:lib", "target": "components/ui/ai-chat/hooks/use-sticky-scroll.ts" }, + { + "path": "registry/ai-chat/hooks/use-typewriter.ts", + "type": "registry:lib", + "target": "components/ui/ai-chat/hooks/use-typewriter.ts" + }, + { + "path": "registry/ai-chat/hooks/use-thinking-label.ts", + "type": "registry:lib", + "target": "components/ui/ai-chat/hooks/use-thinking-label.ts" + }, + { + "path": "registry/ai-chat/components/ai-chat-provider.tsx", + "type": "registry:ui", + "target": "components/ui/ai-chat/components/ai-chat-provider.tsx" + }, { "path": "registry/ai-chat/components/ai-chat.tsx", "type": "registry:ui", @@ -359,6 +463,11 @@ "type": "registry:ui", "target": "components/ui/ai-chat/components/ai-chat-loading.tsx" }, + { + "path": "registry/ai-chat/components/ai-chat-thinking.tsx", + "type": "registry:ui", + "target": "components/ui/ai-chat/components/ai-chat-thinking.tsx" + }, { "path": "registry/ai-chat/components/ai-chat-input.tsx", "type": "registry:ui", @@ -368,6 +477,31 @@ "path": "registry/ai-chat/components/ai-chat-suggestions.tsx", "type": "registry:ui", "target": "components/ui/ai-chat/components/ai-chat-suggestions.tsx" + }, + { + "path": "registry/ai-chat/components/ai-chat-message-actions.tsx", + "type": "registry:ui", + "target": "components/ui/ai-chat/components/ai-chat-message-actions.tsx" + }, + { + "path": "registry/ai-chat/components/ai-chat-code-block.tsx", + "type": "registry:ui", + "target": "components/ui/ai-chat/components/ai-chat-code-block.tsx" + }, + { + "path": "registry/ai-chat/components/ai-chat-empty-state.tsx", + "type": "registry:ui", + "target": "components/ui/ai-chat/components/ai-chat-empty-state.tsx" + }, + { + "path": "registry/ai-chat/components/icons/autopilot.tsx", + "type": "registry:ui", + "target": "components/ui/ai-chat/components/icons/autopilot.tsx" + }, + { + "path": "registry/ai-chat/components/icons/autopilot-gradient.tsx", + "type": "registry:ui", + "target": "components/ui/ai-chat/components/icons/autopilot-gradient.tsx" } ] }, diff --git a/apps/apollo-vertex/registry/ai-chat/adapters/agenthub/tools.ts b/apps/apollo-vertex/registry/ai-chat/adapters/agenthub/tools.ts index 855440c6e..26488eca0 100644 --- a/apps/apollo-vertex/registry/ai-chat/adapters/agenthub/tools.ts +++ b/apps/apollo-vertex/registry/ai-chat/adapters/agenthub/tools.ts @@ -1,4 +1,4 @@ -import { convertSchemaToJsonSchema, type AnyClientTool } from "@tanstack/ai"; +import { type AnyClientTool, convertSchemaToJsonSchema } from "@tanstack/ai"; import type { AgentHubVendor } from "./types"; export function buildToolDefinitions( diff --git a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-code-block.tsx b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-code-block.tsx new file mode 100644 index 000000000..58bd052fa --- /dev/null +++ b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-code-block.tsx @@ -0,0 +1,164 @@ +"use client"; + +import "highlight.js/styles/github.min.css"; + +// Dark-mode override: github-dark-dimmed palette scoped to `.dark` +const DARK_HLJS_STYLE = ` +.dark .hljs { + color: #adbac7; + background: transparent; +} +.dark .hljs-doctag,.dark .hljs-keyword,.dark .hljs-meta .hljs-keyword,.dark .hljs-template-tag,.dark .hljs-template-variable,.dark .hljs-type,.dark .hljs-variable.language_ { + color: #f47067; +} +.dark .hljs-title,.dark .hljs-title.class_,.dark .hljs-title.class_.inherited__,.dark .hljs-title.function_ { + color: #dcbdfb; +} +.dark .hljs-attr,.dark .hljs-attribute,.dark .hljs-literal,.dark .hljs-meta,.dark .hljs-number,.dark .hljs-operator,.dark .hljs-variable,.dark .hljs-selector-attr,.dark .hljs-selector-class,.dark .hljs-selector-id { + color: #6cb6ff; +} +.dark .hljs-regexp,.dark .hljs-string,.dark .hljs-meta .hljs-string { + color: #96d0ff; +} +.dark .hljs-built_in,.dark .hljs-symbol { + color: #f69d50; +} +.dark .hljs-comment,.dark .hljs-code,.dark .hljs-formula { + color: #768390; +} +.dark .hljs-name,.dark .hljs-quote,.dark .hljs-selector-tag,.dark .hljs-selector-pseudo { + color: #8ddb8c; +} +.dark .hljs-subst { + color: #adbac7; +} +.dark .hljs-section { + color: #316dca; + font-weight: bold; +} +.dark .hljs-bullet { + color: #eac55f; +} +.dark .hljs-emphasis { + color: #adbac7; + font-style: italic; +} +.dark .hljs-strong { + color: #adbac7; + font-weight: bold; +} +.dark .hljs-addition { + color: #b4f1b4; + background-color: #1b4721; +} +.dark .hljs-deletion { + color: #ffd8d3; + background-color: #78191b; +} +`; + +import hljs from "highlight.js/lib/core"; +import bash from "highlight.js/lib/languages/bash"; +import css from "highlight.js/lib/languages/css"; +import javascript from "highlight.js/lib/languages/javascript"; +import json from "highlight.js/lib/languages/json"; +import python from "highlight.js/lib/languages/python"; +import sql from "highlight.js/lib/languages/sql"; +import typescript from "highlight.js/lib/languages/typescript"; +import xml from "highlight.js/lib/languages/xml"; +import { Check, Copy } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/registry/tooltip/tooltip"; + +hljs.registerLanguage("javascript", javascript); +hljs.registerLanguage("js", javascript); +hljs.registerLanguage("typescript", typescript); +hljs.registerLanguage("ts", typescript); +hljs.registerLanguage("tsx", typescript); +hljs.registerLanguage("jsx", javascript); +hljs.registerLanguage("python", python); +hljs.registerLanguage("py", python); +hljs.registerLanguage("bash", bash); +hljs.registerLanguage("sh", bash); +hljs.registerLanguage("shell", bash); +hljs.registerLanguage("json", json); +hljs.registerLanguage("css", css); +hljs.registerLanguage("html", xml); +hljs.registerLanguage("xml", xml); +hljs.registerLanguage("sql", sql); + +const COPY_LABEL = "Copy code"; +const COPIED_LABEL = "Copied!"; + +interface AiChatCodeBlockProps { + children: string; + language?: string; +} + +export function AiChatCodeBlock({ children, language }: AiChatCodeBlockProps) { + const [copied, setCopied] = useState(false); + const codeRef = useRef(null); + + const highlightedHtml = + language && hljs.getLanguage(language) + ? hljs.highlight(children, { language }).value + : hljs.highlightAuto(children).value; + + useEffect(() => { + if (codeRef.current) { + codeRef.current.innerHTML = highlightedHtml; + } + }, [highlightedHtml]); + + const handleCopy = async () => { + await navigator.clipboard.writeText(children); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const copyLabel = copied ? COPIED_LABEL : COPY_LABEL; + + return ( + <> + +
+
+ {language && ( + + {language} + + )} + + + + + {copyLabel} + +
+
+          
+        
+
+ + ); +} diff --git a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-empty-state.tsx b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-empty-state.tsx new file mode 100644 index 000000000..397a8acc4 --- /dev/null +++ b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-empty-state.tsx @@ -0,0 +1,31 @@ +"use client"; + +import type { ReactNode } from "react"; + +interface AiChatEmptyStateProps { + title?: string; + description?: string; + icon?: ReactNode; +} + +export function AiChatEmptyState({ + title = "How can I help you?", + description, + icon, +}: AiChatEmptyStateProps) { + return ( +
+ {icon} +
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+
+ ); +} diff --git a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-flow.tsx b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-flow.tsx new file mode 100644 index 000000000..71f6fc38b --- /dev/null +++ b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-flow.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { ChevronLeft, ChevronRight, X } from "lucide-react"; +import { useEffect, useState } from "react"; +import type { FlowOption, ToolResultFlow } from "../utils/ai-chat-utils"; + +const EASE = [0.22, 1, 0.36, 1] as const; + +interface AiChatFlowProps { + flow: ToolResultFlow; + onComplete: ( + answers: { stepId: string; prompt: string; answer: string }[], + ) => void; + onDismiss: () => void; + /** Called on mount and on every step change with a resolver for the current step's free-text answer. */ + onFreeTextReady?: (resolve: (text: string) => void) => void; +} + +export function AiChatFlow({ + flow, + onComplete, + onDismiss, + onFreeTextReady, +}: AiChatFlowProps) { + const [stepIndex, setStepIndex] = useState(0); + const [answers, setAnswers] = useState< + { stepId: string; prompt: string; answer: string }[] + >([]); + + const step = flow.steps[stepIndex]; + const isFirst = stepIndex === 0; + const isLast = stepIndex === flow.steps.length - 1; + const total = flow.steps.length; + + const advance = ( + updated: { stepId: string; prompt: string; answer: string }[], + ) => { + if (isLast) { + onComplete(updated); + } else { + setStepIndex((i) => i + 1); + } + }; + + // Notify parent with a fresh resolver whenever the step changes + useEffect(() => { + onFreeTextReady?.((text) => { + const updated = [ + ...answers.filter((a) => a.stepId !== step.id), + { stepId: step.id, prompt: step.prompt, answer: text }, + ]; + setAnswers(updated); + advance(updated); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stepIndex]); + + const handleSelect = (option: FlowOption) => { + const answer = option.value ?? option.label; + const updated = [ + ...answers.filter((a) => a.stepId !== step.id), + { stepId: step.id, prompt: step.prompt, answer }, + ]; + setAnswers(updated); + advance(updated); + }; + + const handleBack = () => setStepIndex((i) => Math.max(0, i - 1)); + + const handleSkip = () => { + const updated = [ + ...answers.filter((a) => a.stepId !== step.id), + { stepId: step.id, prompt: step.prompt, answer: "(skipped)" }, + ]; + setAnswers(updated); + advance(updated); + }; + + return ( + + {/* Header: prompt + controls */} +
+ + + {step.prompt} + + + +
+
+ + + {stepIndex + 1} + {"/"} + {total} + + +
+ +
+
+ + {/* Options */} + + + {step.options.map((option, i) => ( +
+ {i > 0 && ( + + ))} + + + + ); +} diff --git a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-input.tsx b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-input.tsx index f0da010fe..3cbb4e3a0 100644 --- a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-input.tsx +++ b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-input.tsx @@ -1,105 +1,489 @@ +// oxlint-disable eslint/max-lines -- input bundles file handling, paste, quote chip, and lightbox portal; split would add indirection "use client"; -import { Send, Square, Trash2 } from "lucide-react"; -import type { FormEvent, KeyboardEvent } from "react"; +import { + ArrowUp, + CircleStop, + FileText, + MessageSquareText, + Plus, + X, +} from "lucide-react"; +import { + type ClipboardEvent, + type FocusEvent, + type FormEvent, + forwardRef, + type KeyboardEvent, + type Ref, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; +import { Button } from "@/registry/button/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/registry/dropdown-menu/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/registry/tooltip/tooltip"; + +interface PendingFile { + uid: string; + name: string; + size: number; + type: string; + file: File; + thumbnailUrl?: string; +} interface AiChatInputProps { value: string; onChange: (value: string) => void; - onSubmit: () => void; + onSubmit: (files?: File[]) => void; onStop: () => void; - onClear?: () => void; isLoading: boolean; disabled?: boolean; placeholder?: string; - showClearButton?: boolean; hasMessages?: boolean; + maxLength?: number; + initialFiles?: PendingFile[]; + quotedText?: string | null; + onClearQuote?: () => void; +} + +function makePendingFile(file: File, nameOverride?: string): PendingFile { + const uid = crypto.randomUUID(); + const name = nameOverride ?? file.name; + if (file.type.startsWith("image/")) { + return { + uid, + name, + size: file.size, + type: file.type, + file, + thumbnailUrl: URL.createObjectURL(file), + }; + } + return { uid, name, size: file.size, type: file.type, file }; +} + +export interface AiChatInputHandle { + focus: () => void; } -export function AiChatInput({ - value, - onChange, - onSubmit, - onStop, - onClear, - isLoading, - disabled = false, - placeholder, - showClearButton = true, - hasMessages = false, -}: AiChatInputProps) { - const { t } = useTranslation(); - const displayPlaceholder = placeholder ?? t("type_a_message"); - - const submitMessage = () => { - if (!value.trim() || isLoading) return; - onSubmit(); - }; - - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); - submitMessage(); - }; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { +export const AiChatInput = forwardRef( + function AiChatInput( + { + value, + onChange, + onSubmit, + onStop, + isLoading, + disabled = false, + placeholder, + hasMessages = false, + maxLength, + initialFiles = [], + quotedText, + onClearQuote, + }: AiChatInputProps, + ref: Ref, + ) { + const { t } = useTranslation(); + const textareaRef = useRef(null); + const glowRef = useRef(null); + const [pendingFiles, setPendingFiles] = + useState(initialFiles); + const [lightboxUrl, setLightboxUrl] = useState(null); + + useEffect(() => { + if (!lightboxUrl) return; + const handler = (e: globalThis.KeyboardEvent) => { + if (e.key === "Escape") setLightboxUrl(null); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [lightboxUrl]); + + useImperativeHandle(ref, () => ({ + focus: () => textareaRef.current?.focus({ preventScroll: true }), + })); + const displayPlaceholder = placeholder ?? "Start with a task or goal"; + + const adjustHeight = () => { + if (!hasMessages) return; + const el = textareaRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, 200)}px`; + }; + + const handleChange = (val: string) => { + if (maxLength && val.length > maxLength) return; + onChange(val); + requestAnimationFrame(adjustHeight); + }; + + const submitMessage = () => { + if (!value.trim()) return; + const files = pendingFiles.map((f) => f.file); + if (files.length > 0) onSubmit(files); + else onSubmit(); + pendingFiles.forEach((f) => { + if (f.thumbnailUrl) URL.revokeObjectURL(f.thumbnailUrl); + }); + setPendingFiles([]); + requestAnimationFrame(() => { + const el = textareaRef.current; + if (el) el.style.height = "auto"; + }); + }; + + const handleSubmit = (e: FormEvent) => { e.preventDefault(); submitMessage(); - } - }; - - return ( -
-
-
) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + submitMessage(); + } + }; + + const handleFileClick = () => { + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.multiple = true; + fileInput.addEventListener("change", () => { + if (!fileInput.files) return; + const newFiles = Array.from(fileInput.files).map((file) => + makePendingFile(file), + ); + setPendingFiles((prev) => [...prev, ...newFiles]); + }); + fileInput.click(); + }; + + const removeFile = (index: number) => { + setPendingFiles((prev) => { + const removed = prev[index]; + if (removed?.thumbnailUrl) URL.revokeObjectURL(removed.thumbnailUrl); + return prev.filter((_, i) => i !== index); + }); + }; + + const handlePaste = (e: ClipboardEvent) => { + const imageFiles: PendingFile[] = Array.from(e.clipboardData.items) + .filter( + (item) => item.kind === "file" && item.type.startsWith("image/"), + ) + .flatMap((item) => { + const file = item.getAsFile(); + if (!file) return []; + const name = + file.name === "" + ? `pasted-image.${file.type.split("/")[1]}` + : file.name; + return [makePendingFile(file, name)]; + }); + if (imageFiles.length > 0) { + e.preventDefault(); + setPendingFiles((prev) => [...prev, ...imageFiles]); + } + }; + + const focusStyles = { + onFocusCapture: (e: FocusEvent) => { + const el = e.currentTarget; + el.style.borderColor = "transparent"; + el.style.backgroundImage = `linear-gradient(var(--background), var(--background)), var(--ai-gradient-strong)`; + el.style.backgroundOrigin = "border-box"; + el.style.backgroundClip = "padding-box, border-box"; + el.style.boxShadow = + "0 0 0 3px color-mix(in oklch, var(--muted-foreground) 10%, transparent)"; + if (glowRef.current) glowRef.current.style.opacity = "0"; + }, + onBlurCapture: (e: FocusEvent) => { + if (!e.currentTarget.contains(e.relatedTarget)) { + const el = e.currentTarget; + el.style.borderColor = ""; + el.style.backgroundImage = ""; + el.style.backgroundOrigin = ""; + el.style.backgroundClip = ""; + el.style.boxShadow = ""; + if (glowRef.current) glowRef.current.style.opacity = ""; + } + }, + }; + + const quoteChip = quotedText && ( +
+