Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions changelog/unreleased/add-account-magpie-tiles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
### English

- Add-account picker uses Magpie-style grouped provider tiles (Subscriptions / Experimental) for real login sources only.
- Empty Accounts (and Access when no accounts) show a skippable CaiAI hosted soft link, not a fake provider card.

### 中文

- 添加账号第一步改为 Magpie 风格的分组磁贴(稳定订阅 / 实验性),仅展示真实登录源。
- 账号列表为空时(Access 无账号时同理)显示可跳过的 CaiAI 托管软链,不做假 provider 卡片。
60 changes: 27 additions & 33 deletions frontend/src/components/AddAccountModal.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { CopyIcon } from '@/components/motion/CopyIcon'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { duration, ease, gsap } from '@/lib/motion'
import { Button, Input, Modal, NumberField, Skeleton, TextArea } from '@heroui/react'
import { Button, Input, Modal, NumberField, TextArea } from '@heroui/react'
import { ArrowSquareOut, CaretLeft, CaretRight, CheckCircle, FileCode, Key, ShieldCheck, X } from '@phosphor-icons/react'
import { copyText } from '@/lib/clipboard'
import { BrandMark } from '@/components/BrandMark'
Expand All @@ -10,6 +10,7 @@ import { CompactSwitch } from '@/components/ui/CompactSwitch'
import { FilterToggle } from '@/components/ui/FilterToggle'
import { FormRow } from '@/components/ui/FormRow'
import { OptionTiles } from '@/components/ui/OptionTiles'
import { ProviderTileWall, ProviderTileWallSkeleton } from '@/components/account/ProviderTileWall'
import { useI18n } from '@/hooks/useI18n'
import {
createAccount,
Expand Down Expand Up @@ -82,19 +83,6 @@ const hintKeys: Record<string, string> = {
'kimi-cn': 'accountTypeKimiCNHint',
}

function AccountTypeSkeleton({ ariaLabel }: { ariaLabel: string }) {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2" aria-busy="true" aria-label={ariaLabel}>
{Array.from({ length: 6 }, (_, index) => (
<div key={index} className="flex items-center gap-2.5 rounded-xl bg-surface-secondary px-3 py-2.5">
<Skeleton className="size-7 shrink-0 rounded-lg" />
<Skeleton className="h-4 w-20 rounded-lg" />
</div>
))}
</div>
)
}

async function loadProviderOptions(): Promise<ProviderOption[]> {
const output = await fetchProviders().catch(() => null)
const descriptors = output?.data || []
Expand Down Expand Up @@ -190,7 +178,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
.then((options) => {
if (cancelled) return
setProviderOptions(options)
setAccountType(options[0]?.id || '')
setAccountType('')
setTab('browser')
})
.finally(() => {
Expand All @@ -202,7 +190,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
}, [isOpen])

const activeOption = providerOptions.find((option) => option.id === accountType)
const typesReady = Boolean(activeOption) && !typesLoading
const typesReady = !typesLoading && providerOptions.length > 0
const showPatTab = activeOption?.descriptor.capabilities?.pat_login !== false
const showImportTab = activeOption?.descriptor.capabilities?.import_export !== false
// Command Code has no browser login (a pasted user_… key is the only auth),
Expand Down Expand Up @@ -528,14 +516,6 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
}

const tabPending = (key: TabKey) => busy && tab === key
const typeOptions = providerOptions.map((option) => ({
value: option.id,
label: optionLabel(option, t),
hint: optionHint(option, t),
icon: <ProviderMark provider={option.provider} size={18} />,
disabled: settingsLocked,
}))

