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).
+
) : 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({
))}
);
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() {