mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-02 21:04:57 +00:00
feat(app): Plans settings detect subscriptions automatically
Detected subscriptions card (Claude/Codex): auto-detected tier from the live quota with a connect hint when logged out — like the menubar, never a fixed preset. Manual budget presets now only for providers without OAuth quota (Cursor/SuperGrok); configured claude/codex manual plans stay listed for removal with a superseded note. The Plans dashboard already excludes them next to the authoritative live quota.
This commit is contained in:
parent
0f836db72f
commit
cee0f3501d
2 changed files with 72 additions and 10 deletions
|
|
@ -3,7 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react'
|
|||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ActionResult, AliasRow, CombinedUsage, DeviceScanResult, Identity, MenubarPayload, PriceOverrideList, PriceRates, ShareStatus, StatusJson } from '../lib/types'
|
||||
import type { ActionResult, AliasRow, CombinedUsage, DeviceScanResult, Identity, MenubarPayload, PriceOverrideList, PriceRates, QuotaProvider, ShareStatus, StatusJson } from '../lib/types'
|
||||
import { Settings } from './Settings'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
|
|
@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
|
|||
getDevices: vi.fn<(period: string) => Promise<CombinedUsage>>(),
|
||||
getDevicesScan: vi.fn<() => Promise<DeviceScanResult>>(),
|
||||
getShareStatus: vi.fn<() => Promise<ShareStatus>>(),
|
||||
getQuota: vi.fn<(force?: boolean) => Promise<QuotaProvider[]>>(),
|
||||
getPlans: vi.fn<(period: string) => Promise<StatusJson>>(),
|
||||
getOverview: vi.fn<(period: string, provider: string) => Promise<MenubarPayload>>(),
|
||||
getAliases: vi.fn<() => Promise<AliasRow[]>>(),
|
||||
|
|
@ -43,6 +44,10 @@ const devices: CombinedUsage = {
|
|||
}
|
||||
const scan: DeviceScanResult = { found: [{ name: 'Mac Studio', host: 'mac-studio.local', port: 9732, fingerprint: '7F:2A:19:88:55:44:33:C4', code: 'pair-1', paired: false }] }
|
||||
const overview = { current: { providers: { claude: 12.34, codex: 4.5 } } } as unknown as MenubarPayload
|
||||
const quotaProviders: QuotaProvider[] = [
|
||||
{ provider: 'claude', connection: 'connected', primary: null, details: [], planLabel: 'Max 20x', footerLines: [] },
|
||||
{ provider: 'codex', connection: 'disconnected', primary: null, details: [], planLabel: null, footerLines: [] },
|
||||
]
|
||||
const stored = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => stored.get(key) ?? null,
|
||||
|
|
@ -57,6 +62,7 @@ describe('Settings', () => {
|
|||
mocks.getDevices.mockResolvedValue(devices)
|
||||
mocks.getDevicesScan.mockResolvedValue(scan)
|
||||
mocks.getShareStatus.mockResolvedValue({ sharing: true, name: 'Toruk MacBook Pro', port: 9732, always: false, peers: 1, pending: [] })
|
||||
mocks.getQuota.mockResolvedValue(quotaProviders)
|
||||
mocks.getPlans.mockResolvedValue({ currency: 'EUR', today: { cost: 0, savings: 0, calls: 0 }, month: { cost: 0, savings: 0, calls: 0 }, plans: { claude: { id: 'claude-max', provider: 'claude', budget: 200, spent: 48, percentUsed: 24, status: 'under', projectedMonthEnd: 120, daysUntilReset: 19, periodStart: '2026-07-01', periodEnd: '2026-08-01' } } })
|
||||
mocks.getOverview.mockResolvedValue(overview)
|
||||
mocks.getAliases.mockResolvedValue([{ from: 'proxy-opus', to: 'claude-opus-4-6' }])
|
||||
|
|
@ -207,6 +213,37 @@ describe('Settings', () => {
|
|||
expect(mocks.setPlan).toHaveBeenCalledWith('cursor-pro', 'cursor')
|
||||
})
|
||||
|
||||
it('shows detected subscriptions with an auto-detected tier and a disconnected hint', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Settings period="month" />)
|
||||
await user.click(screen.getByRole('button', { name: 'Plans' }))
|
||||
expect(await screen.findByText('Detected subscriptions')).toBeInTheDocument()
|
||||
expect(screen.getByText('Max 20x')).toBeInTheDocument()
|
||||
expect(screen.getByText('Not connected: log in with the codex CLI')).toBeInTheDocument()
|
||||
expect(mocks.getQuota).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('offers only non-OAuth budget presets; Claude and Codex are excluded', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Settings period="month" />)
|
||||
await user.click(screen.getByRole('button', { name: 'Plans' }))
|
||||
await user.click(await screen.findByLabelText('Add a plan'))
|
||||
expect(screen.getByRole('option', { name: 'Cursor Pro' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: 'SuperGrok' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('option', { name: 'Claude Pro' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('option', { name: 'Claude Max 20x' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('option', { name: 'Claude Max 5x' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still lists a configured Claude manual plan with Remove and a superseded note', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Settings period="month" />)
|
||||
await user.click(screen.getByRole('button', { name: 'Plans' }))
|
||||
expect((await screen.findAllByText('Claude Max 20x')).length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('superseded by the detected subscription')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('chooses an export folder and exports the selected format and provider', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Settings period="month" />)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { codeburn } from '../lib/ipc'
|
|||
import { motionClass } from '../lib/motion'
|
||||
import { showToast } from '../lib/toast'
|
||||
import { ToastHost } from '../components/ToastHost'
|
||||
import type { ActionResult, AliasRow, ClaudeConfigSelector, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, PriceOverrideList, PriceOverrideRow, PriceRates, ShareStatus, StatusJson } from '../lib/types'
|
||||
import type { ActionResult, AliasRow, ClaudeConfigSelector, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, PriceOverrideList, PriceOverrideRow, PriceRates, QuotaProvider, ShareStatus, StatusJson } from '../lib/types'
|
||||
|
||||
export type SettingsPane = 'general' | 'providers' | 'aliases' | 'pricing' | 'plans' | 'devices' | 'export' | 'privacy'
|
||||
type Pane = SettingsPane
|
||||
|
|
@ -30,6 +30,10 @@ const PLAN_PRESETS: PlanPreset[] = [
|
|||
{ id: 'supergrok-heavy', label: 'SuperGrok Heavy', provider: 'grok' },
|
||||
]
|
||||
|
||||
// Claude and Codex subscriptions are detected from the CLI login (see the
|
||||
// detected-subscriptions list), so only non-OAuth providers get a manual preset.
|
||||
const MANUAL_PLAN_PRESETS = PLAN_PRESETS.filter(preset => preset.provider !== 'claude')
|
||||
|
||||
const CURRENCIES = [
|
||||
'USD', 'EUR', 'GBP', 'JPY', 'CNY', 'CAD', 'AUD', 'CHF', 'HKD', 'SGD', 'INR', 'NZD', 'SEK', 'NOK', 'DKK',
|
||||
'KRW', 'BRL', 'MXN', 'ZAR', 'AED', 'SAR', 'TRY', 'PLN', 'THB', 'IDR', 'MYR', 'PHP', 'RUB', 'ILS', 'CZK',
|
||||
|
|
@ -295,10 +299,24 @@ function planSummaries(status: StatusJson): JsonPlanSummary[] {
|
|||
return status.plan ? [status.plan] : []
|
||||
}
|
||||
|
||||
function DetectedRow({ quota }: { quota: QuotaProvider }) {
|
||||
const name = quota.provider === 'claude' ? 'Claude' : 'Codex'
|
||||
return <div className="about-row">
|
||||
<ProviderLogo provider={quota.provider} />
|
||||
<span className="tx">{name}</span>
|
||||
{quota.connection === 'disconnected'
|
||||
? <span className="r set-status">Not connected: log in with the {quota.provider} CLI</span>
|
||||
: <span className="r set-status"><span className="set-dot ok" />{quota.planLabel ?? 'Connected'}</span>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function PlansPane({ period, refreshToken, onNavigate }: { period: Period; refreshToken: number; onNavigate?: (section: Section) => void }) {
|
||||
const [nonce, setNonce] = useState(0)
|
||||
// Match Plans.tsx: steady poll serves cached quota (force=false). The detected
|
||||
// list is read-only, so no manual force is needed here.
|
||||
const quota = usePolled<QuotaProvider[]>(() => codeburn.getQuota(false), [refreshToken])
|
||||
const plans = usePolled<StatusJson>(() => codeburn.getPlans(period), [period, refreshToken, nonce])
|
||||
const [presetId, setPresetId] = useState(PLAN_PRESETS[0]!.id)
|
||||
const [presetId, setPresetId] = useState(MANUAL_PLAN_PRESETS[0]!.id)
|
||||
const configured = plans.data ? planSummaries(plans.data) : []
|
||||
|
||||
const finish = (result: ActionResult) => {
|
||||
|
|
@ -309,21 +327,28 @@ function PlansPane({ period, refreshToken, onNavigate }: { period: Period; refre
|
|||
void codeburn.resetPlan(plan.provider).then(finish)
|
||||
}
|
||||
const add = () => {
|
||||
const preset = PLAN_PRESETS.find(item => item.id === presetId)!
|
||||
const preset = MANUAL_PLAN_PRESETS.find(item => item.id === presetId)!
|
||||
void codeburn.setPlan(preset.id, preset.provider).then(finish)
|
||||
}
|
||||
|
||||
return <section className="set-p on">
|
||||
<div><h3 className="set-h">Plans</h3><p className="set-sub">Set a monthly budget plan per provider. codeburn compares it to your API-equivalent spend.</p></div>
|
||||
<div><h3 className="set-h">Plans</h3><p className="set-sub">Claude and Codex subscriptions connect and auto-detect your tier. Set a manual budget plan for any other provider.</p></div>
|
||||
<div className="card">
|
||||
<div className="about-sec">
|
||||
{plans.error ? <SettingsErrorText error={plans.error} /> : !plans.data ? <p className="set-cap">Loading plans…</p> : configured.length === 0 ? <p className="set-cap">No plans configured.</p> : configured.map(plan => <div className="about-row" key={plan.provider}><span className="tx">{PLAN_PRESETS.find(item => item.id === plan.id)?.label ?? plan.id}<small>{formatConverted(plan.budget)}/month · {plan.provider} · {plan.percentUsed}% used</small></span><span className="r"><ConfirmButton label="Remove" prompt="Remove?" onConfirm={() => remove(plan)} /></span></div>)}
|
||||
</div>
|
||||
<div className="about-sec set-last-sec">
|
||||
<div className="about-row"><label className="tx" htmlFor="settings-plan-preset">Add a plan</label><span className="r"><Dropdown id="settings-plan-preset" ariaLabel="Add a plan" value={presetId} options={PLAN_PRESETS.map(preset => ({ value: preset.id, label: preset.label }))} onChange={value => setPresetId(value as PlanPreset['id'])} width={160} /><button className="btnp btnp-primary" onClick={add}>Add</button></span></div>
|
||||
<div className="about-sec-h">Detected subscriptions</div>
|
||||
{quota.error && !quota.data ? <SettingsErrorText error={quota.error} /> : !quota.data ? <p className="set-cap">Detecting subscriptions…</p> : quota.data.length === 0 ? <p className="set-cap">No detectable subscriptions.</p> : quota.data.map(provider => <DetectedRow key={provider.provider} quota={provider} />)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="set-cap">Presets: Claude Pro, Claude Max 20x, Claude Max 5x, Cursor Pro, SuperGrok, and SuperGrok Heavy. <button className="set-text-button" onClick={() => onNavigate?.('plans')}>Open Plans →</button></p>
|
||||
<div className="card">
|
||||
<div className="about-sec">
|
||||
<div className="about-sec-h">Budget plans (manual)</div>
|
||||
{plans.error ? <SettingsErrorText error={plans.error} /> : !plans.data ? <p className="set-cap">Loading plans…</p> : configured.length === 0 ? <p className="set-cap">No manual plans configured.</p> : configured.map(plan => <div className="about-row" key={plan.provider}><span className="tx">{PLAN_PRESETS.find(item => item.id === plan.id)?.label ?? plan.id}<small>{formatConverted(plan.budget)}/month · {plan.provider} · {plan.percentUsed}% used</small>{(plan.provider === 'claude' || plan.provider === 'codex') && <small>superseded by the detected subscription</small>}</span><span className="r"><ConfirmButton label="Remove" prompt="Remove?" onConfirm={() => remove(plan)} /></span></div>)}
|
||||
</div>
|
||||
<div className="about-sec set-last-sec">
|
||||
<div className="about-row"><label className="tx" htmlFor="settings-plan-preset">Add a plan</label><span className="r"><Dropdown id="settings-plan-preset" ariaLabel="Add a plan" value={presetId} options={MANUAL_PLAN_PRESETS.map(preset => ({ value: preset.id, label: preset.label }))} onChange={value => setPresetId(value as PlanPreset['id'])} width={160} /><button className="btnp btnp-primary" onClick={add}>Add</button></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="set-cap">Claude and Codex plans are detected automatically from your login. <button className="set-text-button" onClick={() => onNavigate?.('plans')}>Open Plans →</button></p>
|
||||
</section>
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue