[codex] Add copy button for rendered code blocks (#619)

Signed-off-by: Mat Balez <60949391+matbalez@users.noreply.github.com>
This commit is contained in:
Mat Balez
2026-05-20 14:25:03 +00:00
committed by GitHub
parent 2a69f285e7
commit 2da3402f10
5 changed files with 266 additions and 18 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ const overrides = new Map([
["src/features/channels/ui/ChannelScreen.tsx", 550], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification
["src/features/notifications/hooks.ts", 535], // notification settings + feed notification lifecycle + profile batch resolution + truncated-pubkey guard + badge state
["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates
["src/features/messages/ui/MessageComposer.tsx", 710], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572)
["src/features/messages/ui/MessageComposer.tsx", 760], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) + Sprout code-block paste branch (round-trips copy-button output as a literal codeBlock so Markdown can't reshape it) + scroll-to-bottom on multi-line paste (#619)
["src/features/settings/ui/SettingsView.tsx", 600],
["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav
["src/shared/api/relayClientSession.ts", 1040], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ConnectionState plumbing & stall-watchdog wiring for half-open WS detection (Warp orange-icon case) + terminal session latch (auth rejection no longer racing back to reconnecting) — emitter + watchdog + reconnect policy logic extracted to relayConnectionStateEmitter.ts / relayStallWatchdog.ts / relayReconnectPolicy.ts
@@ -24,6 +24,7 @@ import {
useRichTextEditor,
} from "@/features/messages/lib/useRichTextEditor";
import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast";
import { getSproutCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { ChannelAutocomplete } from "./ChannelAutocomplete";
@@ -137,6 +138,15 @@ export function MessageComposer({
emojiAutocomplete.isEmojiAutocompleteOpen;
const submitMessageRef = React.useRef<() => void>(() => {});
const composerScrollRef = React.useRef<HTMLDivElement>(null);
const scrollComposerToBottom = React.useCallback(() => {
window.requestAnimationFrame(() => {
const scrollElement = composerScrollRef.current;
if (!scrollElement) return;
scrollElement.scrollTop = scrollElement.scrollHeight;
});
}, []);
const computedPlaceholder = editTarget
? "Edit your message"
@@ -505,6 +515,33 @@ export function MessageComposer({
return true;
}
// --- Sprout code-block paste ---
// The code block copy button writes a small Sprout marker alongside
// plain text. Use it to paste back as a literal code block so Markdown
// parsing cannot reshape indentation, fence markers, or headings.
const codeBlockText = getSproutCodeBlockClipboardText(
event.clipboardData,
);
if (codeBlockText !== null) {
event.preventDefault();
richText.editor
?.chain()
.focus()
.insertContent([
{
type: "codeBlock",
content:
codeBlockText.length > 0
? [{ type: "text", text: codeBlockText }]
: [],
},
{ type: "paragraph" },
])
.run();
scrollComposerToBottom();
return true;
}
// --- Mention / channel-link normalization ---
// When copying from the chat area the browser puts styled HTML
// on the clipboard. TipTap's DOMParser doesn't understand our
@@ -531,11 +568,16 @@ export function MessageComposer({
return true;
}
const plainText = event.clipboardData?.getData("text/plain") ?? "";
if (plainText.includes("\n")) {
scrollComposerToBottom();
}
return false;
},
},
});
}, [richText.editor]);
}, [richText.editor, scrollComposerToBottom]);
// ── Send button state ───────────────────────────────────────────────
const sendDisabled = React.useMemo(
@@ -680,6 +722,8 @@ export function MessageComposer({
{/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler bridges Tiptap editor to autocomplete and submit */}
<div
className="rich-text-composer max-h-32 overflow-y-auto"
data-testid="message-input-scroll"
ref={composerScrollRef}
onKeyDown={handleEditorKeyDown}
>
<EditorContent editor={richText.editor} />
@@ -0,0 +1,57 @@
const SPROUT_CODE_BLOCK_ATTRIBUTE = "data-sprout-code-block";
function escapeHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function createSproutCodeBlockHtml(code: string) {
// Keep the code as one text node; the paste reader recovers it via textContent.
return `<pre ${SPROUT_CODE_BLOCK_ATTRIBUTE}="true"><code>${escapeHtml(code)}</code></pre>`;
}
export async function copyCodeBlockToClipboard(code: string) {
const clipboard = navigator.clipboard;
if (!clipboard) {
throw new Error("Clipboard API is unavailable");
}
if (
typeof ClipboardItem !== "undefined" &&
typeof clipboard.write === "function"
) {
try {
await clipboard.write([
new ClipboardItem({
"text/html": new Blob([createSproutCodeBlockHtml(code)], {
type: "text/html",
}),
"text/plain": new Blob([code], { type: "text/plain" }),
}),
]);
return;
} catch (error) {
console.warn("Failed to write rich code block clipboard data", error);
}
}
await clipboard.writeText(code);
}
export function getSproutCodeBlockClipboardText(
clipboardData: DataTransfer | null | undefined,
) {
const html = clipboardData?.getData("text/html");
if (!html?.includes(SPROUT_CODE_BLOCK_ATTRIBUTE)) {
return null;
}
const document = new DOMParser().parseFromString(html, "text/html");
const code = document.querySelector(`[${SPROUT_CODE_BLOCK_ATTRIBUTE}] code`);
const fallback = document.querySelector(`[${SPROUT_CODE_BLOCK_ATTRIBUTE}]`);
return code?.textContent ?? fallback?.textContent ?? null;
}
+78 -16
View File
@@ -1,9 +1,9 @@
import * as React from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { Copy } from "lucide-react";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import { toast } from "sonner";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
@@ -11,12 +11,15 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { invokeTauri } from "@/shared/api/tauri";
import type { Channel } from "@/shared/api/types";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { copyCodeBlockToClipboard } from "@/shared/lib/codeBlockClipboard";
import { cn } from "@/shared/lib/cn";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import rehypeImageGallery from "@/shared/lib/rehypeImageGallery";
import rehypeSearchHighlight from "@/shared/lib/rehypeSearchHighlight";
import remarkChannelLinks from "@/shared/lib/remarkChannelLinks";
import remarkMentions from "@/shared/lib/remarkMentions";
import { Button } from "@/shared/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import {
classifyChildren,
@@ -103,6 +106,75 @@ function ImageContextMenu({
);
}
function getReactNodeText(node: React.ReactNode): string {
if (typeof node === "string" || typeof node === "number") {
return String(node);
}
if (Array.isArray(node)) {
return node.map(getReactNodeText).join("");
}
if (React.isValidElement<{ children?: React.ReactNode }>(node)) {
return getReactNodeText(node.props.children);
}
return "";
}
function getCodeBlockText(children: React.ReactNode) {
return getReactNodeText(children).replace(/\n$/, "");
}
function MarkdownCodeBlock({ children }: { children?: React.ReactNode }) {
const [isCopying, setIsCopying] = React.useState(false);
const code = React.useMemo(() => getCodeBlockText(children), [children]);
const handleCopy = React.useCallback(
async (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setIsCopying(true);
try {
await copyCodeBlockToClipboard(code);
toast.success("Copied code to clipboard");
} catch (error) {
console.error("Failed to copy code block", error);
toast.error("Failed to copy code");
} finally {
setIsCopying(false);
}
},
[code],
);
return (
<div className="group relative">
<pre className="overflow-x-auto rounded-xl border border-border/70 bg-muted/60 px-3 py-1.5 pr-12 shadow-sm">
{children}
</pre>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Copy code block"
className="absolute right-2 top-2 h-7 w-7 bg-background/80 text-muted-foreground opacity-0 shadow-sm ring-1 ring-border/60 backdrop-blur transition-opacity hover:bg-background hover:text-foreground hover:opacity-100 focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 disabled:opacity-60"
disabled={isCopying}
onClick={handleCopy}
size="icon"
type="button"
variant="ghost"
>
<Copy className="h-3.5 w-3.5" />
<span className="sr-only">Copy code block</span>
</Button>
</TooltipTrigger>
<TooltipContent>Copy code</TooltipContent>
</Tooltip>
</div>
);
}
function createMarkdownComponents(
variant: MarkdownVariant,
channels: Channel[],
@@ -141,18 +213,12 @@ function createMarkdownComponents(
</blockquote>
),
br: () => <br />,
code: ({
children,
className,
...props
}: React.ComponentProps<"code"> & { inline?: boolean }) => {
code: ({ children, className, ...props }: React.ComponentProps<"code">) => {
const code = String(children).replace(/\n$/, "");
const isBlock =
typeof className === "string" && className.includes("language-")
? true
: code.includes("\n");
const isFencedCodeBlock =
typeof className === "string" && className.includes("language-");
if (isBlock) {
if (isFencedCodeBlock || code.includes("\n")) {
return (
<code
{...props}
@@ -297,11 +363,7 @@ function createMarkdownComponents(
return <p className={paragraphClassName}>{children}</p>;
},
pre: ({ children }) => (
<pre className="overflow-x-auto rounded-xl border border-border/70 bg-muted/60 px-3 py-1.5 shadow-sm">
{children}
</pre>
),
pre: ({ children }) => <MarkdownCodeBlock>{children}</MarkdownCodeBlock>,
strong: ({ children }) => (
<strong className="font-semibold">{children}</strong>
),
+85
View File
@@ -49,6 +49,91 @@ test("send multiple messages in sequence", async ({ page }) => {
}
});
test("copy a rendered code block and paste it back as code", async ({
page,
}) => {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
origin: "http://127.0.0.1:4173",
});
const code = "# not a heading\nconst answer = 42;\n indented();";
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await page.evaluate(
(text) => navigator.clipboard.writeText(text),
`\`\`\`ts\n${code}\n\`\`\``,
);
await input.click();
await page.keyboard.press("ControlOrMeta+V");
await page.getByTestId("send-message").click();
const copiedCodeBlock = page.locator("pre", { hasText: code });
await expect(copiedCodeBlock).toHaveCount(1);
const copyButton = page.getByLabel("Copy code block");
await expect(copyButton).toHaveCSS("opacity", "0");
await copiedCodeBlock.hover();
await expect(copyButton).toHaveCSS("opacity", "1");
await copyButton.click();
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(code);
await input.click();
await page.keyboard.press("ControlOrMeta+V");
await input.press("Enter");
await expect(copiedCodeBlock).toHaveCount(2);
});
test("pasting a long copied code block scrolls composer to cursor", async ({
page,
}) => {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"], {
origin: "http://127.0.0.1:4173",
});
const longCode = Array.from(
{ length: 48 },
(_, index) => `const line${index} = ${index};`,
).join("\n");
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await page.evaluate(
(text) => navigator.clipboard.writeText(text),
`\`\`\`ts\n${longCode}\n\`\`\``,
);
await input.click();
await page.keyboard.press("ControlOrMeta+V");
await page.getByTestId("send-message").click();
const copiedCodeBlock = page.locator("pre", { hasText: longCode });
await expect(copiedCodeBlock).toHaveCount(1);
await copiedCodeBlock.hover();
await page.getByLabel("Copy code block").click();
await input.fill("typed before paste");
await page.keyboard.press("ControlOrMeta+V");
const scrollContainer = page.getByTestId("message-input-scroll");
await expect
.poll(() =>
scrollContainer.evaluate(
(element) =>
element.scrollHeight - element.clientHeight - element.scrollTop,
),
)
.toBeLessThanOrEqual(1);
});
test("message input clears after send", async ({ page }) => {
const message = `Clear after send ${Date.now()}`;
const input = page.getByTestId("message-input");