mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 02:20: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
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
259
ui/src/components/OutputFeedbackButtons.tsx
Normal file
259
ui/src/components/OutputFeedbackButtons.tsx
Normal 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 > 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
1
ui/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
Loading…
Add table
Add a link
Reference in a new issue