mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 04:00:38 +09:00
Add feedback voting and thumbs capture flow
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
3db6bdfc3c
commit
c0d0d03bce
66 changed files with 18988 additions and 78 deletions
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue