mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
Merge pull request #52833 from NousResearch/bb/wsl-desktop-fixes
fix(desktop): WSL2 clipboard paste, titlebar layout, HMR survival, and GPU acceleration
This commit is contained in:
commit
5add283ec8
17 changed files with 471 additions and 56 deletions
|
|
@ -43,6 +43,8 @@ const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-reques
|
|||
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
|
||||
const { readWindowsUserEnvVar } = require('./windows-user-env.cjs')
|
||||
const { readWslWindowsClipboardImage } = require('./wsl-clipboard-image.cjs')
|
||||
const { nativeOverlayWidth: computeNativeOverlayWidth } = require('./titlebar-overlay-width.cjs')
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
const { readLiveUpdateMarker } = require('./update-marker.cjs')
|
||||
const {
|
||||
|
|
@ -186,6 +188,16 @@ if (REMOTE_DISPLAY_REASON) {
|
|||
)
|
||||
}
|
||||
|
||||
// WSLg: Chromium blocklists the Mesa vGPU → software compositing → typing lag.
|
||||
// /dev/dxg means a real GPU is available; un-blocklist it. Skipped when a remote
|
||||
// display already forced software (SSH'd-into-WSL).
|
||||
if (IS_WSL && !REMOTE_DISPLAY_REASON && fs.existsSync('/dev/dxg')) {
|
||||
app.commandLine.appendSwitch('ignore-gpu-blocklist')
|
||||
app.commandLine.appendSwitch('enable-gpu-rasterization')
|
||||
app.commandLine.appendSwitch('enable-zero-copy')
|
||||
console.log('[hermes] WSL GPU passthrough (/dev/dxg) detected; enabling GPU acceleration')
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:get-remote-display-reason', () => REMOTE_DISPLAY_REASON)
|
||||
|
||||
// Keep the renderer running at full speed while the window is in the background
|
||||
|
|
@ -398,14 +410,10 @@ const WINDOW_BUTTON_POSITION = {
|
|||
x: 24,
|
||||
y: TITLEBAR_HEIGHT / 2 - MACOS_TRAFFIC_LIGHTS_HEIGHT / 2
|
||||
}
|
||||
// Width Electron reserves for the Windows/Linux native min/max/close cluster
|
||||
// when `titleBarOverlay` is enabled. The OS paints these buttons in the
|
||||
// top-right corner of the renderer; we have to leave that much room on the
|
||||
// right edge so our system tools (file browser, haptics, settings) don't sit
|
||||
// underneath them. macOS uses left-side traffic lights instead and reports a
|
||||
// position via getWindowButtonPosition(), so this width is non-zero only on
|
||||
// non-macOS platforms.
|
||||
const NATIVE_OVERLAY_BUTTON_WIDTH = 144
|
||||
// Right-edge window-control reservation lives in titlebar-overlay-width.cjs
|
||||
// (pure + unit-testable); computeNativeOverlayWidth() applies it per platform.
|
||||
// It's only the pre-layout fallback — the renderer measures the exact overlay
|
||||
// width live via the Window Controls Overlay API.
|
||||
const APP_ICON_PATHS = [
|
||||
path.join(APP_ROOT, 'public', 'apple-touch-icon.png'),
|
||||
path.join(APP_ROOT, 'dist', 'apple-touch-icon.png'),
|
||||
|
|
@ -519,25 +527,48 @@ function getWindowBackgroundColor() {
|
|||
return nativeTheme.shouldUseDarkColors ? '#111111' : '#f7f7f7'
|
||||
}
|
||||
|
||||
// Transparent WCO — renderer chrome shows through. rgba(0,0,0,0) can fall back
|
||||
// to GetFrameColor() on some Electron builds; rgba(1,0,0,0) is the escape hatch.
|
||||
const TITLEBAR_OVERLAY_COLOR = 'rgba(1, 0, 0, 0)'
|
||||
|
||||
function getTitleBarOverlayOptions() {
|
||||
if (IS_MAC) {
|
||||
return { height: TITLEBAR_HEIGHT }
|
||||
}
|
||||
|
||||
if (rendererTitleBarTheme) {
|
||||
return {
|
||||
color: rendererTitleBarTheme.background,
|
||||
height: TITLEBAR_HEIGHT,
|
||||
symbolColor: rendererTitleBarTheme.foreground
|
||||
}
|
||||
// Windows + WSLg paint WCO natively; plain Linux disables it (frameless hidden
|
||||
// titlebar still applies).
|
||||
if (!IS_WINDOWS && !IS_WSL) {
|
||||
return false
|
||||
}
|
||||
|
||||
const useDarkColors = nativeTheme.shouldUseDarkColors
|
||||
|
||||
return {
|
||||
color: useDarkColors ? '#111111' : '#f7f7f7',
|
||||
color: TITLEBAR_OVERLAY_COLOR,
|
||||
height: TITLEBAR_HEIGHT,
|
||||
symbolColor: useDarkColors ? '#f7f7f7' : '#242424'
|
||||
symbolColor:
|
||||
rendererTitleBarTheme && isHexColor(rendererTitleBarTheme.foreground)
|
||||
? rendererTitleBarTheme.foreground
|
||||
: nativeTheme.shouldUseDarkColors
|
||||
? '#f7f7f7'
|
||||
: '#242424'
|
||||
}
|
||||
}
|
||||
|
||||
// Push refreshed overlay options to a live window after a theme/appearance
|
||||
// change. No-op only on plain (non-WSL) Linux, where getTitleBarOverlayOptions()
|
||||
// returns false; the try/catch additionally guards builds where
|
||||
// setTitleBarOverlay isn't supported.
|
||||
function applyTitleBarOverlay(win) {
|
||||
const options = getTitleBarOverlayOptions()
|
||||
if (!options || typeof options !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
win?.setTitleBarOverlay?.(options)
|
||||
} catch {
|
||||
// Overlay not supported on this platform/build — leave the frameless
|
||||
// titlebar as-is.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3760,11 +3791,7 @@ 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
|
||||
return computeNativeOverlayWidth({ isWindows: IS_WINDOWS, isWsl: IS_WSL })
|
||||
}
|
||||
|
||||
function getWindowState() {
|
||||
|
|
@ -5820,7 +5847,7 @@ function createWindow() {
|
|||
if (!nativeThemeListenerInstalled) {
|
||||
nativeThemeListenerInstalled = true
|
||||
nativeTheme.on('updated', () => {
|
||||
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
|
||||
applyTitleBarOverlay(mainWindow)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -6482,11 +6509,21 @@ ipcMain.handle('hermes:saveImageBuffer', async (_event, payload) => {
|
|||
|
||||
ipcMain.handle('hermes:saveClipboardImage', async () => {
|
||||
const image = clipboard.readImage()
|
||||
if (!image || image.isEmpty()) {
|
||||
return ''
|
||||
if (image && !image.isEmpty()) {
|
||||
return writeComposerImage(image.toPNG(), '.png')
|
||||
}
|
||||
|
||||
return writeComposerImage(image.toPNG(), '.png')
|
||||
// WSL2/WSLg doesn't bridge clipboard *images* from the Windows host to the
|
||||
// Linux clipboard Electron reads, so a host screenshot looks empty above.
|
||||
// Pull it straight off the Windows clipboard via PowerShell as a fallback.
|
||||
if (IS_WSL) {
|
||||
const png = readWslWindowsClipboardImage()
|
||||
if (png) {
|
||||
return writeComposerImage(png, '.png')
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:normalizePreviewTarget', (_event, target, baseDir) =>
|
||||
|
|
@ -6506,7 +6543,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).
|
||||
|
|
|
|||
11
apps/desktop/electron/titlebar-overlay-width.cjs
Normal file
11
apps/desktop/electron/titlebar-overlay-width.cjs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Pre-layout fallback for WCO right-edge reservation (--titlebar-tools-right).
|
||||
// Live width comes from navigator.windowControlsOverlay in the renderer.
|
||||
|
||||
const OVERLAY_FALLBACK_WIDTH = 144
|
||||
|
||||
/** @param {{ isWindows?: boolean, isWsl?: boolean }} opts */
|
||||
function nativeOverlayWidth({ isWindows = false, isWsl = false } = {}) {
|
||||
return isWindows || isWsl ? OVERLAY_FALLBACK_WIDTH : 0
|
||||
}
|
||||
|
||||
module.exports = { OVERLAY_FALLBACK_WIDTH, nativeOverlayWidth }
|
||||
29
apps/desktop/electron/titlebar-overlay-width.test.cjs
Normal file
29
apps/desktop/electron/titlebar-overlay-width.test.cjs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { OVERLAY_FALLBACK_WIDTH, nativeOverlayWidth } = require('./titlebar-overlay-width.cjs')
|
||||
|
||||
// This static reservation is only the pre-layout FALLBACK. Once laid out the
|
||||
// renderer reads the exact width from navigator.windowControlsOverlay
|
||||
// (use-window-controls-overlay-width.ts) and uses these values only when the WCO
|
||||
// API is unavailable.
|
||||
|
||||
test('Windows reserves the overlay fallback width', () => {
|
||||
assert.equal(nativeOverlayWidth({ isWindows: true }), OVERLAY_FALLBACK_WIDTH)
|
||||
})
|
||||
|
||||
test('WSLg paints the same WCO, so it reserves the same fallback width', () => {
|
||||
// The original bug: WSL fell through to 0, so the right tools sat under the
|
||||
// controls and the title overran into them.
|
||||
assert.equal(nativeOverlayWidth({ isWsl: true }), OVERLAY_FALLBACK_WIDTH)
|
||||
})
|
||||
|
||||
test('plain Linux and macOS reserve nothing', () => {
|
||||
assert.equal(nativeOverlayWidth({ isWindows: false, isWsl: false }), 0)
|
||||
assert.equal(nativeOverlayWidth(), 0)
|
||||
assert.equal(nativeOverlayWidth({}), 0)
|
||||
})
|
||||
|
||||
test('the fallback width is a sane positive pixel value', () => {
|
||||
assert.ok(Number.isInteger(OVERLAY_FALLBACK_WIDTH) && OVERLAY_FALLBACK_WIDTH > 0)
|
||||
})
|
||||
92
apps/desktop/electron/wsl-clipboard-image.cjs
Normal file
92
apps/desktop/electron/wsl-clipboard-image.cjs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// Pull a Windows-host clipboard image from inside WSL2 via PowerShell (WSLg
|
||||
// bridges text but not images). Returns PNG bytes or null; exec injectable.
|
||||
|
||||
const { execFileSync } = require('node:child_process')
|
||||
|
||||
// STA is mandatory: System.Windows.Forms.Clipboard throws ThreadStateException
|
||||
// off a single-threaded apartment. We emit base64 (not raw bytes) so the PNG
|
||||
// survives stdout's text decoding intact, and write with [Console]::Out.Write
|
||||
// to avoid a trailing newline.
|
||||
const PS_SCRIPT = [
|
||||
'Add-Type -AssemblyName System.Windows.Forms,System.Drawing',
|
||||
'$img = [System.Windows.Forms.Clipboard]::GetImage()',
|
||||
'if ($null -eq $img) { exit 0 }',
|
||||
'$ms = New-Object System.IO.MemoryStream',
|
||||
'$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)',
|
||||
'[Console]::Out.Write([System.Convert]::ToBase64String($ms.ToArray()))'
|
||||
].join('\n')
|
||||
|
||||
// PowerShell's -EncodedCommand takes UTF-16LE base64. Encoding the whole script
|
||||
// this way sidesteps every layer of WSL→Windows quoting (spaces, quotes,
|
||||
// brackets, newlines) that plain -Command arguments would mangle.
|
||||
function encodePowerShellCommand(script) {
|
||||
return Buffer.from(String(script), 'utf16le').toString('base64')
|
||||
}
|
||||
|
||||
// Locate powershell.exe. The bare name resolves through WSL's Windows-interop
|
||||
// PATH on every standard WSL2 setup; the absolute fallback covers a stripped
|
||||
// PATH. Returns the first candidate — execFile surfaces ENOENT if it's wrong
|
||||
// and we fall back to null.
|
||||
function powershellCandidates() {
|
||||
return ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe']
|
||||
}
|
||||
|
||||
function decodeClipboardImageBase64(stdout) {
|
||||
const b64 = String(stdout || '').trim()
|
||||
if (!b64) return null
|
||||
|
||||
let buffer
|
||||
try {
|
||||
buffer = Buffer.from(b64, 'base64')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
// Guard against partial / garbage output: require a real PNG signature.
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
if (buffer.length < PNG_SIGNATURE.length || !buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
// Read the Windows clipboard image from inside WSL. Returns a PNG Buffer, or
|
||||
// null when there's no image, PowerShell is unreachable, or output is invalid.
|
||||
// Linux-only by contract (caller gates on IS_WSL); never throws.
|
||||
function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powershellCandidates() } = {}) {
|
||||
const encoded = encodePowerShellCommand(PS_SCRIPT)
|
||||
|
||||
for (const ps of candidates) {
|
||||
try {
|
||||
const stdout = exec(
|
||||
ps,
|
||||
['-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
timeout: 8000,
|
||||
// A 4K screenshot base64s to a few MB; give stdout generous headroom.
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
// PowerShell writes progress/CLIXML noise to stderr — ignore it.
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
}
|
||||
)
|
||||
const decoded = decodeClipboardImageBase64(stdout)
|
||||
if (decoded) return decoded
|
||||
// Empty stdout = no image on the clipboard; stop, don't try fallbacks.
|
||||
if (String(stdout || '').trim() === '') return null
|
||||
} catch {
|
||||
// This powershell.exe candidate is missing/failed — try the next one.
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
decodeClipboardImageBase64,
|
||||
encodePowerShellCommand,
|
||||
powershellCandidates,
|
||||
readWslWindowsClipboardImage
|
||||
}
|
||||
114
apps/desktop/electron/wsl-clipboard-image.test.cjs
Normal file
114
apps/desktop/electron/wsl-clipboard-image.test.cjs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const {
|
||||
decodeClipboardImageBase64,
|
||||
encodePowerShellCommand,
|
||||
powershellCandidates,
|
||||
readWslWindowsClipboardImage
|
||||
} = require('./wsl-clipboard-image.cjs')
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
|
||||
function fakePngBuffer(extraBytes = 16) {
|
||||
return Buffer.concat([PNG_SIGNATURE, Buffer.alloc(extraBytes, 0x42)])
|
||||
}
|
||||
|
||||
test('encodePowerShellCommand produces UTF-16LE base64 PowerShell can decode', () => {
|
||||
const encoded = encodePowerShellCommand('Write-Output "hi"')
|
||||
const roundTripped = Buffer.from(encoded, 'base64').toString('utf16le')
|
||||
assert.equal(roundTripped, 'Write-Output "hi"')
|
||||
})
|
||||
|
||||
test('decodeClipboardImageBase64 returns a Buffer for valid PNG base64', () => {
|
||||
const png = fakePngBuffer()
|
||||
const decoded = decodeClipboardImageBase64(png.toString('base64'))
|
||||
assert.ok(Buffer.isBuffer(decoded))
|
||||
assert.ok(decoded.equals(png))
|
||||
})
|
||||
|
||||
test('decodeClipboardImageBase64 trims surrounding whitespace before decoding', () => {
|
||||
const png = fakePngBuffer()
|
||||
const decoded = decodeClipboardImageBase64(`\n ${png.toString('base64')} \r\n`)
|
||||
assert.ok(decoded && decoded.equals(png))
|
||||
})
|
||||
|
||||
test('decodeClipboardImageBase64 returns null for empty / whitespace input', () => {
|
||||
assert.equal(decodeClipboardImageBase64(''), null)
|
||||
assert.equal(decodeClipboardImageBase64(' \n '), null)
|
||||
assert.equal(decodeClipboardImageBase64(null), null)
|
||||
assert.equal(decodeClipboardImageBase64(undefined), null)
|
||||
})
|
||||
|
||||
test('decodeClipboardImageBase64 rejects base64 without a PNG signature', () => {
|
||||
// Valid base64, but the decoded bytes are not a PNG.
|
||||
const notPng = Buffer.from('this is not a png at all').toString('base64')
|
||||
assert.equal(decodeClipboardImageBase64(notPng), null)
|
||||
})
|
||||
|
||||
test('readWslWindowsClipboardImage decodes the first candidate that returns a PNG', () => {
|
||||
const png = fakePngBuffer()
|
||||
const calls = []
|
||||
const exec = (cmd, args) => {
|
||||
calls.push({ cmd, args })
|
||||
return png.toString('base64')
|
||||
}
|
||||
|
||||
const result = readWslWindowsClipboardImage({ exec, candidates: ['powershell.exe'] })
|
||||
assert.ok(result && result.equals(png))
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].cmd, 'powershell.exe')
|
||||
// -STA is mandatory for System.Windows.Forms.Clipboard.
|
||||
assert.ok(calls[0].args.includes('-STA'))
|
||||
assert.ok(calls[0].args.includes('-EncodedCommand'))
|
||||
})
|
||||
|
||||
test('readWslWindowsClipboardImage returns null and stops when stdout is empty (no image)', () => {
|
||||
let count = 0
|
||||
const exec = () => {
|
||||
count += 1
|
||||
return ''
|
||||
}
|
||||
|
||||
const result = readWslWindowsClipboardImage({
|
||||
exec,
|
||||
candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe']
|
||||
})
|
||||
assert.equal(result, null)
|
||||
// Empty stdout means "no image on the clipboard" — don't probe further candidates.
|
||||
assert.equal(count, 1)
|
||||
})
|
||||
|
||||
test('readWslWindowsClipboardImage falls through to the next candidate when one throws', () => {
|
||||
const png = fakePngBuffer()
|
||||
const seen = []
|
||||
const exec = cmd => {
|
||||
seen.push(cmd)
|
||||
if (cmd === 'powershell.exe') {
|
||||
throw Object.assign(new Error('not found'), { code: 'ENOENT' })
|
||||
}
|
||||
return png.toString('base64')
|
||||
}
|
||||
|
||||
const result = readWslWindowsClipboardImage({
|
||||
exec,
|
||||
candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe']
|
||||
})
|
||||
assert.ok(result && result.equals(png))
|
||||
assert.deepEqual(seen, ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'])
|
||||
})
|
||||
|
||||
test('readWslWindowsClipboardImage returns null when every candidate throws', () => {
|
||||
const exec = () => {
|
||||
throw new Error('boom')
|
||||
}
|
||||
|
||||
const result = readWslWindowsClipboardImage({ exec, candidates: ['a', 'b'] })
|
||||
assert.equal(result, null)
|
||||
})
|
||||
|
||||
test('powershellCandidates lists the bare name first, then the absolute fallback', () => {
|
||||
const candidates = powershellCandidates()
|
||||
assert.equal(candidates[0], 'powershell.exe')
|
||||
assert.ok(candidates.some(c => c.endsWith('WindowsPowerShell/v1.0/powershell.exe')))
|
||||
})
|
||||
|
|
@ -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/backend-ready.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/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/window-state.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/backend-ready.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/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
|
|
|||
|
|
@ -73,7 +73,11 @@ export function ContextMenu({
|
|||
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
|
||||
{c.images}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
|
||||
<ContextMenuItem
|
||||
disabled={!onPasteClipboardImage}
|
||||
icon={Clipboard}
|
||||
onSelect={onPasteClipboardImage ? () => void onPasteClipboardImage() : undefined}
|
||||
>
|
||||
{c.pasteImage}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
|
||||
|
|
@ -167,7 +171,7 @@ interface ContextMenuItemProps {
|
|||
interface ContextMenuProps {
|
||||
onInsertText: (text: string) => void
|
||||
onOpenUrlDialog: () => void
|
||||
onPasteClipboardImage?: () => void
|
||||
onPasteClipboardImage?: (opts?: { silent?: boolean }) => Promise<boolean> | void
|
||||
onPickFiles?: () => void
|
||||
onPickFolders?: () => void
|
||||
onPickImages?: () => void
|
||||
|
|
|
|||
|
|
@ -784,6 +784,16 @@ export function ChatBar({
|
|||
if (!pastedText) {
|
||||
event.preventDefault()
|
||||
|
||||
// Under WSL2/WSLg the Windows host clipboard doesn't bridge *images* to
|
||||
// the Linux clipboard the DOM paste event reads, so a host screenshot
|
||||
// arrives as an empty paste (no blobs, no text). Fall back to the main
|
||||
// process, which pulls the image straight off the Windows clipboard.
|
||||
// Silent so a genuinely-empty paste doesn't pop a "no image" warning.
|
||||
if (onPasteClipboardImage) {
|
||||
triggerHaptic('selection')
|
||||
void onPasteClipboardImage({ silent: true })
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export interface ChatBarProps {
|
|||
onAddUrl?: (url: string) => void
|
||||
onAttachImageBlob?: (blob: Blob) => Promise<boolean | void> | boolean | void
|
||||
onAttachDroppedItems?: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void
|
||||
onPasteClipboardImage?: () => void
|
||||
onPasteClipboardImage?: (opts?: { silent?: boolean }) => Promise<boolean> | void
|
||||
onPickFiles?: () => void
|
||||
onPickFolders?: () => void
|
||||
onPickImages?: () => void
|
||||
|
|
|
|||
|
|
@ -411,25 +411,36 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
|||
}
|
||||
}, [attachImagePath, copy.attachImages, currentCwd, t.composer.images])
|
||||
|
||||
const pasteClipboardImage = useCallback(async () => {
|
||||
try {
|
||||
const path = await window.hermesDesktop?.saveClipboardImage()
|
||||
const pasteClipboardImage = useCallback(
|
||||
async ({ silent = false }: { silent?: boolean } = {}) => {
|
||||
try {
|
||||
const path = await window.hermesDesktop?.saveClipboardImage()
|
||||
|
||||
if (!path) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: copy.clipboard,
|
||||
message: copy.noClipboardImage
|
||||
})
|
||||
if (!path) {
|
||||
if (!silent) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: copy.clipboard,
|
||||
message: copy.noClipboardImage
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
await attachImagePath(path)
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
notifyError(err, copy.clipboardPasteFailed)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
await attachImagePath(path)
|
||||
} catch (err) {
|
||||
notifyError(err, copy.clipboardPasteFailed)
|
||||
}
|
||||
}, [attachImagePath, copy.clipboard, copy.clipboardPasteFailed, copy.noClipboardImage])
|
||||
},
|
||||
[attachImagePath, copy.clipboard, copy.clipboardPasteFailed, copy.noClipboardImage]
|
||||
)
|
||||
|
||||
const attachContextFolderPath = useCallback(
|
||||
(folderPath: string) => {
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
|||
maxVoiceRecordingSeconds?: number
|
||||
onAttachImageBlob: (blob: Blob) => Promise<boolean | void> | boolean | void
|
||||
onAttachDroppedItems: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void
|
||||
onPasteClipboardImage: () => void
|
||||
onPasteClipboardImage: (opts?: { silent?: boolean }) => Promise<boolean> | void
|
||||
onPickFiles: () => void
|
||||
onPickFolders: () => void
|
||||
onPickImages: () => void
|
||||
|
|
|
|||
|
|
@ -101,6 +101,12 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
|||
'relative flex h-full w-full min-w-0 flex-col overflow-hidden border-(--ui-stroke-tertiary) bg-(--ui-editor-surface-background) text-(--ui-text-tertiary)',
|
||||
panesFlipped ? 'border-r' : 'border-l'
|
||||
)}
|
||||
// Windows/WSLg paint Electron's Window Controls Overlay across our
|
||||
// titlebar band, so the editor-style tab strip (which normally sits IN that
|
||||
// band) would land under the fixed titlebar tools. --right-rail-top-inset
|
||||
// (set by AppShell only when the overlay is present) drops the rail one
|
||||
// titlebar-height so it opens below the band. 0px elsewhere → unchanged.
|
||||
style={{ paddingTop: 'var(--right-rail-top-inset, 0px)' }}
|
||||
>
|
||||
<div className="group/rail-tabs flex h-(--titlebar-height) shrink-0 border-b border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background)">
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1191,7 +1191,7 @@ export function DesktopController() {
|
|||
}}
|
||||
onDismissError={dismissError}
|
||||
onEdit={editMessage}
|
||||
onPasteClipboardImage={() => void composer.pasteClipboardImage()}
|
||||
onPasteClipboardImage={opts => composer.pasteClipboardImage(opts)}
|
||||
onPickFiles={() => void composer.pickContextPaths('file')}
|
||||
onPickFolders={() => void composer.pickContextPaths('folder')}
|
||||
onPickImages={() => void composer.pickImages()}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { isSecondaryWindow } from '@/store/windows'
|
|||
|
||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
|
||||
|
||||
import { useWindowControlsOverlayWidth } from './hooks/use-window-controls-overlay-width'
|
||||
import { KeybindPanel } from './keybind-panel'
|
||||
import { StatusbarControls, type StatusbarItem } from './statusbar-controls'
|
||||
import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar'
|
||||
|
|
@ -86,12 +87,26 @@ export function AppShell({
|
|||
// tool cluster. Gate on isSecondaryWindow, never the narrower new-session flag.
|
||||
const hideTitlebarControls = isSecondaryWindow()
|
||||
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
|
||||
// Width Windows/Linux reserve for the OS-painted min/max/close overlay (zero
|
||||
// on macOS, where window controls sit on the left and are reported via
|
||||
// Width Windows/WSLg reserve for the native min/max/close overlay (zero on
|
||||
// macOS, where window controls sit on the left and are reported via
|
||||
// windowButtonPosition instead). The right tool cluster has to clear them.
|
||||
const nativeOverlayWidth = connection?.nativeOverlayWidth ?? 0
|
||||
// Prefer the EXACT width measured from the live Window Controls Overlay
|
||||
// (precise + self-correcting across DPI/host themes); fall back to the static
|
||||
// reservation the main process sends when the WCO API isn't available.
|
||||
const measuredOverlayWidth = useWindowControlsOverlayWidth()
|
||||
const staticOverlayWidth = connection?.nativeOverlayWidth ?? 0
|
||||
const nativeOverlayWidth = measuredOverlayWidth ?? staticOverlayWidth
|
||||
const titlebarToolsRight = nativeOverlayWidth > 0 ? `${nativeOverlayWidth}px` : '0.75rem'
|
||||
|
||||
// When the native window controls overlay our titlebar band — Windows and
|
||||
// WSLg both paint Electron's Window Controls Overlay and report
|
||||
// nativeOverlayWidth > 0 — the right rail's editor-style tab strip (which
|
||||
// normally lives IN that band) would render at y=0 under the fixed titlebar
|
||||
// tool cluster and collide with it. Drop the right rail one titlebar-height so
|
||||
// it opens BELOW the band. macOS / plain Linux paint no overlay → 0 inset,
|
||||
// layout byte-for-byte unchanged. Consumed as --right-rail-top-inset.
|
||||
const rightRailTopInset = nativeOverlayWidth > 0 ? 'var(--titlebar-height)' : '0px'
|
||||
|
||||
// The inset clears the top-left titlebar buttons when nothing covers the
|
||||
// window's left edge. Default layout: the sessions sidebar sits there.
|
||||
// Flipped layout: the file browser does instead. Both force-collapse to a
|
||||
|
|
@ -159,6 +174,9 @@ export function AppShell({
|
|||
'--titlebar-controls-top': `${titlebarControls.top}px`,
|
||||
'--titlebar-tools-right': titlebarToolsRight,
|
||||
'--titlebar-tools-width': titlebarToolsWidth,
|
||||
// Drops the right rail below the titlebar band when the OS/host paints
|
||||
// window controls over it (Windows/WSLg); 0px elsewhere.
|
||||
'--right-rail-top-inset': rightRailTopInset,
|
||||
// Anchor for the pane-tool cluster's right edge in TitlebarControls.
|
||||
// Sourced from the layout store rather than the PaneShell-emitted
|
||||
// --pane-*-width vars because the titlebar is a sibling of PaneShell
|
||||
|
|
@ -171,6 +189,13 @@ export function AppShell({
|
|||
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
|
||||
)}
|
||||
|
||||
{nativeOverlayWidth > 0 && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-x-0 top-0 z-[4] h-(--titlebar-height) border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background)"
|
||||
/>
|
||||
)}
|
||||
|
||||
<main className="relative z-3 flex min-h-0 w-full flex-1 flex-col overflow-hidden transition-none">
|
||||
<PaneShell className="min-h-0 flex-1">
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
// Measure the EXACT width of the native window-controls overlay (min/max/close)
|
||||
// straight from the browser, instead of a hardcoded reservation.
|
||||
//
|
||||
// When Electron's Window Controls Overlay is active (native Windows AND WSLg),
|
||||
// Chromium exposes `navigator.windowControlsOverlay`. Its getTitlebarAreaRect()
|
||||
// returns the draggable title-bar rect that EXCLUDES the controls, so the
|
||||
// controls' width on the right is `innerWidth - rect.right`. This is precise and
|
||||
// self-correcting across DPI / host themes / window states — no magic numbers,
|
||||
// and it sidesteps the WSLg-vs-Windows footprint guesswork.
|
||||
//
|
||||
// Returns null when WCO is unavailable (macOS, plain Linux, or before first
|
||||
// layout), so callers fall back to the static reservation from the main process.
|
||||
|
||||
interface WindowControlsOverlayLike {
|
||||
visible: boolean
|
||||
getTitlebarAreaRect: () => DOMRect
|
||||
addEventListener: (type: 'geometrychange', cb: () => void) => void
|
||||
removeEventListener: (type: 'geometrychange', cb: () => void) => void
|
||||
}
|
||||
|
||||
const overlay = () =>
|
||||
(navigator as Navigator & { windowControlsOverlay?: WindowControlsOverlayLike }).windowControlsOverlay ?? null
|
||||
|
||||
function measure(wco: WindowControlsOverlayLike | null): number | null {
|
||||
const rect = wco?.visible ? wco.getTitlebarAreaRect() : null
|
||||
|
||||
// No overlay, or it isn't laid out yet.
|
||||
if (!rect?.width) {
|
||||
return null
|
||||
}
|
||||
|
||||
const width = Math.round(window.innerWidth - rect.right)
|
||||
|
||||
return width > 0 ? width : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Live width (px) of the right-side native window-controls overlay, or null when
|
||||
* the platform/build exposes no overlay (caller should use the static fallback).
|
||||
*/
|
||||
export function useWindowControlsOverlayWidth(): number | null {
|
||||
const [width, setWidth] = useState<number | null>(() => measure(overlay()))
|
||||
|
||||
useEffect(() => {
|
||||
const wco = overlay()
|
||||
|
||||
if (!wco) {
|
||||
return
|
||||
}
|
||||
|
||||
const update = () => setWidth(measure(wco))
|
||||
|
||||
// Re-measure on overlay geometry changes (maximize/restore, DPI) and on
|
||||
// window resize (innerWidth feeds the calc).
|
||||
wco.addEventListener('geometrychange', update)
|
||||
window.addEventListener('resize', update)
|
||||
update()
|
||||
|
||||
return () => {
|
||||
wco.removeEventListener('geometrychange', update)
|
||||
window.removeEventListener('resize', update)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return width
|
||||
}
|
||||
|
|
@ -175,7 +175,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||
{visiblePaneTools.length > 0 && (
|
||||
<div
|
||||
aria-label={t.shell.paneControls}
|
||||
className="fixed top-(--titlebar-controls-top) right-[calc(var(--titlebar-tools-right)+var(--shell-preview-toolbar-gap,0))] z-70 flex flex-row items-center gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
||||
className="fixed top-[calc(var(--titlebar-controls-top)+var(--right-rail-top-inset,0px))] right-[calc(var(--titlebar-tools-right)+var(--shell-preview-toolbar-gap,0))] z-70 flex flex-row items-center gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
||||
>
|
||||
{visiblePaneTools.map(tool => (
|
||||
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
|
||||
|
|
|
|||
|
|
@ -157,6 +157,12 @@ function renderedModeFor(colors: DesktopThemeColors, mode: 'light' | 'dark'): 'l
|
|||
// Per-mode mix knobs. Light/dark fallbacks live in styles.css `:root` /
|
||||
// `:root.dark`; setting them inline keeps active-skin overrides surviving
|
||||
// the boot-time paint.
|
||||
// styles.css --theme-neutral-chrome — keep in sync.
|
||||
const NEUTRAL_CHROME = { light: '#f3f3f3', dark: '#0d0d0e' } as const
|
||||
|
||||
const chromeBackground = (background: string, isDark: boolean) =>
|
||||
mix(background, NEUTRAL_CHROME[isDark ? 'dark' : 'light'], isDark ? 0.26 : 0.08)
|
||||
|
||||
const mixesFor = (isDark: boolean): Record<string, string> => ({
|
||||
'--theme-mix-chrome': isDark ? '74%' : '92%',
|
||||
'--theme-mix-sidebar': '100%',
|
||||
|
|
@ -222,8 +228,10 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
|
|||
root.style.setProperty(k, v)
|
||||
}
|
||||
|
||||
const chromeBg = chromeBackground(c.background, isDark)
|
||||
|
||||
window.hermesDesktop?.setTitleBarTheme?.({
|
||||
background: c.background,
|
||||
background: chromeBg,
|
||||
foreground: c.foreground
|
||||
})
|
||||
|
||||
|
|
@ -231,7 +239,7 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
|
|||
// they let a brand-new window paint the themed background on its very first
|
||||
// frame, before this module has even loaded.
|
||||
try {
|
||||
window.localStorage.setItem('hermes-boot-background', c.background)
|
||||
window.localStorage.setItem('hermes-boot-background', chromeBg)
|
||||
window.localStorage.setItem('hermes-boot-color-scheme', rendered)
|
||||
} catch {
|
||||
// Storage may be unavailable (private mode / quota); the inline script
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue