diff --git a/conductor-gui/ui/src/lib/components/RefinementCard.svelte b/conductor-gui/ui/src/lib/components/RefinementCard.svelte index 3d357e40..47f699f0 100644 --- a/conductor-gui/ui/src/lib/components/RefinementCard.svelte +++ b/conductor-gui/ui/src/lib/components/RefinementCard.svelte @@ -20,8 +20,9 @@ } from '$lib/utils/refinement-helpers'; import AlternativeChips from './AlternativeChips.svelte'; import RangeSlider from './RangeSlider.svelte'; - import { deviceBindingsStore } from '$lib/stores.js'; - import { WORKSPACE_VIEWS, workspaceView } from '$lib/stores/workspace.js'; + import { deviceBindingsStore, configStore } from '$lib/stores.js'; + import { generateQuickBinding } from '$lib/utils/quick-bind'; + import { saveDeviceConfig } from '$lib/utils/device-save.js'; export let suggestion = null; // eventHistory reserved for future pattern refinement logic @@ -33,12 +34,38 @@ // Source binding lookup (ADR-022 Phase 3D) $: bindings = $deviceBindingsStore?.bindings || []; $: sourceBinding = sourceDeviceId - ? bindings.find(b => b.device_id === sourceDeviceId) + ? bindings.find(b => b.is_configured && (b.device_id === sourceDeviceId || b.port_name === sourceDeviceId)) : null; $: isBound = !!sourceBinding; - - function handleCreateBinding() { - workspaceView.set(WORKSPACE_VIEWS.DEVICE_SETTINGS); + $: configReady = !!$configStore?.config; + $: bindingsLoaded = !$deviceBindingsStore?.loading; + + let bindingInProgress = false; + let bindingError = ''; + + async function handleCreateBinding() { + if (!sourceDeviceId) return; + const cfg = $configStore?.config; + if (!cfg) return; // button is disabled when !configReady + + bindingInProgress = true; + bindingError = ''; + + // Normalize: use `devices` key (saveDeviceConfig writes to cfg.devices) + // Normalize: prefer cfg.devices if present and non-empty, else cfg.bindings + const bindingsList = (cfg.devices && cfg.devices.length > 0) ? cfg.devices : (cfg.bindings || []); + const usedAliases = new Set(bindingsList.map(d => d.alias)); + const newDevice = generateQuickBinding(sourceDeviceId, usedAliases); + const normalizedCfg = { ...cfg, devices: bindingsList }; + delete normalizedCfg.bindings; + + try { + await saveDeviceConfig(configStore, deviceBindingsStore, 'create', newDevice, normalizedCfg); + } catch (err) { + bindingError = err?.message || String(err); + } finally { + bindingInProgress = false; + } } const dispatch = createEventDispatcher(); @@ -144,7 +171,12 @@ {:else} Unbound - + + {#if bindingError} + {bindingError} + {/if} {/if}
@@ -460,11 +492,21 @@ font-family: inherit; } - .source-bind-btn:hover { + .source-bind-btn:hover:not(:disabled) { background: var(--amber-08); border-color: var(--amber); } + .source-bind-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .source-bind-error { + font-size: 8px; + color: var(--accent); + } + .source-annotation { margin-top: 6px; display: flex; diff --git a/conductor-gui/ui/src/lib/components/RefinementCard.test.ts b/conductor-gui/ui/src/lib/components/RefinementCard.test.ts index 204d5e7e..df37a37f 100644 --- a/conductor-gui/ui/src/lib/components/RefinementCard.test.ts +++ b/conductor-gui/ui/src/lib/components/RefinementCard.test.ts @@ -285,4 +285,37 @@ describe('RefinementCard', () => { expect(captured[0].trigger).toBeTruthy(); }); }); + + // === Inline binding creation (#749) === + + it('shows "Quick Bind" button for unbound source', async () => { + const RefinementCard = (await import('./RefinementCard.svelte')).default; + const { container } = render(RefinementCard, { + props: { suggestion: noteSuggestion, eventHistory: [], deviceName: 'Test', sourceDeviceId: 'Unknown Port' }, + }); + const bindBtn = container.querySelector('.source-bind-btn'); + expect(bindBtn).toBeTruthy(); + expect(bindBtn?.textContent?.trim()).toBe('Quick Bind'); + }); + + it('shows unbound UI when device has no configured binding', async () => { + // Default mock returns empty bindings → sourceBinding is null → unbound path + const RefinementCard = (await import('./RefinementCard.svelte')).default; + const { container } = render(RefinementCard, { + props: { suggestion: noteSuggestion, eventHistory: [], deviceName: 'Test', sourceDeviceId: 'some-device' }, + }); + expect(container.querySelector('.source-unbound')).toBeTruthy(); + expect(container.querySelector('.source-bind-btn')).toBeTruthy(); + }); + + it('Quick Bind button is disabled when config not loaded', async () => { + // configStore defaults to no config in mock → configReady = false → button disabled + const RefinementCard = (await import('./RefinementCard.svelte')).default; + const { container } = render(RefinementCard, { + props: { suggestion: noteSuggestion, eventHistory: [], deviceName: 'Test', sourceDeviceId: 'Mikro MK3' }, + }); + const bindBtn = container.querySelector('.source-bind-btn') as HTMLButtonElement; + expect(bindBtn).toBeTruthy(); + expect(bindBtn.disabled).toBe(true); + }); }); diff --git a/conductor-gui/ui/src/lib/utils/quick-bind.test.ts b/conductor-gui/ui/src/lib/utils/quick-bind.test.ts new file mode 100644 index 00000000..354464df --- /dev/null +++ b/conductor-gui/ui/src/lib/utils/quick-bind.test.ts @@ -0,0 +1,44 @@ +// Copyright 2025 Amiable +// SPDX-License-Identifier: MIT + +import { describe, it, expect } from 'vitest'; +import { generateQuickBinding } from './quick-bind'; + +describe('generateQuickBinding', () => { + it('generates alias from first word of device ID', () => { + const result = generateQuickBinding('Maschine Mikro MK3', new Set()); + expect(result.alias).toBe('maschine'); + expect(result.matchers[0].type).toBe('NameContains'); + expect(result.matchers[0].value).toBe('Maschine Mikro MK3'); + expect(result.enabled).toBe(true); + }); + + it('handles device ID with special characters (underscore-replaced)', () => { + const result = generateQuickBinding('USB-MIDI_Device', new Set()); + expect(result.alias).toBe('usb_midi_device'); + }); + + it('falls back to "device" for empty/unparseable names', () => { + const result = generateQuickBinding('---', new Set()); + expect(result.alias).toBe('device'); + }); + + it('appends suffix for alias collision', () => { + const existing = new Set(['maschine']); + const result = generateQuickBinding('Maschine Mikro MK3', existing); + expect(result.alias).toBe('maschine2'); + }); + + it('increments suffix until unique', () => { + const existing = new Set(['maschine', 'maschine2', 'maschine3']); + const result = generateQuickBinding('Maschine Mikro MK3', existing); + expect(result.alias).toBe('maschine4'); + }); + + it('returns NameContains matcher with full device ID', () => { + const result = generateQuickBinding('nanoKONTROL2 MIDI', new Set()); + expect(result.matchers).toEqual([ + { type: 'NameContains', value: 'nanoKONTROL2 MIDI' }, + ]); + }); +}); diff --git a/conductor-gui/ui/src/lib/utils/quick-bind.ts b/conductor-gui/ui/src/lib/utils/quick-bind.ts new file mode 100644 index 00000000..28e14f83 --- /dev/null +++ b/conductor-gui/ui/src/lib/utils/quick-bind.ts @@ -0,0 +1,47 @@ +// Copyright 2025 Amiable +// SPDX-License-Identifier: MIT + +/** + * quick-bind.ts — Inline binding creation logic (#749) + * + * Generates a new device identity config from a source device ID. + * Extracted for testability. + */ + +export interface QuickBindConfig { + alias: string; + matchers: { type: string; value: string }[]; + enabled: boolean; +} + +/** + * Generate a new device identity config for quick binding. + * + * @param sourceDeviceId - Raw port name / device ID + * @param existingAliases - Set of already-used aliases + * @returns New device config with unique alias and NameContains matcher + */ +export function generateQuickBinding( + sourceDeviceId: string, + existingAliases: Set, +): QuickBindConfig { + // Based on DeviceEditor alias convention (first word, lowercase, non-alphanumeric → underscore) + // with additional trim of leading/trailing underscores and 'device' fallback + const base = sourceDeviceId + .split(' ')[0] + .toLowerCase() + .replace(/[^a-z0-9]/g, '_') + .replace(/^_+|_+$/g, '') || 'device'; + + let alias = base; + let suffix = 2; + while (existingAliases.has(alias)) { + alias = `${base}${suffix++}`; + } + + return { + alias, + matchers: [{ type: 'NameContains', value: sourceDeviceId }], + enabled: true, + }; +}