Skip to content

Commit 4750935

Browse files
committed
feat(workspaces): workspace binding UI refactor + deny-by-default planning
- Refactor binding panel: simplify PanelIdentityHeader, move label/icon editing into ScopeFields, machine badge click expands Scope section - WorkspacesPage: add page-load auto-pop for first unmapped-live entry, refactor routing table to CSS grid pill rows, add ghost row for pure unmapped-live sessions - workspace-binding-form: add label field to Scope section, refactor icon UX (preview button, emoji picker, file-icon split) - Add deny-by-default planning doc (docs/planning/deny-by-default-bindable-callers.md) with full architecture, decisions, phase breakdown, and implementation details for the Unbound resolver variant Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 3da03b9 commit 4750935

5 files changed

Lines changed: 543 additions & 312 deletions

File tree

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

Lines changed: 89 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ export function WorkspacesPage() {
158158
const { toasts, success, error: showError, dismiss } = useToast();
159159
const { confirm, ConfirmDialogElement } = useConfirm();
160160
const openBindingPanel = useBindingPanelStore((state) => state.open);
161+
const isPanelOpen = useBindingPanelStore((state) => state.isOpen);
161162

162163
const [searchQuery, setSearchQuery] = useState('');
163164
const [filter, setFilter] = useState<'all' | 'live' | 'mapped' | 'unmapped'>('all');
@@ -384,6 +385,21 @@ export function WorkspacesPage() {
384385
[appearancesByRoot]
385386
);
386387

388+
// Auto-open binding panel for the first unmapped-live entry on page load.
389+
// Catches `workspace-needs-binding` events that fired before the listener was
390+
// registered (e.g. Cursor was already connected when this page first rendered).
391+
useEffect(() => {
392+
if (isLoading || isPanelOpen) return;
393+
const firstUnmapped = entries.find((e) => e.kind === 'unmapped-live');
394+
if (!firstUnmapped) return;
395+
openBindingPanel({
396+
mode: 'create-from-live',
397+
workspaceRoot: firstUnmapped.root,
398+
appearanceIcon: resolveEntryIcon(firstUnmapped) ?? undefined,
399+
});
400+
// eslint-disable-next-line react-hooks/exhaustive-deps
401+
}, [isLoading]);
402+
387403
const handleRegisterMachine = async (input: {
388404
name: string;
389405
icon: string | null;
@@ -755,43 +771,39 @@ interface EntryCardRoutingRow {
755771
clickable: boolean;
756772
}
757773

774+
const ROUTING_GRID_COLS =
775+
'grid grid-cols-[minmax(0,5.5rem)_minmax(0,1fr)_minmax(0,3.5rem)] gap-x-2';
776+
758777
/**
759-
* Compact routing table for EntryCard footer — machine, feature set, space.
760-
* Uses semantic HTML table (no Table primitive in @mcpmux/ui). Feature set
761-
* names wrap to additional lines when needed.
778+
* Routing footer for EntryCard — fixed 3-column headers with each binding row
779+
* rendered as an aligned chip pill (solid for real bindings, dashed for ghosts).
762780
*/
763781
function EntryCardRoutingTable({
764782
rows,
765-
showMachineColumn,
766783
onRowClick,
767784
onCreateForCurrentMachine,
768785
t,
769786
}: {
770787
rows: EntryCardRoutingRow[];
771-
showMachineColumn: boolean;
772788
onRowClick?: (bindingId: string) => void;
773789
onCreateForCurrentMachine?: () => void;
774790
t: TFunction<['workspaces', 'common']>;
775791
}) {
776792
const headCls =
777-
'pb-1 pr-2 text-left text-[10px] font-semibold uppercase tracking-wider text-[rgb(var(--muted))] last:pr-0';
778-
const cellCls = 'py-0.5 pr-2 align-top text-[11px] text-[rgb(var(--foreground))] last:pr-0';
793+
'text-left text-[10px] font-semibold uppercase tracking-wider text-[rgb(var(--muted))]';
794+
const cellCls = 'min-w-0 text-[11px] text-[rgb(var(--foreground))]';
779795

780796
return (
781-
<table className="w-full border-collapse text-xs">
782-
<colgroup>
783-
{showMachineColumn ? <col className="w-px" /> : null}
784-
<col />
785-
<col className="w-px" />
786-
</colgroup>
787-
<thead>
788-
<tr className="border-b border-[rgb(var(--border-subtle))]">
789-
{showMachineColumn ? <th className={headCls}>{t('card.machine')}</th> : null}
790-
<th className={headCls}>{t('card.routesTo')}</th>
791-
<th className={`${headCls} whitespace-nowrap`}>{t('card.in')}</th>
792-
</tr>
793-
</thead>
794-
<tbody>
797+
<div className="text-xs">
798+
<div
799+
className={`${ROUTING_GRID_COLS} border-b border-[rgb(var(--border-subtle))] pb-1`}
800+
aria-hidden
801+
>
802+
<span className={headCls}>{t('card.machine')}</span>
803+
<span className={headCls}>{t('card.routesTo')}</span>
804+
<span className={`${headCls} whitespace-nowrap`}>{t('card.in')}</span>
805+
</div>
806+
<div className="mt-1.5 flex flex-col gap-1">
795807
{rows.map((row) => {
796808
const fsDisplay = row.fsName || '—';
797809
const spaceDisplay = row.spaceName ?? '—';
@@ -804,18 +816,14 @@ function EntryCardRoutingTable({
804816
? {
805817
role: 'button' as const,
806818
tabIndex: 0,
807-
className: [
808-
'cursor-pointer transition-colors hover:bg-[rgb(var(--surface-hover,var(--background)))]',
809-
row.ghost ? 'opacity-70' : '',
810-
].join(' '),
811819
'aria-label': row.createForCurrentMachine
812820
? t('card.addBindingForMachine', { machine: row.machineLabel })
813821
: t('card.machineRow', { machine: row.machineLabel }),
814-
onClick: (event: ReactMouseEvent<HTMLTableRowElement>) => {
822+
onClick: (event: ReactMouseEvent<HTMLDivElement>) => {
815823
event.stopPropagation();
816824
rowAction();
817825
},
818-
onKeyDown: (event: ReactKeyboardEvent<HTMLTableRowElement>) => {
826+
onKeyDown: (event: ReactKeyboardEvent<HTMLDivElement>) => {
819827
if (event.key === 'Enter' || event.key === ' ') {
820828
event.preventDefault();
821829
event.stopPropagation();
@@ -826,37 +834,48 @@ function EntryCardRoutingTable({
826834
: {};
827835

828836
return (
829-
<tr key={row.key} {...rowProps}>
830-
{showMachineColumn ? (
831-
<td className={`${cellCls} whitespace-nowrap`} title={row.machineLabel}>
832-
<span className="inline-flex max-w-[7rem] items-center gap-1">
833-
{row.machine?.icon ? (
834-
<span className="shrink-0 text-[11px] leading-none">{row.machine.icon}</span>
835-
) : null}
836-
<span className="truncate">{row.machineLabel}</span>
837-
</span>
838-
</td>
839-
) : null}
840-
<td className={cellCls}>
841-
<span
842-
className={[
843-
'block break-words font-medium leading-snug',
844-
row.ghost
845-
? 'italic text-[rgb(var(--muted))]'
846-
: 'text-primary-700 dark:text-primary-300',
847-
].join(' ')}
848-
>
849-
{fsDisplay}
837+
<div
838+
key={row.key}
839+
className={[
840+
ROUTING_GRID_COLS,
841+
'items-center rounded-md border px-1.5 py-1',
842+
row.ghost
843+
? 'border-dashed border-[rgb(var(--border-subtle))] opacity-70'
844+
: 'border-[rgb(var(--border-subtle))] bg-[rgb(var(--background))]',
845+
row.clickable && rowAction
846+
? 'cursor-pointer transition-colors hover:bg-[rgb(var(--surface-hover,var(--background)))]'
847+
: '',
848+
].join(' ')}
849+
{...rowProps}
850+
>
851+
<span className={`${cellCls} truncate whitespace-nowrap`} title={row.machineLabel}>
852+
<span className="inline-flex max-w-full items-center gap-1">
853+
{row.machine?.icon ? (
854+
<span className="shrink-0 text-[11px] leading-none">{row.machine.icon}</span>
855+
) : null}
856+
<span className="truncate">{row.machineLabel}</span>
850857
</span>
851-
</td>
852-
<td className={`${cellCls} whitespace-nowrap`} title={spaceDisplay}>
858+
</span>
859+
<span
860+
className={[
861+
cellCls,
862+
'truncate font-medium',
863+
row.ghost
864+
? 'italic text-[rgb(var(--muted))]'
865+
: 'text-primary-700 dark:text-primary-300',
866+
].join(' ')}
867+
title={fsDisplay}
868+
>
869+
{fsDisplay}
870+
</span>
871+
<span className={`${cellCls} truncate whitespace-nowrap`} title={spaceDisplay}>
853872
{spaceDisplay}
854-
</td>
855-
</tr>
873+
</span>
874+
</div>
856875
);
857876
})}
858-
</tbody>
859-
</table>
877+
</div>
878+
</div>
860879
);
861880
}
862881

@@ -911,6 +930,22 @@ function buildEntryRoutingRows(
911930
});
912931
}
913932

933+
if (entry.kind === 'unmapped-live' && bindings.length === 0) {
934+
const currentMachine = currentMachineId
935+
? machinesById.get(currentMachineId)
936+
: undefined;
937+
rows.push({
938+
key: 'ghost:unmapped',
939+
createForCurrentMachine: true,
940+
ghost: true,
941+
machine: currentMachine,
942+
machineLabel: currentMachine?.name ?? t('card.addBinding'),
943+
fsName: '—',
944+
spaceName: undefined,
945+
clickable: true,
946+
});
947+
}
948+
914949
return rows;
915950
}
916951

@@ -965,11 +1000,6 @@ function EntryCard({
9651000
fsById,
9661001
t,
9671002
);
968-
const showMachineColumn =
969-
bindings.some((b) => b.machine_id != null) ||
970-
routingRows.some((row) => row.ghost) ||
971-
bindings.length > 1;
972-
9731003
return (
9741004
<Card
9751005
className="group relative h-full cursor-pointer transition-all hover:shadow-lg hover:scale-[1.01]"
@@ -1054,19 +1084,10 @@ function EntryCard({
10541084
<div className="mt-auto -mx-6 -mb-6 rounded-b-xl bg-[rgb(var(--surface))] px-5 py-3 text-xs text-[rgb(var(--muted))]">
10551085
<EntryCardRoutingTable
10561086
rows={routingRows}
1057-
showMachineColumn={showMachineColumn}
10581087
onRowClick={onMachineRowClick}
10591088
onCreateForCurrentMachine={onCreateForCurrentMachine}
10601089
t={t}
10611090
/>
1062-
{!binding && (
1063-
<span
1064-
className="mt-2 inline-flex items-center px-1.5 py-0.5 rounded-md text-[10px] font-medium uppercase tracking-wider bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-400 border border-amber-200/70 dark:border-amber-800/60"
1065-
title={t('card.unboundTooltip')}
1066-
>
1067-
{t('card.unbound')}
1068-
</span>
1069-
)}
10701091
</div>
10711092
</CardContent>
10721093
</Card>

0 commit comments

Comments
 (0)