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
22 changes: 22 additions & 0 deletions docs/selection-copy-context-menu/change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Intent

Allow users to copy selected text from Chat conversation content and Task detail content through a localized right-click menu, without changing context menus elsewhere or weakening the Electron renderer boundary.

Risk: standard change because it adds user-visible behavior across shared UI, Chat, Task, localization, and tests, but it does not add IPC, preload capabilities, persistence, or privileged Main-process access.

# Spec

- A non-whitespace DOM selection fully contained in the Chat transcript or Task detail opens a one-item context menu at the right-click location.
- The item is labeled `Copy` in English and `复制` in Simplified Chinese.
- Activating the item writes the exact captured selection, including meaningful surrounding whitespace, through the renderer's existing scoped Clipboard API usage.
- Empty, whitespace-only, out-of-region, or partially out-of-region selections do not open the added menu and do not have their context-menu event consumed.
- Chat history and other existing custom context menus remain outside the new copy regions.
- Existing selection and keyboard-copy behavior remains unchanged; no new Electron IPC or preload capability is introduced.

# Plan

1. Add a reusable selection-copy region that validates selection containment, captures exact text, positions and dismisses a localized menu, and writes the captured value to the clipboard.
2. Apply it only to the Chat transcript and Task detail article while preserving their existing DOM element types and classes.
3. Add English and Chinese localization plus themed menu styling.
4. Add focused tests for selection validation, copy behavior, localization, region integration, and Chat history menu isolation.
5. Run focused tests, `pnpm typecheck`, `pnpm lint`, `pnpm i18n:check`, and `pnpm test`; record manual Electron verification status separately.
111 changes: 111 additions & 0 deletions src/components/SelectionCopyRegion.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { setLanguage } from "../i18n";
import {
SelectionCopyRegion,
selectionTextWithin,
} from "./SelectionCopyRegion";

const writeText = vi.fn().mockResolvedValue(undefined);

function selectContents(node: Node): Selection {
const selection = window.getSelection()!;
selection.removeAllRanges();
const range = document.createRange();
range.selectNodeContents(node);
selection.addRange(range);
return selection;
}

beforeEach(() => {
setLanguage("en");
writeText.mockClear();
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
window.getSelection()?.removeAllRanges();
});

describe("SelectionCopyRegion", () => {
it("returns the exact non-empty selection only when both ends are in the region", () => {
const { container } = render(
<>
<SelectionCopyRegion data-testid="region">
<span data-testid="inside"> copied text </span>
</SelectionCopyRegion>
<span data-testid="outside">outside</span>
</>,
);
const region = screen.getByTestId("region");
const inside = screen.getByTestId("inside").firstChild!;
const outside = screen.getByTestId("outside").firstChild!;

expect(selectionTextWithin(region, selectContents(inside))).toBe(
" copied text ",
);
expect(selectionTextWithin(region, selectContents(outside))).toBeNull();

const whitespace = document.createTextNode(" \n");
container.append(whitespace);
region.append(whitespace);
expect(selectionTextWithin(region, selectContents(whitespace))).toBeNull();

const selection = window.getSelection()!;
selection.removeAllRanges();
const crossingRange = document.createRange();
crossingRange.setStart(inside, 1);
crossingRange.setEnd(outside, outside.textContent!.length);
selection.addRange(crossingRange);
expect(selectionTextWithin(region, selection)).toBeNull();
});

it("opens only for a valid in-region selection and copies the captured full text", async () => {
render(
<SelectionCopyRegion data-testid="region">
<span data-testid="text"> complete selection </span>
</SelectionCopyRegion>,
);
const region = screen.getByTestId("region");
selectContents(screen.getByTestId("text").firstChild!);

fireEvent.contextMenu(region, { clientX: 24, clientY: 30 });
const copy = screen.getByRole("menuitem", { name: "Copy" });
expect(copy).toHaveFocus();
fireEvent.click(copy);

expect(writeText).toHaveBeenCalledWith(" complete selection ");
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});

it("does not consume a context menu without a valid selection", () => {
render(
<SelectionCopyRegion data-testid="region">
No selection
</SelectionCopyRegion>,
);
const event = new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
});

screen.getByTestId("region").dispatchEvent(event);

expect(event.defaultPrevented).toBe(false);
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});

