diff --git a/src/renderer/src/components/SelectedTextCopyMenu.test.tsx b/src/renderer/src/components/SelectedTextCopyMenu.test.tsx
new file mode 100644
index 00000000000..b827e4ca9e0
--- /dev/null
+++ b/src/renderer/src/components/SelectedTextCopyMenu.test.tsx
@@ -0,0 +1,71 @@
+// @vitest-environment happy-dom
+
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { SelectedTextCopyMenu } from './SelectedTextCopyMenu'
+
+vi.mock('@/i18n/i18n', () => ({
+ translate: (_key: string, fallback: string) => fallback
+}))
+
+afterEach(cleanup)
+
+beforeEach(() => {
+ Object.assign(window, { api: { ui: { writeClipboardText: vi.fn() } } })
+ setViewportSize(1200, 800)
+})
+
+function setViewportSize(width: number, height: number): void {
+ Object.assign(window, { innerWidth: width, innerHeight: height })
+}
+
+function openMenuOnSelectedText(): void {
+ render(
+
+ selected words
+
+ )
+ const content = screen.getByTestId('content')
+ const range = document.createRange()
+ range.selectNodeContents(content)
+ const selection = window.getSelection()
+ selection?.removeAllRanges()
+ selection?.addRange(range)
+ fireEvent.contextMenu(content, { clientX: 120, clientY: 240 })
+}
+
+describe('SelectedTextCopyMenu', () => {
+ it('opens on right-click over selected text', () => {
+ openMenuOnSelectedText()
+
+ expect(screen.getByRole('button', { name: 'Copy' })).toBeDefined()
+ })
+
+ it('stays open when a resize reports the same viewport size', () => {
+ openMenuOnSelectedText()
+
+ // Why: the reveal reflow fires a real resize event without changing any dimension.
+ fireEvent(window, new Event('resize'))
+
+ expect(screen.queryByRole('button', { name: 'Copy' })).not.toBeNull()
+ })
+
+ it('closes when a resize actually changes the viewport size', () => {
+ openMenuOnSelectedText()
+
+ setViewportSize(900, 800)
+ fireEvent(window, new Event('resize'))
+
+ expect(screen.queryByRole('button', { name: 'Copy' })).toBeNull()
+ })
+
+ it('still closes on a real resize that follows a no-op one', () => {
+ openMenuOnSelectedText()
+
+ fireEvent(window, new Event('resize'))
+ setViewportSize(1200, 640)
+ fireEvent(window, new Event('resize'))
+
+ expect(screen.queryByRole('button', { name: 'Copy' })).toBeNull()
+ })
+})
diff --git a/src/renderer/src/components/SelectedTextCopyMenu.tsx b/src/renderer/src/components/SelectedTextCopyMenu.tsx
index d08acaf9d44..5198ce2002f 100644
--- a/src/renderer/src/components/SelectedTextCopyMenu.tsx
+++ b/src/renderer/src/components/SelectedTextCopyMenu.tsx
@@ -2,6 +2,7 @@ import React from 'react'
import { createPortal } from 'react-dom'
import { Copy } from 'lucide-react'
import { translate } from '@/i18n/i18n'
+import { addViewportSizeChangeListener } from '@/hooks/viewport-size-change-listener'
type SelectedTextCopyMenuProps = {
children: React.ReactNode
@@ -58,12 +59,14 @@ export function SelectedTextCopyMenu({
window.addEventListener('pointerdown', close)
window.addEventListener('keydown', handleKeyDown, true)
window.addEventListener('scroll', close, true)
- window.addEventListener('resize', close)
+ // Why: a bare resize listener also fires on the main process's reveal reflow, which changes
+ // no dimensions — the menu would close on every window restore.
+ const removeViewportListener = addViewportSizeChangeListener(close)
return () => {
window.removeEventListener('pointerdown', close)
window.removeEventListener('keydown', handleKeyDown, true)
window.removeEventListener('scroll', close, true)
- window.removeEventListener('resize', close)
+ removeViewportListener()
}
}, [menu])
diff --git a/src/renderer/src/components/editor/RichMarkdownLinkBubble.render.test.tsx b/src/renderer/src/components/editor/RichMarkdownLinkBubble.render.test.tsx
new file mode 100644
index 00000000000..eae0896a1aa
--- /dev/null
+++ b/src/renderer/src/components/editor/RichMarkdownLinkBubble.render.test.tsx
@@ -0,0 +1,83 @@
+// @vitest-environment happy-dom
+
+import { cleanup, fireEvent, render } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { TooltipProvider } from '@/components/ui/tooltip'
+import { RichMarkdownLinkBubble, type LinkBubbleState } from './RichMarkdownLinkBubble'
+
+vi.mock('@/i18n/i18n', () => ({
+ translate: (_key: string, fallback: string) => fallback
+}))
+
+afterEach(cleanup)
+
+beforeEach(() => {
+ setViewportSize(1200, 800)
+})
+
+function setViewportSize(width: number, height: number): void {
+ Object.assign(window, { innerWidth: width, innerHeight: height })
+}
+
+const LINK_BUBBLE: LinkBubbleState = {
+ kind: 'markdown',
+ href: 'https://example.com',
+ left: 40,
+ top: 60,
+ openEnabled: true,
+ copyEnabled: true
+}
+
+function renderBubble(): { onDismiss: ReturnType } {
+ const anchorElement = document.createElement('div')
+ document.body.append(anchorElement)
+ const onDismiss = vi.fn()
+ render(
+
+
+
+ )
+ onDismiss.mockClear()
+ return { onDismiss }
+}
+
+describe('RichMarkdownLinkBubble viewport dismissal', () => {
+ it('stays open when a resize reports the same viewport size', () => {
+ const { onDismiss } = renderBubble()
+
+ // Why: the reveal reflow fires a real resize event without changing any dimension.
+ fireEvent(window, new Event('resize'))
+
+ expect(onDismiss).not.toHaveBeenCalled()
+ })
+
+ it('dismisses when a resize actually changes the viewport size', () => {
+ const { onDismiss } = renderBubble()
+
+ setViewportSize(1200, 640)
+ fireEvent(window, new Event('resize'))
+
+ expect(onDismiss).toHaveBeenCalled()
+ })
+
+ it('still dismisses on a real resize that follows a no-op one', () => {
+ const { onDismiss } = renderBubble()
+
+ fireEvent(window, new Event('resize'))
+ setViewportSize(900, 800)
+ fireEvent(window, new Event('resize'))
+
+ expect(onDismiss).toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx b/src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx
index aafffc60376..0751d7216e5 100644
--- a/src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx
+++ b/src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx
@@ -5,6 +5,7 @@ import { Copy, ExternalLink, Pencil, Unlink } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { addViewportSizeChangeListener } from '@/hooks/viewport-size-change-listener'
export type LinkBubbleState = {
kind: 'markdown' | 'html-superscript'
@@ -258,7 +259,9 @@ export function RichMarkdownLinkBubble({
window.addEventListener('pointerdown', dismissOutside, true)
window.addEventListener('focusin', dismissOutside, true)
window.addEventListener('scroll', dismissOnScroll, true)
- window.addEventListener('resize', dismiss)
+ // Why: a bare resize listener also fires on the main process's reveal reflow, which changes
+ // no dimensions — the bubble would dismiss on every window restore.
+ const removeViewportListener = addViewportSizeChangeListener(dismiss)
return () => {
resizeObserver.disconnect()
intersectionObserver.disconnect()
@@ -266,7 +269,7 @@ export function RichMarkdownLinkBubble({
window.removeEventListener('pointerdown', dismissOutside, true)
window.removeEventListener('focusin', dismissOutside, true)
window.removeEventListener('scroll', dismissOnScroll, true)
- window.removeEventListener('resize', dismiss)
+ removeViewportListener()
}
}, [anchorElement])
diff --git a/src/renderer/src/hooks/viewport-size-change-listener.test.ts b/src/renderer/src/hooks/viewport-size-change-listener.test.ts
new file mode 100644
index 00000000000..e35f8934be7
--- /dev/null
+++ b/src/renderer/src/hooks/viewport-size-change-listener.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it, vi } from 'vitest'
+import { addViewportSizeChangeListener } from './viewport-size-change-listener'
+
+function createViewport(width: number, height: number) {
+ const listeners = new Set<() => void>()
+ return {
+ innerWidth: width,
+ innerHeight: height,
+ addEventListener: (_type: 'resize', listener: () => void) => {
+ listeners.add(listener)
+ },
+ removeEventListener: (_type: 'resize', listener: () => void) => {
+ listeners.delete(listener)
+ },
+ resizeTo(nextWidth: number, nextHeight: number) {
+ this.innerWidth = nextWidth
+ this.innerHeight = nextHeight
+ for (const listener of listeners) {
+ listener()
+ }
+ },
+ emitResize() {
+ for (const listener of listeners) {
+ listener()
+ }
+ },
+ get listenerCount() {
+ return listeners.size
+ }
+ }
+}
+
+describe('addViewportSizeChangeListener', () => {
+ it('runs the callback when the viewport size changes', () => {
+ const viewport = createViewport(1024, 768)
+ const onChange = vi.fn()
+ addViewportSizeChangeListener(onChange, viewport)
+ viewport.resizeTo(1024, 900)
+ expect(onChange).toHaveBeenCalledTimes(1)
+ })
+
+ // Why: the reveal reflow nudges the device scale factor, so Chromium fires resize with identical
+ // dimensions — dismissing transient UI there closes it on every window restore.
+ it('ignores a resize that leaves the dimensions untouched', () => {
+ const viewport = createViewport(1024, 768)
+ const onChange = vi.fn()
+ addViewportSizeChangeListener(onChange, viewport)
+ viewport.emitResize()
+ expect(onChange).not.toHaveBeenCalled()
+ })
+
+ it('still reports a later real change after ignoring a no-op resize', () => {
+ const viewport = createViewport(1024, 768)
+ const onChange = vi.fn()
+ addViewportSizeChangeListener(onChange, viewport)
+ viewport.emitResize()
+ viewport.resizeTo(800, 768)
+ expect(onChange).toHaveBeenCalledTimes(1)
+ })
+
+ it('treats each size as the new baseline rather than the original', () => {
+ const viewport = createViewport(1024, 768)
+ const onChange = vi.fn()
+ addViewportSizeChangeListener(onChange, viewport)
+ viewport.resizeTo(900, 768)
+ viewport.emitResize()
+ expect(onChange).toHaveBeenCalledTimes(1)
+ })
+
+ it('stops listening once removed', () => {
+ const viewport = createViewport(1024, 768)
+ const onChange = vi.fn()
+ const remove = addViewportSizeChangeListener(onChange, viewport)
+ remove()
+ expect(viewport.listenerCount).toBe(0)
+ viewport.resizeTo(640, 480)
+ expect(onChange).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/hooks/viewport-size-change-listener.ts b/src/renderer/src/hooks/viewport-size-change-listener.ts
new file mode 100644
index 00000000000..aef4de6acb5
--- /dev/null
+++ b/src/renderer/src/hooks/viewport-size-change-listener.ts
@@ -0,0 +1,32 @@
+/**
+ * Run `onChange` only when a `resize` event actually changed the viewport's CSS size.
+ *
+ * Why: the main process reflows the renderer by briefly nudging the emulated device scale factor
+ * (macOS 26) or the native frame (elsewhere) on every reveal, resume, and restore. Chromium fires
+ * a real `resize` for those even though `innerWidth`/`innerHeight` are identical, so transient UI
+ * bound directly to `resize` gets dismissed with no user interaction.
+ */
+type ViewportEventTarget = Pick & {
+ addEventListener: (type: 'resize', listener: () => void) => void
+ removeEventListener: (type: 'resize', listener: () => void) => void
+}
+
+export function addViewportSizeChangeListener(
+ onChange: () => void,
+ // Why: the suite runs in node with no DOM, so tests pass a stub instead.
+ target: ViewportEventTarget = window
+): () => void {
+ let lastWidth = target.innerWidth
+ let lastHeight = target.innerHeight
+ const handleResize = (): void => {
+ const { innerWidth, innerHeight } = target
+ if (innerWidth === lastWidth && innerHeight === lastHeight) {
+ return
+ }
+ lastWidth = innerWidth
+ lastHeight = innerHeight
+ onChange()
+ }
+ target.addEventListener('resize', handleResize)
+ return () => target.removeEventListener('resize', handleResize)
+}