From 9268b6e73ec30170fbb6da7754930b98b7867dfb Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 29 Mar 2026 20:20:05 +0100 Subject: [PATCH 01/12] feat: #749 inline binding creation from RefinementCard Replace "navigate to DeviceSettings" with in-context Quick Bind: - Auto-generates NameContains matcher from source device_id - Creates binding via configStore.save() without leaving the card - Refreshes deviceBindingsStore so isBound reactively updates - Shows progress state (Binding...) and error feedback - User continues directly to mapping creation after binding Closes #749 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/components/RefinementCard.svelte | 63 +++++++++++++++++-- .../src/lib/components/RefinementCard.test.ts | 24 +++++++ 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/conductor-gui/ui/src/lib/components/RefinementCard.svelte b/conductor-gui/ui/src/lib/components/RefinementCard.svelte index 3d357e40..a5ad12e7 100644 --- a/conductor-gui/ui/src/lib/components/RefinementCard.svelte +++ b/conductor-gui/ui/src/lib/components/RefinementCard.svelte @@ -20,8 +20,7 @@ } 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'; export let suggestion = null; // eventHistory reserved for future pattern refinement logic @@ -37,8 +36,45 @@ : null; $: isBound = !!sourceBinding; - function handleCreateBinding() { - workspaceView.set(WORKSPACE_VIEWS.DEVICE_SETTINGS); + let bindingInProgress = false; + let bindingError = ''; + + async function handleCreateBinding() { + const cfg = $configStore?.config; + if (!cfg || !sourceDeviceId) return; + + bindingInProgress = true; + bindingError = ''; + + // Generate a short alias from the device_id (first word, lowercased) + const alias = sourceDeviceId + .split(/[\s_-]+/)[0] + .toLowerCase() + .replace(/[^a-z0-9]/g, '') || 'device'; + + // Check for alias collision + const existing = cfg.devices || []; + const uniqueAlias = existing.some(d => d.alias === alias) + ? `${alias}-${Date.now() % 10000}` + : alias; + + const newDevice = { + alias: uniqueAlias, + matchers: [{ type: 'NameContains', value: sourceDeviceId }], + enabled: true, + }; + + try { + await configStore.save({ + ...cfg, + devices: [...existing, newDevice], + }); + await deviceBindingsStore.fetch(); + } catch (err) { + bindingError = String(err); + } finally { + bindingInProgress = false; + } } const dispatch = createEventDispatcher(); @@ -144,7 +180,12 @@ {:else} Unbound - + + {#if bindingError} + {bindingError} + {/if} {/if}
@@ -460,11 +501,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: wait; + } + + .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..c8bc5aa2 100644 --- a/conductor-gui/ui/src/lib/components/RefinementCard.test.ts +++ b/conductor-gui/ui/src/lib/components/RefinementCard.test.ts @@ -285,4 +285,28 @@ 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 bound alias when device is bound', async () => { + // This test relies on deviceBindingsStore mock returning a binding + // The default mock returns empty bindings, so sourceBinding will be null + // and the unbound section shows. This verifies the unbound path works. + const RefinementCard = (await import('./RefinementCard.svelte')).default; + const { container } = render(RefinementCard, { + props: { suggestion: noteSuggestion, eventHistory: [], deviceName: 'Test', sourceDeviceId: 'some-device' }, + }); + // With empty binding store, should show unbound + expect(container.querySelector('.source-unbound')).toBeTruthy(); + }); }); From 60d7a0b17e0f7951c7369c8f72ba7444bc8347de Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 29 Mar 2026 20:26:34 +0100 Subject: [PATCH 02/12] =?UTF-8?q?fix:=20review=20feedback=20=E2=80=94=20is?= =?UTF-8?q?=5Fconfigured=20filter,=20alias=20loop,=20delay,=20disable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Filter bindings by is_configured (unconfigured ports aren't bound) - Unique alias via incrementing suffix loop (no timestamp collision) - 600ms delay before fetch to wait for daemon hot-reload - Disable Quick Bind when config not loaded, show error message - Rename misleading test to match actual behavior Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/components/RefinementCard.svelte | 25 +++++++++++++------ .../src/lib/components/RefinementCard.test.ts | 8 +++--- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/conductor-gui/ui/src/lib/components/RefinementCard.svelte b/conductor-gui/ui/src/lib/components/RefinementCard.svelte index a5ad12e7..8098e557 100644 --- a/conductor-gui/ui/src/lib/components/RefinementCard.svelte +++ b/conductor-gui/ui/src/lib/components/RefinementCard.svelte @@ -32,31 +32,38 @@ // Source binding lookup (ADR-022 Phase 3D) $: bindings = $deviceBindingsStore?.bindings || []; $: sourceBinding = sourceDeviceId - ? bindings.find(b => b.device_id === sourceDeviceId) + ? bindings.find(b => b.device_id === sourceDeviceId && b.is_configured) : null; $: isBound = !!sourceBinding; + $: configReady = !!$configStore?.config; let bindingInProgress = false; let bindingError = ''; async function handleCreateBinding() { const cfg = $configStore?.config; - if (!cfg || !sourceDeviceId) return; + if (!cfg || !sourceDeviceId) { + bindingError = 'Config not loaded yet'; + return; + } bindingInProgress = true; bindingError = ''; // Generate a short alias from the device_id (first word, lowercased) - const alias = sourceDeviceId + const base = sourceDeviceId .split(/[\s_-]+/)[0] .toLowerCase() .replace(/[^a-z0-9]/g, '') || 'device'; - // Check for alias collision + // Find unique alias (append suffix if collision) const existing = cfg.devices || []; - const uniqueAlias = existing.some(d => d.alias === alias) - ? `${alias}-${Date.now() % 10000}` - : alias; + const usedAliases = new Set(existing.map(d => d.alias)); + let uniqueAlias = base; + let suffix = 2; + while (usedAliases.has(uniqueAlias)) { + uniqueAlias = `${base}${suffix++}`; + } const newDevice = { alias: uniqueAlias, @@ -69,6 +76,8 @@ ...cfg, devices: [...existing, newDevice], }); + // Wait for daemon hot-reload (500ms debounce) before refreshing bindings + await new Promise(r => setTimeout(r, 600)); await deviceBindingsStore.fetch(); } catch (err) { bindingError = String(err); @@ -180,7 +189,7 @@ {:else} Unbound - {#if bindingError} diff --git a/conductor-gui/ui/src/lib/components/RefinementCard.test.ts b/conductor-gui/ui/src/lib/components/RefinementCard.test.ts index c8bc5aa2..610e4828 100644 --- a/conductor-gui/ui/src/lib/components/RefinementCard.test.ts +++ b/conductor-gui/ui/src/lib/components/RefinementCard.test.ts @@ -298,15 +298,13 @@ describe('RefinementCard', () => { expect(bindBtn?.textContent?.trim()).toBe('Quick Bind'); }); - it('shows bound alias when device is bound', async () => { - // This test relies on deviceBindingsStore mock returning a binding - // The default mock returns empty bindings, so sourceBinding will be null - // and the unbound section shows. This verifies the unbound path works. + 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' }, }); - // With empty binding store, should show unbound expect(container.querySelector('.source-unbound')).toBeTruthy(); + expect(container.querySelector('.source-bind-btn')).toBeTruthy(); }); }); From a8c4336a09d244170f27c21d094a049c6ba75ee7 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 29 Mar 2026 20:38:30 +0100 Subject: [PATCH 03/12] fix: poll for binding instead of fixed delay, better error message - Replace 600ms setTimeout with retry polling (5 attempts, 300ms each) - Use err?.message || String(err) for consistent error text Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/src/lib/components/RefinementCard.svelte | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/conductor-gui/ui/src/lib/components/RefinementCard.svelte b/conductor-gui/ui/src/lib/components/RefinementCard.svelte index 8098e557..d2da53c8 100644 --- a/conductor-gui/ui/src/lib/components/RefinementCard.svelte +++ b/conductor-gui/ui/src/lib/components/RefinementCard.svelte @@ -76,11 +76,16 @@ ...cfg, devices: [...existing, newDevice], }); - // Wait for daemon hot-reload (500ms debounce) before refreshing bindings - await new Promise(r => setTimeout(r, 600)); - await deviceBindingsStore.fetch(); + // Poll for binding to appear (daemon hot-reload debounce is 500ms) + for (let attempt = 0; attempt < 5; attempt++) { + await new Promise(r => setTimeout(r, 300)); + await deviceBindingsStore.fetch(); + const updated = ($deviceBindingsStore?.bindings || []) + .find(b => b.device_id === sourceDeviceId && b.is_configured); + if (updated) break; + } } catch (err) { - bindingError = String(err); + bindingError = err?.message || String(err); } finally { bindingInProgress = false; } From 169453025cf5dba2fa4262460071a54e21aee40b Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 30 Mar 2026 21:37:56 +0100 Subject: [PATCH 04/12] fix: match binding by port_name too (device_id may be alias, not raw port) Co-Authored-By: Claude Opus 4.6 (1M context) --- conductor-gui/ui/src/lib/components/RefinementCard.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/conductor-gui/ui/src/lib/components/RefinementCard.svelte b/conductor-gui/ui/src/lib/components/RefinementCard.svelte index d2da53c8..2a9ff7b0 100644 --- a/conductor-gui/ui/src/lib/components/RefinementCard.svelte +++ b/conductor-gui/ui/src/lib/components/RefinementCard.svelte @@ -32,7 +32,7 @@ // Source binding lookup (ADR-022 Phase 3D) $: bindings = $deviceBindingsStore?.bindings || []; $: sourceBinding = sourceDeviceId - ? bindings.find(b => b.device_id === sourceDeviceId && b.is_configured) + ? bindings.find(b => b.is_configured && (b.device_id === sourceDeviceId || b.port_name === sourceDeviceId)) : null; $: isBound = !!sourceBinding; $: configReady = !!$configStore?.config; @@ -81,7 +81,7 @@ await new Promise(r => setTimeout(r, 300)); await deviceBindingsStore.fetch(); const updated = ($deviceBindingsStore?.bindings || []) - .find(b => b.device_id === sourceDeviceId && b.is_configured); + .find(b => b.is_configured && (b.device_id === sourceDeviceId || b.port_name === sourceDeviceId)); if (updated) break; } } catch (err) { From 582e42cc676c998272951b8c5c6434fa2ce15c1a Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 30 Mar 2026 21:47:13 +0100 Subject: [PATCH 05/12] fix: extract quick-bind utility + 6 tests, split error guards - Extract generateQuickBinding() to quick-bind.ts for testability - 6 unit tests: alias generation, collision handling, fallback, matchers - Split guard: no-op for missing sourceDeviceId, error for missing config - RefinementCard delegates to utility for binding creation Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/components/RefinementCard.svelte | 23 ++-------- .../ui/src/lib/utils/quick-bind.test.ts | 44 +++++++++++++++++++ conductor-gui/ui/src/lib/utils/quick-bind.ts | 44 +++++++++++++++++++ 3 files changed, 92 insertions(+), 19 deletions(-) create mode 100644 conductor-gui/ui/src/lib/utils/quick-bind.test.ts create mode 100644 conductor-gui/ui/src/lib/utils/quick-bind.ts diff --git a/conductor-gui/ui/src/lib/components/RefinementCard.svelte b/conductor-gui/ui/src/lib/components/RefinementCard.svelte index 2a9ff7b0..1fa41680 100644 --- a/conductor-gui/ui/src/lib/components/RefinementCard.svelte +++ b/conductor-gui/ui/src/lib/components/RefinementCard.svelte @@ -21,6 +21,7 @@ import AlternativeChips from './AlternativeChips.svelte'; import RangeSlider from './RangeSlider.svelte'; import { deviceBindingsStore, configStore } from '$lib/stores.js'; + import { generateQuickBinding } from '$lib/utils/quick-bind'; export let suggestion = null; // eventHistory reserved for future pattern refinement logic @@ -41,8 +42,9 @@ let bindingError = ''; async function handleCreateBinding() { + if (!sourceDeviceId) return; // button shouldn't be visible without sourceDeviceId const cfg = $configStore?.config; - if (!cfg || !sourceDeviceId) { + if (!cfg) { bindingError = 'Config not loaded yet'; return; } @@ -50,26 +52,9 @@ bindingInProgress = true; bindingError = ''; - // Generate a short alias from the device_id (first word, lowercased) - const base = sourceDeviceId - .split(/[\s_-]+/)[0] - .toLowerCase() - .replace(/[^a-z0-9]/g, '') || 'device'; - - // Find unique alias (append suffix if collision) const existing = cfg.devices || []; const usedAliases = new Set(existing.map(d => d.alias)); - let uniqueAlias = base; - let suffix = 2; - while (usedAliases.has(uniqueAlias)) { - uniqueAlias = `${base}${suffix++}`; - } - - const newDevice = { - alias: uniqueAlias, - matchers: [{ type: 'NameContains', value: sourceDeviceId }], - enabled: true, - }; + const newDevice = generateQuickBinding(sourceDeviceId, usedAliases); try { await configStore.save({ 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..69efa8c4 --- /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', () => { + const result = generateQuickBinding('USB-MIDI_Device', new Set()); + expect(result.alias).toBe('usb'); + }); + + 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..9a34de5a --- /dev/null +++ b/conductor-gui/ui/src/lib/utils/quick-bind.ts @@ -0,0 +1,44 @@ +// 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 DeviceConfig { + 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, +): DeviceConfig { + const base = sourceDeviceId + .split(/[\s_-]+/)[0] + .toLowerCase() + .replace(/[^a-z0-9]/g, '') || 'device'; + + let alias = base; + let suffix = 2; + while (existingAliases.has(alias)) { + alias = `${base}${suffix++}`; + } + + return { + alias, + matchers: [{ type: 'NameContains', value: sourceDeviceId }], + enabled: true, + }; +} From 2ead77b4b46ce8492b42859a8b8404f0027ea868 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 31 Mar 2026 08:13:42 +0100 Subject: [PATCH 06/12] fix: reuse saveDeviceConfig, cursor not-allowed when config unavailable - Delegate to saveDeviceConfig() instead of duplicating save+fetch - Use cursor: not-allowed (not wait) for disabled-but-not-in-progress Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/src/lib/components/RefinementCard.svelte | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/conductor-gui/ui/src/lib/components/RefinementCard.svelte b/conductor-gui/ui/src/lib/components/RefinementCard.svelte index 1fa41680..47c95bc5 100644 --- a/conductor-gui/ui/src/lib/components/RefinementCard.svelte +++ b/conductor-gui/ui/src/lib/components/RefinementCard.svelte @@ -22,6 +22,7 @@ import RangeSlider from './RangeSlider.svelte'; 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 @@ -57,18 +58,7 @@ const newDevice = generateQuickBinding(sourceDeviceId, usedAliases); try { - await configStore.save({ - ...cfg, - devices: [...existing, newDevice], - }); - // Poll for binding to appear (daemon hot-reload debounce is 500ms) - for (let attempt = 0; attempt < 5; attempt++) { - await new Promise(r => setTimeout(r, 300)); - await deviceBindingsStore.fetch(); - const updated = ($deviceBindingsStore?.bindings || []) - .find(b => b.is_configured && (b.device_id === sourceDeviceId || b.port_name === sourceDeviceId)); - if (updated) break; - } + await saveDeviceConfig(configStore, deviceBindingsStore, 'create', newDevice, cfg); } catch (err) { bindingError = err?.message || String(err); } finally { @@ -507,7 +497,7 @@ .source-bind-btn:disabled { opacity: 0.5; - cursor: wait; + cursor: not-allowed; } .source-bind-error { From 6109576a0ee1d5de94e66b4162f0753fef80977d Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 31 Mar 2026 08:47:01 +0100 Subject: [PATCH 07/12] =?UTF-8?q?fix:=20rename=20DeviceConfig=20=E2=86=92?= =?UTF-8?q?=20QuickBindConfig,=20check=20cfg.bindings=20alias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- conductor-gui/ui/src/lib/components/RefinementCard.svelte | 2 +- conductor-gui/ui/src/lib/utils/quick-bind.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/conductor-gui/ui/src/lib/components/RefinementCard.svelte b/conductor-gui/ui/src/lib/components/RefinementCard.svelte index 47c95bc5..ccb08ca0 100644 --- a/conductor-gui/ui/src/lib/components/RefinementCard.svelte +++ b/conductor-gui/ui/src/lib/components/RefinementCard.svelte @@ -53,7 +53,7 @@ bindingInProgress = true; bindingError = ''; - const existing = cfg.devices || []; + const existing = cfg.devices || cfg.bindings || []; const usedAliases = new Set(existing.map(d => d.alias)); const newDevice = generateQuickBinding(sourceDeviceId, usedAliases); diff --git a/conductor-gui/ui/src/lib/utils/quick-bind.ts b/conductor-gui/ui/src/lib/utils/quick-bind.ts index 9a34de5a..27e05ecf 100644 --- a/conductor-gui/ui/src/lib/utils/quick-bind.ts +++ b/conductor-gui/ui/src/lib/utils/quick-bind.ts @@ -8,7 +8,7 @@ * Extracted for testability. */ -export interface DeviceConfig { +export interface QuickBindConfig { alias: string; matchers: { type: string; value: string }[]; enabled: boolean; @@ -24,7 +24,7 @@ export interface DeviceConfig { export function generateQuickBinding( sourceDeviceId: string, existingAliases: Set, -): DeviceConfig { +): QuickBindConfig { const base = sourceDeviceId .split(/[\s_-]+/)[0] .toLowerCase() From 381dd7afff8160071ab98fb8a94c62db78df19b2 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 31 Mar 2026 09:49:47 +0100 Subject: [PATCH 08/12] fix: normalize config key before save, remove unreachable guard - Normalize cfg to use `devices` key only (delete `bindings` alias) to avoid duplicate key deserialization issues - Remove unreachable 'Config not loaded' error (button already disabled) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/src/lib/components/RefinementCard.svelte | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/conductor-gui/ui/src/lib/components/RefinementCard.svelte b/conductor-gui/ui/src/lib/components/RefinementCard.svelte index ccb08ca0..3f82e2f7 100644 --- a/conductor-gui/ui/src/lib/components/RefinementCard.svelte +++ b/conductor-gui/ui/src/lib/components/RefinementCard.svelte @@ -43,22 +43,22 @@ let bindingError = ''; async function handleCreateBinding() { - if (!sourceDeviceId) return; // button shouldn't be visible without sourceDeviceId + if (!sourceDeviceId) return; const cfg = $configStore?.config; - if (!cfg) { - bindingError = 'Config not loaded yet'; - return; - } + if (!cfg) return; // button is disabled when !configReady bindingInProgress = true; bindingError = ''; - const existing = cfg.devices || cfg.bindings || []; - const usedAliases = new Set(existing.map(d => d.alias)); + // Normalize: use `devices` key (saveDeviceConfig writes to cfg.devices) + const bindingsList = 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, cfg); + await saveDeviceConfig(configStore, deviceBindingsStore, 'create', newDevice, normalizedCfg); } catch (err) { bindingError = err?.message || String(err); } finally { From 769eac3ff5c8dd8338e556236694f8e42feed23f Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 31 Mar 2026 10:57:43 +0100 Subject: [PATCH 09/12] fix: align alias convention with DeviceEditor (underscore, not strip) Co-Authored-By: Claude Opus 4.6 (1M context) --- conductor-gui/ui/src/lib/utils/quick-bind.test.ts | 4 ++-- conductor-gui/ui/src/lib/utils/quick-bind.ts | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/conductor-gui/ui/src/lib/utils/quick-bind.test.ts b/conductor-gui/ui/src/lib/utils/quick-bind.test.ts index 69efa8c4..354464df 100644 --- a/conductor-gui/ui/src/lib/utils/quick-bind.test.ts +++ b/conductor-gui/ui/src/lib/utils/quick-bind.test.ts @@ -13,9 +13,9 @@ describe('generateQuickBinding', () => { expect(result.enabled).toBe(true); }); - it('handles device ID with special characters', () => { + it('handles device ID with special characters (underscore-replaced)', () => { const result = generateQuickBinding('USB-MIDI_Device', new Set()); - expect(result.alias).toBe('usb'); + expect(result.alias).toBe('usb_midi_device'); }); it('falls back to "device" for empty/unparseable names', () => { diff --git a/conductor-gui/ui/src/lib/utils/quick-bind.ts b/conductor-gui/ui/src/lib/utils/quick-bind.ts index 27e05ecf..a1e3bf56 100644 --- a/conductor-gui/ui/src/lib/utils/quick-bind.ts +++ b/conductor-gui/ui/src/lib/utils/quick-bind.ts @@ -25,10 +25,12 @@ export function generateQuickBinding( sourceDeviceId: string, existingAliases: Set, ): QuickBindConfig { + // Match DeviceEditor alias convention: first word, lowercase, non-alphanumeric → underscore const base = sourceDeviceId - .split(/[\s_-]+/)[0] + .split(' ')[0] .toLowerCase() - .replace(/[^a-z0-9]/g, '') || 'device'; + .replace(/[^a-z0-9]/g, '_') + .replace(/^_+|_+$/g, '') || 'device'; let alias = base; let suffix = 2; From e18865f4c8a1b137e443c748739c05541dd9e22c Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 31 Mar 2026 11:12:23 +0100 Subject: [PATCH 10/12] fix: prefer non-empty bindings list, clarify alias comment - Select longer of cfg.devices/cfg.bindings to avoid empty array shadow - Update quick-bind comment to note intentional differences from DeviceEditor Co-Authored-By: Claude Opus 4.6 (1M context) --- conductor-gui/ui/src/lib/components/RefinementCard.svelte | 5 ++++- conductor-gui/ui/src/lib/utils/quick-bind.ts | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/conductor-gui/ui/src/lib/components/RefinementCard.svelte b/conductor-gui/ui/src/lib/components/RefinementCard.svelte index 3f82e2f7..bc401969 100644 --- a/conductor-gui/ui/src/lib/components/RefinementCard.svelte +++ b/conductor-gui/ui/src/lib/components/RefinementCard.svelte @@ -51,7 +51,10 @@ bindingError = ''; // Normalize: use `devices` key (saveDeviceConfig writes to cfg.devices) - const bindingsList = cfg.devices || cfg.bindings || []; + // Prefer the non-empty list (cfg may have devices, bindings, or both via serde alias) + const devs = cfg.devices || []; + const binds = cfg.bindings || []; + const bindingsList = devs.length >= binds.length ? devs : binds; const usedAliases = new Set(bindingsList.map(d => d.alias)); const newDevice = generateQuickBinding(sourceDeviceId, usedAliases); const normalizedCfg = { ...cfg, devices: bindingsList }; diff --git a/conductor-gui/ui/src/lib/utils/quick-bind.ts b/conductor-gui/ui/src/lib/utils/quick-bind.ts index a1e3bf56..28e14f83 100644 --- a/conductor-gui/ui/src/lib/utils/quick-bind.ts +++ b/conductor-gui/ui/src/lib/utils/quick-bind.ts @@ -25,7 +25,8 @@ export function generateQuickBinding( sourceDeviceId: string, existingAliases: Set, ): QuickBindConfig { - // Match DeviceEditor alias convention: first word, lowercase, non-alphanumeric → underscore + // 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() From b746804e590eb2760d30c0e3ea4cdcc6c6c1179f Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 31 Mar 2026 11:37:11 +0100 Subject: [PATCH 11/12] test: add Quick Bind disabled state test when config not loaded Verifies the button is disabled when configReady is false (no config). Full async save flow is covered by quick-bind.ts unit tests (6 tests) and saveDeviceConfig integration (shared utility used across views). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/src/lib/components/RefinementCard.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/conductor-gui/ui/src/lib/components/RefinementCard.test.ts b/conductor-gui/ui/src/lib/components/RefinementCard.test.ts index 610e4828..df37a37f 100644 --- a/conductor-gui/ui/src/lib/components/RefinementCard.test.ts +++ b/conductor-gui/ui/src/lib/components/RefinementCard.test.ts @@ -307,4 +307,15 @@ describe('RefinementCard', () => { 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); + }); }); From a456dd4586990cb013cf39ead3e6485a999adf60 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 31 Mar 2026 16:57:18 +0100 Subject: [PATCH 12/12] fix: deterministic config normalization, disable while bindings loading - Prefer cfg.devices if non-empty, else cfg.bindings (no length comparison) - Disable Quick Bind while deviceBindingsStore is still loading Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/src/lib/components/RefinementCard.svelte | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/conductor-gui/ui/src/lib/components/RefinementCard.svelte b/conductor-gui/ui/src/lib/components/RefinementCard.svelte index bc401969..47f699f0 100644 --- a/conductor-gui/ui/src/lib/components/RefinementCard.svelte +++ b/conductor-gui/ui/src/lib/components/RefinementCard.svelte @@ -38,6 +38,7 @@ : null; $: isBound = !!sourceBinding; $: configReady = !!$configStore?.config; + $: bindingsLoaded = !$deviceBindingsStore?.loading; let bindingInProgress = false; let bindingError = ''; @@ -51,10 +52,8 @@ bindingError = ''; // Normalize: use `devices` key (saveDeviceConfig writes to cfg.devices) - // Prefer the non-empty list (cfg may have devices, bindings, or both via serde alias) - const devs = cfg.devices || []; - const binds = cfg.bindings || []; - const bindingsList = devs.length >= binds.length ? devs : binds; + // 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 }; @@ -172,7 +171,7 @@ {:else} Unbound - {#if bindingError}