Skip to content

Commit 3d32ac6

Browse files
CopilotPureWeen
andcommitted
Add CCA session support: models, bridge protocol, service layer, sidebar UI, and tests
Co-authored-by: PureWeen <5375137+PureWeen@users.noreply.github.com>
1 parent 8ae4199 commit 3d32ac6

6 files changed

Lines changed: 305 additions & 0 deletions

File tree

PolyPilot.Tests/BridgeMessageTests.cs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ public void ClientToServer_TypeConstants_AreCorrect()
142142
Assert.Equal("get_sessions", BridgeMessageTypes.GetSessions);
143143
Assert.Equal("get_history", BridgeMessageTypes.GetHistory);
144144
Assert.Equal("get_persisted_sessions", BridgeMessageTypes.GetPersistedSessions);
145+
Assert.Equal("get_cca_sessions", BridgeMessageTypes.GetCcaSessions);
145146
Assert.Equal("send_message", BridgeMessageTypes.SendMessage);
146147
Assert.Equal("create_session", BridgeMessageTypes.CreateSession);
147148
Assert.Equal("resume_session", BridgeMessageTypes.ResumeSession);
@@ -152,6 +153,12 @@ public void ClientToServer_TypeConstants_AreCorrect()
152153
Assert.Equal("list_directories", BridgeMessageTypes.ListDirectories);
153154
}
154155

156+
[Fact]
157+
public void CcaSessionsList_TypeConstant_IsCorrect()
158+
{
159+
Assert.Equal("cca_sessions", BridgeMessageTypes.CcaSessionsList);
160+
}
161+
155162
[Fact]
156163
public void DirectoriesList_TypeConstant_IsCorrect()
157164
{
@@ -436,4 +443,86 @@ public void AttentionNeededPayload_AllReasons_RoundTrip(AttentionReason reason)
436443

437444
Assert.Equal(reason, restored!.Reason);
438445
}
446+
447+
[Fact]
448+
public void CcaSessionsPayload_RoundTrip()
449+
{
450+
var payload = new CcaSessionsPayload
451+
{
452+
Sessions = new List<CcaSessionSummary>
453+
{
454+
new()
455+
{
456+
SessionId = "cca-guid-1",
457+
Summary = "Fix login bug",
458+
StartTime = new DateTime(2025, 6, 15, 10, 0, 0, DateTimeKind.Utc),
459+
ModifiedTime = new DateTime(2025, 6, 15, 11, 0, 0, DateTimeKind.Utc),
460+
Repository = "owner/repo",
461+
Branch = "copilot/fix-123",
462+
WorkingDirectory = "/home/runner/work/repo"
463+
},
464+
new()
465+
{
466+
SessionId = "cca-guid-2",
467+
Summary = "Add tests for API",
468+
StartTime = new DateTime(2025, 6, 15, 12, 0, 0, DateTimeKind.Utc),
469+
ModifiedTime = new DateTime(2025, 6, 15, 13, 0, 0, DateTimeKind.Utc),
470+
Repository = "owner/other-repo",
471+
Branch = "copilot/add-tests",
472+
}
473+
}
474+
};
475+
var msg = BridgeMessage.Create(BridgeMessageTypes.CcaSessionsList, payload);
476+
var json = msg.Serialize();
477+
var restored = BridgeMessage.Deserialize(json)!.GetPayload<CcaSessionsPayload>();
478+
479+
Assert.NotNull(restored);
480+
Assert.Equal(2, restored!.Sessions.Count);
481+
Assert.Equal("cca-guid-1", restored.Sessions[0].SessionId);
482+
Assert.Equal("Fix login bug", restored.Sessions[0].Summary);
483+
Assert.Equal("owner/repo", restored.Sessions[0].Repository);
484+
Assert.Equal("copilot/fix-123", restored.Sessions[0].Branch);
485+
Assert.Equal("/home/runner/work/repo", restored.Sessions[0].WorkingDirectory);
486+
Assert.Equal("cca-guid-2", restored.Sessions[1].SessionId);
487+
Assert.Equal("Add tests for API", restored.Sessions[1].Summary);
488+
Assert.Null(restored.Sessions[1].WorkingDirectory);
489+
}
490+
491+
[Fact]
492+
public void CcaSessionSummary_NullOptionalFields_RoundTrip()
493+
{
494+
var payload = new CcaSessionsPayload
495+
{
496+
Sessions = new List<CcaSessionSummary>
497+
{
498+
new()
499+
{
500+
SessionId = "cca-minimal",
501+
StartTime = DateTime.UtcNow,
502+
ModifiedTime = DateTime.UtcNow
503+
}
504+
}
505+
};
506+
var msg = BridgeMessage.Create(BridgeMessageTypes.CcaSessionsList, payload);
507+
var json = msg.Serialize();
508+
var restored = BridgeMessage.Deserialize(json)!.GetPayload<CcaSessionsPayload>();
509+
510+
Assert.Single(restored!.Sessions);
511+
Assert.Equal("cca-minimal", restored.Sessions[0].SessionId);
512+
Assert.Null(restored.Sessions[0].Summary);
513+
Assert.Null(restored.Sessions[0].Repository);
514+
Assert.Null(restored.Sessions[0].Branch);
515+
Assert.Null(restored.Sessions[0].WorkingDirectory);
516+
}
517+
518+
[Fact]
519+
public void CcaSessionsPayload_EmptyList_RoundTrip()
520+
{
521+
var payload = new CcaSessionsPayload { Sessions = new List<CcaSessionSummary>() };
522+
var msg = BridgeMessage.Create(BridgeMessageTypes.CcaSessionsList, payload);
523+
var restored = BridgeMessage.Deserialize(msg.Serialize())!.GetPayload<CcaSessionsPayload>();
524+
525+
Assert.NotNull(restored);
526+
Assert.Empty(restored!.Sessions);
527+
}
439528
}

