diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 67b31eb4d75..5d1f35ba450 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -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). diff --git a/apps/desktop/electron/titlebar-overlay-width.cjs b/apps/desktop/electron/titlebar-overlay-width.cjs new file mode 100644 index 00000000000..27a62f1a9f3 --- /dev/null +++ b/apps/desktop/electron/titlebar-overlay-width.cjs @@ -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 } diff --git a/apps/desktop/electron/titlebar-overlay-width.test.cjs b/apps/desktop/electron/titlebar-overlay-width.test.cjs new file mode 100644 index 00000000000..a7b8b6f5215 --- /dev/null +++ b/apps/desktop/electron/titlebar-overlay-width.test.cjs @@ -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) +}) diff --git a/apps/desktop/electron/wsl-clipboard-image.cjs b/apps/desktop/electron/wsl-clipboard-image.cjs new file mode 100644 index 00000000000..c81fe7b2a60 --- /dev/null +++ b/apps/desktop/electron/wsl-clipboard-image.cjs @@ -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 +} diff --git a/apps/desktop/electron/wsl-clipboard-image.test.cjs b/apps/desktop/electron/wsl-clipboard-image.test.cjs new file mode 100644 index 00000000000..343adc1f6d6 --- /dev/null +++ b/apps/desktop/electron/wsl-clipboard-image.test.cjs @@ -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'))) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 141f8219982..56a7374eeef 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/desktop/src/app/chat/composer/context-menu.tsx b/apps/desktop/src/app/chat/composer/context-menu.tsx index 580416dea5b..57c34ebde38 100644 --- a/apps/desktop/src/app/chat/composer/context-menu.tsx +++ b/apps/desktop/src/app/chat/composer/context-menu.tsx @@ -73,7 +73,11 @@ export function ContextMenu({ {c.images} - + void onPasteClipboardImage() : undefined} + > {c.pasteImage} @@ -167,7 +171,7 @@ interface ContextMenuItemProps { interface ContextMenuProps { onInsertText: (text: string) => void onOpenUrlDialog: () => void - onPasteClipboardImage?: () => void + onPasteClipboardImage?: (opts?: { silent?: boolean }) => Promise | void onPickFiles?: () => void onPickFolders?: () => void onPickImages?: () => void diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 8a0ec509b0b..cca8f00638f 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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 } diff --git a/apps/desktop/src/app/chat/composer/types.ts b/apps/desktop/src/app/chat/composer/types.ts index 6d9444a6d93..59c7c17274c 100644 --- a/apps/desktop/src/app/chat/composer/types.ts +++ b/apps/desktop/src/app/chat/composer/types.ts @@ -46,7 +46,7 @@ export interface ChatBarProps { onAddUrl?: (url: string) => void onAttachImageBlob?: (blob: Blob) => Promise | boolean | void onAttachDroppedItems?: (candidates: DroppedFile[]) => Promise | boolean | void - onPasteClipboardImage?: () => void + onPasteClipboardImage?: (opts?: { silent?: boolean }) => Promise | void onPickFiles?: () => void onPickFolders?: () => void onPickImages?: () => void diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts index ddf38340235..26f41864baf 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -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) => { diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index e4a80e61273..51697829d17 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -75,7 +75,7 @@ interface ChatViewProps extends Omit, 'onSubmit'> { maxVoiceRecordingSeconds?: number onAttachImageBlob: (blob: Blob) => Promise | boolean | void onAttachDroppedItems: (candidates: DroppedFile[]) => Promise | boolean | void - onPasteClipboardImage: () => void + onPasteClipboardImage: (opts?: { silent?: boolean }) => Promise | void onPickFiles: () => void onPickFolders: () => void onPickImages: () => void diff --git a/apps/desktop/src/app/chat/right-rail/preview.tsx b/apps/desktop/src/app/chat/right-rail/preview.tsx index d13eb088967..bc34e4b2316 100644 --- a/apps/desktop/src/app/chat/right-rail/preview.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview.tsx @@ -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)' }} >
void composer.pasteClipboardImage()} + onPasteClipboardImage={opts => composer.pasteClipboardImage(opts)} onPickFiles={() => void composer.pickContextPaths('file')} onPickFolders={() => void composer.pickContextPaths('folder')} onPickImages={() => void composer.pickImages()} diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx index b0981681c6c..56deb913952 100644 --- a/apps/desktop/src/app/shell/app-shell.tsx +++ b/apps/desktop/src/app/shell/app-shell.tsx @@ -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({ )} + {nativeOverlayWidth > 0 && ( +
+ )} +
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(() => 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 +} diff --git a/apps/desktop/src/app/shell/titlebar-controls.tsx b/apps/desktop/src/app/shell/titlebar-controls.tsx index d0ace1c8838..fb0cae4307e 100644 --- a/apps/desktop/src/app/shell/titlebar-controls.tsx +++ b/apps/desktop/src/app/shell/titlebar-controls.tsx @@ -175,7 +175,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: {visiblePaneTools.length > 0 && (
{visiblePaneTools.map(tool => ( diff --git a/apps/desktop/src/themes/context.tsx b/apps/desktop/src/themes/context.tsx index 8dec1c9e0a8..575316633fa 100644 --- a/apps/desktop/src/themes/context.tsx +++ b/apps/desktop/src/themes/context.tsx @@ -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 => ({ '--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