Skip to content

Commit 15cf4e4

Browse files
committed
fix(workspaces): card honors longest-prefix inheritance (align with resolver)
The Workspaces card decided mapped/unmapped by an EXACT root lookup while the gateway resolver (and the Effective Features panel) uses longest-prefix match. So a folder whose ancestor is bound (e.g. d:\mcpmux\mcp-mux under a d:\mcpmux binding) showed "Unmapped — no tools" even though it genuinely inherits the parent's tools. Pure display bug; routing was correct. Make the page prefix-aware, mirroring mcpmux_core::longest_prefix_match: - new prefixMatch util (longestPrefixBinding / resolveRootBinding), unit-tested; - Entry gains `inherited` (ancestor binding when there's no own binding); - inherited live folders render as "Inherited" (sky) with "Inherits <FS> from <Space> · via parent <ancestor> — map to override", not "Unmapped / no tools"; - inspector shows an "Inherited mapping" note + override framing in the create-from-live panel for such folders. Byte-proven: tests/ts prefixMatch.test.ts (exact / inherited / longest-prefix / boundary / POSIX). vitest 183 pass, typecheck + eslint clean. Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent 760a024 commit 15cf4e4

3 files changed

Lines changed: 229 additions & 52 deletions

File tree

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

Lines changed: 111 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
} from '@/lib/api/featureSets';
5252
import { useSpaces } from '@/stores';
5353
import 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';
7273
interface 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
}
7987
type 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}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { WorkspaceBinding } from '@/lib/api/workspaceBindings';
2+
3+
/**
4+
* Frontend mirror of `mcpmux_core::longest_prefix_match` — the SAME rule the
5+
* gateway resolver uses to route a reported root to a binding. Both the
6+
* `root` and every binding's `workspace_root` are already normalized
7+
* (drive-letter case, slash direction, no trailing slash) by the time they
8+
* reach the UI, so we compare them as-is: an exact match, or a binding whose
9+
* root is a path-component-boundary prefix of `root`. The longest such match
10+
* wins.
11+
*
12+
* This is what makes the Workspaces card agree with the resolver: a folder
13+
* with no binding of its own still "inherits" an ancestor's binding (e.g.
14+
* `d:\mcpmux\mcp-mux` inherits `d:\mcpmux`), so it is genuinely mapped and
15+
* gets tools — not "unmapped".
16+
*/
17+
export function longestPrefixBinding(
18+
root: string,
19+
bindings: WorkspaceBinding[]
20+
): WorkspaceBinding | null {
21+
let best: WorkspaceBinding | null = null;
22+
for (const b of bindings) {
23+
const c = b.workspace_root;
24+
const boundary = root[c.length];
25+
const matches = root === c || (root.startsWith(c) && (boundary === '/' || boundary === '\\'));
26+
if (matches && (best === null || c.length > best.workspace_root.length)) {
27+
best = b;
28+
}
29+
}
30+
return best;
31+
}
32+
33+
/** Resolve a reported root to its own (exact) binding + the binding it
34+
* effectively resolves through (exact or inherited from an ancestor). */
35+
export function resolveRootBinding(
36+
root: string,
37+
bindings: WorkspaceBinding[]
38+
): { exact: WorkspaceBinding | null; effective: WorkspaceBinding | null } {
39+
const effective = longestPrefixBinding(root, bindings);
40+
const exact = effective && effective.workspace_root === root ? effective : null;
41+
return { exact, effective };
42+
}

0 commit comments

Comments
 (0)