From 329dccbf0e6ef07dde200b942e84c2e8596346ab Mon Sep 17 00:00:00 2001 From: Peter Vachon Date: Thu, 30 Apr 2026 08:00:17 -0400 Subject: [PATCH 1/2] feat(apollo-vertex): ai-chat message actions --- .../app/patterns/ai-chat/page.mdx | 96 +++++++-- apps/apollo-vertex/locales/en.json | 11 +- apps/apollo-vertex/registry.json | 25 +++ .../ai-chat/components/ai-chat-code-block.tsx | 34 ++-- .../ai-chat/components/ai-chat-header.tsx | 108 ++++++++++ .../components/ai-chat-message-actions.tsx | 159 +++++++++++++++ .../ai-chat/components/ai-chat-message.tsx | 185 ++++++++++++++---- .../registry/ai-chat/components/ai-chat.tsx | 184 ++++------------- apps/apollo-vertex/registry/ai-chat/types.ts | 13 ++ .../ai-chat/util/get-ai-chat-message-props.ts | 65 ++++++ apps/apollo-vertex/registry/button/button.tsx | 1 + .../templates/ai-chat/AiChatAgentHubMode.tsx | 37 +++- .../ai-chat/AiChatConversationalAgentMode.tsx | 16 +- 13 files changed, 710 insertions(+), 224 deletions(-) create mode 100644 apps/apollo-vertex/registry/ai-chat/components/ai-chat-header.tsx create mode 100644 apps/apollo-vertex/registry/ai-chat/components/ai-chat-message-actions.tsx create mode 100644 apps/apollo-vertex/registry/ai-chat/types.ts create mode 100644 apps/apollo-vertex/registry/ai-chat/util/get-ai-chat-message-props.ts diff --git a/apps/apollo-vertex/app/patterns/ai-chat/page.mdx b/apps/apollo-vertex/app/patterns/ai-chat/page.mdx index f963d74f7..96fe8a0e1 100644 --- a/apps/apollo-vertex/app/patterns/ai-chat/page.mdx +++ b/apps/apollo-vertex/app/patterns/ai-chat/page.mdx @@ -11,7 +11,7 @@ A composable AI chat UI component for Apollo Vertex. Built with React, TypeScrip ## Features - **TanStack AI Integration** — Works with `useChat` from `@tanstack/ai-react` and `UIMessage` types -- **Composable** — `AiChat` is the shell, `AiChatMessage` renders messages, you iterate parts and render tools inline +- **Composable** — `AiChat` is the shell, `AiChatMessage` renders messages, `getAiChatMessageProps` wires per-message data - **Type-Safe Tool Rendering** — Check `part.name` in the parts loop and TypeScript narrows `part.output` automatically - **AgentHub Adapter** — Built-in adapter for the UiPath AgentHub normalized LLM endpoint (OpenAI + Anthropic models) - **Conversational Agent Adapter** — Built-in adapter for a deployed UiPath Conversational Agent, with session management @@ -50,6 +50,7 @@ npx shadcn@latest add @uipath/ai-chat import { useChat } from '@tanstack/ai-react'; import { AiChat } from '@/components/ui/ai-chat/components/ai-chat'; import { AiChatMessage } from '@/components/ui/ai-chat/components/ai-chat-message'; +import { getAiChatMessageProps } from '@/components/ui/ai-chat/util/get-ai-chat-message-props'; import { createAgentHubConnection } from '@/components/ui/ai-chat/adapters/agenthub/adapter'; function BasicChat() { @@ -60,10 +61,12 @@ function BasicChat() { systemPrompt: 'You are a helpful assistant.', }); - const { messages, sendMessage, isLoading, stop, clear, error } = useChat({ + const { messages, sendMessage, isLoading, status, stop, clear, error } = useChat({ connection, }); + const messageProps = getAiChatMessageProps({ messages, status }); + return ( {messages.map((message) => ( - + ))} ); @@ -84,7 +87,7 @@ function BasicChat() { ## Tool Rendering -Render tool output inline in the chat — just like TanStack AI's own examples. Define tools with `toolDefinition`, pass the input through as output in your client tool, then check `part.name` in the parts loop. TypeScript narrows `part.output` automatically. +Render tool output inline as `AiChatMessage` children. Define tools with `toolDefinition`, pass the input through as output in your client tool, then check `part.name` in the parts loop. TypeScript narrows `part.output` automatically. ```tsx import { z } from 'zod'; @@ -93,6 +96,7 @@ import { clientTools } from '@tanstack/ai-client'; import { stream, useChat } from '@tanstack/ai-react'; import { AiChat } from '@/components/ui/ai-chat/components/ai-chat'; import { AiChatMessage } from '@/components/ui/ai-chat/components/ai-chat-message'; +import { getAiChatMessageProps } from '@/components/ui/ai-chat/util/get-ai-chat-message-props'; // 1. Define tools — output passes input through for rendering const showResultsInput = z.object({ @@ -110,13 +114,15 @@ const showResultsDef = toolDefinition({ const showResults = showResultsDef.client((input) => input); const toolDefs = clientTools(showResults); -// 2. Wire it up — iterate parts, render tools inline +// 2. Wire it up — render tool output as children of each AiChatMessage function ChatWithTools() { - const { messages, sendMessage, isLoading, stop } = useChat({ + const { messages, sendMessage, isLoading, status, stop } = useChat({ connection, tools: toolDefs, }); + const messageProps = getAiChatMessageProps({ messages, status }); + return ( {messages.map((message) => ( - + {message.parts.map((part) => { // TypeScript narrows part.output when you check part.name if (part.type === 'tool-call' && part.name === 'show_results' && part.output) { @@ -140,6 +146,30 @@ function ChatWithTools() { } ``` +## Message Actions + +Each message can show inline actions — copy, thumbs-up/down feedback, regenerate, and edit. Pass the optional callbacks to `getAiChatMessageProps`; it wires them to the right messages (assistant for feedback/regenerate, user for edit) and the action row appears automatically. + +```tsx +const { messages, sendMessage, reload, status } = useChat({ connection }); + +const messageProps = getAiChatMessageProps({ + messages, + status, + onFeedback: (messageId, type) => { + // type: 'positive' | 'negative' — send to your analytics / feedback endpoint + void recordFeedback({ messageId, type }); + }, + onRegenerate: () => void reload(), + onEditMessage: (_messageId, content) => { + // Re-runs the conversation with the edited user message + void sendMessage(content); + }, +}); +``` + +Copy is always available and needs no wiring. Feedback and edit only render when their callbacks are supplied. + ## AgentHub Adapter The built-in adapter for the **UiPath AgentHub** normalized LLM endpoint. It converts TanStack AI `UIMessage` arrays to the AgentHub wire format, calls the endpoint, and parses the SSE response back into AG-UI `StreamChunk` events. @@ -519,7 +549,7 @@ import { ### `` -Chat shell component. Handles layout, scroll, input, loading indicator, suggestions, and errors. Render messages as children. +Chat shell component. Handles layout, scroll, input, loading indicator, suggestions, and errors. Render messages as children — typically `messages.map((m) => )`. | Prop | Type | Default | Description | |------|------|---------|-------------| @@ -527,24 +557,60 @@ Chat shell component. Handles layout, scroll, input, loading indicator, suggesti | `isLoading` | `boolean` | required | Loading state from `useChat` | | `onSendMessage` | `(content: string) => void` | required | Send handler | | `onStop` | `() => void` | required | Stop/abort handler | -| `children` | `ReactNode` | — | Message list (typically `messages.map(...)`) | -| `onClearChat` | `() => void` | — | Clear handler | -| `assistantName` | `string` | `"AI Assistant"` | Assistant display name | +| `children` | `ReactNode` | — | Rendered messages (typically `messages.map(...)`) | +| `onClearChat` | `() => void` | — | Shows a "New conversation" item in the header dropdown when provided | +| `onRetry` | `() => void` | — | Retry handler shown next to the inline error banner | +| `assistantName` | `string` | `"AI Assistant"` | Label used for the assistant in copied conversation text | | `title` | `string` | — | Chat title in the header | +| `header` | `ReactNode` | — | Custom header — replaces the default `` | | `emptyState` | `ReactNode` | — | Custom empty state | +| `suggestions` | `string[]` | — | Quick-start prompts shown below the input in the empty state | +| `onSuggestionClick` | `(suggestion: string) => void` | — | Called when a suggestion is clicked (defaults to sending it as a message) | | `placeholder` | `string` | — | Input placeholder | -| `showClearButton` | `boolean` | `true` | Show the clear button | | `error` | `Error \| null` | — | Inline error banner | ### `` -Renders a single message with avatar, name, markdown text, and children for custom content (tool output). +Renders a single message bubble. Spread the output of `getAiChatMessageProps(message)` to wire up streaming, visibility, and action callbacks. Tool output and inline UI can be passed as children — they render below the markdown bubble inside the assistant column. | Prop | Type | Default | Description | |------|------|---------|-------------| | `message` | `UIMessage` | required | The message to render | -| `assistantName` | `string` | `"AI Assistant"` | Assistant display name | -| `children` | `ReactNode` | — | Custom content rendered below the message text (tool output, etc.) | +| `children` | `ReactNode` | — | Tool output rendered inside the assistant column | +| `isStreaming` | `boolean` | `false` | Hides the action row while the message is being streamed | +| `showActionsAlwaysVisible` | `boolean` | `false` | Keeps the action row visible (used for the latest assistant message); other messages reveal actions on hover/focus | +| `feedback` | `'positive' \| 'negative' \| null` | — | Current feedback — renders thumbs-up/down in a pressed state | +| `onFeedback` | `(type: 'positive' \| 'negative') => void` | — | Thumbs-up/down handler. Feedback buttons only render when provided | +| `onRegenerate` | `() => void` | — | Regenerate handler. Only shown on the latest assistant message | +| `onEditMessage` | `(content: string) => void` | — | Save handler for user-message edits. Edit affordance only renders when provided | + +### `getAiChatMessageProps` + +Plain helper (not a hook) that returns a per-message props factory for ``. Computes streaming/visibility flags and binds id-aware callbacks so consumers can spread the result directly. The returned shape is `AiChatMessageBoundProps` — exported from `types.ts` for consumers who want to wrap or extend it. + +```tsx +const messageProps = getAiChatMessageProps({ + messages, + status, + onFeedback, // (messageId, type) => void + getFeedback, // (messageId) => 'positive' | 'negative' | null + onRegenerate, // () => void + onEditMessage, // (messageId, content) => void +}); + +{messages.map((m) => ( + +))} +``` + +| Option | Type | Description | +|--------|------|-------------| +| `messages` | `UIMessage[]` | Messages from `useChat` | +| `status` | `'ready' \| 'submitted' \| 'streaming' \| 'error'` | Chat lifecycle state from `useChat` | +| `onFeedback` | `(messageId: string, type: 'positive' \| 'negative') => void` | Thumbs-up/down callback | +| `getFeedback` | `(messageId: string) => 'positive' \| 'negative' \| null \| undefined` | Resolves the saved feedback for a message — drives the thumbs-up/down pressed state | +| `onRegenerate` | `() => void` | Regenerate the last assistant response | +| `onEditMessage` | `(messageId: string, content: string) => void` | Save an edited user message | ### `AgentHubAdapterConfig` diff --git a/apps/apollo-vertex/locales/en.json b/apps/apollo-vertex/locales/en.json index f311150b7..00a45f8d7 100644 --- a/apps/apollo-vertex/locales/en.json +++ b/apps/apollo-vertex/locales/en.json @@ -12,6 +12,7 @@ "autopilot_empty_description": "Ask me anything about your automation data.", "asc": "Asc", "assistant": "Assistant", + "bad_response": "Bad response", "business_user": "Business user", "cancel": "Cancel", "card_component": "Card Component", @@ -27,8 +28,11 @@ "close_sidebar": "Close sidebar", "collapse_tool_calls": "Collapse tool calls", "copied": "Copied!", + "copy": "Copy", + "copy_code": "Copy code", "copy_conversation": "Copy conversation", "copy_conversation_failed": "Couldn't copy conversation", + "copy_failed": "Couldn't copy", "copy_payment_id": "Copy payment ID", "custom": "Custom", "dark": "Dark", @@ -37,6 +41,7 @@ "desc": "Desc", "destructive": "Destructive", "displays_mobile_sidebar": "Displays the mobile sidebar.", + "edit": "Edit", "english": "English", "enter_full_screen": "Enter full screen", "error": "Error", @@ -56,6 +61,7 @@ "go_to_last_page": "Go to last page", "go_to_next_page": "Go to next page", "go_to_previous_page": "Go to previous page", + "good_response": "Good response", "hide": "Hide", "how_can_i_help_you": "How can I help you?", "import": "Import", @@ -102,6 +108,7 @@ "rows_per_page": "Rows per page", "rows_selected": "{{selected}} of {{total}} row(s) selected.", "russian": "Russian", + "save_and_rerun": "Save & re-run", "scroll_to_bottom": "Scroll to bottom", "search": "Search...", "search_frameworks": "Search frameworks", @@ -138,6 +145,7 @@ "tool_call": "tool call", "tool_calls": "tool calls", "tools_used": "Tools used", + "try_again": "Try again", "turkish": "Turkish", "type_a_message": "Type a message...", "unknown_entity": "Entity \"{{entity}}\" doesn't exist.", @@ -153,5 +161,6 @@ "wrong_dimension_type_in_entity": "Field \"{{field}}\" on {{entity}} is {{actual}}; this chart needs a {{expected}} field.", "wrong_dimension_type_in_joined": "Field \"{{field}}\" is {{actual}}; this chart needs a {{expected}} field.", "wrong_metric_type_in_entity": "Field \"{{field}}\" on {{entity}} is {{actual}}; {{aggregation}} requires a numeric field.", - "wrong_metric_type_in_joined": "Field \"{{field}}\" is {{actual}}; {{aggregation}} requires a numeric field." + "wrong_metric_type_in_joined": "Field \"{{field}}\" is {{actual}}; {{aggregation}} requires a numeric field.", + "you": "You" } diff --git a/apps/apollo-vertex/registry.json b/apps/apollo-vertex/registry.json index 18e83cabb..7b77a0d6a 100644 --- a/apps/apollo-vertex/registry.json +++ b/apps/apollo-vertex/registry.json @@ -180,6 +180,7 @@ "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)", + "ai-chat-brand-gradient": "linear-gradient(97.73deg, #5D4ED0 8.79%, #1076A0 91.48%)", "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%)", "ai-gradient-start": "#6C5AEF", @@ -268,6 +269,7 @@ "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)", + "ai-chat-brand-gradient": "linear-gradient(97.73deg, #9485F5 8.79%, #69C7DD 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%)", "ai-gradient-start": "#6C5AEF", @@ -309,6 +311,7 @@ "description": "A composable AI chat UI component for TanStack AI with AgentHub adapter, markdown rendering, suggestion buttons, and error display", "dependencies": [ "@mantine/hooks@^9.0.0", + "@radix-ui/react-tooltip@^1.2.8", "@tanstack/ai@0.14.0", "@tanstack/ai-client@0.8.0", "@tanstack/ai-react@0.8.0", @@ -328,8 +331,10 @@ ], "registryDependencies": [ "@uipath/alert-dialog", + "@uipath/button", "@uipath/card", "@uipath/dropdown-menu", + "@uipath/textarea", "@uipath/tooltip" ], "files": [ @@ -389,6 +394,11 @@ "type": "registry:lib", "target": "components/ui/ai-chat/hooks/use-sticky-scroll.ts" }, + { + "path": "registry/ai-chat/util/get-ai-chat-message-props.ts", + "type": "registry:lib", + "target": "components/ui/ai-chat/util/get-ai-chat-message-props.ts" + }, { "path": "registry/ai-chat/components/ai-chat.tsx", "type": "registry:ui", @@ -429,6 +439,21 @@ "type": "registry:ui", "target": "components/ui/ai-chat/components/ai-chat-suggestions.tsx" }, + { + "path": "registry/ai-chat/types.ts", + "type": "registry:lib", + "target": "components/ui/ai-chat/types.ts" + }, + { + "path": "registry/ai-chat/components/ai-chat-header.tsx", + "type": "registry:ui", + "target": "components/ui/ai-chat/components/ai-chat-header.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-thinking.tsx", "type": "registry:ui", 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 index 197d5b786..4469261e3 100644 --- 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 @@ -57,6 +57,7 @@ const DARK_HLJS_STYLE = ` } `; +import { useClipboard } from "@mantine/hooks"; import hljs from "highlight.js/lib/core"; import bash from "highlight.js/lib/languages/bash"; import css from "highlight.js/lib/languages/css"; @@ -67,12 +68,14 @@ 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 { useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; hljs.registerLanguage("javascript", javascript); hljs.registerLanguage("js", javascript); @@ -91,16 +94,14 @@ 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 { t } = useTranslation(); + const { copied, error, copy } = useClipboard({ timeout: 2000 }); const codeRef = useRef(null); const highlightedHtml = @@ -114,13 +115,11 @@ export function AiChatCodeBlock({ children, language }: AiChatCodeBlockProps) { } }, [highlightedHtml]); - const handleCopy = async () => { - await navigator.clipboard.writeText(children); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - const copyLabel = copied ? COPIED_LABEL : COPY_LABEL; + const copyLabel = copied + ? t("copied") + : error + ? t("copy_failed") + : t("copy_code"); return ( <> @@ -136,10 +135,13 @@ export function AiChatCodeBlock({ children, language }: AiChatCodeBlockProps) { + + + copy(conversationText)}> + {copyLabel} + + {onClearChat && ( + + {t("new_conversation")} + + )} + + + + + + {t("new_conversation_confirm_title")} + + + {t("new_conversation_confirm_description")} + + + + {t("cancel")} + onClearChat?.()}> + {t("new_conversation")} + + + + + )} + + ); +} diff --git a/apps/apollo-vertex/registry/ai-chat/components/ai-chat-message-actions.tsx b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-message-actions.tsx new file mode 100644 index 000000000..b5b1ccf5a --- /dev/null +++ b/apps/apollo-vertex/registry/ai-chat/components/ai-chat-message-actions.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useClipboard } from "@mantine/hooks"; +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import { + Check, + Copy, + Pencil, + RefreshCw, + ThumbsDown, + ThumbsUp, +} from "lucide-react"; +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { MessageFeedbackType } from "../types"; + +interface IconActionButtonProps { + icon: ReactNode; + label: string; + onClick: () => void; + pressed?: boolean; +} + +function IconActionButton({ + icon, + label, + onClick, + pressed, +}: IconActionButtonProps) { + return ( + + + + + {label} + + ); +} + +interface CopyToClipboardButtonProps { + value: string; +} + +function CopyToClipboardButton({ value }: CopyToClipboardButtonProps) { + const { t } = useTranslation(); + const { copied, error, copy } = useClipboard({ timeout: 2000 }); + + const label = copied ? t("copied") : error ? t("copy_failed") : t("copy"); + + return ( +