mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 20:46:28 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The task view shows a threaded conversation between the user and the agent, with agent replies rendered as bubbles that carry a "✓ Worked · N tools" summary line. > - The conference-room chat already offers per-message copy and thumbs-up / thumbs-down feedback, but the redesigned task thread dropped these controls from the agent bubble footer. > - Users lose a quick way to copy an agent reply or send feedback on it, and the redesign silently ignored the feedback-vote props it was already given. > - This pull request prepends a copy · thumbs-up · thumbs-down cluster to the bubble summary line and wires the existing feedback-vote props through. > - The benefit is a consistent feedback surface across both chat views, with no new API. ## Linked Issues or Issue Description No public GitHub issue exists for this change; the underlying issue is described inline below following the feature template (`.github/ISSUE_TEMPLATE/feature_request.yml`). #### Problem or motivation The redesigned task thread renders each agent reply with a "✓ Worked · N tools · <timestamp>" summary line, but it dropped the copy and thumbs-up / thumbs-down controls that the conference-room chat still shows. Users can no longer copy an agent reply or vote feedback from the task thread. The redesign component already received `feedbackVotes` and `onVote` props but ignored them. #### Proposed solution Prepend a copy · 👍 · 👎 cluster to the summary line, leading the always-visible timestamp, reusing the shared `IssueChatFeedbackButtons` so both chat views speak the same feedback language. Anchor the cluster to the turn's summary row (a sibling of the expandable tool-history fold) so it stays on the summary line whether the tool history is collapsed or expanded. #### Alternatives considered Placing the cluster inside the expandable fold — rejected because expanding the tool history then re-centered the actions to the middle of the tall fold. #### Roadmap alignment UI polish to the task thread; no core-roadmap overlap. ## What Changed - Add `TaskChatBubbleActions`: a copy · thumbs-up · thumbs-down cluster built on the shared `IssueChatFeedbackButtons`. - Render the cluster on the agent bubble's "✓ Worked · …" summary line, leading the timestamp; runless agent replies get the same cluster with the timestamp trailing. Human and system bubbles are unchanged. - Add a `leading` slot to `TaskChatTurn` so the actions sit on the summary row, a sibling of the tool-history fold, and stay anchored when the fold expands. - Wire the redesign to the `feedbackVotes` / `onVote` props it already received. - Add a demo binding in the `TaskChatLab` dev harness. ## Verification - `pnpm check:token-gates` — 3/3 gates CLEAN. - `pnpm typecheck` — clean across all packages. - `cd ui && pnpm vitest run src/components/task-chat/TaskChatBubble.test.tsx src/components/task-chat/TaskChatTurn.test.tsx` — 31/31 pass. - Manual: open a task thread, confirm the copy / 👍 / 👎 cluster shows on the agent bubble summary line before the timestamp, copy works, votes toggle, and the cluster stays on the summary line when the tool history is expanded. Visual change: snapshot baselines are intentionally not updated, per the `doc/design/DECISION-SHEET.md` entry "Per-change snapshot verification demoted to dormant (Jul 13 2026)". ## Risks Low risk. UI-only change scoped to the redesigned task-chat bubble footer. It reuses an existing shared feedback component and existing vote props; no API, schema, or server change. Human and system bubbles are untouched. ## Model Used Claude Opus 4.8 (Anthropic), model id `claude-opus-4-8`, extended thinking enabled, tool use. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
390 lines
13 KiB
TypeScript
390 lines
13 KiB
TypeScript
import { useEffect, useState, type ReactNode } from "react";
|
|
import type {
|
|
FeedbackDataSharingPreference,
|
|
FeedbackVoteValue,
|
|
} from "@paperclipai/shared";
|
|
import { cn, formatShortDate } from "../lib/utils";
|
|
import { timeAgo } from "../lib/timeAgo";
|
|
import { copyTextToClipboard } from "../lib/clipboard";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Check, Copy, MoreHorizontal, ThumbsDown, ThumbsUp } from "lucide-react";
|
|
|
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
|
/**
|
|
* Short relative timestamp for an agent bubble — "2h ago" within a week, then
|
|
* an absolute short date. Mirrors the task thread's `commentDateLabel` so both
|
|
* surfaces read identically.
|
|
*/
|
|
export function agentBubbleDateLabel(date: Date | string | undefined): string {
|
|
if (!date) return "";
|
|
const then = new Date(date).getTime();
|
|
if (Date.now() - then < WEEK_MS) return timeAgo(date);
|
|
return formatShortDate(date);
|
|
}
|
|
|
|
/**
|
|
* Copy-to-clipboard icon button shared by every agent-bubble footer — the
|
|
* conference-room {@link AgentBubbleActionRow} and the redesigned task thread's
|
|
* {@link TaskChatBubbleActions} (PAP-413). Single-sourced so both footers keep
|
|
* identical sizing, radius, and the "copied ✓" feedback affordance rather than
|
|
* re-declaring the same button markup on each surface.
|
|
*/
|
|
export function BubbleCopyButton({ copyText }: { copyText: string }) {
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
title="Copy message"
|
|
aria-label="Copy message"
|
|
onClick={() => {
|
|
void copyTextToClipboard(copyText)
|
|
.then(() => {
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
})
|
|
.catch(() => {});
|
|
}}
|
|
>
|
|
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Shared agent-bubble action row — copy · 👍 · 👎 · timestamp · ⋯ menu.
|
|
*
|
|
* Rendered below every agent bubble so the task thread (`IssueChatThread`) and
|
|
* the conference room (`BoardChat`) speak the same bubble language (PAP-95 /
|
|
* PAP-105). Each surface supplies its own copy text, timestamp label, optional
|
|
* feedback-vote wiring, and any extra overflow-menu items (e.g. stop-run /
|
|
* view-run on the task side).
|
|
*/
|
|
export function AgentBubbleActionRow({
|
|
copyText,
|
|
dateLabel,
|
|
dateTitle,
|
|
anchorHref,
|
|
feedback,
|
|
menuItems,
|
|
className,
|
|
}: {
|
|
copyText: string;
|
|
/** Short relative label shown inline (e.g. "2h ago"). */
|
|
dateLabel?: string;
|
|
/** Full datetime shown in the hover tooltip. */
|
|
dateTitle?: string;
|
|
/** Anchor href for the timestamp link (deep-link to the comment). */
|
|
anchorHref?: string;
|
|
/** When provided, renders the 👍/👎 feedback buttons wired to this vote API. */
|
|
feedback?: {
|
|
activeVote: FeedbackVoteValue | null;
|
|
sharingPreference: FeedbackDataSharingPreference;
|
|
termsUrl: string | null;
|
|
onVote: (
|
|
vote: FeedbackVoteValue,
|
|
options?: { allowSharing?: boolean; reason?: string },
|
|
) => Promise<void>;
|
|
} | null;
|
|
/** Extra DropdownMenuItem nodes appended after the default "Copy message". */
|
|
menuItems?: ReactNode;
|
|
className?: string;
|
|
}) {
|
|
return (
|
|
<div className={cn("mt-2 flex items-center gap-1", className)}>
|
|
<BubbleCopyButton copyText={copyText} />
|
|
{feedback ? (
|
|
<IssueChatFeedbackButtons
|
|
activeVote={feedback.activeVote}
|
|
sharingPreference={feedback.sharingPreference}
|
|
termsUrl={feedback.termsUrl}
|
|
onVote={feedback.onVote}
|
|
/>
|
|
) : null}
|
|
{dateLabel ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<a
|
|
href={anchorHref}
|
|
className="text-(length:--text-micro) text-muted-foreground hover:text-foreground hover:underline"
|
|
>
|
|
{dateLabel}
|
|
</a>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">
|
|
{dateTitle}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
) : null}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
className="text-muted-foreground hover:text-foreground"
|
|
title="More actions"
|
|
aria-label="More actions"
|
|
>
|
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
void copyTextToClipboard(copyText).catch(() => {});
|
|
}}
|
|
>
|
|
<Copy className="mr-2 h-3.5 w-3.5" />
|
|
Copy message
|
|
</DropdownMenuItem>
|
|
{menuItems}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 👍/👎 feedback buttons with downvote-reason popover and a sharing-preference
|
|
* prompt. Self-contained (no IssueChatCtx) so it is reused by both the task
|
|
* thread and the conference room via {@link AgentBubbleActionRow}.
|
|
*/
|
|
export function IssueChatFeedbackButtons({
|
|
activeVote,
|
|
sharingPreference = "prompt",
|
|
termsUrl,
|
|
onVote,
|
|
}: {
|
|
activeVote: FeedbackVoteValue | null;
|
|
sharingPreference: FeedbackDataSharingPreference;
|
|
termsUrl: string | null;
|
|
onVote: (vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }) => Promise<void>;
|
|
}) {
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [optimisticVote, setOptimisticVote] = useState<FeedbackVoteValue | null>(null);
|
|
const [reasonOpen, setReasonOpen] = useState(false);
|
|
const [downvoteReason, setDownvoteReason] = useState("");
|
|
const [pendingSharingDialog, setPendingSharingDialog] = useState<{
|
|
vote: FeedbackVoteValue;
|
|
reason?: string;
|
|
} | null>(null);
|
|
const visibleVote = optimisticVote ?? activeVote ?? null;
|
|
|
|
useEffect(() => {
|
|
if (optimisticVote && activeVote === optimisticVote) setOptimisticVote(null);
|
|
}, [activeVote, optimisticVote]);
|
|
|
|
async function doVote(
|
|
vote: FeedbackVoteValue,
|
|
options?: { allowSharing?: boolean; reason?: string },
|
|
) {
|
|
setIsSaving(true);
|
|
try {
|
|
await onVote(vote, options);
|
|
} catch {
|
|
setOptimisticVote(null);
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
function handleVote(vote: FeedbackVoteValue, reason?: string) {
|
|
setOptimisticVote(vote);
|
|
if (sharingPreference === "prompt") {
|
|
setPendingSharingDialog({ vote, ...(reason ? { reason } : {}) });
|
|
return;
|
|
}
|
|
const allowSharing = sharingPreference === "allowed";
|
|
void doVote(vote, {
|
|
...(allowSharing ? { allowSharing: true } : {}),
|
|
...(reason ? { reason } : {}),
|
|
});
|
|
}
|
|
|
|
function handleThumbsUp() {
|
|
handleVote("up");
|
|
}
|
|
|
|
function handleThumbsDown() {
|
|
setOptimisticVote("down");
|
|
setReasonOpen(true);
|
|
// Submit the initial down vote right away
|
|
handleVote("down");
|
|
}
|
|
|
|
function handleSubmitReason() {
|
|
if (!downvoteReason.trim()) return;
|
|
// Re-submit with reason attached
|
|
if (sharingPreference === "prompt") {
|
|
setPendingSharingDialog({ vote: "down", reason: downvoteReason });
|
|
} else {
|
|
const allowSharing = sharingPreference === "allowed";
|
|
void doVote("down", {
|
|
...(allowSharing ? { allowSharing: true } : {}),
|
|
reason: downvoteReason,
|
|
});
|
|
}
|
|
setReasonOpen(false);
|
|
setDownvoteReason("");
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
type="button"
|
|
disabled={isSaving}
|
|
className={cn(
|
|
"inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors",
|
|
visibleVote === "up"
|
|
? "text-green-600 dark:text-green-400"
|
|
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
|
)}
|
|
title="Helpful"
|
|
aria-label="Helpful"
|
|
onClick={handleThumbsUp}
|
|
>
|
|
<ThumbsUp className="h-3.5 w-3.5" />
|
|
</button>
|
|
<Popover open={reasonOpen} onOpenChange={setReasonOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
disabled={isSaving}
|
|
className={cn(
|
|
"inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors",
|
|
visibleVote === "down"
|
|
? "text-amber-600 dark:text-amber-400"
|
|
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
|
)}
|
|
title="Needs work"
|
|
aria-label="Needs work"
|
|
onClick={handleThumbsDown}
|
|
>
|
|
<ThumbsDown className="h-3.5 w-3.5" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent side="top" align="start" className="w-80 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 text-sm"
|
|
disabled={isSaving}
|
|
/>
|
|
<div className="mt-2 flex items-center justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={isSaving}
|
|
onClick={() => {
|
|
setReasonOpen(false);
|
|
setDownvoteReason("");
|
|
}}
|
|
>
|
|
Dismiss
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
disabled={isSaving || !downvoteReason.trim()}
|
|
onClick={handleSubmitReason}
|
|
>
|
|
{isSaving ? "Saving..." : "Save note"}
|
|
</Button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
<Dialog
|
|
open={Boolean(pendingSharingDialog)}
|
|
onOpenChange={(open) => {
|
|
if (!open && !isSaving) {
|
|
setPendingSharingDialog(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"
|
|
variant="outline"
|
|
disabled={!pendingSharingDialog || isSaving}
|
|
onClick={() => {
|
|
if (!pendingSharingDialog) return;
|
|
void doVote(
|
|
pendingSharingDialog.vote,
|
|
pendingSharingDialog.reason ? { reason: pendingSharingDialog.reason } : undefined,
|
|
).then(() => setPendingSharingDialog(null));
|
|
}}
|
|
>
|
|
{isSaving ? "Saving..." : "Don't allow"}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
disabled={!pendingSharingDialog || isSaving}
|
|
onClick={() => {
|
|
if (!pendingSharingDialog) return;
|
|
void doVote(pendingSharingDialog.vote, {
|
|
allowSharing: true,
|
|
...(pendingSharingDialog.reason ? { reason: pendingSharingDialog.reason } : {}),
|
|
}).then(() => setPendingSharingDialog(null));
|
|
}}
|
|
>
|
|
{isSaving ? "Saving..." : "Always allow"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|