Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/platforms/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@
"AI",
"Chat"
],
"activationEvents": [],
"activationEvents": [
"onView:opencode.chatView"
],
Comment on lines +37 to +39

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thankyou~~~~~~

"main": "./dist/extension.js",
"contributes": {
"viewsContainers": {
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -102,4 +103,51 @@ describe("TextPartView", () => {
});
});
});

// code-block copy button posts only the <pre><code> text, independent of the
// message-level Copy Markdown action added in copy-chat-markdown.
context("コードブロックのコピーボタンをクリックした場合", () => {
// The global Marked mock returns `<p>${text}</p>`, 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 <pre><code> text only, not the full Markdown source
it("コード本文のみを postMessage に送信すること", () => {
const spy = stubCodeBlockHtml(
'<div class="code-block-wrapper"><div class="code-block-header"><button class="code-block-copy" type="button" aria-label="Copy code">Copy</button></div><pre><code class="hljs language-ts">const x = 1;</code></pre></div>',
);
const part = createTextPart("```ts\nconst x = 1;\n```");
const { container } = render(<TextPartView part={part} />);
const copyBtn = container.querySelector<HTMLElement>(".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(
'<div class="code-block-wrapper"><div class="code-block-header"><button class="code-block-copy" type="button">Copy</button></div><pre><code class="hljs language-py">print("hi")</code></pre></div>',
);
const part = createTextPart('Here is code:\n\n```py\nprint("hi")\n```\n\nDone.');
const { container } = render(<TextPartView part={part} />);
const copyBtn = container.querySelector<HTMLElement>(".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();
});
});
});

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -12,13 +19,50 @@ import { isTaskToolPart, type SubtaskPart, SubtaskPartView } from "../SubtaskPar
import { ToolPartView } from "../ToolPartView";
import styles from "./MessageItem.module.css";

// コピー用アイコン(二重ページのクリップボード)と、コピー完了時のチェックマーク。
// TextPartView のコードブロックコピーボタンと同じ視覚言語に合わせた。
const COPY_ICON = `<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M4 4l1-1h5.414L14 6.586V14l-1 1H5l-1-1V4zm9 3l-3-3H5v10h8V7z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M3 1L2 2v10l1 1V2h6.414l-1-1H3z"/></svg>`;
const CHECK_ICON = `<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M14.431 3.323l-8.47 10-.79-.036-3.35-4.77.818-.574 2.978 4.24 8.051-9.506.763.646z"/></svg>`;

type Props = {
message: MessageWithParts;
activeSessionId: string;
questions: Map<string, QuestionRequest>;
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();
Expand All @@ -29,6 +73,16 @@ export function MessageItem({ message, activeSessionId, questions, onEditAndRese
const [editing, setEditing] = useState(false);
const [editText, setEditText] = useState("");
const editRef = useRef<HTMLTextAreaElement>(null);
// コピー直後にチェックマークへ切り替えるための短時間フラグ。
// 連続クリックでも前のタイマーが残らないように ref で管理する。
const [copied, setCopied] = useState(false);
const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
return () => {
if (copiedTimeoutRef.current) clearTimeout(copiedTimeoutRef.current);
};
}, []);

// このメッセージに紐づく質問リクエストを取得する
// QuestionRequest.tool.messageID でメッセージと紐付ける
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -183,6 +241,25 @@ export function MessageItem({ message, activeSessionId, questions, onEditAndRese
{messageQuestions.map((q) => (
<QuestionView key={q.id} question={q} />
))}
{copyableMarkdown && (
<button
type="button"
className={`${styles.copyMarkdownButton}${copied ? ` ${styles.copied}` : ""}`}
aria-label={t["message.copyMarkdown"]}
title={t["message.copyMarkdown"]}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
postMessage({ type: "copyToClipboard", text: copyableMarkdown });
setCopied(true);
if (copiedTimeoutRef.current) clearTimeout(copiedTimeoutRef.current);
copiedTimeoutRef.current = setTimeout(() => setCopied(false), 1500);
}}
>
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: 固定 SVG 文字列を差し込むだけ */}
<span dangerouslySetInnerHTML={{ __html: copied ? CHECK_ICON : COPY_ICON }} />
</button>
)}
</div>
)}
</div>
Expand Down
1 change: 1 addition & 0 deletions packages/platforms/vscode/webview/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/platforms/vscode/webview/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/platforms/vscode/webview/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const ja: LocaleSchema = {
"message.thought": "思考",
"message.thinking": "思考中…",
"message.toggleThought": "思考の詳細を切り替え",
"message.copyMarkdown": "マークダウンをコピー",

// MessagesArea
"checkpoint.revertTitle": "ここまで巻き戻す",
Expand Down
1 change: 1 addition & 0 deletions packages/platforms/vscode/webview/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const ko: LocaleSchema = {
"message.thought": "사고",
"message.thinking": "사고 중…",
"message.toggleThought": "사고 상세 전환",
"message.copyMarkdown": "마크다운 복사",

// MessagesArea
"checkpoint.revertTitle": "이 지점으로 되돌리기",
Expand Down
1 change: 1 addition & 0 deletions packages/platforms/vscode/webview/locales/pt-br.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/platforms/vscode/webview/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const ru: LocaleSchema = {
"message.thought": "Мысль",
"message.thinking": "Думаю…",
"message.toggleThought": "Переключить детали мысли",
"message.copyMarkdown": "Копировать Markdown",

// MessagesArea
"checkpoint.revertTitle": "Вернуться к этой точке",
Expand Down
1 change: 1 addition & 0 deletions packages/platforms/vscode/webview/locales/zh-cn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const zhCn: LocaleSchema = {
"message.thought": "思考",
"message.thinking": "思考中…",
"message.toggleThought": "切换思考详情",
"message.copyMarkdown": "复制 Markdown",

// MessagesArea
"checkpoint.revertTitle": "回退到此处",
Expand Down
1 change: 1 addition & 0 deletions packages/platforms/vscode/webview/locales/zh-tw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const zhTw: LocaleSchema = {
"message.thought": "思考",
"message.thinking": "思考中…",
"message.toggleThought": "切換思考詳情",
"message.copyMarkdown": "複製 Markdown",

// MessagesArea
"checkpoint.revertTitle": "回退到此處",
Expand Down
Loading