Skip to content

Commit 18beaed

Browse files
committed
feat(ux): add hotkey recorder and overhaul onboarding flow
Introduces major user experience enhancements, including a live hotkey recorder, a redesigned onboarding process, and improved window management. These changes are supported by a significant refactoring of OS services and improved hardware monitoring robustness. - Implements a live hotkey recorder in Settings, allowing users to set shortcuts by pressing key combinations instead of typing them. - Overhauls the onboarding experience to be non-modal and more robust. A skip option is added, and the app now fully initializes in the background while the onboarding window is active. - Improves Result Window behavior, ensuring it opens centered, focused, 800x500 px window, and remembers its position and size across sessions. - Enhances `HardwareMonitoringService` to fail gracefully on unsupported platforms (e.g., macOS ARM64). The dashboard UI now adapts by hiding the monitoring sidebar when it's unavailable. - Decomposes the monolithic `IOsService` into smaller, single-responsibility services (`IHotkeyService`, `IClipboardService`, `IActiveWindowService`, `ISystemService`) to improve modularity. - Improves the build process to dynamically set the `RuntimeIdentifier` based on both OS and architecture (x64/arm64).
1 parent 5796007 commit 18beaed

35 files changed

Lines changed: 1128 additions & 726 deletions
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace ProseFlow.Application.DTOs;
2+
3+
/// <summary>
4+
/// A technology-agnostic Data Transfer Object for representing a captured hotkey combination.
5+
/// </summary>
6+
/// <param name="Key">The primary, non-modifier key that was pressed (e.g., "A", "J", "F5").</param>
7+
/// <param name="Modifiers">A list of modifier keys that were held down (e.g., ["Ctrl", "Shift"]).</param>
8+
public record HotkeyData(string Key, IReadOnlyList<string> Modifiers);

ProseFlow.Application/Events/AppEvents.cs

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,26 @@ public enum NotificationType { Info, Success, Warning, Error }
1515

