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,
+ };
+}