PolyPilot/Components/Layout/SessionSidebar.razor

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,66 @@ else
353353
}
354354
}
355355
}
356+
357+
@if (ccaSessions.Any())
358+
{
359+
<div class="sidebar-divider"></div>
360+
<div class="section-header" @onclick="ToggleCcaSessions" style="cursor: pointer;">
361+
<span class="section-header-label">☁️ CCA Sessions (@ccaSessions.Count)</span>
362+
<span class="toggle-icon">@(showCcaSessions ? "" : "")</span>
363+
</div>
364+
365+
@if (showCcaSessions)
366+
{
367+
@foreach (var cca in ccaSessions.Take(30))
368+
{
369+
var isOpen = IsSessionOpen(cca.SessionId);
370+
<div class="session-item persisted @(isOpen ? "already-open" : "") @(confirmCcaResumeId == cca.SessionId ? "confirm-active" : "")"
371+
@onclick="async () => { if (isOpen) await SwitchToOpenSession(cca.SessionId); else ConfirmCcaResume(cca); }"
372+
title="@(isOpen ? "Click to switch to this session" : GetCcaTooltip(cca))">
373+
<div class="session-info">
374+
<span class="session-name persisted-name">@(cca.Summary ?? "CCA Session")</span>
375+
<div class="session-meta-row">
376+
<div class="session-meta-left">
377+
<span class="session-meta-time">@cca.ModifiedTime.ToString("MMM dd") @cca.ModifiedTime.ToShortTimeString()</span>
378+
@if (!string.IsNullOrEmpty(cca.Repository))
379+
{
380+
<span class="session-meta-dir">@cca.Repository</span>
381+
}
382+
@if (!string.IsNullOrEmpty(cca.Branch))
383+
{
384+
<span class="session-meta-dir">🌿 @cca.Branch</span>
385+
}
386+
</div>
387+
@if (isOpen)
388+
{
389+
<span class="open-badge">Open</span>
390+
}
391+
else if (confirmCcaResumeId != cca.SessionId)
392+
{
393+
<span class="resume-icon">Connect</span>
394+
}
395+
</div>
396+
@if (!isOpen && confirmCcaResumeId == cca.SessionId)
397+
{
398+
<div class="resume-confirm-wrap">
399+
<div class="resume-confirm">
400+
<button class="btn-resume-yes" @onclick="() => ConnectToCcaSession(cca)" @onclick:stopPropagation="true" disabled="@isCreating">
401+
@if (isCreating) { <span>Connecting<span class="dots"><span>.</span><span>.</span><span>.</span></span></span> } else { <span>Connect</span> }
402+
</button>
403+
<button class="btn-resume-no" @onclick="CancelCcaResume" @onclick:stopPropagation="true"></button>
404+
</div>
405+
@if (!string.IsNullOrEmpty(ccaResumeError))
406+
{
407+
<div class="resume-error">@ccaResumeError</div>
408+
}
409+
</div>
410+
}
411+
</div>
412+
</div>
413+
}
414+
}
415+
}
356416
</div>
357417
};
358418

