From 43255229dce82eef0446c79b06c6831333889211 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Mon, 3 Aug 2026 14:48:18 +0000 Subject: [PATCH 1/7] feat(ui): render provider usage reports Amp-Thread-ID: https://ampcode.com/threads/T-019fc7db-6741-721b-9ebb-bed19e2bb6e3 --- packages/presentation/i18n/src/locales/en.ts | 61 +++++++ .../presentation/i18n/src/locales/zh-cn.ts | 57 +++++++ packages/presentation/ui/src/chat/types.ts | 3 + .../__tests__/conversation-surface.test.tsx | 21 +++ .../ui/src/shell/conversation-surface.tsx | 2 + packages/presentation/ui/src/shell/index.ts | 1 + .../ui/src/shell/usage-report-card.tsx | 158 ++++++++++++++++++ 7 files changed, 303 insertions(+) create mode 100644 packages/presentation/ui/src/shell/usage-report-card.tsx diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 82c57a4bf..9cc939ba3 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -567,6 +567,27 @@ export const en = { currentStyle: 'Current view: {style}', }, }, + usageReport: { + title: 'Usage report', + session: 'Current session', + cost: 'Cost', + duration: 'Duration', + lines: 'Lines changed', + tokens: 'tokens', + rateLimits: 'Plan limits', + rateLimitsUnavailable: 'No plan rate-limit information is available for this account.', + window: 'Limit window', + windowMinutes: '{count}-minute window', + unavailable: 'Unavailable', + resets: 'Resets {date}', + lastDay: 'Last 24 hours attribution', + lastWeek: 'Last 7 days attribution', + attributionEmpty: 'No skill, MCP, agent, or plugin attribution reported.', + skill: 'Skill', + mcp: 'MCP', + agent: 'Agent', + plugin: 'Plugin', + }, sidebar: { newTask: 'New Task', search: 'Search', @@ -592,6 +613,7 @@ export const en = { organization: 'Organization', signInCloud: 'Sign in to LinkCode Cloud', account: 'Account', + billingBalance: 'Available balance {currency} {amount}', manageAccount: 'Manage account', signOut: 'Sign out', remoteSignedOut: 'Sign in to connect', @@ -747,6 +769,7 @@ export const en = { notifications: 'Notifications', about: 'About', providers: 'Providers', + billing: 'Billing', agents: 'Agents', imChannel: 'Messaging', plugins: 'Plugins & Skills', @@ -1108,6 +1131,44 @@ export const en = { permissionDenied: 'The browser has blocked notifications. Re-allow them in the browser site settings.', }, + billing: { + title: 'Billing', + signedOut: 'Sign in to LinkCode Cloud to view your balance and manage billing.', + signIn: 'Sign in to LinkCode Cloud', + loadError: 'Failed to load billing: {message}', + organizationHint: 'Current organization: {organization}', + purchaseForbidden: + 'You can view billing for this organization, but only an owner or admin can purchase credits.', + available: 'Available balance', + reserved: 'Reserved balance', + reservedHint: + 'Requests reserve credit for their maximum token count. Any unused amount is released after settlement.', + creditPacks: 'Credit packs', + offersEmpty: 'No credit packs are available right now.', + creditsIncluded: 'Includes {currency} {amount} in credits', + buy: 'Buy', + topUpAmount: 'Custom top-up amount (USD)', + topUp: 'Top up', + topUpHint: 'Minimum {minimum}; {fee}% non-refundable processing fee applies.', + subscription: 'Subscription', + subscriptionUnavailable: + 'Cloud does not expose subscription status yet. Manage an existing subscription in the billing portal.', + manageSubscription: 'Manage subscription', + portalUnavailable: + 'This organization does not have a billing profile yet. The portal becomes available after the first purchase.', + orders: 'Order history', + ordersEmpty: 'No orders yet.', + refundedAmount: 'Refunded {amount}', + actionError: 'Action failed: {message}', + status: { + pending: 'Pending', + paid: 'Paid', + payment_failed: 'Payment failed', + expired: 'Expired', + partially_refunded: 'Partially refunded', + refunded: 'Refunded', + }, + }, imChannel: { title: 'Messaging', hint: 'Connect sessions to Telegram and chat with your local agents from a group topic.', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index afd712eba..6e94b156a 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -553,6 +553,27 @@ export const zhCN = { currentStyle: '当前为{style}差异视图', }, }, + usageReport: { + title: '用量报告', + session: '当前会话', + cost: '费用', + duration: '时长', + lines: '代码行', + tokens: 'tokens', + rateLimits: '套餐限额', + rateLimitsUnavailable: '该账户没有可用的套餐限额信息。', + window: '限额窗口', + windowMinutes: '{count} 分钟窗口', + unavailable: '不可用', + resets: '{date} 重置', + lastDay: '过去 24 小时归因', + lastWeek: '过去 7 天归因', + attributionEmpty: '暂无技能、MCP、智能体或插件归因。', + skill: '技能', + mcp: 'MCP', + agent: '智能体', + plugin: '插件', + }, sidebar: { newTask: '新建任务', search: '搜索', @@ -578,6 +599,7 @@ export const zhCN = { organization: '组织', signInCloud: '登录 LinkCode Cloud', account: '账户', + billingBalance: '可用余额 {currency} {amount}', manageAccount: '管理账户', signOut: '退出登录', remoteSignedOut: '登录后可连接远程主机', @@ -732,6 +754,7 @@ export const zhCN = { notifications: '通知', about: '关于', providers: 'Providers', + billing: '账单', agents: '智能体', imChannel: 'IM 渠道', plugins: '插件与技能', @@ -1080,6 +1103,40 @@ export const zhCN = { permissionRequest: '需要浏览器授权后才能显示通知。', permissionDenied: '浏览器已拦截通知。请在浏览器的站点设置中重新允许。', }, + billing: { + title: '账单', + signedOut: '查看余额和管理账单前,请先登录 LinkCode Cloud。', + signIn: '登录 LinkCode Cloud', + loadError: '加载账单失败:{message}', + organizationHint: '当前组织:{organization}', + purchaseForbidden: '你可以查看该组织的账单,但只有所有者或管理员可以购买额度。', + available: '可用余额', + reserved: '预留余额', + reservedHint: '请求会按最大 token 数预留额度;结算后未使用的部分会自动释放。', + creditPacks: '额度包', + offersEmpty: '当前没有可购买的额度包。', + creditsIncluded: '包含 {currency} {amount} 额度', + buy: '购买', + topUpAmount: '自定义充值金额(USD)', + topUp: '充值', + topUpHint: '最低 {minimum};另收 {fee}% 处理费,处理费不可退款。', + subscription: '订阅', + subscriptionUnavailable: 'Cloud 暂未提供订阅状态读取接口;可前往支付门户管理已有订阅。', + manageSubscription: '管理订阅', + portalUnavailable: '该组织还没有支付账户。完成首次购买后即可使用支付门户。', + orders: '订单记录', + ordersEmpty: '暂无订单。', + refundedAmount: '已退款 {amount}', + actionError: '操作失败:{message}', + status: { + pending: '待支付', + paid: '已支付', + payment_failed: '支付失败', + expired: '已过期', + partially_refunded: '部分退款', + refunded: '已退款', + }, + }, imChannel: { title: 'IM 渠道', hint: '把会话接入 Telegram 等即时通讯平台,在群话题里与本机的智能体对话。', diff --git a/packages/presentation/ui/src/chat/types.ts b/packages/presentation/ui/src/chat/types.ts index 19345996b..a26f5890f 100644 --- a/packages/presentation/ui/src/chat/types.ts +++ b/packages/presentation/ui/src/chat/types.ts @@ -17,6 +17,7 @@ import type { TokenUsage, ToolCall, ToolCallUpdate, + UsageReport, } from '@linkcode/schema'; export type ConversationTurnId = string | null; @@ -107,6 +108,8 @@ export interface ConversationViewModel { status: SessionStatus | null; /** Latest cumulative token usage. */ usage: TokenUsage | null; + /** Latest structured provider usage snapshot; absent in consumers predating this surface. */ + usageReport?: UsageReport | null; /** Active session mode id (e.g. plan / accept-edits), from `current-mode-update`. */ currentModeId: string | null; /** Advertised approval-policy state (the permission axis), from `approval-policy-update`; diff --git a/packages/presentation/ui/src/shell/__tests__/conversation-surface.test.tsx b/packages/presentation/ui/src/shell/__tests__/conversation-surface.test.tsx index 638ac6bec..f1188d4e0 100644 --- a/packages/presentation/ui/src/shell/__tests__/conversation-surface.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/conversation-surface.test.tsx @@ -18,6 +18,10 @@ function translateKey(key: string): string { vi.mock('use-intl', () => ({ useTranslations: () => translateKey, + useFormatter: () => ({ + dateTime: (value: Date) => value.toISOString(), + number: String, + }), })); const { scrollToBottom } = vi.hoisted(() => ({ scrollToBottom: vi.fn() })); @@ -138,6 +142,23 @@ describe('ConversationSurface prompt card', () => { expect(screen.getByRole('button', { name: RE_MAX_EFFORT })).toBeTruthy(); }); + it('renders a structured usage report that has no transcript item', () => { + render( + surface(undefined, { + ...EMPTY_CONVERSATION, + usageReport: { + session: { totalCostUsd: 1.25 }, + rateLimits: { windows: [{ id: 'five-hour', utilization: 42 }] }, + behaviors: { day: { skills: [{ name: 'review', pct: 60 }], mcpServers: [] } }, + }, + }), + ); + + expect(screen.getByText('title')).toBeTruthy(); + expect(screen.getByText('1.25')).toBeTruthy(); + expect(screen.getByText('skill · review')).toBeTruthy(); + }); + it('hides the composer while a prompt card is visible and preserves its draft', () => { const pendingConversation: ConversationViewModel = { ...EMPTY_CONVERSATION, diff --git a/packages/presentation/ui/src/shell/conversation-surface.tsx b/packages/presentation/ui/src/shell/conversation-surface.tsx index 60ec2714c..87c8679b4 100644 --- a/packages/presentation/ui/src/shell/conversation-surface.tsx +++ b/packages/presentation/ui/src/shell/conversation-surface.tsx @@ -13,6 +13,7 @@ import type { ComposerDirectiveControls, ComposerHandle, MentionItem } from './c import { Composer } from './composer'; import type { ComposerAttachment } from './composer-attachments'; import { ConversationPromptDock } from './conversation-prompt-dock'; +import { UsageReportCard } from './usage-report-card'; /** Composer behavior that every app shell must carry as one unit. Keeping the complete controller * required prevents a custom shell from silently dropping only mode, slash, or shell actions. */ @@ -137,6 +138,7 @@ export function ConversationSurface({ /> + {conversation.usageReport && } + + + {t('title')} + {report.subscriptionType && {report.subscriptionType}} + + +
+

{t('session')}

+
+ {session?.totalCostUsd !== undefined && ( + + )} + {session?.totalDurationMs !== undefined && ( + + )} + {(session?.totalLinesAdded !== undefined || + session?.totalLinesRemoved !== undefined) && ( + + )} +
+ {session?.modelUsage && ( +
    + {Object.entries(session.modelUsage).map(([model, usage]) => ( +
  • + {model} + + {(usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)} {t('tokens')} + +
  • + ))} +
+ )} +
+
+

{t('rateLimits')}

