From de0c3db9dde99f13c9cbe94b1cd36fa8786e871e Mon Sep 17 00:00:00 2001 From: Christine Yan Date: Thu, 21 May 2026 13:58:35 -0400 Subject: [PATCH 1/2] fix(chat): ID-based grouped session dropdown Replace title-based session dropdown with ID-based selection to fix sessions with identical DisplayNames being collapsed by .Distinct(). - Remove .Distinct() on channel titles that silently dropped sessions - Group sessions by agent (main/assistant) with disabled header items - Build ComboBox directly with ComboBoxItem objects for grouped layout - InlinePill flyout also shows grouped channel sections - Add BuildSessionTitle() for human-readable disambiguation - Exclude cron sessions from dropdown via :cron: key filter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Chat/OpenClawChatDataProvider.cs | 42 ++++++- .../Chat/OpenClawChatRoot.cs | 35 +++--- .../Chat/OpenClawComposer.cs | 109 +++++++++++++----- 3 files changed, 140 insertions(+), 46 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index 52aa513d3..54b2cbf3d 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -2215,14 +2215,12 @@ private ChatDataSnapshot BuildSnapshotLocked() private static ChatThread ToThread(SessionInfo s) { + var title = BuildSessionTitle(s); + return new ChatThread { - // SessionInfo.Key is the canonical gateway session key; we trust - // it as-is rather than substituting a literal like "main". Id = s.Key ?? string.Empty, - Title = !string.IsNullOrWhiteSpace(s.DisplayName) - ? s.DisplayName! - : (s.IsMain ? "Main session" : s.ShortKey), + Title = title, Status = ChatThreadStatus.Running, Activity = string.IsNullOrEmpty(s.CurrentActivity) ? ChatActivity.Idle : ChatActivity.Working, Workspace = s.Channel, @@ -2233,6 +2231,40 @@ private static ChatThread ToThread(SessionInfo s) }; } + /// + /// Builds a human-readable title from the session key and display name. + /// Keys follow the pattern agent:{agentId}:{sessionSlot} (e.g. agent:main:main, agent:assistant:main). + /// When a DisplayName is set, we append the agent/slot as a qualifier to disambiguate + /// sessions that share the same DisplayName. + /// + private static string BuildSessionTitle(SessionInfo s) + { + var baseName = !string.IsNullOrWhiteSpace(s.DisplayName) + ? s.DisplayName! + : (s.IsMain ? "Main session" : s.ShortKey); + + // Parse agent:agentId:sessionSlot from the key + var parts = (s.Key ?? "").Split(':'); + if (parts.Length >= 3 && parts[0] == "agent") + { + var agentId = parts[1]; // e.g. "main", "assistant" + var sessionSlot = parts[2]; // e.g. "main", "assistant", "cron" + + // For the canonical main session (agent:main:main), just show the base name + if (agentId == "main" && sessionSlot == "main") + return baseName; + + // Otherwise, qualify with agent/slot to distinguish + var qualifier = agentId == sessionSlot + ? agentId // e.g. "assistant" when both match + : $"{agentId}/{sessionSlot}"; // e.g. "assistant/main" + + return $"{baseName} ({qualifier})"; + } + + return baseName; + } + private static DateTimeOffset ToOffset(DateTime dt) { // SessionInfo.StartedAt/UpdatedAt arrive as DateTimeKind.Local or diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs index 0c9e9cb72..e1110c9ca 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs @@ -7,6 +7,8 @@ using OpenClawTray.FunctionalUI.Core; using OpenClawTray.Chat.Explorations; using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using static OpenClawTray.FunctionalUI.Factories; using static OpenClawTray.FunctionalUI.Core.Theme; @@ -402,15 +404,21 @@ Element BuildLoadingElement() OnStopSpeaking: _onStopSpeaking, ScrollToBottomToken: scrollToBottomToken.Value))); - // Distinct list of channel labels (= thread titles) — feeds the - // composer's first ComboBox so the user can switch chats from the - // composer, not just the side rail. Exclude cron sessions which - // are automated/background and shouldn't appear in the chat switcher. - var channelTitles = snapshot.Threads + // Session list for the composer dropdown — grouped by agent, keyed by + // ID so every session gets its own entry regardless of display name. + // Exclude cron sessions which are automated/background. + var channelGroups = snapshot.Threads .Where(t => !string.IsNullOrEmpty(t.Title) && !t.Id.Contains(":cron:", StringComparison.Ordinal)) - .Select(t => t.Title) - .Distinct(StringComparer.Ordinal) + .GroupBy(t => + { + // Parse agent ID from key like "agent:{agentId}:{slot}" + var parts = (t.Id ?? "").Split(':'); + return parts.Length >= 3 && parts[0] == "agent" ? parts[1] : "other"; + }) + .Select(g => new ChannelGroup( + AgentLabel: char.ToUpper(g.Key[0]) + g.Key[1..], + Sessions: g.Select(t => (Id: t.Id, Title: t.Title!)).ToArray())) .ToArray(); Element composer = (effectiveThread is not null && !suppressComposer) @@ -419,7 +427,8 @@ Element BuildLoadingElement() TurnActive: turnActiveOverride, PendingPermission: pendingPermissionOverride, ChannelLabel: effectiveThread.Title ?? "Main session", - AvailableChannels: channelTitles, + ChannelId: effectiveThread.Id, + AvailableChannels: channelGroups, AvailableModels: snapshot.AvailableModels, CurrentModel: effectiveThread.Model, CurrentThinkingLevel: effectiveThread.ThinkingLevel, @@ -430,14 +439,10 @@ Element BuildLoadingElement() }, OnStop: () => OnStop(effectiveThread.Id), OnPermissionResponse: (rid, allow) => OnPermission(effectiveThread.Id, rid, allow), - OnChannelChanged: title => + OnChannelChanged: id => { - var match = Array.Find(snapshot.Threads, t => t.Title == title); - if (match is not null) - { - selectedIdState.Set(match.Id); - selectedIdRef.Current = match.Id; - } + selectedIdState.Set(id); + selectedIdRef.Current = id; }, OnModelChanged: model => RunFireAndForget(ct => _provider.SetModelAsync(effectiveThread.Id, model, ct)), OnThinkingLevelChanged: level => RunFireAndForget(ct => _provider.SetThinkingLevelAsync(effectiveThread.Id, level, ct)), diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs index 0353c6d22..dbb929654 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs @@ -11,6 +11,7 @@ using OpenClawTray.Chat.Explorations; using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Windows.UI; using static OpenClawTray.FunctionalUI.Factories; @@ -37,12 +38,15 @@ namespace OpenClawTray.Chat; /// banner that InputBar used to render are preserved here above the /// composer. /// +public record ChannelGroup(string AgentLabel, (string Id, string Title)[] Sessions); + public record OpenClawComposerProps( string ConnectionState, bool TurnActive, ChatPermissionRequest? PendingPermission, string ChannelLabel, - string[] AvailableChannels, + string? ChannelId, + ChannelGroup[] AvailableChannels, string[] AvailableModels, string? CurrentModel, string? CurrentThinkingLevel, @@ -200,25 +204,66 @@ public override Element Render() }; // ── Row 1: three compact dropdowns ───────────────────────────── - var channelOptions = Props.AvailableChannels is { Length: > 0 } - ? Props.AvailableChannels - : new[] { Props.ChannelLabel ?? "main" }; - var channelIndex = Array.IndexOf(channelOptions, Props.ChannelLabel ?? ""); - if (channelIndex < 0) channelIndex = 0; - var channelCombo = ComboBox(channelOptions, channelIndex, idx => + // Build grouped session ComboBox directly (bypassing the FunctionalUI + // ComboBox helper which only supports flat string[] items). + var groups = Props.AvailableChannels; + var channelCombo = Border() + .Set(border => { - if (idx >= 0 && idx < channelOptions.Length) - Props.OnChannelChanged(channelOptions[idx]); - }) - .Set(cb => - { - cb.MinWidth = 0; - cb.Width = Props.IsCompact ? 180 : 200; - cb.Height = 28; - cb.FontSize = 11; - cb.Padding = new Thickness(8, 0, 4, 0); - cb.CornerRadius = composerCornerRadius; - }).VAlign(VerticalAlignment.Center); + var cb = new ComboBox + { + MinWidth = 0, + Width = Props.IsCompact ? 180 : 200, + Height = 28, + FontSize = 11, + Padding = new Thickness(8, 0, 4, 0), + CornerRadius = composerCornerRadius, + VerticalAlignment = VerticalAlignment.Center, + }; + + ComboBoxItem? selectedItem = null; + foreach (var group in groups) + { + if (groups.Length > 1) + { + cb.Items.Add(new ComboBoxItem + { + Content = group.AgentLabel, + IsEnabled = false, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + FontSize = 10, + Padding = new Thickness(4, 6, 4, 2), + IsHitTestVisible = false, + }); + } + foreach (var session in group.Sessions) + { + var item = new ComboBoxItem + { + Content = session.Title, + Tag = session.Id, + Padding = groups.Length > 1 + ? new Thickness(16, 4, 4, 4) + : new Thickness(4, 4, 4, 4), + }; + cb.Items.Add(item); + if (session.Id == (Props.ChannelId ?? "")) + selectedItem = item; + } + } + + if (selectedItem != null) + cb.SelectedItem = selectedItem; + + var onChanged = Props.OnChannelChanged; + cb.SelectionChanged += (_, _) => + { + if (cb.SelectedItem is ComboBoxItem { Tag: string id }) + onChanged(id); + }; + + border.Child = cb; + }); var models = Props.AvailableModels; var modelIndex = models is { Length: > 0 } && Props.CurrentModel is { } cur @@ -886,14 +931,26 @@ Element IconButton(string glyph, string tip, Action onClick, Brush? foreground = var headerWeight = Microsoft.UI.Text.FontWeights.SemiBold; menuItems.Add(MenuItem("Channel") with { IsEnabled = false, Padding = headerPad, FontWeight = headerWeight }); - foreach (var ch in channelOptions) + foreach (var group in Props.AvailableChannels) { - var name = ch; - menuItems.Add(RadioMenuItem( - name, - "channel", - isChecked: name == channelLabel, - onClick: () => Props.OnChannelChanged(name))); + if (Props.AvailableChannels.Length > 1) + { + menuItems.Add(MenuItem($" {group.AgentLabel}") with + { + IsEnabled = false, + Padding = new Thickness(0, 2, 8, 0), + FontWeight = Microsoft.UI.Text.FontWeights.Normal, + }); + } + foreach (var ch in group.Sessions) + { + var id = ch.Id; + menuItems.Add(RadioMenuItem( + ch.Title, + "channel", + isChecked: id == (Props.ChannelId ?? ""), + onClick: () => Props.OnChannelChanged(id))); + } } menuItems.Add(MenuSeparator()); From a482ca9e17132a1e685b79b87c0765e61a12cacf Mon Sep 17 00:00:00 2001 From: Christine Yan Date: Thu, 21 May 2026 22:02:44 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(chat):=20UI=20polish=20=E2=80=94=20cach?= =?UTF-8?q?ing,=20alignment,=20scroll,=20and=20multi-agent=20nav?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cache last-known chat state (title/model) for pre-connection UI - Sort 'main' agent first in session dropdown - Fix assistant bubble alignment for continuation messages (invisible spacer) - Fix user message clipping behind scrollbar (right margin 8→20px) - Suppress auto-scroll when expanding/collapsing tool call cards - Reduce dropdown header whitespace for symmetry - Per-agent workspace file cache to avoid re-fetching on tab switch - Fix agents nav not showing agents that arrived before HubWindow opened - Request agents list on connect in GatewayService - Thread-safe agent files cache with lock Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Chat/OpenClawChatDataProvider.cs | 96 ++++++++++++++++++- .../Chat/OpenClawChatRoot.cs | 12 ++- .../Chat/OpenClawChatTimeline.cs | 36 ++++--- .../Chat/OpenClawComposer.cs | 4 +- .../Pages/WorkspacePage.xaml.cs | 13 ++- src/OpenClaw.Tray.WinUI/Services/AppState.cs | 17 ++++ .../Services/GatewayService.cs | 17 +++- .../Windows/HubWindow.xaml.cs | 4 + 8 files changed, 179 insertions(+), 20 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs index 54b2cbf3d..aa55c32c0 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs @@ -120,6 +120,9 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider public string DisplayName => "OpenClaw gateway"; + /// Last-known chat state from a previous session, used for pre-connection UI. + internal LastChatState? CachedLastChatState => _lastChatState; + public event EventHandler? Changed; public event EventHandler? NotificationRequested; @@ -147,12 +150,17 @@ internal OpenClawChatDataProvider(IChatGatewayBridge bridge, Action? pos _status = bridge.CurrentStatus; _persistedAbortedIds = LoadAbortedIds(); _toolMetaCache = LoadToolMetaCache(_toolMetaCacheFilePath); + _lastChatState = LoadLastChatState(); // Seed models from whatever the bridge already knows about (a connect // that completed before the provider was constructed will have its // models.list snapshot cached on the bridge). if (bridge.GetCurrentModelsList() is { } seedModels) _availableModels = ExtractModelNames(seedModels); + // Fall back to last-known models so the composer shows a real model + // name while reconnecting instead of the generic "model" placeholder. + else if (_lastChatState?.AvailableModels is { Length: > 0 } cached) + _availableModels = cached; _bridge.StatusChanged += OnStatusChanged; _bridge.SessionsUpdated += OnSessionsUpdated; @@ -801,13 +809,17 @@ public ValueTask DisposeAsync() if (_disposed) return ValueTask.CompletedTask; _disposed = true; System.Threading.Timer? timerToDispose; + System.Threading.Timer? chatStateTimerToDispose; lock (_gate) { timerToDispose = _toolMetaSaveTimer; _toolMetaSaveTimer = null; _toolMetaSaveVersion++; + chatStateTimerToDispose = _lastChatStateSaveTimer; + _lastChatStateSaveTimer = null; } timerToDispose?.Dispose(); + chatStateTimerToDispose?.Dispose(); SaveToolMetaCache(); _bridge.StatusChanged -= OnStatusChanged; _bridge.SessionsUpdated -= OnSessionsUpdated; @@ -2150,7 +2162,8 @@ private ChatDataSnapshot BuildSnapshotLocked() threadList.Add(new ChatThread { Id = ck, - Title = "Main session", + Title = _lastChatState?.ThreadTitle ?? "Main session", + Model = _lastChatState?.Model, Status = ChatThreadStatus.Running, Activity = ChatActivity.Idle, }); @@ -2285,9 +2298,86 @@ private void Publish(ChatDataSnapshot snapshot) if (_post is null) { Changed?.Invoke(this, args); - return; } - _post(() => Changed?.Invoke(this, args)); + else + { + _post(() => Changed?.Invoke(this, args)); + } + + // Debounce-save last-known UI state so the next launch can show + // meaningful labels while reconnecting instead of "Main session"/"model". + if (snapshot.Threads.Length > 0 || snapshot.AvailableModels.Length > 0) + DebounceSaveLastChatState(snapshot); + } + + // ── Last-chat-state cache ────────────────────────────────────────── + // Persists the last-known thread title, model, and available models so + // the UI can show them while reconnecting instead of generic placeholders. + + private static readonly string LastChatStateFilePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "OpenClawTray", "last-chat-state.json"); + + private System.Threading.Timer? _lastChatStateSaveTimer; + + internal sealed class LastChatState + { + public string? DefaultThreadId { get; set; } + public string? ThreadTitle { get; set; } + public string? Model { get; set; } + public string[]? AvailableModels { get; set; } + } + + private LastChatState? _lastChatState; + + internal static LastChatState? LoadLastChatState() + { + try + { + if (!File.Exists(LastChatStateFilePath)) return null; + var json = File.ReadAllText(LastChatStateFilePath); + return System.Text.Json.JsonSerializer.Deserialize(json); + } + catch { return null; } + } + + private void DebounceSaveLastChatState(ChatDataSnapshot snapshot) + { + // Find the default thread to capture its title/model + var defaultThread = snapshot.DefaultThreadId is { } dtId + ? Array.Find(snapshot.Threads, t => t.Id == dtId) + : snapshot.Threads.Length > 0 ? snapshot.Threads[0] : null; + + if (defaultThread is null && snapshot.AvailableModels.Length == 0) return; + + var state = new LastChatState + { + DefaultThreadId = snapshot.DefaultThreadId, + ThreadTitle = defaultThread?.Title, + Model = defaultThread?.Model, + AvailableModels = snapshot.AvailableModels, + }; + + lock (_gate) + { + _lastChatState = state; + _lastChatStateSaveTimer?.Dispose(); + _lastChatStateSaveTimer = new System.Threading.Timer(_ => SaveLastChatState(state), null, 2000, Timeout.Infinite); + } + } + + private static void SaveLastChatState(LastChatState state) + { + try + { + var dir = Path.GetDirectoryName(LastChatStateFilePath); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + var json = System.Text.Json.JsonSerializer.Serialize(state); + var tmp = LastChatStateFilePath + ".tmp"; + File.WriteAllText(tmp, json); + File.Move(tmp, LastChatStateFilePath, overwrite: true); + } + catch { } } private void RaiseNotification(ChatProviderNotification notification) diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs index e1110c9ca..9714e31a0 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs @@ -243,10 +243,15 @@ Element BuildLoadingElement() && snapshot.ComposeTarget.IsReady && snapshot.ComposeTarget.SessionKey is { } composeKey) { + // Use last-known state from the data provider so the composer shows + // the previous session title/model while reconnecting instead of + // generic "Main session"/"model" placeholders. + var lastState = (_provider as OpenClawChatDataProvider)?.CachedLastChatState; composeOnlyThread = new ChatThread { Id = composeKey, - Title = "Main session", + Title = lastState?.ThreadTitle ?? "Main session", + Model = lastState?.Model, Status = ChatThreadStatus.Running, Activity = ChatActivity.Idle, }; @@ -416,8 +421,11 @@ Element BuildLoadingElement() var parts = (t.Id ?? "").Split(':'); return parts.Length >= 3 && parts[0] == "agent" ? parts[1] : "other"; }) + // "main" first (sort key 0), then alphabetical + .OrderBy(g => g.Key.Equals("main", StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase) .Select(g => new ChannelGroup( - AgentLabel: char.ToUpper(g.Key[0]) + g.Key[1..], + AgentLabel: g.Key.Length > 0 ? char.ToUpper(g.Key[0]) + g.Key[1..] : "Unknown", Sessions: g.Select(t => (Id: t.Id, Title: t.Title!)).ToArray())) .ToArray(); diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs index 3a086bf1f..1f8750244 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs @@ -1041,7 +1041,7 @@ Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst VStack(2, bubbleRow, footer) .HAlign(HorizontalAlignment.Stretch) ).Background(new SolidColorBrush(Colors.Transparent)) - .Margin(gutter, topMargin, 16, bottomMargin), + .Margin(gutter, topMargin, 20, bottomMargin), entry.Id); } @@ -1061,12 +1061,15 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends return Empty(); // Avatar shown only on the FIRST entry of a contiguous agent-side - // run. Continuation entries get no spacer — they align flush with - // the tool burst cards above (which also sit at the left inset), - // so the agent column reads as a single vertical edge. - Element leftSlot = !showAssistAvatar || !showAvatar - ? Empty() - : AssistantAvatar().VAlign(VerticalAlignment.Top); + // run. Continuation entries get an invisible spacer the same width + // as the avatar so all bubbles stay left-aligned in a uniform column. + Element leftSlot; + if (!showAssistAvatar) + leftSlot = Empty(); + else if (showAvatar) + leftSlot = AssistantAvatar().VAlign(VerticalAlignment.Top); + else + leftSlot = Border(Empty()).Set(b => { b.Width = 36; b.Height = 0; }); // Assistant bubble — subtle gray with primary text. Radius/Padding // come from ChatExplorationState (BubbleCornerRadius + PaddingDensity). @@ -1103,10 +1106,9 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends var bubbleRow = Grid( [GridSize.Auto, GridSize.Star()], [GridSize.Auto], - leftSlot.Grid(row: 0, column: 0).Margin(0, 0, showAssistAvatar && showAvatar ? bubbleSideMargin : 0, 0), + leftSlot.Grid(row: 0, column: 0).Margin(0, 0, showAssistAvatar ? bubbleSideMargin : 0, 0), card.HAlign(HorizontalAlignment.Left).Grid(row: 0, column: 1) ).HAlign(HorizontalAlignment.Stretch); - Element footer = Empty(); if (endsBurst && showTimestamps) { @@ -1117,7 +1119,7 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends entryMeta?.InputTokens, entryMeta?.OutputTokens, entryMeta?.ResponseTokens, entryMeta?.ContextPercent, chatStampFg, entry.Id, entry.Text ?? ""); - var leftInset = (showAssistAvatar && showAvatar) ? (36 + bubbleSideMargin) : 0; + var leftInset = showAssistAvatar ? (36 + bubbleSideMargin) : 0; leftInset += (int)bubblePadding.Left; footer = footer.Margin(leftInset, 2, 0, 0); } @@ -1367,6 +1369,7 @@ Element PhantomChevron() => Caption("▸") { var next = new HashSet(expandedToolChips.Value); if (!next.Add(token)) next.Remove(token); + suppressAutoFollowRef.Current = true; expandedToolChips.Set(next); }; @@ -1619,7 +1622,12 @@ Element AnchorLeft(Element card) => Grid( Action toggleSummary = () => { var next = new HashSet(expandedToolChips.Value); - if (!next.Add(summaryToken)) next.Remove(summaryToken); + var expanding = next.Add(summaryToken); + if (!expanding) next.Remove(summaryToken); + // Suppress auto-follow so the scroll position stays put + // while the card unfurls — the SizeChanged handler would + // otherwise chase the new bottom. + suppressAutoFollowRef.Current = true; expandedToolChips.Set(next); }; @@ -1806,6 +1814,7 @@ string Truncate(string s, int max) { var next = new HashSet(expandedToolChips.Value); if (!next.Add(taskListToken)) next.Remove(taskListToken); + suppressAutoFollowRef.Current = true; expandedToolChips.Set(next); }; @@ -2306,6 +2315,11 @@ bool BurstIsNestable(System.Collections.Generic.List b) { QueueScrollToBottom(sv, prevSessionIdRef.Current, disableAnimation: true); } + else if (suppressAutoFollowRef.Current) + { + // Reset after one suppressed layout pass (e.g. tool expand/collapse). + suppressAutoFollowRef.Current = false; + } }; } }).Grid(row: 2, column: 0), diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs index dbb929654..5be851dd8 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs @@ -232,7 +232,7 @@ public override Element Render() IsEnabled = false, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, FontSize = 10, - Padding = new Thickness(4, 6, 4, 2), + Padding = new Thickness(4, 2, 4, 2), IsHitTestVisible = false, }); } @@ -244,7 +244,7 @@ public override Element Render() Tag = session.Id, Padding = groups.Length > 1 ? new Thickness(16, 4, 4, 4) - : new Thickness(4, 4, 4, 4), + : new Thickness(8, 4, 4, 4), }; cb.Items.Add(item); if (session.Id == (Props.ChannelId ?? "")) diff --git a/src/OpenClaw.Tray.WinUI/Pages/WorkspacePage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/WorkspacePage.xaml.cs index 8c539d08d..3ac590049 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/WorkspacePage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/WorkspacePage.xaml.cs @@ -33,7 +33,14 @@ public void Initialize() { _appState = CurrentApp.AppState; _appState.PropertyChanged += OnAppStateChanged; - // Only request fresh data when no matching cache exists. + + // Check per-agent cache first, then fall back to single-slot cache + if (_appState.TryGetCachedAgentFilesList(AgentId, out var cachedData)) + { + UpdateAgentFilesList(cachedData); + return; + } + var hasMatchingCache = _appState?.AgentFilesList.HasValue == true && string.Equals(_appState?.AgentFilesListAgentId, AgentId, StringComparison.OrdinalIgnoreCase); var status = CurrentApp.AppState?.Status ?? OpenClaw.Shared.ConnectionStatus.Disconnected; @@ -45,6 +52,10 @@ public void Initialize() ClearTabs(); _ = CurrentApp.GatewayClient.RequestAgentFilesListAsync(AgentId); } + else if (hasMatchingCache) + { + UpdateAgentFilesList(_appState!.AgentFilesList!.Value); + } else if (CurrentApp.GatewayClient == null || status != OpenClaw.Shared.ConnectionStatus.Connected) { FallbackInfoBar.IsOpen = true; diff --git a/src/OpenClaw.Tray.WinUI/Services/AppState.cs b/src/OpenClaw.Tray.WinUI/Services/AppState.cs index abd5d98e0..c16068ea8 100644 --- a/src/OpenClaw.Tray.WinUI/Services/AppState.cs +++ b/src/OpenClaw.Tray.WinUI/Services/AppState.cs @@ -131,6 +131,22 @@ private bool SetField(ref T field, T value, [CallerMemberName] string? name = private string? _agentFilesListAgentId; public string? AgentFilesListAgentId { get => _agentFilesListAgentId; set => SetField(ref _agentFilesListAgentId, value); } + // Per-agent workspace file cache so switching agents doesn't re-fetch. + private readonly Dictionary _agentFilesCache = new(StringComparer.OrdinalIgnoreCase); + private readonly object _agentFilesCacheLock = new(); + + /// Cache workspace file list for a specific agent. + public void CacheAgentFilesList(string agentId, JsonElement data) + { + lock (_agentFilesCacheLock) _agentFilesCache[agentId] = data; + } + + /// Try to get cached workspace file list for an agent. + public bool TryGetCachedAgentFilesList(string agentId, out JsonElement data) + { + lock (_agentFilesCacheLock) return _agentFilesCache.TryGetValue(agentId, out data); + } + private JsonElement? _cronList; public JsonElement? CronList { get => _cronList; set => SetField(ref _cronList, value); } @@ -260,6 +276,7 @@ public void ClearCachedData() SkillsAgentId = null; AgentFilesList = null; AgentFilesListAgentId = null; + lock (_agentFilesCacheLock) _agentFilesCache.Clear(); AgentFileContent = null; CronList = null; CronStatus = null; diff --git a/src/OpenClaw.Tray.WinUI/Services/GatewayService.cs b/src/OpenClaw.Tray.WinUI/Services/GatewayService.cs index 3230c7de0..e9d6af0e4 100644 --- a/src/OpenClaw.Tray.WinUI/Services/GatewayService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/GatewayService.cs @@ -174,6 +174,12 @@ private void OnConnectionStatusChanged(object? sender, ConnectionStatus status) status = status.ToString() }); + // Request agents list on connect so the nav pane can populate. + if (status == ConnectionStatus.Connected && sender is IOperatorGatewayClient client) + { + _ = client.RequestAgentsListAsync(); + } + EnqueueModelUpdate(() => { if (status == ConnectionStatus.Connected) @@ -464,7 +470,16 @@ private void OnAgentFilesListUpdated(object? sender, JsonElement data) { if (sender != _currentClient) return; var cloned = data.Clone(); - EnqueueModelUpdate(() => _state.AgentFilesList = cloned); + var agentId = cloned.TryGetProperty("agentId", out var aidEl) ? aidEl.GetString() : null; + EnqueueModelUpdate(() => + { + _state.AgentFilesList = cloned; + if (!string.IsNullOrEmpty(agentId)) + { + _state.AgentFilesListAgentId = agentId; + _state.CacheAgentFilesList(agentId!, cloned); + } + }); } private void OnAgentFileContentUpdated(object? sender, JsonElement data) diff --git a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs index 49d282825..7e22dd14c 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs @@ -102,6 +102,10 @@ internal void BindToAppState() AppModel.PropertyChanged += OnAppModelChanged; UpdateTitleBarStatus(AppModel.Status); ScheduleGatewayNavVisibilityForStatus(AppModel.Status, debounceDisconnected: false); + + // Apply agents list that may have arrived before this window opened. + if (AppModel.AgentsList.HasValue) + RebuildAgentNavItems(AppModel.AgentsList.Value); } }