diff --git a/CHANGELOG.md b/CHANGELOG.md index 8308042..2f94483 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,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 5 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.svelte b/conductor-gui/ui/src/lib/components/EventRow.svelte index eb64d52..274f352 100644 --- a/conductor-gui/ui/src/lib/components/EventRow.svelte +++ b/conductor-gui/ui/src/lib/components/EventRow.svelte @@ -2,11 +2,14 @@ -
-
- - {#if showLearnBtn} - +{#if fired && !highlight} + +
+
+ +
+ {#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 ?? '?'}
+
Latency {event.latency_us != null ? formatLatency(event.latency_us) : '—'}
+
Mode {event.mode ?? '?'}
+
Time {formatAbsoluteTime(event.timestamp)}
+
{/if}
- - {#if expanded} -
- {#if fired} -
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 {event.result ?? '?'}{event.error ? ` — ${event.error}` : ''}
-
Latency {event.latency_us != null ? formatLatency(event.latency_us) : '—'}
-
Mode {event.mode ?? '?'}
-
Time {formatAbsoluteTime(event.timestamp)}
- {:else} -
Device {event.device_id ?? 'Unknown'}
-
Channel {event.channel ?? '—'}
-
Type {event.event_type}
-
Time {formatAbsoluteTime(event.timestamp)}
+{:else} + +
+
+ + {#if showLearnBtn} + {/if}
- {/if} -
+ + {#if expanded} +
+
Device {event.device_id ?? 'Unknown'}
+
Channel {event.channel ?? '—'}
+
Type {event.event_type}
+
Time {formatAbsoluteTime(event.timestamp)}
+
+ {/if} +
+{/if} diff --git a/conductor-gui/ui/src/lib/components/EventRow.test.ts b/conductor-gui/ui/src/lib/components/EventRow.test.ts index 15f4aab..80b27e6 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 () => { @@ -32,6 +50,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), @@ -77,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 }); @@ -116,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 }); @@ -127,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(); @@ -136,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(); @@ -155,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('▾'); }); @@ -163,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'); }); @@ -172,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'); }); @@ -181,50 +196,87 @@ 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 lightning bolt icon for fired events', async () => { const { container } = await importAndRender({ event: FIRED_EVENT }); - const icon = container.querySelector('.event-fired-icon')!; + 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('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 }); - expect(container.querySelector('.event-row.fired')).toBeTruthy(); + 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 }); + 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 chevron for fired events (same as raw)', async () => { + const { container } = await importAndRender({ event: FIRED_EVENT }); + 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 --- @@ -232,7 +284,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'); @@ -242,8 +294,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'); }); @@ -251,8 +302,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'); }); @@ -260,8 +310,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'); }); @@ -269,23 +318,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'); }); @@ -293,46 +335,54 @@ 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 +
+ {/each} +
+ + 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..1f02fef --- /dev/null +++ b/conductor-gui/ui/src/lib/components/ToastOverlay.test.ts @@ -0,0 +1,147 @@ +/** + * 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); + } + + function makeToast(overrides = {}) { + return { + id: '1', + trigger: 'Note 36', + action: 'Keystroke', + result: 'OK', + type: 'success', + dotClass: 'note', + ttl: 3000, + timestamp: Date.now(), + ...overrides, + }; + } + + it('renders empty container with aria-live when queue is empty', async () => { + const { container } = await importAndRender(); + const toastContainer = container.querySelector('.toast-container'); + expect(toastContainer).toBeTruthy(); + expect(toastContainer.getAttribute('aria-live')).toBe('polite'); + expect(toastContainer.getAttribute('role')).toBe('status'); + expect(container.querySelectorAll('.toast').length).toBe(0); + }); + + it('renders toast items from queue', async () => { + mockToastQueue.set([ + makeToast({ id: '1', trigger: 'Note 36', action: 'Keystroke' }), + makeToast({ id: '2', trigger: 'CC 7', action: 'Volume', dotClass: 'cc' }), + ]); + const { container } = await importAndRender(); + const toasts = container.querySelectorAll('.toast'); + expect(toasts.length).toBe(2); + }); + + it('displays trigger, action, and result as separate spans', async () => { + mockToastQueue.set([ + makeToast({ trigger: 'DoubleTap 87', action: 'Launch Ableton', result: 'opened' }), + ]); + await importAndRender(); + expect(screen.getByText('DoubleTap 87')).toBeTruthy(); + expect(screen.getByText('Launch Ableton')).toBeTruthy(); + expect(screen.getByText('opened')).toBeTruthy(); + }); + + it('renders colored dot matching event type', async () => { + mockToastQueue.set([makeToast({ dotClass: 'cc' })]); + const { container } = await importAndRender(); + const dot = container.querySelector('.toast-dot'); + expect(dot).toBeTruthy(); + expect(dot.style.background).toContain('--event-cc'); + }); + + it('renders arrow separator between trigger and action', async () => { + mockToastQueue.set([makeToast()]); + const { container } = await importAndRender(); + const arrow = container.querySelector('.toast-arrow'); + expect(arrow).toBeTruthy(); + }); + + it('renders result pill with ok class for success', async () => { + mockToastQueue.set([makeToast({ result: 'opened' })]); + const { container } = await importAndRender(); + const result = container.querySelector('.toast-result'); + expect(result).toBeTruthy(); + expect(result.textContent).toBe('opened'); + expect(result.classList.contains('ok')).toBe(true); + }); + + it('renders result pill with err class for error', async () => { + mockToastQueue.set([makeToast({ type: 'error', result: 'fail' })]); + const { container } = await importAndRender(); + const result = container.querySelector('.toast-result'); + expect(result).toBeTruthy(); + expect(result.textContent).toBe('fail'); + expect(result.classList.contains('err')).toBe(true); + }); + + it('clicking dismiss calls dismissToast with correct id', async () => { + mockToastQueue.set([makeToast({ id: 'abc' })]); + 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([makeToast({ type: 'error', result: 'fail' })]); + const { container } = await importAndRender(); + const toast = container.querySelector('.toast'); + expect(toast.classList.contains('error')).toBe(true); + }); + + it('mouseenter calls pauseToast, mouseleave calls resumeToast', async () => { + mockToastQueue.set([makeToast({ id: 'hover-test' })]); + 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('container is centered with column-reverse stacking', async () => { + mockToastQueue.set([makeToast()]); + 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..5b21bdd 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,14 +135,15 @@ {/if} +