@@ -373,6 +433,10 @@ else
373433
private bool showDirectoryPicker;
374434
private List<AgentSessionInfo> sessions = new();
375435
private List<PersistedSessionInfo> persistedSessions = new();
436+
private List<CcaSessionSummary> ccaSessions = new();
437+
private bool showCcaSessions = false;
438+
private string? confirmCcaResumeId = null;
439+
private string? ccaResumeError = null;
376440
private bool showAddRepo = false;
377441
private string newRepoUrl = "";
378442
private string? addRepoError = null;
@@ -531,6 +595,70 @@ else
531595
showPersistedSessions = !showPersistedSessions;
532596
}
533597

598+
private void ToggleCcaSessions()
599+
{
600+
showCcaSessions = !showCcaSessions;
601+
if (showCcaSessions && ccaSessions.Count == 0)
602+
{
603+
_ = LoadCcaSessionsAsync();
604+
}
605+
}
606+
607+
private async Task LoadCcaSessionsAsync()
608+
{
609+
ccaSessions = await CopilotService.GetCcaSessionsAsync();
610+
await InvokeAsync(StateHasChanged);
611+
}
612+
613+
private void ConfirmCcaResume(CcaSessionSummary cca)
614+
{
615+
ccaResumeError = null;
616+
confirmCcaResumeId = confirmCcaResumeId == cca.SessionId ? null : cca.SessionId;
617+
}
618+
619+
private void CancelCcaResume()
620+
{
621+
confirmCcaResumeId = null;
622+
ccaResumeError = null;
623+
}
624+
625+
private async Task ConnectToCcaSession(CcaSessionSummary cca)
626+
{
627+
isCreating = true;
628+
ccaResumeError = null;
629+
try
630+
{
631+
var displayName = cca.Summary ?? "CCA Session";
632+
if (displayName.Length > 30) displayName = displayName[..27] + "...";
633+
var sessionInfo = await CopilotService.ResumeSessionAsync(cca.SessionId, displayName, workingDirectory: cca.WorkingDirectory);
634+
CopilotService.SwitchSession(sessionInfo.Name);
635+
confirmCcaResumeId = null;
636+
// Refresh CCA list since the session is now active
637+
_ = LoadCcaSessionsAsync();
638+
}
639+
catch (Exception ex)
640+
{
641+
ccaResumeError = $"Failed to connect: {ex.Message}";
642+
Console.WriteLine($"Error connecting to CCA session: {ex.Message}");
643+
}
644+
finally
645+
{
646+
isCreating = false;
647+
}
648+
}
649+
650+
private string GetCcaTooltip(CcaSessionSummary cca)
651+
{
652+
var tooltip = $"CCA Session: {cca.SessionId}\n";
653+
if (!string.IsNullOrEmpty(cca.Repository))
654+
tooltip += $"Repository: {cca.Repository}\n";
655+
if (!string.IsNullOrEmpty(cca.Branch))
656+
tooltip += $"Branch: {cca.Branch}\n";
657+
if (!string.IsNullOrEmpty(cca.Summary))
658+
tooltip += $"\n{cca.Summary}";
659+
return tooltip;
660+
}
661+
534662
private async Task HandleCreateSession((string Name, string Model, string Directory, string? WorktreeId, string? InitialPrompt) args)
535663
{
536664
if (isCreating) return;

PolyPilot/Models/BridgeMessages.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ public static class BridgeMessageTypes
7272
public const string GetSessions = "get_sessions";
7373
public const string GetHistory = "get_history";
7474
public const string GetPersistedSessions = "get_persisted_sessions";
75+
public const string GetCcaSessions = "get_cca_sessions";
7576
public const string SendMessage = "send_message";
7677
public const string CreateSession = "create_session";
7778
public const string ResumeSession = "resume_session";
@@ -84,6 +85,7 @@ public static class BridgeMessageTypes
8485

8586
// Server → Client (response)
8687
public const string DirectoriesList = "directories_list";
88+
public const string CcaSessionsList = "cca_sessions";
8789
}
8890