+ {windows.length === 0 ? ( +

{t('rateLimitsUnavailable')}

+ ) : ( +
    + {windows.map((window) => ( + + ))} +
+ )} +
+ + +
+
+ + ); +} + +function RateLimitWindow({ item }: { item: UsageRateLimitWindow }): React.ReactNode { + const t = useTranslations('workbench.usageReport'); + const format = useFormatter(); + const label = + item.label ?? (item.durationMins ? t('windowMinutes', { count: item.durationMins }) : item.id); + return ( +
  • +
    + {label ?? t('window')} + + {item.utilization === null || item.utilization === undefined + ? t('unavailable') + : `${format.number(item.utilization, { maximumFractionDigits: 1 })}%`} + +
    + {item.resetsAt && ( +
    + {t('resets', { + date: format.dateTime(new Date(item.resetsAt), { + dateStyle: 'short', + timeStyle: 'short', + }), + })} +
    + )} +
  • + ); +} + +type BehaviorWindow = NonNullable['day']>; + +function AttributionSection({ + title, + window, +}: { + title: string; + window: BehaviorWindow | undefined; +}): React.ReactNode { + const t = useTranslations('workbench.usageReport'); + const rows = [ + ...(window?.skills ?? []).map((item) => ({ ...item, kind: t('skill') })), + ...(window?.mcpServers ?? []).map((item) => ({ ...item, kind: t('mcp') })), + ...(window?.agents ?? []).map((item) => ({ ...item, kind: t('agent') })), + ...(window?.plugins ?? []).map((item) => ({ ...item, kind: t('plugin') })), + ]; + if (!window) return null; + return ( +
    +

    {title}

    + {rows.length === 0 ? ( +

    {t('attributionEmpty')}

    + ) : ( +
      + {rows.map((item) => ( +
    • + + {item.kind} · {item.name} + + {item.pct}% +
    • + ))} +
    + )} +
    + ); +} + +function Metric({ label, value }: { label: string; value: string }): React.ReactNode { + return ( +
    +
    {label}
    +
    {value}
    +
    + ); +} From 28329c21f0770e3f265ca0b9a0a44b0b103301e7 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Mon, 3 Aug 2026 14:48:26 +0000 Subject: [PATCH 2/7] feat(workbench): add cloud billing runtime Amp-Thread-ID: https://ampcode.com/threads/T-019fc7db-6741-721b-9ebb-bed19e2bb6e3 --- .../src/cloud/__tests__/billing.test.ts | 64 ++++++++ .../workbench/src/cloud/billing-source.ts | 124 +++++++++++++++ .../client/workbench/src/cloud/billing.tsx | 148 ++++++++++++++++++ packages/client/workbench/src/cloud/index.ts | 2 + 4 files changed, 338 insertions(+) create mode 100644 packages/client/workbench/src/cloud/__tests__/billing.test.ts create mode 100644 packages/client/workbench/src/cloud/billing-source.ts create mode 100644 packages/client/workbench/src/cloud/billing.tsx diff --git a/packages/client/workbench/src/cloud/__tests__/billing.test.ts b/packages/client/workbench/src/cloud/__tests__/billing.test.ts new file mode 100644 index 000000000..0a4dbd7d4 --- /dev/null +++ b/packages/client/workbench/src/cloud/__tests__/billing.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { canManageCloudBilling } from '../billing'; +import { createBrowserCloudBillingSource } from '../billing-source'; + +const ORGANIZATION_ID = '00000000-0000-4000-8000-000000000001'; + +function jsonResponse(body: unknown, status = 200): Response { + return Response.json(body, { status }); +} + +afterEach(() => vi.unstubAllGlobals()); + +describe('canManageCloudBilling', () => { + it('matches the server owner/admin gate, including comma-separated roles', () => { + expect(canManageCloudBilling('owner')).toBe(true); + expect(canManageCloudBilling('member, admin')).toBe(true); + expect(canManageCloudBilling(' member , owner ')).toBe(true); + expect(canManageCloudBilling('member')).toBe(false); + expect(canManageCloudBilling('administrator')).toBe(false); + }); +}); + +describe('createBrowserCloudBillingSource', () => { + it('surfaces a missing catalog endpoint instead of misreporting an empty catalog', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ id: ORGANIZATION_ID, name: 'LinkCode', slug: 'linkcode' }), + ) + .mockResolvedValueOnce( + jsonResponse({ id: ORGANIZATION_ID, name: 'LinkCode', slug: 'linkcode' }), + ) + .mockResolvedValueOnce(jsonResponse({ role: 'owner' })) + .mockResolvedValueOnce( + jsonResponse({ + denomination: 'nano_usd', + availableAmount: '0', + reservedAmount: '0', + displayBalance: { currency: 'USD', amount: '0.00' }, + }), + ) + .mockResolvedValueOnce(jsonResponse({ error: 'not found' }, 404)) + .mockResolvedValueOnce(jsonResponse([])) + .mockResolvedValueOnce(jsonResponse([])), + ); + + await expect( + createBrowserCloudBillingSource('https://example.test').overview(), + ).rejects.toThrow(`/organizations/${ORGANIZATION_ID}/billing/offers: 404`); + }); + + it('reports a missing billing profile without throwing an opaque portal error', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValueOnce(jsonResponse({ error: 'not found' }, 404)), + ); + + await expect( + createBrowserCloudBillingSource('https://example.test').portal(), + ).resolves.toBeNull(); + }); +}); diff --git a/packages/client/workbench/src/cloud/billing-source.ts b/packages/client/workbench/src/cloud/billing-source.ts new file mode 100644 index 000000000..8b5038f57 --- /dev/null +++ b/packages/client/workbench/src/cloud/billing-source.ts @@ -0,0 +1,124 @@ +import { z } from 'zod'; +import type { CloudBillingOverview, CloudBillingSource } from './billing'; + +const organizationSchema = z.object({ id: z.uuid(), name: z.string(), slug: z.string() }); +const moneyDisplaySchema = z.object({ currency: z.string(), amount: z.string() }); +const summarySchema = z.object({ + denomination: z.literal('nano_usd'), + availableAmount: z.string(), + reservedAmount: z.string(), + displayBalance: z.object({ currency: z.literal('USD'), amount: z.string() }), +}); +const offersSchema = z.array( + z.object({ + offerVersionId: z.uuid(), + code: z.string(), + name: z.string(), + description: z.string().nullable(), + price: z.object({ currency: z.string(), amountMinor: z.string(), display: moneyDisplaySchema }), + credits: z.object({ + denomination: z.literal('nano_usd'), + amount: z.string(), + display: moneyDisplaySchema, + expiresAfterSeconds: z.number().nullable(), + }), + }), +); +const topUpOptionsSchema = z.array( + z.object({ + offerVersionId: z.uuid(), + code: z.string(), + name: z.string(), + description: z.string().nullable(), + currency: z.literal('usd'), + minimumFaceAmountMinor: z.string(), + minimumDisplay: moneyDisplaySchema, + processingFeeBasisPoints: z.number(), + processingFeeRefundable: z.literal(false), + creditExpiresAfterSeconds: z.number().nullable(), + }), +); +const ordersSchema = z.array( + z.object({ + id: z.uuid(), + currency: z.string(), + priceAmountMinor: z.string(), + creditFaceAmountMinor: z.string().nullable(), + refundedAmountMinor: z.string(), + status: z.enum([ + 'pending', + 'paid', + 'payment_failed', + 'expired', + 'partially_refunded', + 'refunded', + ]), + createdAt: z.iso.datetime(), + }), +); +const checkoutSchema = z.object({ checkoutUrl: z.url() }); +const portalSchema = z.object({ portalUrl: z.url() }); +const roleSchema = z.object({ role: z.string() }); + +/** Browser implementation: the ambient Better Auth cookie authenticates every request. */ +export function createBrowserCloudBillingSource(apiUrl: string): CloudBillingSource { + const call = (path: string, init?: RequestInit): Promise => + fetch(`${apiUrl}${path}`, { + ...init, + credentials: 'include', + headers: { 'content-type': 'application/json', ...init?.headers }, + }); + const json = async (path: string, schema: z.ZodType, init?: RequestInit): Promise => { + const response = await call(path, init); + if (!response.ok) throw new Error(`cloud billing ${path}: ${response.status}`); + return schema.parse(await response.json()); + }; + return { + async overview(): Promise { + const organization = await json('/organizations/default', organizationSchema, { + method: 'POST', + }); + await json('/auth/organization/set-active', organizationSchema, { + method: 'POST', + body: JSON.stringify({ organizationId: organization.id }), + }); + const role = await json('/auth/organization/get-active-member-role', roleSchema); + const base = `/organizations/${organization.id}/billing`; + const [summary, offers, topUpOptions, orders] = await Promise.all([ + json(`${base}/summary`, summarySchema), + json(`${base}/offers`, offersSchema), + json(`${base}/top-up-options`, topUpOptionsSchema), + json(`${base}/orders`, ordersSchema), + ]); + return { + organization: { ...organization, role: role.role }, + summary, + offers, + topUpOptions, + orders, + }; + }, + async checkout(input): Promise { + const result = await json( + `/organizations/${input.organizationId}/billing/checkout-sessions`, + checkoutSchema, + { + method: 'POST', + headers: { 'idempotency-key': crypto.randomUUID() }, + body: JSON.stringify({ + offerVersionId: input.offerVersionId, + currency: 'usd', + creditFaceAmountMinor: input.creditFaceAmountMinor, + }), + }, + ); + return result.checkoutUrl; + }, + async portal(): Promise { + const response = await call('/organizations/portal-sessions', { method: 'POST' }); + if (response.status === 404) return null; + if (!response.ok) throw new Error(`cloud billing portal: ${response.status}`); + return portalSchema.parse(await response.json()).portalUrl; + }, + }; +} diff --git a/packages/client/workbench/src/cloud/billing.tsx b/packages/client/workbench/src/cloud/billing.tsx new file mode 100644 index 000000000..9d2d9a6de --- /dev/null +++ b/packages/client/workbench/src/cloud/billing.tsx @@ -0,0 +1,148 @@ +import { noop } from 'foxact/noop'; +import { createContext, use, useCallback } from 'react'; +import type { SWRResponse } from 'swr'; +import useSWR, { useSWRConfig } from 'swr'; + +export interface CloudBillingOrganization { + id: string; + name: string; + slug: string; + role: string; +} + +export interface CloudBillingSummary { + denomination: 'nano_usd'; + availableAmount: string; + reservedAmount: string; + displayBalance: { currency: 'USD'; amount: string }; +} + +export interface CloudBillingOffer { + offerVersionId: string; + code: string; + name: string; + description: string | null; + price: { + currency: string; + amountMinor: string; + display: { currency: string; amount: string }; + }; + credits: { + denomination: 'nano_usd'; + amount: string; + display: { currency: string; amount: string }; + expiresAfterSeconds: number | null; + }; +} + +export interface CloudBillingTopUpOption { + offerVersionId: string; + code: string; + name: string; + description: string | null; + currency: 'usd'; + minimumFaceAmountMinor: string; + minimumDisplay: { currency: string; amount: string }; + processingFeeBasisPoints: number; + processingFeeRefundable: false; + creditExpiresAfterSeconds: number | null; +} + +export type CloudBillingOrderStatus = + | 'pending' + | 'paid' + | 'payment_failed' + | 'expired' + | 'partially_refunded' + | 'refunded'; + +export interface CloudBillingOrder { + id: string; + currency: string; + priceAmountMinor: string; + creditFaceAmountMinor: string | null; + refundedAmountMinor: string; + status: CloudBillingOrderStatus; + createdAt: string; +} + +export interface CloudBillingOverview { + organization: CloudBillingOrganization; + summary: CloudBillingSummary; + offers: CloudBillingOffer[]; + topUpOptions: CloudBillingTopUpOption[]; + orders: CloudBillingOrder[]; +} + +export interface CloudBillingCheckoutInput { + organizationId: string; + offerVersionId: string; + creditFaceAmountMinor?: string; +} + +export interface CloudBillingSource { + overview: () => Promise; + checkout: (input: CloudBillingCheckoutInput) => Promise; + portal: () => Promise; +} + +const CloudBillingSourceContext = createContext(null); + +export function CloudBillingProvider({ + source, + children, +}: { + source: CloudBillingSource; + children?: React.ReactNode; +}): React.ReactNode { + return {children}; +} + +const BILLING_OVERVIEW_KEY = 'cloud/billing/overview'; + +export function useCloudBilling( + accountKey: string | null | undefined, +): SWRResponse { + const source = use(CloudBillingSourceContext); + return useSWR( + accountKey && source ? [BILLING_OVERVIEW_KEY, accountKey] : null, + source ? source.overview : null, + { revalidateOnFocus: true }, + ); +} + +export interface CloudBillingActions { + checkout: (input: CloudBillingCheckoutInput) => Promise; + portal: () => Promise; +} + +export function useCloudBillingActions(): CloudBillingActions | null { + const source = use(CloudBillingSourceContext); + const { mutate } = useSWRConfig(); + const invalidate = useCallback( + () => mutate((key) => Array.isArray(key) && key[0] === BILLING_OVERVIEW_KEY), + [mutate], + ); + const checkout = useCallback( + async (input: CloudBillingCheckoutInput) => { + if (!source) throw new Error('CloudBillingProvider missing'); + const url = await source.checkout(input); + invalidate().catch(noop); + return url; + }, + [source, invalidate], + ); + const portal = useCallback(async () => { + if (!source) throw new Error('CloudBillingProvider missing'); + return source.portal(); + }, [source]); + + return source ? { checkout, portal } : null; +} + +export function canManageCloudBilling(role: string): boolean { + return role.split(',').some((value) => { + const normalized = value.trim(); + return normalized === 'owner' || normalized === 'admin'; + }); +} diff --git a/packages/client/workbench/src/cloud/index.ts b/packages/client/workbench/src/cloud/index.ts index e42b196fc..fb423c0ef 100644 --- a/packages/client/workbench/src/cloud/index.ts +++ b/packages/client/workbench/src/cloud/index.ts @@ -1,3 +1,5 @@ +export * from './billing'; +export * from './billing-source'; export * from './hosts'; export * from './im'; export * from './im-source'; From 24f8dc4bfc6e9df9f2239846f2d02c78ba4b353c Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Mon, 3 Aug 2026 14:48:34 +0000 Subject: [PATCH 3/7] feat(ui): add billing settings panel Amp-Thread-ID: https://ampcode.com/threads/T-019fc7db-6741-721b-9ebb-bed19e2bb6e3 --- .../ui/src/shell/billing-settings-panel.tsx | 327 ++++++++++++++++++ packages/presentation/ui/src/shell/index.ts | 1 + 2 files changed, 328 insertions(+) create mode 100644 packages/presentation/ui/src/shell/billing-settings-panel.tsx diff --git a/packages/presentation/ui/src/shell/billing-settings-panel.tsx b/packages/presentation/ui/src/shell/billing-settings-panel.tsx new file mode 100644 index 000000000..d47287ed4 --- /dev/null +++ b/packages/presentation/ui/src/shell/billing-settings-panel.tsx @@ -0,0 +1,327 @@ +import { Badge } from 'coss-ui/components/badge'; +import { Button } from 'coss-ui/components/button'; +import { Card, CardHeader, CardPanel, CardTitle } from 'coss-ui/components/card'; +import { Field, FieldDescription, FieldLabel } from 'coss-ui/components/field'; +import { Input } from 'coss-ui/components/input'; +import { Skeleton } from 'coss-ui/components/skeleton'; +import { extractErrorMessage } from 'foxts/extract-error-message'; +import { ExternalLinkIcon } from 'lucide-react'; +import { useState } from 'react'; +import { useFormatter, useTranslations } from 'use-intl'; + +export interface BillingSettingsDataView { + organizationName: string; + canManage: boolean; + availableNanoUsd: string; + reservedNanoUsd: string; + offers: Array<{ + id: string; + name: string; + description: string | null; + price: { currency: string; amount: string }; + credits: { currency: string; amount: string }; + }>; + topUpOptions: Array<{ + id: string; + name: string; + description: string | null; + minimumAmountMinor: string; + minimum: { currency: string; amount: string }; + feeBasisPoints: number; + }>; + orders: Array<{ + id: string; + currency: string; + amountMinor: string; + refundedAmountMinor: string; + status: 'pending' | 'paid' | 'payment_failed' | 'expired' | 'partially_refunded' | 'refunded'; + createdAt: string; + }>; +} + +export interface BillingSettingsPanelProps { + signedIn: boolean; + onSignIn?: () => void; + data: BillingSettingsDataView | undefined; + error: string | null; + onCheckout: (offerId: string, amountMinor?: string) => Promise; + onManageSubscription: () => Promise; +} + +const RE_DOLLAR_AMOUNT = /^(\d{1,6})(?:\.(\d{0,2}))?$/; + +export function BillingSettingsPanel({ + signedIn, + onSignIn, + data, + error, + onCheckout, + onManageSubscription, +}: BillingSettingsPanelProps): React.ReactNode { + const t = useTranslations('settings.billing'); + const [pending, setPending] = useState(null); + const [actionError, setActionError] = useState(null); + const [portalUnavailable, setPortalUnavailable] = useState(false); + + if (!signedIn) { + return ( +
    +

    {t('signedOut')}

    + {onSignIn && ( + + )} +
    + ); + } + if (error !== null && data === undefined) { + return

    {t('loadError', { message: error })}

    ; + } + if (data === undefined) return ; + + const run = (key: string, action: () => Promise): void => { + setPending(key); + setActionError(null); + void action() + .catch((error_: unknown) => setActionError(error_)) + .finally(() => setPending(null)); + }; + + return ( +
    +

    + {t('organizationHint', { organization: data.organizationName })} +

    + + {!data.canManage && ( +

    + {t('purchaseForbidden')} +

    + )} +
    +

    {t('creditPacks')}

    + {data.offers.length === 0 ? ( +

    {t('offersEmpty')}

    + ) : ( +
    + {data.offers.map((offer) => ( + + + {offer.name} +

    + {offer.description ?? t('creditsIncluded', offer.credits)} +

    +
    + +
    + {offer.price.currency} {offer.price.amount} +
    + +
    +
    + ))} +
    + )} +
    + {data.topUpOptions.map((option) => ( + run(option.id, () => onCheckout(option.id, amount))} + /> + ))} +
    +

    {t('subscription')}

    + + +

    {t('subscriptionUnavailable')}

    + +
    +
    + {portalUnavailable && ( +

    {t('portalUnavailable')}

    + )} +
    + + {actionError != null && ( +

    + {t('actionError', { message: extractErrorMessage(actionError, false) ?? '' })} +

    + )} +
    + ); +} + +function BalanceCard({ + availableNanoUsd, + reservedNanoUsd, +}: { + availableNanoUsd: string; + reservedNanoUsd: string; +}): React.ReactNode { + const t = useTranslations('settings.billing'); + const format = useFormatter(); + return ( + + +
    +
    {t('available')}
    +
    + {format.number(nanoUsdToRoundedUsd(availableNanoUsd), { + style: 'currency', + currency: 'USD', + })} +
    +
    +
    +
    {t('reserved')}
    +
    + {format.number(nanoUsdToRoundedUsd(reservedNanoUsd), { + style: 'currency', + currency: 'USD', + })} +
    +
    +

    {t('reservedHint')}

    +
    +
    + ); +} + +function VariableTopUp({ + option, + canManage, + pending, + onSubmit, +}: { + option: BillingSettingsDataView['topUpOptions'][number]; + canManage: boolean; + pending: boolean; + onSubmit: (amountMinor: string) => void; +}): React.ReactNode { + const t = useTranslations('settings.billing'); + const [amount, setAmount] = useState(option.minimum.amount); + const amountMinor = dollarsToMinor(amount); + const valid = amountMinor !== null && BigInt(amountMinor) >= BigInt(option.minimumAmountMinor); + return ( +
    +

    {option.name}

    + + {t('topUpAmount')} +
    + setAmount(event.target.value)} + /> + +
    + + {t('topUpHint', { + minimum: `${option.minimum.currency} ${option.minimum.amount}`, + fee: option.feeBasisPoints / 100, + })} + +
    +
    + ); +} + +function Orders({ orders }: { orders: BillingSettingsDataView['orders'] }): React.ReactNode { + const t = useTranslations('settings.billing'); + const format = useFormatter(); + return ( +
    +

    {t('orders')}

    + {orders.length === 0 ? ( +

    {t('ordersEmpty')}

    + ) : ( +
      + {orders.map((order) => ( +
    • +
      +
      + {format.number(Number(order.amountMinor) / 100, { + style: 'currency', + currency: order.currency.toUpperCase(), + })} +
      +
      + {format.dateTime(new Date(order.createdAt), { dateStyle: 'medium' })} + {order.refundedAmountMinor !== '0' && + ` · ${t('refundedAmount', { + amount: format.number(Number(order.refundedAmountMinor) / 100, { + style: 'currency', + currency: order.currency.toUpperCase(), + }), + })}`} +
      +
      + + {t(`status.${order.status}`)} + +
    • + ))} +
    + )} +
    + ); +} + +function BillingSkeleton(): React.ReactNode { + return ( +
    + + + + +
    + ); +} + +function dollarsToMinor(value: string): string | null { + const match = RE_DOLLAR_AMOUNT.exec(value.trim()); + if (!match) return null; + const minor = BigInt(match[1]) * 100n + BigInt((match.at(2) ?? '').padEnd(2, '0') || '0'); + return minor > 0n ? String(minor) : null; +} + +function nanoUsdToRoundedUsd(value: string): number { + const cents = (BigInt(value) + 5_000_000n) / 10_000_000n; + return Number(cents) / 100; +} diff --git a/packages/presentation/ui/src/shell/index.ts b/packages/presentation/ui/src/shell/index.ts index d3b9d312f..5069038c9 100644 --- a/packages/presentation/ui/src/shell/index.ts +++ b/packages/presentation/ui/src/shell/index.ts @@ -2,6 +2,7 @@ export * from './agent-efforts'; export * from './agent-models'; export * from './agent-onboarding-card'; export * from './appearance-settings-panel'; +export * from './billing-settings-panel'; export * from './command-palette'; export * from './composer'; export * from './composer-attachments'; From 7989842e132bc812584af2129dc4d50627de09bb Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Mon, 3 Aug 2026 14:48:53 +0000 Subject: [PATCH 4/7] feat(desktop): integrate cloud billing Amp-Thread-ID: https://ampcode.com/threads/T-019fc7db-6741-721b-9ebb-bed19e2bb6e3 --- apps/desktop/src/main/cloud-auth/billing.ts | 149 ++++++++++++++++++ apps/desktop/src/main/cloud-auth/client.ts | 2 + apps/desktop/src/main/index.ts | 2 + apps/desktop/src/preload/index.ts | 8 + apps/desktop/src/renderer/src/app.tsx | 47 +++--- .../src/renderer/src/cloud-auth/bridges.ts | 11 +- .../src/renderer/src/settings/billing-tab.tsx | 85 ++++++++++ .../renderer/src/settings/settings-view.tsx | 12 ++ .../src/renderer/src/settings/store.ts | 1 + .../src/renderer/src/shell/desktop-shell.tsx | 6 + .../src/shell/desktop-workbench-shell.tsx | 1 + apps/desktop/src/shared/cloud.ts | 5 + packages/client/workbench/src/cloud/hosts.tsx | 2 +- packages/client/workbench/src/cloud/im.tsx | 2 +- .../client/workbench/src/settings/search.ts | 9 ++ .../ui/src/shell/session-sidebar.tsx | 23 +++ .../presentation/ui/src/shell/shell-frame.tsx | 5 +- 17 files changed, 346 insertions(+), 24 deletions(-) create mode 100644 apps/desktop/src/main/cloud-auth/billing.ts create mode 100644 apps/desktop/src/renderer/src/settings/billing-tab.tsx diff --git a/apps/desktop/src/main/cloud-auth/billing.ts b/apps/desktop/src/main/cloud-auth/billing.ts new file mode 100644 index 000000000..1e76d9dc8 --- /dev/null +++ b/apps/desktop/src/main/cloud-auth/billing.ts @@ -0,0 +1,149 @@ +import { randomUUID } from 'node:crypto'; +import type { CloudBillingOverview } from '@linkcode/workbench'; +import { ipcMain } from 'electron'; +import { z } from 'zod'; +import { + CLOUD_BILLING_CHECKOUT_CHANNEL, + CLOUD_BILLING_OVERVIEW_CHANNEL, + CLOUD_BILLING_PORTAL_CHANNEL, +} from '../../shared/cloud'; +import { authClient, CLOUD_API_URL } from './client'; + +const organizationSchema = z.object({ id: z.uuid(), name: z.string(), slug: z.string() }); +const moneyDisplaySchema = z.object({ currency: z.string(), amount: z.string() }); +const summarySchema = z.object({ + denomination: z.literal('nano_usd'), + availableAmount: z.string(), + reservedAmount: z.string(), + displayBalance: z.object({ currency: z.literal('USD'), amount: z.string() }), +}); +const offersSchema = z.array( + z.object({ + offerVersionId: z.uuid(), + code: z.string(), + name: z.string(), + description: z.string().nullable(), + price: z.object({ currency: z.string(), amountMinor: z.string(), display: moneyDisplaySchema }), + credits: z.object({ + denomination: z.literal('nano_usd'), + amount: z.string(), + display: moneyDisplaySchema, + expiresAfterSeconds: z.number().nullable(), + }), + }), +); +const topUpOptionsSchema = z.array( + z.object({ + offerVersionId: z.uuid(), + code: z.string(), + name: z.string(), + description: z.string().nullable(), + currency: z.literal('usd'), + minimumFaceAmountMinor: z.string(), + minimumDisplay: moneyDisplaySchema, + processingFeeBasisPoints: z.number(), + processingFeeRefundable: z.literal(false), + creditExpiresAfterSeconds: z.number().nullable(), + }), +); +const ordersSchema = z.array( + z.object({ + id: z.uuid(), + currency: z.string(), + priceAmountMinor: z.string(), + creditFaceAmountMinor: z.string().nullable(), + refundedAmountMinor: z.string(), + status: z.enum([ + 'pending', + 'paid', + 'payment_failed', + 'expired', + 'partially_refunded', + 'refunded', + ]), + createdAt: z.iso.datetime(), + }), +); +const checkoutInputSchema = z.object({ + organizationId: z.uuid(), + offerVersionId: z.uuid(), + creditFaceAmountMinor: z + .string() + .regex(/^[1-9]\d{0,7}$/) + .optional(), +}); +const checkoutSchema = z.object({ checkoutUrl: z.url() }); +const portalSchema = z.object({ portalUrl: z.url() }); + +function cloudFetch(path: string, init?: RequestInit): Promise { + return fetch(`${CLOUD_API_URL}${path}`, { + ...init, + headers: { ...init?.headers, cookie: authClient.getCookie() }, + }); +} + +async function cloudJson(path: string, schema: z.ZodType, init?: RequestInit): Promise { + const response = await cloudFetch(path, init); + if (!response.ok) throw new Error(`cloud billing ${path}: ${response.status}`); + return schema.parse(await response.json()); +} + +async function getBillingOverview(): Promise { + const organization = await cloudJson('/organizations/default', organizationSchema, { + method: 'POST', + }); + const active = await authClient.organization.setActive({ organizationId: organization.id }); + if (active.error) throw new Error(`activate billing organization: ${active.error.status}`); + const memberRole = await authClient.organization.getActiveMemberRole(); + if (memberRole.error || !memberRole.data) { + throw new Error(`get billing role: ${memberRole.error?.status ?? 'empty response'}`); + } + const base = `/organizations/${organization.id}/billing`; + const [summary, offers, topUpOptions, orders] = await Promise.all([ + cloudJson(`${base}/summary`, summarySchema), + cloudJson(`${base}/offers`, offersSchema), + cloudJson(`${base}/top-up-options`, topUpOptionsSchema), + cloudJson(`${base}/orders`, ordersSchema), + ]); + return { + organization: { ...organization, role: memberRole.data.role }, + summary, + offers, + topUpOptions, + orders, + }; +} + +async function createCheckout(input: z.infer): Promise { + const result = await cloudJson( + `/organizations/${input.organizationId}/billing/checkout-sessions`, + checkoutSchema, + { + method: 'POST', + headers: { 'content-type': 'application/json', 'idempotency-key': randomUUID() }, + body: JSON.stringify({ + offerVersionId: input.offerVersionId, + currency: 'usd', + creditFaceAmountMinor: input.creditFaceAmountMinor, + }), + }, + ); + return result.checkoutUrl; +} + +async function createPortal(): Promise { + const response = await cloudFetch('/organizations/portal-sessions', { + method: 'POST', + }); + if (response.status === 404) return null; + if (!response.ok) throw new Error(`cloud billing portal: ${response.status}`); + return portalSchema.parse(await response.json()).portalUrl; +} + +export function registerCloudBillingBridge(): void { + ipcMain.handle(CLOUD_BILLING_OVERVIEW_CHANNEL, () => getBillingOverview()); + ipcMain.handle(CLOUD_BILLING_CHECKOUT_CHANNEL, (_event, input: unknown) => + createCheckout(checkoutInputSchema.parse(input)), + ); + ipcMain.handle(CLOUD_BILLING_PORTAL_CHANNEL, () => createPortal()); +} diff --git a/apps/desktop/src/main/cloud-auth/client.ts b/apps/desktop/src/main/cloud-auth/client.ts index 45e955ce5..50a0b856c 100644 --- a/apps/desktop/src/main/cloud-auth/client.ts +++ b/apps/desktop/src/main/cloud-auth/client.ts @@ -1,6 +1,7 @@ import { resolve } from 'node:path'; import { electronClient } from '@better-auth/electron/client'; import { createAuthClient } from 'better-auth/client'; +import { organizationClient } from 'better-auth/client/plugins'; import { app, BrowserWindow, ipcMain } from 'electron'; import { CLOUD_CLAIM_DEEP_LINK_CHANNEL } from '../../shared/cloud'; import { CHANNEL } from '../constants'; @@ -33,6 +34,7 @@ export const authClient = createAuthClient({ // The API mounts better-auth at /auth, not the client default /api/auth. basePath: '/auth', plugins: [ + organizationClient(), electronClient({ signInURL: CLOUD_SIGN_IN_URL, protocol: { scheme: CLOUD_AUTH_SCHEME }, diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index f0eefc1fd..fd1a23e3c 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -10,6 +10,7 @@ import * as Sentry from '@sentry/electron/main'; import { app, BrowserWindow, Menu } from 'electron'; import { DESKTOP_SPAN_NAMES, DESKTOP_TRANSACTION_NAMES } from '../sentry-privacy'; import { applyThemePreference } from './appearance'; +import { registerCloudBillingBridge } from './cloud-auth/billing'; import { setupCloudAuth } from './cloud-auth/client'; import { registerCloudImBridge } from './cloud-auth/im'; import { registerCloudTunnelBridge } from './cloud-auth/tunnel'; @@ -59,6 +60,7 @@ if (app.requestSingleInstanceLock()) { // alongside the rest of the cloud wiring; ipcMain.handle is safe before the app is ready. registerCloudTunnelBridge(); registerCloudImBridge(); + registerCloudBillingBridge(); app .whenReady() diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index b826a19a9..77b6af9f8 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -2,6 +2,9 @@ import { setupRenderer } from '@better-auth/electron/preload'; import { createElectronSystemBridge } from '@linkcode/ipc/electron-renderer'; import { contextBridge, ipcRenderer } from 'electron'; import { + CLOUD_BILLING_CHECKOUT_CHANNEL, + CLOUD_BILLING_OVERVIEW_CHANNEL, + CLOUD_BILLING_PORTAL_CHANNEL, CLOUD_CLAIM_DEEP_LINK_CHANNEL, CLOUD_IM_BINDINGS_CHANNEL, CLOUD_IM_CREATE_BINDING_CHANNEL, @@ -33,6 +36,11 @@ setupRenderer(); contextBridge.exposeInMainWorld('linkcodeCloud', { listHosts: () => ipcRenderer.invoke(CLOUD_LIST_HOSTS_CHANNEL), claimDeepLink: () => ipcRenderer.invoke(CLOUD_CLAIM_DEEP_LINK_CHANNEL), + billing: { + overview: () => ipcRenderer.invoke(CLOUD_BILLING_OVERVIEW_CHANNEL), + checkout: (input: unknown) => ipcRenderer.invoke(CLOUD_BILLING_CHECKOUT_CHANNEL, input), + portal: () => ipcRenderer.invoke(CLOUD_BILLING_PORTAL_CHANNEL), + }, im: { overview: () => ipcRenderer.invoke(CLOUD_IM_OVERVIEW_CHANNEL), bindings: () => ipcRenderer.invoke(CLOUD_IM_BINDINGS_CHANNEL), diff --git a/apps/desktop/src/renderer/src/app.tsx b/apps/desktop/src/renderer/src/app.tsx index 09c2f6aff..d870d94b7 100644 --- a/apps/desktop/src/renderer/src/app.tsx +++ b/apps/desktop/src/renderer/src/app.tsx @@ -1,5 +1,6 @@ import type { CloudHost } from '@linkcode/workbench'; import { + CloudBillingProvider, CloudHostsProvider, CloudImProvider, ConnectionState, @@ -10,6 +11,7 @@ import { WorkbenchAppProviders, WorkbenchProviders, } from '@linkcode/workbench'; +import { ComposeContextProvider } from 'foxact/compose-context-provider'; import { useEffect } from 'foxact/use-abortable-effect'; import { useState } from 'react'; import { DesktopAutomationsView } from './automations/automations-view'; @@ -25,6 +27,7 @@ import { DesktopWorkbenchShell } from './shell/desktop-workbench-shell'; /** Cloud data comes from main (it holds the keychain session); see preload's bridge. */ const listCloudHosts = (): Promise => cloudDataBridge.listHosts(); +const cloudBillingSource = cloudDataBridge.billing; // The preload bridge implements CloudImSource verbatim; hand it to the provider as-is. const cloudImSource = cloudDataBridge.im; @@ -35,27 +38,31 @@ export function DesktopApp(): React.ReactNode { return ( - - - : null} - fallback={} - > - - - - - {/* Gated: Automations lists schedules over the data plane, so it mounts inside the gate. */} - {automationsOpen ? : null} - - {/* Window controls live above the connection gate and the settings overlay so Windows/Linux + , + , + , + ]} + > + : null} + fallback={} + > + + + + + {/* Gated: Automations lists schedules over the data plane, so it mounts inside the gate. */} + {automationsOpen ? : null} + + {/* Window controls live above the connection gate and the settings overlay so Windows/Linux can always minimize/maximize/close — including while the daemon is connecting or down. */} - - - + + ); } diff --git a/apps/desktop/src/renderer/src/cloud-auth/bridges.ts b/apps/desktop/src/renderer/src/cloud-auth/bridges.ts index 08788e9e2..2a50206aa 100644 --- a/apps/desktop/src/renderer/src/cloud-auth/bridges.ts +++ b/apps/desktop/src/renderer/src/cloud-auth/bridges.ts @@ -4,7 +4,7 @@ * process's inferred types across the process boundary, so the vendor's stable shape is mirrored here. */ -import type { CloudHost, CloudImSource } from '@linkcode/workbench'; +import type { CloudBillingSource, CloudHost, CloudImSource } from '@linkcode/workbench'; import { traceRendererIpc } from '../ipc'; /** The authenticated user, as normalized by the electron plugin. Extra IdP fields are preserved. */ @@ -36,6 +36,8 @@ export interface CloudDataBridges { * called right before a sign-in. Resolves to whether the OS accepted it. */ claimDeepLink: () => Promise; + /** Organization billing management; main attaches the keychain-backed session. */ + billing: CloudBillingSource; /** IM Channel management (`/im/*`); same session-in-main model as listHosts. */ im: CloudImSource; }; @@ -47,6 +49,13 @@ const cloudSource = window.linkcodeCloud; export const cloudDataBridge: CloudDataBridges['linkcodeCloud'] = { listHosts: () => traceRendererIpc('cloud.list-hosts', () => cloudSource.listHosts()), claimDeepLink: () => traceRendererIpc('cloud.claim-deep-link', () => cloudSource.claimDeepLink()), + billing: { + overview: () => + traceRendererIpc('cloud.billing.overview', () => cloudSource.billing.overview()), + checkout: (input) => + traceRendererIpc('cloud.billing.checkout', () => cloudSource.billing.checkout(input)), + portal: () => traceRendererIpc('cloud.billing.portal', () => cloudSource.billing.portal()), + }, im: { overview: () => traceRendererIpc('cloud.im.overview', () => cloudSource.im.overview()), bindings: () => traceRendererIpc('cloud.im.bindings', () => cloudSource.im.bindings()), diff --git a/apps/desktop/src/renderer/src/settings/billing-tab.tsx b/apps/desktop/src/renderer/src/settings/billing-tab.tsx new file mode 100644 index 000000000..63a1bba9e --- /dev/null +++ b/apps/desktop/src/renderer/src/settings/billing-tab.tsx @@ -0,0 +1,85 @@ +import type { BillingSettingsDataView } from '@linkcode/ui'; +import { BillingSettingsPanel } from '@linkcode/ui'; +import { + canManageCloudBilling, + useCloudBilling, + useCloudBillingActions, +} from '@linkcode/workbench'; +import { extractErrorMessage } from 'foxts/extract-error-message'; +import { useCloudAccount } from '../cloud-auth/use-cloud-account'; + +export function BillingTab(): React.ReactNode { + const cloudAuth = useCloudAccount(); + const billing = useCloudBilling(cloudAuth.account?.email ?? null); + const actions = useCloudBillingActions(); + const data = billing.data ? toView(billing.data) : undefined; + + const checkout = async ( + offerVersionId: string, + creditFaceAmountMinor?: string, + ): Promise => { + if (!actions || !billing.data) throw new Error('cloud billing source missing'); + const url = await actions.checkout({ + organizationId: billing.data.organization.id, + offerVersionId, + creditFaceAmountMinor, + }); + window.open(url, '_blank', 'noopener,noreferrer'); + }; + const manageSubscription = async (): Promise => { + if (!actions) throw new Error('cloud billing source missing'); + const url = await actions.portal(); + if (url === null) return false; + window.open(url, '_blank', 'noopener,noreferrer'); + return true; + }; + + return ( + + ); +} + +function toView( + data: NonNullable['data']>, +): BillingSettingsDataView { + return { + organizationName: data.organization.name, + canManage: canManageCloudBilling(data.organization.role), + availableNanoUsd: data.summary.availableAmount, + reservedNanoUsd: data.summary.reservedAmount, + offers: data.offers.map((offer) => ({ + id: offer.offerVersionId, + name: offer.name, + description: offer.description, + price: offer.price.display, + credits: offer.credits.display, + })), + topUpOptions: data.topUpOptions.map((option) => ({ + id: option.offerVersionId, + name: option.name, + description: option.description, + minimumAmountMinor: option.minimumFaceAmountMinor, + minimum: option.minimumDisplay, + feeBasisPoints: option.processingFeeBasisPoints, + })), + orders: data.orders.map((order) => ({ + id: order.id, + currency: order.currency, + amountMinor: order.priceAmountMinor, + refundedAmountMinor: order.refundedAmountMinor, + status: order.status, + createdAt: order.createdAt, + })), + }; +} diff --git a/apps/desktop/src/renderer/src/settings/settings-view.tsx b/apps/desktop/src/renderer/src/settings/settings-view.tsx index 6d71ada49..a3c836802 100644 --- a/apps/desktop/src/renderer/src/settings/settings-view.tsx +++ b/apps/desktop/src/renderer/src/settings/settings-view.tsx @@ -18,6 +18,7 @@ import { BellIcon, BotIcon, CodeXmlIcon, + CreditCardIcon, HistoryIcon, InfoIcon, KeyRoundIcon, @@ -37,6 +38,7 @@ import { DEFAULT_LAYOUT } from '../shell/store/model'; import { AboutTab } from './about-tab'; import { AgentsTab } from './agents-tab'; import { AppearanceTab } from './appearance-tab'; +import { BillingTab } from './billing-tab'; import { DeveloperTab } from './developer-tab'; import { GeneralTab } from './general-tab'; import { HistoryImportTab } from './history-import-tab'; @@ -149,6 +151,14 @@ export function SettingsView(): React.ReactNode { active: category === 'providers', onClick: () => setCategory('providers'), }, + { + key: 'billing', + icon: , + label: t('tabs.billing'), + keywords: searchKeywords.billing, + active: category === 'billing', + onClick: () => setCategory('billing'), + }, { key: 'plugins', icon: , @@ -309,6 +319,8 @@ function renderSettingsPanel( return ; case 'providers': return ; + case 'billing': + return ; case 'plugins': return ; case 'agents': diff --git a/apps/desktop/src/renderer/src/settings/store.ts b/apps/desktop/src/renderer/src/settings/store.ts index 453d989e1..0667751a8 100644 --- a/apps/desktop/src/renderer/src/settings/store.ts +++ b/apps/desktop/src/renderer/src/settings/store.ts @@ -11,6 +11,7 @@ export type SettingsCategory = | 'developer' | 'notifications' | 'about' + | 'billing' | 'providers' | 'agents' | 'imChannel' diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index 671db1750..12d1245eb 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -28,6 +28,7 @@ import { suppressSimulatorAutoReveal, TerminalPanel, useBrowserHostRegistration, + useCloudBilling, useCloudHosts, useResourcesPanelStore, useSelectedHostStore, @@ -122,11 +123,13 @@ export function DesktopShell({ BranchStatusComponent, onDismissError, onOpenSettings, + onOpenBilling, onImportHistory, themeType, }: WorkbenchShellProps & { systemBridge: SystemBridge; onOpenSettings?: () => void; + onOpenBilling?: () => void; /** Opens the desktop settings overlay on the History import category. */ onImportHistory?: () => void; themeType: ThemePreference; @@ -171,6 +174,7 @@ export function DesktopShell({ const setResourcesOpen = useResourcesPanelStore((state) => state.setOpen); const toggleResources = useResourcesPanelStore((state) => state.toggle); const remoteHosts = useCloudHosts(cloudAuth.account?.email ?? null); + const billing = useCloudBilling(cloudAuth.account?.email ?? null); const { selectedHostId, selectHost } = useSelectedHostStore( useShallow((state) => ({ selectedHostId: state.selectedHostId, selectHost: state.selectHost })), ); @@ -776,6 +780,8 @@ export function DesktopShell({ onSignIn={cloudAuth.signIn} onSignOut={cloudAuth.signOut} onManageAccount={cloudAuth.manageAccount} + balance={billing.data?.summary.displayBalance} + onOpenBilling={onOpenBilling} remoteHosts={remoteHostItems} remoteHostsLoading={remoteHosts.isLoading} selectedHostId={selectedHostId} diff --git a/apps/desktop/src/renderer/src/shell/desktop-workbench-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-workbench-shell.tsx index 4505e2a15..8630b9f5f 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-workbench-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-workbench-shell.tsx @@ -12,6 +12,7 @@ export function DesktopWorkbenchShell({ header, ...props }: WorkbenchShellProps) systemBridge={systemBridge} header={header} onOpenSettings={() => openDesktopSettings()} + onOpenBilling={() => openDesktopSettings('billing')} onOpenProviderSettings={() => { useProvidersSettingsStore.getState().startAdd(); openDesktopSettings('providers'); diff --git a/apps/desktop/src/shared/cloud.ts b/apps/desktop/src/shared/cloud.ts index 6d07e4dc8..4666fb514 100644 --- a/apps/desktop/src/shared/cloud.ts +++ b/apps/desktop/src/shared/cloud.ts @@ -8,6 +8,11 @@ export const CLOUD_LIST_HOSTS_CHANNEL = 'linkcode.cloud.list-hosts'; // renderer invokes it right before a sign-in so the OAuth deep-link callback comes back here. export const CLOUD_CLAIM_DEEP_LINK_CHANNEL = 'linkcode.cloud.claim-deep-link'; +// Billing management (`/organizations/*/billing/*` on the cloud API). +export const CLOUD_BILLING_OVERVIEW_CHANNEL = 'linkcode.cloud.billing.overview'; +export const CLOUD_BILLING_CHECKOUT_CHANNEL = 'linkcode.cloud.billing.checkout'; +export const CLOUD_BILLING_PORTAL_CHANNEL = 'linkcode.cloud.billing.portal'; + // IM Channel management (`/im/*` on the cloud API). export const CLOUD_IM_OVERVIEW_CHANNEL = 'linkcode.cloud.im.overview'; export const CLOUD_IM_BINDINGS_CHANNEL = 'linkcode.cloud.im.bindings'; diff --git a/packages/client/workbench/src/cloud/hosts.tsx b/packages/client/workbench/src/cloud/hosts.tsx index cc8b6223e..9e01bb68e 100644 --- a/packages/client/workbench/src/cloud/hosts.tsx +++ b/packages/client/workbench/src/cloud/hosts.tsx @@ -14,7 +14,7 @@ export function CloudHostsProvider({ children, }: { source: CloudHostsSource; - children: React.ReactNode; + children?: React.ReactNode; }): React.ReactNode { return {children}; } diff --git a/packages/client/workbench/src/cloud/im.tsx b/packages/client/workbench/src/cloud/im.tsx index 2dba2001b..4bc610f35 100644 --- a/packages/client/workbench/src/cloud/im.tsx +++ b/packages/client/workbench/src/cloud/im.tsx @@ -86,7 +86,7 @@ export function CloudImProvider({ children, }: { source: CloudImSource; - children: React.ReactNode; + children?: React.ReactNode; }): React.ReactNode { return {children}; } diff --git a/packages/client/workbench/src/settings/search.ts b/packages/client/workbench/src/settings/search.ts index feaad84c2..fe5715500 100644 --- a/packages/client/workbench/src/settings/search.ts +++ b/packages/client/workbench/src/settings/search.ts @@ -26,6 +26,7 @@ export interface SettingsSearchKeywords { about: readonly string[]; agents: readonly string[]; providers: readonly string[]; + billing: readonly string[]; plugins: readonly string[]; imChannel: readonly string[]; historyImport: readonly string[]; @@ -91,6 +92,14 @@ export function useSettingsSearchKeywords(): SettingsSearchKeywords { t('providers.accountModel'), ...PROVIDER_SERVICES.map((service) => t(`providers.serviceName.${service}`)), ], + billing: [ + t('billing.available'), + t('billing.reserved'), + t('billing.creditPacks'), + t('billing.topUpAmount'), + t('billing.orders'), + t('billing.subscription'), + ], imChannel: [t('imChannel.connectTitle'), t('imChannel.bindings'), t('imChannel.autoMirror')], plugins: [ t('plugins.title'), diff --git a/packages/presentation/ui/src/shell/session-sidebar.tsx b/packages/presentation/ui/src/shell/session-sidebar.tsx index 97c18b28e..0dbb04312 100644 --- a/packages/presentation/ui/src/shell/session-sidebar.tsx +++ b/packages/presentation/ui/src/shell/session-sidebar.tsx @@ -28,6 +28,7 @@ import { SettingsIcon, ShieldIcon, SparklesIcon, + WalletCardsIcon, } from 'lucide-react'; import { useRef } from 'react'; import { useTranslations } from 'use-intl'; @@ -221,6 +222,8 @@ export function HostFooter({ onSignIn, onSignOut, onManageAccount, + balance, + onOpenBilling, remoteHosts, remoteHostsLoading = false, selectedHostId, @@ -238,6 +241,9 @@ export function HostFooter({ onSignOut?: () => void; /** Opens the IdP account center (profile/avatar) in the system browser; shown when signed in. */ onManageAccount?: () => void; + /** Available Cloud balance, displayed persistently beside the signed-in account. */ + balance?: { currency: string; amount: string }; + onOpenBilling?: () => void; /** The account's online hosts (Remote access); undefined until first load. Requires `account`. */ remoteHosts?: RemoteHostItem[]; /** First load of the host list — the Remote access area shows a checking hint, not "no hosts". */ @@ -270,6 +276,23 @@ export function HostFooter({ } />
    + {account && balance && ( + + + {balance.currency} {balance.amount} + + } + /> + )} {account ? ( void; /** Platform-formatted hint next to the Search entry, e.g. `⌘K`. */ searchShortcut?: string; + /** App-owned cloud/system footer; the default shows only local host state. */ + sidebarFooter?: React.ReactNode; TerminalBlockComponent?: React.ComponentType<{ terminalId: string }>; onDismissError?: () => void; } @@ -162,6 +164,7 @@ export function ShellFrame({ onOpenSearch, onOpenAutomations, searchShortcut, + sidebarFooter, TerminalBlockComponent, BranchStatusComponent, onDismissError, @@ -179,7 +182,7 @@ export function ShellFrame({ activeId={active?.sessionId ?? null} pinnedSessionIds={pinnedSessionIds} collapsedSections={collapsedSections} - footer={} + footer={sidebarFooter ?? } onSelect={onSelectSession} onClose={onCloseSession} onToggleSessionPinned={onToggleSessionPinned} From b43c7189d207d7167eb46c3ff05d880850bf2d6b Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Mon, 3 Aug 2026 14:49:06 +0000 Subject: [PATCH 5/7] feat(webview): integrate cloud billing Amp-Thread-ID: https://ampcode.com/threads/T-019fc7db-6741-721b-9ebb-bed19e2bb6e3 --- apps/webview/src/router.tsx | 2 + apps/webview/src/routes/root-layout.tsx | 13 ++- apps/webview/src/routes/settings/billing.tsx | 96 +++++++++++++++++++ .../src/routes/settings/settings-layout.tsx | 11 +++ .../webview/src/shell/web-workbench-shell.tsx | 49 +++++++++- 5 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 apps/webview/src/routes/settings/billing.tsx diff --git a/apps/webview/src/router.tsx b/apps/webview/src/router.tsx index bfccf3899..4130d2953 100644 --- a/apps/webview/src/router.tsx +++ b/apps/webview/src/router.tsx @@ -2,6 +2,7 @@ import { AutomationsRoute } from '@webview/routes/automations'; import { RootLayout } from '@webview/routes/root-layout'; import { AgentsSettings } from '@webview/routes/settings/agents'; import { AppearanceSettings } from '@webview/routes/settings/appearance'; +import { BillingSettings } from '@webview/routes/settings/billing'; import { DeveloperSettings } from '@webview/routes/settings/developer'; import { GeneralSettings } from '@webview/routes/settings/general'; import { MessagingSettings } from '@webview/routes/settings/messaging'; @@ -33,6 +34,7 @@ export function createWebviewRouter( { path: 'developer', element: }, { path: 'notifications', element: }, { path: 'providers', element: }, + { path: 'billing', element: }, { path: 'plugins', element: }, { path: 'agents', element: }, { path: 'messaging', element: }, diff --git a/apps/webview/src/routes/root-layout.tsx b/apps/webview/src/routes/root-layout.tsx index 4b3497346..9fd02baf1 100644 --- a/apps/webview/src/routes/root-layout.tsx +++ b/apps/webview/src/routes/root-layout.tsx @@ -1,5 +1,7 @@ import { + CloudBillingProvider, CloudImProvider, + createBrowserCloudBillingSource, createBrowserCloudImSource, SessionNotifier, syncProductAnalyticsIdentity, @@ -10,9 +12,11 @@ import { authClient, CLOUD_API_URL } from '@webview/cloud/auth'; import { webviewDaemonConnectionSource } from '@webview/daemon-connection-source'; import { presentWebNotification } from '@webview/notifications'; import { useSettingsStore } from '@webview/settings/store'; +import { ComposeContextProvider } from 'foxact/compose-context-provider'; import { Outlet } from 'react-router'; /** Browser shell: the credential is the shared session cookie, so the source is plain fetch. */ +const cloudBillingSource = createBrowserCloudBillingSource(CLOUD_API_URL); const cloudImSource = createBrowserCloudImSource(CLOUD_API_URL); interface AnalyticsSessionState { data: { user: { id: string } } | null; @@ -39,14 +43,19 @@ export function RootLayout(): React.ReactNode { return ( - + , + , + ]} + > {/* Persistent across route changes, so background notifications keep arriving while the user is on a non-workbench route such as Settings. */} - + ); } diff --git a/apps/webview/src/routes/settings/billing.tsx b/apps/webview/src/routes/settings/billing.tsx new file mode 100644 index 000000000..450c6913a --- /dev/null +++ b/apps/webview/src/routes/settings/billing.tsx @@ -0,0 +1,96 @@ +import type { BillingSettingsDataView } from '@linkcode/ui'; +import { BillingSettingsPanel } from '@linkcode/ui'; +import { + canManageCloudBilling, + useCloudBilling, + useCloudBillingActions, +} from '@linkcode/workbench'; +import { authClient, signInWithCloud } from '@webview/cloud/auth'; +import { extractErrorMessage } from 'foxts/extract-error-message'; + +export function BillingSettings(): React.ReactNode { + const session = authClient.useSession(); + const billing = useCloudBilling(session.data?.user.email ?? null); + const actions = useCloudBillingActions(); + const data = billing.data ? toView(billing.data) : undefined; + + const checkout = async ( + offerVersionId: string, + creditFaceAmountMinor?: string, + ): Promise => { + if (!actions || !billing.data) throw new Error('cloud billing source missing'); + const pendingTab = window.open('about:blank', '_blank'); + try { + const url = await actions.checkout({ + organizationId: billing.data.organization.id, + offerVersionId, + creditFaceAmountMinor, + }); + if (pendingTab) { + pendingTab.opener = null; + pendingTab.location.assign(url); + } else { + window.open(url, '_blank', 'noopener,noreferrer'); + } + } catch (error) { + pendingTab?.close(); + throw error; + } + }; + const manageSubscription = async (): Promise => { + if (!actions) throw new Error('cloud billing source missing'); + const url = await actions.portal(); + if (url === null) return false; + window.open(url, '_blank', 'noopener,noreferrer'); + return true; + }; + + return ( + + ); +} + +function toView( + data: NonNullable['data']>, +): BillingSettingsDataView { + return { + organizationName: data.organization.name, + canManage: canManageCloudBilling(data.organization.role), + availableNanoUsd: data.summary.availableAmount, + reservedNanoUsd: data.summary.reservedAmount, + offers: data.offers.map((offer) => ({ + id: offer.offerVersionId, + name: offer.name, + description: offer.description, + price: offer.price.display, + credits: offer.credits.display, + })), + topUpOptions: data.topUpOptions.map((option) => ({ + id: option.offerVersionId, + name: option.name, + description: option.description, + minimumAmountMinor: option.minimumFaceAmountMinor, + minimum: option.minimumDisplay, + feeBasisPoints: option.processingFeeBasisPoints, + })), + orders: data.orders.map((order) => ({ + id: order.id, + currency: order.currency, + amountMinor: order.priceAmountMinor, + refundedAmountMinor: order.refundedAmountMinor, + status: order.status, + createdAt: order.createdAt, + })), + }; +} diff --git a/apps/webview/src/routes/settings/settings-layout.tsx b/apps/webview/src/routes/settings/settings-layout.tsx index 4f022b84e..057c1bbaf 100644 --- a/apps/webview/src/routes/settings/settings-layout.tsx +++ b/apps/webview/src/routes/settings/settings-layout.tsx @@ -4,6 +4,7 @@ import { BellIcon, BotIcon, CodeXmlIcon, + CreditCardIcon, KeyRoundIcon, PuzzleIcon, SendIcon, @@ -22,6 +23,7 @@ const SETTINGS_ROUTES: Record = { notifications: '/settings/notifications', agents: '/settings/agents', providers: '/settings/providers', + billing: '/settings/billing', plugins: '/settings/plugins', messaging: '/settings/messaging', developer: '/settings/developer', @@ -94,6 +96,14 @@ export function SettingsLayout(): React.ReactNode { active: isActive(pathname, 'providers'), render: , }, + { + key: 'billing', + icon: , + label: t('tabs.billing'), + keywords: searchKeywords.billing, + active: isActive(pathname, 'billing'), + render: , + }, { key: 'plugins', icon: , @@ -172,6 +182,7 @@ function isActive( | 'terminal' | 'developer' | 'notifications' + | 'billing' | 'providers' | 'plugins' | 'agents' diff --git a/apps/webview/src/shell/web-workbench-shell.tsx b/apps/webview/src/shell/web-workbench-shell.tsx index f05cf0701..f81d348f4 100644 --- a/apps/webview/src/shell/web-workbench-shell.tsx +++ b/apps/webview/src/shell/web-workbench-shell.tsx @@ -1,17 +1,27 @@ -import { ErrorBadge, ShellFrame, ShellIconButton, ThreadTitle, TitleStrip } from '@linkcode/ui'; +import { + ErrorBadge, + HostFooter, + ShellFrame, + ShellIconButton, + ThreadTitle, + TitleStrip, +} from '@linkcode/ui'; import type { WorkbenchShellProps } from '@linkcode/workbench'; import { getResourcesPanelPresentation, RESOURCES_FLOATING_COLUMN_WIDTH, RESOURCES_FLOATING_MIN_WORKSPACE_WIDTH, + useCloudBilling, useProvidersSettingsStore, useResourcesPanelStore, WorkspaceServicesMenu, } from '@linkcode/workbench'; +import { authClient, signInWithCloud } from '@webview/cloud/auth'; import { Button } from 'coss-ui/components/button'; import { Card } from 'coss-ui/components/card'; import { Popover, PopoverPopup, PopoverTrigger } from 'coss-ui/components/popover'; import { useMediaQuery } from 'coss-ui/hooks/use-media-query'; +import { noop } from 'foxact/noop'; import { ChevronLeftIcon, ChevronRightIcon, Settings2Icon, SettingsIcon } from 'lucide-react'; import { Link, useNavigate } from 'react-router'; import { useTranslations } from 'use-intl'; @@ -58,6 +68,16 @@ export function WebWorkbenchShell({
    { + void navigate('/settings/billing'); + }} + onOpenSettings={() => { + void navigate('/settings'); + }} + /> + } showPlanInPromptDock={!resourcesSurfaceOpen} onOpenProviderSettings={() => { useProvidersSettingsStore.getState().startAdd(); @@ -156,3 +176,30 @@ export function WebWorkbenchShell({
    ); } + +function WebCloudFooter({ + onOpenBilling, + onOpenSettings, +}: { + onOpenBilling: () => void; + onOpenSettings: () => void; +}): React.ReactNode { + const session = authClient.useSession(); + const account = session.data?.user ?? null; + const billing = useCloudBilling(account?.email ?? null); + return ( + { + authClient.signOut().catch(noop); + }} + onManageAccount={() => + window.open('https://auth.arcbox.dev/account', '_blank', 'noopener,noreferrer') + } + balance={billing.data?.summary.displayBalance} + onOpenBilling={onOpenBilling} + onOpenSettings={onOpenSettings} + /> + ); +} From c9819db1cd914c814673108d95bba8fa1414b809 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Tue, 4 Aug 2026 04:45:49 +0000 Subject: [PATCH 6/7] fix(cloud): hand billing off to hosted console Amp-Thread-ID: https://ampcode.com/threads/T-019fc7db-6741-721b-9ebb-bed19e2bb6e3 --- apps/desktop/package.json | 1 + .../__tests__/cloud-hosted-billing.test.ts | 59 ++++ apps/desktop/src/main/cloud-auth/billing.ts | 149 -------- apps/desktop/src/main/cloud-auth/client.ts | 32 +- apps/desktop/src/main/index.ts | 2 - apps/desktop/src/preload/index.ts | 10 +- apps/desktop/src/renderer/src/app.tsx | 47 ++- .../src/renderer/src/cloud-auth/bridges.ts | 15 +- .../src/renderer/src/settings/billing-tab.tsx | 81 +---- .../src/renderer/src/shell/desktop-shell.tsx | 6 - .../src/shell/desktop-workbench-shell.tsx | 1 - apps/desktop/src/shared/cloud.ts | 6 +- apps/desktop/vite.main.config.ts | 3 +- apps/webview/e2e/browser-smoke.e2e.mts | 4 +- apps/webview/package.json | 1 + apps/webview/src/routes/root-layout.tsx | 13 +- .../settings/__tests__/billing.test.tsx | 43 +++ apps/webview/src/routes/settings/billing.tsx | 94 +---- .../webview/src/shell/web-workbench-shell.tsx | 49 +-- .../src/cloud/__tests__/billing.test.ts | 64 ---- .../workbench/src/cloud/billing-source.ts | 124 ------- .../client/workbench/src/cloud/billing.tsx | 148 -------- packages/client/workbench/src/cloud/hosts.tsx | 2 +- packages/client/workbench/src/cloud/im.tsx | 2 +- packages/client/workbench/src/cloud/index.ts | 2 - .../client/workbench/src/settings/search.ts | 9 +- packages/presentation/i18n/src/locales/en.ts | 39 +-- .../presentation/i18n/src/locales/zh-cn.ts | 35 +- .../__tests__/billing-settings-panel.test.tsx | 27 ++ .../ui/src/shell/billing-settings-panel.tsx | 322 +----------------- .../ui/src/shell/session-sidebar.tsx | 23 -- .../presentation/ui/src/shell/shell-frame.tsx | 5 +- packages/vendor/linkcode-cloud-0.1.0.tgz | Bin 0 -> 3869 bytes pnpm-lock.yaml | 12 + 34 files changed, 233 insertions(+), 1197 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/cloud-hosted-billing.test.ts delete mode 100644 apps/desktop/src/main/cloud-auth/billing.ts create mode 100644 apps/webview/src/routes/settings/__tests__/billing.test.tsx delete mode 100644 packages/client/workbench/src/cloud/__tests__/billing.test.ts delete mode 100644 packages/client/workbench/src/cloud/billing-source.ts delete mode 100644 packages/client/workbench/src/cloud/billing.tsx create mode 100644 packages/presentation/ui/src/shell/__tests__/billing-settings-panel.test.tsx create mode 100644 packages/vendor/linkcode-cloud-0.1.0.tgz diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7c941a002..bec21579f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -31,6 +31,7 @@ "author": "ArcBox Labs ", "dependencies": { "@better-auth/electron": "1.7.0-rc.2", + "@linkcode/cloud": "file:../../packages/vendor/linkcode-cloud-0.1.0.tgz", "@linkcode/common": "workspace:*", "@linkcode/daemon": "workspace:*", "@linkcode/ipc": "workspace:*", diff --git a/apps/desktop/src/main/__tests__/cloud-hosted-billing.test.ts b/apps/desktop/src/main/__tests__/cloud-hosted-billing.test.ts new file mode 100644 index 000000000..36c442c81 --- /dev/null +++ b/apps/desktop/src/main/__tests__/cloud-hosted-billing.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + handlers: new Map unknown>(), + openExternal: vi.fn(), + setAsDefaultProtocolClient: vi.fn(), + setupMain: vi.fn(), +})); + +vi.mock('@better-auth/electron/client', () => ({ + electronClient: () => ({}), +})); + +vi.mock('better-auth/client', () => ({ + createAuthClient: () => ({ setupMain: mocks.setupMain }), +})); + +vi.mock('electron', () => ({ + app: { + commandLine: { + getSwitchValue: () => '', + hasSwitch: () => false, + }, + isPackaged: false, + setAsDefaultProtocolClient: mocks.setAsDefaultProtocolClient, + }, + BrowserWindow: { getAllWindows: () => [] }, + dialog: { showErrorBox: vi.fn() }, + ipcMain: { + handle: (channel: string, handler: () => unknown) => mocks.handlers.set(channel, handler), + }, + shell: { openExternal: mocks.openExternal }, +})); + +vi.mock('../cloud-auth/storage', () => ({ + createSafeStorage: () => ({}), +})); + +describe('desktop hosted billing handoff', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.handlers.clear(); + mocks.setAsDefaultProtocolClient.mockReturnValue(true); + mocks.openExternal.mockResolvedValue(undefined); + }); + + it('opens the SDK URL with a channel-specific native return target', async () => { + const { setupCloudAuth } = await import('../cloud-auth/client'); + const { CLOUD_OPEN_HOSTED_BILLING_CHANNEL } = await import('../../shared/cloud'); + setupCloudAuth(); + + await mocks.handlers.get(CLOUD_OPEN_HOSTED_BILLING_CHANNEL)?.(); + + expect(mocks.setAsDefaultProtocolClient).toHaveBeenCalled(); + expect(mocks.openExternal).toHaveBeenCalledWith( + 'https://console.linkcode.ai/billing?returnTarget=linkcode-dev%3A%2F%2Fbilling%2Freturn', + ); + }); +}); diff --git a/apps/desktop/src/main/cloud-auth/billing.ts b/apps/desktop/src/main/cloud-auth/billing.ts deleted file mode 100644 index 1e76d9dc8..000000000 --- a/apps/desktop/src/main/cloud-auth/billing.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import type { CloudBillingOverview } from '@linkcode/workbench'; -import { ipcMain } from 'electron'; -import { z } from 'zod'; -import { - CLOUD_BILLING_CHECKOUT_CHANNEL, - CLOUD_BILLING_OVERVIEW_CHANNEL, - CLOUD_BILLING_PORTAL_CHANNEL, -} from '../../shared/cloud'; -import { authClient, CLOUD_API_URL } from './client'; - -const organizationSchema = z.object({ id: z.uuid(), name: z.string(), slug: z.string() }); -const moneyDisplaySchema = z.object({ currency: z.string(), amount: z.string() }); -const summarySchema = z.object({ - denomination: z.literal('nano_usd'), - availableAmount: z.string(), - reservedAmount: z.string(), - displayBalance: z.object({ currency: z.literal('USD'), amount: z.string() }), -}); -const offersSchema = z.array( - z.object({ - offerVersionId: z.uuid(), - code: z.string(), - name: z.string(), - description: z.string().nullable(), - price: z.object({ currency: z.string(), amountMinor: z.string(), display: moneyDisplaySchema }), - credits: z.object({ - denomination: z.literal('nano_usd'), - amount: z.string(), - display: moneyDisplaySchema, - expiresAfterSeconds: z.number().nullable(), - }), - }), -); -const topUpOptionsSchema = z.array( - z.object({ - offerVersionId: z.uuid(), - code: z.string(), - name: z.string(), - description: z.string().nullable(), - currency: z.literal('usd'), - minimumFaceAmountMinor: z.string(), - minimumDisplay: moneyDisplaySchema, - processingFeeBasisPoints: z.number(), - processingFeeRefundable: z.literal(false), - creditExpiresAfterSeconds: z.number().nullable(), - }), -); -const ordersSchema = z.array( - z.object({ - id: z.uuid(), - currency: z.string(), - priceAmountMinor: z.string(), - creditFaceAmountMinor: z.string().nullable(), - refundedAmountMinor: z.string(), - status: z.enum([ - 'pending', - 'paid', - 'payment_failed', - 'expired', - 'partially_refunded', - 'refunded', - ]), - createdAt: z.iso.datetime(), - }), -); -const checkoutInputSchema = z.object({ - organizationId: z.uuid(), - offerVersionId: z.uuid(), - creditFaceAmountMinor: z - .string() - .regex(/^[1-9]\d{0,7}$/) - .optional(), -}); -const checkoutSchema = z.object({ checkoutUrl: z.url() }); -const portalSchema = z.object({ portalUrl: z.url() }); - -function cloudFetch(path: string, init?: RequestInit): Promise { - return fetch(`${CLOUD_API_URL}${path}`, { - ...init, - headers: { ...init?.headers, cookie: authClient.getCookie() }, - }); -} - -async function cloudJson(path: string, schema: z.ZodType, init?: RequestInit): Promise { - const response = await cloudFetch(path, init); - if (!response.ok) throw new Error(`cloud billing ${path}: ${response.status}`); - return schema.parse(await response.json()); -} - -async function getBillingOverview(): Promise { - const organization = await cloudJson('/organizations/default', organizationSchema, { - method: 'POST', - }); - const active = await authClient.organization.setActive({ organizationId: organization.id }); - if (active.error) throw new Error(`activate billing organization: ${active.error.status}`); - const memberRole = await authClient.organization.getActiveMemberRole(); - if (memberRole.error || !memberRole.data) { - throw new Error(`get billing role: ${memberRole.error?.status ?? 'empty response'}`); - } - const base = `/organizations/${organization.id}/billing`; - const [summary, offers, topUpOptions, orders] = await Promise.all([ - cloudJson(`${base}/summary`, summarySchema), - cloudJson(`${base}/offers`, offersSchema), - cloudJson(`${base}/top-up-options`, topUpOptionsSchema), - cloudJson(`${base}/orders`, ordersSchema), - ]); - return { - organization: { ...organization, role: memberRole.data.role }, - summary, - offers, - topUpOptions, - orders, - }; -} - -async function createCheckout(input: z.infer): Promise { - const result = await cloudJson( - `/organizations/${input.organizationId}/billing/checkout-sessions`, - checkoutSchema, - { - method: 'POST', - headers: { 'content-type': 'application/json', 'idempotency-key': randomUUID() }, - body: JSON.stringify({ - offerVersionId: input.offerVersionId, - currency: 'usd', - creditFaceAmountMinor: input.creditFaceAmountMinor, - }), - }, - ); - return result.checkoutUrl; -} - -async function createPortal(): Promise { - const response = await cloudFetch('/organizations/portal-sessions', { - method: 'POST', - }); - if (response.status === 404) return null; - if (!response.ok) throw new Error(`cloud billing portal: ${response.status}`); - return portalSchema.parse(await response.json()).portalUrl; -} - -export function registerCloudBillingBridge(): void { - ipcMain.handle(CLOUD_BILLING_OVERVIEW_CHANNEL, () => getBillingOverview()); - ipcMain.handle(CLOUD_BILLING_CHECKOUT_CHANNEL, (_event, input: unknown) => - createCheckout(checkoutInputSchema.parse(input)), - ); - ipcMain.handle(CLOUD_BILLING_PORTAL_CHANNEL, () => createPortal()); -} diff --git a/apps/desktop/src/main/cloud-auth/client.ts b/apps/desktop/src/main/cloud-auth/client.ts index 50a0b856c..3d20658e3 100644 --- a/apps/desktop/src/main/cloud-auth/client.ts +++ b/apps/desktop/src/main/cloud-auth/client.ts @@ -1,9 +1,12 @@ import { resolve } from 'node:path'; import { electronClient } from '@better-auth/electron/client'; +import { createHostedBillingUrl } from '@linkcode/cloud'; import { createAuthClient } from 'better-auth/client'; -import { organizationClient } from 'better-auth/client/plugins'; -import { app, BrowserWindow, ipcMain } from 'electron'; -import { CLOUD_CLAIM_DEEP_LINK_CHANNEL } from '../../shared/cloud'; +import { app, BrowserWindow, ipcMain, shell } from 'electron'; +import { + CLOUD_CLAIM_DEEP_LINK_CHANNEL, + CLOUD_OPEN_HOSTED_BILLING_CHANNEL, +} from '../../shared/cloud'; import { CHANNEL } from '../constants'; import { createSafeStorage } from './storage'; @@ -34,7 +37,6 @@ export const authClient = createAuthClient({ // The API mounts better-auth at /auth, not the client default /api/auth. basePath: '/auth', plugins: [ - organizationClient(), electronClient({ signInURL: CLOUD_SIGN_IN_URL, protocol: { scheme: CLOUD_AUTH_SCHEME }, @@ -48,6 +50,14 @@ export const authClient = createAuthClient({ export type CloudAuthClient = typeof authClient; +function claimDeepLink(): boolean { + return process.defaultApp && typeof process.argv[1] === 'string' + ? app.setAsDefaultProtocolClient(CLOUD_AUTH_SCHEME, process.execPath, [ + resolve(process.argv[1]), + ]) + : app.setAsDefaultProtocolClient(CLOUD_AUTH_SCHEME); +} + /** * Wire the auth client into main. Once a config object is passed, every feature must be opted into * explicitly: `scheme` = protocol + deep-link handlers, `bridges` = the IPC handlers the preload @@ -63,11 +73,11 @@ export function setupCloudAuth(): void { // Re-assert this app as the scheme's OS default right before sign-in, so the callback routes to // THIS running app even if another instance registered the scheme after startup. Mirrors the // plugin's own registration (dev shells must pass execPath + entry argv, packaged builds don't). - ipcMain.handle(CLOUD_CLAIM_DEEP_LINK_CHANNEL, () => - process.defaultApp && typeof process.argv[1] === 'string' - ? app.setAsDefaultProtocolClient(CLOUD_AUTH_SCHEME, process.execPath, [ - resolve(process.argv[1]), - ]) - : app.setAsDefaultProtocolClient(CLOUD_AUTH_SCHEME), - ); + ipcMain.handle(CLOUD_CLAIM_DEEP_LINK_CHANNEL, claimDeepLink); + ipcMain.handle(CLOUD_OPEN_HOSTED_BILLING_CHANNEL, async () => { + claimDeepLink(); + await shell.openExternal( + createHostedBillingUrl({ returnTarget: `${CLOUD_AUTH_SCHEME}://billing/return` }), + ); + }); } diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index fd1a23e3c..f0eefc1fd 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -10,7 +10,6 @@ import * as Sentry from '@sentry/electron/main'; import { app, BrowserWindow, Menu } from 'electron'; import { DESKTOP_SPAN_NAMES, DESKTOP_TRANSACTION_NAMES } from '../sentry-privacy'; import { applyThemePreference } from './appearance'; -import { registerCloudBillingBridge } from './cloud-auth/billing'; import { setupCloudAuth } from './cloud-auth/client'; import { registerCloudImBridge } from './cloud-auth/im'; import { registerCloudTunnelBridge } from './cloud-auth/tunnel'; @@ -60,7 +59,6 @@ if (app.requestSingleInstanceLock()) { // alongside the rest of the cloud wiring; ipcMain.handle is safe before the app is ready. registerCloudTunnelBridge(); registerCloudImBridge(); - registerCloudBillingBridge(); app .whenReady() diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 77b6af9f8..9ce6ccc53 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -2,9 +2,6 @@ import { setupRenderer } from '@better-auth/electron/preload'; import { createElectronSystemBridge } from '@linkcode/ipc/electron-renderer'; import { contextBridge, ipcRenderer } from 'electron'; import { - CLOUD_BILLING_CHECKOUT_CHANNEL, - CLOUD_BILLING_OVERVIEW_CHANNEL, - CLOUD_BILLING_PORTAL_CHANNEL, CLOUD_CLAIM_DEEP_LINK_CHANNEL, CLOUD_IM_BINDINGS_CHANNEL, CLOUD_IM_CREATE_BINDING_CHANNEL, @@ -16,6 +13,7 @@ import { CLOUD_IM_UNLINK_TELEGRAM_CHANNEL, CLOUD_IM_UPDATE_BINDING_CHANNEL, CLOUD_LIST_HOSTS_CHANNEL, + CLOUD_OPEN_HOSTED_BILLING_CHANNEL, } from '../shared/cloud'; /** @@ -36,11 +34,7 @@ setupRenderer(); contextBridge.exposeInMainWorld('linkcodeCloud', { listHosts: () => ipcRenderer.invoke(CLOUD_LIST_HOSTS_CHANNEL), claimDeepLink: () => ipcRenderer.invoke(CLOUD_CLAIM_DEEP_LINK_CHANNEL), - billing: { - overview: () => ipcRenderer.invoke(CLOUD_BILLING_OVERVIEW_CHANNEL), - checkout: (input: unknown) => ipcRenderer.invoke(CLOUD_BILLING_CHECKOUT_CHANNEL, input), - portal: () => ipcRenderer.invoke(CLOUD_BILLING_PORTAL_CHANNEL), - }, + openHostedBilling: () => ipcRenderer.invoke(CLOUD_OPEN_HOSTED_BILLING_CHANNEL), im: { overview: () => ipcRenderer.invoke(CLOUD_IM_OVERVIEW_CHANNEL), bindings: () => ipcRenderer.invoke(CLOUD_IM_BINDINGS_CHANNEL), diff --git a/apps/desktop/src/renderer/src/app.tsx b/apps/desktop/src/renderer/src/app.tsx index d870d94b7..09c2f6aff 100644 --- a/apps/desktop/src/renderer/src/app.tsx +++ b/apps/desktop/src/renderer/src/app.tsx @@ -1,6 +1,5 @@ import type { CloudHost } from '@linkcode/workbench'; import { - CloudBillingProvider, CloudHostsProvider, CloudImProvider, ConnectionState, @@ -11,7 +10,6 @@ import { WorkbenchAppProviders, WorkbenchProviders, } from '@linkcode/workbench'; -import { ComposeContextProvider } from 'foxact/compose-context-provider'; import { useEffect } from 'foxact/use-abortable-effect'; import { useState } from 'react'; import { DesktopAutomationsView } from './automations/automations-view'; @@ -27,7 +25,6 @@ import { DesktopWorkbenchShell } from './shell/desktop-workbench-shell'; /** Cloud data comes from main (it holds the keychain session); see preload's bridge. */ const listCloudHosts = (): Promise => cloudDataBridge.listHosts(); -const cloudBillingSource = cloudDataBridge.billing; // The preload bridge implements CloudImSource verbatim; hand it to the provider as-is. const cloudImSource = cloudDataBridge.im; @@ -38,31 +35,27 @@ export function DesktopApp(): React.ReactNode { return ( - , - , - , - ]} - > - : null} - fallback={} - > - - - - - {/* Gated: Automations lists schedules over the data plane, so it mounts inside the gate. */} - {automationsOpen ? : null} - - {/* Window controls live above the connection gate and the settings overlay so Windows/Linux + + + : null} + fallback={} + > + + + + + {/* Gated: Automations lists schedules over the data plane, so it mounts inside the gate. */} + {automationsOpen ? : null} + + {/* Window controls live above the connection gate and the settings overlay so Windows/Linux can always minimize/maximize/close — including while the daemon is connecting or down. */} - - + + + ); } diff --git a/apps/desktop/src/renderer/src/cloud-auth/bridges.ts b/apps/desktop/src/renderer/src/cloud-auth/bridges.ts index 2a50206aa..b14ff76aa 100644 --- a/apps/desktop/src/renderer/src/cloud-auth/bridges.ts +++ b/apps/desktop/src/renderer/src/cloud-auth/bridges.ts @@ -4,7 +4,7 @@ * process's inferred types across the process boundary, so the vendor's stable shape is mirrored here. */ -import type { CloudBillingSource, CloudHost, CloudImSource } from '@linkcode/workbench'; +import type { CloudHost, CloudImSource } from '@linkcode/workbench'; import { traceRendererIpc } from '../ipc'; /** The authenticated user, as normalized by the electron plugin. Extra IdP fields are preserved. */ @@ -36,8 +36,8 @@ export interface CloudDataBridges { * called right before a sign-in. Resolves to whether the OS accepted it. */ claimDeepLink: () => Promise; - /** Organization billing management; main attaches the keychain-backed session. */ - billing: CloudBillingSource; + /** Opens Cloud's hosted billing surface in the system browser. */ + openHostedBilling: () => Promise; /** IM Channel management (`/im/*`); same session-in-main model as listHosts. */ im: CloudImSource; }; @@ -49,13 +49,8 @@ const cloudSource = window.linkcodeCloud; export const cloudDataBridge: CloudDataBridges['linkcodeCloud'] = { listHosts: () => traceRendererIpc('cloud.list-hosts', () => cloudSource.listHosts()), claimDeepLink: () => traceRendererIpc('cloud.claim-deep-link', () => cloudSource.claimDeepLink()), - billing: { - overview: () => - traceRendererIpc('cloud.billing.overview', () => cloudSource.billing.overview()), - checkout: (input) => - traceRendererIpc('cloud.billing.checkout', () => cloudSource.billing.checkout(input)), - portal: () => traceRendererIpc('cloud.billing.portal', () => cloudSource.billing.portal()), - }, + openHostedBilling: () => + traceRendererIpc('cloud.open-hosted-billing', () => cloudSource.openHostedBilling()), im: { overview: () => traceRendererIpc('cloud.im.overview', () => cloudSource.im.overview()), bindings: () => traceRendererIpc('cloud.im.bindings', () => cloudSource.im.bindings()), diff --git a/apps/desktop/src/renderer/src/settings/billing-tab.tsx b/apps/desktop/src/renderer/src/settings/billing-tab.tsx index 63a1bba9e..eae83c9d0 100644 --- a/apps/desktop/src/renderer/src/settings/billing-tab.tsx +++ b/apps/desktop/src/renderer/src/settings/billing-tab.tsx @@ -1,85 +1,12 @@ -import type { BillingSettingsDataView } from '@linkcode/ui'; import { BillingSettingsPanel } from '@linkcode/ui'; -import { - canManageCloudBilling, - useCloudBilling, - useCloudBillingActions, -} from '@linkcode/workbench'; -import { extractErrorMessage } from 'foxts/extract-error-message'; -import { useCloudAccount } from '../cloud-auth/use-cloud-account'; +import { cloudDataBridge } from '../cloud-auth/bridges'; export function BillingTab(): React.ReactNode { - const cloudAuth = useCloudAccount(); - const billing = useCloudBilling(cloudAuth.account?.email ?? null); - const actions = useCloudBillingActions(); - const data = billing.data ? toView(billing.data) : undefined; - - const checkout = async ( - offerVersionId: string, - creditFaceAmountMinor?: string, - ): Promise => { - if (!actions || !billing.data) throw new Error('cloud billing source missing'); - const url = await actions.checkout({ - organizationId: billing.data.organization.id, - offerVersionId, - creditFaceAmountMinor, - }); - window.open(url, '_blank', 'noopener,noreferrer'); - }; - const manageSubscription = async (): Promise => { - if (!actions) throw new Error('cloud billing source missing'); - const url = await actions.portal(); - if (url === null) return false; - window.open(url, '_blank', 'noopener,noreferrer'); - return true; - }; - return ( { + void cloudDataBridge.openHostedBilling(); + }} /> ); } - -function toView( - data: NonNullable['data']>, -): BillingSettingsDataView { - return { - organizationName: data.organization.name, - canManage: canManageCloudBilling(data.organization.role), - availableNanoUsd: data.summary.availableAmount, - reservedNanoUsd: data.summary.reservedAmount, - offers: data.offers.map((offer) => ({ - id: offer.offerVersionId, - name: offer.name, - description: offer.description, - price: offer.price.display, - credits: offer.credits.display, - })), - topUpOptions: data.topUpOptions.map((option) => ({ - id: option.offerVersionId, - name: option.name, - description: option.description, - minimumAmountMinor: option.minimumFaceAmountMinor, - minimum: option.minimumDisplay, - feeBasisPoints: option.processingFeeBasisPoints, - })), - orders: data.orders.map((order) => ({ - id: order.id, - currency: order.currency, - amountMinor: order.priceAmountMinor, - refundedAmountMinor: order.refundedAmountMinor, - status: order.status, - createdAt: order.createdAt, - })), - }; -} diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index 12d1245eb..671db1750 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -28,7 +28,6 @@ import { suppressSimulatorAutoReveal, TerminalPanel, useBrowserHostRegistration, - useCloudBilling, useCloudHosts, useResourcesPanelStore, useSelectedHostStore, @@ -123,13 +122,11 @@ export function DesktopShell({ BranchStatusComponent, onDismissError, onOpenSettings, - onOpenBilling, onImportHistory, themeType, }: WorkbenchShellProps & { systemBridge: SystemBridge; onOpenSettings?: () => void; - onOpenBilling?: () => void; /** Opens the desktop settings overlay on the History import category. */ onImportHistory?: () => void; themeType: ThemePreference; @@ -174,7 +171,6 @@ export function DesktopShell({ const setResourcesOpen = useResourcesPanelStore((state) => state.setOpen); const toggleResources = useResourcesPanelStore((state) => state.toggle); const remoteHosts = useCloudHosts(cloudAuth.account?.email ?? null); - const billing = useCloudBilling(cloudAuth.account?.email ?? null); const { selectedHostId, selectHost } = useSelectedHostStore( useShallow((state) => ({ selectedHostId: state.selectedHostId, selectHost: state.selectHost })), ); @@ -780,8 +776,6 @@ export function DesktopShell({ onSignIn={cloudAuth.signIn} onSignOut={cloudAuth.signOut} onManageAccount={cloudAuth.manageAccount} - balance={billing.data?.summary.displayBalance} - onOpenBilling={onOpenBilling} remoteHosts={remoteHostItems} remoteHostsLoading={remoteHosts.isLoading} selectedHostId={selectedHostId} diff --git a/apps/desktop/src/renderer/src/shell/desktop-workbench-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-workbench-shell.tsx index 8630b9f5f..4505e2a15 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-workbench-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-workbench-shell.tsx @@ -12,7 +12,6 @@ export function DesktopWorkbenchShell({ header, ...props }: WorkbenchShellProps) systemBridge={systemBridge} header={header} onOpenSettings={() => openDesktopSettings()} - onOpenBilling={() => openDesktopSettings('billing')} onOpenProviderSettings={() => { useProvidersSettingsStore.getState().startAdd(); openDesktopSettings('providers'); diff --git a/apps/desktop/src/shared/cloud.ts b/apps/desktop/src/shared/cloud.ts index 4666fb514..63dc58d48 100644 --- a/apps/desktop/src/shared/cloud.ts +++ b/apps/desktop/src/shared/cloud.ts @@ -8,10 +8,8 @@ export const CLOUD_LIST_HOSTS_CHANNEL = 'linkcode.cloud.list-hosts'; // renderer invokes it right before a sign-in so the OAuth deep-link callback comes back here. export const CLOUD_CLAIM_DEEP_LINK_CHANNEL = 'linkcode.cloud.claim-deep-link'; -// Billing management (`/organizations/*/billing/*` on the cloud API). -export const CLOUD_BILLING_OVERVIEW_CHANNEL = 'linkcode.cloud.billing.overview'; -export const CLOUD_BILLING_CHECKOUT_CHANNEL = 'linkcode.cloud.billing.checkout'; -export const CLOUD_BILLING_PORTAL_CHANNEL = 'linkcode.cloud.billing.portal'; +// Opens the Cloud-owned billing surface; all billing and checkout state stays in the browser. +export const CLOUD_OPEN_HOSTED_BILLING_CHANNEL = 'linkcode.cloud.open-hosted-billing'; // IM Channel management (`/im/*` on the cloud API). export const CLOUD_IM_OVERVIEW_CHANNEL = 'linkcode.cloud.im.overview'; diff --git a/apps/desktop/vite.main.config.ts b/apps/desktop/vite.main.config.ts index e118d472a..a1d2e1137 100644 --- a/apps/desktop/vite.main.config.ts +++ b/apps/desktop/vite.main.config.ts @@ -23,7 +23,8 @@ export default defineConfig({ formats: ['cjs'], }, rolldownOptions: { - external: nodeExternals(), + // @linkcode/cloud is ESM-only; bundle its tiny URL builder into the CJS main process. + external: nodeExternals(['@linkcode/cloud']), output: { entryFileNames: '[name].js', assetFileNames: 'chunks/[name]-[hash][extname]', diff --git a/apps/webview/e2e/browser-smoke.e2e.mts b/apps/webview/e2e/browser-smoke.e2e.mts index 12adad857..e7fe32b8d 100644 --- a/apps/webview/e2e/browser-smoke.e2e.mts +++ b/apps/webview/e2e/browser-smoke.e2e.mts @@ -299,7 +299,9 @@ async function verifyProductionEntry(browser: Browser): Promise { await page.locator('#root > *').waitFor(); await page.getByRole('link', { name: 'Open settings' }).click(); await page.waitForURL(`${server.origin}/settings`); - await page.goto(`${server.origin}/settings`, { waitUntil: 'domcontentloaded' }); + await page.goto(`${server.origin}/settings/billing`, { waitUntil: 'domcontentloaded' }); + await page.getByText('LinkCode does not read or process billing or checkout data.').waitFor(); + await page.getByRole('button', { name: 'Manage on the web' }).waitFor(); await page.getByRole('link', { name: 'Back' }).waitFor(); await page.getByRole('link', { name: 'Back' }).click(); await page.waitForURL(`${server.origin}/`); diff --git a/apps/webview/package.json b/apps/webview/package.json index e82e5ff09..c27cbb0dc 100644 --- a/apps/webview/package.json +++ b/apps/webview/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@hookform/resolvers": "^5.5.7", + "@linkcode/cloud": "file:../../packages/vendor/linkcode-cloud-0.1.0.tgz", "@linkcode/common": "workspace:*", "@linkcode/schema": "workspace:*", "@linkcode/sdk": "workspace:*", diff --git a/apps/webview/src/routes/root-layout.tsx b/apps/webview/src/routes/root-layout.tsx index 9fd02baf1..4b3497346 100644 --- a/apps/webview/src/routes/root-layout.tsx +++ b/apps/webview/src/routes/root-layout.tsx @@ -1,7 +1,5 @@ import { - CloudBillingProvider, CloudImProvider, - createBrowserCloudBillingSource, createBrowserCloudImSource, SessionNotifier, syncProductAnalyticsIdentity, @@ -12,11 +10,9 @@ import { authClient, CLOUD_API_URL } from '@webview/cloud/auth'; import { webviewDaemonConnectionSource } from '@webview/daemon-connection-source'; import { presentWebNotification } from '@webview/notifications'; import { useSettingsStore } from '@webview/settings/store'; -import { ComposeContextProvider } from 'foxact/compose-context-provider'; import { Outlet } from 'react-router'; /** Browser shell: the credential is the shared session cookie, so the source is plain fetch. */ -const cloudBillingSource = createBrowserCloudBillingSource(CLOUD_API_URL); const cloudImSource = createBrowserCloudImSource(CLOUD_API_URL); interface AnalyticsSessionState { data: { user: { id: string } } | null; @@ -43,19 +39,14 @@ export function RootLayout(): React.ReactNode { return ( - , - , - ]} - > + {/* Persistent across route changes, so background notifications keep arriving while the user is on a non-workbench route such as Settings. */} - + ); } diff --git a/apps/webview/src/routes/settings/__tests__/billing.test.tsx b/apps/webview/src/routes/settings/__tests__/billing.test.tsx new file mode 100644 index 000000000..dd93587ed --- /dev/null +++ b/apps/webview/src/routes/settings/__tests__/billing.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + createHostedBillingUrl: vi.fn(() => 'https://console.linkcode.ai/billing'), +})); + +vi.mock('@linkcode/cloud', () => ({ + createHostedBillingUrl: mocks.createHostedBillingUrl, +})); + +vi.mock('@linkcode/ui', () => ({ + BillingSettingsPanel: ({ onOpenBilling }: { onOpenBilling: () => void }) => ( + + ), +})); + +import { BillingSettings } from '../billing'; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('web hosted billing handoff', () => { + it('opens the SDK URL without a desktop return target', () => { + const open = vi.spyOn(window, 'open').mockImplementation(() => null); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'billing' })); + + expect(mocks.createHostedBillingUrl).toHaveBeenCalledWith(); + expect(open).toHaveBeenCalledWith( + 'https://console.linkcode.ai/billing', + '_blank', + 'noopener,noreferrer', + ); + }); +}); diff --git a/apps/webview/src/routes/settings/billing.tsx b/apps/webview/src/routes/settings/billing.tsx index 450c6913a..13b10dff3 100644 --- a/apps/webview/src/routes/settings/billing.tsx +++ b/apps/webview/src/routes/settings/billing.tsx @@ -1,96 +1,14 @@ -import type { BillingSettingsDataView } from '@linkcode/ui'; +import { createHostedBillingUrl } from '@linkcode/cloud'; import { BillingSettingsPanel } from '@linkcode/ui'; -import { - canManageCloudBilling, - useCloudBilling, - useCloudBillingActions, -} from '@linkcode/workbench'; -import { authClient, signInWithCloud } from '@webview/cloud/auth'; -import { extractErrorMessage } from 'foxts/extract-error-message'; -export function BillingSettings(): React.ReactNode { - const session = authClient.useSession(); - const billing = useCloudBilling(session.data?.user.email ?? null); - const actions = useCloudBillingActions(); - const data = billing.data ? toView(billing.data) : undefined; - - const checkout = async ( - offerVersionId: string, - creditFaceAmountMinor?: string, - ): Promise => { - if (!actions || !billing.data) throw new Error('cloud billing source missing'); - const pendingTab = window.open('about:blank', '_blank'); - try { - const url = await actions.checkout({ - organizationId: billing.data.organization.id, - offerVersionId, - creditFaceAmountMinor, - }); - if (pendingTab) { - pendingTab.opener = null; - pendingTab.location.assign(url); - } else { - window.open(url, '_blank', 'noopener,noreferrer'); - } - } catch (error) { - pendingTab?.close(); - throw error; - } - }; - const manageSubscription = async (): Promise => { - if (!actions) throw new Error('cloud billing source missing'); - const url = await actions.portal(); - if (url === null) return false; - window.open(url, '_blank', 'noopener,noreferrer'); - return true; - }; +const HOSTED_BILLING_URL = createHostedBillingUrl(); +export function BillingSettings(): React.ReactNode { return ( { + window.open(HOSTED_BILLING_URL, '_blank', 'noopener,noreferrer'); + }} /> ); } - -function toView( - data: NonNullable['data']>, -): BillingSettingsDataView { - return { - organizationName: data.organization.name, - canManage: canManageCloudBilling(data.organization.role), - availableNanoUsd: data.summary.availableAmount, - reservedNanoUsd: data.summary.reservedAmount, - offers: data.offers.map((offer) => ({ - id: offer.offerVersionId, - name: offer.name, - description: offer.description, - price: offer.price.display, - credits: offer.credits.display, - })), - topUpOptions: data.topUpOptions.map((option) => ({ - id: option.offerVersionId, - name: option.name, - description: option.description, - minimumAmountMinor: option.minimumFaceAmountMinor, - minimum: option.minimumDisplay, - feeBasisPoints: option.processingFeeBasisPoints, - })), - orders: data.orders.map((order) => ({ - id: order.id, - currency: order.currency, - amountMinor: order.priceAmountMinor, - refundedAmountMinor: order.refundedAmountMinor, - status: order.status, - createdAt: order.createdAt, - })), - }; -} diff --git a/apps/webview/src/shell/web-workbench-shell.tsx b/apps/webview/src/shell/web-workbench-shell.tsx index f81d348f4..f05cf0701 100644 --- a/apps/webview/src/shell/web-workbench-shell.tsx +++ b/apps/webview/src/shell/web-workbench-shell.tsx @@ -1,27 +1,17 @@ -import { - ErrorBadge, - HostFooter, - ShellFrame, - ShellIconButton, - ThreadTitle, - TitleStrip, -} from '@linkcode/ui'; +import { ErrorBadge, ShellFrame, ShellIconButton, ThreadTitle, TitleStrip } from '@linkcode/ui'; import type { WorkbenchShellProps } from '@linkcode/workbench'; import { getResourcesPanelPresentation, RESOURCES_FLOATING_COLUMN_WIDTH, RESOURCES_FLOATING_MIN_WORKSPACE_WIDTH, - useCloudBilling, useProvidersSettingsStore, useResourcesPanelStore, WorkspaceServicesMenu, } from '@linkcode/workbench'; -import { authClient, signInWithCloud } from '@webview/cloud/auth'; import { Button } from 'coss-ui/components/button'; import { Card } from 'coss-ui/components/card'; import { Popover, PopoverPopup, PopoverTrigger } from 'coss-ui/components/popover'; import { useMediaQuery } from 'coss-ui/hooks/use-media-query'; -import { noop } from 'foxact/noop'; import { ChevronLeftIcon, ChevronRightIcon, Settings2Icon, SettingsIcon } from 'lucide-react'; import { Link, useNavigate } from 'react-router'; import { useTranslations } from 'use-intl'; @@ -68,16 +58,6 @@ export function WebWorkbenchShell({
    { - void navigate('/settings/billing'); - }} - onOpenSettings={() => { - void navigate('/settings'); - }} - /> - } showPlanInPromptDock={!resourcesSurfaceOpen} onOpenProviderSettings={() => { useProvidersSettingsStore.getState().startAdd(); @@ -176,30 +156,3 @@ export function WebWorkbenchShell({
    ); } - -function WebCloudFooter({ - onOpenBilling, - onOpenSettings, -}: { - onOpenBilling: () => void; - onOpenSettings: () => void; -}): React.ReactNode { - const session = authClient.useSession(); - const account = session.data?.user ?? null; - const billing = useCloudBilling(account?.email ?? null); - return ( - { - authClient.signOut().catch(noop); - }} - onManageAccount={() => - window.open('https://auth.arcbox.dev/account', '_blank', 'noopener,noreferrer') - } - balance={billing.data?.summary.displayBalance} - onOpenBilling={onOpenBilling} - onOpenSettings={onOpenSettings} - /> - ); -} diff --git a/packages/client/workbench/src/cloud/__tests__/billing.test.ts b/packages/client/workbench/src/cloud/__tests__/billing.test.ts deleted file mode 100644 index 0a4dbd7d4..000000000 --- a/packages/client/workbench/src/cloud/__tests__/billing.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { canManageCloudBilling } from '../billing'; -import { createBrowserCloudBillingSource } from '../billing-source'; - -const ORGANIZATION_ID = '00000000-0000-4000-8000-000000000001'; - -function jsonResponse(body: unknown, status = 200): Response { - return Response.json(body, { status }); -} - -afterEach(() => vi.unstubAllGlobals()); - -describe('canManageCloudBilling', () => { - it('matches the server owner/admin gate, including comma-separated roles', () => { - expect(canManageCloudBilling('owner')).toBe(true); - expect(canManageCloudBilling('member, admin')).toBe(true); - expect(canManageCloudBilling(' member , owner ')).toBe(true); - expect(canManageCloudBilling('member')).toBe(false); - expect(canManageCloudBilling('administrator')).toBe(false); - }); -}); - -describe('createBrowserCloudBillingSource', () => { - it('surfaces a missing catalog endpoint instead of misreporting an empty catalog', async () => { - vi.stubGlobal( - 'fetch', - vi - .fn() - .mockResolvedValueOnce( - jsonResponse({ id: ORGANIZATION_ID, name: 'LinkCode', slug: 'linkcode' }), - ) - .mockResolvedValueOnce( - jsonResponse({ id: ORGANIZATION_ID, name: 'LinkCode', slug: 'linkcode' }), - ) - .mockResolvedValueOnce(jsonResponse({ role: 'owner' })) - .mockResolvedValueOnce( - jsonResponse({ - denomination: 'nano_usd', - availableAmount: '0', - reservedAmount: '0', - displayBalance: { currency: 'USD', amount: '0.00' }, - }), - ) - .mockResolvedValueOnce(jsonResponse({ error: 'not found' }, 404)) - .mockResolvedValueOnce(jsonResponse([])) - .mockResolvedValueOnce(jsonResponse([])), - ); - - await expect( - createBrowserCloudBillingSource('https://example.test').overview(), - ).rejects.toThrow(`/organizations/${ORGANIZATION_ID}/billing/offers: 404`); - }); - - it('reports a missing billing profile without throwing an opaque portal error', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValueOnce(jsonResponse({ error: 'not found' }, 404)), - ); - - await expect( - createBrowserCloudBillingSource('https://example.test').portal(), - ).resolves.toBeNull(); - }); -}); diff --git a/packages/client/workbench/src/cloud/billing-source.ts b/packages/client/workbench/src/cloud/billing-source.ts deleted file mode 100644 index 8b5038f57..000000000 --- a/packages/client/workbench/src/cloud/billing-source.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { z } from 'zod'; -import type { CloudBillingOverview, CloudBillingSource } from './billing'; - -const organizationSchema = z.object({ id: z.uuid(), name: z.string(), slug: z.string() }); -const moneyDisplaySchema = z.object({ currency: z.string(), amount: z.string() }); -const summarySchema = z.object({ - denomination: z.literal('nano_usd'), - availableAmount: z.string(), - reservedAmount: z.string(), - displayBalance: z.object({ currency: z.literal('USD'), amount: z.string() }), -}); -const offersSchema = z.array( - z.object({ - offerVersionId: z.uuid(), - code: z.string(), - name: z.string(), - description: z.string().nullable(), - price: z.object({ currency: z.string(), amountMinor: z.string(), display: moneyDisplaySchema }), - credits: z.object({ - denomination: z.literal('nano_usd'), - amount: z.string(), - display: moneyDisplaySchema, - expiresAfterSeconds: z.number().nullable(), - }), - }), -); -const topUpOptionsSchema = z.array( - z.object({ - offerVersionId: z.uuid(), - code: z.string(), - name: z.string(), - description: z.string().nullable(), - currency: z.literal('usd'), - minimumFaceAmountMinor: z.string(), - minimumDisplay: moneyDisplaySchema, - processingFeeBasisPoints: z.number(), - processingFeeRefundable: z.literal(false), - creditExpiresAfterSeconds: z.number().nullable(), - }), -); -const ordersSchema = z.array( - z.object({ - id: z.uuid(), - currency: z.string(), - priceAmountMinor: z.string(), - creditFaceAmountMinor: z.string().nullable(), - refundedAmountMinor: z.string(), - status: z.enum([ - 'pending', - 'paid', - 'payment_failed', - 'expired', - 'partially_refunded', - 'refunded', - ]), - createdAt: z.iso.datetime(), - }), -); -const checkoutSchema = z.object({ checkoutUrl: z.url() }); -const portalSchema = z.object({ portalUrl: z.url() }); -const roleSchema = z.object({ role: z.string() }); - -/** Browser implementation: the ambient Better Auth cookie authenticates every request. */ -export function createBrowserCloudBillingSource(apiUrl: string): CloudBillingSource { - const call = (path: string, init?: RequestInit): Promise => - fetch(`${apiUrl}${path}`, { - ...init, - credentials: 'include', - headers: { 'content-type': 'application/json', ...init?.headers }, - }); - const json = async (path: string, schema: z.ZodType, init?: RequestInit): Promise => { - const response = await call(path, init); - if (!response.ok) throw new Error(`cloud billing ${path}: ${response.status}`); - return schema.parse(await response.json()); - }; - return { - async overview(): Promise { - const organization = await json('/organizations/default', organizationSchema, { - method: 'POST', - }); - await json('/auth/organization/set-active', organizationSchema, { - method: 'POST', - body: JSON.stringify({ organizationId: organization.id }), - }); - const role = await json('/auth/organization/get-active-member-role', roleSchema); - const base = `/organizations/${organization.id}/billing`; - const [summary, offers, topUpOptions, orders] = await Promise.all([ - json(`${base}/summary`, summarySchema), - json(`${base}/offers`, offersSchema), - json(`${base}/top-up-options`, topUpOptionsSchema), - json(`${base}/orders`, ordersSchema), - ]); - return { - organization: { ...organization, role: role.role }, - summary, - offers, - topUpOptions, - orders, - }; - }, - async checkout(input): Promise { - const result = await json( - `/organizations/${input.organizationId}/billing/checkout-sessions`, - checkoutSchema, - { - method: 'POST', - headers: { 'idempotency-key': crypto.randomUUID() }, - body: JSON.stringify({ - offerVersionId: input.offerVersionId, - currency: 'usd', - creditFaceAmountMinor: input.creditFaceAmountMinor, - }), - }, - ); - return result.checkoutUrl; - }, - async portal(): Promise { - const response = await call('/organizations/portal-sessions', { method: 'POST' }); - if (response.status === 404) return null; - if (!response.ok) throw new Error(`cloud billing portal: ${response.status}`); - return portalSchema.parse(await response.json()).portalUrl; - }, - }; -} diff --git a/packages/client/workbench/src/cloud/billing.tsx b/packages/client/workbench/src/cloud/billing.tsx deleted file mode 100644 index 9d2d9a6de..000000000 --- a/packages/client/workbench/src/cloud/billing.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { noop } from 'foxact/noop'; -import { createContext, use, useCallback } from 'react'; -import type { SWRResponse } from 'swr'; -import useSWR, { useSWRConfig } from 'swr'; - -export interface CloudBillingOrganization { - id: string; - name: string; - slug: string; - role: string; -} - -export interface CloudBillingSummary { - denomination: 'nano_usd'; - availableAmount: string; - reservedAmount: string; - displayBalance: { currency: 'USD'; amount: string }; -} - -export interface CloudBillingOffer { - offerVersionId: string; - code: string; - name: string; - description: string | null; - price: { - currency: string; - amountMinor: string; - display: { currency: string; amount: string }; - }; - credits: { - denomination: 'nano_usd'; - amount: string; - display: { currency: string; amount: string }; - expiresAfterSeconds: number | null; - }; -} - -export interface CloudBillingTopUpOption { - offerVersionId: string; - code: string; - name: string; - description: string | null; - currency: 'usd'; - minimumFaceAmountMinor: string; - minimumDisplay: { currency: string; amount: string }; - processingFeeBasisPoints: number; - processingFeeRefundable: false; - creditExpiresAfterSeconds: number | null; -} - -export type CloudBillingOrderStatus = - | 'pending' - | 'paid' - | 'payment_failed' - | 'expired' - | 'partially_refunded' - | 'refunded'; - -export interface CloudBillingOrder { - id: string; - currency: string; - priceAmountMinor: string; - creditFaceAmountMinor: string | null; - refundedAmountMinor: string; - status: CloudBillingOrderStatus; - createdAt: string; -} - -export interface CloudBillingOverview { - organization: CloudBillingOrganization; - summary: CloudBillingSummary; - offers: CloudBillingOffer[]; - topUpOptions: CloudBillingTopUpOption[]; - orders: CloudBillingOrder[]; -} - -export interface CloudBillingCheckoutInput { - organizationId: string; - offerVersionId: string; - creditFaceAmountMinor?: string; -} - -export interface CloudBillingSource { - overview: () => Promise; - checkout: (input: CloudBillingCheckoutInput) => Promise; - portal: () => Promise; -} - -const CloudBillingSourceContext = createContext(null); - -export function CloudBillingProvider({ - source, - children, -}: { - source: CloudBillingSource; - children?: React.ReactNode; -}): React.ReactNode { - return {children}; -} - -const BILLING_OVERVIEW_KEY = 'cloud/billing/overview'; - -export function useCloudBilling( - accountKey: string | null | undefined, -): SWRResponse { - const source = use(CloudBillingSourceContext); - return useSWR( - accountKey && source ? [BILLING_OVERVIEW_KEY, accountKey] : null, - source ? source.overview : null, - { revalidateOnFocus: true }, - ); -} - -export interface CloudBillingActions { - checkout: (input: CloudBillingCheckoutInput) => Promise; - portal: () => Promise; -} - -export function useCloudBillingActions(): CloudBillingActions | null { - const source = use(CloudBillingSourceContext); - const { mutate } = useSWRConfig(); - const invalidate = useCallback( - () => mutate((key) => Array.isArray(key) && key[0] === BILLING_OVERVIEW_KEY), - [mutate], - ); - const checkout = useCallback( - async (input: CloudBillingCheckoutInput) => { - if (!source) throw new Error('CloudBillingProvider missing'); - const url = await source.checkout(input); - invalidate().catch(noop); - return url; - }, - [source, invalidate], - ); - const portal = useCallback(async () => { - if (!source) throw new Error('CloudBillingProvider missing'); - return source.portal(); - }, [source]); - - return source ? { checkout, portal } : null; -} - -export function canManageCloudBilling(role: string): boolean { - return role.split(',').some((value) => { - const normalized = value.trim(); - return normalized === 'owner' || normalized === 'admin'; - }); -} diff --git a/packages/client/workbench/src/cloud/hosts.tsx b/packages/client/workbench/src/cloud/hosts.tsx index 9e01bb68e..cc8b6223e 100644 --- a/packages/client/workbench/src/cloud/hosts.tsx +++ b/packages/client/workbench/src/cloud/hosts.tsx @@ -14,7 +14,7 @@ export function CloudHostsProvider({ children, }: { source: CloudHostsSource; - children?: React.ReactNode; + children: React.ReactNode; }): React.ReactNode { return {children}; } diff --git a/packages/client/workbench/src/cloud/im.tsx b/packages/client/workbench/src/cloud/im.tsx index 4bc610f35..2dba2001b 100644 --- a/packages/client/workbench/src/cloud/im.tsx +++ b/packages/client/workbench/src/cloud/im.tsx @@ -86,7 +86,7 @@ export function CloudImProvider({ children, }: { source: CloudImSource; - children?: React.ReactNode; + children: React.ReactNode; }): React.ReactNode { return {children}; } diff --git a/packages/client/workbench/src/cloud/index.ts b/packages/client/workbench/src/cloud/index.ts index fb423c0ef..e42b196fc 100644 --- a/packages/client/workbench/src/cloud/index.ts +++ b/packages/client/workbench/src/cloud/index.ts @@ -1,5 +1,3 @@ -export * from './billing'; -export * from './billing-source'; export * from './hosts'; export * from './im'; export * from './im-source'; diff --git a/packages/client/workbench/src/settings/search.ts b/packages/client/workbench/src/settings/search.ts index fe5715500..092063b5a 100644 --- a/packages/client/workbench/src/settings/search.ts +++ b/packages/client/workbench/src/settings/search.ts @@ -92,14 +92,7 @@ export function useSettingsSearchKeywords(): SettingsSearchKeywords { t('providers.accountModel'), ...PROVIDER_SERVICES.map((service) => t(`providers.serviceName.${service}`)), ], - billing: [ - t('billing.available'), - t('billing.reserved'), - t('billing.creditPacks'), - t('billing.topUpAmount'), - t('billing.orders'), - t('billing.subscription'), - ], + billing: [t('billing.description'), t('billing.openOnWeb')], imChannel: [t('imChannel.connectTitle'), t('imChannel.bindings'), t('imChannel.autoMirror')], plugins: [ t('plugins.title'), diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 9cc939ba3..d92d2870d 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -613,7 +613,6 @@ export const en = { organization: 'Organization', signInCloud: 'Sign in to LinkCode Cloud', account: 'Account', - billingBalance: 'Available balance {currency} {amount}', manageAccount: 'Manage account', signOut: 'Sign out', remoteSignedOut: 'Sign in to connect', @@ -1133,41 +1132,9 @@ export const en = { }, billing: { title: 'Billing', - signedOut: 'Sign in to LinkCode Cloud to view your balance and manage billing.', - signIn: 'Sign in to LinkCode Cloud', - loadError: 'Failed to load billing: {message}', - organizationHint: 'Current organization: {organization}', - purchaseForbidden: - 'You can view billing for this organization, but only an owner or admin can purchase credits.', - available: 'Available balance', - reserved: 'Reserved balance', - reservedHint: - 'Requests reserve credit for their maximum token count. Any unused amount is released after settlement.', - creditPacks: 'Credit packs', - offersEmpty: 'No credit packs are available right now.', - creditsIncluded: 'Includes {currency} {amount} in credits', - buy: 'Buy', - topUpAmount: 'Custom top-up amount (USD)', - topUp: 'Top up', - topUpHint: 'Minimum {minimum}; {fee}% non-refundable processing fee applies.', - subscription: 'Subscription', - subscriptionUnavailable: - 'Cloud does not expose subscription status yet. Manage an existing subscription in the billing portal.', - manageSubscription: 'Manage subscription', - portalUnavailable: - 'This organization does not have a billing profile yet. The portal becomes available after the first purchase.', - orders: 'Order history', - ordersEmpty: 'No orders yet.', - refundedAmount: 'Refunded {amount}', - actionError: 'Action failed: {message}', - status: { - pending: 'Pending', - paid: 'Paid', - payment_failed: 'Payment failed', - expired: 'Expired', - partially_refunded: 'Partially refunded', - refunded: 'Refunded', - }, + description: 'Balance, top-ups, orders, and subscriptions are managed on LinkCode Cloud.', + hostedHint: 'LinkCode does not read or process billing or checkout data.', + openOnWeb: 'Manage on the web', }, imChannel: { title: 'Messaging', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 6e94b156a..766024d73 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -599,7 +599,6 @@ export const zhCN = { organization: '组织', signInCloud: '登录 LinkCode Cloud', account: '账户', - billingBalance: '可用余额 {currency} {amount}', manageAccount: '管理账户', signOut: '退出登录', remoteSignedOut: '登录后可连接远程主机', @@ -1105,37 +1104,9 @@ export const zhCN = { }, billing: { title: '账单', - signedOut: '查看余额和管理账单前,请先登录 LinkCode Cloud。', - signIn: '登录 LinkCode Cloud', - loadError: '加载账单失败:{message}', - organizationHint: '当前组织:{organization}', - purchaseForbidden: '你可以查看该组织的账单,但只有所有者或管理员可以购买额度。', - available: '可用余额', - reserved: '预留余额', - reservedHint: '请求会按最大 token 数预留额度;结算后未使用的部分会自动释放。', - creditPacks: '额度包', - offersEmpty: '当前没有可购买的额度包。', - creditsIncluded: '包含 {currency} {amount} 额度', - buy: '购买', - topUpAmount: '自定义充值金额(USD)', - topUp: '充值', - topUpHint: '最低 {minimum};另收 {fee}% 处理费,处理费不可退款。', - subscription: '订阅', - subscriptionUnavailable: 'Cloud 暂未提供订阅状态读取接口;可前往支付门户管理已有订阅。', - manageSubscription: '管理订阅', - portalUnavailable: '该组织还没有支付账户。完成首次购买后即可使用支付门户。', - orders: '订单记录', - ordersEmpty: '暂无订单。', - refundedAmount: '已退款 {amount}', - actionError: '操作失败:{message}', - status: { - pending: '待支付', - paid: '已支付', - payment_failed: '支付失败', - expired: '已过期', - partially_refunded: '部分退款', - refunded: '已退款', - }, + description: '余额、充值、订单和订阅均由 LinkCode Cloud 网页端管理。', + hostedHint: 'LinkCode 不读取或处理账单与结账数据。', + openOnWeb: '前往网页端管理', }, imChannel: { title: 'IM 渠道', diff --git a/packages/presentation/ui/src/shell/__tests__/billing-settings-panel.test.tsx b/packages/presentation/ui/src/shell/__tests__/billing-settings-panel.test.tsx new file mode 100644 index 000000000..2eb52dfe4 --- /dev/null +++ b/packages/presentation/ui/src/shell/__tests__/billing-settings-panel.test.tsx @@ -0,0 +1,27 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { BillingSettingsPanel } from '../billing-settings-panel'; + +function translateKey(key: string): string { + return key; +} + +vi.mock('use-intl', () => ({ + useTranslations: () => translateKey, +})); + +afterEach(cleanup); + +describe('BillingSettingsPanel', () => { + it('offers only the hosted billing handoff', () => { + const onOpenBilling = vi.fn(); + render(); + + expect(screen.getByText('description')).toBeTruthy(); + expect(screen.getByText('hostedHint')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'openOnWeb' })); + expect(onOpenBilling).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/presentation/ui/src/shell/billing-settings-panel.tsx b/packages/presentation/ui/src/shell/billing-settings-panel.tsx index d47287ed4..566967ff4 100644 --- a/packages/presentation/ui/src/shell/billing-settings-panel.tsx +++ b/packages/presentation/ui/src/shell/billing-settings-panel.tsx @@ -1,327 +1,29 @@ -import { Badge } from 'coss-ui/components/badge'; import { Button } from 'coss-ui/components/button'; -import { Card, CardHeader, CardPanel, CardTitle } from 'coss-ui/components/card'; -import { Field, FieldDescription, FieldLabel } from 'coss-ui/components/field'; -import { Input } from 'coss-ui/components/input'; -import { Skeleton } from 'coss-ui/components/skeleton'; -import { extractErrorMessage } from 'foxts/extract-error-message'; +import { Card, CardPanel } from 'coss-ui/components/card'; import { ExternalLinkIcon } from 'lucide-react'; -import { useState } from 'react'; -import { useFormatter, useTranslations } from 'use-intl'; - -export interface BillingSettingsDataView { - organizationName: string; - canManage: boolean; - availableNanoUsd: string; - reservedNanoUsd: string; - offers: Array<{ - id: string; - name: string; - description: string | null; - price: { currency: string; amount: string }; - credits: { currency: string; amount: string }; - }>; - topUpOptions: Array<{ - id: string; - name: string; - description: string | null; - minimumAmountMinor: string; - minimum: { currency: string; amount: string }; - feeBasisPoints: number; - }>; - orders: Array<{ - id: string; - currency: string; - amountMinor: string; - refundedAmountMinor: string; - status: 'pending' | 'paid' | 'payment_failed' | 'expired' | 'partially_refunded' | 'refunded'; - createdAt: string; - }>; -} +import { useTranslations } from 'use-intl'; export interface BillingSettingsPanelProps { - signedIn: boolean; - onSignIn?: () => void; - data: BillingSettingsDataView | undefined; - error: string | null; - onCheckout: (offerId: string, amountMinor?: string) => Promise; - onManageSubscription: () => Promise; + onOpenBilling: () => void; } -const RE_DOLLAR_AMOUNT = /^(\d{1,6})(?:\.(\d{0,2}))?$/; - export function BillingSettingsPanel({ - signedIn, - onSignIn, - data, - error, - onCheckout, - onManageSubscription, + onOpenBilling, }: BillingSettingsPanelProps): React.ReactNode { const t = useTranslations('settings.billing'); - const [pending, setPending] = useState(null); - const [actionError, setActionError] = useState(null); - const [portalUnavailable, setPortalUnavailable] = useState(false); - - if (!signedIn) { - return ( -
    -

    {t('signedOut')}

    - {onSignIn && ( - - )} -
    - ); - } - if (error !== null && data === undefined) { - return

    {t('loadError', { message: error })}

    ; - } - if (data === undefined) return ; - - const run = (key: string, action: () => Promise): void => { - setPending(key); - setActionError(null); - void action() - .catch((error_: unknown) => setActionError(error_)) - .finally(() => setPending(null)); - }; - return ( -
    -

    - {t('organizationHint', { organization: data.organizationName })} -

    - - {!data.canManage && ( -

    - {t('purchaseForbidden')} -

    - )} -
    -

    {t('creditPacks')}

    - {data.offers.length === 0 ? ( -

    {t('offersEmpty')}

    - ) : ( -
    - {data.offers.map((offer) => ( - - - {offer.name} -

    - {offer.description ?? t('creditsIncluded', offer.credits)} -

    -
    - -
    - {offer.price.currency} {offer.price.amount} -
    - -
    -
    - ))} -
    - )} -
    - {data.topUpOptions.map((option) => ( - run(option.id, () => onCheckout(option.id, amount))} - /> - ))} -
    -

    {t('subscription')}

    - - -

    {t('subscriptionUnavailable')}

    - -
    -
    - {portalUnavailable && ( -

    {t('portalUnavailable')}

    - )} -
    - - {actionError != null && ( -

    - {t('actionError', { message: extractErrorMessage(actionError, false) ?? '' })} -

    - )} -
    - ); -} - -function BalanceCard({ - availableNanoUsd, - reservedNanoUsd, -}: { - availableNanoUsd: string; - reservedNanoUsd: string; -}): React.ReactNode { - const t = useTranslations('settings.billing'); - const format = useFormatter(); return ( - -
    -
    {t('available')}
    -
    - {format.number(nanoUsdToRoundedUsd(availableNanoUsd), { - style: 'currency', - currency: 'USD', - })} -
    + +
    +

    {t('description')}

    +

    {t('hostedHint')}

    -
    -
    {t('reserved')}
    -
    - {format.number(nanoUsdToRoundedUsd(reservedNanoUsd), { - style: 'currency', - currency: 'USD', - })} -
    -
    -

    {t('reservedHint')}

    +
    ); } - -function VariableTopUp({ - option, - canManage, - pending, - onSubmit, -}: { - option: BillingSettingsDataView['topUpOptions'][number]; - canManage: boolean; - pending: boolean; - onSubmit: (amountMinor: string) => void; -}): React.ReactNode { - const t = useTranslations('settings.billing'); - const [amount, setAmount] = useState(option.minimum.amount); - const amountMinor = dollarsToMinor(amount); - const valid = amountMinor !== null && BigInt(amountMinor) >= BigInt(option.minimumAmountMinor); - return ( -
    -

    {option.name}

    - - {t('topUpAmount')} -
    - setAmount(event.target.value)} - /> - -
    - - {t('topUpHint', { - minimum: `${option.minimum.currency} ${option.minimum.amount}`, - fee: option.feeBasisPoints / 100, - })} - -
    -
    - ); -} - -function Orders({ orders }: { orders: BillingSettingsDataView['orders'] }): React.ReactNode { - const t = useTranslations('settings.billing'); - const format = useFormatter(); - return ( -
    -

    {t('orders')}

    - {orders.length === 0 ? ( -

    {t('ordersEmpty')}

    - ) : ( -
      - {orders.map((order) => ( -
    • -
      -
      - {format.number(Number(order.amountMinor) / 100, { - style: 'currency', - currency: order.currency.toUpperCase(), - })} -
      -
      - {format.dateTime(new Date(order.createdAt), { dateStyle: 'medium' })} - {order.refundedAmountMinor !== '0' && - ` · ${t('refundedAmount', { - amount: format.number(Number(order.refundedAmountMinor) / 100, { - style: 'currency', - currency: order.currency.toUpperCase(), - }), - })}`} -
      -
      - - {t(`status.${order.status}`)} - -
    • - ))} -
    - )} -
    - ); -} - -function BillingSkeleton(): React.ReactNode { - return ( -
    - - - - -
    - ); -} - -function dollarsToMinor(value: string): string | null { - const match = RE_DOLLAR_AMOUNT.exec(value.trim()); - if (!match) return null; - const minor = BigInt(match[1]) * 100n + BigInt((match.at(2) ?? '').padEnd(2, '0') || '0'); - return minor > 0n ? String(minor) : null; -} - -function nanoUsdToRoundedUsd(value: string): number { - const cents = (BigInt(value) + 5_000_000n) / 10_000_000n; - return Number(cents) / 100; -} diff --git a/packages/presentation/ui/src/shell/session-sidebar.tsx b/packages/presentation/ui/src/shell/session-sidebar.tsx index 0dbb04312..97c18b28e 100644 --- a/packages/presentation/ui/src/shell/session-sidebar.tsx +++ b/packages/presentation/ui/src/shell/session-sidebar.tsx @@ -28,7 +28,6 @@ import { SettingsIcon, ShieldIcon, SparklesIcon, - WalletCardsIcon, } from 'lucide-react'; import { useRef } from 'react'; import { useTranslations } from 'use-intl'; @@ -222,8 +221,6 @@ export function HostFooter({ onSignIn, onSignOut, onManageAccount, - balance, - onOpenBilling, remoteHosts, remoteHostsLoading = false, selectedHostId, @@ -241,9 +238,6 @@ export function HostFooter({ onSignOut?: () => void; /** Opens the IdP account center (profile/avatar) in the system browser; shown when signed in. */ onManageAccount?: () => void; - /** Available Cloud balance, displayed persistently beside the signed-in account. */ - balance?: { currency: string; amount: string }; - onOpenBilling?: () => void; /** The account's online hosts (Remote access); undefined until first load. Requires `account`. */ remoteHosts?: RemoteHostItem[]; /** First load of the host list — the Remote access area shows a checking hint, not "no hosts". */ @@ -276,23 +270,6 @@ export function HostFooter({ } />
    - {account && balance && ( - - - {balance.currency} {balance.amount} - - } - /> - )} {account ? ( void; /** Platform-formatted hint next to the Search entry, e.g. `⌘K`. */ searchShortcut?: string; - /** App-owned cloud/system footer; the default shows only local host state. */ - sidebarFooter?: React.ReactNode; TerminalBlockComponent?: React.ComponentType<{ terminalId: string }>; onDismissError?: () => void; } @@ -164,7 +162,6 @@ export function ShellFrame({ onOpenSearch, onOpenAutomations, searchShortcut, - sidebarFooter, TerminalBlockComponent, BranchStatusComponent, onDismissError, @@ -182,7 +179,7 @@ export function ShellFrame({ activeId={active?.sessionId ?? null} pinnedSessionIds={pinnedSessionIds} collapsedSections={collapsedSections} - footer={sidebarFooter ?? } + footer={} onSelect={onSelectSession} onClose={onCloseSession} onToggleSessionPinned={onToggleSessionPinned} diff --git a/packages/vendor/linkcode-cloud-0.1.0.tgz b/packages/vendor/linkcode-cloud-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..ab0d5e8238ed6cc63013473c3de16938930d66a4 GIT binary patch literal 3869 zcmV+&59072iwFP!000001MOOEbK5wQ-p}|IJ({W=drGEd$K$=T%Qq|AN}@VNa!K-J zQn$M?M7AVMD1ZxqQnVTW_p1g7ic-W(_AZ&NyD~pSF$pvp{qpn+kn#8(pW$(@d(-KU zI)C^YPpj2BKRW~ZZM9mvzb8MOK0Aljvmee|Cnx9UtuvtCXEgq+aI^E|#&Z3K*8h5~ z-Oc?Ud3qv7X%H;Fw-Bprt;K9^;UGSQ)7HtK;1$<`-(JIw(wWlS3MHXEX+k?qVMs#_ z4AG!oViK{&b#8=2V_>9m9U~Of8z2mDu$m{h;QAe?X$|3su{*B$0xfF8n4hoo1yrB5 zj<41GCUj*SH6b~k9P{EG^!UUSvoL|zO1~?o_Jn+QqY{i@JTE!(V~9P!J0Wi z9}`Fv8j#9@ka3zP*aR*UuybL;KsTg@9!=y~maf673phAAq?tN7%DA>`h}9xf5~Vdw zSj|N|2aO9ZWDT*ITB4Siz+??}jzWW4Nk|JWl%~~0j4%}`n!s-Ywe&m!~R_M-3BN55$7cP_IEGD6NFEp3-0^X<`7JLm0{thA2D#>HFubd=5Ere9DnID=yyiOt85apGQ5&J;fjQjWd<{h}62HUE)(WGFQb{%XOvqR`3y;b8}~!G6k5pz zc`*q)P~?P*#hyUbxnQX#r<$m4A8lydIGqURr-Q;m3iK^Dxo1Zi@Vrew&;Dv? zZFx@w-KlrGATZ$_kHbFU){tDyQ4$rH3T>>%17Tr?5;afLHSi@DDW9a+r1vY4rnE>6 zwF$;*fi)aJ$}MV0awPlwjOe8?MQ&&&2Z^g7ro8~=)xb-z`wVSF(3RzkW{{%s$|kQO zVFuXyl+AC9t(>-R|MmXR=oF9^9D5sCjL%VG0vE!+_Za7vR8os~3BpvX#U3$HpxMh^ zof0a%@zkh-!OUgjfx*4nu8q?skLSQ)#n)ccA2YegjfDvUk7G2(p?(dmM6G11I4NkV zbUiaxfy$6#WUUfjDr0G=qByocUeOXlTGSLx1E$wGmauaYHbtiym*O8q;^2Lh(g%@v zWUX>sL#$NNmfMt6jK_hkg=*0<#ATkQTJdbVTU@E7*Lwmc3Q+Tf+%!?;<1HEQCIw# z>v*ExM+q(?#`-RiWsK7Cn#uzXTn3P+IH&cRP%PI7mC0cV?-B+XN>@F+rKXkt#XxO6Ba5F({&SKjocz6sKwH0o2`_7XPTg~G|!?W za*?$EZo4Df?~XK>1JzBG-+VgGh5|#BKq*j_T zEwjPo1gK1;jK%4j=XS2N_y;CTqro&q?LU;uX`E^+?%ddXtI(f1u(B)UMYXp-sij@kz`^eJ^UCjXla(62Rmg|*;7Mj$4j_% zx+O!A>w4!A5|%=x+?MjK$i)R7o?AuHHm2CaO3(DmRY72TZV0%A-?{y`oP|0mtk9^b z$qd+dx1;48+@XdOIhX4qNVp_zUG4d4*YTlRy+kj{8mc~N;GnDwoT%1CLxtqTe}fn| zfbTPnhU}@pn4$}|Vfv6ZF@W*Q4s?DVclu+v>kMCY$K%c|T)%<#-CeJH)4uL?px1ug zbmNNF(Jj0l4F40lBe)w5e(K(KZeh@8?LIWxBj}DAaNQntM^Q0+Foc(#Vdwe{+;&Gd zy>|E22->|KylxMN?f$si89}>$3pa!QZFk%q^hZtB`T1_x8I52tgzl@mUbl1Ggl_+) zcYoXMzkq|+-SNx8{TO=PSKV=&<{Vb34W6@Co#D;Pc7NQy?)JLlH%)ln9ruai^T80> zaMvD=yEpf}_7Lvwhj)WfrwRQ*|ESx4KJ4~ibY6A(<0f&%#@%tR6EPNgv>yi<7h;X^ z8L`t&Rmk5N-%qwGpfPh+S!4wcTomD{YcEj&yn)k=j%&NgO~wtRic(3>&2E6XQA?DL zS2f*)$_b`w6*0E=D`Q<*CjJ$#tK>X&y9`)a^2>I+-;z`HPQ$|Hnj5y_k{zo;SG<%F zPgCZ5oEl@12`t#A^~yfw$~XNe|17TU z2mEkDOjhZ6XjRjI=%pQUTjDPf7YnMIowh2oHBvU}^WGxQ_2#^l=T(y{?h0=jxG%-N zB(WS!4natR+7@B)y@f_!No+)nJ=?alY61~4b{0WjIcM^v!**yYLWZ~Z6<G{+7@9*(Y$g^b$)l_TUN{b89nmaePc8Rfc)cQ;~3seaHOG z9OHK?w|*02u4iZubmfmj_~6(UQ`mYRr6^~14p&!K(1Krog?%6Yqn58g798wqwQ^z` zuQTjut@J@7Fjmk7>j;-^t29+BOd5xmj>@C^H$__v^KY$QS6A@_yngP7z3p8VbRgz7 zp2GnW6JLnOW`zv6Q-hq^i*AYX!{gRYNZ8-(v94&&&4FJf%Cxd7R_P%L|C<`yi7c93 zeg67u!DvJ$dM-)Zyvn#SE2WdKp`Wdh(Ol#7l09w=)3?)Jx+A}9k)}eK5~a(4E^Zu~zv zIem)%zsEz4-5}^|TtMT`A-Ep9%N8z9H;U4SzPF;2sO8`K{%mH8FN5h+xbCaJsBjdn zG)#n~^HzV(Q(on`*-)RW1!jEamJgXmHN$X&>^gG;7Anb8^zYpDrN#vm$;CG=f&Kpb z^qm$?)XnJ{z8Oj7@EU+SAPOQk)FB(8u;TMVP|+?%CiSLmtb`4)@6_XYA5}QPr(0Sr+xdX6D^Xju*PrU z|Fb{s-v2#4X+70{zsvJ2?0`OFC6;f%n@&VZ^tLKoN81K;ARIUTdaWP8ZhXy1SKx{Z zXJ9bJ*k29e3zfTjU~!Qk`nG4r*K{=K&c<_d=}%P(JEac4yYVNLEKW|S?bW{P(lwxO zzlE2@MLC}uGWPKBU=5@0*eBCRzr~LN;3Z?##JVmqXRwu;UsP({SF8Xtgq^BVwMhhbrj@cXKZ*`f~2T)qqJsV`1ltv~cEF9AKctEUg{{Mp?FcUuknW)CsNycClw zt8;v~q!R>prsyxol9`o8l)Km&PJ_9vu<_Ji+}r=7q~?pR@f-NxdiKNFZvKCE{)GR( z%i~;b%439GSQ}*ig!@CKDEA=r;|0*Mf}Bxlo?x8Pamqi}y=cPYj~8`I9&YG=PT^k+ fp`;T&)NbRcL-6!GJx|Zmv-SKp5g2-Y06G8wTRW14 literal 0 HcmV?d00001 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3b55e2bb..a28be5a64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -283,6 +283,9 @@ importers: '@better-auth/electron': specifier: 1.7.0-rc.2 version: 1.7.0-rc.2(@better-auth/core@1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.7)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.29.4))(react-dom@19.3.0-canary-96fcba90-20260728(react@19.3.0-canary-96fcba90-20260728))(react@19.3.0-canary-96fcba90-20260728)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(happy-dom@20.10.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(yaml@2.9.0))))(better-call@1.3.7(zod@4.4.3))(electron@43.2.0) + '@linkcode/cloud': + specifier: file:../../packages/vendor/linkcode-cloud-0.1.0.tgz + version: file:packages/vendor/linkcode-cloud-0.1.0.tgz '@linkcode/common': specifier: workspace:* version: link:../../packages/foundation/common @@ -633,6 +636,9 @@ importers: '@hookform/resolvers': specifier: ^5.5.7 version: 5.5.7(@sinclair/typebox@0.27.10)(@standard-schema/spec@1.1.0)(ajv@8.20.0)(react-hook-form@7.83.0(react@19.3.0-canary-96fcba90-20260728))(zod@4.4.3) + '@linkcode/cloud': + specifier: file:../../packages/vendor/linkcode-cloud-0.1.0.tgz + version: file:packages/vendor/linkcode-cloud-0.1.0.tgz '@linkcode/common': specifier: workspace:* version: link:../../packages/foundation/common @@ -3425,6 +3431,10 @@ packages: typescript: optional: true + '@linkcode/cloud@file:packages/vendor/linkcode-cloud-0.1.0.tgz': + resolution: {integrity: sha512-i38bW7xCv8Us+iW3ZDKV64cB3riTvk2rbnbppvrdl+BLNL96VE484yiQ9sopunYaxrRWy7Qi8txFecGh2/s++w==, tarball: file:packages/vendor/linkcode-cloud-0.1.0.tgz} + version: 0.1.0 + '@linkcode/tunnel@0.1.0': resolution: {integrity: sha512-vRUQJApK4IDOicEOJ6B3PD5yBacFOL6Xr4Wel6UpG53TDSkkmv0W5uZw6vX7OMtWthI/S70yyVkvXtFOFho//w==} @@ -13841,6 +13851,8 @@ snapshots: optionalDependencies: typescript: '@typescript/typescript6@6.0.2' + '@linkcode/cloud@file:packages/vendor/linkcode-cloud-0.1.0.tgz': {} + '@linkcode/tunnel@0.1.0': {} '@malept/cross-spawn-promise@2.0.0': From 6407a870e5e5150d303e64abdaa732df6392ca89 Mon Sep 17 00:00:00 2001 From: Chenyu Lu <117573780+lucas77778@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:11:24 +0800 Subject: [PATCH 7/7] feat(desktop): add LinkCode gateway provider (#419) --- apps/desktop/package.json | 1 + .../__tests__/cloud-hosted-billing.test.ts | 37 ++++- apps/desktop/src/main/cloud-auth/client.ts | 21 +++ apps/desktop/src/preload/index.ts | 2 + .../src/renderer/src/cloud-auth/bridges.ts | 4 + .../renderer/src/settings/providers-tab.tsx | 14 +- .../src/renderer/src/shell/desktop-shell.tsx | 2 + .../src/shell/desktop-workbench-shell.tsx | 4 + apps/desktop/src/shared/cloud.ts | 4 + .../providers/__tests__/add-flow.test.tsx | 47 +++++- .../src/settings/providers/add-flow.tsx | 145 +++++++++++++++--- .../src/settings/providers/catalog.ts | 19 +++ .../settings/providers/providers-settings.tsx | 64 +++++++- .../src/__tests__/gateway-error.test.ts | 23 +++ packages/host/agent-adapter/src/base.ts | 21 +++ .../host/agent-adapter/src/gateway-error.ts | 40 +++++ .../agent-adapter/src/native/claude-code.ts | 13 +- .../agent-adapter/src/native/codex/adapter.ts | 17 +- .../src/native/opencode/adapter.ts | 10 +- .../agent-adapter/src/native/pi/adapter.ts | 2 +- packages/host/assets/src/pi-closure.gen.ts | 6 +- packages/presentation/i18n/src/locales/en.ts | 16 ++ .../presentation/i18n/src/locales/zh-cn.ts | 15 ++ .../ui/src/chat/conversation-view.tsx | 4 + .../ui/src/chat/error-message.tsx | 31 +++- .../ui/src/chat/turn-segment-view.tsx | 4 + .../ui/src/shell/conversation-surface.tsx | 4 + .../ui/src/shell/providers/account-detail.tsx | 40 ++++- .../shell/providers/account-master-list.tsx | 10 +- .../ui/src/shell/service-icon.tsx | 21 ++- .../presentation/ui/src/shell/shell-frame.tsx | 4 + pnpm-lock.yaml | 26 +++- 32 files changed, 623 insertions(+), 48 deletions(-) create mode 100644 packages/host/agent-adapter/src/__tests__/gateway-error.test.ts create mode 100644 packages/host/agent-adapter/src/gateway-error.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index bec21579f..09a4094db 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -30,6 +30,7 @@ }, "author": "ArcBox Labs ", "dependencies": { + "@better-auth/api-key": "1.7.0-rc.2", "@better-auth/electron": "1.7.0-rc.2", "@linkcode/cloud": "file:../../packages/vendor/linkcode-cloud-0.1.0.tgz", "@linkcode/common": "workspace:*", diff --git a/apps/desktop/src/main/__tests__/cloud-hosted-billing.test.ts b/apps/desktop/src/main/__tests__/cloud-hosted-billing.test.ts index 36c442c81..31b66c1bc 100644 --- a/apps/desktop/src/main/__tests__/cloud-hosted-billing.test.ts +++ b/apps/desktop/src/main/__tests__/cloud-hosted-billing.test.ts @@ -1,7 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ - handlers: new Map unknown>(), + handlers: new Map unknown>(), + createApiKey: vi.fn(), + getSession: vi.fn(), openExternal: vi.fn(), setAsDefaultProtocolClient: vi.fn(), setupMain: vi.fn(), @@ -11,8 +13,20 @@ vi.mock('@better-auth/electron/client', () => ({ electronClient: () => ({}), })); +vi.mock('@better-auth/api-key/client', () => ({ + apiKeyClient: () => ({}), +})); + +vi.mock('better-auth/client/plugins', () => ({ + organizationClient: () => ({}), +})); + vi.mock('better-auth/client', () => ({ - createAuthClient: () => ({ setupMain: mocks.setupMain }), + createAuthClient: () => ({ + setupMain: mocks.setupMain, + getSession: mocks.getSession, + apiKey: { create: mocks.createApiKey }, + }), })); vi.mock('electron', () => ({ @@ -42,6 +56,11 @@ describe('desktop hosted billing handoff', () => { mocks.handlers.clear(); mocks.setAsDefaultProtocolClient.mockReturnValue(true); mocks.openExternal.mockResolvedValue(undefined); + mocks.getSession.mockResolvedValue({ + data: { session: { activeOrganizationId: 'org_1' } }, + error: null, + }); + mocks.createApiKey.mockResolvedValue({ data: { key: 'lc-gateway-secret' }, error: null }); }); it('opens the SDK URL with a channel-specific native return target', async () => { @@ -56,4 +75,18 @@ describe('desktop hosted billing handoff', () => { 'https://console.linkcode.ai/billing?returnTarget=linkcode-dev%3A%2F%2Fbilling%2Freturn', ); }); + + it('mints a Gateway key in the signed-in session organization', async () => { + const { setupCloudAuth } = await import('../cloud-auth/client'); + const { CLOUD_CREATE_GATEWAY_KEY_CHANNEL } = await import('../../shared/cloud'); + setupCloudAuth(); + + await expect( + mocks.handlers.get(CLOUD_CREATE_GATEWAY_KEY_CHANNEL)?.({}, 'LinkCode Gateway'), + ).resolves.toBe('lc-gateway-secret'); + expect(mocks.createApiKey).toHaveBeenCalledWith({ + name: 'LinkCode Gateway', + organizationId: 'org_1', + }); + }); }); diff --git a/apps/desktop/src/main/cloud-auth/client.ts b/apps/desktop/src/main/cloud-auth/client.ts index 3d20658e3..0c70a953e 100644 --- a/apps/desktop/src/main/cloud-auth/client.ts +++ b/apps/desktop/src/main/cloud-auth/client.ts @@ -1,10 +1,14 @@ import { resolve } from 'node:path'; +import { apiKeyClient } from '@better-auth/api-key/client'; import { electronClient } from '@better-auth/electron/client'; import { createHostedBillingUrl } from '@linkcode/cloud'; import { createAuthClient } from 'better-auth/client'; +import { organizationClient } from 'better-auth/client/plugins'; import { app, BrowserWindow, ipcMain, shell } from 'electron'; +import { z } from 'zod'; import { CLOUD_CLAIM_DEEP_LINK_CHANNEL, + CLOUD_CREATE_GATEWAY_KEY_CHANNEL, CLOUD_OPEN_HOSTED_BILLING_CHANNEL, } from '../../shared/cloud'; import { CHANNEL } from '../constants'; @@ -45,6 +49,8 @@ export const authClient = createAuthClient({ // privileged scheme); the footer renders initials. userImageProxy: { enabled: false }, }), + organizationClient(), + apiKeyClient(), ], }); @@ -58,6 +64,18 @@ function claimDeepLink(): boolean { : app.setAsDefaultProtocolClient(CLOUD_AUTH_SCHEME); } +async function createGatewayKey(name: unknown): Promise { + const parsedName = z.string().trim().min(1).max(80).parse(name); + const session = await authClient.getSession(); + if (session.error) throw new Error(session.error.message); + const organizationId = session.data?.session.activeOrganizationId; + if (!organizationId) throw new Error('Sign in to LinkCode Cloud, then try again'); + + const created = await authClient.apiKey.create({ name: parsedName, organizationId }); + if (created.error) throw new Error(created.error.message); + return created.data.key; +} + /** * Wire the auth client into main. Once a config object is passed, every feature must be opted into * explicitly: `scheme` = protocol + deep-link handlers, `bridges` = the IPC handlers the preload @@ -80,4 +98,7 @@ export function setupCloudAuth(): void { createHostedBillingUrl({ returnTarget: `${CLOUD_AUTH_SCHEME}://billing/return` }), ); }); + ipcMain.handle(CLOUD_CREATE_GATEWAY_KEY_CHANNEL, (_event, name: unknown) => + createGatewayKey(name), + ); } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 9ce6ccc53..2f607b072 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -3,6 +3,7 @@ import { createElectronSystemBridge } from '@linkcode/ipc/electron-renderer'; import { contextBridge, ipcRenderer } from 'electron'; import { CLOUD_CLAIM_DEEP_LINK_CHANNEL, + CLOUD_CREATE_GATEWAY_KEY_CHANNEL, CLOUD_IM_BINDINGS_CHANNEL, CLOUD_IM_CREATE_BINDING_CHANNEL, CLOUD_IM_DELETE_BINDING_CHANNEL, @@ -35,6 +36,7 @@ contextBridge.exposeInMainWorld('linkcodeCloud', { listHosts: () => ipcRenderer.invoke(CLOUD_LIST_HOSTS_CHANNEL), claimDeepLink: () => ipcRenderer.invoke(CLOUD_CLAIM_DEEP_LINK_CHANNEL), openHostedBilling: () => ipcRenderer.invoke(CLOUD_OPEN_HOSTED_BILLING_CHANNEL), + createGatewayKey: (name: string) => ipcRenderer.invoke(CLOUD_CREATE_GATEWAY_KEY_CHANNEL, name), im: { overview: () => ipcRenderer.invoke(CLOUD_IM_OVERVIEW_CHANNEL), bindings: () => ipcRenderer.invoke(CLOUD_IM_BINDINGS_CHANNEL), diff --git a/apps/desktop/src/renderer/src/cloud-auth/bridges.ts b/apps/desktop/src/renderer/src/cloud-auth/bridges.ts index b14ff76aa..b23479e53 100644 --- a/apps/desktop/src/renderer/src/cloud-auth/bridges.ts +++ b/apps/desktop/src/renderer/src/cloud-auth/bridges.ts @@ -38,6 +38,8 @@ export interface CloudDataBridges { claimDeepLink: () => Promise; /** Opens Cloud's hosted billing surface in the system browser. */ openHostedBilling: () => Promise; + /** Mints a LinkCode Gateway key in the signed-in user's active Cloud organization. */ + createGatewayKey: (name: string) => Promise; /** IM Channel management (`/im/*`); same session-in-main model as listHosts. */ im: CloudImSource; }; @@ -51,6 +53,8 @@ export const cloudDataBridge: CloudDataBridges['linkcodeCloud'] = { claimDeepLink: () => traceRendererIpc('cloud.claim-deep-link', () => cloudSource.claimDeepLink()), openHostedBilling: () => traceRendererIpc('cloud.open-hosted-billing', () => cloudSource.openHostedBilling()), + createGatewayKey: (name) => + traceRendererIpc('cloud.create-gateway-key', () => cloudSource.createGatewayKey(name)), im: { overview: () => traceRendererIpc('cloud.im.overview', () => cloudSource.im.overview()), bindings: () => traceRendererIpc('cloud.im.bindings', () => cloudSource.im.bindings()), diff --git a/apps/desktop/src/renderer/src/settings/providers-tab.tsx b/apps/desktop/src/renderer/src/settings/providers-tab.tsx index 88ae658e7..dec0b7522 100644 --- a/apps/desktop/src/renderer/src/settings/providers-tab.tsx +++ b/apps/desktop/src/renderer/src/settings/providers-tab.tsx @@ -1,7 +1,19 @@ import { ProvidersSettingsPanel } from '@linkcode/workbench'; +import { cloudDataBridge } from '../cloud-auth/bridges'; +import { useCloudAccount } from '../cloud-auth/use-cloud-account'; // A transport-backed workbench container: reachable above the connection gate (the `ungated` // slot), degrading to loading/error while the daemon is unreachable — like the history-import tab. export function ProvidersTab(): React.ReactNode { - return ; + const cloud = useCloudAccount(); + return ( + + ); } diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index 671db1750..218061bbf 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -87,6 +87,7 @@ export function DesktopShell({ onDownloadAgent, onContinueUnverified, onOpenProviderSettings, + onOpenBilling, conversation, respondingRequestIds, responseErrors, @@ -456,6 +457,7 @@ export function DesktopShell({ cwd={active?.cwd} runtimeCues={runtimeCues} onOpenProviderSettings={onOpenProviderSettings} + onOpenBilling={onOpenBilling} respondingRequestIds={respondingRequestIds} responseErrors={responseErrors} TerminalBlockComponent={TerminalBlockComponent} diff --git a/apps/desktop/src/renderer/src/shell/desktop-workbench-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-workbench-shell.tsx index 4505e2a15..7366cd4a8 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-workbench-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-workbench-shell.tsx @@ -1,6 +1,7 @@ import type { WorkbenchShellProps } from '@linkcode/workbench'; import { useNavigationHistoryStore, useProvidersSettingsStore } from '@linkcode/workbench'; import { systemBridge } from '@renderer/ipc'; +import { cloudDataBridge } from '../cloud-auth/bridges'; import { openDesktopSettings, useDesktopSettingsStore } from '../settings/store'; import { DesktopShell } from './desktop-shell'; @@ -16,6 +17,9 @@ export function DesktopWorkbenchShell({ header, ...props }: WorkbenchShellProps) useProvidersSettingsStore.getState().startAdd(); openDesktopSettings('providers'); }} + onOpenBilling={() => { + void cloudDataBridge.openHostedBilling(); + }} onOpenAutomations={() => useNavigationHistoryStore.getState().openOverlay('automations')} onImportHistory={() => openDesktopSettings('history-import')} themeType={theme} diff --git a/apps/desktop/src/shared/cloud.ts b/apps/desktop/src/shared/cloud.ts index 63dc58d48..429202b90 100644 --- a/apps/desktop/src/shared/cloud.ts +++ b/apps/desktop/src/shared/cloud.ts @@ -11,6 +11,10 @@ export const CLOUD_CLAIM_DEEP_LINK_CHANNEL = 'linkcode.cloud.claim-deep-link'; // Opens the Cloud-owned billing surface; all billing and checkout state stays in the browser. export const CLOUD_OPEN_HOSTED_BILLING_CHANNEL = 'linkcode.cloud.open-hosted-billing'; +// Mints a LinkCode Gateway key from the authenticated Cloud session. The secret crosses this +// bridge once, then the renderer hands it to the daemon-owned account vault. +export const CLOUD_CREATE_GATEWAY_KEY_CHANNEL = 'linkcode.cloud.create-gateway-key'; + // IM Channel management (`/im/*` on the cloud API). export const CLOUD_IM_OVERVIEW_CHANNEL = 'linkcode.cloud.im.overview'; export const CLOUD_IM_BINDINGS_CHANNEL = 'linkcode.cloud.im.bindings'; diff --git a/packages/client/workbench/src/settings/providers/__tests__/add-flow.test.tsx b/packages/client/workbench/src/settings/providers/__tests__/add-flow.test.tsx index 8d0fef32c..d004e5c7a 100644 --- a/packages/client/workbench/src/settings/providers/__tests__/add-flow.test.tsx +++ b/packages/client/workbench/src/settings/providers/__tests__/add-flow.test.tsx @@ -4,7 +4,7 @@ import type { AgentRuntimes } from '@linkcode/schema'; import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { AgentRuntimeOnboarding } from '../../../agent-runtime/onboarding'; -import { AddAccountForm } from '../add-flow'; +import { AddAccountForm, ServiceCatalogView } from '../add-flow'; function translateKey(key: string): string { return key; @@ -164,4 +164,49 @@ describe('non-subscription account creation', () => { ), ); }); + + it('adds LinkCode Gateway only after the explicit user action', async () => { + const createKey = vi.fn().mockResolvedValue('lc-gateway-key'); + const onSubmit = vi.fn(); + render( + , + ); + + expect(createKey).not.toHaveBeenCalled(); + expect(onSubmit).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'linkCodeUseGateway' })); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(createKey).toHaveBeenCalledWith('serviceName.linkcode-gateway'); + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + service: 'linkcode-gateway', + credential: { type: 'auth-token', token: 'lc-gateway-key' }, + endpoint: { protocol: 'openai-chat', baseUrl: 'https://gateway.linkcode.ai/v1' }, + }), + ); + }); + + it('keeps the Desktop-only Gateway out of clients without its host bridge', () => { + const onPick = vi.fn(); + const { rerender } = render(); + expect(screen.queryByText('serviceName.linkcode-gateway')).toBeNull(); + + rerender(); + fireEvent.click(screen.getByText('serviceName.linkcode-gateway')); + expect(onPick).toHaveBeenCalledWith('linkcode-gateway'); + }); }); diff --git a/packages/client/workbench/src/settings/providers/add-flow.tsx b/packages/client/workbench/src/settings/providers/add-flow.tsx index 203cf9270..b509caace 100644 --- a/packages/client/workbench/src/settings/providers/add-flow.tsx +++ b/packages/client/workbench/src/settings/providers/add-flow.tsx @@ -12,6 +12,7 @@ import { SelectTrigger, SelectValue, } from 'coss-ui/components/select'; +import { extractErrorMessage } from 'foxts/extract-error-message'; import { ChevronLeftIcon } from 'lucide-react'; import { useState } from 'react'; import type { Control, FieldValues, Path } from 'react-hook-form'; @@ -20,7 +21,13 @@ import { useTranslations } from 'use-intl'; import { z } from 'zod'; import type { AgentRuntimeOnboarding } from '../../agent-runtime/onboarding'; import type { ServiceDescriptor, ServiceGroup, ServiceVariant } from './catalog'; -import { fillTemplate, SERVICE_CATALOG, serviceById, templatePlaceholders } from './catalog'; +import { + fillTemplate, + LINKCODE_GATEWAY_SERVICE_ID, + SERVICE_CATALOG, + serviceById, + templatePlaceholders, +} from './catalog'; const GROUPS: ServiceGroup[] = ['subscription', 'direct', 'gateway', 'custom']; @@ -104,8 +111,10 @@ function accountFromCustomDraft(draft: CustomDraft, account?: Account): Account /** Step one of the add flow: the grouped service directory. */ export function ServiceCatalogView({ onPick, + linkCodeGatewayAvailable = false, }: { onPick: (service: string) => void; + linkCodeGatewayAvailable?: boolean; }): React.ReactNode { const t = useTranslations('settings.providers'); return ( @@ -116,24 +125,26 @@ export function ServiceCatalogView({ {t(`group.${group}`)}
    - {(SERVICES_BY_GROUP.get(group) ?? []).map((service) => ( - - ))} + + ), + )}
    ))} @@ -149,6 +160,7 @@ export function AddAccountForm({ busy, onBack, onSubmit, + linkCodeGateway, }: { serviceId: string; runtimes: AgentRuntimes | undefined; @@ -156,6 +168,7 @@ export function AddAccountForm({ busy: boolean; onBack: () => void; onSubmit: (account: Account) => void; + linkCodeGateway?: LinkCodeGatewayAccess; }): React.ReactNode { const t = useTranslations('settings.providers'); const service = serviceById(serviceId); @@ -180,6 +193,13 @@ export function AddAccountForm({ busy={busy} onSubmit={onSubmit} /> + ) : service.kind === 'endpoint' && service.credentialSource === 'linkcode-cloud' ? ( + ) : service.kind === 'endpoint' ? ( ) : ( @@ -189,6 +209,95 @@ export function AddAccountForm({ ); } +export interface LinkCodeGatewayAccess { + signedIn: boolean; + signingIn: boolean; + signIn: () => void; + createKey: (name: string) => Promise; +} + +const LinkCodeGatewayDraftSchema = z.object({ label: z.string().trim().min(1).max(80) }); +type LinkCodeGatewayDraft = z.infer; + +function LinkCodeGatewayForm({ + service, + access, + busy, + onSubmit, +}: { + service: Extract; + access: LinkCodeGatewayAccess | undefined; + busy: boolean; + onSubmit: (account: Account) => void; +}): React.ReactNode { + const t = useTranslations('settings.providers'); + const { + register, + handleSubmit, + setError, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(LinkCodeGatewayDraftSchema), + defaultValues: { label: t(`serviceName.${service.id}`) }, + }); + + if (!access?.signedIn) { + return ( +
    +

    {t('linkCodeSignInHint')}

    +
    + +
    +
    + ); + } + + return ( +
    { + try { + const key = await access.createKey(label); + const variant = service.variants[0]; + onSubmit({ + ...newAccountBase(label), + service: service.id, + credential: { type: 'auth-token', token: key }, + endpoint: { baseUrl: variant.baseUrl, protocol: variant.protocol }, + }); + } catch (error) { + setError('root', { + message: extractErrorMessage(error, false) ?? t('linkCodeGatewayError'), + }); + } + })} + > + + {t('form.label')} + + +

    {t('linkCodeGatewayOptIn')}

    + {errors.root?.message ? ( +

    + {errors.root.message} +

    + ) : null} +
    + +
    +
    + ); +} + /** Existing-account editor shown inside the account management dialog. */ export function EditAccountForm({ account, diff --git a/packages/client/workbench/src/settings/providers/catalog.ts b/packages/client/workbench/src/settings/providers/catalog.ts index 5fbfc98b7..3455caa19 100644 --- a/packages/client/workbench/src/settings/providers/catalog.ts +++ b/packages/client/workbench/src/settings/providers/catalog.ts @@ -27,6 +27,8 @@ export interface ServiceVariant { credentialType: 'api-key' | 'auth-token'; } +export const LINKCODE_GATEWAY_SERVICE_ID = 'linkcode-gateway'; + export type ServiceDescriptor = /** Delegates to an agent CLI's own login store — no secret handled by LinkCode. */ | { id: string; label: string; group: 'subscription'; kind: 'oauth'; agent: AgentKind } @@ -38,6 +40,8 @@ export type ServiceDescriptor = kind: 'endpoint'; variants: ServiceVariant[]; secretPlaceholder?: string; + /** The Desktop Cloud session mints this service's credential; users never paste it. */ + credentialSource?: 'linkcode-cloud'; } /** Free-form endpoint — the full account form. */ | { id: 'custom'; label: string; group: 'custom'; kind: 'custom' }; @@ -90,6 +94,21 @@ export const SERVICE_CATALOG: ServiceDescriptor[] = [ ], secretPlaceholder: 'xai-…', }, + { + id: LINKCODE_GATEWAY_SERVICE_ID, + label: 'LinkCode Gateway', + group: 'gateway', + kind: 'endpoint', + variants: [ + { + id: 'default', + protocol: 'openai-chat', + baseUrl: 'https://gateway.linkcode.ai/v1', + credentialType: 'auth-token', + }, + ], + credentialSource: 'linkcode-cloud', + }, { id: 'openrouter', label: 'OpenRouter', diff --git a/packages/client/workbench/src/settings/providers/providers-settings.tsx b/packages/client/workbench/src/settings/providers/providers-settings.tsx index be5df091a..ec2d7f753 100644 --- a/packages/client/workbench/src/settings/providers/providers-settings.tsx +++ b/packages/client/workbench/src/settings/providers/providers-settings.tsx @@ -3,6 +3,7 @@ import { createAndBindAccount, getAccounts, getProviderConfig, + probeAccountModels, setAccounts, setProviderConfig, } from '@linkcode/sdk'; @@ -15,12 +16,14 @@ import { DialogTitle, } from 'coss-ui/components/dialog'; import { Skeleton } from 'coss-ui/components/skeleton'; +import { extractErrorMessage } from 'foxts/extract-error-message'; import { useTranslations } from 'use-intl'; import { useAgentRuntimes } from '../../agent-runtime/hooks'; import { useAgentRuntimeOnboarding } from '../../agent-runtime/onboarding'; import { useData, useMutation } from '../../runtime/tayori'; +import type { LinkCodeGatewayAccess } from './add-flow'; import { AddAccountForm, EditAccountForm, oauthAccount, ServiceCatalogView } from './add-flow'; -import { serviceById } from './catalog'; +import { LINKCODE_GATEWAY_SERVICE_ID, serviceById } from './catalog'; import { useProvidersSettingsStore } from './store'; import { providerAccountDetailViewModel, @@ -35,7 +38,11 @@ import { * Transport-backed — it must render inside `WorkbenchProviders`, but may sit above the connection * gate, degrading to loading/error while the daemon is down. */ -export function ProvidersSettingsPanel(): React.ReactNode { +export function ProvidersSettingsPanel({ + linkCodeGateway, +}: { + linkCodeGateway?: LinkCodeGatewayAccess; +} = {}): React.ReactNode { const t = useTranslations('settings.providers'); const { data: accounts, @@ -61,6 +68,19 @@ export function ProvidersSettingsPanel(): React.ReactNode { const pool = accounts ?? []; const accountsById = new Map(pool.map((account) => [account.id, account])); const selected = view.kind === 'account' ? accountsById.get(view.accountId) : undefined; + const selectedSecret = + selected?.credential.type === 'api-key' + ? selected.credential + : selected?.credential.type === 'auth-token' + ? selected.credential + : undefined; + const models = useData( + probeAccountModels, + selectedSecret && selected?.endpoint && selected.service === LINKCODE_GATEWAY_SERVICE_ID + ? { endpoint: selected.endpoint, secret: selectedSecret } + : null, + { revalidateOnFocus: false }, + ); const busy = bindAccount.isMutating || saveAccounts.isMutating || saveProviders.isMutating; const selectedDetail = selected === undefined @@ -89,7 +109,8 @@ export function ProvidersSettingsPanel(): React.ReactNode { await saveAccounts.trigger({ accounts: [...pool, account] }); await mutateAccounts(); } - closeDialog(); + if (account.service === LINKCODE_GATEWAY_SERVICE_ID) select(account.id); + else closeDialog(); }; const handleUpdate = async (account: Account): Promise => { @@ -136,6 +157,9 @@ export function ProvidersSettingsPanel(): React.ReactNode { onSelect={select} onAdd={startAdd} onAdoptDetected={handleAdoptDetected} + onUseLinkCodeGateway={ + linkCodeGateway ? () => pickService(LINKCODE_GATEWAY_SERVICE_ID) : undefined + } /> {t('chooseService')} - + ) : ( @@ -172,6 +199,7 @@ export function ProvidersSettingsPanel(): React.ReactNode { runtimes={runtimes} onboarding={onboarding} busy={busy} + linkCodeGateway={linkCodeGateway} onBack={() => { cancelSubscriptionLogin(); backToCatalog(); @@ -194,6 +222,34 @@ export function ProvidersSettingsPanel(): React.ReactNode { { + void models.mutate(); + } + : undefined + } + onSetAccountModel={ + selected.service === LINKCODE_GATEWAY_SERVICE_ID + ? (model) => { + const { model: _oldModel, ...withoutModel } = selected; + void handleUpdate( + model === undefined ? withoutModel : { ...selected, model }, + ); + } + : undefined + } onSetBinding={handleSetBinding} onSetModel={handleSetModel} onEdit={startEdit} diff --git a/packages/host/agent-adapter/src/__tests__/gateway-error.test.ts b/packages/host/agent-adapter/src/__tests__/gateway-error.test.ts new file mode 100644 index 000000000..710a06911 --- /dev/null +++ b/packages/host/agent-adapter/src/__tests__/gateway-error.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { linkCodeGatewayError } from '../gateway-error'; + +describe('linkCodeGatewayError', () => { + it.each([ + [{ statusCode: 402, responseBody: '{"code":"insufficient_credits"}' }, 'insufficient_credits'], + [{ message: 'request failed: billing_unavailable' }, 'billing_unavailable'], + ] as const)('normalizes the frozen billing response %#', (details, code) => { + expect(linkCodeGatewayError('https://gateway.linkcode.ai/v1', details)).toMatchObject({ + code, + recoverable: true, + }); + }); + + it('does not reinterpret third-party provider failures', () => { + expect( + linkCodeGatewayError('https://api.example.com/v1', { + statusCode: 402, + responseBody: '{"code":"insufficient_credits"}', + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/host/agent-adapter/src/base.ts b/packages/host/agent-adapter/src/base.ts index 12109e055..0b0988f79 100644 --- a/packages/host/agent-adapter/src/base.ts +++ b/packages/host/agent-adapter/src/base.ts @@ -37,6 +37,8 @@ import { Listeners } from '@linkcode/transport'; import { extractErrorMessage } from 'foxts/extract-error-message'; import type { AgentAdapter, AgentStartCatalogOptions } from './adapter'; import { nextMessageId, nextRequestId } from './adapter'; +import type { ProviderErrorDetails } from './gateway-error'; +import { linkCodeGatewayError } from './gateway-error'; type PermissionResolver = (outcome: PermissionOutcome) => void; type QuestionResolver = (outcome: QuestionOutcome) => void; @@ -376,6 +378,25 @@ export abstract class BaseAgentAdapter implements AgentAdapter { this.emit({ type: 'error', message, code, recoverable }); } + protected emitProviderError(message: string, details: ProviderErrorDetails = {}): void { + const gatewayError = linkCodeGatewayError(this.opts?.config?.baseUrl, { + message, + ...details, + }); + if (gatewayError) { + this.emitError(gatewayError.message, gatewayError.code, gatewayError.recoverable); + } else { + this.emitError(message); + } + } + + protected emitGatewayError(details: ProviderErrorDetails): boolean { + const gatewayError = linkCodeGatewayError(this.opts?.config?.baseUrl, details); + if (!gatewayError) return false; + this.emitError(gatewayError.message, gatewayError.code, gatewayError.recoverable); + return true; + } + // ── Permission round-trip (resolved by a `permission-response` AgentInput, by id) ── protected requestPermission( prompt: PermissionPrompt, diff --git a/packages/host/agent-adapter/src/gateway-error.ts b/packages/host/agent-adapter/src/gateway-error.ts new file mode 100644 index 000000000..beec0e611 --- /dev/null +++ b/packages/host/agent-adapter/src/gateway-error.ts @@ -0,0 +1,40 @@ +export interface ProviderErrorDetails { + statusCode?: number | null; + code?: string; + responseBody?: string; + message?: string; +} + +type BillingCode = 'insufficient_credits' | 'billing_unavailable'; + +function gatewayHost(baseUrl: unknown): boolean { + if (typeof baseUrl !== 'string') return false; + try { + return new URL(baseUrl).hostname === 'gateway.linkcode.ai'; + } catch { + return false; + } +} + +function typedCode(details: ProviderErrorDetails): BillingCode | undefined { + const text = [details.code, details.responseBody, details.message].filter(Boolean).join(' '); + if (text.includes('insufficient_credits')) return 'insufficient_credits'; + if (text.includes('billing_unavailable')) return 'billing_unavailable'; + if (details.statusCode === 402) return 'insufficient_credits'; + if (details.statusCode === 503) return 'billing_unavailable'; + return undefined; +} + +export function linkCodeGatewayError( + baseUrl: unknown, + details: ProviderErrorDetails, +): { message: string; code: BillingCode; recoverable: true } | undefined { + if (!gatewayHost(baseUrl)) return undefined; + const code = typedCode(details); + if (!code) return undefined; + const message = + code === 'insufficient_credits' + ? 'LinkCode Gateway credits are insufficient' + : 'LinkCode Gateway billing is temporarily unavailable'; + return { message, code, recoverable: true }; +} diff --git a/packages/host/agent-adapter/src/native/claude-code.ts b/packages/host/agent-adapter/src/native/claude-code.ts index e3b5c67ff..b3e7dafde 100644 --- a/packages/host/agent-adapter/src/native/claude-code.ts +++ b/packages/host/agent-adapter/src/native/claude-code.ts @@ -1388,6 +1388,16 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { this.cancelling = false; this.turnActive = false; if (msg.subtype === 'success') { + if ( + this.emitGatewayError({ + statusCode: msg.api_error_status, + message: msg.result, + }) + ) { + this.teardown(); + if (!cancelling) this.emitStatus('idle'); + return; + } if (msg.api_error_status === 401) { this.emitError( 'Claude authentication failed — sign in to Claude', @@ -1411,7 +1421,8 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { // This non-success result is the fallout of our own onCancel()'s interrupt(), not a real // failure — consume the flag instead of surfacing it as an error. } else { - this.emitError(claudeResultErrorMessage(msg), undefined, true); + const message = claudeResultErrorMessage(msg); + this.emitProviderError(message, { message }); } this.teardown(); // A result can beat interrupt()'s control ack. Keep the gate closed until onCancel returns so diff --git a/packages/host/agent-adapter/src/native/codex/adapter.ts b/packages/host/agent-adapter/src/native/codex/adapter.ts index c85d9f0b6..451190ee7 100644 --- a/packages/host/agent-adapter/src/native/codex/adapter.ts +++ b/packages/host/agent-adapter/src/native/codex/adapter.ts @@ -925,7 +925,7 @@ export class CodexAdapter extends BaseAgentAdapter { // An auth retirement rejects any in-flight turn/start when it closes the server; it already // finalized the turn and told the auth story — unwinding again would double-emit idle. if (!this.authFailed) { - this.emitError(extractErrorMessage(err) ?? 'codex: turn failed to start'); + this.emitProviderError(extractErrorMessage(err) ?? 'codex: turn failed to start'); this.teardown(); this.emitStatus('idle'); } @@ -1106,7 +1106,11 @@ export class CodexAdapter extends BaseAgentAdapter { const message = error ? stringField(error, 'message') : undefined; // Turn-fatal errors also arrive as turn/completed(status failed), which finalizes; this // event alone (e.g. a retryable stream hiccup) must not tear the turn down. - this.emitError(message ?? 'Unknown error'); + this.emitProviderError(message ?? 'Unknown error', { + statusCode: error ? numberField(error, 'statusCode') : undefined, + code: error ? stringField(error, 'code') : undefined, + responseBody: error ? stringField(error, 'responseBody') : undefined, + }); break; } default: @@ -1123,7 +1127,14 @@ export class CodexAdapter extends BaseAgentAdapter { const status = stringField(turn, 'status'); if (status === 'failed') { const error = recordField(turn, 'error'); - this.emitError((error && stringField(error, 'message')) ?? 'Codex returned an error'); + this.emitProviderError( + (error && stringField(error, 'message')) ?? 'Codex returned an error', + { + statusCode: error ? numberField(error, 'statusCode') : undefined, + code: error ? stringField(error, 'code') : undefined, + responseBody: error ? stringField(error, 'responseBody') : undefined, + }, + ); } else if (status === 'interrupted') { this.emitStop('cancelled'); } else { diff --git a/packages/host/agent-adapter/src/native/opencode/adapter.ts b/packages/host/agent-adapter/src/native/opencode/adapter.ts index 15dbef1f7..8b4f7371a 100644 --- a/packages/host/agent-adapter/src/native/opencode/adapter.ts +++ b/packages/host/agent-adapter/src/native/opencode/adapter.ts @@ -973,7 +973,15 @@ export class OpenCodeAdapter extends BaseAgentAdapter { ); return; } - this.emitError(message); + const data = error.data as { + statusCode?: number; + responseBody?: string; + message?: string; + }; + this.emitProviderError(message, { + statusCode: data.statusCode, + responseBody: data.responseBody, + }); } /** Answer a `permission.asked` through the shared permission round-trip: Allow→`once`, diff --git a/packages/host/agent-adapter/src/native/pi/adapter.ts b/packages/host/agent-adapter/src/native/pi/adapter.ts index 833e1fa4b..0038dfca9 100644 --- a/packages/host/agent-adapter/src/native/pi/adapter.ts +++ b/packages/host/agent-adapter/src/native/pi/adapter.ts @@ -623,7 +623,7 @@ export class PiAdapter extends BaseAgentAdapter { this.teardown(); if (this.finalOutcome.stopReason === 'aborted') this.emitStop('cancelled'); else if (this.finalOutcome.stopReason === 'error') { - this.emitError(this.finalOutcome.errorMessage ?? 'Pi agent failed'); + this.emitProviderError(this.finalOutcome.errorMessage ?? 'Pi agent failed'); } else this.emitStop('end_turn'); this.emitStatus('idle'); } diff --git a/packages/host/assets/src/pi-closure.gen.ts b/packages/host/assets/src/pi-closure.gen.ts index 42702498b..8d43fd66e 100644 --- a/packages/host/assets/src/pi-closure.gen.ts +++ b/packages/host/assets/src/pi-closure.gen.ts @@ -1088,9 +1088,9 @@ const packages: NpmClosure['packages'] = [ }, { name: 'jose', - version: '6.2.4', + version: '6.2.7', integrity: - 'sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==', + 'sha512-hq1OB1bALKfydZNoViyg6hPVGV4i93ny9Op+n4zP5RSf7SCZEXa/TsG2O3IEr7+WlHRTPnpqDmHfMH6qXAD60w==', path: 'node_modules/jose', }, { @@ -1622,7 +1622,7 @@ const packages: NpmClosure['packages'] = [ export const PI_CLOSURE: NpmClosure = { version: '0.80.6', - revision: 'b77bf10e9a04fe3f', + revision: 'd6ff0228b84fa10c', entry: 'node_modules/@earendil-works/pi-coding-agent/dist/index.js', packages, }; diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index d92d2870d..392942c11 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -136,6 +136,11 @@ export const en = { compacting: 'Compacting context…', compacted: 'Context compacted', compactedTokens: '{pre} → {post} tokens', + insufficientCreditsTitle: 'LinkCode credits needed', + insufficientCreditsHint: 'Top up your balance, then retry this message.', + topUpCredits: 'Top up credits', + billingUnavailableTitle: 'LinkCode billing is temporarily unavailable', + billingUnavailableHint: 'Your request was not sent to the model. Please retry shortly.', minimap: { label: 'Conversation navigation', turn: 'Turn {index}', @@ -1024,6 +1029,15 @@ export const en = { noMatches: 'No matching accounts.', emptyTitle: 'No accounts yet', emptyHint: 'Add a subscription, API key, AI gateway, or custom endpoint.', + linkCodeUseGateway: 'Use LinkCode Gateway', + linkCodeGatewayOptIn: + 'Uses your LinkCode credits. It stays separate from your API keys and is never selected automatically.', + linkCodeSignInHint: 'Sign in to LinkCode to create a Gateway credential.', + linkCodeSignIn: 'Sign in to LinkCode', + linkCodeConnecting: 'Connecting…', + linkCodeGatewayError: 'Could not connect LinkCode Gateway.', + modelLoadError: 'Could not load Gateway models.', + retryModels: 'Retry', detected: 'Detected', accountMenu: 'Account actions', edit: 'Edit account', @@ -1077,6 +1091,7 @@ export const en = { openrouter: 'OpenRouter', 'vercel-gateway': 'Vercel AI Gateway', 'cloudflare-gateway': 'Cloudflare AI Gateway', + 'linkcode-gateway': 'LinkCode Gateway', custom: 'Custom endpoint', }, serviceHint: { @@ -1088,6 +1103,7 @@ export const en = { openrouter: 'Aggregation gateway, both protocol shapes', 'vercel-gateway': 'Server-side translation, both protocol shapes', 'cloudflare-gateway': 'Pass-through gateway, needs Account / Gateway ID', + 'linkcode-gateway': 'Use LinkCode credits without bringing an API key', custom: 'Any base URL + protocol', }, variantName: { diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 766024d73..6e8691e2e 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -132,6 +132,11 @@ export const zhCN = { compacting: '正在压缩上下文…', compacted: '上下文已压缩', compactedTokens: '{pre} → {post} tokens', + insufficientCreditsTitle: '需要 LinkCode 额度', + insufficientCreditsHint: '充值后即可安全重试这条消息。', + topUpCredits: '充值额度', + billingUnavailableTitle: 'LinkCode 计费服务暂时不可用', + billingUnavailableHint: '请求尚未发送给模型,请稍后重试。', minimap: { label: '对话导航', turn: '第 {index} 轮', @@ -998,6 +1003,14 @@ export const zhCN = { noMatches: '没有匹配的账号。', emptyTitle: '尚未添加账号', emptyHint: '添加订阅、API 密钥、AI 网关或自定义端点。', + linkCodeUseGateway: '使用 LinkCode Gateway', + linkCodeGatewayOptIn: '使用你的 LinkCode 额度,与自带 API 密钥相互独立,且绝不会被自动选中。', + linkCodeSignInHint: '登录 LinkCode 以创建 Gateway 凭证。', + linkCodeSignIn: '登录 LinkCode', + linkCodeConnecting: '正在连接…', + linkCodeGatewayError: '无法连接 LinkCode Gateway。', + modelLoadError: '无法加载 Gateway 模型。', + retryModels: '重试', detected: '检测到', accountMenu: '账号操作', edit: '编辑账号', @@ -1051,6 +1064,7 @@ export const zhCN = { openrouter: 'OpenRouter', 'vercel-gateway': 'Vercel AI Gateway', 'cloudflare-gateway': 'Cloudflare AI Gateway', + 'linkcode-gateway': 'LinkCode Gateway', custom: '自定义端点', }, serviceHint: { @@ -1062,6 +1076,7 @@ export const zhCN = { openrouter: '聚合网关,双协议形态', 'vercel-gateway': '服务端翻译,双协议形态', 'cloudflare-gateway': '透传网关,需 Account / Gateway ID', + 'linkcode-gateway': '无需自带 API 密钥,直接使用 LinkCode 额度', custom: '任意 Base URL + 协议', }, variantName: { diff --git a/packages/presentation/ui/src/chat/conversation-view.tsx b/packages/presentation/ui/src/chat/conversation-view.tsx index 3334fa5b2..93338444a 100644 --- a/packages/presentation/ui/src/chat/conversation-view.tsx +++ b/packages/presentation/ui/src/chat/conversation-view.tsx @@ -25,6 +25,8 @@ export interface ConversationViewProps { TerminalBlockComponent?: React.ComponentType<{ terminalId: string }>; /** Opens this turn's workspace changes in the host review surface. */ onReviewChanges?: () => void; + /** Opens the host-owned LinkCode billing surface for a typed gateway credit error. */ + onOpenBilling?: () => void; /** Receives the conversation scroll controller for composer-driven positioning. */ scrollContextRef?: React.Ref; } @@ -37,6 +39,7 @@ export function ConversationView({ modelName, TerminalBlockComponent, onReviewChanges, + onOpenBilling, scrollContextRef, }: ConversationViewProps): React.ReactNode { const t = useTranslations('workbench.conversation'); @@ -124,6 +127,7 @@ export function ConversationView({ TerminalBlockComponent={TerminalBlockComponent} onExpandTask={setExpandedTaskId} onReviewChanges={onReviewChanges} + onOpenBilling={onOpenBilling} /> )} diff --git a/packages/presentation/ui/src/chat/error-message.tsx b/packages/presentation/ui/src/chat/error-message.tsx index 01b739f25..159f53c59 100644 --- a/packages/presentation/ui/src/chat/error-message.tsx +++ b/packages/presentation/ui/src/chat/error-message.tsx @@ -1,4 +1,6 @@ +import { Button } from 'coss-ui/components/button'; import { TriangleAlertIcon } from 'lucide-react'; +import { useTranslations } from 'use-intl'; import { cn } from '../lib/cn'; import { normalizeErrorMessage } from '../lib/error-text'; import { Message, MessageContent } from './message'; @@ -8,11 +10,16 @@ export function ErrorMessage({ message, code, recoverable, + onOpenBilling, }: { message: string; code?: string; recoverable: boolean; + onOpenBilling?: () => void; }): React.ReactNode { + const t = useTranslations('workbench.conversation'); + const insufficientCredits = code === 'insufficient_credits'; + const billingUnavailable = code === 'billing_unavailable'; return ( @@ -23,9 +30,27 @@ export function ErrorMessage({ )} > -
    - {normalizeErrorMessage(message)} - {code ? ({code}) : null} +
    + {insufficientCredits || billingUnavailable ? ( +
    +
    + {t(insufficientCredits ? 'insufficientCreditsTitle' : 'billingUnavailableTitle')} +
    +
    + {t(insufficientCredits ? 'insufficientCreditsHint' : 'billingUnavailableHint')} +
    + {insufficientCredits && onOpenBilling ? ( + + ) : null} +
    + ) : ( +
    + {normalizeErrorMessage(message)} + {code ? ({code}) : null} +
    + )}
    diff --git a/packages/presentation/ui/src/chat/turn-segment-view.tsx b/packages/presentation/ui/src/chat/turn-segment-view.tsx index 39301d4c5..625ed7695 100644 --- a/packages/presentation/ui/src/chat/turn-segment-view.tsx +++ b/packages/presentation/ui/src/chat/turn-segment-view.tsx @@ -41,6 +41,8 @@ export interface TurnSegmentViewProps { onExpandTask: (toolCallId: string) => void; /** Opens this turn's workspace changes in the host review surface. */ onReviewChanges?: () => void; + /** Opens the host-owned LinkCode billing surface for a typed gateway credit error. */ + onOpenBilling?: () => void; } /** @@ -62,6 +64,7 @@ export function TurnSegmentView({ TerminalBlockComponent, onExpandTask, onReviewChanges, + onOpenBilling, }: TurnSegmentViewProps): React.ReactNode { // Children leave the top-level timeline (they render inside their SubagentCard), but the // turn's edit rollup still counts them; the copyable reply text is the main agent's only. @@ -196,6 +199,7 @@ export function TurnSegmentView({ message={item.message} code={item.code} recoverable={item.recoverable} + onOpenBilling={onOpenBilling} /> ); default: diff --git a/packages/presentation/ui/src/shell/conversation-surface.tsx b/packages/presentation/ui/src/shell/conversation-surface.tsx index 87c8679b4..3f5af2052 100644 --- a/packages/presentation/ui/src/shell/conversation-surface.tsx +++ b/packages/presentation/ui/src/shell/conversation-surface.tsx @@ -67,6 +67,8 @@ export interface ConversationSurfaceProps { onOpenVideoPreview?: (path: string) => void; /** Opens workspace changes in the shell's review surface. */ onReviewChanges?: () => void; + /** Opens the host-owned LinkCode billing surface for a typed gateway credit error. */ + onOpenBilling?: () => void; /** Hosts inline content on the daemon's ephemeral origin (sandboxed html previews). */ onHostArtifact?: (content: string, mimeType: string) => Promise<{ url: string }>; /** Promotes a hosted/preview URL to the shell's browser surface; default: new tab. */ @@ -101,6 +103,7 @@ export function ConversationSurface({ onOpenFileArtifact, onOpenVideoPreview, onReviewChanges, + onOpenBilling, onHostArtifact, onOpenPreviewUrl, onPickAttachmentFiles, @@ -135,6 +138,7 @@ export function ConversationSurface({ scrollContextRef={conversationScrollRef} TerminalBlockComponent={TerminalBlockComponent} onReviewChanges={onReviewChanges} + onOpenBilling={onOpenBilling} />
    diff --git a/packages/presentation/ui/src/shell/providers/account-detail.tsx b/packages/presentation/ui/src/shell/providers/account-detail.tsx index daaf86c5a..545dee984 100644 --- a/packages/presentation/ui/src/shell/providers/account-detail.tsx +++ b/packages/presentation/ui/src/shell/providers/account-detail.tsx @@ -1,4 +1,4 @@ -import type { AgentKind } from '@linkcode/schema'; +import type { AccountModel, AgentKind } from '@linkcode/schema'; import { AlertDialog, AlertDialogClose, @@ -19,6 +19,7 @@ import { SelectTrigger, SelectValue, } from 'coss-ui/components/select'; +import { Skeleton } from 'coss-ui/components/skeleton'; import { Switch } from 'coss-ui/components/switch'; import { useClipboard } from 'foxact/use-clipboard'; import { @@ -101,6 +102,11 @@ export function AccountDetail({ busy, onSetBinding, onSetModel, + accountModels, + accountModelsLoading = false, + accountModelsError, + onReloadAccountModels, + onSetAccountModel, onEdit, onRemove, }: { @@ -109,6 +115,11 @@ export function AccountDetail({ busy: boolean; onSetBinding: (kind: AgentKind, accountId: string | undefined) => void; onSetModel: (kind: AgentKind, model: string | undefined) => void; + accountModels?: AccountModel[]; + accountModelsLoading?: boolean; + accountModelsError?: string; + onReloadAccountModels?: () => void; + onSetAccountModel?: (model: string | undefined) => void; onEdit: () => void; onRemove: () => void; }): React.ReactNode { @@ -191,7 +202,32 @@ export function AccountDetail({ ) : null} - {account.accountModel !== undefined && account.accountModel !== '' ? ( + {onSetAccountModel ? ( + + {accountModelsLoading && accountModels === undefined ? ( + + ) : accountModelsError ? ( +
    + + {accountModelsError} + + +
    + ) : ( + ({ + id: model.id, + label: model.label ?? model.id, + }))} + value={account.accountModel ?? ''} + disabled={busy || accountModelsLoading} + onChange={(model) => onSetAccountModel(model === '' ? undefined : model)} + /> + )} +
    + ) : account.accountModel !== undefined && account.accountModel !== '' ? ( {account.accountModel} diff --git a/packages/presentation/ui/src/shell/providers/account-master-list.tsx b/packages/presentation/ui/src/shell/providers/account-master-list.tsx index 101ef3b42..ecff382e3 100644 --- a/packages/presentation/ui/src/shell/providers/account-master-list.tsx +++ b/packages/presentation/ui/src/shell/providers/account-master-list.tsx @@ -44,12 +44,15 @@ export function AccountList({ onSelect, onAdd, onAdoptDetected, + onUseLinkCodeGateway, }: ProviderAccountListViewModel & { loading: boolean; onSelect: (id: string) => void; onAdd: () => void; /** One-click adopt a detected CLI login into the pool (a suggestion card, not a pool member). */ onAdoptDetected: (serviceId: string) => void; + /** Explicit first-party path shown only when no third-party account or login is available. */ + onUseLinkCodeGateway?: () => void; }): React.ReactNode { const t = useTranslations('settings.providers'); const tAgent = useTranslations('workbench.agentKind'); @@ -160,9 +163,14 @@ export function AccountList({ ) : null} {!loading && needle === '' && accounts.length === 0 && detectedLogins.length === 0 ? ( -
  • +
  • {t('emptyTitle')} {t('emptyHint')} + {onUseLinkCodeGateway ? ( + + ) : null}
  • ) : null} {!loading && needle === '' diff --git a/packages/presentation/ui/src/shell/service-icon.tsx b/packages/presentation/ui/src/shell/service-icon.tsx index b05459e85..da983d872 100644 --- a/packages/presentation/ui/src/shell/service-icon.tsx +++ b/packages/presentation/ui/src/shell/service-icon.tsx @@ -1,4 +1,5 @@ /// + import AnthropicGlyph from '~icons/lobe-icons/anthropic'; import ClaudeColorGlyph from '~icons/lobe-icons/claude-color'; import CloudflareColorGlyph from '~icons/lobe-icons/cloudflare-color'; @@ -8,6 +9,18 @@ import VercelGlyph from '~icons/lobe-icons/vercel'; import XaiGlyph from '~icons/lobe-icons/xai'; import { cn } from '../lib/cn'; +function LinkCodeGlyph(props: React.SVGProps): React.ReactNode { + return ( + + + + + + + + ); +} + /** Brand glyphs keyed by `Account.service` (a string join key, like `AgentKind` for `AgentIcon`); * color variants only where lobe-icons ships one, the rest render in currentColor. A missing key * deliberately falls back to label initials, so custom services need no wiring. */ @@ -48,7 +61,13 @@ export function ServiceIcon({ className, )} > - {Glyph ? : initials(label)} + {service === 'linkcode-gateway' ? ( + + ) : Glyph ? ( + + ) : ( + initials(label) + )}
    ); } diff --git a/packages/presentation/ui/src/shell/shell-frame.tsx b/packages/presentation/ui/src/shell/shell-frame.tsx index 007e0ba13..65717bfeb 100644 --- a/packages/presentation/ui/src/shell/shell-frame.tsx +++ b/packages/presentation/ui/src/shell/shell-frame.tsx @@ -68,6 +68,8 @@ export interface ShellFrameProps onContinueUnverified?: (kind: AgentKind) => void; /** Opens Providers settings at the signed-out agent's setup flow. */ onOpenProviderSettings?: (kind: AgentKind) => void; + /** Opens the host-owned LinkCode billing surface for a typed gateway credit error. */ + onOpenBilling?: () => void; conversation: ConversationViewModel; respondingRequestIds: ReadonlySet; responseErrors?: ReadonlyMap; @@ -132,6 +134,7 @@ export function ShellFrame({ onDownloadAgent, onContinueUnverified, onOpenProviderSettings, + onOpenBilling, conversation, respondingRequestIds, responseErrors, @@ -242,6 +245,7 @@ export function ShellFrame({ cwd={active?.cwd} runtimeCues={runtimeCues} onOpenProviderSettings={onOpenProviderSettings} + onOpenBilling={onOpenBilling} respondingRequestIds={respondingRequestIds} responseErrors={responseErrors} TerminalBlockComponent={TerminalBlockComponent} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a28be5a64..bcf4638f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -280,6 +280,9 @@ importers: apps/desktop: dependencies: + '@better-auth/api-key': + specifier: 1.7.0-rc.2 + version: 1.7.0-rc.2(@better-auth/core@1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.7)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(better-auth@1.7.0-rc.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.29.4))(react-dom@19.3.0-canary-96fcba90-20260728(react@19.3.0-canary-96fcba90-20260728))(react@19.3.0-canary-96fcba90-20260728)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(happy-dom@20.10.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(yaml@2.9.0))))(better-call@1.3.7(zod@4.4.3)) '@better-auth/electron': specifier: 1.7.0-rc.2 version: 1.7.0-rc.2(@better-auth/core@1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.7)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.29.4))(react-dom@19.3.0-canary-96fcba90-20260728(react@19.3.0-canary-96fcba90-20260728))(react@19.3.0-canary-96fcba90-20260728)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(happy-dom@20.10.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(yaml@2.9.0))))(better-call@1.3.7(zod@4.4.3))(electron@43.2.0) @@ -2012,6 +2015,14 @@ packages: '@types/react': optional: true + '@better-auth/api-key@1.7.0-rc.2': + resolution: {integrity: sha512-7fyxJhOKKWQ+TtLTvCjxfZU+blE+jvwe4rzunz1RfOafuWEukE2ofRQ8uOMG4ZUYG6SO+tVKxsFDnbJZj2Da3g==} + peerDependencies: + '@better-auth/core': ^1.7.0-rc.2 + '@better-auth/utils': 0.4.2 + better-auth: ^1.7.0-rc.2 + better-call: 1.3.7 + '@better-auth/core@1.7.0-rc.2': resolution: {integrity: sha512-NreNGg68j4qUVVYTcC1DtvRTwSJdCavH5igrMyTO5ghZxnzL4G539uRIzOZmJ64MLzOyOwzWH+JHqpVaj0ZRxw==} peerDependencies: @@ -8347,9 +8358,6 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - jose@6.2.4: - resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==} - jose@6.2.7: resolution: {integrity: sha512-hq1OB1bALKfydZNoViyg6hPVGV4i93ny9Op+n4zP5RSf7SCZEXa/TsG2O3IEr7+WlHRTPnpqDmHfMH6qXAD60w==} @@ -12332,6 +12340,14 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@better-auth/api-key@1.7.0-rc.2(@better-auth/core@1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.7)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(better-auth@1.7.0-rc.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.29.4))(react-dom@19.3.0-canary-96fcba90-20260728(react@19.3.0-canary-96fcba90-20260728))(react@19.3.0-canary-96fcba90-20260728)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(happy-dom@20.10.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(yaml@2.9.0))))(better-call@1.3.7(zod@4.4.3))': + dependencies: + '@better-auth/core': 1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.7)(kysely@0.29.4)(nanostores@1.4.2) + '@better-auth/utils': 0.4.2 + better-auth: 1.7.0-rc.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.29.4))(react-dom@19.3.0-canary-96fcba90-20260728(react@19.3.0-canary-96fcba90-20260728))(react@19.3.0-canary-96fcba90-20260728)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(happy-dom@20.10.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.23.1)(yaml@2.9.0))) + better-call: 1.3.7(zod@4.4.3) + zod: 4.4.3 + '@better-auth/core@1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.7)(kysely@0.29.4)(nanostores@1.4.2)': dependencies: '@better-auth/utils': 0.4.2 @@ -13941,7 +13957,7 @@ snapshots: express: 5.2.1 express-rate-limit: 8.6.1(express@5.2.1) hono: 4.12.32 - jose: 6.2.4 + jose: 6.2.7 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -18972,8 +18988,6 @@ snapshots: jiti@2.7.0: {} - jose@6.2.4: {} - jose@6.2.7: {} joycon@3.1.1: {}