From c5d9d2a0dd7a05d51a67947979dd6016c91a3a82 Mon Sep 17 00:00:00 2001 From: oywino Date: Mon, 14 Sep 2026 15:02:41 +0200 Subject: [PATCH] fix(chat): keep new sessions selected and usable after /new --- .../Chat/ChatLifecycleCommandDispatcher.cs | 12 ++++- .../Chat/OpenClawReactorChatRoot.cs | 10 ++-- .../ChatComposerControllerTests.cs | 32 +++++++++++ .../ChatLifecycleSelectionPolicyTests.cs | 53 +++++++++++++++++++ 4 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 tests/OpenClaw.Tray.Tests/ChatLifecycleSelectionPolicyTests.cs diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatLifecycleCommandDispatcher.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatLifecycleCommandDispatcher.cs index 5b3e0816c..7448840af 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/ChatLifecycleCommandDispatcher.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatLifecycleCommandDispatcher.cs @@ -47,12 +47,20 @@ internal static class ChatLifecycleSelectionPolicy { public static string? RetainPendingForSelection( string? pendingSelectedId, - string? selectedId) => - pendingSelectedId is not null && + string? selectedId, + bool selectedMaterialized = false) => + !selectedMaterialized && pendingSelectedId is not null && string.Equals(pendingSelectedId, selectedId, StringComparison.Ordinal) ? pendingSelectedId : null; + public static bool IsComposeOnlyWelcomeEligible( + string? threadId, + string? pendingSelectedId, + bool hasRealThreads) => + !hasRealThreads || (pendingSelectedId is not null && + string.Equals(threadId, pendingSelectedId, StringComparison.Ordinal)); + public static bool ShouldFallback( string staleSelectedId, string? pendingSelectedId, diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs index 97a1a08a1..63d91fc36 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs @@ -133,9 +133,12 @@ public override Element Render() string.Equals(thread.Id, fallbackId, StringComparison.Ordinal)); } + // A synthetic compose-only thread does not acknowledge session materialization. + _pendingSelectedThreadId = ChatLifecycleSelectionPolicy.RetainPendingForSelection( + _pendingSelectedThreadId, + selectedId, + selectedMaterializedThread is not null); var effectiveThread = selectedMaterializedThread ?? CreateComposeOnlyThread(props.Provider, snapshot); - if (effectiveThread is { } selected && string.Equals(_pendingSelectedThreadId, selected.Id, StringComparison.Ordinal)) - _pendingSelectedThreadId = null; var connectionState = ToConnectionState(snapshot.ConnectionStatus); var isGatewayConnected = string.Equals(connectionState, "connected", StringComparison.Ordinal); @@ -187,7 +190,8 @@ public override Element Render() var welcomeEligible = isEmptyConversation && isGatewayConnected && ( - (isComposeOnly && !hasRealThreads) + (isComposeOnly && ChatLifecycleSelectionPolicy.IsComposeOnlyWelcomeEligible( + effectiveThread?.Id, _pendingSelectedThreadId, hasRealThreads)) || (!isComposeOnly && timeline.HistoryLoaded)); var welcomeEligibilityKey = welcomeEligible ? $"{effectiveThread?.Id}|{isComposeOnly}|{timeline.HistoryLoaded}|{hasRealThreads}" diff --git a/tests/OpenClaw.Tray.Tests/ChatComposerControllerTests.cs b/tests/OpenClaw.Tray.Tests/ChatComposerControllerTests.cs index d128b90c5..a9ded891f 100644 --- a/tests/OpenClaw.Tray.Tests/ChatComposerControllerTests.cs +++ b/tests/OpenClaw.Tray.Tests/ChatComposerControllerTests.cs @@ -271,6 +271,38 @@ public async Task SendAsync_NewCommand_HandsCanonicalSessionKeyToBoundSelection( Assert.Equal(0, port.SendMessageCallCount); } + [Fact] + public async Task SendAsync_AfterNew_FirstMessageTargetsPendingComposeOnlySession() + { + var (vm, controller, port, _) = MakeController(); + vm.SetDraft("/new"); + port.ExecuteLifecycleGate = new TaskCompletionSource(); + port.ExecuteLifecycleGate.SetResult(new ChatLifecycleCommandResult( + ChatLifecycleCommandKind.New, Succeeded: true, NewSessionKey: "new-session-key")); + string? selected = null; + controller.BindSelectionHandoff(key => selected = key); + Assert.True(await controller.SendAsync()); + Assert.Equal("new-session-key", selected); + + string? pending = selected; + for (var render = 0; render < 3; render++) + { + pending = ChatLifecycleSelectionPolicy.RetainPendingForSelection( + pending, selected, selectedMaterialized: false); + Assert.Equal(selected, pending); + Assert.False(ChatLifecycleSelectionPolicy.ShouldFallback(selected!, pending, "session-1")); + // The root projects its effective compose-only thread into inputs + // while the provider's real thread list still contains only Main. + vm.ApplyInputs(MakeInputs(revision: render + 2, thread: MakeThread(pending!))); + } + + vm.SetDraft("first message"); + Assert.True(await controller.SendAsync()); + Assert.Equal(1, port.SendMessageCallCount); + Assert.Equal("new-session-key", port.LastSendMessageCall!.Value.ThreadId); + Assert.Equal("first message", port.LastSendMessageCall.Value.Message); + } + [Fact] public async Task SendAsync_Compact_UsesQueuePathNotLifecycleExecute() { diff --git a/tests/OpenClaw.Tray.Tests/ChatLifecycleSelectionPolicyTests.cs b/tests/OpenClaw.Tray.Tests/ChatLifecycleSelectionPolicyTests.cs new file mode 100644 index 000000000..130a2b803 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/ChatLifecycleSelectionPolicyTests.cs @@ -0,0 +1,53 @@ +using OpenClawTray.Chat; + +namespace OpenClaw.Tray.Tests; + +public sealed class ChatLifecycleSelectionPolicyTests +{ + [Fact] + public void PendingNewSession_SurvivesRepeatedStaleSnapshotsUntilMaterialization() + { + const string selected = "agent:main:new-session"; + string? pending = selected; + + // Main is the only real thread throughout these renders. The selected + // thread is synthetic and must remain usable until history catches up. + for (var render = 0; render < 3; render++) + { + Assert.False(ChatLifecycleSelectionPolicy.ShouldFallback(selected, pending, "main")); + pending = ChatLifecycleSelectionPolicy.RetainPendingForSelection( + pending, selected, selectedMaterialized: false); + Assert.Equal(selected, pending); + Assert.True(ChatLifecycleSelectionPolicy.IsComposeOnlyWelcomeEligible( + selected, pending, hasRealThreads: true)); + } + + pending = ChatLifecycleSelectionPolicy.RetainPendingForSelection( + pending, selected, selectedMaterialized: true); + Assert.Null(pending); + Assert.True(ChatLifecycleSelectionPolicy.ShouldFallback(selected, pending, "main")); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ExplicitNavigationAway_RetiresPreviousPendingSelection(bool materialized) + { + Assert.Null(ChatLifecycleSelectionPolicy.RetainPendingForSelection( + "new-session", "main", materialized)); + Assert.False(ChatLifecycleSelectionPolicy.ShouldFallback("main", null, "main")); + } + + [Theory] + [InlineData(null, false, true)] + [InlineData(null, true, false)] + [InlineData("other-session", false, true)] + [InlineData("other-session", true, false)] + [InlineData("compose-session", true, true)] + public void ComposeOnlyWelcome_PreservesUnrelatedSessionBehavior( + string? pending, bool hasRealThreads, bool expected) + { + Assert.Equal(expected, ChatLifecycleSelectionPolicy.IsComposeOnlyWelcomeEligible( + "compose-session", pending, hasRealThreads)); + } +}