const isZhipu = activeOption?.provider === 'zhipu'
const methodOptions = [
activeOption?.descriptor.capabilities?.browser_login !== false
Expand All @@ -558,8 +538,12 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
return (
<Modal.Root isOpen={isOpen} onOpenChange={(next: boolean) => { if (!next) close() }}>
<Modal.Backdrop variant="blur" isDismissable={!busy}>
<Modal.Container size="lg" scroll="inside" className="sm:max-w-4xl">
<Modal.Dialog>
<Modal.Container
size="lg"
scroll="inside"
className={step === 'method' ? 'sm:max-w-6xl' : 'sm:max-w-5xl'}
>
<Modal.Dialog className={step === 'method' ? 'w-full max-w-6xl' : 'w-full max-w-5xl'}>
<Modal.Header className="relative items-center justify-center px-12 pt-5 text-center">
<div className="min-w-0">
<Modal.Heading className="text-section font-semibold">{t('addAccountTitle')}</Modal.Heading>
Expand All @@ -569,22 +553,32 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
</div>
<Modal.CloseTrigger aria-label={t('close')} className="absolute right-4 top-4 grid size-8 shrink-0 place-items-center rounded-lg text-muted transition-colors hover:bg-surface-secondary hover:text-foreground"><X size={16} /></Modal.CloseTrigger>
</Modal.Header>
<Modal.Body className="px-6 pb-2">
<Modal.Body className={step === 'method' ? 'px-8 pb-5 sm:px-10' : 'px-6 pb-2'}>
<div ref={stepBody}>
{step === 'method' ? (
<>
<section className="space-y-2.5">
{typesLoading ? (
<AccountTypeSkeleton ariaLabel={t('accountType')} />
<ProviderTileWallSkeleton ariaLabel={t('accountType')} />
) : !typesReady ? (
<p className="rounded-lg bg-surface-secondary/45 px-3.5 py-3 text-caption leading-5 text-muted">{t('accountTypeHint')}</p>
) : (
<OptionTiles
<ProviderTileWall
ariaLabel={t('accountType')}
columns={2}
value=""
onChange={chooseProvider}
options={typeOptions}
options={providerOptions.map((option) => {
const regionLabel = option.descriptor.regions.find((r) => r.id === option.region)?.label
|| regionLabels[option.region]
|| ''
return {
id: option.id,
provider: option.provider,
label: optionLabel(option, t),
subtitle: regionLabel,
hint: optionHint(option, t),
}
})}
onSelect={chooseProvider}
disabled={settingsLocked}
/>
)}
</section>
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/components/CaiaiSoftLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useI18n } from '@/hooks/useI18n'

const CAIAI_REGISTER_URL = 'https://caiaiu.com/register?aff=cli2api'

/** Skipable hosted-gateway soft link for empty account states. Not a provider tile. */
export function CaiaiSoftLink({ className = '' }: { className?: string }) {
const { t } = useI18n()
return (
<p className={`text-caption leading-5 text-muted ${className}`.trim()}>
<a
href={CAIAI_REGISTER_URL}
target="_blank"
rel="noopener noreferrer"
className="underline-offset-2 transition-colors hover:text-foreground hover:underline"
>
{t('caiaiSoftLink')}
</a>
</p>
)
}
214 changes: 214 additions & 0 deletions frontend/src/components/account/ProviderTileWall.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { useMemo, useState, type ReactNode } from 'react'
import { Skeleton } from '@heroui/react'
import { ProviderMark } from '@/components/ProviderMark'
import { SearchBar } from '@/components/ui/SearchBar'
import { useI18n } from '@/hooks/useI18n'

export type ProviderTileOption = {
id: string
provider: string
label: string
/** Short line under the title (region / endpoint style). */
subtitle?: string
/** Full description for the native tooltip. */
hint?: string
}

type SectionId = 'subscriptions' | 'experimental'

type Section = {
id: SectionId
titleKey: string
subtitleKey: string
tiles: ProviderTileOption[]
}

/** Stable subscription families shown first in the Magpie-style picker. */
const SUBSCRIPTION_IDS = new Set([
'qoder-global',
'qoder-cn',
'workbuddy-global',
'workbuddy-cn',
'trae-cn',
])

const SUBSCRIPTION_ORDER = [
'qoder-global',
'qoder-cn',
'workbuddy-global',
'workbuddy-cn',
'trae-cn',
]

function sortByKnownOrder(tiles: ProviderTileOption[], order: string[]) {
const rank = new Map(order.map((id, index) => [id, index]))
return [...tiles].sort((a, b) => {
const left = rank.get(a.id) ?? Number.MAX_SAFE_INTEGER
const right = rank.get(b.id) ?? Number.MAX_SAFE_INTEGER
if (left !== right) return left - right
return a.label.localeCompare(b.label)
})
}

function matchesQuery(tile: ProviderTileOption, query: string) {
if (!query) return true
const haystack = `${tile.label} ${tile.subtitle || ''} ${tile.hint || ''} ${tile.provider} ${tile.id}`.toLowerCase()
return haystack.includes(query)
}

export function ProviderTileWallSkeleton({ ariaLabel }: { ariaLabel: string }) {
return (
<div className="space-y-8" aria-busy="true" aria-label={ariaLabel}>
{Array.from({ length: 2 }, (_, section) => (
<div key={section} className="space-y-3">
<div className="space-y-1.5">
<Skeleton className="h-3 w-24 rounded-lg" />
<Skeleton className="h-3 w-40 rounded-lg" />
</div>
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: section === 0 ? 5 : 6 }, (_, index) => (
<div key={index} className="flex items-center gap-3.5 rounded-xl bg-surface-secondary px-4 py-4">
<Skeleton className="size-10 shrink-0 rounded-lg" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton className="h-3.5 w-28 rounded-lg" />
<Skeleton className="h-3 w-36 rounded-lg" />
</div>
</div>
))}
</div>
</div>
))}
</div>
)
}