8991
// --- Server → Client payloads ---
@@ -281,3 +283,21 @@ public class AttentionNeededPayload
281283
public AttentionReason Reason { get; set; }
282284
public string Summary { get; set; } = "";
283285
}
286+
287+
// --- CCA (Copilot Coding Agent) session payloads ---
288+
289+
public class CcaSessionsPayload
290+
{
291+
public List<CcaSessionSummary> Sessions { get; set; } = new();
292+
}
293+
294+
public class CcaSessionSummary
295+
{
296+
public string SessionId { get; set; } = "";
297+
public string? Summary { get; set; }
298+
public DateTime StartTime { get; set; }
299+
public DateTime ModifiedTime { get; set; }
300+
public string? Repository { get; set; }
301+
public string? Branch { get; set; }
302+
public string? WorkingDirectory { get; set; }
303+
}

PolyPilot/Services/CopilotService.Persistence.cs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,46 @@ public IEnumerable<PersistedSessionInfo> GetPersistedSessions()
218218
.OrderByDescending(s => s.LastModified);
219219
}
220220

221+
/// <summary>
222+
/// Gets CCA (Copilot Coding Agent) sessions from the Copilot server.
223+
/// These are cloud-based sessions running in GitHub Actions.
224+
/// </summary>
225+
public async Task<List<CcaSessionSummary>> GetCcaSessionsAsync(CancellationToken cancellationToken = default)
226+
{
227+
// In remote mode, return CCA sessions from the bridge
228+
if (IsRemoteMode)
229+
{
230+
return _bridgeClient.CcaSessions;
231+
}
232+
233+
if (!IsInitialized || _client == null)
234+
return new List<CcaSessionSummary>();
235+
236+
try
237+
{
238+
var sessions = await _client.ListSessionsAsync(cancellationToken: cancellationToken);
239+
return sessions
240+
.Where(s => s.IsRemote)
241+
.Select(s => new CcaSessionSummary
242+
{
243+
SessionId = s.SessionId,
244+
Summary = s.Summary,
245+
StartTime = s.StartTime,
246+
ModifiedTime = s.ModifiedTime,
247+
Repository = s.Context?.Repository,
248+
Branch = s.Context?.Branch,
249+
WorkingDirectory = s.Context?.Cwd,
250+
})
251+
.OrderByDescending(s => s.ModifiedTime)
252+
.ToList();
253+
}
254+
catch (Exception ex)
255+
{
256+
Debug($"Failed to list CCA sessions: {ex.Message}");
257+
return new List<CcaSessionSummary>();
258+
}
259+
}
260+
221261
private static bool IsResumableSessionDirectory(DirectoryInfo di)
222262
{
223263
var eventsFile = Path.Combine(di.FullName, "events.jsonl");

PolyPilot/Services/WsBridgeClient.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public class WsBridgeClient : IDisposable
2424
public string? ActiveSessionName { get; private set; }
2525
public Dictionary<string, List<ChatMessage>> SessionHistories { get; } = new();
2626
public List<PersistedSessionSummary> PersistedSessions { get; private set; } = new();
27+
public List<CcaSessionSummary> CcaSessions { get; private set; } = new();
2728
public string? GitHubAvatarUrl { get; private set; }
2829
public string? GitHubLogin { get; private set; }
2930

@@ -193,6 +194,9 @@ await SendAsync(BridgeMessage.Create(BridgeMessageTypes.AbortSession,
193194
public async Task SendOrganizationCommandAsync(OrganizationCommandPayload cmd, CancellationToken ct = default) =>
194195
await SendAsync(BridgeMessage.Create(BridgeMessageTypes.OrganizationCommand, cmd), ct);
195196

197+
public async Task RequestCcaSessionsAsync(CancellationToken ct = default) =>
198+
await SendAsync(new BridgeMessage { Type = BridgeMessageTypes.GetCcaSessions }, ct);
199+
196200
private TaskCompletionSource<DirectoriesListPayload>? _dirListTcs;
197201

198202
public async Task<DirectoriesListPayload> ListDirectoriesAsync(string? path = null, CancellationToken ct = default)
@@ -370,6 +374,16 @@ private void HandleServerMessage(string json)
370374
}
371375
break;
372376

377+
case BridgeMessageTypes.CcaSessionsList:
378+
var ccaSessions = msg.GetPayload<CcaSessionsPayload>();
379+
if (ccaSessions != null)
380+
{
381+
CcaSessions = ccaSessions.Sessions;
382+
Console.WriteLine($"[WsBridgeClient] Got {CcaSessions.Count} CCA sessions");
383+
OnStateChanged?.Invoke();
384+
}
385+
break;
386+
373387
case BridgeMessageTypes.ToolStarted:
374388
var toolStart = msg.GetPayload<ToolStartedPayload>();
375389
if (toolStart != null)

0 commit comments

Comments
 (0)