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/ContinuationTests.cs b/PolyPilot.Tests/ContinuationTests.cs
new file mode 100644
index 0000000000..759ae97b22
--- /dev/null
+++ b/PolyPilot.Tests/ContinuationTests.cs
@@ -0,0 +1,190 @@
+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 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()
+ {
+ 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.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/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/Components/Layout/SessionListItem.razor b/PolyPilot/Components/Layout/SessionListItem.razor
index f6d3ce3790..a5073bec65 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..7828178be4 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,26 @@ else
}
}
+ private async Task ContinueInNewSession(string sessionName)
+ {
+ try
+ {
+ var (newName, transcript) = await CopilotService.ContinueInNewSessionAsync(sessionName);
+ CopilotService.SwitchSession(newName);
+ CopilotService.SaveUiState(currentPage);
+ await OnSessionSelected.InvokeAsync();
+ // Send the transcript as the first message automatically
+ await CopilotService.SendPromptAsync(newName, transcript);
+ footerStatus = $"β Created '{newName}' β context sent";
+ 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.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.Continuation.cs b/PolyPilot/Services/CopilotService.Continuation.cs
new file mode 100644
index 0000000000..1b4edb9654
--- /dev/null
+++ b/PolyPilot/Services/CopilotService.Continuation.cs
@@ -0,0 +1,152 @@
+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);
+
+ // Inherit model, working directory, and group from source session
+ var groupId = Organization.Sessions.FirstOrDefault(m => m.SessionName == sourceSessionName)?.GroupId;
+ var newName = GenerateContinuationName(sourceSessionName, _sessions.Keys);
+ _ = 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. 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, IEnumerable? existingNames = null)
+ {
+ const string suffix = " (cont'd)";
+ // 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)
+ {
+ var turnLen = 0;
+ foreach (var t in turns) turnLen += t.Length + 1;
+ return 120 + turnLen + 200;
+ }
+}
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 d0be08b81e..3ab839933c 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.
@@ -669,6 +676,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");
}
@@ -2943,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)
@@ -2954,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
}
@@ -4236,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;
}
@@ -4351,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
{
diff --git a/PolyPilot/Services/WsBridgeServer.cs b/PolyPilot/Services/WsBridgeServer.cs
index 69085da8af..8c751d7933 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,
@@ -615,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;
@@ -1190,6 +1202,83 @@ 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)
+ {
+ try
+ {
+ if (_copilot == null || _clients.IsEmpty) return;
+
+ var session = _copilot.GetSession(sessionName);
+ if (session == null) return;
+
+ ChatMessage[] snapshot;
+ lock (session.HistoryLock)
+ {
+ snapshot = session.History.ToArray();
+ }
+
+ 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 β 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
+ {
+ if (File.Exists(m.ImagePath))
+ {
+ var bytes = await File.ReadAllBytesAsync(m.ImagePath);
+ 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 */ }
+ }
+ }
+
+ var payload = new SessionHistoryPayload
+ {
+ SessionName = sessionName,
+ Messages = messagesToSend,
+ TotalCount = totalCount,
+ 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)
{
if (_copilot == null) return;