Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 50 additions & 8 deletions conductor-gui/ui/src/lib/components/RefinementCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
Expand Down Expand Up @@ -144,7 +171,12 @@
{:else}
<span class="source-dot"></span>
<span class="source-unbound">Unbound</span>
<button class="source-bind-btn" on:click={handleCreateBinding}>Create Binding</button>
<button class="source-bind-btn" on:click={handleCreateBinding} disabled={bindingInProgress || !configReady || !bindingsLoaded}>
{bindingInProgress ? 'Binding...' : 'Quick Bind'}
</button>
{#if bindingError}
<span class="source-bind-error">{bindingError}</span>
{/if}
{/if}
</div>
<div class="source-annotation">
Expand Down Expand Up @@ -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;
Expand Down
33 changes: 33 additions & 0 deletions conductor-gui/ui/src/lib/components/RefinementCard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Comment on lines +291 to +299
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new Quick Bind behavior in RefinementCard isn’t exercised end-to-end in tests yet (no test clicks the button with a loaded config and asserts saveDeviceConfig is called, progress label changes to Binding..., and errors render on failure). Adding those assertions would prevent regressions in the inline binding flow.

Copilot uses AI. Check for mistakes.

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();
});
Comment on lines +291 to +309
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new tests only assert that the Quick Bind UI renders, but they don’t exercise the async bind flow (clicking the button → calling save → showing "Binding..." state and/or surfacing an error). Adding at least one test that mocks configStore.save / deviceBindingsStore.fetch and verifies UI state transitions would better cover the newly introduced behavior.

Copilot uses AI. Check for mistakes.

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);
});
});
44 changes: 44 additions & 0 deletions conductor-gui/ui/src/lib/utils/quick-bind.test.ts
Original file line number Diff line number Diff line change
@@ -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' },
]);
});
});
47 changes: 47 additions & 0 deletions conductor-gui/ui/src/lib/utils/quick-bind.ts
Original file line number Diff line number Diff line change
@@ -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<string>,
): 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,
};
}
Loading