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