From ea83c5c822f904b396320c84662200a0c2dff603 Mon Sep 17 00:00:00 2001 From: scotttong Date: Thu, 6 Aug 2026 18:26:44 -0700 Subject: [PATCH] =?UTF-8?q?feat(task-chat):=20bring=20back=20copy/?= =?UTF-8?q?=F0=9F=91=8D/=F0=9F=91=8E=20actions=20on=20the=20agent=20bubble?= =?UTF-8?q?=20footer=20(#11025)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 · " 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 --- ui/src/components/AgentBubbleActionRow.tsx | 49 +++++++---- ui/src/components/TaskChatThread.tsx | 45 ++++++++++ .../task-chat/TaskChatBubble.test.tsx | 59 +++++++++++++ .../components/task-chat/TaskChatBubble.tsx | 25 +++++- .../task-chat/TaskChatBubbleActions.tsx | 50 +++++++++++ .../task-chat/TaskChatThreadView.tsx | 29 ++++++- .../task-chat/TaskChatTurn.test.tsx | 22 +++++ ui/src/components/task-chat/TaskChatTurn.tsx | 85 ++++++++++++------- ui/src/pages/TaskChatLab.tsx | 28 +++++- 9 files changed, 334 insertions(+), 58 deletions(-) create mode 100644 ui/src/components/task-chat/TaskChatBubbleActions.tsx diff --git a/ui/src/components/AgentBubbleActionRow.tsx b/ui/src/components/AgentBubbleActionRow.tsx index 0f41b2f7af..f761d942ae 100644 --- a/ui/src/components/AgentBubbleActionRow.tsx +++ b/ui/src/components/AgentBubbleActionRow.tsx @@ -40,6 +40,36 @@ export function agentBubbleDateLabel(date: Date | string | undefined): string { 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 ( + + ); +} + /** * Shared agent-bubble action row — copy · 👍 · 👎 · timestamp · ⋯ menu. * @@ -79,26 +109,9 @@ export function AgentBubbleActionRow({ menuItems?: ReactNode; className?: string; }) { - const [copied, setCopied] = useState(false); - return (
- + {feedback ? ( { @@ -388,6 +395,43 @@ export function TaskChatThread(props: TaskChatThreadProps) { ); }, [orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, lastCommentIdByRun, hasBrief]); + // Feedback votes keyed by the comment they target (targetType + // "issue_comment"), mirroring IssueChatThread — the redesign attaches the + // 👍/👎 state to each agent bubble by its comment id (PAP-413). + const feedbackVoteByTargetId = useMemo(() => { + const map = new Map(); + for (const feedbackVote of feedbackVotes ?? []) { + if (feedbackVote.targetType !== "issue_comment") continue; + map.set(feedbackVote.targetId, feedbackVote.vote); + } + return map; + }, [feedbackVotes]); + + // copy · 👍 · 👎 cluster for an agent bubble's footer line (PAP-413). Human + // and system bubbles get nothing; copy is always available, and the feedback + // buttons render only when the host wired a vote handler. + const renderMessageActions = useCallback( + (item: TaskChatMessageItem) => { + if (item.author !== "agent" || item.optimistic) return null; + return ( + onVote(item.id, vote, options), + } + : null + } + /> + ); + }, + [onVote, feedbackVoteByTargetId, feedbackDataSharingPreference, feedbackTermsUrl], + ); + const renderInteraction = useCallback( (item: TaskChatInteractionItem) => ( : undefined} + renderMessageActions={renderMessageActions} scroll={!isMobile} /> )} diff --git a/ui/src/components/task-chat/TaskChatBubble.test.tsx b/ui/src/components/task-chat/TaskChatBubble.test.tsx index 9b3c041200..875ee543cb 100644 --- a/ui/src/components/task-chat/TaskChatBubble.test.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.test.tsx @@ -209,3 +209,62 @@ describe("TaskChatBubble footer line (round 9)", () => { expect(plain).toHaveLength(0); }); }); + +describe("TaskChatBubble footer actions (PAP-413)", () => { + let container: HTMLDivElement; + let root: Root | null = null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + flushSync(() => root?.unmount()); + root = null; + container.remove(); + }); + + function render(item: TaskChatMessageItem, opts?: { attachedTurn?: ReactNode; actions?: ReactNode }) { + flushSync(() => + root!.render( + + + , + ), + ); + } + + const actions =
actions
; + + it("hands actions to the attached turn (not this wrapper) so they ride the summary row", () => { + render( + { id: "m1", kind: "message", author: "agent", authorName: "CEO", text: "Done.", timestamp: "2:34 PM" }, + { attachedTurn:
2:34 PM · Worked
, actions }, + ); + const slot = container.querySelector('[data-testid="task-chat-bubble-attached-turn"]'); + expect(slot).not.toBeNull(); + // The turn owns the footer line; the bubble no longer places actions beside + // it (they ride the turn's `leading` slot, anchored to the summary line). + expect(slot?.querySelector('[data-testid="fake-attached-turn"]')).not.toBeNull(); + expect(slot?.querySelector('[data-testid="fake-actions"]')).toBeNull(); + }); + + it("leads a runless agent reply with actions, timestamp trailing", () => { + render( + { id: "m1", kind: "message", author: "agent", authorName: "CEO", text: "Done.", timestamp: "2:34 PM" }, + { actions }, + ); + expect(container.querySelector('[data-testid="fake-actions"]')).not.toBeNull(); + const stamp = [...container.querySelectorAll("span")].find((el) => el.textContent === "2:34 PM"); + expect(stamp).not.toBeNull(); + }); + + it("omits the actions row entirely when none are supplied (human bubble)", () => { + render({ id: "m1", kind: "message", author: "human", text: "Hi", timestamp: "2:34 PM" }); + expect(container.querySelector('[data-testid="fake-actions"]')).toBeNull(); + const stamp = [...container.querySelectorAll("span")].find((el) => el.textContent === "2:34 PM"); + expect(stamp).not.toBeNull(); + }); +}); diff --git a/ui/src/components/task-chat/TaskChatBubble.tsx b/ui/src/components/task-chat/TaskChatBubble.tsx index af8f71e3bb..528fbc985c 100644 --- a/ui/src/components/task-chat/TaskChatBubble.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.tsx @@ -25,6 +25,13 @@ interface TaskChatBubbleProps { * Supplied by TaskChatThreadView when `item.attachedTurn` is set. */ attachedTurn?: ReactNode; + /** + * copy · 👍 · 👎 controls for an agent bubble's footer line (PAP-413). + * Rendered here only for a runless reply (leading the bare timestamp); when + * an attached turn is present it owns these via its `leading` slot instead, + * so this bubble skips them. Human/system bubbles pass nothing. + */ + actions?: ReactNode; } function initialsForName(name: string) { @@ -41,7 +48,7 @@ function initialsForName(name: string) { * bubble with an avatar author header (the agent's assigned icon + name · mode * chip); system notices are centered and recede. */ -export function TaskChatBubble({ item, attachedTurn }: TaskChatBubbleProps) { +export function TaskChatBubble({ item, attachedTurn, actions }: TaskChatBubbleProps) { if (item.interstitial) { // Interstitial updates are ephemeral (PAP-361): while streaming the text // lives on the live parent row's line (TaskChatStatusItem.selfTalk), and @@ -146,10 +153,24 @@ export function TaskChatBubble({ item, attachedTurn }: TaskChatBubbleProps) { ) : attachedTurn ? ( // The settled turn takes over the footer line: timestamp + "✓ Worked" - // summary, always visible; expanding stretches beneath the bubble. + // summary, always visible; expanding stretches beneath the bubble. The + // copy/👍/👎 actions (PAP-413) ride the turn's summary row via its + // `leading` slot — not this wrapper — so they stay anchored to the + // summary line when the tool history expands beneath it.
{attachedTurn}
+ ) : actions ? ( + // Agent reply without run activity: the actions still lead the footer, + // with the always-visible timestamp trailing (PAP-413). +
+ {actions} + {item.timestamp ? ( + + {item.timestamp} + + ) : null} +
) : item.timestamp ? ( // Timestamps are always visible (round 9) — no longer hover-revealed. diff --git a/ui/src/components/task-chat/TaskChatBubbleActions.tsx b/ui/src/components/task-chat/TaskChatBubbleActions.tsx new file mode 100644 index 0000000000..33f6c9f179 --- /dev/null +++ b/ui/src/components/task-chat/TaskChatBubbleActions.tsx @@ -0,0 +1,50 @@ +import type { + FeedbackDataSharingPreference, + FeedbackVoteValue, +} from "@paperclipai/shared"; +import { + BubbleCopyButton, + IssueChatFeedbackButtons, +} from "@/components/AgentBubbleActionRow"; + +/** Feedback-vote wiring for an agent bubble, resolved per comment by the host. */ +export interface TaskChatBubbleFeedback { + activeVote: FeedbackVoteValue | null; + sharingPreference: FeedbackDataSharingPreference; + termsUrl: string | null; + onVote: ( + vote: FeedbackVoteValue, + options?: { allowSharing?: boolean; reason?: string }, + ) => Promise; +} + +/** + * Compact copy · 👍 · 👎 cluster prepended to an agent bubble's footer line + * (PAP-413), leading the "✓ Worked · …" turn summary (or the bare timestamp + * when the reply had no run activity). It reuses the shared + * {@link BubbleCopyButton} and {@link IssueChatFeedbackButtons} so the + * redesigned task thread speaks the same footer language as the conference + * room's {@link AgentBubbleActionRow} without re-declaring their markup; the + * timestamp stays owned by the summary/bubble, so it is not duplicated here. + */ +export function TaskChatBubbleActions({ + copyText, + feedback, +}: { + copyText: string; + feedback?: TaskChatBubbleFeedback | null; +}) { + return ( +
+ + {feedback ? ( + + ) : null} +
+ ); +} diff --git a/ui/src/components/task-chat/TaskChatThreadView.tsx b/ui/src/components/task-chat/TaskChatThreadView.tsx index 9916837b3c..fa316c9699 100644 --- a/ui/src/components/task-chat/TaskChatThreadView.tsx +++ b/ui/src/components/task-chat/TaskChatThreadView.tsx @@ -1,6 +1,10 @@ import type { ReactNode } from "react"; import { cn } from "@/lib/utils"; -import type { TaskChatInteractionItem, TaskChatItem } from "./task-chat-model"; +import type { + TaskChatInteractionItem, + TaskChatItem, + TaskChatMessageItem, +} from "./task-chat-model"; import { TaskChatTurn } from "./TaskChatTurn"; import { TaskChatBubble } from "./TaskChatBubble"; import { TaskChatMarker } from "./TaskChatMarker"; @@ -30,6 +34,12 @@ interface TaskChatThreadViewProps { * nothing without it. */ renderBrief?: () => ReactNode; + /** + * Renders the copy/👍/👎 action cluster prepended to an agent bubble's footer + * line (PAP-413). The live thread binds it to the feedback-vote API; harness + * fixtures omit it and the bubbles render actionless. + */ + renderMessageActions?: (item: TaskChatMessageItem) => ReactNode; className?: string; /** When false, render the list without the scroll container (e.g. previews). */ scroll?: boolean; @@ -40,23 +50,33 @@ function renderItem( onApprovalDecision?: (statusItemId: string, optionId: string) => void, renderInteraction?: (item: TaskChatInteractionItem) => ReactNode, renderBrief?: () => ReactNode, + renderMessageActions?: (item: TaskChatMessageItem) => ReactNode, ) { switch (item.kind) { - case "message": + case "message": { + // Compute the actions once: the bubble renders them for a runless reply + // (footer = actions + timestamp), while an attached turn hands them to + // TaskChatTurn's `leading` slot so they ride the summary line and stay + // put when the tool history expands (PAP-413). The two paths are mutually + // exclusive at runtime, so only one host ever mounts the node. + const actions = renderMessageActions?.(item); return ( renderItem(child, onApprovalDecision)} /> ) : undefined } /> ); + } case "marker": return ; case "thinking": @@ -107,6 +127,7 @@ export function TaskChatThreadView({ onApprovalDecision, renderInteraction, renderBrief, + renderMessageActions, className, scroll = true, }: TaskChatThreadViewProps) { @@ -118,7 +139,9 @@ export function TaskChatThreadView({
) : null} {items.map((item) => ( -
{renderItem(item, onApprovalDecision, renderInteraction, renderBrief)}
+
+ {renderItem(item, onApprovalDecision, renderInteraction, renderBrief, renderMessageActions)} +
))} ); diff --git a/ui/src/components/task-chat/TaskChatTurn.test.tsx b/ui/src/components/task-chat/TaskChatTurn.test.tsx index eec4c9e083..1ec01cf23b 100644 --- a/ui/src/components/task-chat/TaskChatTurn.test.tsx +++ b/ui/src/components/task-chat/TaskChatTurn.test.tsx @@ -226,6 +226,28 @@ describe("TaskChatTurn", () => { expect(fold()?.getAttribute("data-folded")).toBe("false"); }); + it("rides the `leading` slot on the header row, anchored above the fold (PAP-413)", () => { + flushSync(() => { + root.render( + actions} + renderChild={(c) => {c.id}} + />, + ); + }); + const lead = container.querySelector('[data-testid="turn-lead"]'); + expect(lead).not.toBeNull(); + // It sits beside the summary button on the header row — NOT inside the + // expandable fold — so expanding the tool history can't drag it down. + expect(fold()?.contains(lead!)).toBe(false); + expect(summaryBtn()).not.toBeNull(); + // Expanding still works and the leading slot stays put outside the fold. + flushSync(() => summaryBtn()!.click()); + expect(fold()?.getAttribute("data-folded")).toBe("false"); + expect(fold()?.contains(lead!)).toBe(false); + }); + it("leads the settled summary with the bubble timestamp when attached (round 9)", () => { flushSync(() => { root.render( diff --git a/ui/src/components/task-chat/TaskChatTurn.tsx b/ui/src/components/task-chat/TaskChatTurn.tsx index 314396e583..d66426a743 100644 --- a/ui/src/components/task-chat/TaskChatTurn.tsx +++ b/ui/src/components/task-chat/TaskChatTurn.tsx @@ -13,6 +13,13 @@ interface TaskChatTurnProps { * always visible, in the slot the hover-only timestamp used to occupy. */ timestampPrefix?: string; + /** + * Content rendered on the header row, leading the summary line (PAP-413: the + * copy/👍/👎 action cluster). It sits beside the summary button — NOT inside + * the expandable fold — so it stays anchored to the summary line when the + * tool history expands beneath it, instead of drifting to the fold's center. + */ + leading?: ReactNode; } /** Metric segments after the label: "38s · 3 tools · +34 −3 · 12.3k tokens". */ @@ -50,7 +57,7 @@ export function turnSummaryText(summary: TaskChatTurnItem["summary"]): string { * `liveStatus` (harness fixtures) renders its children expanded with no header * and folds when it settles. */ -export function TaskChatTurn({ item, renderChild, timestampPrefix }: TaskChatTurnProps) { +export function TaskChatTurn({ item, renderChild, timestampPrefix, leading }: TaskChatTurnProps) { const parentRow = !item.settled && item.liveStatus != null; // Parent-row live turns and settled turns start as their one-line header; // only the headerless legacy live turn starts expanded. @@ -73,40 +80,52 @@ export function TaskChatTurn({ item, renderChild, timestampPrefix }: TaskChatTur const folded = (item.settled || parentRow) && !open; const SummaryIcon = item.summary.failed ? X : Check; + const header = item.settled ? ( + + ) : parentRow ? ( + // The pill renders the expand button itself, wrapped around only the + // gerund status line — the interstitial row above stays outside the + // hover/click target (PAP-376). Without activity there is no + // chevron/button. + setOpen((o) => !o) : undefined} + /> + ) : null; + return (
- {item.settled ? ( - - ) : parentRow ? ( - // The pill renders the expand button itself, wrapped around only the - // gerund status line — the interstitial row above stays outside the - // hover/click target (PAP-376). Without activity there is no - // chevron/button. - setOpen((o) => !o) : undefined} - /> - ) : null} + {leading ? ( + // Actions ride the header row (items-center matches them to the summary + // line), and the fold below is a separate sibling — so expanding the + // tool history never moves the actions off the summary line (PAP-413). +
+ {leading} + {header} +
+ ) : ( + header + )}
diff --git a/ui/src/pages/TaskChatLab.tsx b/ui/src/pages/TaskChatLab.tsx index ca79e6cb52..cc9590d0da 100644 --- a/ui/src/pages/TaskChatLab.tsx +++ b/ui/src/pages/TaskChatLab.tsx @@ -8,8 +8,32 @@ import { import { buildScenario } from "@/components/task-chat/task-chat-fixtures"; import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView"; import { TaskChatPlanView } from "@/components/task-chat/TaskChatPlanView"; +import { TaskChatBubbleActions } from "@/components/task-chat/TaskChatBubbleActions"; import { TweakPanel } from "@/components/task-chat/TweakPanel"; -import type { TaskChatItem } from "@/components/task-chat/task-chat-model"; +import type { + TaskChatItem, + TaskChatMessageItem, +} from "@/components/task-chat/task-chat-model"; + +/** + * Demo binding for the agent-bubble copy · 👍 · 👎 cluster (PAP-413). The live + * thread wires these to the feedback-vote API; here the votes no-op so the + * harness can show the footer without a control plane. + */ +function labMessageActions(item: TaskChatMessageItem) { + if (item.author !== "agent" || item.optimistic) return null; + return ( + {}, + }} + /> + ); +} /** Replay ticks of selfTalk gap between scripted interstitial updates (~0.7s at 1×). */ const SELF_TALK_GAP_TICKS = 30; @@ -210,7 +234,7 @@ export function TaskChatLab() {
) : (
- +
)}