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

@ -28,7 +28,14 @@ export const companiesApi = {
data: Partial<
Pick<
Company,
"name" | "description" | "status" | "budgetMonthlyCents" | "requireBoardApprovalForNewAgents" | "brandColor" | "logoAssetId"
| "name"
| "description"
| "status"
| "budgetMonthlyCents"
| "requireBoardApprovalForNewAgents"
| "feedbackDataSharingEnabled"
| "brandColor"
| "logoAssetId"
>
>,
) => api.patch<Company>(`/companies/${companyId}`, data),

View file

@ -1,6 +1,9 @@
import type {
Approval,
DocumentRevision,
FeedbackTargetType,
FeedbackTrace,
FeedbackVote,
Issue,
IssueAttachment,
IssueComment,
@ -76,6 +79,26 @@ export const issuesApi = {
}),
release: (id: string) => api.post<Issue>(`/issues/${id}/release`, {}),
listComments: (id: string) => api.get<IssueComment[]>(`/issues/${id}/comments`),
listFeedbackVotes: (id: string) => api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
listFeedbackTraces: (id: string, filters?: Record<string, string | boolean | undefined>) => {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters ?? {})) {
if (value === undefined) continue;
params.set(key, String(value));
}
const qs = params.toString();
return api.get<FeedbackTrace[]>(`/issues/${id}/feedback-traces${qs ? `?${qs}` : ""}`);
},
upsertFeedbackVote: (
id: string,
data: {
targetType: FeedbackTargetType;
targetId: string;
vote: "up" | "down";
reason?: string;
allowSharing?: boolean;
},
) => api.post<FeedbackVote>(`/issues/${id}/feedback-votes`, data),
addComment: (id: string, body: string, reopen?: boolean, interrupt?: boolean) =>
api.post<IssueComment>(
`/issues/${id}/comments`,

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

View file

@ -43,6 +43,7 @@ export const queryKeys = {
["issues", companyId, "execution-workspace", executionWorkspaceId] as const,
detail: (id: string) => ["issues", "detail", id] as const,
comments: (issueId: string) => ["issues", "comments", issueId] as const,
feedbackVotes: (issueId: string) => ["issues", "feedback-votes", issueId] as const,
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
documents: (issueId: string) => ["issues", "documents", issueId] as const,
documentRevisions: (issueId: string, key: string) => ["issues", "document-revisions", issueId, key] as const,

View file

@ -1,5 +1,6 @@
import { ChangeEvent, useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION } from "@paperclipai/shared";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
@ -22,6 +23,8 @@ type AgentSnippetInput = {
testResolutionUrl?: string | null;
};
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
export function CompanySettings() {
const {
companies,
@ -80,6 +83,27 @@ export function CompanySettings() {
}
});
const feedbackSharingMutation = useMutation({
mutationFn: (enabled: boolean) =>
companiesApi.update(selectedCompanyId!, {
feedbackDataSharingEnabled: enabled,
}),
onSuccess: (_company, enabled) => {
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
pushToast({
title: enabled ? "Feedback sharing enabled" : "Feedback sharing disabled",
tone: "success",
});
},
onError: (err) => {
pushToast({
title: "Failed to update feedback sharing",
body: err instanceof Error ? err.message : "Unknown error",
tone: "error",
});
},
});
const inviteMutation = useMutation({
mutationFn: () =>
accessApi.createOpenClawInvitePrompt(selectedCompanyId!),
@ -392,6 +416,48 @@ export function CompanySettings() {
</div>
</div>
<div className="space-y-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Feedback Sharing
</div>
<div className="space-y-3 rounded-md border border-border px-4 py-4">
<ToggleField
label="Allow sharing voted AI outputs with Paperclip Labs"
hint="Only AI-generated outputs you explicitly vote on are eligible for feedback sharing."
checked={!!selectedCompany.feedbackDataSharingEnabled}
onChange={(enabled) => feedbackSharingMutation.mutate(enabled)}
/>
<p className="text-sm text-muted-foreground">
Votes are always saved locally. This setting controls whether voted AI outputs may also be marked for sharing with Paperclip Labs.
</p>
<div className="space-y-1 text-xs text-muted-foreground">
<div>
Terms version: {selectedCompany.feedbackDataSharingTermsVersion ?? DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION}
</div>
{selectedCompany.feedbackDataSharingConsentAt ? (
<div>
Enabled {new Date(selectedCompany.feedbackDataSharingConsentAt).toLocaleString()}
{selectedCompany.feedbackDataSharingConsentByUserId
? ` by ${selectedCompany.feedbackDataSharingConsentByUserId}`
: ""}
</div>
) : (
<div>Sharing is currently disabled.</div>
)}
{FEEDBACK_TERMS_URL ? (
<a
href={FEEDBACK_TERMS_URL}
target="_blank"
rel="noreferrer"
className="inline-flex text-foreground underline underline-offset-4"
>
Read our terms of service
</a>
) : null}
</div>
</div>
</div>
{/* Invites */}
<div className="space-y-4" data-testid="company-settings-invites-section">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">

View file

@ -6,6 +6,8 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
export function InstanceGeneralSettings() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
@ -23,9 +25,8 @@ export function InstanceGeneralSettings() {
queryFn: () => instanceSettingsApi.getGeneral(),
});
const toggleMutation = useMutation({
mutationFn: async (enabled: boolean) =>
instanceSettingsApi.updateGeneral({ censorUsernameInLogs: enabled }),
const updateGeneralMutation = useMutation({
mutationFn: instanceSettingsApi.updateGeneral,
onSuccess: async () => {
setActionError(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.instance.generalSettings });
@ -50,6 +51,7 @@ export function InstanceGeneralSettings() {
}
const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true;
const feedbackDataSharingPreference = generalQuery.data?.feedbackDataSharingPreference ?? "prompt";
return (
<div className="max-w-4xl space-y-6">
@ -83,12 +85,16 @@ export function InstanceGeneralSettings() {
type="button"
data-slot="toggle"
aria-label="Toggle username log censoring"
disabled={toggleMutation.isPending}
disabled={updateGeneralMutation.isPending}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
censorUsernameInLogs ? "bg-green-600" : "bg-muted",
)}
onClick={() => toggleMutation.mutate(!censorUsernameInLogs)}
onClick={() =>
updateGeneralMutation.mutate({
censorUsernameInLogs: !censorUsernameInLogs,
})
}
>
<span
className={cn(
@ -99,6 +105,82 @@ export function InstanceGeneralSettings() {
</button>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="space-y-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">AI feedback sharing</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Control whether thumbs up and thumbs down votes can send the voted AI output to
Paperclip Labs. Votes are always saved locally.
</p>
{FEEDBACK_TERMS_URL ? (
<a
href={FEEDBACK_TERMS_URL}
target="_blank"
rel="noreferrer"
className="inline-flex text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
>
Read our terms of service
</a>
) : null}
</div>
{feedbackDataSharingPreference === "prompt" ? (
<div className="rounded-lg border border-border/70 bg-accent/20 px-3 py-2 text-sm text-muted-foreground">
No default is saved yet. The next thumbs up or thumbs down choice will ask once and
then save the answer here.
</div>
) : null}
<div className="flex flex-wrap gap-2">
{[
{
value: "allowed",
label: "Always allow",
description: "Share voted AI outputs automatically.",
},
{
value: "not_allowed",
label: "Don't allow",
description: "Keep voted AI outputs local only.",
},
].map((option) => {
const active = feedbackDataSharingPreference === option.value;
return (
<button
key={option.value}
type="button"
disabled={updateGeneralMutation.isPending}
className={cn(
"rounded-lg border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
active
? "border-foreground bg-accent text-foreground"
: "border-border bg-background hover:bg-accent/50",
)}
onClick={() =>
updateGeneralMutation.mutate({
feedbackDataSharingPreference: option.value as
| "allowed"
| "not_allowed",
})
}
>
<div className="text-sm font-medium">{option.label}</div>
<div className="text-xs text-muted-foreground">
{option.description}
</div>
</button>
);
})}
</div>
<p className="text-xs text-muted-foreground">
To retest the first-use prompt in local dev, remove the{" "}
<code>feedbackDataSharingPreference</code> key from the{" "}
<code>instance_settings.general</code> JSON row for this instance, or set it back to{" "}
<code>"prompt"</code>. Unset and <code>"prompt"</code> both mean no default has been
chosen yet.
</p>
</div>
</section>
</div>
);
}

View file

@ -7,6 +7,7 @@ import { activityApi } from "../api/activity";
import { heartbeatsApi } from "../api/heartbeats";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { instanceSettingsApi } from "../api/instanceSettings";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { usePanel } from "../context/PanelContext";
@ -64,7 +65,7 @@ import {
Trash2,
} from "lucide-react";
import type { ActivityEvent } from "@paperclipai/shared";
import type { Agent, Issue, IssueAttachment, IssueComment } from "@paperclipai/shared";
import type { Agent, FeedbackVote, Issue, IssueAttachment, IssueComment } from "@paperclipai/shared";
type CommentReassignment = IssueCommentReassignment;
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
@ -81,6 +82,7 @@ const ACTION_LABELS: Record<string, string> = {
"issue.checked_out": "checked out the issue",
"issue.released": "released the issue",
"issue.comment_added": "added a comment",
"issue.feedback_vote_saved": "saved feedback on an AI output",
"issue.attachment_added": "added an attachment",
"issue.attachment_removed": "removed an attachment",
"issue.document_created": "created a document",
@ -99,6 +101,8 @@ const ACTION_LABELS: Record<string, string> = {
"approval.rejected": "rejected",
};
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
function humanizeValue(value: unknown): string {
if (typeof value !== "string") return String(value ?? "none");
return value.replace(/_/g, " ");
@ -197,6 +201,63 @@ function formatAction(action: string, details?: Record<string, unknown> | null):
return ACTION_LABELS[action] ?? action.replace(/[._]/g, " ");
}
function mergeOptimisticFeedbackVote(
previousVotes: FeedbackVote[] | undefined,
nextVote: {
issueId: string;
targetType: "issue_comment" | "issue_document_revision";
targetId: string;
vote: "up" | "down";
reason?: string;
},
currentUserId: string | null,
): FeedbackVote[] {
const now = new Date();
const existingVotes = previousVotes ?? [];
const existingIndex = existingVotes.findIndex(
(feedbackVote) =>
feedbackVote.targetType === nextVote.targetType &&
feedbackVote.targetId === nextVote.targetId &&
(!currentUserId || feedbackVote.authorUserId === currentUserId),
);
if (existingIndex >= 0) {
const existingVote = existingVotes[existingIndex]!;
const updatedVote: FeedbackVote = {
...existingVote,
vote: nextVote.vote,
reason:
nextVote.reason !== undefined
? nextVote.reason.trim() || null
: existingVote.reason,
updatedAt: now,
};
const nextVotes = [...existingVotes];
nextVotes[existingIndex] = updatedVote;
return nextVotes;
}
return [
...existingVotes,
{
id: `optimistic:${nextVote.targetType}:${nextVote.targetId}`,
companyId: "",
issueId: nextVote.issueId,
targetType: nextVote.targetType,
targetId: nextVote.targetId,
authorUserId: currentUserId ?? "current-user",
vote: nextVote.vote,
reason: nextVote.reason?.trim() || null,
sharedWithLabs: false,
sharedAt: null,
consentVersion: null,
redactionSummary: null,
createdAt: now,
updatedAt: now,
},
];
}
function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map<string, Agent> }) {
const id = evt.actorId;
if (evt.actorType === "agent") {
@ -210,7 +271,7 @@ function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map<st
export function IssueDetail() {
const { issueId } = useParams<{ issueId: string }>();
const { selectedCompanyId } = useCompany();
const { selectedCompanyId, selectedCompany } = useCompany();
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
@ -328,6 +389,18 @@ export function IssueDetail() {
enabled: !!selectedCompanyId,
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const { data: feedbackVotes } = useQuery({
queryKey: queryKeys.issues.feedbackVotes(issueId!),
queryFn: () => issuesApi.listFeedbackVotes(issueId!),
enabled: !!issueId && !!currentUserId,
});
const { data: instanceGeneralSettings } = useQuery({
queryKey: queryKeys.instance.generalSettings,
queryFn: () => instanceSettingsApi.getGeneral(),
enabled: !!issueId,
retry: false,
});
const feedbackDataSharingPreference = instanceGeneralSettings?.feedbackDataSharingPreference ?? "prompt";
const { orderedProjects } = useProjectOrder({
projects: projects ?? [],
companyId: selectedCompanyId,
@ -517,6 +590,7 @@ export function IssueDetail() {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.feedbackVotes(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) });
@ -723,6 +797,71 @@ export function IssueDetail() {
},
});
const feedbackVoteMutation = useMutation({
mutationFn: (variables: {
targetType: "issue_comment" | "issue_document_revision";
targetId: string;
vote: "up" | "down";
reason?: string;
allowSharing?: boolean;
sharingPreferenceAtSubmit: "allowed" | "not_allowed" | "prompt";
}) =>
issuesApi.upsertFeedbackVote(issueId!, {
targetType: variables.targetType,
targetId: variables.targetId,
vote: variables.vote,
...(variables.reason ? { reason: variables.reason } : {}),
...(variables.allowSharing ? { allowSharing: true } : {}),
}),
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: queryKeys.issues.feedbackVotes(issueId!) });
const previousVotes = queryClient.getQueryData<FeedbackVote[]>(
queryKeys.issues.feedbackVotes(issueId!),
);
queryClient.setQueryData<FeedbackVote[]>(
queryKeys.issues.feedbackVotes(issueId!),
mergeOptimisticFeedbackVote(
previousVotes,
{
issueId: issueId!,
targetType: variables.targetType,
targetId: variables.targetId,
vote: variables.vote,
reason: variables.reason,
},
currentUserId,
),
);
return { previousVotes };
},
onSuccess: (_savedVote, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.feedbackVotes(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
queryClient.invalidateQueries({ queryKey: queryKeys.instance.generalSettings });
pushToast({
title:
variables.sharingPreferenceAtSubmit === "prompt"
? variables.allowSharing
? "Feedback saved. Future votes will share"
: "Feedback saved. Future votes will stay local"
: variables.allowSharing
? "Feedback saved and sharing enabled"
: "Feedback saved",
tone: "success",
});
},
onError: (err, _variables, context) => {
if (context?.previousVotes) {
queryClient.setQueryData(queryKeys.issues.feedbackVotes(issueId!), context.previousVotes);
}
pushToast({
title: "Failed to save feedback",
body: err instanceof Error ? err.message : "Unknown error",
tone: "error",
});
},
});
const uploadAttachment = useMutation({
mutationFn: async (file: File) => {
if (!selectedCompanyId) throw new Error("No company selected");
@ -1123,11 +1262,24 @@ export function IssueDetail() {
<IssueDocumentsSection
issue={issue}
canDeleteDocuments={Boolean(session?.user?.id)}
feedbackVotes={feedbackVotes}
feedbackDataSharingPreference={feedbackDataSharingPreference}
feedbackTermsUrl={FEEDBACK_TERMS_URL}
mentions={mentionOptions}
imageUploadHandler={async (file) => {
const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath;
}}
onVote={async (revisionId, vote, options) => {
await feedbackVoteMutation.mutateAsync({
targetType: "issue_document_revision",
targetId: revisionId,
vote,
reason: options?.reason,
allowSharing: options?.allowSharing,
sharingPreferenceAtSubmit: feedbackDataSharingPreference,
});
}}
extraActions={!hasAttachments ? attachmentUploadButton : undefined}
/>
@ -1234,6 +1386,9 @@ export function IssueDetail() {
<CommentThread
comments={timelineComments}
queuedComments={queuedComments}
feedbackVotes={feedbackVotes}
feedbackDataSharingPreference={feedbackDataSharingPreference}
feedbackTermsUrl={FEEDBACK_TERMS_URL}
linkedRuns={timelineRuns}
companyId={issue.companyId}
projectId={issue.projectId}
@ -1249,6 +1404,16 @@ export function IssueDetail() {
await interruptQueuedComment.mutateAsync(runId);
}}
interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null}
onVote={async (commentId, vote, options) => {
await feedbackVoteMutation.mutateAsync({
targetType: "issue_comment",
targetId: commentId,
vote,
reason: options?.reason,
allowSharing: options?.allowSharing,
sharingPreferenceAtSubmit: feedbackDataSharingPreference,
});
}}
onAdd={async (body, reopen, reassignment) => {
if (reassignment) {
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });

1
ui/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />