Skip to content

Commit 8d752fb

Browse files
committed
fix(workspace-binding): Phase 2 — Persist WorkspaceNeedsBinding dismissals
Autonomous decisions: - Put dismissal CRUD on InboundClientRepository — same db handle pattern as machines.rs; no new repo type. - WorkspacesPage auto-open uses root-only dismissal check when client_id is unknown — matches page-load catch-up without session context. - clear_binding_prompt_dismissals_for_root on create/update — clears all client rows for that root so a later regression re-prompts everyone. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 4001253 commit 8d752fb

11 files changed

Lines changed: 267 additions & 18 deletions

File tree

apps/desktop/src-tauri/src/commands/workspace_binding.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use mcpmux_core::{
1212
DomainEvent, FeatureSet, FeatureSetType, MemberMode, MemberType, ServerFeature,
1313
WorkspaceBinding, WorkspaceRootValidation,
1414
};
15+
use mcpmux_storage::InboundClientRepository;
1516
use serde::{Deserialize, Serialize};
1617
use tauri::State;
1718
use tokio::sync::RwLock;
@@ -23,6 +24,21 @@ use super::server_manager::ServerManagerState;
2324
use super::workspace_appearance::maybe_remove_orphaned_icon_file;
2425
use crate::state::AppState;
2526

