Skip to content

Commit 163ee0b

Browse files
authored
feat(featureset): protect Starter from deletion + clarify mapping popup (#176)
Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent 7fc50a0 commit 163ee0b

11 files changed

Lines changed: 208 additions & 51 deletions

File tree

apps/desktop/src/features/featuresets/FeatureSetPanel.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,11 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda
5858

5959
// Both FS types are member-driven now.
6060
const isConfigurable = true;
61-
// The auto-seeded "Starter" FS is treated identically to a Custom one
62-
// — the type tag is a UI hint, not a routing flag.
61+
// The auto-seeded "Starter" FS has editable membership like a Custom one
62+
// (change which tools it includes, or empty it). What's locked is its
63+
// identity + lifecycle: it's the default fallback for unmapped folders, so
64+
// its name is fixed (the backend ignores name changes on builtin rows) and
65+
// it can't be deleted — the Delete action below is gated to Custom sets.
6366
const isStarter = isStarterFeatureSet(featureSet);
6467
const isCustom = featureSet.feature_set_type === 'custom';
6568

@@ -257,7 +260,7 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda
257260
<span
258261
title={
259262
isStarter
260-
? 'Auto-created with this Space. Edit, rename, or delete freely — no special routing role.'
263+
? "Auto-created with this Space. The default set for folders you haven't mapped — edit which tools it includes; its name is fixed and it can't be deleted."
261264
: undefined
262265
}
263266
className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium border ${
@@ -339,7 +342,7 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda
339342
<div className="flex gap-2">
340343
<Star className="h-4 w-4 text-yellow-500 flex-shrink-0 mt-0.5" />
341344
<div className="text-xs text-yellow-800 dark:text-yellow-200">
342-
<strong>Starter FeatureSet:</strong> auto-created with this Space. It&apos;s an ordinary FeatureSet — edit, rename, or delete it freely. <em>No special routing role:</em> Workspace bindings and per-client grants pick FeatureSets explicitly.
345+
<strong>Starter FeatureSet:</strong> auto-created with this Space and used as the <em>default</em> for folders you haven&apos;t explicitly mapped (and rootless sessions). Edit which tools it includes (or empty it) to change what they get. Its name is fixed and it <strong>can&apos;t be deleted</strong>, since the fallback always needs a stable target.
343346
</div>
344347
</div>
345348
</div>

apps/desktop/src/features/featuresets/FeatureSetsPage.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,9 +199,9 @@ export function FeatureSetsPage() {
199199
})
200200
.sort((a, b) => {
201201
// Starter FS first (pinned to top — operator usually wants the
202-
// auto-seeded one near the top so they can edit / delete it
203-
// first), then Custom sets alphabetically. The 'default' key is
204-
// kept so a stale row read pre-migration still sorts correctly.
202+
// auto-seeded default set near the top so they can edit it first),
203+
// then Custom sets alphabetically. The 'default' key is kept so a
204+
// stale row read pre-migration still sorts correctly.
205205
const order: Record<string, number> = {
206206
starter: 0,
207207
default: 0,
@@ -375,7 +375,7 @@ export function FeatureSetsPage() {
375375
{isStarter && (
376376
<div
377377
className="absolute right-3 top-3 flex items-center gap-1.5 rounded-full bg-gradient-to-r from-emerald-500 to-green-500 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-white shadow-[0_4px_12px_-2px_rgb(16_185_129/0.5)]"
378-
title="Auto-seeded with this Space. Edit, rename, or delete freely — no special routing role; bindings and per-client grants pick FeatureSets explicitly."
378+
title="Auto-seeded with this Space. The default set for folders you haven't mapped — edit which tools it includes to change what they get. Its name is fixed and it can't be deleted."
379379
data-testid={`featureset-starter-badge-${fs.id}`}
380380
>
381381
<CheckCircle2 className="h-3 w-3" />

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

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
/**
22
* Workspace Binding Sheet
33
*
4-
* Fires when a connected client session resolves via source=Default for a
5-
* workspace root that has no binding yet. The user picks a Space + a
6-
* FeatureSet in that space, and we write a WorkspaceBinding locking both.
4+
* Fires when a connected client session reports a workspace root that has no
5+
* explicit binding yet — the folder is already working via the default
6+
* Starter set, and this sheet offers to map it to something else. The user
7+
* picks a Space + a FeatureSet, and we write a WorkspaceBinding locking both.
78
*
89
* • Space picker — defaults to the caller's current space, can be changed.
9-
* • FS picker — always includes a "space default" option (follow
10-
* whichever FS is active for the selected Space) plus
11-
* every Default + Custom set in that space.
12-
* • Dismiss — nothing written, ask again next session.
10+
* • FS picker — pre-selects the Space's Starter (the active default),
11+
* plus every Starter + Custom set in that space.
12+
* • Modify — writes the binding for the picked Space + FS.
13+
* • Close — nothing written; the folder keeps the default Starter
14+
* set, and the sheet re-offers next session.
1315
*
1416
* Committing the binding emits `WorkspaceBindingChanged` on the backend,
1517
* which triggers `notifications/tools/list_changed` — the client re-fetches
@@ -61,8 +63,8 @@ export function WorkspaceBindingSheet() {
6163
// Only dedupe the currently-open sheet against itself — if one is already
6264
// showing, swallow a second emit for the same session. We deliberately
6365
// don't dedupe across sessions / reconnects: the backend only emits when
64-
// `source=Default` (i.e. no binding exists), and reconnecting a client
65-
// is a normal signal that the user may want to configure the folder.
66+
// the folder has no explicit binding (it's on the default Starter set), and
67+
// reconnecting a client is a normal signal that the user may want to map it.
6668
// Persisting the dismissal in a ref would black-hole later attempts
6769
// until the next app restart, which is how this bug surfaced before.
6870
const currentSessionRef = useRef<string | null>(null);
@@ -74,7 +76,7 @@ export function WorkspaceBindingSheet() {
7476
(event) => {
7577
// Swallow only while a sheet is already showing — the user is
7678
// mid-decision, a second emit would stack a new sheet on top. Once
77-
// the current sheet closes (Save or Not now), the next emit from
79+
// the current sheet closes (Modify or Close), the next emit from
7880
// any fresh session on an unbound root opens the sheet again.
7981
if (currentSessionRef.current !== null) return;
8082
const p = event.payload;
@@ -194,12 +196,13 @@ export function WorkspaceBindingSheet() {
194196
New workspace detected
195197
</div>
196198
<h2 className="text-[22px] font-semibold leading-tight tracking-tight text-[rgb(var(--foreground))]">
197-
Which tools should this folder get?
199+
This folder is using your Starter set
198200
</h2>
199201
<p className="mt-2 text-sm text-[rgb(var(--muted))]">
200202
You just opened this folder in a connected app. It&apos;s already
201-
using your default Starter tools — pick a Space and feature set to
202-
give it a specific set instead, or keep the default.
203+
configured with your default Starter tools. Pick a different Space
204+
or feature set below to change what it gets, or close to keep the
205+
Starter.
203206
</p>
204207

205208
<div className="mt-5 flex items-start gap-3 rounded-xl border border-[rgb(var(--border))] bg-[rgb(var(--surface))] px-4 py-3">
@@ -282,17 +285,18 @@ export function WorkspaceBindingSheet() {
282285
{error}
283286
</div>
284287
)}
285-
{/* "Not now" auto-sizes to its label; the primary action takes
286-
the rest of the row. Equal flex-1 columns wrapped the longer
287-
"Remember for this folder" text onto two lines. */}
288+
{/* "Close" keeps the default Starter set (nothing written); the
289+
primary "Modify" applies the picked Space + feature set as an
290+
explicit mapping. Labels deliberately avoid "Not now", which read
291+
as "this folder is unmapped / has no tools" — it isn't. */}
288292
<div className="flex gap-2">
289293
<Button
290294
variant="secondary"
291295
className="px-5"
292296
onClick={handleDismiss}
293297
disabled={saving}
294298
>
295-
Not now
299+
Close
296300
</Button>
297301
<Button
298302
variant="primary"
@@ -305,7 +309,7 @@ export function WorkspaceBindingSheet() {
305309
) : (
306310
<Check className="mr-1.5 h-4 w-4" />
307311
)}
308-
Remember for this folder
312+
Modify
309313
</Button>
310314
</div>
311315
<p className="mt-3 text-center text-[11px] text-[rgb(var(--muted))]">

apps/desktop/src/lib/api/featureSets.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import { invoke } from '@tauri-apps/api/core';
77
* - `custom`: user-defined.
88
*/
99
/**
10-
* `starter` is the auto-seeded FS that comes with each Space. It has no
11-
* special routing role under resolver v3 — bindings and per-client grants
12-
* pick FeatureSets explicitly. The legacy `'default'` value is accepted on
13-
* read because migration 013 rewrites stored rows lazily and a stale fetch
14-
* could still surface it; new writes use `'starter'`.
10+
* `starter` is the auto-seeded FS that comes with each Space. It's the
11+
* default fallback the resolver routes unmapped folders / rootless sessions
12+
* to, so it's builtin: its members are editable (change which tools it
13+
* includes, or empty it), but it can't be renamed or deleted. The legacy
14+
* `'default'` value is accepted on read because migration 013 rewrites stored
15+
* rows lazily and a stale fetch could still surface it; new writes use
16+
* `'starter'`.
1517
*/
1618
export type FeatureSetType = 'starter' | 'default' | 'custom';
1719

crates/mcpmux-core/src/domain/feature_set.rs

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
//! FeatureSet entity - permission bundles for tools/prompts/resources
22
//!
33
//! Each FeatureSet is scoped to a space and is one of two types:
4-
//! - **Starter**: auto-created with the Space as a convenient starting
5-
//! point. Has no special routing role under the resolver — bindings and
6-
//! per-client grants pick FeatureSets explicitly. Pre-resolver-v3 this
7-
//! was the "Default" type and acted as the implicit fallback; that
8-
//! behaviour is gone, and the rename reflects the type's actual job
9-
//! (a seed you can rename, edit, or delete freely).
4+
//! - **Starter**: auto-created with the Space. It's the **default fallback**
5+
//! for folders that aren't explicitly mapped (and for rootless/unknown
6+
//! sessions) — the resolver routes them here instead of denying. Its
7+
//! membership is editable (change which tools it includes, or empty it to
8+
//! grant nothing by default), but its **identity is locked**: builtin, so
9+
//! not renamable and not deletable since the fallback always needs a stable
10+
//! target. (Pre-resolver-v3 it was the "Default" type and also acted as the
11+
//! implicit fallback; that role is back after a stint where it was a no-op
12+
//! seed.)
1013
//! - **Custom**: any other operator-defined FeatureSet.
1114
1215
use chrono::{DateTime, Utc};
@@ -16,15 +19,18 @@ use uuid::Uuid;
1619
/// The type of a FeatureSet.
1720
///
1821
/// `Starter` is auto-created once per Space; `Custom` covers everything
19-
/// else. Routing-wise the two are interchangeable — the type tag is
20-
/// purely a UI affordance ("this one came pre-seeded with the Space").
22+
/// else. The type tag carries routing weight: the Starter is the default
23+
/// fallback the resolver routes unmapped/rootless sessions to, and it's
24+
/// builtin (not deletable).
2125
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2226
#[serde(rename_all = "kebab-case")]
2327
#[derive(Default)]
2428
pub enum FeatureSetType {
25-
/// Auto-created with the Space. Editable / deletable like any other
26-
/// FS — no special routing semantics. Was historically called
27-
/// `Default` (DB column value carried over via migration 013).
29+
/// Auto-created with the Space and used as the **default fallback** for
30+
/// unmapped folders / rootless sessions. Its members are editable, but its
31+
/// identity is locked: builtin — not renamable and not deletable. Was
32+
/// historically called `Default` (DB column value carried over via
33+
/// migration 013).
2834
Starter,
2935
/// Any operator-defined FeatureSet.
3036
#[default]
@@ -237,9 +243,10 @@ impl FeatureSet {
237243
id: format!("fs_default_{}", space_id),
238244
name: "Starter".to_string(),
239245
description: Some(
240-
"Auto-created with this Space. Edit, rename, or delete freely \
241-
— bindings and per-client grants pick FeatureSets explicitly, \
242-
so this one has no special routing role."
246+
"Auto-created with this Space — the default set for folders \
247+
you haven't explicitly mapped. Edit which tools it includes \
248+
to change what they get. Its name is fixed and it can't be \
249+
deleted."
243250
.to_string(),
244251
),
245252
icon: Some("⭐".to_string()),

crates/mcpmux-storage/src/database.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ const MIGRATIONS: &[Migration] = &[
118118
name: "purge_orphaned_feature_set_members",
119119
sql: include_str!("migrations/017_purge_orphaned_feature_set_members.sql"),
120120
},
121+
Migration {
122+
version: 18,
123+
name: "starter_is_default_fallback_copy",
124+
sql: include_str!("migrations/018_starter_is_default_fallback_copy.sql"),
125+
},
121126
];
122127

123128
/// SQLite database wrapper.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
-- Migration 018: the Starter FS is the default fallback again (and can't be
2+
-- deleted).
3+
--
4+
-- After the "default FeatureSet for unmapped roots" change, an unmapped folder
5+
-- (plus rootless / unknown sessions) falls back to the default Space's Starter
6+
-- FS instead of being denied. That makes the Starter load-bearing: it's the
7+
-- default toolset for anything not explicitly mapped, and it is no longer
8+
-- deletable. Migrations 014/015 (and the seed paths) set a description that
9+
-- now lies — "no special routing role; delete freely" — so rewrite it.
10+
--
11+
-- Safety: only rewrite rows that STILL match the exact 014/015 seed text, so
12+
-- an operator who customized the copy keeps their change.
13+
14+
UPDATE feature_sets
15+
SET description = 'Auto-created with this Space — the default set for folders you haven''t explicitly mapped. Edit which tools it includes to change what they get. Its name is fixed and it can''t be deleted.'
16+
WHERE is_builtin = 1
17+
AND feature_set_type IN ('starter', 'default')
18+
AND description = 'Auto-created with this Space. Edit, rename, or delete freely — bindings and per-client grants pick FeatureSets explicitly, so this one has no special routing role.';

crates/mcpmux-storage/src/repositories/feature_set_repository.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,9 +230,20 @@ impl FeatureSetRepository for SqliteFeatureSetRepository {
230230
let db = self.db.lock().await;
231231
let conn = db.connection();
232232

233+
// Builtin FeatureSets (the auto-seeded Starter) are the default
234+
// fallback for unmapped folders, so their identity is fixed: name,
235+
// description, and icon are preserved here regardless of the incoming
236+
// values — only the MEMBERS (replaced below) and `updated_at` are
237+
// editable. The DB's own `is_builtin` flag governs (not the caller's
238+
// struct), so the lock holds for every caller, including the
239+
// member-set command that routes through update(). Custom sets update
240+
// normally.
233241
let rows_affected = conn.execute(
234-
"UPDATE feature_sets
235-
SET name = ?2, description = ?3, icon = ?4, updated_at = ?5
242+
"UPDATE feature_sets
243+
SET name = CASE WHEN is_builtin = 1 THEN name ELSE ?2 END,
244+
description = CASE WHEN is_builtin = 1 THEN description ELSE ?3 END,
245+
icon = CASE WHEN is_builtin = 1 THEN icon ELSE ?4 END,
246+
updated_at = ?5
236247
WHERE id = ?1 AND is_deleted = 0",
237248
params![
238249
feature_set.id,

crates/mcpmux-storage/src/repositories/space_repository.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,13 @@ impl SpaceRepository for SqliteSpaceRepository {
109109
// Auto-seed the builtin "Starter" FeatureSet for this Space — a
110110
// ready-to-use starting point. The id prefix `fs_default_<space>`
111111
// is preserved for FK-stability across the rename (migration 013).
112-
// No special routing role under resolver v3 — bindings and per-
113-
// client grants pick FeatureSets explicitly.
112+
// The Starter is the default fallback for folders that aren't
113+
// explicitly mapped (and rootless/unknown sessions), so it's
114+
// load-bearing and builtin: members are editable, but it can't be
115+
// renamed or deleted.
114116
conn.execute(
115117
"INSERT OR IGNORE INTO feature_sets (id, name, description, icon, space_id, feature_set_type, is_builtin, created_at, updated_at)
116-
VALUES (?1, 'Starter', 'Auto-created with this Space. Edit, rename, or delete freely — bindings and per-client grants pick FeatureSets explicitly, so this one has no special routing role.', '⭐', ?2, 'starter', 1, ?3, ?3)",
118+
VALUES (?1, 'Starter', 'Auto-created with this Space — the default set for folders you haven''t explicitly mapped. Edit which tools it includes to change what they get. Its name is fixed and it can''t be deleted.', '⭐', ?2, 'starter', 1, ?3, ?3)",
117119
params![
118120
format!("fs_default_{}", space_id),
119121
space_id,

0 commit comments

Comments
 (0)