fix: restore native text context menu

This commit is contained in:
2026-09-07 17:07:57 +08:00
parent 8c13ae76db
commit 813d1ca02e
6 changed files with 355 additions and 0 deletions

View File

@@ -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.

View File

@@ -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-privateRenderer 首次渲染最近 120 个节点,向上滚动时按 100 个节点追加更早内容并保持阅读位置。Main 精确识别 `token_point_balance_exhausted` / `词元点数余额不足`,只向 Renderer 投影不可重试的稳定配额错误和安全中文提示,重开会话后仍可见且不暴露上游原始错误或 request id。

View File

@@ -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,
});
});
}

View File

@@ -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

View File

@@ -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<void> {
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<ContextMenuProbe | null> {
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);
}
});
});

View File

@@ -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<Electron.EditFlags>;
};
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,
});
});
});