Skip to content
Open
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
7 changes: 7 additions & 0 deletions src/OpenClaw.Shared/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ public class OpenClawNotification
public string? Agent { get; set; } // agent name/identifier
public string? Intent { get; set; } // normalized intent (reminder, build, alert)
public string[]? Tags { get; set; } // free-form routing tags

/// <summary>
/// The session key associated with this notification (e.g. the chat session
/// that produced an assistant response). Used by toast activation to open
/// the specific session instead of the default/main session.
/// </summary>
public string? SessionKey { get; set; }
}

/// <summary>
Expand Down
9 changes: 5 additions & 4 deletions src/OpenClaw.Shared/OpenClawGatewayClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2560,7 +2560,7 @@ private void HandleChatEvent(JsonElement root, int rawMessageLength)
// HIGH 4: log shape only — content previously
// surfaced in the operator log.
_logger.Info($"Assistant response: role={role} state={state} len={text.Length}");
EmitChatNotification(text);
EmitChatNotification(text, sessionKey);
}
}
}
Expand All @@ -2580,7 +2580,7 @@ private void HandleChatEvent(JsonElement root, int rawMessageLength)
{
// HIGH 4: log shape only.
_logger.Info($"Assistant response (legacy): role={role} state={state} len={text.Length}");
EmitChatNotification(text);
EmitChatNotification(text, sessionKey);
}
}
}
Expand Down Expand Up @@ -2691,13 +2691,14 @@ private void EmitRawChatEvent(JsonElement payload)
}
}

private void EmitChatNotification(string text)
private void EmitChatNotification(string text, string? sessionKey = null)
{
var displayText = text.Length > 200 ? text[..200] + "…" : text;
var notification = new OpenClawNotification
{
Message = displayText,
IsChat = true
IsChat = true,
SessionKey = sessionKey
};
var (title, type) = _categorizer.Classify(notification, _userRules);
notification.Title = title;
Expand Down
2 changes: 1 addition & 1 deletion src/OpenClaw.Tray.WinUI/App.ToastActivation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ private void OnToastActivated(ToastNotificationActivatedEventArgsCompat args)
},
OpenDashboard = () => OpenDashboard(),
OpenSettings = ShowSettings,
OpenChat = ShowWebChat,
OpenChat = sessionKey => ShowWebChat(sessionKey),
OpenActivity = () => ShowHub("channels"),
CopyPairingCommand = command =>
{
Expand Down
35 changes: 29 additions & 6 deletions src/OpenClaw.Tray.WinUI/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ public partial class App : Application, OpenClawTray.Services.IAppCommands
/// <summary>The full device ID of the local node service (if running).</summary>
internal string? NodeFullDeviceId => _nodeService?.FullDeviceId;

/// <summary>
/// Session key that the chat surface should select on its next mount.
/// Used when the user clicks a session from SessionsPage or a notification
/// while the HubWindow may not yet exist. Consumed (cleared) by ChatPage.
/// </summary>
public string? PendingChatSessionKey { get; set; }

public OpenClawTray.Chat.OpenClawChatDataProvider? ChatProvider => _chatCoordinator?.Provider;

/// <summary>
Expand Down Expand Up @@ -2411,10 +2418,15 @@ private void OnGatewayNotificationReceived(object? sender, OpenClawNotification

if (notification.IsChat)
{
builder.AddArgument("action", "open_chat")
.AddButton(new ToastButton()
.SetContent("Open Chat")
.AddArgument("action", "open_chat"));
builder.AddArgument("action", "open_chat");
if (!string.IsNullOrEmpty(notification.SessionKey))
{
builder.AddArgument("sessionKey", notification.SessionKey);
}
builder.AddButton(new ToastButton()
.SetContent("Open Chat")
.AddArgument("action", "open_chat")
.AddArgument("sessionKey", notification.SessionKey ?? ""));
}

_toastService!.ShowToast(builder);
Expand Down Expand Up @@ -2805,7 +2817,7 @@ private void OnSettingsSaved(object? sender, EventArgs e)
}
}

private void ShowWebChat()
private void ShowWebChat(string? sessionKey = null)
{
if (_settings == null) return;
if (!TryResolveChatCredentials(out _, out _, out _, out var isBootstrapToken))
Expand All @@ -2824,6 +2836,17 @@ private void ShowWebChat()
return;
}

// Stash the session key on both App (fallback when HubWindow doesn't exist)
// and HubWindow (existing path) so ChatPage can pick it up after navigation.
if (!string.IsNullOrEmpty(sessionKey))
{
PendingChatSessionKey = sessionKey;
if (_hubWindow != null)
{
_hubWindow.PendingChatSessionKey = sessionKey;
}
}

