Add feedback voting and thumbs capture flow

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-02 09:11:49 -05:00
parent 3db6bdfc3c
commit c0d0d03bce
66 changed files with 18988 additions and 78 deletions

View file

@ -1,12 +1,19 @@
import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import { Link, useLocation } from "react-router-dom";
import type { IssueComment, Agent } from "@paperclipai/shared";
import type {
Agent,
FeedbackDataSharingPreference,
FeedbackVote,
FeedbackVoteValue,
IssueComment,
} from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { Check, Copy, Paperclip } from "lucide-react";
import { Identity } from "./Identity";
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
import { StatusBadge } from "./StatusBadge";
import { AgentIcon } from "./AgentIconPicker";
import { formatDateTime } from "../lib/utils";
@ -38,9 +45,17 @@ interface CommentReassignment {
interface CommentThreadProps {
comments: CommentWithRunMeta[];
queuedComments?: CommentWithRunMeta[];
feedbackVotes?: FeedbackVote[];
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
linkedRuns?: LinkedRunItem[];
companyId?: string | null;
projectId?: string | null;
onVote?: (
commentId: string,
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) => Promise<void>;
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
issueStatus?: string;
agentMap?: Map<string, Agent>;
@ -127,6 +142,11 @@ function CommentCard({
agentMap,
companyId,
projectId,
feedbackVote = null,
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
onVote,
voting = false,
highlightCommentId,
queued = false,
}: {
@ -134,6 +154,14 @@ function CommentCard({
agentMap?: Map<string, Agent>;
companyId?: string | null;
projectId?: string | null;
feedbackVote?: FeedbackVoteValue | null;
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
onVote?: (
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) => Promise<void>;
voting?: boolean;
highlightCommentId?: string | null;
queued?: boolean;
}) {
@ -218,6 +246,15 @@ function CommentCard({
/>
</div>
) : null}
{comment.authorAgentId && onVote && !isQueued && !isPending ? (
<OutputFeedbackButtons
activeVote={feedbackVote}
disabled={voting}
sharingPreference={feedbackDataSharingPreference}
termsUrl={feedbackTermsUrl}
onVote={onVote}
/>
) : null}
{comment.runId && !isPending ? (
<div className="mt-2 pt-2 border-t border-border/60">
{comment.runAgentId ? (
@ -247,12 +284,26 @@ const TimelineList = memo(function TimelineList({
agentMap,
companyId,
projectId,
feedbackVoteByTargetId,
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
onVote,
votingTargetId,
highlightCommentId,
}: {
timeline: TimelineItem[];
agentMap?: Map<string, Agent>;
companyId?: string | null;
projectId?: string | null;
feedbackVoteByTargetId?: Map<string, FeedbackVoteValue>;
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
onVote?: (
commentId: string,
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) => Promise<void>;
votingTargetId?: string | null;
highlightCommentId?: string | null;
}) {
if (timeline.length === 0) {
@ -299,6 +350,11 @@ const TimelineList = memo(function TimelineList({
agentMap={agentMap}
companyId={companyId}
projectId={projectId}
feedbackVote={feedbackVoteByTargetId?.get(comment.id) ?? null}
feedbackDataSharingPreference={feedbackDataSharingPreference}
feedbackTermsUrl={feedbackTermsUrl}
onVote={onVote ? (vote, options) => onVote(comment.id, vote, options) : undefined}
voting={votingTargetId === comment.id}
highlightCommentId={highlightCommentId}
/>
);
@ -310,9 +366,13 @@ const TimelineList = memo(function TimelineList({
export function CommentThread({
comments,
queuedComments = [],
feedbackVotes = [],
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
linkedRuns = [],
companyId,
projectId,
onVote,
onAdd,
agentMap,
imageUploadHandler,
@ -334,6 +394,7 @@ export function CommentThread({
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
const [highlightCommentId, setHighlightCommentId] = useState<string | null>(null);
const [votingTargetId, setVotingTargetId] = useState<string | null>(null);
const editorRef = useRef<MarkdownEditorRef>(null);
const attachInputRef = useRef<HTMLInputElement | null>(null);
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -360,6 +421,15 @@ export function CommentThread({
});
}, [comments, linkedRuns]);
const feedbackVoteByTargetId = useMemo(() => {
const map = new Map<string, FeedbackVoteValue>();
for (const feedbackVote of feedbackVotes) {
if (feedbackVote.targetType !== "issue_comment") continue;
map.set(feedbackVote.targetId, feedbackVote.vote);
}
return map;
}, [feedbackVotes]);
// Build mention options from agent map (exclude terminated agents)
const mentions = useMemo<MentionOption[]>(() => {
if (providedMentions) return providedMentions;
@ -463,21 +533,38 @@ export function CommentThread({
}
}
async function handleFeedbackVote(
commentId: string,
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) {
if (!onVote) return;
setVotingTargetId(commentId);
try {
await onVote(commentId, vote, options);
} finally {
setVotingTargetId(null);
}
}
const canSubmit = !submitting && !!body.trim();
return (
<div className="space-y-4">
<h3 className="text-sm font-semibold">Comments &amp; Runs ({timeline.length + queuedComments.length})</h3>
{timeline.length > 0 ? (
<TimelineList
timeline={timeline}
agentMap={agentMap}
companyId={companyId}
projectId={projectId}
highlightCommentId={highlightCommentId}
/>
) : null}
<TimelineList
timeline={timeline}
agentMap={agentMap}
companyId={companyId}
projectId={projectId}
feedbackVoteByTargetId={feedbackVoteByTargetId}
feedbackDataSharingPreference={feedbackDataSharingPreference}
onVote={onVote ? handleFeedbackVote : undefined}
votingTargetId={votingTargetId}
highlightCommentId={highlightCommentId}
feedbackTermsUrl={feedbackTermsUrl}
/>
{liveRunSlot}
@ -599,6 +686,7 @@ export function CommentThread({
</Button>
</div>
</div>
</div>
);
}

View file

@ -1,6 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { DocumentRevision, Issue, IssueDocument } from "@paperclipai/shared";
import type {
DocumentRevision,
FeedbackDataSharingPreference,
FeedbackVote,
FeedbackVoteValue,
Issue,
IssueDocument,
} from "@paperclipai/shared";
import { useLocation } from "@/lib/router";
import { ApiError } from "../api/client";
import { issuesApi } from "../api/issues";
@ -9,6 +16,7 @@ import { queryKeys } from "../lib/queryKeys";
import { cn, relativeTime } from "../lib/utils";
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@ -101,14 +109,26 @@ function documentHasUnsavedChanges(doc: IssueDocument, draft: DraftState | null)
export function IssueDocumentsSection({
issue,
canDeleteDocuments,
feedbackVotes = [],
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
mentions,
imageUploadHandler,
onVote,
extraActions,
}: {
issue: Issue;
canDeleteDocuments: boolean;
feedbackVotes?: FeedbackVote[];
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
mentions?: MentionOption[];
imageUploadHandler?: (file: File) => Promise<string>;
onVote?: (
revisionId: string,
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) => Promise<void>;
extraActions?: ReactNode;
}) {
const queryClient = useQueryClient();
@ -207,6 +227,15 @@ export function IssueDocumentsSection({
});
}, [documents]);
const feedbackVoteByTargetId = useMemo(() => {
const map = new Map<string, FeedbackVoteValue>();
for (const feedbackVote of feedbackVotes) {
if (feedbackVote.targetType !== "issue_document_revision") continue;
map.set(feedbackVote.targetId, feedbackVote.vote);
}
return map;
}, [feedbackVotes]);
const hasRealPlan = sortedDocuments.some((doc) => doc.key === "plan");
const isEmpty = sortedDocuments.length === 0 && !issue.legacyPlanDocument;
const newDocumentKeyError =
@ -718,6 +747,7 @@ export function IssueDocumentsSection({
const displayedRevisionNumber = selectedHistoricalRevision?.revisionNumber ?? doc.latestRevisionNumber;
const displayedUpdatedAt = selectedHistoricalRevision?.createdAt ?? doc.updatedAt;
const showTitle = !isPlanKey(doc.key) && !!displayedTitle.trim() && !titlesMatchKey(displayedTitle, doc.key);
const canVoteOnDocument = Boolean(doc.latestRevisionId && doc.updatedByAgentId && !doc.updatedByUserId && onVote);
return (
<div
@ -1053,6 +1083,16 @@ export function IssueDocumentsSection({
: ""}
</span>
</div>
{canVoteOnDocument && doc.latestRevisionId ? (
<OutputFeedbackButtons
activeVote={feedbackVoteByTargetId.get(doc.latestRevisionId) ?? null}
sharingPreference={feedbackDataSharingPreference}
termsUrl={feedbackTermsUrl}
onVote={(vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }) =>
onVote?.(doc.latestRevisionId!, vote, options) ?? Promise.resolve()
}
/>
) : null}
</div>
) : null}

View file

@ -0,0 +1,259 @@
import { useEffect, useState } from "react";
import type { FeedbackDataSharingPreference, FeedbackVoteValue } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ThumbsDown, ThumbsUp } from "lucide-react";
import { cn } from "../lib/utils";
export function OutputFeedbackButtons({
activeVote,
disabled = false,
sharingPreference = "prompt",
termsUrl = null,
onVote,
}: {
activeVote?: FeedbackVoteValue | null;
disabled?: boolean;
sharingPreference?: FeedbackDataSharingPreference;
termsUrl?: string | null;
onVote: (vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }) => Promise<void>;
}) {
const [pendingVote, setPendingVote] = useState<{
vote: FeedbackVoteValue;
reason?: string;
keepReasonPromptOpen?: boolean;
} | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [downvoteReason, setDownvoteReason] = useState("");
const [collectingDownvoteReason, setCollectingDownvoteReason] = useState(false);
const [downvoteAllowSharing, setDownvoteAllowSharing] = useState<boolean | undefined>(undefined);
const [optimisticVote, setOptimisticVote] = useState<FeedbackVoteValue | null>(null);
const visibleVote = optimisticVote ?? activeVote ?? null;
useEffect(() => {
if (optimisticVote && activeVote === optimisticVote) {
setOptimisticVote(null);
}
}, [activeVote, optimisticVote]);
async function submitVote(
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
behavior?: { keepReasonPromptOpen?: boolean },
) {
setIsSaving(true);
try {
await onVote(vote, options);
setPendingVote(null);
if (!behavior?.keepReasonPromptOpen) {
setCollectingDownvoteReason(false);
setDownvoteReason("");
setDownvoteAllowSharing(undefined);
}
} catch (error) {
setOptimisticVote(null);
throw error;
} finally {
setIsSaving(false);
}
}
function beginVote(
vote: FeedbackVoteValue,
reason?: string,
behavior?: { keepReasonPromptOpen?: boolean },
) {
if (sharingPreference === "prompt") {
setPendingVote({
vote,
...(reason ? { reason } : {}),
...(behavior?.keepReasonPromptOpen ? { keepReasonPromptOpen: true } : {}),
});
return;
}
const allowSharing = sharingPreference === "allowed";
if (vote === "down") {
setDownvoteAllowSharing(allowSharing);
}
void submitVote(
vote,
{
...(allowSharing ? { allowSharing: true } : {}),
...(reason ? { reason } : {}),
},
behavior,
);
}
function handleVote(vote: FeedbackVoteValue) {
setOptimisticVote(vote);
if (vote === "down") {
setCollectingDownvoteReason(true);
setDownvoteReason("");
setDownvoteAllowSharing(undefined);
void beginVote("down", undefined, { keepReasonPromptOpen: true });
return;
}
void beginVote(vote);
}
return (
<>
<div className="mt-3 flex items-center gap-2 border-t border-border/60 pt-3">
<Button
type="button"
size="sm"
variant="outline"
disabled={disabled || isSaving}
className={cn(visibleVote === "up" && "border-green-600/50 bg-green-500/10 text-green-700")}
onClick={() => handleVote("up")}
>
<ThumbsUp className="mr-1.5 h-3.5 w-3.5" />
Helpful
</Button>
<Button
type="button"
size="sm"
variant="outline"
disabled={disabled || isSaving}
className={cn(visibleVote === "down" && "border-amber-600/50 bg-amber-500/10 text-amber-800")}
onClick={() => handleVote("down")}
>
<ThumbsDown className="mr-1.5 h-3.5 w-3.5" />
Needs work
</Button>
</div>
{collectingDownvoteReason ? (
<div className="mt-2 rounded-md border border-border/60 bg-accent/20 p-3">
<div className="mb-2 text-sm font-medium">What could have been better?</div>
<Textarea
value={downvoteReason}
onChange={(event) => setDownvoteReason(event.target.value)}
placeholder="Add a short note"
className="min-h-20 resize-y bg-background"
disabled={disabled || isSaving}
/>
<div className="mt-3 flex items-center justify-end gap-2">
<Button
type="button"
size="sm"
variant="outline"
disabled={disabled || isSaving}
onClick={() => {
setCollectingDownvoteReason(false);
setDownvoteReason("");
setDownvoteAllowSharing(undefined);
}}
>
Dismiss
</Button>
<Button
type="button"
size="sm"
disabled={disabled || isSaving || !downvoteReason.trim()}
onClick={() => {
void submitVote("down", {
...(downvoteAllowSharing ? { allowSharing: true } : {}),
reason: downvoteReason,
});
}}
>
{isSaving ? "Saving..." : "Save note"}
</Button>
</div>
</div>
) : null}
<Dialog
open={Boolean(pendingVote)}
onOpenChange={(open) => {
if (!open && !isSaving) {
setPendingVote(null);
setOptimisticVote(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Save your feedback sharing preference</DialogTitle>
<DialogDescription>
Choose whether voted AI outputs can be shared with Paperclip Labs. This
answer becomes the default for future thumbs up and thumbs down votes.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm text-muted-foreground">
<p>
This vote is always saved locally.
</p>
<p>
Choose <span className="font-medium text-foreground">Always allow</span> to share
this vote and future voted AI outputs. Choose{" "}
<span className="font-medium text-foreground">Don't allow</span> to keep this vote
and future votes local.
</p>
<p>
You can change this later in Instance Settings &gt; General.
</p>
{termsUrl ? (
<a
href={termsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex text-sm text-foreground underline underline-offset-4"
>
Read our terms of service
</a>
) : null}
</div>
<DialogFooter>
<Button
type="button"
disabled={!pendingVote || isSaving}
onClick={() => {
if (!pendingVote) return;
if (pendingVote.vote === "down") {
setDownvoteAllowSharing(false);
}
void submitVote(
pendingVote.vote,
pendingVote.reason ? { reason: pendingVote.reason } : undefined,
{ keepReasonPromptOpen: pendingVote.keepReasonPromptOpen },
);
}}
>
{isSaving ? "Saving..." : "Don't allow"}
</Button>
<Button
type="button"
disabled={!pendingVote || isSaving}
onClick={() => {
if (!pendingVote) return;
if (pendingVote.vote === "down") {
setDownvoteAllowSharing(true);
}
void submitVote(
pendingVote.vote,
{
allowSharing: true,
...(pendingVote.reason ? { reason: pendingVote.reason } : {}),
},
{ keepReasonPromptOpen: pendingVote.keepReasonPromptOpen },
);
}}
>
{isSaving ? "Saving..." : "Always allow"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}