Skip to content

feat: #749 inline binding creation from RefinementCard#792

Merged
amiable-dev merged 12 commits intomainfrom
feat/749-inline-binding
Mar 31, 2026
Merged

feat: #749 inline binding creation from RefinementCard#792
amiable-dev merged 12 commits intomainfrom
feat/749-inline-binding

Conversation

@amiable-dev
Copy link
Copy Markdown
Owner

Summary

Replace the "navigate to DeviceSettings" button with in-context Quick Bind that creates a binding without leaving the RefinementCard:

  • Auto-generates NameContains matcher from sourceDeviceId
  • Creates binding via configStore.save() inline
  • Refreshes deviceBindingsStore so isBound reactively flips to true
  • Shows progress (Binding...) and error feedback
  • User continues directly to mapping creation — no view switch needed

This completes the ADR-022 D5 / ADR-017 A2.2 acceptance criterion: "Flow connects to MappingEditor after binding creation".

Closes #749

Test plan

  • 24 RefinementCard tests pass (22 existing + 2 new)
  • Quick Bind button visible for unbound sources
  • After bind, source status flips to bound with alias
  • cargo test --workspace passes

🤖 Generated with Claude Code

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) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 29, 2026 19:20
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR updates the RefinementCard “events → mapping” flow to support creating a device binding inline (Quick Bind) instead of navigating away to Device Settings, aligning with #749 / ADR-022 Phase 3D / ADR-017 A2.2.

Changes:

  • Adds inline “Quick Bind” UI with progress/error display and attempts to create a new [[devices]] entry via configStore.save().
  • Refreshes bindings after save via deviceBindingsStore.fetch() to drive reactive bound/unbound UI.
  • Adds basic RefinementCard tests around the new binding section.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
conductor-gui/ui/src/lib/components/RefinementCard.svelte Implements Quick Bind UI and inline config save + bindings refresh logic.
conductor-gui/ui/src/lib/components/RefinementCard.test.ts Adds tests intended to cover Quick Bind visibility / bound-state rendering.


