Skip to content
Open
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
24 changes: 15 additions & 9 deletions apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,8 +478,10 @@ export const make = Effect.gen(function* () {
});

window.webContents.on("context-menu", (event, params) => {
event.preventDefault();

// Build a native menu only for Electron-exclusive capabilities that the
// web renderer cannot provide: spellcheck suggestions, Copy Link, and
// Copy Image. Standard text editing (Cut / Copy / Paste / Select All)
// is handled by the themed web-side context menu instead.
const menuTemplate: Electron.MenuItemConstructorOptions[] = [];

if (params.misspelledWord) {
Expand Down Expand Up @@ -515,14 +517,18 @@ export const make = Effect.gen(function* () {
menuTemplate.push({ type: "separator" });
}

menuTemplate.push(
{ role: "cut", enabled: params.editFlags.canCut },
{ role: "copy", enabled: params.editFlags.canCopy },
{ role: "paste", enabled: params.editFlags.canPaste },
{ role: "selectAll", enabled: params.editFlags.canSelectAll },
);
if (menuTemplate.length > 0) {
// Strip trailing separator before showing.
const last = menuTemplate[menuTemplate.length - 1];
if (last?.type === "separator") {
menuTemplate.pop();
}
void runPromise(electronMenu.popupTemplate({ window, template: menuTemplate }));
}

void runPromise(electronMenu.popupTemplate({ window, template: menuTemplate }));
// Always suppress the default native menu so the web-side themed menu
// can handle standard text operations (Cut, Copy, Paste, Select All).
event.preventDefault();
});

window.webContents.setWindowOpenHandler(({ url }) => {
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/AppRoot.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { useEffect } from "react";
import { RouterProvider } from "@tanstack/react-router";

import { ElectronBrowserHost } from "./browser/ElectronBrowserHost";
import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts";
import { QuitHoldOverlay } from "./components/QuitHoldOverlay";
import { installGlobalTextContextMenu } from "./contextMenuFallback";
import { AppAtomRegistryProvider } from "./rpc/atomRegistry";
import type { AppRouter } from "./router";

Expand All @@ -12,6 +14,10 @@ import type { AppRouter } from "./router";
* share the same atom registry as routed UI.
*/
export function AppRoot({ router }: { readonly router: AppRouter }) {
useEffect(() => {
return installGlobalTextContextMenu();
}, []);

return (
<AppAtomRegistryProvider>
<RouterProvider router={router} />
Expand Down
20 changes: 18 additions & 2 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ describe("buildBulkTitleRegenerationContextMenuItem", () => {
).toEqual({
id: "regenerate-title",
label: "Regenerate titles (3)",
icon: "refresh-cw",
});
});

Expand All @@ -206,6 +207,7 @@ describe("buildBulkTitleRegenerationContextMenuItem", () => {
).toEqual({
id: "regenerate-title",
label: "Regenerating… (2)",
icon: "refresh-cw",
disabled: true,
});
});
Expand All @@ -224,13 +226,27 @@ describe("buildMultiSelectThreadContextMenuItems", () => {
it("offers bulk archive with the selected count", () => {
expect(
buildMultiSelectThreadContextMenuItems({ count: 3, hasRunningThread: false }),
).toContainEqual({ id: "archive", label: "Archive (3)", disabled: false });
).toContainEqual({
id: "archive",
label: "Archive (3)",
icon: "archive",
tone: "warning",
disabled: false,
separatorBefore: true,
});
});

it("disables bulk archive when a selected thread is running", () => {
expect(
buildMultiSelectThreadContextMenuItems({ count: 2, hasRunningThread: true }),
).toContainEqual({ id: "archive", label: "Archive (2)", disabled: true });
).toContainEqual({
id: "archive",
label: "Archive (2)",
icon: "archive",
tone: "warning",
disabled: true,
separatorBefore: true,
});
});
});

Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,16 @@ export function buildMultiSelectThreadContextMenuItems(input: {
hasRunningThread: boolean;
}): readonly ContextMenuItem<"mark-unread" | "archive" | "delete">[] {
return [
{ id: "mark-unread", label: `Mark unread (${input.count})` },
{ id: "mark-unread", label: `Mark unread (${input.count})`, icon: "mail-open" },
{
id: "archive",
label: `Archive (${input.count})`,
icon: "archive",
tone: "warning",
disabled: input.hasRunningThread,
separatorBefore: true,
},
{ id: "delete", label: `Delete (${input.count})`, destructive: true },
{ id: "delete", label: `Delete (${input.count})`, destructive: true, icon: "trash" },
];
}

Expand All @@ -115,12 +118,14 @@ export function buildBulkTitleRegenerationContextMenuItem(input: {
return {
id: "regenerate-title",
label: `Regenerating… (${input.supportedCount})`,
icon: "refresh-cw",
disabled: true,
};
}
return {
id: "regenerate-title",
label: `Regenerate titles (${input.actionableCount})`,
icon: "refresh-cw",
};
}

Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2833,12 +2833,13 @@ export default function Sidebar() {
const clicked = await settlePromise(() =>
api.contextMenu.show(
[
{ id: "settle", label: `Settle (${count})` },
{ id: "settle", label: `Settle (${count})`, icon: "circle-check" },
...(canSnoozeSelection
? [
{
id: "snooze",
label: `Snooze (${count})`,
icon: "clock",
children: snoozePresets.map((preset) => ({
id: `snooze:${preset.id}`,
label: `${preset.label} (${preset.whenLabel})`,
Expand All @@ -2847,8 +2848,8 @@ export default function Sidebar() {
]
: []),
...(titleRegenerationMenuItem ? [titleRegenerationMenuItem] : []),
{ id: "mark-unread", label: `Mark unread (${count})` },
{ id: "delete", label: `Delete (${count})`, destructive: true },
{ id: "mark-unread", label: `Mark unread (${count})`, icon: "mail-open" },
{ id: "delete", label: `Delete (${count})`, destructive: true, icon: "trash" },
],
position,
),
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/files/FileBrowserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,8 @@ export default function FileBrowserPanel({
try {
const clicked = await api.contextMenu.show(
[
{ id: "copy-mention", label: "Copy mention" },
{ id: "add-to-chat", label: "Add to chat" },
{ id: "copy-mention", label: "Copy mention", icon: "copy" },
{ id: "add-to-chat", label: "Add to chat", icon: "message-square-plus" },
],
position,
);
Expand Down
39 changes: 39 additions & 0 deletions apps/web/src/components/threadActionMenu.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,45 @@ describe("buildThreadActionMenuItems", () => {
expect(ids(baseState)).toEqual(expect.arrayContaining(["pin", "settle", "snooze"]));
});

it("assigns semantic icons to each thread action", () => {
const items = buildThreadActionMenuItems({ ...baseState, branch: "feat/menu" });
const icons = Object.fromEntries(items.map((item) => [item.id, item.icon]));

expect(icons).toMatchObject({
"new-thread-on-branch": "message-square-plus",
pin: "pin",
settle: "circle-check",
snooze: "clock",
rename: "pencil",
"regenerate-title": "refresh-cw",
"mark-unread": "mail-open",
copy: "copy",
archive: "archive",
delete: "trash",
});
const copyItem = items.find((item) => item.id === "copy");
expect(copyItem?.children?.map((child) => ({ id: child.id, icon: child.icon }))).toEqual([
{ id: "copy-path", icon: "folder" },
{ id: "copy-branch", icon: "git-branch" },
{ id: "copy-thread-id", icon: "hash" },
]);
expect(items.at(-1)).toMatchObject({ id: "delete", destructive: true, icon: "trash" });
expect(
buildThreadActionMenuItems({
...baseState,
isPinned: true,
isSettled: true,
isSnoozed: true,
}),
).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "unpin", icon: "pin-off" }),
expect.objectContaining({ id: "unsettle", icon: "circle-check" }),
expect.objectContaining({ id: "unsnooze", icon: "clock" }),
]),
);
});

it("disables snooze when the thread cannot snooze, keeping presets visible", () => {
const snooze = buildThreadActionMenuItems({ ...baseState, canSnoozeNow: false }).find(
(item) => item.id === "snooze",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/threadActionMenu.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export function buildThreadActionMenuItems(
id: "archive",
label: "Archive thread",
icon: "archive",
tone: "warning",
disabled: state.isRunning,
separatorBefore: true,
},
Expand Down
56 changes: 51 additions & 5 deletions apps/web/src/contextMenuFallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,11 @@ class FakeElement {

constructor(readonly tagName: string) {}

get isConnected() {
let current: FakeElement | null = this;
while (current?.parent) {
current = current.parent;
get isConnected(): boolean {
for (let current: FakeElement | null = this as FakeElement; current; current = current.parent) {
if (current.tagName === "body") return true;
}
return current?.tagName === "body";
return false;
}

appendChild(child: FakeElement) {
Expand Down Expand Up @@ -67,6 +66,9 @@ class FakeElement {

setAttribute(name: string, value: string) {
this.attributes.set(name, value);
if (name === "class") {
this.className = value;
}
}

dispatchEvent(event: FakeDomEvent) {
Expand Down Expand Up @@ -157,6 +159,10 @@ class FakeDocument {
return new FakeElement(tagName);
}

createElementNS(_ns: string, tagName: string) {
return new FakeElement(tagName);
}

addEventListener(type: string, listener: FakeListener) {
const existing = this.listeners.get(type) ?? [];
existing.push(listener);
Expand Down Expand Up @@ -234,6 +240,46 @@ describe("showContextMenuFallback", () => {
await expect(selectionPromise).resolves.toBeNull();
});

it("renders icons and applies appropriate tone classes and colors", async () => {
const selectionPromise = showContextMenuFallback([
{ id: "rename", label: "Rename", icon: "pencil" },
{ id: "archive", label: "Archive", tone: "warning", icon: "archive" },
{ id: "delete", label: "Delete", destructive: true, icon: "trash" },
{ id: "disabled-item", label: "Disabled", disabled: true, icon: "clock" },
]);

const renameButton = findButton("Rename");
const archiveButton = findButton("Archive");
const deleteButton = findButton("Delete");
const disabledButton = findButton("Disabled");

expect(renameButton).toBeTruthy();
expect(archiveButton).toBeTruthy();
expect(deleteButton).toBeTruthy();
expect(disabledButton).toBeTruthy();

const renameSvg = renameButton?.querySelectorAll("svg")[0];
const archiveSvg = archiveButton?.querySelectorAll("svg")[0];
const deleteSvg = deleteButton?.querySelectorAll("svg")[0];
const disabledSvg = disabledButton?.querySelectorAll("svg")[0];

expect(renameSvg).toBeTruthy();
expect(renameSvg?.className).toContain("text-muted-foreground");
expect(archiveSvg).toBeTruthy();
expect(archiveSvg?.className).toContain("text-warning-foreground");
expect(deleteSvg).toBeTruthy();
expect(deleteSvg?.className).toContain("text-destructive-foreground");
expect(disabledSvg).toBeTruthy();

expect(archiveButton?.style.color).toBe("var(--warning-foreground)");
expect(deleteButton?.style.color).toBe("var(--destructive-foreground)");
expect(disabledButton?.style.color).toBe("var(--contrast-muted-foreground)");
expect(disabledButton?.style.opacity).toBe("0.64");

dismissContextMenu();
await expect(selectionPromise).resolves.toBeNull();
});

it("resolves a clicked flat menu item", async () => {
const selectionPromise = showContextMenuFallback([
{ id: "rename", label: "Rename" },
Expand Down
Loading
Loading