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
19 changes: 19 additions & 0 deletions .github/workflows/connect-ui-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: Build and Release Connect UI

on:
push:
tags:
- 'v*-connect'
workflow_dispatch:

permissions:
contents: read

jobs:
build:
uses: ./.github/workflows/_build-app.yml
permissions:
contents: write
with:
app-name: connect-ui
# connect-ui does not branch on agent type — no REACT_APP_AGENT_NAME.
4 changes: 3 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 Expand Up @@ -291,6 +292,7 @@ Workflows in `.github/workflows/` — every workflow that runs `yarn` reads Node
| `check-pull-request.yml` | PRs to any branch | `nx run-many --target=lint` + `nx run-many --target=test --passWithNoTests`. **No build, no typecheck.** |
| `agentsfun-ui-build.yml` | Tag `v*-agentsfun` | Builds and releases `agentsfun-ui-build.zip` |
| `babydegen-ui-build.yml` | Tag `v*-modius`, `v*-optimus`, **or** `v*-basius` | Sets `REACT_APP_AGENT_NAME` from the tag suffix, builds, and releases `babydegen-ui-build.zip` |
| `connect-ui-build.yml` | Tag `v*-connect` | Builds and releases `connect-ui-build.zip` (no agent env var) |
| `predict-ui-build.yml` | Tag `v*-omenstrat-trader` **or** `v*-polystrat-trader` | Sets `REACT_APP_AGENT_NAME` (`omenstrat_trader`/`polystrat_trader`), builds, and releases `predict-ui-build.zip` |
| `gitleaks.yml` | PRs to any branch | Downloads gitleaks `v8.30.1` (SHA256-verified) and scans full history against `.gitleaks.toml` |

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
30 changes: 30 additions & 0 deletions apps/connect-ui/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# connect-ui

React application for Pearl Connect (BYOA).
Served by the [pearl-connect](https://github.com/valory-xyz/pearl-connect) agent binary at `http://127.0.0.1:8716` and embedded by [Pearl](https://github.com/valory-xyz/olas-operate-app) via iframe.

## 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 |

## 📦 Release process

1. Bump the version in `package.json`
2. Push a new tag with the `-connect` suffix (e.g., `v1.0.0-connect`)
3. The CI will build and release the contents of the `dist/apps/connect-ui` directory as `connect-ui-build.zip` on a GitHub Release

The [pearl-connect](https://github.com/valory-xyz/pearl-connect) binary consumes the zip: its server looks for the unpacked bundle in `pearl_connect/assets/ui` (`ui_build_dir()`) and serves it at `GET /`. Pearl pins the UI release tag in `frontend/constants/serviceTemplates/agentUiReleases.ts` in [olas-operate-app](https://github.com/valory-xyz/olas-operate-app).
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: ['0x735faab1c4ec41128c367afb5c3bac73509f70bb'] },
},
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('0x735f...70bb')).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