Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# CLAUDE.md — agent-ui-monorepo

NX monorepo by Valory AG. Contains three React apps and six shared libraries for agent-based UIs.
NX monorepo by Valory AG. Contains four React apps and six shared libraries for agent-based UIs.

---

Expand All @@ -10,6 +10,7 @@ NX monorepo by Valory AG. Contains three React apps and six shared libraries for
apps/
agentsfun-ui/ # Agents.fun social/memecoin UI (dev 4300, preview 4400)
babydegen-ui/ # Portfolio + withdrawal UI for Modius/Optimus/Basius (dev 4300, preview 4400)
connect-ui/ # Pearl Connect (BYOA) agent UI — Profile sections built, endpoints TBD (dev 4500, preview 4600)
predict-ui/ # Prediction-market UI for Omenstrat/Polystrat (dev 4200, preview 4300)

libs/
Expand Down
2 changes: 2 additions & 0 deletions apps/connect-ui/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Use mock data instead of live API (true | false)
IS_MOCK_ENABLED=false
21 changes: 21 additions & 0 deletions apps/connect-ui/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# connect-ui

Connect UI app.

## Development

```bash
# Copy env file first
cp apps/connect-ui/.env.example apps/connect-ui/.env

yarn nx dev connect-ui # http://localhost:4500
yarn nx build connect-ui
yarn nx test connect-ui
yarn nx lint connect-ui
```

## Environment variables

| Variable | Values | Effect |
| ----------------- | ----------------- | --------------------------------------- |
| `IS_MOCK_ENABLED` | `true` \| `false` | Use mock data instead of live API calls |
76 changes: 76 additions & 0 deletions apps/connect-ui/__tests__/App.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';

import App from '../src/App';
import { ConnectSettings } from '../src/types';

const restrictedSettings: ConnectSettings = {
protected: {
mode: 'restricted',
whitelist: { gnosis: ['0x4554fe75c1f5576c1d7f765b2a036c199adae329'] },
},
harness: 'claude_code_desktop',
};

const unrestrictedSettings: ConnectSettings = {
...restrictedSettings,
protected: { ...restrictedSettings.protected, mode: 'unrestricted' },
};

const renderApp = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
);
};

describe('App', () => {
beforeEach(() => {
global.fetch = jest.fn();
});

afterEach(() => {
const g = global as unknown as Record<string, unknown>;
delete g['fetch'];
});

it('shows a spinner while settings are loading', () => {
(global.fetch as jest.Mock).mockReturnValue(new Promise(() => undefined));

const { container } = renderApp();

expect(container.querySelector('.ant-spin')).toBeInTheDocument();
});

it('renders all sections including the whitelist in restricted mode', async () => {
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: () => Promise.resolve(restrictedSettings),
});

renderApp();

await waitFor(() => expect(screen.getByText('Get started with Connect')).toBeInTheDocument());
expect(screen.getByText('Coding tool')).toBeInTheDocument();
expect(screen.getByText('Transaction mode')).toBeInTheDocument();
expect(screen.getByText('Whitelisted addresses')).toBeInTheDocument();
expect(screen.getByText('Olas Marketplace')).toBeInTheDocument();
expect(screen.queryByText('Unrestricted mode is on')).not.toBeInTheDocument();
});

it('hides the whitelist and shows the banner in unrestricted mode', async () => {
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: () => Promise.resolve(unrestrictedSettings),
});

renderApp();

await waitFor(() => expect(screen.getByText('Unrestricted mode is on')).toBeInTheDocument());
expect(screen.queryByText('Whitelisted addresses')).not.toBeInTheDocument();
});
});
62 changes: 62 additions & 0 deletions apps/connect-ui/__tests__/components/CodingTool.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';

import { CodingTool } from '../../src/components/CodingTool/CodingTool';
import { ConnectSettings } from '../../src/types';

