Skip to content

Commit 669e99f

Browse files
authored
feat(ui): opencode global connect + client icons (#184)
Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent e2ec055 commit 669e99f

10 files changed

Lines changed: 171 additions & 12 deletions

File tree

Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 4 additions & 0 deletions
Loading
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Brand icon for an MCP client.
3+
*
4+
* Some official marks are theme-specific — opencode's logo is a dark mark meant
5+
* for light backgrounds and a light mark for dark backgrounds, so neither reads
6+
* on both themes. When both variants are provided we render both and toggle with
7+
* Tailwind's `dark:` variant; a single asset is shown as-is. Returns `null` when
8+
* no asset is given so the caller can render its own fallback glyph.
9+
*/
10+
export function ClientBrandIcon({
11+
light,
12+
dark,
13+
alt = '',
14+
className = '',
15+
}: {
16+
light?: string;
17+
dark?: string;
18+
alt?: string;
19+
className?: string;
20+
}) {
21+
if (!light && !dark) return null;
22+
if (light && dark) {
23+
return (
24+
<>
25+
<img src={light} alt={alt} className={`${className} block dark:hidden`} />
26+
<img src={dark} alt={alt} className={`${className} hidden dark:block`} />
27+
</>
28+
);
29+
}
30+
return <img src={(light ?? dark) as string} alt={alt} className={className} />;
31+
}

apps/desktop/src/components/ConnectIDEs.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import claudeIcon from '@/assets/client-icons/claude.svg';
77
import windsurfIcon from '@/assets/client-icons/windsurf.svg';
88
import jetbrainsIcon from '@/assets/client-icons/jetbrains.svg';
99
import androidStudioIcon from '@/assets/client-icons/android-studio.svg';
10+
import opencodeIcon from '@/assets/client-icons/opencode.svg';
11+
import opencodeIconDark from '@/assets/client-icons/opencode-dark.svg';
1012
import { addToVscode, addToCursor } from '@/lib/api/clientInstall';
13+
import { ClientBrandIcon } from './ClientBrandIcon';
1114

1215
type GridAction = 'deep_link' | 'copy_command' | 'copy_config';
1316

@@ -16,6 +19,8 @@ interface GridEntry {
1619
name: string;
1720
label: string;
1821
icon?: string;
22+
/** Optional dark-theme variant; rendered via ClientBrandIcon when present. */
23+
iconDark?: string;
1924
action: GridAction;
2025
handler: (() => Promise<void>) | string;
2126
/**
@@ -94,6 +99,19 @@ export function ConnectIDEsGrid({ gatewayUrl, gatewayRunning }: ConnectIDEsGridP
9499
'loads mcpmux on the next `claude` invocation (existing sessions need ' +
95100
'/restart). Approve on this page when it connects.',
96101
},
102+
{
103+
id: 'opencode',
104+
name: 'opencode',
105+
label: 'opencode',
106+
icon: opencodeIcon,
107+
iconDark: opencodeIconDark,
108+
action: 'copy_config',
109+
handler: `"mcpmux": {\n "type": "remote",\n "url": "${mcpUrl}"\n}`,
110+
nextStep:
111+
'Copies a JSON snippet. In opencode, paste it under "mcp" in opencode.json ' +
112+
'(project) or ~/.config/opencode/opencode.json (global), then restart ' +
113+
'opencode. Approve on this page when it connects.',
114+
},
97115
{
98116
id: 'jetbrains',
99117
name: 'JetBrains IDEs',
@@ -184,8 +202,9 @@ export function ConnectIDEsGrid({ gatewayUrl, gatewayRunning }: ConnectIDEsGridP
184202
data-testid={`client-icon-${entry.id}`}
185203
>
186204
{entry.icon ? (
187-
<img
188-
src={entry.icon}
205+
<ClientBrandIcon
206+
light={entry.icon}
207+
dark={entry.iconDark}
189208
alt={entry.name}
190209
className="h-5 w-5 object-contain"
191210
/>

apps/desktop/src/features/workspaces/WorkspaceInstallPanel.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,34 @@
11
import { useCallback, useEffect, useState } from 'react';
2-
import { Check, Copy, Download, Loader2, ShieldCheck, ShieldOff, AlertCircle } from 'lucide-react';
2+
import {
3+
AppWindow,
4+
Check,
5+
Copy,
6+
Download,
7+
Loader2,
8+
ShieldCheck,
9+
ShieldOff,
10+
AlertCircle,
11+
} from 'lucide-react';
312
import { Button } from '@mcpmux/ui';
13+
import cursorIcon from '@/assets/client-icons/cursor.svg';
14+
import claudeIcon from '@/assets/client-icons/claude.svg';
15+
import vscodeIcon from '@/assets/client-icons/vscode.png';
16+
import opencodeIcon from '@/assets/client-icons/opencode.svg';
17+
import opencodeIconDark from '@/assets/client-icons/opencode-dark.svg';
18+
import zedIcon from '@/assets/client-icons/zed.svg';
19+
import { ClientBrandIcon } from '@/components/ClientBrandIcon';
420
import { getGatewayStatus } from '@/lib/api/gateway';
521
import { useNavigateTo, useSetPendingSettingsSection } from '@/stores';
22+
23+
/** Brand icon per supported client id (falls back to a generic glyph). opencode
24+
* ships theme-specific marks, so it carries a dark variant. */
25+
const CLIENT_ICONS: Record<string, { light: string; dark?: string }> = {
26+
cursor: { light: cursorIcon },
27+
'claude-code': { light: claudeIcon },
28+
vscode: { light: vscodeIcon },
29+
opencode: { light: opencodeIcon, dark: opencodeIconDark },
30+
zed: { light: zedIcon },
31+
};
632
import {
733
generateWorkspaceConfigSnippet,
834
getGatewayAuthDisabled,
@@ -209,6 +235,15 @@ export function WorkspaceInstallPanel({ workspaceRoot }: { workspaceRoot: string
209235
onChange={() => toggleClient(c.id)}
210236
className="h-4 w-4 flex-shrink-0 accent-primary-500"
211237
/>
238+
{CLIENT_ICONS[c.id] ? (
239+
<ClientBrandIcon
240+
light={CLIENT_ICONS[c.id].light}
241+
dark={CLIENT_ICONS[c.id].dark}
242+
className="h-5 w-5 flex-shrink-0 object-contain"
243+
/>
244+
) : (
245+
<AppWindow className="h-5 w-5 flex-shrink-0 text-[rgb(var(--muted))]" />
246+
)}
212247
<div className="min-w-0 flex-1">
213248
<div className="text-sm font-medium text-[rgb(var(--foreground))]">{c.label}</div>
214249
<div className="truncate font-mono text-[11px] text-[rgb(var(--muted))]">

apps/desktop/src/features/workspaces/WorkspaceSetupWizard.tsx

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useEffect, useMemo, useState } from 'react';
22
import { open as openDialog } from '@tauri-apps/plugin-dialog';
33
import {
4+
AlertCircle,
45
ArrowLeft,
56
ArrowRight,
67
Check,
@@ -89,6 +90,12 @@ export function WorkspaceSetupWizard({
8990
() => reportedRoots.filter((r) => !boundRoots.has(r.toLowerCase())),
9091
[reportedRoots, boundRoots]
9192
);
93+
// Block picking a folder that already has a mapping (e.g. chosen via the
94+
// folder dialog) — it must be edited from the Workspaces list, not re-created.
95+
const alreadyMapped = useMemo(
96+
() => !!folder && boundRoots.has(folder.toLowerCase()),
97+
[folder, boundRoots]
98+
);
9299

93100
const pickFolder = async () => {
94101
try {
@@ -182,13 +189,31 @@ export function WorkspaceSetupWizard({
182189
</Button>
183190

184191
{folder && (
185-
<div className="flex items-center gap-2 rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--background))] px-3 py-2">
186-
<Check className="h-4 w-4 flex-shrink-0 text-green-600" />
192+
<div
193+
className={`flex items-center gap-2 rounded-lg border px-3 py-2 ${
194+
alreadyMapped
195+
? 'border-amber-300 bg-amber-50 dark:border-amber-800/60 dark:bg-amber-900/20'
196+
: 'border-[rgb(var(--border))] bg-[rgb(var(--background))]'
197+
}`}
198+
>
199+
{alreadyMapped ? (
200+
<AlertCircle className="h-4 w-4 flex-shrink-0 text-amber-600" />
201+
) : (
202+
<Check className="h-4 w-4 flex-shrink-0 text-green-600" />
203+
)}
187204
<span className="truncate font-mono text-xs" title={folder}>
188205
{folder}
189206
</span>
190207
</div>
191208
)}
209+
{alreadyMapped && (
210+
<p
211+
className="text-xs text-amber-700 dark:text-amber-400"
212+
data-testid="wizard-folder-mapped-error"
213+
>
214+
This folder is already mapped — edit it from the Workspaces list instead.
215+
</p>
216+
)}
192217

193218
{unmappedRoots.length > 0 && (
194219
<div>
@@ -315,7 +340,7 @@ export function WorkspaceSetupWizard({
315340
variant="primary"
316341
size="sm"
317342
onClick={() => setStep((s) => (s + 1) as 1 | 2 | 3)}
318-
disabled={step === 1 && !folder}
343+
disabled={step === 1 && (!folder || alreadyMapped)}
319344
data-testid="wizard-next"
320345
>
321346
{step === 2 ? 'Next' : 'Continue'}

tests/ts/components/ConnectIDEs.test.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,30 @@ describe('ConnectIDEs', () => {
3535
expect(screen.getByTestId('client-icon-vscode')).toBeInTheDocument();
3636
expect(screen.getByTestId('client-icon-cursor')).toBeInTheDocument();
3737
expect(screen.getByTestId('client-icon-claude-code')).toBeInTheDocument();
38+
expect(screen.getByTestId('client-icon-opencode')).toBeInTheDocument();
3839
expect(screen.getByTestId('client-icon-copy-config')).toBeInTheDocument();
3940
});
4041

42+
it('offers a global opencode config snippet (no workspace header)', async () => {
43+
const user = userEvent.setup();
44+
const writeText = vi.fn().mockResolvedValue(undefined);
45+
Object.defineProperty(navigator, 'clipboard', {
46+
value: { writeText },
47+
writable: true,
48+
configurable: true,
49+
});
50+
render(<ConnectIDEs gatewayUrl="http://localhost:45818" gatewayRunning={true} />);
51+
52+
await user.click(screen.getByTestId('client-icon-opencode'));
53+
await user.click(screen.getByRole('button', { name: /Copy config/i }));
54+
55+
const copied = writeText.mock.calls[0][0] as string;
56+
expect(copied).toContain('"type": "remote"');
57+
expect(copied).toContain('localhost:45818/mcp');
58+
// Global connect carries no per-workspace header.
59+
expect(copied).not.toContain('X-Mcpmux-Workspace');
60+
});
61+
4162
it('should show labels under icons', () => {
4263
render(
4364
<ConnectIDEs gatewayUrl="http://localhost:45818" gatewayRunning={true} />

tests/ts/components/WorkspaceInstallPanel.test.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,14 @@ describe('WorkspaceInstallPanel', () => {
7474
.mockResolvedValue({ running: true, url: 'http://localhost:45818' });
7575
});
7676

77-
it('lists every supported client', async () => {
77+
it('lists every supported client, each with an icon', async () => {
7878
render(<WorkspaceInstallPanel workspaceRoot={ROOT} />);
7979
expect(await screen.findByText('Cursor')).toBeTruthy();
8080
for (const c of CLIENTS) {
81-
expect(screen.getByTestId(`workspace-install-client-${c.id}`)).toBeTruthy();
81+
const row = screen.getByTestId(`workspace-install-client-${c.id}`);
82+
expect(row).toBeTruthy();
83+
// Each known client renders a brand icon image.
84+
expect(row.querySelector('img')).toBeTruthy();
8285
}
8386
});
8487

tests/ts/components/WorkspaceSetupWizard.test.tsx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
1111
import { render, screen, waitFor } from '@testing-library/react';
1212
import userEvent from '@testing-library/user-event';
1313

14-
const { openMock, validateMock } = vi.hoisted(() => ({
15-
openMock: vi.fn(),
14+
const { validateMock } = vi.hoisted(() => ({
1615
validateMock: vi.fn(),
1716
}));
1817

19-
vi.mock('@tauri-apps/plugin-dialog', () => ({ open: openMock }));
18+
// `@tauri-apps/plugin-dialog` is mocked globally in setup.ts (open: vi.fn()).
19+
// We reconfigure that shared mock per-test via vi.importMock (a static import
20+
// of this mocked-only package isn't Vite-resolvable from the test).
2021
vi.mock('@/lib/api/workspaceBindings', () => ({ validateWorkspaceRoot: validateMock }));
2122
vi.mock('@/lib/api/featureSets', () => ({
2223
isStarterFeatureSet: (fs: { feature_set_type: string }) =>
@@ -50,7 +51,6 @@ const props = (over: any = {}) => ({
5051

5152
describe('WorkspaceSetupWizard', () => {
5253
beforeEach(() => {
53-
openMock.mockReset();
5454
validateMock.mockReset();
5555
});
5656

@@ -87,6 +87,25 @@ describe('WorkspaceSetupWizard', () => {
8787
expect(p.onClose).not.toHaveBeenCalled();
8888
});
8989

90+
it('does not offer an already-mapped folder in the detected list', () => {
91+
// The quick-pick list filters out folders that already have a binding, so a
92+
// mapped folder can't be re-picked there; an unmapped one is still offered.
93+
// (Picking a mapped folder via the OS dialog is guarded separately by the
94+
// alreadyMapped check, which disables Next and shows an inline error.)
95+
render(
96+
<WorkspaceSetupWizard
97+
{...props({
98+
reportedRoots: ['/proj/app', '/proj/other'],
99+
existingBindings: [
100+
{ id: 'b1', workspace_root: '/proj/app', space_id: 's1', feature_set_ids: ['fs_starter'] },
101+
],
102+
})}
103+
/>
104+
);
105+
expect(screen.queryByRole('button', { name: /\/proj\/app$/ })).toBeNull();
106+
expect(screen.getByRole('button', { name: /\/proj\/other$/ })).toBeTruthy();
107+
});
108+
90109
it('lets you go Back from a later step', async () => {
91110
const user = userEvent.setup();
92111
render(<WorkspaceSetupWizard {...props()} />);

0 commit comments

Comments
 (0)