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:
-
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.
-
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.
-
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):
| Filter | Store | Values | Scope |
|---|---|---|---|
| Device | eventDeviceFilter (writable) | 'all' | device ID string | Single-select toggle |
| Type | eventTypeFilter (writable) | 'all' | 'cc' | 'note' | 'bend' | 'aftertouch' | 'fired' | Single-select chips |
| Channel | eventChannelFilter (writable) | 'all' | '1'–'16' | Dropdown |
| Fired | eventFiredFilter (writable) | boolean | Toggle |
| Quick | derived from type + fired | 'all' | 'raw' | 'fired' | Composite convenience |
Signal Flow V3 (proposed in mockups, not yet implemented):
| Filter | Store | Values | Scope |
|---|---|---|---|
| Mode | signalFlowModeFilter (new) | mode name string | Dropdown |
| Device | signalFlowDeviceFilter (new) | Set of device IDs (multi-select) | Chip toggles |
| Type | signalFlowTypeFilter (new) | Set of trigger types (multi-select) | Chip toggles |
| Density | signalFlowDensity (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:
| Metric | Source | Persistence |
|---|---|---|
| Mapping count (per device, per mode) | mappingsStore + deviceBindingsStore | Structural — recomputed on config change |
| Unmapped count | T1 topology (ADR-015 D2) — observed trigger types with no matching mapping | Session — accumulates from event stream |
| Fire rate (events/min) | T2 signal pulse (ADR-015 D3) — rolling 30s window | Ephemeral — recomputed every 30s |
| Type distribution | mappingsStore — count of each trigger type per device | Structural |
| Warning count | T3 alerts (ADR-015 D4) — loop detection, sustained unmapped, errors | Session — event-driven |
| Sparkline history | NEW — 8-sample rolling fire rate history | Ephemeral — 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:
| Tier | Filter | Shared? | Rationale |
|---|---|---|---|
| Shared | Device | Yes (default) | The 80% case: user is investigating one device across both topology and raw events |
| Shared | Mode | Yes (default) | Mode constrains which mappings are active and which events are relevant |
| Panel-local | Type | No | Semantically different: Events type = raw MIDI byte type; Signal Flow type = mapping trigger type |
| Panel-local | Channel | No | Only relevant to Events (raw MIDI); Signal Flow operates at mapping level |
| Panel-local | Fired toggle | No | Only relevant to Events stream |
| View-specific | Density | No | Only relevant to Signal Flow |
| View-specific | Quick filters | No | Events-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).
D2: Link/Unlink Toggle with Visual Indicator
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
localStoragekeyconductor: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
localStoragekeyconductor: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
eventDeviceFilterimport withactiveDeviceFilterfromworkspaceFilters.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
linkedprop that controls whether the 🔗 indicator is shown - Add
compactprop 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-chipbuttons 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'scurrentModestate) - 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-chipbuttons in Signal Flow V3 mockup's toolbar for "All", "Note", "CC", etc. — these are replaced by a<TypeFilterChips>component - The hardcoded
chipsarray inEventFilter.svelte— extracted to a sharedtriggerTypeChipsconstant
What to create:
TypeFilterChips.svelte— reusable chip row component acceptingstoreprop (which writable to read/write),chipsprop (which type values to show), andmultiSelectprop (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:
| Consumer | Current import | New import |
|---|---|---|
DeviceStatusPills.svelte | eventDeviceFilter from events.js | activeDeviceFilter from workspaceFilters.js |
filteredEvents derived store | eventDeviceFilter from events.js | eventsDeviceFilter from workspaceFilters.js |
EventFilter.test.ts | eventDeviceFilter from events.js | eventsDeviceFilter 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:
| Data | Persistence | Rationale |
|---|---|---|
| Mapping count | None needed — derived from config store | Recomputed on every config change |
| Type distribution | None needed — derived from config store | Recomputed on every config change |
| Unmapped count | Session (in signalPulseStore) | Accumulates during session, irrelevant between sessions |
| Fire rate | Ephemeral (in signalPulseStore) | 30s rolling window, meaningless to persist |
| Sparkline history | Ephemeral (module-level ring buffer) | 4min window, no value persisting |
| Warning count | Session (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
| Shortcut | Action | Scope |
|---|---|---|
F | Toggle filter pane expand/collapse | Signal Flow workspace (when focused) |
Shift+F | Toggle Events filter expand/collapse | Events sidebar (when focused) |
D | Cycle density: Expanded → Compact → Expanded | Signal Flow workspace |
L | Toggle link/unlink filters | Global (any panel focused) |
1–9 | Quick-select device by index | When filter pane is expanded |
0 | Select all devices | When 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
| File | Purpose |
|---|---|
stores/workspaceFilters.js | Shared filter state: activeDeviceFilter, activeModeFilter, filtersLinked, panel-local overrides, derived effective filters |
stores/workspaceFilters.test.ts | Tests for linked/unlinked behaviour, override snapping, mode pinning |
stores/signalFlowMetrics.js | Derived aggregate metrics for compact mode (mapping count, type distribution, fire rate sparkline, unmapped count, warnings) |
stores/signalFlowMetrics.test.ts | Tests for metric derivation, sparkline ring buffer |
components/TypeFilterChips.svelte | Reusable type filter chip row (single-select or multi-select mode) |
components/FilterSummaryBar.svelte | Collapsed filter summary line (mode · devices · types) with click-to-expand |
components/LinkToggle.svelte | 🔗/⛓️💥 link/unlink button with tooltip |
Modified Files
| File | Change |
|---|---|
stores/events.js | Remove eventDeviceFilter. Update filteredEvents to consume eventsDeviceFilter from workspaceFilters.js. Add _unmapped flag computation. |
components/EventFilter.svelte | Extract type chips to TypeFilterChips. Add collapsed/expanded toggle. Import activeModeFilter for unmapped tagging. |
components/DeviceStatusPills.svelte | Switch from eventDeviceFilter to activeDeviceFilter. Change single-select → multi-select. Add compact and linked props. |
panels/EventStreamPanel.svelte | Add mode indicator badge. Wrap DeviceStatusPills + EventFilter in collapsible pane. Default collapsed. |
stores/workspace.js | Add signalFlowFilterCollapsed, eventsFilterCollapsed persisted booleans. |
components/EventFilter.test.ts | Update imports, test multi-select device behaviour, test linked/unlinked propagation. |
Removed
| Item | Replacement |
|---|---|
eventDeviceFilter store in events.js | activeDeviceFilter + 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 mode | Aggregate 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
| State | Storage | Key | Lifetime | Reset Trigger |
|---|---|---|---|---|
filtersLinked | localStorage | conductor:filtersLinked | Across sessions | Manual toggle |
activeDeviceFilter | sessionStorage | conductor:activeDeviceFilter | Current session | Session end / app restart |
activeModeFilter | sessionStorage | conductor:activeModeFilter | Current session | Session end / app restart |
signalFlowFilterCollapsed | localStorage | conductor:sfFilterCollapsed | Across sessions | Manual toggle / auto at <500px |
eventsFilterCollapsed | localStorage | conductor:evFilterCollapsed | Across sessions | Manual toggle |
signalFlowDensity | localStorage | conductor:sfDensity | Across sessions | Manual toggle / auto at <500px |
signalFlowTypeFilter | sessionStorage | conductor:sfTypeFilter | Current session | Session end |
signalFlowDeviceOverride | Memory only | — | Until re-link or session end | Re-link / page reload |
eventsDeviceOverride | Memory only | — | Until re-link or session end | Re-link / page reload |
| Sparkline ring buffers | Memory only | — | Until page reload | Page reload |
| Fire rate aggregates | Memory only | — | 30s rolling window | Continuous recomputation |
| Unmapped counts | Memory only | — | Accumulates in session | clearEvents() / 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.jswithactiveDeviceFilter,activeModeFilter,filtersLinked, override stores, derived effective filters +workspaceFilters.test.ts - #634 (1B): Migrate
DeviceStatusPills.sveltefromeventDeviceFiltertoactiveDeviceFilterwith multi-select - #635 (1C): Update
filteredEventsinevents.jsto useeventsDeviceFilter(Set-based). RemoveeventDeviceFilterexport (breaking change). UpdateEventFilter.test.ts.
Phase 2: Collapsible Filter Panes — 4–6h (Issues #636–#637)
- #636 (2A): Create
FilterSummaryBar.svelte(collapsed summary line). AddsignalFlowFilterCollapsedandeventsFilterCollapsedtoworkspace.jswith 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).
Phase 3: Link/Unlink Toggle — 3–4h (Issue #638)
- #638: Create
LinkToggle.svelte. WirefiltersLinkedtoggle to override store activation/deactivation. "Snap to Signal Flow state" on re-link.Lkeyboard shortcut. 🔗 / ⛓️💥 indicator.
Phase 4: Type Filter Component & Mode Filter — 4–5h (Issues #639–#640)
- #639 (4A): Extract
TypeFilterChips.sveltefromEventFilter.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.jswithdeviceMetricsderived store. Wire tosignalPulseStore(ADR-015). Implement sparkline ring buffer. CreatesignalFlowMetrics.test.ts. - #642 (5B): Wire compact mode UI to
deviceMetricsstore (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
_unmappedflag tofilteredEvents. 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
signalPulseStoreas 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/Lshortcuts let power users manage filters without mouse interaction
Negative
- Breaking change: Removing
eventDeviceFilterrequires updating all consumers — test coverage must be comprehensive before migration - Store chain depth:
eventBuffer→signalPulseStore→deviceMetrics→ 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.jsgracefully 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 bysignalFlowMetrics.js(#641). If ADR-015 Phase 2 is not implemented, compact mode shows structural metrics only. - ADR-014 (Mapping Feedback & Simulate):
mappingFireStateandmappingFireCountfromevents.jsprovide per-mapping fire tracking. ADR-019 does not modify these — they remain inevents.jsand are consumed by Signal Flow's expanded branch view (not compact mode). - ADR-017 (Unified Mapping Experience): The
+ MapCTA 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 — seeimplementation-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 FlowMappingBranch. 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 witheventDeviceFilter,eventTypeFilter,filteredEventsconductor-gui/ui/src/lib/components/EventFilter.svelte: Current type filter chipsconductor-gui/ui/src/lib/components/DeviceStatusPills.svelte: Current device filter pillsconductor-gui/ui/src/lib/panels/EventStreamPanel.svelte: Current Events sidebar compositionconductor-gui/ui/src/lib/stores/workspace.js: Persistence patterns (readLocalBool,persistDebounced)conductor-gui/ui/src/lib/stores/feedback-settings.js:localStoragepersistence 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