Skip to content

Commit e59459c

Browse files
Remember native chat session selection
1 parent c3371d6 commit e59459c

3 files changed

Lines changed: 213 additions & 14 deletions

File tree

src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs

Lines changed: 88 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider
9494
private readonly object _attachmentMetaSaveGate = new();
9595
private readonly string _toolMetaCacheFilePath;
9696
private readonly string _attachmentMetaCacheFilePath;
97+
private readonly string _lastChatStateFilePath;
98+
private readonly TimeSpan _lastChatStateSaveDelay;
9799
private System.Threading.Timer? _toolMetaSaveTimer; // debounce cache writes
98100
private long _toolMetaSaveVersion;
99101
private bool _toolMetaCacheDirty;
@@ -242,7 +244,9 @@ internal OpenClawChatDataProvider(
242244
IChatGatewayBridge bridge,
243245
Action<Action>? post,
244246
string toolMetaCacheFilePath,
245-
string? attachmentMetaCacheFilePath = null)
247+
string? attachmentMetaCacheFilePath = null,
248+
string? lastChatStateFilePath = null,
249+
TimeSpan? lastChatStateSaveDelay = null)
246250
{
247251
_bridge = bridge ?? throw new ArgumentNullException(nameof(bridge));
248252
_post = post;
@@ -252,11 +256,15 @@ internal OpenClawChatDataProvider(
252256
_attachmentMetaCacheFilePath = !string.IsNullOrWhiteSpace(attachmentMetaCacheFilePath)
253257
? attachmentMetaCacheFilePath
254258
: DefaultAttachmentMetaCacheFilePath(_toolMetaCacheFilePath);
259+
_lastChatStateFilePath = !string.IsNullOrWhiteSpace(lastChatStateFilePath)
260+
? lastChatStateFilePath
261+
: LastChatStateFilePath;
262+
_lastChatStateSaveDelay = lastChatStateSaveDelay ?? TimeSpan.FromSeconds(2);
255263
_status = bridge.CurrentStatus;
256264
_persistedAbortedIds = LoadAbortedIds();
257265
_toolMetaCache = LoadToolMetaCache(_toolMetaCacheFilePath);
258266
_attachmentMetaCache = LoadAttachmentMetaCache(_attachmentMetaCacheFilePath);
259-
_lastChatState = LoadLastChatState();
267+
_lastChatState = LoadLastChatState(_lastChatStateFilePath);
260268

261269
// Seed models from whatever the bridge already knows about (a connect
262270
// that completed before the provider was constructed will have its
@@ -306,6 +314,34 @@ public Task<ChatDataSnapshot> LoadAsync(CancellationToken cancellationToken = de
306314
}
307315
}
308316

317+
internal void RememberSelectedThread(string? threadId)
318+
{
319+
if (string.IsNullOrWhiteSpace(threadId))
320+
return;
321+
322+
LastChatState? state;
323+
lock (_gate)
324+
{
325+
if (!TryGetSessionLocked(threadId, out var session))
326+
return;
327+
328+
state = new LastChatState
329+
{
330+
DefaultThreadId = threadId,
331+
ThreadTitle = BuildSessionTitle(session),
332+
Model = session.Model,
333+
ModelProvider = session.Provider,
334+
AvailableModels = _availableModels,
335+
};
336+
_lastChatState = state;
337+
_lastChatStateSaveVersion++;
338+
_lastChatStateSaveTimer?.Dispose();
339+
_lastChatStateSaveTimer = null;
340+
}
341+
342+
SaveLastChatState(state, _lastChatStateFilePath);
343+
}
344+
309345
// Explicit interface implementation (no attachments).
310346
Task IChatDataProvider.SendMessageAsync(string threadId, string message, CancellationToken cancellationToken)
311347
=> SendMessageAsync(threadId, message, cancellationToken, attachments: null);
@@ -5678,6 +5714,12 @@ private ChatDataSnapshot BuildSnapshotLocked()
56785714

56795715
private string? ResolveDefaultThreadIdLocked()
56805716
{
5717+
if (_lastChatState?.DefaultThreadId is { Length: > 0 } rememberedThreadId)
5718+
{
5719+
if (TryGetSessionLocked(rememberedThreadId, out _) || !_sessionsListReceived)
5720+
return rememberedThreadId;
5721+
}
5722+
56815723
// Prefer the gateway's canonical main session (IsMain on SessionInfo)
56825724
// so we never have to guess from a literal like "main". Only fall back
56835725
// to the compose target (pre-materialization) or the first available
@@ -5700,8 +5742,11 @@ private ChatDataSnapshot BuildSnapshotLocked()
57005742
private void RememberLastSessionStateLocked()
57015743
{
57025744
if (_sessions.Length == 0) return;
5703-
var session = _sessions.FirstOrDefault(s => s.IsMain && !string.IsNullOrEmpty(s.Key))
5704-
?? _sessions.FirstOrDefault(s => !string.IsNullOrEmpty(s.Key));
5745+
var defaultThreadId = ResolveDefaultThreadIdLocked();
5746+
var session = defaultThreadId is { Length: > 0 } && TryGetSessionLocked(defaultThreadId, out var selected)
5747+
? selected
5748+
: _sessions.FirstOrDefault(s => s.IsMain && !string.IsNullOrEmpty(s.Key))
5749+
?? _sessions.FirstOrDefault(s => !string.IsNullOrEmpty(s.Key));
57055750
if (session is null) return;
57065751

57075752
_lastChatState = new LastChatState
@@ -5714,6 +5759,22 @@ private void RememberLastSessionStateLocked()
57145759
};
57155760
}
57165761

5762+
private bool TryGetSessionLocked(string threadId, out SessionInfo session)
5763+
{
5764+
for (int i = 0; i < _sessions.Length; i++)
5765+
{
5766+
var candidate = _sessions[i];
5767+
if (string.Equals(candidate.Key, threadId, StringComparison.Ordinal))
5768+
{
5769+
session = candidate;
5770+
return true;
5771+
}
5772+
}
5773+
5774+
session = default!;
5775+
return false;
5776+
}
5777+
57175778
private static ChatThread ToThread(SessionInfo s)
57185779
{
57195780
var title = BuildSessionTitle(s);
@@ -5811,6 +5872,7 @@ private void Publish(ChatDataSnapshot snapshot)
58115872
AppIdentity.ResolveLocalDataDirectory(), "last-chat-state.json");
58125873

58135874
private System.Threading.Timer? _lastChatStateSaveTimer;
5875+
private long _lastChatStateSaveVersion;
58145876

58155877
internal sealed class LastChatState
58165878
{
@@ -5861,21 +5923,38 @@ private void DebounceSaveLastChatState(ChatDataSnapshot snapshot)
58615923
lock (_gate)
58625924
{
58635925
_lastChatState = state;
5926+
_lastChatStateSaveVersion++;
5927+
var saveVersion = _lastChatStateSaveVersion;
58645928
_lastChatStateSaveTimer?.Dispose();
5865-
_lastChatStateSaveTimer = new System.Threading.Timer(_ => SaveLastChatState(state), null, 2000, Timeout.Infinite);
5929+
var path = _lastChatStateFilePath;
5930+
_lastChatStateSaveTimer = new System.Threading.Timer(_ => SaveLastChatStateIfCurrent(state, path, saveVersion), null, _lastChatStateSaveDelay, Timeout.InfiniteTimeSpan);
58665931
}
58675932
}
58685933

5869-
private static void SaveLastChatState(LastChatState state)
5934+
private void SaveLastChatStateIfCurrent(LastChatState state, string path, long saveVersion)
58705935
{
5936+
lock (_gate)
5937+
{
5938+
if (saveVersion != _lastChatStateSaveVersion)
5939+
return;
5940+
5941+
SaveLastChatState(state, path);
5942+
_lastChatStateSaveTimer?.Dispose();
5943+
_lastChatStateSaveTimer = null;
5944+
}
5945+
}
5946+
5947+
private static void SaveLastChatState(LastChatState state, string? pathOverride = null)
5948+
{
5949+
var path = pathOverride ?? LastChatStateFilePath;
58715950
try
58725951
{
5873-
var dir = Path.GetDirectoryName(LastChatStateFilePath);
5952+
var dir = Path.GetDirectoryName(path);
58745953
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
58755954
var json = System.Text.Json.JsonSerializer.Serialize(state);
5876-
var tmp = LastChatStateFilePath + ".tmp";
5955+
var tmp = path + ".tmp";
58775956
File.WriteAllText(tmp, json);
5878-
File.Move(tmp, LastChatStateFilePath, overwrite: true);
5957+
File.Move(tmp, path, overwrite: true);
58795958
}
58805959
catch (Exception ex) { Logger.Debug($"ChatDataProvider: persist LastChatState failed: {ex.Message}"); }
58815960
}

src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,11 @@ void SetPendingAttachments(IReadOnlyList<ChatAttachment> attachments)
145145
_scrollToBottomToken = () => scrollToBottomToken.Set(scrollToBottomToken.Value + 1);
146146
SetSpeakerMuted = muted => speakerMuted.Set(muted);
147147
var snapshotState = UseState<ChatDataSnapshot?>(null, threadSafe: true);
148-
var selectedIdState = UseState<string?>(_initialThreadId, threadSafe: true);
148+
var initialSelectedId = _initialThreadId ?? (_provider as OpenClawChatDataProvider)?.CachedLastChatState?.DefaultThreadId;
149+
var selectedIdState = UseState<string?>(initialSelectedId, threadSafe: true);
149150
// UseRef tracks the selected ID across renders so that closures captured
150151
// inside UseEffect always read the latest value (UseState structs go stale).
151-
var selectedIdRef = UseRef<string?>(_initialThreadId);
152+
var selectedIdRef = UseRef<string?>(initialSelectedId);
152153
selectedIdRef.Current = selectedIdState.Value;
153154

154155
UseEffect((Func<Action>)(() =>
@@ -581,6 +582,8 @@ Element BuildLoadingElement()
581582
{
582583
selectedIdState.Set(id);
583584
selectedIdRef.Current = id;
585+
if (_provider is OpenClawChatDataProvider nativeProvider)
586+
nativeProvider.RememberSelectedThread(id);
584587
},
585588
OnModelChanged: model => ObserveFireAndForget(_provider.SetModelAsync(composerThread.Id!, model)),
586589
OnModelCleared: () => ObserveFireAndForget(_provider.ClearModelAsync(composerThread.Id!)),

tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs

Lines changed: 120 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,16 +121,23 @@ public Task ResolveExecApprovalAsync(string approvalId, string decision)
121121
}
122122

123123
private static (FakeBridge bridge, OpenClawChatDataProvider provider, List<ChatDataSnapshot> snapshots, List<ChatProviderNotification> notifications)
124-
CreateProvider(SessionInfo[]? initial = null, string? toolMetaCachePath = null, string? attachmentMetaCachePath = null)
124+
CreateProvider(
125+
SessionInfo[]? initial = null,
126+
string? toolMetaCachePath = null,
127+
string? attachmentMetaCachePath = null,
128+
string? lastChatStatePath = null,
129+
TimeSpan? lastChatStateSaveDelay = null)
125130
{
126131
var bridge = new FakeBridge { Sessions = initial ?? Array.Empty<SessionInfo>() };
127-
var provider = toolMetaCachePath is null && attachmentMetaCachePath is null
132+
var provider = toolMetaCachePath is null && attachmentMetaCachePath is null && lastChatStatePath is null && lastChatStateSaveDelay is null
128133
? new OpenClawChatDataProvider(bridge)
129134
: new OpenClawChatDataProvider(
130135
bridge,
131136
post: null,
132137
toolMetaCacheFilePath: toolMetaCachePath ?? Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "tool-metadata.json"),
133-
attachmentMetaCacheFilePath: attachmentMetaCachePath);
138+
attachmentMetaCacheFilePath: attachmentMetaCachePath,
139+
lastChatStateFilePath: lastChatStatePath,
140+
lastChatStateSaveDelay: lastChatStateSaveDelay);
134141
var snapshots = new List<ChatDataSnapshot>();
135142
var notifications = new List<ChatProviderNotification>();
136143
provider.Changed += (_, e) => snapshots.Add(e.Snapshot);
@@ -6644,6 +6651,116 @@ public async Task ResolveDefaultThreadId_PrefersIsMain_NotLiteralStringMatch()
66446651
Assert.Equal("agent:main:main", snap.DefaultThreadId);
66456652
}
66466653

