-
{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 (
-
- );
-}
-
-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
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
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')}
+
+
+ {access?.signingIn ? t('linkCodeConnecting') : t('linkCodeSignIn')}
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
+
/** 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
+ }
/>
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 ? (
+