27+
fn inbound_client_repo(state: &AppState) -> InboundClientRepository {
28+
InboundClientRepository::new(state.database())
29+
}
30+
31+
/// Clear persisted prompt dismissals after a binding is saved for a root.
32+
async fn clear_binding_prompt_dismissals(
33+
state: &AppState,
34+
workspace_root: &str,
35+
) -> Result<(), String> {
36+
inbound_client_repo(state)
37+
.clear_binding_prompt_dismissals_for_root(workspace_root)
38+
.await
39+
.map_err(|e| e.to_string())
40+
}
41+
2642
/// Publish `WorkspaceBindingChanged` on the gateway's domain bus so
2743
/// MCPNotifier broadcasts `list_changed` to every peer whose session now
2844
/// routes through the changed binding.
@@ -429,6 +445,8 @@ pub async fn create_workspace_binding(
429445
.await
430446
.map_err(|e| e.to_string())?;
431447

448+
clear_binding_prompt_dismissals(&state, &binding.workspace_root).await?;
449+
432450
if binding_type == BindingType::Path {
433451
clear_appearance_for_bound_root(&state, &normalized).await?;
434452
}
@@ -533,6 +551,8 @@ pub async fn update_workspace_binding(
533551
.await
534552
.map_err(|e| e.to_string())?;
535553

554+
clear_binding_prompt_dismissals(&state, &updated.workspace_root).await?;
555+
536556
if binding_type == BindingType::Path {
537557
clear_appearance_for_bound_root(&state, &normalized).await?;
538558
}
@@ -561,6 +581,40 @@ pub async fn update_workspace_binding(
561581
Ok(updated.into())
562582
}
563583

584+
/// Record that the user closed the WorkspaceNeedsBinding panel without saving.
585+
#[tauri::command]
586+
pub async fn dismiss_workspace_binding_prompt(
587+
client_id: String,
588+
workspace_root: String,
589+
state: State<'_, AppState>,
590+
) -> Result<(), String> {
591+
inbound_client_repo(&state)
592+
.dismiss_binding_prompt(&client_id, &workspace_root)
593+
.await
594+
.map_err(|e| e.to_string())
595+
}
596+
597+
/// Whether the binding prompt was dismissed for a client/root pair, or — when
598+
/// `client_id` is omitted — for any client on that workspace root.
599+
#[tauri::command]
600+
pub async fn is_workspace_binding_prompt_dismissed(
601+
workspace_root: String,
602+
client_id: Option<String>,
603+
state: State<'_, AppState>,
604+
) -> Result<bool, String> {
605+
let repo = inbound_client_repo(&state);
606+
match client_id {
607+
Some(cid) if !cid.is_empty() => repo
608+
.is_binding_prompt_dismissed(&cid, &workspace_root)
609+
.await
610+
.map_err(|e| e.to_string()),
611+
_ => repo
612+
.is_binding_prompt_dismissed_for_root(&workspace_root)
613+
.await
614+
.map_err(|e| e.to_string()),
615+
}
616+
}
617+
564618
/// Delete a binding by id.
565619
#[tauri::command]
566620
pub async fn delete_workspace_binding(

apps/desktop/src-tauri/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1042,6 +1042,8 @@ pub fn run() {
10421042
commands::forget_reported_root,
10431043
commands::create_workspace_binding,
10441044
commands::update_workspace_binding,
1045+
commands::dismiss_workspace_binding_prompt,
1046+
commands::is_workspace_binding_prompt_dismissed,
10451047
commands::delete_workspace_binding,
10461048
commands::validate_workspace_root,
10471049
commands::get_workspace_effective_features,

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
clearUnmappedReportedRoots,
5050
forgetReportedRoot,
5151
getWorkspaceEffectiveFeatures,
52+
isWorkspaceBindingPromptDismissed,
5253
listReportedWorkspaceRoots,
5354
listWorkspaceBindings,
5455
type EffectiveFeature,
@@ -412,11 +413,20 @@ export function WorkspacesPage() {
412413
if (isLoading || isPanelOpen) return;
413414
const firstUnmapped = entries.find((e) => e.kind === 'unmapped-live');
414415
if (!firstUnmapped) return;
415-
openBindingPanel({
416-
mode: 'create-from-live',
417-
workspaceRoot: firstUnmapped.root,
418-
appearanceIcon: resolveEntryIcon(firstUnmapped) ?? undefined,
419-
});
416+
417+
void (async () => {
418+
try {
419+
const dismissed = await isWorkspaceBindingPromptDismissed(firstUnmapped.root);
420+
if (dismissed || useBindingPanelStore.getState().isOpen) return;
421+
openBindingPanel({
422+
mode: 'create-from-live',
423+
workspaceRoot: firstUnmapped.root,
424+
appearanceIcon: resolveEntryIcon(firstUnmapped) ?? undefined,
425+
});
426+
} catch {
427+
/* best-effort — skip auto-open on check failure */
428+
}
429+
})();
420430
// eslint-disable-next-line react-hooks/exhaustive-deps
421431
}, [isLoading]);
422432

apps/desktop/src/features/workspaces/workspace-binding-panel.component.tsx

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { apiCall } from '@/lib/api/transport';
2020
import {
2121
createWorkspaceBinding,
2222
deleteWorkspaceBinding,
23+
dismissWorkspaceBindingPrompt,
2324
listWorkspaceBindings,
2425
updateWorkspaceBinding,
2526
validateWorkspaceRoot,
@@ -385,15 +386,6 @@ export function WorkspaceBindingPanel() {
385386
};
386387
}, [isOpen, payload, showError, t]);
387388

388-
useEffect(() => {
389-
if (!isOpen) return;
390-
const onKey = (e: KeyboardEvent) => {
391-
if (e.key === 'Escape') close();
392-
};
393-
window.addEventListener('keydown', onKey);
394-
return () => window.removeEventListener('keydown', onKey);
395-
}, [isOpen, close]);
396-
397389
const formInitial = useMemo(
398390
() => (payload ? buildFormInitial(payload, spaces) : null),
399391
[payload, spaces],
@@ -427,6 +419,27 @@ export function WorkspaceBindingPanel() {
427419
? bindingMachineId(machineId)
428420
: machineIds[0] ?? null;
429421

422+
/** Close the panel; record a dismissal for create-from-live prompts with a client id. */
423+
const handlePanelClose = useCallback(() => {
424+
const rootToDismiss =
425+
payload?.workspaceRoot ?? payload?.binding?.workspace_root ?? workspaceRoot;
426+
if (payload?.mode === 'create-from-live' && payload.clientId && rootToDismiss) {
427+
void dismissWorkspaceBindingPrompt(payload.clientId, rootToDismiss).catch(
428+
() => undefined,
429+
);
430+
}
431+
close();
432+
}, [payload, workspaceRoot, close]);
433+
434+
useEffect(() => {
435+
if (!isOpen) return;
436+
const onKey = (e: KeyboardEvent) => {
437+
if (e.key === 'Escape') handlePanelClose();
438+
};
439+
window.addEventListener('keydown', onKey);
440+
return () => window.removeEventListener('keydown', onKey);
441+
}, [isOpen, handlePanelClose]);
442+
430443
useEffect(() => {
431444
if (!isOpen || !payload || loadingData) return;
432445
const initial = formInitial;
@@ -898,7 +911,7 @@ export function WorkspaceBindingPanel() {
898911
<>
899912
<div
900913
className="fixed inset-0 bg-black/20 backdrop-blur-[2px] z-40 animate-in fade-in duration-200"
901-
onClick={close}
914+
onClick={handlePanelClose}
902915
data-testid="workspace-binding-panel-backdrop"
903916
/>
904917
<div
@@ -942,7 +955,7 @@ export function WorkspaceBindingPanel() {
942955
/>
943956
<button
944957
type="button"
945-
onClick={close}
958+
onClick={handlePanelClose}
946959
className="p-1.5 rounded-lg hover:bg-[rgb(var(--surface-hover))] transition-colors flex-shrink-0"
947960
aria-label={t('panel.closeAria')}
948961
>
@@ -1268,7 +1281,7 @@ export function WorkspaceBindingPanel() {
12681281
)}
12691282
{createSubmitLabel}
12701283
</Button>
1271-
<Button variant="secondary" size="md" onClick={close} disabled={submitting}>
1284+
<Button variant="secondary" size="md" onClick={handlePanelClose} disabled={submitting}>
12721285
{t('common:actions.cancel')}
12731286
</Button>
12741287
</div>

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,28 @@ export async function deleteWorkspaceBinding(id: string): Promise<void> {
133133
return apiCall('delete_workspace_binding', { id });
134134
}
135135

136+
/** Persist a WorkspaceNeedsBinding panel dismissal for a client/root pair. */
137+
export async function dismissWorkspaceBindingPrompt(
138+
clientId: string,
139+
workspaceRoot: string,
140+
): Promise<void> {
141+
return apiCall('dismiss_workspace_binding_prompt', { clientId, workspaceRoot });
142+
}
143+
144+
/**
145+
* True when the user previously closed the binding prompt without saving.
146+
* Omit `clientId` to check whether any client dismissed that workspace root.
147+
*/
148+
export async function isWorkspaceBindingPromptDismissed(
149+
workspaceRoot: string,
150+
clientId?: string | null,
151+
): Promise<boolean> {
152+
return apiCall('is_workspace_binding_prompt_dismissed', {
153+
workspaceRoot,
154+
...(clientId ? { clientId } : {}),
155+
});
156+
}
157+
136158
/** Convenience: build a `WorkspaceBindingInput` from a binding-shaped object. */
137159
export function toInput(b: WorkspaceBinding): WorkspaceBindingInput {
138160
return {

crates/mcpmux-gateway/src/mcp/handler.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,21 @@ impl McpMuxGatewayHandler {
155155
resolved.space_id,
156156
root_for_prompt,
157157
) {
158+
let dismissed = services
159+
.dependencies
160+
.inbound_client_repo
161+
.is_binding_prompt_dismissed(client_id, root)
162+
.await
163+
.unwrap_or(false);
164+
if dismissed {
165+
debug!(
166+
%client_id,
167+
workspace_root = root,
168+
"Skipping WorkspaceNeedsBinding — user dismissed this prompt"
169+
);
170+
return;
171+
}
172+
158173
// Lock the popup's Space field when the folder is scoped to
159174
// a Space by base directory — the user shouldn't be able to
160175
// bind it elsewhere.

crates/mcpmux-storage/src/database.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,11 @@ const MIGRATIONS: &[Migration] = &[
233233
name: "public_url_rename",
234234
sql: include_str!("migrations/040_public_url_rename.sql"),
235235
},
236+
Migration {
237+
version: 41,
238+
name: "workspace_binding_prompt_dismissals",
239+
sql: include_str!("migrations/041_workspace_binding_prompt_dismissals.sql"),
240+
},
236241
];
237242

238243
/// SQLite database wrapper.
@@ -773,7 +778,7 @@ mod tests {
773778
|row| row.get(0),
774779
)
775780
.unwrap();
776-
assert_eq!(version, 40);
781+
assert_eq!(version, 41);
777782

778783
let v16_name: String = db
779784
.conn
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
-- Migration 041: persist WorkspaceNeedsBinding panel dismissals per client + root.
2+
--
3+
-- Closing the binding prompt without saving records (client_id, workspace_root)
4+
-- so reconnects do not re-fire the popup. Cleared when a binding is saved for
5+
-- that workspace_root so a later regression surfaces again.
6+
7+
CREATE TABLE IF NOT EXISTS workspace_binding_prompt_dismissals (
8+
client_id TEXT NOT NULL,
9+
workspace_root TEXT NOT NULL,
10+
dismissed_at TEXT NOT NULL,
11+
PRIMARY KEY (client_id, workspace_root)
12+
);
13+
14+
CREATE INDEX IF NOT EXISTS idx_wbpd_workspace_root
15+
ON workspace_binding_prompt_dismissals (workspace_root);

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1061,6 +1061,77 @@ impl InboundClientRepository {
10611061

10621062
Ok(grants)
10631063
}
1064+
1065+
/// True when the user dismissed the WorkspaceNeedsBinding prompt for this
1066+
/// `(client_id, workspace_root)` pair.
1067+
pub async fn is_binding_prompt_dismissed(
1068+
&self,
1069+
client_id: &str,
1070+
workspace_root: &str,
1071+
) -> Result<bool> {
1072+
let db = self.db.lock().await;
1073+
let conn = db.connection();
1074+
let count: i64 = conn
1075+
.query_row(
1076+
"SELECT COUNT(*) FROM workspace_binding_prompt_dismissals \
1077+
WHERE client_id = ?1 AND workspace_root = ?2",
1078+
params![client_id, workspace_root],
1079+
|row| row.get(0),
1080+
)
1081+
.unwrap_or(0);
1082+
Ok(count > 0)
1083+
}
1084+
1085+
/// True when any client dismissed the prompt for this workspace root.
1086+
/// Used by the Workspaces page auto-open path when no client id is known.
1087+
pub async fn is_binding_prompt_dismissed_for_root(&self, workspace_root: &str) -> Result<bool> {
1088+
let db = self.db.lock().await;
1089+
let conn = db.connection();
1090+
let count: i64 = conn
1091+
.query_row(
1092+
"SELECT COUNT(*) FROM workspace_binding_prompt_dismissals \
1093+
WHERE workspace_root = ?1",
1094+
params![workspace_root],
1095+
|row| row.get(0),
1096+
)
1097+
.unwrap_or(0);
1098+
Ok(count > 0)
1099+
}
1100+
1101+
/// Record that the user closed the binding prompt without saving.
1102+
pub async fn dismiss_binding_prompt(
1103+
&self,
1104+
client_id: &str,
1105+
workspace_root: &str,
1106+
) -> Result<()> {
1107+
let db = self.db.lock().await;
1108+
let conn = db.connection();
1109+
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
1110+
conn.execute(
1111+
"INSERT OR REPLACE INTO workspace_binding_prompt_dismissals \
1112+
(client_id, workspace_root, dismissed_at) VALUES (?1, ?2, ?3)",
1113+
params![client_id, workspace_root, now],
1114+
)?;
1115+
debug!(
1116+
client_id,
1117+
workspace_root, "[OAuth] Dismissed workspace binding prompt"
1118+
);
1119+
Ok(())
1120+
}
1121+
1122+
/// Remove dismissals for a workspace root after a binding is saved.
1123+
pub async fn clear_binding_prompt_dismissals_for_root(
1124+
&self,
1125+
workspace_root: &str,
1126+
) -> Result<()> {
1127+
let db = self.db.lock().await;
1128+
let conn = db.connection();
1129+
conn.execute(
1130+
"DELETE FROM workspace_binding_prompt_dismissals WHERE workspace_root = ?1",
1131+
params![workspace_root],
1132+
)?;
1133+
Ok(())
1134+
}
10641135
}
10651136

10661137
#[cfg(test)]

0 commit comments

Comments
 (0)