it("uses the current Chinese copy label", () => {
setLanguage("zh-CN");
render(
<SelectionCopyRegion data-testid="region">
<span data-testid="text">可复制文字</span>
</SelectionCopyRegion>,
);
selectContents(screen.getByTestId("text").firstChild!);

fireEvent.contextMenu(screen.getByTestId("region"));

expect(screen.getByRole("menuitem", { name: "复制" })).toBeInTheDocument();
});
});
137 changes: 137 additions & 0 deletions src/components/SelectionCopyRegion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import {
useEffect,
useRef,
useState,
type HTMLAttributes,
type MouseEvent as ReactMouseEvent,
} from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "../i18n";

const MENU_WIDTH = 142;
const MENU_HEIGHT = 35;
const VIEWPORT_MARGIN = 8;

interface CopyMenuState {
text: string;
left: number;
top: number;
}

interface SelectionCopyRegionProps extends HTMLAttributes<HTMLElement> {
as?: "article" | "div";
elementRef?: (node: HTMLElement | null) => void;
}

export function selectionTextWithin(
region: HTMLElement,
selection: Selection | null,
): string | null {
if (
!selection ||
selection.rangeCount === 0 ||
!selection.anchorNode ||
!selection.focusNode ||
!region.contains(selection.anchorNode) ||
!region.contains(selection.focusNode)
)
return null;
const text = selection.toString();
return text.trim() ? text : null;
}