function ProviderTile({
tile,
disabled,
onSelect,
}: {
tile: ProviderTileOption
disabled?: boolean
onSelect: (id: string) => void
}) {
return (
<button
type="button"
disabled={disabled}
title={tile.hint || tile.subtitle || tile.label}
onClick={() => onSelect(tile.id)}
className="flex min-w-0 items-center gap-3.5 rounded-xl bg-surface-secondary px-4 py-4 text-left transition-[transform,opacity,background-color] duration-200 ease-out hover:bg-surface-tertiary active:translate-y-px active:scale-[0.98] motion-reduce:transition-none motion-reduce:active:translate-y-0 motion-reduce:active:scale-100 disabled:cursor-not-allowed disabled:opacity-50 disabled:active:translate-y-0 disabled:active:scale-100"
>
<span className="grid size-10 shrink-0 place-items-center rounded-lg bg-surface text-foreground [&_svg]:size-[18px]">
<ProviderMark provider={tile.provider} size={18} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-body font-medium leading-5 text-foreground line-clamp-2">{tile.label}</span>
{tile.subtitle ? (
<span className="mt-0.5 block text-caption leading-4 text-muted line-clamp-1">{tile.subtitle}</span>
) : null}
</span>
</button>
)
}

function SectionBlock({
title,
subtitle,
tiles,
disabled,
onSelect,
}: {
title: string
subtitle: string
tiles: ProviderTileOption[]
disabled?: boolean
onSelect: (id: string) => void
}) {
if (!tiles.length) return null
return (
<section className="space-y-3.5">
<div className="space-y-0.5">
<h3 className="text-[10px] font-semibold tracking-[0.12em] uppercase text-muted">{title}</h3>
{subtitle ? <p className="text-caption leading-5 text-muted">{subtitle}</p> : null}
</div>
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-3">
{tiles.map((tile) => (
<ProviderTile key={tile.id} tile={tile} disabled={disabled} onSelect={onSelect} />
))}
</div>
</section>
)
}