async function handleCreateBinding() {
const cfg = $configStore?.config;
if (!cfg || !sourceDeviceId) return;
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

When config isn’t loaded ($configStore.config is null) or sourceDeviceId is missing, handleCreateBinding returns without any user feedback, but the button remains clickable. Consider disabling the Quick Bind button until config is available and/or setting a user-visible bindingError explaining why binding can’t be created yet.

Suggested change
if (!cfg || !sourceDeviceId) return;
if (!cfg) {
bindingError = 'Cannot create binding: configuration not loaded yet.';
return;
}
if (!sourceDeviceId) {
bindingError = 'Cannot create binding: source device is unknown.';
return;
}

Copilot uses AI. Check for mistakes.
Comment on lines +302 to +310
// 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();
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

The test "shows bound alias when device is bound" doesn’t set up a bound device and actually asserts the unbound UI (.source-unbound). This makes the test name misleading and leaves the bound rendering path untested. Consider mocking deviceBindingsStore (or invoke('get_device_bindings') + calling deviceBindingsStore.fetch()) to return a binding with is_configured: true, then assert that .source-alias renders the alias and the Quick Bind button is absent/disabled.

Suggested change
// 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();
// Arrange: mock device bindings so that "some-device" is configured with an alias
const { invoke } = await import('@tauri-apps/api/core');
(invoke as unknown as vi.Mock).mockResolvedValueOnce([
{
device_id: 'some-device',
alias: 'My Device Alias',
is_configured: true,
},
]);
const RefinementCard = (await import('./RefinementCard.svelte')).default;
const { container } = render(RefinementCard, {
props: {
suggestion: noteSuggestion,
eventHistory: [],
deviceName: 'Test',
sourceDeviceId: 'some-device',
},
});
// Assert: alias is shown and Quick Bind button is not rendered
const aliasEl = await screen.findByText('My Device Alias');
expect(aliasEl).toBeTruthy();
expect(container.querySelector('.source-alias')).toBeTruthy();
const bindBtn = container.querySelector('.source-bind-btn');
expect(bindBtn).toBeFalsy();

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +59
// Check for alias collision
const existing = cfg.devices || [];
const uniqueAlias = existing.some(d => d.alias === alias)
? `${alias}-${Date.now() % 10000}`
: alias;
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

Alias collision handling only checks the base alias, then appends ${Date.now() % 10000} once. This can still produce duplicates (e.g., an existing pads-1234, or two binds in the same millisecond with the same modulo), which will fail config validation on save. Consider generating the alias with a monotonic suffix loop (e.g., alias, alias-2, alias-3, …) until it’s not present in cfg.devices.

Suggested change
// Check for alias collision
const existing = cfg.devices || [];
const uniqueAlias = existing.some(d => d.alias === alias)
? `${alias}-${Date.now() % 10000}`
: alias;
// Check for alias collision and generate a unique alias
const existing = cfg.devices || [];
const baseAlias = alias;
let uniqueAlias = baseAlias;
let suffix = 2;
while (existing.some(d => d.alias === uniqueAlias)) {
uniqueAlias = `${baseAlias}-${suffix}`;
suffix += 1;
}

Copilot uses AI. Check for mistakes.
Comment on lines 33 to 37
$: bindings = $deviceBindingsStore?.bindings || [];
$: sourceBinding = sourceDeviceId
? bindings.find(b => b.device_id === sourceDeviceId)
: null;
$: isBound = !!sourceBinding;
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

sourceBinding/isBound currently treats any matching entry in deviceBindingsStore.bindings as “bound”, but the daemon includes unconfigured ports in this list (is_configured: false) and uses device_id = port name for those. This will incorrectly hide the Quick Bind UI for truly unbound sources, and after binding creation the entry’s device_id becomes the alias (so b.device_id === sourceDeviceId will stop matching for the captured event’s port name). Consider resolving by port_name as well as device_id, and define isBound based on sourceBinding.is_configured (and render alias from sourceBinding.device_id).

Copilot uses AI. Check for mistakes.
Comment on lines +67 to +77
try {
await configStore.save({
...cfg,
devices: [...existing, newDevice],
});
await deviceBindingsStore.fetch();
} catch (err) {
bindingError = String(err);
} finally {
bindingInProgress = false;
}
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

handleCreateBinding calls deviceBindingsStore.fetch() immediately after configStore.save(), but the Tauri save_config path relies on the daemon’s ConfigWatcher to hot-reload “within 500ms”. Fetching bindings before the reload completes can return the old binding list, so the UI may stop showing Binding... and still appear unbound. Consider forcing an immediate reload (reload_config), delaying/polling until the new port is reported as is_configured: true, or keeping bindingInProgress true until isBound flips (with a timeout/error).

Copilot uses AI. Check for mistakes.
- 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) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

Comment on lines +79 to +81
// Wait for daemon hot-reload (500ms debounce) before refreshing bindings
await new Promise(r => setTimeout(r, 600));
await deviceBindingsStore.fetch();
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

The fixed setTimeout(600ms) before refreshing bindings is brittle (reload timing can vary; the daemon’s watcher is debounced at 500ms but processing can take longer). This can lead to flaky UI where Quick Bind appears to “not work” on slower machines. Prefer an explicit reload_config call (available via the API) or a bounded retry loop that fetches bindings until the new configured binding appears or a timeout is reached.

