Skip to main content

ADR-019: Unified Workspace Filtering & Aggregate Metrics

Status

Draft (For Discussion)

Context

Problem Statement

Conductor's GUI v2 has two panels that filter the same underlying signal data — the Events sidebar and the Signal Flow workspace — but their filter implementations are entirely independent. This creates three concrete problems:

  1. Duplicated UI chrome: Both panels render device filter chips (DeviceStatusPills + Signal Flow device chips) and event type filters (EventFilter type chips + Signal Flow type chips). On a 1440px-wide screen with Chat, Workspace, and Events panels visible, the combined filter surface area consumes ~120px of vertical space across two locations.

  2. Inconsistent state: Clicking "Mikro MK3" in Signal Flow does not update the Events sidebar, and vice versa. Users debugging a device must click the same device in two places, or accept that one panel shows unrelated data.

  3. Missing aggregate metrics persistence: The V3 Signal Flow compact mode introduces aggregate metrics (mapping counts, unmapped counts, fire rates, type distribution) that require rolling-window computation. No persistence strategy exists for these metrics — they are currently described only as ephemeral UI state, but they overlap heavily with ADR-015's T2 Signal Pulse data that is computed for LLM context injection.

Current Filter Inventory

Events sidebar (EventStreamPanel.svelte):

FilterStoreValuesScope
DeviceeventDeviceFilter (writable)'all' | device ID stringSingle-select toggle
TypeeventTypeFilter (writable)'all' | 'cc' | 'note' | 'bend' | 'aftertouch' | 'fired'Single-select chips
ChanneleventChannelFilter (writable)'all' | '1''16'Dropdown
FiredeventFiredFilter (writable)booleanToggle
Quickderived from type + fired'all' | 'raw' | 'fired'Composite convenience

Signal Flow V3 (proposed in mockups, not yet implemented):

FilterStoreValuesScope
ModesignalFlowModeFilter (new)mode name stringDropdown
DevicesignalFlowDeviceFilter (new)Set of device IDs (multi-select)Chip toggles
TypesignalFlowTypeFilter (new)Set of trigger types (multi-select)Chip toggles
DensitysignalFlowDensity (new)'expanded' | 'compact'Toggle

Overlap: Device and Type filters exist in both panels with different store shapes (single-select string vs multi-select Set) and different semantics (Events: raw MIDI event types; Signal Flow: mapping trigger types).

Screen Real Estate Pressure

The three-panel layout (Chat 320px + Workspace flexible + Events 280px) on a 1440px screen leaves ~840px for the workspace. With a filter toolbar consuming 36px vertically in both Signal Flow and Events, plus DeviceStatusPills taking another 32px in Events, the effective filtering overhead is 68px in Events alone — nearly 8% of a typical 900px-tall viewport. This pressure intensifies on laptop screens (768px viewport height).

Aggregate Metrics — Where Do They Come From?

The V3 compact mode shows per-device metrics that need a computation source:

MetricSourcePersistence
Mapping count (per device, per mode)mappingsStore + deviceBindingsStoreStructural — recomputed on config change
Unmapped countT1 topology (ADR-015 D2) — observed trigger types with no matching mappingSession — accumulates from event stream
Fire rate (events/min)T2 signal pulse (ADR-015 D3) — rolling 30s windowEphemeral — recomputed every 30s
Type distributionmappingsStore — count of each trigger type per deviceStructural
Warning countT3 alerts (ADR-015 D4) — loop detection, sustained unmapped, errorsSession — event-driven
Sparkline historyNEW — 8-sample rolling fire rate historyEphemeral — ring buffer in memory

The mapping count and type distribution are structural (derivable from config) and already available from existing stores. The fire rate, unmapped count, and sparkline are runtime metrics that must be computed from the event stream. ADR-015's signalPulseStore already proposes exactly this computation for LLM context — the compact mode UI should consume the same data, not re-derive it.


Decision

D1: Three-Tier Filter Architecture — Shared, Panel-Local, and View-Specific

Filters are categorised into three tiers based on whether they make sense across panels:

TierFilterShared?Rationale
SharedDeviceYes (default)The 80% case: user is investigating one device across both topology and raw events
SharedModeYes (default)Mode constrains which mappings are active and which events are relevant
Panel-localTypeNoSemantically different: Events type = raw MIDI byte type; Signal Flow type = mapping trigger type
Panel-localChannelNoOnly relevant to Events (raw MIDI); Signal Flow operates at mapping level
Panel-localFired toggleNoOnly relevant to Events stream
View-specificDensityNoOnly relevant to Signal Flow
View-specificQuick filtersNoEvents-only convenience shortcuts

Implementation: A new workspaceFilters.js store module holds the shared state:

// stores/workspaceFilters.js

import { writable, derived } from 'svelte/store';

/** Whether shared filters are linked between panels */
export const filtersLinked = writable(readLocalBool('conductor:filtersLinked', true));

/** Shared device filter — multi-select Set of device IDs.
* 'all' is represented by an empty set (meaning no filter applied). */
export const activeDeviceFilter = writable(new Set());

/** Shared mode filter — the mode whose mappings/events are shown */
export const activeModeFilter = writable(null); // null = currentMode from daemon

/** Panel-local overrides (only active when filtersLinked is false) */
export const signalFlowDeviceOverride = writable(null); // null = use shared
export const eventsDeviceOverride = writable(null); // null = use shared

/** Derived: effective device filter for Signal Flow */
export const signalFlowDeviceFilter = derived(
[filtersLinked, activeDeviceFilter, signalFlowDeviceOverride],
([$linked, $shared, $override]) => $linked ? $shared : ($override ?? $shared)
);

/** Derived: effective device filter for Events */
export const eventsDeviceFilter = derived(
[filtersLinked, activeDeviceFilter, eventsDeviceOverride],
([$linked, $shared, $override]) => $linked ? $shared : ($override ?? $shared)
);

Persistence: filtersLinked is persisted to localStorage (matches existing pattern in workspace.js). activeDeviceFilter and activeModeFilter are session-scoped (sessionStorage) — they reset between sessions because device availability and active mode change between sessions. Panel-local overrides are ephemeral (in-memory only).

A small chain-link icon (🔗 linked / ⛓️‍💥 unlinked) appears on the Signal Flow filter toolbar (not on Events, to avoid adding UI to the already-dense sidebar). Clicking it toggles filtersLinked.

When linked (default):

  • Clicking a device chip in Signal Flow updates activeDeviceFilter → Events sidebar immediately reflects the change
  • Clicking a device pill in Events sidebar updates activeDeviceFilter → Signal Flow immediately reflects the change
  • Mode selector changes propagate to both panels
  • A subtle 🔗 icon appears in the filter toolbar