export function SelectionCopyRegion({
as: Tag = "div",
children,
elementRef,
onContextMenu,
...props
}: SelectionCopyRegionProps) {
const { t } = useTranslation();
const regionRef = useRef<HTMLElement | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const [menu, setMenu] = useState<CopyMenuState | null>(null);

useEffect(() => {
if (!menu) return;
menuRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
const closeOnOutsidePress = (event: MouseEvent) => {
if (!menuRef.current?.contains(event.target as Node)) setMenu(null);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
setMenu(null);
};
const close = () => setMenu(null);
document.addEventListener("mousedown", closeOnOutsidePress);
document.addEventListener("keydown", closeOnEscape);
window.addEventListener("blur", close);
window.addEventListener("resize", close);
return () => {
document.removeEventListener("mousedown", closeOnOutsidePress);
document.removeEventListener("keydown", closeOnEscape);
window.removeEventListener("blur", close);
window.removeEventListener("resize", close);
};
}, [menu]);

const handleContextMenu = (event: ReactMouseEvent<HTMLElement>) => {
onContextMenu?.(event);
if (event.defaultPrevented) return;
const region = regionRef.current;
const text = region
? selectionTextWithin(region, window.getSelection())
: null;
if (!text) {
setMenu(null);
return;
}
event.preventDefault();
const maxLeft = Math.max(VIEWPORT_MARGIN, window.innerWidth - MENU_WIDTH);
const maxTop = Math.max(VIEWPORT_MARGIN, window.innerHeight - MENU_HEIGHT);
setMenu({
text,
left: Math.max(VIEWPORT_MARGIN, Math.min(event.clientX, maxLeft)),
top: Math.max(VIEWPORT_MARGIN, Math.min(event.clientY, maxTop)),
});
};

return (
<>
<Tag
{...props}
ref={(node) => {
regionRef.current = node;
elementRef?.(node);
}}
onContextMenu={handleContextMenu}
>
{children}
</Tag>
{menu
? createPortal(
<div
className="selection-copy-menu"
role="menu"
ref={menuRef}
style={{ left: menu.left, top: menu.top }}
>
<button
type="button"
role="menuitem"
onClick={() => {
const copy = navigator.clipboard?.writeText(menu.text);
setMenu(null);
void copy?.catch(() => undefined);
}}
>
{t("contextMenu.copy")}
</button>
</div>,
document.body,
)
: null}
</>
);
}
1 change: 1 addition & 0 deletions src/features/chat/ChatHistory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ describe("ChatHistory", () => {
const renameAction = screen.getByRole("menuitem", {
name: "Rename Fix tests",
});
expect(screen.queryByRole("menuitem", { name: "Copy" })).toBeNull();
expect(renameAction).toHaveFocus();
fireEvent.click(renameAction);
fireEvent.change(screen.getByRole("textbox", { name: "Session name" }), {
Expand Down
29 changes: 29 additions & 0 deletions src/features/chat/ChatView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,35 @@ describe("ChatView", () => {
expect(screen.queryByText("Start a conversation with pi")).toBeNull();
});

it("copies a complete selection from rendered conversation content", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
const { container } = render(
<ChatView
worktreeId="worktree-1"
sessionId="session-1"
api={api()}
runtime={new ChatRuntime({ batchMs: 0 })}
/>,
);
const heading = await screen.findByRole("heading", {
name: "Markdown result",
});
const selection = window.getSelection()!;
selection.removeAllRanges();
const range = document.createRange();
range.selectNodeContents(heading);
selection.addRange(range);

fireEvent.contextMenu(container.querySelector(".chat-transcript")!);
fireEvent.click(screen.getByRole("menuitem", { name: "Copy" }));

expect(writeText).toHaveBeenCalledWith("Markdown result");
});

it("renders only the final answer until the work summary is expanded", async () => {
render(
<ChatView
Expand Down
9 changes: 7 additions & 2 deletions src/features/chat/ChatView.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useState, type ReactNode } from "react";
import { SelectionCopyRegion } from "../../components/SelectionCopyRegion";
import { useTranslation, type TranslationKey } from "../../i18n";
import type { ChatApi } from "./chatApi";
import { chatRuntime, type ChatRuntime, useChatSession } from "./chatRuntime";
Expand Down Expand Up @@ -121,7 +122,11 @@ function ChatViewContent({
return (
<section className="chat-view" aria-label={t("chat.session")}>
<div className="chat-transcript-shell">
<div ref={transcriptRef} className="chat-transcript" aria-live="polite">
<SelectionCopyRegion
elementRef={transcriptRef}
className="chat-transcript"
aria-live="polite"
>
{intro}
{state.status === "loading" && (
<div>{t("chat.loadingConversation")}</div>
Expand Down Expand Up @@ -174,7 +179,7 @@ function ChatViewContent({
}
})}
{afterMessages}
</div>
</SelectionCopyRegion>
{showScrollToBottom && (
<button
type="button"
Expand Down
2 changes: 1 addition & 1 deletion src/features/chat/useChatScrollController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function useChatScrollController(
}, []);

const transcriptRef = useCallback(
(element: HTMLDivElement | null) => {
(element: HTMLElement | null) => {
cleanupRef.current();
elementRef.current = element;
if (!element) return;
Expand Down
26 changes: 26 additions & 0 deletions src/features/tasks/TaskDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,32 @@ const reviewTask = taskDetail({
beforeEach(() => vi.spyOn(window, "confirm").mockReturnValue(true));

describe("TaskDetail", () => {
it("copies selected Task detail text with the localized context menu", () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
const { container } = render(
<TaskDetail
api={createTasksApiMock()}
onChange={vi.fn()}
task={taskDetail({ title: "Selection copy task" })}
/>,
);
const title = screen.getByRole("heading", { name: "Selection copy task" });
const selection = window.getSelection()!;
selection.removeAllRanges();
const range = document.createRange();
range.selectNodeContents(title);
selection.addRange(range);

fireEvent.contextMenu(container.querySelector(".task-detail")!);
fireEvent.click(screen.getByRole("menuitem", { name: "Copy" }));

expect(writeText).toHaveBeenCalledWith("Selection copy task");
});

it("keeps a previous run report accessible while a follow-up run is queued", () => {
render(
<TaskDetail
Expand Down
Loading
Loading