diff --git a/apps/desktop/src/main/editorContextMenu.test.ts b/apps/desktop/src/main/editorContextMenu.test.ts new file mode 100644 index 000000000..e1b5db77d --- /dev/null +++ b/apps/desktop/src/main/editorContextMenu.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { BrowserWindow, ContextMenuParams, MenuItemConstructorOptions } from "electron"; + +const electronMocks = vi.hoisted(() => ({ + buildFromTemplate: vi.fn(), + popup: vi.fn(), +})); + +vi.mock("electron", () => ({ + Menu: { + buildFromTemplate: electronMocks.buildFromTemplate, + }, +})); + +import { buildEditableContextMenuTemplate, installEditableContextMenu } from "./editorContextMenu"; + +function contextMenuParams(overrides: Partial = {}): ContextMenuParams { + return { + x: 0, + y: 0, + frame: null, + linkURL: "", + linkText: "", + pageURL: "http://localhost", + frameURL: "http://localhost", + srcURL: "", + mediaType: "none", + hasImageContents: false, + isEditable: true, + selectionText: "", + titleText: "", + altText: "", + suggestedFilename: "", + selectionRect: { x: 0, y: 0, width: 0, height: 0 }, + selectionStartOffset: 0, + referrerPolicy: { policy: "default", url: "" }, + misspelledWord: "", + dictionarySuggestions: [], + frameCharset: "utf-8", + formControlType: "text-area", + spellcheckEnabled: true, + menuSourceType: "mouse", + mediaFlags: { + inError: false, + isPaused: false, + isMuted: false, + hasAudio: false, + isLooping: false, + isControlsVisible: false, + canToggleControls: false, + canPrint: false, + canSave: false, + canShowPictureInPicture: false, + isShowingPictureInPicture: false, + canRotate: false, + canLoop: false, + }, + editFlags: { + canUndo: true, + canRedo: false, + canCut: true, + canCopy: true, + canPaste: true, + canDelete: true, + canSelectAll: true, + canEditRichly: false, + }, + ...overrides, + }; +} + +function fakeWindow() { + const listeners = new Map void>(); + const replaceMisspelling = vi.fn(); + const addWordToSpellCheckerDictionary = vi.fn(); + const win = { + webContents: { + on: vi.fn((event: string, listener: (...args: unknown[]) => void) => listeners.set(event, listener)), + replaceMisspelling, + session: { addWordToSpellCheckerDictionary }, + }, + } as unknown as BrowserWindow; + return { win, listeners, replaceMisspelling, addWordToSpellCheckerDictionary }; +} + +describe("editable context menu", () => { + beforeEach(() => { + electronMocks.popup.mockReset(); + electronMocks.buildFromTemplate.mockReset(); + electronMocks.buildFromTemplate.mockReturnValue({ popup: electronMocks.popup }); + }); + + it("does not replace custom menus on non-editable renderer content", () => { + const { win, listeners } = fakeWindow(); + installEditableContextMenu(win); + + listeners.get("context-menu")?.({}, contextMenuParams({ isEditable: false })); + + expect(win.webContents.on).toHaveBeenCalledWith("context-menu", expect.any(Function)); + expect(electronMocks.buildFromTemplate).not.toHaveBeenCalled(); + expect(electronMocks.popup).not.toHaveBeenCalled(); + }); + + it("offers spelling suggestions and dictionary actions before edit commands", () => { + const { win, replaceMisspelling, addWordToSpellCheckerDictionary } = fakeWindow(); + const template = buildEditableContextMenuTemplate( + win.webContents, + contextMenuParams({ misspelledWord: "mispelled", dictionarySuggestions: ["misspelled", "misapplied"] }), + ); + + expect(template?.slice(0, 5).map((item) => item.label ?? item.type)).toEqual([ + "misspelled", + "misapplied", + "separator", + "Add to dictionary", + "separator", + ]); + template?.[0]?.click?.({} as never, undefined, {} as never); + template?.[3]?.click?.({} as never, undefined, {} as never); + expect(replaceMisspelling).toHaveBeenCalledWith("misspelled"); + expect(addWordToSpellCheckerDictionary).toHaveBeenCalledWith("mispelled"); + }); + + it("shows native edit roles using Chromium's enabled flags", () => { + const { win, listeners } = fakeWindow(); + installEditableContextMenu(win); + + listeners.get("context-menu")?.({}, contextMenuParams()); + + const template = electronMocks.buildFromTemplate.mock.calls[0]?.[0] as MenuItemConstructorOptions[]; + expect(template.map((item) => item.role ?? item.type)).toEqual([ + "undo", + "redo", + "separator", + "cut", + "copy", + "paste", + "pasteAndMatchStyle", + "delete", + "separator", + "selectAll", + ]); + expect(template.find((item) => item.role === "redo")?.enabled).toBe(false); + expect(electronMocks.popup).toHaveBeenCalledWith({ + window: win, + frame: undefined, + x: 0, + y: 0, + sourceType: "mouse", + }); + }); +}); diff --git a/apps/desktop/src/main/editorContextMenu.ts b/apps/desktop/src/main/editorContextMenu.ts new file mode 100644 index 000000000..bfb052a46 --- /dev/null +++ b/apps/desktop/src/main/editorContextMenu.ts @@ -0,0 +1,61 @@ +import { Menu, type BrowserWindow, type ContextMenuParams, type MenuItemConstructorOptions } from "electron"; + +type EditableContextMenuTarget = Pick; + +export function buildEditableContextMenuTemplate( + target: EditableContextMenuTarget, + params: ContextMenuParams, +): MenuItemConstructorOptions[] | null { + if (!params.isEditable) return null; + + const template: MenuItemConstructorOptions[] = []; + if (params.misspelledWord) { + if (params.dictionarySuggestions.length) { + for (const suggestion of params.dictionarySuggestions) { + template.push({ + label: suggestion, + click: () => target.replaceMisspelling(suggestion), + }); + } + } else { + template.push({ label: "No spelling suggestions", enabled: false }); + } + template.push( + { type: "separator" }, + { + label: "Add to dictionary", + click: () => target.session.addWordToSpellCheckerDictionary(params.misspelledWord), + }, + { type: "separator" }, + ); + } + + template.push( + { role: "undo", enabled: params.editFlags.canUndo }, + { role: "redo", enabled: params.editFlags.canRedo }, + { type: "separator" }, + { role: "cut", enabled: params.editFlags.canCut }, + { role: "copy", enabled: params.editFlags.canCopy }, + { role: "paste", enabled: params.editFlags.canPaste }, + { role: "pasteAndMatchStyle", enabled: params.editFlags.canPaste }, + { role: "delete", enabled: params.editFlags.canDelete }, + { type: "separator" }, + { role: "selectAll", enabled: params.editFlags.canSelectAll }, + ); + + return template; +} + +export function installEditableContextMenu(win: BrowserWindow): void { + win.webContents.on("context-menu", (_event, params) => { + const template = buildEditableContextMenuTemplate(win.webContents, params); + if (!template) return; + Menu.buildFromTemplate(template).popup({ + window: win, + frame: params.frame ?? undefined, + x: params.x, + y: params.y, + sourceType: params.menuSourceType, + }); + }); +} diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 2f6b5f060..d9eefadd1 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -99,6 +99,7 @@ import { createExternalSessionsService } from "./services/externalSessions/exter import { runGit } from "./services/git/git"; import { createJobEngine } from "./services/jobs/jobEngine"; import { createTranscriptionService } from "./services/transcription/transcriptionService"; +import { installEditableContextMenu } from "./editorContextMenu"; import { createAiIntegrationService } from "./services/ai/aiIntegrationService"; import { augmentProcessPathWithShellAndKnownCliDirs, setPathEnvValue } from "./services/ai/cliExecutableResolver"; import { createAgentChatService, writeSessionLinearIssueContextFile } from "./services/chat/agentChatService"; @@ -633,6 +634,7 @@ async function createWindow(args: { }); args.onCreated?.(win); + installEditableContextMenu(win); win.webContents.on("will-attach-webview", (event, webPreferences, params) => { const src = typeof params.src === "string" ? params.src : ""; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index da3b3ba8d..b66101c2b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -230,6 +230,16 @@ The desktop app is a **client of the runtime**. It owns a trusted main process, **Multi-window shell.** `main.ts` hosts multiple `BrowserWindow` instances; opening another project opens it in a dedicated window. Each window has its own runtime binding (local pool or a specific remote target). The global `/chats` route surfaces through a real machine-level **Chats** top tab (its existence tracked in session-only `personalChatsTabOpen` app state, active-ness derived from the route) that coexists with project tabs and survives project open/switch/close, or runs inside the current project tab without clearing that binding. Personal-chat IPC therefore targets the local brain from local/no-project windows and the remote brain from an SSH-bound project window. External controllers — for example a `ade code` TUI — can drive desktop window navigation via the `app/navigate` JSON-RPC method against the runtime; the desktop's IPC tracing carries window ID so logs distinguish which renderer surface invoked a channel. +Every desktop `BrowserWindow` installs the shared native editable-control menu +from `apps/desktop/src/main/editorContextMenu.ts`. Electron supplies spelling +suggestions, edit capabilities, coordinates, and the originating frame through +its `context-menu` event; ADE turns those values into the platform menu for +ordinary editable controls, including the Work composer. Non-editable targets +are ignored so renderer-owned menus such as file and lane actions keep their +existing behavior. Because the hook is attached in `createWindow`, local, +remote-bound, and additional project windows share the same copy/paste, +undo/redo, select-all, and macOS spelling-dictionary behavior. + **Account Activity is outside the project binding.** `attentionAccountCoordinator.ts` is the desktop main-process boundary for snapshot, acknowledgment, presence, and preference calls. Signed-in reads go