export function ProviderTileWall({
options,
onSelect,
disabled,
empty,
ariaLabel,
}: {
options: ProviderTileOption[]
onSelect: (id: string) => void
disabled?: boolean
empty?: ReactNode
ariaLabel: string
}) {
const { t } = useI18n()
const [query, setQuery] = useState('')
const normalized = query.trim().toLowerCase()

const sections = useMemo(() => {
const subscriptions = sortByKnownOrder(
options.filter((tile) => SUBSCRIPTION_IDS.has(tile.id)),
SUBSCRIPTION_ORDER,
)
const experimental = options
.filter((tile) => !SUBSCRIPTION_IDS.has(tile.id))
.sort((a, b) => a.label.localeCompare(b.label))

const built: Section[] = [
{
id: 'subscriptions',
titleKey: 'providerGroupSubscriptions',
subtitleKey: 'providerGroupSubscriptionsHint',
tiles: subscriptions.filter((tile) => matchesQuery(tile, normalized)),
},
{
id: 'experimental',
titleKey: 'providerGroupExperimental',
subtitleKey: 'providerGroupExperimentalHint',
tiles: experimental.filter((tile) => matchesQuery(tile, normalized)),
},
]
return built
}, [normalized, options])

const visibleCount = sections.reduce((sum, section) => sum + section.tiles.length, 0)

return (
<div className="space-y-8" role="listbox" aria-label={ariaLabel}>
<SearchBar
value={query}
onChange={setQuery}
placeholder={t('providerFindPlaceholder')}
ariaLabel={t('providerFindPlaceholder')}
className="max-w-sm"
/>
{visibleCount === 0 ? (
empty || <p className="rounded-lg bg-surface-secondary/45 px-3.5 py-3 text-caption leading-5 text-muted">{t('providerFindEmpty')}</p>
) : (
sections.map((section) => (
<SectionBlock
key={section.id}
title={t(section.titleKey)}
subtitle={t(section.subtitleKey)}
tiles={section.tiles}
disabled={disabled}
onSelect={onSelect}
/>
))
)}
</div>
)
}
14 changes: 14 additions & 0 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,13 @@ export const messages: Record<Lang, Dict> = {
accountTypeQwenworkCNHint: '千问办公 China (qwenwork.cn) via browser OAuth or imported credential. The authorization link is single-use: open it directly in a browser already signed in to the target account, and do not send it through chat tools. The CN region has not yet been verified with a real account.',
accountTypeKimiCN: 'Kimi Code CN',
accountTypeKimiCNHint: 'Kimi Code China subscription via device-code sign-in against auth.kimi.com. Uses the same OAuth client as the official CLI, so the login you already completed with Kimi Code can be pasted in as its credential JSON.',
providerGroupSubscriptions: 'Subscriptions',
providerGroupSubscriptionsHint: 'stable account logins',
providerGroupExperimental: 'Experimental',
providerGroupExperimentalHint: 'early adapters',
providerFindPlaceholder: 'Find a provider...',
providerFindEmpty: 'No providers match this search.',
caiaiSoftLink: 'Prefer hosted? Try CaiAI',
addAccountDesc: 'Create an isolated account session. Each account keeps its own runtime home.',
tabBrowser: 'Browser login',
tabPat: 'PAT',
Expand Down Expand Up @@ -1657,6 +1664,13 @@ export const messages: Record<Lang, Dict> = {
accountTypeQwenworkCNHint: '千问办公国内版(qwenwork.cn),浏览器授权或导入凭证。授权链接只能使用一次:请在已登录目标账号的浏览器窗口里直接打开,不要经聊天工具发送。国内版尚未经真实账号验收。',
accountTypeKimiCN: 'Kimi Code 国内版',
accountTypeKimiCNHint: 'Kimi Code 中国区订阅,走 auth.kimi.com 的设备码授权,与官方 CLI 使用同一个 OAuth 客户端。已在本机登录 Kimi Code 的,可直接粘贴它的凭证 JSON 导入。',
providerGroupSubscriptions: '稳定订阅',
providerGroupSubscriptionsHint: '稳定的账号登录源',
providerGroupExperimental: '实验性',
providerGroupExperimentalHint: '早期适配器',
providerFindPlaceholder: '查找登录源...',
providerFindEmpty: '没有符合搜索的登录源。',
caiaiSoftLink: '不想自托管?用托管 Gateway → CaiAI',
addAccountDesc: '创建一个隔离会话并登录。每个账号使用独立的运行时目录。',
tabBrowser: '浏览器登录',
tabPat: 'PAT',
Expand Down
Loading
Loading