From f38bd17a8ce7aef08d1387bb3f31488101b157f6 Mon Sep 17 00:00:00 2001 From: pasqualevitiello Date: Sat, 17 Oct 2026 16:52:44 +0200 Subject: [PATCH 1/8] feat(agent): add agent family with conversation, message, actions, and announcer Co-Authored-By: Claude Opus 5.5 --- apps/ui/package.json | 2 +- apps/ui/public/r/agent.json | 70 +++++++++ .../default/agent/agent-announcer.tsx | 30 ++++ .../default/agent/agent-conversation.tsx | 43 +++++ .../default/agent/agent-message-actions.tsx | 148 ++++++++++++++++++ .../registry/default/agent/agent-message.tsx | 60 +++++++ .../agent/hooks/use-agent-announcer.ts | 52 ++++++ .../agent/hooks/use-agent-reduced-motion.ts | 11 ++ .../agent/lib/get-agent-announcement.ts | 46 ++++++ .../agent/lib/get-agent-scroll-offset.ts | 11 ++ apps/ui/registry/index.ts | 2 + apps/ui/registry/registry-agent.ts | 63 ++++++++ apps/ui/scripts/sync-ui.mts | 2 + apps/ui/scripts/validate-registry-deps.mts | 6 + .../agent/get-agent-announcement.test.ts | 91 +++++++++++ .../agent/get-agent-scroll-offset.test.ts | 12 ++ packages/ui/package.json | 5 +- packages/ui/src/agent/agent-announcer.tsx | 30 ++++ packages/ui/src/agent/agent-conversation.tsx | 43 +++++ .../ui/src/agent/agent-message-actions.tsx | 148 ++++++++++++++++++ packages/ui/src/agent/agent-message.tsx | 60 +++++++ .../ui/src/agent/hooks/use-agent-announcer.ts | 52 ++++++ .../agent/hooks/use-agent-reduced-motion.ts | 11 ++ .../src/agent/lib/get-agent-announcement.ts | 46 ++++++ .../src/agent/lib/get-agent-scroll-offset.ts | 11 ++ 25 files changed, 1053 insertions(+), 2 deletions(-) create mode 100644 apps/ui/public/r/agent.json create mode 100644 apps/ui/registry/default/agent/agent-announcer.tsx create mode 100644 apps/ui/registry/default/agent/agent-conversation.tsx create mode 100644 apps/ui/registry/default/agent/agent-message-actions.tsx create mode 100644 apps/ui/registry/default/agent/agent-message.tsx create mode 100644 apps/ui/registry/default/agent/hooks/use-agent-announcer.ts create mode 100644 apps/ui/registry/default/agent/hooks/use-agent-reduced-motion.ts create mode 100644 apps/ui/registry/default/agent/lib/get-agent-announcement.ts create mode 100644 apps/ui/registry/default/agent/lib/get-agent-scroll-offset.ts create mode 100644 apps/ui/registry/registry-agent.ts create mode 100644 apps/ui/tests/agent/get-agent-announcement.test.ts create mode 100644 apps/ui/tests/agent/get-agent-scroll-offset.test.ts create mode 100644 packages/ui/src/agent/agent-announcer.tsx create mode 100644 packages/ui/src/agent/agent-conversation.tsx create mode 100644 packages/ui/src/agent/agent-message-actions.tsx create mode 100644 packages/ui/src/agent/agent-message.tsx create mode 100644 packages/ui/src/agent/hooks/use-agent-announcer.ts create mode 100644 packages/ui/src/agent/hooks/use-agent-reduced-motion.ts create mode 100644 packages/ui/src/agent/lib/get-agent-announcement.ts create mode 100644 packages/ui/src/agent/lib/get-agent-scroll-offset.ts diff --git a/apps/ui/package.json b/apps/ui/package.json index c5163683f..4ca1d030b 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -55,7 +55,7 @@ "start": "next start --port 4000", "typecheck": "tsc --noEmit && tsc --noEmit --project tsconfig.tests.json", "ui:sync": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/sync-ui.mts && bun run ui:sync:imports", - "ui:sync:imports": "cd ../.. && bunx biome check --write --assist-enabled=true --linter-enabled=false --formatter-enabled=false --indent-style=space --indent-width=2 --config-path .biome-packages-sync/packages-sync.json packages/ui/src/hooks packages/ui/src/lib packages/ui/src/components packages/ui/src/chat > /dev/null 2>&1 || true" + "ui:sync:imports": "cd ../.. && bunx biome check --write --assist-enabled=true --linter-enabled=false --formatter-enabled=false --indent-style=space --indent-width=2 --config-path .biome-packages-sync/packages-sync.json packages/ui/src/hooks packages/ui/src/lib packages/ui/src/components packages/ui/src/chat packages/ui/src/agent > /dev/null 2>&1 || true" }, "version": "0.1.0" } diff --git a/apps/ui/public/r/agent.json b/apps/ui/public/r/agent.json new file mode 100644 index 000000000..84f5b8d66 --- /dev/null +++ b/apps/ui/public/r/agent.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "agent", + "description": "Agent conversation, messages, message actions, and a screen reader announcer built on chat.", + "dependencies": [ + "lucide-react", + "@base-ui/react" + ], + "registryDependencies": [ + "@coss/button", + "@coss/chat", + "@coss/composer", + "@coss/toolbar", + "@coss/tooltip", + "@coss/use-copy-to-clipboard", + "@coss/use-media-query", + "@coss/utils" + ], + "files": [ + { + "path": "registry/default/agent/agent-announcer.tsx", + "content": "import type { ComponentProps, ReactElement } from \"react\";\nimport { cn } from \"@/registry/default/lib/utils\";\nimport type { AgentAnnouncerController } from \"./hooks/use-agent-announcer\";\n\nexport type AgentAnnouncerProps = Omit<\n ComponentProps<\"span\">,\n \"children\" | \"role\"\n> & {\n announcer: AgentAnnouncerController;\n};\n\n/** The conversation's one status region. Render it once, outside the scrolling log. */\nexport function AgentAnnouncer({\n announcer,\n className,\n ...props\n}: AgentAnnouncerProps): ReactElement {\n return (\n \n {announcer.message}\n \n );\n}\n", + "type": "registry:component", + "target": "@components/agent/agent-announcer.tsx" + }, + { + "path": "registry/default/agent/agent-conversation.tsx", + "content": "\"use client\";\n\nimport type { ReactElement, UIEvent } from \"react\";\nimport { cn } from \"@/registry/default/lib/utils\";\nimport {\n ChatConversation,\n type ChatConversationProps,\n} from \"../chat/chat-conversation\";\nimport {\n AGENT_SCROLL_OFFSET_VAR,\n getAgentScrollOffset,\n} from \"./lib/get-agent-scroll-offset\";\n\nexport type AgentConversationProps = ChatConversationProps;\n\nexport function AgentConversation({\n scroll,\n live = \"off\",\n contentClassName,\n ...props\n}: AgentConversationProps): ReactElement {\n const onScroll = (event: UIEvent): void => {\n event.currentTarget.style.setProperty(\n AGENT_SCROLL_OFFSET_VAR,\n getAgentScrollOffset(event.currentTarget.scrollTop),\n );\n scroll.onScroll(event);\n };\n\n return (\n \n );\n}\n", + "type": "registry:component", + "target": "@components/agent/agent-conversation.tsx" + }, + { + "path": "registry/default/agent/agent-message-actions.tsx", + "content": "\"use client\";\n\nimport type { Toolbar as ToolbarPrimitive } from \"@base-ui/react/toolbar\";\nimport { CheckIcon, CopyIcon, PencilIcon } from \"lucide-react\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { useCopyToClipboard } from \"@/registry/default/hooks/use-copy-to-clipboard\";\nimport { cn } from \"@/registry/default/lib/utils\";\nimport { Button, type ButtonProps } from \"@/registry/default/ui/button\";\nimport { Toolbar, ToolbarButton } from \"@/registry/default/ui/toolbar\";\nimport {\n Tooltip,\n TooltipPopup,\n TooltipTrigger,\n} from \"@/registry/default/ui/tooltip\";\n\nexport type AgentMessageActionsProps = Omit<\n ToolbarPrimitive.Root.Props,\n \"aria-label\"\n> & {\n label: string;\n};\n\nexport function AgentMessageActions({\n label,\n className,\n ...props\n}: AgentMessageActionsProps): ReactElement {\n return (\n \n );\n}\n\nexport type AgentMessageActionProps = Omit<\n ButtonProps,\n \"aria-label\" | \"children\" | \"size\" | \"variant\"\n> & {\n label: string;\n children: ReactNode;\n};\n\nexport function AgentMessageAction({\n label,\n children,\n onClick,\n render,\n disabled,\n className,\n ...props\n}: AgentMessageActionProps): ReactElement {\n // An action that can't do anything is a real disabled button, never aria-disabled alone.\n const inactive = disabled ?? (!onClick && !render);\n return (\n \n \n }\n />\n }\n >\n {children}\n \n {label}\n \n );\n}\n\nexport type AgentMessageCopyActionProps = Omit<\n AgentMessageActionProps,\n \"children\" | \"onClick\" | \"render\"\n> & {\n text: string;\n copiedLabel: string;\n /** Called after the text reaches the clipboard, e.g. to announce `copiedLabel`. */\n onCopy?: () => void;\n};\n\nexport function AgentMessageCopyAction({\n text,\n label,\n copiedLabel,\n onCopy,\n disabled,\n ...props\n}: AgentMessageCopyActionProps): ReactElement {\n const { copyToClipboard, isCopied } = useCopyToClipboard({ onCopy });\n return (\n copyToClipboard(text)}\n {...props}\n >\n {isCopied ? (\n \n ) : (\n \n )}\n \n );\n}\n\nexport type AgentMessageEditActionProps = Omit<\n AgentMessageActionProps,\n \"children\" | \"onClick\" | \"render\"\n> & {\n onEdit: () => void;\n};\n\nexport function AgentMessageEditAction({\n onEdit,\n ...props\n}: AgentMessageEditActionProps): ReactElement {\n return (\n \n \n \n );\n}\n", + "type": "registry:component", + "target": "@components/agent/agent-message-actions.tsx" + }, + { + "path": "registry/default/agent/agent-message.tsx", + "content": "\"use client\";\n\nimport type { ReactElement, ReactNode } from \"react\";\nimport { ChatBubble } from \"../chat/chat-bubble\";\nimport {\n ChatMessageContent,\n ChatMessageFooter,\n ChatMessageLayout,\n type ChatMessageLayoutProps,\n} from \"../chat/chat-message-layout\";\nimport { ChatMessageText } from \"../chat/chat-message-text\";\n\nexport type AgentMessageProps = Omit & {\n from: \"user\" | \"assistant\";\n actions?: ReactNode;\n /** Keep actions shown instead of revealing them on hover and focus. */\n actionsAlwaysVisible?: boolean;\n footer?: ReactNode;\n streaming?: boolean;\n};\n\nexport function AgentMessage({\n from,\n actions,\n actionsAlwaysVisible = false,\n footer,\n streaming,\n children,\n ...props\n}: AgentMessageProps): ReactElement {\n const user = from === \"user\";\n const content =\n typeof children === \"string\" ? (\n \n ) : (\n children\n );\n\n return (\n \n \n {user ? (\n \n {content}\n \n ) : (\n content\n )}\n {actions}\n {footer && {footer}}\n \n \n );\n}\n", + "type": "registry:component", + "target": "@components/agent/agent-message.tsx" + }, + { + "path": "registry/default/agent/hooks/use-agent-announcer.ts", + "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport {\n type AgentAnnouncementState,\n type AgentPhase,\n getAgentAnnouncement,\n getAgentAnnouncerMessage,\n} from \"../lib/get-agent-announcement\";\n\nexport type AgentAnnouncerLabels = Partial<\n Record, string>\n>;\n\nexport type AgentAnnouncerOptions = {\n phase: AgentPhase;\n /** Localized text for each phase. A phase without a label is never announced. */\n labels: AgentAnnouncerLabels;\n /** Replaces the phase label, such as the error or the approval question. */\n detail?: string;\n /** Changing it starts a new baseline, so a switched conversation isn't replayed. */\n conversationId?: string;\n};\n\nexport function useAgentAnnouncer({\n phase,\n labels,\n detail,\n conversationId,\n}: AgentAnnouncerOptions) {\n const [message, setMessage] = useState(\"\");\n const previousRef = useRef(null);\n const text = phase === \"idle\" ? \"\" : (detail ?? labels[phase] ?? \"\");\n\n useEffect(() => {\n const next = { conversationId, phase, text };\n const announcement = getAgentAnnouncement(previousRef.current, next);\n previousRef.current = next;\n if (announcement)\n setMessage((current) => getAgentAnnouncerMessage(current, announcement));\n }, [conversationId, phase, text]);\n\n /** Announce a one-off result, such as a copied message. */\n const announce = useCallback((value: string): void => {\n if (value)\n setMessage((current) => getAgentAnnouncerMessage(current, value));\n }, []);\n\n return { announce, message };\n}\n\nexport type AgentAnnouncerController = ReturnType;\n", + "type": "registry:component", + "target": "@components/agent/hooks/use-agent-announcer.ts" + }, + { + "path": "registry/default/agent/hooks/use-agent-reduced-motion.ts", + "content": "\"use client\";\n\nimport { useMediaQuery } from \"@/registry/default/hooks/use-media-query\";\n\n/**\n * The agent family's reduced-motion check for motion timed in JavaScript, such as an\n * exit delay or an animation loop. CSS transitions use `motion-reduce:` variants instead.\n */\nexport function useAgentReducedMotion(): boolean {\n return useMediaQuery(\"(prefers-reduced-motion: reduce)\");\n}\n", + "type": "registry:component", + "target": "@components/agent/hooks/use-agent-reduced-motion.ts" + }, + { + "path": "registry/default/agent/lib/get-agent-announcement.ts", + "content": "export type AgentPhase =\n | \"idle\"\n | \"thinking\"\n | \"working\"\n | \"approval\"\n | \"done\"\n | \"stopped\"\n | \"failed\";\n\nexport type AgentAnnouncementState = {\n phase: AgentPhase;\n /** What this phase would say: a phase label, or a detail such as the error. */\n text: string;\n conversationId?: string;\n};\n\nfunction isBusy(phase: AgentPhase): boolean {\n return phase === \"thinking\" || phase === \"working\";\n}\n\nexport function getAgentAnnouncement(\n previous: AgentAnnouncementState | null,\n next: AgentAnnouncementState,\n): string {\n // A new mount or conversation only records a baseline; its state isn't replayed.\n if (previous === null || previous.conversationId !== next.conversationId)\n return \"\";\n if (next.phase !== previous.phase) {\n if (isBusy(next.phase) && isBusy(previous.phase)) return \"\";\n return next.text;\n }\n // Busy text is progress, streamed or not: only settled phases re-announce new text.\n if (isBusy(next.phase) || next.text === previous.text) return \"\";\n return next.text;\n}\n\nconst REPEAT_MARK = \" \";\n\n/** Live regions skip unchanged content, so a repeat toggles a trailing no-break space. */\nexport function getAgentAnnouncerMessage(\n current: string,\n text: string,\n): string {\n if (current === text) return `${text}${REPEAT_MARK}`;\n return text;\n}\n", + "type": "registry:component", + "target": "@components/agent/lib/get-agent-announcement.ts" + }, + { + "path": "registry/default/agent/lib/get-agent-scroll-offset.ts", + "content": "export const AGENT_SCROLL_OFFSET_VAR = \"--agent-conversation-scroll-top\";\n\n/**\n * Base UI popover viewports crossfade a cloned snapshot of the outgoing content,\n * and a clone keeps attributes but not scroll position. Written to the viewport's\n * inline style, this offset travels with the clone so the transcript can be\n * translated back to where the reader was.\n */\nexport function getAgentScrollOffset(scrollTop: number): string {\n return `${Math.max(0, scrollTop)}px`;\n}\n", + "type": "registry:component", + "target": "@components/agent/lib/get-agent-scroll-offset.ts" + } + ], + "type": "registry:ui" +} \ No newline at end of file diff --git a/apps/ui/registry/default/agent/agent-announcer.tsx b/apps/ui/registry/default/agent/agent-announcer.tsx new file mode 100644 index 000000000..c7a0886fb --- /dev/null +++ b/apps/ui/registry/default/agent/agent-announcer.tsx @@ -0,0 +1,30 @@ +import type { ComponentProps, ReactElement } from "react"; +import { cn } from "@/registry/default/lib/utils"; +import type { AgentAnnouncerController } from "./hooks/use-agent-announcer"; + +export type AgentAnnouncerProps = Omit< + ComponentProps<"span">, + "children" | "role" +> & { + announcer: AgentAnnouncerController; +}; + +/** The conversation's one status region. Render it once, outside the scrolling log. */ +export function AgentAnnouncer({ + announcer, + className, + ...props +}: AgentAnnouncerProps): ReactElement { + return ( + + {announcer.message} + + ); +} diff --git a/apps/ui/registry/default/agent/agent-conversation.tsx b/apps/ui/registry/default/agent/agent-conversation.tsx new file mode 100644 index 000000000..0ecfa5d8b --- /dev/null +++ b/apps/ui/registry/default/agent/agent-conversation.tsx @@ -0,0 +1,43 @@ +"use client"; + +import type { ReactElement, UIEvent } from "react"; +import { cn } from "@/registry/default/lib/utils"; +import { + ChatConversation, + type ChatConversationProps, +} from "../chat/chat-conversation"; +import { + AGENT_SCROLL_OFFSET_VAR, + getAgentScrollOffset, +} from "./lib/get-agent-scroll-offset"; + +export type AgentConversationProps = ChatConversationProps; + +export function AgentConversation({ + scroll, + live = "off", + contentClassName, + ...props +}: AgentConversationProps): ReactElement { + const onScroll = (event: UIEvent): void => { + event.currentTarget.style.setProperty( + AGENT_SCROLL_OFFSET_VAR, + getAgentScrollOffset(event.currentTarget.scrollTop), + ); + scroll.onScroll(event); + }; + + return ( + + ); +} diff --git a/apps/ui/registry/default/agent/agent-message-actions.tsx b/apps/ui/registry/default/agent/agent-message-actions.tsx new file mode 100644 index 000000000..98ae9db3c --- /dev/null +++ b/apps/ui/registry/default/agent/agent-message-actions.tsx @@ -0,0 +1,148 @@ +"use client"; + +import type { Toolbar as ToolbarPrimitive } from "@base-ui/react/toolbar"; +import { CheckIcon, CopyIcon, PencilIcon } from "lucide-react"; +import type { ReactElement, ReactNode } from "react"; +import { useCopyToClipboard } from "@/registry/default/hooks/use-copy-to-clipboard"; +import { cn } from "@/registry/default/lib/utils"; +import { Button, type ButtonProps } from "@/registry/default/ui/button"; +import { Toolbar, ToolbarButton } from "@/registry/default/ui/toolbar"; +import { + Tooltip, + TooltipPopup, + TooltipTrigger, +} from "@/registry/default/ui/tooltip"; + +export type AgentMessageActionsProps = Omit< + ToolbarPrimitive.Root.Props, + "aria-label" +> & { + label: string; +}; + +export function AgentMessageActions({ + label, + className, + ...props +}: AgentMessageActionsProps): ReactElement { + return ( + + ); +} + +export type AgentMessageActionProps = Omit< + ButtonProps, + "aria-label" | "children" | "size" | "variant" +> & { + label: string; + children: ReactNode; +}; + +export function AgentMessageAction({ + label, + children, + onClick, + render, + disabled, + className, + ...props +}: AgentMessageActionProps): ReactElement { + // An action that can't do anything is a real disabled button, never aria-disabled alone. + const inactive = disabled ?? (!onClick && !render); + return ( + + + } + /> + } + > + {children} + + {label} + + ); +} + +export type AgentMessageCopyActionProps = Omit< + AgentMessageActionProps, + "children" | "onClick" | "render" +> & { + text: string; + copiedLabel: string; + /** Called after the text reaches the clipboard, e.g. to announce `copiedLabel`. */ + onCopy?: () => void; +}; + +export function AgentMessageCopyAction({ + text, + label, + copiedLabel, + onCopy, + disabled, + ...props +}: AgentMessageCopyActionProps): ReactElement { + const { copyToClipboard, isCopied } = useCopyToClipboard({ onCopy }); + return ( + copyToClipboard(text)} + {...props} + > + {isCopied ? ( + + ); +} + +export type AgentMessageEditActionProps = Omit< + AgentMessageActionProps, + "children" | "onClick" | "render" +> & { + onEdit: () => void; +}; + +export function AgentMessageEditAction({ + onEdit, + ...props +}: AgentMessageEditActionProps): ReactElement { + return ( + + + ); +} diff --git a/apps/ui/registry/default/agent/agent-message.tsx b/apps/ui/registry/default/agent/agent-message.tsx new file mode 100644 index 000000000..9c9d245d6 --- /dev/null +++ b/apps/ui/registry/default/agent/agent-message.tsx @@ -0,0 +1,60 @@ +"use client"; + +import type { ReactElement, ReactNode } from "react"; +import { ChatBubble } from "../chat/chat-bubble"; +import { + ChatMessageContent, + ChatMessageFooter, + ChatMessageLayout, + type ChatMessageLayoutProps, +} from "../chat/chat-message-layout"; +import { ChatMessageText } from "../chat/chat-message-text"; + +export type AgentMessageProps = Omit & { + from: "user" | "assistant"; + actions?: ReactNode; + /** Keep actions shown instead of revealing them on hover and focus. */ + actionsAlwaysVisible?: boolean; + footer?: ReactNode; + streaming?: boolean; +}; + +export function AgentMessage({ + from, + actions, + actionsAlwaysVisible = false, + footer, + streaming, + children, + ...props +}: AgentMessageProps): ReactElement { + const user = from === "user"; + const content = + typeof children === "string" ? ( + + ) : ( + children + ); + + return ( + + + {user ? ( + + {content} + + ) : ( + content + )} + {actions} + {footer && {footer}} + + + ); +} diff --git a/apps/ui/registry/default/agent/hooks/use-agent-announcer.ts b/apps/ui/registry/default/agent/hooks/use-agent-announcer.ts new file mode 100644 index 000000000..cbdbbbbe0 --- /dev/null +++ b/apps/ui/registry/default/agent/hooks/use-agent-announcer.ts @@ -0,0 +1,52 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + type AgentAnnouncementState, + type AgentPhase, + getAgentAnnouncement, + getAgentAnnouncerMessage, +} from "../lib/get-agent-announcement"; + +export type AgentAnnouncerLabels = Partial< + Record, string> +>; + +export type AgentAnnouncerOptions = { + phase: AgentPhase; + /** Localized text for each phase. A phase without a label is never announced. */ + labels: AgentAnnouncerLabels; + /** Replaces the phase label, such as the error or the approval question. */ + detail?: string; + /** Changing it starts a new baseline, so a switched conversation isn't replayed. */ + conversationId?: string; +}; + +export function useAgentAnnouncer({ + phase, + labels, + detail, + conversationId, +}: AgentAnnouncerOptions) { + const [message, setMessage] = useState(""); + const previousRef = useRef(null); + const text = phase === "idle" ? "" : (detail ?? labels[phase] ?? ""); + + useEffect(() => { + const next = { conversationId, phase, text }; + const announcement = getAgentAnnouncement(previousRef.current, next); + previousRef.current = next; + if (announcement) + setMessage((current) => getAgentAnnouncerMessage(current, announcement)); + }, [conversationId, phase, text]); + + /** Announce a one-off result, such as a copied message. */ + const announce = useCallback((value: string): void => { + if (value) + setMessage((current) => getAgentAnnouncerMessage(current, value)); + }, []); + + return { announce, message }; +} + +export type AgentAnnouncerController = ReturnType; diff --git a/apps/ui/registry/default/agent/hooks/use-agent-reduced-motion.ts b/apps/ui/registry/default/agent/hooks/use-agent-reduced-motion.ts new file mode 100644 index 000000000..666d64571 --- /dev/null +++ b/apps/ui/registry/default/agent/hooks/use-agent-reduced-motion.ts @@ -0,0 +1,11 @@ +"use client"; + +import { useMediaQuery } from "@/registry/default/hooks/use-media-query"; + +/** + * The agent family's reduced-motion check for motion timed in JavaScript, such as an + * exit delay or an animation loop. CSS transitions use `motion-reduce:` variants instead. + */ +export function useAgentReducedMotion(): boolean { + return useMediaQuery("(prefers-reduced-motion: reduce)"); +} diff --git a/apps/ui/registry/default/agent/lib/get-agent-announcement.ts b/apps/ui/registry/default/agent/lib/get-agent-announcement.ts new file mode 100644 index 000000000..0857cefb6 --- /dev/null +++ b/apps/ui/registry/default/agent/lib/get-agent-announcement.ts @@ -0,0 +1,46 @@ +export type AgentPhase = + | "idle" + | "thinking" + | "working" + | "approval" + | "done" + | "stopped" + | "failed"; + +export type AgentAnnouncementState = { + phase: AgentPhase; + /** What this phase would say: a phase label, or a detail such as the error. */ + text: string; + conversationId?: string; +}; + +function isBusy(phase: AgentPhase): boolean { + return phase === "thinking" || phase === "working"; +} + +export function getAgentAnnouncement( + previous: AgentAnnouncementState | null, + next: AgentAnnouncementState, +): string { + // A new mount or conversation only records a baseline; its state isn't replayed. + if (previous === null || previous.conversationId !== next.conversationId) + return ""; + if (next.phase !== previous.phase) { + if (isBusy(next.phase) && isBusy(previous.phase)) return ""; + return next.text; + } + // Busy text is progress, streamed or not: only settled phases re-announce new text. + if (isBusy(next.phase) || next.text === previous.text) return ""; + return next.text; +} + +const REPEAT_MARK = " "; + +/** Live regions skip unchanged content, so a repeat toggles a trailing no-break space. */ +export function getAgentAnnouncerMessage( + current: string, + text: string, +): string { + if (current === text) return `${text}${REPEAT_MARK}`; + return text; +} diff --git a/apps/ui/registry/default/agent/lib/get-agent-scroll-offset.ts b/apps/ui/registry/default/agent/lib/get-agent-scroll-offset.ts new file mode 100644 index 000000000..1f3f393bb --- /dev/null +++ b/apps/ui/registry/default/agent/lib/get-agent-scroll-offset.ts @@ -0,0 +1,11 @@ +export const AGENT_SCROLL_OFFSET_VAR = "--agent-conversation-scroll-top"; + +/** + * Base UI popover viewports crossfade a cloned snapshot of the outgoing content, + * and a clone keeps attributes but not scroll position. Written to the viewport's + * inline style, this offset travels with the clone so the transcript can be + * translated back to where the reader was. + */ +export function getAgentScrollOffset(scrollTop: number): string { + return `${Math.max(0, scrollTop)}px`; +} diff --git a/apps/ui/registry/index.ts b/apps/ui/registry/index.ts index c0a89b518..8b0f46caf 100644 --- a/apps/ui/registry/index.ts +++ b/apps/ui/registry/index.ts @@ -1,4 +1,5 @@ import type { Registry } from "shadcn/schema"; +import { agent } from "@/registry/registry-agent"; import { baseUi } from "@/registry/registry-base-ui"; import { chat } from "@/registry/registry-chat"; import { fonts } from "@/registry/registry-fonts"; @@ -13,6 +14,7 @@ export const registry = { items: [ ...ui, ...chat, + ...agent, ...(particles as Registry["items"]), ...styles, ...fonts, diff --git a/apps/ui/registry/registry-agent.ts b/apps/ui/registry/registry-agent.ts new file mode 100644 index 000000000..5975d07f7 --- /dev/null +++ b/apps/ui/registry/registry-agent.ts @@ -0,0 +1,63 @@ +import type { Registry } from "shadcn/schema"; + +export const agent: Registry["items"] = [ + { + name: "agent", + type: "registry:ui", + description: + "Agent conversation, messages, message actions, and a screen reader announcer built on chat.", + dependencies: ["lucide-react", "@base-ui/react"], + registryDependencies: [ + "@coss/button", + "@coss/chat", + "@coss/composer", + "@coss/toolbar", + "@coss/tooltip", + "@coss/use-copy-to-clipboard", + "@coss/use-media-query", + "@coss/utils", + ], + files: [ + { + path: "agent/agent-announcer.tsx", + type: "registry:component", + target: "@components/agent/agent-announcer.tsx", + }, + { + path: "agent/agent-conversation.tsx", + type: "registry:component", + target: "@components/agent/agent-conversation.tsx", + }, + { + path: "agent/agent-message-actions.tsx", + type: "registry:component", + target: "@components/agent/agent-message-actions.tsx", + }, + { + path: "agent/agent-message.tsx", + type: "registry:component", + target: "@components/agent/agent-message.tsx", + }, + { + path: "agent/hooks/use-agent-announcer.ts", + type: "registry:component", + target: "@components/agent/hooks/use-agent-announcer.ts", + }, + { + path: "agent/hooks/use-agent-reduced-motion.ts", + type: "registry:component", + target: "@components/agent/hooks/use-agent-reduced-motion.ts", + }, + { + path: "agent/lib/get-agent-announcement.ts", + type: "registry:component", + target: "@components/agent/lib/get-agent-announcement.ts", + }, + { + path: "agent/lib/get-agent-scroll-offset.ts", + type: "registry:component", + target: "@components/agent/lib/get-agent-scroll-offset.ts", + }, + ], + }, +]; diff --git a/apps/ui/scripts/sync-ui.mts b/apps/ui/scripts/sync-ui.mts index 6ef576ea8..a5a9fe78a 100644 --- a/apps/ui/scripts/sync-ui.mts +++ b/apps/ui/scripts/sync-ui.mts @@ -5,6 +5,7 @@ async function resolvePaths() { const cwd = process.cwd(); const sourceRoot = path.join(cwd, "registry/default"); const sourceDirs = { + agent: path.join(sourceRoot, "agent"), baseUi: path.join(sourceRoot, "base-ui"), chat: path.join(sourceRoot, "chat"), hooks: path.join(sourceRoot, "hooks"), @@ -14,6 +15,7 @@ async function resolvePaths() { // From apps/ui → ../../packages/ui/src/* const targetRoot = path.resolve(cwd, "../../packages/ui/src"); const targetDirs = { + agent: path.join(targetRoot, "agent"), baseUi: path.join(targetRoot, "base-ui"), chat: path.join(targetRoot, "chat"), hooks: path.join(targetRoot, "hooks"), diff --git a/apps/ui/scripts/validate-registry-deps.mts b/apps/ui/scripts/validate-registry-deps.mts index 4676b3a8c..4184adf25 100644 --- a/apps/ui/scripts/validate-registry-deps.mts +++ b/apps/ui/scripts/validate-registry-deps.mts @@ -90,6 +90,12 @@ function parseImports(content: string): { continue; } + // Check if it's an agent family import (@/registry/default/agent/*) + if (/^@\/registry\/default\/agent\//.test(importPath)) { + registryDeps.add(getRegistryPackageName("agent")); + continue; + } + // Check if it's already a @coss import if (importPath.startsWith("@coss/")) { // Skip @coss/utils as it's a common utility that shouldn't be treated as a dependency diff --git a/apps/ui/tests/agent/get-agent-announcement.test.ts b/apps/ui/tests/agent/get-agent-announcement.test.ts new file mode 100644 index 000000000..789e41f09 --- /dev/null +++ b/apps/ui/tests/agent/get-agent-announcement.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "bun:test"; +import { + getAgentAnnouncement, + getAgentAnnouncerMessage, +} from "../../registry/default/agent/lib/get-agent-announcement"; + +describe("agent announcement", () => { + it("records the first state as a baseline without announcing it", () => { + expect( + getAgentAnnouncement(null, { phase: "working", text: "Working" }), + ).toBe(""); + }); + + it("announces a phase change with that phase's text", () => { + expect( + getAgentAnnouncement( + { phase: "idle", text: "" }, + { phase: "thinking", text: "Thinking" }, + ), + ).toBe("Thinking"); + }); + + it("stays quiet when a busy turn moves between thinking and working", () => { + expect( + getAgentAnnouncement( + { phase: "thinking", text: "Thinking" }, + { phase: "working", text: "Working" }, + ), + ).toBe(""); + expect( + getAgentAnnouncement( + { phase: "working", text: "Working" }, + { phase: "thinking", text: "Thinking" }, + ), + ).toBe(""); + }); + + it("replays nothing when the conversation changes", () => { + expect( + getAgentAnnouncement( + { conversationId: "a", phase: "done", text: "Response ready" }, + { conversationId: "b", phase: "failed", text: "Network error" }, + ), + ).toBe(""); + }); + + it("announces a new error or approval within the same phase", () => { + expect( + getAgentAnnouncement( + { phase: "failed", text: "Network error" }, + { phase: "failed", text: "Rate limited" }, + ), + ).toBe("Rate limited"); + expect( + getAgentAnnouncement( + { phase: "approval", text: "Cancel the 3 p.m. meeting?" }, + { phase: "approval", text: "Move the 4 p.m. meeting?" }, + ), + ).toBe("Move the 4 p.m. meeting?"); + }); + + it("never re-announces an unchanged phase or busy progress", () => { + expect( + getAgentAnnouncement( + { phase: "failed", text: "Network error" }, + { phase: "failed", text: "Network error" }, + ), + ).toBe(""); + expect( + getAgentAnnouncement( + { phase: "working", text: "Searching calendars" }, + { phase: "working", text: "Checking availability" }, + ), + ).toBe(""); + }); +}); + +describe("agent announcer message", () => { + it("shows a new message as given", () => { + expect(getAgentAnnouncerMessage("Thinking", "Copied")).toBe("Copied"); + }); + + it("changes a repeated message so a screen reader reads it again", () => { + const first = getAgentAnnouncerMessage("", "Copied"); + const second = getAgentAnnouncerMessage(first, "Copied"); + const third = getAgentAnnouncerMessage(second, "Copied"); + expect(second).not.toBe(first); + expect(third).not.toBe(second); + expect(second.trim()).toBe("Copied"); + }); +}); diff --git a/apps/ui/tests/agent/get-agent-scroll-offset.test.ts b/apps/ui/tests/agent/get-agent-scroll-offset.test.ts new file mode 100644 index 000000000..14c059cd6 --- /dev/null +++ b/apps/ui/tests/agent/get-agent-scroll-offset.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "bun:test"; +import { getAgentScrollOffset } from "../../registry/default/agent/lib/get-agent-scroll-offset"; + +describe("agent scroll offset", () => { + it("records how far the transcript is scrolled", () => { + expect(getAgentScrollOffset(240)).toBe("240px"); + }); + + it("ignores overscroll bounce above the top", () => { + expect(getAgentScrollOffset(-18)).toBe("0px"); + }); +}); diff --git a/packages/ui/package.json b/packages/ui/package.json index 1f8df0bf1..8c044dad6 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -39,7 +39,10 @@ "./shared/*": "./src/shared/*.tsx", "./chat/*": "./src/chat/*.tsx", "./chat/hooks/*": "./src/chat/hooks/*.ts", - "./chat/lib/*": "./src/chat/lib/*.ts" + "./chat/lib/*": "./src/chat/lib/*.ts", + "./agent/*": "./src/agent/*.tsx", + "./agent/hooks/*": "./src/agent/hooks/*.ts", + "./agent/lib/*": "./src/agent/lib/*.ts" }, "license": "AGPL-3.0-or-later", "name": "@coss/ui", diff --git a/packages/ui/src/agent/agent-announcer.tsx b/packages/ui/src/agent/agent-announcer.tsx new file mode 100644 index 000000000..902d21264 --- /dev/null +++ b/packages/ui/src/agent/agent-announcer.tsx @@ -0,0 +1,30 @@ +import { cn } from "@coss/ui/lib/utils"; +import type { ComponentProps, ReactElement } from "react"; +import type { AgentAnnouncerController } from "./hooks/use-agent-announcer"; + +export type AgentAnnouncerProps = Omit< + ComponentProps<"span">, + "children" | "role" +> & { + announcer: AgentAnnouncerController; +}; + +/** The conversation's one status region. Render it once, outside the scrolling log. */ +export function AgentAnnouncer({ + announcer, + className, + ...props +}: AgentAnnouncerProps): ReactElement { + return ( + + {announcer.message} + + ); +} diff --git a/packages/ui/src/agent/agent-conversation.tsx b/packages/ui/src/agent/agent-conversation.tsx new file mode 100644 index 000000000..c6cb1606c --- /dev/null +++ b/packages/ui/src/agent/agent-conversation.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { cn } from "@coss/ui/lib/utils"; +import type { ReactElement, UIEvent } from "react"; +import { + ChatConversation, + type ChatConversationProps, +} from "../chat/chat-conversation"; +import { + AGENT_SCROLL_OFFSET_VAR, + getAgentScrollOffset, +} from "./lib/get-agent-scroll-offset"; + +export type AgentConversationProps = ChatConversationProps; + +export function AgentConversation({ + scroll, + live = "off", + contentClassName, + ...props +}: AgentConversationProps): ReactElement { + const onScroll = (event: UIEvent): void => { + event.currentTarget.style.setProperty( + AGENT_SCROLL_OFFSET_VAR, + getAgentScrollOffset(event.currentTarget.scrollTop), + ); + scroll.onScroll(event); + }; + + return ( + + ); +} diff --git a/packages/ui/src/agent/agent-message-actions.tsx b/packages/ui/src/agent/agent-message-actions.tsx new file mode 100644 index 000000000..5bdd9fd88 --- /dev/null +++ b/packages/ui/src/agent/agent-message-actions.tsx @@ -0,0 +1,148 @@ +"use client"; + +import type { Toolbar as ToolbarPrimitive } from "@base-ui/react/toolbar"; +import { Button, type ButtonProps } from "@coss/ui/components/button"; +import { Toolbar, ToolbarButton } from "@coss/ui/components/toolbar"; +import { + Tooltip, + TooltipPopup, + TooltipTrigger, +} from "@coss/ui/components/tooltip"; +import { useCopyToClipboard } from "@coss/ui/hooks/use-copy-to-clipboard"; +import { cn } from "@coss/ui/lib/utils"; +import { CheckIcon, CopyIcon, PencilIcon } from "lucide-react"; +import type { ReactElement, ReactNode } from "react"; + +export type AgentMessageActionsProps = Omit< + ToolbarPrimitive.Root.Props, + "aria-label" +> & { + label: string; +}; + +export function AgentMessageActions({ + label, + className, + ...props +}: AgentMessageActionsProps): ReactElement { + return ( + + ); +} + +export type AgentMessageActionProps = Omit< + ButtonProps, + "aria-label" | "children" | "size" | "variant" +> & { + label: string; + children: ReactNode; +}; + +export function AgentMessageAction({ + label, + children, + onClick, + render, + disabled, + className, + ...props +}: AgentMessageActionProps): ReactElement { + // An action that can't do anything is a real disabled button, never aria-disabled alone. + const inactive = disabled ?? (!onClick && !render); + return ( + + + } + /> + } + > + {children} + + {label} + + ); +} + +export type AgentMessageCopyActionProps = Omit< + AgentMessageActionProps, + "children" | "onClick" | "render" +> & { + text: string; + copiedLabel: string; + /** Called after the text reaches the clipboard, e.g. to announce `copiedLabel`. */ + onCopy?: () => void; +}; + +export function AgentMessageCopyAction({ + text, + label, + copiedLabel, + onCopy, + disabled, + ...props +}: AgentMessageCopyActionProps): ReactElement { + const { copyToClipboard, isCopied } = useCopyToClipboard({ onCopy }); + return ( + copyToClipboard(text)} + {...props} + > + {isCopied ? ( + + ); +} + +export type AgentMessageEditActionProps = Omit< + AgentMessageActionProps, + "children" | "onClick" | "render" +> & { + onEdit: () => void; +}; + +export function AgentMessageEditAction({ + onEdit, + ...props +}: AgentMessageEditActionProps): ReactElement { + return ( + + + ); +} diff --git a/packages/ui/src/agent/agent-message.tsx b/packages/ui/src/agent/agent-message.tsx new file mode 100644 index 000000000..9c9d245d6 --- /dev/null +++ b/packages/ui/src/agent/agent-message.tsx @@ -0,0 +1,60 @@ +"use client"; + +import type { ReactElement, ReactNode } from "react"; +import { ChatBubble } from "../chat/chat-bubble"; +import { + ChatMessageContent, + ChatMessageFooter, + ChatMessageLayout, + type ChatMessageLayoutProps, +} from "../chat/chat-message-layout"; +import { ChatMessageText } from "../chat/chat-message-text"; + +export type AgentMessageProps = Omit & { + from: "user" | "assistant"; + actions?: ReactNode; + /** Keep actions shown instead of revealing them on hover and focus. */ + actionsAlwaysVisible?: boolean; + footer?: ReactNode; + streaming?: boolean; +}; + +export function AgentMessage({ + from, + actions, + actionsAlwaysVisible = false, + footer, + streaming, + children, + ...props +}: AgentMessageProps): ReactElement { + const user = from === "user"; + const content = + typeof children === "string" ? ( + + ) : ( + children + ); + + return ( + + + {user ? ( + + {content} + + ) : ( + content + )} + {actions} + {footer && {footer}} + + + ); +} diff --git a/packages/ui/src/agent/hooks/use-agent-announcer.ts b/packages/ui/src/agent/hooks/use-agent-announcer.ts new file mode 100644 index 000000000..cbdbbbbe0 --- /dev/null +++ b/packages/ui/src/agent/hooks/use-agent-announcer.ts @@ -0,0 +1,52 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + type AgentAnnouncementState, + type AgentPhase, + getAgentAnnouncement, + getAgentAnnouncerMessage, +} from "../lib/get-agent-announcement"; + +export type AgentAnnouncerLabels = Partial< + Record, string> +>; + +export type AgentAnnouncerOptions = { + phase: AgentPhase; + /** Localized text for each phase. A phase without a label is never announced. */ + labels: AgentAnnouncerLabels; + /** Replaces the phase label, such as the error or the approval question. */ + detail?: string; + /** Changing it starts a new baseline, so a switched conversation isn't replayed. */ + conversationId?: string; +}; + +export function useAgentAnnouncer({ + phase, + labels, + detail, + conversationId, +}: AgentAnnouncerOptions) { + const [message, setMessage] = useState(""); + const previousRef = useRef(null); + const text = phase === "idle" ? "" : (detail ?? labels[phase] ?? ""); + + useEffect(() => { + const next = { conversationId, phase, text }; + const announcement = getAgentAnnouncement(previousRef.current, next); + previousRef.current = next; + if (announcement) + setMessage((current) => getAgentAnnouncerMessage(current, announcement)); + }, [conversationId, phase, text]); + + /** Announce a one-off result, such as a copied message. */ + const announce = useCallback((value: string): void => { + if (value) + setMessage((current) => getAgentAnnouncerMessage(current, value)); + }, []); + + return { announce, message }; +} + +export type AgentAnnouncerController = ReturnType; diff --git a/packages/ui/src/agent/hooks/use-agent-reduced-motion.ts b/packages/ui/src/agent/hooks/use-agent-reduced-motion.ts new file mode 100644 index 000000000..b78530d72 --- /dev/null +++ b/packages/ui/src/agent/hooks/use-agent-reduced-motion.ts @@ -0,0 +1,11 @@ +"use client"; + +import { useMediaQuery } from "@coss/ui/hooks/use-media-query"; + +/** + * The agent family's reduced-motion check for motion timed in JavaScript, such as an + * exit delay or an animation loop. CSS transitions use `motion-reduce:` variants instead. + */ +export function useAgentReducedMotion(): boolean { + return useMediaQuery("(prefers-reduced-motion: reduce)"); +} diff --git a/packages/ui/src/agent/lib/get-agent-announcement.ts b/packages/ui/src/agent/lib/get-agent-announcement.ts new file mode 100644 index 000000000..0857cefb6 --- /dev/null +++ b/packages/ui/src/agent/lib/get-agent-announcement.ts @@ -0,0 +1,46 @@ +export type AgentPhase = + | "idle" + | "thinking" + | "working" + | "approval" + | "done" + | "stopped" + | "failed"; + +export type AgentAnnouncementState = { + phase: AgentPhase; + /** What this phase would say: a phase label, or a detail such as the error. */ + text: string; + conversationId?: string; +}; + +function isBusy(phase: AgentPhase): boolean { + return phase === "thinking" || phase === "working"; +} + +export function getAgentAnnouncement( + previous: AgentAnnouncementState | null, + next: AgentAnnouncementState, +): string { + // A new mount or conversation only records a baseline; its state isn't replayed. + if (previous === null || previous.conversationId !== next.conversationId) + return ""; + if (next.phase !== previous.phase) { + if (isBusy(next.phase) && isBusy(previous.phase)) return ""; + return next.text; + } + // Busy text is progress, streamed or not: only settled phases re-announce new text. + if (isBusy(next.phase) || next.text === previous.text) return ""; + return next.text; +} + +const REPEAT_MARK = " "; + +/** Live regions skip unchanged content, so a repeat toggles a trailing no-break space. */ +export function getAgentAnnouncerMessage( + current: string, + text: string, +): string { + if (current === text) return `${text}${REPEAT_MARK}`; + return text; +} diff --git a/packages/ui/src/agent/lib/get-agent-scroll-offset.ts b/packages/ui/src/agent/lib/get-agent-scroll-offset.ts new file mode 100644 index 000000000..1f3f393bb --- /dev/null +++ b/packages/ui/src/agent/lib/get-agent-scroll-offset.ts @@ -0,0 +1,11 @@ +export const AGENT_SCROLL_OFFSET_VAR = "--agent-conversation-scroll-top"; + +/** + * Base UI popover viewports crossfade a cloned snapshot of the outgoing content, + * and a clone keeps attributes but not scroll position. Written to the viewport's + * inline style, this offset travels with the clone so the transcript can be + * translated back to where the reader was. + */ +export function getAgentScrollOffset(scrollTop: number): string { + return `${Math.max(0, scrollTop)}px`; +} From 7e2928a8312f6266dff72d12badfb9a9c654dbaf Mon Sep 17 00:00:00 2001 From: pasqualevitiello Date: Sat, 17 Oct 2026 16:52:45 +0200 Subject: [PATCH 2/8] docs(agent): add Agent UI docs page with conversation, message, actions, and announcer particles Co-Authored-By: Claude Opus 5.5 --- apps/ui/content/docs/(root)/agent.mdx | 166 ++++++++++++++++++ apps/ui/content/docs/composites/meta.json | 2 +- apps/ui/lib/docs.ts | 1 + apps/ui/public/r/p-agent-1.json | 28 +++ apps/ui/public/r/p-agent-2.json | 28 +++ apps/ui/public/r/p-agent-3.json | 29 +++ apps/ui/public/r/p-agent-4.json | 24 +++ apps/ui/public/r/registry.json | 162 +++++++++++++++++ apps/ui/registry.json | 162 +++++++++++++++++ apps/ui/registry/__index__.tsx | 118 +++++++++++++ .../registry/default/particles/p-agent-1.tsx | 81 +++++++++ .../registry/default/particles/p-agent-2.tsx | 66 +++++++ .../registry/default/particles/p-agent-3.tsx | 74 ++++++++ .../registry/default/particles/p-agent-4.tsx | 81 +++++++++ apps/ui/registry/registry-categories.ts | 1 + apps/ui/registry/registry-particles.ts | 55 ++++++ 16 files changed, 1077 insertions(+), 1 deletion(-) create mode 100644 apps/ui/content/docs/(root)/agent.mdx create mode 100644 apps/ui/public/r/p-agent-1.json create mode 100644 apps/ui/public/r/p-agent-2.json create mode 100644 apps/ui/public/r/p-agent-3.json create mode 100644 apps/ui/public/r/p-agent-4.json create mode 100644 apps/ui/registry/default/particles/p-agent-1.tsx create mode 100644 apps/ui/registry/default/particles/p-agent-2.tsx create mode 100644 apps/ui/registry/default/particles/p-agent-3.tsx create mode 100644 apps/ui/registry/default/particles/p-agent-4.tsx diff --git a/apps/ui/content/docs/(root)/agent.mdx b/apps/ui/content/docs/(root)/agent.mdx new file mode 100644 index 000000000..829b71665 --- /dev/null +++ b/apps/ui/content/docs/(root)/agent.mdx @@ -0,0 +1,166 @@ +--- +title: Agent UI +description: Install and compose Agent UI, a family for AI agent conversations built on Chat UI and Composer. +--- + +Agent UI is a family of components for conversations with an AI agent, in a panel or a page. It builds on [Chat UI](/ui/docs/chat) and the shared [Composer](/ui/docs/components/composer): where an agent and a chat mean the same thing, such as the scroll container, the message shell, timestamps, Markdown and streaming text, Agent UI uses the chat part. The components handle presentation, local interaction and screen reader announcements. Your application owns the agent runtime, messages, persistence and authorization. + +The family is new. This page covers the conversation, messages, message actions, the announcer and the motion policy. Composer, turn content and chrome parts follow. + +## Start here + +- **Build a conversation:** install `@coss/agent`, then use the [minimal example](#minimal-conversation). +- **Working as an agent:** read [this page as Markdown](/ui/docs/agent.md). The [agent registry source](/ui/r/agent.json) includes file contents and dependencies. Use the exported TypeScript props as the API contract. + +## Install + +Configure coss ui using [Get started](/ui/docs/get-started), then install the family: + +```bash +npx shadcn@latest add @coss/agent +``` + +This installs the agent source into your configured components directory under `agent/`, including its hooks and helpers. It also installs `@coss/chat` and `@coss/composer`, which the family builds on, and the coss components they need. + +## Minimal conversation + +A conversation needs a bounded height, a chat scroll controller, messages and one announcer. Render the announcer once per conversation, outside the scrolling log. + +```tsx +"use client"; + +import { AgentAnnouncer } from "@/components/agent/agent-announcer"; +import { AgentConversation } from "@/components/agent/agent-conversation"; +import { AgentMessage } from "@/components/agent/agent-message"; +import { useAgentAnnouncer } from "@/components/agent/hooks/use-agent-announcer"; +import type { AgentPhase } from "@/components/agent/lib/get-agent-announcement"; +import { useChatScroll } from "@/components/chat/hooks/use-chat-scroll"; + +type Message = { id: string; from: "user" | "assistant"; text: string }; + +const labels = { + done: "Response ready", + failed: "Something went wrong", + stopped: "Response stopped", + thinking: "Thinking", + working: "Working", +}; + +export function AgentChat({ + messages, + phase, +}: { + messages: Message[]; + phase: AgentPhase; +}) { + const scroll = useChatScroll({ itemCount: messages.length }); + const announcer = useAgentAnnouncer({ labels, phase }); + + return ( +
+ + {messages.map((message) => ( + + {message.text} + + ))} + + +
+ ); +} +``` + +## Component map + +Import components directly from their files. There is no provider. + +| Area | Components | Responsibility | +| --- | --- | --- | +| Conversation | `AgentConversation`, `useChatScroll` | Chat scroll viewport and end following, quiet by default, keeps its place in popover crossfades | +| Message | `AgentMessage` | User bubble or full-width reply on the chat message shell, actions and footer slots, streaming state | +| Content | `ChatMessageText`, `ChatMessageMarkdown`, `ChatMessageCaret`, `ChatMessageTimestamp` | Chat parts for text, Markdown, the streaming caret and timestamps | +| Actions | `AgentMessageActions`, `AgentMessageAction`, `AgentMessageCopyAction`, `AgentMessageEditAction` | Toolbar revealed on hover and focus, generic icon action, copy and edit | +| Announcer | `AgentAnnouncer`, `useAgentAnnouncer`, `getAgentAnnouncement` | One status region per conversation and the rule for what it says | +| Motion | `useAgentReducedMotion` | Reduced-motion check for motion timed in JavaScript | + +## Source map + +Paths below are relative to your configured components directory after installation. In the coss repository, the canonical sources live under `apps/ui/registry/default/`. + +| File | Read it for | +| --- | --- | +| `agent/agent-*.tsx` | Exported component props, required labels, slots and defaults | +| `agent/hooks/use-agent-announcer.ts` | Announcer options, labels and the `announce` method | +| `agent/lib/get-agent-announcement.ts` | Agent phases and which transitions are announced | +| `agent/lib/get-agent-scroll-offset.ts` | Why the conversation records its scroll offset | +| `chat/chat-conversation.tsx`, `chat/hooks/use-chat-scroll.ts` | The chat scroll container and controller the conversation builds on | + +## Conversation + + + +`AgentConversation` is `ChatConversation` with agent defaults, so it takes the same props: a `scroll` controller from `useChatScroll`, a `label` for the log and a `jumpLabel` for the jump-to-latest button. It follows new content while the reader is at the bottom and shows the jump button once they scroll up. Update `itemCount` when messages change, and `layoutKey` for other size changes such as a growing streamed reply. Set `scroll.atBottomRef.current = true` before appending the user’s own prompt so the conversation follows it. See [Scrolling and replies](/ui/docs/chat#scrolling-and-replies) for the rest of the controller. + +It differs from `ChatConversation` in three ways: + +- **`live` defaults to `"off"`.** A polite log would read every streamed chunk. Announce phase changes through the [announcer](#announcer) instead. +- **Messages are spaced** in a column with a `gap-6` and top padding. Override it with `contentClassName`. +- **It keeps its place in popover crossfades.** A coss popover crossfades a cloned snapshot of its old content when it switches between triggers, for example when a floating panel moves from one conversation to another. A DOM clone keeps attributes but not scroll position, so the outgoing transcript would jump to its top. The conversation writes its scroll offset to `--agent-conversation-scroll-top` on the viewport, which the clone inherits, and translates the snapshot’s content back by that offset. Outside a popover this does nothing. + +## Messages + + + +`AgentMessage` requires `from`, either `"user"` or `"assistant"`. User messages render in the chat bubble at the end of the row. Assistant replies render full width with no bubble, so Markdown, tables and code have room. String children render through `ChatMessageText`; pass `ChatMessageMarkdown` or your own content for anything richer. + +Set `streaming` while the reply is still arriving. It sets `data-streaming` on the content, as in chat. Put `ChatMessageCaret` inside the last text block and remove it when the stream ends. Render the `footer` from the start, for example with a “Writing…” label that becomes a `ChatMessageTimestamp`, so the row doesn’t jump when the reply finishes. + +The root is the chat message layout with `data-slot="agent-message"` and `data-from`. Use `render` to change its element. + +## Message actions + + + +Pass an `AgentMessageActions` toolbar to the message’s `actions` slot. It needs a `label`, and arrow keys move between its actions. Every action is a toolbar part, so place actions inside it. Inside a message, the actions stay hidden on fine pointers until the message is hovered or focused, or one of their popups is open. They’re always shown on coarse pointers, where there’s no hover. Set `actionsAlwaysVisible` on the message to keep them shown, for example on the latest reply. Hidden actions keep their space, so revealing them never moves the conversation. + +- **`AgentMessageCopyAction`** copies `text`. While the text is copied, it shows a check and its `copiedLabel`. Use `onCopy` to announce the result through the announcer. +- **`AgentMessageEditAction`** calls `onEdit`. Your application decides what editing means, such as moving the prompt back into the composer. +- **`AgentMessageAction`** is the generic icon action for anything else, such as retry. Pass the icon as children and a `label`, which names the button and fills its tooltip. + +An action with nothing to do is a disabled button, never one that only says so: an `AgentMessageAction` without `onClick` or `render` is disabled, and so is a copy action with empty `text`. Don’t render actions your application can’t perform. + +## Announcer + + + +Each conversation has one status region. `useAgentAnnouncer` decides what it says, and `AgentAnnouncer` renders it. Feed the hook the agent’s current `phase` and localized `labels`. It announces transitions, never streamed text: + +| Transition | Announced | +| --- | --- | +| First render, or a new `conversationId` | Nothing; the current state is the baseline | +| Into `thinking` or `working` from a settled phase | That phase’s label | +| Between `thinking` and `working` | Nothing | +| Into `approval`, `done`, `stopped` or `failed` | That phase’s label, or `detail` when given | +| A new `detail` within `approval` or `failed` | The new detail, such as a second error | +| Into `idle` | Nothing | + +A phase without a label is never announced. Pass the error message or the approval question as `detail`. For results that aren’t phases, such as a copied message or a copied conversation, call `announcer.announce(text)`. Repeating the same text is read again. + +`getAgentAnnouncement(previous, next)` is the pure rule behind the hook. Use it to test your own mapping from your agent runtime to phases. + +## Reduced motion + +Every transition in the family has a reduced-motion path: CSS transitions use `motion-reduce:transition-none`, and loops such as the streaming caret run only under `motion-safe`. For motion timed in JavaScript, such as an exit delay before a crossfade, read `useAgentReducedMotion()` and skip the delay when it returns `true`. Follow the same policy in your compositions. + +## Before shipping + +Check keyboard-only operation of message actions, a screen reader with streamed replies, a touch device and reduced motion. Test long replies, stopped and failed turns, and switching conversations, with your own agent runtime. diff --git a/apps/ui/content/docs/composites/meta.json b/apps/ui/content/docs/composites/meta.json index e999671f2..6f43a701c 100644 --- a/apps/ui/content/docs/composites/meta.json +++ b/apps/ui/content/docs/composites/meta.json @@ -1,4 +1,4 @@ { - "pages": ["[Chat UI](/docs/chat)"], + "pages": ["[Chat UI](/docs/chat)", "[Agent UI](/docs/agent)"], "title": "Interfaces" } diff --git a/apps/ui/lib/docs.ts b/apps/ui/lib/docs.ts index 40a112697..a06cd6ff6 100644 --- a/apps/ui/lib/docs.ts +++ b/apps/ui/lib/docs.ts @@ -2,4 +2,5 @@ export const PAGES_NEW = [ // "/docs/components/{component-name}", "/docs/components/composer", "/docs/chat", + "/docs/agent", ]; diff --git a/apps/ui/public/r/p-agent-1.json b/apps/ui/public/r/p-agent-1.json new file mode 100644 index 000000000..060c5d2a8 --- /dev/null +++ b/apps/ui/public/r/p-agent-1.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "p-agent-1", + "description": "Agent conversation that follows new replies and offers a jump to latest", + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "@coss/agent", + "@coss/button", + "@coss/chat" + ], + "files": [ + { + "path": "registry/default/particles/p-agent-1.tsx", + "content": "\"use client\";\n\nimport { PlusIcon } from \"lucide-react\";\nimport { useState } from \"react\";\nimport { AgentConversation } from \"@/registry/default/agent/agent-conversation\";\nimport { AgentMessage } from \"@/registry/default/agent/agent-message\";\nimport { useChatScroll } from \"@/registry/default/chat/hooks/use-chat-scroll\";\nimport { Button } from \"@/registry/default/ui/button\";\n\ntype Message = { id: number; from: \"user\" | \"assistant\"; text: string };\n\nconst initialMessages: Message[] = [\n { from: \"user\", id: 1, text: \"Find a time for the design review.\" },\n {\n from: \"assistant\",\n id: 2,\n text: \"Everyone is free on Thursday from 10:00 to 11:30, and on Friday after 14:00.\",\n },\n { from: \"user\", id: 3, text: \"Thursday, please. Keep it to an hour.\" },\n {\n from: \"assistant\",\n id: 4,\n text: \"I’ve held Thursday 10:00–11:00 and invited the four reviewers.\",\n },\n { from: \"user\", id: 5, text: \"Add the brief to the invite.\" },\n {\n from: \"assistant\",\n id: 6,\n text: \"Done. The brief is attached, and everyone can edit the agenda.\",\n },\n];\n\nconst replies = [\n \"Two reviewers accepted. I’ll remind the others an hour before.\",\n \"Priya asked to join remotely, so I added a video link.\",\n \"All four reviewers accepted. You’re set for Thursday.\",\n];\n\nexport default function Particle() {\n const [messages, setMessages] = useState(initialMessages);\n const scroll = useChatScroll({ itemCount: messages.length });\n const added = messages.length - initialMessages.length;\n\n const addReply = () => {\n setMessages((current) => [\n ...current,\n {\n from: \"assistant\",\n id: current.length + 1,\n text: replies[added % replies.length] ?? \"\",\n },\n ]);\n };\n\n return (\n
\n
\n \n {messages.map((message) => (\n \n {message.text}\n \n ))}\n \n
\n \n \n Add a reply\n \n
\n );\n}\n", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-md" + }, + "categories": [ + "agent", + "scroll area" + ], + "type": "registry:block" +} \ No newline at end of file diff --git a/apps/ui/public/r/p-agent-2.json b/apps/ui/public/r/p-agent-2.json new file mode 100644 index 000000000..6d827a1b3 --- /dev/null +++ b/apps/ui/public/r/p-agent-2.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "p-agent-2", + "description": "User prompt and a streaming agent reply on the chat message shell", + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "@coss/agent", + "@coss/button", + "@coss/chat" + ], + "files": [ + { + "path": "registry/default/particles/p-agent-2.tsx", + "content": "\"use client\";\n\nimport { RotateCcwIcon } from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport { AgentMessage } from \"@/registry/default/agent/agent-message\";\nimport { ChatMessageCaret } from \"@/registry/default/chat/chat-message-caret\";\nimport { ChatMessageText } from \"@/registry/default/chat/chat-message-text\";\nimport { ChatMessageTimestamp } from \"@/registry/default/chat/chat-message-timestamp\";\nimport { Button } from \"@/registry/default/ui/button\";\n\nconst reply =\n \"You have three meetings tomorrow. The first starts at 09:30, and there’s a free hour after lunch if you need focus time.\";\nconst chunks = reply.match(/\\S+\\s*/g) ?? [];\n\nexport default function Particle() {\n const [count, setCount] = useState(0);\n const streaming = count < chunks.length;\n\n useEffect(() => {\n if (count >= chunks.length) return;\n const timeout = window.setTimeout(() => setCount(count + 1), 90);\n return () => window.clearTimeout(timeout);\n }, [count]);\n\n return (\n
\n \n 16:02\n \n }\n >\n What does tomorrow look like?\n \n \n 16:02\n \n )\n }\n >\n \n {streaming && }\n \n \n setCount(0)}\n >\n \n Replay\n \n
\n );\n}\n", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm" + }, + "categories": [ + "agent", + "loading" + ], + "type": "registry:block" +} \ No newline at end of file diff --git a/apps/ui/public/r/p-agent-3.json b/apps/ui/public/r/p-agent-3.json new file mode 100644 index 000000000..cd6bc114c --- /dev/null +++ b/apps/ui/public/r/p-agent-3.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "p-agent-3", + "description": "Copy, edit, and custom message actions revealed on hover and focus", + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "@coss/agent", + "@coss/label", + "@coss/switch" + ], + "files": [ + { + "path": "registry/default/particles/p-agent-3.tsx", + "content": "\"use client\";\n\nimport { RotateCcwIcon } from \"lucide-react\";\nimport { useState } from \"react\";\nimport { AgentMessage } from \"@/registry/default/agent/agent-message\";\nimport {\n AgentMessageAction,\n AgentMessageActions,\n AgentMessageCopyAction,\n AgentMessageEditAction,\n} from \"@/registry/default/agent/agent-message-actions\";\nimport { Label } from \"@/registry/default/ui/label\";\nimport { Switch } from \"@/registry/default/ui/switch\";\n\nconst prompt = \"Draft a reply declining Friday’s offsite.\";\nconst answer =\n \"Thanks for the invite. I can’t make Friday’s offsite, but I’d love to hear how it goes and help with any follow-ups.\";\n\nexport default function Particle() {\n const [alwaysVisible, setAlwaysVisible] = useState(false);\n const [editing, setEditing] = useState(false);\n const [retries, setRetries] = useState(0);\n\n return (\n
\n \n setEditing(!editing)}\n />\n \n \n }\n >\n {prompt}\n \n 0 ? `Retried ${retries}×` : undefined}\n actions={\n \n \n setRetries(retries + 1)}\n >\n \n \n \n }\n >\n {answer}\n \n \n
\n );\n}\n", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm" + }, + "categories": [ + "agent", + "copy", + "toolbar" + ], + "type": "registry:block" +} \ No newline at end of file diff --git a/apps/ui/public/r/p-agent-4.json b/apps/ui/public/r/p-agent-4.json new file mode 100644 index 000000000..94167b50b --- /dev/null +++ b/apps/ui/public/r/p-agent-4.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "p-agent-4", + "description": "One status region announcing agent phase changes and copy results", + "registryDependencies": [ + "@coss/agent", + "@coss/button" + ], + "files": [ + { + "path": "registry/default/particles/p-agent-4.tsx", + "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { AgentAnnouncer } from \"@/registry/default/agent/agent-announcer\";\nimport {\n AgentMessageActions,\n AgentMessageCopyAction,\n} from \"@/registry/default/agent/agent-message-actions\";\nimport { useAgentAnnouncer } from \"@/registry/default/agent/hooks/use-agent-announcer\";\nimport type { AgentPhase } from \"@/registry/default/agent/lib/get-agent-announcement\";\nimport { Button } from \"@/registry/default/ui/button\";\n\nconst labels = {\n approval: \"Approval needed\",\n done: \"Response ready\",\n failed: \"Something went wrong\",\n stopped: \"Response stopped\",\n thinking: \"Thinking\",\n working: \"Working\",\n};\n\nexport default function Particle() {\n const [phase, setPhase] = useState(\"idle\");\n const announcer = useAgentAnnouncer({ labels, phase });\n const busy = phase === \"thinking\" || phase === \"working\";\n\n useEffect(() => {\n if (!busy) return;\n const timeout = window.setTimeout(\n () => setPhase(phase === \"thinking\" ? \"working\" : \"done\"),\n 1500,\n );\n return () => window.clearTimeout(timeout);\n }, [busy, phase]);\n\n return (\n
\n
\n setPhase(\"thinking\")}\n >\n Send\n \n setPhase(\"stopped\")}\n >\n Stop\n \n setPhase(\"failed\")}\n >\n Fail\n \n \n announcer.announce(\"Copied\")}\n />\n \n
\n

\n Screen readers hear:{\" \"}\n \n {announcer.message || \"nothing yet\"}\n \n

\n \n
\n );\n}\n", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm" + }, + "categories": [ + "agent", + "info" + ], + "type": "registry:block" +} \ No newline at end of file diff --git a/apps/ui/public/r/registry.json b/apps/ui/public/r/registry.json index c6a471ce4..22b680846 100644 --- a/apps/ui/public/r/registry.json +++ b/apps/ui/public/r/registry.json @@ -1214,6 +1214,67 @@ } ] }, + { + "name": "agent", + "type": "registry:ui", + "description": "Agent conversation, messages, message actions, and a screen reader announcer built on chat.", + "dependencies": [ + "lucide-react", + "@base-ui/react" + ], + "registryDependencies": [ + "@coss/button", + "@coss/chat", + "@coss/composer", + "@coss/toolbar", + "@coss/tooltip", + "@coss/use-copy-to-clipboard", + "@coss/use-media-query", + "@coss/utils" + ], + "files": [ + { + "path": "registry/default/agent/agent-announcer.tsx", + "type": "registry:component", + "target": "@components/agent/agent-announcer.tsx" + }, + { + "path": "registry/default/agent/agent-conversation.tsx", + "type": "registry:component", + "target": "@components/agent/agent-conversation.tsx" + }, + { + "path": "registry/default/agent/agent-message-actions.tsx", + "type": "registry:component", + "target": "@components/agent/agent-message-actions.tsx" + }, + { + "path": "registry/default/agent/agent-message.tsx", + "type": "registry:component", + "target": "@components/agent/agent-message.tsx" + }, + { + "path": "registry/default/agent/hooks/use-agent-announcer.ts", + "type": "registry:component", + "target": "@components/agent/hooks/use-agent-announcer.ts" + }, + { + "path": "registry/default/agent/hooks/use-agent-reduced-motion.ts", + "type": "registry:component", + "target": "@components/agent/hooks/use-agent-reduced-motion.ts" + }, + { + "path": "registry/default/agent/lib/get-agent-announcement.ts", + "type": "registry:component", + "target": "@components/agent/lib/get-agent-announcement.ts" + }, + { + "path": "registry/default/agent/lib/get-agent-scroll-offset.ts", + "type": "registry:component", + "target": "@components/agent/lib/get-agent-scroll-offset.ts" + } + ] + }, { "categories": [ "accordion" @@ -1362,6 +1423,107 @@ ], "type": "registry:block" }, + { + "categories": [ + "agent", + "scroll area" + ], + "dependencies": [ + "lucide-react" + ], + "description": "Agent conversation that follows new replies and offers a jump to latest", + "files": [ + { + "path": "registry/default/particles/p-agent-1.tsx", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-md" + }, + "name": "p-agent-1", + "registryDependencies": [ + "@coss/agent", + "@coss/button", + "@coss/chat" + ], + "type": "registry:block" + }, + { + "categories": [ + "agent", + "loading" + ], + "dependencies": [ + "lucide-react" + ], + "description": "User prompt and a streaming agent reply on the chat message shell", + "files": [ + { + "path": "registry/default/particles/p-agent-2.tsx", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm" + }, + "name": "p-agent-2", + "registryDependencies": [ + "@coss/agent", + "@coss/button", + "@coss/chat" + ], + "type": "registry:block" + }, + { + "categories": [ + "agent", + "copy", + "toolbar" + ], + "dependencies": [ + "lucide-react" + ], + "description": "Copy, edit, and custom message actions revealed on hover and focus", + "files": [ + { + "path": "registry/default/particles/p-agent-3.tsx", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm" + }, + "name": "p-agent-3", + "registryDependencies": [ + "@coss/agent", + "@coss/label", + "@coss/switch" + ], + "type": "registry:block" + }, + { + "categories": [ + "agent", + "info" + ], + "description": "One status region announcing agent phase changes and copy results", + "files": [ + { + "path": "registry/default/particles/p-agent-4.tsx", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm" + }, + "name": "p-agent-4", + "registryDependencies": [ + "@coss/agent", + "@coss/button" + ], + "type": "registry:block" + }, { "categories": [ "alert", diff --git a/apps/ui/registry.json b/apps/ui/registry.json index c6a471ce4..22b680846 100644 --- a/apps/ui/registry.json +++ b/apps/ui/registry.json @@ -1214,6 +1214,67 @@ } ] }, + { + "name": "agent", + "type": "registry:ui", + "description": "Agent conversation, messages, message actions, and a screen reader announcer built on chat.", + "dependencies": [ + "lucide-react", + "@base-ui/react" + ], + "registryDependencies": [ + "@coss/button", + "@coss/chat", + "@coss/composer", + "@coss/toolbar", + "@coss/tooltip", + "@coss/use-copy-to-clipboard", + "@coss/use-media-query", + "@coss/utils" + ], + "files": [ + { + "path": "registry/default/agent/agent-announcer.tsx", + "type": "registry:component", + "target": "@components/agent/agent-announcer.tsx" + }, + { + "path": "registry/default/agent/agent-conversation.tsx", + "type": "registry:component", + "target": "@components/agent/agent-conversation.tsx" + }, + { + "path": "registry/default/agent/agent-message-actions.tsx", + "type": "registry:component", + "target": "@components/agent/agent-message-actions.tsx" + }, + { + "path": "registry/default/agent/agent-message.tsx", + "type": "registry:component", + "target": "@components/agent/agent-message.tsx" + }, + { + "path": "registry/default/agent/hooks/use-agent-announcer.ts", + "type": "registry:component", + "target": "@components/agent/hooks/use-agent-announcer.ts" + }, + { + "path": "registry/default/agent/hooks/use-agent-reduced-motion.ts", + "type": "registry:component", + "target": "@components/agent/hooks/use-agent-reduced-motion.ts" + }, + { + "path": "registry/default/agent/lib/get-agent-announcement.ts", + "type": "registry:component", + "target": "@components/agent/lib/get-agent-announcement.ts" + }, + { + "path": "registry/default/agent/lib/get-agent-scroll-offset.ts", + "type": "registry:component", + "target": "@components/agent/lib/get-agent-scroll-offset.ts" + } + ] + }, { "categories": [ "accordion" @@ -1362,6 +1423,107 @@ ], "type": "registry:block" }, + { + "categories": [ + "agent", + "scroll area" + ], + "dependencies": [ + "lucide-react" + ], + "description": "Agent conversation that follows new replies and offers a jump to latest", + "files": [ + { + "path": "registry/default/particles/p-agent-1.tsx", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-md" + }, + "name": "p-agent-1", + "registryDependencies": [ + "@coss/agent", + "@coss/button", + "@coss/chat" + ], + "type": "registry:block" + }, + { + "categories": [ + "agent", + "loading" + ], + "dependencies": [ + "lucide-react" + ], + "description": "User prompt and a streaming agent reply on the chat message shell", + "files": [ + { + "path": "registry/default/particles/p-agent-2.tsx", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm" + }, + "name": "p-agent-2", + "registryDependencies": [ + "@coss/agent", + "@coss/button", + "@coss/chat" + ], + "type": "registry:block" + }, + { + "categories": [ + "agent", + "copy", + "toolbar" + ], + "dependencies": [ + "lucide-react" + ], + "description": "Copy, edit, and custom message actions revealed on hover and focus", + "files": [ + { + "path": "registry/default/particles/p-agent-3.tsx", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm" + }, + "name": "p-agent-3", + "registryDependencies": [ + "@coss/agent", + "@coss/label", + "@coss/switch" + ], + "type": "registry:block" + }, + { + "categories": [ + "agent", + "info" + ], + "description": "One status region announcing agent phase changes and copy results", + "files": [ + { + "path": "registry/default/particles/p-agent-4.tsx", + "type": "registry:block" + } + ], + "meta": { + "className": "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm" + }, + "name": "p-agent-4", + "registryDependencies": [ + "@coss/agent", + "@coss/button" + ], + "type": "registry:block" + }, { "categories": [ "alert", diff --git a/apps/ui/registry/__index__.tsx b/apps/ui/registry/__index__.tsx index 2630c5f22..55348ccd9 100644 --- a/apps/ui/registry/__index__.tsx +++ b/apps/ui/registry/__index__.tsx @@ -1165,6 +1165,52 @@ export const Index: Record = { categories: undefined, meta: undefined, }, + "agent": { + name: "agent", + description: "Agent conversation, messages, message actions, and a screen reader announcer built on chat.", + type: "registry:ui", + registryDependencies: ["@coss/button","@coss/chat","@coss/composer","@coss/toolbar","@coss/tooltip","@coss/use-copy-to-clipboard","@coss/use-media-query","@coss/utils"], + files: [{ + path: "registry/default/agent/agent-announcer.tsx", + type: "registry:component", + target: "@components/agent/agent-announcer.tsx" + },{ + path: "registry/default/agent/agent-conversation.tsx", + type: "registry:component", + target: "@components/agent/agent-conversation.tsx" + },{ + path: "registry/default/agent/agent-message-actions.tsx", + type: "registry:component", + target: "@components/agent/agent-message-actions.tsx" + },{ + path: "registry/default/agent/agent-message.tsx", + type: "registry:component", + target: "@components/agent/agent-message.tsx" + },{ + path: "registry/default/agent/hooks/use-agent-announcer.ts", + type: "registry:component", + target: "@components/agent/hooks/use-agent-announcer.ts" + },{ + path: "registry/default/agent/hooks/use-agent-reduced-motion.ts", + type: "registry:component", + target: "@components/agent/hooks/use-agent-reduced-motion.ts" + },{ + path: "registry/default/agent/lib/get-agent-announcement.ts", + type: "registry:component", + target: "@components/agent/lib/get-agent-announcement.ts" + },{ + path: "registry/default/agent/lib/get-agent-scroll-offset.ts", + type: "registry:component", + target: "@components/agent/lib/get-agent-scroll-offset.ts" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/default/agent/agent-announcer.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: undefined, + meta: undefined, + }, "p-accordion-1": { name: "p-accordion-1", description: "Basic accordion", @@ -1291,6 +1337,78 @@ export const Index: Record = { categories: ["alert"], meta: {"className":"**:data-[slot=preview]:w-full"}, }, + "p-agent-1": { + name: "p-agent-1", + description: "Agent conversation that follows new replies and offers a jump to latest", + type: "registry:block", + registryDependencies: ["@coss/agent","@coss/button","@coss/chat"], + files: [{ + path: "registry/default/particles/p-agent-1.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/default/particles/p-agent-1.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: ["agent","scroll area"], + meta: {"className":"**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-md"}, + }, + "p-agent-2": { + name: "p-agent-2", + description: "User prompt and a streaming agent reply on the chat message shell", + type: "registry:block", + registryDependencies: ["@coss/agent","@coss/button","@coss/chat"], + files: [{ + path: "registry/default/particles/p-agent-2.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/default/particles/p-agent-2.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: ["agent","loading"], + meta: {"className":"**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm"}, + }, + "p-agent-3": { + name: "p-agent-3", + description: "Copy, edit, and custom message actions revealed on hover and focus", + type: "registry:block", + registryDependencies: ["@coss/agent","@coss/label","@coss/switch"], + files: [{ + path: "registry/default/particles/p-agent-3.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/default/particles/p-agent-3.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: ["agent","copy","toolbar"], + meta: {"className":"**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm"}, + }, + "p-agent-4": { + name: "p-agent-4", + description: "One status region announcing agent phase changes and copy results", + type: "registry:block", + registryDependencies: ["@coss/agent","@coss/button"], + files: [{ + path: "registry/default/particles/p-agent-4.tsx", + type: "registry:block", + target: "" + }], + component: React.lazy(async () => { + const mod = await import("@/registry/default/particles/p-agent-4.tsx") + const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name + return { default: mod.default || mod[exportName] } + }), + categories: ["agent","info"], + meta: {"className":"**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm"}, + }, "p-alert-4": { name: "p-alert-4", description: "Info alert", diff --git a/apps/ui/registry/default/particles/p-agent-1.tsx b/apps/ui/registry/default/particles/p-agent-1.tsx new file mode 100644 index 000000000..c7cc17b1d --- /dev/null +++ b/apps/ui/registry/default/particles/p-agent-1.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { PlusIcon } from "lucide-react"; +import { useState } from "react"; +import { AgentConversation } from "@/registry/default/agent/agent-conversation"; +import { AgentMessage } from "@/registry/default/agent/agent-message"; +import { useChatScroll } from "@/registry/default/chat/hooks/use-chat-scroll"; +import { Button } from "@/registry/default/ui/button"; + +type Message = { id: number; from: "user" | "assistant"; text: string }; + +const initialMessages: Message[] = [ + { from: "user", id: 1, text: "Find a time for the design review." }, + { + from: "assistant", + id: 2, + text: "Everyone is free on Thursday from 10:00 to 11:30, and on Friday after 14:00.", + }, + { from: "user", id: 3, text: "Thursday, please. Keep it to an hour." }, + { + from: "assistant", + id: 4, + text: "I’ve held Thursday 10:00–11:00 and invited the four reviewers.", + }, + { from: "user", id: 5, text: "Add the brief to the invite." }, + { + from: "assistant", + id: 6, + text: "Done. The brief is attached, and everyone can edit the agenda.", + }, +]; + +const replies = [ + "Two reviewers accepted. I’ll remind the others an hour before.", + "Priya asked to join remotely, so I added a video link.", + "All four reviewers accepted. You’re set for Thursday.", +]; + +export default function Particle() { + const [messages, setMessages] = useState(initialMessages); + const scroll = useChatScroll({ itemCount: messages.length }); + const added = messages.length - initialMessages.length; + + const addReply = () => { + setMessages((current) => [ + ...current, + { + from: "assistant", + id: current.length + 1, + text: replies[added % replies.length] ?? "", + }, + ]); + }; + + return ( +
+
+ + {messages.map((message) => ( + + {message.text} + + ))} + +
+ +
+ ); +} diff --git a/apps/ui/registry/default/particles/p-agent-2.tsx b/apps/ui/registry/default/particles/p-agent-2.tsx new file mode 100644 index 000000000..6e9b0fa45 --- /dev/null +++ b/apps/ui/registry/default/particles/p-agent-2.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { RotateCcwIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { AgentMessage } from "@/registry/default/agent/agent-message"; +import { ChatMessageCaret } from "@/registry/default/chat/chat-message-caret"; +import { ChatMessageText } from "@/registry/default/chat/chat-message-text"; +import { ChatMessageTimestamp } from "@/registry/default/chat/chat-message-timestamp"; +import { Button } from "@/registry/default/ui/button"; + +const reply = + "You have three meetings tomorrow. The first starts at 09:30, and there’s a free hour after lunch if you need focus time."; +const chunks = reply.match(/\S+\s*/g) ?? []; + +export default function Particle() { + const [count, setCount] = useState(0); + const streaming = count < chunks.length; + + useEffect(() => { + if (count >= chunks.length) return; + const timeout = window.setTimeout(() => setCount(count + 1), 90); + return () => window.clearTimeout(timeout); + }, [count]); + + return ( +
+ + 16:02 + + } + > + What does tomorrow look like? + + + 16:02 + + ) + } + > + + {streaming && } + + + +
+ ); +} diff --git a/apps/ui/registry/default/particles/p-agent-3.tsx b/apps/ui/registry/default/particles/p-agent-3.tsx new file mode 100644 index 000000000..b25c019c4 --- /dev/null +++ b/apps/ui/registry/default/particles/p-agent-3.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { RotateCcwIcon } from "lucide-react"; +import { useState } from "react"; +import { AgentMessage } from "@/registry/default/agent/agent-message"; +import { + AgentMessageAction, + AgentMessageActions, + AgentMessageCopyAction, + AgentMessageEditAction, +} from "@/registry/default/agent/agent-message-actions"; +import { Label } from "@/registry/default/ui/label"; +import { Switch } from "@/registry/default/ui/switch"; + +const prompt = "Draft a reply declining Friday’s offsite."; +const answer = + "Thanks for the invite. I can’t make Friday’s offsite, but I’d love to hear how it goes and help with any follow-ups."; + +export default function Particle() { + const [alwaysVisible, setAlwaysVisible] = useState(false); + const [editing, setEditing] = useState(false); + const [retries, setRetries] = useState(0); + + return ( +
+ + setEditing(!editing)} + /> + + + } + > + {prompt} + + 0 ? `Retried ${retries}×` : undefined} + actions={ + + + setRetries(retries + 1)} + > + + + } + > + {answer} + + +
+ ); +} diff --git a/apps/ui/registry/default/particles/p-agent-4.tsx b/apps/ui/registry/default/particles/p-agent-4.tsx new file mode 100644 index 000000000..1a5a11df8 --- /dev/null +++ b/apps/ui/registry/default/particles/p-agent-4.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { AgentAnnouncer } from "@/registry/default/agent/agent-announcer"; +import { + AgentMessageActions, + AgentMessageCopyAction, +} from "@/registry/default/agent/agent-message-actions"; +import { useAgentAnnouncer } from "@/registry/default/agent/hooks/use-agent-announcer"; +import type { AgentPhase } from "@/registry/default/agent/lib/get-agent-announcement"; +import { Button } from "@/registry/default/ui/button"; + +const labels = { + approval: "Approval needed", + done: "Response ready", + failed: "Something went wrong", + stopped: "Response stopped", + thinking: "Thinking", + working: "Working", +}; + +export default function Particle() { + const [phase, setPhase] = useState("idle"); + const announcer = useAgentAnnouncer({ labels, phase }); + const busy = phase === "thinking" || phase === "working"; + + useEffect(() => { + if (!busy) return; + const timeout = window.setTimeout( + () => setPhase(phase === "thinking" ? "working" : "done"), + 1500, + ); + return () => window.clearTimeout(timeout); + }, [busy, phase]); + + return ( +
+
+ + + + + announcer.announce("Copied")} + /> + +
+ + +
+ ); +} diff --git a/apps/ui/registry/registry-categories.ts b/apps/ui/registry/registry-categories.ts index 74c2fce65..d1dfca717 100644 --- a/apps/ui/registry/registry-categories.ts +++ b/apps/ui/registry/registry-categories.ts @@ -9,6 +9,7 @@ export const registryCategories = [ // UI components "accordion", + "agent", "alert", "alert dialog", "autocomplete", diff --git a/apps/ui/registry/registry-particles.ts b/apps/ui/registry/registry-particles.ts index 78e646025..cb51d75b4 100644 --- a/apps/ui/registry/registry-particles.ts +++ b/apps/ui/registry/registry-particles.ts @@ -95,6 +95,61 @@ export const particles: ParticleItem[] = [ registryDependencies: ["@coss/alert", "@coss/button"], type: "registry:block", }, + { + categories: categories("agent", "scroll area"), + dependencies: ["lucide-react"], + description: + "Agent conversation that follows new replies and offers a jump to latest", + files: [{ path: "particles/p-agent-1.tsx", type: "registry:block" }], + meta: { + className: + "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-md", + }, + name: "p-agent-1", + registryDependencies: ["@coss/agent", "@coss/button", "@coss/chat"], + type: "registry:block", + }, + { + categories: categories("agent", "loading"), + dependencies: ["lucide-react"], + description: + "User prompt and a streaming agent reply on the chat message shell", + files: [{ path: "particles/p-agent-2.tsx", type: "registry:block" }], + meta: { + className: + "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm", + }, + name: "p-agent-2", + registryDependencies: ["@coss/agent", "@coss/button", "@coss/chat"], + type: "registry:block", + }, + { + categories: categories("agent", "copy", "toolbar"), + dependencies: ["lucide-react"], + description: + "Copy, edit, and custom message actions revealed on hover and focus", + files: [{ path: "particles/p-agent-3.tsx", type: "registry:block" }], + meta: { + className: + "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm", + }, + name: "p-agent-3", + registryDependencies: ["@coss/agent", "@coss/label", "@coss/switch"], + type: "registry:block", + }, + { + categories: categories("agent", "info"), + description: + "One status region announcing agent phase changes and copy results", + files: [{ path: "particles/p-agent-4.tsx", type: "registry:block" }], + meta: { + className: + "**:data-[slot=preview]:w-full **:data-[slot=preview]:max-w-sm", + }, + name: "p-agent-4", + registryDependencies: ["@coss/agent", "@coss/button"], + type: "registry:block", + }, { categories: categories("alert", "info"), dependencies: ["lucide-react"], From 1a9639d1fc691295f2f222b236494d6dbb5a4dc4 Mon Sep 17 00:00:00 2001 From: pasqualevitiello Date: Sat, 17 Oct 2026 16:56:45 +0200 Subject: [PATCH 3/8] fix(agent): keep copy action name stable and disable handlerless actions even when disabled is false Co-Authored-By: Claude Opus 5.5 --- apps/ui/content/docs/(root)/agent.mdx | 8 ++++---- apps/ui/public/r/agent.json | 6 +++--- apps/ui/public/r/p-agent-2.json | 2 +- apps/ui/public/r/registry.json | 2 +- apps/ui/registry.json | 2 +- apps/ui/registry/__index__.tsx | 2 +- .../registry/default/agent/agent-conversation.tsx | 1 + .../default/agent/agent-message-actions.tsx | 14 +++++++++----- apps/ui/registry/default/agent/agent-message.tsx | 2 -- apps/ui/registry/registry-particles.ts | 2 +- packages/ui/src/agent/agent-conversation.tsx | 1 + packages/ui/src/agent/agent-message-actions.tsx | 14 +++++++++----- packages/ui/src/agent/agent-message.tsx | 2 -- 13 files changed, 32 insertions(+), 26 deletions(-) diff --git a/apps/ui/content/docs/(root)/agent.mdx b/apps/ui/content/docs/(root)/agent.mdx index 829b71665..4ae35e065 100644 --- a/apps/ui/content/docs/(root)/agent.mdx +++ b/apps/ui/content/docs/(root)/agent.mdx @@ -132,11 +132,11 @@ The root is the chat message layout with `data-slot="agent-message"` and `data-f Pass an `AgentMessageActions` toolbar to the message’s `actions` slot. It needs a `label`, and arrow keys move between its actions. Every action is a toolbar part, so place actions inside it. Inside a message, the actions stay hidden on fine pointers until the message is hovered or focused, or one of their popups is open. They’re always shown on coarse pointers, where there’s no hover. Set `actionsAlwaysVisible` on the message to keep them shown, for example on the latest reply. Hidden actions keep their space, so revealing them never moves the conversation. -- **`AgentMessageCopyAction`** copies `text`. While the text is copied, it shows a check and its `copiedLabel`. Use `onCopy` to announce the result through the announcer. +- **`AgentMessageCopyAction`** copies `text`. While the text is copied, it shows a check and its `copiedLabel` as the tooltip. Its accessible name stays `label`, so screen readers hear the result once: announce `copiedLabel` from `onCopy` through the [announcer](#announcer). - **`AgentMessageEditAction`** calls `onEdit`. Your application decides what editing means, such as moving the prompt back into the composer. -- **`AgentMessageAction`** is the generic icon action for anything else, such as retry. Pass the icon as children and a `label`, which names the button and fills its tooltip. +- **`AgentMessageAction`** is the generic icon action for anything else, such as retry. Pass the icon as children and a `label`, which names the button and fills its tooltip unless you pass `tooltip`. -An action with nothing to do is a disabled button, never one that only says so: an `AgentMessageAction` without `onClick` or `render` is disabled, and so is a copy action with empty `text`. Don’t render actions your application can’t perform. +An action with nothing to do is a disabled button, never one that only says so: an `AgentMessageAction` without `onClick` or `render` is disabled, even with `disabled={false}`, and so is a copy action with empty `text`. Don’t render actions your application can’t perform. ## Announcer @@ -159,7 +159,7 @@ A phase without a label is never announced. Pass the error message or the approv ## Reduced motion -Every transition in the family has a reduced-motion path: CSS transitions use `motion-reduce:transition-none`, and loops such as the streaming caret run only under `motion-safe`. For motion timed in JavaScript, such as an exit delay before a crossfade, read `useAgentReducedMotion()` and skip the delay when it returns `true`. Follow the same policy in your compositions. +Every transition the agent parts add has a reduced-motion path: CSS transitions use `motion-reduce:transition-none`, and loops such as the streaming caret run only under `motion-safe`. Parts the family composes, such as chat’s jump to latest and coss tooltips, follow their own primitives. For motion timed in JavaScript, such as an exit delay before a crossfade, read `useAgentReducedMotion()` and skip the delay when it returns `true`. Follow the same policy in your compositions. ## Before shipping diff --git a/apps/ui/public/r/agent.json b/apps/ui/public/r/agent.json index 84f5b8d66..15724b917 100644 --- a/apps/ui/public/r/agent.json +++ b/apps/ui/public/r/agent.json @@ -25,19 +25,19 @@ }, { "path": "registry/default/agent/agent-conversation.tsx", - "content": "\"use client\";\n\nimport type { ReactElement, UIEvent } from \"react\";\nimport { cn } from \"@/registry/default/lib/utils\";\nimport {\n ChatConversation,\n type ChatConversationProps,\n} from \"../chat/chat-conversation\";\nimport {\n AGENT_SCROLL_OFFSET_VAR,\n getAgentScrollOffset,\n} from \"./lib/get-agent-scroll-offset\";\n\nexport type AgentConversationProps = ChatConversationProps;\n\nexport function AgentConversation({\n scroll,\n live = \"off\",\n contentClassName,\n ...props\n}: AgentConversationProps): ReactElement {\n const onScroll = (event: UIEvent): void => {\n event.currentTarget.style.setProperty(\n AGENT_SCROLL_OFFSET_VAR,\n getAgentScrollOffset(event.currentTarget.scrollTop),\n );\n scroll.onScroll(event);\n };\n\n return (\n \n );\n}\n", + "content": "\"use client\";\n\nimport type { ReactElement, UIEvent } from \"react\";\nimport { cn } from \"@/registry/default/lib/utils\";\nimport {\n ChatConversation,\n type ChatConversationProps,\n} from \"../chat/chat-conversation\";\nimport {\n AGENT_SCROLL_OFFSET_VAR,\n getAgentScrollOffset,\n} from \"./lib/get-agent-scroll-offset\";\n\nexport type AgentConversationProps = ChatConversationProps;\n\nexport function AgentConversation({\n scroll,\n live = \"off\",\n contentClassName,\n ...props\n}: AgentConversationProps): ReactElement {\n const onScroll = (event: UIEvent): void => {\n event.currentTarget.style.setProperty(\n AGENT_SCROLL_OFFSET_VAR,\n getAgentScrollOffset(event.currentTarget.scrollTop),\n );\n scroll.onScroll(event);\n };\n\n return (\n \n );\n}\n", "type": "registry:component", "target": "@components/agent/agent-conversation.tsx" }, { "path": "registry/default/agent/agent-message-actions.tsx", - "content": "\"use client\";\n\nimport type { Toolbar as ToolbarPrimitive } from \"@base-ui/react/toolbar\";\nimport { CheckIcon, CopyIcon, PencilIcon } from \"lucide-react\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { useCopyToClipboard } from \"@/registry/default/hooks/use-copy-to-clipboard\";\nimport { cn } from \"@/registry/default/lib/utils\";\nimport { Button, type ButtonProps } from \"@/registry/default/ui/button\";\nimport { Toolbar, ToolbarButton } from \"@/registry/default/ui/toolbar\";\nimport {\n Tooltip,\n TooltipPopup,\n TooltipTrigger,\n} from \"@/registry/default/ui/tooltip\";\n\nexport type AgentMessageActionsProps = Omit<\n ToolbarPrimitive.Root.Props,\n \"aria-label\"\n> & {\n label: string;\n};\n\nexport function AgentMessageActions({\n label,\n className,\n ...props\n}: AgentMessageActionsProps): ReactElement {\n return (\n \n );\n}\n\nexport type AgentMessageActionProps = Omit<\n ButtonProps,\n \"aria-label\" | \"children\" | \"size\" | \"variant\"\n> & {\n label: string;\n children: ReactNode;\n};\n\nexport function AgentMessageAction({\n label,\n children,\n onClick,\n render,\n disabled,\n className,\n ...props\n}: AgentMessageActionProps): ReactElement {\n // An action that can't do anything is a real disabled button, never aria-disabled alone.\n const inactive = disabled ?? (!onClick && !render);\n return (\n \n \n }\n />\n }\n >\n {children}\n \n {label}\n \n );\n}\n\nexport type AgentMessageCopyActionProps = Omit<\n AgentMessageActionProps,\n \"children\" | \"onClick\" | \"render\"\n> & {\n text: string;\n copiedLabel: string;\n /** Called after the text reaches the clipboard, e.g. to announce `copiedLabel`. */\n onCopy?: () => void;\n};\n\nexport function AgentMessageCopyAction({\n text,\n label,\n copiedLabel,\n onCopy,\n disabled,\n ...props\n}: AgentMessageCopyActionProps): ReactElement {\n const { copyToClipboard, isCopied } = useCopyToClipboard({ onCopy });\n return (\n copyToClipboard(text)}\n {...props}\n >\n {isCopied ? (\n \n ) : (\n \n )}\n \n );\n}\n\nexport type AgentMessageEditActionProps = Omit<\n AgentMessageActionProps,\n \"children\" | \"onClick\" | \"render\"\n> & {\n onEdit: () => void;\n};\n\nexport function AgentMessageEditAction({\n onEdit,\n ...props\n}: AgentMessageEditActionProps): ReactElement {\n return (\n \n \n \n );\n}\n", + "content": "\"use client\";\n\nimport type { Toolbar as ToolbarPrimitive } from \"@base-ui/react/toolbar\";\nimport { CheckIcon, CopyIcon, PencilIcon } from \"lucide-react\";\nimport type { ReactElement, ReactNode } from \"react\";\nimport { useCopyToClipboard } from \"@/registry/default/hooks/use-copy-to-clipboard\";\nimport { cn } from \"@/registry/default/lib/utils\";\nimport { Button, type ButtonProps } from \"@/registry/default/ui/button\";\nimport { Toolbar, ToolbarButton } from \"@/registry/default/ui/toolbar\";\nimport {\n Tooltip,\n TooltipPopup,\n TooltipTrigger,\n} from \"@/registry/default/ui/tooltip\";\n\nexport type AgentMessageActionsProps = Omit<\n ToolbarPrimitive.Root.Props,\n \"aria-label\"\n> & {\n label: string;\n};\n\nexport function AgentMessageActions({\n label,\n className,\n ...props\n}: AgentMessageActionsProps): ReactElement {\n return (\n \n );\n}\n\nexport type AgentMessageActionProps = Omit<\n ButtonProps,\n \"aria-label\" | \"children\" | \"size\" | \"variant\"\n> & {\n label: string;\n children: ReactNode;\n /** Tooltip content. Defaults to `label`. */\n tooltip?: ReactNode;\n};\n\nexport function AgentMessageAction({\n label,\n children,\n tooltip = label,\n onClick,\n render,\n disabled,\n className,\n ...props\n}: AgentMessageActionProps): ReactElement {\n // An action that can't do anything is a real disabled button, never aria-disabled alone.\n const inactive = disabled || (!onClick && !render);\n return (\n \n \n }\n />\n }\n >\n {children}\n \n {tooltip}\n \n );\n}\n\nexport type AgentMessageCopyActionProps = Omit<\n AgentMessageActionProps,\n \"children\" | \"onClick\" | \"render\" | \"tooltip\"\n> & {\n text: string;\n copiedLabel: string;\n /** Called after the text reaches the clipboard. Announce `copiedLabel` here; the button's name stays `label`. */\n onCopy?: () => void;\n};\n\nexport function AgentMessageCopyAction({\n text,\n label,\n copiedLabel,\n onCopy,\n disabled,\n ...props\n}: AgentMessageCopyActionProps): ReactElement {\n const { copyToClipboard, isCopied } = useCopyToClipboard({ onCopy });\n return (\n copyToClipboard(text)}\n {...props}\n >\n {isCopied ? (\n \n ) : (\n \n )}\n \n );\n}\n\nexport type AgentMessageEditActionProps = Omit<\n AgentMessageActionProps,\n \"children\" | \"onClick\" | \"render\"\n> & {\n onEdit: () => void;\n};\n\nexport function AgentMessageEditAction({\n onEdit,\n ...props\n}: AgentMessageEditActionProps): ReactElement {\n return (\n \n \n \n );\n}\n", "type": "registry:component", "target": "@components/agent/agent-message-actions.tsx" }, { "path": "registry/default/agent/agent-message.tsx", - "content": "\"use client\";\n\nimport type { ReactElement, ReactNode } from \"react\";\nimport { ChatBubble } from \"../chat/chat-bubble\";\nimport {\n ChatMessageContent,\n ChatMessageFooter,\n ChatMessageLayout,\n type ChatMessageLayoutProps,\n} from \"../chat/chat-message-layout\";\nimport { ChatMessageText } from \"../chat/chat-message-text\";\n\nexport type AgentMessageProps = Omit & {\n from: \"user\" | \"assistant\";\n actions?: ReactNode;\n /** Keep actions shown instead of revealing them on hover and focus. */\n actionsAlwaysVisible?: boolean;\n footer?: ReactNode;\n streaming?: boolean;\n};\n\nexport function AgentMessage({\n from,\n actions,\n actionsAlwaysVisible = false,\n footer,\n streaming,\n children,\n ...props\n}: AgentMessageProps): ReactElement {\n const user = from === \"user\";\n const content =\n typeof children === \"string\" ? (\n \n ) : (\n children\n );\n\n return (\n \n \n {user ? (\n \n {content}\n \n ) : (\n content\n )}\n {actions}\n {footer && {footer}}\n \n \n );\n}\n", + "content": "import type { ReactElement, ReactNode } from \"react\";\nimport { ChatBubble } from \"../chat/chat-bubble\";\nimport {\n ChatMessageContent,\n ChatMessageFooter,\n ChatMessageLayout,\n type ChatMessageLayoutProps,\n} from \"../chat/chat-message-layout\";\nimport { ChatMessageText } from \"../chat/chat-message-text\";\n\nexport type AgentMessageProps = Omit & {\n from: \"user\" | \"assistant\";\n actions?: ReactNode;\n /** Keep actions shown instead of revealing them on hover and focus. */\n actionsAlwaysVisible?: boolean;\n footer?: ReactNode;\n streaming?: boolean;\n};\n\nexport function AgentMessage({\n from,\n actions,\n actionsAlwaysVisible = false,\n footer,\n streaming,\n children,\n ...props\n}: AgentMessageProps): ReactElement {\n const user = from === \"user\";\n const content =\n typeof children === \"string\" ? (\n \n ) : (\n children\n );\n\n return (\n \n \n {user ? (\n \n {content}\n \n ) : (\n content\n )}\n {actions}\n {footer && {footer}}\n \n \n );\n}\n", "type": "registry:component", "target": "@components/agent/agent-message.tsx" }, diff --git a/apps/ui/public/r/p-agent-2.json b/apps/ui/public/r/p-agent-2.json index 6d827a1b3..0a71f63d4 100644 --- a/apps/ui/public/r/p-agent-2.json +++ b/apps/ui/public/r/p-agent-2.json @@ -1,7 +1,7 @@ { "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "p-agent-2", - "description": "User prompt and a streaming agent reply on the chat message shell", + "description": "User prompt and a streaming agent reply with a caret and timestamps", "dependencies": [ "lucide-react" ], diff --git a/apps/ui/public/r/registry.json b/apps/ui/public/r/registry.json index 22b680846..714e0d490 100644 --- a/apps/ui/public/r/registry.json +++ b/apps/ui/public/r/registry.json @@ -1457,7 +1457,7 @@ "dependencies": [ "lucide-react" ], - "description": "User prompt and a streaming agent reply on the chat message shell", + "description": "User prompt and a streaming agent reply with a caret and timestamps", "files": [ { "path": "registry/default/particles/p-agent-2.tsx", diff --git a/apps/ui/registry.json b/apps/ui/registry.json index 22b680846..714e0d490 100644 --- a/apps/ui/registry.json +++ b/apps/ui/registry.json @@ -1457,7 +1457,7 @@ "dependencies": [ "lucide-react" ], - "description": "User prompt and a streaming agent reply on the chat message shell", + "description": "User prompt and a streaming agent reply with a caret and timestamps", "files": [ { "path": "registry/default/particles/p-agent-2.tsx", diff --git a/apps/ui/registry/__index__.tsx b/apps/ui/registry/__index__.tsx index 55348ccd9..fd6ba9c3d 100644 --- a/apps/ui/registry/__index__.tsx +++ b/apps/ui/registry/__index__.tsx @@ -1357,7 +1357,7 @@ export const Index: Record = { }, "p-agent-2": { name: "p-agent-2", - description: "User prompt and a streaming agent reply on the chat message shell", + description: "User prompt and a streaming agent reply with a caret and timestamps", type: "registry:block", registryDependencies: ["@coss/agent","@coss/button","@coss/chat"], files: [{ diff --git a/apps/ui/registry/default/agent/agent-conversation.tsx b/apps/ui/registry/default/agent/agent-conversation.tsx index 0ecfa5d8b..d4a250a8f 100644 --- a/apps/ui/registry/default/agent/agent-conversation.tsx +++ b/apps/ui/registry/default/agent/agent-conversation.tsx @@ -34,6 +34,7 @@ export function AgentConversation({ live={live} contentClassName={cn( // Inside a popover crossfade snapshot ([data-previous]), keep the reader's place. + // The variable is AGENT_SCROLL_OFFSET_VAR; Tailwind needs it spelled out here. "flex in-data-previous:-translate-y-(--agent-conversation-scroll-top) flex-col gap-6 pt-4", contentClassName, )} diff --git a/apps/ui/registry/default/agent/agent-message-actions.tsx b/apps/ui/registry/default/agent/agent-message-actions.tsx index 98ae9db3c..f74211803 100644 --- a/apps/ui/registry/default/agent/agent-message-actions.tsx +++ b/apps/ui/registry/default/agent/agent-message-actions.tsx @@ -47,11 +47,14 @@ export type AgentMessageActionProps = Omit< > & { label: string; children: ReactNode; + /** Tooltip content. Defaults to `label`. */ + tooltip?: ReactNode; }; export function AgentMessageAction({ label, children, + tooltip = label, onClick, render, disabled, @@ -59,7 +62,7 @@ export function AgentMessageAction({ ...props }: AgentMessageActionProps): ReactElement { // An action that can't do anything is a real disabled button, never aria-disabled alone. - const inactive = disabled ?? (!onClick && !render); + const inactive = disabled || (!onClick && !render); return ( {children} - {label} + {tooltip} ); } export type AgentMessageCopyActionProps = Omit< AgentMessageActionProps, - "children" | "onClick" | "render" + "children" | "onClick" | "render" | "tooltip" > & { text: string; copiedLabel: string; - /** Called after the text reaches the clipboard, e.g. to announce `copiedLabel`. */ + /** Called after the text reaches the clipboard. Announce `copiedLabel` here; the button's name stays `label`. */ onCopy?: () => void; }; @@ -116,7 +119,8 @@ export function AgentMessageCopyAction({ copyToClipboard(text)} {...props} > diff --git a/apps/ui/registry/default/agent/agent-message.tsx b/apps/ui/registry/default/agent/agent-message.tsx index 9c9d245d6..f1fdfac0f 100644 --- a/apps/ui/registry/default/agent/agent-message.tsx +++ b/apps/ui/registry/default/agent/agent-message.tsx @@ -1,5 +1,3 @@ -"use client"; - import type { ReactElement, ReactNode } from "react"; import { ChatBubble } from "../chat/chat-bubble"; import { diff --git a/apps/ui/registry/registry-particles.ts b/apps/ui/registry/registry-particles.ts index cb51d75b4..577c265f2 100644 --- a/apps/ui/registry/registry-particles.ts +++ b/apps/ui/registry/registry-particles.ts @@ -113,7 +113,7 @@ export const particles: ParticleItem[] = [ categories: categories("agent", "loading"), dependencies: ["lucide-react"], description: - "User prompt and a streaming agent reply on the chat message shell", + "User prompt and a streaming agent reply with a caret and timestamps", files: [{ path: "particles/p-agent-2.tsx", type: "registry:block" }], meta: { className: diff --git a/packages/ui/src/agent/agent-conversation.tsx b/packages/ui/src/agent/agent-conversation.tsx index c6cb1606c..193d4a049 100644 --- a/packages/ui/src/agent/agent-conversation.tsx +++ b/packages/ui/src/agent/agent-conversation.tsx @@ -34,6 +34,7 @@ export function AgentConversation({ live={live} contentClassName={cn( // Inside a popover crossfade snapshot ([data-previous]), keep the reader's place. + // The variable is AGENT_SCROLL_OFFSET_VAR; Tailwind needs it spelled out here. "flex in-data-previous:-translate-y-(--agent-conversation-scroll-top) flex-col gap-6 pt-4", contentClassName, )} diff --git a/packages/ui/src/agent/agent-message-actions.tsx b/packages/ui/src/agent/agent-message-actions.tsx index 5bdd9fd88..91b4380c1 100644 --- a/packages/ui/src/agent/agent-message-actions.tsx +++ b/packages/ui/src/agent/agent-message-actions.tsx @@ -47,11 +47,14 @@ export type AgentMessageActionProps = Omit< > & { label: string; children: ReactNode; + /** Tooltip content. Defaults to `label`. */ + tooltip?: ReactNode; }; export function AgentMessageAction({ label, children, + tooltip = label, onClick, render, disabled, @@ -59,7 +62,7 @@ export function AgentMessageAction({ ...props }: AgentMessageActionProps): ReactElement { // An action that can't do anything is a real disabled button, never aria-disabled alone. - const inactive = disabled ?? (!onClick && !render); + const inactive = disabled || (!onClick && !render); return ( {children} - {label} + {tooltip} ); } export type AgentMessageCopyActionProps = Omit< AgentMessageActionProps, - "children" | "onClick" | "render" + "children" | "onClick" | "render" | "tooltip" > & { text: string; copiedLabel: string; - /** Called after the text reaches the clipboard, e.g. to announce `copiedLabel`. */ + /** Called after the text reaches the clipboard. Announce `copiedLabel` here; the button's name stays `label`. */ onCopy?: () => void; }; @@ -116,7 +119,8 @@ export function AgentMessageCopyAction({ copyToClipboard(text)} {...props} > diff --git a/packages/ui/src/agent/agent-message.tsx b/packages/ui/src/agent/agent-message.tsx index 9c9d245d6..f1fdfac0f 100644 --- a/packages/ui/src/agent/agent-message.tsx +++ b/packages/ui/src/agent/agent-message.tsx @@ -1,5 +1,3 @@ -"use client"; - import type { ReactElement, ReactNode } from "react"; import { ChatBubble } from "../chat/chat-bubble"; import { From 70da5fb18c88736a7536e5267aa975f7b8056743 Mon Sep 17 00:00:00 2001 From: pasqualevitiello Date: Sat, 17 Oct 2026 17:01:27 +0200 Subject: [PATCH 4/8] fix(chat): key the avatar column on the message group instead of its slot name Co-Authored-By: Claude Opus 5.5 --- apps/ui/public/r/chat.json | 2 +- apps/ui/registry/default/chat/chat-message-layout.tsx | 4 ++-- packages/ui/src/chat/chat-message-layout.tsx | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/ui/public/r/chat.json b/apps/ui/public/r/chat.json index f1930eb37..5e7ba121d 100644 --- a/apps/ui/public/r/chat.json +++ b/apps/ui/public/r/chat.json @@ -89,7 +89,7 @@ }, { "path": "registry/default/chat/chat-message-layout.tsx", - "content": "\"use client\";\n\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport type { ComponentProps, ReactElement } from \"react\";\nimport { cn } from \"@/registry/default/lib/utils\";\n\nexport type ChatMessageLayoutProps = useRender.ComponentProps<\"article\"> & {\n align?: \"start\" | \"end\";\n};\n\nexport function ChatMessageLayout({\n align = \"start\",\n className,\n render,\n ...props\n}: ChatMessageLayoutProps): ReactElement {\n const defaultProps = {\n \"data-slot\": \"chat-message\",\n \"data-align\": align,\n className: cn(\n \"group/message relative grid scroll-m-8 grid-cols-1 items-start rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring has-[>[data-slot=chat-message-avatar]]:grid-cols-[auto_minmax(0,1fr)] has-[>[data-slot=chat-message-avatar]]:gap-x-2 has-[>[data-slot=chat-message-header]]:gap-y-1\",\n className,\n ),\n };\n\n return useRender({\n defaultTagName: \"article\",\n render,\n props: mergeProps<\"article\">(defaultProps, props),\n });\n}\n\nexport function ChatMessageAvatar({\n className,\n ...props\n}: ComponentProps<\"div\">): ReactElement {\n return (\n \n );\n}\n\nexport function ChatMessageHeader({\n className,\n ...props\n}: ComponentProps<\"div\">): ReactElement {\n return (\n [data-slot=chat-message-avatar])]:col-start-2 row-start-1 px-1 font-medium text-muted-foreground text-xs group-data-[align=end]/message:justify-self-end\",\n className,\n )}\n {...props}\n />\n );\n}\n\nexport type ChatMessageContentProps = ComponentProps<\"div\"> & {\n streaming?: boolean;\n};\n\nexport function ChatMessageContent({\n streaming = false,\n className,\n ...props\n}: ChatMessageContentProps): ReactElement {\n return (\n [data-slot=chat-message-avatar])]:col-start-2 row-start-2 flex min-w-0 flex-col gap-1 group-data-[align=end]/message:items-end\",\n className,\n )}\n {...props}\n />\n );\n}\n\nexport function ChatMessageFooter({\n className,\n ...props\n}: ComponentProps<\"div\">): ReactElement {\n return (\n \n );\n}\n", + "content": "\"use client\";\n\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport type { ComponentProps, ReactElement } from \"react\";\nimport { cn } from \"@/registry/default/lib/utils\";\n\nexport type ChatMessageLayoutProps = useRender.ComponentProps<\"article\"> & {\n align?: \"start\" | \"end\";\n};\n\nexport function ChatMessageLayout({\n align = \"start\",\n className,\n render,\n ...props\n}: ChatMessageLayoutProps): ReactElement {\n const defaultProps = {\n \"data-slot\": \"chat-message\",\n \"data-align\": align,\n className: cn(\n \"group/message relative grid scroll-m-8 grid-cols-1 items-start rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring has-[>[data-slot=chat-message-avatar]]:grid-cols-[auto_minmax(0,1fr)] has-[>[data-slot=chat-message-avatar]]:gap-x-2 has-[>[data-slot=chat-message-header]]:gap-y-1\",\n className,\n ),\n };\n\n return useRender({\n defaultTagName: \"article\",\n render,\n props: mergeProps<\"article\">(defaultProps, props),\n });\n}\n\nexport function ChatMessageAvatar({\n className,\n ...props\n}: ComponentProps<\"div\">): ReactElement {\n return (\n \n );\n}\n\nexport function ChatMessageHeader({\n className,\n ...props\n}: ComponentProps<\"div\">): ReactElement {\n return (\n [data-slot=chat-message-avatar]]/message:col-start-2 group-data-[align=end]/message:justify-self-end\",\n className,\n )}\n {...props}\n />\n );\n}\n\nexport type ChatMessageContentProps = ComponentProps<\"div\"> & {\n streaming?: boolean;\n};\n\nexport function ChatMessageContent({\n streaming = false,\n className,\n ...props\n}: ChatMessageContentProps): ReactElement {\n return (\n [data-slot=chat-message-avatar]]/message:col-start-2 group-data-[align=end]/message:items-end\",\n className,\n )}\n {...props}\n />\n );\n}\n\nexport function ChatMessageFooter({\n className,\n ...props\n}: ComponentProps<\"div\">): ReactElement {\n return (\n \n );\n}\n", "type": "registry:component", "target": "@components/chat/chat-message-layout.tsx" }, diff --git a/apps/ui/registry/default/chat/chat-message-layout.tsx b/apps/ui/registry/default/chat/chat-message-layout.tsx index add721748..b2882d7ef 100644 --- a/apps/ui/registry/default/chat/chat-message-layout.tsx +++ b/apps/ui/registry/default/chat/chat-message-layout.tsx @@ -52,7 +52,7 @@ export function ChatMessageHeader({
[data-slot=chat-message-avatar])]:col-start-2 row-start-1 px-1 font-medium text-muted-foreground text-xs group-data-[align=end]/message:justify-self-end", + "col-start-1 row-start-1 px-1 font-medium text-muted-foreground text-xs group-has-[>[data-slot=chat-message-avatar]]/message:col-start-2 group-data-[align=end]/message:justify-self-end", className, )} {...props} @@ -74,7 +74,7 @@ export function ChatMessageContent({ data-slot="chat-message-content" data-streaming={streaming || undefined} className={cn( - "col-start-1 in-[[data-slot=chat-message]:has(>[data-slot=chat-message-avatar])]:col-start-2 row-start-2 flex min-w-0 flex-col gap-1 group-data-[align=end]/message:items-end", + "col-start-1 row-start-2 flex min-w-0 flex-col gap-1 group-has-[>[data-slot=chat-message-avatar]]/message:col-start-2 group-data-[align=end]/message:items-end", className, )} {...props} diff --git a/packages/ui/src/chat/chat-message-layout.tsx b/packages/ui/src/chat/chat-message-layout.tsx index 924b982ae..37c39cf8b 100644 --- a/packages/ui/src/chat/chat-message-layout.tsx +++ b/packages/ui/src/chat/chat-message-layout.tsx @@ -52,7 +52,7 @@ export function ChatMessageHeader({
[data-slot=chat-message-avatar])]:col-start-2 row-start-1 px-1 font-medium text-muted-foreground text-xs group-data-[align=end]/message:justify-self-end", + "col-start-1 row-start-1 px-1 font-medium text-muted-foreground text-xs group-has-[>[data-slot=chat-message-avatar]]/message:col-start-2 group-data-[align=end]/message:justify-self-end", className, )} {...props} @@ -74,7 +74,7 @@ export function ChatMessageContent({ data-slot="chat-message-content" data-streaming={streaming || undefined} className={cn( - "col-start-1 in-[[data-slot=chat-message]:has(>[data-slot=chat-message-avatar])]:col-start-2 row-start-2 flex min-w-0 flex-col gap-1 group-data-[align=end]/message:items-end", + "col-start-1 row-start-2 flex min-w-0 flex-col gap-1 group-has-[>[data-slot=chat-message-avatar]]/message:col-start-2 group-data-[align=end]/message:items-end", className, )} {...props} From b6d5c83979d838dabd7d9901e4de00f750e33216 Mon Sep 17 00:00:00 2001 From: pasqualevitiello Date: Sat, 17 Oct 2026 17:01:28 +0200 Subject: [PATCH 5/8] feat(agent): add an avatar slot to AgentMessage Co-Authored-By: Claude Opus 5.5 --- apps/ui/content/docs/(root)/agent.mdx | 4 +++- apps/ui/public/r/agent.json | 2 +- apps/ui/public/r/p-agent-2.json | 5 +++-- apps/ui/public/r/registry.json | 3 ++- apps/ui/registry.json | 3 ++- apps/ui/registry/__index__.tsx | 4 ++-- apps/ui/registry/default/agent/agent-message.tsx | 5 +++++ apps/ui/registry/default/particles/p-agent-2.tsx | 10 +++++++++- apps/ui/registry/registry-particles.ts | 9 +++++++-- packages/ui/src/agent/agent-message.tsx | 5 +++++ 10 files changed, 39 insertions(+), 11 deletions(-) diff --git a/apps/ui/content/docs/(root)/agent.mdx b/apps/ui/content/docs/(root)/agent.mdx index 4ae35e065..48c44aeac 100644 --- a/apps/ui/content/docs/(root)/agent.mdx +++ b/apps/ui/content/docs/(root)/agent.mdx @@ -86,7 +86,7 @@ Import components directly from their files. There is no provider. | Area | Components | Responsibility | | --- | --- | --- | | Conversation | `AgentConversation`, `useChatScroll` | Chat scroll viewport and end following, quiet by default, keeps its place in popover crossfades | -| Message | `AgentMessage` | User bubble or full-width reply on the chat message shell, actions and footer slots, streaming state | +| Message | `AgentMessage` | User bubble or full-width reply on the chat message shell, avatar, actions and footer slots, streaming state | | Content | `ChatMessageText`, `ChatMessageMarkdown`, `ChatMessageCaret`, `ChatMessageTimestamp` | Chat parts for text, Markdown, the streaming caret and timestamps | | Actions | `AgentMessageActions`, `AgentMessageAction`, `AgentMessageCopyAction`, `AgentMessageEditAction` | Toolbar revealed on hover and focus, generic icon action, copy and edit | | Announcer | `AgentAnnouncer`, `useAgentAnnouncer`, `getAgentAnnouncement` | One status region per conversation and the rule for what it says | @@ -122,6 +122,8 @@ It differs from `ChatConversation` in three ways: `AgentMessage` requires `from`, either `"user"` or `"assistant"`. User messages render in the chat bubble at the end of the row. Assistant replies render full width with no bubble, so Markdown, tables and code have room. String children render through `ChatMessageText`; pass `ChatMessageMarkdown` or your own content for anything richer. +Pass the agent’s identity mark, such as an avatar or a logo, to `avatar`. It sits in the chat avatar column, at the top of the reply. Use it on assistant replies; mark it `aria-hidden` when the conversation already names the agent. + Set `streaming` while the reply is still arriving. It sets `data-streaming` on the content, as in chat. Put `ChatMessageCaret` inside the last text block and remove it when the stream ends. Render the `footer` from the start, for example with a “Writing…” label that becomes a `ChatMessageTimestamp`, so the row doesn’t jump when the reply finishes. The root is the chat message layout with `data-slot="agent-message"` and `data-from`. Use `render` to change its element. diff --git a/apps/ui/public/r/agent.json b/apps/ui/public/r/agent.json index 15724b917..97a1bdf33 100644 --- a/apps/ui/public/r/agent.json +++ b/apps/ui/public/r/agent.json @@ -37,7 +37,7 @@ }, { "path": "registry/default/agent/agent-message.tsx", - "content": "import type { ReactElement, ReactNode } from \"react\";\nimport { ChatBubble } from \"../chat/chat-bubble\";\nimport {\n ChatMessageContent,\n ChatMessageFooter,\n ChatMessageLayout,\n type ChatMessageLayoutProps,\n} from \"../chat/chat-message-layout\";\nimport { ChatMessageText } from \"../chat/chat-message-text\";\n\nexport type AgentMessageProps = Omit & {\n from: \"user\" | \"assistant\";\n actions?: ReactNode;\n /** Keep actions shown instead of revealing them on hover and focus. */\n actionsAlwaysVisible?: boolean;\n footer?: ReactNode;\n streaming?: boolean;\n};\n\nexport function AgentMessage({\n from,\n actions,\n actionsAlwaysVisible = false,\n footer,\n streaming,\n children,\n ...props\n}: AgentMessageProps): ReactElement {\n const user = from === \"user\";\n const content =\n typeof children === \"string\" ? (\n \n ) : (\n children\n );\n\n return (\n \n \n {user ? (\n \n {content}\n \n ) : (\n content\n )}\n {actions}\n {footer && {footer}}\n \n \n );\n}\n", + "content": "import type { ReactElement, ReactNode } from \"react\";\nimport { ChatBubble } from \"../chat/chat-bubble\";\nimport {\n ChatMessageAvatar,\n ChatMessageContent,\n ChatMessageFooter,\n ChatMessageLayout,\n type ChatMessageLayoutProps,\n} from \"../chat/chat-message-layout\";\nimport { ChatMessageText } from \"../chat/chat-message-text\";\n\nexport type AgentMessageProps = Omit & {\n from: \"user\" | \"assistant\";\n /** The agent's identity mark beside the message, typically on assistant replies. */\n avatar?: ReactNode;\n actions?: ReactNode;\n /** Keep actions shown instead of revealing them on hover and focus. */\n actionsAlwaysVisible?: boolean;\n footer?: ReactNode;\n streaming?: boolean;\n};\n\nexport function AgentMessage({\n from,\n avatar,\n actions,\n actionsAlwaysVisible = false,\n footer,\n streaming,\n children,\n ...props\n}: AgentMessageProps): ReactElement {\n const user = from === \"user\";\n const content =\n typeof children === \"string\" ? (\n \n ) : (\n children\n );\n\n return (\n \n {avatar && {avatar}}\n \n {user ? (\n \n {content}\n \n ) : (\n content\n )}\n {actions}\n {footer && {footer}}\n \n \n );\n}\n", "type": "registry:component", "target": "@components/agent/agent-message.tsx" }, diff --git a/apps/ui/public/r/p-agent-2.json b/apps/ui/public/r/p-agent-2.json index 0a71f63d4..1c819bdf8 100644 --- a/apps/ui/public/r/p-agent-2.json +++ b/apps/ui/public/r/p-agent-2.json @@ -1,19 +1,20 @@ { "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "p-agent-2", - "description": "User prompt and a streaming agent reply with a caret and timestamps", + "description": "User prompt and a streaming agent reply with an avatar, caret, and timestamps", "dependencies": [ "lucide-react" ], "registryDependencies": [ "@coss/agent", + "@coss/avatar", "@coss/button", "@coss/chat" ], "files": [ { "path": "registry/default/particles/p-agent-2.tsx", - "content": "\"use client\";\n\nimport { RotateCcwIcon } from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport { AgentMessage } from \"@/registry/default/agent/agent-message\";\nimport { ChatMessageCaret } from \"@/registry/default/chat/chat-message-caret\";\nimport { ChatMessageText } from \"@/registry/default/chat/chat-message-text\";\nimport { ChatMessageTimestamp } from \"@/registry/default/chat/chat-message-timestamp\";\nimport { Button } from \"@/registry/default/ui/button\";\n\nconst reply =\n \"You have three meetings tomorrow. The first starts at 09:30, and there’s a free hour after lunch if you need focus time.\";\nconst chunks = reply.match(/\\S+\\s*/g) ?? [];\n\nexport default function Particle() {\n const [count, setCount] = useState(0);\n const streaming = count < chunks.length;\n\n useEffect(() => {\n if (count >= chunks.length) return;\n const timeout = window.setTimeout(() => setCount(count + 1), 90);\n return () => window.clearTimeout(timeout);\n }, [count]);\n\n return (\n
\n \n 16:02\n \n }\n >\n What does tomorrow look like?\n \n \n 16:02\n \n )\n }\n >\n \n {streaming && }\n \n \n setCount(0)}\n >\n \n Replay\n \n
\n );\n}\n", + "content": "\"use client\";\n\nimport { BotIcon, RotateCcwIcon } from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport { AgentMessage } from \"@/registry/default/agent/agent-message\";\nimport { ChatMessageCaret } from \"@/registry/default/chat/chat-message-caret\";\nimport { ChatMessageText } from \"@/registry/default/chat/chat-message-text\";\nimport { ChatMessageTimestamp } from \"@/registry/default/chat/chat-message-timestamp\";\nimport { Avatar, AvatarFallback } from \"@/registry/default/ui/avatar\";\nimport { Button } from \"@/registry/default/ui/button\";\n\nconst reply =\n \"You have three meetings tomorrow. The first starts at 09:30, and there’s a free hour after lunch if you need focus time.\";\nconst chunks = reply.match(/\\S+\\s*/g) ?? [];\n\nexport default function Particle() {\n const [count, setCount] = useState(0);\n const streaming = count < chunks.length;\n\n useEffect(() => {\n if (count >= chunks.length) return;\n const timeout = window.setTimeout(() => setCount(count + 1), 90);\n return () => window.clearTimeout(timeout);\n }, [count]);\n\n return (\n
\n \n 16:02\n \n }\n >\n What does tomorrow look like?\n \n \n \n \n \n \n }\n streaming={streaming}\n footer={\n streaming ? (\n \"Writing…\"\n ) : (\n \n 16:02\n \n )\n }\n >\n \n {streaming && }\n \n \n setCount(0)}\n >\n \n Replay\n \n
\n );\n}\n", "type": "registry:block" } ], diff --git a/apps/ui/public/r/registry.json b/apps/ui/public/r/registry.json index 714e0d490..9721e1991 100644 --- a/apps/ui/public/r/registry.json +++ b/apps/ui/public/r/registry.json @@ -1457,7 +1457,7 @@ "dependencies": [ "lucide-react" ], - "description": "User prompt and a streaming agent reply with a caret and timestamps", + "description": "User prompt and a streaming agent reply with an avatar, caret, and timestamps", "files": [ { "path": "registry/default/particles/p-agent-2.tsx", @@ -1470,6 +1470,7 @@ "name": "p-agent-2", "registryDependencies": [ "@coss/agent", + "@coss/avatar", "@coss/button", "@coss/chat" ], diff --git a/apps/ui/registry.json b/apps/ui/registry.json index 714e0d490..9721e1991 100644 --- a/apps/ui/registry.json +++ b/apps/ui/registry.json @@ -1457,7 +1457,7 @@ "dependencies": [ "lucide-react" ], - "description": "User prompt and a streaming agent reply with a caret and timestamps", + "description": "User prompt and a streaming agent reply with an avatar, caret, and timestamps", "files": [ { "path": "registry/default/particles/p-agent-2.tsx", @@ -1470,6 +1470,7 @@ "name": "p-agent-2", "registryDependencies": [ "@coss/agent", + "@coss/avatar", "@coss/button", "@coss/chat" ], diff --git a/apps/ui/registry/__index__.tsx b/apps/ui/registry/__index__.tsx index fd6ba9c3d..f3cc96ac8 100644 --- a/apps/ui/registry/__index__.tsx +++ b/apps/ui/registry/__index__.tsx @@ -1357,9 +1357,9 @@ export const Index: Record = { }, "p-agent-2": { name: "p-agent-2", - description: "User prompt and a streaming agent reply with a caret and timestamps", + description: "User prompt and a streaming agent reply with an avatar, caret, and timestamps", type: "registry:block", - registryDependencies: ["@coss/agent","@coss/button","@coss/chat"], + registryDependencies: ["@coss/agent","@coss/avatar","@coss/button","@coss/chat"], files: [{ path: "registry/default/particles/p-agent-2.tsx", type: "registry:block", diff --git a/apps/ui/registry/default/agent/agent-message.tsx b/apps/ui/registry/default/agent/agent-message.tsx index f1fdfac0f..9012b5eeb 100644 --- a/apps/ui/registry/default/agent/agent-message.tsx +++ b/apps/ui/registry/default/agent/agent-message.tsx @@ -1,6 +1,7 @@ import type { ReactElement, ReactNode } from "react"; import { ChatBubble } from "../chat/chat-bubble"; import { + ChatMessageAvatar, ChatMessageContent, ChatMessageFooter, ChatMessageLayout, @@ -10,6 +11,8 @@ import { ChatMessageText } from "../chat/chat-message-text"; export type AgentMessageProps = Omit & { from: "user" | "assistant"; + /** The agent's identity mark beside the message, typically on assistant replies. */ + avatar?: ReactNode; actions?: ReactNode; /** Keep actions shown instead of revealing them on hover and focus. */ actionsAlwaysVisible?: boolean; @@ -19,6 +22,7 @@ export type AgentMessageProps = Omit & { export function AgentMessage({ from, + avatar, actions, actionsAlwaysVisible = false, footer, @@ -42,6 +46,7 @@ export function AgentMessage({ data-actions-visible={actionsAlwaysVisible || undefined} {...props} > + {avatar && {avatar}} {user ? ( diff --git a/apps/ui/registry/default/particles/p-agent-2.tsx b/apps/ui/registry/default/particles/p-agent-2.tsx index 6e9b0fa45..d37184e16 100644 --- a/apps/ui/registry/default/particles/p-agent-2.tsx +++ b/apps/ui/registry/default/particles/p-agent-2.tsx @@ -1,11 +1,12 @@ "use client"; -import { RotateCcwIcon } from "lucide-react"; +import { BotIcon, RotateCcwIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { AgentMessage } from "@/registry/default/agent/agent-message"; import { ChatMessageCaret } from "@/registry/default/chat/chat-message-caret"; import { ChatMessageText } from "@/registry/default/chat/chat-message-text"; import { ChatMessageTimestamp } from "@/registry/default/chat/chat-message-timestamp"; +import { Avatar, AvatarFallback } from "@/registry/default/ui/avatar"; import { Button } from "@/registry/default/ui/button"; const reply = @@ -36,6 +37,13 @@ export default function Particle() {