From 945f641b7b048d866dde45b377348272d37fd4ba Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 8 Mar 2026 09:18:51 +0000 Subject: [PATCH 01/11] feat(gui): Toast notification system for mapping fires (#487) Add transient workspace toast overlays for mapping fire feedback (ADR-014 Phase 4). New toasts.js store with auto-dismiss, hover-pause, error persistence, coalescence for continuous controls, and max 3 visible. ToastOverlay component mounted in WorkspacePanel with slide-in animation. Integration in pushFiredEvent() gated by toastsEnabled/toastsContinuous. 30 new tests (15 store + 10 component + 5 integration). Closes #487 Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 1 + conductor-gui/ui/src/App.test.ts | 2 + .../lib/components/DeviceStatusPills.test.ts | 2 + .../ui/src/lib/components/EventFilter.test.ts | 2 + .../ui/src/lib/components/EventRow.test.ts | 2 + .../ui/src/lib/components/ToastOverlay.svelte | 146 +++++++++++ .../src/lib/components/ToastOverlay.test.ts | 132 ++++++++++ .../src/lib/panels/EventStreamPanel.test.ts | 2 + .../ui/src/lib/panels/WorkspacePanel.svelte | 3 + .../ui/src/lib/panels/WorkspacePanel.test.ts | 13 + conductor-gui/ui/src/lib/stores/events.js | 40 ++- .../ui/src/lib/stores/events.test.ts | 79 ++++++ conductor-gui/ui/src/lib/stores/toasts.js | 200 +++++++++++++++ .../ui/src/lib/stores/toasts.test.ts | 234 ++++++++++++++++++ 14 files changed, 857 insertions(+), 1 deletion(-) create mode 100644 conductor-gui/ui/src/lib/components/ToastOverlay.svelte create mode 100644 conductor-gui/ui/src/lib/components/ToastOverlay.test.ts create mode 100644 conductor-gui/ui/src/lib/stores/toasts.js create mode 100644 conductor-gui/ui/src/lib/stores/toasts.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 03a8ac5..6badce3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **GUI**: EventFilter Fired toggle and quick filters (#486). Independent "⚡ Fired" toggle for mapping_fired event visibility, quick filter buttons (All/Raw Only/Fired Only), and state consistency guard preventing contradictory filter states. +- **GUI**: Toast notification system for mapping fires (#487, ADR-014 Phase 4). Transient workspace overlays showing trigger→action summaries with auto-dismiss (3s), hover-pause, error persistence, coalescence for continuous controls, max 3 visible. `toastsEnabled`/`toastsContinuous` settings gate toast emission. - **GUI v2 Phase 1 — Layout Shell**: Rewrote `App.svelte` from router-based 8-view layout to three-zone unified workspace (Chat | Workspace | Events). Created `ChatPanel`, `WorkspacePanel`, `EventStreamPanel` panel components. Created `TitleBar` with device/profile dropdowns and settings gear. Created `workspace.js` store (9 workspace views, config diff/MIDI learn action routing) and `events.js` store (ring buffer, filters, app-level Tauri event listener). Added `theme.css` with full navy/indigo CSS variable palette. Updated `StatusBar` with CSS variables and dynamic version. Deleted old `views/` directory and `Sidebar.svelte`. - **GUI v2 Phase 2 — Config Approval Flow**: Replaced modal-based config approval with workspace-integrated diff view. Created `ConfigDiffView` (pending change review with approve/edit/reject buttons, expiration countdown, change descriptions, diff preview), `MappingStateView` (mode tabs + mapping list, compact mode), `MappingRow` (trigger→action display with colored event-type dots and NEW/UPD badges), `DiffBlock` (simplified diff renderer). Created `diff-helpers.ts` utility with `formatTriggerText`, `formatActionText`, `getTriggerDotColor`. Wired LLM plan creation to workspace via `showConfigDiff()`. Non-mapping workspace views show compact mapping list below. **Spec deviation**: Reused existing `llm_apply_plan`/`llm_reject_plan` Rust commands instead of adding 3 new backend commands (same functionality, avoids duplication). - **GUI v2 Phase 3 — MIDI Learn Refinement**: Replaced MIDI Learn modal capture with workspace-integrated refinement card. Created `RefinementCard` (interpretation chips, editable parameter fields, advanced velocity range slider, confirm/relearn/cancel actions), `AlternativeChips` (interpretation selector), `RangeSlider` (dual-thumb velocity range slider), `MidiLearnRefinement` (workspace view container). Created `refinement-helpers.ts` utility with `getAlternativeInterpretations`, `getParameterFields`, `buildTriggerFromParams`. Added `Refining` state to `LearnSessionState` in Rust backend. Wired event selection to workspace refinement via `showMidiLearnRefinement()`. **Spec deviation**: Refinement logic implemented as pure TypeScript functions instead of new Rust Tauri commands — event data from existing `stop_midi_learn` response is sufficient, avoids unnecessary IPC round-trips. diff --git a/conductor-gui/ui/src/App.test.ts b/conductor-gui/ui/src/App.test.ts index f477abc..e567ee5 100644 --- a/conductor-gui/ui/src/App.test.ts +++ b/conductor-gui/ui/src/App.test.ts @@ -108,6 +108,8 @@ vi.mock('$lib/stores/events.js', async () => { initEventListener: vi.fn(() => Promise.resolve(() => {})), restartEventMonitoring: vi.fn(() => Promise.resolve(() => {})), eventFiredFilter: writable(true), + toastsEnabled: writable(true), + toastsContinuous: writable(false), eventMonitorStatus: writable('connecting'), eventStreamPanelVisible: writable(true), }; diff --git a/conductor-gui/ui/src/lib/components/DeviceStatusPills.test.ts b/conductor-gui/ui/src/lib/components/DeviceStatusPills.test.ts index 12d660b..dcf77fa 100644 --- a/conductor-gui/ui/src/lib/components/DeviceStatusPills.test.ts +++ b/conductor-gui/ui/src/lib/components/DeviceStatusPills.test.ts @@ -40,6 +40,8 @@ vi.mock('$lib/stores/events.js', async () => { eventChannelFilter: writable('all'), eventDeviceFilter: mockEventDeviceFilter, eventFiredFilter: writable(true), + toastsEnabled: writable(true), + toastsContinuous: writable(false), eventStreamVisible: writable(true), learnSessionActive: writable(false), autoScroll: writable(true), diff --git a/conductor-gui/ui/src/lib/components/EventFilter.test.ts b/conductor-gui/ui/src/lib/components/EventFilter.test.ts index 5fbfaf2..0627272 100644 --- a/conductor-gui/ui/src/lib/components/EventFilter.test.ts +++ b/conductor-gui/ui/src/lib/components/EventFilter.test.ts @@ -24,6 +24,8 @@ vi.mock('$lib/stores/events.js', async () => { eventFiredFilter: mockEventFiredFilter, eventChannelFilter: writable('all'), eventDeviceFilter: writable('all'), + toastsEnabled: writable(true), + toastsContinuous: writable(false), eventStreamVisible: writable(true), learnSessionActive: writable(false), autoScroll: writable(true), diff --git a/conductor-gui/ui/src/lib/components/EventRow.test.ts b/conductor-gui/ui/src/lib/components/EventRow.test.ts index 15f4aab..f01e8a0 100644 --- a/conductor-gui/ui/src/lib/components/EventRow.test.ts +++ b/conductor-gui/ui/src/lib/components/EventRow.test.ts @@ -32,6 +32,8 @@ vi.mock('$lib/stores/events.js', async () => { eventChannelFilter: writable('all'), eventDeviceFilter: writable('all'), eventFiredFilter: writable(true), + toastsEnabled: writable(true), + toastsContinuous: writable(false), eventStreamVisible: writable(true), learnSessionActive: writable(false), autoScroll: writable(true), diff --git a/conductor-gui/ui/src/lib/components/ToastOverlay.svelte b/conductor-gui/ui/src/lib/components/ToastOverlay.svelte new file mode 100644 index 0000000..071c5d3 --- /dev/null +++ b/conductor-gui/ui/src/lib/components/ToastOverlay.svelte @@ -0,0 +1,146 @@ + + + + + + + +{#if $toastQueue.length > 0} +
+ {#each $toastQueue as toast (toast.id)} + +
pauseToast(toast.id)} + on:mouseleave={() => resumeToast(toast.id)} + > + {#if toast.icon} + {toast.icon} + {/if} +
+
{toast.title}
+
{toast.subtitle}
+
+ +
+ {/each} +
+{/if} + + diff --git a/conductor-gui/ui/src/lib/components/ToastOverlay.test.ts b/conductor-gui/ui/src/lib/components/ToastOverlay.test.ts new file mode 100644 index 0000000..f448012 --- /dev/null +++ b/conductor-gui/ui/src/lib/components/ToastOverlay.test.ts @@ -0,0 +1,132 @@ +/** + * ToastOverlay component tests (ADR-014 Phase 4) + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/svelte'; + +const mockToastQueue = vi.hoisted(() => { + const { writable } = require('svelte/store'); + return writable([]); +}); + +const mockDismissToast = vi.fn(); +const mockPauseToast = vi.fn(); +const mockResumeToast = vi.fn(); + +vi.mock('$lib/stores/toasts.js', () => ({ + toastQueue: mockToastQueue, + dismissToast: mockDismissToast, + pauseToast: mockPauseToast, + resumeToast: mockResumeToast, +})); + +describe('ToastOverlay', () => { + afterEach(() => { + cleanup(); + mockToastQueue.set([]); + mockDismissToast.mockClear(); + mockPauseToast.mockClear(); + mockResumeToast.mockClear(); + }); + + async function importAndRender() { + const ToastOverlay = (await import('./ToastOverlay.svelte')).default; + return render(ToastOverlay); + } + + it('renders nothing when queue is empty', async () => { + const { container } = await importAndRender(); + const toasts = container.querySelectorAll('.toast'); + expect(toasts.length).toBe(0); + }); + + it('renders toast items from queue', async () => { + mockToastQueue.set([ + { id: '1', title: 'Note 36 → Keystroke', subtitle: '⌘+C sent', type: 'success', icon: '🎵', ttl: 3000, timestamp: Date.now() }, + { id: '2', title: 'CC 7 → Volume', subtitle: 'vol=80', type: 'success', icon: '🎛', ttl: 3000, timestamp: Date.now() }, + ]); + const { container } = await importAndRender(); + const toasts = container.querySelectorAll('.toast'); + expect(toasts.length).toBe(2); + }); + + it('displays title and subtitle', async () => { + mockToastQueue.set([ + { id: '1', title: 'Note 36 → Keystroke', subtitle: '⌘+C sent', type: 'success', icon: '🎵', ttl: 3000, timestamp: Date.now() }, + ]); + await importAndRender(); + expect(screen.getByText('Note 36 → Keystroke')).toBeTruthy(); + expect(screen.getByText('⌘+C sent')).toBeTruthy(); + }); + + it('displays icon', async () => { + mockToastQueue.set([ + { id: '1', title: 'test', subtitle: 'sub', type: 'success', icon: '🎵', ttl: 3000, timestamp: Date.now() }, + ]); + const { container } = await importAndRender(); + const icon = container.querySelector('.toast-icon'); + expect(icon).toBeTruthy(); + expect(icon.textContent).toContain('🎵'); + }); + + it('renders dismiss button', async () => { + mockToastQueue.set([ + { id: '1', title: 'test', subtitle: 'sub', type: 'success', icon: '🎵', ttl: 3000, timestamp: Date.now() }, + ]); + const { container } = await importAndRender(); + const dismissBtn = container.querySelector('.toast-dismiss'); + expect(dismissBtn).toBeTruthy(); + }); + + it('clicking dismiss calls dismissToast with correct id', async () => { + mockToastQueue.set([ + { id: 'abc', title: 'test', subtitle: 'sub', type: 'success', icon: '🎵', ttl: 3000, timestamp: Date.now() }, + ]); + const { container } = await importAndRender(); + const dismissBtn = container.querySelector('.toast-dismiss'); + await fireEvent.click(dismissBtn); + expect(mockDismissToast).toHaveBeenCalledWith('abc'); + }); + + it('error toast has error class', async () => { + mockToastQueue.set([ + { id: '1', title: 'error', subtitle: 'failed', type: 'error', icon: '❌', ttl: 3000, timestamp: Date.now() }, + ]); + const { container } = await importAndRender(); + const toast = container.querySelector('.toast'); + expect(toast.classList.contains('error')).toBe(true); + }); + + it('success toast has success class', async () => { + mockToastQueue.set([ + { id: '1', title: 'ok', subtitle: 'done', type: 'success', icon: '✓', ttl: 3000, timestamp: Date.now() }, + ]); + const { container } = await importAndRender(); + const toast = container.querySelector('.toast'); + expect(toast.classList.contains('success')).toBe(true); + }); + + it('mouseenter calls pauseToast, mouseleave calls resumeToast', async () => { + mockToastQueue.set([ + { id: 'hover-test', title: 'test', subtitle: 'sub', type: 'success', icon: '🎵', ttl: 3000, timestamp: Date.now() }, + ]); + const { container } = await importAndRender(); + const toast = container.querySelector('.toast'); + + await fireEvent.mouseEnter(toast); + expect(mockPauseToast).toHaveBeenCalledWith('hover-test'); + + await fireEvent.mouseLeave(toast); + expect(mockResumeToast).toHaveBeenCalledWith('hover-test'); + }); + + it('has toast-container with proper positioning class', async () => { + mockToastQueue.set([ + { id: '1', title: 'test', subtitle: 'sub', type: 'success', icon: '🎵', ttl: 3000, timestamp: Date.now() }, + ]); + const { container } = await importAndRender(); + const overlay = container.querySelector('.toast-container'); + expect(overlay).toBeTruthy(); + }); +}); diff --git a/conductor-gui/ui/src/lib/panels/EventStreamPanel.test.ts b/conductor-gui/ui/src/lib/panels/EventStreamPanel.test.ts index 84385e0..cc9f5b3 100644 --- a/conductor-gui/ui/src/lib/panels/EventStreamPanel.test.ts +++ b/conductor-gui/ui/src/lib/panels/EventStreamPanel.test.ts @@ -41,6 +41,8 @@ vi.mock('$lib/stores/events.js', () => { eventDeviceFilter: writable('all'), eventBuffer: writable([]), eventFiredFilter: writable(true), + toastsEnabled: writable(true), + toastsContinuous: writable(false), eventStreamVisible: writable(true), pushEvent: vi.fn(), pushEvents: vi.fn(), diff --git a/conductor-gui/ui/src/lib/panels/WorkspacePanel.svelte b/conductor-gui/ui/src/lib/panels/WorkspacePanel.svelte index 0e3148e..88ae72a 100644 --- a/conductor-gui/ui/src/lib/panels/WorkspacePanel.svelte +++ b/conductor-gui/ui/src/lib/panels/WorkspacePanel.svelte @@ -22,6 +22,7 @@ import DeviceSettingsView from '$lib/workspace/DeviceSettingsView.svelte'; import RawConfigView from '$lib/workspace/RawConfigView.svelte'; import ConfigHistoryView from '$lib/workspace/ConfigHistoryView.svelte'; + import ToastOverlay from '$lib/components/ToastOverlay.svelte'; let showNav = false; @@ -134,10 +135,12 @@ {/if} + diff --git a/conductor-gui/ui/src/lib/components/EventRow.test.ts b/conductor-gui/ui/src/lib/components/EventRow.test.ts index f01e8a0..2d9d70f 100644 --- a/conductor-gui/ui/src/lib/components/EventRow.test.ts +++ b/conductor-gui/ui/src/lib/components/EventRow.test.ts @@ -1,5 +1,5 @@ /** - * EventRow tests (GUI v2 Phase 4 + ADR-014 Phase 3A) + * EventRow tests (GUI v2 Phase 4 + ADR-014) */ import { describe, it, expect, vi, afterEach } from 'vitest'; @@ -21,6 +21,24 @@ vi.mock('$lib/utils/event-helpers', () => ({ if (t === 'encoder') return 'encoder'; return 'unknown'; }, + getFiredTriggerLabel: (e) => { + if (typeof e !== 'object' || e === null || e._type !== 'mapping_fired') return '?'; + const t = e?.trigger; + const type = t?.type ?? '?'; + const num = t?.number; + const label = type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + return num != null ? `${label} ${num}` : label; + }, + getFiredActionLabel: (e) => { + if (typeof e !== 'object' || e === null || e._type !== 'mapping_fired') return '?'; + return e?.action?.summary ?? e?.action?.type ?? 'Action'; + }, + getFiredResultLabel: (e) => { + if (typeof e !== 'object' || e === null || e._type !== 'mapping_fired') return { text: '?', isError: false }; + const isError = e?.result === 'error' || !!e?.error; + if (isError) return { text: `✗ ${e.error || 'fail'}`, isError: true }; + return { text: `✓ ${e.result || 'OK'}`, isError: false }; + }, })); vi.mock('$lib/stores/events.js', async () => { @@ -79,7 +97,7 @@ describe('EventRow', () => { return render(EventRow, { props }); } - // --- Existing functionality --- + // --- Raw event functionality --- it('renders event type label', async () => { await importAndRender({ event: RAW_EVENT }); @@ -118,7 +136,7 @@ describe('EventRow', () => { await fireEvent.click(btn); }); - // --- Expand/Collapse (ADR-014 Phase 3A) --- + // --- Expand/Collapse --- it('starts collapsed by default', async () => { const { container } = await importAndRender({ event: RAW_EVENT }); @@ -129,7 +147,6 @@ describe('EventRow', () => { it('expands on click to show detail panel', async () => { const { container } = await importAndRender({ event: RAW_EVENT }); const toggle = container.querySelector('.event-toggle')!; - expect(toggle).toBeTruthy(); await fireEvent.click(toggle); expect(container.querySelector('.event-row.expanded')).toBeTruthy(); expect(container.querySelector('.event-detail-panel')).toBeTruthy(); @@ -138,14 +155,13 @@ describe('EventRow', () => { it('collapses on second click', async () => { const { container } = await importAndRender({ event: RAW_EVENT }); const toggle = container.querySelector('.event-toggle')!; - expect(toggle).toBeTruthy(); await fireEvent.click(toggle); expect(container.querySelector('.expanded')).toBeTruthy(); await fireEvent.click(toggle); expect(container.querySelector('.expanded')).toBeNull(); }); - it('shows chevron element', async () => { + it('shows chevron element for raw events', async () => { const { container } = await importAndRender({ event: RAW_EVENT }); const chevron = container.querySelector('.event-chevron'); expect(chevron).toBeTruthy(); @@ -157,7 +173,6 @@ describe('EventRow', () => { const toggle = container.querySelector('.event-toggle')!; await fireEvent.click(toggle); const chevron = container.querySelector('.event-chevron')!; - expect(chevron).toBeTruthy(); expect(chevron.textContent).toBe('▾'); }); @@ -165,7 +180,6 @@ describe('EventRow', () => { const { container } = await importAndRender({ event: RAW_EVENT }); await fireEvent.click(container.querySelector('.event-toggle')!); const panel = container.querySelector('.event-detail-panel')!; - expect(panel).toBeTruthy(); expect(panel.textContent).toContain('Device'); expect(panel.textContent).toContain('Mikro'); }); @@ -174,7 +188,6 @@ describe('EventRow', () => { const { container } = await importAndRender({ event: RAW_EVENT }); await fireEvent.click(container.querySelector('.event-toggle')!); const panel = container.querySelector('.event-detail-panel')!; - expect(panel).toBeTruthy(); expect(panel.textContent).toContain('Channel'); expect(panel.textContent).toContain('1'); }); @@ -183,50 +196,81 @@ describe('EventRow', () => { const { container } = await importAndRender({ event: RAW_EVENT }); await fireEvent.click(container.querySelector('.event-toggle')!); const panel = container.querySelector('.event-detail-panel')!; - expect(panel).toBeTruthy(); expect(panel.textContent).toContain('Time'); expect(panel.textContent).toContain('12:34:56.789'); }); - // --- Fired event styling (ADR-014 Phase 3A) --- + // --- Fired event styling (Option D mockup) --- - it('renders fired icon ⚡ for mapping_fired events', async () => { + it('renders colored dot (not emoji) for fired events', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); - const icon = container.querySelector('.event-fired-icon')!; - expect(icon).toBeTruthy(); - expect(icon.textContent).toBe('⚡'); + const dot = container.querySelector('.event-dot.note'); + expect(dot).toBeTruthy(); + // No emoji icon + expect(container.querySelector('.event-fired-icon')).toBeNull(); }); - it('does not render standard dot for fired events', async () => { + it('renders "Fired" label with fired-type class', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); - expect(container.querySelector('.event-dot')).toBeNull(); + const firedType = container.querySelector('.fired-type'); + expect(firedType).toBeTruthy(); + expect(firedType.textContent).toBe('Fired'); }); - it('renders "Fired" label for mapping_fired events', async () => { + it('uses mapping-fired class on event-row for fired events', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); - const typeLabel = container.querySelector('.event-type')!; - expect(typeLabel).toBeTruthy(); - expect(typeLabel.textContent).toBe('Fired'); + expect(container.querySelector('.mapping-fired')).toBeTruthy(); }); - it('applies fired class on event-row for fired events', async () => { + it('renders structured trigger → action content', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT }); + const trigger = container.querySelector('.fired-trigger'); + const action = container.querySelector('.fired-action'); + const arrow = container.querySelector('.fired-arrow'); + expect(trigger).toBeTruthy(); + expect(trigger.textContent).toBe('Note 36'); + expect(arrow).toBeTruthy(); + expect(arrow.textContent).toBe('→'); + expect(action).toBeTruthy(); + expect(action.textContent).toBe('⌘+C'); + }); + + it('renders result pill for fired events', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); - expect(container.querySelector('.event-row.fired')).toBeTruthy(); + const result = container.querySelector('.fired-result'); + expect(result).toBeTruthy(); + expect(result.textContent).toBe('✓ ok'); + expect(result.classList.contains('ok')).toBe(true); }); - it('shows trigger→action content for fired events', async () => { - const { container } = await importAndRender({ event: FIRED_EVENT }); - const detail = container.querySelector('.event-detail')!; - expect(detail).toBeTruthy(); - expect(detail.textContent).toContain('note'); - expect(detail.textContent).toContain('⌘+C'); + it('renders error result pill for error events', async () => { + const errorEvent = { ...FIRED_EVENT, result: 'error', error: 'App not found' }; + const { container } = await importAndRender({ event: errorEvent }); + const result = container.querySelector('.fired-result'); + expect(result).toBeTruthy(); + expect(result.textContent).toBe('✗ App not found'); + expect(result.classList.contains('err')).toBe(true); }); - it('has left border colored by trigger type for fired events', async () => { + it('applies action-failed class for error fired events', async () => { + const errorEvent = { ...FIRED_EVENT, result: 'error', error: 'App not found' }; + const { container } = await importAndRender({ event: errorEvent }); + expect(container.querySelector('.mapping-fired.action-failed')).toBeTruthy(); + }); + + it('has green background for success fired events', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); - const row = container.querySelector('.event-row.fired'); + const row = container.querySelector('.mapping-fired'); expect(row).toBeTruthy(); - // The fired class presence implies border styling via CSS + // mapping-fired class implies green-08 background via CSS + expect(row.classList.contains('action-failed')).toBe(false); + }); + + it('shows expand hint for fired events', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT }); + const hint = container.querySelector('.fired-expand-hint'); + expect(hint).toBeTruthy(); + expect(hint.textContent).toBe('▸'); }); // --- Fired event detail panel --- @@ -234,7 +278,7 @@ describe('EventRow', () => { it('fired detail panel shows trigger info', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); await fireEvent.click(container.querySelector('.event-toggle')!); - const panel = container.querySelector('.event-detail-panel')!; + const panel = container.querySelector('.fired-detail-panel')!; expect(panel).toBeTruthy(); expect(panel.textContent).toContain('Trigger'); expect(panel.textContent).toContain('note'); @@ -244,8 +288,7 @@ describe('EventRow', () => { it('fired detail panel shows action info', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); await fireEvent.click(container.querySelector('.event-toggle')!); - const panel = container.querySelector('.event-detail-panel')!; - expect(panel).toBeTruthy(); + const panel = container.querySelector('.fired-detail-panel')!; expect(panel.textContent).toContain('Action'); expect(panel.textContent).toContain('⌘+C'); }); @@ -253,8 +296,7 @@ describe('EventRow', () => { it('fired detail panel shows result', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); await fireEvent.click(container.querySelector('.event-toggle')!); - const panel = container.querySelector('.event-detail-panel')!; - expect(panel).toBeTruthy(); + const panel = container.querySelector('.fired-detail-panel')!; expect(panel.textContent).toContain('Result'); expect(panel.textContent).toContain('ok'); }); @@ -262,8 +304,7 @@ describe('EventRow', () => { it('fired detail panel shows latency', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); await fireEvent.click(container.querySelector('.event-toggle')!); - const panel = container.querySelector('.event-detail-panel')!; - expect(panel).toBeTruthy(); + const panel = container.querySelector('.fired-detail-panel')!; expect(panel.textContent).toContain('Latency'); expect(panel.textContent).toContain('0.5ms'); }); @@ -271,23 +312,16 @@ describe('EventRow', () => { it('fired detail panel shows mode', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); await fireEvent.click(container.querySelector('.event-toggle')!); - const panel = container.querySelector('.event-detail-panel')!; - expect(panel).toBeTruthy(); + const panel = container.querySelector('.fired-detail-panel')!; expect(panel.textContent).toContain('Mode'); expect(panel.textContent).toContain('Default'); }); it('fired detail panel shows error message when result is error', async () => { - const errorEvent = { - ...FIRED_EVENT, - result: 'error', - error: 'App not found', - }; + const errorEvent = { ...FIRED_EVENT, result: 'error', error: 'App not found' }; const { container } = await importAndRender({ event: errorEvent }); await fireEvent.click(container.querySelector('.event-toggle')!); - const panel = container.querySelector('.event-detail-panel')!; - expect(panel).toBeTruthy(); - expect(panel.textContent).toContain('error'); + const panel = container.querySelector('.fired-detail-panel')!; expect(panel.textContent).toContain('App not found'); }); @@ -295,44 +329,40 @@ describe('EventRow', () => { const { container } = await importAndRender({ event: RAW_EVENT, showLearnBtn: true }); const btn = screen.getByText('Learn'); await fireEvent.click(btn); - // stopPropagation should prevent expand expect(container.querySelector('.expanded')).toBeNull(); }); - // --- Keyboard accessibility (native - {#if showLearnBtn} - - {/if} - {#if expanded}
Trigger {event.trigger?.type ?? '?'} #{event.trigger?.number ?? '?'} v={event.trigger?.value ?? '?'}{event.trigger?.device ? ` (${event.trigger.device})` : ''}
@@ -300,8 +296,27 @@ .mapping-fired .event-toggle { font-size: var(--font-size-base); + padding-left: 12px; /* compensate for 2px border-left to align with raw rows */ } + /* ─── Fired icon (⚡ colored by trigger type) ─── */ + .event-fired-icon { + font-size: 8px; + flex-shrink: 0; + line-height: 1; + width: 5px; /* match .event-dot width so "Fired" aligns with "Note"/"CC" */ + overflow: visible; + text-align: center; + } + + .event-fired-icon.note { color: var(--green); } + .event-fired-icon.cc { color: var(--blue); } + .event-fired-icon.aftertouch { color: var(--amber); } + .event-fired-icon.pitchbend, + .event-fired-icon.encoder { color: var(--purple); } + .event-fired-icon.gamepad { color: var(--event-gamepad, var(--gray)); } + .event-fired-icon.unknown { color: var(--text-dim); } + .fired-type { min-width: 55px; font-weight: 600; diff --git a/conductor-gui/ui/src/lib/components/EventRow.test.ts b/conductor-gui/ui/src/lib/components/EventRow.test.ts index 2d9d70f..9fbb2e4 100644 --- a/conductor-gui/ui/src/lib/components/EventRow.test.ts +++ b/conductor-gui/ui/src/lib/components/EventRow.test.ts @@ -202,12 +202,13 @@ describe('EventRow', () => { // --- Fired event styling (Option D mockup) --- - it('renders colored dot (not emoji) for fired events', async () => { + it('renders colored lightning bolt icon for fired events', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); - const dot = container.querySelector('.event-dot.note'); - expect(dot).toBeTruthy(); - // No emoji icon - expect(container.querySelector('.event-fired-icon')).toBeNull(); + const icon = container.querySelector('.event-fired-icon.note'); + expect(icon).toBeTruthy(); + expect(icon.textContent).toBe('⚡'); + // No plain dot for fired events + expect(container.querySelector('.event-dot')).toBeNull(); }); it('renders "Fired" label with fired-type class', async () => { @@ -367,4 +368,16 @@ describe('EventRow', () => { await fireEvent.click(toggle); expect(toggle.getAttribute('aria-expanded')).toBe('true'); }); + + // --- #528: Learn button suppressed on fired events --- + + it('does not show Learn button on fired events even when showLearnBtn is true', async () => { + await importAndRender({ event: FIRED_EVENT, showLearnBtn: true }); + expect(screen.queryByText('Learn')).toBeNull(); + }); + + it('still shows Learn button on raw events when showLearnBtn is true', async () => { + await importAndRender({ event: RAW_EVENT, showLearnBtn: true }); + expect(screen.getByText('Learn')).toBeTruthy(); + }); }); From 27f4e69d7636da37cab346ad2809adae687a255e Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 9 Mar 2026 16:16:16 +0000 Subject: [PATCH 08/11] fix(gui): Simplify chat input placeholder text Change placeholder from "Ask me about configuring your MIDI controller..." to "Ask Conductor..." for brevity and consistency with product naming. Co-Authored-By: Claude Opus 4.6 --- conductor-gui/ui/src/lib/views/ChatView.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conductor-gui/ui/src/lib/views/ChatView.svelte b/conductor-gui/ui/src/lib/views/ChatView.svelte index 4d9e4f9..66a7fa5 100644 --- a/conductor-gui/ui/src/lib/views/ChatView.svelte +++ b/conductor-gui/ui/src/lib/views/ChatView.svelte @@ -523,7 +523,7 @@ value={inputValue} on:input={handleInputChange} on:keydown={handleKeyDown} - placeholder="Ask me about configuring your MIDI controller..." + placeholder="Ask Conductor..." rows="1" disabled={$isChatProcessing} > From 56cd46525e9f38b1a57df7da50bf711e4ed7e60d Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 9 Mar 2026 16:36:57 +0000 Subject: [PATCH 09/11] fix(gui): Address PR review feedback and align fired event rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix result_short → result field in toast payload (field doesn't exist) - Fix encoder/gamepad dot class distinction (number >= 128 = gamepad) - Always render aria-live container for screen reader compatibility - Add focus-visible rule on toast dismiss for keyboard accessibility - Replace !important with combined CSS selector for specificity - Remove duplicate error message in fired event detail panel - Fix misleading docstring in getFiredResultLabel - Make Learn button absolutely positioned to fix time/chevron alignment between fired and raw event rows - Increase MAX_VISIBLE toasts from 3 to 5 - Add 18 unit tests for getFiredTriggerLabel/ActionLabel/ResultLabel - Update toast eviction test for new MAX_VISIBLE value Co-Authored-By: Claude Opus 4.6 --- .../ui/src/lib/components/EventRow.svelte | 70 +++++++-------- .../ui/src/lib/components/EventRow.test.ts | 13 ++- .../ui/src/lib/components/ToastOverlay.svelte | 63 +++++++------- .../src/lib/components/ToastOverlay.test.ts | 9 +- conductor-gui/ui/src/lib/stores/events.js | 15 +++- conductor-gui/ui/src/lib/stores/toasts.js | 2 +- .../ui/src/lib/stores/toasts.test.ts | 10 ++- .../ui/src/lib/utils/event-helpers.test.ts | 87 +++++++++++++++++++ .../ui/src/lib/utils/event-helpers.ts | 2 +- 9 files changed, 185 insertions(+), 86 deletions(-) diff --git a/conductor-gui/ui/src/lib/components/EventRow.svelte b/conductor-gui/ui/src/lib/components/EventRow.svelte index 837685c..cd207c0 100644 --- a/conductor-gui/ui/src/lib/components/EventRow.svelte +++ b/conductor-gui/ui/src/lib/components/EventRow.svelte @@ -55,29 +55,32 @@ class:expanded class:action-failed={firedIsError} > - +
+ +
{#if expanded}
Trigger {event.trigger?.type ?? '?'} #{event.trigger?.number ?? '?'} v={event.trigger?.value ?? '?'}{event.trigger?.device ? ` (${event.trigger.device})` : ''}
Action {event.action?.type ?? '?'}: {event.action?.summary ?? '?'}
-
Result {firedResult?.text ?? '?'}{event.error ? ` — ${event.error}` : ''}
+
Result {firedResult?.text ?? '?'}
Latency {event.latency_us != null ? formatLatency(event.latency_us) : '—'}
Mode {event.mode ?? '?'}
Time {formatAbsoluteTime(event.timestamp)}
@@ -142,6 +145,7 @@ } .event-main { + position: relative; display: flex; align-items: center; gap: 0; @@ -240,10 +244,13 @@ } .event-learn-btn { + position: absolute; + right: 60px; + top: 50%; + transform: translateY(-50%); opacity: 0; font-size: 9px; padding: 1px 6px; - margin-right: 14px; border-radius: 4px; background: var(--accent); color: var(--text-on-accent); @@ -251,7 +258,7 @@ cursor: pointer; font-family: inherit; transition: opacity 0.15s; - flex-shrink: 0; + z-index: 1; } .event-row:hover .event-learn-btn, @@ -278,7 +285,6 @@ background: var(--green-08, rgba(78, 204, 163, 0.08)); border-left: 2px solid var(--green); cursor: pointer; - flex-wrap: wrap; } .mapping-fired:hover { @@ -320,13 +326,13 @@ .fired-type { min-width: 55px; font-weight: 600; - color: var(--green); + color: var(--accent); font-size: var(--font-size-base); flex-shrink: 0; } .fired-type.failed { - color: var(--accent); + color: var(--text-dim); } .fired-summary { @@ -376,22 +382,6 @@ color: var(--accent); } - .fired-expand-hint { - font-size: 8px; - color: var(--text-dim); - opacity: 0; - flex-shrink: 0; - transition: opacity 0.15s; - margin-left: 4px; - } - - .mapping-fired:hover .fired-expand-hint { - opacity: 0.6; - } - - .mapping-fired.expanded .fired-expand-hint { - opacity: 0.6; - } /* ─── Fired detail panel ─── */ .fired-detail-panel { diff --git a/conductor-gui/ui/src/lib/components/EventRow.test.ts b/conductor-gui/ui/src/lib/components/EventRow.test.ts index 9fbb2e4..80b27e6 100644 --- a/conductor-gui/ui/src/lib/components/EventRow.test.ts +++ b/conductor-gui/ui/src/lib/components/EventRow.test.ts @@ -267,11 +267,16 @@ describe('EventRow', () => { expect(row.classList.contains('action-failed')).toBe(false); }); - it('shows expand hint for fired events', async () => { + it('shows chevron for fired events (same as raw)', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); - const hint = container.querySelector('.fired-expand-hint'); - expect(hint).toBeTruthy(); - expect(hint.textContent).toBe('▸'); + const chevron = container.querySelector('.event-chevron'); + expect(chevron).toBeTruthy(); + expect(chevron.textContent).toBe('▸'); + }); + + it('shows relative time for fired events', async () => { + await importAndRender({ event: FIRED_EVENT }); + expect(screen.getByText('now')).toBeTruthy(); }); // --- Fired event detail panel --- diff --git a/conductor-gui/ui/src/lib/components/ToastOverlay.svelte b/conductor-gui/ui/src/lib/components/ToastOverlay.svelte index fe91759..86d3107 100644 --- a/conductor-gui/ui/src/lib/components/ToastOverlay.svelte +++ b/conductor-gui/ui/src/lib/components/ToastOverlay.svelte @@ -16,34 +16,32 @@ import { toastQueue, dismissToast, pauseToast, resumeToast } from '$lib/stores/toasts.js'; -{#if $toastQueue.length > 0} -
- {#each $toastQueue as toast (toast.id)} - -
pauseToast(toast.id)} - on:mouseleave={() => resumeToast(toast.id)} - > - - {toast.trigger} - - {toast.action} - {#if toast.type === 'error'} - {toast.result} - {:else} - {toast.result} - {/if} - -
- {/each} -
-{/if} +
+ {#each $toastQueue as toast (toast.id)} + +
pauseToast(toast.id)} + on:mouseleave={() => resumeToast(toast.id)} + > + + {toast.trigger} + + {toast.action} + {#if toast.type === 'error'} + {toast.result} + {:else} + {toast.result} + {/if} + +
+ {/each} +