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
138 changes: 130 additions & 8 deletions src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider

public string DisplayName => "OpenClaw gateway";

/// <summary>Last-known chat state from a previous session, used for pre-connection UI.</summary>
internal LastChatState? CachedLastChatState => _lastChatState;

public event EventHandler<ChatDataChangedEventArgs>? Changed;
public event EventHandler<ChatProviderNotificationEventArgs>? NotificationRequested;

Expand Down Expand Up @@ -147,12 +150,17 @@ internal OpenClawChatDataProvider(IChatGatewayBridge bridge, Action<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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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,
Expand All @@ -2233,6 +2244,40 @@ private static ChatThread ToThread(SessionInfo s)
};
}

/// <summary>
/// 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.
/// </summary>
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
Expand All @@ -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<LastChatState>(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)
Expand Down
45 changes: 29 additions & 16 deletions src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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)),
Expand Down
36 changes: 25 additions & 11 deletions src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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).
Expand Down Expand Up @@ -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)
{
Expand All @@ -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);
}
Expand Down Expand Up @@ -1367,6 +1369,7 @@ Element PhantomChevron() => Caption("▸")
{
var next = new HashSet<string>(expandedToolChips.Value);
if (!next.Add(token)) next.Remove(token);
suppressAutoFollowRef.Current = true;
expandedToolChips.Set(next);
};

Expand Down Expand Up @@ -1619,7 +1622,12 @@ Element AnchorLeft(Element card) => Grid(
Action toggleSummary = () =>
{
var next = new HashSet<string>(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);
};

Expand Down Expand Up @@ -1806,6 +1814,7 @@ string Truncate(string s, int max)
{
var next = new HashSet<string>(expandedToolChips.Value);
if (!next.Add(taskListToken)) next.Remove(taskListToken);
suppressAutoFollowRef.Current = true;
expandedToolChips.Set(next);
};

Expand Down Expand Up @@ -2306,6 +2315,11 @@ bool BurstIsNestable(System.Collections.Generic.List<ChatTimelineItem> 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),
Expand Down
Loading
Loading