@@ -157,22 +157,6 @@ export function WorkspacesPage() {
157157 return m ;
158158 } , [ spaces ] ) ;
159159
160- /**
161- * The system's routing fallback: the `is_default` Space plus that Space's
162- * Default FeatureSet. Sessions whose reported root has no binding resolve
163- * here. We compute it once and pass it down so EntryCard can show the
164- * effective FS on every row, including unmapped ones.
165- */
166- const fallback = useMemo ( ( ) => {
167- const space = spaces . find ( ( s ) => s . is_default ) ?? spaces [ 0 ] ?? null ;
168- if ( ! space ) return null ;
169- const fs =
170- featureSets . find (
171- ( f ) => f . space_id === space . id && isStarterFeatureSet ( f )
172- ) ?? null ;
173- return { space, fs } ;
174- } , [ spaces , featureSets ] ) ;
175-
176160 /**
177161 * Unified list: live-reported roots come first (unmapped amber, then
178162 * mapped emerald), then persisted bindings whose clients aren't live.
@@ -379,25 +363,24 @@ export function WorkspacesPage() {
379363 { filtered . map ( ( entry ) => {
380364 const isSelected =
381365 selected ?. mode === 'entry' && selected . id === entry . id ;
382- // For mapped entries: trust the binding. For unmapped: fall
383- // back to the system's default Space + its Default FS so
384- // every card answers "what tools does this folder see?".
366+ // Mapped entries show their bound Space + FeatureSet names.
367+ // Unmapped entries deliberately show no preview — the card
368+ // reads "Not mapped" because the folder genuinely gets no
369+ // tools until the user maps it.
385370 const resolvedSpaceName = entry . binding
386371 ? spaceById . get ( entry . binding . space_id ) ?. name
387- : fallback ?. space . name ;
388- const resolvedFsName = entry . binding
389- ? formatFsList (
390- entry . binding . feature_set_ids . map (
391- ( id ) => fsById . get ( id ) ?. name ?? id
392- )
372+ : undefined ;
373+ const fsNames = entry . binding
374+ ? entry . binding . feature_set_ids . map (
375+ ( id ) => fsById . get ( id ) ?. name ?? id
393376 )
394- : fallback ?. fs ?. name ;
377+ : [ ] ;
395378 return (
396379 < EntryCard
397380 key = { entry . id }
398381 entry = { entry }
399382 spaceName = { resolvedSpaceName }
400- fsName = { resolvedFsName }
383+ fsNames = { fsNames }
401384 selected = { isSelected }
402385 onClick = { ( ) => setSelected ( { mode : 'entry' , id : entry . id } ) }
403386 />
@@ -522,134 +505,126 @@ function SegmentedFilter<T extends string>({
522505}
523506
524507// ---------------------------------------------------------------------------
525- // Entry card — a workspace folder and the tools it maps to. Modern tile:
526- // gradient status icon, folder-name-first hierarchy, chip summary.
508+ // Entry card — a workspace folder and the tools it maps to. Matches the
509+ // Clients/Servers card template: flat surface icon box + subtle border,
510+ // w-14 icon, p-6, hover:scale. Folder name leads, full path beneath, and a
511+ // feature-set summary that collapses to "first + N more" so it stays tidy
512+ // no matter how many sets a folder maps to.
527513// ---------------------------------------------------------------------------
528514
529- type Tone = 'amber' | 'emerald' | 'neutral' ;
530-
531515/** Last path segment — the folder's own name (`proj` from `/a/b/proj`). */
532516function folderName ( path : string ) : string {
533517 const parts = path . split ( / [ / \\ ] / ) . filter ( Boolean ) ;
534518 return parts [ parts . length - 1 ] || path ;
535519}
536520
537- /** Everything above the folder name, shown as a muted breadcrumb line. */
538- function folderParent ( path : string ) : string {
539- const trimmed = path . replace ( / [ / \\ ] + $ / , '' ) ;
540- const idx = Math . max ( trimmed . lastIndexOf ( '/' ) , trimmed . lastIndexOf ( '\\' ) ) ;
541- return idx > 0 ? trimmed . slice ( 0 , idx ) : '' ;
542- }
543-
544- const TILE_TONES : Record < Tone , string > = {
545- emerald : 'bg-gradient-to-br from-emerald-400 to-teal-500 shadow-emerald-500/25' ,
546- amber : 'bg-gradient-to-br from-amber-400 to-orange-500 shadow-amber-500/25' ,
547- neutral :
548- 'bg-gradient-to-br from-slate-400 to-slate-500 dark:from-slate-600 dark:to-slate-700 shadow-slate-900/10' ,
549- } ;
550-
551- /** The gradient folder tile with a pulsing live indicator. */
552- function WorkspaceTile ( { tone, live } : { tone : Tone ; live : boolean } ) {
553- return (
554- < div className = "relative flex-shrink-0" >
555- < div
556- className = { [
557- 'flex h-12 w-12 items-center justify-center rounded-2xl text-white shadow-lg ring-1 ring-inset ring-white/25' ,
558- TILE_TONES [ tone ] ,
559- ] . join ( ' ' ) }
560- >
561- { live ? (
562- < FolderOpen className = "h-[22px] w-[22px]" strokeWidth = { 2 } />
563- ) : (
564- < Folder className = "h-[22px] w-[22px]" strokeWidth = { 2 } />
565- ) }
566- </ div >
567- { live && (
568- < span
569- className = "absolute -right-1 -top-1 flex h-3 w-3"
570- title = "A client is active in this folder right now"
571- >
572- < span className = "absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
573- < span className = "relative inline-flex h-3 w-3 rounded-full bg-emerald-500 ring-2 ring-[rgb(var(--background))]" />
574- </ span >
575- ) }
576- </ div >
577- ) ;
521+ /**
522+ * Compact feature-set summary for the card footer. Lists up to two names,
523+ * then collapses to "first + N more" so a folder mapped to many sets doesn't
524+ * blow out the card. Full list is exposed via the `title` tooltip at the call
525+ * site.
526+ */
527+ function summarizeFeatureSets ( names : string [ ] ) : string {
528+ if ( names . length === 0 ) return 'No tools' ;
529+ if ( names . length <= 2 ) return names . join ( ' + ' ) ;
530+ return `${ names [ 0 ] } + ${ names . length - 1 } more` ;
578531}
579532
580533function EntryCard ( {
581534 entry,
582535 spaceName,
583- fsName ,
536+ fsNames ,
584537 selected,
585538 onClick,
586539} : {
587540 entry : Entry ;
588541 spaceName : string | undefined ;
589- fsName : string | undefined ;
542+ /** Resolved FeatureSet names for a mapped folder; empty when unmapped. */
543+ fsNames : string [ ] ;
590544 selected : boolean ;
591545 onClick : ( ) => void ;
592546} ) {
593- const tone : Tone =
547+ const folderColor =
594548 entry . kind === 'unmapped-live'
595- ? 'amber'
549+ ? 'text- amber-500 '
596550 : entry . kind === 'mapped-live'
597- ? 'emerald'
598- : 'neutral ' ;
551+ ? 'text- emerald-500 '
552+ : 'text-[rgb(var(--muted))] ' ;
599553 const name = folderName ( entry . root ) ;
600- const parent = folderParent ( entry . root ) ;
601554
602555 return (
603556 < Card
604- className = { `group relative cursor-pointer overflow-hidden rounded-2xl transition-all duration-200 hover:-translate-y-0.5 hover:shadow-xl ${
605- selected
606- ? 'ring-2 ring-primary-500 shadow-lg'
607- : 'hover:ring-1 hover:ring-[rgb(var(--border-strong,var(--border)))]'
557+ className = { `cursor-pointer transition-all hover:shadow-lg hover:scale-[1.01] ${
558+ selected ? 'ring-2 ring-primary-500 shadow-lg' : ''
608559 } `}
609560 onClick = { onClick }
610561 data-testid = { `workspace-entry-${ entry . id } ` }
611562 >
612- < CardContent className = "p-5" >
613- < div className = "flex items-start gap-3.5" >
614- < WorkspaceTile tone = { tone } live = { entry . isLive } />
563+ < CardContent className = "p-6" >
564+ < div className = "mb-4 flex items-start gap-4" >
565+ < div className = "relative flex-shrink-0" >
566+ < div className = "flex h-14 w-14 items-center justify-center rounded-xl border border-[rgb(var(--border-subtle))] bg-[rgb(var(--surface))]" >
567+ { entry . isLive ? (
568+ < FolderOpen className = { `h-6 w-6 ${ folderColor } ` } />
569+ ) : (
570+ < Folder className = { `h-6 w-6 ${ folderColor } ` } />
571+ ) }
572+ </ div >
573+ { entry . isLive && (
574+ < span
575+ className = "absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-emerald-500 ring-2 ring-[rgb(var(--background))]"
576+ title = "A client is active in this folder right now"
577+ />
578+ ) }
579+ </ div >
615580 < div className = "min-w-0 flex-1" >
616581 < div className = "mb-1 flex flex-wrap items-center gap-2" >
617582 { entry . kind === 'unmapped-live' && < Pill tone = "amber" > Unmapped</ Pill > }
618583 { entry . kind === 'mapped-offline' && < Pill tone = "neutral" > Offline</ Pill > }
619584 { entry . kind === 'mapped-live' && < Pill tone = "emerald" > Live</ Pill > }
620585 </ div >
621- < p
622- className = "truncate text-[15px] font-semibold text-[rgb(var(--foreground))]"
623- title = { entry . root }
624- >
586+ < h3 className = "truncate text-base font-semibold" title = { entry . root } >
625587 { name }
626- </ p >
588+ </ h3 >
627589 < p
628- className = "truncate font-mono text-[11px] text-[rgb(var(--muted))]"
590+ className = "truncate font-mono text-xs text-[rgb(var(--muted))]"
629591 title = { entry . root }
630592 >
631- { parent || entry . root }
593+ { entry . root }
632594 </ p >
633595 </ div >
634- < ChevronRight className = "h-4 w-4 flex-shrink-0 text-[rgb(var(--muted))] opacity-0 transition-opacity group-hover:opacity-100" />
635596 </ div >
636597
637- < div className = "mt-4 border-t border-[rgb(var(--border-subtle))] pt-3.5 text-xs" >
598+ < div className = "border-t border-[rgb(var(--border-subtle))] pt-4 text-xs" >
638599 { entry . binding ? (
639- < div className = "flex flex-wrap items-center gap-1.5 text-[rgb(var(--muted))]" >
640- < span > Serves</ span >
641- < Chip tone = "primary" > { fsName ?? '—' } </ Chip >
642- < span > from</ span >
643- < Chip tone = "neutral" > { spaceName ?? '—' } </ Chip >
644- </ div >
645- ) : (
646- < div className = "flex flex-wrap items-center gap-1.5" >
647- < span className = "inline-flex items-center gap-1.5 font-medium text-amber-600 dark:text-amber-400" >
648- < AlertCircle className = "h-3.5 w-3.5" />
649- Not mapped
600+ < div className = "flex items-center justify-between gap-3" >
601+ < span className = "inline-flex min-w-0 items-center gap-1.5" >
602+ < Layers className = "h-3.5 w-3.5 flex-shrink-0 text-primary-500" />
603+ < span
604+ className = "truncate font-medium text-[rgb(var(--foreground))]"
605+ title = { fsNames . join ( ', ' ) }
606+ >
607+ { summarizeFeatureSets ( fsNames ) }
608+ </ span >
609+ { fsNames . length > 1 && (
610+ < span
611+ className = "flex-shrink-0 rounded-full bg-primary-500/10 px-1.5 text-[10px] font-bold tabular-nums text-primary-600 dark:text-primary-300"
612+ title = { `${ fsNames . length } feature sets` }
613+ >
614+ { fsNames . length }
615+ </ span >
616+ ) }
617+ </ span >
618+ < span className = "inline-flex flex-shrink-0 items-center gap-1.5 text-[rgb(var(--muted))]" >
619+ < span > in</ span >
620+ < Chip tone = "neutral" > { spaceName ?? '—' } </ Chip >
650621 </ span >
651- < span className = "text-[rgb(var(--muted))]" > — gets no tools until you map it</ span >
652622 </ div >
623+ ) : (
624+ < span className = "inline-flex items-center gap-1.5 font-medium text-amber-600 dark:text-amber-400" >
625+ < AlertCircle className = "h-3.5 w-3.5 flex-shrink-0" />
626+ Not mapped — no tools until you map it
627+ </ span >
653628 ) }
654629 </ div >
655630 </ CardContent >
0 commit comments