diff --git a/PolyPilot.Tests/TestStubs.cs b/PolyPilot.Tests/TestStubs.cs index ec8fcc0e47..6b7c9191f6 100644 --- a/PolyPilot.Tests/TestStubs.cs +++ b/PolyPilot.Tests/TestStubs.cs @@ -59,6 +59,7 @@ internal class StubWsBridgeClient : IWsBridgeClient public List Sessions { get; set; } = new(); public string? ActiveSessionName { get; set; } public System.Collections.Concurrent.ConcurrentDictionary> SessionHistories { get; } = new(); + public System.Collections.Concurrent.ConcurrentDictionary SessionHistoryHasMore { get; } = new(); public List PersistedSessions { get; set; } = new(); public string? GitHubAvatarUrl { get; set; } public string? GitHubLogin { get; set; } @@ -87,7 +88,7 @@ public Task RequestSessionsAsync(CancellationToken ct = default) RequestSessionsCallCount++; return Task.CompletedTask; } - public Task RequestHistoryAsync(string sessionName, CancellationToken ct = default) => Task.CompletedTask; + public Task RequestHistoryAsync(string sessionName, int? limit = null, CancellationToken ct = default) => Task.CompletedTask; public Task SendMessageAsync(string sessionName, string message, CancellationToken ct = default) => Task.CompletedTask; public Task CreateSessionAsync(string name, string? model = null, string? workingDirectory = null, CancellationToken ct = default) => Task.CompletedTask; public string? LastSwitchedSession { get; private set; } diff --git a/PolyPilot/Components/ExpandedSessionView.razor b/PolyPilot/Components/ExpandedSessionView.razor index 717e8dc71c..72abce6d12 100644 --- a/PolyPilot/Components/ExpandedSessionView.razor +++ b/PolyPilot/Components/ExpandedSessionView.razor @@ -105,11 +105,27 @@
@{ var expandedMessages = GetWindowedMessages(); } - @if (Session.History.Count > expandedMessages.Count) + @if (IsLoadingHistory) { - +
+
+ Loading conversation… +
+ } + else + { + @if (HasMoreRemoteHistory) + { + + } + else if (Session.History.Count > expandedMessages.Count) + { + + } } OnFontSizeChange { get; set; } [Parameter] public EventCallback OnLoadMore { get; set; } + [Parameter] public EventCallback OnLoadFullHistory { get; set; } [Parameter] public EventCallback<(string SessionName, FiestaStartRequest Request)> OnStartFiesta { get; set; } [Parameter] public EventCallback OnStopFiesta { get; set; } [Parameter] public EventCallback OnStopReflection { get; set; } @@ -302,6 +321,7 @@ } private void LoadMore() => OnLoadMore.InvokeAsync(Session.Name); + private void LoadFullHistory() => OnLoadFullHistory.InvokeAsync(Session.Name); private static string PrettifyModel(string modelId) { diff --git a/PolyPilot/Components/ExpandedSessionView.razor.css b/PolyPilot/Components/ExpandedSessionView.razor.css index 207621336f..c35e7bec7a 100644 --- a/PolyPilot/Components/ExpandedSessionView.razor.css +++ b/PolyPilot/Components/ExpandedSessionView.razor.css @@ -293,6 +293,30 @@ color: var(--text-primary); } +/* Loading history indicator (remote mode) */ +.loading-history-indicator { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 2rem; + flex: 1; + color: var(--text-secondary); + font-size: var(--type-callout, 0.85rem); +} + +.loading-history-spinner { + width: 24px; + height: 24px; + border: 2.5px solid var(--hover-bg); + border-top-color: var(--accent-primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { to { transform: rotate(360deg); } } + /* Error bar (expanded) */ .expanded-card .error-bar { display: flex; diff --git a/PolyPilot/Components/Pages/Dashboard.razor b/PolyPilot/Components/Pages/Dashboard.razor index 7e4a08df92..d4240b4a98 100644 --- a/PolyPilot/Components/Pages/Dashboard.razor +++ b/PolyPilot/Components/Pages/Dashboard.razor @@ -115,6 +115,8 @@ @key="session.Name"> @@ -2595,6 +2598,11 @@ expandedMessageCounts[sessionName] = current + 25; } + private async Task LoadFullRemoteHistory(string sessionName) + { + await CopilotService.LoadFullRemoteHistoryAsync(sessionName); + } + // === Model, plan mode, font, token helpers === private static readonly string[] _fallbackModels = new[] diff --git a/PolyPilot/Models/BridgeMessages.cs b/PolyPilot/Models/BridgeMessages.cs index 4d5d732afc..4b402f9151 100644 --- a/PolyPilot/Models/BridgeMessages.cs +++ b/PolyPilot/Models/BridgeMessages.cs @@ -146,6 +146,10 @@ public class SessionHistoryPayload { public string SessionName { get; set; } = ""; public List Messages { get; set; } = new(); + /// Total message count on the server (may be more than Messages.Count when limited). + public int TotalCount { get; set; } + /// True when the server has older messages not included in this response. + public bool HasMore { get; set; } } public class ContentDeltaPayload @@ -225,6 +229,10 @@ public class ErrorPayload public class GetHistoryPayload { public string SessionName { get; set; } = ""; + /// + /// Max messages to return (most recent). Null = all messages. + /// + public int? Limit { get; set; } } public class SendMessagePayload diff --git a/PolyPilot/Services/CopilotService.Bridge.cs b/PolyPilot/Services/CopilotService.Bridge.cs index 157f367ac8..2bb2cad205 100644 --- a/PolyPilot/Services/CopilotService.Bridge.cs +++ b/PolyPilot/Services/CopilotService.Bridge.cs @@ -333,7 +333,7 @@ private void SyncRemoteSessions() { foreach (var name in sessionsNeedingHistory) { - try { await _bridgeClient.RequestHistoryAsync(name); } + try { await _bridgeClient.RequestHistoryAsync(name, limit: 10); } catch { } } }); @@ -353,6 +353,21 @@ private void SyncRemoteSessions() private AgentSessionInfo? GetRemoteSession(string name) => _sessions.TryGetValue(name, out var state) ? state.Info : null; + /// + /// Whether the server has more history for this session than what's been loaded. + /// + public bool HasMoreRemoteHistory(string sessionName) => + IsRemoteMode && _bridgeClient.SessionHistoryHasMore.TryGetValue(sessionName, out var hasMore) && hasMore; + + /// + /// Request the full (unlimited) history for a session from the remote server. + /// + public async Task LoadFullRemoteHistoryAsync(string sessionName) + { + if (!IsRemoteMode) return; + await _bridgeClient.RequestHistoryAsync(sessionName, limit: null); + } + // --- Remote repo operations --- public async Task<(string RepoId, string RepoName)?> AddRepoRemoteAsync(string url, Action? onProgress = null, CancellationToken ct = default) diff --git a/PolyPilot/Services/IWsBridgeClient.cs b/PolyPilot/Services/IWsBridgeClient.cs index ebc3655c75..feab778df9 100644 --- a/PolyPilot/Services/IWsBridgeClient.cs +++ b/PolyPilot/Services/IWsBridgeClient.cs @@ -12,6 +12,7 @@ public interface IWsBridgeClient List Sessions { get; } string? ActiveSessionName { get; } System.Collections.Concurrent.ConcurrentDictionary> SessionHistories { get; } + System.Collections.Concurrent.ConcurrentDictionary SessionHistoryHasMore { get; } List PersistedSessions { get; } string? GitHubAvatarUrl { get; } string? GitHubLogin { get; } @@ -37,7 +38,7 @@ public interface IWsBridgeClient Task ConnectAsync(string wsUrl, string? authToken = null, CancellationToken ct = default); void Stop(); Task RequestSessionsAsync(CancellationToken ct = default); - Task RequestHistoryAsync(string sessionName, CancellationToken ct = default); + Task RequestHistoryAsync(string sessionName, int? limit = null, CancellationToken ct = default); Task SendMessageAsync(string sessionName, string message, CancellationToken ct = default); Task CreateSessionAsync(string name, string? model = null, string? workingDirectory = null, CancellationToken ct = default); Task SwitchSessionAsync(string name, CancellationToken ct = default); diff --git a/PolyPilot/Services/WsBridgeClient.cs b/PolyPilot/Services/WsBridgeClient.cs index ec3213554c..123ca706e1 100644 --- a/PolyPilot/Services/WsBridgeClient.cs +++ b/PolyPilot/Services/WsBridgeClient.cs @@ -26,6 +26,7 @@ public class WsBridgeClient : IWsBridgeClient, IDisposable public List Sessions { get; private set; } = new(); public string? ActiveSessionName { get; private set; } public ConcurrentDictionary> SessionHistories { get; } = new(); + public ConcurrentDictionary SessionHistoryHasMore { get; } = new(); public List PersistedSessions { get; private set; } = new(); public string? GitHubAvatarUrl { get; private set; } public string? GitHubLogin { get; private set; } @@ -168,9 +169,9 @@ public void Stop() public async Task RequestSessionsAsync(CancellationToken ct = default) => await SendAsync(new BridgeMessage { Type = BridgeMessageTypes.GetSessions }, ct); - public async Task RequestHistoryAsync(string sessionName, CancellationToken ct = default) => + public async Task RequestHistoryAsync(string sessionName, int? limit = null, CancellationToken ct = default) => await SendAsync(BridgeMessage.Create(BridgeMessageTypes.GetHistory, - new GetHistoryPayload { SessionName = sessionName }), ct); + new GetHistoryPayload { SessionName = sessionName, Limit = limit }), ct); public async Task SendMessageAsync(string sessionName, string message, CancellationToken ct = default) => await SendAsync(BridgeMessage.Create(BridgeMessageTypes.SendMessage, @@ -460,7 +461,8 @@ private void HandleServerMessage(string json) if (history != null) { SessionHistories[history.SessionName] = history.Messages; - Console.WriteLine($"[WsBridgeClient] Got history for '{history.SessionName}': {history.Messages.Count} messages"); + SessionHistoryHasMore[history.SessionName] = history.HasMore; + Console.WriteLine($"[WsBridgeClient] Got history for '{history.SessionName}': {history.Messages.Count} messages (total={history.TotalCount}, hasMore={history.HasMore})"); OnStateChanged?.Invoke(); } break; diff --git a/PolyPilot/Services/WsBridgeServer.cs b/PolyPilot/Services/WsBridgeServer.cs index 24c79bc88c..ff69ba3b8c 100644 --- a/PolyPilot/Services/WsBridgeServer.cs +++ b/PolyPilot/Services/WsBridgeServer.cs @@ -306,13 +306,13 @@ await SendToClientAsync(clientId, ws, } await SendPersistedToClient(clientId, ws, ct); - // Send history for all active sessions so mobile has full state on connect + // Send recent history for all active sessions (limited to reduce initial payload) if (_copilot != null) { foreach (var session in _copilot.GetAllSessions()) { if (session.History.Count > 0) - await SendSessionHistoryToClient(clientId, ws, session.Name, ct); + await SendSessionHistoryToClient(clientId, ws, session.Name, 10, ct); } } @@ -371,7 +371,7 @@ await SendToClientAsync(clientId, ws, case BridgeMessageTypes.GetHistory: var histReq = msg.GetPayload(); if (histReq != null) - await SendSessionHistoryToClient(clientId, ws, histReq.SessionName, ct); + await SendSessionHistoryToClient(clientId, ws, histReq.SessionName, histReq.Limit, ct); break; case BridgeMessageTypes.SendMessage: @@ -415,7 +415,7 @@ await SendToClientAsync(clientId, ws, { _copilot.SetActiveSession(switchReq.SessionName); BroadcastSessionsList(); - await SendSessionHistoryToClient(clientId, ws, switchReq.SessionName, ct); + await SendSessionHistoryToClient(clientId, ws, switchReq.SessionName, 10, ct); } break; @@ -748,15 +748,32 @@ private async Task SendPersistedToClient(string clientId, WebSocket ws, Cancella await SendToClientAsync(clientId, ws, msg, ct); } - private async Task SendSessionHistoryToClient(string clientId, WebSocket ws, string sessionName, CancellationToken ct) + private async Task SendSessionHistoryToClient(string clientId, WebSocket ws, string sessionName, int? limit, CancellationToken ct) { if (_copilot == null) return; var session = _copilot.GetSession(sessionName); if (session == null) return; + var allMessages = session.History; + var totalCount = allMessages.Count; + + // Apply limit — take the most recent N messages + List messagesToSend; + bool hasMore; + if (limit.HasValue && limit.Value < totalCount) + { + messagesToSend = allMessages.Skip(totalCount - limit.Value).ToList(); + hasMore = true; + } + else + { + messagesToSend = allMessages.ToList(); + hasMore = false; + } + // Populate ImageDataUri for Image messages so mobile can render them - foreach (var m in session.History) + foreach (var m in messagesToSend) { if (m.MessageType == ChatMessageType.Image && string.IsNullOrEmpty(m.ImageDataUri) && !string.IsNullOrEmpty(m.ImagePath)) { @@ -775,7 +792,9 @@ private async Task SendSessionHistoryToClient(string clientId, WebSocket ws, str var payload = new SessionHistoryPayload { SessionName = sessionName, - Messages = session.History.ToList() + Messages = messagesToSend, + TotalCount = totalCount, + HasMore = hasMore }; var msg = BridgeMessage.Create(BridgeMessageTypes.SessionHistory, payload); await SendToClientAsync(clientId, ws, msg, ct);