Skip to content

Commit e73eb47

Browse files
authored
feat: add FeedBackButtons chord (#44)
* feat(core): add FeedbackButtons chord * feat(example): add FeedbackButtons to example app * feat(docs): add FeedbackButtons docs, demo, and playground
1 parent 438a92b commit e73eb47

18 files changed

Lines changed: 666 additions & 1 deletion

File tree

apps/docs/app/page.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ const majorChords = [
5050
"Collapsible accordion for AI reasoning with auto-expand during streaming.",
5151
href: "/docs/major-chords/reasoning-accordion",
5252
},
53+
{
54+
name: "FeedbackButtons",
55+
description:
56+
"Thumbs up/down feedback buttons with automatic state management.",
57+
href: "/docs/major-chords/feedback-buttons",
58+
},
5359
];
5460

5561
const minorChords = [
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"use client";
2+
3+
import {
4+
AssistantRuntimeProvider,
5+
MessagePrimitive,
6+
ThreadPrimitive,
7+
useLocalRuntime,
8+
} from "@assistant-ui/react";
9+
import { FeedbackButtons } from "@assistant-ui/chords";
10+
import { DemoWrapper } from "./demo-wrapper";
11+
import type { ChatModelAdapter } from "@assistant-ui/react";
12+
import { FC } from "react";
13+
14+
const demoAdapter: ChatModelAdapter = {
15+
async *run({ abortSignal }) {
16+
const text =
17+
"Here's a helpful response! Try clicking the thumbs up or thumbs down buttons below to submit feedback.";
18+
for (let i = 0; i < text.length; i++) {
19+
if (abortSignal.aborted) return;
20+
await new Promise((r) => setTimeout(r, 12));
21+
yield {
22+
content: [{ type: "text" as const, text: text.slice(0, i + 1) }],
23+
};
24+
}
25+
},
26+
};
27+
28+
export function FeedbackButtonsDemo() {
29+
const runtime = useLocalRuntime(demoAdapter, {
30+
adapters: {
31+
feedback: {
32+
submit: async () => {},
33+
},
34+
},
35+
});
36+
37+
return (
38+
<DemoWrapper>
39+
<AssistantRuntimeProvider runtime={runtime}>
40+
<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">
41+
<div className="relative min-h-0 flex-1">
42+
<ThreadPrimitive.Viewport className="flex h-full flex-col gap-2 overflow-y-auto px-4 pt-4 pb-4">
43+
<ThreadPrimitive.Messages
44+
components={{
45+
UserMessage: DemoUserMessage,
46+
AssistantMessage: DemoAssistantMessage,
47+
}}
48+
/>
49+
</ThreadPrimitive.Viewport>
50+
</div>
51+
<div className="border-t border-zinc-300 dark:border-zinc-800 px-4 py-2">
52+
<ThreadPrimitive.If running={false}>
53+
<ThreadPrimitive.Suggestion
54+
prompt="Give me a helpful tip"
55+
send
56+
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"
57+
>
58+
Send a message to see feedback buttons
59+
</ThreadPrimitive.Suggestion>
60+
</ThreadPrimitive.If>
61+
</div>
62+
</ThreadPrimitive.Root>
63+
</AssistantRuntimeProvider>
64+
</DemoWrapper>
65+
);
66+
}
67+
68+
const DemoUserMessage: FC = () => (
69+
<MessagePrimitive.Root className="group/message mx-auto flex w-full max-w-3xl flex-col items-end gap-1">
70+
<div className="max-w-[80%] rounded-3xl bg-zinc-100 px-5 text-zinc-900 dark:bg-white/10 dark:text-white/90">
71+
<MessagePrimitive.Content />
72+
</div>
73+
</MessagePrimitive.Root>
74+
);
75+
76+
function DemoAssistantMessage() {
77+
return (
78+
<MessagePrimitive.Root className="group/message mx-auto flex w-full max-w-3xl gap-3">
79+
<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">
80+
A
81+
</div>
82+
<div className="flex-1 pt-0.5">
83+
<MessagePrimitive.Parts
84+
components={{
85+
Text: ({ text }) => (
86+
<span className="text-sm dark:text-white/90 text-black">
87+
{text}
88+
</span>
89+
),
90+
}}
91+
/>
92+
<div className="mt-2">
93+
<FeedbackButtons />
94+
</div>
95+
</div>
96+
</MessagePrimitive.Root>
97+
);
98+
}

apps/docs/components/playground/code-generators/generate-code.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,14 @@ const generators: Record<ChordId, (config: ChordConfig) => CodeGenResult> = {
140140
</ThreadPrimitive.Empty>`,
141141
};
142142
},
143+
"feedback-buttons": (config) => {
144+
const defaults = chordRegistry["feedback-buttons"].defaultConfig;
145+
const props = buildPropsString(config, defaults);
146+
return {
147+
imports: `import { FeedbackButtons } from "@assistant-ui/chords";`,
148+
jsx: `<FeedbackButtons${props} />`,
149+
};
150+
},
143151
"reasoning-accordion": (config) => {
144152
const defaults = chordRegistry["reasoning-accordion"].defaultConfig;
145153
const props = buildPropsString(config, defaults);

apps/docs/components/playground/controls-panel.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { ThreadEmptyControls } from "./controls/thread-empty-controls";
1313
import { AttachmentControls } from "./controls/attachment-controls";
1414
import { ScrollToBottomControls } from "./controls/scroll-to-bottom-controls";
1515
import { ReasoningAccordionControls } from "./controls/reasoning-accordion-controls";
16+
import { FeedbackButtonsControls } from "./controls/feedback-buttons-controls";
1617

1718
type ControlsPanelProps = {
1819
chordId: ChordId;
@@ -35,6 +36,7 @@ const controlsMap: Partial<
3536
attachment: AttachmentControls,
3637
"scroll-to-bottom": ScrollToBottomControls,
3738
"reasoning-accordion": ReasoningAccordionControls,
39+
"feedback-buttons": FeedbackButtonsControls,
3840
};
3941

4042
export function ControlsPanel({ chordId, config, onChange }: ControlsPanelProps) {
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"use client";
2+
3+
import type { ChordConfig } from "@/lib/playground/types";
4+
import { TextInput, StyleBuilder } from "./shared";
5+
6+
const DEFAULT_BUTTON =
7+
"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";
8+
9+
export function FeedbackButtonsControls({
10+
config,
11+
onChange,
12+
}: {
13+
config: ChordConfig;
14+
onChange: (c: ChordConfig) => void;
15+
}) {
16+
return (
17+
<div className="flex flex-col gap-3">
18+
<TextInput
19+
label="positiveLabel"
20+
value={(config.positiveLabel as string) ?? "Good response"}
21+
onChange={(v) => onChange({ ...config, positiveLabel: v || undefined })}
22+
placeholder="Good response"
23+
/>
24+
<TextInput
25+
label="negativeLabel"
26+
value={(config.negativeLabel as string) ?? "Bad response"}
27+
onChange={(v) => onChange({ ...config, negativeLabel: v || undefined })}
28+
placeholder="Bad response"
29+
/>
30+
<StyleBuilder
31+
label="className"
32+
value={(config.className as string) ?? ""}
33+
defaultValue=""
34+
onChange={(v) => onChange({ ...config, className: v || undefined })}
35+
controls={["bg", "rounded", "p"]}
36+
/>
37+
<StyleBuilder
38+
label="positiveClassName"
39+
value={(config.positiveClassName as string) ?? ""}
40+
defaultValue={DEFAULT_BUTTON}
41+
onChange={(v) =>
42+
onChange({ ...config, positiveClassName: v || undefined })
43+
}
44+
controls={["bg", "text"]}
45+
/>
46+
<StyleBuilder
47+
label="negativeClassName"
48+
value={(config.negativeClassName as string) ?? ""}
49+
defaultValue={DEFAULT_BUTTON}
50+
onChange={(v) =>
51+
onChange({ ...config, negativeClassName: v || undefined })
52+
}
53+
controls={["bg", "text"]}
54+
/>
55+
</div>
56+
);
57+
}

apps/docs/components/playground/preview-panel.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { SuggestionChipsPreview } from "./previews/suggestion-chips-preview";
1818
import { ThreadEmptyPreview } from "./previews/thread-empty-preview";
1919
import { ScrollToBottomPreview } from "./previews/scroll-to-bottom-preview";
2020
import { ReasoningAccordionPreview } from "./previews/reasoning-accordion-preview";
21+
import { FeedbackButtonsPreview } from "./previews/feedback-buttons-preview";
2122

2223
type PreviewPanelProps = {
2324
chordId: ChordId;
@@ -38,6 +39,7 @@ const previewMap: Record<ChordId, React.FC<{ config: ChordConfig }>> = {
3839
"thread-empty": ThreadEmptyPreview,
3940
"scroll-to-bottom": ScrollToBottomPreview,
4041
"reasoning-accordion": ReasoningAccordionPreview,
42+
"feedback-buttons": FeedbackButtonsPreview,
4143
};
4244

4345
export function PreviewPanel({ chordId, config }: PreviewPanelProps) {
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"use client";
2+
3+
import {
4+
AssistantRuntimeProvider,
5+
ThreadPrimitive,
6+
MessagePrimitive,
7+
useLocalRuntime,
8+
} from "@assistant-ui/react";
9+
import { FeedbackButtons } from "@assistant-ui/chords";
10+
import type { ChordConfig } from "@/lib/playground/types";
11+
import { createPlaygroundAdapter } from "@/lib/playground/mock-runtime";
12+
import { useRef } from "react";
13+
14+
const UserMessage = () => (
15+
<MessagePrimitive.Root className="mx-auto flex w-full max-w-3xl flex-col items-end gap-1">
16+
<div className="max-w-[80%] rounded-3xl bg-zinc-100 px-5 text-zinc-900 dark:bg-white/10 dark:text-white/90">
17+
<MessagePrimitive.Content />
18+
</div>
19+
</MessagePrimitive.Root>
20+
);
21+
22+
const AssistantMessage = ({ config }: { config: ChordConfig }) => (
23+
<MessagePrimitive.Root className="group/message mx-auto flex w-full max-w-3xl gap-3">
24+
<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">
25+
A
26+
</div>
27+
<div className="flex-1 pt-0.5">
28+
<MessagePrimitive.Parts
29+
components={{
30+
Text: ({ text }) => (
31+
<span className="text-sm text-zinc-900 dark:text-white/90">
32+
{text}
33+
</span>
34+
),
35+
}}
36+
/>
37+
<div className="mt-2">
38+
<FeedbackButtons
39+
className={(config.className as string) || undefined}
40+
positiveClassName={
41+
(config.positiveClassName as string) || undefined
42+
}
43+
negativeClassName={
44+
(config.negativeClassName as string) || undefined
45+
}
46+
iconClassName={(config.iconClassName as string) || undefined}
47+
positiveLabel={(config.positiveLabel as string) || undefined}
48+
negativeLabel={(config.negativeLabel as string) || undefined}
49+
/>
50+
</div>
51+
</div>
52+
</MessagePrimitive.Root>
53+
);
54+
55+
export function FeedbackButtonsPreview({
56+
config,
57+
}: {
58+
config: ChordConfig;
59+
}) {
60+
const adapter = useRef(createPlaygroundAdapter());
61+
const runtime = useLocalRuntime(adapter.current, {
62+
adapters: {
63+
feedback: {
64+
submit: async () => {},
65+
},
66+
},
67+
});
68+
69+
return (
70+
<AssistantRuntimeProvider runtime={runtime}>
71+
<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">
72+
<div className="relative min-h-0 flex-1">
73+
<ThreadPrimitive.Viewport className="flex h-full flex-col gap-2 overflow-y-auto px-4 pt-4 pb-4">
74+
<ThreadPrimitive.Messages
75+
components={{
76+
UserMessage,
77+
AssistantMessage: () => <AssistantMessage config={config} />,
78+
}}
79+
/>
80+
</ThreadPrimitive.Viewport>
81+
</div>
82+
<div className="border-t border-zinc-200 dark:border-zinc-800 px-4 py-2">
83+
<ThreadPrimitive.If running={false}>
84+
<ThreadPrimitive.Suggestion
85+
prompt="Tell me something helpful"
86+
send
87+
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"
88+
>
89+
Click to see feedback buttons
90+
</ThreadPrimitive.Suggestion>
91+
</ThreadPrimitive.If>
92+
</div>
93+
</ThreadPrimitive.Root>
94+
</AssistantRuntimeProvider>
95+
);
96+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
---
2+
title: FeedbackButtons
3+
description: Thumbs up / thumbs down feedback buttons with automatic state management.
4+
---
5+
6+
## Overview
7+
8+
`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`.
9+
10+
## Demo
11+
12+
<FeedbackButtonsDemo />
13+
14+
## When to use
15+
16+
- Collect user feedback on assistant responses
17+
- Show which response was rated positively or negatively
18+
- Build feedback loops for model improvement
19+
20+
## Features
21+
22+
- **State-aware**: Automatically highlights the submitted feedback button via `data-submitted`
23+
- **Zero wiring**: `submitFeedback` is called by the underlying primitives — no callbacks needed
24+
- **Customizable**: Override classes, labels, and icons
25+
- **Accessible**: Proper `aria-label` on each button
26+
27+
## Props
28+
29+
| Prop | Type | Default | Description |
30+
|------|------|---------|-------------|
31+
| `className` | string || Root container class |
32+
| `positiveClassName` | string || Thumbs up button class |
33+
| `negativeClassName` | string || Thumbs down button class |
34+
| `iconClassName` | string || Icon class for both icons |
35+
| `positiveLabel` | string | `"Good response"` | Aria-label for thumbs up |
36+
| `negativeLabel` | string | `"Bad response"` | Aria-label for thumbs down |
37+
| `renderPositiveIcon` | `() => ReactNode` || Custom thumbs up icon |
38+
| `renderNegativeIcon` | `() => ReactNode` || Custom thumbs down icon |
39+
40+
## Basic Usage
41+
42+
```tsx
43+
import { FeedbackButtons } from "@assistant-ui/chords";
44+
45+
// Inside an assistant message
46+
<MessagePrimitive.Root>
47+
<MessagePrimitive.Content />
48+
<FeedbackButtons />
49+
</MessagePrimitive.Root>
50+
```
51+
52+
## With MessageActionBar
53+
54+
```tsx
55+
<div className="flex items-center gap-2">
56+
<MessageActionBar actions={["copy", "reload"]} />
57+
<FeedbackButtons />
58+
</div>
59+
```
60+
61+
## Custom Labels
62+
63+
```tsx
64+
<FeedbackButtons
65+
positiveLabel="Helpful"
66+
negativeLabel="Not helpful"
67+
/>
68+
```
69+
70+
## Underlying Primitives
71+
72+
`FeedbackButtons` wraps:
73+
- `ActionBarPrimitive.FeedbackPositive` — calls `submitFeedback({ type: "positive" })`, sets `data-submitted` when positive feedback is active
74+
- `ActionBarPrimitive.FeedbackNegative` — calls `submitFeedback({ type: "negative" })`, sets `data-submitted` when negative feedback is active
75+
76+
The `data-submitted` attribute is used for styling the active/highlighted state.

apps/docs/content/docs/major-chords/meta.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"follow-up-suggestions",
1010
"attachment",
1111
"tool-call-renderer",
12-
"reasoning-accordion"
12+
"reasoning-accordion",
13+
"feedback-buttons"
1314
]
1415
}

0 commit comments

Comments
 (0)