fix: restore native text context menu
This commit is contained in:
@@ -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.
|
||||
@@ -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。
|
||||
|
||||
52
electron/main/context-menu.ts
Normal file
52
electron/main/context-menu.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
85
tests/e2e/context-menu.spec.ts
Normal file
85
tests/e2e/context-menu.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
120
tests/unit/context-menu.test.ts
Normal file
120
tests/unit/context-menu.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user