6654+
[Fact]
6655+
public async Task RememberSelectedThread_PrefersSelectionAfterReload()
6656+
{
6657+
using var temp = new TempDirectory();
6658+
var sessions = new[]
6659+
{
6660+
new SessionInfo { Key = "agent:main:main", IsMain = true, DisplayName = "Main" },
6661+
new SessionInfo { Key = "agent:main:review", IsMain = false, DisplayName = "Review", Model = "gpt-5.1", Provider = "openai" }
6662+
};
6663+
var (_, provider, _, _) = CreateProvider(
6664+
sessions,
6665+
lastChatStatePath: Path.Combine(temp.DirectoryPath, "last-chat-state.json"));
6666+
6667+
var first = await provider.LoadAsync();
6668+
Assert.Equal("agent:main:main", first.DefaultThreadId);
6669+
6670+
provider.RememberSelectedThread("agent:main:review");
6671+
var reloaded = await provider.LoadAsync();
6672+
6673+
Assert.Equal("agent:main:review", reloaded.DefaultThreadId);
6674+
Assert.Equal("agent:main:review", provider.CachedLastChatState?.DefaultThreadId);
6675+
Assert.Equal("Review (main/review)", provider.CachedLastChatState?.ThreadTitle);
6676+
Assert.Equal("gpt-5.1", provider.CachedLastChatState?.Model);
6677+
Assert.Equal("openai", provider.CachedLastChatState?.ModelProvider);
6678+
}
6679+
6680+
[Fact]
6681+
public async Task RememberSelectedThread_FallsBackWhenSelectionDisappears()
6682+
{
6683+
using var temp = new TempDirectory();
6684+
var main = new SessionInfo { Key = "agent:main:main", IsMain = true, DisplayName = "Main" };
6685+
var review = new SessionInfo { Key = "agent:main:review", IsMain = false, DisplayName = "Review" };
6686+
var (bridge, provider, snapshots, _) = CreateProvider(
6687+
new[] { main, review },
6688+
lastChatStatePath: Path.Combine(temp.DirectoryPath, "last-chat-state.json"));
6689+
6690+
await provider.LoadAsync();
6691+
provider.RememberSelectedThread("agent:main:review");
6692+
snapshots.Clear();
6693+
6694+
bridge.RaiseSessions(new[] { main });
6695+
6696+
Assert.Equal("agent:main:main", snapshots[^1].DefaultThreadId);
6697+
Assert.Equal("agent:main:main", provider.CachedLastChatState?.DefaultThreadId);
6698+
}
6699+
6700+
[Fact]
6701+
public async Task RememberSelectedThread_CancelsPendingDefaultStateSave()
6702+
{
6703+
using var temp = new TempDirectory();
6704+
var statePath = Path.Combine(temp.DirectoryPath, "last-chat-state.json");
6705+
var main = new SessionInfo { Key = "agent:main:main", IsMain = true, DisplayName = "Main" };
6706+
var review = new SessionInfo { Key = "agent:main:review", IsMain = false, DisplayName = "Review" };
6707+
var (bridge, provider, _, _) = CreateProvider(
6708+
new[] { main, review },
6709+
lastChatStatePath: statePath,
6710+
lastChatStateSaveDelay: TimeSpan.FromMilliseconds(25));
6711+
6712+
bridge.RaiseSessions(new[] { main, review });
6713+
provider.RememberSelectedThread("agent:main:review");
6714+
6715+
await Task.Delay(150);
6716+
6717+
var persisted = OpenClawChatDataProvider.LoadLastChatState(statePath);
6718+
Assert.Equal("agent:main:review", persisted?.DefaultThreadId);
6719+
}
6720+
6721+
[Fact]
6722+
public async Task RememberSelectedThread_ModelsBeforeSessionsKeepsSavedSelectionPending()
6723+
{
6724+
using var temp = new TempDirectory();
6725+
var statePath = Path.Combine(temp.DirectoryPath, "last-chat-state.json");
6726+
await File.WriteAllTextAsync(statePath, JsonSerializer.Serialize(new OpenClawChatDataProvider.LastChatState
6727+
{
6728+
DefaultThreadId = "agent:main:review",
6729+
ThreadTitle = "Review (main/review)",
6730+
Model = "gpt-5.1",
6731+
ModelProvider = "openai",
6732+
AvailableModels = new[] { "gpt-5.0" }
6733+
}));
6734+
var main = new SessionInfo { Key = "agent:main:main", IsMain = true, DisplayName = "Main" };
6735+
var review = new SessionInfo { Key = "agent:main:review", IsMain = false, DisplayName = "Review", Model = "gpt-5.1", Provider = "openai" };
6736+
var (bridge, provider, snapshots, _) = CreateProvider(
6737+
lastChatStatePath: statePath,
6738+
lastChatStateSaveDelay: TimeSpan.FromMilliseconds(25));
6739+
6740+
await provider.LoadAsync();
6741+
snapshots.Clear();
6742+
6743+
bridge.RaiseModels(new ModelsListInfo
6744+
{
6745+
Models = new List<ModelInfo>
6746+
{
6747+
new() { Id = "gpt-5.1", Name = "GPT-5.1" }
6748+
}
6749+
});
6750+
6751+
Assert.Equal("agent:main:review", snapshots[^1].DefaultThreadId);
6752+
Assert.Equal("agent:main:review", provider.CachedLastChatState?.DefaultThreadId);
6753+
6754+
await Task.Delay(150);
6755+
var persistedBeforeSessions = OpenClawChatDataProvider.LoadLastChatState(statePath);
6756+
Assert.Equal("agent:main:review", persistedBeforeSessions?.DefaultThreadId);
6757+
6758+
bridge.RaiseSessions(new[] { main, review });
6759+
6760+
Assert.Equal("agent:main:review", snapshots[^1].DefaultThreadId);
6761+
Assert.Equal("agent:main:review", provider.CachedLastChatState?.DefaultThreadId);
6762+
}
6763+
66476764
// ─── RespondToPermissionAsync routes through the RPC bridge ────────────
66486765
// These tests pin the slash-command → RPC behavioral pivot. The old code
66496766
// sent ``/approve <id> <decision>`` as chat input, which deadlocked

0 commit comments

Comments
 (0)