From 9bdd88b72bc90a95367bc65fc2a296d33a0213cf Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Fri, 14 Aug 2026 02:09:53 +0200 Subject: [PATCH 01/11] fix(web): make terminal selection actions reliable --- .../desktop/src/electron/ElectronMenu.test.ts | 5 +- apps/desktop/src/electron/ElectronMenu.ts | 2 + .../components/ThreadTerminalDrawer.test.ts | 35 ++++ .../src/components/ThreadTerminalDrawer.tsx | 194 +++++++++++++----- apps/web/src/contextMenuFallback.test.ts | 104 +++++++++- apps/web/src/contextMenuFallback.ts | 109 ++++++++-- apps/web/src/localApi.test.ts | 52 ++++- apps/web/src/localApi.ts | 12 +- apps/web/src/terminal/ghostty/surface.ts | 14 +- packages/contracts/src/ipc.ts | 8 + 10 files changed, 463 insertions(+), 72 deletions(-) diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index 58870bbab1db..40e246722f2e 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -77,7 +77,7 @@ describe("ElectronMenu", () => { const electronMenu = yield* ElectronMenu.ElectronMenu; const selectedItemId = yield* electronMenu.showContextMenu({ window: makeWindow(), - items: [{ id: "copy", label: "Copy" }], + items: [{ id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C" }], position: Option.none(), }); @@ -98,7 +98,7 @@ describe("ElectronMenu", () => { const electronMenu = yield* ElectronMenu.ElectronMenu; const selectedItemId = yield* electronMenu.showContextMenu({ window: makeWindow(2), - items: [{ id: "copy", label: "Copy" }], + items: [{ id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C" }], position: Option.some({ x: 10.8, y: 20.2 }), }); @@ -107,6 +107,7 @@ describe("ElectronMenu", () => { assert.equal(popupOptions?.y, 40); assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0][0], { label: "Copy", + accelerator: "Ctrl+Shift+C", enabled: true, click: buildFromTemplateMock.mock.calls[0]?.[0][0].click, }); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 4d3e5a1c2416..eed4d9bceae4 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -76,6 +76,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM const normalizedItem: ContextMenuItem = { id: sourceItem.id, label: sourceItem.label, + ...(sourceItem.accelerator ? { accelerator: sourceItem.accelerator } : {}), destructive: sourceItem.destructive === true, disabled: sourceItem.disabled === true, }; @@ -150,6 +151,7 @@ export const make = Effect.gen(function* () { const itemOption: Electron.MenuItemConstructorOptions = { label: item.label, + ...(item.accelerator ? { accelerator: item.accelerator } : {}), enabled: !item.disabled, }; if (item.children && item.children.length > 0) { diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index e60d1d71678f..63c068ab4f30 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -4,10 +4,45 @@ import { resolveTerminalSelectionActionPosition, shouldHandleTerminalExit, shouldHandleTerminalSelectionMouseUp, + terminalContextMenuItems, terminalSelectionActionDelayForClickCount, terminalSelectionLineRange, } from "./ThreadTerminalDrawer"; +describe("terminalContextMenuItems", () => { + it("offers terminal actions and disables selection-only actions without a selection", () => { + expect(terminalContextMenuItems({ canAddToChat: false, canCopy: false }, "Win32")).toEqual([ + { id: "add-to-chat", label: "Add to chat", disabled: true }, + { id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C", disabled: true }, + { id: "paste", label: "Paste", accelerator: "Ctrl+Shift+V" }, + ]); + }); + + it("enables copy and add to chat when terminal text is selected", () => { + expect(terminalContextMenuItems({ canAddToChat: true, canCopy: true }, "Win32")).toEqual([ + { id: "add-to-chat", label: "Add to chat", disabled: false }, + { id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C", disabled: false }, + { id: "paste", label: "Paste", accelerator: "Ctrl+Shift+V" }, + ]); + }); + + it("keeps Copy enabled for selections that cannot be added to chat", () => { + expect(terminalContextMenuItems({ canAddToChat: false, canCopy: true }, "Win32")).toEqual([ + { id: "add-to-chat", label: "Add to chat", disabled: true }, + { id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C", disabled: false }, + { id: "paste", label: "Paste", accelerator: "Ctrl+Shift+V" }, + ]); + }); + + it("uses native Command shortcuts on macOS", () => { + expect(terminalContextMenuItems({ canAddToChat: true, canCopy: true }, "MacIntel")).toEqual([ + { id: "add-to-chat", label: "Add to chat", disabled: false }, + { id: "copy", label: "Copy", accelerator: "Command+C", disabled: false }, + { id: "paste", label: "Paste", accelerator: "Command+V" }, + ]); + }); +}); + describe("resolveTerminalSelectionActionPosition", () => { it("prefers the selection rect over the last pointer position", () => { expect( diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 87f0ed4ae706..f07d024f76c2 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -13,6 +13,7 @@ import { XIcon, } from "lucide-react"; import { + type ContextMenuItem, type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, @@ -32,7 +33,7 @@ import { } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; -import { cn } from "~/lib/utils"; +import { cn, isMacPlatform } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; import { GhosttyTerminalSurface, @@ -76,6 +77,31 @@ const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260; +export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste"; + +interface TerminalSelectionAction { + readonly position: { x: number; y: number }; + readonly clipboardText: string; + readonly selection: TerminalContextSelection | null; +} + +export function terminalContextMenuItems( + availability: { readonly canAddToChat: boolean; readonly canCopy: boolean }, + platform = navigator.platform, +): readonly ContextMenuItem[] { + const isMac = isMacPlatform(platform); + return [ + { id: "add-to-chat", label: "Add to chat", disabled: !availability.canAddToChat }, + { + id: "copy", + label: "Copy", + accelerator: isMac ? "Command+C" : "Ctrl+Shift+C", + disabled: !availability.canCopy, + }, + { id: "paste", label: "Paste", accelerator: isMac ? "Command+V" : "Ctrl+Shift+V" }, + ]; +} + function maxDrawerHeight(): number { if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT; return Math.max(MIN_DRAWER_HEIGHT, Math.floor(window.innerHeight * MAX_DRAWER_HEIGHT_RATIO)); @@ -440,6 +466,7 @@ export function TerminalViewport({ onData: (data) => handleData(data), onResize: (cols, rows) => void resizeTerminal(cols, rows), onSelectionChange: () => handleSelectionChange(), + onContextMenu: (event) => handleContextMenu(event), beforeKey: (event) => handleBeforeKey(event), onLinkActivate: (text, event) => handleLinkActivate(text, event), }; @@ -472,8 +499,12 @@ export function TerminalViewport({ synchronizeTerminalStatus(terminal, latestSession.status); if (autoFocus) window.requestAnimationFrame(() => terminal.focus()); + let terminalMenuAbortController: AbortController | null = null; const clearSelectionAction = () => { selectionActionRequestIdRef.current += 1; + terminalMenuAbortController?.abort(); + terminalMenuAbortController = null; + selectionActionMenuOpenRef.current = false; if (selectionActionTimerRef.current !== null) { window.clearTimeout(selectionActionTimerRef.current); selectionActionTimerRef.current = null; @@ -481,11 +512,7 @@ export function TerminalViewport({ }; setupCleanups.push(clearSelectionAction); - const readSelectionAction = (): { - position: { x: number; y: number }; - clipboardText: string; - selection: TerminalContextSelection; - } | null => { + const readSelectionAction = (): TerminalSelectionAction | null => { const activeTerminal = terminalRef.current; const mountElement = containerRef.current; if (!activeTerminal || !mountElement || !activeTerminal.hasSelection()) { @@ -494,10 +521,9 @@ export function TerminalViewport({ const selectionText = activeTerminal.getSelection(); const selectionPosition = activeTerminal.getSelectionPosition(); const normalizedText = selectionText.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, ""); - if (!selectionPosition || normalizedText.length === 0) { + if (selectionText.length === 0) { return null; } - const { lineStart, lineEnd } = terminalSelectionLineRange(selectionPosition); const bounds = mountElement.getBoundingClientRect(); const position = resolveTerminalSelectionActionPosition({ bounds, @@ -507,21 +533,67 @@ export function TerminalViewport({ return { position, clipboardText: selectionText, - selection: { - terminalId, - terminalLabel: readTerminalLabel(), - lineStart, - lineEnd, - text: normalizedText, - }, + selection: + selectionPosition && normalizedText.length > 0 + ? { + terminalId, + terminalLabel: readTerminalLabel(), + ...terminalSelectionLineRange(selectionPosition), + text: normalizedText, + } + : null, }; }; - const showSelectionAction = async () => { - if (!localApi) { - clearSelectionAction(); - return; + const performTerminalMenuAction = async ( + clicked: TerminalContextMenuAction | null, + selectionAction: TerminalSelectionAction | null, + isCurrent: () => boolean = () => true, + ) => { + switch (clicked) { + case "add-to-chat": + if (!selectionAction?.selection || !isCurrent()) return; + handleAddTerminalContext(selectionAction.selection); + terminalRef.current?.clearSelection(); + return; + case "copy": + if (!selectionAction || !isCurrent()) return; + try { + await writeTextToClipboard(selectionAction.clipboardText, "terminal selection"); + } catch (error) { + if (!isCurrent()) return; + const activeTerminal = terminalRef.current; + if (activeTerminal) { + writeSystemMessage( + activeTerminal, + error instanceof Error ? error.message : "Unable to copy terminal selection", + ); + } + } + return; + case "paste": + if (!isCurrent()) return; + try { + const text = await navigator.clipboard.readText(); + if (isCurrent()) terminalRef.current?.paste(text); + } catch (error) { + if (!isCurrent()) return; + const activeTerminal = terminalRef.current; + if (activeTerminal) { + writeSystemMessage( + activeTerminal, + error instanceof Error ? error.message : "Unable to read clipboard text", + ); + } + } + return; + case null: + return; } + }; + + const showSelectionAction = async () => { + if (!localApi) return; if (selectionActionMenuOpenRef.current) { return; } @@ -531,47 +603,69 @@ export function TerminalViewport({ return; } const requestId = ++selectionActionRequestIdRef.current; + terminalMenuAbortController?.abort(); + const abortController = new AbortController(); + terminalMenuAbortController = abortController; selectionActionMenuOpenRef.current = true; const clicked = await localApi.contextMenu .show( - [ - { id: "add-to-chat", label: "Add to chat" }, - { id: "copy", label: "Copy" }, - ], + terminalContextMenuItems({ + canAddToChat: nextAction.selection !== null, + canCopy: true, + }), nextAction.position, + { presentation: "styled", signal: abortController.signal }, ) .finally(() => { - selectionActionMenuOpenRef.current = false; + if (terminalMenuAbortController === abortController) { + terminalMenuAbortController = null; + selectionActionMenuOpenRef.current = false; + terminalRef.current?.focus(); + } }); if (requestId !== selectionActionRequestIdRef.current || clicked === null) { return; } - switch (clicked) { - case "add-to-chat": - handleAddTerminalContext(nextAction.selection); - terminalRef.current?.clearSelection(); - terminalRef.current?.focus(); - return; - case "copy": - try { - await writeTextToClipboard(nextAction.clipboardText, "terminal selection"); - } catch (error) { - if (requestId !== selectionActionRequestIdRef.current) { - return; - } - const activeTerminal = terminalRef.current; - if (activeTerminal) { - writeSystemMessage( - activeTerminal, - error instanceof Error ? error.message : "Unable to copy terminal selection", - ); - } - } - if (requestId === selectionActionRequestIdRef.current) { + await performTerminalMenuAction( + clicked, + nextAction, + () => + requestId === selectionActionRequestIdRef.current && !abortController.signal.aborted, + ); + }; + + const showTerminalContextMenu = async (event: MouseEvent) => { + if (!localApi) return; + const selectionAction = readSelectionAction(); + const requestId = ++selectionActionRequestIdRef.current; + terminalMenuAbortController?.abort(); + const abortController = new AbortController(); + terminalMenuAbortController = abortController; + selectionActionMenuOpenRef.current = true; + const clicked = await localApi.contextMenu + .show( + terminalContextMenuItems({ + canAddToChat: selectionAction?.selection != null, + canCopy: selectionAction !== null, + }), + { + x: event.clientX, + y: event.clientY, + }, + { presentation: "styled", signal: abortController.signal }, + ) + .finally(() => { + if (terminalMenuAbortController === abortController) { + terminalMenuAbortController = null; + selectionActionMenuOpenRef.current = false; terminalRef.current?.focus(); } - return; - } + }); + await performTerminalMenuAction(clicked, selectionAction, () => { + return ( + requestId === selectionActionRequestIdRef.current && !abortController.signal.aborted + ); + }); }; const sendTerminalInput = async (data: string, fallbackError: string) => { @@ -667,6 +761,10 @@ export function TerminalViewport({ })(); } + function handleContextMenu(event: MouseEvent): void { + clearSelectionAction(); + void showTerminalContextMenu(event); + } function handleData(data: string): void { void (async () => { const result = await writeTerminal(data); diff --git a/apps/web/src/contextMenuFallback.test.ts b/apps/web/src/contextMenuFallback.test.ts index d36f1a1d11b6..5e9e5ba341c6 100644 --- a/apps/web/src/contextMenuFallback.test.ts +++ b/apps/web/src/contextMenuFallback.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { dismissContextMenu, showContextMenuFallback } from "./contextMenuFallback"; +import { + contextMenuAcceleratorAction, + dismissContextMenu, + showContextMenuFallback, +} from "./contextMenuFallback"; type FakeListener = (event: FakeDomEvent) => void; @@ -17,6 +21,8 @@ class FakeDomEvent { preventDefault() { this.defaultPrevented = true; } + + stopPropagation() {} } class FakeElement { @@ -139,6 +145,13 @@ class FakeDocument { } } + dispatchEvent(event: FakeDomEvent) { + for (const listener of this.listeners.get(event.type) ?? []) { + listener(event); + } + return true; + } + querySelectorAll(tagName: string) { return this.body.querySelectorAll(tagName); } @@ -196,6 +209,18 @@ describe("showContextMenuFallback", () => { await expect(selectionPromise).resolves.toBe("rename"); }); + it("renders shortcut hints next to menu labels", () => { + void showContextMenuFallback([ + { id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C" }, + { id: "paste", label: "Paste", accelerator: "Command+V" }, + ]); + + const shortcuts = (document as unknown as FakeDocument) + .querySelectorAll("kbd") + .map((element) => element.textContent); + expect(shortcuts).toEqual(["Ctrl+Shift+C", "⌘V"]); + }); + it("ignores a click from the gesture that opened the menu", async () => { let enablePointerSelection: ((time: number) => void) | undefined; vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => { @@ -213,6 +238,40 @@ describe("showContextMenuFallback", () => { await expect(selectionPromise).resolves.toBe("rename"); }); + it("closes without an action when its owner aborts", async () => { + const abortController = new AbortController(); + const selectionPromise = showContextMenuFallback( + [{ id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C" }], + undefined, + { signal: abortController.signal }, + ); + + abortController.abort(); + + await expect(selectionPromise).resolves.toBeNull(); + expect((document as unknown as FakeDocument).querySelectorAll("button")).toHaveLength(0); + }); + + it("closes when the page or terminal is scrolled", async () => { + const selectionPromise = showContextMenuFallback([{ id: "copy", label: "Copy" }]); + + (document as unknown as FakeDocument).dispatchEvent(new FakeDomEvent("wheel")); + + await expect(selectionPromise).resolves.toBeNull(); + expect((document as unknown as FakeDocument).querySelectorAll("button")).toHaveLength(0); + }); + + it("stays open while its own scrollable contents are scrolled", () => { + void showContextMenuFallback([{ id: "copy", label: "Copy" }]); + const menu = (document as unknown as FakeDocument).body.children[0]; + + (document as unknown as FakeDocument).dispatchEvent( + new FakeDomEvent("wheel", { target: menu }), + ); + + expect(findButton("Copy")).toBeTruthy(); + }); + it("opens nested submenus and resolves the clicked leaf id", async () => { const selectionPromise = showContextMenuFallback([ { @@ -270,3 +329,46 @@ describe("dismissContextMenu", () => { await expect(secondPromise).resolves.toBeNull(); }); }); + +describe("contextMenuAcceleratorAction", () => { + const event = (overrides: Partial[1]> = {}) => ({ + altKey: false, + ctrlKey: false, + key: "", + metaKey: false, + shiftKey: false, + ...overrides, + }); + + it("matches Windows and Linux terminal shortcuts", () => { + const items = [ + { id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C" }, + { id: "paste", label: "Paste", accelerator: "Ctrl+Shift+V" }, + ] as const; + expect( + contextMenuAcceleratorAction(items, event({ key: "c", ctrlKey: true, shiftKey: true })), + ).toBe("copy"); + expect( + contextMenuAcceleratorAction(items, event({ key: "V", ctrlKey: true, shiftKey: true })), + ).toBe("paste"); + }); + + it("matches macOS Command shortcuts", () => { + expect( + contextMenuAcceleratorAction( + [{ id: "copy", label: "Copy", accelerator: "Command+C" }], + event({ key: "c", metaKey: true }), + ), + ).toBe("copy"); + }); + + it("does not activate disabled or partially matched shortcuts", () => { + const items = [ + { id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C", disabled: true }, + ] as const; + expect( + contextMenuAcceleratorAction(items, event({ key: "c", ctrlKey: true, shiftKey: true })), + ).toBeNull(); + expect(contextMenuAcceleratorAction(items, event({ key: "c", ctrlKey: true }))).toBeNull(); + }); +}); diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 769826e3999c..9b947f96d948 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -116,6 +116,33 @@ export function dismissContextMenu(): void { activeContextMenuDismiss = null; } +export function contextMenuAcceleratorAction( + items: readonly ContextMenuItem[], + event: Pick, +): T | null { + for (const item of items) { + if (item.disabled) continue; + if (item.children) { + const childAction = contextMenuAcceleratorAction(item.children, event); + if (childAction !== null) return childAction; + } + if (!item.accelerator) continue; + + const parts = item.accelerator.toLowerCase().split("+"); + const key = parts.at(-1); + if ( + event.key.toLowerCase() === key && + event.altKey === parts.includes("alt") && + event.ctrlKey === parts.includes("ctrl") && + event.metaKey === (parts.includes("command") || parts.includes("cmd")) && + event.shiftKey === parts.includes("shift") + ) { + return item.id; + } + } + return null; +} + /** * Imperative DOM-based context menu for non-Electron environments. * Supports nested submenus and resolves with the clicked leaf item id. @@ -123,8 +150,13 @@ export function dismissContextMenu(): void { export function showContextMenuFallback( items: readonly ContextMenuItem[], position?: { x: number; y: number }, + options?: { readonly signal?: AbortSignal }, ): Promise { return new Promise((resolve) => { + if (options?.signal?.aborted) { + resolve(null); + return; + } const menuStack: HTMLDivElement[] = []; let isDisposed = false; let canDismissFromPointer = false; @@ -139,19 +171,30 @@ export function showContextMenuFallback( if (activeContextMenuDismiss === dismiss) { activeContextMenuDismiss = null; } - document.removeEventListener("keydown", onKeyDown); + document.removeEventListener("keydown", onKeyDown, true); document.removeEventListener("pointerdown", onPointerDown, true); document.removeEventListener("contextmenu", onContextMenu, true); + document.removeEventListener("wheel", onWheel, true); + options?.signal?.removeEventListener("abort", onAbort); for (const menu of menuStack) { menu.remove(); } resolve(result); }; + const onAbort = () => cleanup(null); + const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); cleanup(null); + return; + } + const acceleratorAction = contextMenuAcceleratorAction(items, event); + if (acceleratorAction !== null) { + event.preventDefault(); + event.stopPropagation(); + cleanup(acceleratorAction); } }; @@ -170,6 +213,13 @@ export function showContextMenuFallback( cleanup(null); }; + const onWheel = (event: WheelEvent) => { + if (isNodeWithinMenuStack(event.target, menuStack)) { + return; + } + cleanup(null); + }; + const closeMenusFromLevel = (level: number) => { while (menuStack.length > level) { menuStack.pop()?.remove(); @@ -184,20 +234,27 @@ export function showContextMenuFallback( ) => { closeMenusFromLevel(level); + const usesCompactShortcutLayout = entries.some((item) => item.accelerator); const menu = document.createElement("div"); - menu.className = - "dropdown-glass fixed z-[10000] min-w-32 max-w-sm overflow-hidden rounded-lg bg-clip-padding text-popover-foreground outline-none"; - menu.style.cssText = - "position:fixed;z-index:10000;min-width:8rem;max-width:24rem;overflow:hidden;border-radius:var(--radius-lg);background-clip:padding-box;color:var(--popover-foreground);outline:none;pointer-events:auto;"; + menu.className = `dropdown-glass fixed z-[10000] max-w-sm overflow-hidden bg-clip-padding text-popover-foreground outline-none ${ + usesCompactShortcutLayout ? "min-w-[14.25rem] rounded-[11px]" : "min-w-32 rounded-lg" + }`; + menu.style.cssText = `position:fixed;z-index:10000;min-width:${ + usesCompactShortcutLayout ? "min(14.25rem,calc(100vw - 0.75rem))" : "8rem" + };max-width:24rem;overflow:hidden;border-radius:${ + usesCompactShortcutLayout ? "0.6875rem" : "var(--radius-lg)" + };background-clip:padding-box;color:var(--popover-foreground);outline:none;pointer-events:auto;`; menu.style.left = `${preferredLeft}px`; menu.style.top = `${preferredTop}px`; menu.dataset.level = String(level); const inner = document.createElement("div"); inner.className = - "max-h-[min(24rem,70vh)] min-w-0 max-w-sm overflow-y-auto overflow-x-hidden p-1"; - inner.style.cssText = - "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;"; + "max-h-[min(24rem,70vh)] min-w-0 max-w-sm overflow-y-auto overflow-x-hidden p-1 data-[compact=true]:p-[5px]"; + inner.dataset.compact = String(usesCompactShortcutLayout); + inner.style.cssText = `max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:${ + usesCompactShortcutLayout ? "0.3125rem" : "0.25rem" + };`; for (const item of entries) { if (item.header === true) { @@ -216,21 +273,31 @@ export function showContextMenuFallback( button.type = "button"; const isDisabled = item.disabled === true; button.disabled = isDisabled; - const rowBase = - "flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-left outline-none transition-colors sm:min-h-7 sm:text-sm min-h-8 text-base"; + const rowBase = usesCompactShortcutLayout + ? "flex min-h-[2.125rem] w-full cursor-default select-none items-center gap-[9px] rounded-[7px] px-2 py-1.5 text-left text-xs outline-none transition-colors" + : "flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-left outline-none transition-colors sm:min-h-7 sm:text-sm min-h-8 text-base"; button.className = isDisabled - ? `${rowBase} pointer-events-none cursor-not-allowed text-muted-foreground opacity-64` + ? `${rowBase} pointer-events-none cursor-not-allowed text-muted-foreground ${ + usesCompactShortcutLayout ? "opacity-52" : "opacity-64" + }` : isLeafDestructive ? `${rowBase} text-destructive-foreground hover:bg-destructive/10 hover:text-destructive-foreground` : `${rowBase} text-foreground hover:bg-accent hover:text-accent-foreground`; - button.style.cssText = - "display:flex;width:100%;min-height:1.75rem;align-items:center;gap:0.5rem;border:0;border-radius:var(--radius-sm);background:transparent;padding:0.25rem 0.5rem;color:var(--foreground);font-family:var(--font-sans,system-ui,sans-serif);font-size:0.875rem;line-height:1.25rem;text-align:left;cursor:default;"; + button.style.cssText = `display:flex;width:100%;min-height:${ + usesCompactShortcutLayout ? "2.125rem" : "1.75rem" + };align-items:center;gap:${usesCompactShortcutLayout ? "0.5625rem" : "0.5rem"};border:0;border-radius:${ + usesCompactShortcutLayout ? "0.4375rem" : "var(--radius-sm)" + };background:transparent;padding:${ + usesCompactShortcutLayout ? "0.375rem 0.5rem" : "0.25rem 0.5rem" + };color:var(--foreground);font-family:var(--font-sans,system-ui,sans-serif);font-size:${ + usesCompactShortcutLayout ? "0.75rem" : "0.875rem" + };font-weight:${usesCompactShortcutLayout ? "450" : "400"};line-height:1.25rem;text-align:left;cursor:default;`; if (isLeafDestructive) { button.style.color = "var(--destructive-foreground)"; } if (isDisabled) { button.style.color = "var(--muted-foreground)"; - button.style.opacity = "0.64"; + button.style.opacity = usesCompactShortcutLayout ? "0.52" : "0.64"; button.style.pointerEvents = "none"; } @@ -246,6 +313,16 @@ export function showContextMenuFallback( label.textContent = item.label; button.appendChild(label); + if (item.accelerator) { + const accelerator = document.createElement("kbd"); + accelerator.className = + "ms-auto shrink-0 rounded border border-border/70 bg-muted/70 px-[5px] font-mono text-[10px] text-muted-foreground leading-[1.55]"; + accelerator.style.cssText = + "margin-inline-start:auto;flex-shrink:0;border:1px solid color-mix(in srgb,var(--border) 70%,transparent);border-radius:0.25rem;background:color-mix(in srgb,var(--muted) 70%,transparent);padding:0 0.3125rem;color:var(--muted-foreground);font-family:var(--font-mono,monospace);font-size:0.625rem;line-height:1.55;"; + accelerator.textContent = item.accelerator.replace(/^Command\+/u, "⌘"); + button.appendChild(accelerator); + } + if (hasChildren) { const chevron = document.createElement("span"); chevron.className = "ms-auto shrink-0 text-muted-foreground/80 text-sm leading-none"; @@ -315,9 +392,11 @@ export function showContextMenuFallback( }); }; - document.addEventListener("keydown", onKeyDown); + document.addEventListener("keydown", onKeyDown, true); document.addEventListener("pointerdown", onPointerDown, true); document.addEventListener("contextmenu", onContextMenu, true); + document.addEventListener("wheel", onWheel, { capture: true, passive: true }); + options?.signal?.addEventListener("abort", onAbort, { once: true }); openMenu(items, position?.x ?? 0, position?.y ?? 0, 0); // Only one fallback menu can be open at a time: a new show must dismiss // any prior one, or its DOM and listeners leak and close() can only ever diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 9220252cb20e..ac8276749294 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -11,6 +11,7 @@ const showContextMenuFallbackMock = ( items: readonly ContextMenuItem[], position?: { x: number; y: number }, + options?: { readonly signal?: AbortSignal }, ) => Promise >(); const dismissContextMenuMock = vi.fn<() => void>(); @@ -84,7 +85,7 @@ describe("LocalApi", () => { const items = [{ id: "rename", label: "Rename" }] as const; await expect(createLocalApi().contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename"); - expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 }); + expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 }, undefined); }); it("dismisses an open browser context menu without a desktop bridge", async () => { @@ -142,6 +143,55 @@ describe("LocalApi", () => { expect(setClientSettings).toHaveBeenCalledWith(DEFAULT_CLIENT_SETTINGS); }); + it("keeps context menus native on desktop when fallback cancellation is available", async () => { + const showContextMenu = vi.fn().mockResolvedValue("copy"); + showContextMenuFallbackMock.mockResolvedValue("paste"); + testWindow().desktopBridge = { showContextMenu } as unknown as DesktopBridge; + const abortController = new AbortController(); + const { createLocalApi } = await import("./localApi"); + const items = [{ id: "paste", label: "Paste", accelerator: "Ctrl+Shift+V" }] as const; + + await expect( + createLocalApi().contextMenu.show( + items, + { x: 4, y: 5 }, + { + signal: abortController.signal, + }, + ), + ).resolves.toBe("copy"); + expect(showContextMenu).toHaveBeenCalledWith(items, { x: 4, y: 5 }); + expect(showContextMenuFallbackMock).not.toHaveBeenCalled(); + }); + + it("uses the styled context menu on desktop when requested", async () => { + const showContextMenu = vi.fn().mockResolvedValue("copy"); + showContextMenuFallbackMock.mockResolvedValue("paste"); + testWindow().desktopBridge = { showContextMenu } as unknown as DesktopBridge; + const abortController = new AbortController(); + const { createLocalApi } = await import("./localApi"); + const items = [{ id: "paste", label: "Paste", accelerator: "Ctrl+Shift+V" }] as const; + + await expect( + createLocalApi().contextMenu.show( + items, + { x: 4, y: 5 }, + { + presentation: "styled", + signal: abortController.signal, + }, + ), + ).resolves.toBe("paste"); + expect(showContextMenu).not.toHaveBeenCalled(); + expect(showContextMenuFallbackMock).toHaveBeenCalledWith( + items, + { x: 4, y: 5 }, + { + signal: abortController.signal, + }, + ); + }); + it("persists client settings in browser storage", async () => { const { createLocalApi } = await import("./localApi"); const api = createLocalApi(); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 863388106a3e..1f2e1ba900fb 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -35,11 +35,19 @@ function createBrowserLocalApi(): LocalApi { show: async ( items: readonly ContextMenuItem[], position?: { x: number; y: number }, + options?: { + readonly presentation?: "native" | "styled"; + readonly signal?: AbortSignal; + }, ): Promise => { - if (window.desktopBridge) { + if (window.desktopBridge && options?.presentation !== "styled") { return window.desktopBridge.showContextMenu(items, position) as Promise; } - return showContextMenuFallback(items, position); + return showContextMenuFallback( + items, + position, + options?.signal ? { signal: options.signal } : undefined, + ); }, // A native desktop menu blocks keyboard input and closes on outside // interaction, so nothing to do there; the DOM fallback needs an explicit diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 8a9c796b948b..81fc58e6e0e2 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -463,6 +463,7 @@ export interface GhosttyTerminalSurfaceOptions { readonly onData: (data: string) => void; readonly onResize: (cols: number, rows: number) => void; readonly onSelectionChange: () => void; + readonly onContextMenu?: (event: MouseEvent) => void; readonly beforeKey: (event: KeyboardEvent) => boolean; readonly onLinkActivate: (text: string, event: MouseEvent) => void; } @@ -846,6 +847,11 @@ export class GhosttyTerminalSurface { this.requestRender(); } + paste(data: string): void { + if (this.disposed || data.length === 0) return; + this.options.onData(this.core.encodePaste(data)); + } + scrollToBottom(): void { this.core.scrollToBottom(); this.forceFullRender = true; @@ -970,7 +976,7 @@ export class GhosttyTerminalSurface { (text) => { if (this.disposed || this.pasteShortcutToken !== token) return; this.pasteShortcutToken += 1; - if (text.length > 0) this.options.onData(this.core.encodePaste(text)); + this.paste(text); }, () => { // Clipboard read denied; the native paste event remains the path. @@ -1061,7 +1067,7 @@ export class GhosttyTerminalSurface { // The native paste won the race with actual text; a pending clipboard read // must not double. An empty native paste leaves the read as the only path. this.pasteShortcutToken += 1; - this.options.onData(this.core.encodePaste(data)); + this.paste(data); }; private readonly onCompositionStart = () => { @@ -1370,9 +1376,11 @@ export class GhosttyTerminalSurface { }; private readonly onContextMenu = (event: MouseEvent) => { + event.preventDefault(); if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { - event.preventDefault(); + return; } + this.options.onContextMenu?.(event); }; private readonly onScrollbarPointerDown = (event: PointerEvent) => { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index f99d4d34b4d2..e9cfccee23fa 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -104,6 +104,8 @@ import type { export interface ContextMenuItem { id: T; label: string; + /** Shortcut notation displayed alongside the label by menu hosts that support accelerators. */ + accelerator?: string; destructive?: boolean; disabled?: boolean; /** Renders as a non-interactive section header label. Web fallback only — stripped on desktop native menus. */ @@ -116,6 +118,7 @@ export interface ContextMenuItem { export interface ContextMenuItemSchemaType { readonly id: string; readonly label: string; + readonly accelerator?: string; readonly destructive?: boolean; readonly disabled?: boolean; readonly header?: boolean; @@ -126,6 +129,7 @@ export interface ContextMenuItemSchemaType { export const ContextMenuItemSchema: Schema.Codec = Schema.Struct({ id: Schema.String, label: Schema.String, + accelerator: Schema.optionalKey(Schema.String), destructive: Schema.optionalKey(Schema.Boolean), disabled: Schema.optionalKey(Schema.Boolean), header: Schema.optionalKey(Schema.Boolean), @@ -1188,6 +1192,10 @@ export interface LocalApi { show: ( items: readonly ContextMenuItem[], position?: { x: number; y: number }, + options?: { + readonly presentation?: "native" | "styled"; + readonly signal?: AbortSignal; + }, ) => Promise; close: () => Promise; }; From f3672690e106549370f9089223460ac7cf2b0c96 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Fri, 14 Aug 2026 02:27:59 +0200 Subject: [PATCH 02/11] fix(web): prevent duplicate terminal paste --- apps/web/src/terminal/ghostty/surface.test.ts | 24 +++++++++++++++++++ apps/web/src/terminal/ghostty/surface.ts | 3 +-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 18cf95901209..6c78e56b4a9d 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -4,6 +4,7 @@ import type { GhosttyCell, GhosttyRow } from "./core"; import { DEFAULT_TERMINAL_FONT_FAMILY, DEFAULT_TERMINAL_FONT_SIZE, + GhosttyTerminalSurface, advanceTerminalSelectionClickSequence, ghosttyMouseButton, isTerminalAltGraphText, @@ -28,6 +29,17 @@ import { terminalWheelDeltaRows, } from "./surface"; +const terminalSurfaceForPaste = (onData: (data: string) => void) => { + const surface = Object.create(GhosttyTerminalSurface.prototype) as GhosttyTerminalSurface; + Object.assign(surface, { + disposed: false, + pasteShortcutToken: 0, + options: { onData }, + core: { encodePaste: (data: string) => `[paste]${data}` }, + }); + return surface; +}; + const cell = (text: string): GhosttyCell => ({ text, wide: 0, @@ -255,6 +267,18 @@ describe("isTerminalPasteShortcut", () => { }); }); +describe("GhosttyTerminalSurface paste", () => { + it("invalidates an outstanding keyboard clipboard read", () => { + const onData = vi.fn(); + const surface = terminalSurfaceForPaste(onData); + + surface.paste("from context menu"); + + expect(onData).toHaveBeenCalledWith("[paste]from context menu"); + expect((surface as unknown as { pasteShortcutToken: number }).pasteShortcutToken).toBe(1); + }); +}); + describe("isTerminalCompositionCommitInput", () => { it("identifies browser composition follow-up input", () => { expect(isTerminalCompositionCommitInput({ inputType: "" })).toBe(true); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 81fc58e6e0e2..e27b3c10ad9c 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -849,6 +849,7 @@ export class GhosttyTerminalSurface { paste(data: string): void { if (this.disposed || data.length === 0) return; + this.pasteShortcutToken += 1; this.options.onData(this.core.encodePaste(data)); } @@ -975,7 +976,6 @@ export class GhosttyTerminalSurface { void clipboard.readText().then( (text) => { if (this.disposed || this.pasteShortcutToken !== token) return; - this.pasteShortcutToken += 1; this.paste(text); }, () => { @@ -1066,7 +1066,6 @@ export class GhosttyTerminalSurface { if (data.length === 0) return; // The native paste won the race with actual text; a pending clipboard read // must not double. An empty native paste leaves the read as the only path. - this.pasteShortcutToken += 1; this.paste(data); }; From 3c6c91242d920be2d32d183b4a15bf680f6a93a7 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Fri, 14 Aug 2026 02:32:08 +0200 Subject: [PATCH 03/11] fix(web): preserve focus when terminal menu closes --- .../components/ThreadTerminalDrawer.test.ts | 10 ++++++ .../src/components/ThreadTerminalDrawer.tsx | 31 +++++++++++-------- apps/web/src/contextMenuFallback.test.ts | 10 ++++++ apps/web/src/contextMenuFallback.ts | 4 ++- 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index 63c068ab4f30..75fec2e45e94 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -4,11 +4,21 @@ import { resolveTerminalSelectionActionPosition, shouldHandleTerminalExit, shouldHandleTerminalSelectionMouseUp, + shouldRestoreTerminalFocusAfterMenuAction, terminalContextMenuItems, terminalSelectionActionDelayForClickCount, terminalSelectionLineRange, } from "./ThreadTerminalDrawer"; +describe("shouldRestoreTerminalFocusAfterMenuAction", () => { + it("restores focus only after terminal-local actions", () => { + expect(shouldRestoreTerminalFocusAfterMenuAction("copy")).toBe(true); + expect(shouldRestoreTerminalFocusAfterMenuAction("paste")).toBe(true); + expect(shouldRestoreTerminalFocusAfterMenuAction("add-to-chat")).toBe(false); + expect(shouldRestoreTerminalFocusAfterMenuAction(null)).toBe(false); + }); +}); + describe("terminalContextMenuItems", () => { it("offers terminal actions and disables selection-only actions without a selection", () => { expect(terminalContextMenuItems({ canAddToChat: false, canCopy: false }, "Win32")).toEqual([ diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index f07d024f76c2..5d155ae7f8fb 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -79,6 +79,12 @@ const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260; export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste"; +export function shouldRestoreTerminalFocusAfterMenuAction( + action: TerminalContextMenuAction | null, +): boolean { + return action === "copy" || action === "paste"; +} + interface TerminalSelectionAction { readonly position: { x: number; y: number }; readonly clipboardText: string; @@ -620,18 +626,17 @@ export function TerminalViewport({ if (terminalMenuAbortController === abortController) { terminalMenuAbortController = null; selectionActionMenuOpenRef.current = false; - terminalRef.current?.focus(); } }); if (requestId !== selectionActionRequestIdRef.current || clicked === null) { return; } - await performTerminalMenuAction( - clicked, - nextAction, - () => - requestId === selectionActionRequestIdRef.current && !abortController.signal.aborted, - ); + const isCurrent = () => + requestId === selectionActionRequestIdRef.current && !abortController.signal.aborted; + await performTerminalMenuAction(clicked, nextAction, isCurrent); + if (shouldRestoreTerminalFocusAfterMenuAction(clicked) && isCurrent()) { + terminalRef.current?.focus(); + } }; const showTerminalContextMenu = async (event: MouseEvent) => { @@ -658,14 +663,14 @@ export function TerminalViewport({ if (terminalMenuAbortController === abortController) { terminalMenuAbortController = null; selectionActionMenuOpenRef.current = false; - terminalRef.current?.focus(); } }); - await performTerminalMenuAction(clicked, selectionAction, () => { - return ( - requestId === selectionActionRequestIdRef.current && !abortController.signal.aborted - ); - }); + const isCurrent = () => + requestId === selectionActionRequestIdRef.current && !abortController.signal.aborted; + await performTerminalMenuAction(clicked, selectionAction, isCurrent); + if (shouldRestoreTerminalFocusAfterMenuAction(clicked) && isCurrent()) { + terminalRef.current?.focus(); + } }; const sendTerminalInput = async (data: string, fallbackError: string) => { diff --git a/apps/web/src/contextMenuFallback.test.ts b/apps/web/src/contextMenuFallback.test.ts index 5e9e5ba341c6..daf7430e4ca1 100644 --- a/apps/web/src/contextMenuFallback.test.ts +++ b/apps/web/src/contextMenuFallback.test.ts @@ -334,6 +334,7 @@ describe("contextMenuAcceleratorAction", () => { const event = (overrides: Partial[1]> = {}) => ({ altKey: false, ctrlKey: false, + isComposing: false, key: "", metaKey: false, shiftKey: false, @@ -371,4 +372,13 @@ describe("contextMenuAcceleratorAction", () => { ).toBeNull(); expect(contextMenuAcceleratorAction(items, event({ key: "c", ctrlKey: true }))).toBeNull(); }); + + it("does not activate shortcuts during IME composition", () => { + expect( + contextMenuAcceleratorAction( + [{ id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C" }], + event({ key: "c", ctrlKey: true, shiftKey: true, isComposing: true }), + ), + ).toBeNull(); + }); }); diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 9b947f96d948..f664b126a0f7 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -118,8 +118,9 @@ export function dismissContextMenu(): void { export function contextMenuAcceleratorAction( items: readonly ContextMenuItem[], - event: Pick, + event: Pick, ): T | null { + if (event.isComposing) return null; for (const item of items) { if (item.disabled) continue; if (item.children) { @@ -185,6 +186,7 @@ export function showContextMenuFallback( const onAbort = () => cleanup(null); const onKeyDown = (event: KeyboardEvent) => { + if (event.isComposing) return; if (event.key === "Escape") { event.preventDefault(); cleanup(null); From 61755c5fee65e58bae280e0427f7c1f455229803 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Fri, 14 Aug 2026 22:32:41 +0200 Subject: [PATCH 04/11] fix(web): close styled terminal menu on desktop --- apps/web/src/localApi.test.ts | 9 +++++++++ apps/web/src/localApi.ts | 9 +++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index ac8276749294..6ed4641ad9b4 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -96,6 +96,15 @@ describe("LocalApi", () => { expect(dismissContextMenuMock).toHaveBeenCalledOnce(); }); + it("dismisses a styled context menu with a desktop bridge", async () => { + testWindow().desktopBridge = {} as DesktopBridge; + const { createLocalApi } = await import("./localApi"); + + await createLocalApi().contextMenu.close(); + + expect(dismissContextMenuMock).toHaveBeenCalledOnce(); + }); + it("uses the themed confirmation host when it is available", async () => { requestConfirmDialogMock.mockResolvedValue(true); const { createLocalApi } = await import("./localApi"); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 1f2e1ba900fb..039381e8099b 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -49,13 +49,10 @@ function createBrowserLocalApi(): LocalApi { options?.signal ? { signal: options.signal } : undefined, ); }, - // A native desktop menu blocks keyboard input and closes on outside - // interaction, so nothing to do there; the DOM fallback needs an explicit - // dismiss when the state behind it goes away. + // Native desktop menus close on outside interaction; dismissing the DOM + // fallback is still necessary for callers that requested styled presentation. close: async () => { - if (!window.desktopBridge) { - dismissContextMenu(); - } + dismissContextMenu(); }, }, persistence: { From bff9ef4e541d47dcac8f79f4cb5b3a570591972e Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Sun, 23 Aug 2026 20:58:52 +0200 Subject: [PATCH 05/11] fix(web): align terminal menu interactions --- .../src/components/ThreadTerminalDrawer.tsx | 6 +- apps/web/src/contextMenuFallback.test.ts | 14 +++-- apps/web/src/contextMenuFallback.ts | 56 +++++++++---------- apps/web/src/localApi.test.ts | 2 + apps/web/src/localApi.ts | 8 ++- packages/contracts/src/ipc.ts | 1 + 6 files changed, 50 insertions(+), 37 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 221cf3deaf20..b653a4dec95a 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -499,7 +499,7 @@ export function TerminalViewport({ // while `create` is still awaiting WASM — before the handler below it // exists. The ref is only assigned once that setup has run. onContextMenu: (event) => { - if (terminalRef.current) void showTerminalContextMenu(event); + if (terminalRef.current) handleContextMenu(event); }, }; const terminal = await GhosttyTerminalSurface.create(mount, terminalOptions); @@ -651,7 +651,7 @@ export function TerminalViewport({ canCopy: true, }), nextAction.position, - { presentation: "styled", signal: abortController.signal }, + { layout: "compact", presentation: "styled", signal: abortController.signal }, ) .finally(() => { if (terminalMenuAbortController === abortController) { @@ -691,7 +691,7 @@ export function TerminalViewport({ x: event.clientX, y: event.clientY, }, - { presentation: "styled", signal: abortController.signal }, + { layout: "compact", presentation: "styled", signal: abortController.signal }, ) .finally(() => { if (terminalMenuAbortController === abortController) { diff --git a/apps/web/src/contextMenuFallback.test.ts b/apps/web/src/contextMenuFallback.test.ts index 343a8bdc0ea2..a1f72f708f28 100644 --- a/apps/web/src/contextMenuFallback.test.ts +++ b/apps/web/src/contextMenuFallback.test.ts @@ -261,15 +261,19 @@ describe("showContextMenuFallback", () => { }); it("renders shortcut hints next to menu labels", () => { - void showContextMenuFallback([ - { id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C" }, - { id: "paste", label: "Paste", accelerator: "Command+V" }, - ]); + void showContextMenuFallback( + [ + { id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C" }, + { id: "paste", label: "Paste", accelerator: "Command+Shift+V" }, + ], + undefined, + { layout: "compact" }, + ); const shortcuts = (document as unknown as FakeDocument) .querySelectorAll("kbd") .map((element) => element.textContent); - expect(shortcuts).toEqual(["Ctrl+Shift+C", "⌘V"]); + expect(shortcuts).toEqual(["Ctrl+Shift+C", "⇧⌘V"]); }); it("ignores a click from the gesture that opened the menu", async () => { diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 3a70dfbc5154..9cf402f4aec6 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -225,6 +225,15 @@ export function contextMenuAcceleratorAction( return null; } +function formatContextMenuAccelerator(accelerator: string): string { + const parts = accelerator.split("+"); + if (!parts.some((part) => part === "Command" || part === "Cmd")) return accelerator; + const key = parts.at(-1) ?? ""; + return `${parts.includes("Ctrl") ? "⌃" : ""}${parts.includes("Alt") ? "⌥" : ""}${ + parts.includes("Shift") ? "⇧" : "" + }⌘${key}`; +} + /** * Imperative DOM-based context menu for non-Electron environments. * Supports nested submenus and resolves with the clicked leaf item id. @@ -232,7 +241,10 @@ export function contextMenuAcceleratorAction( export function showContextMenuFallback( items: readonly ContextMenuItem[], position?: { x: number; y: number }, - options?: { readonly signal?: AbortSignal }, + options?: { + readonly layout?: "default" | "compact"; + readonly signal?: AbortSignal; + }, ): Promise { return new Promise((resolve) => { if (options?.signal?.aborted) { @@ -326,27 +338,24 @@ export function showContextMenuFallback( ) => { closeMenusFromLevel(level); - const usesCompactShortcutLayout = entries.some((item) => item.accelerator); + const usesCompactShortcutLayout = options?.layout === "compact"; const menu = document.createElement("div"); menu.className = `dropdown-glass fixed z-[10000] max-w-sm overflow-hidden bg-clip-padding text-popover-foreground outline-none ${ - usesCompactShortcutLayout ? "min-w-[14.25rem] rounded-[11px]" : "min-w-32 rounded-lg" + usesCompactShortcutLayout ? "min-w-56 rounded-lg" : "min-w-32 rounded-lg" }`; menu.style.cssText = `position:fixed;z-index:10000;min-width:${ - usesCompactShortcutLayout ? "min(14.25rem,calc(100vw - 0.75rem))" : "8rem" - };max-width:24rem;overflow:hidden;border-radius:${ - usesCompactShortcutLayout ? "0.6875rem" : "var(--radius-lg)" - };background-clip:padding-box;color:var(--contrast-popover-foreground);outline:none;pointer-events:auto;`; + usesCompactShortcutLayout ? "min(14rem,calc(100vw - 0.75rem))" : "8rem" + };max-width:24rem;overflow:hidden;border-radius:var(--radius-lg);background-clip:padding-box;color:var(--contrast-popover-foreground);outline:none;pointer-events:auto;`; menu.style.left = `${preferredLeft}px`; menu.style.top = `${preferredTop}px`; menu.dataset.level = String(level); const inner = document.createElement("div"); inner.className = - "max-h-[min(24rem,70vh)] min-w-0 max-w-sm overflow-y-auto overflow-x-hidden p-1 data-[compact=true]:p-[5px]"; + "max-h-[min(24rem,70vh)] min-w-0 max-w-sm overflow-y-auto overflow-x-hidden p-1"; inner.dataset.compact = String(usesCompactShortcutLayout); - inner.style.cssText = `max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:${ - usesCompactShortcutLayout ? "0.3125rem" : "0.25rem" - };`; + inner.style.cssText = + "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;"; for (const item of entries) { if (item.separatorBefore === true && inner.children.length > 0) { @@ -376,30 +385,21 @@ export function showContextMenuFallback( const isDisabled = item.disabled === true; button.disabled = isDisabled; const rowBase = usesCompactShortcutLayout - ? "flex min-h-[2.125rem] w-full cursor-default select-none items-center gap-[9px] rounded-[7px] px-2 py-1.5 text-left text-xs outline-none transition-colors" + ? "flex min-h-8 w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-left text-sm outline-none transition-colors sm:min-h-7 sm:text-xs" : "flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-left outline-none transition-colors sm:min-h-7 sm:text-sm min-h-8 text-base"; button.className = isDisabled - ? `${rowBase} pointer-events-none cursor-not-allowed text-muted-foreground ${ - usesCompactShortcutLayout ? "opacity-52" : "opacity-64" - }` + ? `${rowBase} pointer-events-none cursor-not-allowed text-muted-foreground opacity-64` : isLeafDestructive ? `${rowBase} text-destructive-foreground hover:bg-destructive/10 hover:text-destructive-foreground` : `${rowBase} text-foreground hover:bg-accent hover:text-accent-foreground`; - button.style.cssText = `display:flex;width:100%;min-height:${ - usesCompactShortcutLayout ? "2.125rem" : "1.75rem" - };align-items:center;gap:${usesCompactShortcutLayout ? "0.5625rem" : "0.5rem"};border:0;border-radius:${ - usesCompactShortcutLayout ? "0.4375rem" : "var(--radius-sm)" - };background:transparent;padding:${ - usesCompactShortcutLayout ? "0.375rem 0.5rem" : "0.25rem 0.5rem" - };color:var(--contrast-foreground);font-family:var(--font-sans,system-ui,sans-serif);font-size:${ - usesCompactShortcutLayout ? "0.75rem" : "0.875rem" - };font-weight:${usesCompactShortcutLayout ? "450" : "400"};line-height:1.25rem;text-align:left;cursor:default;`; + button.style.cssText = + "display:flex;width:100%;align-items:center;gap:0.5rem;border:0;border-radius:var(--radius-sm);background:transparent;padding:0.25rem 0.5rem;color:var(--contrast-foreground);font-family:var(--font-sans,system-ui,sans-serif);font-weight:400;line-height:1.25rem;text-align:left;cursor:default;"; if (isLeafDestructive) { button.style.color = "var(--destructive-foreground)"; } if (isDisabled) { button.style.color = "var(--contrast-muted-foreground)"; - button.style.opacity = usesCompactShortcutLayout ? "0.52" : "0.64"; + button.style.opacity = "0.64"; button.style.pointerEvents = "none"; } @@ -418,10 +418,10 @@ export function showContextMenuFallback( if (item.accelerator) { const accelerator = document.createElement("kbd"); accelerator.className = - "ms-auto shrink-0 rounded border border-border/70 bg-muted/70 px-[5px] font-mono text-[10px] text-muted-foreground leading-[1.55]"; + "ms-auto shrink-0 font-medium font-sans text-secondary-label text-xs tracking-widest"; accelerator.style.cssText = - "margin-inline-start:auto;flex-shrink:0;border:1px solid color-mix(in srgb,var(--border) 70%,transparent);border-radius:0.25rem;background:color-mix(in srgb,var(--muted) 70%,transparent);padding:0 0.3125rem;color:var(--muted-foreground);font-family:var(--font-mono,monospace);font-size:0.625rem;line-height:1.55;"; - accelerator.textContent = item.accelerator.replace(/^Command\+/u, "⌘"); + "margin-inline-start:auto;flex-shrink:0;color:var(--secondary-label);font-family:var(--font-sans,system-ui,sans-serif);font-size:0.75rem;font-weight:500;letter-spacing:0.1em;"; + accelerator.textContent = formatContextMenuAccelerator(item.accelerator); button.appendChild(accelerator); } diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 6ed4641ad9b4..34d4e5b86439 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -186,6 +186,7 @@ describe("LocalApi", () => { items, { x: 4, y: 5 }, { + layout: "compact", presentation: "styled", signal: abortController.signal, }, @@ -196,6 +197,7 @@ describe("LocalApi", () => { items, { x: 4, y: 5 }, { + layout: "compact", signal: abortController.signal, }, ); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 039381e8099b..9d0d73060c57 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -36,6 +36,7 @@ function createBrowserLocalApi(): LocalApi { items: readonly ContextMenuItem[], position?: { x: number; y: number }, options?: { + readonly layout?: "default" | "compact"; readonly presentation?: "native" | "styled"; readonly signal?: AbortSignal; }, @@ -46,7 +47,12 @@ function createBrowserLocalApi(): LocalApi { return showContextMenuFallback( items, position, - options?.signal ? { signal: options.signal } : undefined, + options?.layout !== undefined || options?.signal !== undefined + ? { + ...(options.layout === undefined ? {} : { layout: options.layout }), + ...(options.signal === undefined ? {} : { signal: options.signal }), + } + : undefined, ); }, // Native desktop menus close on outside interaction; dismissing the DOM diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index d9995533aef5..2b8794b1769f 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1263,6 +1263,7 @@ export interface LocalApi { items: readonly ContextMenuItem[], position?: { x: number; y: number }, options?: { + readonly layout?: "default" | "compact"; readonly presentation?: "native" | "styled"; readonly signal?: AbortSignal; }, From ccc5f7d1bd1acf8615917bd5ba1d2a8ab07d2822 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Sun, 23 Aug 2026 21:00:33 +0200 Subject: [PATCH 06/11] fix(web): preserve focus after terminal menu actions --- .../web/src/components/ThreadTerminalDrawer.tsx | 14 ++++++++++++-- apps/web/src/contextMenuFallback.test.ts | 17 +++++++++++++++++ apps/web/src/contextMenuFallback.ts | 7 ++++++- apps/web/src/localApi.test.ts | 2 ++ apps/web/src/localApi.ts | 8 +++++++- packages/contracts/src/ipc.ts | 1 + 6 files changed, 45 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index b653a4dec95a..0ae4b115165d 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -651,7 +651,12 @@ export function TerminalViewport({ canCopy: true, }), nextAction.position, - { layout: "compact", presentation: "styled", signal: abortController.signal }, + { + layout: "compact", + presentation: "styled", + restoreFocus: false, + signal: abortController.signal, + }, ) .finally(() => { if (terminalMenuAbortController === abortController) { @@ -691,7 +696,12 @@ export function TerminalViewport({ x: event.clientX, y: event.clientY, }, - { layout: "compact", presentation: "styled", signal: abortController.signal }, + { + layout: "compact", + presentation: "styled", + restoreFocus: false, + signal: abortController.signal, + }, ) .finally(() => { if (terminalMenuAbortController === abortController) { diff --git a/apps/web/src/contextMenuFallback.test.ts b/apps/web/src/contextMenuFallback.test.ts index a1f72f708f28..6802b5422feb 100644 --- a/apps/web/src/contextMenuFallback.test.ts +++ b/apps/web/src/contextMenuFallback.test.ts @@ -389,6 +389,23 @@ describe("showContextMenuFallback", () => { await expect(selectionPromise).resolves.toBe("copy:branch"); expect(invoker.focused).toBe(true); }); + + it("can leave focus restoration to the caller", async () => { + const invoker = (document as unknown as FakeDocument).createElement("button"); + (document as unknown as FakeDocument).body.appendChild(invoker); + invoker.focus(); + const selectionPromise = showContextMenuFallback( + [{ id: "add-to-chat", label: "Add to chat" }], + undefined, + { restoreFocus: false }, + ); + const action = findButton("Add to chat"); + action?.focus(); + action?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + await expect(selectionPromise).resolves.toBe("add-to-chat"); + expect(invoker.focused).toBe(false); + }); }); describe("dismissContextMenu", () => { diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 9cf402f4aec6..dc4e497ec4e2 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -243,6 +243,7 @@ export function showContextMenuFallback( position?: { x: number; y: number }, options?: { readonly layout?: "default" | "compact"; + readonly restoreFocus?: boolean; readonly signal?: AbortSignal; }, ): Promise { @@ -277,7 +278,11 @@ export function showContextMenuFallback( for (const menu of menuStack) { menu.remove(); } - if (shouldRestoreFocus && previouslyFocusedElement?.isConnected) { + if ( + options?.restoreFocus !== false && + shouldRestoreFocus && + previouslyFocusedElement?.isConnected + ) { previouslyFocusedElement.focus({ preventScroll: true }); } resolve(result); diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 34d4e5b86439..7a3ddd31c861 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -188,6 +188,7 @@ describe("LocalApi", () => { { layout: "compact", presentation: "styled", + restoreFocus: false, signal: abortController.signal, }, ), @@ -198,6 +199,7 @@ describe("LocalApi", () => { { x: 4, y: 5 }, { layout: "compact", + restoreFocus: false, signal: abortController.signal, }, ); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 9d0d73060c57..1373e7507aa4 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -38,6 +38,7 @@ function createBrowserLocalApi(): LocalApi { options?: { readonly layout?: "default" | "compact"; readonly presentation?: "native" | "styled"; + readonly restoreFocus?: boolean; readonly signal?: AbortSignal; }, ): Promise => { @@ -47,9 +48,14 @@ function createBrowserLocalApi(): LocalApi { return showContextMenuFallback( items, position, - options?.layout !== undefined || options?.signal !== undefined + options?.layout !== undefined || + options?.restoreFocus !== undefined || + options?.signal !== undefined ? { ...(options.layout === undefined ? {} : { layout: options.layout }), + ...(options.restoreFocus === undefined + ? {} + : { restoreFocus: options.restoreFocus }), ...(options.signal === undefined ? {} : { signal: options.signal }), } : undefined, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 2b8794b1769f..582afc71486b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1265,6 +1265,7 @@ export interface LocalApi { options?: { readonly layout?: "default" | "compact"; readonly presentation?: "native" | "styled"; + readonly restoreFocus?: boolean; readonly signal?: AbortSignal; }, ) => Promise; From a52e65f528b271ce199887d72daeac63926a9e68 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Sun, 23 Aug 2026 21:07:42 +0200 Subject: [PATCH 07/11] fix(web): respect contrast in terminal menu --- apps/web/src/contextMenuFallback.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index dc4e497ec4e2..c475b697849a 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -425,7 +425,7 @@ export function showContextMenuFallback( accelerator.className = "ms-auto shrink-0 font-medium font-sans text-secondary-label text-xs tracking-widest"; accelerator.style.cssText = - "margin-inline-start:auto;flex-shrink:0;color:var(--secondary-label);font-family:var(--font-sans,system-ui,sans-serif);font-size:0.75rem;font-weight:500;letter-spacing:0.1em;"; + "margin-inline-start:auto;flex-shrink:0;color:var(--contrast-secondary-label);font-family:var(--font-sans,system-ui,sans-serif);font-size:0.75rem;font-weight:500;letter-spacing:0.1em;"; accelerator.textContent = formatContextMenuAccelerator(item.accelerator); button.appendChild(accelerator); } From 7d4c21bae8d1912e08d39c6ff597d5b22ba54c9b Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Sun, 23 Aug 2026 21:10:25 +0200 Subject: [PATCH 08/11] fix(web): handle terminal menu failures --- .../src/components/ThreadTerminalDrawer.tsx | 122 +++++++++++------- apps/web/src/contextMenuFallback.ts | 1 - apps/web/src/terminal/ghostty/surface.ts | 4 +- 3 files changed, 77 insertions(+), 50 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 0ae4b115165d..4d06a39b7459 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -644,34 +644,48 @@ export function TerminalViewport({ terminalMenuAbortController = abortController; selectionActionMenuOpenRef.current = true; openSelectionMenuRequestIdRef.current = requestId; - const clicked = await localApi.contextMenu - .show( - terminalContextMenuItems({ - canAddToChat: nextAction.selection !== null, - canCopy: true, - }), - nextAction.position, - { - layout: "compact", - presentation: "styled", - restoreFocus: false, - signal: abortController.signal, - }, - ) - .finally(() => { - if (terminalMenuAbortController === abortController) { - terminalMenuAbortController = null; - selectionActionMenuOpenRef.current = false; - } - if (openSelectionMenuRequestIdRef.current === requestId) { - openSelectionMenuRequestIdRef.current = null; + const isCurrent = () => + requestId === selectionActionRequestIdRef.current && !abortController.signal.aborted; + let clicked: TerminalContextMenuAction | null; + try { + clicked = await localApi.contextMenu + .show( + terminalContextMenuItems({ + canAddToChat: nextAction.selection !== null, + canCopy: true, + }), + nextAction.position, + { + layout: "compact", + presentation: "styled", + restoreFocus: false, + signal: abortController.signal, + }, + ) + .finally(() => { + if (terminalMenuAbortController === abortController) { + terminalMenuAbortController = null; + selectionActionMenuOpenRef.current = false; + } + if (openSelectionMenuRequestIdRef.current === requestId) { + openSelectionMenuRequestIdRef.current = null; + } + }); + } catch (error) { + if (isCurrent()) { + const activeTerminal = terminalRef.current; + if (activeTerminal) { + writeSystemMessage( + activeTerminal, + error instanceof Error ? error.message : "Unable to open the terminal context menu", + ); } - }); + } + return; + } if (requestId !== selectionActionRequestIdRef.current || clicked === null) { return; } - const isCurrent = () => - requestId === selectionActionRequestIdRef.current && !abortController.signal.aborted; await performTerminalMenuAction(clicked, nextAction, isCurrent); if (shouldRestoreTerminalFocusAfterMenuAction(clicked) && isCurrent()) { terminalRef.current?.focus(); @@ -686,31 +700,45 @@ export function TerminalViewport({ const abortController = new AbortController(); terminalMenuAbortController = abortController; selectionActionMenuOpenRef.current = true; - const clicked = await localApi.contextMenu - .show( - terminalContextMenuItems({ - canAddToChat: selectionAction?.selection != null, - canCopy: selectionAction !== null, - }), - { - x: event.clientX, - y: event.clientY, - }, - { - layout: "compact", - presentation: "styled", - restoreFocus: false, - signal: abortController.signal, - }, - ) - .finally(() => { - if (terminalMenuAbortController === abortController) { - terminalMenuAbortController = null; - selectionActionMenuOpenRef.current = false; - } - }); const isCurrent = () => requestId === selectionActionRequestIdRef.current && !abortController.signal.aborted; + let clicked: TerminalContextMenuAction | null; + try { + clicked = await localApi.contextMenu + .show( + terminalContextMenuItems({ + canAddToChat: selectionAction?.selection != null, + canCopy: selectionAction !== null, + }), + { + x: event.clientX, + y: event.clientY, + }, + { + layout: "compact", + presentation: "styled", + restoreFocus: false, + signal: abortController.signal, + }, + ) + .finally(() => { + if (terminalMenuAbortController === abortController) { + terminalMenuAbortController = null; + selectionActionMenuOpenRef.current = false; + } + }); + } catch (error) { + if (isCurrent()) { + const activeTerminal = terminalRef.current; + if (activeTerminal) { + writeSystemMessage( + activeTerminal, + error instanceof Error ? error.message : "Unable to open the terminal context menu", + ); + } + } + return; + } await performTerminalMenuAction(clicked, selectionAction, isCurrent); if (shouldRestoreTerminalFocusAfterMenuAction(clicked) && isCurrent()) { terminalRef.current?.focus(); diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index c475b697849a..52fa00e3497a 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -358,7 +358,6 @@ export function showContextMenuFallback( const inner = document.createElement("div"); inner.className = "max-h-[min(24rem,70vh)] min-w-0 max-w-sm overflow-y-auto overflow-x-hidden p-1"; - inner.dataset.compact = String(usesCompactShortcutLayout); inner.style.cssText = "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;"; diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index e07c17cdaa8b..e7d708a85bcc 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -545,8 +545,8 @@ export interface GhosttyTerminalSurfaceOptions { readonly onLinkActivate: (text: string, event: MouseEvent) => void; /** * A right-click the running application did not claim through mouse - * reporting. The host owns the menu, so it also owns preventing the browser - * default — whose Paste entry can never reach a canvas terminal. + * reporting. The surface suppresses the browser default — whose Paste entry + * can never reach a canvas terminal — before handing the gesture to the host. */ readonly onContextMenu?: (event: MouseEvent) => void; } From c7a2a5c0072f0405b7ec830fdbc91f09bbd93ee2 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Tue, 25 Aug 2026 21:01:26 +0200 Subject: [PATCH 09/11] test(web): cover terminal menu lifecycle --- .../components/ThreadTerminalDrawer.test.ts | 135 ++++++++++++++- .../src/components/ThreadTerminalDrawer.tsx | 160 ++++++++++-------- 2 files changed, 221 insertions(+), 74 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index 75fec2e45e94..b6728ce1aa10 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { resolveTerminalSelectionActionPosition, + runTerminalMenuRequest, shouldHandleTerminalExit, shouldHandleTerminalSelectionMouseUp, shouldRestoreTerminalFocusAfterMenuAction, @@ -10,6 +11,138 @@ import { terminalSelectionLineRange, } from "./ThreadTerminalDrawer"; +describe("runTerminalMenuRequest", () => { + it("drops an action when the menu owner aborts before it resolves", async () => { + const abortController = new AbortController(); + let resolveMenu: (action: "copy") => void = () => {}; + const open = vi.fn( + () => + new Promise<"copy">((resolve) => { + resolveMenu = resolve; + }), + ); + const perform = vi.fn(async () => {}); + const reportOpenError = vi.fn(); + const focusTerminal = vi.fn(); + const request = runTerminalMenuRequest({ + signal: abortController.signal, + isCurrentRequest: () => true, + open, + perform, + reportOpenError, + focusTerminal, + }); + + abortController.abort(); + resolveMenu("copy"); + await request; + + expect(perform).not.toHaveBeenCalled(); + expect(reportOpenError).not.toHaveBeenCalled(); + expect(focusTerminal).not.toHaveBeenCalled(); + }); + + it("does not restore focus when teardown aborts an action in flight", async () => { + const abortController = new AbortController(); + let finishAction: () => void = () => {}; + const perform = vi.fn( + (_action: "add-to-chat" | "copy" | "paste", isCurrent: () => boolean) => + new Promise((resolve) => { + expect(isCurrent()).toBe(true); + finishAction = () => { + expect(isCurrent()).toBe(false); + resolve(); + }; + }), + ); + const focusTerminal = vi.fn(); + const request = runTerminalMenuRequest({ + signal: abortController.signal, + isCurrentRequest: () => true, + open: async () => "copy", + perform, + reportOpenError: vi.fn(), + focusTerminal, + }); + await vi.waitFor(() => expect(perform).toHaveBeenCalledOnce()); + + abortController.abort(); + finishAction(); + await request; + + expect(focusTerminal).not.toHaveBeenCalled(); + }); + + it("suppresses a rejection from a superseded menu request", async () => { + const reportOpenError = vi.fn(); + + await runTerminalMenuRequest({ + signal: new AbortController().signal, + isCurrentRequest: () => false, + open: () => Promise.reject(new Error("stale menu failure")), + perform: vi.fn(async () => {}), + reportOpenError, + focusTerminal: vi.fn(), + }); + + expect(reportOpenError).not.toHaveBeenCalled(); + }); + + it("reports a failure from the current menu request", async () => { + const error = new Error("menu failed"); + const reportOpenError = vi.fn(); + + await runTerminalMenuRequest({ + signal: new AbortController().signal, + isCurrentRequest: () => true, + open: () => Promise.reject(error), + perform: vi.fn(async () => {}), + reportOpenError, + focusTerminal: vi.fn(), + }); + + expect(reportOpenError).toHaveBeenCalledWith(error); + }); + + it("does nothing when the menu is dismissed without an action", async () => { + const perform = vi.fn(async () => {}); + const focusTerminal = vi.fn(); + + await runTerminalMenuRequest({ + signal: new AbortController().signal, + isCurrentRequest: () => true, + open: async () => null, + perform, + reportOpenError: vi.fn(), + focusTerminal, + }); + + expect(perform).not.toHaveBeenCalled(); + expect(focusTerminal).not.toHaveBeenCalled(); + }); + + it.each([ + ["copy", true], + ["paste", true], + ["add-to-chat", false], + ] as const)("performs %s and restores focus only when required", async (action, shouldFocus) => { + const perform = vi.fn(async () => {}); + const focusTerminal = vi.fn(); + + await runTerminalMenuRequest({ + signal: new AbortController().signal, + isCurrentRequest: () => true, + open: async () => action, + perform, + reportOpenError: vi.fn(), + focusTerminal, + }); + + expect(perform).toHaveBeenCalledWith(action, expect.any(Function)); + expect(focusTerminal).toHaveBeenCalledTimes(shouldFocus ? 1 : 0); + }); +}); + describe("shouldRestoreTerminalFocusAfterMenuAction", () => { it("restores focus only after terminal-local actions", () => { expect(shouldRestoreTerminalFocusAfterMenuAction("copy")).toBe(true); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 4d06a39b7459..117bae651cde 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -87,6 +87,29 @@ export function shouldRestoreTerminalFocusAfterMenuAction( return action === "copy" || action === "paste"; } +export async function runTerminalMenuRequest(options: { + readonly signal: AbortSignal; + readonly isCurrentRequest: () => boolean; + readonly open: () => Promise; + readonly perform: (action: TerminalContextMenuAction, isCurrent: () => boolean) => Promise; + readonly reportOpenError: (error: unknown) => void; + readonly focusTerminal: () => void; +}): Promise { + const isCurrent = () => options.isCurrentRequest() && !options.signal.aborted; + let action: TerminalContextMenuAction | null; + try { + action = await options.open(); + } catch (error) { + if (isCurrent()) options.reportOpenError(error); + return; + } + if (action === null || !isCurrent()) return; + await options.perform(action, isCurrent); + if (shouldRestoreTerminalFocusAfterMenuAction(action) && isCurrent()) { + options.focusTerminal(); + } +} + interface TerminalSelectionAction { readonly position: { x: number; y: number }; readonly clipboardText: string; @@ -644,35 +667,36 @@ export function TerminalViewport({ terminalMenuAbortController = abortController; selectionActionMenuOpenRef.current = true; openSelectionMenuRequestIdRef.current = requestId; - const isCurrent = () => - requestId === selectionActionRequestIdRef.current && !abortController.signal.aborted; - let clicked: TerminalContextMenuAction | null; - try { - clicked = await localApi.contextMenu - .show( - terminalContextMenuItems({ - canAddToChat: nextAction.selection !== null, - canCopy: true, + await runTerminalMenuRequest({ + signal: abortController.signal, + isCurrentRequest: () => requestId === selectionActionRequestIdRef.current, + open: () => + localApi.contextMenu + .show( + terminalContextMenuItems({ + canAddToChat: nextAction.selection !== null, + canCopy: true, + }), + nextAction.position, + { + layout: "compact", + presentation: "styled", + restoreFocus: false, + signal: abortController.signal, + }, + ) + .finally(() => { + if (terminalMenuAbortController === abortController) { + terminalMenuAbortController = null; + selectionActionMenuOpenRef.current = false; + } + if (openSelectionMenuRequestIdRef.current === requestId) { + openSelectionMenuRequestIdRef.current = null; + } }), - nextAction.position, - { - layout: "compact", - presentation: "styled", - restoreFocus: false, - signal: abortController.signal, - }, - ) - .finally(() => { - if (terminalMenuAbortController === abortController) { - terminalMenuAbortController = null; - selectionActionMenuOpenRef.current = false; - } - if (openSelectionMenuRequestIdRef.current === requestId) { - openSelectionMenuRequestIdRef.current = null; - } - }); - } catch (error) { - if (isCurrent()) { + perform: (clicked, isCurrent) => + performTerminalMenuAction(clicked, nextAction, isCurrent), + reportOpenError: (error) => { const activeTerminal = terminalRef.current; if (activeTerminal) { writeSystemMessage( @@ -680,16 +704,9 @@ export function TerminalViewport({ error instanceof Error ? error.message : "Unable to open the terminal context menu", ); } - } - return; - } - if (requestId !== selectionActionRequestIdRef.current || clicked === null) { - return; - } - await performTerminalMenuAction(clicked, nextAction, isCurrent); - if (shouldRestoreTerminalFocusAfterMenuAction(clicked) && isCurrent()) { - terminalRef.current?.focus(); - } + }, + focusTerminal: () => terminalRef.current?.focus(), + }); }; const showTerminalContextMenu = async (event: MouseEvent) => { @@ -700,35 +717,36 @@ export function TerminalViewport({ const abortController = new AbortController(); terminalMenuAbortController = abortController; selectionActionMenuOpenRef.current = true; - const isCurrent = () => - requestId === selectionActionRequestIdRef.current && !abortController.signal.aborted; - let clicked: TerminalContextMenuAction | null; - try { - clicked = await localApi.contextMenu - .show( - terminalContextMenuItems({ - canAddToChat: selectionAction?.selection != null, - canCopy: selectionAction !== null, + await runTerminalMenuRequest({ + signal: abortController.signal, + isCurrentRequest: () => requestId === selectionActionRequestIdRef.current, + open: () => + localApi.contextMenu + .show( + terminalContextMenuItems({ + canAddToChat: selectionAction?.selection != null, + canCopy: selectionAction !== null, + }), + { + x: event.clientX, + y: event.clientY, + }, + { + layout: "compact", + presentation: "styled", + restoreFocus: false, + signal: abortController.signal, + }, + ) + .finally(() => { + if (terminalMenuAbortController === abortController) { + terminalMenuAbortController = null; + selectionActionMenuOpenRef.current = false; + } }), - { - x: event.clientX, - y: event.clientY, - }, - { - layout: "compact", - presentation: "styled", - restoreFocus: false, - signal: abortController.signal, - }, - ) - .finally(() => { - if (terminalMenuAbortController === abortController) { - terminalMenuAbortController = null; - selectionActionMenuOpenRef.current = false; - } - }); - } catch (error) { - if (isCurrent()) { + perform: (clicked, isCurrent) => + performTerminalMenuAction(clicked, selectionAction, isCurrent), + reportOpenError: (error) => { const activeTerminal = terminalRef.current; if (activeTerminal) { writeSystemMessage( @@ -736,13 +754,9 @@ export function TerminalViewport({ error instanceof Error ? error.message : "Unable to open the terminal context menu", ); } - } - return; - } - await performTerminalMenuAction(clicked, selectionAction, isCurrent); - if (shouldRestoreTerminalFocusAfterMenuAction(clicked) && isCurrent()) { - terminalRef.current?.focus(); - } + }, + focusTerminal: () => terminalRef.current?.focus(), + }); }; const sendTerminalInput = async (data: string, fallbackError: string) => { From 475ca8e9ec31cf22e14cee541d853d92aebb0c76 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Tue, 25 Aug 2026 21:22:56 +0200 Subject: [PATCH 10/11] fix(web): restore terminal focus after menu dismissal --- apps/web/src/components/ThreadTerminalDrawer.tsx | 2 -- apps/web/src/contextMenuFallback.test.ts | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 117bae651cde..49f69e33a6bc 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -681,7 +681,6 @@ export function TerminalViewport({ { layout: "compact", presentation: "styled", - restoreFocus: false, signal: abortController.signal, }, ) @@ -734,7 +733,6 @@ export function TerminalViewport({ { layout: "compact", presentation: "styled", - restoreFocus: false, signal: abortController.signal, }, ) diff --git a/apps/web/src/contextMenuFallback.test.ts b/apps/web/src/contextMenuFallback.test.ts index 6802b5422feb..5db0ea9a1188 100644 --- a/apps/web/src/contextMenuFallback.test.ts +++ b/apps/web/src/contextMenuFallback.test.ts @@ -406,6 +406,21 @@ describe("showContextMenuFallback", () => { await expect(selectionPromise).resolves.toBe("add-to-chat"); expect(invoker.focused).toBe(false); }); + + it("restores focus after dismissing a menu whose hovered row took focus", async () => { + const invoker = (document as unknown as FakeDocument).createElement("button"); + (document as unknown as FakeDocument).body.appendChild(invoker); + invoker.focus(); + const selectionPromise = showContextMenuFallback([{ id: "copy", label: "Copy" }]); + const action = findButton("Copy"); + + action?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(action?.focused).toBe(true); + dismissContextMenu(); + + await expect(selectionPromise).resolves.toBeNull(); + expect(invoker.focused).toBe(true); + }); }); describe("dismissContextMenu", () => { From 1c7827cdaf847752afa988e915671fb94b0248d5 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Tue, 25 Aug 2026 21:43:34 +0200 Subject: [PATCH 11/11] fix(web): make terminal menu focus action-aware --- .../src/components/ThreadTerminalDrawer.tsx | 2 + apps/web/src/contextMenuFallback.test.ts | 46 +++++++++++++++++-- apps/web/src/contextMenuFallback.ts | 21 +++++---- apps/web/src/localApi.ts | 2 +- packages/contracts/src/ipc.ts | 2 +- 5 files changed, 58 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 49f69e33a6bc..296e205f83b2 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -681,6 +681,7 @@ export function TerminalViewport({ { layout: "compact", presentation: "styled", + restoreFocus: "on-dismiss", signal: abortController.signal, }, ) @@ -733,6 +734,7 @@ export function TerminalViewport({ { layout: "compact", presentation: "styled", + restoreFocus: "on-dismiss", signal: abortController.signal, }, ) diff --git a/apps/web/src/contextMenuFallback.test.ts b/apps/web/src/contextMenuFallback.test.ts index 5db0ea9a1188..1e7908fd313d 100644 --- a/apps/web/src/contextMenuFallback.test.ts +++ b/apps/web/src/contextMenuFallback.test.ts @@ -407,20 +407,60 @@ describe("showContextMenuFallback", () => { expect(invoker.focused).toBe(false); }); - it("restores focus after dismissing a menu whose hovered row took focus", async () => { + it("restores focus after an interactive dismissal when requested", async () => { const invoker = (document as unknown as FakeDocument).createElement("button"); (document as unknown as FakeDocument).body.appendChild(invoker); invoker.focus(); - const selectionPromise = showContextMenuFallback([{ id: "copy", label: "Copy" }]); + const selectionPromise = showContextMenuFallback([{ id: "copy", label: "Copy" }], undefined, { + restoreFocus: "on-dismiss", + }); const action = findButton("Copy"); action?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); expect(action?.focused).toBe(true); - dismissContextMenu(); + (document as unknown as FakeDocument).dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape" }), + ); await expect(selectionPromise).resolves.toBeNull(); expect(invoker.focused).toBe(true); }); + + it("does not restore focus after an action when only dismissals should restore it", async () => { + const invoker = (document as unknown as FakeDocument).createElement("button"); + (document as unknown as FakeDocument).body.appendChild(invoker); + invoker.focus(); + const selectionPromise = showContextMenuFallback( + [{ id: "add-to-chat", label: "Add to chat" }], + undefined, + { restoreFocus: "on-dismiss" }, + ); + const action = findButton("Add to chat"); + + action?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + action?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + await expect(selectionPromise).resolves.toBe("add-to-chat"); + expect(invoker.focused).toBe(false); + }); + + it("does not restore focus when an on-dismiss menu is aborted", async () => { + const invoker = (document as unknown as FakeDocument).createElement("button"); + (document as unknown as FakeDocument).body.appendChild(invoker); + invoker.focus(); + const abortController = new AbortController(); + const selectionPromise = showContextMenuFallback([{ id: "copy", label: "Copy" }], undefined, { + restoreFocus: "on-dismiss", + signal: abortController.signal, + }); + const action = findButton("Copy"); + + action?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + abortController.abort(); + + await expect(selectionPromise).resolves.toBeNull(); + expect(invoker.focused).toBe(false); + }); }); describe("dismissContextMenu", () => { diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 52fa00e3497a..3fc836ea9458 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -243,7 +243,7 @@ export function showContextMenuFallback( position?: { x: number; y: number }, options?: { readonly layout?: "default" | "compact"; - readonly restoreFocus?: boolean; + readonly restoreFocus?: boolean | "on-dismiss"; readonly signal?: AbortSignal; }, ): Promise { @@ -259,9 +259,9 @@ export function showContextMenuFallback( let isDisposed = false; let canDismissFromPointer = false; - const dismiss = () => cleanup(null); + const dismiss = () => cleanup(null, "programmatic"); - const cleanup = (result: T | null) => { + const cleanup = (result: T | null, reason: "action" | "interaction" | "programmatic") => { if (isDisposed) { return; } @@ -280,6 +280,7 @@ export function showContextMenuFallback( } if ( options?.restoreFocus !== false && + (options?.restoreFocus !== "on-dismiss" || reason === "interaction") && shouldRestoreFocus && previouslyFocusedElement?.isConnected ) { @@ -288,20 +289,20 @@ export function showContextMenuFallback( resolve(result); }; - const onAbort = () => cleanup(null); + const onAbort = () => cleanup(null, "programmatic"); const onKeyDown = (event: KeyboardEvent) => { if (event.isComposing) return; if (event.key === "Escape") { event.preventDefault(); - cleanup(null); + cleanup(null, "interaction"); return; } const acceleratorAction = contextMenuAcceleratorAction(items, event); if (acceleratorAction !== null) { event.preventDefault(); event.stopPropagation(); - cleanup(acceleratorAction); + cleanup(acceleratorAction, "action"); } }; @@ -309,7 +310,7 @@ export function showContextMenuFallback( if (!canDismissFromPointer || isNodeWithinMenuStack(event.target, menuStack)) { return; } - cleanup(null); + cleanup(null, "interaction"); }; const onContextMenu = (event: MouseEvent) => { @@ -317,14 +318,14 @@ export function showContextMenuFallback( return; } event.preventDefault(); - cleanup(null); + cleanup(null, "interaction"); }; const onWheel = (event: WheelEvent) => { if (isNodeWithinMenuStack(event.target, menuStack)) { return; } - cleanup(null); + cleanup(null, "interaction"); }; const closeMenusFromLevel = (level: number) => { @@ -514,7 +515,7 @@ export function showContextMenuFallback( closeMenusFromLevel(level + 1); }); button.addEventListener("click", () => { - if (canDismissFromPointer) cleanup(item.id); + if (canDismissFromPointer) cleanup(item.id, "action"); }); } } diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 1373e7507aa4..41c7d46730f8 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -38,7 +38,7 @@ function createBrowserLocalApi(): LocalApi { options?: { readonly layout?: "default" | "compact"; readonly presentation?: "native" | "styled"; - readonly restoreFocus?: boolean; + readonly restoreFocus?: boolean | "on-dismiss"; readonly signal?: AbortSignal; }, ): Promise => { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 582afc71486b..4d210aae3805 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1265,7 +1265,7 @@ export interface LocalApi { options?: { readonly layout?: "default" | "compact"; readonly presentation?: "native" | "styled"; - readonly restoreFocus?: boolean; + readonly restoreFocus?: boolean | "on-dismiss"; readonly signal?: AbortSignal; }, ) => Promise;