Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
27 changes: 27 additions & 0 deletions apps/mesh/src/api/routes/feedback.ts
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 }>();
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
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,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
}),
);

return c.json({ ok: true });
});

return app;
}
2 changes: 2 additions & 0 deletions apps/mesh/src/api/routes/org-scoped.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { createOrgScopedWellKnownProtectedResourceRoutes } from "./oauth-proxy";
import { createSsoRoutes } from "./org-sso";
import { createProxyRoutes } from "./proxy";
import { createSelfRoutes } from "./self";
import { createFeedbackRoutes } from "./feedback";
import { createHomeNextActionsRoutes } from "./home-next-actions";
import { createThreadOutputsRoutes } from "./thread-outputs";
import { createTriggerCallbackRoutes } from "./trigger-callback";
Expand Down Expand Up @@ -82,6 +83,7 @@ export const createOrgScopedApi = (deps: OrgScopedDeps) => {
app.route("/", createFileUploadRoutes()); // /api/:org/file-configs/:id/upload
app.route("/sandbox", createSandboxRoutes()); // /api/:org/sandbox/:virtualMcpId/:branch/*
app.route("/", createHomeNextActionsRoutes());
app.route("/", createFeedbackRoutes());
app.route("/deco-sites", createDecoSitesOrgRoutes()); // /api/:org/deco-sites
app.route("/sso", createSsoRoutes()); // /api/:org/sso/* (renamed from /api/org-sso)
app.route(
Expand Down
16 changes: 16 additions & 0 deletions apps/mesh/src/web/components/account-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
File06,
Globe01,
LogOut01,
MessageChatCircle,
Monitor01,
Moon01,
Plus,
Expand All @@ -43,6 +44,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 +536,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 @@ -570,6 +573,14 @@ export function AccountPopover() {
});
},
},
{
key: "feedback",
label: "Feedback",
icon: <MessageChatCircle size={16} />,
onClick: () => {
setFeedbackOpen(true);
},
},
{
key: "terms",
label: "Terms of Use",
Expand Down Expand Up @@ -732,6 +743,11 @@ export function AccountPopover() {
open={creatingOrg}
onOpenChange={setCreatingOrg}
/>
<FeedbackDialog
open={feedbackOpen}
onOpenChange={setFeedbackOpen}
orgSlug={orgParam ?? ""}
/>
</>
);
}
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, 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>
);
}
74 changes: 70 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);
};

// Only show copy/feedback on the last part (the one with extraActions/usage stats)
const showCopyButton = copyable && !!extraActions;
const showActions = showCopyButton || extraActions;

return (
Expand Down Expand Up @@ -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>
);
}
Loading
Loading