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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/30896-connect-agent-client-picker.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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')) {
Expand All @@ -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(<ConnectAgent />);
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(<ConnectAgent />);
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)', () => {
Expand Down Expand Up @@ -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());
Expand All @@ -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();
});
});
Loading
Loading