const settings: ConnectSettings = {
protected: { mode: 'restricted', whitelist: { gnosis: ['0xabc'] } },
harness: 'claude_code_desktop',
};

const renderComponent = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<CodingTool settings={settings} />
</QueryClientProvider>,
);
};

describe('CodingTool', () => {
beforeEach(() => {
global.fetch = jest.fn();
});

afterEach(() => {
const g = global as unknown as Record<string, unknown>;
delete g['fetch'];
});

it('renders the section copy and the current coding tool', () => {
renderComponent();

expect(screen.getByText('Coding tool')).toBeInTheDocument();
expect(screen.getByText('Choose where you run your Connect agent.')).toBeInTheDocument();
expect(screen.getByText('Claude Desktop')).toBeInTheDocument();
});

it('PATCHes the harness without a password when another tool is selected', async () => {
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ ...settings, harness: 'claude_code_cli' }),
});

const { container } = renderComponent();

const selector = container.querySelector('.ant-select-selector');
if (!selector) throw new Error('Expected select to be rendered');
fireEvent.mouseDown(selector);
fireEvent.click(await screen.findByText('Claude Code CLI'));

await waitFor(() => expect(global.fetch).toHaveBeenCalled());
const [url, init] = (global.fetch as jest.Mock).mock.calls[0];
expect(url).toContain('/settings');
expect(init.method).toBe('PATCH');
expect(JSON.parse(init.body)).toEqual({ harness: 'claude_code_cli' });
expect(screen.queryByText(/Enter password/)).not.toBeInTheDocument();
});
});
105 changes: 105 additions & 0 deletions apps/connect-ui/__tests__/components/GetStarted.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';

import { GetStarted } from '../../src/components/GetStarted/GetStarted';

const renderComponent = () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<GetStarted />
</QueryClientProvider>,
);
};

describe('GetStarted', () => {
beforeEach(() => {
global.fetch = jest.fn();
});

afterEach(() => {
const g = global as unknown as Record<string, unknown>;
delete g['fetch'];
});

it('renders the section copy and button', () => {
renderComponent();

expect(screen.getByText('Get started with Connect')).toBeInTheDocument();
expect(
screen.getByText('Work with your Connect agent from your coding tool.'),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /New Connect Session/ })).toBeInTheDocument();
});

it('POSTs to /session on click and shows no error on a successful launch', async () => {
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ launched: true, harness: 'claude_code_desktop' }),
});

renderComponent();

fireEvent.click(screen.getByRole('button', { name: /New Connect Session/ }));

await waitFor(() =>
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/session'),
expect.objectContaining({ method: 'POST' }),
),
);
expect(document.querySelector('.ant-alert')).not.toBeInTheDocument();
});

it('shows the server-reported launch error as a dismissable alert', async () => {
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
status: 200,
json: () =>
Promise.resolve({
launched: false,
harness: 'claude_code_desktop',
error: 'Claude Desktop did not handle the deep link.',
}),
});

const { container } = renderComponent();

fireEvent.click(screen.getByRole('button', { name: /New Connect Session/ }));

await waitFor(() =>
expect(screen.getByText('Claude Desktop did not handle the deep link.')).toBeInTheDocument(),
);

const closeButton = container.querySelector('.ant-alert-close-icon');
if (!closeButton) throw new Error('Expected alert close button');
fireEvent.click(closeButton);

await waitFor(() =>
expect(
screen.queryByText('Claude Desktop did not handle the deep link.'),
).not.toBeInTheDocument(),
);
});

it('shows the not-ready message on 503', async () => {
(global.fetch as jest.Mock).mockResolvedValue({
ok: false,
status: 503,
json: () => Promise.resolve({}),
});

renderComponent();

fireEvent.click(screen.getByRole('button', { name: /New Connect Session/ }));

await waitFor(() =>
expect(
screen.getByText('The agent is still starting up. Please try again shortly.'),
).toBeInTheDocument(),
);
});
});
Loading
Loading