ShowHub("chat");
}

Expand Down Expand Up @@ -3896,7 +3919,7 @@ private void HandleDeepLink(string uri)
CopyActivitySummary = _diagnosticsClipboard!.CopyActivitySummary,
CopyExtensibilitySummary = _diagnosticsClipboard!.CopyExtensibilitySummary,
RestartSshTunnel = RestartSshTunnel,
OpenChat = ShowWebChat,
OpenChat = () => ShowWebChat(),
OpenCommandCenter = ShowStatusDetail,
OpenTrayMenu = ShowTrayMenuPopup,
OpenActivityStream = ShowActivityStream,
Expand Down
49 changes: 38 additions & 11 deletions src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public sealed partial class ChatPage : Page
private global::Windows.Foundation.TypedEventHandler<CoreWebView2, CoreWebView2NavigationCompletedEventArgs>? _navCompletedHandler;
private global::Windows.Foundation.TypedEventHandler<CoreWebView2, CoreWebView2NavigationStartingEventArgs>? _navStartingHandler;
private IGatewayConnectionManager? _connectionManager;
private string? _pendingWebViewSessionKey;
private static readonly HttpClient s_httpClient = new()
{
Timeout = TimeSpan.FromSeconds(3)
Expand Down Expand Up @@ -213,18 +214,20 @@ private void ShowFunctionalSurface()
var provider = app?.ChatProvider;
Func<string, Task>? readAloud = app is null ? null : app.SpeakChatTextAsync;

// Consume a pending session-key hand-off from SessionsPage so the
// chat root mounts with that thread selected. Any pending key forces
// a remount — _mountedThreadId only records what we asked for, not
// what the user later picked inside the composer's dropdown, so we
// cannot use it to detect "already on the right thread".
var pendingSessionKey = _hub?.PendingChatSessionKey;
if (pendingSessionKey is not null && _hub is not null)
// Consume a pending session-key hand-off from SessionsPage or a
// notification toast so the chat root mounts with that thread selected.
// Any pending key forces a remount — _mountedThreadId only records what
// we asked for, not what the user later picked inside the composer's
// dropdown, so we cannot use it to detect "already on the right thread".
var pendingSessionKey = _hub?.PendingChatSessionKey
?? (App.Current as App)?.PendingChatSessionKey;
if (!string.IsNullOrEmpty(pendingSessionKey))
{
_hub.PendingChatSessionKey = null;
if (_hub is not null) _hub.PendingChatSessionKey = null;
if (App.Current is App currentApp) currentApp.PendingChatSessionKey = null;
}
var threadIdToMount = pendingSessionKey ?? _mountedThreadId;
var forceRemount = pendingSessionKey is not null;
var forceRemount = !string.IsNullOrEmpty(pendingSessionKey);

if (_functionalHost is not null
&& ReferenceEquals(_mountedProvider, provider)
Expand Down Expand Up @@ -288,6 +291,20 @@ private void ShowFunctionalSurface()

private void ShowWebViewSurface(bool forceNavigate = false)
{
// Consume pending session key for WebView mode.
var pendingSessionKey = _hub?.PendingChatSessionKey
?? (App.Current as App)?.PendingChatSessionKey;
if (!string.IsNullOrEmpty(pendingSessionKey))
{
if (_hub is not null) _hub.PendingChatSessionKey = null;
if (App.Current is App currentApp) currentApp.PendingChatSessionKey = null;
_pendingWebViewSessionKey = pendingSessionKey;
}
else
{
_pendingWebViewSessionKey = null;
}

// Tear down native chat (so the WebView2 owns the row) and (re)init WebView2.
_webViewMode = true;
DisposeFunctionalHost();
Expand Down Expand Up @@ -325,9 +342,18 @@ private bool NavigateWebViewToCurrentChatUrl()
if (string.IsNullOrEmpty(_chatUrl) || WebView.CoreWebView2 is null)
return false;

var url = _chatUrl;
if (!string.IsNullOrEmpty(_pendingWebViewSessionKey))
{
var baseUrl = System.Text.RegularExpressions.Regex.Replace(_chatUrl, @"[&?]session=[^&]*", "");
var separator = baseUrl.Contains('?') ? "&" : "?";
url = $"{baseUrl}{separator}session={Uri.EscapeDataString(_pendingWebViewSessionKey)}";
_pendingWebViewSessionKey = null;
}

ErrorPanel.Visibility = Visibility.Collapsed;
WebView.Visibility = Visibility.Visible;
WebView.CoreWebView2.Navigate(_chatUrl);
WebView.CoreWebView2.Navigate(url);
return true;
}

Expand Down Expand Up @@ -392,7 +418,7 @@ private async Task InitializeWebViewAsync(SettingsManager settings)
return;
}

if (!GatewayChatHelper.TryBuildChatUrl(credential.GatewayUrl, credential.Token, out var chatUrl, out var errorMessage))
if (!GatewayChatHelper.TryBuildChatUrl(credential.GatewayUrl, credential.Token, out var chatUrl, out var errorMessage, _pendingWebViewSessionKey))
{
PlaceholderPanel.Visibility = Visibility.Collapsed;
ErrorPanel.Visibility = Visibility.Visible;
Expand All @@ -401,6 +427,7 @@ private async Task InitializeWebViewAsync(SettingsManager settings)
}

_chatUrl = chatUrl;
_pendingWebViewSessionKey = null;

PlaceholderPanel.Visibility = Visibility.Collapsed;
ErrorPanel.Visibility = Visibility.Collapsed;
Expand Down
3 changes: 3 additions & 0 deletions src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,9 @@ private void OnOpenChat(object sender, RoutedEventArgs e)
{
if (sender is Button btn && btn.Tag is string key)
{
// Stash the target session on both App (fallback when the HubWindow
// doesn't exist yet) and HubWindow (existing path consumed by ChatPage).
CurrentApp.PendingChatSessionKey = key;
if (CurrentApp.ActiveHubWindow is HubWindow hub)
{
hub.PendingChatSessionKey = key;
Expand Down
5 changes: 3 additions & 2 deletions src/OpenClaw.Tray.WinUI/Services/ToastActivationRouter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ public sealed class ToastActivationActions
public required Action<string> OpenUrl { get; init; }
public required Action OpenDashboard { get; init; }
public required Action OpenSettings { get; init; }
public required Action OpenChat { get; init; }
public required Action<string?> OpenChat { get; init; }
public required Action OpenActivity { get; init; }
public required Action<string> CopyPairingCommand { get; init; }
}
Expand Down Expand Up @@ -34,7 +34,8 @@ public static void Route(
actions.OpenSettings();
break;
case "open_chat":
actions.OpenChat();
var sessionKey = getArgument("sessionKey");
actions.OpenChat(sessionKey);
break;
case "open_activity":
actions.OpenActivity();
Expand Down
2 changes: 1 addition & 1 deletion tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ public void ToastActivation_RoutesOnUiThread()
Assert.Contains("ToastActivationRouter.Route", method);
Assert.Contains("OpenDashboard = () => OpenDashboard()", method);
Assert.Contains("OpenSettings = ShowSettings", method);
Assert.Contains("OpenChat = ShowWebChat", method);
Assert.Contains("OpenChat = sessionKey => ShowWebChat(sessionKey)", method);
Assert.Contains("OpenActivity = () => ShowHub(\"channels\")", method);
Assert.Contains("CopyPairingCommand = command =>", method);
}
Expand Down
22 changes: 20 additions & 2 deletions tests/OpenClaw.Tray.Tests/ToastActivationRouterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ public sealed class ToastActivationRouterTests
[Theory]
[InlineData("open_dashboard", "dashboard")]
[InlineData("open_settings", "settings")]
[InlineData("open_chat", "chat")]
[InlineData("open_chat", "chat:(null)")]
[InlineData("open_activity", "activity")]
public void Route_DispatchesSimpleActions(string action, string expected)
{
Expand Down Expand Up @@ -57,6 +57,24 @@ public void Route_CopyPairingCommand_RequiresCommandArgument()
Assert.Equal(["copy:openclaw pair approve abc"], calls);
}

[Fact]
public void Route_OpenChat_PassesSessionKeyArgument()
{
var calls = new List<string>();

ToastActivationRouter.Route(
"open_chat",
key => key == "sessionKey" ? "agent:main:scratch" : null,
BuildActions(calls));

ToastActivationRouter.Route(
"open_chat",
_ => null,
BuildActions(calls));

Assert.Equal(["chat:agent:main:scratch", "chat:(null)"], calls);
}

[Fact]
public void Route_UnknownAction_NoOps()
{
Expand All @@ -75,7 +93,7 @@ public void Route_UnknownAction_NoOps()
OpenUrl = url => calls.Add($"url:{url}"),
OpenDashboard = () => calls.Add("dashboard"),
OpenSettings = () => calls.Add("settings"),
OpenChat = () => calls.Add("chat"),
OpenChat = key => calls.Add($"chat:{key ?? "(null)"}"),
OpenActivity = () => calls.Add("activity"),
CopyPairingCommand = command => calls.Add($"copy:{command}")
};
Expand Down