Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 41 additions & 19 deletions apps/desktop/electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ const crypto = require('node:crypto')
const fs = require('node:fs')
const http = require('node:http')
const https = require('node:https')
const net = require('node:net')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const { execFileSync, spawn } = require('node:child_process')
Expand All @@ -33,6 +32,11 @@ const {
SESSION_WINDOW_MIN_HEIGHT,
SESSION_WINDOW_MIN_WIDTH
} = require('./session-windows.cjs')
const {
chatWindowChromeOptions,
nativeOverlayWidthForPlatform,
usesNativeSystemTitleBar
} = require('./window-chrome.cjs')
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
Expand Down Expand Up @@ -485,6 +489,23 @@ function getTitleBarOverlayOptions() {
}
}

function getChatWindowChromeOptions() {
return chatWindowChromeOptions({
platform: process.platform,
isMac: IS_MAC,
titleBarOverlay: getTitleBarOverlayOptions(),
trafficLightPosition: WINDOW_BUTTON_POSITION
})
}

function applyTitleBarOverlay(win) {
if (usesNativeSystemTitleBar(process.platform)) {
return
}

win?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
}

const MEDIA_MIME_TYPES = {
'.avi': 'video/x-msvideo',
'.bmp': 'image/bmp',
Expand Down Expand Up @@ -3298,16 +3319,22 @@ function getWindowButtonPosition() {

function getNativeOverlayWidth() {
// macOS reports traffic-light coords via windowButtonPosition; the
// titlebarOverlay there doesn't reserve right-edge space. Windows/Linux
// render the native window-controls overlay on the right, so the renderer
// needs to inset its right cluster by this much to clear them.
return IS_MAC ? 0 : NATIVE_OVERLAY_BUTTON_WIDTH
// titlebarOverlay there doesn't reserve right-edge space. Windows renders the
// native window-controls overlay on the right, so the renderer needs to inset
// its right cluster by this much to clear them. Linux keeps the system
// titlebar, so there is no renderer overlay to clear.
return nativeOverlayWidthForPlatform({
platform: process.platform,
isMac: IS_MAC,
nativeOverlayButtonWidth: NATIVE_OVERLAY_BUTTON_WIDTH
})
}

function getWindowState() {
return {
isFullscreen: Boolean(mainWindow?.isFullScreen?.()),
nativeOverlayWidth: getNativeOverlayWidth(),
usesNativeTitleBar: usesNativeSystemTitleBar(process.platform),
windowButtonPosition: getWindowButtonPosition()
}
}
Expand Down Expand Up @@ -5098,9 +5125,7 @@ function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) {
minWidth: SESSION_WINDOW_MIN_WIDTH,
minHeight: SESSION_WINDOW_MIN_HEIGHT,
title: 'Hermes',
titleBarStyle: 'hidden',
titleBarOverlay: getTitleBarOverlayOptions(),
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
...getChatWindowChromeOptions(),
vibrancy: IS_MAC ? 'sidebar' : undefined,
opacity: windowOpacity(),
icon,
Expand Down Expand Up @@ -5162,15 +5187,12 @@ function createWindow() {
minWidth: 400,
minHeight: 620,
title: 'Hermes',
// Frameless title bar on every platform so the renderer can paint the
// "hide sidebar" button (and other left-side titlebar tools) flush with
// the top edge — matching the macOS layout where the traffic lights sit
// inside the same band. On Windows/Linux, titleBarOverlay tells Electron
// to paint native min/max/close in the top-right of the renderer; on
// macOS it just reserves a content inset alongside the traffic lights.
titleBarStyle: 'hidden',
titleBarOverlay: getTitleBarOverlayOptions(),
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
// Frameless title bar where Electron's custom chrome is stable so the
// renderer can paint the "hide sidebar" button (and other left-side
// titlebar tools) flush with the top edge. Linux uses the system titlebar:
// on GNOME/X11 the hidden-titlebar drag region can expose a window menu
// whose selected actions freeze the desktop session.
...getChatWindowChromeOptions(),
vibrancy: IS_MAC ? 'sidebar' : undefined,
opacity: windowOpacity(),
icon,
Expand All @@ -5197,7 +5219,7 @@ function createWindow() {
if (!nativeThemeListenerInstalled) {
nativeThemeListenerInstalled = true
nativeTheme.on('updated', () => {
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
applyTitleBarOverlay(mainWindow)
})
}
}
Expand Down Expand Up @@ -5756,7 +5778,7 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => {
background: payload.background,
foreground: payload.foreground
}
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
applyTitleBarOverlay(mainWindow)
})

// Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH).
Expand Down
41 changes: 41 additions & 0 deletions apps/desktop/electron/window-chrome.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// BrowserWindow titlebar/chrome policy kept pure so platform-specific choices
// can be tested without booting Electron.

function usesNativeSystemTitleBar(platform = process.platform) {
return platform === 'linux'
}

function chatWindowChromeOptions({
platform = process.platform,
isMac = platform === 'darwin',
titleBarOverlay,
trafficLightPosition
} = {}) {
if (usesNativeSystemTitleBar(platform)) {
return {}
}

return {
titleBarStyle: 'hidden',
titleBarOverlay,
trafficLightPosition: isMac ? trafficLightPosition : undefined
}
}

function nativeOverlayWidthForPlatform({
platform = process.platform,
isMac = platform === 'darwin',
nativeOverlayButtonWidth
} = {}) {
if (isMac || usesNativeSystemTitleBar(platform)) {
return 0
}

return nativeOverlayButtonWidth
}

module.exports = {
chatWindowChromeOptions,
nativeOverlayWidthForPlatform,
usesNativeSystemTitleBar
}
63 changes: 63 additions & 0 deletions apps/desktop/electron/window-chrome.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
const assert = require('node:assert/strict')
const test = require('node:test')

const {
chatWindowChromeOptions,
nativeOverlayWidthForPlatform,
usesNativeSystemTitleBar
} = require('./window-chrome.cjs')

const overlay = { color: '#111111', height: 34, symbolColor: '#f7f7f7' }
const trafficLightPosition = { x: 24, y: 10 }

test('Linux keeps the system titlebar and avoids Electron titlebar overlay', () => {
const options = chatWindowChromeOptions({
platform: 'linux',
titleBarOverlay: overlay,
trafficLightPosition
})

assert.deepEqual(options, {})
assert.equal(usesNativeSystemTitleBar('linux'), true)
})

test('macOS keeps the hidden titlebar and traffic-light placement', () => {
const options = chatWindowChromeOptions({
platform: 'darwin',
isMac: true,
titleBarOverlay: { height: 34 },
trafficLightPosition
})

assert.deepEqual(options, {
titleBarStyle: 'hidden',
titleBarOverlay: { height: 34 },
trafficLightPosition
})
})

test('Windows keeps hidden titlebar overlay without macOS traffic-light options', () => {
const options = chatWindowChromeOptions({
platform: 'win32',
isMac: false,
titleBarOverlay: overlay,
trafficLightPosition
})

assert.deepEqual(options, {
titleBarStyle: 'hidden',
titleBarOverlay: overlay,
trafficLightPosition: undefined
})
})

test('native overlay width is reserved only for non-Linux non-macOS overlay chrome', () => {
const width = 144

assert.equal(nativeOverlayWidthForPlatform({ platform: 'darwin', isMac: true, nativeOverlayButtonWidth: width }), 0)
assert.equal(nativeOverlayWidthForPlatform({ platform: 'linux', isMac: false, nativeOverlayButtonWidth: width }), 0)
assert.equal(
nativeOverlayWidthForPlatform({ platform: 'win32', isMac: false, nativeOverlayButtonWidth: width }),
width
)
})
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/windows-user-env.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/window-chrome.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/windows-user-env.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
Expand Down
11 changes: 10 additions & 1 deletion apps/desktop/src/app/overlays/overlay-view.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { useStore } from '@nanostores/react'
import { type ReactNode, useEffect } from 'react'

import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { translateNow } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $connection } from '@/store/session'

