Skip to content

Commit 1cd6c86

Browse files
committed
fix(workspaces): restore binding panel regressions and UX tweaks
Restore icon upload in Scope, sticky create footer, lifted machineIds for header badge, space_locked wiring, duplicate-root validation, and appearance icon seeding. Scope precedes Routing; all sections default open. Update panel tests and planning doc. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent c98fda7 commit 1cd6c86

10 files changed

Lines changed: 564 additions & 131 deletions

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,7 @@ export function WorkspacesPage() {
607607
openBindingPanel({
608608
mode: 'create-from-live',
609609
workspaceRoot: entry.root,
610+
appearanceIcon: resolveEntryIcon(entry) ?? undefined,
610611
});
611612
}}
612613
onMachineRowClick={(bindingId) => {
@@ -619,6 +620,7 @@ export function WorkspacesPage() {
619620
openBindingPanel({
620621
mode: 'create-from-live',
621622
workspaceRoot: entry.root,
623+
appearanceIcon: resolveEntryIcon(entry) ?? undefined,
622624
});
623625
}}
624626
onForget={

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

Lines changed: 160 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,17 @@ import {
1212
AlertCircle,
1313
Check,
1414
ChevronDown,
15+
FolderOpen,
1516
FolderSearch,
1617
Loader2,
1718
} from 'lucide-react';
1819
import { Button } from '@mcpmux/ui';
1920
import {
21+
type WorkspaceBinding,
2022
type WorkspaceBindingInput,
2123
} from '@/lib/api/workspaceBindings';
24+
import { uploadWorkspaceIcon } from '@/lib/api/workspaceAppearances';
25+
import { ServerIcon } from '@/components/ServerIcon';
2226
import { isStarterFeatureSet, type FeatureSet } from '@/lib/api/featureSets';
2327
import { createMachine, getHostname, type Machine } from '@/lib/api/machines';
2428
import type { Space } from '@/lib/api/spaces';
@@ -93,7 +97,21 @@ export type RootValidationState =
9397
| { state: 'idle' }
9498
| { state: 'checking' }
9599
| { state: 'ok'; normalized: string }
96-
| { state: 'error'; reason: string };
100+
| { state: 'error'; reason: string; duplicate?: boolean };
101+
102+
/** True when two bindings would collide on the partial unique indexes. */
103+
export function bindingScopeConflicts(
104+
existing: WorkspaceBinding,
105+
root: string,
106+
machineId: string | null,
107+
clientId: string | null | undefined,
108+
): boolean {
109+
if (existing.workspace_root !== root) return false;
110+
return (
111+
(existing.machine_id ?? null) === machineId &&
112+
(existing.client_id ?? null) === (clientId ?? null)
113+
);
114+
}
97115

98116
/** Map empty machine picker value to null for API payloads. */
99117
export function bindingMachineId(value: string): string | null {
@@ -152,6 +170,7 @@ export function RoutingFields({
152170
setSpaceId,
153171
fsIds,
154172
setFsIds,
173+
spacePickerDisabled = false,
155174
t,
156175
}: {
157176
spaces: Space[];
@@ -160,6 +179,7 @@ export function RoutingFields({
160179
setSpaceId: (value: string) => void;
161180
fsIds: string[];
162181
setFsIds: (value: string[] | ((prev: string[]) => string[])) => void;
182+
spacePickerDisabled?: boolean;
163183
t: TFunction<['workspaces', 'common']>;
164184
}) {
165185
const [fsSearch, setFsSearch] = useState('');
@@ -201,17 +221,21 @@ export function RoutingFields({
201221

202222
return (
203223
<div className="space-y-5">
204-
<FormField label={t('form.space')} hint={t('form.spaceHint')}>
224+
<FormField
225+
label={t('form.space')}
226+
hint={spacePickerDisabled ? t('form.spaceLockedHint') : t('form.spaceHint')}
227+
>
205228
<Picker
206229
value={spaceId}
207230
onChange={setSpaceId}
208231
placeholder={t('form.pickSpace')}
232+
disabled={spacePickerDisabled}
209233
options={spaces.map((s) => ({
210234
value: s.id,
211235
label: s.is_default ? `${s.name}${t('form.defaultSuffix')}` : s.name,
212236
icon: s.icon ?? undefined,
213237
}))}
214-
testId="workspace-binding-space"
238+
testId="workspace-binding-space-picker"
215239
/>
216240
</FormField>
217241

@@ -327,52 +351,51 @@ export function RoutingFields({
327351
}
328352

329353
/**
330-
* Machine scope and workspace root fields for workspace bindings.
354+
* Machine scope, workspace root, and advanced icon fields for workspace bindings.
331355
*/
332356
export function ScopeFields({
333357
mode,
334358
machines,
335-
localMachineId,
336359
machineId,
337360
setMachineId,
361+
machineIds,
362+
setMachineIds,
363+
icon,
364+
setIcon,
365+
onPersistIcon,
338366
root,
339367
setRoot,
340368
rootValidation,
341369
rootEditable,
342-
canSubmit,
343-
submitting,
344-
onFormSubmit,
345-
onCancel,
346370
onError,
347371
t,
348372
}: {
349373
mode: 'create' | 'edit' | 'create-from-live';
350374
machines: Machine[];
351-
localMachineId: string | null;
352375
machineId: string;
353376
setMachineId: (value: string) => void;
377+
machineIds: string[];
378+
setMachineIds: (value: string[] | ((prev: string[]) => string[])) => void;
379+
icon: string;
380+
setIcon: (value: string) => void;
381+
onPersistIcon?: (nextIcon: string) => Promise<void>;
354382
root: string;
355383
setRoot: (value: string) => void;
356384
rootValidation: RootValidationState;
357385
rootEditable: boolean;
358-
canSubmit: boolean;
359-
submitting: boolean;
360-
onFormSubmit: (machineTargets: (string | null)[]) => Promise<void>;
361-
onCancel: () => void;
362386
onError: (message: string) => void;
363387
t: TFunction<['workspaces', 'common']>;
364388
}) {
365389
const rootRef = useRef<HTMLInputElement | null>(null);
366-
const [machineIds, setMachineIds] = useState<string[]>(() =>
367-
mode === 'edit' ? [] : localMachineId ? [localMachineId] : [],
368-
);
390+
const [iconFilePath, setIconFilePath] = useState('');
369391
const [localMachines, setLocalMachines] = useState<Machine[]>(machines);
370392
const [showNewMachine, setShowNewMachine] = useState(false);
371393
const [newMachineName, setNewMachineName] = useState('');
372394
const [newMachineIcon, setNewMachineIcon] = useState('');
373395
const [newMachineHostname, setNewMachineHostname] = useState('');
374396
const [creatingMachine, setCreatingMachine] = useState(false);
375397
const isEdit = mode === 'edit';
398+
const trimmedIcon = icon.trim();
376399

377400
useEffect(() => {
378401
setLocalMachines(machines);
@@ -386,6 +409,15 @@ export function ScopeFields({
386409
setMachineIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
387410
};
388411

412+
/**
413+
* Persist icon immediately after upload so the card updates without waiting for autosave.
414+
*/
415+
const persistIconNow = async (nextIcon: string) => {
416+
if (onPersistIcon) {
417+
await onPersistIcon(nextIcon);
418+
}
419+
};
420+
389421
const machineOptions = useMemo(
390422
() => localMachines.map((m) => ({ value: m.id, label: m.name, icon: m.icon ?? undefined })),
391423
[localMachines],
@@ -435,11 +467,112 @@ export function ScopeFields({
435467
}
436468
};
437469

438-
const submitLabel =
439-
mode === 'create-from-live' ? t('form.saveBinding') : t('form.createBinding');
440-
441470
return (
442471
<div className="space-y-5">
472+
<FormField label={t('form.icon')} hint={t('form.iconHint')}>
473+
<div className="space-y-2.5">
474+
<div className="flex items-start gap-3">
475+
<div className="w-14 h-14 rounded-xl border border-[rgb(var(--border-subtle))] bg-[rgb(var(--background))] flex items-center justify-center flex-shrink-0">
476+
{trimmedIcon ? (
477+
<ServerIcon icon={trimmedIcon} className="h-9 w-9 object-contain" fallback="📁" />
478+
) : (
479+
<FolderOpen className="h-6 w-6 text-[rgb(var(--muted))]" />
480+
)}
481+
</div>
482+
<div className="flex-1 min-w-0 space-y-2">
483+
<input
484+
type="text"
485+
value={icon}
486+
onChange={(e) => {
487+
const next = e.target.value;
488+
setIcon(next);
489+
}}
490+
placeholder={t('form.iconPlaceholder')}
491+
className="w-full h-10 px-3 rounded-lg text-sm bg-[rgb(var(--background))] border border-[rgb(var(--border))] focus:outline-none focus:ring-2 focus:ring-primary-500"
492+
data-testid="workspace-binding-icon-input"
493+
/>
494+
<div className="flex items-center gap-2 flex-wrap">
495+
{isTauri() ? (
496+
<Button
497+
variant="secondary"
498+
size="sm"
499+
onClick={async () => {
500+
try {
501+
const picked = await pickPath({
502+
directory: false,
503+
multiple: false,
504+
title: t('form.pickIconTitle'),
505+
filters: [
506+
{
507+
name: t('form.imagesFilter'),
508+
extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif'],
509+
},
510+
],
511+
});
512+
if (typeof picked !== 'string' || picked.length === 0) return;
513+
const localRef = await uploadWorkspaceIcon(picked);
514+
setIcon(localRef);
515+
await persistIconNow(localRef);
516+
} catch (e) {
517+
onError(e instanceof Error ? e.message : String(e));
518+
}
519+
}}
520+
data-testid="workspace-binding-icon-upload"
521+
>
522+
{t('form.upload')}
523+
</Button>
524+
) : (
525+
<>
526+
<input
527+
type="text"
528+
value={iconFilePath}
529+
onChange={(e) => setIconFilePath(e.target.value)}
530+
placeholder="Enter absolute path"
531+
className="min-w-0 flex-1 px-3 py-2 rounded-lg text-sm bg-[rgb(var(--background))] border border-[rgb(var(--border))] focus:outline-none focus:ring-2 focus:ring-primary-500"
532+
data-testid="workspace-binding-icon-path-input"
533+
/>
534+
<Button
535+
variant="secondary"
536+
size="sm"
537+
disabled={!iconFilePath.trim()}
538+
onClick={async () => {
539+
const picked = iconFilePath.trim();
540+
if (!picked) return;
541+
try {
542+
const localRef = await uploadWorkspaceIcon(picked);
543+
setIcon(localRef);
544+
await persistIconNow(localRef);
545+
setIconFilePath('');
546+
} catch (e) {
547+
onError(e instanceof Error ? e.message : String(e));
548+
}
549+
}}
550+
data-testid="workspace-binding-icon-upload"
551+
>
552+
{t('form.upload')}
553+
</Button>
554+
</>
555+
)}
556+
<Button
557+
variant="ghost"
558+
size="sm"
559+
onClick={() => {
560+
setIcon('');
561+
void persistIconNow('').catch((e) =>
562+
onError(e instanceof Error ? e.message : String(e)),
563+
);
564+
}}
565+
disabled={!trimmedIcon}
566+
data-testid="workspace-binding-icon-clear"
567+
>
568+
{t('form.clear')}
569+
</Button>
570+
</div>
571+
</div>
572+
</div>
573+
</div>
574+
</FormField>
575+
443576
<FormField label={t('form.machine')} hint={t('form.machineHint')}>
444577
{isEdit ? (
445578
<div className="space-y-2">
@@ -643,32 +776,6 @@ export function ScopeFields({
643776
</div>
644777
<RootValidationHint state={rootValidation} editable={rootEditable} originalValue={root} t={t} />
645778
</FormField>
646-
647-
{!isEdit && (
648-
<div className="flex items-center gap-2 pt-1">
649-
<Button
650-
variant="primary"
651-
size="md"
652-
onClick={() => {
653-
const targets = machineIds.length > 0 ? machineIds : [null as string | null];
654-
void onFormSubmit(targets);
655-
}}
656-
disabled={!canSubmit}
657-
className="flex-1"
658-
data-testid="workspace-binding-submit"
659-
>
660-
{submitting ? (
661-
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />
662-
) : (
663-
<Check className="h-4 w-4 mr-1.5" />
664-
)}
665-
{submitLabel}
666-
</Button>
667-
<Button variant="secondary" size="md" onClick={onCancel} disabled={submitting}>
668-
{t('common:actions.cancel')}
669-
</Button>
670-
</div>
671-
)}
672779
</div>
673780
);
674781
}
@@ -680,11 +787,7 @@ function RootValidationHint({
680787
originalValue,
681788
t,
682789
}: {
683-
state:
684-
| { state: 'idle' }
685-
| { state: 'checking' }
686-
| { state: 'ok'; normalized: string }
687-
| { state: 'error'; reason: string };
790+
state: RootValidationState;
688791
editable: boolean;
689792
originalValue: string;
690793
t: TFunction<['workspaces', 'common']>;
@@ -711,7 +814,12 @@ function RootValidationHint({
711814
}
712815
if (state.state === 'error') {
713816
return (
714-
<p className="mt-1.5 text-[11px] text-red-600 dark:text-red-400">{state.reason}</p>
817+
<p
818+
className="mt-1.5 text-[11px] text-red-600 dark:text-red-400"
819+
data-testid={state.duplicate ? 'workspace-binding-duplicate-error' : undefined}
820+
>
821+
{state.reason}
822+
</p>
715823
);
716824
}
717825
const changed = state.normalized !== originalValue.trim();

0 commit comments

Comments
 (0)