1616
public static class AppEvents
1717
{
18+
/// <summary>
19+
/// Configuration flag to enable or disable the floating menu feature globally.
20+
/// </summary>
21+
public static bool IsShowFloatingMenuEnabled = true;
22+
23+
/// <summary>
24+
/// Configuration flag to enable or disable the result window display feature globally.
25+
/// </summary>
26+
public static bool IsShowResultWindowEnabled = true;
27+
28+
/// <summary>
29+
/// Configuration flag to enable or disable system notifications (toasts) globally.
30+
/// </summary>
31+
public static bool IsShowNotificationEnabled = true;
32+
33+
/// <summary>
34+
/// Configuration flag to enable or disable the conflict resolution UI globally.
35+
/// </summary>
36+
public static bool IsResolveConflictsEnabled = true;
37+
1838
/// <summary>
1939
/// Raised when the Action Orchestration Service needs the UI to display the Floating Action Menu.
2040
/// The UI layer subscribes to this, shows the menu, and returns the user's selection.
@@ -27,12 +47,13 @@ public static class AppEvents
2747
/// </summary>
2848
public static async Task<ActionExecutionRequest?> RequestFloatingMenuAsync(IEnumerable<Action> availableActions, string activeAppContext)
2949
{
50+
if (!IsShowFloatingMenuEnabled) return null;
51+
3052
return ShowFloatingMenuRequested is not null
3153
? await ShowFloatingMenuRequested.Invoke(availableActions, activeAppContext)
3254
: await Task.FromResult<ActionExecutionRequest?>(null);
3355
}
3456

35-
3657
/// <summary>
3758
/// Raised when a result needs to be displayed in a window.
3859
/// The UI subscribes and is responsible for showing the window and then returning a Task
@@ -46,13 +67,14 @@ public static class AppEvents
4667
/// <returns>A RefinementRequest if the user wants to refine, otherwise null.</returns>
4768
public static async Task<RefinementRequest?> RequestResultWindowAsync(ResultWindowData data)
4869
{
70+
if (!IsShowResultWindowEnabled) return null;
71+
4972
return ShowResultWindowAndAwaitRefinement is not null
5073
? await ShowResultWindowAndAwaitRefinement.Invoke(data)
5174
: await Task.FromResult<RefinementRequest?>(null);
5275
// Graceful failure
5376
}
5477

55-
5678
/// <summary>
5779
/// Raised to show a system notification (toast).
5880
/// The UI layer subscribes to this to display feedback.
@@ -64,9 +86,11 @@ public static class AppEvents
6486
/// </summary>
6587
public static void RequestNotification(string message, NotificationType type)
6688
{
89+
if (!IsShowNotificationEnabled) return;
90+
6791
ShowNotificationRequested?.Invoke(message, type);
6892
}
69-
93+
7094
/// <summary>
7195
/// Raised when the Action Management Service detects conflicts during an import.
7296
/// The UI layer subscribes to this, shows a resolution dialog, and returns the user's choices.
@@ -79,6 +103,8 @@ public static void RequestNotification(string message, NotificationType type)
79103
/// <returns>A list of resolved conflicts, or null if the user cancelled the operation.</returns>
80104
public static async Task<List<ActionConflict>?> RequestConflictResolutionAsync(List<ActionConflict> conflicts)
81105
{
106+
if (!IsResolveConflictsEnabled) return null;
107+
82108
return ResolveConflictsRequested is not null
83109
? await ResolveConflictsRequested.Invoke(conflicts)
84110
: await Task.FromResult<List<ActionConflict>?>(null);
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using ProseFlow.Application.DTOs;
2+
3+
namespace ProseFlow.Application.Interfaces;
4+
5+
/// <summary>
6+
/// Defines a contract for a service that can globally capture a single hotkey combination.
7+
/// This acts as a mediator between the global hook and the UI for configuration purposes.
8+
/// </summary>
9+
public interface IHotkeyRecordingService
10+
{
11+
/// <summary>
12+
/// Fired when a valid, complete hotkey combination has been detected while in recording mode.
13+
/// The event payload is a technology-agnostic DTO.
14+
/// </summary>
15+
event Action<HotkeyData> HotkeyDetected;
16+
17+
/// <summary>
18+
/// Fired when the state of pressed modifiers changes during recording, providing live feedback.
19+
/// The string payload is a user-friendly representation of the current state (e.g., "Ctrl+Shift...").
20+
/// </summary>
21+
event Action<string> RecordingStateUpdated;
22+
23+
/// <summary>
24+
/// Gets a value indicating whether the service is currently listening for a single hotkey input.
25+
/// </summary>
26+
bool IsRecording { get; }
27+
28+
/// <summary>
29+
/// Puts the service into recording mode.
30+
/// It will listen for the next valid key combination press.
31+
/// </summary>
32+
void BeginRecording();
33+
34+
/// <summary>
35+
/// Takes the service out of recording mode.
36+
/// </summary>
37+
void EndRecording();
38+
}

ProseFlow.Application/Services/ActionOrchestrationService.cs

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using ProseFlow.Application.Interfaces;
77
using ProseFlow.Core.Enums;
88
using ProseFlow.Core.Interfaces;
9+
using ProseFlow.Core.Interfaces.Os;
910
using ProseFlow.Core.Models;
1011
using Action = ProseFlow.Core.Models.Action;
1112

@@ -14,30 +15,36 @@ namespace ProseFlow.Application.Services;
1415
public class ActionOrchestrationService : IDisposable
1516
{
1617
private readonly IServiceScopeFactory _scopeFactory;
17-
private readonly IOsService _osService;
1818
private readonly IReadOnlyDictionary<string, IAiProvider> _providers;
1919
private readonly ILocalSessionService _localSessionService;
2020
private readonly ILogger<ActionOrchestrationService> _logger;
21+
22+
private readonly IHotkeyService _hotkeyService;
23+
private readonly IActiveWindowService _activeWindowService;
24+
private readonly IClipboardService _clipboardService;
2125

22-
public ActionOrchestrationService(IServiceScopeFactory scopeFactory, IOsService osService,
26+
public ActionOrchestrationService(IServiceScopeFactory scopeFactory, IHotkeyService hotkeyService, IActiveWindowService activeWindowService,
27+
IClipboardService clipboardService,
2328
IEnumerable<IAiProvider> providers, ILocalSessionService localSessionService, ILogger<ActionOrchestrationService> logger)
2429
{
2530
_scopeFactory = scopeFactory;
26-
_osService = osService;
31+
_hotkeyService = hotkeyService;
32+
_activeWindowService = activeWindowService;
33+
_clipboardService = clipboardService;
2734
_localSessionService = localSessionService;
2835
_providers = providers.ToDictionary(p => p.Name, p => p, StringComparer.OrdinalIgnoreCase);
2936
_logger = logger;
3037
}
3138

3239
public void Initialize()
3340
{
34-
_osService.ActionMenuHotkeyPressed += async () => await HandleActionMenuHotkeyAsync();
35-
_osService.SmartPasteHotkeyPressed += async () => await HandleSmartPasteHotkeyAsync();
41+
_hotkeyService.ActionMenuHotkeyPressed += async () => await HandleActionMenuHotkeyAsync();
42+
_hotkeyService.SmartPasteHotkeyPressed += async () => await HandleSmartPasteHotkeyAsync();
3643
}
3744

3845
private async Task HandleActionMenuHotkeyAsync()
3946
{
40-
var activeAppContext = await _osService.GetActiveWindowProcessNameAsync();
47+
var activeAppContext = await _activeWindowService.GetActiveWindowProcessNameAsync();
4148
var allActions = await ExecuteQueryAsync(unitOfWork => unitOfWork.Actions.GetAllOrderedAsync());
4249

4350
// Filter actions based on context
@@ -98,7 +105,7 @@ private async Task ProcessRequestAsync(ActionExecutionRequest request)
98105

99106
try
100107
{
101-
var userInput = await _osService.GetSelectedTextAsync();
108+
var userInput = await _clipboardService.GetSelectedTextAsync();
102109
if (string.IsNullOrWhiteSpace(userInput))
103110
{
104111
AppEvents.RequestNotification("No text selected or clipboard is empty.", NotificationType.Warning);
@@ -192,7 +199,7 @@ await LogToHistoryAsync(
192199
latencyMs,
193200
aiResponse.TokensPerSecond);
194201

195-
await _osService.PasteTextAsync(aiResponse.Content);
202+
await _clipboardService.PasteTextAsync(aiResponse.Content);
196203
}
197204

198205
overallStopwatch.Stop();
@@ -305,7 +312,7 @@ private async Task LogToHistoryAsync(string actionName, string providerType, str
305312

306313
public void Dispose()
307314
{
308-
_osService.Dispose();
315+
_hotkeyService.Dispose();
309316
foreach (var provider in _providers.Values)
310317
{
311318
provider.Dispose();

ProseFlow.Core/Interfaces/IActiveWindowTracker.cs

Lines changed: 0 additions & 15 deletions
This file was deleted.

ProseFlow.Core/Interfaces/IOsService.cs

Lines changed: 0 additions & 48 deletions
This file was deleted.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace ProseFlow.Core.Interfaces.Os;
2+
3+
/// <summary>
4+
/// Defines the contract for a service that tracks the active window.
5+
/// </summary>
6+
public interface IActiveWindowService
7+
{
8+
/// <summary>
9+
/// Gets the process name of the currently active window.
10+
/// </summary>
11+
/// <returns>The active window's process name.</returns>
12+
Task<string> GetActiveWindowProcessNameAsync();
13+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace ProseFlow.Core.Interfaces.Os;
2+
3+
/// <summary>
4+
/// Defines the contract for a service that handles clipboard interactions.
5+
/// </summary>
6+
public interface IClipboardService
7+
{
8+
/// <summary>
9+
/// Attempts to get the currently selected text by simulating a copy action.
10+
/// </summary>
11+
/// <returns>The selected text, or null if no text is selected or could be copied.</returns>
12+
Task<string?> GetSelectedTextAsync();
13+
14+
/// <summary>
15+
/// Pastes the specified text at the current cursor position by simulating a paste action.
16+
/// </summary>
17+
/// <param name="text">The text to paste.</param>
18+
Task PasteTextAsync(string text);
19+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
namespace ProseFlow.Core.Interfaces.Os;
2+
3+
/// <summary>
4+
/// Defines the contract for a service that manages global hotkeys.
5+
/// </summary>
6+
public interface IHotkeyService : IDisposable
7+
{
8+
/// <summary>
9+
/// Fired when the hotkey combination for the Action Menu is pressed.
10+
/// </summary>
11+
event Action? ActionMenuHotkeyPressed;
12+
13+
/// <summary>
14+
/// Fired when the hotkey combination for Smart Paste is pressed.
15+
/// </summary>
16+
event Action? SmartPasteHotkeyPressed;
17+
18+
/// <summary>
19+
/// Starts the global hook to listen for keyboard events.
20+
/// </summary>
21+
Task StartHookAsync();
22+
23+
/// <summary>
24+
/// Updates the hotkey combinations from string configurations.
25+
/// </summary>
26+
/// <param name="actionMenuHotkey">The hotkey string for the Action Menu.</param>
27+
/// <param name="smartPasteHotkey">The hotkey string for Smart Paste.</param>
28+
void UpdateHotkeys(string actionMenuHotkey, string smartPasteHotkey);
29+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace ProseFlow.Core.Interfaces.Os;
2+
3+
/// <summary>
4+
/// Defines the contract for a service that handles general OS-level interactions.
5+
/// </summary>
6+
public interface ISystemService
7+
{
8+
/// <summary>
9+
/// Configures the application to launch automatically at login.
10+
/// </summary>
11+
/// <param name="isEnabled">True to enable launch at login, false to disable.</param>
12+
void SetLaunchAtLogin(bool isEnabled);
13+
}

0 commit comments

Comments
 (0)