diff --git a/packages/workshop-frontend/src/ChatInterface.markdown.test.ts b/packages/workshop-frontend/src/ChatInterface.markdown.test.ts index 4cf6bcc6c1..b26ce0ab10 100644 --- a/packages/workshop-frontend/src/ChatInterface.markdown.test.ts +++ b/packages/workshop-frontend/src/ChatInterface.markdown.test.ts @@ -2,7 +2,7 @@ import { act, createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { MarkdownMessage } from "./ChatInterface"; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -14,6 +14,7 @@ describe("MarkdownMessage line breaks", () => { afterEach(async () => { if (root) await act(async () => root.unmount()); container?.remove(); + vi.unstubAllGlobals(); }); async function render(message: string) { @@ -43,4 +44,19 @@ describe("MarkdownMessage line breaks", () => { expect(paragraphs[0].textContent).toBe("para one"); expect(paragraphs[1].textContent).toBe("para two"); }); + + it("copies fenced code without the Markdown trailing newline", async () => { + const writeText = vi.fn<(text: string) => Promise>(async () => undefined); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + await render("```ts\nconst answer = 42;\n```"); + + const button = container.querySelector( + 'button[aria-label="Copy code"]', + ); + expect(button?.title).toBe("Copy code"); + + await act(async () => button?.click()); + + expect(writeText).toHaveBeenCalledWith("const answer = 42;"); + }); }); diff --git a/packages/workshop-frontend/src/ChatInterface.module.css b/packages/workshop-frontend/src/ChatInterface.module.css index d6c87c1141..e63ac90463 100644 --- a/packages/workshop-frontend/src/ChatInterface.module.css +++ b/packages/workshop-frontend/src/ChatInterface.module.css @@ -28,13 +28,18 @@ font-size: 0.85em; } +.codeBlock { + position: relative; + margin: 0.5em 0; +} + .markdownContent pre { background-color: var(--color-kumo-elevated); border: 1px solid var(--color-kumo-line); border-radius: 8px; - padding: 10px 12px; + padding: 10px 44px 10px 12px; overflow-x: auto; - margin: 0.5em 0; + margin: 0; } .markdownContent pre code { background: none; @@ -43,6 +48,43 @@ font-size: 0.8em; } +.codeCopyButton { + position: absolute; + top: 8px; + right: 8px; + z-index: 1; + display: flex; + padding: 4px; + border: 1px solid var(--color-kumo-line); + border-radius: 5px; + background-color: var(--color-kumo-base); + box-shadow: 0 1px 2px color-mix(in srgb, var(--text-color-kumo-default) 20%, transparent); + color: var(--text-color-kumo-subtle); + cursor: pointer; + opacity: 0.6; + transition: background-color 150ms ease-out, border-color 150ms ease-out, + box-shadow 150ms ease-out, color 150ms ease-out, opacity 150ms ease-out, + transform 100ms ease-out; +} + +.codeCopyButton:hover, +.codeCopyButton:focus-visible { + background-color: var(--color-kumo-tint); + border-color: var(--color-kumo-fill); + color: var(--text-color-kumo-default); + opacity: 1; +} + +.codeCopyButton:active { + box-shadow: none; + transform: translateY(1px); +} + +.codeCopyButton:focus-visible { + outline: 2px solid var(--color-kumo-brand); + outline-offset: 1px; +} + .markdownContent ul, .markdownContent ol { margin: 0.4em 0; diff --git a/packages/workshop-frontend/src/ChatInterface.tsx b/packages/workshop-frontend/src/ChatInterface.tsx index 2d7ba89617..c5ae6faffe 100644 --- a/packages/workshop-frontend/src/ChatInterface.tsx +++ b/packages/workshop-frontend/src/ChatInterface.tsx @@ -1,6 +1,7 @@ import { isTransientRpcError, logRpcFailure } from "./rpcErrors"; import { Fragment, + isValidElement, memo, useState, useEffect, @@ -8,6 +9,7 @@ import { useRef, useMemo, useCallback, + type ComponentPropsWithoutRef, type Dispatch, type ReactNode, type SetStateAction, @@ -40,6 +42,7 @@ import { ArrowsClockwise, Lightning, Copy, + Clipboard as ClipboardIcon, WarningCircle, Code, File as FileIcon, @@ -1237,10 +1240,33 @@ function FormatMention({ format }: { format: MessageFormatRef }) { ); } +function CodeBlock({ children, ...props }: ComponentPropsWithoutRef<"pre">) { + const code = isValidElement<{ children?: ReactNode }>(children) && + typeof children.props.children === "string" + ? children.props.children.replace(/\n$/, "") + : ""; + + return ( +
+
{children}
+ +
+ ); +} + function getMarkdownComponents( mentionsByToken?: Map, ): Components { return { + pre: ({ node: _node, ...props }) => , table: ({ node: _node, children, ...props }) => (
{children}