From 813d1ca02e4efc7c51fe180560bfa4caacfb8670 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Mon, 7 Sep 2026 17:07:57 +0800 Subject: [PATCH] fix: restore native text context menu --- .../20260907-input-context-menu-a7c31e.md | 95 ++++++++++++++ README.md | 1 + electron/main/context-menu.ts | 52 ++++++++ electron/main/index.ts | 2 + tests/e2e/context-menu.spec.ts | 85 +++++++++++++ tests/unit/context-menu.test.ts | 120 ++++++++++++++++++ 6 files changed, 355 insertions(+) create mode 100644 .project-docs/30-worklog/tasks/20260907-input-context-menu-a7c31e.md create mode 100644 electron/main/context-menu.ts create mode 100644 tests/e2e/context-menu.spec.ts create mode 100644 tests/unit/context-menu.test.ts diff --git a/.project-docs/30-worklog/tasks/20260907-input-context-menu-a7c31e.md b/.project-docs/30-worklog/tasks/20260907-input-context-menu-a7c31e.md new file mode 100644 index 0000000..35f6675 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260907-input-context-menu-a7c31e.md @@ -0,0 +1,95 @@ +# Task: Fix AI programming input context menu + +## Identity + +- Task ID: 20260907-input-context-menu-a7c31e +- Mode: Feature +- Branch: codex/20260907-input-context-menu-a7c31e-input-context-menu +- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260907-input-context-menu-a7c31e +- Base commit: 8c13ae76db6fa47972bdf9ff3de932eb761aaffe +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Reproduce the AI Programming composer symptom where right-clicking editable text + exposes no native editing menu. +- Compare the current integrated primary-window wiring with completed task + `20260819-context-menu-impl-c7d4a2e1` and reuse only behavior still compatible + with the current window lifecycle. +- Restore the smallest Main-owned native text-editing context-menu behavior and add + focused regression coverage at the real window/event seam. +- Update user-facing documentation or Electron E2E coverage only where current + behavior and the shared fixture require it. + +## Intent And Constraints + +- Electron Main owns desktop context-menu integration. Do not add Renderer, + Preload, IPC, Host API, or direct clipboard access for this fix. +- Use the primary window's `webContents` `context-menu` event and native editing + roles when that remains the confirmed compatible seam. +- Show a menu only for editable controls or an existing text selection; derive + enabled actions from the actual Electron edit context. +- Preserve Renderer-owned custom menus, Monaco behavior, the detached Agent Browser, + existing application-menu behavior, Chinese-only UI, and the single light visual + system. +- Build a deterministic red-capable feedback loop for the reported input symptom + before selecting a cause or changing production code. +- Work only in the isolated task worktree; do not modify, adopt, clean, or merge the + occupied root `main` worktree or any peer worktree. + +## Outcome + +- Confirmed the failure at the real Electron window seam: a renderer right-click + produced no native menu because the primary `webContents` had zero + `context-menu` listeners. The earlier implementation in task + `20260819-context-menu-impl-c7d4a2e1` exists only on its isolated branch and + was never integrated into the current `main` history. +- Added a Main-owned text context-menu module that builds native Chinese + `cut`, `copy`, `paste`, and `selectAll` roles from Electron's actual + `isEditable`, `selectionText`, and `editFlags` context. Ordinary non-editable + content still produces no menu. +- Registered the listener once during primary-window creation without adding + Renderer, Preload, IPC, Host API, or direct clipboard access. +- Added five focused unit cases and an Electron E2E regression that dispatches + a real right-click inside the primary Renderer window and observes one native + menu popup with the expected editing actions. +- Synchronized `README.md` with the restored desktop behavior and ownership + boundary. + +## Verification + +- Red phase: + - `corepack pnpm exec playwright test tests/e2e/context-menu.spec.ts` failed + twice with `popupCount: 0` before the production change. + - A temporary targeted probe reported zero `context-menu` listeners on the + primary `webContents`; the probe was removed before completion. + - `corepack pnpm exec vitest run tests/unit/context-menu.test.ts` failed to + resolve the absent Main module before implementation. +- Green phase: + - `corepack pnpm exec vitest run tests/unit/context-menu.test.ts` — 5 passed. + - `corepack pnpm exec playwright test tests/e2e/context-menu.spec.ts` — 1 + passed. + - `corepack pnpm run typecheck` — passed. + - `corepack pnpm exec eslint electron/main/context-menu.ts electron/main/index.ts tests/unit/context-menu.test.ts tests/e2e/context-menu.spec.ts` + — passed. + - `corepack pnpm run lint:check` — passed with 5 pre-existing warnings in + untouched files and no errors. + - `corepack pnpm run build:vite` — passed for Renderer, Main, Preload, and + release utility bundles; only existing chunk/dynamic-import warnings were + reported. + - `corepack pnpm test` — 226 test files passed; one unrelated real-process + timing assertion in `pi-agent-server-process-real.test.ts` measured 2323ms + against a 2000ms threshold. Its isolated rerun with + `corepack pnpm exec vitest run tests/unit/pi-agent-server-process-real.test.ts --maxWorkers=1` + passed all 6 tests, so no unrelated timing code or threshold was changed. + - `git diff --check` — passed. + +## Follow-ups + +- Integrate this ready branch through the project Integration Gate after the + current owner of the root `main` worktree releases it. + +## Promotion Candidates + +- None recorded. diff --git a/README.md b/README.md index 17449cb..cabb14a 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Makelore 是一个面向软件、视觉创作与智能机器人的 AI 桌面工 ## 当前产品状态 - 桌面技术栈:Electron、React 19、Vite、TypeScript、Zustand、Tailwind CSS。 +- 桌面文本右键菜单:主 Renderer 窗口由 Electron Main 提供原生“剪切、复制、粘贴、全选”操作;菜单只在可编辑区域或已有文本选择时出现,普通空白区域不显示,也不向 Renderer 暴露剪贴板 IPC。 - AI 编程核心对话运行时:Electron Main 按需启动一个长驻 Pi Agent Server,同一进程内为每条 Conversation 建立独立的 Runtime、Session、凭证视图、扩展上下文和 JSONL 通道,同时复用 Pi 模块与进程启动成本;Server 异常退出时所有旧通道统一失效,下一次恢复只重启一个 Server。正式包中的 Agent Server 从明确传入的 `resources/pi-runtime` 根定位依赖清单与包入口,不从脚本所在目录或应用 `node_modules` 猜测解析。Renderer 不直接启动或调用 Pi,也不读取其 wire 类型。项目与 Conversation 元数据先从本地读取,选中对话后才按需准备对应逻辑线程;输入框不等待运行时准备完成。同一助手工具批次中,会取得项目写租约的 `bash`、`edit`、`write` 与动态产品工具按顺序执行,避免批次在命令启动前自锁;纯只读工具仍可并行。子 Agent 仍使用独立的短生命周期进程,以保留并发配额、故障和写入边界;macOS 上两类后台进程都使用 Electron 自带的 Helper,不在 Dock 中创建额外应用图标。 - AI 编程默认入口:进入 Code 后保持在 `/chat`。尚未选择本地项目时,页面以麦洛 M 标识和居中引导语建立焦点,下方提供独立的“新增项目”主操作以及可横向浏览、直接进入的已有项目卡片;项目创建和进入仍复用侧栏现有流程。已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。 - AI 编程历史与配额反馈:Pi 上下文压缩不删除用户可见的 active-branch 历史,压缩摘要保持 Main-private;Renderer 首次渲染最近 120 个节点,向上滚动时按 100 个节点追加更早内容并保持阅读位置。Main 精确识别 `token_point_balance_exhausted` / `词元点数余额不足`,只向 Renderer 投影不可重试的稳定配额错误和安全中文提示,重开会话后仍可见且不暴露上游原始错误或 request id。 diff --git a/electron/main/context-menu.ts b/electron/main/context-menu.ts new file mode 100644 index 0000000..5573ef0 --- /dev/null +++ b/electron/main/context-menu.ts @@ -0,0 +1,52 @@ +import { Menu } from 'electron'; +import type { BrowserWindow } from 'electron'; + +export type TextContextMenuContext = Pick< + Electron.ContextMenuParams, + 'isEditable' | 'selectionText' | 'editFlags' +>; + +export function createTextContextMenuTemplate( + context: TextContextMenuContext, +): Electron.MenuItemConstructorOptions[] | null { + if (!context.isEditable && context.selectionText.length === 0) { + return null; + } + + return [ + { + label: '剪切', + role: 'cut', + enabled: context.isEditable && context.editFlags.canCut, + }, + { + label: '复制', + role: 'copy', + enabled: context.editFlags.canCopy, + }, + { + label: '粘贴', + role: 'paste', + enabled: context.isEditable && context.editFlags.canPaste, + }, + { type: 'separator' }, + { + label: '全选', + role: 'selectAll', + enabled: context.editFlags.canSelectAll, + }, + ]; +} + +export function registerTextContextMenu(win: BrowserWindow): void { + win.webContents.on('context-menu', (_event, params) => { + const template = createTextContextMenuTemplate(params); + if (!template) return; + + Menu.buildFromTemplate(template).popup({ + window: win, + frame: params.frame ?? undefined, + sourceType: params.menuSourceType, + }); + }); +} diff --git a/electron/main/index.ts b/electron/main/index.ts index afe8cfb..9dd753e 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -9,6 +9,7 @@ import { join } from 'node:path'; import { registerIpcHandlers } from './ipc-handlers'; import { createTray } from './tray'; import { createMenu } from './menu'; +import { registerTextContextMenu } from './context-menu'; import { registerZoomShortcuts } from './zoom-shortcuts'; import { getNativeWindowMaterialOptions } from './window-material'; @@ -272,6 +273,7 @@ function createWindow(): BrowserWindow { }); installMediaPermissionHandler(win); + registerTextContextMenu(win); registerZoomShortcuts(win); // Handle external links — only allow safe protocols to prevent arbitrary diff --git a/tests/e2e/context-menu.spec.ts b/tests/e2e/context-menu.spec.ts new file mode 100644 index 0000000..31e6fd8 --- /dev/null +++ b/tests/e2e/context-menu.spec.ts @@ -0,0 +1,85 @@ +import type { ElectronApplication } from '@playwright/test'; +import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; + +type ContextMenuProbe = { + popupCount: number; + template: Array<{ + label: string | null; + role: string | null; + type: string; + }>; +}; + +async function installContextMenuProbe(app: ElectronApplication): Promise { + await app.evaluate(({ Menu }) => { + const probe: ContextMenuProbe = { + popupCount: 0, + template: [], + }; + const globalWithProbe = globalThis as typeof globalThis & { + __makeloreContextMenuProbe?: ContextMenuProbe; + }; + globalWithProbe.__makeloreContextMenuProbe = probe; + + Menu.buildFromTemplate = ((template) => { + probe.template = template.map((item) => ({ + label: item.label ?? null, + role: item.role ?? null, + type: item.type ?? 'normal', + })); + + return { + popup: () => { + probe.popupCount += 1; + }, + } as unknown as Electron.Menu; + }) as typeof Menu.buildFromTemplate; + }); +} + +async function readContextMenuProbe(app: ElectronApplication): Promise { + return await app.evaluate(() => { + const globalWithProbe = globalThis as typeof globalThis & { + __makeloreContextMenuProbe?: ContextMenuProbe; + }; + return globalWithProbe.__makeloreContextMenuProbe ?? null; + }); +} + +test.describe('Makelore text context menu', () => { + test('opens native editing actions for editable text', async ({ launchElectronApp }) => { + const app = await launchElectronApp({ skipSetup: true }); + + try { + const page = await getStableWindow(app); + await installContextMenuProbe(app); + await page.evaluate(() => { + const input = document.createElement('input'); + input.id = 'context-menu-e2e-input'; + input.style.position = 'fixed'; + input.style.inset = '80px auto auto 80px'; + input.style.zIndex = '2147483647'; + + document.body.append(input); + }); + + const input = page.locator('#context-menu-e2e-input'); + await input.fill('Makelore'); + await input.selectText(); + await input.click({ button: 'right' }); + + await expect.poll(async () => await readContextMenuProbe(app)).toEqual({ + popupCount: 1, + template: [ + { label: '剪切', role: 'cut', type: 'normal' }, + { label: '复制', role: 'copy', type: 'normal' }, + { label: '粘贴', role: 'paste', type: 'normal' }, + { label: null, role: null, type: 'separator' }, + { label: '全选', role: 'selectAll', type: 'normal' }, + ], + }); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/tests/unit/context-menu.test.ts b/tests/unit/context-menu.test.ts new file mode 100644 index 0000000..f48d5bb --- /dev/null +++ b/tests/unit/context-menu.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import { + createTextContextMenuTemplate, + type TextContextMenuContext, +} from '@electron/main/context-menu'; + +type ContextOverrides = { + isEditable?: boolean; + selectionText?: string; + editFlags?: Partial; +}; + +function context(overrides: ContextOverrides = {}): TextContextMenuContext { + return { + isEditable: overrides.isEditable ?? false, + selectionText: overrides.selectionText ?? '', + editFlags: { + canUndo: false, + canRedo: false, + canCut: false, + canCopy: false, + canPaste: false, + canDelete: false, + canSelectAll: false, + canEditRichly: false, + ...overrides.editFlags, + }, + }; +} + +function enabledByRole(template: Electron.MenuItemConstructorOptions[] | null) { + return Object.fromEntries( + (template ?? []) + .filter((item) => item.role) + .map((item) => [item.role, item.enabled]), + ); +} + +describe('text context menu', () => { + it('does not create a menu for ordinary non-editable content', () => { + expect(createTextContextMenuTemplate(context())).toBeNull(); + }); + + it('allows paste and select all in an editable control without a selection', () => { + const template = createTextContextMenuTemplate(context({ + isEditable: true, + editFlags: { + canPaste: true, + canSelectAll: true, + }, + })); + + expect(template?.map((item) => item.label ?? item.type)).toEqual([ + '剪切', + '复制', + '粘贴', + 'separator', + '全选', + ]); + expect(enabledByRole(template)).toEqual({ + cut: false, + copy: false, + paste: true, + selectAll: true, + }); + }); + + it('enables every requested action for an editable selection when supported', () => { + const template = createTextContextMenuTemplate(context({ + isEditable: true, + selectionText: 'Makelore', + editFlags: { + canCut: true, + canCopy: true, + canPaste: true, + canSelectAll: true, + }, + })); + + expect(enabledByRole(template)).toEqual({ + cut: true, + copy: true, + paste: true, + selectAll: true, + }); + }); + + it('keeps cut and paste disabled for a read-only selection', () => { + const template = createTextContextMenuTemplate(context({ + selectionText: 'Makelore', + editFlags: { + canCut: true, + canCopy: true, + canPaste: true, + canSelectAll: true, + }, + })); + + expect(enabledByRole(template)).toEqual({ + cut: false, + copy: true, + paste: false, + selectAll: true, + }); + }); + + it('respects disabled renderer editing capabilities', () => { + const template = createTextContextMenuTemplate(context({ + isEditable: true, + selectionText: 'Makelore', + })); + + expect(enabledByRole(template)).toEqual({ + cut: false, + copy: false, + paste: false, + selectAll: false, + }); + }); +});