From 2f9d3d43c35d31f6768d554c5fc0cab1340da12a Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 6 Nov 2026 06:35:16 -0600 Subject: [PATCH] feat(ui): copy own task messages as Markdown (#19442) 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 agents at work. > - The task thread shows human messages and agent replies. > - Users can copy agent replies with an existing control. > - Users need the same control for their own messages. > - This pull request adds that control beside the message time. > - Users can copy the original Markdown and attachment links. ## Linked Issues or Issue Description **What existing behavior does this improve?** Copy a message from the task thread. **Subsystem affected** ui/ — React board UI. **Current behavior** Your own messages have no copy control beside the time. **Proposed behavior** Move the pointer over the time to show a copy icon. Select it to copy the original Markdown. Keyboard focus also shows the icon. Touch screens show it at all times. **Reason and benefit** Users can reuse their message text without losing Markdown or attachment links. **Breaking changes** None. ## What Changed - Reuse TaskChatBubbleActions in the footer of your sent messages. - Keep the timestamp and iMessage source label. - Show the control on hover or keyboard focus. Keep it visible on touch screens and small screens. - Respect the reduced motion setting. - Test original Markdown, attachment links, copy feedback, other authors, and pending messages. - Add the required membership role to the existing routine-user test fixture so UI typecheck can pass. - Keep this behavior in TaskChatBubble. No shared contract change is needed. ## Verification - 45 focused component, artifact, and clipboard tests pass after rebase onto master. - The routine-user fixture tests pass: 9 tests. - UI typecheck, UI production build, design-token gates, and browser checks pass. - Five screenshots cover idle, hover, copied feedback, keyboard focus, and touch states. The existing Storybook own-message story uses the production component with sample content. - Browser checks verify clipboard contents and reduced motion. - GitHub PR CI passed for d46d2890c44e759c3e720f158255f1d53dce1a97: build, typecheck, selected general tests, all eight e2e shards, policy, security, and aggregate verification. Storybook visual and out-of-scope suites were skipped by CI. - Greptile and Paperclip reviews both scored the current commit 5/5 with no actionable findings. No open review threads. - Full repository typecheck and build stop because Cargo is missing in the local environment. Earlier full tests reached resource limits. No full-suite pass is claimed. - The first typecheck after rebase found stale local dependencies and a missing membershipRole in a routine-user test fixture. A frozen dependency install resolved the package errors. This PR adds the required fixture field and the later UI typecheck passes. - Browser states to verify: idle, hover, keyboard focus, copied feedback, touch, and reduced motion. ## Risks - Low risk. The change adds a footer control for sent messages owned by the viewer. - The control uses the existing clipboard fallback and error feedback. ## Model Used - OpenAI GPT-6 Codex. Exact deployed model ID and context window are not exposed in this runtime. The agent used reasoning, tools, and code execution. ## 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 - [x] My branch name describes the change and contains no internal ticket id or instance details - [x] I have run focused tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (the behavior is described here) - [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: Paperclip --- .../RoutineResponsibleUser.test.tsx | 2 +- .../task-chat/TaskChatBubble.test.tsx | 33 +++++++++++++++++++ .../components/task-chat/TaskChatBubble.tsx | 12 +++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/ui/src/components/RoutineResponsibleUser.test.tsx b/ui/src/components/RoutineResponsibleUser.test.tsx index 55e75f6825e..b5836f6ead5 100644 --- a/ui/src/components/RoutineResponsibleUser.test.tsx +++ b/ui/src/components/RoutineResponsibleUser.test.tsx @@ -15,7 +15,7 @@ const routine = { latestRevisionId: "revision-1", canChangeResponsibleUser: true, } as RoutineDetail; const users = ["Alex", "Sam"].map((name) => ({ - principalId: `user-${name.toLowerCase()}`, status: "active" as const, + principalId: `user-${name.toLowerCase()}`, status: "active" as const, membershipRole: "member" as const, user: { id: `user-${name.toLowerCase()}`, name, email: `${name.toLowerCase()}@example.test`, image: null }, })); diff --git a/ui/src/components/task-chat/TaskChatBubble.test.tsx b/ui/src/components/task-chat/TaskChatBubble.test.tsx index 99416ae0d17..efa46befe8a 100644 --- a/ui/src/components/task-chat/TaskChatBubble.test.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.test.tsx @@ -9,6 +9,7 @@ import { ThemeProvider } from "@/context/ThemeContext"; import { IssueGalleryContext } from "@/context/IssueGalleryContext"; import { MemoryRouter, useLocation } from "@/lib/router"; import { TaskChatAgentIdentity, TaskChatBubble } from "./TaskChatBubble"; +import { copyTextToClipboard } from "@/lib/clipboard"; import { paperclipHumanAvatarUrl } from "@/lib/issue-chat-human-author"; import type { TaskChatMessageItem } from "./task-chat-model"; @@ -17,6 +18,8 @@ vi.mock("@/context/CompanyContext", async (importOriginal) => ({ useCompany: () => ({ selectedCompany: { issuePrefix: "TES" } }), })); +vi.mock("@/lib/clipboard", () => ({ copyTextToClipboard: vi.fn().mockResolvedValue(undefined) })); + function CurrentPath() { return {useLocation().pathname}; } @@ -144,6 +147,36 @@ describe("TaskChatBubble attachment chips", () => { expect(container.textContent).not.toContain("Sent from iMessage"); }); + it("copies the viewer's original Markdown beside the timestamp, including attachment links", async () => { + const text = "**My message**\n\n[report.txt](/api/attachments/report/content)"; + flushSync(() => root!.render( + + + , + )); + const toolbar = container.querySelector('[data-testid="task-chat-bubble-actions"]')!; + expect(toolbar.parentElement?.parentElement?.textContent).toContain("15m ago"); + const button = toolbar.querySelector('button[aria-label="Copy message"]') as HTMLButtonElement; + await act(async () => button.click()); + expect(copyTextToClipboard).toHaveBeenLastCalledWith(text); + expect(button.querySelector(".lucide-check")).not.toBeNull(); + }); + + it.each([ + { author: "human" as const, isCurrentUser: false }, + { author: "human" as const, isCurrentUser: undefined }, + { author: "agent" as const, isCurrentUser: true }, + { author: "human" as const, isCurrentUser: true, optimistic: "pending" as const }, + { author: "human" as const, isCurrentUser: true, optimistic: "queued" as const }, + ])("omits the own-message toolbar for %j", (state) => { + flushSync(() => root!.render( + + + , + )); + expect(container.querySelector('[data-testid="task-chat-bubble-actions"]')).toBeNull(); + }); + it("opens attachment images in the shared task gallery", () => { const openGallery = vi.fn(() => true); const contentPath = "/api/attachments/shared-image/content"; diff --git a/ui/src/components/task-chat/TaskChatBubble.tsx b/ui/src/components/task-chat/TaskChatBubble.tsx index 6df836a443c..da67905b546 100644 --- a/ui/src/components/task-chat/TaskChatBubble.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.tsx @@ -42,6 +42,7 @@ import { stripStandaloneImageEmbeds, type AttachmentRef, } from "./task-chat-attachments"; +import { TaskChatBubbleActions } from "./TaskChatBubbleActions"; import { TaskChatSystemNotice } from "./TaskChatSystemNotice"; import type { TaskChatMessageItem } from "./task-chat-model"; @@ -431,6 +432,17 @@ function TaskChatBubbleContent({ > {attachedTurn} + ) : isOwnMessage ? ( +
+ + {sentFromIMessage ? "Sent from iMessage" : null} + {sentFromIMessage && timestamp ? " · " : null} + {timestamp} + +
+ +
+
) : actions ? ( streamlined ? (