Copilot uses AI. Check for mistakes.
await new Promise(r => setTimeout(r, 600));
await deviceBindingsStore.fetch();
} catch (err) {
bindingError = String(err);
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

bindingError = String(err) can easily render as [object Object] (or lose the useful message) depending on what gets thrown. Elsewhere in the UI the pattern is err?.message || String(err); using the same here will provide better, consistent error text.

Suggested change
bindingError = String(err);
bindingError = err?.message || String(err);

Copilot uses AI. Check for mistakes.
Comment on lines +43 to +86
async function handleCreateBinding() {
const cfg = $configStore?.config;
if (!cfg || !sourceDeviceId) {
bindingError = 'Config not loaded yet';
return;
}

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

try {
await configStore.save({
...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);
} finally {
bindingInProgress = false;
}
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

The new inline binding creation flow (alias generation, configStore.save, refresh/error states) isn’t covered by tests yet—current additions only assert that the button/unbound UI renders. Add a component test that clicks Quick Bind with mocked configStore.save/deviceBindingsStore.fetch and asserts the saved config includes the new device entry (unique alias + NameContains matcher), plus a test for the error path (surfacing bindingError).

Copilot uses AI. Check for mistakes.
- 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) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment on lines 33 to +38
$: 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;
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

sourceBinding only checks b.device_id === sourceDeviceId for configured bindings. In get_device_bindings, configured entries have device_id as the alias but also include port_name for the physical port. When sourceDeviceId is the raw/unconfigured id/port name, an already-configured binding won’t be detected and the UI may incorrectly show “Unbound” (and allow creating a duplicate binding). Consider resolving the binding by is_configured && (b.device_id === sourceDeviceId || b.port_name === sourceDeviceId) so both alias- and port-sourced events are handled.

Copilot uses AI. Check for mistakes.
…port)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comment on lines +45 to +48
if (!cfg || !sourceDeviceId) {
bindingError = 'Config not loaded yet';
return;
}
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

The guard conflates two different failure cases (config missing vs. sourceDeviceId missing) into the same error message ('Config not loaded yet'). Consider splitting this so the user gets an accurate message (e.g., 'Config not loaded' vs. 'No source device to bind'), or avoid setting an error at all for the unreachable sourceDeviceId case.

Suggested change
if (!cfg || !sourceDeviceId) {
bindingError = 'Config not loaded yet';
return;
}
if (!cfg) {
bindingError = 'Config not loaded yet';
return;
}
if (!sourceDeviceId) {
bindingError = 'No source device to bind';
return;
}

Copilot uses AI. Check for mistakes.
Comment on lines +43 to +78
async function handleCreateBinding() {
const cfg = $configStore?.config;
if (!cfg || !sourceDeviceId) {
bindingError = 'Config not loaded yet';
return;
}

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

try {
await configStore.save({
...cfg,
devices: [...existing, newDevice],
});
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

This adds a non-trivial async flow (configStore.save + refresh/poll + error/progress UI), but the tests only assert that the unbound UI renders. Add component tests that mock configStore/deviceBindingsStore to verify: clicking Quick Bind calls configStore.save with the expected device entry, the button shows 'Binding...' while in progress, and errors are rendered when save/fetch fails.

Copilot uses AI. Check for mistakes.
- 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) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment on lines +55 to +68
const existing = cfg.devices || [];
const usedAliases = new Set(existing.map(d => d.alias));
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 || [])
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

This duplicates the existing device identity save path (append to config.devices + configStore.save + deviceBindingsStore.fetch) that is already centralized in $lib/utils/device-save.js. Consider reusing saveDeviceConfig(configStore, deviceBindingsStore, 'create', newDevice, cfg) for the save+initial refresh, and keep only the additional polling logic here. This reduces drift risk if the save flow changes (e.g., validation/migration tweaks).

Copilot uses AI. Check for mistakes.

.source-bind-btn:disabled {
opacity: 0.5;
cursor: wait;
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

.source-bind-btn:disabled always uses cursor: wait;, but the button is also disabled when !configReady (config not loaded), not just when bindingInProgress. This makes the UI imply an in-progress operation when it may simply be unavailable. Consider using cursor: not-allowed (or default) for the config-not-ready case, or gate the cursor: wait styling behind an explicit in-progress class/state.

Suggested change
cursor: wait;
cursor: not-allowed;

Copilot uses AI. Check for mistakes.
- 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) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment on lines +11 to +15
export interface DeviceConfig {
alias: string;
matchers: { type: string; value: string }[];
enabled: boolean;
}
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.

DeviceConfig is a very generic exported type name and can be confused with the legacy top-level config’s device settings or other device-related configs. Consider renaming it to something specific like DeviceIdentityConfig/QuickBindDeviceIdentityConfig, or avoid exporting it if it’s only used internally by generateQuickBinding.

Copilot uses AI. Check for mistakes.
bindingInProgress = true;
bindingError = '';

const existing = cfg.devices || [];
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.

cfg.devices may be undefined depending on how the backend serializes device identities. In conductor-core, the field serializes as bindings (with devices as a legacy alias), so reading only cfg.devices can miss existing aliases and lead to collisions/validation errors. Consider normalizing with cfg.bindings || cfg.devices || [] before computing usedAliases (and ensure the saved config uses a consistent key).

Suggested change
const existing = cfg.devices || [];
const existing = cfg.bindings || cfg.devices || [];

Copilot uses AI. Check for mistakes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

Comment on lines +56 to +61
const existing = cfg.devices || cfg.bindings || [];
const usedAliases = new Set(existing.map(d => d.alias));
const newDevice = generateQuickBinding(sourceDeviceId, usedAliases);

try {
await saveDeviceConfig(configStore, deviceBindingsStore, 'create', newDevice, cfg);
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.

saveDeviceConfig() only appends to updatedConfig.devices, but the backend Config serializes device identities under the bindings key (#[serde(rename = "bindings", alias = "devices")] in conductor-core/src/config/types.rs). If cfg came from get_config() it will likely already contain bindings; spreading it and then adding devices will send both keys back to save_config, which can fail deserialization as a duplicate field (or silently pick the wrong one). Consider normalizing to a single key before saving (prefer bindings) or updating saveDeviceConfig to write to bindings when present.

Suggested change
const existing = cfg.devices || cfg.bindings || [];
const usedAliases = new Set(existing.map(d => d.alias));
const newDevice = generateQuickBinding(sourceDeviceId, usedAliases);
try {
await saveDeviceConfig(configStore, deviceBindingsStore, 'create', newDevice, cfg);
const bindingsList = cfg.devices || cfg.bindings || [];
const usedAliases = new Set(bindingsList.map(d => d.alias));
const newDevice = generateQuickBinding(sourceDeviceId, usedAliases);
// Normalize config: use a single key (`devices`) to avoid sending both
let normalizedCfg = { ...cfg, devices: bindingsList };
if ('bindings' in normalizedCfg) {
const { bindings, ...rest } = normalizedCfg;
normalizedCfg = rest;
}
try {
await saveDeviceConfig(configStore, deviceBindingsStore, 'create', newDevice, normalizedCfg);

Copilot uses AI. Check for mistakes.
if (!sourceDeviceId) return; // button shouldn't be visible without sourceDeviceId
const cfg = $configStore?.config;
if (!cfg) {
bindingError = 'Config not loaded yet';
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.

bindingError = 'Config not loaded yet' is unreachable in practice because the button is disabled when !configReady (which is derived from the same $configStore?.config check). Either remove this branch or enable the button and rely on the error message as the user-visible feedback when config isn’t available yet.

Suggested change
bindingError = 'Config not loaded yet';

Copilot uses AI. Check for mistakes.
Comment on lines +291 to +309
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();
});
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.
- 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) <noreply@anthropic.com>
@amiable-dev amiable-dev requested a review from Copilot March 31, 2026 08:50
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment on lines +28 to +31
const base = sourceDeviceId
.split(/[\s_-]+/)[0]
.toLowerCase()
.replace(/[^a-z0-9]/g, '') || 'device';
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.

generateQuickBinding builds aliases differently from the existing auto-alias behavior in DeviceEditor (DeviceEditor uses port.name.split(' ')[0].toLowerCase().replace(/[^a-z0-9]/g, '_'), while this strips separators and removes non-alphanumerics entirely). This can produce overly-generic aliases like usb for USB-MIDI_Device and increases collision risk/inconsistency across the UI. Consider reusing the same normalization rule (or extracting a shared helper) so quick-bind and the editor generate consistent aliases.

Suggested change
const base = sourceDeviceId
.split(/[\s_-]+/)[0]
.toLowerCase()
.replace(/[^a-z0-9]/g, '') || 'device';
const base =
sourceDeviceId
.split(' ')[0]
.toLowerCase()
.replace(/[^a-z0-9]/g, '_') || 'device';

Copilot uses AI. Check for mistakes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@amiable-dev amiable-dev requested a review from Copilot March 31, 2026 10:00
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment on lines +28 to +33
// Match DeviceEditor alias convention: first word, lowercase, non-alphanumeric → underscore
const base = sourceDeviceId
.split(' ')[0]
.toLowerCase()
.replace(/[^a-z0-9]/g, '_')
.replace(/^_+|_+$/g, '') || 'device';
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 comment says this matches the DeviceEditor alias convention, but the normalization here differs (this trims leading/trailing underscores and falls back to 'device', while DeviceEditor’s auto-fill does neither). Either align the logic exactly with DeviceEditor or update the comment to reflect the intentional differences so future changes don’t drift.

Copilot uses AI. Check for mistakes.
Comment on lines +53 to +58
// 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;
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.

const bindingsList = cfg.devices || cfg.bindings || [] will prefer cfg.devices even if it’s an empty array while cfg.bindings contains the real device list, which could lead to creating/saving a config that drops existing bindings. Consider explicitly selecting the non-empty list (or merging / preferring the canonical key) before saving.

Copilot uses AI. Check for mistakes.
- 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) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment on lines +45 to +69
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)
// 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 };
delete normalizedCfg.bindings;

try {
await saveDeviceConfig(configStore, deviceBindingsStore, 'create', newDevice, normalizedCfg);
} catch (err) {
bindingError = err?.message || String(err);
} finally {
bindingInProgress = false;
}
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.

Inline binding creation is now non-trivial (config normalization, save via saveDeviceConfig, error/progress handling), but the added tests only assert the button/UI is present. Add a test that clicks Quick Bind with mocked configStore.save and deviceBindingsStore.fetch to verify the save is invoked, the button disables/shows "Binding...", and that failures surface via bindingError.

Copilot uses AI. Check for mistakes.
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) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