When unlinked:

  • Each panel reads from its own override store
  • First filter change after unlinking copies the current shared state into the override, then diverges
  • A ⛓️‍💥 icon with amber tint indicates independent mode
  • Re-linking snaps both panels back to the shared state (Signal Flow's current state wins, as it is the primary filter surface)

Conflict resolution: When the user re-links after diverging, Signal Flow's device selection is promoted to the shared state. This follows the principle that Signal Flow is the primary analytical view (structural topology), while Events is the supporting evidence stream.

D3: Collapsible Unified Filter Pane

To address vertical real-estate pressure, both filter surfaces support collapse:

Signal Flow filter toolbar:

  • Default: visible when Signal Flow tab is active
  • Collapse: click a chevron on the toolbar's right edge to collapse to a single 20px summary line showing: Mode: Default · 2 devices · All types · ▤
  • Expand: click the summary line or press F (keyboard shortcut) to expand
  • Auto-collapse: when workspace width drops below 500px, auto-collapse to summary and switch to compact density
  • Persistence: collapse state persisted to localStorage key conductor:signalFlowFilterCollapsed

Events sidebar filter area:

  • Default: collapsed to a single summary line: Mikro · All · All Ch (device · type · channel)
  • Expand: click the summary line to expand the full filter chips + channel dropdown
  • Collapse: click the summary line again, or click outside
  • Persistence: collapse state persisted to localStorage key conductor:eventsFilterCollapsed

Smart default: Events filter starts collapsed because the Events sidebar is narrow (220–280px) and the primary filter action (device selection) is already handled by DeviceStatusPills at the top. Full filter expansion is the power-user path.

D4: Promote DeviceStatusPills to Shared Filter Widget

Currently, DeviceStatusPills.svelte is embedded exclusively in EventStreamPanel.svelte and reads/writes eventDeviceFilter. This component becomes the canonical device filter widget used in both panels:

Changes to DeviceStatusPills.svelte:

  • Replace eventDeviceFilter import with activeDeviceFilter from workspaceFilters.js
  • Change from single-select toggle (clicking same device = "all") to multi-select (clicking = toggle in/out of set; clicking last active = select all)
  • Add linked prop that controls whether the 🔗 indicator is shown
  • Add compact prop for narrow rendering (abbreviate device names to first 8 chars + )
  • Retain right-click context menu for mute/unmute

Usage:

  • Events sidebar: <DeviceStatusPills compact={sidebarWidth < 300} />
  • Signal Flow toolbar: replaces the inline filter-chip buttons with <DeviceStatusPills linked={true} />

This eliminates duplicated device chip rendering logic.

D5: Mode Filter — New Shared Control

Neither panel currently has a mode filter. Signal Flow V3 mockups proposed one; Events has none. Mode is added as a shared filter:

Store: activeModeFilter in workspaceFilters.js (see D1).

Behaviour:

  • Default: null — meaning "current mode" (reads from daemon's currentMode state)
  • Dropdown shows: Current (Default) (dynamic label) + all defined modes
  • When null, the label updates reactively when the daemon mode changes (via T3 mode_change event)
  • When explicitly set to a mode name, it stays pinned even if the daemon mode changes — a subtle indicator shows "pinned" vs "live"

Effect on Events: When a mode filter is active, Events only shows events from devices that have mappings in that mode. Raw MIDI events don't inherently carry mode information, but they do carry device IDs — and the mode's mapping config tells us which devices are relevant. Events that don't match any mapping-relevant device are dimmed (not hidden) to preserve the full stream for debugging.

Effect on Signal Flow: Shows only mappings for the selected mode. This is the primary use case — Signal Flow without mode filtering shows all mappings across all modes, which is the clutter problem V3 was designed to solve.

D6: Remove — EventFilter Type Chip Duplication

With the filter architecture clarified, type filters remain panel-local (D1). However, the current EventFilter.svelte type chips duplicate presentation logic that could be shared.

What to remove:

  • The inline filter-chip buttons in Signal Flow V3 mockup's toolbar for "All", "Note", "CC", etc. — these are replaced by a <TypeFilterChips> component
  • The hardcoded chips array in EventFilter.svelte — extracted to a shared triggerTypeChips constant

What to create:

  • TypeFilterChips.svelte — reusable chip row component accepting store prop (which writable to read/write), chips prop (which type values to show), and multiSelect prop (boolean)
  • Events uses it with store={eventTypeFilter}, multiSelect={false}, chips: All/CC/Note/Bend/AT/Fired
  • Signal Flow uses it with store={signalFlowTypeFilter}, multiSelect={true}, chips: All/Note/CC/AT/Bend/Enc/Gamepad — plus an "Unmapped" chip that toggles unmapped row visibility

Why multi-select for Signal Flow: In a structural topology view, you often want to see "Note AND CC mappings" but hide "Encoder mappings". Multi-select (additive/subtractive) is natural for filtering a fixed list. In the Events stream, single-select is simpler because you're scanning a rapidly-scrolling list.

D7: Remove — eventDeviceFilter Store

The existing eventDeviceFilter writable in events.js is replaced by the shared activeDeviceFilter in workspaceFilters.js. All consumers must be migrated:

ConsumerCurrent importNew import
DeviceStatusPills.svelteeventDeviceFilter from events.jsactiveDeviceFilter from workspaceFilters.js
filteredEvents derived storeeventDeviceFilter from events.jseventsDeviceFilter from workspaceFilters.js
EventFilter.test.tseventDeviceFilter from events.jseventsDeviceFilter from workspaceFilters.js

Migration note: The store shape changes from writable<string> ('all' | device ID) to derived<Set<string>> (empty set = all, populated set = only those devices). The filteredEvents derived store's device filter logic must be updated:

// Before (events.js):
if ($device !== 'all' && e.device_id !== $device) return false;

// After (events.js, using eventsDeviceFilter):
if ($device.size > 0 && !$device.has(e.device_id)) return false;

D8: Aggregate Metrics Store — signalFlowMetrics.js

The compact mode's aggregate metrics (mapping count, unmapped count, fire rate, type distribution, sparkline, warnings) are derived from existing stores rather than computed independently:

// stores/signalFlowMetrics.js

import { derived } from 'svelte/store';
import { mappingsStore, deviceBindingsStore } from '$lib/stores.js';
import { signalPulseStore } from '$lib/stores/signalContext.js'; // ADR-015 D8
import { activeModeFilter } from '$lib/stores/workspaceFilters.js';

/**
* Per-device aggregate metrics for Signal Flow compact mode.
* Shape: Map<deviceId, DeviceMetrics>
*
* Each DeviceMetrics:
* mappingCount: number — from mappingsStore (structural)
* unmappedCount: number — from signalPulseStore.unmapped_by_device (T2)
* fireRate: number — from signalPulseStore.devices[id].events_per_sec (T2)
* fireRateHistory: number[] — ring buffer of last 8 fireRate samples (sparkline)
* typeDistribution: Map<string, number> — from mappingsStore trigger types (structural)
* warningCount: number — from signalAlerts filtered by device (T3)
* hasActiveAlert: boolean — true if any unacknowledged T3 alert for this device
*/
export const deviceMetrics = derived(
[mappingsStore, deviceBindingsStore, signalPulseStore, activeModeFilter],
([$mappings, $devices, $pulse, $mode]) => {
const metrics = new Map();
// ... compute per-device metrics from existing stores
return metrics;
}
);

Sparkline history: The fireRateHistory is a ring buffer of 8 samples, updated every T2 pulse interval (30s). This gives a 4-minute trend window in the sparkline. The ring buffer is maintained in a module-level variable (not in the store itself, to avoid circular subscriptions):

// Module-level sparkline accumulator (not persisted)
const _sparklineBuffers = new Map(); // deviceId → number[8]

signalPulseStore.subscribe($pulse => {
if (!$pulse?.devices) return;
for (const [deviceId, devicePulse] of Object.entries($pulse.devices)) {
if (!_sparklineBuffers.has(deviceId)) {
_sparklineBuffers.set(deviceId, new Array(8).fill(0));
}
const buf = _sparklineBuffers.get(deviceId);
buf.push(devicePulse.events_per_sec ?? 0);
if (buf.length > 8) buf.shift();
}
});

Persistence strategy:

DataPersistenceRationale
Mapping countNone needed — derived from config storeRecomputed on every config change
Type distributionNone needed — derived from config storeRecomputed on every config change
Unmapped countSession (in signalPulseStore)Accumulates during session, irrelevant between sessions
Fire rateEphemeral (in signalPulseStore)30s rolling window, meaningless to persist
Sparkline historyEphemeral (module-level ring buffer)4min window, no value persisting
Warning countSession (in signalAlerts)Alerts are pruned after 5min per ADR-015 D4

Key design choice: No aggregate metrics require disk persistence. They are all derivable from structural config (which is already persisted as TOML) or from the event stream (which is ephemeral by design). This avoids adding a persistence layer for transient UI state.

D9: Signal Flow Consumes signalPulseStore Directly

Rather than computing fire rates, unmapped counts, and event rates independently, Signal Flow subscribes to the same signalPulseStore that ADR-015 defines for LLM context. This creates a single source of truth:

eventBuffer (raw events)

signalPulseStore (30s rolling window aggregation)
↓ ↓
signalFlowMetrics signalContextForLLM
(compact mode UI) (T2 injection into chat)

Why this matters: If Signal Flow computed its own fire rates from eventBuffer, there would be two independent rolling-window computations over the same data. Inconsistencies between the UI (showing 31 fires/min) and the LLM context (saying "47 fires in 30s") would confuse users who can see both.

The signalPulseStore becomes a shared aggregation layer, computed once, consumed by both the visual and conversational interfaces. This is the same architectural principle described in ADR-015 D9 ("Integration with Signal Flow View") — ADR-019 makes it concrete.

D10: Unmapped Events — Filter Chip in Signal Flow

Signal Flow gains a dedicated "Unmapped" toggle in its type filter chips:

  • Default: ON (unmapped rows visible at 50% opacity)
  • Toggle OFF: unmapped rows hidden entirely — useful during performance when you only care about active mappings
  • Interaction with compact mode: When ON, the device summary row shows the amber unmapped count. When OFF, the unmapped count is hidden and the type distribution bar fills the freed space.

This is distinct from the Events sidebar, which has no concept of "unmapped" — it shows all raw events regardless of whether they match a mapping. The "unmapped" classification is a Signal Flow concern (structural topology), not an Events concern (raw stream).

D11: Keyboard Shortcuts

ShortcutActionScope
FToggle filter pane expand/collapseSignal Flow workspace (when focused)
Shift+FToggle Events filter expand/collapseEvents sidebar (when focused)
DCycle density: Expanded → Compact → ExpandedSignal Flow workspace
LToggle link/unlink filtersGlobal (any panel focused)
19Quick-select device by indexWhen filter pane is expanded
0Select all devicesWhen filter pane is expanded

Implementation: Keyboard shortcuts are handled by a filterKeyboardHandler action (Svelte use: directive) applied to the workspace and sidebar containers. Shortcuts only fire when the container has focus (not when typing in chat or editing mappings).

D12: Events Sidebar — New Features

The Events sidebar gains two new capabilities that align it with the unified filter architecture:

D12.1: Mode indicator (read-only)

A subtle mode badge appears in the Events panel header: Events · Default (or Events · Live etc.). This is read-only — it reflects activeModeFilter from the shared store. When mode is "current" (null), it shows the daemon's current mode. When pinned, it shows the pinned mode name with a 📌 icon.

Purpose: When the Events sidebar filters by device (via the shared device filter), knowing the active mode helps the user understand why certain events are/aren't triggering mappings.

D12.2: Unmapped event tagging

Events in the stream that don't match any mapping in the active mode gain a subtle dashed-border indicator: a small unmapped tag after the event description. This bridges the gap between the Events sidebar (raw stream) and Signal Flow (structural topology).

Implementation: The filteredEvents derived store is extended with an _unmapped boolean flag per event, computed by checking if the event's trigger signature (type + device + channel + number) matches any mapping in the active mode's config.

Performance note: This check runs against the filtered event list (max 200 events) and the mode's mapping list (typically 5–30 mappings). The cross-product is small enough for synchronous computation in the derived store.


Specification: Component Changes

New Files

FilePurpose
stores/workspaceFilters.jsShared filter state: activeDeviceFilter, activeModeFilter, filtersLinked, panel-local overrides, derived effective filters
stores/workspaceFilters.test.tsTests for linked/unlinked behaviour, override snapping, mode pinning
stores/signalFlowMetrics.jsDerived aggregate metrics for compact mode (mapping count, type distribution, fire rate sparkline, unmapped count, warnings)
stores/signalFlowMetrics.test.tsTests for metric derivation, sparkline ring buffer
components/TypeFilterChips.svelteReusable type filter chip row (single-select or multi-select mode)
components/FilterSummaryBar.svelteCollapsed filter summary line (mode · devices · types) with click-to-expand
components/LinkToggle.svelte🔗/⛓️‍💥 link/unlink button with tooltip

Modified Files

FileChange
stores/events.jsRemove eventDeviceFilter. Update filteredEvents to consume eventsDeviceFilter from workspaceFilters.js. Add _unmapped flag computation.
components/EventFilter.svelteExtract type chips to TypeFilterChips. Add collapsed/expanded toggle. Import activeModeFilter for unmapped tagging.
components/DeviceStatusPills.svelteSwitch from eventDeviceFilter to activeDeviceFilter. Change single-select → multi-select. Add compact and linked props.
panels/EventStreamPanel.svelteAdd mode indicator badge. Wrap DeviceStatusPills + EventFilter in collapsible pane. Default collapsed.
stores/workspace.jsAdd signalFlowFilterCollapsed, eventsFilterCollapsed persisted booleans.
components/EventFilter.test.tsUpdate imports, test multi-select device behaviour, test linked/unlinked propagation.

Removed

ItemReplacement
eventDeviceFilter store in events.jsactiveDeviceFilter + eventsDeviceFilter in workspaceFilters.js
Inline device filter-chip buttons in Signal Flow toolbar<DeviceStatusPills> component
Inline type filter-chip buttons in Signal Flow toolbar<TypeFilterChips> component
ds-mapping-pills CSS and HTML in compact modeAggregate metrics layout (type bar, sparkline, stat cells) via signalFlowMetrics.js

Specification: Data Flow

Linked Mode (Default)

User clicks "Mikro MK3" chip in Signal Flow

activeDeviceFilter.update(set => toggle 'mikro-mk3-midi' in set)

┌──────────────────────────────────┬──────────────────────────────────┐
│ signalFlowDeviceFilter (derived) │ eventsDeviceFilter (derived) │
│ → Signal Flow re-renders with │ → filteredEvents re-derives with │
│ only Mikro device group │ only Mikro events visible │
└──────────────────────────────────┴──────────────────────────────────┘

Unlinked Mode

User clicks ⛓️‍💥 (unlink) then clicks "LaunchControl" in Events sidebar

eventsDeviceOverride.set(new Set(['launch-control-xl']))

┌──────────────────────────────────┬──────────────────────────────────┐
│ signalFlowDeviceFilter (derived) │ eventsDeviceFilter (derived) │
│ → Still uses activeDeviceFilter │ → Uses eventsDeviceOverride │
│ (Mikro selected from before) │ (LaunchControl only) │
└──────────────────────────────────┴──────────────────────────────────┘

Aggregate Metrics Flow

eventBuffer (raw events, max 200, ring buffer)
↓ subscribe (every push)
signalPulseStore (ADR-015 D3, 30s rolling window)
↓ derive
deviceMetrics (per-device aggregates for compact mode)

├── mappingCount ← mappingsStore (structural)
├── typeDistribution ← mappingsStore (structural)
├── unmappedCount ← signalPulseStore.unmapped_by_device
├── fireRate ← signalPulseStore.devices[id].events_per_sec
├── fireRateHistory ← module-level ring buffer (8 samples)
└── warningCount ← signalAlerts (T3, ADR-015 D4)

Filter Collapse State Flow

User clicks ▾ on Signal Flow toolbar

signalFlowFilterCollapsed.set(true)
↓ persisted to localStorage
FilterSummaryBar renders: "Mode: Default · 2 devices · All types · ▤"
↓ click summary bar
signalFlowFilterCollapsed.set(false)
↓ persisted to localStorage
Full filter toolbar renders

Specification: Persistence Summary

StateStorageKeyLifetimeReset Trigger
filtersLinkedlocalStorageconductor:filtersLinkedAcross sessionsManual toggle
activeDeviceFiltersessionStorageconductor:activeDeviceFilterCurrent sessionSession end / app restart
activeModeFiltersessionStorageconductor:activeModeFilterCurrent sessionSession end / app restart
signalFlowFilterCollapsedlocalStorageconductor:sfFilterCollapsedAcross sessionsManual toggle / auto at <500px
eventsFilterCollapsedlocalStorageconductor:evFilterCollapsedAcross sessionsManual toggle
signalFlowDensitylocalStorageconductor:sfDensityAcross sessionsManual toggle / auto at <500px
signalFlowTypeFiltersessionStorageconductor:sfTypeFilterCurrent sessionSession end
signalFlowDeviceOverrideMemory onlyUntil re-link or session endRe-link / page reload
eventsDeviceOverrideMemory onlyUntil re-link or session endRe-link / page reload
Sparkline ring buffersMemory onlyUntil page reloadPage reload
Fire rate aggregatesMemory only30s rolling windowContinuous recomputation
Unmapped countsMemory onlyAccumulates in sessionclearEvents() / session end

Design principle: Only user-intentional preferences (collapse state, linked/unlinked, density) are persisted across sessions via localStorage. Filter selections (which device, which mode) are session-scoped because they depend on runtime state (which devices are connected, which mode is active). Ephemeral metrics (fire rate, sparkline) are never persisted — they are always fresh from the event stream.


Implementation Plan

Phase 1: Shared Filter Store & Migration — 6–8h (Issues #633–#635)

  • #633 (1A): Create workspaceFilters.js with activeDeviceFilter, activeModeFilter, filtersLinked, override stores, derived effective filters + workspaceFilters.test.ts
  • #634 (1B): Migrate DeviceStatusPills.svelte from eventDeviceFilter to activeDeviceFilter with multi-select
  • #635 (1C): Update filteredEvents in events.js to use eventsDeviceFilter (Set-based). Remove eventDeviceFilter export (breaking change). Update EventFilter.test.ts.

Phase 2: Collapsible Filter Panes — 4–6h (Issues #636–#637)

  • #636 (2A): Create FilterSummaryBar.svelte (collapsed summary line). Add signalFlowFilterCollapsed and eventsFilterCollapsed to workspace.js with localStorage persistence.
  • #637 (2B): Wire collapsible filter panes in EventStreamPanel and Signal Flow toolbar. Add auto-collapse at <500px. Add keyboard shortcuts (F, Shift+F).
  • #638: Create LinkToggle.svelte. Wire filtersLinked toggle to override store activation/deactivation. "Snap to Signal Flow state" on re-link. L keyboard shortcut. 🔗 / ⛓️‍💥 indicator.

Phase 4: Type Filter Component & Mode Filter — 4–5h (Issues #639–#640)

  • #639 (4A): Extract TypeFilterChips.svelte from EventFilter.svelte. Create Signal Flow type filter with multi-select and "Unmapped" chip.
  • #640 (4B): Add shared mode selector dropdown. Implement mode pinning (explicit vs "current") with 📌 indicator. Add mode indicator badge to Events panel header.

Phase 5: Aggregate Metrics Store — 4–6h (Issues #641–#642)

  • #641 (5A): Create signalFlowMetrics.js with deviceMetrics derived store. Wire to signalPulseStore (ADR-015). Implement sparkline ring buffer. Create signalFlowMetrics.test.ts.
  • #642 (5B): Wire compact mode UI to deviceMetrics store (type bar segments, sparkline bars, stat cells).

Phase 6: Events Sidebar Enhancements — 3–4h (Issue #643)

  • #643: Add mode indicator badge to EventStreamPanel header. Add _unmapped flag to filteredEvents. Add unmapped event tagging in EventRow. Test across mode changes.

Phase 7: Integration Testing & Polish — 3–4h (Issue #644)

  • #644: End-to-end tests: linked propagation, unlink → diverge → re-link, auto-collapse, keyboard shortcuts, EventFilter non-regression, performance (200 events × 30 mappings).

Total estimated effort: 27–37 hours across 7 phases.

Tracking issue: #645

Phases 2 (#636–#637) and 3 (#638) can run in parallel after Phase 1. Phase 4 (#639–#640) can run in parallel with Phase 5 (#641–#642). Phase 6 (#643) depends on Phase 1 and Phase 4. ADR-020 Phase 1 (#656–#659) depends on Phase 1 completing first. ADR-020 Phase 3 (#663–#664) depends on Phase 5 (#641–#642).


Consequences

Positive

  • Single mental model: Users click a device once and both panels respond — the investigation context is unified
  • Reduced vertical overhead: Collapsible filter panes recover 68px+ of vertical space in Events sidebar; Signal Flow collapse recovers 36px
  • No metric duplication: Compact mode's aggregate metrics consume the same signalPulseStore as the LLM context (ADR-015), ensuring consistency between what the user sees and what the LLM knows
  • Progressive disclosure: Default collapsed filters in Events preserve scanning space; power users expand when needed
  • Keyboard-first: F/D/L shortcuts let power users manage filters without mouse interaction

Negative

  • Breaking change: Removing eventDeviceFilter requires updating all consumers — test coverage must be comprehensive before migration
  • Store chain depth: eventBuffersignalPulseStoredeviceMetrics → compact mode UI is a 4-level derived chain. Svelte's derived stores are synchronous, so this is fast, but debugging state issues requires tracing through multiple stores
  • Linked default may surprise: Users accustomed to independent panels may be surprised when clicking a device in Signal Flow changes Events. The 🔗 indicator and unlink option mitigate this, but it's a behaviour change

Risks

  • R1: Multi-select device filter complexity: Moving from single-select (click to toggle one device) to multi-select (click to toggle in/out of set) changes interaction semantics. Mitigation: when only one device is selected and user clicks it, select all (same as current "reset to all" behaviour).
  • R2: signalPulseStore dependency: If ADR-015 Phase 2 (Signal Pulse) is not yet implemented, the compact mode metrics that depend on it (fire rate, unmapped count, sparkline) will show no data. Mitigation: signalFlowMetrics.js gracefully degrades — structural metrics (mapping count, type distribution) are always available; runtime metrics show "—" when pulse data is unavailable.
  • R3: Events filter collapsed by default: Users who relied on always-visible type chips in Events may initially be confused. Mitigation: first-use tooltip ("Click to expand filters") and the summary line always shows current filter state.

Dependencies

  • #583 (Signal Flow V2): ✅ MERGED — V2 rendering model that ADR-019 filters retrofit into; ADR-020 (#656–#667) subsequently migrates to V3 rendering
  • #568 (Chat bridge): ✅ MERGED — Click dispatch infrastructure preserved by ADR-020 Phase 2 (#661)
  • ADR-015 (LLM Signal Awareness): signalPulseStore (T2) provides fire rate, unmapped counts, and event rate data consumed by signalFlowMetrics.js (#641). If ADR-015 Phase 2 is not implemented, compact mode shows structural metrics only.
  • ADR-014 (Mapping Feedback & Simulate): mappingFireState and mappingFireCount from events.js provide per-mapping fire tracking. ADR-019 does not modify these — they remain in events.js and are consumed by Signal Flow's expanded branch view (not compact mode).
  • ADR-017 (Unified Mapping Experience): The + Map CTA on unmapped rows opens the MappingEditor defined in ADR-017. Filter state (active mode, active device) is passed as pre-fill context.
  • ADR-020 (V3 Structural Rendering Migration, #656–#667): ADR-020 Phase 1 depends on ADR-019 Phase 1 (#633–#635) for shared filter stores. ADR-020 Phase 3 depends on ADR-019 Phase 5 (#641–#642) for deviceMetrics. The two ADRs are designed to interleave — see implementation-order-adr-019-020-021.md.
  • ADR-021 (Input/Output Device Qualification, #669–#676): Output-only devices are excluded from event filtering (signalFlowDeviceFilter, eventsDeviceFilter). ADR-021 Phase 3 (#672–#673) updates device pills and views with direction indicators. ADR-021 Phase 4 (#674) adds action target resolution to Signal Flow MappingBranch. Backend phases (#669–#671) run in parallel with ADR-019/020.
  • Signal Flow V3/V4 Mockups (signal-flow-v3-mockups.html, signal-flow-v4-io-device-mockups.html): V3 = structural rendering spec; V4 = I/O device qualification layer. ADR-019 provides the architectural backing; ADR-020 provides structural rendering; ADR-021 adds I/O context.

References

  • conductor-gui/ui/src/lib/stores/events.js: Current event store with eventDeviceFilter, eventTypeFilter, filteredEvents
  • conductor-gui/ui/src/lib/components/EventFilter.svelte: Current type filter chips
  • conductor-gui/ui/src/lib/components/DeviceStatusPills.svelte: Current device filter pills
  • conductor-gui/ui/src/lib/panels/EventStreamPanel.svelte: Current Events sidebar composition
  • conductor-gui/ui/src/lib/stores/workspace.js: Persistence patterns (readLocalBool, persistDebounced)
  • conductor-gui/ui/src/lib/stores/feedback-settings.js: localStorage persistence pattern with migration
  • ADR-014: Mapping Feedback & Simulate (mappingFireState, toast notifications)
  • ADR-015: LLM Signal Awareness (signalPulseStore, T1/T2/T3 context tiers)
  • ADR-017: Unified Mapping Experience (MappingEditor, trigger/action sub-editors)
  • signal-flow-v3-mockups.html: V3 compact mode aggregate metrics, filter toolbar, density toggle