Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions src/OpenClaw.Client/OpenClawHttpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public sealed class OpenClawHttpClient : IDisposable
private readonly Uri _integrationSessionSearchUri;
private readonly Uri _integrationProfilesUri;
private readonly Uri _integrationToolPresetsUri;
private readonly Uri _integrationWorkflowsUri;
private readonly Uri _integrationAutomationsUri;
private readonly Uri _integrationRuntimeEventsUri;
private readonly Uri _integrationMessagesUri;
Expand Down Expand Up @@ -105,6 +106,7 @@ public OpenClawHttpClient(string baseUrl, string? authToken, HttpClient? httpCli
_integrationSessionSearchUri = new Uri(baseUri, "/api/integration/session-search");
_integrationProfilesUri = new Uri(baseUri, "/api/integration/profiles");
_integrationToolPresetsUri = new Uri(baseUri, "/api/integration/tool-presets");
_integrationWorkflowsUri = new Uri(baseUri, "/api/integration/workflows");
_integrationAutomationsUri = new Uri(baseUri, "/api/integration/automations");
_integrationRuntimeEventsUri = new Uri(baseUri, "/api/integration/runtime-events");
_integrationMessagesUri = new Uri(baseUri, "/api/integration/messages");
Expand Down Expand Up @@ -687,6 +689,50 @@ public async Task<MutationResponse> DeleteAutomationAsync(string automationId, C
return await SendAsync(req, CoreJsonContext.Default.MutationResponse, cancellationToken);
}

public Task<IntegrationAutomationRunsResponse> GetAutomationRunsAsync(string automationId, CancellationToken cancellationToken)
=> GetAsync(BuildAutomationRunsUri(automationId), CoreJsonContext.Default.IntegrationAutomationRunsResponse, cancellationToken);

public Task<IntegrationAutomationRunDetailResponse> GetAutomationRunAsync(string automationId, string runId, CancellationToken cancellationToken)
=> GetAsync(BuildAutomationRunDetailUri(automationId, runId), CoreJsonContext.Default.IntegrationAutomationRunDetailResponse, cancellationToken);

public async Task<MutationResponse> ReplayAutomationRunAsync(string automationId, string runId, CancellationToken cancellationToken)
{
using var req = new HttpRequestMessage(HttpMethod.Post, BuildAutomationRunReplayUri(automationId, runId));
return await SendAsync(req, CoreJsonContext.Default.MutationResponse, cancellationToken);
}

public async Task<MutationResponse> ClearAutomationQuarantineAsync(string automationId, CancellationToken cancellationToken)
{
using var req = new HttpRequestMessage(HttpMethod.Post, BuildAutomationQuarantineClearUri(automationId));
return await SendAsync(req, CoreJsonContext.Default.MutationResponse, cancellationToken);
}

public Task<IntegrationWorkflowsResponse> ListWorkflowsAsync(CancellationToken cancellationToken)
=> GetAsync(_integrationWorkflowsUri, CoreJsonContext.Default.IntegrationWorkflowsResponse, cancellationToken);

public async Task<AgentWorkflowRunResult> RunWorkflowAsync(string workflowId, AgentWorkflowRequest request, CancellationToken cancellationToken)
{
using var req = new HttpRequestMessage(HttpMethod.Post, BuildWorkflowRunsUri(workflowId))
{
Content = BuildJsonContent(request, CoreJsonContext.Default.AgentWorkflowRequest)
};

return await SendAsync(req, CoreJsonContext.Default.AgentWorkflowRunResult, cancellationToken);
}

public Task<AgentWorkflowRunSnapshot> GetWorkflowRunAsync(string workflowId, string runId, CancellationToken cancellationToken)
=> GetAsync(BuildWorkflowRunUri(workflowId, runId), CoreJsonContext.Default.AgentWorkflowRunSnapshot, cancellationToken);

public async Task<AgentWorkflowRunSnapshot> RespondWorkflowRunAsync(string workflowId, string runId, AgentWorkflowResponse response, CancellationToken cancellationToken)
{
using var req = new HttpRequestMessage(HttpMethod.Post, BuildWorkflowRunResponsesUri(workflowId, runId))
{
Content = BuildJsonContent(response, CoreJsonContext.Default.AgentWorkflowResponse)
};

return await SendAsync(req, CoreJsonContext.Default.AgentWorkflowRunSnapshot, cancellationToken);
}

public Task<IntegrationRuntimeEventsResponse> QueryRuntimeEventsAsync(
RuntimeEventQuery query,
CancellationToken cancellationToken)
Expand Down Expand Up @@ -1401,6 +1447,45 @@ private Uri BuildAutomationUri(string automationId)
private Uri BuildAutomationRunUri(string automationId)
=> new($"{BuildAutomationUri(automationId).AbsoluteUri}/run", UriKind.Absolute);

private Uri BuildAutomationRunsUri(string automationId)
=> new($"{BuildAutomationUri(automationId).AbsoluteUri}/runs", UriKind.Absolute);

private Uri BuildAutomationRunDetailUri(string automationId, string runId)
{
if (string.IsNullOrWhiteSpace(runId))
throw new ArgumentException("Automation run id is required.", nameof(runId));

return new Uri($"{BuildAutomationRunsUri(automationId).AbsoluteUri}/{Uri.EscapeDataString(runId)}", UriKind.Absolute);
}

private Uri BuildAutomationRunReplayUri(string automationId, string runId)
=> new($"{BuildAutomationRunDetailUri(automationId, runId).AbsoluteUri}/replay", UriKind.Absolute);

private Uri BuildAutomationQuarantineClearUri(string automationId)
=> new($"{BuildAutomationUri(automationId).AbsoluteUri}/quarantine/clear", UriKind.Absolute);

private Uri BuildWorkflowUri(string workflowId)
{
if (string.IsNullOrWhiteSpace(workflowId))
throw new ArgumentException("Workflow id is required.", nameof(workflowId));

return new Uri($"{_integrationWorkflowsUri.AbsoluteUri.TrimEnd('/')}/{Uri.EscapeDataString(workflowId)}", UriKind.Absolute);
}

private Uri BuildWorkflowRunsUri(string workflowId)
=> new($"{BuildWorkflowUri(workflowId).AbsoluteUri}/runs", UriKind.Absolute);

private Uri BuildWorkflowRunUri(string workflowId, string runId)
{
if (string.IsNullOrWhiteSpace(runId))
throw new ArgumentException("Workflow run id is required.", nameof(runId));

return new Uri($"{BuildWorkflowRunsUri(workflowId).AbsoluteUri}/{Uri.EscapeDataString(runId)}", UriKind.Absolute);
}

private Uri BuildWorkflowRunResponsesUri(string workflowId, string runId)
=> new($"{BuildWorkflowRunUri(workflowId, runId).AbsoluteUri}/responses", UriKind.Absolute);

private Uri BuildAutomationTemplatesUri(bool admin)
{
var baseUri = admin ? _adminAutomationsUri : _integrationAutomationsUri;
Expand Down
7 changes: 4 additions & 3 deletions src/OpenClaw.Companion/App.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
<local:ViewLocator/>
</Application.DataTemplates>

<Application.Styles>
<FluentTheme />
</Application.Styles>
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://OpenClaw.Companion/Styles/CompanionStyles.axaml" />
</Application.Styles>
</Application>
4 changes: 3 additions & 1 deletion src/OpenClaw.Companion/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,12 @@ public override void OnFrameworkInitializationCompleted()
var viewModel = new MainWindowViewModel(settings, _client, managedGateway: _managedGateway);
viewModel.AttachDesktopNotifier(new DesktopNotifier());

desktop.MainWindow = new MainWindow
var mainWindow = new MainWindow
{
DataContext = viewModel,
};
viewModel.AttachConfirmationDialogService(new WindowConfirmationDialogService(mainWindow));
desktop.MainWindow = mainWindow;

viewModel.StartApprovalsPolling();
var initializeLocalGatewayTask = viewModel.InitializeLocalGatewayAsync();
Expand Down
18 changes: 18 additions & 0 deletions src/OpenClaw.Companion/Models/ChatMessage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,22 @@ public sealed record ChatMessage
ChatRole.Assistant => "OpenClaw",
_ => "System"
};

public bool IsUser => Role == ChatRole.User;
public bool IsAssistant => Role == ChatRole.Assistant;
public bool IsSystem => Role == ChatRole.System && !IsToolEvent && !IsError;
private string SafeText => Text ?? "";
public bool IsToolEvent => Role == ChatRole.System &&
(SafeText.StartsWith("Agent invoked tool:", StringComparison.OrdinalIgnoreCase) ||
SafeText.Contains("tool approval", StringComparison.OrdinalIgnoreCase) ||
SafeText.Contains("requires operator approval", StringComparison.OrdinalIgnoreCase));

public bool IsError => Role == ChatRole.System &&
(SafeText.StartsWith("Error:", StringComparison.OrdinalIgnoreCase) ||
SafeText.Contains(" failed", StringComparison.OrdinalIgnoreCase) ||
SafeText.Contains("unavailable", StringComparison.OrdinalIgnoreCase) ||
SafeText.Contains("blocked", StringComparison.OrdinalIgnoreCase) ||
SafeText.Contains("denied", StringComparison.OrdinalIgnoreCase));

public bool IsStreamingPlaceholder => Role == ChatRole.Assistant && string.IsNullOrWhiteSpace(Text);
}
12 changes: 12 additions & 0 deletions src/OpenClaw.Companion/Services/IConfirmationDialogService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace OpenClaw.Companion.Services;

