From d7528b6afae0120db32c6d48e0f6e02d9f3ebb6f Mon Sep 17 00:00:00 2001 From: hamzamerzic <10846014+hamzamerzic@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:02:28 +0000 Subject: [PATCH 1/2] Fix drawer action menu placement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Möbius Agent --- .../src/components/Drawer/AppsDirectory.css | 11 - frontend/src/components/Drawer/Drawer.css | 366 +++++++++++------ frontend/src/components/Drawer/Drawer.jsx | 385 +++++++----------- .../Drawer/DrawerItemActionMenu.jsx | 310 ++++++++++++++ .../Shell/__tests__/workspaceUi.test.js | 44 +- .../__tests__/appsDirectoryContract.test.js | 44 +- .../lib/__tests__/contextMenuGeometry.test.js | 37 ++ frontend/src/lib/contextMenuGeometry.js | 50 +++ 8 files changed, 855 insertions(+), 392 deletions(-) create mode 100644 frontend/src/components/Drawer/DrawerItemActionMenu.jsx create mode 100644 frontend/src/lib/__tests__/contextMenuGeometry.test.js create mode 100644 frontend/src/lib/contextMenuGeometry.js diff --git a/frontend/src/components/Drawer/AppsDirectory.css b/frontend/src/components/Drawer/AppsDirectory.css index fbd0bbd04..cdbea8cb8 100644 --- a/frontend/src/components/Drawer/AppsDirectory.css +++ b/frontend/src/components/Drawer/AppsDirectory.css @@ -214,17 +214,6 @@ white-space: nowrap; } -.apps-directory__card-menu-anchor.drawer__more { - position: absolute; - top: 50%; - left: 50%; - width: 1px; - height: 1px; - padding: 0; - opacity: 0; - pointer-events: none; -} - .apps-directory__card--editing { align-items: center; min-height: 112px; diff --git a/frontend/src/components/Drawer/Drawer.css b/frontend/src/components/Drawer/Drawer.css index d2b37e5d4..675179511 100644 --- a/frontend/src/components/Drawer/Drawer.css +++ b/frontend/src/components/Drawer/Drawer.css @@ -345,9 +345,6 @@ the composer pill's focus halo so the platform reads as a single design system. */ .drawer__item:focus-visible, -.drawer__more:focus-visible, -.drawer__menu-item:focus-visible, -.drawer__menu-confirm-btn:focus-visible, .drawer__rename-input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; @@ -405,12 +402,8 @@ visibility: hidden; } -/* ── Row + three-dots menu ─────────────────────────── */ +/* ── Context-menu row ──────────────────────────────── */ -/* The row stacks two siblings (button + menu trigger) horizontally so - * the trigger isn't nested inside the row button (nested - - - {!confirmingDelete && !confirmingDeleteData ? ( - <> - actions.pin(kind, id, !pinned)} - className="drawer__menu-item--icon" - > - {pinned - ? - actions.startRename(kind, id, surface)}>Rename - {kind === 'app' && slug && ( - // Opens the in-PWA InstallSheet to set the home-screen - // name + icon first; the sheet saves, then navigates - // same-tab to `/apps//?install=1`. Same-tab keeps - // the user in the installed Möbius PWA context — no - // jarring browser-tab pop-out — and lets engagement - // from the parent shell count toward the per-origin - // Site Engagement score that gates beforeinstallprompt. - actions.install(item)}> - Install to home screen - - )} - {kind === 'app' && isDrawerAppShareEligible(item) && ( - actions.share(item)}> - Share app - - )} - {kind === 'chat' ? ( - // Chats soft-delete with 7-day recovery, so no - // confirm step — one tap deletes, the note below - // tells the user how to undo via the agent. - // Close the parent's menu state BEFORE onDelete fires: - // the row unmounts as soon as the refetch lands, but - // the parent's openMenu still references this row's - // id, leaving a Radix trigger looking "pressed" on - // whichever row slides up into the slot. - { - actions.toggleMenu(kind, id, false, surface) - actions.remove(kind, id) - }} - className="drawer__menu-item--danger" - > - Delete - - ) : ( - // Deleting an app is a reversible soft-delete (the agent - // can recover it for 7 days, like a chat), but we still - // want a confirm step. `preventDefault` on onSelect stops - // Radix from auto-closing the menu when the item is - // selected — we want the menu to stay open and swap - // to the confirm-chip below. - { e.preventDefault(); setConfirmingDelete(true) }} - className="drawer__menu-item--danger" - > - Delete - - )} - {kind === 'app' && ( - // Wipes the app's stored data but keeps it installed — a - // separate action from Delete (which removes the whole app). - // Same preventDefault-to-hold-open + confirm-chip pattern as - // the app Delete above; the wording stays exactly "Delete - // data" (no "keeps your data" phrasing). - { e.preventDefault(); setConfirmingDeleteData(true) }} - className="drawer__menu-item--danger" - > - Delete data - - )} - - ) : confirmingDeleteData ? ( -
- Delete data? -
- - -
-
- ) : ( -
- Confirm delete? -
- - -
-
- )} - {/* The 7-day recovery note applies to Delete (soft-delete), not - to the immediate, non-recoverable "Delete data" wipe — hide it - while that confirm chip is showing. */} - {!confirmingDeleteData && ( -

- The agent can recover deleted {kind === 'chat' ? 'chats' : 'apps'} for 7 days. -

- )} -
- + + ) + : null} + pinned={pinned} + canInstall={kind === 'app' && Boolean(item.slug)} + canShare={kind === 'app' && isDrawerAppShareEligible(item)} + placement={menuPlacement} + restoreFocusRef={restoreFocusRef} + onClose={() => actions.toggleMenu(kind, id, false, surface)} + onPin={() => actions.pin(kind, id, !pinned)} + onRename={() => actions.startRename(kind, id, surface)} + onInstall={() => actions.install(item)} + onShare={() => actions.share(item)} + onDelete={() => actions.remove(kind, id)} + onDeleteData={() => actions.removeData(id)} + /> ) } diff --git a/frontend/src/components/Drawer/DrawerItemActionMenu.jsx b/frontend/src/components/Drawer/DrawerItemActionMenu.jsx new file mode 100644 index 000000000..b813ae5e9 --- /dev/null +++ b/frontend/src/components/Drawer/DrawerItemActionMenu.jsx @@ -0,0 +1,310 @@ +/* DrawerItemActionMenu gives app launcher cards and drawer rows one + pointer-accurate desktop menu and one named, thumb-friendly compact-screen + action sheet. */ + +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { Chat, Pin, PinFilled, X } from '@openai/apps-sdk-ui/components/Icon' +import { placeContextMenu } from '../../lib/contextMenuGeometry.js' + +function focusableMenuItems(menu) { + return [...(menu?.querySelectorAll('[role="menuitem"]:not([disabled])') || [])] +} + +export default function DrawerItemActionMenu({ + open, + itemKind, + itemName, + icon, + pinned, + canInstall, + canShare, + placement, + restoreFocusRef, + onClose, + onPin, + onRename, + onInstall, + onShare, + onDelete, + onDeleteData, +}) { + const menuRef = useRef(null) + const wasOpenRef = useRef(false) + const restoreOnCloseRef = useRef(true) + const [confirmation, setConfirmation] = useState(null) + const [position, setPosition] = useState(null) + + function close({ restoreFocus = true } = {}) { + restoreOnCloseRef.current = restoreFocus + onClose() + } + + useEffect(() => { + if (open) { + wasOpenRef.current = true + return + } + setConfirmation(null) + setPosition(null) + if (!wasOpenRef.current) return + wasOpenRef.current = false + if (!restoreOnCloseRef.current) return + const frame = requestAnimationFrame(() => restoreFocusRef?.current?.focus()) + return () => cancelAnimationFrame(frame) + }, [open, restoreFocusRef]) + + useEffect(() => { + if (!open) return + function closeFromKeyboard(event) { + if (event.key !== 'Escape' && event.key !== 'Tab') return + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + restoreOnCloseRef.current = true + onClose() + } + document.addEventListener('keydown', closeFromKeyboard, true) + return () => document.removeEventListener('keydown', closeFromKeyboard, true) + }, [open, onClose]) + + useLayoutEffect(() => { + if (!open || !menuRef.current) return + const root = document.documentElement + const rootRect = root.getBoundingClientRect() + const menuRect = menuRef.current + const placementX = Number(placement?.clientX) + const placementY = Number(placement?.clientY) + setPosition(placeContextMenu({ + clientPoint: { + x: Number.isFinite(placementX) + ? placementX + : rootRect.left + rootRect.width / 2, + y: Number.isFinite(placementY) + ? placementY + : rootRect.top + rootRect.height / 2, + }, + clientViewport: rootRect, + layoutViewport: { + width: root.offsetWidth || root.clientWidth || rootRect.width, + height: root.offsetHeight || root.clientHeight || rootRect.height, + }, + menuSize: { + width: menuRect.offsetWidth, + height: menuRect.offsetHeight, + }, + })) + }, [open, placement, confirmation]) + + useLayoutEffect(() => { + if (!open || !position || !menuRef.current) return + const frame = requestAnimationFrame(() => { + focusableMenuItems(menuRef.current)[0]?.focus() + }) + return () => cancelAnimationFrame(frame) + }, [open, position, confirmation]) + + if (!open) return null + + function run(action, { restoreFocus = true } = {}) { + close({ restoreFocus }) + action() + } + + function handleDeleteAction() { + if (itemKind === 'chat') { + run(onDelete, { restoreFocus: false }) + return + } + setConfirmation('delete') + } + + function onMenuKeyDown(event) { + if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return + const items = focusableMenuItems(menuRef.current) + if (!items.length) return + event.preventDefault() + const current = items.indexOf(document.activeElement) + const next = event.key === 'Home' + ? 0 + : event.key === 'End' + ? items.length - 1 + : event.key === 'ArrowDown' + ? (current + 1 + items.length) % items.length + : (current - 1 + items.length) % items.length + items[next].focus() + } + + const kindLabel = itemKind === 'chat' ? 'Chat' : 'App' + const recoveryLabel = itemKind === 'chat' ? 'chats' : 'apps' + const identityIcon = itemKind === 'chat' + ? ( + + ) + : icon + + const layer = ( +
{ + if (event.target === event.currentTarget) close() + }} + onContextMenu={event => event.preventDefault()} + onWheel={event => { + if (event.target === event.currentTarget) close() + }} + > +
event.stopPropagation()} + onKeyDown={onMenuKeyDown} + > + + ) + + return createPortal(layer, document.body) +} diff --git a/frontend/src/components/Shell/__tests__/workspaceUi.test.js b/frontend/src/components/Shell/__tests__/workspaceUi.test.js index 39cb4520f..5ea0cc6fe 100644 --- a/frontend/src/components/Shell/__tests__/workspaceUi.test.js +++ b/frontend/src/components/Shell/__tests__/workspaceUi.test.js @@ -11,6 +11,10 @@ const shellBrand = readFileSync(new URL('../ShellBrand.jsx', import.meta.url), ' const newChatLanding = readFileSync(new URL('../NewChatLanding.jsx', import.meta.url), 'utf8') const workspaceViewSrc = readFileSync(new URL('../workspaceView.js', import.meta.url), 'utf8') const drawer = readFileSync(new URL('../../Drawer/Drawer.jsx', import.meta.url), 'utf8') +const drawerItemActionMenu = readFileSync( + new URL('../../Drawer/DrawerItemActionMenu.jsx', import.meta.url), + 'utf8', +) const paneModelSrc = readFileSync(new URL('../paneModel.js', import.meta.url), 'utf8') const chrome = readFileSync(new URL('../WorkspaceChrome.jsx', import.meta.url), 'utf8') const dragBinding = readFileSync(new URL('../useWorkspaceDrag.js', import.meta.url), 'utf8') @@ -837,9 +841,34 @@ test('large drawer lists memoize ordering and row actions without changing row o assert.doesNotMatch(drawer, /onSelect=\{\(\) => on(?:Chat|App)/) }) +test('row-owned context menus have no detached hidden-anchor path', () => { + assert.match(drawer, / { + assert.match( + drawerItemActionMenu, + /function handleDeleteAction\(\)[\s\S]*?itemKind === 'chat'[\s\S]*?run\(onDelete, \{ restoreFocus: false \}\)[\s\S]*?return[\s\S]*?setConfirmation\('delete'\)/, + ) + assert.match( + drawerItemActionMenu, + /className="drawer__item-action-item drawer__item-action-item--danger"\s*\n\s*onClick=\{handleDeleteAction\}/, + ) + assert.match( + drawerItemActionMenu, + /confirmation === 'delete-data'[\s\S]*?confirmation === 'delete'/, + 'app and app-data deletion must retain their confirmation paths', + ) +}) + test('drawer row menus use one semantic context-menu path across pointer types', () => { - assert.match(drawer, /function openRowMenu\(event\)[\s\S]*?actions\.toggleMenu\(kind, id, true, surface\)/) - assert.match(drawer, /onContextMenu=\{openRowMenu\}/) + assert.match(drawer, /function openItemMenu\(event\)[\s\S]*?actions\.toggleMenu\(kind, id, true, surface,/) + assert.equal((drawer.match(/onContextMenu=\{openItemMenu\}/g) || []).length, 2, + 'app cards, app rows, and chat rows must share one opening function') assert.match(dragBinding, /srcEl\.dispatchEvent\(new window\.MouseEvent\('contextmenu'/) assert.doesNotMatch( dragBinding, @@ -853,12 +882,19 @@ test('a secondary-button release cannot immediately select a flipped drawer menu assert.match(drawer, /event\.pointerType !== 'mouse' \|\| event\.button !== 2/) assert.match(drawer, /window\.addEventListener\('pointerup', onSecondaryPointerUp, true\)/) assert.match(drawer, /upEvent\.pointerId !== pointerId \|\| upEvent\.button !== 2/) - assert.match(drawer, /cleanup\(\)[\s\S]*?actions\.toggleMenu\(kind, id, true, surface\)/) + assert.match(drawer, /cleanup\(\)[\s\S]*?actions\.toggleMenu\(kind, id, true, surface, placement\)/) assert.match(drawer, /timer = setTimeout\(cleanup, 1500\)/) }) +test('launcher cards and drawer rows share the same long-press threshold and movement slop', () => { + assert.match(drawer, /import \{ DRAWER_HOLD_MS, PRE_HOLD_MOVE_PX \} from '\.\.\/Shell\/dragController\.js'/) + assert.match(drawer, /\}, DRAWER_HOLD_MS\)/) + assert.match(drawer, /> PRE_HOLD_MOVE_PX/) + assert.doesNotMatch(drawer, /520/) +}) + test('double-click edits a drawer row name instead of duplicating its context menu', () => { - assert.match(drawer, /onDoubleClick=\{event => \{[\s\S]*?actions\.startRename\(kind, id\)/) + assert.match(drawer, /onDoubleClick=\{event => \{[\s\S]*?actions\.startRename\(kind, id, surface\)/) }) test('the Settings surface responds to PANE width via a query container', () => { diff --git a/frontend/src/lib/__tests__/appsDirectoryContract.test.js b/frontend/src/lib/__tests__/appsDirectoryContract.test.js index 31afa0f42..7a259e964 100644 --- a/frontend/src/lib/__tests__/appsDirectoryContract.test.js +++ b/frontend/src/lib/__tests__/appsDirectoryContract.test.js @@ -9,7 +9,13 @@ const src = resolve(here, '../..') const drawer = readFileSync(resolve(src, 'components/Drawer/Drawer.jsx'), 'utf8') const directory = readFileSync(resolve(src, 'components/Drawer/AppsDirectory.jsx'), 'utf8') const css = readFileSync(resolve(src, 'components/Drawer/AppsDirectory.css'), 'utf8') +const drawerCss = readFileSync(resolve(src, 'components/Drawer/Drawer.css'), 'utf8') +const itemActionMenu = readFileSync( + resolve(src, 'components/Drawer/DrawerItemActionMenu.jsx'), + 'utf8', +) const shell = readFileSync(resolve(src, 'components/Shell/Shell.jsx'), 'utf8') +const shellCss = readFileSync(resolve(src, 'components/Shell/Shell.css'), 'utf8') const tabModel = readFileSync(resolve(src, 'components/Shell/tabModel.js'), 'utf8') const navigationIcons = readFileSync(resolve(src, 'components/navigationIcons.js'), 'utf8') @@ -23,8 +29,8 @@ test('Apps is a single drawer destination and the old full app list is gone', () test('the directory preserves app management on every card', () => { assert.match(drawer, /variant="card"/) assert.match(drawer, / { assert.match(tabModel, /APPS_TAB_KEY = 'apps:apps'/) assert.match(shell, /const APPS_KEY = tabModel\.APPS_TAB_KEY/) assert.match(css, /@media \(max-width: 720px\)[\s\S]*?grid-template-columns: repeat\(4/) - assert.match(drawer, /onContextMenu=\{openCardMenu\}/) - assert.match(drawer, /setTimeout\(\(\) => \{[\s\S]*?toggleMenu[\s\S]*?520\)/) - assert.match( - drawer, - /triggerClassName="drawer__more apps-directory__card-menu-anchor"\s+triggerHidden/, - 'the invisible menu anchor must not add a ghost keyboard focus stop', - ) + assert.equal((drawer.match(/onContextMenu=\{openItemMenu\}/g) || []).length, 2, + 'launcher cards and drawer rows must enter the same context-menu path') + assert.match(drawer, /DRAWER_HOLD_MS, PRE_HOLD_MOVE_PX/) + assert.match(drawer, /setTimeout\(\(\) => \{[\s\S]*?toggleMenu[\s\S]*?DRAWER_HOLD_MS\)/) + assert.doesNotMatch(drawer, /520/) + assert.match(drawer, /menuPlacement=\{openMenu/) + assert.match(itemActionMenu, /placeContextMenu/) + assert.match(itemActionMenu, /stopImmediatePropagation/) + assert.match(drawerCss, /@media \(max-width: 720px\)[\s\S]*?drawer__item-action-menu/) + assert.match(drawerCss, /bottom: max\(12px, env\(safe-area-inset-bottom\)\)/) assert.match(shell, /const navigationSurfaceOpen = modalDrawerOpen/) }) +test('chat and app rows share one placed action menu contract', () => { + assert.match(drawer, / { + const desktop = shellCss.match(/@media \(min-width: 1024px\) \{[\s\S]*$/)?.[0] || '' + assert.doesNotMatch(desktop, /(?:^|[;{])\s*zoom\s*:/, + 'desktop density must not scale the viewport away from pointer coordinates') +}) + test('the app directory distinguishes loading, errors, and confirmed emptiness', () => { assert.match(drawer, /status=\{appsStatus\}/) assert.match(directory, /status === 'loading'/) diff --git a/frontend/src/lib/__tests__/contextMenuGeometry.test.js b/frontend/src/lib/__tests__/contextMenuGeometry.test.js new file mode 100644 index 000000000..05f563152 --- /dev/null +++ b/frontend/src/lib/__tests__/contextMenuGeometry.test.js @@ -0,0 +1,37 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { placeContextMenu } from '../contextMenuGeometry.js' + +test('a desktop context menu stays beside the painted pointer through root zoom', () => { + const position = placeContextMenu({ + clientPoint: { x: 380, y: 215 }, + clientViewport: { left: 0, top: 0, width: 1512, height: 861 }, + layoutViewport: { width: 1680, height: 956.6667 }, + menuSize: { width: 220, height: 190 }, + }) + + assert.ok(Math.abs(position.x * 0.9 - 388) < 0.1) + assert.ok(Math.abs(position.y * 0.9 - 223) < 0.1) +}) + +test('a context menu flips before the right and bottom viewport edges', () => { + const position = placeContextMenu({ + clientPoint: { x: 790, y: 590 }, + clientViewport: { left: 0, top: 0, width: 800, height: 600 }, + layoutViewport: { width: 800, height: 600 }, + menuSize: { width: 220, height: 180 }, + }) + + assert.deepEqual(position, { x: 562, y: 402 }) +}) + +test('an oversized context menu clamps to the viewport padding', () => { + const position = placeContextMenu({ + clientPoint: { x: 10, y: 10 }, + clientViewport: { left: 0, top: 0, width: 200, height: 150 }, + layoutViewport: { width: 200, height: 150 }, + menuSize: { width: 240, height: 180 }, + }) + + assert.deepEqual(position, { x: 12, y: 12 }) +}) diff --git a/frontend/src/lib/contextMenuGeometry.js b/frontend/src/lib/contextMenuGeometry.js new file mode 100644 index 000000000..49d71842f --- /dev/null +++ b/frontend/src/lib/contextMenuGeometry.js @@ -0,0 +1,50 @@ +/* Context-menu geometry bridges painted pointer coordinates to the root + layout space, then keeps the menu beside the pointer and inside the viewport. */ + +function positiveNumber(value, fallback) { + const number = Number(value) + return Number.isFinite(number) && number > 0 ? number : fallback +} + +function clamp(value, minimum, maximum) { + return Math.min(Math.max(value, minimum), Math.max(minimum, maximum)) +} + +export function placeContextMenu({ + clientPoint, + clientViewport, + layoutViewport, + menuSize, + gap = 8, + padding = 12, +}) { + const clientWidth = positiveNumber(clientViewport?.width, 1) + const clientHeight = positiveNumber(clientViewport?.height, 1) + const layoutWidth = positiveNumber(layoutViewport?.width, clientWidth) + const layoutHeight = positiveNumber(layoutViewport?.height, clientHeight) + const scaleX = layoutWidth / clientWidth + const scaleY = layoutHeight / clientHeight + const pointX = (Number(clientPoint?.x) - (Number(clientViewport?.left) || 0)) * scaleX + const pointY = (Number(clientPoint?.y) - (Number(clientViewport?.top) || 0)) * scaleY + const menuWidth = positiveNumber(menuSize?.width, 0) + const menuHeight = positiveNumber(menuSize?.height, 0) + const gapX = Math.max(0, Number(gap) || 0) * scaleX + const gapY = Math.max(0, Number(gap) || 0) * scaleY + const paddingX = Math.max(0, Number(padding) || 0) * scaleX + const paddingY = Math.max(0, Number(padding) || 0) * scaleY + + let x = pointX + gapX + if (x + menuWidth > layoutWidth - paddingX) { + x = pointX - menuWidth - gapX + } + + let y = pointY + gapY + if (y + menuHeight > layoutHeight - paddingY) { + y = pointY - menuHeight - gapY + } + + return { + x: clamp(x, paddingX, layoutWidth - menuWidth - paddingX), + y: clamp(y, paddingY, layoutHeight - menuHeight - paddingY), + } +} From f2a8ace9b10c130fff3ddd298df615571f66da8d Mon Sep 17 00:00:00 2001 From: hamzamerzic <10846014+hamzamerzic@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:15:09 +0000 Subject: [PATCH 2/2] Restore explicit drawer action triggers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Möbius Agent --- frontend/src/components/Drawer/Drawer.css | 41 +++++++++++++++++++ frontend/src/components/Drawer/Drawer.jsx | 12 +++++- .../Shell/__tests__/workspaceUi.test.js | 8 +++- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Drawer/Drawer.css b/frontend/src/components/Drawer/Drawer.css index 675179511..f14342d02 100644 --- a/frontend/src/components/Drawer/Drawer.css +++ b/frontend/src/components/Drawer/Drawer.css @@ -422,6 +422,47 @@ touch-action: pan-y pinch-zoom; } +.drawer__more { + flex: 0 0 40px; + align-self: stretch; + display: grid; + place-items: center; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--muted); + cursor: pointer; + opacity: 0; + transition: opacity 0.12s, color 0.12s, background 0.12s, transform 0.08s; + -webkit-tap-highlight-color: transparent; +} + +.drawer__more:active { + color: var(--text); + background: var(--surface); + transform: scale(0.92); +} + +.drawer__more[aria-expanded="true"] { + opacity: 1; + color: var(--accent); + background: var(--accent-dim); +} + +.drawer__row:focus-within .drawer__more { opacity: 1; } + +@media (hover: hover) and (pointer: fine) { + .drawer__row:hover .drawer__more { opacity: 1; } + .drawer__more:hover { + color: var(--text); + background: var(--surface); + } +} + +@media (hover: none) { + .drawer__more { opacity: 0.5; } +} + /* App cards and drawer rows share one action-menu placement path. Pointer coordinates are converted to root layout space so shell density never shifts a menu away from the click. */ diff --git a/frontend/src/components/Drawer/Drawer.jsx b/frontend/src/components/Drawer/Drawer.jsx index bd8854ac7..75a27c903 100644 --- a/frontend/src/components/Drawer/Drawer.jsx +++ b/frontend/src/components/Drawer/Drawer.jsx @@ -1,7 +1,7 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useQueryClient } from '@tanstack/react-query' -import { Chats, PinFilled } from '@openai/apps-sdk-ui/components/Icon' +import { Chats, DotsVerticalMoreMenu, PinFilled } from '@openai/apps-sdk-ui/components/Icon' import { EmptyMessage } from '@openai/apps-sdk-ui/components/EmptyMessage' import { api } from '../../api/client.js' import { appQueries, chatQueries } from '../../hooks/queries.js' @@ -1546,6 +1546,16 @@ const DrawerRow = memo(function DrawerRow({ ) : null} {label} + on(?:Chat|App)/) }) -test('row-owned context menus have no detached hidden-anchor path', () => { +test('row-owned context menus keep a visible trigger without hidden anchors', () => { assert.match(drawer, /