Skip to content

Commit 3da03b9

Browse files
committed
fix(workspaces): keep edit panel open; header machine quick-switch
Remove workspace-binding-changed auto-close so Clear and autosave no longer dismiss the drawer. Replace header machine badge with a dropdown for quick scope switching. Add regression tests and planning doc notes. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 345c5a2 commit 3da03b9

4 files changed

Lines changed: 255 additions & 45 deletions

File tree

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

Lines changed: 153 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
Trash2,
1515
X,
1616
} from 'lucide-react';
17-
import { Button, useConfirm, useToast } from '@mcpmux/ui';
17+
import { Button, useConfirm, useToast, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@mcpmux/ui';
1818
import { useWorkspaceEvents } from '@/lib/backend/events';
1919
import { apiCall } from '@/lib/api/transport';
2020
import {
@@ -58,7 +58,6 @@ import {
5858
import {
5959
CollapsibleSection,
6060
EffectiveFeaturesContent,
61-
type CollapsibleSectionRef,
6261
} from './WorkspacesPage';
6362

6463
/**
@@ -137,6 +136,132 @@ function Pill({
137136
);
138137
}
139138

139+
/**
140+
* Compact machine quick-switch dropdown in the panel header (mirrors Scope machine picker).
141+
*/
142+
function HeaderMachineDropdown({
143+
mode,
144+
machines,
145+
machineId,
146+
setMachineId,
147+
machineIds,
148+
setMachineIds,
149+
machineBadgeLabel,
150+
machineBadgeIcon,
151+
t,
152+
}: {
153+
mode: 'create' | 'edit' | 'create-from-live';
154+
machines: Machine[];
155+
machineId: string;
156+
setMachineId: (value: string) => void;
157+
machineIds: string[];
158+
setMachineIds: (value: string[]) => void;
159+
machineBadgeLabel: string;
160+
machineBadgeIcon: string | null;
161+
t: TFunction<['workspaces', 'common']>;
162+
}) {
163+
const [menuOpen, setMenuOpen] = useState(false);
164+
const usesSingleMachine = mode === 'edit';
165+
166+
/** Apply a machine selection from the header dropdown. */
167+
const handleSelect = (nextId: string) => {
168+
if (usesSingleMachine) {
169+
setMachineId(nextId);
170+
} else {
171+
setMachineIds(nextId ? [nextId] : []);
172+
}
173+
setMenuOpen(false);
174+
};
175+
176+
/** Whether the dropdown row matches the current machine scope. */
177+
const isSelected = (candidateId: string | null): boolean => {
178+
if (usesSingleMachine) {
179+
if (!candidateId) return !machineId;
180+
return machineId === candidateId;
181+
}
182+
if (!candidateId) return machineIds.length === 0;
183+
return machineIds.length === 1 && machineIds[0] === candidateId;
184+
};
185+
186+
return (
187+
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
188+
<DropdownMenuTrigger
189+
className="flex-shrink-0 rounded-md transition-colors hover:bg-[rgb(var(--surface-hover))] focus:outline-none focus:ring-2 focus:ring-primary-500 cursor-pointer"
190+
data-testid="workspace-binding-header-machine-badge"
191+
title={t('panel.machineQuickSwitch')}
192+
>
193+
<Pill tone="neutral">
194+
<span className="inline-flex items-center gap-1 normal-case tracking-normal">
195+
{machineBadgeIcon ? (
196+
<span className="text-xs leading-none">{machineBadgeIcon}</span>
197+
) : null}
198+
{machineBadgeLabel}
199+
<ChevronDown className="h-3 w-3 opacity-70" />
200+
</span>
201+
</Pill>
202+
</DropdownMenuTrigger>
203+
<DropdownMenuContent
204+
align="end"
205+
className="w-52 py-1 px-1 bg-[rgb(var(--surface))] border border-[rgb(var(--border))] rounded-lg shadow-lg"
206+
data-testid="workspace-binding-header-machine-menu"
207+
>
208+
<HeaderMachineOption
209+
label={t('panel.machineGlobal')}
210+
selected={isSelected(null)}
211+
onSelect={() => handleSelect('')}
212+
testId="workspace-binding-header-machine-global"
213+
/>
214+
{machines.map((machine) => (
215+
<HeaderMachineOption
216+
key={machine.id}
217+
label={machine.name}
218+
icon={machine.icon}
219+
selected={isSelected(machine.id)}
220+
onSelect={() => handleSelect(machine.id)}
221+
testId={`workspace-binding-header-machine-${machine.id}`}
222+
/>
223+
))}
224+
</DropdownMenuContent>
225+
</DropdownMenu>
226+
);
227+
}
228+
229+
/**
230+
* Single row in the header machine quick-switch menu.
231+
*/
232+
function HeaderMachineOption({
233+
label,
234+
icon,
235+
selected,
236+
onSelect,
237+
testId,
238+
}: {
239+
label: string;
240+
icon?: string | null;
241+
selected: boolean;
242+
onSelect: () => void;
243+
testId?: string;
244+
}) {
245+
return (
246+
<button
247+
type="button"
248+
role="menuitem"
249+
onClick={onSelect}
250+
className={[
251+
'w-full flex items-center gap-2 px-2.5 py-2 rounded-md text-left text-sm transition-colors',
252+
selected
253+
? 'bg-primary-500/10 text-primary-700 dark:text-primary-300'
254+
: 'hover:bg-[rgb(var(--surface-hover))] text-[rgb(var(--foreground))]',
255+
].join(' ')}
256+
data-testid={testId}
257+
>
258+
{icon ? <span className="text-sm leading-none flex-shrink-0">{icon}</span> : null}
259+
<span className="flex-1 min-w-0 truncate">{label}</span>
260+
{selected ? <Check className="h-4 w-4 flex-shrink-0 text-primary-600" /> : null}
261+
</button>
262+
);
263+
}
264+
140265
/**
141266
* Inline-editable workspace identity in the panel header: icon, label, and machine badge.
142267
*/
@@ -147,9 +272,13 @@ function PanelIdentityHeader({
147272
icon,
148273
setIcon,
149274
workspaceRoot,
275+
machines,
276+
machineId,
277+
setMachineId,
278+
machineIds,
279+
setMachineIds,
150280
machineBadgeLabel,
151281
machineBadgeIcon,
152-
onMachineBadgeClick,
153282
onIconClear,
154283
badges,
155284
footer,
@@ -161,9 +290,13 @@ function PanelIdentityHeader({
161290
icon: string;
162291
setIcon: (value: string) => void;
163292
workspaceRoot?: string;
293+
machines: Machine[];
294+
machineId: string;
295+
setMachineId: (value: string) => void;
296+
machineIds: string[];
297+
setMachineIds: (value: string[]) => void;
164298
machineBadgeLabel: string;
165299
machineBadgeIcon: string | null;
166-
onMachineBadgeClick: () => void;
167300
onIconClear: () => void;
168301
badges?: ReactNode;
169302
footer?: ReactNode;
@@ -219,22 +352,17 @@ function PanelIdentityHeader({
219352
{footer}
220353
</div>
221354

222-
<button
223-
type="button"
224-
onClick={onMachineBadgeClick}
225-
className="flex-shrink-0 rounded-md transition-colors hover:bg-[rgb(var(--surface-hover))] focus:outline-none focus:ring-2 focus:ring-primary-500"
226-
data-testid="workspace-binding-header-machine-badge"
227-
title={machineBadgeLabel}
228-
>
229-
<Pill tone="neutral">
230-
<span className="inline-flex items-center gap-1 normal-case tracking-normal">
231-
{machineBadgeIcon ? (
232-
<span className="text-xs leading-none">{machineBadgeIcon}</span>
233-
) : null}
234-
{machineBadgeLabel}
235-
</span>
236-
</Pill>
237-
</button>
355+
<HeaderMachineDropdown
356+
mode={mode}
357+
machines={machines}
358+
machineId={machineId}
359+
setMachineId={setMachineId}
360+
machineIds={machineIds}
361+
setMachineIds={setMachineIds}
362+
machineBadgeLabel={machineBadgeLabel}
363+
machineBadgeIcon={machineBadgeIcon}
364+
t={t}
365+
/>
238366
</div>
239367
);
240368
}
@@ -303,7 +431,6 @@ export function WorkspaceBindingPanel() {
303431
const lastSavedRef = useRef<WorkspaceBindingInput | null>(null);
304432
const lastSavedAppearanceRef = useRef<string | null>(null);
305433
const pendingPayloadRef = useRef<WorkspaceBindingInput | null>(null);
306-
const scopeSectionRef = useRef<CollapsibleSectionRef>(null);
307434
const onSubmitRef = useRef<(input: WorkspaceBindingInput) => Promise<void>>(async () => undefined);
308435
const onSaveStatusChangeRef = useRef(setSaveStatus);
309436

@@ -331,19 +458,6 @@ export function WorkspaceBindingPanel() {
331458
});
332459
}, [subscribe, open]);
333460

334-
useEffect(() => {
335-
return subscribe('workspace-binding-changed', (changedPayload) => {
336-
const state = useBindingPanelStore.getState();
337-
if (!state.isOpen || !state.payload) return;
338-
const changed = changedPayload.workspace_root.toLowerCase();
339-
const payloadRoot = state.payload.workspaceRoot?.toLowerCase();
340-
const bindingRoot = state.payload.binding?.workspace_root.toLowerCase();
341-
if (changed === payloadRoot || changed === bindingRoot) {
342-
close();
343-
}
344-
});
345-
}, [subscribe, close]);
346-
347461
useEffect(() => {
348462
if (!isOpen || !payload) return;
349463
let cancelled = false;
@@ -842,10 +956,6 @@ export function WorkspaceBindingPanel() {
842956
}
843957
}, [isEdit, formInitial, canSubmit, handlePersistIcon, showError, t]);
844958

845-
const handleMachineBadgeClick = useCallback(() => {
846-
scopeSectionRef.current?.expand();
847-
}, []);
848-
849959
if (!isOpen || !payload) return null;
850960

851961
const binding = payload.binding ?? null;
@@ -894,9 +1004,13 @@ export function WorkspaceBindingPanel() {
8941004
icon={icon}
8951005
setIcon={setIcon}
8961006
workspaceRoot={workspaceRoot}
1007+
machines={machines}
1008+
machineId={machineId}
1009+
setMachineId={setMachineId}
1010+
machineIds={machineIds}
1011+
setMachineIds={setMachineIds}
8971012
machineBadgeLabel={machineBadgeLabel}
8981013
machineBadgeIcon={machineBadgeIcon}
899-
onMachineBadgeClick={handleMachineBadgeClick}
9001014
onIconClear={handleHeaderIconClear}
9011015
badges={
9021016
<>
@@ -1032,7 +1146,6 @@ export function WorkspaceBindingPanel() {
10321146
)}
10331147

10341148
<CollapsibleSection
1035-
ref={scopeSectionRef}
10361149
icon={<Monitor className="h-5 w-5" />}
10371150
tone="primary"
10381151
title={t('panel.scope')}

apps/desktop/src/locales/en/workspaces.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@
9292
"removeBinding": "Remove binding",
9393
"identityPlaceholder": "Add a label…",
9494
"machineGlobal": "Global",
95-
"machineCount": "{{count}} machines"
95+
"machineCount": "{{count}} machines",
96+
"machineQuickSwitch": "Switch machine scope"
9697
},
9798
"saveStatus": {
9899
"saving": "Saving",

docs/planning/sidesheet-panel-identity-header.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Workspace Binding Panel — Identity Header Refactor
22

33
**Last Updated:** Jun 28, 2026
4-
**Status:** Complete (including restoration pass)
4+
**Status:** Complete (including restoration pass + auto-close fix)
55
**Branch:** `feat/workspace-machine-binding`
66
**Depends on:** None — builds on current panel in `main`
77
**Unblocks:** Cleaner routing UX for multi-machine users; removes identity clutter from the Mapping form
@@ -100,7 +100,7 @@ WorkspaceBindingPanel
100100

101101
| File | Change |
102102
| ---- | ------ |
103-
| [`workspace-binding-panel.component.tsx`](../../apps/desktop/src/features/workspaces/workspace-binding-panel.component.tsx) | Lifted state, header, Routing/Scope sections, sticky create footer, duplicate root check, machine badge resolver, routing subtitle for create-from-live |
103+
| [`workspace-binding-panel.component.tsx`](../../apps/desktop/src/features/workspaces/workspace-binding-panel.component.tsx) | Lifted state, header, Routing/Scope sections, sticky create footer, duplicate root check, machine badge resolver, routing subtitle for create-from-live; removed erroneous `workspace-binding-changed` auto-close listener |
104104
| [`workspace-binding-form.component.tsx`](../../apps/desktop/src/features/workspaces/workspace-binding-form.component.tsx) | `RoutingFields`, `ScopeFields`, icon upload in Scope, `bindingScopeConflicts`, duplicate error testid |
105105
| [`bindingPanelStore.ts`](../../apps/desktop/src/stores/bindingPanelStore.ts) | `spaceLocked`, `appearanceIcon` on payload |
106106
| [`WorkspacesPage.tsx`](../../apps/desktop/src/features/workspaces/WorkspacesPage.tsx) | Pass `appearanceIcon` when opening create-from-live from cards |
@@ -117,7 +117,8 @@ WorkspaceBindingPanel
117117
| 1 — Lift form state to panel | Done | `da725d6` |
118118
| 2 — Panel header identity + machine badge | Done | `a06c7ab` |
119119
| 3 — Routing + Scope sections, badge wiring | Done | `ef6763f` |
120-
| 4 — Restoration pass | Done | uncommitted |
120+
| 4 — Restoration pass | Done | `c68bc55` |
121+
| 5 — Auto-close on edit save fix | Done | uncommitted |
121122

122123
---
123124

@@ -138,6 +139,22 @@ Regressions found after Phase 3 and fixed:
138139

139140
---
140141

142+
## Post-ship fixes (Jun 28, 2026)
143+
144+
### Edit panel closed on Clear / autosave
145+
146+
**Symptom:** Clicking icon **Clear** or changing machine scope to **No machine** while editing a binding dismissed the entire panel. The save often succeeded, but the UX looked broken.
147+
148+
**Cause:** A `workspace-binding-changed` listener in `WorkspaceBindingPanel` called `close()` whenever the event's `workspace_root` matched the open binding. Every in-panel edit save (icon persist, machine change, 1.5s autosave) emits that event — contradicting the autosave invariant ("no behavior change").
149+
150+
**Fix:** Removed the auto-close listener. Panel now closes only via explicit paths: backdrop / X / Escape, create submit success, delete, disable-prompt link, create footer Cancel.
151+
152+
**Tests:** `WorkspaceBindingPrompt.test.tsx` — edit mode stays open after `workspace-binding-changed` and after icon Clear persist.
153+
154+
**Tradeoff:** If a binding is deleted or mutated externally while the edit panel is open, stale data may show until the user closes manually. Acceptable; matches pre-refactor edit behavior.
155+
156+
---
157+
141158
## Related Documentation
142159

143160
- [`projects-grouped-machine-cards.md`](./projects-grouped-machine-cards.md)

0 commit comments

Comments
 (0)