public interface IConfirmationDialogService
{
Task<bool> ConfirmAsync(string title, string message, string confirmText, string cancelText, CancellationToken cancellationToken);
}

public sealed class DenyConfirmationDialogService : IConfirmationDialogService
{
public Task<bool> ConfirmAsync(string title, string message, string confirmText, string cancelText, CancellationToken cancellationToken)
=> Task.FromResult(false);
}
107 changes: 107 additions & 0 deletions src/OpenClaw.Companion/Services/WindowConfirmationDialogService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Threading;

namespace OpenClaw.Companion.Services;

public sealed class WindowConfirmationDialogService(Window owner) : IConfirmationDialogService
{
public async Task<bool> ConfirmAsync(
string title,
string message,
string confirmText,
string cancelText,
CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
return false;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

var dialog = new Window
{
Title = title,
Width = 420,
SizeToContent = SizeToContent.Height,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
CanResize = false
};

var result = false;
var confirmButton = new Button
{
Content = confirmText,
MinWidth = 96,
HorizontalContentAlignment = HorizontalAlignment.Center
};
var cancelButton = new Button
{
Content = cancelText,
MinWidth = 96,
HorizontalContentAlignment = HorizontalAlignment.Center
};

confirmButton.Click += (_, _) =>
{
result = true;
dialog.Close();
};
cancelButton.Click += (_, _) =>
{
result = false;
dialog.Close();
};

dialog.Content = new StackPanel
{
Margin = new Avalonia.Thickness(20),
Spacing = 16,
Children =
{
new TextBlock
{
Text = message,
TextWrapping = Avalonia.Media.TextWrapping.Wrap
},
new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 8,
Children = { cancelButton, confirmButton }
}
}
};

var cancellationRequested = false;
dialog.Opened += (_, _) =>
{
if (cancellationRequested)
dialog.Close();
};

using var cancellationRegistration = cancellationToken.Register(() =>
Dispatcher.UIThread.Post(() =>
{
result = false;
cancellationRequested = true;
if (dialog.IsVisible)
dialog.Close();
}));

if (cancellationToken.IsCancellationRequested)
return false;

try
{
await dialog.ShowDialog(owner).WaitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
result = false;
if (dialog.IsVisible)
dialog.Close();
return false;
}

return result && !cancellationToken.IsCancellationRequested;
}
}
72 changes: 72 additions & 0 deletions src/OpenClaw.Companion/Styles/CompanionStyles.axaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<Styles xmlns="https://github.com/avaloniaui">
<Style Selector="TextBlock.page-title">
<Setter Property="FontSize" Value="22" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>