Comment on lines +53 to +61
// 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;
const usedAliases = new Set(bindingsList.map(d => d.alias));
const newDevice = generateQuickBinding(sourceDeviceId, usedAliases);
const normalizedCfg = { ...cfg, devices: bindingsList };
delete normalizedCfg.bindings;
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.

bindingsList selection by comparing cfg.devices.length vs cfg.bindings.length is brittle when both keys are present (can pick a stale list and drop/restore entries). Prefer a deterministic normalization (e.g., if cfg.devices is non-empty use it; else use cfg.bindings; if both are non-empty consider merging by alias or throwing) before saving.

Copilot uses AI. Check for mistakes.
<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}>
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 Quick Bind button can become enabled while deviceBindingsStore is still loading, which can briefly make already-configured sources look unbound and allow creating a duplicate binding. Consider also disabling Quick Bind while $deviceBindingsStore.loading (or gating the unbound UI on bindings being loaded).

Suggested change
<button class="source-bind-btn" on:click={handleCreateBinding} disabled={bindingInProgress || !configReady}>
<button
class="source-bind-btn"
on:click={handleCreateBinding}
disabled={bindingInProgress || !configReady || $deviceBindingsStore.loading}
>

Copilot uses AI. Check for mistakes.
Comment on lines +291 to +299
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');
});
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.
- 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) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

@amiable-dev amiable-dev merged commit 704f380 into main Mar 31, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-022 Phase 3D: Events-to-Binding bridge in RefinementCard (ADR-017 A2.2)

2 participants