Skip to content

feat: double-click dedup and right-click filtering for auto-zoom#1665

Open
namearth5005 wants to merge 8 commits intoCapSoftware:mainfrom
namearth5005:feat/auto-zoom-click-filtering
Open

feat: double-click dedup and right-click filtering for auto-zoom#1665
namearth5005 wants to merge 8 commits intoCapSoftware:mainfrom
namearth5005:feat/auto-zoom-click-filtering

Conversation

@namearth5005
Copy link

@namearth5005 namearth5005 commented Mar 18, 2026

Summary

Adds click preprocessing to auto-zoom segment generation (refs #1646, builds on #1663, #1664):

  • Double-click deduplication — rapid clicks within double_click_threshold_ms (default 400ms) on the same button are collapsed to a single click, preventing redundant zoom segments from double-clicks
  • Right-click filtering — when ignore_right_clicks is true (default), non-primary button clicks (cursor_num != 0) are stripped before segment generation, so context menu interactions don't trigger zoom
  • 3 new tests covering dedup, right-click filtering enabled/disabled

How it works

Preprocessing runs before click sorting and grouping:

  1. clicks.retain(|c| c.cursor_num == 0) removes right/middle clicks
  2. After sorting, a linear scan removes same-button down-clicks within the threshold window

Config fields added to AutoZoomConfig

Field Type Default Purpose
double_click_threshold_ms f64 400.0 Time window for deduplication
ignore_right_clicks bool true Filter non-primary clicks

Test plan

  • double_click_deduplication — two clicks 200ms apart produce same segments as single click
  • right_click_ignored — right-click produces no zoom segment when filtering enabled
  • right_click_allowed_when_disabled — right-click works normally when filtering disabled
  • Manual: double-click on a file → single smooth zoom, not two jittery zooms

Greptile Summary

This PR adds two click preprocessing steps to the auto-zoom segment generator — double-click deduplication (collapsing rapid same-button clicks within a configurable threshold) and right-click filtering — along with a new AutoZoomConfig struct that promotes all previously hardcoded constants into configurable fields. A dead_zone_radius feature is also quietly introduced, merging spatially co-located clicks into a single zoom group.

Key changes:

  • AutoZoomConfig struct added to crates/project/src/configuration.rs with 14 fields and sensible defaults; wired into GeneralSettingsStore, recording completion, and the Tauri editor command.
  • generate_zoom_segments_from_clicks_impl now accepts &AutoZoomConfig, replacing all const literals with configurable values, and runs the two new preprocessing passes before grouping.
  • Three new tests cover dedup, right-click filtering enabled/disabled, and two dead-zone scenarios.
  • experimental.tsx gains a SettingSlider component and exposes Zoom Amount, Sensitivity, and Smoothing controls when auto-zoom is enabled.

Issues found:

  • right_click_ignored test is broken: the three large movement events included in the test generate movement-based segments that cover t=1.0s, making has_click_segment always true and the assertion always fail — even when right-click filtering is working correctly. The moves should be removed or replaced with zero-displacement events.
  • Dead zone merging lacks a time constraint: in_dead_zone checks only spatial proximity to the group centroid, with no temporal bound. Two clicks at the same screen position minutes apart will be collapsed into one group, producing an erroneously long zoom segment. The check should be gated behind the same click_group_time_threshold_secs guard used by time_and_spatial.
  • Orphaned up-events after dedup: clicks.remove(j) drops the duplicate down-event but leaves its paired up-event in the vector; while harmless today, it leaves the clicks slice inconsistent for any future consumer of up-events.
  • Hardcoded frontend defaults: the fallback autoZoomConfig object in experimental.tsx duplicates Rust-side defaults and will silently diverge if backend defaults change.

Confidence Score: 2/5

  • Not safe to merge as-is: a new test will fail in CI, and the dead zone logic can produce excessively long zoom segments in production recordings.
  • Two functional bugs were identified — a broken test that will fail on every CI run, and a time-unbounded dead zone condition that can corrupt segment generation for users who click the same UI element multiple times throughout a recording. Both issues need to be resolved before this is production-ready.
  • apps/desktop/src-tauri/src/recording.rs requires the most attention: fix the right_click_ignored test move events and add a time constraint to the in_dead_zone grouping condition.

Important Files Changed

Filename Overview
apps/desktop/src-tauri/src/recording.rs Core zoom-segment logic extended with right-click filtering, double-click dedup, and dead zone merging; contains two bugs: the right_click_ignored test will fail due to large movement events generating segments, and the dead zone grouping condition lacks a time constraint that can create excessively long zoom segments.
crates/project/src/configuration.rs New AutoZoomConfig struct added with sensible defaults; clean derivation of Type, Serialize, Deserialize, and explicit Default impl — no issues found.
apps/desktop/src-tauri/src/general_settings.rs Adds auto_zoom_config field to GeneralSettingsStore with #[serde(default)] and proper Default initialization — straightforward and correct.
apps/desktop/src-tauri/src/lib.rs Tauri command updated to accept AppHandle, reads settings with a silent fallback to defaults (unwrap_or(None).unwrap_or_default()), and threads the config through correctly; the silent error swallowing is acceptable here but worth noting.
apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx New SettingSlider component and three auto-zoom sliders added; hardcoded default values in the fallback store object risk drifting from backend defaults, but the UI logic itself is correct.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[generate_zoom_segments_from_clicks_impl] --> B{ignore_right_clicks?}
    B -- Yes --> C[clicks.retain cursor_num == 0]
    B -- No --> D[Keep all clicks]
    C --> E[Sort clicks by time_ms]
    D --> E
    E --> F{double_click_threshold_ms > 0?}
    F -- Yes --> G[Deduplicate: remove same-button down-clicks within threshold window]
    F -- No --> H[Skip dedup]
    G --> I[Remove trailing clicks beyond activity_end_limit]
    H --> I
    I --> J[Build click_positions map from move events]
    J --> K[Group clicks by time + spatial proximity OR dead zone centroid]
    K --> L[Build click intervals with pre/post padding]
    L --> M[Build movement intervals from significant moves]
    M --> N[Merge overlapping intervals with gap threshold]
    N --> O[Filter segments below min_segment_duration]
    O --> P[Return ZoomSegments with zoom_amount]
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/recording.rs
Line: 2675-2696

Comment:
**`right_click_ignored` test will always fail due to movement segments**

The test includes three move events with large displacements — (0.1→0.5→0.9) in both axes — that will generate movement-based zoom segments regardless of whether right-clicks are filtered.

When the move event at `999ms` is processed:
- Distance ≈ `sqrt((0.5-0.1)² + (0.5-0.1)²) ≈ 0.566`, which far exceeds both `movement_event_distance_threshold` (0.02) and `movement_window_distance_threshold` (0.08).
- This produces a segment from `0.699s` to `2.499s`, which covers `t=1.0s` (the click time).

Since `has_click_segment` checks if **any** segment covers `t=1.0s` (not just click-generated ones), the movement segment causes the assertion `!has_click_segment` to fail — even when right-click filtering is working correctly.

To fix the test, the movement events should be removed or replaced with small/stationary ones that won't trigger the movement detection threshold:

```suggestion
        let moves: Vec<cap_project::cursor_captures::CursorMoveEvent> = vec![];
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/recording.rs
Line: 2123-2141

Comment:
**Dead zone merging has no time constraint**

The `in_dead_zone` condition merges a new click into an existing group based purely on spatial proximity to the group's centroid. Unlike `time_and_spatial`, which enforces both `time_close` (< 2.5 s by default) and spatial proximity, `in_dead_zone` has **no temporal bound**.

Consider a scenario where a user clicks a toolbar button at `t=5s`, works for 3 minutes, then clicks the same button again at `t=185s`. Both clicks are within `dead_zone_radius` (0.1) of each other, so the second click is merged into the first group. The resulting segment spans from `~4.6s` to `~186.8s` — nearly the entire recording — instead of producing two short, appropriate zoom segments.

To prevent this, the dead zone check should also respect the same `click_group_time_threshold_secs` (or a comparable time window):

```rust
let in_dead_zone = dead_zone_radius > 0.0 && click_pos.is_some() && {
    let (cx, cy) = click_pos.unwrap();
    // Check time proximity first to avoid iterating positions unnecessarily
    let time_close = group.iter().any(|&gi| {
        let group_time = clicks[gi].time_ms / 1000.0;
        (click_time - group_time).abs() < click_group_time_threshold_secs
    });
    if !time_close {
        false
    } else {
        let group_positions: Vec<(f64, f64)> = group
            .iter()
            .filter_map(|&gi| click_positions.get(&gi).copied())
            .collect();
        if group_positions.is_empty() {
            false
        } else {
            let count = group_positions.len() as f64;
            let centroid_x = group_positions.iter().map(|(x, _)| x).sum::<f64>() / count;
            let centroid_y = group_positions.iter().map(|(_, y)| y).sum::<f64>() / count;
            let dx = cx - centroid_x;
            let dy = cy - centroid_y;
            (dx * dx + dy * dy).sqrt() < dead_zone_radius
        }
    }
};
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/recording.rs
Line: 2045-2069

Comment:
**Dedup removes down events but leaves orphaned up events**

When a duplicate down event is removed via `clicks.remove(j)`, its paired up event (`down: false`) is left in the vector. For example, with a double-click sequence `[1000ms↓, 1050ms↑, 1200ms↓, 1250ms↑]`, after dedup the vector becomes `[1000ms↓, 1050ms↑, 1250ms↑]` — with an orphaned up event at 1250ms.

This doesn't cause incorrect segment generation currently (grouping only iterates `c.down` events), but it leaves the `clicks` vector in an inconsistent state that could silently break future code relying on down/up pairing. Consider also removing the immediately-following up event:

```rust
if clicks[j].cursor_num == clicks[i].cursor_num {
    clicks.remove(j);
    // Also remove the paired up-event if it immediately follows
    if j < clicks.len() && !clicks[j].down && clicks[j].cursor_num == clicks[i].cursor_num {
        clicks.remove(j);
    }
} else {
    j += 1;
}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx
Line: 68-84

Comment:
**Hardcoded default config values may drift from Rust defaults**

The `autoZoomConfig` fallback object in `createStore` duplicates the default values from `AutoZoomConfig::default()` in Rust. If a default is updated on the backend (e.g., `zoom_amount` is bumped from 1.5 to 2.0), the frontend fallback will silently serve the stale value to users who have never persisted a config.

Consider deriving these defaults from the serialized Tauri command response rather than hardcoding them. At a minimum, the fallback should be an empty object (`{}`) that lets backend-provided `serde` defaults fill in, rather than a manually maintained copy:

```suggestion
			autoZoomConfig: {} as AutoZoomConfig,
```

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: "feat(recording): add..."

Greptile also left 4 inline comments on this PR.

(4/5) You can add custom instructions or style guidelines for the agent here!

Comment on lines +2675 to +2696
let moves = vec![
move_event(500.0, 0.1, 0.1),
move_event(999.0, 0.5, 0.5),
move_event(1500.0, 0.9, 0.9),
];

let config = cap_project::AutoZoomConfig {
ignore_right_clicks: true,
..Default::default()
};

let segments = generate_zoom_segments_from_clicks_impl(clicks, moves, 20.0, &config);

let has_click_segment = segments.iter().any(|s| {
let click_time_secs = 1.0;
s.start <= click_time_secs && s.end >= click_time_secs
});

assert!(
!has_click_segment,
"right-click should be filtered out when ignore_right_clicks is true"
);
Copy link
Contributor

Choose a reason for hiding this comment

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

P1 right_click_ignored test will always fail due to movement segments

The test includes three move events with large displacements — (0.1→0.5→0.9) in both axes — that will generate movement-based zoom segments regardless of whether right-clicks are filtered.

When the move event at 999ms is processed:

  • Distance ≈ sqrt((0.5-0.1)² + (0.5-0.1)²) ≈ 0.566, which far exceeds both movement_event_distance_threshold (0.02) and movement_window_distance_threshold (0.08).
  • This produces a segment from 0.699s to 2.499s, which covers t=1.0s (the click time).

Since has_click_segment checks if any segment covers t=1.0s (not just click-generated ones), the movement segment causes the assertion !has_click_segment to fail — even when right-click filtering is working correctly.

To fix the test, the movement events should be removed or replaced with small/stationary ones that won't trigger the movement detection threshold:

Suggested change
let moves = vec![
move_event(500.0, 0.1, 0.1),
move_event(999.0, 0.5, 0.5),
move_event(1500.0, 0.9, 0.9),
];
let config = cap_project::AutoZoomConfig {
ignore_right_clicks: true,
..Default::default()
};
let segments = generate_zoom_segments_from_clicks_impl(clicks, moves, 20.0, &config);
let has_click_segment = segments.iter().any(|s| {
let click_time_secs = 1.0;
s.start <= click_time_secs && s.end >= click_time_secs
});
assert!(
!has_click_segment,
"right-click should be filtered out when ignore_right_clicks is true"
);
let moves: Vec<cap_project::cursor_captures::CursorMoveEvent> = vec![];
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/recording.rs
Line: 2675-2696

Comment:
**`right_click_ignored` test will always fail due to movement segments**

The test includes three move events with large displacements — (0.1→0.5→0.9) in both axes — that will generate movement-based zoom segments regardless of whether right-clicks are filtered.

When the move event at `999ms` is processed:
- Distance ≈ `sqrt((0.5-0.1)² + (0.5-0.1)²) ≈ 0.566`, which far exceeds both `movement_event_distance_threshold` (0.02) and `movement_window_distance_threshold` (0.08).
- This produces a segment from `0.699s` to `2.499s`, which covers `t=1.0s` (the click time).

Since `has_click_segment` checks if **any** segment covers `t=1.0s` (not just click-generated ones), the movement segment causes the assertion `!has_click_segment` to fail — even when right-click filtering is working correctly.

To fix the test, the movement events should be removed or replaced with small/stationary ones that won't trigger the movement detection threshold:

```suggestion
        let moves: Vec<cap_project::cursor_captures::CursorMoveEvent> = vec![];
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +2123 to +2141
let in_dead_zone = dead_zone_radius > 0.0 && click_pos.is_some() && {
let (cx, cy) = click_pos.unwrap();
let group_positions: Vec<(f64, f64)> = group
.iter()
.filter_map(|&gi| click_positions.get(&gi).copied())
.collect();
if group_positions.is_empty() {
false
} else {
let count = group_positions.len() as f64;
let centroid_x = group_positions.iter().map(|(x, _)| x).sum::<f64>() / count;
let centroid_y = group_positions.iter().map(|(_, y)| y).sum::<f64>() / count;
let dx = cx - centroid_x;
let dy = cy - centroid_y;
(dx * dx + dy * dy).sqrt() < dead_zone_radius
}
};

if time_and_spatial || in_dead_zone {
Copy link
Contributor

Choose a reason for hiding this comment

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

P1 Dead zone merging has no time constraint

The in_dead_zone condition merges a new click into an existing group based purely on spatial proximity to the group's centroid. Unlike time_and_spatial, which enforces both time_close (< 2.5 s by default) and spatial proximity, in_dead_zone has no temporal bound.

Consider a scenario where a user clicks a toolbar button at t=5s, works for 3 minutes, then clicks the same button again at t=185s. Both clicks are within dead_zone_radius (0.1) of each other, so the second click is merged into the first group. The resulting segment spans from ~4.6s to ~186.8s — nearly the entire recording — instead of producing two short, appropriate zoom segments.

To prevent this, the dead zone check should also respect the same click_group_time_threshold_secs (or a comparable time window):

let in_dead_zone = dead_zone_radius > 0.0 && click_pos.is_some() && {
    let (cx, cy) = click_pos.unwrap();
    // Check time proximity first to avoid iterating positions unnecessarily
    let time_close = group.iter().any(|&gi| {
        let group_time = clicks[gi].time_ms / 1000.0;
        (click_time - group_time).abs() < click_group_time_threshold_secs
    });
    if !time_close {
        false
    } else {
        let group_positions: Vec<(f64, f64)> = group
            .iter()
            .filter_map(|&gi| click_positions.get(&gi).copied())
            .collect();
        if group_positions.is_empty() {
            false
        } else {
            let count = group_positions.len() as f64;
            let centroid_x = group_positions.iter().map(|(x, _)| x).sum::<f64>() / count;
            let centroid_y = group_positions.iter().map(|(_, y)| y).sum::<f64>() / count;
            let dx = cx - centroid_x;
            let dy = cy - centroid_y;
            (dx * dx + dy * dy).sqrt() < dead_zone_radius
        }
    }
};
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/recording.rs
Line: 2123-2141

Comment:
**Dead zone merging has no time constraint**

The `in_dead_zone` condition merges a new click into an existing group based purely on spatial proximity to the group's centroid. Unlike `time_and_spatial`, which enforces both `time_close` (< 2.5 s by default) and spatial proximity, `in_dead_zone` has **no temporal bound**.

Consider a scenario where a user clicks a toolbar button at `t=5s`, works for 3 minutes, then clicks the same button again at `t=185s`. Both clicks are within `dead_zone_radius` (0.1) of each other, so the second click is merged into the first group. The resulting segment spans from `~4.6s` to `~186.8s` — nearly the entire recording — instead of producing two short, appropriate zoom segments.

To prevent this, the dead zone check should also respect the same `click_group_time_threshold_secs` (or a comparable time window):

```rust
let in_dead_zone = dead_zone_radius > 0.0 && click_pos.is_some() && {
    let (cx, cy) = click_pos.unwrap();
    // Check time proximity first to avoid iterating positions unnecessarily
    let time_close = group.iter().any(|&gi| {
        let group_time = clicks[gi].time_ms / 1000.0;
        (click_time - group_time).abs() < click_group_time_threshold_secs
    });
    if !time_close {
        false
    } else {
        let group_positions: Vec<(f64, f64)> = group
            .iter()
            .filter_map(|&gi| click_positions.get(&gi).copied())
            .collect();
        if group_positions.is_empty() {
            false
        } else {
            let count = group_positions.len() as f64;
            let centroid_x = group_positions.iter().map(|(x, _)| x).sum::<f64>() / count;
            let centroid_y = group_positions.iter().map(|(_, y)| y).sum::<f64>() / count;
            let dx = cx - centroid_x;
            let dy = cy - centroid_y;
            (dx * dx + dy * dy).sqrt() < dead_zone_radius
        }
    }
};
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +2045 to +2069
if config.double_click_threshold_ms > 0.0 {
let mut i = 0;
while i < clicks.len() {
if !clicks[i].down {
i += 1;
continue;
}
let mut j = i + 1;
while j < clicks.len() {
if !clicks[j].down {
j += 1;
continue;
}
if clicks[j].time_ms - clicks[i].time_ms > config.double_click_threshold_ms {
break;
}
if clicks[j].cursor_num == clicks[i].cursor_num {
clicks.remove(j);
} else {
j += 1;
}
}
i += 1;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

P2 Dedup removes down events but leaves orphaned up events

When a duplicate down event is removed via clicks.remove(j), its paired up event (down: false) is left in the vector. For example, with a double-click sequence [1000ms↓, 1050ms↑, 1200ms↓, 1250ms↑], after dedup the vector becomes [1000ms↓, 1050ms↑, 1250ms↑] — with an orphaned up event at 1250ms.

This doesn't cause incorrect segment generation currently (grouping only iterates c.down events), but it leaves the clicks vector in an inconsistent state that could silently break future code relying on down/up pairing. Consider also removing the immediately-following up event:

if clicks[j].cursor_num == clicks[i].cursor_num {
    clicks.remove(j);
    // Also remove the paired up-event if it immediately follows
    if j < clicks.len() && !clicks[j].down && clicks[j].cursor_num == clicks[i].cursor_num {
        clicks.remove(j);
    }
} else {
    j += 1;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/recording.rs
Line: 2045-2069

Comment:
**Dedup removes down events but leaves orphaned up events**

When a duplicate down event is removed via `clicks.remove(j)`, its paired up event (`down: false`) is left in the vector. For example, with a double-click sequence `[1000ms↓, 1050ms↑, 1200ms↓, 1250ms↑]`, after dedup the vector becomes `[1000ms↓, 1050ms↑, 1250ms↑]` — with an orphaned up event at 1250ms.

This doesn't cause incorrect segment generation currently (grouping only iterates `c.down` events), but it leaves the `clicks` vector in an inconsistent state that could silently break future code relying on down/up pairing. Consider also removing the immediately-following up event:

```rust
if clicks[j].cursor_num == clicks[i].cursor_num {
    clicks.remove(j);
    // Also remove the paired up-event if it immediately follows
    if j < clicks.len() && !clicks[j].down && clicks[j].cursor_num == clicks[i].cursor_num {
        clicks.remove(j);
    }
} else {
    j += 1;
}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +68 to 84
autoZoomConfig: {
zoomAmount: 1.5,
clickGroupTimeThreshold: 2.5,
clickGroupSpatialThreshold: 0.15,
clickPrePadding: 0.4,
clickPostPadding: 1.8,
movementPrePadding: 0.3,
movementPostPadding: 1.5,
mergeGapThreshold: 0.8,
minSegmentDuration: 1.0,
movementEventDistanceThreshold: 0.02,
movementWindowDistanceThreshold: 0.08,
deadZoneRadius: 0.1,
doubleClickThresholdMs: 400.0,
ignoreRightClicks: true,
},
},
Copy link
Contributor

Choose a reason for hiding this comment

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

P2 Hardcoded default config values may drift from Rust defaults

The autoZoomConfig fallback object in createStore duplicates the default values from AutoZoomConfig::default() in Rust. If a default is updated on the backend (e.g., zoom_amount is bumped from 1.5 to 2.0), the frontend fallback will silently serve the stale value to users who have never persisted a config.

Consider deriving these defaults from the serialized Tauri command response rather than hardcoding them. At a minimum, the fallback should be an empty object ({}) that lets backend-provided serde defaults fill in, rather than a manually maintained copy:

Suggested change
autoZoomConfig: {
zoomAmount: 1.5,
clickGroupTimeThreshold: 2.5,
clickGroupSpatialThreshold: 0.15,
clickPrePadding: 0.4,
clickPostPadding: 1.8,
movementPrePadding: 0.3,
movementPostPadding: 1.5,
mergeGapThreshold: 0.8,
minSegmentDuration: 1.0,
movementEventDistanceThreshold: 0.02,
movementWindowDistanceThreshold: 0.08,
deadZoneRadius: 0.1,
doubleClickThresholdMs: 400.0,
ignoreRightClicks: true,
},
},
autoZoomConfig: {} as AutoZoomConfig,
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx
Line: 68-84

Comment:
**Hardcoded default config values may drift from Rust defaults**

The `autoZoomConfig` fallback object in `createStore` duplicates the default values from `AutoZoomConfig::default()` in Rust. If a default is updated on the backend (e.g., `zoom_amount` is bumped from 1.5 to 2.0), the frontend fallback will silently serve the stale value to users who have never persisted a config.

Consider deriving these defaults from the serialized Tauri command response rather than hardcoding them. At a minimum, the fallback should be an empty object (`{}`) that lets backend-provided `serde` defaults fill in, rather than a manually maintained copy:

```suggestion
			autoZoomConfig: {} as AutoZoomConfig,
```

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant