Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/docs/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
98 changes: 98 additions & 0 deletions apps/docs/components/demo/feedback-buttons-demo.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DemoWrapper>
<AssistantRuntimeProvider runtime={runtime}>
<ThreadPrimitive.Root className="flex h-87.5 flex-col rounded-xl border border-zinc-400 dark:border-zinc-800 bg-white text-zinc-900 dark:bg-zinc-950 dark:text-white px-8">
<div className="relative min-h-0 flex-1">
<ThreadPrimitive.Viewport className="flex h-full flex-col gap-2 overflow-y-auto px-4 pt-4 pb-4">
<ThreadPrimitive.Messages
components={{
UserMessage: DemoUserMessage,
AssistantMessage: DemoAssistantMessage,
}}
/>
</ThreadPrimitive.Viewport>
</div>
<div className="border-t border-zinc-300 dark:border-zinc-800 px-4 py-2">
<ThreadPrimitive.If running={false}>
<ThreadPrimitive.Suggestion
prompt="Give me a helpful tip"
send
className="w-full rounded-lg dark:bg-white/5 bg-black/10 px-3 py-2 text-left text-sm dark:text-white/60 text-black dark:hover:bg-white/10 hover:bg-black/15 transition-colors"
>
Send a message to see feedback buttons
</ThreadPrimitive.Suggestion>
</ThreadPrimitive.If>
</div>
</ThreadPrimitive.Root>
</AssistantRuntimeProvider>
</DemoWrapper>
);
}

const DemoUserMessage: FC = () => (
<MessagePrimitive.Root className="group/message mx-auto flex w-full max-w-3xl flex-col items-end gap-1">
<div className="max-w-[80%] rounded-3xl bg-zinc-100 px-5 text-zinc-900 dark:bg-white/10 dark:text-white/90">
<MessagePrimitive.Content />
</div>
</MessagePrimitive.Root>
);