interface OverlayViewProps {
children: ReactNode
Expand All @@ -23,6 +25,8 @@ export function OverlayView({
headerContent,
rootClassName
}: OverlayViewProps) {
const usesNativeTitleBar = Boolean(useStore($connection)?.usesNativeTitleBar)

const closeOverlay = () => {
triggerHaptic('close')
onClose()
Expand Down Expand Up @@ -63,7 +67,12 @@ export function OverlayView({
rootClassName
)}
>
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-[calc(var(--titlebar-height)+0.1875rem)] [-webkit-app-region:drag]">
<div
className={cn(
'pointer-events-none absolute inset-x-0 top-0 z-10 h-[calc(var(--titlebar-height)+0.1875rem)]',
!usesNativeTitleBar && '[-webkit-app-region:drag]'
)}
>
{headerContent && (
<div className="pointer-events-auto absolute left-1/2 top-[calc(0.5rem+var(--titlebar-height)/2)] -translate-x-1/2 -translate-y-1/2 [-webkit-app-region:no-drag]">
{headerContent}
Expand Down
12 changes: 10 additions & 2 deletions apps/desktop/src/app/shell/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { NotificationStack } from '@/components/notifications'
import { PaneShell } from '@/components/pane-shell'
import { SidebarProvider } from '@/components/ui/sidebar'
import { useMediaQuery } from '@/hooks/use-media-query'
import { cn } from '@/lib/utils'
import {
$fileBrowserOpen,
$panesFlipped,
Expand Down Expand Up @@ -78,6 +79,7 @@ export function AppShell({
const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)
const fileBrowserWidthOverride = useStore($paneWidthOverride(FILE_BROWSER_PANE_ID))
const connection = useStore($connection)
const usesNativeTitleBar = Boolean(connection?.usesNativeTitleBar)
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
// Every secondary window (new-session scratch, subagent watch, cmd-click
Expand Down Expand Up @@ -174,11 +176,17 @@ export function AppShell({
<PaneShell className="min-h-0 flex-1">
<div
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 z-1 h-(--titlebar-height) w-(--titlebar-controls-left) [-webkit-app-region:drag]"
className={cn(
'pointer-events-none absolute left-0 top-0 z-1 h-(--titlebar-height) w-(--titlebar-controls-left)',
!usesNativeTitleBar && '[-webkit-app-region:drag]'
)}
/>
<div
aria-hidden="true"
className="pointer-events-none absolute top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right)+var(--titlebar-tools-width)+0.75rem)] [-webkit-app-region:drag]"
className={cn(
'pointer-events-none absolute top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right)+var(--titlebar-tools-width)+0.75rem)]',
!usesNativeTitleBar && '[-webkit-app-region:drag]'
)}
/>

{children}
Expand Down
8 changes: 5 additions & 3 deletions apps/desktop/src/app/shell/titlebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ export const TITLEBAR_CONTROL_HEIGHT = 22
export const TITLEBAR_CONTROLS_TOP = (TITLEBAR_HEIGHT - TITLEBAR_CONTROL_HEIGHT) / 2
export const TITLEBAR_FALLBACK_WINDOW_BUTTON_X = 24
// Edge inset used when no left-side native controls take up that space —
// Windows/Linux (native overlay is on the right) and macOS fullscreen
// (traffic lights are hidden). Matches the right-cluster's 0.75rem padding.
// Windows (native overlay is on the right), Linux (system titlebar is outside
// the renderer), and macOS fullscreen (traffic lights are hidden). Matches the
// right-cluster's 0.75rem padding.
export const TITLEBAR_EDGE_INSET = 14

// Titlebar palette only. All sizing/radius/cursor/centering come from the
Expand All @@ -34,7 +35,8 @@ export function titlebarControlsPosition(
const top = Math.max(0, TITLEBAR_CONTROLS_TOP)

// No left-side native controls to dodge:
// - Windows/Linux: native min/max/close render on the right via titleBarOverlay.
// - Windows: native min/max/close render on the right via titleBarOverlay.
// - Linux: the system titlebar is outside the renderer.
// - macOS fullscreen: traffic lights are hidden.
// In both cases, pin the cluster to the edge with a small inset.
if (windowButtonPosition === null || isFullscreen) {
Expand Down
17 changes: 13 additions & 4 deletions apps/desktop/src/components/assistant-ui/thread-list.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import {
type CSSProperties,
type ComponentProps,
type CSSProperties,
type FC,
memo,
type ReactNode,
Expand All @@ -15,6 +16,7 @@ import { useStickToBottom } from 'use-stick-to-bottom'

import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { $connection } from '@/store/session'
import {
onScrollToBottomRequest,
onThreadEditClose,
Expand Down Expand Up @@ -138,13 +140,15 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
// hide the titlebar tool cluster + session header, but the OS traffic lights
// still sit in the top-left, so reserve the titlebar gap above the transcript.
const secondaryWindow = isSecondaryWindow()
const usesNativeTitleBar = Boolean(useStore($connection)?.usesNativeTitleBar)
// NB: CSS calc() requires whitespace around the +/- operator. This string is
// assigned verbatim to the --sticky-human-top inline style below (it does not
// go through Tailwind, which would auto-space it), so the spaces are load-
// bearing — without them the declaration is invalid, gets dropped, and the
// sticky user bubble falls back to its ~4px default and slides under the OS
// traffic lights.
const secondaryTitlebarGap = 'calc(var(--titlebar-height) + 0.75rem)'

const threadContentTopPad = secondaryWindow
? 'pt-[calc(var(--titlebar-height)+0.75rem)]'
: 'pt-[calc(var(--titlebar-height)-0.5rem)]'
Expand Down Expand Up @@ -262,11 +266,16 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
// Secondary windows hide the titlebar chrome, so the scroller runs to
// the window's top edge and streamed text slides up under the OS
// traffic lights. Content padding alone scrolls away with the text — a
// fixed opaque strip (the titlebar's drag region) masks anything behind
// it and keeps the window draggable, matching the main window's header.
// fixed opaque strip masks anything behind it. It doubles as a drag
// region only when the renderer owns custom chrome; Linux keeps the
// system titlebar outside the renderer to avoid the GNOME/X11 window
// menu freeze.
<div
aria-hidden="true"
className="absolute inset-x-0 top-0 z-10 h-(--titlebar-height) bg-background [-webkit-app-region:drag]"
className={cn(
'absolute inset-x-0 top-0 z-10 h-(--titlebar-height) bg-background',
!usesNativeTitleBar && '[-webkit-app-region:drag]'
)}
/>
)}
<div
Expand Down
Loading