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
152 changes: 152 additions & 0 deletions apps/desktop/src/main/editorContextMenu.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<string, (...args: unknown[]) => 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",
});
});
});
61 changes: 61 additions & 0 deletions apps/desktop/src/main/editorContextMenu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Menu, type BrowserWindow, type ContextMenuParams, type MenuItemConstructorOptions } from "electron";

type EditableContextMenuTarget = Pick<BrowserWindow["webContents"], "replaceMisspelling" | "session">;

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,
});
});
}
2 changes: 2 additions & 0 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 : "";
Expand Down
10 changes: 10 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down