- Discoverable "+" button in single-window state — issue #75 — AppKit's native tab bar (with its own "+" button) only renders when ≥2 tabs are in the group, which means single-window users had no way to discover that tabs exist short of
Cmd+Tmuscle-memory. Added a small "+" button at the top-right of the title-bar drag zone that's always visible in single-window state. When a second tab opens and AppKit's own tab bar appears, our button auto-hides so we don't double up with AppKit's "+". Click routes toAppDelegate.newBrowserTab()(the same target asCmd+T). Reporter: @cygnusignis. Closes #75. - Standard application menu items (Hide / Hide Others / Show All / Services) — issue #77 —
Cmd+Hdid nothing because the hand-rolled application menu inAppDelegate.setupMenu()only had About / Check for Updates / Settings / Quit. TheWindow → Show Hermesitem usedCmd+Shift+H(uppercasekeyEquivalent), and there was noNSApplication.hide(_:)selector registered against any item withCmd+H. Added the standard macOS HIG application-menu group between Preferences and Quit: Services submenu (auto-populated by AppKit viaNSApp.servicesMenu), Hide [App] (Cmd+H), Hide Others (Option+Cmd+H), and Show All. Reporter: @cygnusignis. Closes #77.
- Tab-bar chrome no longer flashes light from overlay or partial-paint samples — issue #70 follow-up — v1.6.2 stopped reading pixel samples for chrome when a
<meta theme-color>was available, but the appearance-resolution rule (which decides whether to apply.aquaor.darkAqua) still flipped to light at any luminance ≥ 0.5. A poisoned UserDefaults cache (e.g. an off-white sample stuck from before v1.6.2) could persist and make the next launch open with.aquaeven when the user's actual theme was dark — giving them a flash of light AppKit material before the bridge re-sampled. Per Nathan's directive ("default dark, only flip light with strong evidence"), raised the threshold to 0.85 (genuinely near-white). Both code paths —loadCachedTheme()on launch and the livehermesThememessage handler — now share a singleAppDelegate.appearanceForLuminance(_:)helper. Canonical light-theme--bgvalues for hermes-webui are #FEFCF7 (~0.99) and #FAF9F5 (~0.98), well above the threshold; canonical dark-theme--bgvalues are around #1A1A1A (~0.10), well below. Anything in the 0.5…0.85 murky middle (overlay dim layers, half-translucent panels, partial-paint mount samples) stays.darkAqua. Reporter: @cygnusignis (#70 follow-up). - "Update Now" no longer navigates to a JSON
{"error":"not found"}page — issue #76 — pressing "Update Now" in the WebUI's update banner could land the WKWebView on a bare JSON page (recovers on app restart). Cause: a race during the post-update server restart where_waitForServerThenReload()'slocation.reload()arrives at a moment when the server's route table is partially built — the home route returns the{"error":"not found"}JSON shape via the route table's_not_foundfallback. Added a defensive guard indecidePolicyForthat cancels any in-app navigation to/api/*paths. The WebUI's JS treats those as fetch targets only; if a navigation slips through (this race or any future regression), the WKWebView keeps the previous page state visible instead of rendering a JSON body as the entire page. Companion WebUI-side fix tracked at nesquena/hermes-webui#1835 to lock down the home route to never return JSON. Reporter: @cygnusignis. Closes #76.
- Issue #78 (surface compression status) closed as wrong-repo. Real fix targets the WebUI's compression UI gaps — refiled as nesquena/hermes-webui#1832 (in-progress signal), #1833 (in-conversation marker), and #1834 (toast TTL / sticky banner).
- Issue #73 (latent skin-only sync gap) deferred — every current theme has a distinct
--bg, so the bug doesn't surface in production. Stays open against a future skin that overlays accent on a shared--bg.
- Sequential screenshot pastes drop all but the first — bug #1733 — the Mac app's paste handler hardcoded the file name
screenshot.pngfor every paste. The WebUI'saddFiles()helper (static/ui.js) dedupes the composer'spendingFilesarray byf.name, so when a user takes multiple screenshots in succession and pastes them one at a time (Cmd+Shift+4 → Cmd+V → Cmd+Shift+4 → Cmd+V → …), only the first pasted image survived in the composer tray. The same workflow worked correctly in a regular browser becausestatic/boot.js's paste path already suffixes the timestamp into the filename. Mirrored that scheme on the Swift side:screenshot-<msTimestamp>-<sequence>.pngwhere<sequence>is a monotonic per-process counter that disambiguates pastes that land within the same millisecond. Each paste now appears as its own chip in the composer tray and uploads alongside the others. Closes nesquena/hermes-webui#1733.
- Native AppKit tab dividers restored — v1.6.0–v1.6.2 painted
NSWindow.backgroundColorwith the page's exact RGB so the title-bar zone matched the page edge seamlessly. With native tabs visible, that flat fill swamped the tab bar's translucent material — the dividers between tabs disappeared and the active tab no longer read as elevated. Fix: stop overridingwindow.backgroundColor. Let it fall back to AppKit's default for the currentNSAppearanceso the native tab bar paints with its system tonal materials. The SSH footer still tints with the exact page RGB (it has no native treatment to preserve). The WKWebView'sunderPageBackgroundColorand the documentStart background-paint script still backstop new-tab / reload paint with the cached theme, so the no-flicker behaviour from v1.6.1 (fix #52) is preserved. - Cross-tab theme/skin propagation — Changing the theme in one tab did not re-render sibling tabs — each WKWebView held its own rendered theme until manually reloaded, so users could see two tabs in different themes simultaneously. hermes-webui's
boot.jsdoes not listen forstorageevents (verified May 2026), so a sharedWKWebsiteDataStorealone wasn't enough. Fix: whenAppDelegate.updateAppearancefires (a tab's bridge reported a stable new colour passing the 2.5 s STABILITY_MS gate), broadcast a small JS snippet to every browser window's WKWebView calling_applyTheme(localStorage.getItem('hermes-theme'))and_applySkin(...)plus the picker-sync helpers. Those functions are top-level inboot.js(loaded as a regular<script>, not a module), making them globals onwindowand reachable fromevaluateJavaScript. localStorage is shared across same-origin WKWebViews via the default data store, so each sibling tab reads the values the source tab just wrote via_pickTheme/_pickSkin. Idempotent on the source tab.typeof === 'function'guard handles tabs still mid-load. The cross-tab sync directly references_applyTheme/_applySkin/_syncThemePicker/_syncSkinPickerfrom hermes-webui'sboot.js— if any of those are renamed or converted to ES module locals, the Mac shim breaks silently. Long-term shape: a stablewindow.hermes.setTheme(theme, skin)API on the web side.
- Tab-bar / title-bar strip flashes white in multi-tab sessions, and stays stuck after closing the offending tab — bug #70 — v1.6.1's theme bridge sampled the page background by walking
document.elementsFromPoint(x, y)at three fixed viewport coordinates. Any opaque modal, lightbox, settings panel, file-tree overlay, or image preview that covered any of the three sample points and stayed visible past the 2.5 s stability gate would make the bridge fire the overlay's colour. With multi-tab, that one tab's overlay-derived colour was then propagated to every other window's chrome, producing the symptom Cygnus filed in #70 (white tab strip persisting until the user could refresh). The pixel-sampling architecture is fundamentally fragile against legitimate page overlays. Fix: prefer the WebUI's own<meta name="theme-color" id="hermes-theme-color">tag (introduced in hermes-webui v0.51.x+) as the single source of truth — it's page-controlled, overlay-resistant, and updated byboot.jswhenever the user toggles theme or skin. The bridge falls back to the original pixel-sampling path when the meta tag is absent (older WebUI servers, raw error pages), so behavior is unchanged for self-hosters who haven't updated yet. Also adds aMutationObserveron the meta tag'scontentattribute so toggles propagate without waiting for the 2 s poll tick. Closes #70.
- AppKit chrome (tab bar, title bar, traffic lights, status bars) now follows the web UI theme dynamically — v1.6.0's multi-window/tabs feature exposed an appearance mismatch in light mode: the active tab rendered as a bright white strip against the always-dark Hermes web content. Single-window mode hid this since
.fullSizeContentView+titlebarAppearsTransparentleft no opaque AppKit chrome to clash with. Fix: a theme-bridgeWKUserScriptsamples the page's effective background colour at three viewport pixels (viadocument.elementsFromPoint) and reports it to Swift viawebkit.messageHandlers.hermesTheme.AppDelegate.updateAppearancepropagates the matchingNSAppearance(.aqua / .darkAqua) and the exact RGB to every browser, Preferences, Error, and Splash window — keeping the tab-bar strip, title bar, traffic lights, and SSH footer in lock-step with whatever theme the web UI currently shows. Three layers of defense against flicker: (1) match-suppression — samples that match the chrome's current colour are dropped silently, (2) 2500 ms stability gate — non-matching samples must hold steady for 2.5 s before firing, so React mount-time dark flashes never propagate, (3) persisted theme cache (UserDefaults, 7-day TTL) —loadCachedTheme()runs beforestartTunnelso the splash and first browser window open with the last-seen theme. Pre-paintunderPageBackgroundColorand thedocumentStartbody/html background script both read the cached colour, so Cmd+R and new-tab paint the correct theme from the very first frame. - Tab title shows the active conversation name —
webView.titleis mirrored intowindow.titlevia KVO, with a regex-strip of trailing" — Hermes"/" — Hermes Agent"/ pipe-or-hyphen-or-middle-dot separators so the brand suffix doesn't repeat noise on a Mac tab. Truncated to 40 chars with an ellipsis for longer titles. Falls back to"Hermes Agent ● host:port"in direct mode (preserving the v1.5.0 health indicator) and"Hermes Agent"in SSH mode (the SSH status bar already surfaces host info). - SSH status bar at the bottom of the window now matches the page background colour exactly —
NSVisualEffectView .titlebarmaterial was tinting the colour off via vibrancy, breaking the visual seam between the tab-bar strip and the SSH footer. Switched to plainNSViewwith explicit.layer.backgroundColor = currentBackgroundColor.cgColor, applied through a newapplyChromeBackgroundColor(_:)method thatAppDelegate.updateAppearancecalls on every browser window. The 1-px separator re-resolvesNSColor.separatorColor.cgColorin the window's effective appearance to keep its tone crisp. - Web-app titlebar auto-hides when the native AppKit tab bar is rendering — once you have multiple tabs, the AppKit tab strip already shows each conversation's name (mirrored from
webView.titlevia the KVO bridge). The web app's redundant.app-titlebarrow (hard-coded "Hermes" text + sub badge) collapses via abody.hermes-mac-tabbedclass injected at documentStart, then toggled by a newupdateAppTitlebarClass(tabbed:)helper on everyupdateWebViewLayoutpass and afterdidFinish. Closing tabs back down to one restores it. Bonus: switched the visibility check from rawtabbedWindows.count > 1toNSWindowTabGroup.isTabBarVisible, which also catches the explicit Window → Show Tab Bar case with a single window (a latent v1.6.0 bug where AppKit's bar would clip web content when manually requested). - Find bar covered by webView on resize / tab change (#68) — v1.6.0's
updateWebViewLayout()(called fromwindowDidResize, fullscreen transitions, and thetabbedWindowsKVO observer) didn't consultfindBarVisible, so any of those triggers grew the WKWebView frame back over the find bar — hiding the search field while the bar remained in the view hierarchy. Fixed by subtracting the 36 px find-bar height fromtopYwhen the find bar is open. The find bar's own frame is anchored tocontentLayoutRect.maxY - barHeightso it follows the title-bar zone correctly across all transitions; only webView height needed the carve-out.
Engineering notes: most of this branch is the dynamic-theme-tracking architecture. Three layers of flicker defense (match-suppression, 2500 ms stability gate, persisted UserDefaults cache) work together so the only situation a user sees a colour change is when they actually toggled themes — never on Cmd+R, Cmd+T, or app launch. The 2500 ms gate makes user-initiated theme toggles feel slightly delayed (the chrome catches up 2.5 s after the page); future work could expose an explicit hermes-webui → Mac wrapper signal that bypasses the gate for known-deliberate changes.
-
Multi-window and native tabs (#42) — open multiple independent Hermes sessions from the same app.
Cmd+Nopens a new window;Cmd+Tdoes the same and lands as a tab when the user's "Prefer Tabs" system preference is set to Always (or In Full Screen). Each window owns its ownWKWebViewso server-side sessions stay independent — chat history, scroll position, in-flight streams, cookies, and localStorage are preserved per window. AppKit's native tabbing system fills inShow Tab Bar,Show All Tabs,Move Tab to New Window, andMerge All Windowsautomatically in the Window menu, plus the tab-bar plus button (wired throughnewWindowForTab). The frontmost browser window receives all menu actions (Find, Zoom, Reload); the global hotkey (defaultCmd+Shift+H) and Dock-icon click both surface the most-recently-active window. Reconnect logic (SSH tunnel restore, network recovery viaNWPathMonitor, manual retry) fans out across every open window — every WKWebView reloads in place, no session loss. Window frame autosave applies only to the first window of a session; subsequent windows cascade from the front-most window's frame so they're visibly stacked. Closes #42.Engineering notes: Opus pre-commit advisor caught four real correctness bugs before this shipped — (1)
onNavigationFailedretain cycle leaking the BrowserWindowController + WKWebView for every nav failure, (2)windowShouldClosereturningfalseunconditionally caused closed tabs to phantom inbrowserWindowsbecausewindowWillClosedoesn't fire onorderOut, (3)windowDidExitFullScreenclobberedwindowWasFullScreeneven when other windows remained fullscreen, (4)alphaValue=0fade-in created a visible flash on tabs joining an existing tab group. All four fixed inline before push.
- Download links now save instead of rendering raw content — clicking a download link in the web UI loaded the raw response data into the WKWebView window instead of prompting for a save location. The
decidePolicyFor navigationResponsehandler now intercepts responses whoseContent-Dispositionheader begins withattachment(case-insensitive) or whose MIME type WebKit can't render, and hands them off toWKDownload. The newWKDownloadDelegateextension presents anNSSavePanelpre-filled with the server's suggested filename, runs as a sheet on the main window, and surfaces failures via a sheet alert. Closes the gap where attachments from the chat UI (file exports, downloads from previous sessions, etc.) had no way to be saved. (#66, thanks @redsparklabs)
- Window drag regression — after the v1.5.0
.fullSizeContentViewchange, the main window could no longer be moved by dragging the title bar.WKWebViewcovers the full content area including the transparent native title bar strip and intercepts all mouse events;-webkit-app-region: dragin the web page's CSS has no effect onNSWindowdragging. Fixed by adding a thin transparentTitleBarDragViewoverlay positioned over the title bar zone (height 38 px, matching.app-titlebarin the web UI) that callswindow.performDrag(with:)onmouseDown. The view is fully transparent and only captures hits within its own bounds. Traffic lights live inNSThemeFrameabovecontentViewand are unaffected. (fixes #64)
- Title bar text re-centered — v1.5.1 hid the
.app-titlebar-iconwithdisplay: none, which collapsed its layout space and shifted the title text left. Changed tovisibility: hiddenso the icon is invisible but still occupies its flex slot, keeping the title centered as intended. Closes #61.
- Title bar icon no longer overlaps traffic lights — the web app's
.app-titlebar-iconSVG logo is hidden when running inside the Mac wrapper via an injecteddocumentStartstylesheet. With.fullSizeContentView(added in v1.5.0) the icon appeared right next to the close button. The rest of the web title bar is unaffected. Closes #59.
- Configurable global hotkey — the hardcoded Cmd+Shift+H shortcut is now user-configurable in Preferences. Click the new recorder field to arm it, press any combo with Cmd/Ctrl/Option, and the shortcut updates immediately. Press Delete while recording to clear (disable) the hotkey. The combo is persisted in UserDefaults and survives app restarts. Closes #41.
- Full-size content view — web content now extends under the native macOS title bar using
.fullSizeContentView+titlebarAppearsTransparent. The web app's custom.app-titlebarelement sits in the title-bar region, eliminating the doubled native/web header. Traffic lights stay visible via a--traffic-light-widthCSS custom property (default80px, refined to the exact measured value after first paint). The variable resets to0pxin fullscreen and restores on exit. Closes #57.
- Session state preserved on reconnect — tunnel drops, network blips, and manual reconnects
no longer destroy the WKWebView. The existing browser window is hidden (orderOut) and reused
on reconnect via
reconnectInPlace(), preserving localStorage, cookies, IndexedDB, and scroll position. The WKWebView is only replaced when the error window takes over on a failed reconnect. Closes #10.
- Find submenu in Edit menu — Edit → Find → Find… (Cmd+F), Find Next (Cmd+G), Find Previous (Cmd+Shift+G). Makes the find feature discoverable via the standard macOS menu convention instead of requiring users to know the shortcut.
- White flash on startup eliminated (for real this time) — the window is now started
with
alphaValue = 0and fades in (0.15s) ondidFinishNavigation— the point at which WKWebView has actually painted its first frame. AhasCompletedFirstPaintflag ensures the animation only fires once; SPA route changes and Cmd+R reloads are unaffected. Additionally, the NSWindow frame background and the WKWebView pre-paintdocumentStartscript are both set to#1a1a1a(dark) regardless of the system colour scheme, so any gap between window-visible and first paint is never white in any situation. Closes #52.
- Find in page — Cmd+F — pressing Cmd+F opens a native find bar (NSSearchField overlay
with vibrancy) anchored below the title bar.
‹/›buttons and Cmd+G / Cmd+Shift+G cycle through results viawindow.find()JS. Pressing Done or Escape closes the bar. Closes #37, closes #45.
- Connection error copy no longer hardcodes
~/hermes-webui-public— the error window now says "Run: bash start.sh (or: docker compose up -d)" instead of the path that only applied to one specific install layout. Closes #40.
build.shnow embeds entitlements in local ad-hoc builds —codesigninbuild.shwas signing without--entitlements Entitlements.plist, so locally-built.appbundles never had any embedded entitlements (includingcom.apple.security.device.audio-input). CI was correct (it already passed--entitlements). Local builds now embed the same entitlements as CI-signed DMGs, making mic and other entitlement-gated features testable without a full CI run. (reviewer follow-up from #50)- Stale comment in
LaunchBehaviorTests— the warm-up regression history comment still referenceddefault(for:)and omitted the v1.3.5 entitlement root cause. Updated to accurately describe the fix history through v1.3.5. (reviewer follow-up from #50)
- Microphone actually works — root cause finally found — the
Entitlements.plisthadcom.apple.security.device.microphonewhich is not a valid hardened-runtime entitlement key and is silently ignored by the codesigner. The correct key iscom.apple.security.device.audio-input. Every DMG since the beginning of the project was signed without the actual mic entitlement, makinggetUserMedia()fail at the OS level regardless of TCC status. Fixed inEntitlements.plist. CI applies the plist via--entitlements Entitlements.plistso the fix propagates automatically. - WKUIDelegate mic delegate no longer short-circuits on
.authorized— the previous implementation checkedAVCaptureDevice.authorizationStatusand calleddecisionHandler(.grant)directly when.authorized, bypassingrequestAccess. That bypass skips the XPC message totccdthat WebContent needs for its capture attribution to succeed. The delegate now always routes throughAVCaptureDevice.requestAccess— when already.authorizedit completes immediately with no UI, when.notDeterminedit shows the OS prompt, when.deniedit shows the recovery alert. (user-reported) warmUpCaptureSubsystemnow usesrequestAccessinstead ofAVCaptureDevice.default—default(for: .audio)only queries IOKit and does not contacttccd.requestAccesssends the XPC message that primes the attribution chain for WebContent.
- Regression documentation tests —
LaunchBehaviorTests.swiftwith documented invariants forwarmUpCaptureSubsystemand the window frame autosave pattern. Tests pass trivially but carry the full regression history so future refactors cannot delete these invariants silently. Test count: 20 → 22. (reviewer follow-up from #49)
- Microphone actually works again —
getUserMedia()was failing withNotAllowedErroreven when TCC was.authorized(mic enabled in System Settings). Root cause: removing the launch-timerequestMicrophonePermission()call in v1.3.2 also removed its undocumented side effect of initialising AVFoundation in the host process. The WebContent XPC process has noaudio-inputentitlement of its own and inherits TCC attribution via the host's active AVFoundation session. Without that session, capture fails at the platform layer regardless of the delegate returning.grant. Fixed:warmUpCaptureSubsystem()callsAVCaptureDevice.default(for: .audio)silently at launch — no UI, no prompt, no change in UX — purely to establish the attribution chain that WebContent inherits. (user-reported regression since v1.3.2) - Shared window autosave name constant —
"HermesMainWindow"appeared twice inBrowserWindowController: once as thewindowFrameAutosaveNamevalue and once embedded in the derived UserDefaults key"NSWindow Frame HermesMainWindow". Extracted toprivate static let windowAutosaveName. The derived key is now interpolated from the constant, eliminating the drift risk. (reviewer follow-up from #48)
- Microphone "access denied" error after v1.3.2 upgrade — v1.3.2 correctly removed the
aggressive launch-time mic prompt, but also inadvertently removed the recovery path for users
whose TCC permission was already
.denied. Those users got silent failure with no way back. Fixed: the.deniedbranch now callsdecisionHandler(.deny)immediately (so WebKit doesn't wait) and then shows a single "Open System Settings" alert pointing to Privacy & Security → Microphone. Throttled to once per app session so it can't spam. (user-reported regression from v1.3.2) - Window size still resetting to default after v1.3.2 — the v1.3.2 fix called
setFrameAutosaveNameandsetFrameUsingNameon the rawNSWindowbeforesuper.init.NSWindowControllerhas its ownwindowFrameAutosaveNameproperty that both saves and restores the frame — when the controller initialises, its default empty value overwrote the window-level setting, so subsequent resizes were never persisted. Fixed: removed the window-level calls and setself.windowFrameAutosaveName = "HermesMainWindow"on the controller aftersuper.init. AppKit now manages save and restore atomically.center()is guarded to first-launch only. (user-reported regression from v1.3.2)
- Microphone permission prompt no longer appears on every launch — removed the proactive
requestMicrophonePermission()call fromapplicationDidFinishLaunching. That call fired on every launch and, once the user had denied mic access, showed anNSAlerton every subsequent launch regardless of whether the mic was needed. TheWKUIDelegatemethodrequestMediaCapturePermissionForalready handles mic grants correctly and lazily — the OS prompt only appears the first time the user actually clicks the mic button in the web UI. (user-reported) - Window size now persists across launches — for programmatically created
NSWindowinstances,setFrameAutosaveNamesaves future frame changes but does not restore the previously saved frame on re-creation. AddedsetFrameUsingName("HermesMainWindow")immediately after the autosave call;center()now only runs on first launch (when no saved frame exists). Last used size and position are preserved across restarts. (user-reported) - Navigation failure double-fire guard — both
didFailProvisionalNavigationand the 5xxdecidePolicyFor navigationResponsehandler can fire on the same failing load event during teardown. AddeddidReportNavigationFailureflag so the error window is only opened once per navigation attempt.
- Shared zoom key constant —
"webViewMagnification"was defined as a private static inAppDelegateand duplicated as a bare string literal inBrowserWindowController. MadeAppDelegate.zoomKeyinternal soBrowserWindowControllerreferences the single source of truth. (reviewer follow-up from #44) - NWPathMonitor reconnect scope documented — added inline comment to
scheduleAutoReconnectclarifying that it fires on network-link events only, not backend-health events. (NSApp.delegate as? AppDelegate)cast comment — acknowledged the intentional coupling and why a full protocol abstraction isn't warranted here.
- Auto-reconnect on network restore (NWPathMonitor) — when WiFi drops and comes back,
or a VPN connects, the app automatically re-attempts connection without any manual click.
Only fires when the app is already in an error or disconnected state; no action is taken
if the backend is simply down with a healthy network. Uses
Network.frameworkNWPathMonitor, no Accessibility permission required. (closes #38) - Zoom level persistence — zoom level (Cmd++/Cmd+-/Cmd+0) is now saved to UserDefaults and restored after every page load, including reconnects. (part of closes #43)
- Full-screen state persistence — if the app was in full-screen when quit, it returns to
full-screen on next launch. Uses
NSWindowDelegatewindowDidEnterFullScreen/windowDidExitFullScreencallbacks. (part of closes #43) - Dock icon badge when offline — the Dock icon shows a "!" badge when the backend is unreachable (direct mode health check failure or SSH tunnel disconnects). Clears automatically when the connection is restored. Visible even when the window is hidden. (closes #39)
- View → Open in Browser — opens the configured Hermes URL in the system default browser. Useful for comparison or debugging without changing any settings.
- Preferences window truncation — widened window from 480 to 520px. "Save & Reconnect" button width increased (100 → 140px) so the label no longer clips. Notification checkbox width increased (290 → 330px) to fit the full label. All section headers, dividers, and input fields scaled accordingly. (fixes #34)
- Window → Show Hermes (⌘⇧H) — menu item in the Window menu that mirrors the global hotkey, making it discoverable. Teaches the shortcut to users who scan menus. (fixes #35)
- Preferences: global shortcut label — read-only "Global shortcut: ⌘⇧H — bring Hermes forward from any app" row in the APP section of Preferences. (fixes #35)
- README: Keyboard shortcuts table — new section listing all six keyboard shortcuts including the global ⌘⇧H. (fixes #35)
- RegisterEventHotKey OSStatus now checked — if Cmd+Shift+H is already claimed by another app (Alfred, Raycast, etc.), a diagnostic
NSLogfires instead of silently no-opping. Surfaced by the v1.2.0 independent review. - Redundant
notificationsEnabledseed removed —seedDefaultsIfNeeded()was settingnotificationsEnabledalongsideUserDefaults.standard.register(defaults:), which already covers both fresh installs and upgrades. The seed inregister(defaults:)is the authoritative one; the duplicate inseedDefaultsIfNeededis removed. - Notifications checkbox init cleaned up —
targetandactionare now set in theNSButtoninitializer directly, removing the redundant two-line post-init reassignment.
- Notification copy — preferences toggle now reads "Show a notification when a response completes while the app is in the background" — replacing the old "tab" wording left over from browser context. The toggle is wired to a new UserDefaults key (
notificationsEnabled, default on) so users can disable native notifications. (fixes #28)
- Cmd+, — opens Preferences (standard macOS keyboard shortcut). Already wired; documented here explicitly.
- Cmd+R — reloads the WebUI page via a new View → Reload menu item.
- Cmd+W — hides the window instead of quitting. App stays running in the Dock; clicking the Dock icon brings it back.
applicationShouldTerminateAfterLastWindowClosedreturnsfalseandapplicationShouldHandleReopenre-surfaces the window. - Window position memory —
setFrameAutosaveName("HermesMainWindow")restores last size and position on every launch. - Global hotkey Cmd+Shift+H — brings Hermes forward from any app using Carbon
RegisterEventHotKey. No Accessibility permission required; works on first launch. (closes #6)
- Launch at login — Preferences now includes a "Launch at login" checkbox (macOS 13+, SMAppService). Correctly handles
.requiresApprovalstate and shows a System Settings nudge. Disabled with an inline note on macOS 12. (fixes #3) - View zoom — Cmd+/Cmd− keyboard shortcuts — new View menu with Zoom In, Zoom Out, and Actual Size. Pinch-to-zoom was already enabled; this adds keyboard control. (fixes #24)
- Connection status in window title — direct-mode window title now shows the active backend host and a live indicator (● connected / ○ offline). A 30-second health poll detects when the backend goes away without a full page reload. (fixes #29)
- Non-localhost URLs silently failing — plain
http://to Tailscale IPs, LAN addresses, and custom hostnames was blocked by App Transport Security in WKWebView. AddedNSAllowsArbitraryLoadsInWebContentto Info.plist (bothbuild.shand CI workflow). URLSession (used for connection preflight) keeps default ATS restrictions. (fixes #25) - Dark mode white flash on startup — WKWebView rendered white before the dark theme loaded. Set
underPageBackgroundColorto.windowBackgroundColor(macOS 12+) and added adocumentStartuserScript to set the HTML background before first paint. (fixes #23) - Error screen didn't name the backend repo — first-time users who hit "Cannot connect" had no way to find the backend. Added a clickable "github.com/nesquena/hermes-webui" link to the error screen in direct mode. (fixes #27)
- SSH tunnel silently broken on servers where
localhostresolves to IPv6 first — the tunnel forwarded tolocalhost:<port>on the remote side, but many Linux systems maplocalhostto::1ahead of127.0.0.1in/etc/hosts. Combined with the common case of dev servers binding only to IPv4127.0.0.1, ssh would connect to[::1]:<port>and get "connection reset" on every request — while the local port check happily reported "Tunnel connected". The forward now always targets127.0.0.1, matching what most dev servers bind to. - "Tunnel connected" shown even when the tunnel was end-to-end broken — readiness check used to be a local TCP connect to the forwarded port, which ssh accepts immediately regardless of whether the far end is reachable. Replaced with an HTTP round-trip probe so the status reflects what the browser will actually experience.
- Try Again button on the connection-error screen led to a permanent white window — the HTML error page was loaded with a nil base URL, so
window.location.reload()reloadedabout:blank. Replaced the WebView error page with a small native error window whose Try Again button re-runs the full connection flow.
- Connection failures show a compact native window instead of a full-size WebView error page. The main browser window only opens after the app has verified the server responds — an HTTP preflight runs in direct mode, and in SSH mode the tunnel's HTTP probe gates the browser. If either fails, a small native "Cannot connect" window appears with Try Again and Preferences… buttons.
- Test Connection false "Unreachable" — clicking Test Connection against a running server often showed "✗ Unreachable" even when the server was working. Two bugs: the probe used a
HEADrequest (many dev servers return 405/501 for HEAD even when GET works), and the success range was restricted to HTTP 200–399 (so a 404 or 500 falsely showed as unreachable). Now uses GET and treats any HTTP response as reachable — only an actual network failure shows ✗. - App icon rendered with a white chrome frame in the Dock — the source PNG had a ~260 px opaque light-gray margin around the squircle, which showed up as a pale tile behind the icon on dark Dock backgrounds. Replaced with a properly cropped icon where the squircle fills the full canvas edge-to-edge and the area outside the rounded corners is genuinely transparent.
- Release notes now mirror the CHANGELOG. The GitHub release body (and therefore Sparkle's "update available" dialog) now starts with a "What's changed" section auto-extracted from this file for the matching version tag, so users can see the actual list of fixes instead of generic download boilerplate. New releases without a CHANGELOG entry show a clear placeholder.
- Auto-update "error launching the installer" — clicking Install Update from the Sparkle dialog failed with "An error occurred while launching the installer" because the app was shipping Sparkle's sandboxed XPC services (
Downloader.xpc,Installer.xpc) despite not being sandboxed itself. Per Sparkle's own docs, XPC services are only for sandboxed apps; shipping them in a non-sandboxed app causes launchd to reject the XPC launch. With the XPCs removed from the embedded framework, Sparkle falls back to its in-process installer path, which is the supported flow for non-sandboxed apps.
- macOS notifications (#8) — when a response finishes while the app window is in the background, a native macOS notification appears. Permission is requested on first trigger. Works via a debounced MutationObserver injected into the WebView.
- Test Connection button in Preferences (#4) — click to verify the target URL is reachable before saving. Shows "✓ Connected" or "✗ Unreachable" inline with a 5-second timeout. Works for both direct and SSH tunnel modes.
- White screen on failed connection (#12) — if the WebView cannot reach the server, a helpful error page now loads instead of a blank white screen. Shows the target URL, mode-specific guidance (direct: start command, SSH: tunnel check), and a Try Again button.
- Sparkle 2 auto-update support — app now checks
https://hermes-webui.github.io/hermes-swift-mac/appcast.xmlon launch and shows a native update dialog when a new version is available. A "Check for Updates…" menu item is available under the app menu at any time. (PR #21, closes #17) Entitlements.plist— hardened runtime entitlements for network access and microphone. Required for notarization. App remains unsandboxed so SSH tunnel (NSTask) continues to work. (PR #21)appcast.xmltemplate in repo root — Sparkle update feed published athttps://hermes-webui.github.io/hermes-swift-mac/appcast.xml. (PR #21)
- WKWebView navigation guard — external links (any http/https URL that is not localhost or the configured SSH host) now open in Safari instead of navigating inside the app.
file://URLs are blocked entirely. (PR #21, closes #7)
- CI release workflow now imports a Developer ID Application certificate, signs the app with hardened runtime, notarizes via
notarytool, and staples the ticket to the DMG. Users on v1.0.4+ will no longer see the Gatekeeper "unidentified developer" warning on first launch. (PR #21) - CI generates a Sparkle ed25519 signature for each DMG and embeds it in the release notes for appcast maintenance. (PR #21)
- Microphone permission prompt at app launch — macOS shows the system dialog on first run before the user touches the mic button. If previously denied, a native alert appears with an "Open System Settings" button linking directly to Privacy & Security → Microphone. (PR #18, fixes #16, by @redsparklabs)
requestMediaCapturePermissionForWKUIDelegate method — WKWebView now forwards microphone access requests through the macOS TCC authorization lifecycle before granting or denyinggetUserMedia. Without this, the browsergetUserMediacall silently fails even when system permission is granted. (PR #18, fixes #16, by @redsparklabs)NSMicrophoneUsageDescriptionadded to Info.plist (both build.sh and CI workflow) — macOS requires this string to show the system microphone permission dialog. Previously present only in build.sh, now also in the CI workflow so downloaded DMGs work correctly. (PR #18, fixes #16)
- Web notification permission prompts suppressed — a WKUserScript overrides
Notification.requestPermissionto always resolve as "denied", preventing browser-style permission dialogs from appearing inside the native wrapper. UNUserNotificationCenter is the correct path for response alerts in a native app. (PR #18, fixes #14, by @redsparklabs) webkitSpeechRecognitionsuppressed via WKUserScript — forces hermes-webui to fall back to its MediaRecorder +/api/transcribebackend path, which works reliably. WebKit's built-in local speech model is slow and inconsistent. (PR #18, by @redsparklabs)
All notable changes to Hermes Agent for macOS are documented here.
- Buttons like "New conversation" had no effect when the WebView lost focus; the first click was consumed entirely by focus restoration and never reached JavaScript. Fixed by subclassing
WKWebViewasHermesWebViewand overridingacceptsFirstMouseto returntrue, so a refocusing click also registers as content interaction. (PR #19, fixes #13, by @redsparklabs) - Keyboard shortcuts (Cmd+K, etc.) required an extra click after switching away and back. Fixed by implementing
NSWindowDelegate.windowDidBecomeKeyto restore WebView keyboard focus whenever the window becomes key. (PR #19, by @redsparklabs)
CFBundleShortVersionStringmissing frombuild.sh— locally-built apps showed an empty version string in About dialog and Finder Get Info. Now set from the version argument. (PR #1)SplashWindowControllercontainer view did not resize with the window — missingautoresizingMask = [.width, .height]. Fixed. (PR #1)
- README improvements: added install instructions, Gatekeeper workaround, SSH security section, architecture table, troubleshooting guide. (PR #1)
Initial public release.
- Native macOS app wrapping Hermes Web UI in a WKWebView window — no Electron, no dependencies beyond Xcode Command Line Tools
- Direct (local) mode connecting to
http://localhost:8787by default - SSH tunnel mode with full lifecycle management — start, monitor, reconnect, graceful teardown on quit
- Clipboard integration: paste text (JSON-encoded for safety) and images (base64, in-memory) via Cmd+V
- File upload support via the native open panel
- Native Preferences window (Cmd+,) with port validation and scheme enforcement
- Splash screen while connecting or establishing the SSH tunnel
- Status bar with live tunnel state and one-click Reconnect button (SSH mode only)
- Edit menu for Undo, Redo, Cut, Copy, Paste, Select All (required for WKWebView responder chain)
- Safe signal handling via DispatchSource (SIGTERM, SIGINT)
- SSH security:
StrictHostKeyChecking=accept-new,ExitOnForwardFailure=yes,Process.argumentsarray (no shell injection) - Universal binary (arm64 + x86_64) built and released via GitHub Actions on tag push
- Created by @redsparklabs