From 0a8d4da69ac38c20387db8eaa03d45d9a098969e Mon Sep 17 00:00:00 2001 From: ethernet Date: Fri, 26 Jun 2026 22:07:54 -0400 Subject: [PATCH] WIPipw wipwip --- apps/desktop/src/app/desktop-controller.tsx | 3 +- .../OnboardingGatewayConnection.tsx | 238 ++++++++++++++++++ .../src/components/boot-failure-overlay.tsx | 8 +- .../desktop-onboarding-overlay.test.tsx | 6 +- .../components/desktop-onboarding-overlay.tsx | 59 ++++- .../gateway-connecting-overlay.test.tsx | 3 +- apps/desktop/src/i18n/en.ts | 22 +- apps/desktop/src/i18n/ja.ts | 22 +- apps/desktop/src/i18n/types.ts | 20 ++ apps/desktop/src/i18n/zh-hant.ts | 22 +- apps/desktop/src/i18n/zh.ts | 22 +- apps/desktop/src/store/onboarding.test.ts | 1 + apps/desktop/src/store/onboarding.ts | 161 +++++++++++- scripts/slice_tests.py | 0 14 files changed, 560 insertions(+), 27 deletions(-) create mode 100644 apps/desktop/src/components/OnboardingGatewayConnection.tsx create mode 100644 scripts/slice_tests.py diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 9df44d628ce..d2b490f56f7 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -10,6 +10,7 @@ import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overla import { Pane, PaneMain } from '@/components/pane-shell' import { RemoteDisplayBanner } from '@/components/remote-display-banner' import { useMediaQuery } from '@/hooks/use-media-query' +import { isThinClient } from '@/lib/build-mode' import { cn } from '@/lib/utils' import { useSkinCommand } from '@/themes/use-skin-command' @@ -1108,7 +1109,7 @@ export function DesktopController() { {!isSecondaryWindow() && } {!isSecondaryWindow() && ( { void refreshHermesConfig() void refreshCurrentModel() diff --git a/apps/desktop/src/components/OnboardingGatewayConnection.tsx b/apps/desktop/src/components/OnboardingGatewayConnection.tsx new file mode 100644 index 00000000000..ef9706bd97a --- /dev/null +++ b/apps/desktop/src/components/OnboardingGatewayConnection.tsx @@ -0,0 +1,238 @@ +// ─── Gateway connect form ─────────────────────────────────────────────── +// Shown on thin-client first run (the only onboarding path) and on the +// thick client when the user picks "Connect to a remote gateway". Reuses +// the same desktop bridge IPC (probe / save / apply / oauth-login) as +// Settings → Gateway, but wrapped in the onboarding card chrome. + +import { useEffect, useRef, useState } from "react" + +import type { DesktopConnectionProbeResult } from "@/global" +import { useI18n } from "@/i18n" +import { AlertCircle, ChevronLeft, Globe, Loader2, LogIn } from "@/lib/icons" +import { gatewayOauthLogin, saveGatewayConnection } from "@/store/onboarding" + +import { Button } from "./ui/button" +import { ErrorIcon } from "./ui/error-state" +import { Input } from "./ui/input" + +type GatewayProbeStatus = 'idle' | 'probing' | 'done' | 'error' + +function useGatewayProbe(url: string) { + const [status, setStatus] = useState('idle') + const [probe, setProbe] = useState(null) + const seq = useRef(0) + + useEffect(() => { + const trimmed = url.trim() + + if (!trimmed || !/^https?:\/\//i.test(trimmed)) { + setStatus('idle') + setProbe(null) + + return + } + + const desktop = window.hermesDesktop + + if (!desktop?.probeConnectionConfig) { + return + } + + const current = ++seq.current + setStatus('probing') + + const timer = setTimeout(() => { + desktop + .probeConnectionConfig(trimmed) + .then(result => { + if (seq.current !== current) { + return + } + + setProbe(result) + setStatus(result.reachable ? 'done' : 'error') + }) + .catch(() => { + if (seq.current !== current) { + return + } + + setProbe(null) + setStatus('error') + }) + }, 500) + + return () => clearTimeout(timer) + }, [url]) + + return { status, probe } +} + +export function GatewayConnectForm({ onBack }: { onBack: null | (() => void) }) { + const { t } = useI18n() + const g = t.onboarding.gateway + + const [url, setUrl] = useState('') + const [token, setToken] = useState('') + const [error, setError] = useState(null) + const [busy, setBusy] = useState<'save' | 'signin' | null>(null) + + const { status: probeStatus, probe } = useGatewayProbe(url) + + const trimmedUrl = url.trim() + const hasUrl = Boolean(trimmedUrl) && /^https?:\/\//i.test(trimmedUrl) + + // Effective auth mode: a reachable probe wins; otherwise fall back to + // 'token' so the token box is visible by default. + const authMode = probeStatus === 'done' && probe && probe.authMode !== 'unknown' + ? probe.authMode + : 'token' + + const authResolved = probeStatus === 'done' + + const providers = probe?.providers ?? [] + + const providerLabel = providers.length === 1 + ? (providers[0].displayName || providers[0].name) + : providers.length > 1 + ? providers.map(p => p.displayName || p.name).join(' / ') + : g.identityProvider + + const isPasswordProvider = providers.length > 0 && providers.every(p => p.supportsPassword) + + const canSubmit = hasUrl && (authMode === 'oauth' ? false : Boolean(token.trim())) + + const submit = async () => { + if (!hasUrl || busy) { + return + } + + setBusy('save') + setError(null) + + const result = await saveGatewayConnection(trimmedUrl, authMode, token.trim() || undefined) + + if (!result.ok) { + setError(result.message ?? g.saveFailed) + setBusy(null) + } + // On success, applyConnectionConfig reloads the window — no need to clear busy. + } + + const signIn = async () => { + if (!hasUrl || busy) { + return + } + + setBusy('signin') + setError(null) + + const result = await gatewayOauthLogin(trimmedUrl) + + if (!result.ok) { + setError(result.message ?? g.signInFailed) + setBusy(null) + } + // On success, applyConnectionConfig reloads the window. + } + + return ( +
+ {onBack ? ( + + ) : null} + +
+ + setUrl(e.target.value)} + placeholder="https://gateway.example.com/hermes" + value={url} + /> +

{g.urlHint}

+
+ + {/* Probe status */} + {hasUrl && probeStatus === 'probing' ? ( +
+ + {g.probing} +
+ ) : null} + + {hasUrl && probeStatus === 'error' ? ( +
+ + {g.probeError} +
+ ) : null} + + {/* OAuth / password gateways: show sign-in button */} + {hasUrl && authResolved && authMode === 'oauth' ? ( +
+
+ +
+

+ {isPasswordProvider ? g.passwordHint : g.oauthHint(providerLabel)} +

+
+ ) : null} + + {/* Token gateways: show token input */} + {hasUrl && authResolved && authMode === 'token' ? ( +
+ + setToken(e.target.value)} + onKeyDown={e => e.key === 'Enter' && void submit()} + placeholder={g.tokenPlaceholder} + type="password" + value={token} + /> +

{g.tokenHint}

+
+ ) : null} + + {/* While probing (or probe error) and no saved config, neither auth UI shows — + show a hint instead of a blank gap. */} + {hasUrl && !authResolved ? ( +

{g.probing}

+ ) : null} + + {error ? ( +
+ + {error} +
+ ) : null} + +
+
+ +
+
+ ) +} + diff --git a/apps/desktop/src/components/boot-failure-overlay.tsx b/apps/desktop/src/components/boot-failure-overlay.tsx index 5836f335aac..d00f6388c44 100644 --- a/apps/desktop/src/components/boot-failure-overlay.tsx +++ b/apps/desktop/src/components/boot-failure-overlay.tsx @@ -38,10 +38,16 @@ export function BootFailureOverlay() { const [remoteReauth, setRemoteReauth] = useState(null) const visible = Boolean(boot.error) && !boot.running + // While first-run onboarding owns the picker/flow we let it surface its own // progress; the recovery overlay is for hard failures, which it covers via a // higher z-index regardless of onboarding state. - const suppressed = onboarding.flow.status !== 'idle' && onboarding.flow.status !== 'error' + // Thin client: when the gateway connect form is showing (no remote configured + // yet), suppress the boot-failure overlay — the "no remote" error is expected + // and the user is already looking at the form to fix it. + const suppressed = + (onboarding.flow.status !== 'idle' && onboarding.flow.status !== 'error') || + onboarding.gatewayMode useEffect(() => { if (!visible) { diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx index 930280faf9d..520f716885d 100644 --- a/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx +++ b/apps/desktop/src/components/desktop-onboarding-overlay.test.tsx @@ -27,7 +27,8 @@ function setProviders(providers: OAuthProvider[]) { requested: false, firstRunSkipped: false, manual: false, - localEndpoint: false + localEndpoint: false, + gatewayMode: false } satisfies DesktopOnboardingState) } @@ -51,7 +52,8 @@ afterEach(() => { requested: false, firstRunSkipped: false, manual: false, - localEndpoint: false + localEndpoint: false, + gatewayMode: false }) }) diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.tsx index 7ea1c11ffb4..a4e50daf5f4 100644 --- a/apps/desktop/src/components/desktop-onboarding-overlay.tsx +++ b/apps/desktop/src/components/desktop-onboarding-overlay.tsx @@ -8,9 +8,11 @@ import { Codicon } from '@/components/ui/codicon' import { ErrorIcon } from '@/components/ui/error-state' import { Input } from '@/components/ui/input' import { Loader } from '@/components/ui/loader' +import type { DesktopConnectionProbeResult } from '@/global' import { getGlobalModelOptions } from '@/hermes' import { useI18n } from '@/i18n' -import { Check, ChevronDown, ChevronLeft, ChevronRight, ExternalLink, KeyRound, Loader2, Terminal } from '@/lib/icons' +import { isThinClient } from '@/lib/build-mode' +import { AlertCircle, Check, ChevronDown, ChevronLeft, ChevronRight, ExternalLink, Globe, KeyRound, Loader2, LogIn, Terminal } from '@/lib/icons' import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' import { cn } from '@/lib/utils' import { $desktopBoot, type DesktopBootState } from '@/store/boot' @@ -25,19 +27,24 @@ import { DEFAULT_MANUAL_ONBOARDING_REASON, DEFAULT_ONBOARDING_REASON, dismissFirstRunOnboarding, + exitGatewayMode, + gatewayOauthLogin, type OnboardingContext, type OnboardingFlow, peekPendingProviderOAuth, recheckExternalSignin, refreshOnboarding, + saveGatewayConnection, saveOnboardingApiKey, setOnboardingCode, setOnboardingMode, setOnboardingModel, + startGatewayOnboarding, startProviderOAuth, submitOnboardingCode } from '@/store/onboarding' import type { ModelOptionProvider, OAuthProvider } from '@/types/hermes' +import { GatewayConnectForm } from './OnboardingGatewayConnection' interface DesktopOnboardingOverlayProps { enabled: boolean @@ -267,7 +274,10 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway // The user chose "I'll choose a provider later" on first run. Stay out of the // way on every subsequent launch — they re-enter via Settings → Providers // (manual mode), which sets manual=true and bypasses this gate. - if (onboarding.firstRunSkipped && !onboarding.manual) { + // Thin client: never respect the skip — there's no "choose later" escape from + // the gateway form (it's the only path), and a skipped thin client has no + // working backend to fall back on. + if (onboarding.firstRunSkipped && !onboarding.manual && !isThinClient()) { return null } @@ -288,7 +298,9 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway // In manual mode the app is already configured, so the flow is "ready" // immediately — no runtime gate needed. Otherwise wait for the readiness // check (configured === false) before showing the picker. - const ready = onboarding.manual || (enabled && onboarding.configured === false) + // Gateway mode (thin client or user picked "remote gateway") is also ready + // immediately — there's no provider list to wait for. + const ready = onboarding.manual || onboarding.gatewayMode || (enabled && onboarding.configured === false) const showPicker = flow.status === 'idle' || flow.status === 'success' // The final "you're in" screen drops the card chrome and floats centered on // the surface — same bare, cinematic treatment as the connecting overlay. @@ -332,7 +344,11 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
{reason ? : null} {ready ? ( - showPicker ? ( + onboarding.gatewayMode && showPicker ? ( + + ) : showPicker ? ( ) : ( @@ -397,6 +413,7 @@ function Header() { ) } + export const FEATURED_ID = 'nous' const SHOW_ALL_KEY = 'hermes-onboarding-show-all-v1' @@ -491,15 +508,27 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) { In manual mode the overlay already has a close affordance, so the "choose later" escape would be redundant — hide it. */} {manual ? : } - +
+ + +
) @@ -811,6 +840,10 @@ function FlowPanel({ return } + if (flow.status === 'gateway_connected') { + return {t.onboarding.gateway.connecting} + } + if (flow.status === 'confirming_model') { return } diff --git a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx index e5e49315985..0fa287d41f9 100644 --- a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx +++ b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx @@ -42,7 +42,8 @@ function resetStores() { requested: false, firstRunSkipped: false, manual: false, - localEndpoint: false + localEndpoint: false, + gatewayMode: false }) } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index f7841f760a3..5bccc561675 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1735,7 +1735,27 @@ export const en: Translations = { price: (input, output) => `${input} in / ${output} out per Mtok`, change: 'Change', startChatting: 'Begin', - docs: provider => `${provider} docs` + docs: provider => `${provider} docs`, + connectRemoteGateway: 'Connect to a remote gateway', + gateway: { + urlLabel: 'Gateway URL', + urlHint: 'Base URL for the remote Hermes gateway. Path prefixes are supported, e.g. /hermes.', + probing: 'Checking how this gateway authenticates…', + probeError: 'Could not reach this gateway yet. Check the URL — the auth method will appear once it responds.', + signIn: 'Sign in', + signInWith: provider => `Sign in with ${provider}`, + passwordHint: 'This gateway uses a username and password. Sign in to authorize this app.', + oauthHint: provider => `This gateway uses OAuth. Sign in with ${provider} to authorize this app.`, + tokenLabel: 'Session token', + tokenPlaceholder: 'Paste session token', + tokenHint: 'The dashboard session token used for REST and WebSocket access.', + connect: 'Connect', + connecting: 'Connecting', + saveFailed: 'Could not save the gateway connection.', + signInFailed: 'Sign-in failed. Try again.', + backToProviders: 'Back to providers', + identityProvider: 'the gateway' + } }, modelPicker: { diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 741712a6d3c..d273dafba41 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1859,7 +1859,27 @@ export const ja = defineLocale({ price: (input, output) => `${input} 入力 / ${output} 出力 per Mtok`, change: '変更', startChatting: '始める', - docs: provider => `${provider} ドキュメント` + docs: provider => `${provider} ドキュメント`, + connectRemoteGateway: 'リモートゲートウェイに接続', + gateway: { + urlLabel: 'ゲートウェイ URL', + urlHint: 'リモート Hermes ゲートウェイのベース URL。パスプレフィックス対応(例: /hermes)。', + probing: 'ゲートウェイの認証方式を確認中…', + probeError: 'ゲートウェイに接続できません。URL を確認してください — 応答後に認証方式が表示されます。', + signIn: 'サインイン', + signInWith: provider => `${provider} でサインイン`, + passwordHint: 'このゲートウェイはユーザー名とパスワードを使用します。サインインしてアプリを認証してください。', + oauthHint: provider => `このゲートウェイは OAuth を使用します。${provider} でサインインしてアプリを認証してください。`, + tokenLabel: 'セッショントークン', + tokenPlaceholder: 'セッショントークンを貼り付け', + tokenHint: 'REST および WebSocket アクセスに使用されるダッシュボードセッショントークン。', + connect: '接続', + connecting: '接続中', + saveFailed: 'ゲートウェイ接続を保存できませんでした。', + signInFailed: 'サインインに失敗しました。再試行してください。', + backToProviders: 'プロバイダーに戻る', + identityProvider: 'ゲートウェイ' + } }, modelPicker: { diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 54534f915e7..50fa9ca2683 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1393,6 +1393,26 @@ export interface Translations { change: string startChatting: string docs: (provider: string) => string + connectRemoteGateway: string + gateway: { + urlLabel: string + urlHint: string + probing: string + probeError: string + signIn: string + signInWith: (provider: string) => string + passwordHint: string + oauthHint: (provider: string) => string + tokenLabel: string + tokenPlaceholder: string + tokenHint: string + connect: string + connecting: string + saveFailed: string + signInFailed: string + backToProviders: string + identityProvider: string + } } modelPicker: { diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index fbf2ed0822b..9354d4c5ffe 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1797,7 +1797,27 @@ export const zhHant = defineLocale({ price: (input, output) => `${input} 輸入 / ${output} 輸出 每 Mtok`, change: '變更', startChatting: '開始', - docs: provider => `${provider} 文件` + docs: provider => `${provider} 文件`, + connectRemoteGateway: '連線到遠端閘道', + gateway: { + urlLabel: '閘道 URL', + urlHint: '遠端 Hermes 閘道的基礎 URL。支援路徑前綴,例如 /hermes。', + probing: '正在檢查閘道認證方式…', + probeError: '暫時無法連線到此閘道。請檢查 URL — 認證方式將在閘道回應後顯示。', + signIn: '登入', + signInWith: provider => `使用 ${provider} 登入`, + passwordHint: '此閘道使用使用者名稱和密碼。登入以授權此應用。', + oauthHint: provider => `此閘道使用 OAuth。使用 ${provider} 登入以授權此應用。`, + tokenLabel: '工作階段權杖', + tokenPlaceholder: '貼上工作階段權杖', + tokenHint: '用於 REST 和 WebSocket 存取的儀表板工作階段權杖。', + connect: '連線', + connecting: '正在連線', + saveFailed: '無法儲存閘道連線。', + signInFailed: '登入失敗。請重試。', + backToProviders: '返回提供商列表', + identityProvider: '閘道' + } }, modelPicker: { diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 6d029367eae..0bbbd9771e9 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1909,7 +1909,27 @@ export const zh: Translations = { price: (input, output) => `${input} 输入 / ${output} 输出每 Mtok`, change: '更改', startChatting: '开始', - docs: provider => `${provider} 文档` + docs: provider => `${provider} 文档`, + connectRemoteGateway: '连接到远程网关', + gateway: { + urlLabel: '网关地址', + urlHint: '远程 Hermes 网关的基础 URL。支持路径前缀,例如 /hermes。', + probing: '正在检查网关认证方式…', + probeError: '暂时无法连接到此网关。请检查 URL — 认证方式将在网关响应后显示。', + signIn: '登录', + signInWith: provider => `使用 ${provider} 登录`, + passwordHint: '此网关使用用户名和密码。登录以授权此应用。', + oauthHint: provider => `此网关使用 OAuth。使用 ${provider} 登录以授权此应用。`, + tokenLabel: '会话令牌', + tokenPlaceholder: '粘贴会话令牌', + tokenHint: '用于 REST 和 WebSocket 访问的仪表板会话令牌。', + connect: '连接', + connecting: '正在连接', + saveFailed: '无法保存网关连接。', + signInFailed: '登录失败。请重试。', + backToProviders: '返回提供商列表', + identityProvider: '网关' + } }, modelPicker: { diff --git a/apps/desktop/src/store/onboarding.test.ts b/apps/desktop/src/store/onboarding.test.ts index 17e9964cc81..534864a2621 100644 --- a/apps/desktop/src/store/onboarding.test.ts +++ b/apps/desktop/src/store/onboarding.test.ts @@ -35,6 +35,7 @@ function baseState(overrides: Partial = {}): DesktopOnbo firstRunSkipped: false, manual: false, localEndpoint: false, + gatewayMode: false, ...overrides } } diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts index 9ef3754be7b..d76220f51ca 100644 --- a/apps/desktop/src/store/onboarding.ts +++ b/apps/desktop/src/store/onboarding.ts @@ -12,6 +12,7 @@ import { submitOAuthCode, validateProviderCredential } from '@/hermes' +import { isThinClient } from '@/lib/build-mode' import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness' import { notify, notifyError } from '@/store/notifications' import type { ModelOptionProvider, OAuthProvider, OAuthStartResponse } from '@/types/hermes' @@ -20,7 +21,7 @@ type PkceStart = Extract type DeviceStart = Extract type LoopbackStart = Extract -export type OnboardingMode = 'apikey' | 'oauth' +export type OnboardingMode = 'apikey' | 'oauth' | 'gateway' export type OnboardingFlow = | { status: 'idle' } @@ -34,6 +35,14 @@ export type OnboardingFlow = | { provider: OAuthProvider; start: OAuthStartResponse; status: 'submitting' } | { copied: boolean; provider: OAuthProvider; status: 'external_pending' } | { provider: OAuthProvider; status: 'success' } + | { + // Gateway onboarding: a remote gateway was configured (URL + auth), + // and the connection was saved+applied. The overlay shows a brief + // "connected" confirm before completing — the apply already reloads + // the window, so this is mostly a transitional state. + gatewayUrl: string + status: 'gateway_connected' + } | { // After successful credential acquisition, before completing // onboarding: show the user which model they're getting and let @@ -77,6 +86,11 @@ export interface DesktopOnboardingState { * custom endpoint"). Forces the API-key form with the local option * preselected instead of the OAuth picker. */ localEndpoint: boolean + /** True when the overlay should show the gateway-connection form instead + * of the provider picker. Set on thin-client builds (where + * there's no local backend to configure providers for), or when the user + * picks "Connect to a remote gateway" from the thick-client picker. */ + gatewayMode: boolean } export interface OnboardingContext { @@ -156,7 +170,8 @@ const INITIAL: DesktopOnboardingState = { requested: false, firstRunSkipped: readCachedSkipped(), manual: false, - localEndpoint: false + localEndpoint: false, + gatewayMode: false } export const $desktopOnboarding = atom(INITIAL) @@ -430,6 +445,105 @@ export function startManualLocalEndpoint(reason: null | string = null) { }) } +// Open the onboarding overlay on the gateway-connection form. Used on the +// thin client's first run (no local backend → must configure a remote +// gateway), and on the thick client when the user picks "Connect to a +// remote gateway" from the provider picker. The form drives the same +// desktop bridge IPC (probe / save / apply / oauth-login / test) as the +// Settings → Gateway page — just wrapped in the onboarding chrome. +export function startGatewayOnboarding(reason: null | string = null) { + pendingProviderOAuthId = null + patch({ + gatewayMode: true, + manual: false, + mode: 'gateway', + requested: true, + localEndpoint: false, + reason: reason ? reason.trim() || DEFAULT_ONBOARDING_REASON : null, + flow: { status: 'idle' } + }) +} + +// Save + apply a remote gateway connection from the onboarding form. On +// success, `applyConnectionConfig` reloads the window — so we just set the +// transitional "gateway_connected" flow state and let the reload handle +// the rest. On failure, surface the error in the form. +export async function saveGatewayConnection( + url: string, + authMode: 'oauth' | 'token', + token: string | undefined +): Promise<{ ok: boolean; message?: string }> { + const trimmedUrl = url.trim() + + if (!trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) { + return { ok: false, message: 'Enter a valid gateway URL (https://…).' } + } + + try { + await window.hermesDesktop?.saveConnectionConfig({ + mode: 'remote', + remoteUrl: trimmedUrl, + remoteAuthMode: authMode, + remoteToken: authMode === 'token' ? token?.trim() || undefined : undefined + }) + // applyConnectionConfig restarts the backend / reloads the window. + await window.hermesDesktop?.applyConnectionConfig({ + mode: 'remote', + remoteUrl: trimmedUrl, + remoteAuthMode: authMode, + remoteToken: authMode === 'token' ? token?.trim() || undefined : undefined + }) + setFlow({ status: 'gateway_connected', gatewayUrl: trimmedUrl }) + + return { ok: true } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + + return { ok: false, message: msg } + } +} + +// Initiate OAuth sign-in against a remote gateway from the onboarding form. +// Mirrors GatewaySettings.signIn: save the URL + oauth mode first (so the login +// window has a target), then open the login window. On success, apply the +// connection which reloads the window. +export async function gatewayOauthLogin( + url: string +): Promise<{ ok: boolean; connected: boolean; message?: string }> { + const trimmedUrl = url.trim() + + if (!trimmedUrl) { + return { ok: false, connected: false, message: 'Enter a gateway URL first.' } + } + + try { + await window.hermesDesktop?.saveConnectionConfig({ + mode: 'remote', + remoteUrl: trimmedUrl, + remoteAuthMode: 'oauth' + }) + + const result = await window.hermesDesktop?.oauthLoginConnectionConfig(trimmedUrl) + + if (result?.connected) { + await window.hermesDesktop?.applyConnectionConfig({ + mode: 'remote', + remoteUrl: trimmedUrl, + remoteAuthMode: 'oauth' + }) + setFlow({ status: 'gateway_connected', gatewayUrl: trimmedUrl }) + + return { ok: true, connected: true } + } + + return { ok: false, connected: false, message: 'Sign-in was not completed.' } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + + return { ok: false, connected: false, message: msg } + } +} + // One-shot hand-off used when the dedicated Providers settings page launches a // specific provider's sign-in: we open the manual onboarding overlay AND // remember which provider to start, so the overlay drives that exact OAuth @@ -461,7 +575,7 @@ export function clearPendingProviderOAuth() { export function closeManualOnboarding() { pendingProviderOAuthId = null - patch({ manual: false, requested: false, localEndpoint: false, flow: { status: 'idle' } }) + patch({ manual: false, requested: false, localEndpoint: false, gatewayMode: false, flow: { status: 'idle' } }) } export function completeDesktopOnboarding() { @@ -479,7 +593,8 @@ export function completeDesktopOnboarding() { requested: false, firstRunSkipped: false, manual: false, - localEndpoint: false + localEndpoint: false, + gatewayMode: false }) } @@ -492,13 +607,21 @@ export function completeDesktopOnboarding() { export function dismissFirstRunOnboarding() { clearPoll() writeCachedSkipped(true) - patch({ firstRunSkipped: true, requested: false, manual: false, localEndpoint: false, flow: { status: 'idle' } }) + patch({ firstRunSkipped: true, requested: false, manual: false, localEndpoint: false, gatewayMode: false, flow: { status: 'idle' } }) } export function setOnboardingMode(mode: OnboardingMode) { patch({ mode }) } +// Exit the gateway connect form and return to the provider picker. +// Only used on the thick client where the user can go back from the +// gateway form to the provider list. On thin client the gateway form +// is the only option, so the back button is hidden. +export function exitGatewayMode() { + patch({ gatewayMode: false, mode: 'oauth', flow: { status: 'idle' } }) +} + export async function refreshOnboarding(ctx: OnboardingContext) { // Manual mode (user opened the selector from a working app): never // auto-dismiss on runtime-ready — the whole point is to let them add / @@ -510,6 +633,34 @@ export async function refreshOnboarding(ctx: OnboardingContext) { return false } + // Thin client: there's no local backend, so the runtime check always + // fails. Instead of showing the provider picker, check if a remote + // gateway is already configured. If it is, the boot will succeed and + // onCompleted fires. If not, show the gateway connect form. + if (isThinClient()) { + const config = await window.hermesDesktop?.getConnectionConfig?.().catch(() => null) + + if (config?.mode === 'remote' && config.remoteUrl) { + // A remote gateway is configured — let the boot proceed. The gateway + // boot hook will either connect or surface a reauth failure. + completeDesktopOnboarding() + ctx.onCompleted?.() + + return true + } + + // No remote gateway configured — show the gateway connect form. + writeCachedConfigured(false) + patch({ + configured: false, + gatewayMode: true, + mode: 'gateway', + reason: 'Connect to a remote gateway to get started.' + }) + + return false + } + const runtime = await checkRuntime(ctx) if (runtime.ready) { diff --git a/scripts/slice_tests.py b/scripts/slice_tests.py new file mode 100644 index 00000000000..e69de29bb2d