diff --git a/.changeset/30896-connect-agent-client-picker.md b/.changeset/30896-connect-agent-client-picker.md
new file mode 100644
index 00000000000..6b309858636
--- /dev/null
+++ b/.changeset/30896-connect-agent-client-picker.md
@@ -0,0 +1,11 @@
+---
+'@object-ui/app-shell': patch
+'@object-ui/i18n': patch
+---
+
+feat(app-shell): Connect an Agent asks which AI client you use and shows only that client's steps
+
+Clause-②: no
+Metadata-protocol: none
+
+The Connect an Agent page no longer lays out every client at once. Under the endpoint and the SKILL.md download, which stay on top for everyone, the page asks which AI assistant you use (Claude, Codex, Cursor, WorkBuddy, Doubao Work, QwenWork) and shows that one's steps; switching is one click, and this browser remembers your choice (`os:pref:connect.client` in the console's workspace preference store). The API key form for scripts and CI moves out of the client list into a collapsed "Advanced: scripts and CI" section at the bottom. The three new strings ship in all ten console languages.
diff --git a/ui/packages/app-shell/src/console/connect/ConnectAgentWidget.keyName-13355.test.tsx b/ui/packages/app-shell/src/console/connect/ConnectAgentWidget.keyName-13355.test.tsx
index 6f3b51c238a..2f33d537a78 100644
--- a/ui/packages/app-shell/src/console/connect/ConnectAgentWidget.keyName-13355.test.tsx
+++ b/ui/packages/app-shell/src/console/connect/ConnectAgentWidget.keyName-13355.test.tsx
@@ -17,6 +17,11 @@
* - §3 confirming posts exactly the typed (trimmed) name, never a default, and
* the key is revealed once.
*
+ * The key form lives in the collapsed "Advanced: scripts and CI" section, so
+ * those cases open it first. The last block holds the page's shape: the
+ * endpoint and SKILL.md on top, a picker that shows one client's steps and
+ * remembers the choice on this browser, and the key form collapsed.
+ *
* `fetch` is stubbed by path; `t` echoes the KEY (with the `name` option
* appended when one is passed), as the sibling suites do, so the assertions
* read which string the widget asked for and with which name.
@@ -49,6 +54,7 @@ function keyPosts(): unknown[] {
}
beforeEach(() => {
+ window.localStorage.clear();
fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes('/api/v1/discovery')) {
@@ -74,11 +80,35 @@ afterEach(() => {
vi.unstubAllGlobals();
});
+/** Render and wait for discovery to settle: the client picker is up. */
+async function renderPage() {
+ const view = render();
+ await screen.findByRole('tablist');
+ return view;
+}
+
+/** The API key form sits in the collapsed Advanced section; open it and return its name box. */
async function renderSettled() {
- render();
+ await renderPage();
+ fireEvent.click(screen.getByRole('button', { name: /«connectAgent\.advanced\.title»/ }));
return screen.findByPlaceholderText('«connectAgent.apiKey.namePlaceholder»');
}
+/** Pick a client in the picker (Radix tabs activate on mouse-down). */
+function pickClient(label: string) {
+ fireEvent.mouseDown(screen.getByRole('tab', { name: label }), { button: 0 });
+}
+
+const PICKER_LABELS = [
+ 'Claude',
+ 'Codex',
+ 'Cursor',
+ 'WorkBuddy',
+ // Product names a pack may write in its own script (zh: 豆包工作 / 千问办公).
+ '«connectAgent.doubao.title»',
+ '«connectAgent.qwenwork.title»',
+];
+
const createButton = () => screen.getByRole('button', { name: '«connectAgent.apiKey.mint»' });
describe('Connect an Agent mints a key only under a typed, confirmed name (objectui#13355)', () => {
@@ -143,53 +173,48 @@ describe('Connect an Agent offers the Chinese clients after the international on
const copyable = () =>
Array.from(document.querySelectorAll('code, dd')).map((el) => el.textContent ?? '');
- it('renders one card list, international clients first, then WorkBuddy, Doubao Work and QwenWork', async () => {
- await renderSettled();
- const order = [
- // One Claude card carries both ways in: the connector, then the CLI.
- 'Claude',
- 'claude.ai / Claude Desktop',
- 'Claude Code',
- 'Codex',
- 'Cursor',
- 'WorkBuddy',
- // Product names a pack may write in its own script (zh: 豆包工作 / 千问办公).
- '«connectAgent.doubao.title»',
- '«connectAgent.qwenwork.title»',
- ].map((title) => screen.getByText(title));
- for (let i = 1; i < order.length; i++) {
- const follows = order[i - 1].compareDocumentPosition(order[i]) & Node.DOCUMENT_POSITION_FOLLOWING;
- expect(follows, `${order[i].textContent} renders after ${order[i - 1].textContent}`).toBeTruthy();
- }
- // One list: no group headings split the cards.
- expect(document.querySelectorAll('h3')).toHaveLength(0);
+ it('offers one picker, international clients first, then WorkBuddy, Doubao Work and QwenWork', async () => {
+ await renderPage();
+ expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual(PICKER_LABELS);
for (const dropped of ['DuMate', 'VS Code / Copilot']) {
expect(screen.queryByText(dropped)).not.toBeInTheDocument();
}
- for (const key of ['codex', 'workbuddy', 'doubao', 'qwenwork']) {
- expect(screen.getByText(`«connectAgent.${key}.body»`)).toBeInTheDocument();
+ // Claude carries both ways in: the connector, then the CLI.
+ const desktop = screen.getByText('claude.ai / Claude Desktop');
+ const code = screen.getByText('Claude Code');
+ expect(desktop.compareDocumentPosition(code) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ for (const [label, key] of [
+ ['Codex', 'codex'],
+ ['WorkBuddy', 'workbuddy'],
+ ['«connectAgent.doubao.title»', 'doubao'],
+ ['«connectAgent.qwenwork.title»', 'qwenwork'],
+ ]) {
+ pickClient(label);
+ expect(await screen.findByText(`«connectAgent.${key}.body»`)).toBeInTheDocument();
expect(screen.getByText(`«connectAgent.${key}.hint»`)).toBeInTheDocument();
}
});
it('hands each client its own transport spelling for this deployment\'s MCP URL', async () => {
- await renderSettled();
- const values = copyable();
+ await renderPage();
const json = (type: string) =>
JSON.stringify({ mcpServers: { objectstack: { type, url: MCP_URL } } }, null, 2);
// WorkBuddy's mcp.json and QwenWork's JSON import spell Streamable HTTP differently.
- expect(values).toContain(json('streamableHttp'));
- expect(values).toContain(json('streamable-http'));
- // The Codex app and Doubao Work are dialog forms: name, transport and URL, each copyable.
- expect(values.filter((v) => v === 'objectstack')).toHaveLength(2);
- expect(values).toContain('Streamable HTTP');
- expect(values).toContain('HTTP');
- expect(values.filter((v) => v === MCP_URL).length).toBeGreaterThanOrEqual(4);
- // The Codex card describes the app's form, not a config.toml snippet.
- expect(values.some((v) => v.includes('[mcp_servers.objectstack]'))).toBe(false);
+ pickClient('WorkBuddy');
+ await waitFor(() => expect(copyable()).toContain(json('streamableHttp')));
+ pickClient('«connectAgent.qwenwork.title»');
+ await waitFor(() => expect(copyable()).toContain(json('streamable-http')));
+ // The Codex app and Doubao Work are dialog forms: name, transport and URL, each copyable
+ // (after the endpoint's own URL on top).
+ pickClient('Codex');
+ await waitFor(() => expect(copyable()).toEqual([MCP_URL, 'objectstack', 'Streamable HTTP', MCP_URL]));
+ // The Codex steps describe the app's form, not a config.toml snippet.
+ expect(copyable().some((v) => v.includes('[mcp_servers.objectstack]'))).toBe(false);
+ pickClient('«connectAgent.doubao.title»');
+ await waitFor(() => expect(copyable()).toEqual([MCP_URL, 'objectstack', 'HTTP', MCP_URL]));
});
- it('reveals a minted key in the Authorization: Bearer form the cards point to', async () => {
+ it('reveals a minted key in the Authorization: Bearer form the steps point to', async () => {
const input = await renderSettled();
fireEvent.change(input, { target: { value: 'ci-agent' } });
fireEvent.click(createButton());
@@ -202,3 +227,55 @@ describe('Connect an Agent offers the Chinese clients after the international on
expect(screen.getByText(`x-api-key: ${MINTED_KEY}`)).toBeInTheDocument();
});
});
+
+describe('Connect an Agent asks which client the person uses and shows only those steps', () => {
+ const PREFERENCE_KEY = 'os:pref:connect.client';
+ const CLAUDE_CODE = /^claude mcp add --transport http objectstack /;
+
+ it('§1 the endpoint and SKILL.md stay on top; one client\'s steps show; the API key form is collapsed', async () => {
+ await renderPage();
+ // On top, for everyone: the endpoint and the skill download.
+ const endpoint = screen.getByText('«connectAgent.url.title»');
+ const picker = screen.getByText('«connectAgent.client.title»');
+ expect(endpoint.compareDocumentPosition(picker) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(screen.getByRole('link', { name: /«connectAgent\.url\.downloadSkill»/ })).toHaveAttribute(
+ 'download',
+ 'SKILL.md',
+ );
+ // A person who never chose sees Claude, and only Claude.
+ expect(screen.getByRole('tab', { name: 'Claude' })).toHaveAttribute('aria-selected', 'true');
+ expect(screen.getByText(CLAUDE_CODE)).toBeInTheDocument();
+ for (const key of ['codex', 'cursor', 'workbuddy', 'doubao', 'qwenwork']) {
+ expect(screen.queryByText(`«connectAgent.${key}.body»`)).not.toBeInTheDocument();
+ }
+ // Scripts and CI: the API key form waits behind the collapsed Advanced section, after the picker.
+ const advanced = screen.getByRole('button', { name: /«connectAgent\.advanced\.title»/ });
+ expect(advanced).toHaveAttribute('aria-expanded', 'false');
+ expect(picker.compareDocumentPosition(advanced) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(screen.queryByText('«connectAgent.apiKey.title»')).not.toBeInTheDocument();
+ expect(screen.queryByPlaceholderText('«connectAgent.apiKey.namePlaceholder»')).not.toBeInTheDocument();
+ fireEvent.click(advanced);
+ expect(advanced).toHaveAttribute('aria-expanded', 'true');
+ expect(await screen.findByText('«connectAgent.apiKey.title»')).toBeInTheDocument();
+ });
+
+ it('§2 one click switches clients, and this browser remembers the choice', async () => {
+ const first = await renderPage();
+ pickClient('Cursor');
+ expect(await screen.findByText('«connectAgent.cursor.body»')).toBeInTheDocument();
+ expect(screen.queryByText(CLAUDE_CODE)).not.toBeInTheDocument();
+ expect(JSON.parse(window.localStorage.getItem(PREFERENCE_KEY) ?? 'null')).toEqual({ client: 'cursor', v: 1 });
+
+ first.unmount();
+ await renderPage();
+ expect(screen.getByRole('tab', { name: 'Cursor' })).toHaveAttribute('aria-selected', 'true');
+ expect(screen.getByText('«connectAgent.cursor.body»')).toBeInTheDocument();
+ });
+
+ it('§3 a stored client the page does not offer reads as no choice', async () => {
+ window.localStorage.setItem(PREFERENCE_KEY, JSON.stringify({ client: 'copilot', v: 1 }));
+ await renderPage();
+ expect(screen.getByRole('tab', { name: 'Claude' })).toHaveAttribute('aria-selected', 'true');
+ expect(screen.getByText(CLAUDE_CODE)).toBeInTheDocument();
+ });
+});
diff --git a/ui/packages/app-shell/src/console/connect/ConnectAgentWidget.tsx b/ui/packages/app-shell/src/console/connect/ConnectAgentWidget.tsx
index 37a73b61682..a5b9a50dc40 100644
--- a/ui/packages/app-shell/src/console/connect/ConnectAgentWidget.tsx
+++ b/ui/packages/app-shell/src/console/connect/ConnectAgentWidget.tsx
@@ -10,20 +10,24 @@
* for people who don't know what MCP is:
*
* 1. the environment's MCP URL (from `/discovery` → `routes.mcp`) + copy,
- * 2. per-client connect cards with copy-paste-ready content — one Claude
- * card (claude.ai / Claude Desktop and Claude Code), the Codex app and
- * Cursor deeplink first, then the Chinese clients WorkBuddy, Doubao Work
- * and QwenWork.
- * OAuth is the default story on every card (the deployment is its own
+ * 2. the portable SKILL.md download (`GET /api/v1/mcp/skill`), beside the
+ * URL, for everyone,
+ * 3. a picker that asks which client the person uses and shows only that
+ * client's steps — Claude (claude.ai / Claude Desktop and Claude Code),
+ * the Codex app, Cursor, then the Chinese clients WorkBuddy, Doubao Work
+ * and QwenWork. The choice is the reader's, kept on this browser
+ * (`CONNECT_CLIENT_PREFERENCE`); switching is one click.
+ * OAuth is the default story for every client (the deployment is its own
* authorization server; you sign in as yourself); the Chinese clients'
- * cards name the API-key header as the fallback for a client version
+ * steps name the API-key header as the fallback for a client version
* without the OAuth flow,
- * 3. the portable SKILL.md download (`GET /api/v1/mcp/skill`),
* 4. API-key minting for headless callers (CI, machine accounts) via the
- * existing `POST /api/v1/keys` — shown once, copy-once. As on Setup →
- * API Keys → Create Key, the key must be named (Create stays disabled
- * on a blank name; nothing is minted under a default name), and an
- * in-app confirm naming the key stands between the click and the mint.
+ * existing `POST /api/v1/keys`, in a collapsed "Advanced: scripts and CI"
+ * section at the bottom — no client, so not a peer of the clients.
+ * Shown once, copy-once. As on Setup → API Keys → Create Key, the key
+ * must be named (Create stays disabled on a blank name; nothing is
+ * minted under a default name), and an in-app confirm naming the key
+ * stands between the click and the mint.
*
* When `/discovery` does not advertise `routes.mcp` (deployment opted out via
* `OS_MCP_SERVER_ENABLED=false`) the widget renders a disabled empty state —
@@ -46,12 +50,21 @@ import {
CardTitle,
Badge,
Button,
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
Input,
+ cn,
Skeleton,
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
} from '@object-ui/components';
import {
Bot,
Check,
+ ChevronRight,
Copy,
Download,
ExternalLink,
@@ -61,6 +74,14 @@ import {
import { TokenStorage } from '@object-ui/auth';
import { useObjectTranslation } from '@object-ui/i18n';
import { ComponentRegistry } from '@object-ui/core';
+import { useWorkspacePreference } from '../../utils/workspacePreference.js';
+import {
+ CONNECT_CLIENT_IDS,
+ CONNECT_CLIENT_PREFERENCE,
+ DEFAULT_CONNECT_CLIENT,
+ isConnectClientId,
+ type ConnectClientId,
+} from './connectClientPreference.js';
const SERVER_URL = (import.meta.env.VITE_SERVER_URL || '').replace(/\/$/, '');
@@ -144,6 +165,12 @@ export function ConnectAgent() {
const [loading, setLoading] = useState(true);
const [mcpUrl, setMcpUrl] = useState(null);
const [loadError, setLoadError] = useState(null);
+ const [client, setClient] = useWorkspacePreference(
+ CONNECT_CLIENT_PREFERENCE,
+ 'user',
+ DEFAULT_CONNECT_CLIENT,
+ );
+ const [advancedOpen, setAdvancedOpen] = useState(false);
// API-key minting state
const [keyName, setKeyName] = useState('');
@@ -179,6 +206,17 @@ export function ConnectAgent() {
return `cursor://anysphere.cursor-deeplink/mcp/install?name=objectstack&config=${encodeURIComponent(config)}`;
}, [mcpUrl]);
+ // Brand names stay as the client spells them; the two Chinese clients'
+ // names are translated (their English names differ from their Chinese ones).
+ const clientLabels: Record = {
+ claude: 'Claude',
+ codex: 'Codex',
+ cursor: 'Cursor',
+ workbuddy: 'WorkBuddy',
+ doubao: t('connectAgent.doubao.title'),
+ qwenwork: t('connectAgent.qwenwork.title'),
+ };
+
const mintKey = async (name: string) => {
// A key is always minted under the name the administrator typed — never
// a default one (the route would otherwise store an anonymous key).
@@ -258,52 +296,58 @@ export function ConnectAgent() {
- {/* ── Per-client cards: international clients first, then the Chinese ones ── */}
-
- {/* Claude: one card, two ways in — the claude.ai / Desktop connector and
- the Claude Code CLI. A full row, so the six cards below pair up. */}
-
-
- Claude
-
-
-
-
claude.ai / Claude Desktop
-
{t('connectAgent.claude.body')}
-
-
{t('connectAgent.claude.reachability')}
-
-
-
Claude Code
-
{t('connectAgent.claudeCode.body')}
-
-
{t('connectAgent.claudeCode.plugin')}
-
-
-
-
+ {/* ── Which client: one client's steps at a time ─────────────────── */}
+
+
+ {t('connectAgent.client.title')}
+