-
Notifications
You must be signed in to change notification settings - Fork 50
feat(feedback): feedback button, per-message thumbs, PostHog events #3623
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
eddf87f
2adc7b5
2d096d2
b0b64b4
3c07f57
67dbfa8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import { Hono } from "hono"; | ||
| import type { Env } from "../hono-env"; | ||
|
|
||
| export function createFeedbackRoutes() { | ||
| const app = new Hono<Env>(); | ||
|
|
||
| app.post("/feedback", async (c) => { | ||
| const mesh = c.get("meshContext"); | ||
| const body = await c.req.json<{ message?: unknown }>(); | ||
| const message = | ||
| typeof body?.message === "string" ? body.message.trim() : ""; | ||
| if (!message) return c.json({ error: "message required" }, 400); | ||
|
|
||
| console.log( | ||
| JSON.stringify({ | ||
| event: "user_feedback", | ||
| org_id: mesh.organization?.id, | ||
| user_id: mesh.auth.user?.id, | ||
| message, | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
|
||
| }), | ||
| ); | ||
|
|
||
| return c.json({ ok: true }); | ||
| }); | ||
|
|
||
| return app; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| import { useState } from "react"; | ||
| import { Button } from "@deco/ui/components/button.tsx"; | ||
| import { | ||
| Dialog, | ||
| DialogContent, | ||
| DialogHeader, | ||
| DialogTitle, | ||
| } from "@deco/ui/components/dialog.tsx"; | ||
| import { Textarea } from "@deco/ui/components/textarea.tsx"; | ||
| import { cn } from "@deco/ui/lib/utils.ts"; | ||
| import { toast } from "@deco/ui/components/sonner.js"; | ||
| import { track, getSessionReplayUrl } from "@/web/lib/posthog-client"; | ||
|
|
||
| const REASONS = [ | ||
| "Incorrect or incomplete", | ||
| "Not what I asked for", | ||
| "Slow or buggy", | ||
| "Style or tone", | ||
| "Safety or legal concern", | ||
| "Other", | ||
| ] as const; | ||
|
|
||
| type Reason = (typeof REASONS)[number]; | ||
|
|
||
| interface MessageFeedbackDialogProps { | ||
| open: boolean; | ||
| onOpenChange: (open: boolean) => void; | ||
| messageId: string; | ||
| threadId: string | null; | ||
| } | ||
|
|
||
| export function MessageFeedbackDialog({ | ||
| open, | ||
| onOpenChange, | ||
| messageId, | ||
| threadId, | ||
| }: MessageFeedbackDialogProps) { | ||
| const [selected, setSelected] = useState<Set<Reason>>(new Set()); | ||
| const [details, setDetails] = useState(""); | ||
|
|
||
| const toggle = (reason: Reason) => { | ||
| setSelected((prev) => { | ||
| const next = new Set(prev); | ||
| if (next.has(reason)) { | ||
| next.delete(reason); | ||
| } else { | ||
| next.add(reason); | ||
| } | ||
| return next; | ||
| }); | ||
| }; | ||
|
|
||
| const handleSubmit = () => { | ||
| track("chat_message_feedback_negative", { | ||
| message_id: messageId, | ||
| thread_id: threadId, | ||
| reasons: [...selected], | ||
| details: details.trim() || undefined, | ||
| session_replay_url: getSessionReplayUrl(), | ||
| }); | ||
| setSelected(new Set()); | ||
| setDetails(""); | ||
| onOpenChange(false); | ||
| toast.success("Feedback sent — thank you!"); | ||
| }; | ||
|
|
||
| const handleOpenChange = (next: boolean) => { | ||
| if (!next) { | ||
| setSelected(new Set()); | ||
| setDetails(""); | ||
| } | ||
| onOpenChange(next); | ||
| }; | ||
|
|
||
| return ( | ||
| <Dialog open={open} onOpenChange={handleOpenChange}> | ||
| <DialogContent className="sm:max-w-lg gap-5"> | ||
| <DialogHeader> | ||
| <DialogTitle className="text-base font-semibold"> | ||
| Share feedback | ||
| </DialogTitle> | ||
| </DialogHeader> | ||
|
|
||
| <div className="flex flex-wrap gap-2"> | ||
| {REASONS.map((reason) => ( | ||
| <button | ||
| key={reason} | ||
| type="button" | ||
| onClick={() => toggle(reason)} | ||
| className={cn( | ||
| "px-3 py-1.5 rounded-full border text-sm transition-colors", | ||
| selected.has(reason) | ||
| ? "border-foreground bg-foreground text-background" | ||
| : "border-border text-foreground hover:border-foreground/50", | ||
| )} | ||
| > | ||
| {reason} | ||
| </button> | ||
| ))} | ||
| </div> | ||
|
|
||
| <Textarea | ||
| value={details} | ||
| onChange={(e) => setDetails(e.target.value)} | ||
| placeholder="Share details (optional)" | ||
| className="min-h-28 resize-none" | ||
| onKeyDown={(e) => { | ||
| if ( | ||
| e.key === "Enter" && | ||
| (e.metaKey || e.ctrlKey) && | ||
| !(selected.size === 0 && !details.trim()) | ||
| ) | ||
| handleSubmit(); | ||
| }} | ||
| /> | ||
|
|
||
| <div className="flex justify-end"> | ||
| <Button | ||
| onClick={handleSubmit} | ||
| disabled={selected.size === 0 && !details.trim()} | ||
| size="sm" | ||
| > | ||
| Submit | ||
| </Button> | ||
| </div> | ||
| </DialogContent> | ||
| </Dialog> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,9 +2,11 @@ import { useState, type ReactNode } from "react"; | |
| import { useCopy } from "@deco/ui/hooks/use-copy.ts"; | ||
| import { cn } from "@deco/ui/lib/utils.ts"; | ||
| import { MemoizedMarkdown } from "../../markdown.tsx"; | ||
| import { Check, Copy01 } from "@untitledui/icons"; | ||
| import { Check, Copy01, ThumbsDown, ThumbsUp } from "@untitledui/icons"; | ||
| import type { TextUIPart } from "ai"; | ||
| import { track } from "@/web/lib/posthog-client"; | ||
| import { track, getSessionReplayUrl } from "@/web/lib/posthog-client"; | ||
| import { useOptionalChatTask } from "../../context.tsx"; | ||
| import { MessageFeedbackDialog } from "../../message-feedback-dialog.tsx"; | ||
|
|
||
| interface MessageTextPartProps { | ||
| id: string; | ||
|
|
@@ -23,7 +25,10 @@ export function MessageTextPart({ | |
| alwaysShowActions = false, | ||
| }: MessageTextPartProps) { | ||
| const { handleCopy } = useCopy(); | ||
| const threadId = useOptionalChatTask()?.taskId ?? null; | ||
| const [isCopied, setIsCopied] = useState(false); | ||
| const [feedback, setFeedback] = useState<"positive" | null>(null); | ||
| const [negativeOpen, setNegativeOpen] = useState(false); | ||
|
|
||
| const handleCopyMessage = async () => { | ||
| track("chat_message_copied", { | ||
|
|
@@ -35,8 +40,30 @@ export function MessageTextPart({ | |
| setTimeout(() => setIsCopied(false), 2000); | ||
| }; | ||
|
|
||
| // Only show copy button on the last part (the one with extraActions/usage stats) | ||
| const showCopyButton = copyable && extraActions; | ||
| const handleThumbsUp = () => { | ||
| if (feedback === "positive") { | ||
| setFeedback(null); | ||
| track("chat_message_feedback_positive_undone", { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Positive feedback events miss Prompt for AI agents
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Disagree — omitting The negative flow originally included it but it was removed because chat messages in this MCP control plane regularly contain API keys, OAuth tokens, and connection strings. Sending raw message content to PostHog (a third-party SaaS) is a data leak risk regardless of whether the feedback is positive or negative. Both flows use
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the feedback! I've saved this as a new learning to improve future reviews. |
||
| message_id: id, | ||
| thread_id: threadId, | ||
| session_replay_url: getSessionReplayUrl(), | ||
| }); | ||
| } else { | ||
| setFeedback("positive"); | ||
| track("chat_message_feedback_positive", { | ||
| message_id: id, | ||
| thread_id: threadId, | ||
| session_replay_url: getSessionReplayUrl(), | ||
| }); | ||
| } | ||
| }; | ||
|
|
||
| const handleThumbsDown = () => { | ||
| setNegativeOpen(true); | ||
| }; | ||
|
|
||
| // Only show copy/feedback on the last part (the one with extraActions/usage stats) | ||
| const showCopyButton = copyable && !!extraActions; | ||
| const showActions = showCopyButton || extraActions; | ||
|
|
||
| return ( | ||
|
|
@@ -69,8 +96,47 @@ export function MessageTextPart({ | |
| )} | ||
| </button> | ||
| )} | ||
| {showCopyButton && ( | ||
| <> | ||
| <span className="text-muted-foreground/40 select-none">·</span> | ||
| <button | ||
| type="button" | ||
| onClick={handleThumbsUp} | ||
| className={cn( | ||
| "transition-colors active:scale-[0.97]", | ||
| feedback === "positive" | ||
| ? "text-foreground" | ||
| : "text-muted-foreground [@media(hover:hover)]:hover:text-foreground", | ||
| )} | ||
| aria-label="Good response" | ||
| > | ||
| <ThumbsUp | ||
| className={cn( | ||
| "size-4", | ||
| feedback === "positive" && "fill-current", | ||
| )} | ||
| /> | ||
| </button> | ||
| {feedback !== "positive" && ( | ||
| <button | ||
| type="button" | ||
| onClick={handleThumbsDown} | ||
| className="text-muted-foreground [@media(hover:hover)]:hover:text-foreground transition-colors active:scale-[0.97]" | ||
| aria-label="Bad response" | ||
| > | ||
| <ThumbsDown className="size-4" /> | ||
| </button> | ||
| )} | ||
| </> | ||
| )} | ||
| </div> | ||
| )} | ||
| <MessageFeedbackDialog | ||
| open={negativeOpen} | ||
| onOpenChange={setNegativeOpen} | ||
| messageId={id} | ||
| threadId={threadId} | ||
| /> | ||
| </div> | ||
| ); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.