Files
hermes-agent/apps/desktop/src/main.tsx
T
SHL0MS 3cc7f220cd fix(desktop): strip off-scheme paint from selection copies
Chromium's native selection copy serializes the selection as text/html
with every element's computed color inlined. Copied from a dark theme,
body text lands on the clipboard as near-white (the app ink computes to
color(srgb 0.902 0.929 0.953 / 0.94)); pasted into a light-background
target such as an email, it is invisible.

The renderer never writes rich text itself, so this payload can only
come from Chromium's serializer — which runs after copy handlers decline,
meaning clipboardData reads back empty inside the event. The new guard
therefore decides from the live DOM: it scores the computed ink of the
selected text against the rendered theme mode, and only when they are
opposite schemes does it own the payload, writing text/plain plus a
tag-structured text/html with no paint declarations.

Structure (headings, lists, tables, links, bold/italic, code layout)
survives; colors come from the paste target's defaults. A generic
font-family anchor (sans-serif, monospace inside code) keeps receivers
that convert HTML to rich text on their own compose font instead of the
Times browser default. Same-scheme copies and selections starting inside
editable fields pass through untouched.
2026-08-21 22:25:22 -05:00

98 lines
4.7 KiB
TypeScript

import './styles.css'
// Side-effect: reports in-flight turns to the main process for the quit guard.
import './store/active-work'
// Side-effect: mirrors the machine's AC/battery state for poll demotion.
import './store/power'
// Side-effect: applies the persisted window translucency on load.
import './store/translucency'
// Dev-only render/state churn counters. MUST precede the `react-dom` import
// below: react-dom captures the devtools hook at module init, so bippy has to
// install during THIS import's evaluation or every commit goes unseen
// (verified — a late install reports renderers=0, commits=0). `vite.config.ts`
// aliases this specifier to a no-op module for non-dev builds, so neither the
// counters nor bippy reach a shipped renderer.
import '@/debug/dev-only'
import { QueryClientProvider } from '@tanstack/react-query'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { HashRouter } from 'react-router'
import App from './app'
import { RootErrorBoundary } from './components/error-boundary'
import { HapticsProvider } from './components/haptics-provider'
import { RootTooltipProvider } from './components/ui/tooltip'
import { I18nProvider } from './i18n'
import { installClipboardShim } from './lib/clipboard'
import { queryClient } from './lib/query-client'
import { installRendererAnimationPauseState } from './lib/renderer-loop-pause'
import { installSelectionCopyColorGuard } from './lib/selection-copy-colors'
import { ThemeProvider } from './themes/context'
installClipboardShim()
// Chromium serializes selection copies (Cmd+C, right-click Copy) with the
// theme's computed colors inlined; without this guard a dark-theme selection
// pastes as near-white text into light-background targets.
installSelectionCopyColorGuard()
// The perf probe ships in dev, and in a production build ONLY when explicitly
// opted in (VITE_PERF_PROBE=1) — this lets the perf harness measure a real,
// minified production renderer for representative absolute numbers. Normal
// `npm run build` leaves the flag unset, so the probe never reaches users.
if (import.meta.env.MODE !== 'production' || import.meta.env.VITE_PERF_PROBE === '1') {
import('./app/chat/perf-probe')
}
const winParam = new URLSearchParams(window.location.search).get('win')
if (winParam === 'hud') {
document.title = 'Hermes HUD'
}
if (winParam === 'overlay') {
void import('./app/pet-overlay/overlay-root').then(({ mountPetOverlay }) => mountPetOverlay())
} else if (winParam === 'quick') {
void import('./app/quick-entry/quick-entry-root').then(({ mountQuickEntry }) => mountQuickEntry())
} else if (winParam === 'wake') {
void import('./app/wake-indicator/wake-indicator-root').then(({ mountWakeIndicator }) => mountWakeIndicator())
} else {
// CSS animations do not inherit Chromium's JS-loop pause policy. Mirror the
// main window's focus/visibility state to :root so decorative infinite
// animations stop producing frames when nobody can see them.
installRendererAnimationPauseState()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RootErrorBoundary>
<QueryClientProvider client={queryClient}>
<I18nProvider>
<ThemeProvider>
<HapticsProvider>
{/* ONE tooltip provider for the whole app. Every `Tip` used to
carry its own, and with ~107 call sites those subtrees
dominated unrelated interactions (52,784 TooltipProvider
renders in a single sash drag). Radix's provider holds only
refs and stable callbacks, so hoisting is what it's for. */}
<RootTooltipProvider>
{/* useTransitions={false}: react-router v7's HashRouter wraps every
route state update in React.startTransition() by default. In
React 19's concurrent renderer, transitions are non-urgent — React
can yield mid-render and resume later. When the app is under load
(streaming token deltas, gateway events, store updates), those
higher-priority updates keep interrupting the transition, starving
the route change commit. The session sidebar highlight + main pane
both freeze for seconds despite the main thread being free.
Disabling transitions makes navigate() commit at default priority. */}
<HashRouter useTransitions={false}>
<App />
</HashRouter>
</RootTooltipProvider>
</HapticsProvider>
</ThemeProvider>
</I18nProvider>
</QueryClientProvider>
</RootErrorBoundary>
</StrictMode>
)
}