Skip to content
Merged
24 changes: 22 additions & 2 deletions apps/desktop/src/components/desktop-onboarding-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,17 @@ const PROVIDER_DISPLAY: Record<string, { order: number; title: string }> = {
anthropic: { order: 1, title: 'Anthropic Claude' },
'openai-codex': { order: 2, title: 'OpenAI Codex / ChatGPT' },
'minimax-oauth': { order: 3, title: 'MiniMax' },
'claude-code': { order: 4, title: 'Claude Code' },
'qwen-oauth': { order: 5, title: 'Qwen Code' }
'xai-oauth': { order: 4, title: 'xAI Grok' },
'claude-code': { order: 5, title: 'Claude Code' },
'qwen-oauth': { order: 6, title: 'Qwen Code' }
}

const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`

const FLOW_SUBTITLES: Record<OAuthProvider['flow'], string> = {
pkce: 'Opens your browser to sign in, then continues here',
device_code: 'Opens a verification page in your browser — Hermes connects automatically',
loopback: 'Opens your browser to sign in — Hermes connects automatically',
external: 'Sign in once in your terminal, then come back to chat'
}

Expand Down Expand Up @@ -565,6 +567,24 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
)
}

if (flow.status === 'awaiting_browser') {
return (
<Step title={`Sign in with ${title}`}>
<p className="text-sm text-muted-foreground">
We opened {title} in your browser. Authorize Hermes there and you'll be connected
automatically — nothing to copy or paste.
</p>
<FlowFooter left={<DocsLink href={flow.start.auth_url}>Re-open sign-in page</DocsLink>}>
<span className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Waiting for you to authorize...
</span>
<CancelBtn size="sm" />
</FlowFooter>
</Step>
)
}

if (flow.status === 'external_pending') {
return (
<Step title={`Sign in with ${title}`}>
Expand Down
44 changes: 41 additions & 3 deletions apps/desktop/src/store/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { ModelOptionProvider, OAuthProvider, OAuthStartResponse } from '@/t

type PkceStart = Extract<OAuthStartResponse, { flow: 'pkce' }>
type DeviceStart = Extract<OAuthStartResponse, { flow: 'device_code' }>
type LoopbackStart = Extract<OAuthStartResponse, { flow: 'loopback' }>

export type OnboardingMode = 'apikey' | 'oauth'

Expand All @@ -26,6 +27,10 @@ export type OnboardingFlow =
| { provider: OAuthProvider; status: 'starting' }
| { code: string; provider: OAuthProvider; start: PkceStart; status: 'awaiting_user' }
| { copied: boolean; provider: OAuthProvider; start: DeviceStart; status: 'polling' }
// Loopback PKCE (xAI Grok): browser opens, the local backend's 127.0.0.1
// listener catches the redirect, and we poll until the worker finishes.
// No code to paste and no user_code to show — just a waiting state.
| { provider: OAuthProvider; start: LoopbackStart; status: 'awaiting_browser' }
| { provider: OAuthProvider; start: OAuthStartResponse; status: 'submitting' }
| { copied: boolean; provider: OAuthProvider; status: 'external_pending' }
| { provider: OAuthProvider; status: 'success' }
Expand Down Expand Up @@ -406,6 +411,26 @@ export async function refreshOnboarding(ctx: OnboardingContext) {
return false
}

// Open a sign-in URL via the desktop bridge, falling back to window.open
// when the bridge isn't present (e.g. the web dashboard / dev preview) so
// the flow never silently stalls in a waiting state. Mirrors the pattern in
// apps/desktop/src/app/artifacts/index.tsx.
async function openSignInUrl(url: string) {
if (window.hermesDesktop?.openExternal) {
try {
await window.hermesDesktop.openExternal(url)

return
} catch {
// Bridge present but failed (no OS handler, user denied, etc.). Fall
// through to window.open so the sign-in URL still opens and the flow
// doesn't strand a pending OAuth session in a waiting state.
}
}

window.open(url, '_blank', 'noopener,noreferrer')
}
Comment thread
OutThisLife marked this conversation as resolved.

export async function startProviderOAuth(provider: OAuthProvider, ctx: OnboardingContext) {
clearPoll()

Expand All @@ -419,22 +444,35 @@ export async function startProviderOAuth(provider: OAuthProvider, ctx: Onboardin

try {
const start = await startOAuthLogin(provider.id)
await window.hermesDesktop?.openExternal(start.flow === 'pkce' ? start.auth_url : start.verification_url)
const browserUrl = start.flow === 'device_code' ? start.verification_url : start.auth_url
await openSignInUrl(browserUrl)

if (start.flow === 'pkce') {
setFlow({ status: 'awaiting_user', provider, start, code: '' })

return
}

if (start.flow === 'loopback') {
// No code to paste: the redirect lands on the backend's loopback
// listener. Just wait and poll the session until the worker finishes.
setFlow({ status: 'awaiting_browser', provider, start })
pollTimer = window.setInterval(() => void pollSession(provider, start, ctx), POLL_MS)

return
}

setFlow({ status: 'polling', provider, start, copied: false })
pollTimer = window.setInterval(() => void pollDevice(provider, start, ctx), POLL_MS)
pollTimer = window.setInterval(() => void pollSession(provider, start, ctx), POLL_MS)
} catch (error) {
setFlow({ status: 'error', provider, message: `Could not start sign-in: ${errMessage(error)}` })
}
}

async function pollDevice(provider: OAuthProvider, start: DeviceStart, ctx: OnboardingContext) {
// Poll a session-backed flow (device_code or loopback) until it resolves.
// Both shapes only need the session_id to poll; the start is threaded
// through to the error flow so the user can retry from the same context.
async function pollSession(provider: OAuthProvider, start: DeviceStart | LoopbackStart, ctx: OnboardingContext) {
try {
const { error_message, status } = await pollOAuthSession(provider.id, start.session_id)

Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/types/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export interface OAuthProviderStatus {
export interface OAuthProvider {
cli_command: string
docs_url: string
flow: 'device_code' | 'external' | 'pkce'
flow: 'device_code' | 'external' | 'loopback' | 'pkce'
id: string
name: string
status: OAuthProviderStatus
Expand All @@ -73,6 +73,12 @@ export type OAuthStartResponse =
user_code: string
verification_url: string
}
| {
auth_url: string
expires_in: number
flow: 'loopback'
session_id: string
}

export interface OAuthSubmitResponse {
message?: string
Expand Down
Loading
Loading