From 4b16cd4698cc9c6c473049a1f7498f29552a694e Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 26 Mar 2026 08:53:12 -0500 Subject: [PATCH 1/7] feat: add sync button for mobile + diagnostic logging for missed messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a πŸ”„ sync button in the chat header (visible in remote mode) that forces a full session + history refresh from the desktop server. After sync, shows a brief toast-style message: 'Synced: N new messages' or 'Already up to date'. Diagnostic logging: - [SYNC] tags written to event-diagnostics.log on every force-refresh - Logs before/after message counts, elapsed time, and a warning when messages were missed during streaming (delta > 0) - Enables post-mortem analysis of sync gaps without needing verbose tracing Implementation: - ForceRefreshRemoteAsync() in CopilotService.Bridge.cs: requests full session list + unlimited history for active session, compares pre/post counts, returns SyncResult with diagnostics - Sync button in ExpandedSessionView.razor with spin animation during sync - Timer-based message dismissal (4s) with proper cleanup in DisposeAsync - [SYNC] added to Debug() log filter for event-diagnostics.log persistence Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Components/ExpandedSessionView.razor | 40 +++++++ .../Components/ExpandedSessionView.razor.css | 24 +++++ PolyPilot/Services/CopilotService.Bridge.cs | 102 ++++++++++++++++++ PolyPilot/Services/CopilotService.cs | 1 + 4 files changed, 167 insertions(+) diff --git a/PolyPilot/Components/ExpandedSessionView.razor b/PolyPilot/Components/ExpandedSessionView.razor index a2ae445d92..f576599c48 100644 --- a/PolyPilot/Components/ExpandedSessionView.razor +++ b/PolyPilot/Components/ExpandedSessionView.razor @@ -64,6 +64,14 @@ } + @if (CopilotService.IsRemoteMode) + { + + } @@ -362,6 +370,37 @@ private bool _isListening; private string _partialText = ""; private CancellationTokenSource? _listenCts; + private bool _syncing; + private string? _syncMessage; + private System.Threading.Timer? _syncMessageTimer; + + private async Task ForceSync() + { + if (_syncing) return; + _syncing = true; + _syncMessage = null; + StateHasChanged(); + + try + { + var result = await CopilotService.ForceRefreshRemoteAsync(Session.Name); + _syncMessage = result.Message; + StateHasChanged(); + + // Clear the message after 4 seconds + _syncMessageTimer?.Dispose(); + _syncMessageTimer = new System.Threading.Timer(_ => + { + _syncMessage = null; + InvokeAsync(StateHasChanged); + }, null, 4000, Timeout.Infinite); + } + finally + { + _syncing = false; + StateHasChanged(); + } + } private async Task ToggleListening() { @@ -1026,5 +1065,6 @@ try { await JS.InvokeVoidAsync("eval", "window.__ppRef = null;"); } catch { } _promptDotNetRef?.Dispose(); _promptDotNetRef = null; + _syncMessageTimer?.Dispose(); } } diff --git a/PolyPilot/Components/ExpandedSessionView.razor.css b/PolyPilot/Components/ExpandedSessionView.razor.css index 5776a7392a..61c7e6b436 100644 --- a/PolyPilot/Components/ExpandedSessionView.razor.css +++ b/PolyPilot/Components/ExpandedSessionView.razor.css @@ -196,6 +196,30 @@ color: var(--accent-primary); } +.sync-btn { + border: none; + background: transparent; + color: inherit; + padding: 0.1rem 0.2rem; + transition: transform 0.3s ease; +} + +.sync-btn.syncing { + animation: spin 0.8s linear infinite; + opacity: 0.5; + pointer-events: none; +} + +.sync-btn.has-message { + color: var(--accent-primary); + opacity: 1; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + .fiesta-panel-inline { margin: 0.5rem 1rem 0; border: 1px solid var(--control-border); diff --git a/PolyPilot/Services/CopilotService.Bridge.cs b/PolyPilot/Services/CopilotService.Bridge.cs index 5b9e7899dc..4d59834339 100644 --- a/PolyPilot/Services/CopilotService.Bridge.cs +++ b/PolyPilot/Services/CopilotService.Bridge.cs @@ -622,6 +622,108 @@ public async Task LoadFullRemoteHistoryAsync(string sessionName) await _bridgeClient.RequestHistoryAsync(sessionName, limit: null); } + /// + /// Force a full sync of sessions and history from the remote server. + /// Returns diagnostic info about what changed (for the sync button on mobile). + /// + public async Task ForceRefreshRemoteAsync(string? activeSessionName = null) + { + if (!IsRemoteMode || !_bridgeClient.IsConnected) + return new SyncResult { Success = false, Message = "Not connected" }; + + var result = new SyncResult(); + var sw = System.Diagnostics.Stopwatch.StartNew(); + + try + { + // Snapshot pre-sync state + var preSyncSessionCount = _sessions.Count; + var preSyncMessageCount = 0; + AgentSessionInfo? activeInfo = null; + if (activeSessionName != null && _sessions.TryGetValue(activeSessionName, out var activeState)) + { + activeInfo = activeState.Info; + lock (activeInfo.HistoryLock) + preSyncMessageCount = activeInfo.History.Count; + } + + // Request fresh sessions + full history for active session + await _bridgeClient.RequestSessionsAsync(); + if (activeSessionName != null) + await _bridgeClient.RequestHistoryAsync(activeSessionName, limit: null); + + // Wait briefly for WsBridgeClient to process the responses + await Task.Delay(500); + + // Snapshot post-sync state + var postSyncSessionCount = _sessions.Count; + var postSyncMessageCount = 0; + if (activeInfo != null) + { + lock (activeInfo.HistoryLock) + postSyncMessageCount = activeInfo.History.Count; + } + + var sessionDelta = postSyncSessionCount - preSyncSessionCount; + var messageDelta = postSyncMessageCount - preSyncMessageCount; + + result.Success = true; + result.SessionCountBefore = preSyncSessionCount; + result.SessionCountAfter = postSyncSessionCount; + result.MessageCountBefore = preSyncMessageCount; + result.MessageCountAfter = postSyncMessageCount; + result.ElapsedMs = sw.ElapsedMilliseconds; + + // Build user-facing message + var parts = new List(); + if (messageDelta > 0) + parts.Add($"{messageDelta} new message{(messageDelta != 1 ? "s" : "")}"); + if (sessionDelta > 0) + parts.Add($"{sessionDelta} new session{(sessionDelta != 1 ? "s" : "")}"); + result.Message = parts.Count > 0 + ? $"Synced: {string.Join(", ", parts)}" + : "Already up to date"; + + // Diagnostic logging: detect missed messages + if (messageDelta > 0) + { + Debug($"[SYNC] Force refresh for '{activeSessionName}': " + + $"{preSyncMessageCount}β†’{postSyncMessageCount} messages " + + $"(+{messageDelta}), {sw.ElapsedMilliseconds}ms. " + + $"⚠️ {messageDelta} messages were missed during streaming."); + } + else + { + Debug($"[SYNC] Force refresh for '{activeSessionName}': " + + $"{postSyncMessageCount} messages, up to date, {sw.ElapsedMilliseconds}ms"); + } + } + catch (Exception ex) + { + result.Success = false; + result.Message = $"Sync failed: {ex.Message}"; + result.ElapsedMs = sw.ElapsedMilliseconds; + Debug($"[SYNC] Force refresh failed: {ex.Message}"); + } + + OnStateChanged?.Invoke(); + return result; + } + + /// + /// Result of a forced remote sync operation. + /// + public class SyncResult + { + public bool Success { get; set; } + public string Message { get; set; } = ""; + public int SessionCountBefore { get; set; } + public int SessionCountAfter { get; set; } + public int MessageCountBefore { get; set; } + public int MessageCountAfter { get; set; } + public long ElapsedMs { get; set; } + } + // --- Remote repo operations --- public async Task<(string RepoId, string RepoName)?> AddRepoRemoteAsync(string url, Action? onProgress = null, CancellationToken ct = default) diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index d0be08b81e..e6b0d321e3 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -669,6 +669,7 @@ internal static bool ShouldPersistDiagnostic(string message) message.StartsWith("[PERMISSION") || message.StartsWith("[RESUME-ABORT") || message.StartsWith("[KEEPALIVE") || message.StartsWith("[ERROR") || message.StartsWith("[ABORT") || message.StartsWith("[BRIDGE") || + message.StartsWith("[SYNC") || message.Contains("watchdog") || message.Contains("Failed to"); } From 7e970e8bab8a8b33d407fae4d12f60de831c6388 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 26 Mar 2026 09:18:49 -0500 Subject: [PATCH 2/7] fix: broadcast session history to mobile after each sub-turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: The server broadcasts content_delta events to mobile in real-time, but NEVER pushes the authoritative session history after FlushCurrentResponse adds text to History. Mobile's only source of message content was the content_delta stream β€” if any deltas were missed (WS buffering, timing, network jitter), the text was permanently lost until a manual history request. Fix: After each AssistantTurnEndEvent, broadcast the current session history to all connected clients via BroadcastSessionHistoryAsync(). FlushCurrentResponse runs before OnTurnEnd, so History is up-to-date. This ensures mobile always has the complete, authoritative history after each text segment β€” even if content_delta events were dropped. The broadcast uses the existing HistoryLimitForBridge (200 messages) cap and includes ImageDataUri population for Image messages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Services/WsBridgeServer.cs | 65 ++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/PolyPilot/Services/WsBridgeServer.cs b/PolyPilot/Services/WsBridgeServer.cs index 69085da8af..59571ae768 100644 --- a/PolyPilot/Services/WsBridgeServer.cs +++ b/PolyPilot/Services/WsBridgeServer.cs @@ -143,8 +143,16 @@ public void SetCopilotService(CopilotService copilot) Broadcast(BridgeMessage.Create(BridgeMessageTypes.TurnStart, new SessionNamePayload { SessionName = session })); _copilot.OnTurnEnd += (session) => + { Broadcast(BridgeMessage.Create(BridgeMessageTypes.TurnEnd, new SessionNamePayload { SessionName = session })); + // Push updated history to all clients after each sub-turn flush. + // FlushCurrentResponse runs before OnTurnEnd, so History is up-to-date. + // Without this, mobile only has content_delta-built messages β€” if any + // deltas were missed (WS buffering, timing), the text is permanently lost + // until the user manually requests a history sync. + _ = BroadcastSessionHistoryAsync(session); + }; _copilot.OnSessionComplete += (session, summary) => { Broadcast(BridgeMessage.Create(BridgeMessageTypes.SessionComplete, @@ -1190,6 +1198,63 @@ private void BroadcastOrganizationState() Broadcast(msg); } + /// + /// Push the current session history to all connected clients. + /// Called after FlushCurrentResponse on each sub-turn end so mobile clients + /// have authoritative history even if content_delta events were missed. + /// + private async Task BroadcastSessionHistoryAsync(string sessionName) + { + if (_copilot == null || _clients.IsEmpty) return; + + var session = _copilot.GetSession(sessionName); + if (session == null) return; + + ChatMessage[] snapshot; + try { snapshot = session.History.ToArray(); } + catch { return; } // concurrent modification β€” skip this broadcast + + var totalCount = snapshot.Length; + List messagesToSend; + bool hasMore; + if (totalCount > CopilotService.HistoryLimitForBridge) + { + messagesToSend = snapshot.Skip(totalCount - CopilotService.HistoryLimitForBridge).ToList(); + hasMore = true; + } + else + { + messagesToSend = snapshot.ToList(); + hasMore = false; + } + + // Populate ImageDataUri for Image messages + foreach (var m in messagesToSend) + { + if (m.MessageType == ChatMessageType.Image && string.IsNullOrEmpty(m.ImageDataUri) && !string.IsNullOrEmpty(m.ImagePath)) + { + try + { + if (File.Exists(m.ImagePath)) + { + var bytes = await File.ReadAllBytesAsync(m.ImagePath); + m.ImageDataUri = $"data:{ImageMimeType(m.ImagePath)};base64,{Convert.ToBase64String(bytes)}"; + } + } + catch { /* best effort */ } + } + } + + var payload = new SessionHistoryPayload + { + SessionName = sessionName, + Messages = messagesToSend, + TotalCount = totalCount, + HasMore = hasMore + }; + Broadcast(BridgeMessage.Create(BridgeMessageTypes.SessionHistory, payload)); + } + private async Task HandleOrganizationCommandAsync(OrganizationCommandPayload cmd) { if (_copilot == null) return; From 84eecff4b3d6a9ef4b1a36c453777e73dcfdea33 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 26 Mar 2026 10:04:16 -0500 Subject: [PATCH 3/7] feat: add 'Continue in new session' to session context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a menu item that creates a new session pre-filled with a markdown transcript of the source session's conversation history. The transcript includes user messages, truncated assistant responses, tool call status, and a pointer to events.jsonl for the agent to read full logs. - CopilotService.Continuation.cs: BuildContinuationTranscript() and ContinueInNewSessionAsync() with 6K char budget, oldest-first trimming - SessionListItem.razor: 'πŸ”„ Continue in new session' menu item (hidden for multi-agent workers and sessions without SessionId) - SessionSidebar.razor: ContinueInNewSession handler wired to all 5 SessionListItem instances - Dashboard.razor: consumes PendingDrafts from CopilotService on render - CopilotService.cs: PendingDrafts ConcurrentDictionary for cross-component draft transfer - 11 tests covering transcript building, name generation, truncation, budget trimming, and message type handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/ContinuationTests.cs | 158 ++++++++++++++++++ PolyPilot.Tests/PolyPilot.Tests.csproj | 1 + .../Components/Layout/SessionListItem.razor | 7 + .../Components/Layout/SessionSidebar.razor | 34 +++- PolyPilot/Components/Pages/Dashboard.razor | 7 + .../Services/CopilotService.Continuation.cs | 133 +++++++++++++++ PolyPilot/Services/CopilotService.cs | 7 + 7 files changed, 342 insertions(+), 5 deletions(-) create mode 100644 PolyPilot.Tests/ContinuationTests.cs create mode 100644 PolyPilot/Services/CopilotService.Continuation.cs diff --git a/PolyPilot.Tests/ContinuationTests.cs b/PolyPilot.Tests/ContinuationTests.cs new file mode 100644 index 0000000000..541d8999e1 --- /dev/null +++ b/PolyPilot.Tests/ContinuationTests.cs @@ -0,0 +1,158 @@ +using PolyPilot.Models; +using PolyPilot.Services; + +namespace PolyPilot.Tests; + +public class ContinuationTests +{ + [Fact] + public void GenerateContinuationName_AppendsContdSuffix() + { + var result = CopilotService.GenerateContinuationName("my-session"); + Assert.Equal("my-session (cont'd)", result); + } + + [Fact] + public void GenerateContinuationName_StripsExistingSuffix() + { + var result = CopilotService.GenerateContinuationName("my-session (cont'd)"); + Assert.Equal("my-session (cont'd)", result); + } + + [Fact] + public void BuildContinuationTranscript_IncludesUserMessages() + { + var history = new List + { + new("user", "Hello world", DateTime.Now, ChatMessageType.User), + new("assistant", "Hi there!", DateTime.Now, ChatMessageType.Assistant), + }; + + var result = CopilotService.BuildContinuationTranscript(history, "test-session", "abc-123"); + + Assert.Contains("**User:** Hello world", result); + Assert.Contains("**Assistant:** Hi there!", result); + Assert.Contains("test-session", result); + } + + [Fact] + public void BuildContinuationTranscript_TruncatesLongAssistantMessages() + { + var longContent = new string('x', 500); + var history = new List + { + new("assistant", longContent, DateTime.Now, ChatMessageType.Assistant), + }; + + var result = CopilotService.BuildContinuationTranscript(history, "test", "abc"); + + // Should be truncated to 400 chars + ellipsis + Assert.DoesNotContain(longContent, result); + Assert.Contains("…", result); + } + + [Fact] + public void BuildContinuationTranscript_IncludesToolCalls() + { + var history = new List + { + ChatMessage.ToolCallMessage("edit", "tc-1", "editing file"), + }; + // Mark complete + success + history[0].IsComplete = true; + history[0].IsSuccess = true; + + var result = CopilotService.BuildContinuationTranscript(history, "test", "abc"); + + Assert.Contains("πŸ”§ edit βœ…", result); + } + + [Fact] + public void BuildContinuationTranscript_IncludesSessionIdPath() + { + var history = new List + { + new("user", "test", DateTime.Now, ChatMessageType.User), + }; + + var result = CopilotService.BuildContinuationTranscript(history, "test", "abc-123"); + + Assert.Contains("~/.copilot/session-state/abc-123/events.jsonl", result); + } + + [Fact] + public void BuildContinuationTranscript_HandlesNullSessionId() + { + var history = new List + { + new("user", "test", DateTime.Now, ChatMessageType.User), + }; + + var result = CopilotService.BuildContinuationTranscript(history, "test", null); + + Assert.Contains("session ID not available", result); + } + + [Fact] + public void BuildContinuationTranscript_SkipsSystemMessages() + { + var history = new List + { + new("system", "System init", DateTime.Now, ChatMessageType.System), + new("user", "Hello", DateTime.Now, ChatMessageType.User), + }; + + var result = CopilotService.BuildContinuationTranscript(history, "test", "abc"); + + Assert.DoesNotContain("System init", result); + Assert.Contains("**User:** Hello", result); + } + + [Fact] + public void BuildContinuationTranscript_TrimsOldTurnsWhenOverBudget() + { + var history = new List(); + // Add many turns to exceed 6000 char budget + for (int i = 0; i < 50; i++) + { + history.Add(new("user", $"Question {i}: {new string('q', 100)}", DateTime.Now, ChatMessageType.User)); + history.Add(new("assistant", $"Answer {i}: {new string('a', 300)}", DateTime.Now, ChatMessageType.Assistant)); + } + + var result = CopilotService.BuildContinuationTranscript(history, "test", "abc"); + + // Should not contain the first turn (trimmed) + Assert.DoesNotContain("Question 0:", result); + // Should contain the last turn (preserved) + Assert.Contains("Question 49:", result); + // Total length should be reasonable + Assert.True(result.Length < 8000, $"Transcript too long: {result.Length}"); + } + + [Fact] + public void BuildContinuationTranscript_IncludesErrorMessages() + { + var history = new List + { + ChatMessage.ErrorMessage("Something went wrong", "bash"), + }; + + var result = CopilotService.BuildContinuationTranscript(history, "test", "abc"); + + Assert.Contains("⚠️ Error:", result); + Assert.Contains("Something went wrong", result); + } + + [Fact] + public void BuildContinuationTranscript_IncludesImageMessages() + { + var history = new List + { + ChatMessage.ImageMessage("/path/to/img.png", null, "Screenshot of bug"), + }; + + var result = CopilotService.BuildContinuationTranscript(history, "test", "abc"); + + Assert.Contains("πŸ–ΌοΈ Image: Screenshot of bug", result); + } +} diff --git a/PolyPilot.Tests/PolyPilot.Tests.csproj b/PolyPilot.Tests/PolyPilot.Tests.csproj index 404fb38d3c..43c8f833f0 100644 --- a/PolyPilot.Tests/PolyPilot.Tests.csproj +++ b/PolyPilot.Tests/PolyPilot.Tests.csproj @@ -61,6 +61,7 @@ + diff --git a/PolyPilot/Components/Layout/SessionListItem.razor b/PolyPilot/Components/Layout/SessionListItem.razor index f6d3ce3790..c737e80c7d 100644 --- a/PolyPilot/Components/Layout/SessionListItem.razor +++ b/PolyPilot/Components/Layout/SessionListItem.razor @@ -225,6 +225,12 @@ πŸ“Š Analyze Efficiency } + @if (!string.IsNullOrEmpty(Session.SessionId) && !IsMultiAgentMember) + { + + } @if (PlatformHelper.IsDesktop && !string.IsNullOrEmpty(Session.SessionId) && currentGroup?.IsCodespace != true) { @@ -276,6 +282,7 @@ [Parameter] public EventCallback OnReportBug { get; set; } [Parameter] public EventCallback OnFixWithCopilot { get; set; } [Parameter] public EventCallback OnAnalyze { get; set; } + [Parameter] public EventCallback OnContinueInNewSession { get; set; } /// /// Show close confirmation via a JS-created dialog (outside Blazor's DOM tree). diff --git a/PolyPilot/Components/Layout/SessionSidebar.razor b/PolyPilot/Components/Layout/SessionSidebar.razor index 6bd663bd57..b42b783cbe 100644 --- a/PolyPilot/Components/Layout/SessionSidebar.razor +++ b/PolyPilot/Components/Layout/SessionSidebar.razor @@ -798,7 +798,8 @@ else OnCloseMenu="() => { openMenuSession = null; }" OnReportBug="() => OpenBugReportForSession(orchSName)" OnFixWithCopilot="() => OpenFixItForSession(orchSName)" - OnAnalyze="() => AnalyzeSessionEfficiency(orchSName)" /> + OnAnalyze="() => AnalyzeSessionEfficiency(orchSName)" + OnContinueInNewSession="() => ContinueInNewSession(orchSName)" /> } var workerSessions = groupSessions.Where(s => s.Name != orchName).ToList(); var workerCount = workerSessions.Count; @@ -840,7 +841,8 @@ else OnCloseMenu="() => { openMenuSession = null; }" OnReportBug="() => OpenBugReportForSession(wSName)" OnFixWithCopilot="() => OpenFixItForSession(wSName)" - OnAnalyze="() => AnalyzeSessionEfficiency(wSName)" /> + OnAnalyze="() => AnalyzeSessionEfficiency(wSName)" + OnContinueInNewSession="() => ContinueInNewSession(wSName)" /> } } } @@ -874,7 +876,8 @@ else OnCloseMenu="() => { openMenuSession = null; }" OnReportBug="() => OpenBugReportForSession(sName)" OnFixWithCopilot="() => OpenFixItForSession(sName)" - OnAnalyze="() => AnalyzeSessionEfficiency(sName)" /> + OnAnalyze="() => AnalyzeSessionEfficiency(sName)" + OnContinueInNewSession="() => ContinueInNewSession(sName)" /> } @if (HasUnpinnedToggle(groupSessions)) { @@ -911,7 +914,8 @@ else OnCloseMenu="() => { openMenuSession = null; }" OnReportBug="() => OpenBugReportForSession(sName)" OnFixWithCopilot="() => OpenFixItForSession(sName)" - OnAnalyze="() => AnalyzeSessionEfficiency(sName)" /> + OnAnalyze="() => AnalyzeSessionEfficiency(sName)" + OnContinueInNewSession="() => ContinueInNewSession(sName)" /> } } } @@ -1098,7 +1102,8 @@ else OnCloseMenu="() => { openMenuSession = null; }" OnReportBug="() => OpenBugReportForSession(sName)" OnFixWithCopilot="() => OpenFixItForSession(sName)" - OnAnalyze="() => AnalyzeSessionEfficiency(sName)" /> + OnAnalyze="() => AnalyzeSessionEfficiency(sName)" + OnContinueInNewSession="() => ContinueInNewSession(sName)" /> } @if (!string.IsNullOrEmpty(_codespaceErrors.GetValueOrDefault(group.Id))) { @@ -2859,6 +2864,25 @@ else } } + private async Task ContinueInNewSession(string sessionName) + { + try + { + var (newName, transcript) = await CopilotService.ContinueInNewSessionAsync(sessionName); + CopilotService.PendingDrafts[newName] = transcript; + CopilotService.SwitchSession(newName); + CopilotService.SaveUiState(currentPage); + await OnSessionSelected.InvokeAsync(); + footerStatus = $"βœ“ Created '{newName}' β€” draft pre-filled"; + StateHasChanged(); + } + catch (Exception ex) + { + footerStatus = $"βœ— Continue in new session failed: {ex.Message}"; + StateHasChanged(); + } + } + private void CloseFooterPanel() { showBugReport = false; diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index 4248434f72..2e40b880b7 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -952,6 +952,13 @@ catch (ObjectDisposedException) { } } + // Consume any pending drafts from "Continue in new session" + foreach (var kvp in CopilotService.PendingDrafts) + { + if (CopilotService.PendingDrafts.TryRemove(kvp.Key, out var draft)) + draftBySession[kvp.Key] = draft; + } + // Auto-scroll all card message containers to bottom // Restore draft text, focus, and cursor position β€” all in ONE JS call var draftsJson = "{}"; diff --git a/PolyPilot/Services/CopilotService.Continuation.cs b/PolyPilot/Services/CopilotService.Continuation.cs new file mode 100644 index 0000000000..7c7070d6a8 --- /dev/null +++ b/PolyPilot/Services/CopilotService.Continuation.cs @@ -0,0 +1,133 @@ +using System.Text; +using PolyPilot.Models; + +namespace PolyPilot.Services; + +public partial class CopilotService +{ + /// + /// Creates a new session pre-filled with context from an existing session's conversation history. + /// Returns (newSessionName, transcript) so the caller can set the draft in the chat input. + /// + public async Task<(string NewSessionName, string Transcript)> ContinueInNewSessionAsync( + string sourceSessionName, CancellationToken ct = default) + { + if (!_sessions.TryGetValue(sourceSessionName, out var sourceState)) + throw new InvalidOperationException($"Session '{sourceSessionName}' not found"); + + var info = sourceState.Info; + List history; + lock (info.HistoryLock) + { + history = info.History.ToList(); + } + + var transcript = BuildContinuationTranscript(history, sourceSessionName, info.SessionId); + var newName = GenerateContinuationName(sourceSessionName); + + // Inherit model, working directory, and group from source session + var groupId = Organization.Sessions.FirstOrDefault(m => m.SessionName == sourceSessionName)?.GroupId; + var newSession = await CreateSessionAsync(newName, info.Model, info.WorkingDirectory, ct, groupId); + + return (newName, transcript); + } + + /// + /// Builds a markdown transcript from conversation history, suitable for pre-filling + /// a new session's chat input. Caps at ~6000 chars, trimming oldest turns first. + /// + internal static string BuildContinuationTranscript( + List history, string sourceSessionName, string? sessionId) + { + const int maxChars = 6000; + const int assistantTruncateLen = 400; + + // Build per-turn summaries + var turns = new List(); + foreach (var msg in history) + { + switch (msg.MessageType) + { + case ChatMessageType.User: + turns.Add($"**User:** {msg.Content}"); + break; + + case ChatMessageType.Assistant: + var content = msg.Content; + if (content.Length > assistantTruncateLen) + content = content[..assistantTruncateLen] + "…"; + turns.Add($"**Assistant:** {content}"); + break; + + case ChatMessageType.ToolCall: + var status = msg.IsComplete ? (msg.IsSuccess ? "βœ…" : "❌") : "⏳"; + var toolDisplay = msg.ToolName ?? "unknown"; + turns.Add($" πŸ”§ {toolDisplay} {status}"); + break; + + case ChatMessageType.Error: + turns.Add($" ⚠️ Error: {Truncate(msg.Content, 150)}"); + break; + + case ChatMessageType.System: + // Skip system messages β€” not useful for context + break; + + case ChatMessageType.Image: + turns.Add($" πŸ–ΌοΈ Image{(string.IsNullOrEmpty(msg.Caption) ? "" : $": {msg.Caption}")}"); + break; + + default: + // Reasoning, ShellOutput, Diff, Reflection β€” skip for brevity + break; + } + } + + // Trim oldest turns to fit within budget + while (turns.Count > 2 && EstimateLength(turns, sourceSessionName, sessionId) > maxChars) + { + turns.RemoveAt(0); + } + + var sb = new StringBuilder(); + sb.AppendLine($"I'm continuing work from the session \"{sourceSessionName}\". Here's the conversation context:"); + sb.AppendLine(); + sb.AppendLine("---"); + foreach (var turn in turns) + { + sb.AppendLine(turn); + } + sb.AppendLine("---"); + sb.AppendLine(); + sb.AppendLine("Please read the above context and continue where we left off. If you need more detail, the full session log is available at:"); + + if (!string.IsNullOrEmpty(sessionId)) + { + var eventsPath = Path.Combine("~/.copilot/session-state", sessionId, "events.jsonl"); + sb.AppendLine($"`{eventsPath}`"); + } + else + { + sb.AppendLine("(session ID not available β€” check ~/.copilot/session-state/ for recent sessions)"); + } + + return sb.ToString(); + } + + /// + /// Generates a continuation session name, stripping existing " (cont'd)" suffix to prevent nesting. + /// + internal static string GenerateContinuationName(string sourceName) + { + const string suffix = " (cont'd)"; + var baseName = sourceName.EndsWith(suffix) ? sourceName[..^suffix.Length] : sourceName; + return baseName + suffix; + } + + private static int EstimateLength(List turns, string sourceName, string? sessionId) + { + var turnLen = 0; + foreach (var t in turns) turnLen += t.Length + 1; + return 120 + turnLen + 200; + } +} diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index e6b0d321e3..f4d41db866 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -19,6 +19,13 @@ public partial class CopilotService : IAsyncDisposable // Sessions currently receiving streaming content via bridge events β€” history sync skipped to avoid duplicates private readonly ConcurrentDictionary _remoteStreamingSessions = new(); + /// + /// Drafts queued by "Continue in new session" for the Dashboard to pick up. + /// Key = session name, Value = pre-filled prompt text. + /// Dashboard consumes entries when it renders the session's input. + /// + internal readonly ConcurrentDictionary PendingDrafts = new(); + /// /// Whether a session's history is still being synced after a turn completed (streaming guard active). /// Used by the UI to avoid clearing streaming content before the history sync replaces it. From 5c35db10dc8ba14cc8c423a4e8b1321e59637305 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 26 Mar 2026 11:13:44 -0500 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?HistoryLock,=20name=20collisions,=20image=20clone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BroadcastSessionHistoryAsync: use lock(session.HistoryLock) for History.ToArray() instead of try/catch (C1 β€” real data race) - BroadcastSessionHistoryAsync: clone ChatMessage before setting ImageDataUri to avoid mutating shared History objects (M1) - BroadcastSessionHistoryAsync: add top-level try/catch for fire-and-forget safety - GenerateContinuationName: accept existing names and append counter suffix (cont'd 2, cont'd 3...) when name already exists (C2) - Strip numbered continuation suffixes (cont'd N) when re-continuing - Use discard for CreateSessionAsync return value (L2) - 4 new tests for name collision handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/ContinuationTests.cs | 32 +++++++++++++++++ .../Services/CopilotService.Continuation.cs | 31 ++++++++++++---- PolyPilot/Services/WsBridgeServer.cs | 36 ++++++++++++++----- 3 files changed, 85 insertions(+), 14 deletions(-) diff --git a/PolyPilot.Tests/ContinuationTests.cs b/PolyPilot.Tests/ContinuationTests.cs index 541d8999e1..759ae97b22 100644 --- a/PolyPilot.Tests/ContinuationTests.cs +++ b/PolyPilot.Tests/ContinuationTests.cs @@ -19,6 +19,38 @@ public void GenerateContinuationName_StripsExistingSuffix() Assert.Equal("my-session (cont'd)", result); } + [Fact] + public void GenerateContinuationName_IncrementsWhenNameExists() + { + var existing = new[] { "my-session (cont'd)" }; + var result = CopilotService.GenerateContinuationName("my-session", existing); + Assert.Equal("my-session (cont'd 2)", result); + } + + [Fact] + public void GenerateContinuationName_IncrementsMultipleTimes() + { + var existing = new[] { "my-session (cont'd)", "my-session (cont'd 2)", "my-session (cont'd 3)" }; + var result = CopilotService.GenerateContinuationName("my-session", existing); + Assert.Equal("my-session (cont'd 4)", result); + } + + [Fact] + public void GenerateContinuationName_FromContdSourceWithExisting() + { + // Continuing from an already-continued session when the base name also exists + var existing = new[] { "my-session (cont'd)" }; + var result = CopilotService.GenerateContinuationName("my-session (cont'd)", existing); + Assert.Equal("my-session (cont'd 2)", result); + } + + [Fact] + public void GenerateContinuationName_StripsNumberedSuffix() + { + var result = CopilotService.GenerateContinuationName("my-session (cont'd 3)"); + Assert.Equal("my-session (cont'd)", result); + } + [Fact] public void BuildContinuationTranscript_IncludesUserMessages() { diff --git a/PolyPilot/Services/CopilotService.Continuation.cs b/PolyPilot/Services/CopilotService.Continuation.cs index 7c7070d6a8..1b4edb9654 100644 --- a/PolyPilot/Services/CopilotService.Continuation.cs +++ b/PolyPilot/Services/CopilotService.Continuation.cs @@ -23,11 +23,11 @@ public partial class CopilotService } var transcript = BuildContinuationTranscript(history, sourceSessionName, info.SessionId); - var newName = GenerateContinuationName(sourceSessionName); // Inherit model, working directory, and group from source session var groupId = Organization.Sessions.FirstOrDefault(m => m.SessionName == sourceSessionName)?.GroupId; - var newSession = await CreateSessionAsync(newName, info.Model, info.WorkingDirectory, ct, groupId); + var newName = GenerateContinuationName(sourceSessionName, _sessions.Keys); + _ = await CreateSessionAsync(newName, info.Model, info.WorkingDirectory, ct, groupId); return (newName, transcript); } @@ -115,13 +115,32 @@ internal static string BuildContinuationTranscript( } /// - /// Generates a continuation session name, stripping existing " (cont'd)" suffix to prevent nesting. + /// Generates a continuation session name. Strips existing " (cont'd)" or " (cont'd N)" suffix, + /// then appends a counter if the name already exists among active sessions. /// - internal static string GenerateContinuationName(string sourceName) + internal static string GenerateContinuationName(string sourceName, IEnumerable? existingNames = null) { const string suffix = " (cont'd)"; - var baseName = sourceName.EndsWith(suffix) ? sourceName[..^suffix.Length] : sourceName; - return baseName + suffix; + // Strip existing continuation suffixes: " (cont'd)" or " (cont'd 2)", " (cont'd 3)", etc. + var baseName = sourceName; + if (baseName.EndsWith(suffix)) + baseName = baseName[..^suffix.Length]; + else if (System.Text.RegularExpressions.Regex.Match(baseName, @" \(cont'd \d+\)$") is { Success: true } m) + baseName = baseName[..m.Index]; + + var existing = existingNames != null ? new HashSet(existingNames) : new HashSet(); + var candidate = baseName + suffix; + if (!existing.Contains(candidate)) + return candidate; + + for (int i = 2; i < 100; i++) + { + candidate = $"{baseName} (cont'd {i})"; + if (!existing.Contains(candidate)) + return candidate; + } + + return $"{baseName} (cont'd {DateTime.UtcNow.Ticks})"; } private static int EstimateLength(List turns, string sourceName, string? sessionId) diff --git a/PolyPilot/Services/WsBridgeServer.cs b/PolyPilot/Services/WsBridgeServer.cs index 59571ae768..5ad0f98468 100644 --- a/PolyPilot/Services/WsBridgeServer.cs +++ b/PolyPilot/Services/WsBridgeServer.cs @@ -1205,14 +1205,18 @@ private void BroadcastOrganizationState() /// private async Task BroadcastSessionHistoryAsync(string sessionName) { - if (_copilot == null || _clients.IsEmpty) return; + try + { + if (_copilot == null || _clients.IsEmpty) return; - var session = _copilot.GetSession(sessionName); - if (session == null) return; + var session = _copilot.GetSession(sessionName); + if (session == null) return; ChatMessage[] snapshot; - try { snapshot = session.History.ToArray(); } - catch { return; } // concurrent modification β€” skip this broadcast + lock (session.HistoryLock) + { + snapshot = session.History.ToArray(); + } var totalCount = snapshot.Length; List messagesToSend; @@ -1228,9 +1232,10 @@ private async Task BroadcastSessionHistoryAsync(string sessionName) hasMore = false; } - // Populate ImageDataUri for Image messages - foreach (var m in messagesToSend) + // Populate ImageDataUri for Image messages β€” clone to avoid mutating shared History objects + for (int i = 0; i < messagesToSend.Count; i++) { + var m = messagesToSend[i]; if (m.MessageType == ChatMessageType.Image && string.IsNullOrEmpty(m.ImageDataUri) && !string.IsNullOrEmpty(m.ImagePath)) { try @@ -1238,7 +1243,17 @@ private async Task BroadcastSessionHistoryAsync(string sessionName) if (File.Exists(m.ImagePath)) { var bytes = await File.ReadAllBytesAsync(m.ImagePath); - m.ImageDataUri = $"data:{ImageMimeType(m.ImagePath)};base64,{Convert.ToBase64String(bytes)}"; + var clone = new ChatMessage(m.Role, m.Content, m.Timestamp, m.MessageType) + { + ImagePath = m.ImagePath, + Caption = m.Caption, + ToolCallId = m.ToolCallId, + ToolName = m.ToolName, + IsComplete = m.IsComplete, + IsSuccess = m.IsSuccess, + ImageDataUri = $"data:{ImageMimeType(m.ImagePath)};base64,{Convert.ToBase64String(bytes)}" + }; + messagesToSend[i] = clone; } } catch { /* best effort */ } @@ -1253,6 +1268,11 @@ private async Task BroadcastSessionHistoryAsync(string sessionName) HasMore = hasMore }; Broadcast(BridgeMessage.Create(BridgeMessageTypes.SessionHistory, payload)); + } + catch (Exception ex) + { + Console.WriteLine($"[WsBridge] BroadcastSessionHistory error for '{sessionName}': {ex.Message}"); + } } private async Task HandleOrganizationCommandAsync(OrganizationCommandPayload cmd) From 066731ce98ceb212436236484f912b137325b47f Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 26 Mar 2026 15:12:43 -0500 Subject: [PATCH 5/7] fix: throw on bridge disconnect instead of silently dropping messages Root cause: WsBridgeClient.SendAsync silently returned when the WebSocket was disconnected, causing messages sent from mobile during a disconnect window to vanish without any error feedback. - SendAsync now throws InvalidOperationException when WS not open - SendPromptAsync adds pre-flight IsConnected check for fast fail - SendPromptAsync catches bridge errors and clears IsProcessing - Fire-and-forget callers (SwitchSession, DeleteGroup) get error logging - 6 new tests covering disconnect, mid-send failure, and state cleanup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/BridgeDisconnectTests.cs | 135 ++++++++++++++++++ PolyPilot.Tests/TestStubs.cs | 8 +- .../Services/CopilotService.Organization.cs | 4 +- PolyPilot/Services/CopilotService.cs | 26 +++- PolyPilot/Services/WsBridgeClient.cs | 10 +- 5 files changed, 174 insertions(+), 9 deletions(-) create mode 100644 PolyPilot.Tests/BridgeDisconnectTests.cs diff --git a/PolyPilot.Tests/BridgeDisconnectTests.cs b/PolyPilot.Tests/BridgeDisconnectTests.cs new file mode 100644 index 0000000000..8f4bd3a4d9 --- /dev/null +++ b/PolyPilot.Tests/BridgeDisconnectTests.cs @@ -0,0 +1,135 @@ +using Microsoft.Extensions.DependencyInjection; +using PolyPilot.Models; +using PolyPilot.Services; + +namespace PolyPilot.Tests; + +/// +/// Tests for the bridge disconnect fix: SendAsync must throw (not silently drop) +/// when the WebSocket is disconnected, and callers must clean up IsProcessing. +/// Regression test for: user sends message from mobile during WS disconnect, +/// message silently dropped with no error feedback. +/// +public class BridgeDisconnectTests +{ + private readonly StubChatDatabase _chatDb = new(); + private readonly StubServerManager _serverManager = new(); + private readonly StubWsBridgeClient _bridgeClient = new(); + private readonly StubDemoService _demoService = new(); + private readonly IServiceProvider _serviceProvider; + + public BridgeDisconnectTests() + { + var services = new ServiceCollection(); + _serviceProvider = services.BuildServiceProvider(); + } + + private CopilotService CreateRemoteService() + { + _bridgeClient.IsConnected = true; // Default to connected for setup + var svc = new CopilotService(_chatDb, _serverManager, _bridgeClient, new RepoManager(), _serviceProvider, _demoService); + // Set IsRemoteMode via reflection (normally set during InitializeAsync with remote connection) + typeof(CopilotService).GetProperty(nameof(CopilotService.IsRemoteMode))!.SetValue(svc, true); + return svc; + } + + private async Task AddRemoteSession(CopilotService svc, string name) + { + // Create session via public API (in remote mode this adds to _sessions + Organization) + var wasConnected = _bridgeClient.IsConnected; + _bridgeClient.IsConnected = true; + await svc.CreateSessionAsync(name, "test-model"); + _bridgeClient.IsConnected = wasConnected; + } + + [Fact] + public async Task SendPromptAsync_WhenDisconnected_ThrowsAndCleansUpProcessing() + { + var svc = CreateRemoteService(); + await AddRemoteSession(svc, "test-session"); + _bridgeClient.IsConnected = false; + + var ex = await Assert.ThrowsAsync( + () => svc.SendPromptAsync("test-session", "hello")); + + Assert.Contains("Not connected", ex.Message); + + // IsProcessing must be cleared so the session isn't stuck + var session = svc.GetSession("test-session"); + Assert.NotNull(session); + Assert.False(session!.IsProcessing); + } + + [Fact] + public async Task SendPromptAsync_WhenConnected_Succeeds() + { + var svc = CreateRemoteService(); + await AddRemoteSession(svc, "test-session"); + _bridgeClient.IsConnected = true; + _bridgeClient.ThrowOnSend = false; + + await svc.SendPromptAsync("test-session", "hello"); + + // User message should be in history + var session = svc.GetSession("test-session"); + Assert.NotNull(session); + Assert.Single(session!.History, m => m.IsUser && m.Content == "hello"); + } + + [Fact] + public async Task SendPromptAsync_WhenBridgeThrowsDuringSend_CleansUpProcessing() + { + var svc = CreateRemoteService(); + await AddRemoteSession(svc, "test-session"); + _bridgeClient.IsConnected = true; + _bridgeClient.ThrowOnSend = true; // Connected but send fails + + await Assert.ThrowsAsync( + () => svc.SendPromptAsync("test-session", "hello")); + + // IsProcessing must be cleaned up even though pre-flight check passed + var session = svc.GetSession("test-session"); + Assert.NotNull(session); + Assert.False(session!.IsProcessing); + } + + [Fact] + public async Task SendPromptAsync_WhenDisconnected_UserMessageNotAdded() + { + var svc = CreateRemoteService(); + await AddRemoteSession(svc, "test-session"); + _bridgeClient.IsConnected = false; + + await Assert.ThrowsAsync( + () => svc.SendPromptAsync("test-session", "hello")); + + // Pre-flight check throws before adding the message β€” history should be empty + var session = svc.GetSession("test-session"); + Assert.NotNull(session); + Assert.Empty(session!.History); + } + + [Fact] + public async Task SendPromptAsync_WhenBridgeThrows_UserMessageInHistory() + { + var svc = CreateRemoteService(); + await AddRemoteSession(svc, "test-session"); + _bridgeClient.IsConnected = true; + _bridgeClient.ThrowOnSend = true; + + await Assert.ThrowsAsync( + () => svc.SendPromptAsync("test-session", "hello")); + + // Pre-flight passed so the user message was optimistically added + var session = svc.GetSession("test-session"); + Assert.NotNull(session); + Assert.Single(session!.History, m => m.IsUser && m.Content == "hello"); + } + + [Fact] + public void IsRemoteMode_WhenSet_ReportsCorrectly() + { + var svc = CreateRemoteService(); + Assert.True(svc.IsRemoteMode); + } +} diff --git a/PolyPilot.Tests/TestStubs.cs b/PolyPilot.Tests/TestStubs.cs index 1a8eb3936b..4cbbb9eee8 100644 --- a/PolyPilot.Tests/TestStubs.cs +++ b/PolyPilot.Tests/TestStubs.cs @@ -60,6 +60,7 @@ public Task StartServerAsync(int port) internal class StubWsBridgeClient : IWsBridgeClient { public bool IsConnected { get; set; } + public bool ThrowOnSend { get; set; } public bool HasReceivedSessionsList { get; set; } public List Sessions { get; set; } = new(); public string? ActiveSessionName { get; set; } @@ -98,7 +99,12 @@ public Task RequestSessionsAsync(CancellationToken ct = default) return Task.CompletedTask; } public Task RequestHistoryAsync(string sessionName, int? limit = null, CancellationToken ct = default) => Task.CompletedTask; - public Task SendMessageAsync(string sessionName, string message, string? agentMode = null, CancellationToken ct = default) => Task.CompletedTask; + public Task SendMessageAsync(string sessionName, string message, string? agentMode = null, CancellationToken ct = default) + { + if (ThrowOnSend) + throw new InvalidOperationException("Not connected to server"); + return Task.CompletedTask; + } public Task CreateSessionAsync(string name, string? model = null, string? workingDirectory = null, CancellationToken ct = default) => Task.CompletedTask; public string? LastSwitchedSession { get; private set; } public int SwitchSessionCallCount { get; private set; } diff --git a/PolyPilot/Services/CopilotService.Organization.cs b/PolyPilot/Services/CopilotService.Organization.cs index b4ba6151bf..394f2d144d 100644 --- a/PolyPilot/Services/CopilotService.Organization.cs +++ b/PolyPilot/Services/CopilotService.Organization.cs @@ -1019,7 +1019,9 @@ public void DeleteGroup(string groupId) RemoveGroupsWhere(g => g.Id == groupId); OnStateChanged?.Invoke(); // Tell server to do the real cleanup - _ = _bridgeClient.SendOrganizationCommandAsync(new OrganizationCommandPayload { Command = "delete_group", GroupId = groupId }); + _ = _bridgeClient.SendOrganizationCommandAsync(new OrganizationCommandPayload { Command = "delete_group", GroupId = groupId }) + .ContinueWith(t => Console.WriteLine($"[CopilotService] DeleteGroup bridge error: {t.Exception?.InnerException?.Message}"), + TaskContinuationOptions.OnlyOnFaulted); return; } diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs index f4d41db866..3ab839933c 100644 --- a/PolyPilot/Services/CopilotService.cs +++ b/PolyPilot/Services/CopilotService.cs @@ -2951,6 +2951,9 @@ public async Task SendPromptAsync(string sessionName, string prompt, Lis // In remote mode, delegate to WsBridgeClient if (IsRemoteMode) { + if (!_bridgeClient.IsConnected) + throw new InvalidOperationException("Not connected to server. Reconnecting…"); + // Add user message locally for immediate UI feedback var session = GetRemoteSession(sessionName); if (session != null && !skipHistoryMessage) @@ -2962,7 +2965,20 @@ public async Task SendPromptAsync(string sessionName, string prompt, Lis if (session != null) session.IsProcessing = true; OnStateChanged?.Invoke(); - await _bridgeClient.SendMessageAsync(sessionName, prompt, agentMode, cancellationToken); + try + { + await _bridgeClient.SendMessageAsync(sessionName, prompt, agentMode, cancellationToken); + } + catch + { + // Send failed (disconnected) β€” clean up processing state + if (session != null) + { + session.IsProcessing = false; + OnStateChanged?.Invoke(); + } + throw; + } return ""; // Response comes via events } @@ -4244,7 +4260,9 @@ public bool SwitchSession(string name) _activeSessionName = name; if (IsRemoteMode) - _ = _bridgeClient.SwitchSessionAsync(name); + _ = _bridgeClient.SwitchSessionAsync(name) + .ContinueWith(t => Console.WriteLine($"[CopilotService] SwitchSession bridge error: {t.Exception?.InnerException?.Message}"), + TaskContinuationOptions.OnlyOnFaulted); OnStateChanged?.Invoke(); return true; } @@ -4359,7 +4377,9 @@ public void SetActiveSession(string? name) _activeSessionName = name; activeState.Info.LastUpdatedAt = DateTime.Now; if (IsRemoteMode) - _ = _bridgeClient.SwitchSessionAsync(name); + _ = _bridgeClient.SwitchSessionAsync(name) + .ContinueWith(t => Console.WriteLine($"[CopilotService] SwitchSession bridge error: {t.Exception?.InnerException?.Message}"), + TaskContinuationOptions.OnlyOnFaulted); } } diff --git a/PolyPilot/Services/WsBridgeClient.cs b/PolyPilot/Services/WsBridgeClient.cs index 5fbbbf40d3..0dec41e674 100644 --- a/PolyPilot/Services/WsBridgeClient.cs +++ b/PolyPilot/Services/WsBridgeClient.cs @@ -884,17 +884,19 @@ private void HandleServerMessage(string json) private async Task SendAsync(BridgeMessage msg, CancellationToken ct) { - if (_ws?.State != WebSocketState.Open) return; + if (_ws?.State != WebSocketState.Open) + throw new InvalidOperationException("Not connected to server"); var bytes = Encoding.UTF8.GetBytes(msg.Serialize()); try { await _sendLock.WaitAsync(ct); } - catch (ObjectDisposedException) { return; } + catch (ObjectDisposedException) { throw new InvalidOperationException("Bridge client has been disposed"); } try { - if (_ws?.State == WebSocketState.Open) - await _ws.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, ct); + if (_ws?.State != WebSocketState.Open) + throw new InvalidOperationException("Server disconnected during send"); + await _ws.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, ct); } finally { From 848c25c6c83651ac46b2f9ec1411503a22e9bf88 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 26 Mar 2026 16:24:54 -0500 Subject: [PATCH 6/7] feat: auto-send continuation transcript + update menu label - Changed 'Continue in new session' to auto-send the transcript as the first message instead of pre-filling the input field - Updated menu label to 'Continue in new session (same worktree)' to clarify it inherits the source session's working directory Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Components/Layout/SessionListItem.razor | 2 +- PolyPilot/Components/Layout/SessionSidebar.razor | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/PolyPilot/Components/Layout/SessionListItem.razor b/PolyPilot/Components/Layout/SessionListItem.razor index c737e80c7d..a5073bec65 100644 --- a/PolyPilot/Components/Layout/SessionListItem.razor +++ b/PolyPilot/Components/Layout/SessionListItem.razor @@ -228,7 +228,7 @@ @if (!string.IsNullOrEmpty(Session.SessionId) && !IsMultiAgentMember) { } diff --git a/PolyPilot/Components/Layout/SessionSidebar.razor b/PolyPilot/Components/Layout/SessionSidebar.razor index b42b783cbe..7828178be4 100644 --- a/PolyPilot/Components/Layout/SessionSidebar.razor +++ b/PolyPilot/Components/Layout/SessionSidebar.razor @@ -2869,11 +2869,12 @@ else try { var (newName, transcript) = await CopilotService.ContinueInNewSessionAsync(sessionName); - CopilotService.PendingDrafts[newName] = transcript; CopilotService.SwitchSession(newName); CopilotService.SaveUiState(currentPage); await OnSessionSelected.InvokeAsync(); - footerStatus = $"βœ“ Created '{newName}' β€” draft pre-filled"; + // Send the transcript as the first message automatically + await CopilotService.SendPromptAsync(newName, transcript); + footerStatus = $"βœ“ Created '{newName}' β€” context sent"; StateHasChanged(); } catch (Exception ex) From 0a4190f844918e13078f56e4152f21fdd81e5ea2 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Thu, 26 Mar 2026 16:28:10 -0500 Subject: [PATCH 7/7] fix: marshal abort_session to UI thread in WsBridgeServer When mobile sends abort_session, AbortSessionAsync mutates IsProcessing and History which must run on the UI thread. Without marshaling, it threw 'not associated with the Dispatcher' errors and the abort silently failed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Services/WsBridgeServer.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/PolyPilot/Services/WsBridgeServer.cs b/PolyPilot/Services/WsBridgeServer.cs index 5ad0f98468..8c751d7933 100644 --- a/PolyPilot/Services/WsBridgeServer.cs +++ b/PolyPilot/Services/WsBridgeServer.cs @@ -623,7 +623,11 @@ await SendToClientAsync(clientId, ws, if (abortReq != null && !string.IsNullOrWhiteSpace(abortReq.SessionName)) { Console.WriteLine($"[WsBridge] Client aborting session '{abortReq.SessionName}'"); - await _copilot.AbortSessionAsync(abortReq.SessionName); + // AbortSessionAsync mutates IsProcessing/History β€” must run on UI thread + _copilot.InvokeOnUI(() => + { + _ = _copilot.AbortSessionAsync(abortReq.SessionName); + }); } break;