diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx
index 583a6fdcd0..085e6a2118 100644
--- a/apps/desktop/src/app/settings/config-settings.tsx
+++ b/apps/desktop/src/app/settings/config-settings.tsx
@@ -21,6 +21,7 @@ import {
setDataUrlReadMaxMb
} from '@/store/data-url-read-max'
import { $disableF12, setDisableF12 } from '@/store/disable-f12'
+import { $alwaysExternalLinks, setAlwaysExternalLinks } from '@/store/external-links'
import { $keepAwake, setKeepAwake } from '@/store/keep-awake'
import { notify, notifyError } from '@/store/notifications'
import { normalizeProfileKey } from '@/store/profile'
@@ -101,6 +102,7 @@ function ConfigSettingsInner({
const c = t.settings.config
const keepAwake = useStore($keepAwake)
const disableF12 = useStore($disableF12)
+ const alwaysExternalLinks = useStore($alwaysExternalLinks)
// The editable draft is local (debounced autosave watches it), but it's seeded
// from — and saved back through — the shared config cache, so edits are visible
// in the MCP/model surfaces and reopening the page doesn't reload-flash.
@@ -453,6 +455,12 @@ function ConfigSettingsInner({
label={c.disableF12Title}
onChange={setDisableF12}
/>
+
>
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index d1c50181f3..cd42efd002 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -1313,6 +1313,9 @@ export const en: Translations = {
keepAwakeDesc: 'Stop this machine from sleeping so long or overnight runs keep going. The display can still dim.',
disableF12Title: 'Disable F12 DevTools',
disableF12Desc: 'Block F12 from opening Developer Tools. Ctrl+Shift+I (or Cmd+Opt+I on Mac) still works.',
+ alwaysExternalLinksTitle: 'Always open links in external browser',
+ alwaysExternalLinksDesc:
+ 'Open every link you click in your system browser instead of the in-app browser. "Open in in-app browser" in the right-click menu still works.',
attachmentSizeTitle: 'Max preview / image load size',
attachmentSizeDesc:
'How big a local file Desktop will load for previews and image attach, in MB. Default is 16. Remote non-image attach uses a separate 256 MB cap. Setting this very high loads the whole file into memory and can freeze or crash the app.',
diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts
index cc8072320a..3595bf23ab 100644
--- a/apps/desktop/src/i18n/ru.ts
+++ b/apps/desktop/src/i18n/ru.ts
@@ -1054,6 +1054,9 @@ export const ru = defineLocale({
disableF12Title: 'Отключить F12 DevTools',
disableF12Desc:
'Блокирует открытие Developer Tools по F12. Ctrl+Shift+I (на Mac — Cmd+Opt+I) продолжает работать.',
+ alwaysExternalLinksTitle: 'Всегда открывать ссылки во внешнем браузере',
+ alwaysExternalLinksDesc:
+ 'Открывать каждую ссылку в системном браузере вместо встроенного. Пункт «Открыть во встроенном браузере» в контекстном меню продолжает работать.',
attachmentSizeTitle: 'Макс. размер превью / загрузки изображений',
attachmentSizeDesc:
'Насколько большой локальный файл приложение будет загружать для превью и вложений, в МБ. По умолчанию 16. Для удалённых неграфических вложений действует отдельный лимит 256 МБ. Слишком большое значение загружает весь файл в память и может подвесить или уронить приложение.',
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 71c64d97d9..1e63640160 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -1092,6 +1092,8 @@ export interface Translations {
keepAwakeDesc: string
disableF12Title: string
disableF12Desc: string
+ alwaysExternalLinksTitle: string
+ alwaysExternalLinksDesc: string
attachmentSizeTitle: string
attachmentSizeDesc: string
attachmentSizeUnit: string
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 86c5f1baeb..94fabba438 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -1277,6 +1277,9 @@ export const zh = defineLocale({
keepAwakeDesc: '阻止本机休眠,让长时间或通宵运行继续进行。屏幕仍可变暗。',
disableF12Title: '禁用 F12 开发者工具',
disableF12Desc: '阻止 F12 打开开发者工具。Ctrl+Shift+I(Mac 上为 Cmd+Opt+I)仍然可用。',
+ alwaysExternalLinksTitle: '始终在外部浏览器中打开链接',
+ alwaysExternalLinksDesc:
+ '点击的每个链接都在系统浏览器中打开,而不是应用内浏览器。右键菜单中的“在应用内浏览器中打开”仍然可用。',
attachmentSizeTitle: '预览 / 图片加载大小上限',
attachmentSizeDesc:
'桌面端为预览和图片附件加载本地文件的大小上限(MB)。默认为 16。远程非图片附件使用单独的 256 MB 上限。设置过大会将整个文件读入内存,可能导致应用卡死或崩溃。',
diff --git a/apps/desktop/src/lib/external-link.test.tsx b/apps/desktop/src/lib/external-link.test.tsx
index 14d99d80af..141b8f54d8 100644
--- a/apps/desktop/src/lib/external-link.test.tsx
+++ b/apps/desktop/src/lib/external-link.test.tsx
@@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
import { afterEach, describe, expect, it, vi } from 'vitest'
import { IS_MAC } from '@/lib/keybinds/combo'
+import { setAlwaysExternalLinks } from '@/store/external-links'
import { $previewTabs, closeRightRail } from '@/store/preview'
import {
@@ -41,6 +42,7 @@ function installTitleBridge(title: string) {
afterEach(() => {
__resetLinkTitleCache()
closeRightRail()
+ setAlwaysExternalLinks(false)
vi.restoreAllMocks()
cleanup()
@@ -136,6 +138,19 @@ describe('external link helpers', () => {
expect($previewTabs.get()).toHaveLength(0)
})
+ it('sends a plain click to the OS browser when "always external" is on', () => {
+ const openExternal = vi.fn().mockResolvedValue(undefined)
+ installDesktopBridge({ openExternal: openExternal as unknown as Window['hermesDesktop']['openExternal'] })
+ setAlwaysExternalLinks(true)
+
+ render(Example link)
+
+ fireEvent.click(screen.getByRole('link', { name: 'Example link' }))
+
+ expect(openExternal).toHaveBeenCalledWith('https://example.com/path/to/resource')
+ expect($previewTabs.get()).toHaveLength(0)
+ })
+
it('treats only the HUD renderer as a native-link surface', () => {
expect(hudForcesNativeLinks('')).toBe(false)
expect(hudForcesNativeLinks('?win=secondary')).toBe(false)
diff --git a/apps/desktop/src/lib/external-link.tsx b/apps/desktop/src/lib/external-link.tsx
index 26b0b69de8..5314ecafd8 100644
--- a/apps/desktop/src/lib/external-link.tsx
+++ b/apps/desktop/src/lib/external-link.tsx
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react'
import { ArrowUpRight } from '@/lib/icons'
import { IS_MAC } from '@/lib/keybinds/combo'
+import { $alwaysExternalLinks } from '@/store/external-links'
import { resolveBrandIcon } from './brand-icon'
import { cn } from './utils'
@@ -244,7 +245,8 @@ export function hudForcesNativeLinks(search = typeof window === 'undefined' ? ''
*
* Everything that ISN'T a web page — `mailto:`, `file:`, a custom scheme — has
* no business in the webview and always hands off to the OS. The HUD has no
- * browser pane, so it always takes the OS path.
+ * browser pane, so it always takes the OS path. The "Always open links in
+ * external browser" setting (`$alwaysExternalLinks`) sends every click there.
*/
export function openLink(href: string, options: { native?: boolean } = {}): void {
const target = normalizeExternalUrl(href)
@@ -255,6 +257,7 @@ export function openLink(href: string, options: { native?: boolean } = {}): void
if (
options.native ||
+ $alwaysExternalLinks.get() ||
isConnectorAuthorizationLink(target) ||
hudForcesNativeLinks() ||
!/^https?:$/i.test(parseUrl(target)?.protocol ?? '')
diff --git a/apps/desktop/src/store/external-links.test.ts b/apps/desktop/src/store/external-links.test.ts
new file mode 100644
index 0000000000..f111ea8265
--- /dev/null
+++ b/apps/desktop/src/store/external-links.test.ts
@@ -0,0 +1,29 @@
+import { afterEach, describe, expect, it } from 'vitest'
+
+import { storedBoolean } from '@/lib/storage'
+
+import { $alwaysExternalLinks, setAlwaysExternalLinks } from './external-links'
+
+const KEY = 'hermes.desktop.alwaysExternalLinks.v1'
+
+afterEach(() => {
+ setAlwaysExternalLinks(false)
+})
+
+describe('always-external-links store', () => {
+ it('defaults off and persists the pref', () => {
+ expect($alwaysExternalLinks.get()).toBe(false)
+
+ setAlwaysExternalLinks(true)
+ expect(storedBoolean(KEY, false)).toBe(true)
+
+ setAlwaysExternalLinks(false)
+ expect(storedBoolean(KEY, true)).toBe(false)
+ })
+
+ it('follows a change made in another window', () => {
+ window.localStorage.setItem(KEY, 'true')
+ window.dispatchEvent(new StorageEvent('storage', { key: KEY }))
+ expect($alwaysExternalLinks.get()).toBe(true)
+ })
+})
diff --git a/apps/desktop/src/store/external-links.ts b/apps/desktop/src/store/external-links.ts
new file mode 100644
index 0000000000..f22c8c0c08
--- /dev/null
+++ b/apps/desktop/src/store/external-links.ts
@@ -0,0 +1,34 @@
+/**
+ * Always open links in the system browser — a device-local preference.
+ *
+ * Off (the default): a clicked web link opens in the in-app browser, and
+ * ⌘/Ctrl-click or middle-click escapes to the OS browser. On: every clicked
+ * link goes to the OS browser (see `openLink` in `@/lib/external-link`).
+ *
+ * Renderer-owned: it only decides where THIS machine's link clicks land. The
+ * `storage` listener keeps every open window in step when one window flips it.
+ * Explicit "Open in in-app browser" menu actions and agent-driven previews are
+ * not link clicks and stay unaffected.
+ */
+
+import { atom } from 'nanostores'
+
+import { persistBoolean, storedBoolean } from '@/lib/storage'
+
+const KEY = 'hermes.desktop.alwaysExternalLinks.v1'
+
+export const $alwaysExternalLinks = atom(typeof window === 'undefined' ? false : storedBoolean(KEY, false))
+
+export function setAlwaysExternalLinks(on: boolean): void {
+ $alwaysExternalLinks.set(on)
+}
+
+if (typeof window !== 'undefined') {
+ $alwaysExternalLinks.subscribe(on => persistBoolean(KEY, on))
+
+ window.addEventListener('storage', event => {
+ if (event.key === KEY) {
+ $alwaysExternalLinks.set(storedBoolean(KEY, false))
+ }
+ })
+}