Skip to content

Commit 94decc8

Browse files
committed
feat(server-clone): Phase 2 — Clone wizard UI
Autonomous decisions: - Post-clone configure uses ServersPage inline config modal — ConfigEditorModal is for space JSON, not per-server credentials - cloned_from typed via local ServerViewModelWithClone extension — kept Phase 2 scope off types/registry.ts - Clone badge replaces Manual badge when clonedFrom is set — lineage is more useful than generic Manual label Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 55d0758 commit 94decc8

5 files changed

Lines changed: 515 additions & 8 deletions

File tree

apps/desktop/src/components/SourceBadge.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import type { InstallationSource } from '@/types/registry';
66

77
interface SourceBadgeProps {
88
source: InstallationSource | undefined;
9+
/** Source server ID when this install is a clone (display-only lineage). */
10+
clonedFrom?: string | null;
911
className?: string;
1012
}
1113

@@ -16,7 +18,19 @@ interface SourceBadgeProps {
1618
* - Config File: Green badge - synced from user's JSON config file
1719
* - Manual: Gray badge - manually entered via UI
1820
*/
19-
export function SourceBadge({ source, className = '' }: SourceBadgeProps) {
21+
export function SourceBadge({ source, clonedFrom, className = '' }: SourceBadgeProps) {
22+
if (clonedFrom) {
23+
return (
24+
<span
25+
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200 ${className}`}
26+
title={`Cloned from ${clonedFrom}`}
27+
data-testid="source-badge-clone"
28+
>
29+
Clone of {clonedFrom}
30+
</span>
31+
);
32+
}
33+
2034
if (!source) {
2135
return null;
2236
}
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
/**
2+
* CloneAccountModal — wizard for adding another account of an installed MCP server.
3+
*/
4+
5+
import { useCallback, useEffect, useState } from 'react';
6+
import { Copy, Loader2, X } from 'lucide-react';
7+
import type { ServerViewModel } from '@/types/registry';
8+
import {
9+
CLONE_SUFFIX_SUGGESTIONS,
10+
cloneServer,
11+
deriveCloneAlias,
12+
deriveCloneServerId,
13+
isCloneIdAvailable,
14+
suggestCloneSuffix,
15+
type ClonedInstalledServer,
16+
} from '@/lib/api/serverClone';
17+
18+
export interface CloneAccountModalProps {
19+
open: boolean;
20+
spaceId: string;
21+
sourceServer: ServerViewModel;
22+
onClose: () => void;
23+
/** Called after a successful clone with the new install row. */
24+
onCloned: (cloned: ClonedInstalledServer) => void;
25+
}
26+
27+
/**
28+
* Modal for creating a suffixed clone of an installed server in the same space.
29+
*/
30+
export function CloneAccountModal({
31+
open,
32+
spaceId,
33+
sourceServer,
34+
onClose,
35+
onCloned,
36+
}: CloneAccountModalProps) {
37+
const [suffix, setSuffix] = useState('');
38+
const [isChecking, setIsChecking] = useState(false);
39+
const [isAvailable, setIsAvailable] = useState<boolean | null>(null);
40+
const [isSubmitting, setIsSubmitting] = useState(false);
41+
const [submitError, setSubmitError] = useState<string | null>(null);
42+
const [isLoadingSuggestion, setIsLoadingSuggestion] = useState(false);
43+
44+
const previewId = deriveCloneServerId(sourceServer.id, suffix);
45+
const previewAlias = deriveCloneAlias(suffix);
46+
const hasSuffix = suffix.trim().length > 0;
47+
const hasCollision = hasSuffix && isAvailable === false;
48+
49+
/**
50+
* Load the first available suggested suffix when the modal opens.
51+
*/
52+
useEffect(() => {
53+
if (!open) {
54+
return;
55+
}
56+
57+
let cancelled = false;
58+
59+
const loadSuggestion = async () => {
60+
setIsLoadingSuggestion(true);
61+
setSubmitError(null);
62+
try {
63+
const suggested = await suggestCloneSuffix(spaceId, sourceServer.id);
64+
if (!cancelled) {
65+
setSuffix(suggested);
66+
}
67+
} catch (e) {
68+
if (!cancelled) {
69+
setSuffix(CLONE_SUFFIX_SUGGESTIONS[0]);
70+
setSubmitError(String(e));
71+
}
72+
} finally {
73+
if (!cancelled) {
74+
setIsLoadingSuggestion(false);
75+
}
76+
}
77+
};
78+
79+
loadSuggestion();
80+
81+
return () => {
82+
cancelled = true;
83+
};
84+
}, [open, spaceId, sourceServer.id]);
85+
86+
/**
87+
* Debounced collision check against the backend.
88+
*/
89+
useEffect(() => {
90+
if (!open || !hasSuffix) {
91+
setIsAvailable(null);
92+
setIsChecking(false);
93+
return;
94+
}
95+
96+
let cancelled = false;
97+
setIsChecking(true);
98+
99+
const timer = setTimeout(async () => {
100+
try {
101+
const available = await isCloneIdAvailable(spaceId, sourceServer.id, suffix);
102+
if (!cancelled) {
103+
setIsAvailable(available);
104+
}
105+
} catch {
106+
if (!cancelled) {
107+
setIsAvailable(null);
108+
}
109+
} finally {
110+
if (!cancelled) {
111+
setIsChecking(false);
112+
}
113+
}
114+
}, 300);
115+
116+
return () => {
117+
cancelled = true;
118+
clearTimeout(timer);
119+
};
120+
}, [open, spaceId, sourceServer.id, suffix, hasSuffix]);
121+
122+
/**
123+
* Submit the clone request.
124+
*/
125+
const handleSubmit = useCallback(async () => {
126+
if (!hasSuffix || hasCollision || isChecking) {
127+
return;
128+
}
129+
130+
setIsSubmitting(true);
131+
setSubmitError(null);
132+
133+
try {
134+
const cloned = await cloneServer(spaceId, sourceServer.id, suffix);
135+
onCloned(cloned);
136+
onClose();
137+
} catch (e) {
138+
setSubmitError(String(e));
139+
} finally {
140+
setIsSubmitting(false);
141+
}
142+
}, [hasSuffix, hasCollision, isChecking, spaceId, sourceServer.id, suffix, onCloned, onClose]);
143+
144+
if (!open) {
145+
return null;
146+
}
147+
148+
const canSubmit = hasSuffix && !hasCollision && !isChecking && !isSubmitting && !isLoadingSuggestion;
149+
150+
return (
151+
<div
152+
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4"
153+
data-testid="clone-account-modal-overlay"
154+
>
155+
<div
156+
className="dropdown-menu w-full max-w-md p-6 animate-in fade-in scale-in duration-150"
157+
data-testid="clone-account-modal"
158+
>
159+
<div className="flex items-start justify-between gap-3 mb-4">
160+
<div className="flex items-center gap-3">
161+
<div className="p-2 rounded-lg bg-[rgb(var(--primary))]/10">
162+
<Copy className="h-5 w-5 text-[rgb(var(--primary))]" />
163+
</div>
164+
<div>
165+
<h3 className="text-lg font-semibold text-[rgb(var(--foreground))]">
166+
Add another account
167+
</h3>
168+
<p className="text-sm text-[rgb(var(--muted))]">
169+
Clone {sourceServer.name} with a separate credential set
170+
</p>
171+
</div>
172+
</div>
173+
<button
174+
onClick={onClose}
175+
className="p-1 rounded hover:bg-[rgb(var(--surface-hover))] text-[rgb(var(--muted))] transition-colors"
176+
aria-label="Close"
177+
data-testid="clone-account-close-btn"
178+
>
179+
<X className="h-5 w-5" />
180+
</button>
181+
</div>
182+
183+
<div className="space-y-4">
184+
<div>
185+
<label
186+
htmlFor="clone-suffix"
187+
className="block text-sm font-medium text-[rgb(var(--foreground))] mb-1"
188+
>
189+
Account label
190+
</label>
191+
<p className="text-xs text-[rgb(var(--muted))] mb-2">
192+
Used in the server ID and tool prefix (e.g. work, personal)
193+
</p>
194+
<input
195+
id="clone-suffix"
196+
type="text"
197+
value={suffix}
198+
onChange={(e) => setSuffix(e.target.value)}
199+
placeholder="work"
200+
className={`input w-full ${hasCollision ? 'border-[rgb(var(--error))]' : ''}`}
201+
disabled={isLoadingSuggestion || isSubmitting}
202+
data-testid="clone-suffix-input"
203+
/>
204+
{hasCollision && (
205+
<p className="text-xs text-[rgb(var(--error))] mt-1" data-testid="clone-collision-error">
206+
An account with this label already exists in this space
207+
</p>
208+
)}
209+
</div>
210+
211+
<div>
212+
<p className="text-xs font-medium text-[rgb(var(--muted))] mb-2">Suggestions</p>
213+
<div className="flex flex-wrap gap-2">
214+
{CLONE_SUFFIX_SUGGESTIONS.map((suggestion) => (
215+
<button
216+
key={suggestion}
217+
type="button"
218+
onClick={() => setSuffix(suggestion)}
219+
className={`px-2.5 py-1 text-xs rounded-md border transition-colors ${
220+
suffix === suggestion
221+
? 'border-[rgb(var(--primary))] bg-[rgb(var(--primary))]/10 text-[rgb(var(--primary))]'
222+
: 'border-[rgb(var(--border))] text-[rgb(var(--muted))] hover:bg-[rgb(var(--surface-hover))]'
223+
}`}
224+
data-testid={`clone-suffix-suggestion-${suggestion}`}
225+
>
226+
{suggestion}
227+
</button>
228+
))}
229+
</div>
230+
</div>
231+
232+
{hasSuffix && (
233+
<div className="rounded-lg border border-[rgb(var(--border-subtle))] bg-[rgb(var(--surface-dim))] p-3 space-y-2">
234+
<div className="flex items-center justify-between gap-2 text-sm">
235+
<span className="text-[rgb(var(--muted))]">Server ID</span>
236+
<code className="text-xs font-mono text-[rgb(var(--foreground))]">{previewId || '—'}</code>
237+
</div>
238+
<div className="flex items-center justify-between gap-2 text-sm">
239+
<span className="text-[rgb(var(--muted))]">Tool prefix</span>
240+
<code className="text-xs font-mono text-[rgb(var(--foreground))]">
241+
{previewAlias ? `${previewAlias}_*` : '—'}
242+
</code>
243+
</div>
244+
{isChecking && (
245+
<div className="flex items-center gap-2 text-xs text-[rgb(var(--muted))]">
246+
<Loader2 className="h-3 w-3 animate-spin" />
247+
Checking availability…
248+
</div>
249+
)}
250+
</div>
251+
)}
252+
253+
<p className="text-xs text-[rgb(var(--muted))]">
254+
The clone copies the server definition but not credentials. You will configure this
255+
account before enabling it.
256+
</p>
257+
258+
{submitError && (
259+
<p className="text-sm text-[rgb(var(--error))]" data-testid="clone-submit-error">
260+
{submitError}
261+
</p>
262+
)}
263+
264+
<div className="flex justify-end gap-2 pt-2">
265+
<button
266+
onClick={onClose}
267+
className="px-4 py-2 text-sm rounded-lg border border-[rgb(var(--border))] text-[rgb(var(--muted))] hover:bg-[rgb(var(--surface-hover))] transition-colors"
268+
disabled={isSubmitting}
269+
data-testid="clone-cancel-btn"
270+
>
271+
Cancel
272+
</button>
273+
<button
274+
onClick={handleSubmit}
275+
disabled={!canSubmit}
276+
className="px-4 py-2 text-sm rounded-lg bg-[rgb(var(--primary))] text-[rgb(var(--primary-foreground))] hover:bg-[rgb(var(--primary-hover))] disabled:opacity-50 transition-colors flex items-center gap-2"
277+
data-testid="clone-submit-btn"
278+
>
279+
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
280+
{isSubmitting ? 'Creating…' : 'Create account'}
281+
</button>
282+
</div>
283+
</div>
284+
</div>
285+
</div>
286+
);
287+
}

apps/desktop/src/features/servers/ServerActionMenu.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
import { useState, useRef, useEffect } from 'react';
14-
import { MoreVertical, Settings, RefreshCw, RotateCcw, FileText, Code, Trash2 } from 'lucide-react';
14+
import { MoreVertical, Settings, RefreshCw, RotateCcw, FileText, Code, Trash2, Copy } from 'lucide-react';
1515

1616
export interface ServerActionMenuProps {
1717
serverId: string;
@@ -20,11 +20,14 @@ export interface ServerActionMenuProps {
2020
isOAuth: boolean;
2121
isEnabled: boolean;
2222
isConnected: boolean;
23+
/** Show "Add another account…" for registry/manual installs (not clones-of-clones). */
24+
canCloneAccount?: boolean;
2325
onConfigure: () => void;
2426
onRefresh: () => void;
2527
onReconnect: () => void;
2628
onViewLogs: () => void;
2729
onViewDefinition: () => void;
30+
onCloneAccount?: () => void;
2831
onUninstall: () => void;
2932
}
3033

@@ -35,11 +38,13 @@ export function ServerActionMenu({
3538
isOAuth,
3639
isEnabled,
3740
isConnected: _isConnected,
41+
canCloneAccount = false,
3842
onConfigure,
3943
onRefresh,
4044
onReconnect,
4145
onViewLogs,
4246
onViewDefinition,
47+
onCloneAccount,
4348
onUninstall,
4449
}: ServerActionMenuProps) {
4550
const [isOpen, setIsOpen] = useState(false);
@@ -163,6 +168,19 @@ export function ServerActionMenu({
163168
View Definition
164169
</button>
165170

171+
{/* Add another account - registry/manual installs only, not clones-of-clones */}
172+
{canCloneAccount && onCloneAccount && (
173+
<button
174+
onClick={() => handleAction(onCloneAccount)}
175+
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[rgb(var(--foreground))] hover:bg-[rgb(var(--surface-hover))] transition-colors"
176+
role="menuitem"
177+
data-testid={`clone-account-${serverId}`}
178+
>
179+
<Copy className="h-4 w-4 text-[rgb(var(--muted))]" />
180+
Add another account…
181+
</button>
182+
)}
183+
166184
{/* Separator */}
167185
<div className="my-1 border-t border-[rgb(var(--border-subtle))]" />
168186

0 commit comments

Comments
 (0)