@@ -51,6 +51,7 @@ import {
5151} from '@/lib/api/featureSets' ;
5252import { useSpaces } from '@/stores' ;
5353import type { Space } from '@/lib/api/spaces' ;
54+ import { resolveRootBinding } from './prefixMatch' ;
5455
5556/**
5657 * Workspaces page.
@@ -68,12 +69,19 @@ import type { Space } from '@/lib/api/spaces';
6869 * • OFFLINE + mapped → neutral
6970 */
7071
71- type EntryKind = 'unmapped-live' | 'mapped-live' | 'mapped-offline' ;
72+ type EntryKind = 'unmapped-live' | 'mapped-live' | 'inherited-live' | ' mapped-offline';
7273interface Entry {
7374 id : string ;
7475 kind : EntryKind ;
7576 root : string ;
77+ /** This folder's OWN (exact) binding, if any. Drives edit/delete. */
7678 binding : WorkspaceBinding | null ;
79+ /**
80+ * An ancestor binding this folder resolves through when it has no exact
81+ * binding of its own (longest-prefix inheritance — mirrors the gateway
82+ * resolver). Drives the "inherits from …" display.
83+ */
84+ inherited : WorkspaceBinding | null ;
7785 isLive : boolean ;
7886}
7987type Selected = { mode : 'new' } | { mode : 'entry' ; id : string } ;
@@ -141,11 +149,6 @@ export function WorkspacesPage() {
141149 }
142150 } ;
143151
144- const bindingsByRoot = useMemo ( ( ) => {
145- const m = new Map < string , WorkspaceBinding > ( ) ;
146- for ( const b of bindings ) m . set ( b . workspace_root . toLowerCase ( ) , b ) ;
147- return m ;
148- } , [ bindings ] ) ;
149152 const fsById = useMemo ( ( ) => {
150153 const m = new Map < string , FeatureSet > ( ) ;
151154 for ( const f of featureSets ) m . set ( f . id , f ) ;
@@ -168,12 +171,21 @@ export function WorkspacesPage() {
168171 const key = root . toLowerCase ( ) ;
169172 if ( seen . has ( key ) ) continue ;
170173 seen . add ( key ) ;
171- const binding = bindingsByRoot . get ( key ) ?? null ;
174+ // Match the gateway resolver: longest-prefix, so a folder with no
175+ // binding of its own still resolves through an ancestor's (inherited).
176+ const { exact, effective } = resolveRootBinding ( root , bindings ) ;
177+ const inherited = exact ? null : effective ;
178+ const kind : EntryKind = exact
179+ ? 'mapped-live'
180+ : inherited
181+ ? 'inherited-live'
182+ : 'unmapped-live' ;
172183 list . push ( {
173- id : binding ?. id ?? `live:${ root } ` ,
174- kind : binding ? 'mapped-live' : 'unmapped-live' ,
184+ id : exact ?. id ?? `live:${ root } ` ,
185+ kind,
175186 root,
176- binding,
187+ binding : exact ,
188+ inherited,
177189 isLive : true ,
178190 } ) ;
179191 }
@@ -186,31 +198,33 @@ export function WorkspacesPage() {
186198 kind : 'mapped-offline' ,
187199 root : b . workspace_root ,
188200 binding : b ,
201+ inherited : null ,
189202 isLive : false ,
190203 } ) ;
191204 }
192205 const rank : Record < EntryKind , number > = {
193206 'unmapped-live' : 0 ,
194207 'mapped-live' : 1 ,
195- 'mapped-offline' : 2 ,
208+ 'inherited-live' : 2 ,
209+ 'mapped-offline' : 3 ,
196210 } ;
197211 return list . sort ( ( a , b ) => {
198212 const o = rank [ a . kind ] - rank [ b . kind ] ;
199213 return o !== 0 ? o : a . root . localeCompare ( b . root ) ;
200214 } ) ;
201- } , [ bindings , bindingsByRoot , reportedRoots ] ) ;
215+ } , [ bindings , reportedRoots ] ) ;
202216
203217 const filtered = useMemo ( ( ) => {
204218 const q = searchQuery . trim ( ) . toLowerCase ( ) ;
205219 return entries . filter ( ( e ) => {
206220 if ( filter === 'live' && ! e . isLive ) return false ;
207221 if ( filter === 'unmapped' && e . kind !== 'unmapped-live' ) return false ;
208222 if ( ! q ) return true ;
209- const spaceName = e . binding ? spaceById . get ( e . binding . space_id ) ?. name ?? '' : '' ;
210- const fsNames = e . binding
211- ? e . binding . feature_set_ids
212- . map ( ( id ) => fsById . get ( id ) ?. name ?? '' )
213- . join ( ' ' )
223+ // Resolve display names from the effective binding (own or inherited).
224+ const eff = e . binding ?? e . inherited ;
225+ const spaceName = eff ? spaceById . get ( eff . space_id ) ?. name ?? '' : '' ;
226+ const fsNames = eff
227+ ? eff . feature_set_ids . map ( ( id ) => fsById . get ( id ) ?. name ?? '' ) . join ( ' ' )
214228 : '' ;
215229 return (
216230 e . root . toLowerCase ( ) . includes ( q ) ||
@@ -363,17 +377,16 @@ export function WorkspacesPage() {
363377 { filtered . map ( ( entry ) => {
364378 const isSelected =
365379 selected ?. mode === 'entry' && selected . id === entry . id ;
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.
370- const resolvedSpaceName = entry . binding
371- ? spaceById . get ( entry . binding . space_id ) ?. name
380+ // Show the EFFECTIVE binding's Space + FeatureSet names — the
381+ // folder's own binding, or the ancestor it inherits from.
382+ // Truly-unmapped entries (no own + no inherited) show no
383+ // preview and read "Not mapped".
384+ const eff = entry . binding ?? entry . inherited ;
385+ const resolvedSpaceName = eff
386+ ? spaceById . get ( eff . space_id ) ?. name
372387 : undefined ;
373- const fsNames = entry . binding
374- ? entry . binding . feature_set_ids . map (
375- ( id ) => fsById . get ( id ) ?. name ?? id
376- )
388+ const fsNames = eff
389+ ? eff . feature_set_ids . map ( ( id ) => fsById . get ( id ) ?. name ?? id )
377390 : [ ] ;
378391 return (
379392 < EntryCard
@@ -540,6 +553,10 @@ const CARD_TONES = {
540553 strip : 'bg-emerald-500' ,
541554 box : 'bg-emerald-50 text-emerald-600 ring-emerald-200/70 dark:bg-emerald-900/20 dark:text-emerald-400 dark:ring-emerald-800/50' ,
542555 } ,
556+ sky : {
557+ strip : 'bg-sky-500' ,
558+ box : 'bg-sky-50 text-sky-600 ring-sky-200/70 dark:bg-sky-900/20 dark:text-sky-400 dark:ring-sky-800/50' ,
559+ } ,
543560 amber : {
544561 strip : 'bg-amber-500' ,
545562 box : 'bg-amber-50 text-amber-600 ring-amber-200/70 dark:bg-amber-900/20 dark:text-amber-400 dark:ring-amber-800/50' ,
@@ -569,9 +586,13 @@ function EntryCard({
569586 ? 'amber'
570587 : entry . kind === 'mapped-live'
571588 ? 'emerald'
572- : 'neutral' ;
589+ : entry . kind === 'inherited-live'
590+ ? 'sky'
591+ : 'neutral' ;
573592 const t = CARD_TONES [ tone ] ;
574593 const name = folderName ( entry . root ) ;
594+ // The ancestor folder this entry inherits its mapping from (if any).
595+ const inheritedFrom = entry . inherited ? folderName ( entry . inherited . workspace_root ) : null ;
575596
576597 return (
577598 < Card
@@ -607,6 +628,7 @@ function EntryCard({
607628 { entry . kind === 'unmapped-live' && < Pill tone = "amber" > Unmapped</ Pill > }
608629 { entry . kind === 'mapped-offline' && < Pill tone = "neutral" > Offline</ Pill > }
609630 { entry . kind === 'mapped-live' && < Pill tone = "emerald" > Live</ Pill > }
631+ { entry . kind === 'inherited-live' && < Pill tone = "sky" > Inherited</ Pill > }
610632 </ div >
611633 < h3 className = "truncate text-base font-semibold" title = { entry . root } >
612634 { name }
@@ -621,29 +643,42 @@ function EntryCard({
621643 </ div >
622644
623645 < div className = "border-t border-[rgb(var(--border-subtle))] pt-4 text-xs" >
624- { entry . binding ? (
625- < div className = "flex items-center justify-between gap-3" >
626- < span className = "inline-flex min-w-0 items-center gap-1.5" >
627- < Layers className = "h-3.5 w-3.5 flex-shrink-0 text-primary-500" />
628- < span
629- className = "truncate font-medium text-[rgb(var(--foreground))]"
630- title = { fsNames . join ( ', ' ) }
631- >
632- { summarizeFeatureSets ( fsNames ) }
633- </ span >
634- { fsNames . length > 1 && (
646+ { entry . binding || entry . inherited ? (
647+ < div className = "space-y-1" >
648+ < div className = "flex items-center justify-between gap-3" >
649+ < span className = "inline-flex min-w-0 items-center gap-1.5" >
650+ < Layers className = "h-3.5 w-3.5 flex-shrink-0 text-primary-500" />
651+ < span className = "flex-shrink-0 text-[rgb(var(--muted))]" >
652+ { entry . binding ? 'Serves' : 'Inherits' }
653+ </ span >
635654 < span
636- 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 "
637- title = { ` ${ fsNames . length } feature sets` }
655+ className = "truncate font-medium text-[rgb(var(--foreground))] "
656+ title = { fsNames . join ( ', ' ) }
638657 >
639- { fsNames . length }
658+ { summarizeFeatureSets ( fsNames ) }
640659 </ span >
641- ) }
642- </ span >
643- < span className = "inline-flex flex-shrink-0 items-center gap-1.5 text-[rgb(var(--muted))]" >
644- < span > in</ span >
645- < Chip tone = "neutral" > { spaceName ?? '—' } </ Chip >
646- </ span >
660+ { fsNames . length > 1 && (
661+ < span
662+ 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"
663+ title = { `${ fsNames . length } feature sets` }
664+ >
665+ { fsNames . length }
666+ </ span >
667+ ) }
668+ </ span >
669+ < span className = "inline-flex flex-shrink-0 items-center gap-1.5 text-[rgb(var(--muted))]" >
670+ < span > in</ span >
671+ < Chip tone = "neutral" > { spaceName ?? '—' } </ Chip >
672+ </ span >
673+ </ div >
674+ { entry . inherited && (
675+ < div
676+ className = "truncate text-[11px] text-sky-600 dark:text-sky-400"
677+ title = { entry . inherited . workspace_root }
678+ >
679+ via parent mapping { inheritedFrom } — map this folder to override
680+ </ div >
681+ ) }
647682 </ div >
648683 ) : (
649684 < span className = "inline-flex items-center gap-1.5 font-medium text-amber-600 dark:text-amber-400" >
@@ -662,14 +697,16 @@ function Pill({
662697 tone,
663698} : {
664699 children : React . ReactNode ;
665- tone : 'amber' | 'emerald' | 'neutral' ;
700+ tone : 'amber' | 'emerald' | 'sky' | ' neutral';
666701} ) {
667702 const cls =
668703 tone === 'amber'
669704 ? 'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-400 border-amber-200/80 dark:border-amber-800/60'
670705 : tone === 'emerald'
671706 ? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400 border-emerald-200/80 dark:border-emerald-800/60'
672- : 'bg-[rgb(var(--surface))] text-[rgb(var(--muted))] border-[rgb(var(--border-subtle))]' ;
707+ : tone === 'sky'
708+ ? 'bg-sky-50 dark:bg-sky-900/20 text-sky-700 dark:text-sky-400 border-sky-200/80 dark:border-sky-800/60'
709+ : 'bg-[rgb(var(--surface))] text-[rgb(var(--muted))] border-[rgb(var(--border-subtle))]' ;
673710 return (
674711 < span
675712 className = { `inline-flex items-center px-1.5 py-0.5 rounded-md border text-[10px] font-semibold uppercase tracking-wider ${ cls } ` }
@@ -905,7 +942,10 @@ function InspectorPanel({
905942 < div className = "flex-1 min-w-0" >
906943 < div className = "flex items-center gap-2 mb-0.5 flex-wrap" >
907944 { ! isNew && entry ?. isLive && < Pill tone = "emerald" > Live</ Pill > }
908- { ! isNew && entry && ! isMapped && < Pill tone = "amber" > Unmapped</ Pill > }
945+ { ! isNew && entry ?. inherited && < Pill tone = "sky" > Inherited</ Pill > }
946+ { ! isNew && entry && ! isMapped && ! entry . inherited && (
947+ < Pill tone = "amber" > Unmapped</ Pill >
948+ ) }
909949 { ! isNew && entry && isMapped && ! entry . isLive && < Pill tone = "neutral" > Offline</ Pill > }
910950 </ div >
911951 < h2 className = "text-lg font-bold truncate" > { title } </ h2 >
@@ -936,7 +976,9 @@ function InspectorPanel({
936976 mode === 'create'
937977 ? 'Choose the folder and the tools it should get.'
938978 : mode === 'create-from-live'
939- ? 'This folder is open in an app but has no tools yet — map it.'
979+ ? entry ?. inherited
980+ ? 'Inherits a parent mapping — save here to override it for this folder.'
981+ : 'This folder is open in an app but has no tools yet — map it.'
940982 : isMapped && entry ?. binding
941983 ? `Gives ${
942984 formatFsList (
@@ -953,6 +995,23 @@ function InspectorPanel({
953995 headerExtra = { < SaveStatusPill status = { saveStatus } /> }
954996 testId = "workspace-mapping-section"
955997 >
998+ { entry ?. inherited && ! isMapped && (
999+ < div
1000+ className = "mb-4 rounded-lg border border-sky-200 bg-sky-50 px-3.5 py-3 text-xs leading-relaxed text-sky-800 dark:border-sky-800/60 dark:bg-sky-900/20 dark:text-sky-300"
1001+ data-testid = "workspace-inherited-note"
1002+ >
1003+ < span className = "font-semibold" > Inherited mapping.</ span > This folder
1004+ has no mapping of its own — it currently resolves through its parent{ ' ' }
1005+ < code className = "font-mono" > { entry . inherited . workspace_root } </ code > (
1006+ { formatFsList (
1007+ entry . inherited . feature_set_ids . map (
1008+ ( id ) => featureSets . find ( ( f ) => f . id === id ) ?. name ?? id
1009+ )
1010+ ) || 'no tools' }
1011+ ). Saving below creates a mapping for < em > this</ em > folder that
1012+ overrides the inherited one.
1013+ </ div >
1014+ ) }
9561015 < BindingForm
9571016 mode = { mode }
9581017 spaces = { spaces }
0 commit comments