diff --git a/packages/platforms/vscode/package.json b/packages/platforms/vscode/package.json index 858eedc..5e4c283 100644 --- a/packages/platforms/vscode/package.json +++ b/packages/platforms/vscode/package.json @@ -34,7 +34,9 @@ "AI", "Chat" ], - "activationEvents": [], + "activationEvents": [ + "onView:opencode.chatView" + ], "main": "./dist/extension.js", "contributes": { "viewsContainers": { diff --git a/packages/platforms/vscode/webview/__tests__/components/molecules/TextPartView.test.tsx b/packages/platforms/vscode/webview/__tests__/components/molecules/TextPartView.test.tsx index 00f8bba..f6e0dd8 100644 --- a/packages/platforms/vscode/webview/__tests__/components/molecules/TextPartView.test.tsx +++ b/packages/platforms/vscode/webview/__tests__/components/molecules/TextPartView.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render } from "@testing-library/react"; +import { Marked } from "marked"; import { describe, expect, it, vi } from "vitest"; import { TextPartView } from "../../../components/molecules/TextPartView"; import { postMessage } from "../../../vscode-api"; @@ -102,4 +103,51 @@ describe("TextPartView", () => { }); }); }); + + // code-block copy button posts only the
 text, independent of the
+  // message-level Copy Markdown action added in copy-chat-markdown.
+  context("コードブロックのコピーボタンをクリックした場合", () => {
+    // The global Marked mock returns `

${text}

`, which never emits a + // `.code-block-wrapper`. For these tests we override the prototype to + // produce the structure that TextPartView's custom renderer would emit. + function stubCodeBlockHtml(html: string) { + return vi.spyOn(Marked.prototype, "parse").mockReturnValueOnce(html); + } + + // posts the raw
 text only, not the full Markdown source
+    it("コード本文のみを postMessage に送信すること", () => {
+      const spy = stubCodeBlockHtml(
+        '
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": "回退到此處",