From de8fed32fdbffcfad4268b879845b6baaba052a0 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Fri, 29 May 2026 17:00:45 -0500 Subject: [PATCH] feat(desktop): lead onboarding with Nous Portal + fix fresh-install detection (#34970) - Feature Nous Portal as the primary onboarding card (Recommended tag, app logo, single pitch line); collapse other OAuth providers behind an "Other providers" disclosure whose open/closed state persists. - Surface OpenRouter as a one-click API-key option inside the disclosure; move "I have an API key" to a quiet bottom-right link. - Treat "no provider configured" as a normal onboarding state, not a red error banner (provider-setup-errors copy match). - Fix setup.runtime_check: it reported ready when the resolved runtime had an empty credential or only implicit Bedrock/IAM, so fresh installs never saw onboarding. Now requires a usable credential. - Auto-wire Windows fonts for WSL2 users so the renderer renders real Segoe UI instead of the DejaVu fallback; make WSL detection env-independent via the /proc kernel marker. --- apps/desktop/electron/bootstrap-platform.cjs | 13 +- .../electron/bootstrap-platform.test.cjs | 3 +- apps/desktop/electron/main.cjs | 43 ++++- .../desktop-onboarding-overlay.test.tsx | 70 +++++++ .../components/desktop-onboarding-overlay.tsx | 179 ++++++++++++++---- .../src/lib/provider-setup-errors.test.ts | 1 + apps/desktop/src/lib/provider-setup-errors.ts | 2 +- tests/test_tui_gateway_server.py | 51 +++++ tui_gateway/server.py | 41 ++++ 9 files changed, 361 insertions(+), 42 deletions(-) create mode 100644 apps/desktop/src/components/desktop-onboarding-overlay.test.tsx diff --git a/apps/desktop/electron/bootstrap-platform.cjs b/apps/desktop/electron/bootstrap-platform.cjs index 0a9eb1f1780..d6a5e7165fe 100644 --- a/apps/desktop/electron/bootstrap-platform.cjs +++ b/apps/desktop/electron/bootstrap-platform.cjs @@ -1,6 +1,15 @@ -function isWslEnvironment(env = process.env, platform = process.platform) { +const fs = require('node:fs') + +function isWslEnvironment(env = process.env, platform = process.platform, kernelRelease = null) { if (platform !== 'linux') return false - return Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP) + if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true + + try { + const release = kernelRelease ?? fs.readFileSync('/proc/sys/kernel/osrelease', 'utf8') + return /microsoft|wsl/i.test(release) + } catch { + return false + } } function isWindowsBinaryPathInWsl(filePath, options = {}) { diff --git a/apps/desktop/electron/bootstrap-platform.test.cjs b/apps/desktop/electron/bootstrap-platform.test.cjs index a96d6a2b724..baa8431125a 100644 --- a/apps/desktop/electron/bootstrap-platform.test.cjs +++ b/apps/desktop/electron/bootstrap-platform.test.cjs @@ -8,7 +8,8 @@ const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } test('isWslEnvironment detects WSL2 env vars on linux', () => { assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true) assert.equal(isWslEnvironment({ WSL_INTEROP: '/run/WSL/123_interop' }, 'linux'), true) - assert.equal(isWslEnvironment({}, 'linux'), false) + assert.equal(isWslEnvironment({}, 'linux', '6.6.87.2-microsoft-standard-WSL2'), true) + assert.equal(isWslEnvironment({}, 'linux', '6.6.87-generic'), false) assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'darwin'), false) }) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 7f4f9b22dd9..4bc746812c3 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -490,6 +490,44 @@ function openExternalUrl(rawUrl) { return true } +function ensureWslWindowsFonts() { + if (!IS_WSL) return + + const fontsDir = ['/mnt/c/Windows/Fonts', '/mnt/c/windows/fonts'].find(candidate => { + try { + return fs.statSync(candidate).isDirectory() + } catch { + return false + } + }) + if (!fontsDir) return + + try { + const confDir = path.join(app.getPath('home'), '.config', 'fontconfig', 'conf.d') + const confPath = path.join(confDir, '99-hermes-wsl-windows-fonts.conf') + let existing = '' + try { + existing = fs.readFileSync(confPath, 'utf8') + } catch { + existing = '' + } + if (existing.includes(fontsDir)) return + + fs.mkdirSync(confDir, { recursive: true }) + fs.writeFileSync( + confPath, + `\n\n\n ${fontsDir}\n\n` + ) + rememberLog(`[fonts] wired WSL Windows fonts for renderer: ${fontsDir}`) + + const cache = spawn('fc-cache', ['-f', fontsDir], { detached: true, stdio: 'ignore' }) + cache.on('error', () => undefined) + cache.unref() + } catch (error) { + rememberLog(`[fonts] WSL font setup skipped: ${error.message}`) + } +} + function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)) } @@ -1341,9 +1379,7 @@ function resolveHermesBackend(dashboardArgs) { shell: false } } - rememberLog( - `Ignoring system Python ${python}: hermes_cli is not importable; falling through to bootstrap.` - ) + rememberLog(`Ignoring system Python ${python}: hermes_cli is not importable; falling through to bootstrap.`) } // 6. Nothing usable yet -- signal the bootstrap runner that we need to @@ -3296,6 +3332,7 @@ app.whenReady().then(() => { Menu.setApplicationMenu(null) } installMediaPermissions() + ensureWslWindowsFonts() createWindow() app.on('activate', () => { diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx new file mode 100644 index 00000000000..388b7d0a815 --- /dev/null +++ b/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx @@ -0,0 +1,70 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import type { OAuthProvider } from '@/types/hermes' + +import { $desktopOnboarding, type DesktopOnboardingState, type OnboardingContext } from '@/store/onboarding' + +import { Picker } from './desktop-onboarding-overlay' + +function provider(id: string, name = id): OAuthProvider { + return { + cli_command: `hermes login ${id}`, + docs_url: `https://example.com/${id}`, + flow: 'pkce', + id, + name, + status: { logged_in: false } + } +} + +function setProviders(providers: OAuthProvider[]) { + $desktopOnboarding.set({ + configured: false, + flow: { status: 'idle' }, + mode: 'oauth', + providers, + reason: null, + requested: false + } satisfies DesktopOnboardingState) +} + +const ctx: OnboardingContext = { requestGateway: async () => undefined as never } + +afterEach(() => { + cleanup() + $desktopOnboarding.set({ + configured: null, + flow: { status: 'idle' }, + mode: 'oauth', + providers: null, + reason: null, + requested: false + }) +}) + +describe('onboarding Picker', () => { + it('features Nous Portal and hides other providers behind a disclosure', () => { + setProviders([provider('anthropic', 'Anthropic Claude'), provider('nous', 'Nous Portal')]) + render() + + expect(screen.getByText('Nous Portal')).toBeTruthy() + expect(screen.getByText('Recommended')).toBeTruthy() + expect(screen.queryByText('Anthropic Claude')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Other providers' })) + + expect(screen.getByText('Anthropic Claude')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Collapse' })).toBeTruthy() + }) + + it('shows every provider directly when Nous Portal is absent', () => { + setProviders([provider('anthropic', 'Anthropic Claude'), provider('openai-codex', 'OpenAI Codex / ChatGPT')]) + render() + + expect(screen.getByText('Anthropic Claude')).toBeTruthy() + expect(screen.getByText('OpenAI Codex / ChatGPT')).toBeTruthy() + expect(screen.queryByText('Other sign-in options')).toBeNull() + expect(screen.queryByText('Recommended')).toBeNull() + }) +}) diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.tsx index d9e39861c49..0b75aadd15d 100644 --- a/apps/desktop/src/components/desktop-onboarding-overlay.tsx +++ b/apps/desktop/src/components/desktop-onboarding-overlay.tsx @@ -4,7 +4,17 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { ModelPickerDialog } from '@/components/model-picker' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Check, ChevronLeft, ChevronRight, ExternalLink, KeyRound, Loader2, Sparkles } from '@/lib/icons' +import { + Check, + ChevronDown, + ChevronLeft, + ChevronRight, + ExternalLink, + KeyRound, + Loader2, + Sparkles, + Terminal +} from '@/lib/icons' import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' import { cn } from '@/lib/utils' import { $desktopBoot, type DesktopBootState } from '@/store/boot' @@ -99,9 +109,9 @@ const PROVIDER_DISPLAY: Record = { } const FLOW_SUBTITLES: Record = { - pkce: 'Opens your browser to sign in, then continues here.', - device_code: 'Opens a verification page in your browser. Hermes connects automatically.', - external: 'Sign in once in your terminal, then come back to chat.' + pkce: 'Opens your browser to sign in, then continues here', + device_code: 'Opens a verification page in your browser — Hermes connects automatically', + external: 'Sign in once in your terminal, then come back to chat' } const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name @@ -147,7 +157,7 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
-
+
{reason ? : null} {ready ? showPicker ? : : }
@@ -209,13 +219,13 @@ function Preparing({ boot }: { boot: DesktopBootState }) { function Header() { return ( -
+
-

Welcome to Hermes

+

Let's get you setup with Hermes Agent

Connect a model provider to start chatting. Most options take one click.

@@ -225,8 +235,31 @@ function Header() { ) } -function Picker({ ctx }: { ctx: OnboardingContext }) { +const FEATURED_ID = 'nous' +const FEATURED_PITCH = 'One subscription, 300+ frontier models — the recommended way to run Hermes' +const SHOW_ALL_KEY = 'hermes-onboarding-show-all-v1' + +const readShowAll = () => { + try { + return window.localStorage.getItem(SHOW_ALL_KEY) === '1' + } catch { + return false + } +} + +const persistShowAll = (value: boolean) => { + try { + window.localStorage.setItem(SHOW_ALL_KEY, value ? '1' : '0') + } catch { + // localStorage unavailable — degrade silently. + } + + return value +} + +export function Picker({ ctx }: { ctx: OnboardingContext }) { const { mode, providers } = useStore($desktopOnboarding) + const [showAll, setShowAll] = useState(readShowAll) const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers]) const hasOauth = ordered.length > 0 @@ -234,42 +267,123 @@ function Picker({ ctx }: { ctx: OnboardingContext }) { return } + if (providers === null) { + return Looking up providers... + } + + const select = (p: OAuthProvider) => void startProviderOAuth(p, ctx) + const featured = ordered.find(p => p.id === FEATURED_ID) ?? null + const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered + // Collapse the secondary providers behind a disclosure only when Nous + // Portal is present to anchor the choice — otherwise show the full list. + const collapsible = Boolean(featured) && rest.length > 0 + const showRest = !collapsible || showAll + return ( -
- {providers === null ? ( - Looking up providers... - ) : ( - ordered.map(provider => ( - void startProviderOAuth(p, ctx)} provider={provider} /> - )) - )} - setOnboardingMode('apikey')}>I have an API key +
+ {featured ? : null} + {showRest ? ( + <> + {rest.map(p => ( + + ))} + setOnboardingMode('apikey')} /> + + ) : null} + {collapsible ? ( + + ) : null} +
+ +
) } -function FooterLink({ children, onClick }: { children: React.ReactNode; onClick: () => void }) { +function FeaturedProviderRow({ + onSelect, + provider +}: { + onSelect: (provider: OAuthProvider) => void + provider: OAuthProvider +}) { + const loggedIn = provider.status?.logged_in + return ( -
- -
+ + ) +} + +function ConnectedTag() { + return ( + + + Connected + + ) +} + +function KeyProviderRow({ onClick }: { onClick: () => void }) { + return ( + ) } function ProviderRow({ onSelect, provider }: { onSelect: (provider: OAuthProvider) => void; provider: OAuthProvider }) { const loggedIn = provider.status?.logged_in - const Trail = provider.flow === 'external' ? ExternalLink : ChevronRight + const Trail = provider.flow === 'external' ? Terminal : ChevronRight return (