Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
24 changes: 10 additions & 14 deletions apps/mesh/src/web/components/account-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,14 @@ import { useIsMobile } from "@deco/ui/hooks/use-mobile.ts";
import {
Check,
Copy01,
File06,
Globe01,
LogOut01,
MessageChatCircle,
Monitor01,
Moon01,
Plus,
SearchMd,
Settings02,
Shield01,
Sun,
Users03,
VolumeMax,
Expand All @@ -43,6 +42,7 @@ import { authClient } from "@/web/lib/auth-client";
import { track } from "@/web/lib/posthog-client";
import { clearPersistedQueryCache } from "@/web/lib/query-persist";
import { CreateOrganizationDialog } from "@/web/components/create-organization-dialog";
import { FeedbackDialog } from "@/web/components/feedback-dialog";
import { usePreferences, type ThemeMode } from "@/web/hooks/use-preferences.ts";
import { toast } from "@deco/ui/components/sonner.js";

Expand Down Expand Up @@ -534,6 +534,7 @@ export function AccountPopover() {

const [open, setOpen] = useState(false);
const [creatingOrg, setCreatingOrg] = useState(false);
const [feedbackOpen, setFeedbackOpen] = useState(false);

const user = session?.user;
const userImage = (user as { image?: string } | undefined)?.image;
Expand Down Expand Up @@ -571,18 +572,12 @@ export function AccountPopover() {
},
},
{
key: "terms",
label: "Terms of Use",
icon: <File06 size={16} />,
href: "https://www.decocms.com/terms-of-use",
external: true,
},
{
key: "privacy",
label: "Privacy Policy",
icon: <Shield01 size={16} />,
href: "https://www.decocms.com/privacy-policy",
external: true,
key: "feedback",
label: "Feedback",
icon: <MessageChatCircle size={16} />,
onClick: () => {
setFeedbackOpen(true);
},
},
{
key: "github",
Expand Down Expand Up @@ -732,6 +727,7 @@ export function AccountPopover() {
open={creatingOrg}
onOpenChange={setCreatingOrg}
/>
<FeedbackDialog open={feedbackOpen} onOpenChange={setFeedbackOpen} />
</>
);
}
129 changes: 129 additions & 0 deletions apps/mesh/src/web/components/chat/message-feedback-dialog.tsx
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 } 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;
messageContent: string;
sessionReplayUrl: string | null;
}

export function MessageFeedbackDialog({
open,
onOpenChange,
messageId,
threadId,
messageContent,
sessionReplayUrl,
}: 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,
message_content: messageContent.slice(0, 500) || undefined,
session_replay_url: sessionReplayUrl,
});
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)) handleSubmit();
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
}}
/>

<div className="flex justify-end">
<Button
onClick={handleSubmit}
disabled={selected.size === 0 && !details.trim()}
size="sm"
>
Submit
</Button>
</div>
</DialogContent>
</Dialog>
);
}
76 changes: 72 additions & 4 deletions apps/mesh/src/web/components/chat/message/parts/text-part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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", {
Expand All @@ -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", {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Positive feedback events miss message_content. Add snippet like negative flow so analytics stays consistent per message.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/src/web/components/chat/message/parts/text-part.tsx, line 46:

<comment>Positive feedback events miss `message_content`. Add snippet like negative flow so analytics stays consistent per message.</comment>

<file context>
@@ -35,8 +40,30 @@ export function MessageTextPart({
+  const handleThumbsUp = () => {
+    if (feedback === "positive") {
+      setFeedback(null);
+      track("chat_message_feedback_positive_undone", {
+        message_id: id,
+        thread_id: threadId,
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disagree — omitting message_content from the positive event is intentional, not an oversight.

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 message_id + thread_id for correlation. Full content can be retrieved internally from those IDs. Keeping the two events symmetric means both are safe, not both are unsafe.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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);
};

// Fix: show copy on any copyable part, not only when extraActions is present
const showCopyButton = copyable;
const showActions = showCopyButton || extraActions;

return (
Expand Down Expand Up @@ -69,8 +96,49 @@ 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}
messageContent={part.text}
sessionReplayUrl={getSessionReplayUrl()}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
/>
</div>
);
}
89 changes: 89 additions & 0 deletions apps/mesh/src/web/components/feedback-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { useState } from "react";
import { MessageChatCircle } from "@untitledui/icons";
import { Button } from "@deco/ui/components/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@deco/ui/components/dialog.tsx";
import { Textarea } from "@deco/ui/components/textarea.tsx";
import { toast } from "@deco/ui/components/sonner.js";
import { track, getSessionReplayUrl } from "@/web/lib/posthog-client";

interface FeedbackDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}

export function FeedbackDialog({ open, onOpenChange }: FeedbackDialogProps) {
const [message, setMessage] = useState("");
const [sending, setSending] = useState(false);

const handleSubmit = async () => {
if (!message.trim()) return;
setSending(true);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
track("user_feedback", {
message: message.trim(),
session_replay_url: getSessionReplayUrl(),
});
setSending(false);
setMessage("");
onOpenChange(false);
toast.success("Feedback sent — thank you!");
};

const handleOpenChange = (next: boolean) => {
if (!next) setMessage("");
onOpenChange(next);
};

return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md gap-5">
<DialogHeader className="gap-1">
<DialogTitle className="flex items-center gap-2 text-base font-semibold">
<MessageChatCircle size={18} className="text-muted-foreground" />
Feedback
</DialogTitle>
<DialogDescription>
Tell us what's on your mind — bugs, ideas, or anything else.
</DialogDescription>
</DialogHeader>

<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-foreground">Message</label>
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Tell us about your experience, bugs you've found, or features you'd like to see..."
className="min-h-32 resize-none"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
handleSubmit();
}
}}
/>
</div>

<div className="flex items-center justify-between pt-1">
<a
href="mailto:contact@decocms.com"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
contact@decocms.com
</a>
<Button
onClick={handleSubmit}
disabled={!message.trim() || sending}
size="sm"
>
Send feedback
<span className="ml-1.5 text-xs opacity-60">⌘↵</span>
</Button>
</div>
</DialogContent>
</Dialog>
);
}
Loading
Loading