Skip to content

Commit 6de9e80

Browse files
committed
fix(ui): official opencode logo (theme-aware) + block re-mapping a mapped folder
- Replace the placeholder opencode mark with the official brand Logo. It's theme-specific (dark mark for light backgrounds, light for dark), so ship both variants and swap via a small ClientBrandIcon component used by the home connect grid and the per-workspace install panel. - Setup walkthrough: validate the chosen folder. A folder that already has a mapping (e.g. picked via the OS dialog) now shows an inline error and blocks Next; the detected-workspaces quick-pick already excludes mapped folders. Tests: detected list excludes an already-mapped folder; full TS suite green (222). Claude-Session: https://claude.ai/code/session_01Baan9JmzR43uxxRUh7CAMF Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent afb52ce commit 6de9e80

7 files changed

Lines changed: 105 additions & 24 deletions

File tree

Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 1 addition & 5 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: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ 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';
1010
import opencodeIcon from '@/assets/client-icons/opencode.svg';
11+
import opencodeIconDark from '@/assets/client-icons/opencode-dark.svg';
1112
import { addToVscode, addToCursor } from '@/lib/api/clientInstall';
13+
import { ClientBrandIcon } from './ClientBrandIcon';
1214

1315
type GridAction = 'deep_link' | 'copy_command' | 'copy_config';
1416

@@ -17,6 +19,8 @@ interface GridEntry {
1719
name: string;
1820
label: string;
1921
icon?: string;
22+
/** Optional dark-theme variant; rendered via ClientBrandIcon when present. */
23+
iconDark?: string;
2024
action: GridAction;
2125
handler: (() => Promise<void>) | string;
2226
/**
@@ -100,6 +104,7 @@ export function ConnectIDEsGrid({ gatewayUrl, gatewayRunning }: ConnectIDEsGridP
100104
name: 'opencode',
101105
label: 'opencode',
102106
icon: opencodeIcon,
107+
iconDark: opencodeIconDark,
103108
action: 'copy_config',
104109
handler: `"mcpmux": {\n "type": "remote",\n "url": "${mcpUrl}"\n}`,
105110
nextStep:
@@ -197,8 +202,9 @@ export function ConnectIDEsGrid({ gatewayUrl, gatewayRunning }: ConnectIDEsGridP
197202
data-testid={`client-icon-${entry.id}`}
198203
>
199204
{entry.icon ? (
200-
<img
201-
src={entry.icon}
205+
<ClientBrandIcon
206+
light={entry.icon}
207+
dark={entry.iconDark}
202208
alt={entry.name}
203209
className="h-5 w-5 object-contain"
204210
/>

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

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,20 @@ import cursorIcon from '@/assets/client-icons/cursor.svg';
1414
import claudeIcon from '@/assets/client-icons/claude.svg';
1515
import vscodeIcon from '@/assets/client-icons/vscode.png';
1616
import opencodeIcon from '@/assets/client-icons/opencode.svg';
17+
import opencodeIconDark from '@/assets/client-icons/opencode-dark.svg';
1718
import zedIcon from '@/assets/client-icons/zed.svg';
19+
import { ClientBrandIcon } from '@/components/ClientBrandIcon';
1820
import { getGatewayStatus } from '@/lib/api/gateway';
1921
import { useNavigateTo, useSetPendingSettingsSection } from '@/stores';
2022

21-
/** Brand icon per supported client id (falls back to a generic glyph). */
22-
const CLIENT_ICONS: Record<string, string> = {
23-
cursor: cursorIcon,
24-
'claude-code': claudeIcon,
25-
vscode: vscodeIcon,
26-
opencode: opencodeIcon,
27-
zed: zedIcon,
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 },
2831
};
2932
import {
3033
generateWorkspaceConfigSnippet,
@@ -233,9 +236,9 @@ export function WorkspaceInstallPanel({ workspaceRoot }: { workspaceRoot: string
233236
className="h-4 w-4 flex-shrink-0 accent-primary-500"
234237
/>
235238
{CLIENT_ICONS[c.id] ? (
236-
<img
237-
src={CLIENT_ICONS[c.id]}
238-
alt=""
239+
<ClientBrandIcon
240+
light={CLIENT_ICONS[c.id].light}
241+
dark={CLIENT_ICONS[c.id].dark}
239242
className="h-5 w-5 flex-shrink-0 object-contain"
240243
/>
241244
) : (

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/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)