<Style Selector="TextBlock.section-title">
<Setter Property="FontSize" Value="15" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>

<Style Selector="TextBlock.muted">
<Setter Property="Opacity" Value="0.68" />
<Setter Property="FontSize" Value="12" />
</Style>

<Style Selector="Border.section-card">
<Setter Property="Padding" Value="14" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlForegroundBaseLowBrush}" />
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}" />
</Style>

<Style Selector="Border.metric-card">
<Setter Property="Padding" Value="12" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlForegroundBaseLowBrush}" />
<Setter Property="MinHeight" Value="84" />
</Style>

<Style Selector="Border.badge">
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Padding" Value="8,3" />
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundChromeMediumBrush}" />
</Style>

<Style Selector="TextBox.monospace">
<Setter Property="FontFamily" Value="Consolas, Menlo, monospace" />
<Setter Property="FontSize" Value="12" />
</Style>

<Style Selector="Button.primary">
<Setter Property="FontWeight" Value="SemiBold" />
</Style>

<Style Selector="Button.danger">
<Setter Property="Background" Value="#a61f1f" />
<Setter Property="Foreground" Value="White" />
<Setter Property="BorderBrush" Value="#a61f1f" />
</Style>

<Style Selector="Border.empty-state">
<Setter Property="Padding" Value="24" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlForegroundBaseLowBrush}" />
</Style>

<Style Selector="Border.error-banner">
<Setter Property="Padding" Value="10" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Background" Value="#3a1515" />
<Setter Property="BorderBrush" Value="#ff453a" />
<Setter Property="BorderThickness" Value="1" />
</Style>

<Style Selector="Border.error-banner TextBlock">
<Setter Property="Foreground" Value="White" />
</Style>
</Styles>
20 changes: 20 additions & 0 deletions src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Approvals.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ private void NotifyPendingApprovalsChanged()
OnPropertyChanged(nameof(QueueSeverity));
OnPropertyChanged(nameof(QueueSeverityIsLight));
OnPropertyChanged(nameof(QueueSeverityIsHeavy));
OnPropertyChanged(nameof(PendingApprovalsBadge));
}

[RelayCommand(CanExecute = nameof(CanRefreshApprovals))]
Expand Down Expand Up @@ -363,6 +364,25 @@ internal void MergePendingApprovals(IReadOnlyList<PendingApprovalItem> latest)

private async Task SubmitApprovalDecisionAsync(PendingApprovalItem item, bool approved)
{
if (!approved)
{
var confirmed = await ConfirmMutationAsync(
"Deny approval",
$"Deny '{item.ToolName}' from {item.Origin}?",
"Deny");
if (!confirmed)
return;
}
else if (item.IsMutation)
{
var confirmed = await ConfirmMutationAsync(
"Approve mutation",
$"Approve mutation tool '{item.ToolName}' from {item.Origin}? Review the arguments before continuing.",
"Approve");
if (!confirmed)
return;
}

using var client = CreateAdminClient(out var error);
if (client is null)
{
Expand Down
Loading