function DemoAssistantMessage() {
return (
<MessagePrimitive.Root className="group/message mx-auto flex w-full max-w-3xl gap-3">
<div className="mt-2.5 flex size-8 shrink-0 items-center justify-center rounded-full border border-zinc-300 text-xs shadow dark:border-white/15">
A
</div>
<div className="flex-1 pt-0.5">
<MessagePrimitive.Parts
components={{
Text: ({ text }) => (
<span className="text-sm dark:text-white/90 text-black">
{text}
</span>
),
}}
/>
<div className="mt-2">
<FeedbackButtons />
</div>
</div>
</MessagePrimitive.Root>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,14 @@ const generators: Record<ChordId, (config: ChordConfig) => CodeGenResult> = {
</ThreadPrimitive.Empty>`,
};
},
"feedback-buttons": (config) => {
const defaults = chordRegistry["feedback-buttons"].defaultConfig;
const props = buildPropsString(config, defaults);
return {
imports: `import { FeedbackButtons } from "@assistant-ui/chords";`,
jsx: `<FeedbackButtons${props} />`,
};
},
"reasoning-accordion": (config) => {
const defaults = chordRegistry["reasoning-accordion"].defaultConfig;
const props = buildPropsString(config, defaults);
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/components/playground/controls-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-3">
<TextInput
label="positiveLabel"
value={(config.positiveLabel as string) ?? "Good response"}
onChange={(v) => onChange({ ...config, positiveLabel: v || undefined })}
placeholder="Good response"
/>
<TextInput
label="negativeLabel"
value={(config.negativeLabel as string) ?? "Bad response"}
onChange={(v) => onChange({ ...config, negativeLabel: v || undefined })}
placeholder="Bad response"
/>
<StyleBuilder
label="className"
value={(config.className as string) ?? ""}
defaultValue=""
onChange={(v) => onChange({ ...config, className: v || undefined })}
controls={["bg", "rounded", "p"]}
/>
<StyleBuilder
label="positiveClassName"
value={(config.positiveClassName as string) ?? ""}
defaultValue={DEFAULT_BUTTON}
onChange={(v) =>
onChange({ ...config, positiveClassName: v || undefined })
}
controls={["bg", "text"]}
/>
<StyleBuilder
label="negativeClassName"
value={(config.negativeClassName as string) ?? ""}
defaultValue={DEFAULT_BUTTON}
onChange={(v) =>
onChange({ ...config, negativeClassName: v || undefined })
}
controls={["bg", "text"]}
/>
</div>
);
}
2 changes: 2 additions & 0 deletions apps/docs/components/playground/preview-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -38,6 +39,7 @@ const previewMap: Record<ChordId, React.FC<{ config: ChordConfig }>> = {
"thread-empty": ThreadEmptyPreview,
"scroll-to-bottom": ScrollToBottomPreview,
"reasoning-accordion": ReasoningAccordionPreview,
"feedback-buttons": FeedbackButtonsPreview,
};

export function PreviewPanel({ chordId, config }: PreviewPanelProps) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = () => (
<MessagePrimitive.Root className="mx-auto flex w-full max-w-3xl flex-col items-end gap-1">
<div className="max-w-[80%] rounded-3xl bg-zinc-100 px-5 text-zinc-900 dark:bg-white/10 dark:text-white/90">
<MessagePrimitive.Content />
</div>
</MessagePrimitive.Root>
);

const AssistantMessage = ({ config }: { config: ChordConfig }) => (
<MessagePrimitive.Root className="group/message mx-auto flex w-full max-w-3xl gap-3">
<div className="mt-2.5 flex size-8 shrink-0 items-center justify-center rounded-full border border-zinc-300 text-xs shadow dark:border-white/15">
A
</div>
<div className="flex-1 pt-0.5">
<MessagePrimitive.Parts
components={{
Text: ({ text }) => (
<span className="text-sm text-zinc-900 dark:text-white/90">
{text}
</span>
),
}}
/>
<div className="mt-2">
<FeedbackButtons
className={(config.className as string) || undefined}
positiveClassName={
(config.positiveClassName as string) || undefined
}
negativeClassName={
(config.negativeClassName as string) || undefined
}
iconClassName={(config.iconClassName as string) || undefined}
positiveLabel={(config.positiveLabel as string) || undefined}
negativeLabel={(config.negativeLabel as string) || undefined}
/>
</div>
</div>
</MessagePrimitive.Root>
);

export function FeedbackButtonsPreview({
config,
}: {
config: ChordConfig;
}) {
const adapter = useRef(createPlaygroundAdapter());
const runtime = useLocalRuntime(adapter.current, {
adapters: {
feedback: {
submit: async () => {},
},
},
});

return (
<AssistantRuntimeProvider runtime={runtime}>
<ThreadPrimitive.Root className="flex h-80 flex-col rounded-xl border border-zinc-300 dark:border-zinc-800 bg-white dark:bg-zinc-950 text-zinc-900 dark:text-white">
<div className="relative min-h-0 flex-1">
<ThreadPrimitive.Viewport className="flex h-full flex-col gap-2 overflow-y-auto px-4 pt-4 pb-4">
<ThreadPrimitive.Messages
components={{
UserMessage,
AssistantMessage: () => <AssistantMessage config={config} />,
}}
/>
</ThreadPrimitive.Viewport>
</div>
<div className="border-t border-zinc-200 dark:border-zinc-800 px-4 py-2">
<ThreadPrimitive.If running={false}>
<ThreadPrimitive.Suggestion
prompt="Tell me something helpful"
send
className="w-full rounded-lg bg-zinc-100 dark:bg-white/5 px-3 py-2 text-left text-sm text-zinc-600 dark:text-white/60 transition-colors hover:bg-zinc-200 dark:hover:bg-white/10"
>
Click to see feedback buttons
</ThreadPrimitive.Suggestion>
</ThreadPrimitive.If>
</div>
</ThreadPrimitive.Root>
</AssistantRuntimeProvider>
);
}
76 changes: 76 additions & 0 deletions apps/docs/content/docs/major-chords/feedback-buttons.mdx
Original file line number Diff line number Diff line change
@@ -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

<FeedbackButtonsDemo />

## 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
<MessagePrimitive.Root>
<MessagePrimitive.Content />
<FeedbackButtons />
</MessagePrimitive.Root>
```

## With MessageActionBar

```tsx
<div className="flex items-center gap-2">
<MessageActionBar actions={["copy", "reload"]} />
<FeedbackButtons />
</div>
```

## Custom Labels

```tsx
<FeedbackButtons
positiveLabel="Helpful"
negativeLabel="Not helpful"
/>
```

## 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.
3 changes: 2 additions & 1 deletion apps/docs/content/docs/major-chords/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"follow-up-suggestions",
"attachment",
"tool-call-renderer",
"reasoning-accordion"
"reasoning-accordion",
"feedback-buttons"
]
}
Loading