diff --git a/apps/docs/app/page.tsx b/apps/docs/app/page.tsx index ab5929d..a7d54bb 100644 --- a/apps/docs/app/page.tsx +++ b/apps/docs/app/page.tsx @@ -50,6 +50,12 @@ const majorChords = [ "Collapsible accordion for AI reasoning with auto-expand during streaming.", href: "/docs/major-chords/reasoning-accordion", }, + { + name: "FeedbackButtons", + description: + "Thumbs up/down feedback buttons with automatic state management.", + href: "/docs/major-chords/feedback-buttons", + }, ]; const minorChords = [ diff --git a/apps/docs/components/demo/feedback-buttons-demo.tsx b/apps/docs/components/demo/feedback-buttons-demo.tsx new file mode 100644 index 0000000..9a32a06 --- /dev/null +++ b/apps/docs/components/demo/feedback-buttons-demo.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { + AssistantRuntimeProvider, + MessagePrimitive, + ThreadPrimitive, + useLocalRuntime, +} from "@assistant-ui/react"; +import { FeedbackButtons } from "@assistant-ui/chords"; +import { DemoWrapper } from "./demo-wrapper"; +import type { ChatModelAdapter } from "@assistant-ui/react"; +import { FC } from "react"; + +const demoAdapter: ChatModelAdapter = { + async *run({ abortSignal }) { + const text = + "Here's a helpful response! Try clicking the thumbs up or thumbs down buttons below to submit feedback."; + for (let i = 0; i < text.length; i++) { + if (abortSignal.aborted) return; + await new Promise((r) => setTimeout(r, 12)); + yield { + content: [{ type: "text" as const, text: text.slice(0, i + 1) }], + }; + } + }, +}; + +export function FeedbackButtonsDemo() { + const runtime = useLocalRuntime(demoAdapter, { + adapters: { + feedback: { + submit: async () => {}, + }, + }, + }); + + return ( + + + +
+ + + +
+
+ + + Send a message to see feedback buttons + + +
+
+
+
+ ); +} + +const DemoUserMessage: FC = () => ( + +
+ +
+
+); + +function DemoAssistantMessage() { + return ( + +
+ A +
+
+ ( + + {text} + + ), + }} + /> +
+ +
+
+
+ ); +} diff --git a/apps/docs/components/playground/code-generators/generate-code.ts b/apps/docs/components/playground/code-generators/generate-code.ts index 954d39e..3fb2e6c 100644 --- a/apps/docs/components/playground/code-generators/generate-code.ts +++ b/apps/docs/components/playground/code-generators/generate-code.ts @@ -140,6 +140,14 @@ const generators: Record CodeGenResult> = { `, }; }, + "feedback-buttons": (config) => { + const defaults = chordRegistry["feedback-buttons"].defaultConfig; + const props = buildPropsString(config, defaults); + return { + imports: `import { FeedbackButtons } from "@assistant-ui/chords";`, + jsx: ``, + }; + }, "reasoning-accordion": (config) => { const defaults = chordRegistry["reasoning-accordion"].defaultConfig; const props = buildPropsString(config, defaults); diff --git a/apps/docs/components/playground/controls-panel.tsx b/apps/docs/components/playground/controls-panel.tsx index ad0d405..0733856 100644 --- a/apps/docs/components/playground/controls-panel.tsx +++ b/apps/docs/components/playground/controls-panel.tsx @@ -13,6 +13,7 @@ import { ThreadEmptyControls } from "./controls/thread-empty-controls"; import { AttachmentControls } from "./controls/attachment-controls"; import { ScrollToBottomControls } from "./controls/scroll-to-bottom-controls"; import { ReasoningAccordionControls } from "./controls/reasoning-accordion-controls"; +import { FeedbackButtonsControls } from "./controls/feedback-buttons-controls"; type ControlsPanelProps = { chordId: ChordId; @@ -35,6 +36,7 @@ const controlsMap: Partial< attachment: AttachmentControls, "scroll-to-bottom": ScrollToBottomControls, "reasoning-accordion": ReasoningAccordionControls, + "feedback-buttons": FeedbackButtonsControls, }; export function ControlsPanel({ chordId, config, onChange }: ControlsPanelProps) { diff --git a/apps/docs/components/playground/controls/feedback-buttons-controls.tsx b/apps/docs/components/playground/controls/feedback-buttons-controls.tsx new file mode 100644 index 0000000..788295b --- /dev/null +++ b/apps/docs/components/playground/controls/feedback-buttons-controls.tsx @@ -0,0 +1,57 @@ +"use client"; + +import type { ChordConfig } from "@/lib/playground/types"; +import { TextInput, StyleBuilder } from "./shared"; + +const DEFAULT_BUTTON = + "group inline-flex items-center justify-center rounded-md p-1.5 text-zinc-400 transition-colors hover:text-zinc-700 dark:text-zinc-500 dark:hover:text-zinc-200 data-[submitted]:text-zinc-900 dark:data-[submitted]:text-zinc-100"; + +export function FeedbackButtonsControls({ + config, + onChange, +}: { + config: ChordConfig; + onChange: (c: ChordConfig) => void; +}) { + return ( +
+ onChange({ ...config, positiveLabel: v || undefined })} + placeholder="Good response" + /> + onChange({ ...config, negativeLabel: v || undefined })} + placeholder="Bad response" + /> + onChange({ ...config, className: v || undefined })} + controls={["bg", "rounded", "p"]} + /> + + onChange({ ...config, positiveClassName: v || undefined }) + } + controls={["bg", "text"]} + /> + + onChange({ ...config, negativeClassName: v || undefined }) + } + controls={["bg", "text"]} + /> +
+ ); +} diff --git a/apps/docs/components/playground/preview-panel.tsx b/apps/docs/components/playground/preview-panel.tsx index 6affc96..aa2d3f2 100644 --- a/apps/docs/components/playground/preview-panel.tsx +++ b/apps/docs/components/playground/preview-panel.tsx @@ -18,6 +18,7 @@ import { SuggestionChipsPreview } from "./previews/suggestion-chips-preview"; import { ThreadEmptyPreview } from "./previews/thread-empty-preview"; import { ScrollToBottomPreview } from "./previews/scroll-to-bottom-preview"; import { ReasoningAccordionPreview } from "./previews/reasoning-accordion-preview"; +import { FeedbackButtonsPreview } from "./previews/feedback-buttons-preview"; type PreviewPanelProps = { chordId: ChordId; @@ -38,6 +39,7 @@ const previewMap: Record> = { "thread-empty": ThreadEmptyPreview, "scroll-to-bottom": ScrollToBottomPreview, "reasoning-accordion": ReasoningAccordionPreview, + "feedback-buttons": FeedbackButtonsPreview, }; export function PreviewPanel({ chordId, config }: PreviewPanelProps) { diff --git a/apps/docs/components/playground/previews/feedback-buttons-preview.tsx b/apps/docs/components/playground/previews/feedback-buttons-preview.tsx new file mode 100644 index 0000000..810dc62 --- /dev/null +++ b/apps/docs/components/playground/previews/feedback-buttons-preview.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { + AssistantRuntimeProvider, + ThreadPrimitive, + MessagePrimitive, + useLocalRuntime, +} from "@assistant-ui/react"; +import { FeedbackButtons } from "@assistant-ui/chords"; +import type { ChordConfig } from "@/lib/playground/types"; +import { createPlaygroundAdapter } from "@/lib/playground/mock-runtime"; +import { useRef } from "react"; + +const UserMessage = () => ( + +
+ +
+
+); + +const AssistantMessage = ({ config }: { config: ChordConfig }) => ( + +
+ A +
+
+ ( + + {text} + + ), + }} + /> +
+ +
+
+
+); + +export function FeedbackButtonsPreview({ + config, +}: { + config: ChordConfig; +}) { + const adapter = useRef(createPlaygroundAdapter()); + const runtime = useLocalRuntime(adapter.current, { + adapters: { + feedback: { + submit: async () => {}, + }, + }, + }); + + return ( + + +
+ + , + }} + /> + +
+
+ + + Click to see feedback buttons + + +
+
+
+ ); +} diff --git a/apps/docs/content/docs/major-chords/feedback-buttons.mdx b/apps/docs/content/docs/major-chords/feedback-buttons.mdx new file mode 100644 index 0000000..dd5c49f --- /dev/null +++ b/apps/docs/content/docs/major-chords/feedback-buttons.mdx @@ -0,0 +1,76 @@ +--- +title: FeedbackButtons +description: Thumbs up / thumbs down feedback buttons with automatic state management. +--- + +## Overview + +`FeedbackButtons` provides thumbs up and thumbs down buttons for assistant messages. It wraps `ActionBarPrimitive.FeedbackPositive` and `ActionBarPrimitive.FeedbackNegative`, which handle `submitFeedback` calls and highlight the selected button automatically via `data-submitted`. + +## Demo + + + +## When to use + +- Collect user feedback on assistant responses +- Show which response was rated positively or negatively +- Build feedback loops for model improvement + +## Features + +- **State-aware**: Automatically highlights the submitted feedback button via `data-submitted` +- **Zero wiring**: `submitFeedback` is called by the underlying primitives — no callbacks needed +- **Customizable**: Override classes, labels, and icons +- **Accessible**: Proper `aria-label` on each button + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `className` | string | — | Root container class | +| `positiveClassName` | string | — | Thumbs up button class | +| `negativeClassName` | string | — | Thumbs down button class | +| `iconClassName` | string | — | Icon class for both icons | +| `positiveLabel` | string | `"Good response"` | Aria-label for thumbs up | +| `negativeLabel` | string | `"Bad response"` | Aria-label for thumbs down | +| `renderPositiveIcon` | `() => ReactNode` | — | Custom thumbs up icon | +| `renderNegativeIcon` | `() => ReactNode` | — | Custom thumbs down icon | + +## Basic Usage + +```tsx +import { FeedbackButtons } from "@assistant-ui/chords"; + +// Inside an assistant message + + + + +``` + +## With MessageActionBar + +```tsx +
+ + +
+``` + +## Custom Labels + +```tsx + +``` + +## Underlying Primitives + +`FeedbackButtons` wraps: +- `ActionBarPrimitive.FeedbackPositive` — calls `submitFeedback({ type: "positive" })`, sets `data-submitted` when positive feedback is active +- `ActionBarPrimitive.FeedbackNegative` — calls `submitFeedback({ type: "negative" })`, sets `data-submitted` when negative feedback is active + +The `data-submitted` attribute is used for styling the active/highlighted state. diff --git a/apps/docs/content/docs/major-chords/meta.json b/apps/docs/content/docs/major-chords/meta.json index 7a7f837..afbc359 100644 --- a/apps/docs/content/docs/major-chords/meta.json +++ b/apps/docs/content/docs/major-chords/meta.json @@ -9,6 +9,7 @@ "follow-up-suggestions", "attachment", "tool-call-renderer", - "reasoning-accordion" + "reasoning-accordion", + "feedback-buttons" ] } diff --git a/apps/docs/lib/playground/chord-registry.ts b/apps/docs/lib/playground/chord-registry.ts index dea5fc3..be9db03 100644 --- a/apps/docs/lib/playground/chord-registry.ts +++ b/apps/docs/lib/playground/chord-registry.ts @@ -103,6 +103,16 @@ export const chordRegistry: Record = { icon: "U", }, }, + "feedback-buttons": { + id: "feedback-buttons", + name: "FeedbackButtons", + category: "major", + description: "Thumbs up/down feedback buttons with automatic state management", + defaultConfig: { + positiveLabel: "Good response", + negativeLabel: "Bad response", + }, + }, "reasoning-accordion": { id: "reasoning-accordion", name: "ReasoningAccordion", diff --git a/apps/docs/lib/playground/types.ts b/apps/docs/lib/playground/types.ts index d8235e7..570a15e 100644 --- a/apps/docs/lib/playground/types.ts +++ b/apps/docs/lib/playground/types.ts @@ -12,6 +12,7 @@ export const CHORD_IDS = [ "thread-empty", "scroll-to-bottom", "reasoning-accordion", + "feedback-buttons", ] as const; export type ChordId = (typeof CHORD_IDS)[number]; diff --git a/apps/docs/mdx-components.tsx b/apps/docs/mdx-components.tsx index ecda148..78820aa 100644 --- a/apps/docs/mdx-components.tsx +++ b/apps/docs/mdx-components.tsx @@ -27,6 +27,7 @@ import { FollowUpSuggestionsDemo } from "@/components/demo/follow-up-suggestions import { AttachmentDemo } from "@/components/demo/attachment-demo"; import { ToolCallRendererDemo } from "@/components/demo/tool-call-renderer-demo"; import { ReasoningAccordionDemo } from "@/components/demo/reasoning-accordion-demo"; +import { FeedbackButtonsDemo } from "@/components/demo/feedback-buttons-demo"; type Components = Record>; @@ -59,6 +60,7 @@ export function getMDXComponents(components?: Components): Components { AttachmentDemo, ToolCallRendererDemo, ReasoningAccordionDemo, + FeedbackButtonsDemo, blockquote: (props: any) => {props.children}, ...components, }; diff --git a/apps/example/app/components/Assistant.tsx b/apps/example/app/components/Assistant.tsx index 426a44b..32b7e9a 100644 --- a/apps/example/app/components/Assistant.tsx +++ b/apps/example/app/components/Assistant.tsx @@ -15,6 +15,7 @@ import { ComposerAddAttachment, ComposerAttachments, EditComposer, + FeedbackButtons, FollowUpSuggestions, MessageActionBar, MessageAttachments, @@ -41,6 +42,11 @@ export const Assistant: FC = () => { ]; }, }, + feedback: { + submit: async ({ type }) => { + console.log(`Feedback submitted: ${type}`); + }, + }, }, }); @@ -155,6 +161,7 @@ const AssistantMessage: FC = () => {
+
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0d19791..cb259f2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,3 +11,4 @@ export * from "./primitives/follow-up-suggestions"; export * from "./primitives/attachment"; export * from "./primitives/tool-call-renderer"; export * from "./primitives/reasoning-accordion"; +export * from "./primitives/feedback-buttons"; diff --git a/packages/core/src/primitives/feedback-buttons/FeedbackButtons.tsx b/packages/core/src/primitives/feedback-buttons/FeedbackButtons.tsx new file mode 100644 index 0000000..5663a71 --- /dev/null +++ b/packages/core/src/primitives/feedback-buttons/FeedbackButtons.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { ActionBarPrimitive } from "@assistant-ui/react"; +import { + DEFAULT_ROOT_CLASSNAME, + DEFAULT_BUTTON_CLASSNAME, + DEFAULT_ICON_CLASSNAME, +} from "./defaults"; + +export type FeedbackButtonsProps = { + /** Root container class. */ + className?: string; + /** Thumbs up button class. */ + positiveClassName?: string; + /** Thumbs down button class. */ + negativeClassName?: string; + /** Icon class applied to both icons. */ + iconClassName?: string; + /** Aria-label for thumbs up. @default "Good response" */ + positiveLabel?: string; + /** Aria-label for thumbs down. @default "Bad response" */ + negativeLabel?: string; + /** Custom thumbs up icon renderer. */ + renderPositiveIcon?: () => React.ReactNode; + /** Custom thumbs down icon renderer. */ + renderNegativeIcon?: () => React.ReactNode; +}; + +/** + * Thumbs up / thumbs down feedback buttons for assistant messages. + * + * Wraps `ActionBarPrimitive.FeedbackPositive` and `ActionBarPrimitive.FeedbackNegative`. + * The primitives handle `submitFeedback` calls and set `data-submitted` automatically. + * + * Must be rendered inside a message context (e.g. within `MessagePrimitive.Root` + * or alongside `ActionBarPrimitive.Root`). + * + * ```tsx + * + * ``` + */ +export function FeedbackButtons({ + className, + positiveClassName, + negativeClassName, + iconClassName, + positiveLabel = "Good response", + negativeLabel = "Bad response", + renderPositiveIcon, + renderNegativeIcon, +}: FeedbackButtonsProps) { + return ( +
+ + {renderPositiveIcon ? ( + renderPositiveIcon() + ) : ( + + )} + + + {renderNegativeIcon ? ( + renderNegativeIcon() + ) : ( + + )} + +
+ ); +} + +FeedbackButtons.displayName = "FeedbackButtons"; + +// --- Inline SVG icons (no lucide dependency) --- +// Each renders an outline icon (hidden when submitted) and a filled icon +// (hidden when not submitted). The parent button's `data-submitted` attribute +// controls visibility via CSS: `group-data-[submitted]` shows/hides. + +const OUTLINE_HIDE = "block group-data-[submitted]:hidden"; +const FILLED_SHOW = "hidden group-data-[submitted]:block"; + +function ThumbsUpIcons({ className }: { className?: string }) { + return ( + + {/* Outline (default) */} + + + + + {/* Filled (submitted) */} + + + + + + ); +} + +function ThumbsDownIcons({ className }: { className?: string }) { + return ( + + {/* Outline (default) */} + + + + + {/* Filled (submitted) */} + + + + + + ); +} diff --git a/packages/core/src/primitives/feedback-buttons/__tests__/FeedbackButtons.test.tsx b/packages/core/src/primitives/feedback-buttons/__tests__/FeedbackButtons.test.tsx new file mode 100644 index 0000000..25cd233 --- /dev/null +++ b/packages/core/src/primitives/feedback-buttons/__tests__/FeedbackButtons.test.tsx @@ -0,0 +1,138 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; + +vi.mock("@assistant-ui/react", () => ({ + ActionBarPrimitive: { + FeedbackPositive: ({ children, className, "aria-label": ariaLabel, ...rest }: any) => ( + + ), + FeedbackNegative: ({ children, className, "aria-label": ariaLabel, ...rest }: any) => ( + + ), + }, +})); + +import { FeedbackButtons } from "../FeedbackButtons"; +import { + DEFAULT_ROOT_CLASSNAME, + DEFAULT_BUTTON_CLASSNAME, + DEFAULT_ICON_CLASSNAME, +} from "../defaults"; + +describe("FeedbackButtons", () => { + it("renders both buttons", () => { + render(); + expect(screen.getByTestId("feedback-positive")).toBeInTheDocument(); + expect(screen.getByTestId("feedback-negative")).toBeInTheDocument(); + }); + + it("uses default root class", () => { + const { container } = render(); + expect(container.firstChild).toHaveClass(...DEFAULT_ROOT_CLASSNAME.split(" ")); + }); + + it("uses default button class on both buttons", () => { + render(); + expect(screen.getByTestId("feedback-positive").className).toBe( + DEFAULT_BUTTON_CLASSNAME, + ); + expect(screen.getByTestId("feedback-negative").className).toBe( + DEFAULT_BUTTON_CLASSNAME, + ); + }); + + it("uses custom className", () => { + const { container } = render(); + expect(container.firstChild).toHaveClass("custom-root"); + }); + + it("uses custom positiveClassName", () => { + render(); + expect(screen.getByTestId("feedback-positive").className).toBe("custom-pos"); + }); + + it("uses custom negativeClassName", () => { + render(); + expect(screen.getByTestId("feedback-negative").className).toBe("custom-neg"); + }); + + it("renders default aria-labels", () => { + render(); + expect(screen.getByTestId("feedback-positive")).toHaveAttribute( + "aria-label", + "Good response", + ); + expect(screen.getByTestId("feedback-negative")).toHaveAttribute( + "aria-label", + "Bad response", + ); + }); + + it("uses custom labels", () => { + render( + , + ); + expect(screen.getByTestId("feedback-positive")).toHaveAttribute( + "aria-label", + "Helpful", + ); + expect(screen.getByTestId("feedback-negative")).toHaveAttribute( + "aria-label", + "Not helpful", + ); + }); + + it("renders default SVG icons", () => { + render(); + const posBtn = screen.getByTestId("feedback-positive"); + const negBtn = screen.getByTestId("feedback-negative"); + expect(posBtn.querySelector("svg")).toBeInTheDocument(); + expect(negBtn.querySelector("svg")).toBeInTheDocument(); + }); + + it("applies default icon class to icon wrapper", () => { + render(); + const posWrapper = screen.getByTestId("feedback-positive").querySelector("span"); + const negWrapper = screen.getByTestId("feedback-negative").querySelector("span"); + expect(posWrapper).toHaveClass(...DEFAULT_ICON_CLASSNAME.split(" ")); + expect(negWrapper).toHaveClass(...DEFAULT_ICON_CLASSNAME.split(" ")); + }); + + it("applies custom iconClassName to icon wrapper", () => { + render(); + const posWrapper = screen.getByTestId("feedback-positive").querySelector("span"); + expect(posWrapper).toHaveClass("size-6"); + }); + + it("renders custom positive icon", () => { + render( + +} + />, + ); + expect(screen.getByTestId("custom-pos-icon")).toBeInTheDocument(); + }); + + it("renders custom negative icon", () => { + render( + -} + />, + ); + expect(screen.getByTestId("custom-neg-icon")).toBeInTheDocument(); + }); +}); diff --git a/packages/core/src/primitives/feedback-buttons/defaults.ts b/packages/core/src/primitives/feedback-buttons/defaults.ts new file mode 100644 index 0000000..b2eaf66 --- /dev/null +++ b/packages/core/src/primitives/feedback-buttons/defaults.ts @@ -0,0 +1,7 @@ +export const DEFAULT_ROOT_CLASSNAME = + "flex items-center gap-1"; + +export const DEFAULT_BUTTON_CLASSNAME = + "group inline-flex items-center justify-center rounded-md p-1.5 text-zinc-400 transition-colors hover:text-zinc-700 dark:text-zinc-500 dark:hover:text-zinc-200 data-[submitted]:text-zinc-900 dark:data-[submitted]:text-zinc-100"; + +export const DEFAULT_ICON_CLASSNAME = "size-4"; diff --git a/packages/core/src/primitives/feedback-buttons/index.ts b/packages/core/src/primitives/feedback-buttons/index.ts new file mode 100644 index 0000000..00c7c49 --- /dev/null +++ b/packages/core/src/primitives/feedback-buttons/index.ts @@ -0,0 +1 @@ +export { FeedbackButtons, type FeedbackButtonsProps } from "./FeedbackButtons";