fix(desktop): external CLI providers + center mode tabs

External-CLI providers (Claude Code, Qwen Code) now open an in-overlay panel with the CLI command, copy button, and an "I've signed in" recheck instead of firing an invisible toast. Center the Sign in / API key tab control so it sits under the heading instead of hugging the left edge.
This commit is contained in:
Brooklyn Nicholson
2026-05-07 23:47:15 -04:00
parent 37d1c57f8a
commit 726a1a97a7
2 changed files with 112 additions and 18 deletions
@@ -9,8 +9,10 @@ import {
$desktopOnboarding,
cancelOnboardingFlow,
copyDeviceCode,
copyExternalCommand,
type OnboardingContext,
type OnboardingFlow,
recheckExternalSignin,
refreshOnboarding,
saveOnboardingApiKey,
setOnboardingCode,
@@ -224,7 +226,7 @@ function ModeTabs<T extends string>({ mode, onChange, tabs }: ModeTabsProps<T>)
<div
aria-label="Connection method"
className={cn(
'grid w-full max-w-xs gap-1 rounded-full border border-border bg-muted/40 p-1 text-xs font-medium',
'mx-auto grid w-full max-w-xs gap-1 rounded-full border border-border bg-muted/40 p-1 text-xs font-medium',
TAB_COLS[tabs.length] ?? 'grid-cols-2'
)}
role="tablist"
@@ -446,6 +448,47 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
)
}
if (flow.status === 'external_pending') {
return (
<div className="grid gap-4">
<div>
<h3 className="text-sm font-semibold">Sign in with {title}</h3>
<p className="mt-1 text-sm text-muted-foreground">
{title} signs in through its own CLI. Run this command in a terminal, then come back and pick "I've signed
in":
</p>
</div>
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-secondary/30 px-4 py-3">
<code className="font-mono text-sm">{flow.provider.cli_command}</code>
<Button onClick={() => void copyExternalCommand()} size="sm" variant="outline">
{flow.copied ? <Check className="size-4" /> : 'Copy'}
</Button>
</div>
<div className="flex items-center justify-between gap-3">
{flow.provider.docs_url ? (
<Button asChild size="xs" variant="ghost">
<a href={flow.provider.docs_url} rel="noreferrer" target="_blank">
<ExternalLink className="size-3" />
{title} docs
</a>
</Button>
) : (
<span />
)}
<div className="flex gap-2">
<Button onClick={cancelOnboardingFlow} variant="ghost">
Cancel
</Button>
<Button onClick={() => void recheckExternalSignin(ctx)}>
<Check className="size-4" />
I've signed in
</Button>
</div>
</div>
</div>
)
}
if (flow.status !== 'polling') {
return null
}
+68 -17
View File
@@ -19,6 +19,7 @@ export type OnboardingFlow =
| { code: string; provider: OAuthProvider; start: Extract<OAuthStartResponse, { flow: 'pkce' }>; status: 'awaiting_user' }
| { copied: boolean; provider: OAuthProvider; start: Extract<OAuthStartResponse, { flow: 'device_code' }>; status: 'polling' }
| { provider: OAuthProvider; start: OAuthStartResponse; status: 'submitting' }
| { copied: boolean; provider: OAuthProvider; status: 'external_pending' }
| { provider: OAuthProvider; status: 'success' }
| { message: string; provider?: OAuthProvider; start?: OAuthStartResponse; status: 'error' }
@@ -48,7 +49,14 @@ export interface OnboardingContext {
}
const POLL_MS = 2000
const BUSY: ReadonlySet<OnboardingFlow['status']> = new Set(['starting', 'awaiting_user', 'polling', 'submitting'])
const BUSY: ReadonlySet<OnboardingFlow['status']> = new Set([
'starting',
'awaiting_user',
'polling',
'submitting',
'external_pending'
])
let pollTimer: number | null = null
@@ -147,11 +155,8 @@ async function finalize(provider: OAuthProvider, ctx: OnboardingContext) {
export async function startProviderOAuth(provider: OAuthProvider, ctx: OnboardingContext) {
if (provider.flow === 'external') {
notify({
kind: 'info',
title: `${provider.name} uses an external CLI`,
message: `Run \`${provider.cli_command}\` in a terminal, then come back to retry.`
})
clearPoll()
setFlow({ status: 'external_pending', provider, copied: false })
return
}
@@ -250,6 +255,29 @@ export function cancelOnboardingFlow() {
setFlow({ status: 'idle' })
}
async function copyAndFlash(text: string, predicate: (flow: OnboardingFlow) => boolean) {
try {
await navigator.clipboard.writeText(text)
} catch {
return
}
const { flow } = $desktopOnboarding.get()
if (!predicate(flow) || !('copied' in flow)) {
return
}
setFlow({ ...flow, copied: true })
window.setTimeout(() => {
const current = $desktopOnboarding.get().flow
if (predicate(current) && 'copied' in current) {
setFlow({ ...current, copied: false })
}
}, 1500)
}
export async function copyDeviceCode() {
const { flow } = $desktopOnboarding.get()
@@ -257,18 +285,41 @@ export async function copyDeviceCode() {
return
}
try {
await navigator.clipboard.writeText(flow.start.user_code)
setFlow({ ...flow, copied: true })
window.setTimeout(() => {
const current = $desktopOnboarding.get().flow
await copyAndFlash(
flow.start.user_code,
f => f.status === 'polling' && f.start.session_id === flow.start.session_id
)
}
if (current.status === 'polling' && current.start.session_id === flow.start.session_id) {
setFlow({ ...current, copied: false })
}
}, 1500)
} catch {
// Clipboard write blocked — user can still type the code from the dialog.
export async function copyExternalCommand() {
const { flow } = $desktopOnboarding.get()
if (flow.status !== 'external_pending') {
return
}
await copyAndFlash(flow.provider.cli_command, f => f.status === 'external_pending' && f.provider.id === flow.provider.id)
}
export async function recheckExternalSignin(ctx: OnboardingContext) {
const { flow } = $desktopOnboarding.get()
if (flow.status !== 'external_pending') {
return
}
const ok = await checkRuntime(ctx)
if (ok) {
notify({ kind: 'success', title: 'Hermes is ready', message: `${flow.provider.name} connected.` })
completeDesktopOnboarding()
ctx.onCompleted?.()
} else {
setFlow({
status: 'error',
provider: flow.provider,
message: `Hermes still cannot reach ${flow.provider.name}. Run \`${flow.provider.cli_command}\` in a terminal first.`
})
}
}