Merge remote-tracking branch 'origin/main' into ethie/pm-clean

This commit is contained in:
ethernet
2026-09-24 02:52:31 -04:00
9 changed files with 101 additions and 1 deletions

View File

@@ -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}
/>
<ToggleRow
checked={alwaysExternalLinks}
description={c.alwaysExternalLinksDesc}
label={c.alwaysExternalLinksTitle}
onChange={setAlwaysExternalLinks}
/>
<PoolLimitsSetting />
<QuickEntrySettings />
</>

View File

@@ -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.',

View File

@@ -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 МБ. Слишком большое значение загружает весь файл в память и может подвесить или уронить приложение.',

View File

@@ -1092,6 +1092,8 @@ export interface Translations {
keepAwakeDesc: string
disableF12Title: string
disableF12Desc: string
alwaysExternalLinksTitle: string
alwaysExternalLinksDesc: string
attachmentSizeTitle: string
attachmentSizeDesc: string
attachmentSizeUnit: string

View File

@@ -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 上限。设置过大会将整个文件读入内存,可能导致应用卡死或崩溃。',

View File

@@ -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(<ExternalLink href="https://example.com/path/to/resource">Example link</ExternalLink>)
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)

View File

@@ -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 ?? '')

View File

@@ -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)
})
})

View File

@@ -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<boolean>(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))
}
})
}