55 ArrowLeft ,
66 ArrowRight ,
77 Check ,
8+ Copy ,
89 FolderOpen ,
910 FolderSearch ,
1011 Layers ,
@@ -53,6 +54,11 @@ export function WorkspaceSetupWizard({
5354 onError : ( msg : string ) => void ;
5455} ) {
5556 const [ step , setStep ] = useState < 1 | 2 | 3 > ( 1 ) ;
57+ // A mapping is keyed by a folder PATH (the default) or an arbitrary ID/label
58+ // (a client id, machine name, … — for headless/remote clients). `folder`
59+ // holds whichever value the user enters.
60+ const [ bindingType , setBindingType ] = useState < 'path' | 'id' > ( 'path' ) ;
61+ const isId = bindingType === 'id' ;
5662 const [ folder , setFolder ] = useState ( '' ) ;
5763 const [ validating , setValidating ] = useState ( false ) ;
5864 const [ saving , setSaving ] = useState ( false ) ;
@@ -127,6 +133,7 @@ export function WorkspaceSetupWizard({
127133 workspace_root : folder ,
128134 space_id : spaceId ,
129135 feature_set_ids : Array . from ( fsIds ) ,
136+ binding_type : bindingType ,
130137 } ) ;
131138 // The parent transitions to the new mapping's inspector (which shows its
132139 // effective features) — don't close here, or that view would be lost.
@@ -136,19 +143,21 @@ export function WorkspaceSetupWizard({
136143 }
137144 } ;
138145
139- const TITLES = [ 'Choose a folder' , 'Connect your apps' , 'Choose its tools' ] as const ;
146+ const TITLES = isId
147+ ? ( [ 'Choose an id' , 'How clients connect' , 'Choose its tools' ] as const )
148+ : ( [ 'Choose a folder' , 'Connect your apps' , 'Choose its tools' ] as const ) ;
140149
141150 return (
142151 < div
143- className = "fixed right-0 top -0 bottom -0 z-50 flex w-full min-w-[420px] max-w-[480px] flex-col border-l border-[rgb(var(--border))] bg-[rgb(var(--surface))] shadow-2xl animate-in slide-in-from-right duration-300"
152+ className = "animate-in slide-in-from- right fixed bottom -0 right -0 top -0 z-50 flex w-full min-w-[420px] max-w-[480px] flex-col border-l border-[rgb(var(--border))] bg-[rgb(var(--surface))] shadow-2xl duration-300"
144153 data-testid = "workspace-setup-wizard"
145154 >
146155 { /* Header + progress */ }
147156 < div className = "flex-shrink-0 border-b border-[rgb(var(--border))] bg-[rgb(var(--surface-elevated))] p-4" >
148157 < div className = "flex items-start justify-between" >
149158 < div className = "min-w-0" >
150159 < div className = "text-xs font-medium uppercase tracking-wider text-[rgb(var(--muted))]" >
151- Set up a folder · Step { step } of 3
160+ { isId ? ' Set up an ID mapping' : 'Set up a folder' } · Step { step } of 3
152161 </ div >
153162 < h2 className = "mt-0.5 text-lg font-bold" > { TITLES [ step - 1 ] } </ h2 >
154163 </ div >
@@ -175,80 +184,154 @@ export function WorkspaceSetupWizard({
175184 < div className = "flex-1 overflow-y-auto p-6" >
176185 { step === 1 && (
177186 < div className = "space-y-4" data-testid = "wizard-step-folder" >
178- < p className = "text-sm text-[rgb(var(--muted))]" >
179- Which project folder do you want to map? Pick one, or choose a folder an app already
180- opened.
181- </ p >
182- < Button variant = "primary" size = "sm" onClick = { pickFolder } disabled = { validating } >
183- { validating ? (
184- < Loader2 className = "mr-2 h-4 w-4 animate-spin" />
185- ) : (
186- < FolderOpen className = "mr-2 h-4 w-4" />
187- ) }
188- Choose folder…
189- </ Button >
187+ { /* Folder vs ID — a folder routes editors by the path they open; an
188+ id routes a headless/remote client by an exact label it sends. */ }
189+ < div className = "flex gap-1 rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--background))] p-1" >
190+ { ( [ 'path' , 'id' ] as const ) . map ( ( t ) => (
191+ < button
192+ key = { t }
193+ type = "button"
194+ onClick = { ( ) => setBindingType ( t ) }
195+ className = { [
196+ 'flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors' ,
197+ bindingType === t
198+ ? 'bg-primary-500 text-white'
199+ : 'text-[rgb(var(--muted))] hover:bg-[rgb(var(--surface-hover))]' ,
200+ ] . join ( ' ' ) }
201+ data-testid = { `wizard-type-${ t } ` }
202+ >
203+ { t === 'path' ? 'Folder' : 'ID / label' }
204+ </ button >
205+ ) ) }
206+ </ div >
190207
191- { folder && (
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" />
208+ { isId ? (
209+ < >
210+ < p className = "text-sm text-[rgb(var(--muted))]" >
211+ Enter an id or label — a client id, machine name, or any string. A headless or
212+ remote client that sends this exact value in the{ ' ' }
213+ < code className = "font-mono text-xs" > X-Mcpmux-Workspace</ code > header gets the
214+ tools you choose next.
215+ </ p >
216+ < input
217+ type = "text"
218+ value = { folder }
219+ onChange = { ( e ) => setFolder ( e . target . value ) }
220+ placeholder = "e.g. a client id or machine name"
221+ className = "focus:ring-primary-500 w-full rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--background))] px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2"
222+ data-testid = "wizard-id-input"
223+ />
224+ { alreadyMapped && (
225+ < p
226+ className = "text-xs text-amber-700 dark:text-amber-400"
227+ data-testid = "wizard-folder-mapped-error"
228+ >
229+ That id is already mapped — edit it from the Mapping list instead.
230+ </ p >
203231 ) }
204- < span className = "truncate font-mono text-xs" title = { folder } >
205- { folder }
206- </ span >
207- </ div >
208- ) }
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- ) }
232+ </ >
233+ ) : (
234+ < >
235+ < p className = "text-sm text-[rgb(var(--muted))]" >
236+ Which project folder do you want to map? Pick one, or choose a folder an app
237+ already opened.
238+ </ p >
239+ < Button variant = "primary" size = "sm" onClick = { pickFolder } disabled = { validating } >
240+ { validating ? (
241+ < Loader2 className = "mr-2 h-4 w-4 animate-spin" />
242+ ) : (
243+ < FolderOpen className = "mr-2 h-4 w-4" />
244+ ) }
245+ Choose folder…
246+ </ Button >
217247
218- { unmappedRoots . length > 0 && (
219- < div >
220- < div className = "mb-1.5 flex items-center gap-1.5 text-xs font-medium text-[rgb(var(--muted))]" >
221- < FolderSearch className = "h-3.5 w-3.5" />
222- Detected workspaces
223- </ div >
224- < div className = "overflow-hidden rounded-lg border border-[rgb(var(--border))]" >
225- { unmappedRoots . slice ( 0 , 6 ) . map ( ( r , i ) => (
226- < button
227- key = { r }
228- type = "button"
229- onClick = { ( ) => setFolder ( r ) }
230- className = { `flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-[rgb(var(--surface-hover))] ${
231- i > 0 ? 'border-t border-[rgb(var(--border-subtle))]' : ''
232- } ${ folder === r ? 'bg-primary-500/5' : '' } `}
233- >
234- < FolderOpen className = "h-4 w-4 flex-shrink-0 text-[rgb(var(--muted))]" />
235- < span className = "truncate font-mono text-xs" title = { r } >
236- { r }
237- </ span >
238- </ button >
239- ) ) }
240- </ div >
241- </ div >
248+ { folder && (
249+ < div
250+ className = { `flex items-center gap-2 rounded-lg border px-3 py-2 ${
251+ alreadyMapped
252+ ? 'border-amber-300 bg-amber-50 dark:border-amber-800/60 dark:bg-amber-900/20'
253+ : 'border-[rgb(var(--border))] bg-[rgb(var(--background))]'
254+ } `}
255+ >
256+ { alreadyMapped ? (
257+ < AlertCircle className = "h-4 w-4 flex-shrink-0 text-amber-600" />
258+ ) : (
259+ < Check className = "h-4 w-4 flex-shrink-0 text-green-600" />
260+ ) }
261+ < span className = "truncate font-mono text-xs" title = { folder } >
262+ { folder }
263+ </ span >
264+ </ div >
265+ ) }
266+ { alreadyMapped && (
267+ < p
268+ className = "text-xs text-amber-700 dark:text-amber-400"
269+ data-testid = "wizard-folder-mapped-error"
270+ >
271+ This folder is already mapped — edit it from the Workspaces list instead.
272+ </ p >
273+ ) }
274+
275+ { unmappedRoots . length > 0 && (
276+ < div >
277+ < div className = "mb-1.5 flex items-center gap-1.5 text-xs font-medium text-[rgb(var(--muted))]" >
278+ < FolderSearch className = "h-3.5 w-3.5" />
279+ Detected workspaces
280+ </ div >
281+ < div className = "overflow-hidden rounded-lg border border-[rgb(var(--border))]" >
282+ { unmappedRoots . slice ( 0 , 6 ) . map ( ( r , i ) => (
283+ < button
284+ key = { r }
285+ type = "button"
286+ onClick = { ( ) => setFolder ( r ) }
287+ className = { `flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-[rgb(var(--surface-hover))] ${
288+ i > 0 ? 'border-t border-[rgb(var(--border-subtle))]' : ''
289+ } ${ folder === r ? 'bg-primary-500/5' : '' } `}
290+ >
291+ < FolderOpen className = "h-4 w-4 flex-shrink-0 text-[rgb(var(--muted))]" />
292+ < span className = "truncate font-mono text-xs" title = { r } >
293+ { r }
294+ </ span >
295+ </ button >
296+ ) ) }
297+ </ div >
298+ </ div >
299+ ) }
300+ </ >
242301 ) }
243302 </ div >
244303 ) }
245304
246305 { step === 2 && (
247306 < div className = "space-y-3" data-testid = "wizard-step-apps" >
248- < WorkspaceInstallPanel workspaceRoot = { folder } />
249- < p className = "text-center text-xs text-[rgb(var(--muted))]" >
250- Optional — you can connect apps later from this folder's mapping.
251- </ p >
307+ { isId ? (
308+ < div className = "space-y-3" >
309+ < p className = "text-sm text-[rgb(var(--muted))]" >
310+ A headless or remote client routes here by sending this id in the{ ' ' }
311+ < code className = "font-mono text-xs" > X-Mcpmux-Workspace</ code > header. There's
312+ no folder to auto-write app config for — copy the value into your client.
313+ </ p >
314+ < div className = "flex items-stretch gap-2" >
315+ < code className = "flex-1 select-all break-all rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--background))] px-3 py-2 font-mono text-xs" >
316+ { folder || '—' }
317+ </ code >
318+ < Button
319+ variant = "secondary"
320+ size = "sm"
321+ onClick = { ( ) => void navigator . clipboard . writeText ( folder ) . catch ( ( ) => { } ) }
322+ >
323+ < Copy className = "h-3.5 w-3.5" />
324+ </ Button >
325+ </ div >
326+ </ div >
327+ ) : (
328+ < >
329+ < WorkspaceInstallPanel workspaceRoot = { folder } />
330+ < p className = "text-center text-xs text-[rgb(var(--muted))]" >
331+ Optional — you can connect apps later from this folder's mapping.
332+ </ p >
333+ </ >
334+ ) }
252335 </ div >
253336 ) }
254337
@@ -299,9 +382,9 @@ export function WorkspaceSetupWizard({
299382 type = "checkbox"
300383 checked = { fsIds . has ( fs . id ) }
301384 onChange = { ( ) => toggleFs ( fs . id ) }
302- className = "h-4 w-4 flex-shrink-0 accent-primary-500 "
385+ className = "accent-primary-500 h-4 w-4 flex-shrink-0"
303386 />
304- < Layers className = "h-4 w-4 flex-shrink-0 text-primary-500 " />
387+ < Layers className = "text-primary-500 h-4 w-4 flex-shrink-0" />
305388 < span className = "min-w-0 flex-1 truncate text-sm font-medium" > { fs . name } </ span >
306389 { isStarterFeatureSet ( fs ) && (
307390 < span className = "flex-shrink-0 rounded-full bg-[rgb(var(--surface))] px-1.5 text-[10px] font-semibold uppercase tracking-wider text-[rgb(var(--muted))]" >
@@ -354,7 +437,11 @@ export function WorkspaceSetupWizard({
354437 disabled = { saving || fsIds . size === 0 || ! folder }
355438 data-testid = "wizard-finish"
356439 >
357- { saving ? < Loader2 className = "mr-1.5 h-4 w-4 animate-spin" /> : < Wrench className = "mr-1.5 h-4 w-4" /> }
440+ { saving ? (
441+ < Loader2 className = "mr-1.5 h-4 w-4 animate-spin" />
442+ ) : (
443+ < Wrench className = "mr-1.5 h-4 w-4" />
444+ ) }
358445 Finish
359446 </ Button >
360447 ) }
0 commit comments