From e371598dbe0fc8dae6c43499a43d72404fa74903 Mon Sep 17 00:00:00 2001 From: Malcolm Riddoch Date: Mon, 8 Jun 2026 16:35:25 +1200 Subject: [PATCH] feat(vscode): add per-message Copy Markdown action on assistant replies Add a small clipboard action under every assistant message that posts the raw Markdown source (including code fences and raw KaTeX) via the existing copyToClipboard message, so users can paste the answer elsewhere without losing formatting or math. - Export getAssistantMarkdownSource(parts) and getCopyableAssistantMarkdownSource(parts, { isUser, isShell }) from MessageItem.tsx. The former joins visible TextPart.text values with "\n\n" and returns null when there is nothing copyable; the latter short-circuits to null for user messages and shell-result assistant messages so only normal assistant replies qualify. - Render a
const x = 1;
', + ); + const part = createTextPart("```ts\nconst x = 1;\n```"); + const { container } = render(); + const copyBtn = container.querySelector(".code-block-copy"); + expect(copyBtn).toBeInTheDocument(); + fireEvent.click(copyBtn!); + expect(vi.mocked(postMessage)).toHaveBeenCalledWith({ + type: "copyToClipboard", + text: "const x = 1;", + }); + spy.mockRestore(); + }); + + // does not include the code fence or surrounding prose + it("コードフェンスや前後の文章を含まないこと", () => { + const spy = stubCodeBlockHtml( + '
print("hi")
', + ); + const part = createTextPart('Here is code:\n\n```py\nprint("hi")\n```\n\nDone.'); + const { container } = render(); + const copyBtn = container.querySelector(".code-block-copy"); + expect(copyBtn).toBeInTheDocument(); + fireEvent.click(copyBtn!); + const call = vi.mocked(postMessage).mock.calls[0]?.[0]; + expect(call).toEqual({ type: "copyToClipboard", text: 'print("hi")' }); + // フェンスや前後に投稿された場合の文字列が混入していないこと + expect(call?.text).not.toContain("```"); + expect(call?.text).not.toContain("Here is code"); + expect(call?.text).not.toContain("Done."); + spy.mockRestore(); + }); + }); }); diff --git a/packages/platforms/vscode/webview/__tests__/components/organisms/MessageItem.test.tsx b/packages/platforms/vscode/webview/__tests__/components/organisms/MessageItem.test.tsx index bb73428..8b9d0fe 100644 --- a/packages/platforms/vscode/webview/__tests__/components/organisms/MessageItem.test.tsx +++ b/packages/platforms/vscode/webview/__tests__/components/organisms/MessageItem.test.tsx @@ -1,10 +1,15 @@ import { fireEvent, render, screen } from "@testing-library/react"; -import type { ReactNode } from "react"; +import { act, type ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; import type { MessageWithParts } from "../../../App"; -import { MessageItem } from "../../../components/organisms/MessageItem"; +import { + getAssistantMarkdownSource, + getCopyableAssistantMarkdownSource, + MessageItem, +} from "../../../components/organisms/MessageItem/MessageItem"; import { AppContextProvider, type AppContextValue } from "../../../contexts/AppContext"; -import { createMessage, createTextPart, createToolPart } from "../../factories"; +import { postMessage } from "../../../vscode-api"; +import { createMessage, createSubtaskPart, createTextPart, createToolPart } from "../../factories"; /** AppContext 必須の値を最小限で提供するラッパー */ function createContextWrapper(overrides: Partial = {}) { @@ -24,6 +29,9 @@ describe("MessageItem", () => { questions: new Map(), onEditAndResend: vi.fn(), }; + // デフォルトロケール(en)の "message.copyMarkdown" 値。 + // aria-label / title / 可視テキストの期待値として全 Copy Markdown 関連テストで使う。 + const COPY_MARKDOWN_LABEL = "Copy Markdown"; // when rendered with a user message context("ユーザーメッセージの場合", () => { @@ -126,4 +134,455 @@ describe("MessageItem", () => { expect(container.querySelector("[class*='userBubble']")).not.toBeInTheDocument(); }); }); + + // Copy Markdown action rendering + describe("Copy Markdown アクション", () => { + const copyButtonQuery = () => screen.queryByRole("button", { name: COPY_MARKDOWN_LABEL }); + + // 通常アシスタントで text パートがある時のみ表示される + it("通常の assistant メッセージで表示されること", () => { + const assistantMsg: MessageWithParts = { + info: createMessage({ role: "assistant" }), + parts: [createTextPart("Response")], + }; + render(, { wrapper }); + expect(copyButtonQuery()).toBeInTheDocument(); + }); + + // ユーザーメッセージには表示されない + it("user メッセージでは表示されないこと", () => { + const userMsg: MessageWithParts = { + info: createMessage({ role: "user" }), + parts: [createTextPart("Hello")], + }; + render(, { wrapper }); + expect(copyButtonQuery()).not.toBeInTheDocument(); + }); + + // シェル結果の assistant には表示されない + it("shell の assistant メッセージでは表示されないこと", () => { + const shellWrapper = createContextWrapper({ isShellMessage: (id: string) => id === "shell-msg" }); + const shellMsg: MessageWithParts = { + info: createMessage({ id: "shell-msg", role: "assistant" }), + parts: [ + createTextPart("The following tool was executed by the user"), + createToolPart("bash", { + state: { status: "completed", title: "ls", input: { command: "ls" }, output: "file.txt" }, + } as any), + ], + }; + render(, { wrapper: shellWrapper }); + expect(copyButtonQuery()).not.toBeInTheDocument(); + }); + + // シェルユーザーメッセージにも表示されない + it("shell の user メッセージでは表示されないこと", () => { + const shellWrapper = createContextWrapper({ isShellMessage: (id: string) => id === "shell-user" }); + const shellUserMsg: MessageWithParts = { + info: createMessage({ id: "shell-user", role: "user" }), + parts: [createTextPart("!ls -la")], + }; + render(, { wrapper: shellWrapper }); + expect(copyButtonQuery()).not.toBeInTheDocument(); + }); + + // text パートが無く非テキストのみの場合は表示されない + it("非テキストパートのみの assistant では表示されないこと", () => { + const toolOnlyMsg: MessageWithParts = { + info: createMessage({ role: "assistant" }), + // SubtaskPartView は childSessions を要求するため、ここでは ToolPart のみを使い + // UI レンダリングを伴わずに「コピー対象テキスト無し」を検証する。 + parts: [createToolPart("file_read")], + }; + render(, { wrapper }); + expect(copyButtonQuery()).not.toBeInTheDocument(); + }); + + // クリックしてもユーザー編集モードに入らず、例外も投げない + it("クリックしても編集モードに入らず例外を投げないこと", () => { + const assistantMsg: MessageWithParts = { + info: createMessage({ role: "assistant" }), + parts: [createTextPart("Response")], + }; + const { container } = render(, { wrapper }); + const button = copyButtonQuery(); + expect(button).toBeInTheDocument(); + expect(() => fireEvent.click(button!)).not.toThrow(); + // ユーザーメッセージ編集 textarea は生成されない + expect(container.querySelector(".editTextarea")).not.toBeInTheDocument(); + }); + + // aria-label / title がロケール文字列に一致し、ボタン本体はアイコン SVG を表示すること + it("aria-label / title がロケール文字列(Copy Markdown)になり、本体はアイコン SVG であること", () => { + const assistantMsg: MessageWithParts = { + info: createMessage({ role: "assistant" }), + parts: [createTextPart("Response")], + }; + const { container } = render(, { wrapper }); + const button = copyButtonQuery(); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute("aria-label", COPY_MARKDOWN_LABEL); + expect(button).toHaveAttribute("title", COPY_MARKDOWN_LABEL); + // 可視テキストは出さず、アイコン SVG を表示する + expect(button?.textContent?.trim()).toBe(""); + const svg = container.querySelector(".copyMarkdownButton svg"); + expect(svg).toBeInTheDocument(); + expect(svg?.getAttribute("viewBox")).toBe("0 0 16 16"); + }); + + // クリック直後はチェックマークアイコンに切り替えられ、1500ms 後にコピーマークへ戻ること + it("クリック後にチェックマークへ切り替わり 1500ms 後にコピーマークへ戻ること", () => { + vi.useFakeTimers(); + try { + const assistantMsg: MessageWithParts = { + info: createMessage({ role: "assistant" }), + parts: [createTextPart("Response")], + }; + const { container } = render(, { wrapper }); + const button = screen.getByRole("button", { name: COPY_MARKDOWN_LABEL }); + fireEvent.click(button); + // クリック直後は .copied クラスが付き、path d 属性がチェックマークになる + const btnAfter = container.querySelector(".copyMarkdownButton"); + expect(btnAfter?.className).toContain("copied"); + const svgAfter = btnAfter?.querySelector("svg path"); + // チェックマークの path は d="M14.431..." で始まる(クリップボードの d="M4 4l1-1..." と区別) + expect(svgAfter?.getAttribute("d")?.startsWith("M14.431")).toBe(true); + // postMessage は発火している + expect(postMessage).toHaveBeenCalledWith({ type: "copyToClipboard", text: "Response" }); + // 1500ms 経過で .copied クラスが外れ、コピーマークへ戻る + act(() => { + vi.advanceTimersByTime(1500); + }); + const btnLater = container.querySelector(".copyMarkdownButton"); + expect(btnLater?.className).not.toContain("copied"); + const svgLater = btnLater?.querySelector("svg path"); + expect(svgLater?.getAttribute("d")?.startsWith("M4 4l1-1")).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + }); + + // Copy Markdown クリックで postMessage されること + describe("Copy Markdown クリックで postMessage されること", () => { + // KaTeX を含む生ソースがそのまま postMessage されること + it("KaTeX を含む assistant メッセージのクリックで生ソースが postMessage されること", () => { + const source = "Inline $E=mc^2$\n\nDisplay:\n\n$$\\frac{\\partial}{\\partial t}$$"; + const assistantMsg: MessageWithParts = { + info: createMessage({ role: "assistant" }), + parts: [createTextPart(source)], + }; + render(, { wrapper }); + const button = screen.getByRole("button", { name: COPY_MARKDOWN_LABEL }); + fireEvent.click(button); + expect(postMessage).toHaveBeenCalledWith({ type: "copyToClipboard", text: source }); + }); + + // 複数テキストパートは \n\n 連結で postMessage されること + it("複数のテキストパートは \\n\\n 連結で postMessage されること", () => { + const assistantMsg: MessageWithParts = { + info: createMessage({ role: "assistant" }), + parts: [createTextPart("First paragraph."), createTextPart("Second paragraph with `code`.")], + }; + render(, { wrapper }); + const button = screen.getByRole("button", { name: COPY_MARKDOWN_LABEL }); + fireEvent.click(button); + expect(postMessage).toHaveBeenCalledWith({ + type: "copyToClipboard", + text: "First paragraph.\n\nSecond paragraph with `code`.", + }); + }); + + // postMessage される文字列が生ソース(KaTeX 記法を含む)であること + it("postMessage される文字列は KaTeX 記法を含む生ソースであること", () => { + const source = "Inline $E=mc^2$\n\n$$\\frac{\\partial}{\\partial t}$$"; + const assistantMsg: MessageWithParts = { + info: createMessage({ role: "assistant" }), + parts: [createTextPart(source)], + }; + render(, { wrapper }); + const button = screen.getByRole("button", { name: COPY_MARKDOWN_LABEL }); + fireEvent.click(button); + const call = vi.mocked(postMessage).mock.calls[0]?.[0]; + // 生ソースと完全一致する(getCopyableAssistantMarkdownSource の戻り値がそのまま送られる) + expect(call).toEqual({ type: "copyToClipboard", text: source }); + // KaTeX ソースが保持されている + expect(call?.text).toContain("$$\\frac{\\partial}{\\partial t}$$"); + }); + + // ユーザーメッセージではボタンが描画されず postMessage も発火しないこと + it("user メッセージでは postMessage が呼ばれないこと", () => { + const userMsg: MessageWithParts = { + info: createMessage({ role: "user" }), + parts: [createTextPart("Hello")], + }; + render(, { wrapper }); + const button = screen.queryByRole("button", { name: COPY_MARKDOWN_LABEL }); + expect(button).not.toBeInTheDocument(); + expect(postMessage).not.toHaveBeenCalled(); + }); + + // シェル結果の assistant メッセージでは postMessage も発火しないこと + it("shell の assistant メッセージでは postMessage が呼ばれないこと", () => { + const shellWrapper = createContextWrapper({ isShellMessage: (id: string) => id === "shell-msg" }); + const shellMsg: MessageWithParts = { + info: createMessage({ id: "shell-msg", role: "assistant" }), + parts: [ + createTextPart("The following tool was executed by the user"), + createToolPart("bash", { + state: { status: "completed", title: "ls", input: { command: "ls" }, output: "file.txt" }, + } as any), + ], + }; + render(, { wrapper: shellWrapper }); + const button = screen.queryByRole("button", { name: COPY_MARKDOWN_LABEL }); + expect(button).not.toBeInTheDocument(); + expect(postMessage).not.toHaveBeenCalled(); + }); + + // 非テキストのみ / テキスト無しの assistant では postMessage も発火しないこと + it("非テキストパートのみの assistant では postMessage が呼ばれないこと", () => { + const toolOnlyMsg: MessageWithParts = { + info: createMessage({ role: "assistant" }), + parts: [createToolPart("file_read")], + }; + render(, { wrapper }); + const button = screen.queryByRole("button", { name: COPY_MARKDOWN_LABEL }); + expect(button).not.toBeInTheDocument(); + expect(postMessage).not.toHaveBeenCalled(); + }); + }); + + // Code-block copy と message-level Copy Markdown の独立性を担保する回帰テスト + // (copy-chat-markdown Step 3.1)。 + describe("コードブロックコピーと Markdown コピーの独立", () => { + // メッセージレベル: コードフェンスを含む生ソースがそのまま postMessage されること + it("メッセージレベルの Copy Markdown はコードフェンスを含む生ソースを postMessage すること", () => { + const source = "Here is code:\n\n```ts\nconst x = 1;\n```\n\nDone."; + const assistantMsg: MessageWithParts = { + info: createMessage({ role: "assistant" }), + parts: [createTextPart(source)], + }; + render(, { wrapper }); + const button = screen.getByRole("button", { name: COPY_MARKDOWN_LABEL }); + fireEvent.click(button); + // コードフェンスを含む完全な Markdown ソースが送られる + expect(postMessage).toHaveBeenCalledWith({ type: "copyToClipboard", text: source }); + // フェンスや前後に投稿された場合の文字列が含まれている + const call = vi.mocked(postMessage).mock.calls[0]?.[0]; + expect(call?.text).toContain("```ts"); + expect(call?.text).toContain("const x = 1;"); + expect(call?.text).toContain("Done."); + }); + + // コードブロックレベル: 同じメッセージにコードブロックが含まれていても、 + // メッセージレベルはコード本文だけにはせず、必ずソース全体を postMessage する + it("メッセージレベルボタンはコード本文のみに切り詰めた形で postMessage しないこと", () => { + const source = "Here is code:\n\n```ts\nconst x = 1;\n```"; + const assistantMsg: MessageWithParts = { + info: createMessage({ role: "assistant" }), + parts: [createTextPart(source)], + }; + render(, { wrapper }); + const button = screen.getByRole("button", { name: COPY_MARKDOWN_LABEL }); + fireEvent.click(button); + const call = vi.mocked(postMessage).mock.calls[0]?.[0]; + // メッセージレベルは Markdown 全体を送り、コード本文のみの切り詰めは行わない + expect(call?.text).not.toBe("const x = 1;"); + expect(call?.text).toBe(source); + }); + }); + + // ネイティブ選択コピー (Ctrl+C) と Markdown コピーの独立 (copy-chat-markdown Step 3.2)。 + // MessageItem は document/window の copy イベントに一切干渉しないことが期待される。 + describe("ネイティブ選択コピーと Markdown コピーの独立", () => { + // MessageItem は document の copy イベントをリッスンしていないこと + it("document に copy イベントリスナーが追加されていないこと", () => { + const addSpy = vi.spyOn(document, "addEventListener"); + const assistantMsg: MessageWithParts = { + info: createMessage({ role: "assistant" }), + parts: [createTextPart("Response")], + }; + render(, { wrapper }); + const copyRegistrations = addSpy.mock.calls.filter(([type]) => type === "copy"); + expect(copyRegistrations).toHaveLength(0); + addSpy.mockRestore(); + }); + + // MessageItem は window の copy イベントもリッスンしていないこと + it("window に copy イベントリスナーが追加されていないこと", () => { + const addSpy = vi.spyOn(window, "addEventListener"); + const assistantMsg: MessageWithParts = { + info: createMessage({ role: "assistant" }), + parts: [createTextPart("Response")], + }; + render(, { wrapper }); + const copyRegistrations = addSpy.mock.calls.filter(([type]) => type === "copy"); + expect(copyRegistrations).toHaveLength(0); + addSpy.mockRestore(); + }); + + // ユーザーがコンテンツ領域でテキスト選択 → Ctrl+C した場合、 + // ネイティブの copy イベントが preventDefault されずブラウザ既定のコピー処理が走る + it("content 要素で copy イベントが preventDefault されないこと", () => { + const assistantMsg: MessageWithParts = { + info: createMessage({ role: "assistant" }), + parts: [createTextPart("Response")], + }; + const { container } = render(, { wrapper }); + const contentEl = container.querySelector(".content"); + expect(contentEl).toBeInTheDocument(); + // ユーザーがテキストを選択した状態を模倣(jsdom では getSelection は null を返すので + // clipboardData のみ設定した copy イベントを dispatch する) + const event = new Event("copy", { bubbles: true, cancelable: true }); + // clipboardData は ClipboardEvent でのみ読めるが、jsdom 環境では + // 「preventDefault されていない」ことを defaultPrevented で検証できれば十分。 + Object.defineProperty(event, "clipboardData", { + value: { setData: vi.fn(), getData: vi.fn(() => "Response") }, + writable: false, + }); + contentEl!.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + // ネイティブ copy 経由では postMessage は発火しない(ボタン onClick ではない) + expect(postMessage).not.toHaveBeenCalled(); + }); + + // ボタンのクリックハンドラは自分の click イベントにのみ preventDefault/stopPropagation + // をかけており、document の copy イベントには干渉しない + it("Copy Markdown ボタンの click は document の copy イベントに伝播しないこと", () => { + const assistantMsg: MessageWithParts = { + info: createMessage({ role: "assistant" }), + parts: [createTextPart("Response")], + }; + const { container } = render(, { wrapper }); + const button = screen.getByRole("button", { name: COPY_MARKDOWN_LABEL }); + // ボタンの click 後でも、別途 dispatch した copy イベントは preventDefault されない + fireEvent.click(button); + const event = new Event("copy", { bubbles: true, cancelable: true }); + container.querySelector(".content")!.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + }); + + // ボタン自身のテキストもネイティブ選択可能(user-select が無効化されていない) + it("Copy Markdown ボタンのテキストがネイティブ選択可能なままであること", () => { + const assistantMsg: MessageWithParts = { + info: createMessage({ role: "assistant" }), + parts: [createTextPart("Response")], + }; + const { container } = render(, { wrapper }); + const button = container.querySelector(".copyMarkdownButton"); + expect(button).toBeInTheDocument(); + // user-select が none になっていないことを確認(CSS Modules の非スコープ化により + // クラス名はそのまま解決される) + const styles = window.getComputedStyle(button!); + expect(styles.userSelect).not.toBe("none"); + }); + }); + + // getAssistantMarkdownSource helper + describe("getAssistantMarkdownSource", () => { + // 単一のテキストパートは生の Markdown / KaTeX ソースをそのまま返すこと + it("単一のテキストパートを KaTeX を含めてそのまま返すこと", () => { + const source = "Inline math: $E=mc^2$\n\nDisplay:\n\n$$\\frac{\\partial}{\\partial t}$$"; + const result = getAssistantMarkdownSource([createTextPart(source)]); + expect(result).toBe(source); + }); + + // 複数のテキストパートは表示順を維持し空行で連結すること + it("複数のテキストパートを \\n\\n で連結して返すこと", () => { + const result = getAssistantMarkdownSource([ + createTextPart("First paragraph."), + createTextPart("Second paragraph with `code`."), + ]); + expect(result).toBe("First paragraph.\n\nSecond paragraph with `code`."); + }); + + // テキスト以外のパートは除外されること + it("非テキストパート(tool / subtask / reasoning)を除外すること", () => { + const result = getAssistantMarkdownSource([ + createTextPart("Visible text."), + createToolPart("file_read"), + createSubtaskPart("general", "desc"), + { + id: "part-reasoning", + sessionID: "session-1", + messageID: "msg-1", + type: "reasoning", + text: "internal thought that must not be copied", + }, + ]); + expect(result).toBe("Visible text."); + }); + + // 空文字列のパートはコピー対象から除外されること + it("空テキストパートを無視すること", () => { + const result = getAssistantMarkdownSource([ + createTextPart(""), + createTextPart("Only this is copyable."), + createTextPart(""), + ]); + expect(result).toBe("Only this is copyable."); + }); + + // 全て空 or テキスト以外なら null を返すこと + it("空テキストのみ、もしくは非テキストのみの場合 null を返すこと", () => { + expect(getAssistantMarkdownSource([createTextPart("")])).toBeNull(); + expect(getAssistantMarkdownSource([createToolPart("bash"), createSubtaskPart("general", "desc")])).toBeNull(); + expect(getAssistantMarkdownSource([])).toBeNull(); + }); + }); + + // getCopyableAssistantMarkdownSource context-aware helper + describe("getCopyableAssistantMarkdownSource", () => { + // 通常の assistant メッセージは Step 1.1 と同じ生 Markdown を返すこと + it("通常の assistant メッセージで KaTeX を含む生ソースを返すこと", () => { + const source = "Inline $E=mc^2$\n\n$$\\frac{\\partial}{\\partial t}$$"; + const result = getCopyableAssistantMarkdownSource([createTextPart(source)], { + isUser: false, + isShell: false, + }); + expect(result).toBe(source); + }); + + // ユーザーメッセージはコピー対象外 + it("user メッセージは null を返すこと", () => { + const result = getCopyableAssistantMarkdownSource([createTextPart("Hello")], { + isUser: true, + isShell: false, + }); + expect(result).toBeNull(); + }); + + // シェル結果の assistant メッセージはコピー対象外 + it("shell の assistant メッセージは null を返すこと", () => { + const result = getCopyableAssistantMarkdownSource( + [ + createTextPart("The following tool was executed by the user"), + createToolPart("bash", { + state: { status: "completed", title: "ls", input: { command: "ls" }, output: "file.txt" }, + } as any), + ], + { isUser: false, isShell: true }, + ); + expect(result).toBeNull(); + }); + + // 可視テキストパートが無い assistant は null + it("非テキストパートのみの assistant は null を返すこと", () => { + const result = getCopyableAssistantMarkdownSource( + [createToolPart("file_read"), createSubtaskPart("general", "desc")], + { isUser: false, isShell: false }, + ); + expect(result).toBeNull(); + }); + + // テキスト + 非テキスト混在はテキスト部分のみを返すこと + it("テキストと非テキストが混在する場合テキスト部分のみ返すこと", () => { + const result = getCopyableAssistantMarkdownSource( + [createTextPart("Visible text."), createToolPart("file_read"), createSubtaskPart("general", "desc")], + { isUser: false, isShell: false }, + ); + expect(result).toBe("Visible text."); + }); + }); }); diff --git a/packages/platforms/vscode/webview/components/organisms/MessageItem/MessageItem.module.css b/packages/platforms/vscode/webview/components/organisms/MessageItem/MessageItem.module.css index f1ef953..4749904 100644 --- a/packages/platforms/vscode/webview/components/organisms/MessageItem/MessageItem.module.css +++ b/packages/platforms/vscode/webview/components/organisms/MessageItem/MessageItem.module.css @@ -96,6 +96,38 @@ border-radius: 4px; } +/* --- Copy Markdown action (per-message) --- */ + +.copyMarkdownButton { + display: inline-flex; + align-items: center; + justify-content: center; + margin-top: 6px; + padding: 2px 6px; + border: none; + border-radius: 3px; + background: transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; + transition: + color 0.15s, + background-color 0.15s; +} + +.copyMarkdownButton:hover { + color: var(--vscode-foreground); + background-color: var(--vscode-toolbar-hoverBackground); +} + +.copyMarkdownButton:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 2px; +} + +.copyMarkdownButton.copied { + color: var(--vscode-charts-green, #89d185); +} + /* --- Markdown in messages --- */ .content :global(.markdown) code { diff --git a/packages/platforms/vscode/webview/components/organisms/MessageItem/MessageItem.tsx b/packages/platforms/vscode/webview/components/organisms/MessageItem/MessageItem.tsx index f13f629..7611597 100644 --- a/packages/platforms/vscode/webview/components/organisms/MessageItem/MessageItem.tsx +++ b/packages/platforms/vscode/webview/components/organisms/MessageItem/MessageItem.tsx @@ -1,8 +1,15 @@ -import type { QuestionRequest, ReasoningPart as ReasoningPartType, TextPart, ToolPart } from "@opencodegui/core"; +import type { + MessagePart, + QuestionRequest, + ReasoningPart as ReasoningPartType, + TextPart, + ToolPart, +} from "@opencodegui/core"; import { useCallback, useEffect, useRef, useState } from "react"; import type { MessageWithParts } from "../../../App"; import { useAppContext } from "../../../contexts/AppContext"; import { useLocale } from "../../../locales"; +import { postMessage } from "../../../vscode-api"; import { ActionButton } from "../../atoms/ActionButton"; import { ChevronRightIcon, EditIcon, InfoCircleIcon, SpinnerIcon } from "../../atoms/icons"; import { ShellResultView } from "../../molecules/ShellResultView"; @@ -12,6 +19,11 @@ import { isTaskToolPart, type SubtaskPart, SubtaskPartView } from "../SubtaskPar import { ToolPartView } from "../ToolPartView"; import styles from "./MessageItem.module.css"; +// コピー用アイコン(二重ページのクリップボード)と、コピー完了時のチェックマーク。 +// TextPartView のコードブロックコピーボタンと同じ視覚言語に合わせた。 +const COPY_ICON = ``; +const CHECK_ICON = ``; + type Props = { message: MessageWithParts; activeSessionId: string; @@ -19,6 +31,38 @@ type Props = { onEditAndResend?: (messageId: string, text: string) => void; }; +/** + * アシスタントメッセージの可視テキストパートから、コピー用の Markdown ソースを組み立てる。 + * + * - `type === "text"` のパートのみを対象とする(tool/reasoning/subtask/file 等は除外)。 + * - 複数パートは Markdown ブロック境界を保つため `\n\n` で連結する。 + * - 空文字列のパートは可視コンテンツではないので除外する。 + * - コピー対象が存在しない場合は `null` を返す(no-copy sentinel)。 + */ +export function getAssistantMarkdownSource(parts: MessagePart[]): string | null { + const texts = parts + .filter((part): part is TextPart => part.type === "text") + .map((part) => part.text) + .filter((text) => text.length > 0); + + return texts.length > 0 ? texts.join("\n\n") : null; +} + +/** + * メッセージのコンテキスト(role / shell 判定)に応じてコピー対象を絞り込む。 + * + * - ユーザーメッセージ・シェル結果のアシスタントメッセージはコピー対象外。 + * - それ以外(通常の assistant)で可視テキストパートがあれば Step 1.1 の Markdown ソースを返す。 + * - 可視テキストパートが無ければ `null`(no-copy sentinel)。 + */ +export function getCopyableAssistantMarkdownSource( + parts: MessagePart[], + options: { isUser: boolean; isShell: boolean }, +): string | null { + if (options.isUser || options.isShell) return null; + return getAssistantMarkdownSource(parts); +} + export function MessageItem({ message, activeSessionId, questions, onEditAndResend }: Props) { const t = useLocale(); const { isShellMessage, childSessions, onNavigateToChild } = useAppContext(); @@ -29,6 +73,16 @@ export function MessageItem({ message, activeSessionId, questions, onEditAndRese const [editing, setEditing] = useState(false); const [editText, setEditText] = useState(""); const editRef = useRef(null); + // コピー直後にチェックマークへ切り替えるための短時間フラグ。 + // 連続クリックでも前のタイマーが残らないように ref で管理する。 + const [copied, setCopied] = useState(false); + const copiedTimeoutRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (copiedTimeoutRef.current) clearTimeout(copiedTimeoutRef.current); + }; + }, []); // このメッセージに紐づく質問リクエストを取得する // QuestionRequest.tool.messageID でメッセージと紐付ける @@ -56,6 +110,10 @@ export function MessageItem({ message, activeSessionId, questions, onEditAndRese }) : []; + // コピー対象の Markdown ソースを組み立てる。 + // user メッセージ / shell 結果 / 可視テキスト無しの場合は null。 + const copyableMarkdown = getCopyableAssistantMarkdownSource(parts, { isUser, isShell }); + const handleStartEdit = useCallback(() => { if (!isUser || !userText) return; setEditText(userText); @@ -183,6 +241,25 @@ export function MessageItem({ message, activeSessionId, questions, onEditAndRese {messageQuestions.map((q) => ( ))} + {copyableMarkdown && ( + + )} )} diff --git a/packages/platforms/vscode/webview/locales/en.ts b/packages/platforms/vscode/webview/locales/en.ts index d9499c0..109c8b5 100644 --- a/packages/platforms/vscode/webview/locales/en.ts +++ b/packages/platforms/vscode/webview/locales/en.ts @@ -31,6 +31,7 @@ export const en = { "message.thought": "Thought", "message.thinking": "Thinking…", "message.toggleThought": "Toggle thought details", + "message.copyMarkdown": "Copy Markdown", // MessagesArea "checkpoint.revertTitle": "Revert to this point", diff --git a/packages/platforms/vscode/webview/locales/es.ts b/packages/platforms/vscode/webview/locales/es.ts index def2883..6b06935 100644 --- a/packages/platforms/vscode/webview/locales/es.ts +++ b/packages/platforms/vscode/webview/locales/es.ts @@ -33,6 +33,7 @@ export const es: LocaleSchema = { "message.thought": "Pensamiento", "message.thinking": "Pensando…", "message.toggleThought": "Alternar detalles de pensamiento", + "message.copyMarkdown": "Copiar Markdown", // MessagesArea "checkpoint.revertTitle": "Revertir a este punto", diff --git a/packages/platforms/vscode/webview/locales/ja.ts b/packages/platforms/vscode/webview/locales/ja.ts index 51399e3..751c6be 100644 --- a/packages/platforms/vscode/webview/locales/ja.ts +++ b/packages/platforms/vscode/webview/locales/ja.ts @@ -33,6 +33,7 @@ export const ja: LocaleSchema = { "message.thought": "思考", "message.thinking": "思考中…", "message.toggleThought": "思考の詳細を切り替え", + "message.copyMarkdown": "マークダウンをコピー", // MessagesArea "checkpoint.revertTitle": "ここまで巻き戻す", diff --git a/packages/platforms/vscode/webview/locales/ko.ts b/packages/platforms/vscode/webview/locales/ko.ts index c112eca..1a01612 100644 --- a/packages/platforms/vscode/webview/locales/ko.ts +++ b/packages/platforms/vscode/webview/locales/ko.ts @@ -33,6 +33,7 @@ export const ko: LocaleSchema = { "message.thought": "사고", "message.thinking": "사고 중…", "message.toggleThought": "사고 상세 전환", + "message.copyMarkdown": "마크다운 복사", // MessagesArea "checkpoint.revertTitle": "이 지점으로 되돌리기", diff --git a/packages/platforms/vscode/webview/locales/pt-br.ts b/packages/platforms/vscode/webview/locales/pt-br.ts index 34d02c9..5daa751 100644 --- a/packages/platforms/vscode/webview/locales/pt-br.ts +++ b/packages/platforms/vscode/webview/locales/pt-br.ts @@ -33,6 +33,7 @@ export const ptBr: LocaleSchema = { "message.thought": "Pensamento", "message.thinking": "Pensando…", "message.toggleThought": "Alternar detalhes do pensamento", + "message.copyMarkdown": "Copiar Markdown", // MessagesArea "checkpoint.revertTitle": "Reverter para este ponto", diff --git a/packages/platforms/vscode/webview/locales/ru.ts b/packages/platforms/vscode/webview/locales/ru.ts index c2a5f87..7053949 100644 --- a/packages/platforms/vscode/webview/locales/ru.ts +++ b/packages/platforms/vscode/webview/locales/ru.ts @@ -33,6 +33,7 @@ export const ru: LocaleSchema = { "message.thought": "Мысль", "message.thinking": "Думаю…", "message.toggleThought": "Переключить детали мысли", + "message.copyMarkdown": "Копировать Markdown", // MessagesArea "checkpoint.revertTitle": "Вернуться к этой точке", diff --git a/packages/platforms/vscode/webview/locales/zh-cn.ts b/packages/platforms/vscode/webview/locales/zh-cn.ts index 1201245..ece7aa5 100644 --- a/packages/platforms/vscode/webview/locales/zh-cn.ts +++ b/packages/platforms/vscode/webview/locales/zh-cn.ts @@ -33,6 +33,7 @@ export const zhCn: LocaleSchema = { "message.thought": "思考", "message.thinking": "思考中…", "message.toggleThought": "切换思考详情", + "message.copyMarkdown": "复制 Markdown", // MessagesArea "checkpoint.revertTitle": "回退到此处", diff --git a/packages/platforms/vscode/webview/locales/zh-tw.ts b/packages/platforms/vscode/webview/locales/zh-tw.ts index 218e095..4dbbef5 100644 --- a/packages/platforms/vscode/webview/locales/zh-tw.ts +++ b/packages/platforms/vscode/webview/locales/zh-tw.ts @@ -33,6 +33,7 @@ export const zhTw: LocaleSchema = { "message.thought": "思考", "message.thinking": "思考中…", "message.toggleThought": "切換思考詳情", + "message.copyMarkdown": "複製 Markdown", // MessagesArea "checkpoint.revertTitle": "回退到此處",