feat(ui): copy own task messages as Markdown (#19442)

## 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 d46d2890c4:
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 <[email protected]>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-06 06:35:16 -06:00
1 parent 523818ea9b
commit 2f9d3d43c3
3 files changed
+46 -1

No files matched your search

@@ -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 },
}));
@@ -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 <output>{useLocation().pathname}</output>;
}
@@ -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(
<ThemeProvider>
<TaskChatBubble item={{ id: "own", kind: "message", author: "human", isCurrentUser: true, text, timestamp: "15m ago" }} />
</ThemeProvider>,
));
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(
<ThemeProvider>
<TaskChatBubble item={{ id: "other", kind: "message", text: "Message", timestamp: "15m ago", ...state }} />
</ThemeProvider>,
));
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";
@@ -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}
</div>
) : isOwnMessage ? (
<div className="group/message-toolbar flex items-center gap-1">
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
{sentFromIMessage ? "Sent from iMessage" : null}
{sentFromIMessage && timestamp ? " · " : null}
{timestamp}
</span>
<div className="transition-opacity motion-reduce:transition-none sm:opacity-0 sm:group-hover/message-toolbar:opacity-100 sm:group-focus-within/message-toolbar:opacity-100 pointer-coarse:opacity-100">
<TaskChatBubbleActions copyText={item.text} />
</div>
</div>
) : actions ? (
streamlined ? (
<div className="flex w-full items-center justify-between gap-2 px-1">