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": "回退到此處",