Files
PaperClipAI/ui/src/components/AgentBubbleActionRow.tsx
T
scotttongandClaude Opus 4.8 ea83c5c822 feat(task-chat): bring back copy/👍/👎 actions on the agent bubble footer (#11025)
## 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>
2026-08-06 18:26:44 -07:00

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