Skip to content

Commit 4ab677b

Browse files
committed
feat(workspaces): adopt-clone prefill and machine-aware binding panel
Prefill create-from-live bindings from sibling folder names on other machines, default the target machine to client/viewer/local identity, and make effective features preview machine-scoped. Clarify remove-binding confirm copy per machine. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 3828da4 commit 4ab677b

9 files changed

Lines changed: 240 additions & 28 deletions

File tree

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

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -708,6 +708,7 @@ fn enrich_feature(
708708
#[tauri::command]
709709
pub async fn get_workspace_effective_features(
710710
workspace_root: String,
711+
machine_id: Option<String>,
711712
state: State<'_, AppState>,
712713
sm_state: State<'_, Arc<RwLock<ServerManagerState>>>,
713714
) -> Result<WorkspaceEffectiveFeaturesDto, String> {
@@ -718,6 +719,8 @@ pub async fn get_workspace_effective_features(
718719
WorkspaceRootValidation::Invalid { reason } => return Err(reason),
719720
};
720721

722+
let parsed_machine_id = parse_optional_machine_id(machine_id.as_deref())?;
723+
721724
// 2. Default Space — the routing fallback.
722725
let default_space = state
723726
.space_service
@@ -726,12 +729,28 @@ pub async fn get_workspace_effective_features(
726729
.map_err(|e| e.to_string())?
727730
.ok_or("No default Space configured")?;
728731

729-
// 3. Tier 1: longest-prefix workspace binding match.
730-
let binding = state
731-
.workspace_binding_repository
732-
.find_exact_for_roots(std::slice::from_ref(&normalized))
733-
.await
734-
.map_err(|e| e.to_string())?;
732+
// 3. Binding lookup: machine-scoped when machine_id is set, else legacy exact match.
733+
let binding = if let Some(mid) = parsed_machine_id {
734+
match state
735+
.workspace_binding_repository
736+
.find_exact_for_machine(&mid, &normalized, None)
737+
.await
738+
.map_err(|e| e.to_string())?
739+
{
740+
Some(b) => Some(b),
741+
None => state
742+
.workspace_binding_repository
743+
.find_exact_global(&normalized)
744+
.await
745+
.map_err(|e| e.to_string())?,
746+
}
747+
} else {
748+
state
749+
.workspace_binding_repository
750+
.find_exact_for_roots(std::slice::from_ref(&normalized))
751+
.await
752+
.map_err(|e| e.to_string())?
753+
};
735754

736755
let (source, binding_id, space_id, fs_ids) = match binding {
737756
Some(b) => (

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1363,10 +1363,12 @@ function buildServerGroups(data: WorkspaceEffectiveFeatures): ServerGroup[] {
13631363
*/
13641364
export function EffectiveFeaturesContent({
13651365
root,
1366+
machineId,
13661367
onTotalChange,
13671368
t,
13681369
}: {
13691370
root: string;
1371+
machineId?: string | null;
13701372
onTotalChange?: (total: number | null) => void;
13711373
t: TFunction<['workspaces', 'common']>;
13721374
}) {
@@ -1385,7 +1387,7 @@ export function EffectiveFeaturesContent({
13851387
setError(null);
13861388
onTotalChange?.(null);
13871389
/* eslint-enable react-hooks/set-state-in-effect */
1388-
void getWorkspaceEffectiveFeatures(root)
1390+
void getWorkspaceEffectiveFeatures(root, machineId)
13891391
.then((d) => {
13901392
if (cancelled) return;
13911393
setData(d);
@@ -1405,18 +1407,18 @@ export function EffectiveFeaturesContent({
14051407
return () => {
14061408
cancelled = true;
14071409
};
1408-
}, [root, onTotalChange]);
1410+
}, [root, machineId, onTotalChange]);
14091411

14101412
const reloadEffectiveFeatures = useCallback(() => {
1411-
void getWorkspaceEffectiveFeatures(root)
1413+
void getWorkspaceEffectiveFeatures(root, machineId)
14121414
.then((d) => {
14131415
setData(d);
14141416
onTotalChange?.(d.tools.length + d.prompts.length + d.resources.length);
14151417
})
14161418
.catch(() => {
14171419
/* ignore — initial load already surfaced any error */
14181420
});
1419-
}, [root, onTotalChange]);
1421+
}, [root, machineId, onTotalChange]);
14201422

14211423
// Re-fetch on binding / server-status changes so the panel stays honest
14221424
// without the user reopening it.

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,14 @@ export function normalizeIcon(icon: string | null | undefined): string | null {
9494
return trimmed.length > 0 ? trimmed : null;
9595
}
9696

97+
/**
98+
* Last path segment of a workspace root, normalized for cross-platform matching.
99+
*/
100+
export function folderName(root: string): string {
101+
const segments = root.replace(/\\/g, '/').replace(/\/$/, '').split('/');
102+
return segments[segments.length - 1] ?? root;
103+
}
104+
97105
/** True when the icon value is an uploaded file ref or URL, not a plain emoji. */
98106
function isWorkspaceFileIcon(icon: string): boolean {
99107
const trimmed = icon.trim();

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

Lines changed: 150 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,15 @@ import { listFeatureSets, type FeatureSet } from '@/lib/api/featureSets';
4242
import { listSpaces, type Space } from '@/lib/api/spaces';
4343
import { ServerIcon } from '@/components/ServerIcon';
4444
import { useBindingPanelStore } from '@/stores/bindingPanelStore';
45+
import { useViewerIdentity } from '@/hooks/use-viewer-identity.hook';
4546
import {
4647
RoutingFields,
4748
SaveStatusPill,
4849
ScopeFields,
4950
bindingMachineId,
5051
bindingScopeConflicts,
5152
buildBindingPayload,
53+
folderName,
5254
normalizeIcon,
5355
sameBindingInput,
5456
type RootValidationState,
@@ -252,16 +254,19 @@ function buildFormInitial(
252254
export function WorkspaceBindingPanel() {
253255
const { t } = useTranslation(['workspaces', 'common']);
254256
const { isOpen, payload, open, close } = useBindingPanelStore();
257+
const { machineId: viewerMachineId } = useViewerIdentity();
255258
const { subscribe } = useWorkspaceEvents();
256259
const { confirm, ConfirmDialogElement } = useConfirm();
257260
const { error: showError } = useToast();
258261

259262
const [spaces, setSpaces] = useState<Space[]>([]);
260263
const [featureSets, setFeatureSets] = useState<FeatureSet[]>([]);
261264
const [machines, setMachines] = useState<Machine[]>([]);
265+
const [allBindings, setAllBindings] = useState<WorkspaceBinding[]>([]);
262266
const [localMachineId, setLocalMachineId] = useState<string | null>(null);
263267
const [clientMachineId, setClientMachineIdState] = useState<string | null>(null);
264268
const [showMachineCallout, setShowMachineCallout] = useState(false);
269+
const [adoptDismissed, setAdoptDismissed] = useState(false);
265270
const [assignMachineId, setAssignMachineId] = useState('');
266271
const [creatingMachine, setCreatingMachine] = useState(false);
267272
const [newMachineName, setNewMachineName] = useState('');
@@ -320,19 +325,23 @@ export function WorkspaceBindingPanel() {
320325
setEffectiveTotal(null);
321326
setCreatingMachine(false);
322327
setNewMachineName('');
328+
setAdoptDismissed(false);
323329

324330
void (async () => {
325331
try {
326-
const [loadedSpaces, loadedFs, loadedMachines, loadedLocalId] = await Promise.all([
332+
const [loadedSpaces, loadedFs, loadedMachines, loadedBindings, loadedLocalId] =
333+
await Promise.all([
327334
listSpaces(),
328335
listFeatureSets(),
329336
listMachines().catch(() => [] as Machine[]),
337+
listWorkspaceBindings().catch(() => [] as WorkspaceBinding[]),
330338
getLocalMachineId().catch(() => null),
331339
]);
332340
if (cancelled) return;
333341
setSpaces(loadedSpaces);
334342
setFeatureSets(loadedFs);
335343
setMachines(loadedMachines);
344+
setAllBindings(loadedBindings);
336345
setLocalMachineId(loadedLocalId);
337346

338347
if (payload.mode === 'create-from-live' && payload.clientId) {
@@ -389,6 +398,23 @@ export function WorkspaceBindingPanel() {
389398
const spaceLocked = payload?.spaceLocked ?? false;
390399
const panelKey = `${mode}:${payload?.binding?.id ?? workspaceRoot ?? 'new'}:${spaces.length}`;
391400

401+
const defaultTargetMachineId =
402+
clientMachineId ?? viewerMachineId ?? localMachineId ?? null;
403+
404+
const siblingBindings = useMemo(() => {
405+
if (mode !== 'create-from-live' || !workspaceRoot) return [];
406+
const currentFolder = folderName(workspaceRoot).toLowerCase();
407+
return allBindings.filter(
408+
(b) =>
409+
b.workspace_root.toLowerCase() !== workspaceRoot.toLowerCase() &&
410+
folderName(b.workspace_root).toLowerCase() === currentFolder,
411+
);
412+
}, [mode, workspaceRoot, allBindings]);
413+
414+
const effectiveMachineId = isEdit
415+
? bindingMachineId(machineId)
416+
: machineIds[0] ?? null;
417+
392418
useEffect(() => {
393419
if (!isOpen || !payload || loadingData) return;
394420
const initial = formInitial;
@@ -399,15 +425,24 @@ export function WorkspaceBindingPanel() {
399425
setFsIds(initial?.feature_set_ids ?? []);
400426
setMachineId(initial?.machine_id ?? '');
401427
setMachineIds(
402-
mode === 'edit' ? [] : localMachineId ? [localMachineId] : [],
428+
mode === 'edit' ? [] : defaultTargetMachineId ? [defaultTargetMachineId] : [],
403429
);
404430
setRootValidation({ state: 'idle' });
405431
setSubmitting(false);
406432
lastSavedRef.current = null;
407433
pendingPayloadRef.current = null;
408434
lastSavedAppearanceRef.current =
409435
mode === 'create-from-live' ? normalizeIcon(initial?.icon) : null;
410-
}, [panelKey, loadingData, isOpen, payload, formInitial, defaultSpaceId, mode, localMachineId]);
436+
}, [
437+
panelKey,
438+
loadingData,
439+
isOpen,
440+
payload,
441+
formInitial,
442+
defaultSpaceId,
443+
mode,
444+
defaultTargetMachineId,
445+
]);
411446

412447
useEffect(() => {
413448
if (!rootEditable) {
@@ -727,9 +762,17 @@ export function WorkspaceBindingPanel() {
727762
const handleDelete = useCallback(async () => {
728763
if (!payload?.binding) return;
729764
const binding = payload.binding;
765+
const displayName = binding.label?.trim() || folderName(binding.workspace_root);
766+
const machineName = binding.machine_id
767+
? machines.find((m) => m.id === binding.machine_id)?.name
768+
: null;
769+
const message =
770+
machineName != null
771+
? t('confirm.removeMessageMachine', { machine: machineName, name: displayName })
772+
: t('confirm.removeMessageGlobal', { name: displayName });
730773
const ok = await confirm({
731774
title: t('confirm.removeTitle'),
732-
message: t('confirm.removeMessage', { path: binding.workspace_root }),
775+
message,
733776
confirmLabel: t('confirm.removeLabel'),
734777
cancelLabel: t('common:actions.cancel'),
735778
variant: 'danger',
@@ -741,7 +784,7 @@ export function WorkspaceBindingPanel() {
741784
} catch (e) {
742785
showError(t('toast.failedToRemove'), e instanceof Error ? e.message : String(e));
743786
}
744-
}, [payload, confirm, close, showError, t]);
787+
}, [payload, machines, confirm, close, showError, t]);
745788

746789
const handleAssignMachine = async () => {
747790
if (!payload?.clientId || !assignMachineId || assigningMachine) return;
@@ -864,6 +907,13 @@ export function WorkspaceBindingPanel() {
864907
{mode === 'edit' && binding && <Pill tone="neutral">{t('card.offline')}</Pill>}
865908
</>
866909
}
910+
footer={
911+
mode === 'create-from-live' ? (
912+
<p className="text-xs text-[rgb(var(--muted))] mt-1">
913+
{t('panel.targeting', { machine: machineBadgeLabel })}
914+
</p>
915+
) : null
916+
}
867917
t={t}
868918
/>
869919
<button
@@ -989,6 +1039,100 @@ export function WorkspaceBindingPanel() {
9891039
</div>
9901040
)}
9911041

1042+
{mode === 'create-from-live' && siblingBindings.length > 0 && !adoptDismissed && (
1043+
<div
1044+
className="rounded-xl border border-primary-200/80 dark:border-primary-800/50 bg-primary-50/50 dark:bg-primary-900/10 p-4 space-y-3"
1045+
data-testid="workspace-binding-adopt-card"
1046+
>
1047+
<div>
1048+
<p className="text-sm font-semibold text-[rgb(var(--foreground))]">
1049+
{t('panel.adoptTitle')}
1050+
</p>
1051+
<p className="text-xs text-[rgb(var(--muted))] mt-1">{t('panel.adoptDesc')}</p>
1052+
</div>
1053+
<div className="overflow-x-auto rounded-lg border border-[rgb(var(--border-subtle))]">
1054+
<table className="w-full text-xs">
1055+
<thead>
1056+
<tr className="border-b border-[rgb(var(--border-subtle))] bg-[rgb(var(--surface))]">
1057+
<th className="px-2 py-1.5 text-left font-semibold text-[rgb(var(--muted))]">
1058+
{t('panel.adoptColMachine')}
1059+
</th>
1060+
<th className="px-2 py-1.5 text-left font-semibold text-[rgb(var(--muted))]">
1061+
{t('panel.adoptColPath')}
1062+
</th>
1063+
<th className="px-2 py-1.5 text-left font-semibold text-[rgb(var(--muted))]">
1064+
{t('panel.adoptColSpace')}
1065+
</th>
1066+
<th className="px-2 py-1.5 text-left font-semibold text-[rgb(var(--muted))]">
1067+
{t('panel.adoptColToolSet')}
1068+
</th>
1069+
<th className="px-2 py-1.5" />
1070+
</tr>
1071+
</thead>
1072+
<tbody>
1073+
{siblingBindings.map((sibling) => {
1074+
const machine = sibling.machine_id
1075+
? machinesById.get(sibling.machine_id)
1076+
: null;
1077+
const machineLabel = machine?.name ?? t('panel.machineGlobal');
1078+
const spaceName =
1079+
spaces.find((s) => s.id === sibling.space_id)?.name ?? '—';
1080+
const fsNames = formatFsList(
1081+
sibling.feature_set_ids.map(
1082+
(id) => featureSets.find((f) => f.id === id)?.name ?? id,
1083+
),
1084+
);
1085+
return (
1086+
<tr
1087+
key={sibling.id}
1088+
className="border-b border-[rgb(var(--border-subtle))] last:border-0"
1089+
>
1090+
<td className="px-2 py-2 whitespace-nowrap">
1091+
<span className="inline-flex items-center gap-1">
1092+
{machine?.icon ? (
1093+
<span className="text-sm leading-none">{machine.icon}</span>
1094+
) : null}
1095+
{machineLabel}
1096+
</span>
1097+
</td>
1098+
<td className="px-2 py-2 font-mono text-[10px] break-all max-w-[120px]">
1099+
{sibling.workspace_root}
1100+
</td>
1101+
<td className="px-2 py-2 whitespace-nowrap">{spaceName}</td>
1102+
<td className="px-2 py-2">{fsNames || '—'}</td>
1103+
<td className="px-2 py-2 whitespace-nowrap">
1104+
<Button
1105+
variant="secondary"
1106+
size="sm"
1107+
onClick={() => {
1108+
setSpaceId(sibling.space_id);
1109+
setFsIds(sibling.feature_set_ids);
1110+
if (sibling.label) setLabel(sibling.label);
1111+
if (sibling.icon) setIcon(sibling.icon);
1112+
setAdoptDismissed(true);
1113+
}}
1114+
data-testid={`workspace-binding-adopt-use-${sibling.id}`}
1115+
>
1116+
{t('panel.adoptUseThis')}
1117+
</Button>
1118+
</td>
1119+
</tr>
1120+
);
1121+
})}
1122+
</tbody>
1123+
</table>
1124+
</div>
1125+
<button
1126+
type="button"
1127+
onClick={() => setAdoptDismissed(true)}
1128+
className="text-xs text-[rgb(var(--muted))] underline-offset-2 hover:text-[rgb(var(--foreground))] hover:underline"
1129+
data-testid="workspace-binding-adopt-start-fresh"
1130+
>
1131+
{t('panel.adoptStartFresh')}
1132+
</button>
1133+
</div>
1134+
)}
1135+
9921136
<CollapsibleSection
9931137
ref={scopeSectionRef}
9941138
icon={<Monitor className="h-5 w-5" />}
@@ -1054,6 +1198,7 @@ export function WorkspaceBindingPanel() {
10541198
>
10551199
<EffectiveFeaturesContent
10561200
root={workspaceRoot}
1201+
machineId={effectiveMachineId}
10571202
onTotalChange={setEffectiveTotal}
10581203
t={t}
10591204
/>

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,11 @@ export interface WorkspaceEffectiveFeatures {
207207
* availability — same view the gateway resolver builds for live sessions.
208208
*/
209209
export async function getWorkspaceEffectiveFeatures(
210-
workspaceRoot: string
210+
workspaceRoot: string,
211+
machineId?: string | null,
211212
): Promise<WorkspaceEffectiveFeatures> {
212-
return apiCall('get_workspace_effective_features', { workspaceRoot });
213+
return apiCall('get_workspace_effective_features', {
214+
workspaceRoot,
215+
...(machineId ? { machineId } : {}),
216+
});
213217
}

apps/desktop/src/lib/backend/data/fetch-api.routes/workspaces.routes.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ export const workspacesRoutes: Record<string, RouteHandler> = {
2727
}),
2828
get_workspace_effective_features: (args) => ({
2929
method: 'GET',
30-
path: `/api/v1/workspaces/effective-features${buildQuery({ workspaceRoot: args.workspaceRoot })}`,
30+
path: `/api/v1/workspaces/effective-features${buildQuery({
31+
workspaceRoot: args.workspaceRoot,
32+
machineId: args.machineId,
33+
})}`,
3134
}),
3235
list_workspace_appearances: () => ({ method: 'GET', path: '/api/v1/workspaces/appearances' }),
3336
resolve_workspace_icon_path: (args) => ({

0 commit comments

Comments
 (0)