diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
index 52aa513d3..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,
});
@@ -2215,14 +2228,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 +2244,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
@@ -2253,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 0c9e9cb72..9714e31a0 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;
@@ -241,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,
};
@@ -402,15 +409,24 @@ 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";
+ })
+ // "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: 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();
Element composer = (effectiveThread is not null && !suppressComposer)
@@ -419,7 +435,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 +447,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/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 0353c6d22..5be851dd8 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, 2, 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(8, 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());
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);
}
}